Compare commits

..

1 commit
main ... dev

Author SHA1 Message Date
jaseg
b5ff6a966b Fix variable expression calculation 2023-05-08 23:11:51 +02:00
585 changed files with 3876 additions and 248511 deletions

2
.gitignore vendored
View file

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

View file

@ -14,54 +14,53 @@ build:archlinux:
GIT_SUBMODULE_STRATEGY: none GIT_SUBMODULE_STRATEGY: none
script: script:
- git config --global --add safe.directory "$CI_PROJECT_DIR" - git config --global --add safe.directory "$CI_PROJECT_DIR"
- uv build - pip3 install --user wheel setuptools
- python3 setup.py sdist bdist_wheel
artifacts: artifacts:
name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara" name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
paths: paths:
- dist/* - dist/*
# FIXME: disable tests since (a) currenty kicad-cli is broken (aborts on start), and the workaround of using an older test:archlinux:
# version from the KiCad project's kicad-cli containers does not work in gitlab CI. Pain. stage: test
#test:archlinux: image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
# stage: test script:
# image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest" - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-symbols
# script: - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-footprints
# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-symbols - env KICAD_SYMBOLS=kicad-symbols KICAD_FOOTPRINTS=kicad-footprints pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*'
# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-footprints dependencies:
# - env KICAD_SYMBOLS=kicad-symbols KICAD_FOOTPRINTS=kicad-footprints pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*' - build:archlinux
# dependencies: cache:
# - build:archlinux key: test-image-cache
# cache: paths:
# key: test-image-cache - gerbonara/tests/image_cache/*.svg
# paths: - gerbonara/tests/image_cache/*.png
# - gerbonara/tests/image_cache/*.svg artifacts:
# - gerbonara/tests/image_cache/*.png name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
# artifacts: when: on_failure
# name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara" paths:
# when: on_failure - gerbonara_test_failures/*
# paths:
# - gerbonara_test_failures/* test:ubuntu2204:
# stage: test
#test:ubuntu-rolling: image: "registry.gitlab.com/gerbolyze/build-containers/ubuntu:22.04"
# stage: test script:
# image: "registry.gitlab.com/gerbolyze/build-containers/ubuntu:rolling" - python3 -m pip install pytest beautifulsoup4 pillow numpy slugify lxml click scipy
# script: - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-symbols
# - python3 -m pip install --break-system-packages pytest beautifulsoup4 pillow numpy slugify lxml click scipy - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-footprints
# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-symbols - env KICAD_SYMBOLS=kicad-symbols KICAD_FOOTPRINTS=kicad-footprints python3 -m pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*'
# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-footprints dependencies:
# - env KICAD_SYMBOLS=kicad-symbols KICAD_FOOTPRINTS=kicad-footprints python3 -m pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*' - build:archlinux
# dependencies: cache:
# - build:archlinux key: test-image-cache
# cache: paths:
# key: test-image-cache - gerbonara/tests/image_cache/*.svg
# paths: - gerbonara/tests/image_cache/*.png
# - gerbonara/tests/image_cache/*.svg artifacts:
# - gerbonara/tests/image_cache/*.png name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
# artifacts: when: on_failure
# name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara" paths:
# when: on_failure - gerbonara_test_failures/*
# paths:
# - gerbonara_test_failures/*
docs:archlinux: docs:archlinux:
stage: test stage: test
@ -84,7 +83,7 @@ publish:gerbonara:
cache: {} cache: {}
script: script:
- export TWINE_USERNAME TWINE_PASSWORD - export TWINE_USERNAME TWINE_PASSWORD
- pip3 install --user --break-system-packages twine rich - pip3 install --user twine rich
- twine upload dist/* - twine upload dist/*
dependencies: dependencies:
- build:archlinux - build:archlinux

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, ``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. 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] .. option:: --warnings [default|ignore|once]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

View file

@ -1,64 +0,0 @@
.. _examples-doc:
Examples
========
Solder mask rings
-----------------
This example script takes a board exported with a more recent KiCad version, and removes solder mask everywhere, but
leaves a thin ring of solder mask around every pad. Might be useful for some artsy boards.
.. image:: ex-mask-islands.png
.. code-block:: python
from gerbonara import *
from shapely import *
stack = layers.LayerStack.open('gerber')
# Let's work in mm here. Gerbonara will take care to convert units when the file is in US customary units.
(x1, y1), (x2, y2) = stack.bounding_box(unit=utils.MM)
for l in [stack['bottom mask'], stack['top mask']]:
# The solder mask gerber layer by convention is "negative". That is, a "dark" polarity (drawn) Gerber primitive
# will result in an opening in the solder mask. Conversely, an empty gerber file would lead to the entire board
# being covered in solder mask.
#
# Here, we add a rectangle covering the entire board so the entire board is *free* of solder mask.
new = [graphic_objects.Region(
[(x1, y1), (x1, y2), (x2, y2), (x2, y1), (x1, y1)],
unit=utils.MM,
polarity_dark=True)]
# Iterate through all objects on the solder mask layer. In later KiCad versions, everything on the solder mask
# layer is exported as a Gerber region, which is a really bad idea, but makes things easy for us here.
for obj in l.objects:
if isinstance(obj, gerbonara.graphic_objects.Region):
regions = []
else:
regions = [gerbonara.graphic_objects.Region.from_arc_poly(prim.to_arc_poly())
for prim in obj.to_primitives(unit=gerbonara.utils.MM)]
for obj in regions:
# Convert the region to a shapely line string
ls = LineString(obj.outline).normalize()
# Ask shapely to offset the line string by 1 mm
out = ls.offset_curve(obj.unit(1, 'mm'))
# For negative offsets, this operation can result in an object being split up into multiple parts, so we
# might get back a MultiLineString instead of a LineString.
for ls in (out.geoms if hasattr(out, 'geoms') else [out]):
# Convert the resulting shapely object back to a Gerber region.
new.append(graphic_objects.Region(
unit=obj.unit,
polarity_dark=not obj.polarity_dark,
outline=list(ls.coords)))
# Append the new objects to the original layer data
l.objects = new + l.objects
# Write the modified layer stack to a new Gerber directory
stack.save_to_directory('output-gerbers')

View file

@ -48,7 +48,6 @@ Features
cli cli
api-concepts api-concepts
examples
file-api file-api
object-api object-api
apertures apertures
@ -71,12 +70,10 @@ Then, you are ready to read and write gerber files:
from gerbonara import LayerStack from gerbonara import LayerStack
stack = LayerStack.open('output/gerber') stack = LayerStack.from_directory('output/gerber')
w, h = stack.outline.size('mm') w, h = stack.outline.size('mm')
print(f'Board size is {w:.1f} mm x {h:.1f} mm') print(f'Board size is {w:.1f} mm x {h:.1f} mm')
You can find some more elaborate examples in this doc's :ref:`Examples section<examples-doc>`.
Command-Line Interface Command-Line Interface
====================== ======================

View file

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

View file

@ -2,7 +2,6 @@
import math import math
from gerbonara.utils import MM
from gerbonara.graphic_objects import Arc from gerbonara.graphic_objects import Arc
from gerbonara.graphic_objects import rotate_point from gerbonara.graphic_objects import rotate_point
@ -23,8 +22,7 @@ def approx_test():
x1, y1 = rotate_point(0, -1, start_angle*eps) x1, y1 = rotate_point(0, -1, start_angle*eps)
x2, y2 = rotate_point(x1, y1, sweep_angle*eps*(-1 if clockwise else 1)) 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, arc = Arc(x1+cx, y1+cy, x2+cx, y2+cy, -x1, -y1, clockwise=clockwise, aperture=None, polarity_dark=True)
polarity_dark=True, unit=MM)
lines = arc.approximate(max_error=max_error) 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=' ') 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 .ipc356 import Netlist
from .layers import LayerStack from .layers import LayerStack
from .utils import MM, Inch from .utils import MM, Inch
from importlib.metadata import version
__version__ = version('gerbonara') __version__ = '1.0.2'

9
gerbonara/__main__.py Normal file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env python3
import click
from .cli import cli
if __name__ == '__main__':
cli()

View file

@ -7,7 +7,6 @@ from dataclasses import dataclass
import operator import operator
import re import re
import ast import ast
import math
from ..utils import LengthUnit, MM, Inch, MILLIMETERS_PER_INCH from ..utils import LengthUnit, MM, Inch, MILLIMETERS_PER_INCH
@ -34,7 +33,7 @@ class Expression:
def calculate(self, variable_binding={}, unit=None): def calculate(self, variable_binding={}, unit=None):
expr = self.converted(unit).optimized(variable_binding) expr = self.converted(unit).optimized(variable_binding)
if not isinstance(expr, ConstantExpression): if not isinstance(expr, ConstantExpression):
raise IndexError(f'Cannot fully resolve expression due to unresolved parameters: residual expression {expr} under parameters {variable_binding}') raise IndexError(f'Cannot fully resolve expression due to unresolved variables: {expr} with variables {variable_binding}')
return expr.value return expr.value
def __add__(self, other): def __add__(self, other):
@ -62,18 +61,11 @@ class Expression:
return expr(other) / self return expr(other) / self
def __neg__(self): def __neg__(self):
return NegatedExpression(self).optimized() return 0 - self
def __pos__(self): def __pos__(self):
return self return self
def parameters(self):
return tuple()
@property
def _operator(self):
return None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class UnitExpression(Expression): class UnitExpression(Expression):
@ -87,8 +79,8 @@ class UnitExpression(Expression):
object.__setattr__(self, 'expr', expr) object.__setattr__(self, 'expr', expr)
object.__setattr__(self, 'unit', unit) object.__setattr__(self, 'unit', unit)
def to_gerber(self, register_variable=None, unit=None): def to_gerber(self, unit=None):
return self.converted(unit).optimized().to_gerber(register_variable) return self.converted(unit).optimized().to_gerber()
def __eq__(self, other): def __eq__(self, other):
return type(other) == type(self) and \ return type(other) == type(self) and \
@ -155,10 +147,6 @@ class UnitExpression(Expression):
def __pos__(self): def __pos__(self):
return self return self
def parameters(self):
return self.expr.parameters()
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ConstantExpression(Expression): class ConstantExpression(Expression):
value: float value: float
@ -167,44 +155,14 @@ class ConstantExpression(Expression):
return float(self.value) return float(self.value)
def __eq__(self, other): def __eq__(self, other):
try: return type(self) == type(other) and self.value == other.value
return math.isclose(self.value, float(other), abs_tol=1e-9)
except TypeError:
return False
def to_gerber(self, register_variable=None, unit=None): def to_gerber(self, _unit=None):
if self == 0: # Avoid producing "-0" for negative floating point zeros
return '0'
return f'{self.value:.6f}'.rstrip('0').rstrip('.') return f'{self.value:.6f}'.rstrip('0').rstrip('.')
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class VariableExpression(Expression): 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={}):
opt = self.expr.optimized(variable_binding)
if isinstance(opt, OperatorExpression):
return self
else:
return opt
def __eq__(self, other):
return type(self) == type(other) and self.expr == other.expr
def to_gerber(self, register_variable=None, unit=None):
if register_variable is None:
return self.expr.to_gerber(None, unit)
else:
num = register_variable(self.expr.converted(unit).optimized())
return f'${num}'
@dataclass(frozen=True, slots=True)
class ParameterExpression(Expression):
''' An expression that refers to a macro variable or parameter '''
number: int number: int
def optimized(self, variable_binding={}): def optimized(self, variable_binding={}):
@ -216,50 +174,9 @@ class ParameterExpression(Expression):
return type(self) == type(other) and \ return type(self) == type(other) and \
self.number == other.number self.number == other.number
def to_gerber(self, register_variable=None, unit=None): def to_gerber(self, _unit=None):
return f'${self.number}' return f'${self.number}'
def parameters(self):
yield self
@dataclass(frozen=True, slots=True)
class NegatedExpression(Expression):
value: Expression
def optimized(self, variable_binding={}):
match self.value.optimized(variable_binding):
# -(-x) == x
case NegatedExpression(inner_value):
return inner_value
# -(x) == -x
case ConstantExpression(inner_value):
return ConstantExpression(-inner_value)
# -(x-y) == y-x
case OperatorExpression(operator.sub, l, r):
return OperatorExpression(operator.sub, r, l)
# Round very small values and negative floating point zeros to a (positive) zero
case 0:
return expr(0)
# Default case
case x:
return NegatedExpression(x)
@property
def _operator(self):
return self.value._operator
def __eq__(self, other):
return type(self) == type(other) and \
self.value == other.value
def to_gerber(self, register_variable=None, unit=None):
val_str = self.value.to_gerber(register_variable, unit)
if isinstance(self.value, (VariableExpression, ParameterExpression)):
return f'-{val_str}'
else:
return f'-({val_str})'
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class OperatorExpression(Expression): class OperatorExpression(Expression):
@ -278,88 +195,22 @@ class OperatorExpression(Expression):
self.l == other.l and \ self.l == other.l and \
self.r == other.r self.r == other.r
@property
def _operator(self):
return self.op
def optimized(self, variable_binding={}): def optimized(self, variable_binding={}):
l = self.l.optimized(variable_binding) l = self.l.optimized(variable_binding)
r = self.r.optimized(variable_binding) r = self.r.optimized(variable_binding)
match (l, self.op, r): #if self.op in (operator.add, operator.mul):
case (ConstantExpression(), op, ConstantExpression()): # if id(r) < id(l):
return ConstantExpression(self.op(float(l), float(r))) # l, r = r, l
# Minimize operations with neutral elements and zeros if isinstance(l, ConstantExpression) and isinstance(r, ConstantExpression):
# 0 + x == x return ConstantExpression(self.op(float(l), float(r)))
case (0, operator.add, r):
return r
# x + 0 == x
case (l, operator.add, 0):
return l
# 0 * x == 0
case (0, operator.mul, r):
return expr(0)
# x * 0 == 0
case (l, operator.mul, 0):
return expr(0)
# x * 1 == x
case (l, operator.mul, 1):
return l
# 1 * x == x
case (1, operator.mul, r):
return r
# x * -1 == -x
case (l, operator.mul, -1):
rv = -l
# -1 * x == -x
case (-1, operator.mul, r):
rv = -r
# x - 0 == x
case (l, operator.sub, 0):
return l
# 0 - x == -x (unary minus)
case (0, operator.sub, r):
rv = -r
# x - x == 0
case (l, operator.sub, r) if l == r:
return expr(0)
# x - -y == x + y
case (l, operator.sub, NegatedExpression(r)):
rv = (l + r)
# x / 1 == x
case (l, operator.truediv, 1):
return l
# x / -1 == -x
case (l, operator.truediv, -1):
rv = -l
# x / x == 1
case (l, operator.truediv, r) if l == r:
return expr(1)
# -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
# -x + y == y - x
case (NegatedExpression(l), operator.add, r):
rv = r-l
case _: # default return OperatorExpression(self.op, l, r)
return OperatorExpression(self.op, l, r)
def to_gerber(self, unit=None):
return expr(rv).optimized(variable_binding) lval = self.l.to_gerber(unit)
rval = self.r.to_gerber(unit)
def to_gerber(self, register_variable=None, unit=None):
lval = self.l.to_gerber(register_variable, unit)
rval = self.r.to_gerber(register_variable, unit)
if isinstance(self.l, OperatorExpression): if isinstance(self.l, OperatorExpression):
lval = f'({lval})' lval = f'({lval})'
@ -373,7 +224,3 @@ class OperatorExpression(Expression):
return f'{lval}{op}{rval}' return f'{lval}{op}{rval}'
def parameters(self):
yield from self.l.parameters()
yield from self.r.parameters()

View file

@ -0,0 +1,231 @@
#!/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 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):
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), _map_expression(node.right))
elif isinstance(node, ast.UnaryOp):
if type(node.op) == ast.UAdd:
return _map_expression(node.operand)
else:
return OperatorExpression(operator.sub, ConstantExpression(0), _map_expression(node.operand))
elif isinstance(node, ast.Name):
return VariableExpression(int(node.id[3:])) # node.id has format var[0-9]+
else:
raise SyntaxError('Invalid aperture macro expression')
def _parse_expression(expr):
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)
@dataclass(frozen=True, slots=True)
class ApertureMacro:
name: str = field(default=None, hash=False, compare=False)
primitives: tuple = ()
variables: 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 = {}
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
name, expr = block.partition('=')
number = int(name[1:])
if number in variables:
raise SyntaxError(f'Re-definition of aperture macro variable {number} inside macro')
variables[number] = _parse_expression(expr)
else: # primitive
primitive, *args = block.split(',')
args = [ _parse_expression(arg) for arg in args ]
primitives.append(ap.PRIMITIVE_CLASSES[int(primitive)].from_arglist(unit, args))
variables = [variables.get(i+1) for i in range(max(variables.keys(), default=0))]
return kls(macro_name, tuple(primitives), tuple(variables), tuple(comments))
def __str__(self):
return f'<Aperture macro {self.name}, variables {str(self.variables)}, 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 to_gerber(self, unit=None):
""" Serialize this macro's content (without the name) into Gerber using the given file unit """
comments = [ str(c) for c in self.comments ]
variable_defs = [ f'${var}={str(expr)[1:-1]}' for var, expr in enumerate(self.variables, start=1) if expr is not None ]
primitive_defs = [ prim.to_gerber(unit) for prim in self.primitives ]
return '*\n'.join(comments + variable_defs + primitive_defs)
def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None, polarity_dark=True):
variables = {i: v for i, v in enumerate(self.variables, start=1) if v is not None}
for number, value in enumerate(parameters, start=1):
if number in variables:
raise SyntaxError(f'Re-definition of aperture macro variable {number} through parameter {value}')
variables[number] = value
for primitive in self.primitives:
yield from primitive.to_graphic_primitives(offset, rotation, variables, 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 = VariableExpression
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', (
ap.Circle('mm', 1, var(1), 0, 0, var(4) * -deg_per_rad),
*_generic_hole(2)))
rect = ApertureMacro('GNR', (
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', (
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', (
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', (
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),
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', (
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', (
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

@ -7,7 +7,7 @@
import warnings import warnings
import contextlib import contextlib
import math import math
from dataclasses import dataclass, fields, replace from dataclasses import dataclass, fields
from .expression import Expression, UnitExpression, ConstantExpression, expr from .expression import Expression, UnitExpression, ConstantExpression, expr
@ -34,6 +34,7 @@ def rad_to_deg(a):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class Primitive: class Primitive:
unit: LengthUnit unit: LengthUnit
exposure : Expression
def __post_init__(self): def __post_init__(self):
for field in fields(self): for field in fields(self):
@ -45,16 +46,9 @@ class Primitive:
elif field.type == Expression: elif field.type == Expression:
object.__setattr__(self, field.name, expr(getattr(self, field.name))) object.__setattr__(self, field.name, expr(getattr(self, field.name)))
def to_gerber(self, register_variable=None, settings=None): def to_gerber(self, unit=None):
return f'{self.code},' + ','.join( return f'{self.code},' + ','.join(
getattr(self, field.name).optimized().to_gerber(register_variable, settings.unit) getattr(self, field.name).to_gerber(unit) for field in fields(self) if field.name != 'unit')
for field in fields(self) if issubclass(field.type, Expression))
def substitute_params(self, binding, unit):
out = replace(self, unit=unit, **{
field.name: getattr(self, field.name).calculate(binding, unit)
for field in fields(self) if issubclass(field.type, Expression)})
return out
def __str__(self): def __str__(self):
attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__) attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__)
@ -67,11 +61,6 @@ class Primitive:
def from_arglist(kls, unit, arglist): def from_arglist(kls, unit, arglist):
return kls(unit, *arglist) return kls(unit, *arglist)
def parameters(self):
for field in fields(self):
if issubclass(field.type, Expression):
yield from getattr(self, field.name).parameters()
class Calculator: class Calculator:
def __init__(self, instance, variable_binding={}, unit=None): def __init__(self, instance, variable_binding={}, unit=None):
self.instance = instance self.instance = instance
@ -94,29 +83,18 @@ class Primitive:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class Circle(Primitive): class Circle(Primitive):
code = 1 code = 1
exposure : Expression
diameter : UnitExpression diameter : UnitExpression
# center x/y # center x/y
x : UnitExpression = 0 x : UnitExpression
y : UnitExpression = 0 y : UnitExpression
rotation : Expression = 0 rotation : Expression = 0
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
with self.Calculator(self, variable_binding, unit) as calc: 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 = rotate_point(calc.x, calc.y, -(deg_to_rad(calc.rotation) + rotation), 0, 0)
x, y = x+offset[0], y+offset[1] 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)) ] return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def substitute_params(self, binding, unit):
with self.Calculator(self, binding, unit) as calc:
x, y = rotate_point(calc.x, calc.y, -deg_to_rad(calc.rotation), 0, 0)
new = Circle(unit, self.exposure, calc.diameter, x, y)
return new
def dilated(self, offset, unit): def dilated(self, offset, unit):
return replace(self, diameter=self.diameter + UnitExpression(offset, unit)) return replace(self, diameter=self.diameter + UnitExpression(offset, unit))
@ -128,7 +106,6 @@ class Circle(Primitive):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class VectorLine(Primitive): class VectorLine(Primitive):
code = 20 code = 20
exposure : Expression
width : UnitExpression width : UnitExpression
start_x : UnitExpression start_x : UnitExpression
start_y : UnitExpression start_y : UnitExpression
@ -148,18 +125,9 @@ class VectorLine(Primitive):
center_x, center_y = center_x+offset[0], center_y+offset[1] center_x, center_y = center_x+offset[0], center_y+offset[1]
rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x) 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, return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation,
polarity_dark=(bool(calc.exposure) == polarity_dark)) ] polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def substitute_params(self, binding, unit):
with self.Calculator(self, binding, unit) as calc:
x1, y1 = rotate_point(calc.start_x, calc.start_y, -deg_to_rad(calc.rotation), 0, 0)
x2, y2 = rotate_point(calc.end_x, calc.end_y, -deg_to_rad(calc.rotation), 0, 0)
return VectorLine(unit, calc.exposure, calc.width, x1, y1, x2, y2)
def dilated(self, offset, unit): def dilated(self, offset, unit):
return replace(self, width=self.width + UnitExpression(2*offset, unit)) return replace(self, width=self.width + UnitExpression(2*offset, unit))
@ -174,7 +142,6 @@ class VectorLine(Primitive):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class CenterLine(Primitive): class CenterLine(Primitive):
code = 21 code = 21
exposure : Expression
width : UnitExpression width : UnitExpression
height : UnitExpression height : UnitExpression
# center x/y # center x/y
@ -189,17 +156,8 @@ class CenterLine(Primitive):
x, y = x+offset[0], y+offset[1] x, y = x+offset[0], y+offset[1]
w, h = calc.width, calc.height 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)) ] return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def substitute_params(self, binding, unit):
with self.Calculator(self, binding, unit) as calc:
x1, y1 = rotate_point(calc.x, calc.y-calc.height/2, -deg_to_rad(calc.rotation), 0, 0)
x2, y2 = rotate_point(calc.x, calc.y+calc.height/2, -deg_to_rad(calc.rotation), 0, 0)
return VectorLine(unit, calc.exposure, calc.width, x1, y1, x2, y2)
def dilated(self, offset, unit): def dilated(self, offset, unit):
return replace(self, width=self.width + UnitExpression(2*offset, unit)) return replace(self, width=self.width + UnitExpression(2*offset, unit))
@ -214,7 +172,6 @@ class CenterLine(Primitive):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class Polygon(Primitive): class Polygon(Primitive):
code = 5 code = 5
exposure : Expression
n_vertices : Expression n_vertices : Expression
# center x/y # center x/y
x : UnitExpression x : UnitExpression
@ -227,8 +184,7 @@ class Polygon(Primitive):
rotation += deg_to_rad(calc.rotation) rotation += deg_to_rad(calc.rotation)
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0) x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
x, y = x+offset[0], y+offset[1] x, y = x+offset[0], y+offset[1]
print('xy', calc.x, calc.y) return [ gp.ArcPoly.from_regular_polygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation,
return [ gp.ArcPoly.from_regular_polygon(x, y, calc.diameter/2, int(calc.n_vertices), rotation,
polarity_dark=(bool(calc.exposure) == polarity_dark)) ] polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def dilated(self, offset, unit): def dilated(self, offset, unit):
@ -241,60 +197,9 @@ class Polygon(Primitive):
y=self.y * UnitExpression(scale)) y=self.y * UnitExpression(scale))
@dataclass(frozen=True, slots=True)
class Moire(Primitive):
""" Deprecated, but still found in some really old gerber files. """
code = 6
# center x/y
x : UnitExpression
y : UnitExpression
d_outer : UnitExpression
line_thickness : UnitExpression
gap_w : UnitExpression
num_circles : Expression
crosshair_thickness : UnitExpression = 0
crosshair_length : UnitExpression =0
rotation : Expression = 0
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
with self.Calculator(self, variable_binding, unit) as calc:
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]
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)
yield gp.Circle(x, y, calc.d_inner/2 - i*pitch - calc.line_thickness, polarity_dark=False)
if math.isclose(calc.crosshair_thickness, 0, abs_tol=1e-6) or\
math.isclose(calc.crosshair_length, 0, abs_tol=1e-6):
return
yield gp.Rectangle(x, y, crosshair_length, crosshair_thickness, rotation=rotation, polarity_dark=True)
yield gp.Rectangle(x, y, crosshair_thickness, crosshair_length, rotation=rotation, polarity_dark=True)
def dilate(self, offset, unit):
# I'd rather print a warning and produce graphically slightly incorrect output in these few cases here than
# producing macros that may evaluate to primitives with negative values.
warnings.warn('Attempted dilation of macro aperture thermal primitive. This is not supported.')
def scale(self, scale):
return replace(self,
d_outer=self.d_outer * UnitExpression(scale),
d_inner=self.d_inner * UnitExpression(scale),
gap_w=self.gap_w * UnitExpression(scale),
x=self.x * UnitExpression(scale),
y=self.y * UnitExpression(scale))
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class Thermal(Primitive): class Thermal(Primitive):
code = 7 code = 7
# Note: Thermal primitives according to spec don't have an exposure variable
# center x/y # center x/y
x : UnitExpression x : UnitExpression
y : UnitExpression y : UnitExpression
@ -309,16 +214,13 @@ class Thermal(Primitive):
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0) x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
x, y = x+offset[0], y+offset[1] x, y = x+offset[0], y+offset[1]
dark = True dark = (bool(calc.exposure) == polarity_dark)
if math.isclose(calc.d_outer, 0):
return []
return [ return [
gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark), gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark),
gp.Circle(x, y, calc.d_inner/2, polarity_dark=not 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, d_outer, 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, gap_w, d_outer, rotation=rotation, polarity_dark=not dark),
] ]
def dilate(self, offset, unit): def dilate(self, offset, unit):
@ -338,7 +240,6 @@ class Thermal(Primitive):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class Outline(Primitive): class Outline(Primitive):
code = 4 code = 4
exposure : Expression
length: Expression length: Expression
coords: tuple coords: tuple
rotation: Expression = 0 rotation: Expression = 0
@ -375,24 +276,21 @@ class Outline(Primitive):
def __str__(self): def __str__(self):
return f'<Outline {len(self.coords)} points>' return f'<Outline {len(self.coords)} points>'
def to_gerber(self, register_variable=None, settings=None): def to_gerber(self, unit=None):
# Calculate out rotation since at least gerbv mis-renders Outlines with rotation other than zero.
rotation = self.rotation.optimized() rotation = self.rotation.optimized()
coords = ','.join(coord.optimized().to_gerber(register_variable, settings.unit) for coord in self.coords) coords = self.coords
return f'{self.code},{self.exposure.optimized().to_gerber(register_variable)},{len(self.coords)//2-1},{coords},{rotation.to_gerber(register_variable)}' if isinstance(rotation, ConstantExpression):
rotation = math.radians(rotation.value)
def substitute_params(self, binding, unit): # This will work even with variables in x and y, we just need to pass in cx and cy as UnitExpressions
with self.Calculator(self, binding, unit) as calc: unit_zero = UnitExpression(expr(0), MM)
rotation = calc.rotation coords = [ rotate_point(x, y, -rotation, cx=unit_zero, cy=unit_zero) for x, y in self.points ]
coords = [ rotate_point(x.calculate(binding, unit), y.calculate(binding, unit), -deg_to_rad(rotation), 0, 0)
for x, y in self.points ]
coords = [ e for point in coords for e in point ] coords = [ e for point in coords for e in point ]
return Outline(unit, calc.exposure, calc.length, coords)
def parameters(self): rotation = ConstantExpression(0)
yield from Primitive.parameters(self)
for expr in self.coords: coords = ','.join(coord.to_gerber(unit) for coord in coords)
yield from expr.parameters() return f'{self.code},{self.exposure.to_gerber()},{len(self.coords)//2-1},{coords},{rotation.to_gerber()}'
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
with self.Calculator(self, variable_binding, unit) as calc: with self.Calculator(self, variable_binding, unit) as calc:
@ -400,10 +298,6 @@ class Outline(Primitive):
bound_coords = [ rotate_point(calc(x), calc(y), -rotation, 0, 0) for x, y in self.points ] 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_coords = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ]
bound_radii = [None] * len(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))] return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))]
def dilated(self, offset, unit): def dilated(self, offset, unit):
@ -419,7 +313,7 @@ class Comment:
code = 0 code = 0
comment: str comment: str
def to_gerber(self, register_variable=None, settings=None): def to_gerber(self, unit=None):
return f'0 {self.comment}' return f'0 {self.comment}'
def dilated(self, offset, unit): def dilated(self, offset, unit):
@ -437,7 +331,6 @@ PRIMITIVE_CLASSES = {
CenterLine, CenterLine,
Outline, Outline,
Polygon, Polygon,
Moire,
Thermal, Thermal,
]}, ]},
# alternative codes # alternative codes

View file

@ -16,11 +16,11 @@
# limitations under the License. # limitations under the License.
# #
import warnings
import math import math
from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY
from functools import lru_cache from functools import lru_cache
from .aperture_macros.parse import GenericMacros
from .utils import LengthUnit, MM, Inch, sum_bounds from .utils import LengthUnit, MM, Inch, sum_bounds
from . import graphic_primitives as gp from . import graphic_primitives as gp
@ -150,8 +150,6 @@ class ExcellonTool(Aperture):
# Internal use, for layer dilation. # Internal use, for layer dilation.
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
offset = unit(offset, self.unit) offset = unit(offset, self.unit)
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, diameter=self.diameter+2*offset) return replace(self, diameter=self.diameter+2*offset)
@lru_cache() @lru_cache()
@ -159,8 +157,7 @@ class ExcellonTool(Aperture):
return self return self
def to_macro(self, rotation=0): def to_macro(self, rotation=0):
from .aperture_macros.parse import GenericMacros return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM)
def _params(self, unit=None): def _params(self, unit=None):
return (self.unit.convert_to(unit, self.diameter),) return (self.unit.convert_to(unit, self.diameter),)
@ -191,8 +188,6 @@ class CircleAperture(Aperture):
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit) offset = self.unit(offset, unit)
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, diameter=self.diameter+2*offset, hole_dia=None) return replace(self, diameter=self.diameter+2*offset, hole_dia=None)
@lru_cache() @lru_cache()
@ -205,9 +200,7 @@ class CircleAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale) hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self, rotation=0): def to_macro(self, rotation=0):
from .aperture_macros.parse import GenericMacros return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
return GenericMacros.circle(MM(self.diameter, self.unit),
MM(self.hole_dia, self.unit))
def _params(self, unit=None): def _params(self, unit=None):
return _strip_right( return _strip_right(
@ -242,15 +235,13 @@ class RectangleAperture(Aperture):
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit) offset = self.unit(offset, unit)
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None) return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None)
@lru_cache() @lru_cache()
def rotated(self, angle=0): def rotated(self, angle=0):
if math.isclose(angle % math.pi, 0, abs_tol=1e-6): if math.isclose(angle % math.pi, 0):
return self return self
elif math.isclose(angle % math.pi, math.pi/2, abs_tol=1e-6): elif math.isclose(angle % math.pi, math.pi/2):
return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
else: # odd angle else: # odd angle
return self.to_macro(angle) return self.to_macro(angle)
@ -262,11 +253,12 @@ class RectangleAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale) hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self, rotation=0): def to_macro(self, rotation=0):
from .aperture_macros.parse import GenericMacros return ApertureMacroInstance(GenericMacros.rect,
return GenericMacros.rect(MM(self.w, self.unit), (MM(self.w, self.unit),
MM(self.h, self.unit), MM(self.h, self.unit),
MM(self.hole_dia, self.unit), MM(self.hole_dia, self.unit) or 0,
rotation) 0,
rotation))
def _params(self, unit=None): def _params(self, unit=None):
return _strip_right( return _strip_right(
@ -303,8 +295,6 @@ class ObroundAperture(Aperture):
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit) offset = self.unit(offset, unit)
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None) return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None)
@lru_cache() @lru_cache()
@ -330,11 +320,12 @@ class ObroundAperture(Aperture):
rotation -= -math.pi/2 rotation -= -math.pi/2
inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
from .aperture_macros.parse import GenericMacros return ApertureMacroInstance(GenericMacros.obround,
return GenericMacros.obround(MM(inst.w, self.unit), (MM(inst.w, self.unit),
MM(inst.h, self.unit), MM(inst.h, self.unit),
MM(inst.hole_dia, self.unit) or 0, MM(inst.hole_dia, self.unit) or 0,
rotation) 0,
rotation))
def _params(self, unit=None): def _params(self, unit=None):
return _strip_right( return _strip_right(
@ -371,8 +362,6 @@ class PolygonAperture(Aperture):
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit) offset = self.unit(offset, unit)
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, diameter=self.diameter+2*offset, hole_dia=None) return replace(self, diameter=self.diameter+2*offset, hole_dia=None)
flash = _flash_hole flash = _flash_hole
@ -390,22 +379,16 @@ class PolygonAperture(Aperture):
hole_dia=None if self.hole_dia is None else self.hole_dia*scale) hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
def to_macro(self): def to_macro(self):
from .aperture_macros.parse import GenericMacros return ApertureMacroInstance(GenericMacros.polygon, self._params(MM))
return GenericMacros.polygon(self.n_vertices,
MM(self.diameter, self.unit),
MM(self.hole_dia, self.unit),
self.rotation)
def _params(self, unit=None): def _params(self, unit=None):
rotation = self.rotation % (2*math.pi / self.n_vertices) rotation = self.rotation % (2*math.pi / self.n_vertices)
if math.isclose(rotation, 0, abs_tol=1e-6): if math.isclose(rotation, 0, abs_tol=1e-6):
rotation = None rotation = None
else:
rotation = math.degrees(rotation)
if self.hole_dia is not None: if self.hole_dia is not None:
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia) return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia)
elif rotation is not None and not math.isclose(rotation, 0, abs_tol=1e-6): elif rotation is not None and not math.isclose(rotation, 0):
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation
else: else:
return self.unit.convert_to(unit, self.diameter), self.n_vertices return self.unit.convert_to(unit, self.diameter), self.n_vertices
@ -435,13 +418,11 @@ class ApertureMacroInstance(Aperture):
return out return out
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, macro=self.macro.dilated(offset, unit)) return replace(self, macro=self.macro.dilated(offset, unit))
@lru_cache() @lru_cache()
def rotated(self, angle=0.0): def rotated(self, angle=0.0):
if math.isclose(angle % (2*math.pi), 0, abs_tol=1e-6): if math.isclose(angle % (2*math.pi), 0):
return self return self
else: else:
return self.to_macro(angle) return self.to_macro(angle)
@ -452,17 +433,9 @@ class ApertureMacroInstance(Aperture):
def scaled(self, scale): def scaled(self, scale):
return replace(self, macro=self.macro.scaled(scale)) return replace(self, macro=self.macro.scaled(scale))
def calculate_out(self, unit=None, macro_name=None):
return replace(self,
parameters=tuple(),
macro=self.macro.substitute_params(self._params(unit), unit, macro_name))
def _params(self, unit=None): def _params(self, unit=None):
# We ignore "unit" here as we convert the actual macro, not this instantiation. # We ignore "unit" here as we convert the actual macro, not this instantiation.
# We do this because here we do not have information about which parameter has which physical units. # We do this because here we do not have information about which parameter has which physical units.
parameters = self.parameters return tuple(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.')
parameters = parameters[:self.macro.num_parameters]
return tuple(parameters)

View file

@ -0,0 +1,221 @@
from .sexp import *
from .sexp_mapper import *
import time
from dataclasses import field
import math
import uuid
from contextlib import contextmanager
from itertools import cycle
@sexp_type('color')
class Color:
r: int = None
g: int = None
b: int = None
a: int = None
@sexp_type('stroke')
class Stroke:
width: Named(float) = 0.254
type: Named(AtomChoice(Atom.dash, Atom.dot, Atom.dash_dot_dot, Atom.dash_dot, Atom.default, Atom.solid)) = Atom.default
color: Color = None
class Dasher:
def __init__(self, obj):
if obj.stroke:
w, t = obj.stroke.width, obj.stroke.type
else:
w = obj.width or 0
t = Atom.solid
self.width = w
gap = 4*w
dot = 0
dash = 11*w
self.pattern = {
Atom.dash: [dash, gap],
Atom.dot: [dot, gap],
Atom.dash_dot_dot: [dash, gap, dot, gap, dot, gap],
Atom.dash_dot: [dash, gap, dot, gap],
Atom.default: [1e99],
Atom.solid: [1e99]}[t]
self.solid = t in (Atom.default, Atom.solid)
self.start_x, self.start_y = None, None
self.cur_x, self.cur_y = None, None
self.segments = []
def move(self, x, y):
if self.cur_x is None:
self.start_x, self.start_y = x, y
self.cur_x, self.cur_y = x, y
def line(self, x, y):
if x is None or y is None:
raise ValueError('line() called before move()')
self.segments.append((self.cur_x, self.cur_y, x, y))
self.cur_x, self.cur_y = x, y
def close(self):
self.segments.append((self.cur_x, self.cur_y, self.start_x, self.start_y))
self.cur_x, self.cur_y = None, None
@staticmethod
def _interpolate(x1, y1, x2, y2, length):
dx, dy = x2-x1, y2-y1
total = math.hypot(dx, dy)
if total == 0:
return x2, y2
frac = length / total
return x1 + dx*frac, y1 + dy*frac
def __iter__(self):
it = iter(self.segments)
segment_remaining, segment_pos = 0, 0
if self.width is None or self.width < 1e-3:
return
for length, stroked in cycle(zip(self.pattern, cycle([True, False]))):
length = max(1e-12, length)
import sys
while length > 0:
if segment_remaining == 0:
try:
x1, y1, x2, y2 = next(it)
except StopIteration:
return
dx, dy = x2-x1, y2-y1
lx, ly = x1, y1
segment_remaining = math.hypot(dx, dy)
segment_pos = 0
if segment_remaining > length:
segment_pos += length
ix, iy = self._interpolate(x1, y1, x2, y2, segment_pos)
segment_remaining -= length
if stroked:
yield lx, ly, ix, iy
lx, ly = ix, iy
break
else:
length -= segment_remaining
segment_remaining = 0
if stroked:
yield lx, ly, x2, y2
@sexp_type('xy')
class XYCoord:
x: float = 0
y: float = 0
def isclose(self, other, tol=1e-6):
return math.isclose(self.x, other.x, tol) and math.isclose(self.y, other.y, tol)
@sexp_type('pts')
class PointList:
xy : List(XYCoord) = field(default_factory=list)
@sexp_type('xyz')
class XYZCoord:
x: float = 0
y: float = 0
z: float = 0
@sexp_type('at')
class AtPos(XYCoord):
x: float = 0 # in millimeter
y: float = 0 # in millimeter
rotation: int = 0 # in degrees, can only be 0, 90, 180 or 270.
unlocked: Flag() = False
def __before_sexp__(self):
self.rotation = int(round(self.rotation % 360))
@property
def rotation_rad(self):
return math.radians(self.rotation)
@rotation_rad.setter
def rotation_rad(self, value):
self.rotation = math.degrees(value)
@sexp_type('font')
class FontSpec:
face: Named(str) = None
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(1.27, 1.27))
thickness: Named(float) = None
bold: Flag() = False
italic: Flag() = False
line_spacing: Named(float) = None
@sexp_type('justify')
class Justify:
h: AtomChoice(Atom.left, Atom.right) = None
v: AtomChoice(Atom.top, Atom.bottom) = None
mirror: Flag() = False
@sexp_type('effects')
class TextEffect:
font: FontSpec = field(default_factory=FontSpec)
justify: OmitDefault(Justify) = field(default_factory=Justify)
hide: Flag() = False
@sexp_type('tstamp')
class Timestamp:
value: str = field(default_factory=uuid.uuid4)
def __after_parse__(self, parent):
self.value = str(self.value)
def before_sexp(self):
self.value = Atom(str(self.value))
def bump(self):
self.value = uuid.uuid4()
@sexp_type('tedit')
class EditTime:
value: str = field(default_factory=time.time)
def __after_parse__(self, parent):
self.value = int(str(self.value), 16)
def __before_sexp__(self):
self.value = Atom(f'{int(self.value):08X}')
def bump(self):
self.value = time.time()
if __name__ == '__main__':
class Foo:
pass
foo = Foo()
foo.stroke = troke(0.01, Atom.dash_dot_dot)
d = Dasher(foo)
#d = Dasher(Stroke(0.01, Atom.solid))
d.move(1, 1)
d.line(1, 2)
d.line(3, 2)
d.line(3, 1)
d.close()
print('<?xml version="1.0" standalone="no"?>')
print('<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">')
print('<svg version="1.1" width="4cm" height="3cm" viewBox="0 0 4 3" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">')
for x1, y1, x2, y2 in d:
print(f'<path fill="none" stroke="black" stroke-width="0.01" stroke-linecap="round" d="M {x1},{y1} L {x2},{y2}"/>')
print('</svg>')

View file

@ -2,7 +2,6 @@
Library for handling KiCad's footprint files (`*.kicad_mod`). Library for handling KiCad's footprint files (`*.kicad_mod`).
""" """
import re
import copy import copy
import enum import enum
import string import string
@ -12,7 +11,7 @@ import time
import fnmatch import fnmatch
from itertools import chain from itertools import chain
from pathlib import Path from pathlib import Path
from dataclasses import field, replace from dataclasses import field
from .sexp import * from .sexp import *
from .base_types import * from .base_types import *
@ -21,7 +20,6 @@ from . import graphical_primitives as gr
from ..primitives import Positioned from ..primitives import Positioned
from ... import __version__
from ... import graphic_primitives as gp from ... import graphic_primitives as gp
from ... import graphic_objects as go from ... import graphic_objects as go
from ... import apertures as ap from ... import apertures as ap
@ -32,22 +30,18 @@ from ...aperture_macros.parse import GenericMacros, ApertureMacro
from ...aperture_macros import primitive as amp from ...aperture_macros import primitive as amp
class _MISSING: @sexp_type('property')
pass class Property:
key: str = ''
value: str = ''
def angle_difference(a, b):
return (b - a + math.pi) % (2*math.pi) - math.pi
@sexp_type('attr') @sexp_type('attr')
class Attribute: class Attribute:
type: AtomChoice(Atom.smd, Atom.through_hole) = None type: AtomChoice(Atom.smd, Atom.through_hole) = None
board_only: Flag() = False board_only: Flag() = False
virtual: Flag() = False # prior to 20208026
exclude_from_pos_files: Flag() = False exclude_from_pos_files: Flag() = False
exclude_from_bom: Flag() = False exclude_from_bom: Flag() = False
allow_missing_courtyard: Flag() = False
allow_soldermask_bridges: Flag() = False
dnp: Flag() = False
@sexp_type('fp_text') @sexp_type('fp_text')
@ -55,9 +49,8 @@ class Text:
type: AtomChoice(Atom.reference, Atom.value, Atom.user) = Atom.user type: AtomChoice(Atom.reference, Atom.value, Atom.user) = Atom.user
text: str = "" text: str = ""
at: AtPos = field(default_factory=AtPos) at: AtPos = field(default_factory=AtPos)
unlocked: OmitDefault(Named(YesNoAtom())) = False unlocked: Flag() = False
layer: Named(str) = None layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
hide: Flag() = False hide: Flag() = False
effects: TextEffect = field(default_factory=TextEffect) effects: TextEffect = field(default_factory=TextEffect)
tstamp: Timestamp = None tstamp: Timestamp = None
@ -74,15 +67,12 @@ class TextBox:
locked: Flag() = False locked: Flag() = False
text: str = None text: str = None
start: Rename(XYCoord) = None start: Rename(XYCoord) = None
end: Rename(XYCoord) = None end: Named(XYCoord) = None
margins: Rename(gr.Margins) = None
pts: PointList = None pts: PointList = None
angle: Named(float) = 0.0 angle: Named(float) = 0.0
layer: Named(str) = None layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
tstamp: Timestamp = None tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect) effects: TextEffect = field(default_factory=TextEffect)
border: Named(YesNoAtom()) = False
stroke: Stroke = field(default_factory=Stroke) stroke: Stroke = field(default_factory=Stroke)
render_cache: RenderCache = None render_cache: RenderCache = None
@ -95,23 +85,18 @@ class Line:
start: Rename(XYCoord) = None start: Rename(XYCoord) = None
end: Rename(XYCoord) = None end: Rename(XYCoord) = None
layer: Named(str) = None layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None width: Named(float) = None
stroke: Stroke = None stroke: Stroke = None
locked: Flag() = False locked: Flag() = False
tstamp: Timestamp = None tstamp: Timestamp = None
def to_graphical_primitive(self, flip=False):
# FIXME flip
return gr.Line(self.start, self.end, self.layer, self.width, self.stroke, self.tstamp)
def render(self, variables=None, cache=None): def render(self, variables=None, cache=None):
dasher = Dasher(self) dasher = Dasher(self)
dasher.move(self.start.x, self.start.y) dasher.move(self.start.x, self.start.y)
dasher.line(self.end.x, self.end.y) dasher.line(self.end.x, self.end.y)
for x1, y1, x2, y2 in dasher: for x1, y1, x2, y2 in dasher:
yield go.Line(x1, -y1, x2, -y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
@sexp_type('fp_rect') @sexp_type('fp_rect')
@ -119,10 +104,9 @@ class Rectangle:
start: Rename(XYCoord) = None start: Rename(XYCoord) = None
end: Rename(XYCoord) = None end: Rename(XYCoord) = None
layer: Named(str) = None layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None width: Named(float) = None
stroke: Stroke = None stroke: Stroke = None
fill: gr.FillMode = None fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
locked: Flag() = False locked: Flag() = False
tstamp: Timestamp = None tstamp: Timestamp = None
@ -134,7 +118,7 @@ class Rectangle:
w, h = x2-x1, y2-y1 w, h = x2-x1, y2-y1
if self.fill == Atom.solid: if self.fill == Atom.solid:
yield go.Region.from_rectangle(x1, -y1, w, h, unit=MM) yield go.Region.from_rectangle(x1, y1, w, h, unit=MM)
dasher = Dasher(self) dasher = Dasher(self)
dasher.move(x1, y1) dasher.move(x1, y1)
@ -145,7 +129,7 @@ class Rectangle:
aperture = ap.CircleAperture(dasher.width, unit=MM) aperture = ap.CircleAperture(dasher.width, unit=MM)
for x1, y1, x2, y2 in dasher: for x1, y1, x2, y2 in dasher:
yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM) yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM)
@sexp_type('fp_circle') @sexp_type('fp_circle')
@ -153,10 +137,9 @@ class Circle:
center: Rename(XYCoord) = None center: Rename(XYCoord) = None
end: Rename(XYCoord) = None end: Rename(XYCoord) = None
layer: Named(str) = None layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None width: Named(float) = None
stroke: Stroke = None stroke: Stroke = None
fill: gr.FillMode = None fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
locked: Flag() = False locked: Flag() = False
tstamp: Timestamp = None tstamp: Timestamp = None
@ -167,7 +150,7 @@ class Circle:
dasher = Dasher(self) dasher = Dasher(self)
aperture = ap.CircleAperture(dasher.width or 0, unit=MM) aperture = ap.CircleAperture(dasher.width or 0, unit=MM)
circle = go.Arc.from_circle(x, -y, r, aperture=aperture, unit=MM) circle = go.Arc.from_circle(x, y, r, aperture=aperture, unit=MM)
if self.fill == Atom.solid: if self.fill == Atom.solid:
yield circle.to_region() yield circle.to_region()
@ -181,25 +164,19 @@ class Circle:
aperture = ap.CircleAperture(dasher.width, unit=MM) aperture = ap.CircleAperture(dasher.width, unit=MM)
for x1, y1, x2, y2 in dasher: for x1, y1, x2, y2 in dasher:
yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM) yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM)
@sexp_type('fp_arc') @sexp_type('fp_arc')
class Arc: class Arc:
start: Rename(XYCoord) = None start: Rename(XYCoord) = None
mid: Rename(XYCoord) = None mid: Rename(XYCoord) = None
end: Rename(XYCoord) = None end: Rename(XYCoord) = None
width: Named(float) = None
angle: Named(float) = None
stroke: Stroke = None
layer: Named(str) = None layer: Named(str) = None
uuid: UUID = field(default_factory=UUID) width: Named(float) = None
stroke: Stroke = None
locked: Flag() = False locked: Flag() = False
tstamp: Timestamp = None tstamp: Timestamp = None
def to_graphical_primitive(self, flip=False):
# FIXME flip
return gr.Arc(self.start, self.mid, self.end, self.layer, self.width, self.stroke, self.tstamp)
def render(self, variables=None, cache=None): def render(self, variables=None, cache=None):
mx, my = self.mid.x, self.mid.y mx, my = self.mid.x, self.mid.y
@ -211,7 +188,7 @@ class Arc:
if math.isclose(x1, x2, abs_tol=1e-6) and math.isclose(y1, y2, abs_tol=1e-6): if math.isclose(x1, x2, abs_tol=1e-6) and math.isclose(y1, y2, abs_tol=1e-6):
cx = (x1 + mx) / 2 cx = (x1 + mx) / 2
cy = (y1 + my) / 2 cy = (y1 + my) / 2
arc = go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), clockwise=True, aperture=aperture, unit=MM) arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, clockwise=True, aperture=aperture, unit=MM)
if dasher.solid: if dasher.solid:
yield arc yield arc
@ -221,7 +198,7 @@ class Arc:
dasher.segments.append((line.x1, line.y1, line.x2, line.y2)) dasher.segments.append((line.x1, line.y1, line.x2, line.y2))
for line in dasher: for line in dasher:
yield go.Line(x1, -y1, x2, -y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
else: else:
# https://stackoverflow.com/questions/56224824/how-do-i-find-the-circumcenter-of-the-triangle-using-python-without-external-lib # https://stackoverflow.com/questions/56224824/how-do-i-find-the-circumcenter-of-the-triangle-using-python-without-external-lib
@ -230,7 +207,7 @@ class Arc:
cy = ((x1 * x1 + y1 * y1) * (mx - x2) + (x2 * x2 + y2 * y2) * (x1 - mx) + (mx * mx + my * my) * (x2 - x1)) / d cy = ((x1 * x1 + y1 * y1) * (mx - x2) + (x2 * x2 + y2 * y2) * (x1 - mx) + (mx * mx + my * my) * (x2 - x1)) / d
# KiCad only has clockwise arcs. # KiCad only has clockwise arcs.
arc = go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), clockwise=True, aperture=aperture, unit=MM) arc = go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, clockwise=False, aperture=aperture, unit=MM)
if dasher.solid: if dasher.solid:
yield arc yield arc
@ -240,44 +217,41 @@ class Arc:
dasher.segments.append((line.x1, line.y1, line.x2, line.y2)) dasher.segments.append((line.x1, line.y1, line.x2, line.y2))
for line in dasher: for line in dasher:
yield go.Line(x1, -y1, x2, -y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM) yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
@sexp_type('fp_poly') @sexp_type('fp_poly')
class Polygon: class Polygon:
pts: PointList = field(default_factory=list) pts: PointList = field(default_factory=PointList)
layer: Named(str) = None layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None width: Named(float) = None
stroke: Stroke = None stroke: Stroke = None
fill: gr.FillMode = None fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
locked: Flag() = False locked: Flag() = False
tstamp: Timestamp = None tstamp: Timestamp = None
def render(self, variables=None, cache=None): def render(self, variables=None, cache=None):
if len(self.pts) < 2: if len(self.pts.xy) < 2:
return return
dasher = Dasher(self) dasher = Dasher(self)
start = self.pts[0] start = self.pts.xy[0]
dasher.move(start.x, start.y) 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) dasher.line(point.x, point.y)
if dasher.width > 0: aperture = ap.CircleAperture(dasher.width, unit=MM)
aperture = ap.CircleAperture(dasher.width, unit=MM) for x1, y1, x2, y2 in dasher:
for x1, y1, x2, y2 in dasher: yield go.Line(x1, y1, x2, y2, aperture=aperture, unit=MM)
yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM)
if self.fill == Atom.solid: 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') @sexp_type('fp_curve')
class Curve: class Curve:
pts: PointList = field(default_factory=list) pts: PointList = field(default_factory=PointList)
layer: Named(str) = None layer: Named(str) = None
uuid: UUID = field(default_factory=UUID)
width: Named(float) = None width: Named(float) = None
stroke: Stroke = None stroke: Stroke = None
locked: Flag() = False locked: Flag() = False
@ -287,6 +261,46 @@ class Curve:
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.') 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
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') @sexp_type('drill')
class Drill: class Drill:
oval: Flag() = False oval: Flag() = False
@ -295,6 +309,12 @@ class Drill:
offset: Rename(XYCoord) = None offset: Rename(XYCoord) = None
@sexp_type('net')
class NetDef:
number: int = None
name: str = None
@sexp_type('options') @sexp_type('options')
class CustomPadOptions: class CustomPadOptions:
clearance: Named(AtomChoice(Atom.outline, Atom.convexhull)) = Atom.outline clearance: Named(AtomChoice(Atom.outline, Atom.convexhull)) = Atom.outline
@ -311,7 +331,7 @@ class CustomPadPrimitives:
polygons: List(gr.Polygon) = field(default_factory=list) polygons: List(gr.Polygon) = field(default_factory=list)
curves: List(gr.Curve) = field(default_factory=list) curves: List(gr.Curve) = field(default_factory=list)
width: Named(float) = None width: Named(float) = None
fill: gr.FillMode = True fill: Named(YesNoAtom()) = True
def all(self): def all(self):
yield from self.lines yield from self.lines
@ -322,33 +342,36 @@ class CustomPadPrimitives:
yield from self.curves 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') @sexp_type('pad')
class Pad(NetMixin): class Pad:
number: str = None number: str = None
type: AtomChoice(Atom.thru_hole, Atom.smd, Atom.connect, Atom.np_thru_hole) = 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 shape: AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom) = None
at: AtPos = field(default_factory=AtPos) at: AtPos = field(default_factory=AtPos)
locked: Flag() = False locked: Wrap(Flag()) = False
size: Rename(XYCoord) = field(default_factory=XYCoord) size: Rename(XYCoord) = field(default_factory=XYCoord)
drill: Drill = None drill: Drill = None
layers: Named(Array(str)) = field(default_factory=list) layers: Named(Array(str)) = field(default_factory=list)
properties: List(Property) = field(default_factory=list) properties: List(Property) = field(default_factory=list)
remove_unused_layers: Named(YesNoAtom()) = False remove_unused_layers: Wrap(Flag()) = False
keep_end_layers: Named(YesNoAtom()) = False keep_end_layers: Wrap(Flag()) = False
zone_layer_connections: Named(Array(str)) = field(default_factory=list)
uuid: UUID = field(default_factory=UUID)
rect_delta: Rename(XYCoord) = None rect_delta: Rename(XYCoord) = None
roundrect_rratio: Named(float) = None roundrect_rratio: Named(float) = None
thermal_bridge_angle: Named(int) = 45 thermal_bridge_angle: Named(int) = 45
thermal_bridge_width: Named(float) = 0.5
chamfer_ratio: Named(float) = None chamfer_ratio: Named(float) = None
chamfer: Chamfer = None chamfer: Chamfer = None
net: Net = None net: NetDef = None
tstamp: Timestamp = None tstamp: Timestamp = None
pin_function: Named(str) = None pin_function: Named(str) = None
pintype: Named(str) = None pintype: Named(str) = None
pinfunction: Named(str) = None
teardrops: gr.TeardropSpec = None
die_length: Named(float) = None die_length: Named(float) = None
solder_mask_margin: Named(float) = None solder_mask_margin: Named(float) = None
solder_paste_margin: Named(float) = None solder_paste_margin: Named(float) = None
@ -358,41 +381,7 @@ class Pad(NetMixin):
thermal_width: Named(float) = None thermal_width: Named(float) = None
thermal_gap: Named(float) = None thermal_gap: Named(float) = None
options: OmitDefault(CustomPadOptions) = None options: OmitDefault(CustomPadOptions) = None
padstack: gr.PadStack = None
primitives: OmitDefault(CustomPadPrimitives) = None primitives: OmitDefault(CustomPadPrimitives) = None
_: SEXP_END = None
footprint: object = field(repr=False, default=None)
def __after_parse__(self, parent=None):
self.layers = unfuck_layers(self.layers)
def __before_sexp__(self):
self.layers = fuck_layers(self.layers)
@property
def abs_pos(self):
if self.footprint:
px, py, pr = self.footprint.at.x, self.footprint.at.y, self.footprint.at.rotation
else:
px, py, pr = 0, 0, 0
x, y = rotate_point(self.at.x, self.at.y, math.radians(pr))
return x+px, y+py, self.at.rotation, False
@property
def layer_mask(self):
return layer_mask(self.layers)
def offset(self, x=0, y=0):
self.at = self.at.with_offset(x, y)
def find_connected_footprints(self, **filters):
""" Find footprints connected to the same net as this pad """
return self.footprint.board.find_footprints(net=self.net.name, **filters)
def find_same_net(self, include_vias=True):
""" Find traces and vias of the same net as this pad. """
return self.footprint.board.find_traces(self.net.name, include_vias=include_vias)
def render(self, variables=None, margin=None, cache=None): def render(self, variables=None, margin=None, cache=None):
#if self.type in (Atom.connect, Atom.np_thru_hole): #if self.type in (Atom.connect, Atom.np_thru_hole):
@ -412,7 +401,7 @@ class Pad(NetMixin):
else: else:
aperture = self.aperture(margin) aperture = self.aperture(margin)
yield go.Flash(self.at.x+ox, -(self.at.y+oy), aperture, unit=MM) yield go.Flash(self.at.x+ox, self.at.y+oy, aperture, unit=MM)
def aperture(self, margin=None): def aperture(self, margin=None):
rotation = math.radians(self.at.rotation) rotation = math.radians(self.at.rotation)
@ -423,16 +412,16 @@ class Pad(NetMixin):
elif self.shape == Atom.rect: elif self.shape == Atom.rect:
if margin > 0: if margin > 0:
return GenericMacros.rounded_rect(self.size.x+2*margin, return ap.ApertureMacroInstance(GenericMacros.rounded_rect,
self.size.y+2*margin, (self.size.x+2*margin, self.size.y+2*margin,
margin, margin,
0, # no hole 0, 0, # no hole
rotation) rotation), unit=MM)
else: else:
return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation) return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(rotation)
elif self.shape == Atom.oval: elif self.shape == Atom.oval:
return ap.ObroundAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation) return ap.ObroundAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(rotation)
elif self.shape == Atom.trapezoid: elif self.shape == Atom.trapezoid:
# KiCad's trapezoid aperture "rect_delta" param is just weird to the point that I think it's probably # KiCad's trapezoid aperture "rect_delta" param is just weird to the point that I think it's probably
@ -454,31 +443,30 @@ class Pad(NetMixin):
# Note: KiCad already uses MM units, so no conversion needed here. # Note: KiCad already uses MM units, so no conversion needed here.
alpha = math.atan(y / dy) if dy > 0 else 0 alpha = math.atan(y / dy) if dy > 0 else 0
return GenericMacros.isosceles_trapezoid(x+dy+2*margin*math.cos(alpha), return ap.ApertureMacroInstance(GenericMacros.isosceles_trapezoid,
y+2*margin, (x+dy+2*margin*math.cos(alpha), y+2*margin,
2*dy, 2*dy,
0, # no hole 0, 0, # no hole
-rotation + math.pi) rotation), unit=MM)
else: else:
return GenericMacros.rounded_isosceles_trapezoid(x+dy, return ap.ApertureMacroInstance(GenericMacros.rounded_isosceles_trapezoid,
y, (x+dy, y,
2*dy, 2*dy, margin,
margin, 0, 0, # no hole
0, # no hole rotation), unit=MM)
-rotation + math.pi)
elif self.shape == Atom.roundrect: elif self.shape == Atom.roundrect:
x, y = self.size.x, self.size.y x, y = self.size.x, self.size.y
r = min(x, y) * self.roundrect_rratio r = min(x, y) * self.roundrect_rratio
if margin > -r: if margin > -r:
return GenericMacros.rounded_rect(x+2*margin, return ap.ApertureMacroInstance(GenericMacros.rounded_rect,
y+2*margin, (x+2*margin, y+2*margin,
r+margin, r+margin,
0, # no hole 0, 0, # no hole
rotation) rotation), unit=MM)
else: else:
return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(-rotation) return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(rotation)
elif self.shape == Atom.custom: elif self.shape == Atom.custom:
primitives = [] primitives = []
@ -487,7 +475,7 @@ class Pad(NetMixin):
for obj in self.primitives.all(): for obj in self.primitives.all():
for gn_obj in obj.render(): for gn_obj in obj.render():
if margin and isinstance(gn_obj, (go.Line, go.Arc)): if margin and isinstance(gn_obj, (go.Line, go.Arc)):
gn_obj = replace(gn_obj, aperture=gn_obj.aperture.dilated(margin)) gn_obj = gn_obj.dilated(margin)
if isinstance(gn_obj, go.Region) and margin > 0: if isinstance(gn_obj, go.Region) and margin > 0:
for line in gn_obj.outline_objects(ap.CircleAperture(2*margin, unit=MM)): for line in gn_obj.outline_objects(ap.CircleAperture(2*margin, unit=MM)):
@ -520,7 +508,7 @@ class Pad(NetMixin):
elif self.options.anchor == Atom.circle and self.size.x > 0: elif self.options.anchor == Atom.circle and self.size.x > 0:
primitives.append(amp.Circle(MM, 1, self.size.x+2*margin, 0, 0, 0)) primitives.append(amp.Circle(MM, 1, self.size.x+2*margin, 0, 0, 0))
macro = ApertureMacro(primitives=tuple(primitives)).rotated(-rotation) macro = ApertureMacro(primitives=tuple(primitives)).rotated(rotation)
return ap.ApertureMacroInstance(macro, unit=MM) return ap.ApertureMacroInstance(macro, unit=MM)
def render_drill(self): def render_drill(self):
@ -545,40 +533,38 @@ class Pad(NetMixin):
dy = 0 dy = 0
aperture = ap.ExcellonTool(min(dia, w), plated=plated, unit=MM) aperture = ap.ExcellonTool(min(dia, w), plated=plated, unit=MM)
l = go.Line(ox-dx, -(oy-dy), ox+dx, -(oy+dy), aperture=aperture, unit=MM) l = go.Line(ox-dx, oy-dy, ox+dx, oy+dy, aperture=aperture, unit=MM)
l.rotate(math.radians(self.at.rotation)) l.rotate(math.radians(self.at.rotation))
l.offset(self.at.x, -self.at.y) l.offset(self.at.x, self.at.y)
yield l yield l
else: else:
aperture = ap.ExcellonTool(self.drill.diameter, plated=plated, unit=MM) aperture = ap.ExcellonTool(self.drill.diameter, plated=plated, unit=MM)
yield go.Flash(self.at.x, -self.at.y, aperture=aperture, unit=MM) yield go.Flash(self.at.x, self.at.y, aperture=aperture, unit=MM)
@sexp_type('group')
class Group:
name: str = ""
id: Named(str) = ""
members: Named(List(str)) = field(default_factory=list)
@sexp_type('model') @sexp_type('model')
class Model: class Model:
name: str = '' name: str = ''
hide: Flag() = False
at: Named(XYZCoord) = field(default_factory=XYZCoord) at: Named(XYZCoord) = field(default_factory=XYZCoord)
offset: Named(XYZCoord) = field(default_factory=XYZCoord) offset: Named(XYZCoord) = field(default_factory=XYZCoord)
opacity: Named(float) = None
scale: Named(XYZCoord) = field(default_factory=XYZCoord) scale: Named(XYZCoord) = field(default_factory=XYZCoord)
rotate: Named(XYZCoord) = field(default_factory=XYZCoord) rotate: Named(XYZCoord) = field(default_factory=XYZCoord)
@sexp_type('component_classes') SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018]
class FootprintComponentClasses:
classes: List(Named(str, name='class')) = field(default_factory=list)
SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
@sexp_type('footprint') @sexp_type('footprint')
class Footprint: class Footprint:
name: str = None name: str = None
_version: Named(int, name='version') = 20221018 _version: Named(int, name='version') = 20210108
uuid: UUID = field(default_factory=UUID) generator: Named(Atom) = Atom.kicad_library_utils
generator: Named(str) = Atom.gerbonara
generator_version: Named(str) = __version__
locked: Flag() = False locked: Flag() = False
placed: Flag() = False placed: Flag() = False
layer: Named(str) = 'F.Cu' layer: Named(str) = 'F.Cu'
@ -587,24 +573,20 @@ class Footprint:
at: AtPos = field(default_factory=AtPos) at: AtPos = field(default_factory=AtPos)
descr: Named(str) = None descr: Named(str) = None
tags: Named(str) = None tags: Named(str) = None
properties: List(DrawnProperty) = field(default_factory=list) properties: List(Property) = field(default_factory=list)
component_classes: FootprintComponentClasses = None
path: Named(str) = None path: Named(str) = None
sheetname: Named(str) = None
sheetfile: Named(str) = None
autoplace_cost90: Named(float) = None autoplace_cost90: Named(float) = None
autoplace_cost180: Named(float) = None autoplace_cost180: Named(float) = None
solder_mask_margin: Named(float) = None solder_mask_margin: Named(float) = None
solder_paste_margin_ratio: Named(float) = None
solder_paste_margin: Named(float) = None solder_paste_margin: Named(float) = None
solder_paste_ratio: Named(float) = None solder_paste_ratio: Named(float) = None
clearance: Named(float) = None clearance: Named(float) = None
zone_connect: Named(int) = None zone_connect: Named(int) = None
thermal_width: Named(float) = None thermal_width: Named(float) = None
thermal_gap: Named(float) = None thermal_gap: Named(float) = None
attributes: Attribute = field(default_factory=Attribute) attributes: List(Attribute) = field(default_factory=list)
private_layers: Named(str) = None private_layers: Named(str) = None
net_tie_pad_groups: Named(Array(str)) = None net_tie_pad_groups: Named(str) = None
texts: List(Text) = field(default_factory=list) texts: List(Text) = field(default_factory=list)
text_boxes: List(TextBox) = field(default_factory=list) text_boxes: List(TextBox) = field(default_factory=list)
lines: List(Line) = field(default_factory=list) lines: List(Line) = field(default_factory=list)
@ -613,98 +595,14 @@ class Footprint:
arcs: List(Arc) = field(default_factory=list) arcs: List(Arc) = field(default_factory=list)
polygons: List(Polygon) = field(default_factory=list) polygons: List(Polygon) = field(default_factory=list)
curves: List(Curve) = 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) pads: List(Pad) = field(default_factory=list)
zones: List(Zone) = field(default_factory=list) zones: List(Zone) = field(default_factory=list)
groups: List(Group) = field(default_factory=list) groups: List(Group) = field(default_factory=list)
embedded_fonts: Named(YesNoAtom()) = False
models: List(Model) = field(default_factory=list) models: List(Model) = field(default_factory=list)
_ : SEXP_END = None _ : SEXP_END = None
original_filename: str = None original_filename: str = None
board: object = field(repr=False, default=None) _bounding_box: tuple = None
def __after_parse__(self, parent):
for pad in self.pads:
pad.footprint = self
def property_value(self, key, default=_MISSING):
for prop in self.properties:
if prop.key == key:
return prop.value
if default is not _MISSING:
return default
raise IndexError(f'Footprint has no property named "{key}"')
def set_property(self, key, value, x=0, y=0, rotation=0, layer='F.Fab', hide=True, effects=None):
for prop in self.properties:
if prop.key == key:
old_value, prop.value = prop.value, value
return old_value
if effects is None:
effects = TextEffect()
self.properties.append(DrawnProperty(key, value,
at=AtPos(x, y, rotation, unlocked=True),
layer=layer,
hide=hide,
effects=effects))
def make_standard_properties(self):
if not self.property_value('Reference', None):
self.set_property('Reference', 'REF**', 0, 0, 0, 'F.SilkS')
if not self.property_value('Value', None):
self.set_property('Value', self.name or 'VAL**', 0, 0, 0, hide=False)
if not self.property_value('Footprint', None):
self.set_property('Footprint', '', 0, 0, 0)
if not self.property_value('Datasheet', None):
self.set_property('Datasheet', '', 0, 0, 0)
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}
def find_pads(self, number=None, net=None):
for pad in self.pads:
if number is not None and pad.number == str(number):
yield pad
elif isinstance(net, str) and fnmatch.fnmatch(pad.net.name, net):
yield pad
elif net is not None and pad.net.number == net:
yield pad
def pad(self, number=None, net=None):
candidates = list(self.find_pads(number=number, net=net))
if not candidates:
raise IndexError(f'No such pad "{number or net}"')
if len(candidates) > 1:
raise IndexError(f'Ambiguous pad "{number or net}", {len(candidates)} matching pads.')
return candidates[0]
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 @property
def version(self): def version(self):
@ -715,36 +613,9 @@ class Footprint:
if value not in SUPPORTED_FILE_FORMAT_VERSIONS: if value not in SUPPORTED_FILE_FORMAT_VERSIONS:
raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.') raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.')
@property
def reference(self):
return self.property_value('Reference')
@reference.setter
def reference(self, value):
self.set_property('Reference', value)
@property
def parsed_reference(self):
ref = self.reference
if (m := re.match(r'^.*[^0-9]([0-9]+)$', ref)):
return m.group(0), int(m.group(1))
else:
return ref
@property
def value(self):
return self.property_value('Value')
@value.setter
def value(self, value):
self.set_property('Value', value)
def write(self, filename=None): def write(self, filename=None):
with open(filename or self.original_filename, 'w') as f: with open(filename or self.original_filename, 'w') as f:
f.write(self.serialize()) f.write(build_sexp(sexp(self)))
def serialize(self):
return build_sexp(sexp(type(self), self)[0])
@classmethod @classmethod
def open_pretty(kls, pretty_dir, fp_name, *args, **kwargs): def open_pretty(kls, pretty_dir, fp_name, *args, **kwargs):
@ -767,111 +638,11 @@ class Footprint:
def load(kls, data, *args, **kwargs): def load(kls, data, *args, **kwargs):
return kls.parse(data, *args, **kwargs) return kls.parse(data, *args, **kwargs)
@property
def side(self):
return 'front' if self.layer == 'F.Cu' else 'back'
@side.setter
def side(self, value):
if value not in ('front', 'back'):
raise ValueError(f'side must be either "front" or "back", not {side!r}')
if self.side != value:
self.flip()
def flip(self):
def flip_layer(name):
if name.startswith('F.'):
return f'B.{name[2:]}'
elif name.startswith('B.'):
return f'F.{name[2:]}'
else:
return name
self.layer = flip_layer(self.layer)
for obj in self.objects():
if getattr(obj, 'layer', None) is not None:
obj.layer = flip_layer(obj.layer)
if hasattr(obj, 'layers'):
obj.layers = [flip_layer(name) for name in obj.layers]
for obj in chain(self.texts, self.text_boxes):
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)
@property @property
def single_sided(self): def single_sided(self):
raise NotImplementedError() raise NotImplementedError()
def face(self, direction, pad=None, net=None):
if not net and not pad:
pad = '1'
candidates = list(self.find_pads(net=net, number=pad)) def objects(self, text=False, pads=True):
if len(candidates) == 0:
raise KeyError(f'Reference pad "{net or pad}" not found.')
if len(candidates) > 1:
raise KeyError(f'Reference pad "{net or pad}" is ambiguous, {len(candidates)} matching pads found.')
pad = candidates[0]
pad_angle = math.atan2(pad.at.y, pad.at.x)
target_angle = {
'right': 0,
'top right': math.pi/4,
'top': math.pi/2,
'top left': 3*math.pi/4,
'left': math.pi,
'bottom left': -3*math.pi/4,
'bottom': -math.pi/2,
'bottom right': -math.pi/4}.get(direction, direction)
delta = angle_difference(target_angle, pad_angle)
adj = round(delta / (math.pi/2)) * math.pi/2
self.set_rotation(adj)
def rotate(self, angle=None, cx=None, cy=None, **reference_pad):
""" Rotate this footprint by the given angle in radians, counter-clockwise. When (cx, cy) are given, rotate
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.rotation = (self.at.rotation + math.degrees(angle)) % 360
for pad in self.pads:
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
for text in self.texts:
text.at.rotation = (text.at.rotation + math.degrees(angle)) % 360
def set_rotation(self, angle):
old_deg = self.at.rotation
new_deg = self.at.rotation = -math.degrees(angle)
delta = new_deg - old_deg
for pad in self.pads:
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
for text in self.texts:
text.at.rotation = (text.at.rotation + delta) % 360
def objects(self, text=False, pads=True, groups=True, zones=True):
return chain( return chain(
(self.texts if text else []), (self.texts if text else []),
(self.text_boxes if text else []), (self.text_boxes if text else []),
@ -882,19 +653,15 @@ class Footprint:
self.polygons, self.polygons,
self.curves, self.curves,
(self.dimensions if text else []), (self.dimensions if text else []),
(self.pads if pads else []), (self.pads if pads else []))
(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, side=None, variables={}, cache=None):
x += self.at.x x += self.at.x
y += self.at.y y += self.at.y
rotation += math.radians(self.at.rotation) rotation += math.radians(self.at.rotation)
flip = (side != 'top') if side else (self.layer != 'F.Cu')
if layer_map is None: for obj in self.objects(pads=False, text=text):
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)): if not (layer := layer_map.get(obj.layer)):
continue continue
@ -948,13 +715,38 @@ class Footprint:
layer_stack.drill_pth.append(fe) layer_stack.drill_pth.append(fe)
def bounding_box(self, unit=MM): def bounding_box(self, unit=MM):
if not hasattr(self, '_bounding_box'): if not self._bounding_box:
stack = LayerStack() 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, side='top', text=False, variables={})
self._bounding_box = stack.bounding_box(unit) self._bounding_box = stack.bounding_box(unit)
return self._bounding_box return self._bounding_box
LAYER_MAP_K2G = {
'F.Cu': ('top', 'copper'),
'B.Cu': ('bottom', 'copper'),
'F.SilkS': ('top', 'silk'),
'B.SilkS': ('bottom', 'silk'),
'F.Paste': ('top', 'paste'),
'B.Paste': ('bottom', 'paste'),
'F.Mask': ('top', 'mask'),
'B.Mask': ('bottom', 'mask'),
'B.CrtYd': ('bottom', 'courtyard'),
'F.CrtYd': ('top', 'courtyard'),
'B.Fab': ('bottom', 'fabrication'),
'F.Fab': ('top', 'fabrication'),
'B.Adhes': ('bottom', 'adhesive'),
'F.Adhes': ('top', 'adhesive'),
'Dwgs.User': ('mechanical', 'drawings'),
'Cmts.User': ('mechanical', 'comments'),
'Edge.Cuts': ('mechanical', 'outline'),
}
LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()}
@dataclass @dataclass
class FootprintInstance(Positioned): class FootprintInstance(Positioned):
sexp: Footprint = None sexp: Footprint = None
@ -964,7 +756,7 @@ class FootprintInstance(Positioned):
variables: dict = field(default_factory=lambda: {}) variables: dict = field(default_factory=lambda: {})
def render(self, layer_stack, cache=None): def render(self, layer_stack, cache=None):
x, y, rotation, flip= self.abs_pos x, y, rotation = self.abs_pos
x, y = MM(x, self.unit), MM(y, self.unit) x, y = MM(x, self.unit), MM(y, self.unit)
variables = dict(self.variables) variables = dict(self.variables)
@ -975,16 +767,17 @@ class FootprintInstance(Positioned):
if self.value is not None: if self.value is not None:
variables['VALUE'] = str(self.value) 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, x=x, y=y, rotation=rotation,
flip=flip, side=self.side,
text=(not self.hide_text), text=(not self.hide_text),
variables=variables, cache=cache) variables=variables, cache=cache)
def bounding_box(self, unit=MM): def bounding_box(self, unit=MM):
return offset_bounds(self.sexp.bounding_box(unit), unit(self.x, self.unit), unit(self.y, self.unit)) return offset_bounds(self.sexp.bounding_box(unit), unit(self.x, self.unit), unit(self.y, self.unit))
if __name__ == '__main__': if __name__ == '__main__':
import sys import sys
from ...layers import LayerStack from ...layers import LayerStack

View file

@ -0,0 +1,230 @@
import string
import math
from .sexp import *
from .base_types import *
from .primitives import *
from ... import graphic_objects as go
from ... import apertures as ap
from ...newstroke import Newstroke
from ...utils import rotate_point, MM
@sexp_type('layer')
class TextLayer:
layer: str = ''
knockout: Flag() = False
@sexp_type('gr_text')
class Text:
text: str = ''
at: AtPos = field(default_factory=AtPos)
layer: TextLayer = field(default_factory=TextLayer)
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
def render(self, variables={}):
if not self.effects or self.effects.hide or not self.effects.font:
return
font = Newstroke.load()
line_width = self.effects.font.thickness
text = string.Template(self.text).safe_substitute(variables)
strokes = list(font.render(text, size=self.effects.font.size.y))
min_x = min(x for st in strokes for x, y in st)
min_y = min(y for st in strokes for x, y in st)
max_x = max(x for st in strokes for x, y in st)
max_y = max(y for st in strokes for x, y in st)
w = max_x - min_x
h = max_y - min_y
offx = -min_x + {
None: -w/2,
Atom.right: -w,
Atom.left: 0
}[self.effects.justify.h if self.effects.justify else None]
offy = {
None: self.effects.font.size.y/2,
Atom.top: self.effects.font.size.y,
Atom.bottom: 0
}[self.effects.justify.v if self.effects.justify else None]
aperture = ap.CircleAperture(line_width or 0.2, unit=MM)
for stroke in strokes:
out = []
for x, y in stroke:
x, y = x+offx, y+offy
x, y = rotate_point(x, y, math.radians(self.at.rotation or 0))
x, y = x+self.at.x, y+self.at.y
out.append((x, y))
for p1, p2 in zip(out[:-1], out[1:]):
yield go.Line(*p1, *p2, aperture=aperture, unit=MM)
@sexp_type('gr_text_box')
class TextBox:
locked: Flag() = False
text: str = ''
start: Named(XYCoord) = None
end: Named(XYCoord) = None
pts: PointList = field(default_factory=PointList)
angle: OmitDefault(Named(float)) = 0.0
layer: Named(str) = ""
tstamp: Timestamp = None
effects: TextEffect = field(default_factory=TextEffect)
stroke: Stroke = field(default_factory=Stroke)
render_cache: RenderCache = None
def render(self, variables={}):
text = string.Template(self.text).safe_substitute(variables)
if text != self.text:
raise ValueError('Rendering of vector font text with variables not yet supported')
if not render_cache or not render_cache.polygons:
raise ValueError('Vector font text with empty render cache')
for poly in render_cache.polygons:
reg = go.Region([(p.x, p.y) for p in poly.pts.xy], unit=MM)
if self.stroke:
if self.stroke.type not in (None, Atom.default, Atom.solid):
raise ValueError('Dashed strokes are not supported on vector text')
yield from reg.outline_objects(aperture=ap.CircleAperture(self.stroke.width, unit=MM))
yield reg
@sexp_type('gr_line')
class Line:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
angle: Named(float) = None # wat
layer: Named(str) = None
width: Named(float) = None
tstamp: Timestamp = None
def render(self, variables=None):
if self.angle:
raise NotImplementedError('Angles on lines are not implemented. Please raise an issue and provide an example file.')
aperture = ap.CircleAperture(self.width, unit=MM)
yield go.Line(self.start.x, self.start.y, self.end.x, self.end.y, aperture=aperture, unit=MM)
@sexp_type('fill')
class FillMode:
# Needed for compatibility with weird files
fill: AtomChoice(Atom.solid, Atom.yes, Atom.no, Atom.none) = False
@classmethod
def __map__(self, obj, parent=None):
return obj[1] in (Atom.solid, Atom.yes)
@classmethod
def __sexp__(self, value):
yield [Atom.fill, Atom.solid if value else Atom.none]
@sexp_type('gr_rect')
class Rectangle:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
fill: FillMode = False
tstamp: Timestamp = None
def render(self, variables=None):
rect = go.Region.from_rectangle(self.start.x, self.start.y,
self.end.x-self.start.x, self.end.y-self.start.y,
unit=MM)
if self.fill:
yield rect
if self.width:
yield from rect.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM))
@sexp_type('gr_circle')
class Circle:
center: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
fill: FillMode = False
tstamp: Timestamp = None
def render(self, variables=None):
r = math.dist((self.center.x, self.center.y), (self.end.x, self.end.y))
aperture = ap.CircleAperture(self.width or 0, unit=MM)
arc = go.Arc.from_circle(self.center.x, self.center.y, r, aperture=aperture, unit=MM)
if self.width:
yield arc
if self.fill:
yield arc.to_region()
@sexp_type('gr_arc')
class Arc:
start: Rename(XYCoord) = None
mid: Rename(XYCoord) = None
end: Rename(XYCoord) = None
layer: Named(str) = None
width: Named(float) = None
tstamp: Timestamp = None
def render(self, variables=None):
if not self.width:
return
cx, cy = self.mid.x, self.mid.y
x1, y1 = self.start.x, self.start.y
x2, y2 = self.end.x, self.end.y
yield go.Arc(x1, y1, x2, y2, cx-x1, cy-y1, aperture=ap.CircleAperture(self.width or 0, unit=MM), clockwise=True, unit=MM)
@sexp_type('gr_poly')
class Polygon:
pts: PointList = field(default_factory=PointList)
layer: Named(str) = None
width: Named(float) = None
fill: FillMode = True
tstamp: Timestamp = None
def render(self, variables=None):
reg = go.Region([(pt.x, pt.y) for pt in self.pts.xy], unit=MM)
if self.width and self.width >= 0.005:
yield from reg.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM))
if self.fill:
yield reg
@sexp_type('gr_curve')
class Curve:
pts: PointList = field(default_factory=PointList)
layer: Named(str) = None
width: Named(float) = None
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.')
@sexp_type('gr_bbox')
class AnnotationBBox:
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
def render(self, variables=None):
return []

View file

@ -0,0 +1,97 @@
import enum
from .sexp import *
from .base_types import *
@sexp_type('hatch')
class Hatch:
style: AtomChoice(Atom.none, Atom.edge, Atom.full) = Atom.edge
pitch: float = 0.5
@sexp_type('connect_pads')
class PadConnection:
type: AtomChoice(Atom.thru_hole_only, Atom.full, Atom.no) = None
clearance: Named(float) = 0
@sexp_type('keepout')
class ZoneKeepout:
tracks_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='tracks') = True
vias_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='vias') = True
pads_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='pads') = True
copperpour_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='copperpour') = True
footprints_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='footprints') = True
@sexp_type('smoothing')
class ZoneSmoothing:
style: AtomChoice(Atom.chamfer, Atom.fillet) = Atom.chamfer
radius: Named(float) = None
@sexp_type('fill')
class ZoneFill:
yes: Flag() = False
mode: Flag(atom=Atom.hatched) = False
thermal_gap: Named(float) = 0.508
thermal_bridge_width: Named(float) = 0.508
smoothing: ZoneSmoothing = None
island_removal_node: Named(int) = None
islan_area_min: Named(float) = None
hatch_thickness: Named(float) = None
hatch_gap: Named(float) = None
hatch_orientation: Named(int) = None
hatch_smoothing_level: Named(int) = None
hatch_smoothing_value: Named(float) = None
hatch_border_algorithm: Named(int) = None
hatch_min_hole_area: Named(float) = None
@sexp_type('filled_polygon')
class FillPolygon:
layer: Named(str) = ""
pts: PointList = field(default_factory=PointList)
@sexp_type('fill_segments')
class FillSegment:
layer: Named(str) = ""
pts: PointList = field(default_factory=PointList)
@sexp_type('zone')
class Zone:
net: Named(int) = 0
net_name: Named(str) = ""
layer: Named(str) = None
layers: Named(Array(str)) = None
tstamp: Timestamp = None
name: Named(str) = None
hatch: Hatch = None
priority: OmitDefault(Named(int)) = 0
connect_pads: PadConnection = field(default_factory=PadConnection)
min_thickness: Named(float) = 0.254
filled_areas_thickness: Flag() = True
keepouts: List(ZoneKeepout) = field(default_factory=list)
fill: ZoneFill = field(default_factory=ZoneFill)
polygon: Named(PointList) = field(default_factory=PointList)
fill_polygons: List(FillPolygon) = field(default_factory=list)
fill_segments: List(FillSegment) = field(default_factory=list)
@sexp_type('polygon')
class RenderCachePolygon:
pts: PointList = field(default_factory=PointList)
@sexp_type('render_cache')
class RenderCache:
text: str = None
rotation: int = 0
polygons: List(RenderCachePolygon) = field(default_factory=list)

View file

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

View file

@ -0,0 +1,289 @@
from dataclasses import MISSING
from .sexp import *
SEXP_END = type('SEXP_END', (), {})
class AtomChoice:
def __init__(self, *choices):
self.choices = choices
def __contains__(self, value):
return value in self.choices
def __atoms__(self):
return self.choices
def __map__(self, obj, parent=None):
obj, = obj
if obj not in self:
raise TypeError(f'Invalid atom {obj} for {type(self)}, valid choices are: {", ".join(map(str, self.choices))}')
return obj
def __sexp__(self, value):
yield value
class Flag:
def __init__(self, atom=None, invert=None):
self.atom, self.invert = atom, invert
def __bind_field__(self, field):
if self.atom is None:
self.atom = Atom(field.name)
if self.invert is None:
self.invert = bool(field.default)
def __atoms__(self):
return [self.atom]
def __map__(self, obj, parent=None):
return not self.invert
def __sexp__(self, value):
if bool(value) == (not self.invert):
yield self.atom
def sexp(t, v):
if v is None:
return []
elif t in (int, float, str, Atom):
return [t(v)]
elif hasattr(t, '__sexp__'):
return list(t.__sexp__(v))
elif isinstance(t, list):
t, = t
return [sexp(t, elem) for elem in v]
else:
raise TypeError(f'Python type {t} has no defined s-expression serialization')
def map_sexp(t, v, parent=None):
if t is not Atom and hasattr(t, '__map__'):
return t.__map__(v, parent=parent)
elif t in (int, float, str, Atom):
v, = v
if not isinstance(v, t):
types = set({type(v), t})
if types == {int, float} or types == {str, Atom}:
v = t(v)
else:
raise TypeError(f'Cannot map s-expression value {v} of type {type(v)} to Python type {t}')
return v
elif isinstance(t, list):
t, = t
return [map_sexp(t, elem, parent=parent) for elem in v]
else:
raise TypeError(f'Python type {t} has no defined s-expression deserialization')
class WrapperType:
def __init__(self, next_type):
self.next_type = next_type
def __bind_field__(self, field):
self.field = field
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
def __atoms__(self):
if hasattr(self, 'name_atom'):
return [self.name_atom]
elif self.next_type is Atom:
return []
else:
return getattr(self.next_type, '__atoms__', lambda: [])()
class Named(WrapperType):
def __init__(self, next_type, name=None):
super().__init__(next_type)
self.name_atom = Atom(name) if name else None
def __bind_field__(self, field):
if self.next_type is not Atom:
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
if self.name_atom is None:
self.name_atom = Atom(field.name)
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)
else:
return map_sexp(self.next_type, obj, parent=parent)
def __sexp__(self, value):
value = sexp(self.next_type, value)
if value is not None:
yield [self.name_atom, *value]
class Rename(WrapperType):
def __init__(self, next_type, name=None):
super().__init__(next_type)
self.name_atom = Atom(name) if name else None
def __bind_field__(self, field):
if self.name_atom is None:
self.name_atom = Atom(field.name)
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)
if self.next_type in (str, float, int, Atom):
yield [self.name_atom, *value]
else:
key, *rest = value
yield [self.name_atom, *rest]
class OmitDefault(WrapperType):
def __bind_field__(self, field):
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
if field.default_factory != MISSING:
self.default = field.default_factory()
else:
self.default = field.default
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)
class YesNoAtom:
def __init__(self, yes=Atom.yes, no=Atom.no):
self.yes, self.no = yes, no
def __map__(self, value, parent=None):
value, = value
return value == self.yes
def __sexp__(self, value):
yield self.yes if value else self.no
class Wrap(WrapperType):
def __map__(self, value, parent=None):
value, = value
return map_sexp(self.next_type, value, parent=parent)
def __sexp__(self, value):
for inner in sexp(self.next_type, value):
yield [inner]
class Array(WrapperType):
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)
class List(WrapperType):
def __bind_field__(self, field):
self.attr = field.name
def __map__(self, value, parent):
l = getattr(parent, self.attr, [])
mapped = map_sexp(self.next_type, value, parent=parent)
l.append(mapped)
setattr(parent, self.attr, l)
def __sexp__(self, value):
for elem in value:
yield from sexp(self.next_type, elem)
class _SexpTemplate:
@staticmethod
def __atoms__(kls):
return [kls.name_atom]
@staticmethod
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)
if mapped is not None:
setattr(inst, name, mapped)
elif isinstance(v, list):
name, etype = kls.keys[v[0]]
mapped = map_sexp(etype, v, parent=inst)
if mapped is not None:
setattr(inst, name, mapped)
else:
try:
pos_key = next(positional)
setattr(inst, pos_key.name, v)
except StopIteration:
raise TypeError(f'Unhandled positional argument {v!r} while parsing {kls}')
getattr(inst, '__after_parse__', lambda x: None)(parent)
return inst
@staticmethod
def __sexp__(kls, value):
getattr(value, '__before_sexp__', lambda: None)()
out = [kls.name_atom]
for f in fields(kls):
if f.type is SEXP_END:
break
out += sexp(f.type, getattr(value, f.name))
yield out
@staticmethod
def parse(kls, data, *args, **kwargs):
return kls.__map__(parse_sexp(data), *args, **kwargs)
@staticmethod
def sexp(self):
return next(self.__sexp__(self))
def sexp_type(name=None):
def register(cls):
cls = dataclass(cls)
cls.name_atom = Atom(name) if name is not None else None
for key in '__sexp__', '__map__', '__atoms__', 'parse':
if not hasattr(cls, key):
setattr(cls, key, classmethod(getattr(_SexpTemplate, key)))
if not hasattr(cls, 'sexp'):
setattr(cls, 'sexp', getattr(_SexpTemplate, 'sexp'))
cls.positional = []
cls.keys = {}
for f in fields(cls):
f_type = f.type
if f_type is SEXP_END:
break
if hasattr(f_type, '__bind_field__'):
f_type.__bind_field__(f)
atoms = getattr(f_type, '__atoms__', lambda: [])
atoms = list(atoms())
for atom in atoms:
cls.keys[atom] = (f.name, f_type)
if not atoms:
cls.positional.append(f)
return cls
return register

View file

@ -0,0 +1,446 @@
"""
Library for processing KiCad's symbol files.
"""
import json
import string
import math
import re
import sys
import itertools
from fnmatch import fnmatch
from collections import defaultdict
from dataclasses import field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from .sexp import *
from .sexp_mapper import *
from .base_types import *
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)
PIN_STYLE = AtomChoice(Atom.line, Atom.inverted, Atom.clock, Atom.inverted_clock, Atom.input_low, Atom.clock_low,
Atom.output_low, Atom.edge_clock_high, Atom.non_logic)
@sexp_type('alternate')
class AltFunction:
name: str = None
etype: PIN_ETYPE = Atom.unspecified
shape: PIN_STYLE = Atom.line
@sexp_type('__styled_text')
class StyledText:
value: str = None
effects: TextEffect = field(default_factory=TextEffect)
@sexp_type('pin')
class Pin:
etype: PIN_ETYPE = Atom.unspecified
style: PIN_STYLE = Atom.line
at: AtPos = field(default_factory=AtPos)
length: Named(float) = 2.54
hide: Flag() = False
name: Rename(StyledText) = field(default_factory=StyledText)
number: Rename(StyledText) = field(default_factory=StyledText)
alternates: List(AltFunction) = field(default_factory=list)
@property
def direction(self):
return {0: 'R', 90: 'U', 180: 'L', 270: 'D'}.get(self.at.rotation, 'R')
@direction.setter
def direction(self, value):
self.at.rotation = {0: 'R', 90: 'U', 180: 'L', 270: 'D'}[value[0].upper()]
@sexp_type('fill')
class Fill:
type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background)) = Atom.none
@sexp_type('circle')
class Circle:
center: Rename(XYCoord) = field(default_factory=XYCoord)
radius: Named(float) = 0.0
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
@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)
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
# TODO add function to calculate center, bounding box
@sexp_type('polyline')
class Polyline:
pts: PointList = field(default_factory=PointList)
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
@property
def points(self):
return self.pts.xy
@points.setter
def points(self, value):
self.pts.xy = value
@property
def closed(self):
# if the last and first point are the same, we consider the polyline closed
# a closed triangle will have 4 points (A-B-C-A) stored in the list of points
return len(self.points) > 3 and self.points[0] == self.points[-1]
@property
def bbox(self):
if not self.points:
return (0.0, 0.0, 0.0, 0.0)
return (min(p.x for p in self.points),
min(p.y for p in self.points),
max(p.x for p in self.points),
max(p.y for p in self.points))
def as_rectangle(self):
(maxx, maxy, minx, miny) = self.get_boundingbox()
return Rectangle(
minx,
maxy,
maxx,
miny,
self.stroke_width,
self.stroke_color,
self.fill_type,
self.fill_color,
unit=self.unit,
demorgan=self.demorgan,
)
def get_center_of_boundingbox(self):
(maxx, maxy, minx, miny) = self.get_boundingbox()
return ((minx + maxx) / 2, ((miny + maxy) / 2))
def is_rectangle(self):
# a rectangle has 5 points and is closed
if len(self.points) != 5 or not self.is_closed():
return False
# construct lines between the points
p0 = self.points[0]
for p1_idx in range(1, len(self.points)):
p1 = self.points[p1_idx]
dx = p1.x - p0.x
dy = p1.y - p0.y
if dx != 0 and dy != 0:
# if a line is neither horizontal or vertical its not
# part of a rectangle
return False
# select next point
p0 = p1
return True
@sexp_type('at')
class TextPos(XYCoord):
x: float = 0 # in millimeter
y: float = 0 # in millimeter
rotation: int = 0 # in degrees
def __after_parse__(self, parent):
self.rotation = self.rotation / 10
def __before_sexp__(self):
self.rotation = round((self.rotation % 360) * 10)
@property
def rotation_rad(self):
return math.radians(self.rotation)
@rotation_rad.setter
def rotation_rad(self, value):
self.rotation = math.degrees(value)
@sexp_type('text')
class Text:
text: str = None
at: TextPos = field(default_factory=TextPos)
rotation: float = None
effects: TextEffect = field(default_factory=TextEffect)
@sexp_type('rectangle')
class Rectangle:
"""
Some v6 symbols use rectangles, newer ones encode them as polylines.
At some point in time we can most likely remove this class since its not used anymore
"""
start: Rename(XYCoord) = None
end: Rename(XYCoord) = None
stroke: Stroke = field(default_factory=Stroke)
fill: Fill = field(default_factory=Fill)
def as_polyline(self):
x1, y1 = self.start
x2, y2 = self.end
return Polyline([Point(x1, y1), Point(x2, y1), Point(x2, y2), Point(x1, y2), Point(x1, y1)],
self.stroke, self.fill)
@sexp_type('property')
class Property:
name: str = None
value: str = None
id: Named(int) = None
at: AtPos = field(default_factory=AtPos)
effects: TextEffect = field(default_factory=TextEffect)
@sexp_type('pin_numbers')
class PinNumberSpec:
hide: Flag() = False
@sexp_type('pin_names')
class PinNameSpec:
offset: OmitDefault(Named(float)) = 0.508
hide: Flag() = False
@sexp_type('symbol')
class Unit:
name: str = None
circles: List(Circle) = field(default_factory=list)
arcs: List(Arc) = field(default_factory=list)
polylines: List(Polyline) = field(default_factory=list)
rectangles: List(Rectangle) = field(default_factory=list)
texts: List(Text) = field(default_factory=list)
pins: List(Pin) = field(default_factory=list)
unit_name: Named(str) = None
_ : SEXP_END = None
global_units: list = field(default_factory=list)
unit_global: Flag() = False
style_global: Flag() = False
demorgan_style: int = 1
unit_index: int = 1
symbol = None
def __after_parse__(self, parent):
self.symbol = parent
if not (m := re.fullmatch(r'(.*)_([0-9]+)_([0-9]+)', self.name)):
raise FormatError(f'Invalid unit name "{self.name}"')
sym_name, unit_index, demorgan_style = m.groups()
if sym_name != self.symbol.name:
raise FormatError(f'Unit name "{self.name}" does not match symbol name "{self.symbol.name}"')
self.demorgan_style = int(demorgan_style)
self.unit_index = int(unit_index)
self.style_global = self._demorgan_style == 0
self.unit_global = self.unit_index == 0
def __before_sexp__(self):
self.name = f'{self.symbol.name}_{self.unit_index}_{self.demorgan_style}'
def __getattr__(self, name):
if name.startswith('all_'):
name = name[4:]
return itertools.chain(getattr(self.global_units, name, []), getattr(self, name, []))
def pin_stacks(self):
stacks = defaultdict(lambda: set())
for pin in self.all_pins():
stacks[(pin.at.x, pin.at.y)].add(pin)
return stacks
@sexp_type('symbol')
class Symbol:
name: str = None
extends: Named(str) = None
power: Wrap(Flag()) = False
pin_numbers: OmitDefault(PinNumberSpec) = field(default_factory=PinNumberSpec)
pin_names: OmitDefault(PinNameSpec) = field(default_factory=PinNameSpec)
in_bom: Named(YesNoAtom()) = True
on_board: Named(YesNoAtom()) = True
properties: List(Property) = field(default_factory=list)
raw_units: List(Unit) = field(default_factory=list)
_ : SEXP_END = None
styles: {str: {str: Unit}} = None
global_units: {str: {str: Unit}} = None
library = None
def __after_parse__(self, parent):
self.library = parent
self.global_units = {}
self.styles = {}
if self.extends:
self.in_bom = None
self.on_board = None
self.properties = {prop.name: prop for prop in self.properties}
if (prop := self.properties.get('ki_fp_filters')):
prop.value = prop.value.split() if prop.value else []
for unit in self.raw_units:
if unit.unit_global or unit.style_global:
d = self.global_units.get(unit.demorgan_style, {})
d[unit.name] = unit
self.global_units[unit.demorgan_style] = d
for other in self.raw_units:
if other.unit_global or other.style_global or other == unit:
continue
if not (unit.unit_global or other.name == unit.name):
continue
if not (unit.style_global or other.demorgan_style == unit.demorgan_style):
continue
other.global_units.append(unit)
else:
d = self.styles.get(unit.demorgan_style, {})
d[unit.name] = unit
self.styles[unit.demorgan_style] = d
def __before_sexp__(self):
self.raw_units = ([unit for style in self.global_units.values() for unit in style.values()] +
[unit for style in self.styles.values() for unit in style.values()])
if (prop := self.properties.get('ki_fp_filters')):
if not isinstance(prop.value, str):
prop.value = ' '.join(prop.value)
self.properties = list(self.properties.values())
def default_properties(self):
for i, (name, value, hide) in enumerate([
('Reference', 'U', False),
('Value', None, False),
('Footprint', None, True),
('Datasheet', None, True),
('ki_locked', None, True),
('ki_keywords', None, True),
('ki_description', None, True),
('ki_fp_filters', None, False),
]):
self.properties[name] = Property(name=name, value=value, id=i, effects=TextEffect(hide=hide))
def units(self, demorgan_style=None):
if self.extends:
return self.library[self.extends].units(demorgan_style)
else:
return self.styles.get(demorgan_style or 'default', {})
def get_center_rectangle(self, units):
# return a polyline for the requested unit that is a rectangle
# and is closest to the center
candidates = {}
# building a dict with floats as keys.. there needs to be a rule against that^^
pl_rects = [i.as_polyline() for i in self.rectangles]
pl_rects.extend(pl for pl in self.polylines if pl.is_rectangle())
for pl in pl_rects:
if pl.unit in units:
# extract the center, calculate the distance to origin
(x, y) = pl.get_center_of_boundingbox()
dist = math.sqrt(x * x + y * y)
candidates[dist] = pl
if candidates:
# sort the list return the first (smallest) item
return candidates[sorted(candidates.keys())[0]]
return None
def is_graphic_symbol(self):
return self.extends is None and (
not self.pins or self.get_property("Reference").value == "#SYM"
)
def pins_by_name(self, demorgan_style=None):
pins = defaultdict(lambda: set())
for unit in self.units(demorgan_style):
for pin in unit.all_pins:
pins[pin.name].add(pin)
return pins
def pins_by_number(self, demorgan_style=None):
pins = defaultdict(lambda: set())
for unit in self.units(demorgan_style):
for pin in unit.all_pins:
pins[pin.number].add(pin)
return pins
def __getattr__(self, name):
if name.startswith('all_'):
return itertools.chain(getattr(unit, name) for unit in self.raw_units)
def filter_pins(self, name=None, direction=None, electrical_type=None):
for pin in self.all_pins:
if name and not fnmatch(pin.name, name):
continue
if direction and not pin.direction in direction:
continue
if electrical_type and not pin.etype in electical_type:
continue
yield pin
def heuristically_small(self):
""" Heuristically try to determine whether this is a "small" component like a resistor, capacitor, LED, diode,
or transistor etc. When we have at most two pins, or there is no filled rectangle as symbol outline and we have
3 or 4 pins, we assume this is a small symbol.
"""
if len(self.all_pins) <= 2:
return True
if len(self.all_pins) > 4:
return False
return bool(self.get_center_rectangle(range(self.unit_count)))
SUPPORTED_FILE_FORMAT_VERSIONS = [20211014, 20220914]
@sexp_type('kicad_symbol_lib')
class Library:
_version: Named(int, name='version') = 20211014
generator: Named(Atom) = Atom.kicad_library_utils
symbols: List(Symbol) = field(default_factory=list)
_ : SEXP_END = None
original_filename: str = None
@property
def version(self):
return self._version
@version.setter
def version(self, value):
if value not in SUPPORTED_FILE_FORMAT_VERSIONS:
raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.')
@classmethod
def open(cls, filename: str):
with open(filename) as f:
return cls.parse(f.read())
def write(self, filename=None):
with open(filename or self.original_filename, 'w') as f:
f.write(build_sexp(sexp(self)))
if __name__ == "__main__":
if len(sys.argv) >= 2:
a = Library.open(sys.argv[1])
print(build_sexp(sexp(a)))
else:
print("pass a .kicad_sym file please")

View file

@ -4,7 +4,7 @@ import math
import warnings import warnings
from copy import copy from copy import copy
from itertools import zip_longest, chain from itertools import zip_longest, chain
from dataclasses import dataclass, field, replace, KW_ONLY from dataclasses import dataclass, field, KW_ONLY
from collections import defaultdict from collections import defaultdict
from ..utils import LengthUnit, MM, rotate_point, svg_arc, sum_bounds, bbox_intersect, Tag, offset_bounds from ..utils import LengthUnit, MM, rotate_point, svg_arc, sum_bounds, bbox_intersect, Tag, offset_bounds
@ -14,9 +14,6 @@ from ..apertures import Aperture, CircleAperture, ObroundAperture, RectangleAper
from ..newstroke import Newstroke from ..newstroke import Newstroke
class UNDEFINED:
pass
def sgn(x): def sgn(x):
return -1 if x < 0 else 1 return -1 if x < 0 else 1
@ -54,7 +51,7 @@ class Board:
@property @property
def abs_pos(self): def abs_pos(self):
return self.x, self.y, self.rotation, False return self.x, self.y, self.rotation
def add_silk(self, side, obj): def add_silk(self, side, obj):
if side not in ('top', 'bottom'): if side not in ('top', 'bottom'):
@ -118,7 +115,7 @@ class Board:
def layer_stack(self, layer_stack=None): def layer_stack(self, layer_stack=None):
if layer_stack is None: if layer_stack is None:
layer_stack = LayerStack(board_name='proto') layer_stack = LayerStack()
cache = {} cache = {}
for obj in chain(self.objects): for obj in chain(self.objects):
@ -145,56 +142,21 @@ class Positioned:
y: float y: float
_: KW_ONLY _: KW_ONLY
rotation: float = 0.0 rotation: float = 0.0
flip: bool = False side: str = 'top'
unit: LengthUnit = MM unit: LengthUnit = MM
parent: object = None parent: object = None
def flip(self):
self.side = 'top' if self.side == 'bottom' else 'bottom'
@property @property
def abs_pos(self): def abs_pos(self):
if self.parent is None: if self.parent is None:
px, py, pa, pf = 0, 0, 0, False px, py, pa = 0, 0, 0
else: else:
px, py, pa, pf = self.parent.abs_pos px, py, pa = self.parent.abs_pos
return self.x+px, self.y+py, self.rotation+pa, (bool(self.flip) != bool(pf)) return self.x+px, self.y+py, self.rotation+pa
def bounding_box(self, unit=MM):
stack = LayerStack()
self.render(stack)
objects = chain(*(l.objects for l in stack.graphic_layers.values()),
stack.drill_pth.objects, stack.drill_npth.objects)
objects = list(objects)
#print('foo', type(self).__name__,
# [(type(obj).__name__, [prim.bounding_box() for prim in obj.to_primitives(unit)]) for obj in objects], file=sys.stderr)
return sum_bounds(prim.bounding_box() for obj in objects for prim in obj.to_primitives(unit))
def overlaps(self, bbox, unit=MM):
return bbox_intersect(self.bounding_box(unit), bbox)
@property
def single_sided(self):
return True
# The dataclass API is slightly idiotic here, so we have to duplicate the entire thing.
@dataclass(frozen=True)
class FrozenPositioned:
x: float
y: float
_: KW_ONLY
rotation: float = 0.0
flip: bool = False
unit: LengthUnit = MM
parent: object = None
@property
def abs_pos(self):
if self.parent is None:
px, py, pa, pf = 0, 0, 0, False
else:
px, py, pa, pf = self.parent.abs_pos
return self.x+px, self.y+py, self.rotation+pa, (bool(self.flip) != bool(pf))
def bounding_box(self, unit=MM): def bounding_box(self, unit=MM):
stack = LayerStack() stack = LayerStack()
@ -215,7 +177,7 @@ class FrozenPositioned:
@dataclass @dataclass
class Graphics(Positioned): class ObjectGroup(Positioned):
top_copper: list = field(default_factory=list) top_copper: list = field(default_factory=list)
top_mask: list = field(default_factory=list) top_mask: list = field(default_factory=list)
top_silk: list = field(default_factory=list) top_silk: list = field(default_factory=list)
@ -226,10 +188,15 @@ class Graphics(Positioned):
bottom_paste: list = field(default_factory=list) bottom_paste: list = field(default_factory=list)
drill_npth: list = field(default_factory=list) drill_npth: list = field(default_factory=list)
drill_pth: list = field(default_factory=list) drill_pth: list = field(default_factory=list)
objects: list = field(default_factory=list)
def render(self, layer_stack, cache=None): def render(self, layer_stack, cache=None):
x, y, rotation, flip = self.abs_pos x, y, rotation = self.abs_pos
top, bottom = ('bottom', 'top') if flip else ('top', 'bottom') top, bottom = ('bottom', 'top') if self.side == 'bottom' else ('top', 'bottom')
for obj in self.objects:
obj.parent = self
obj.render(layer_stack, cache=cache)
for target, source in [ for target, source in [
(layer_stack[top, 'copper'], self.top_copper), (layer_stack[top, 'copper'], self.top_copper),
@ -262,6 +229,7 @@ class Graphics(Positioned):
self.bottom_paste, self.bottom_paste,
self.drill_npth, self.drill_npth,
self.drill_pth, self.drill_pth,
self.objects,
))), unit(self.x, self.unit), unit(self.y, self.unit)) ))), unit(self.x, self.unit), unit(self.y, self.unit))
else: else:
return super().bounding_box(unit) return super().bounding_box(unit)
@ -274,30 +242,6 @@ class Graphics(Positioned):
return not (any_drill or (any_top and any_bottom)) return not (any_drill or (any_top and any_bottom))
@dataclass
class ObjectGroup(Positioned):
objects: list = field(default_factory=list)
def render(self, layer_stack, cache=None):
for obj in self.objects:
if not isinstance(obj, Positioned):
raise ValueError(f'ObjectGroup members must be children of Positioned, not {type(obj)}')
obj.parent = self
obj.render(layer_stack, cache=cache)
def bounding_box(self, unit=MM):
if math.isclose(self.rotation, 0, abs_tol=1e-3):
return offset_bounds(sum_bounds((obj.bounding_box(unit=unit) for obj in self.objects)),
unit(self.x, self.unit), unit(self.y, self.unit))
else:
return super().bounding_box(unit)
@property
def single_sided(self):
return all(obj.single_sided for obj in self.objects)
@dataclass @dataclass
class Text(Positioned): class Text(Positioned):
text: str text: str
@ -309,7 +253,7 @@ class Text(Positioned):
polarity_dark: bool = True polarity_dark: bool = True
def render(self, layer_stack, cache=None): def render(self, layer_stack, cache=None):
obj_x, obj_y, rotation, flip = self.abs_pos obj_x, obj_y, rotation = self.abs_pos
global newstroke_font global newstroke_font
if newstroke_font is None: if newstroke_font is None:
@ -322,7 +266,6 @@ class Text(Positioned):
xs = [x for points in strokes for x, _y in points] xs = [x for points in strokes for x, _y in points]
ys = [y for points in strokes for _x, y in points] ys = [y for points in strokes for _x, y in points]
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys) min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
h = self.font_size + self.stroke_width # (max_y - min_y)
if self.h_align == 'left': if self.h_align == 'left':
x0 = 0 x0 = 0
@ -333,16 +276,16 @@ class Text(Positioned):
else: else:
raise ValueError('h_align must be one of "left", "center", or "right".') raise ValueError('h_align must be one of "left", "center", or "right".')
if self.v_align == 'bottom': if self.v_align == 'top':
y0 = h y0 = -(max_y - min_y)
elif self.v_align == 'middle': elif self.v_align == 'middle':
y0 = h/2 y0 = -(max_y - min_y)/2
elif self.v_align == 'top': elif self.v_align == 'bottom':
y0 = 0 y0 = 0
else: else:
raise ValueError('v_align must be one of "top", "middle", or "bottom".') raise ValueError('v_align must be one of "top", "middle", or "bottom".')
if self.flip: if self.side == 'bottom':
x0 += min_x + max_x x0 += min_x + max_x
x_sign = -1 x_sign = -1
else: else:
@ -352,10 +295,10 @@ class Text(Positioned):
for stroke in strokes: for stroke in strokes:
for (x1, y1), (x2, y2) in zip(stroke[:-1], stroke[1:]): for (x1, y1), (x2, y2) in zip(stroke[:-1], stroke[1:]):
obj = Line(x0+x_sign*x1, y0+y1, x0+x_sign*x2, y0+y2, aperture=ap, unit=self.unit, polarity_dark=self.polarity_dark) obj = Line(x0+x_sign*x1, y0-y1, x0+x_sign*x2, y0-y2, aperture=ap, unit=self.unit, polarity_dark=self.polarity_dark)
obj.rotate(rotation) obj.rotate(rotation)
obj.offset(obj_x, obj_y) obj.offset(obj_x, obj_y)
layer_stack['bottom' if flip else 'top', self.layer].objects.append(obj) layer_stack[self.side, self.layer].objects.append(obj)
def bounding_box(self, unit=MM): def bounding_box(self, unit=MM):
approx_w = len(self.text)*self.font_size*0.75 + self.stroke_width approx_w = len(self.text)*self.font_size*0.75 + self.stroke_width
@ -369,208 +312,165 @@ class Text(Positioned):
x0 = -approx_w x0 = -approx_w
if self.v_align == 'top': if self.v_align == 'top':
y0 = 0 y0 = -approx_h
elif self.v_align == 'middle': elif self.v_align == 'middle':
y0 = -approx_h/2 y0 = -approx_h/2
elif self.v_align == 'bottom': elif self.v_align == 'bottom':
y0 = -approx_h y0 = 0
return (self.x+x0, self.y+y0), (self.x+x0+approx_w, self.y+y0+approx_h) return (self.x+x0, self.y+y0), (self.x+x0+approx_w, self.y+y0+approx_h)
@dataclass(frozen=True, slots=True) @dataclass
class PadStackAperture: class Pad(Positioned):
aperture: Aperture pass
side: str
layer: str
offset_x: float = 0 # in PadStack units
offset_y: float = 0
rotation: float = 0
invert: bool = False
@dataclass(frozen=True, slots=True) @dataclass
class PadStack: class SMDPad(Pad):
_: KW_ONLY copper_aperture: Aperture
unit: LengthUnit = MM mask_aperture: Aperture
paste_aperture: Aperture
silk_features: list = field(default_factory=list)
@property def render(self, layer_stack, cache=None):
def apertures(self): x, y, rotation = self.abs_pos
raise NotImplementedError() layer_stack[self.side, 'copper'].objects.append(Flash(x, y, self.copper_aperture.rotated(rotation), unit=self.unit))
layer_stack[self.side, 'mask' ].objects.append(Flash(x, y, self.mask_aperture.rotated(rotation), unit=self.unit))
def flashes(self, x, y, rotation: float = 0, flip: bool = False): if self.paste_aperture:
for ap in self.apertures: layer_stack[self.side, 'paste' ].objects.append(Flash(x, y, self.paste_aperture.rotated(rotation), unit=self.unit))
aperture = ap.aperture.rotated(ap.rotation + rotation) layer_stack[self.side, 'silk' ].objects.extend([copy(feature).rotate(rotation).offset(x, y, self.unit)
fl = Flash(ap.offset_x, ap.offset_y, aperture, polarity_dark=not ap.invert, unit=self.unit) for feature in self.silk_features])
fl.rotate(rotation)
fl.offset(x, y)
side = ap.side
if flip:
side = {'top': 'bottom', 'bottom': 'top'}.get(side, side)
yield side, ap.layer, fl
def render(self, layer_stack, x, y, rotation: float = 0, flip: bool = False):
for side, layer, flash in self.flashes(x, y, rotation, flip):
if side == 'drill' and layer == 'plated':
layer_stack.drill_pth.objects.append(flash)
elif side == 'drill' and layer == 'nonplated':
layer_stack.drill_npth.objects.append(flash)
elif (side, layer) in layer_stack:
layer_stack[side, layer].objects.append(flash)
@property
def single_sided(self):
return len({ap.side for ap in self.apertures}) <= 1
@dataclass(frozen=True, slots=True)
class SMDStack(PadStack):
aperture: Aperture
mask_expansion: float = 0.0
paste_expansion: float = 0.0
paste: bool = True
flip: bool = False
@property
def side(self):
return 'bottom' if self.flip else 'top'
@property
def apertures(self):
yield PadStackAperture(self.aperture, self.side, 'copper')
yield PadStackAperture(self.aperture.dilated(self.mask_expansion, self.unit), self.side, 'mask')
if self.paste:
yield PadStackAperture(self.aperture.dilated(self.paste_expansion, self.unit), self.side, 'paste')
@classmethod @classmethod
def rect(kls, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, flip=False, unit=MM): def rect(kls, x, y, w, h, rotation=0, side='top', mask_expansion=0.0, paste_expansion=0.0, paste=True, unit=MM):
ap = RectangleAperture(w, h, unit=unit).rotated(rotation) ap_c = RectangleAperture(w, h, unit=unit)
return kls(ap, mask_expansion, paste_expansion, paste, flip, unit=unit) ap_m = RectangleAperture(w+2*mask_expansion, h+2*mask_expansion, unit=unit)
ap_p = RectangleAperture(w+2*paste_expansion, h+2*paste_expansion, unit=unit) if paste else None
return kls(x, y, side=side, copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, rotation=rotation,
unit=unit)
@classmethod @classmethod
def circle(kls, dia, mask_expansion=0.0, paste_expansion=0.0, paste=True, flip=False, unit=MM): def circle(kls, x, y, dia, side='top', mask_expansion=0.0, paste_expansion=0.0, paste=True, unit=MM):
return kls(CircleAperture(dia, unit=unit), mask_expansion, paste_expansion, paste, flip, unit=unit) ap_c = CircleAperture(dia, unit=unit)
ap_m = CircleAperture(dia+2*mask_expansion, unit=unit)
ap_p = CircleAperture(dia+2*paste_expansion, unit=unit) if paste else None
return kls(x, y, side=side, copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, unit=unit)
@dataclass(frozen=True, slots=True) @dataclass
class MechanicalHoleStack(PadStack): class THTPad(Pad):
drill_dia: float drill_dia: float
mask_expansion: float = 0.0 pad_top: SMDPad
mask_aperture = None pad_bottom: SMDPad = None
aperture_inner: Aperture = None
@property
def apertures(self):
mask_aperture = self.mask_aperture or CircleAperture(self.drill_dia + self.mask_expansion, unit=self.unit)
yield PadStackAperture(mask_aperture, 'top', 'mask')
yield PadStackAperture(mask_aperture, 'bottom', 'mask')
@property
def single_sided(self):
return False
@dataclass(frozen=True, slots=True)
class THTPad(PadStack):
drill_dia: float
pad_top: SMDStack
pad_bottom: SMDStack = None
aperture_inner: Aperture = UNDEFINED
plated: bool = True plated: bool = True
def __post_init__(self): def __post_init__(self):
if self.pad_bottom is None: if self.pad_bottom is None:
object.__setattr__(self, 'pad_bottom', replace(self.pad_top, flip=True)) import sys
self.pad_bottom = copy(self.pad_top)
if self.aperture_inner is UNDEFINED: self.pad_bottom.flip()
object.__setattr__(self, 'aperture_inner', self.pad_top.aperture)
if self.pad_top.flip: self.pad_top.parent = self.pad_bottom.parent = self
raise ValueError('top pad cannot be flipped')
@property if (self.pad_top.side, self.pad_bottom.side) != ('top', 'bottom'):
def plating(self): raise ValueError(f'The top and bottom pads must have side set to top and bottom, respectively. Currently, the top pad side is set to "{self.pad_top.side}" and the bottom pad side to "{self.pad_bottom.side}".')
return 'plated' if self.plated else 'nonplated'
@property
def apertures(self):
yield from self.pad_top.apertures
yield from self.pad_bottom.apertures
if self.aperture_inner is not None:
yield PadStackAperture(self.aperture_inner, 'inner', 'copper')
yield PadStackAperture(ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), 'drill', self.plating)
@property
def single_sided(self):
return False
@classmethod
def rect(kls, drill_dia, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
pad = SMDStack.rect(w, h, rotation, mask_expansion, paste_expansion, paste, unit=unit)
return kls(drill_dia, pad, plated=plated)
@classmethod
def circle(kls, drill_dia, dia, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
pad = SMDStack.circle(dia, mask_expansion, paste_expansion, paste, unit=unit)
return kls(drill_dia, pad, plated=plated)
@classmethod
def obround(kls, drill_dia, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
ap = ObroundAperture(w, h, unit=unit).rotated(rotation)
pad = SMDStack(ap, mask_expansion, paste_expansion, paste, unit=unit)
return kls(drill_dia, pad, plated=plated)
@dataclass(frozen=True, slots=True)
class ThroughViaStack(PadStack):
hole: float
dia: float = None
tented: bool = True
def __post_init__(self):
if self.dia == None:
object.__setattr__(self, 'dia', self.hole*2)
@property
def single_sided(self):
return False
@property
def apertures(self):
copper_aperture = CircleAperture(self.dia, unit=self.unit)
yield PadStackAperture(copper_aperture, 'top', 'copper')
yield PadStackAperture(copper_aperture, 'bottom', 'copper')
yield PadStackAperture(copper_aperture, 'inner', 'copper')
if self.tented:
yield PadStackAperture(copper_aperture, 'top', 'mask')
yield PadStackAperture(copper_aperture, 'bottom', 'mask')
yield PadStackAperture(ExcellonTool(self.hole, plated=True, unit=self.unit), 'drill', 'plated')
@dataclass(frozen=True, slots=True)
class Via(FrozenPositioned):
pad_stack: PadStack
def render(self, layer_stack, cache=None): def render(self, layer_stack, cache=None):
x, y, rotation, flip = self.abs_pos x, y, rotation = self.abs_pos
self.pad_stack.render(layer_stack, x, y, rotation, flip) self.pad_top.parent = self
self.pad_top.render(layer_stack)
if self.pad_bottom:
self.pad_bottom.parent = self
self.pad_bottom.render(layer_stack)
if self.aperture_inner is None:
(x_min, y_min), (x_max, y_max) = self.pad_top.bounding_box(MM)
w_top = x_max - x_min
h_top = y_max - y_min
if self.pad_bottom:
(x_min, y_min), (x_max, y_max) = self.pad_bottom.bounding_box(MM)
w_bottom = x_max - x_min
h_bottom = y_max - y_min
w_top = min(w_top, w_bottom)
h_top = min(h_top, h_bottom)
self.aperture_inner = CircleAperture(min(w_top, h_top), unit=MM)
for (side, use), layer in layer_stack.inner_layers:
layer.objects.append(Flash(x, y, self.aperture_inner.rotated(rotation), unit=self.unit))
hole = Flash(x, y, ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), unit=self.unit)
if self.plated:
layer_stack.drill_pth.objects.append(hole)
else:
layer_stack.drill_npth.objects.append(hole)
@property
def single_sided(self):
return False
@classmethod @classmethod
def at(kls, x, y, hole, dia=None, tented=True, unit=MM): def rect(kls, x, y, hole_dia, w, h=None, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
return kls(x, y, ThroughViaStack(hole, dia, tented, unit=unit), unit=unit) if h is None:
h = w
pad = SMDPad.rect(0, 0, w, h, mask_expansion=mask_expansion, paste_expansion=paste_expansion, paste=paste, unit=unit)
return kls(x, y, hole_dia, pad, rotation=rotation, plated=plated, unit=unit)
@classmethod
def circle(kls, x, y, hole_dia, dia, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
pad = SMDPad.circle(0, 0, dia, mask_expansion=mask_expansion, paste_expansion=paste_expansion, paste=paste, unit=unit)
return kls(x, y, hole_dia, pad, plated=plated, unit=unit)
@classmethod
def obround(kls, x, y, hole_dia, w, h, rotation=0, mask_expansion=0.0, paste_expanson=0.0, paste=True, plated=True, unit=MM):
ap_c = ObroundAperture(w, h, unit=unit)
ap_m = ObroundAperture(w+2*mask_expansion, h+2*mask_expansion, unit=unit)
ap_p = ObroundAperture(w, h, unit=unit) if paste else None
pad = SMDPad(0, 0, side='top', copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, unit=unit)
return kls(x, y, hole_dia, pad, rotation=rotation, plated=plated, unit=unit)
@dataclass @dataclass
class Pad(Positioned): class Hole(Positioned):
pad_stack: PadStack diameter: float
mask_copper_margin: float = 0.2
def render(self, layer_stack, cache=None): def render(self, layer_stack, cache=None):
x, y, rotation, flip = self.abs_pos x, y, rotation = self.abs_pos
self.pad_stack.render(layer_stack, x, y, rotation, flip)
hole = Flash(x, y, ExcellonTool(self.diameter, plated=False, unit=self.unit), unit=self.unit)
layer_stack.drill_npth.objects.append(hole)
if self.mask_copper_margin > 0:
mask = Flash(x, y, CircleAperture(self.mask_copper_margin, unit=self.unit), polarity_dark=False, unit=self.unit)
layer_stack['top', 'copper'].objects.append(mask)
layer_stack['bottom', 'copper'].objects.append(mask)
@property
def single_sided(self):
return False
@dataclass
class Via(Positioned):
diameter: float
hole: float
def render(self, layer_stack, cache=None):
x, y, rotation = self.abs_pos
aperture = CircleAperture(diameter=self.diameter, unit=self.unit)
tool = ExcellonTool(diameter=self.hole, unit=self.unit)
for (side, use), layer in layer_stack.copper_layers:
layer.objects.append(Flash(x, y, aperture, unit=self.unit))
layer_stack.drill_pth.objects.append(Flash(x, y, tool, unit=self.unit))
@property @property
def single_sided(self): def single_sided(self):
return self.pad_stack.single_sided return False
@dataclass @dataclass
@ -580,9 +480,8 @@ class Trace:
end: object = None end: object = None
waypoints: [(float, float)] = field(default_factory=list) waypoints: [(float, float)] = field(default_factory=list)
style: str = 'oblique' style: str = 'oblique'
orientation: [str] = tuple() # 'cw' or 'ccw' orientation: [str] = tuple() # 'top' or 'bottom'
roundover: float = 0 roundover: float = 0
side: str = 'top'
unit: LengthUnit = MM unit: LengthUnit = MM
parent: object = None parent: object = None
@ -730,13 +629,13 @@ class Trace:
yield Line(line_b.x1, line_b.y1, x3, y3, aperture=aperture, unit=self.unit) yield Line(line_b.x1, line_b.y1, x3, y3, aperture=aperture, unit=self.unit)
def to_graphic_objects(self): def _to_graphic_objects(self):
start, end = self.start, self.end start, end = self.start, self.end
if not isinstance(start, tuple): if not isinstance(start, tuple):
*start, _rotation, _flip = start.abs_pos *start, _rotation = start.abs_pos
if not isinstance(end, tuple): if not isinstance(end, tuple):
*end, _rotation, _flip = end.abs_pos *end, _rotation = end.abs_pos
aperture = CircleAperture(diameter=self.width, unit=self.unit) aperture = CircleAperture(diameter=self.width, unit=self.unit)
@ -750,7 +649,7 @@ class Trace:
return self._round_over(points, aperture) return self._round_over(points, aperture)
def render(self, layer_stack, cache=None): def render(self, layer_stack, cache=None):
layer_stack[self.side, 'copper'].objects.extend(self.to_graphic_objects()) layer_stack[self.side, 'copper'].objects.extend(self._to_graphic_objects())
def _route_demo(): def _route_demo():
from ..utils import setup_svg, Tag from ..utils import setup_svg, Tag

739
gerbonara/cad/protoboard.py Normal file
View file

@ -0,0 +1,739 @@
import sys
import re
import math
import string
import itertools
from copy import copy, deepcopy
import warnings
import importlib.resources
from ..utils import MM, rotate_point
from .primitives import *
from ..graphic_objects import Region
from ..apertures import RectangleAperture, CircleAperture, ApertureMacroInstance
from ..aperture_macros.parse import ApertureMacro, VariableExpression
from ..aperture_macros import primitive as amp
from .kicad import footprints as kfp
from . import data as package_data
class ProtoBoard(Board):
def __init__(self, w, h, content, margin=None, corner_radius=None, mounting_hole_dia=None, mounting_hole_offset=None, unit=MM):
corner_radius = corner_radius or unit(1.5, MM)
super().__init__(w, h, corner_radius, unit=unit)
self.margin = margin or unit(2, MM)
self.content = content
if mounting_hole_dia:
mounting_hole_offset = mounting_hole_offset or mounting_hole_dia*2
ko = mounting_hole_offset*2
self.add(Hole(mounting_hole_offset, mounting_hole_offset, mounting_hole_dia, unit=unit))
self.add(Hole(w-mounting_hole_offset, mounting_hole_offset, mounting_hole_dia, unit=unit))
self.add(Hole(mounting_hole_offset, h-mounting_hole_offset, mounting_hole_dia, unit=unit))
self.add(Hole(w-mounting_hole_offset, h-mounting_hole_offset, mounting_hole_dia, unit=unit))
self.keepouts.append(((0, 0), (ko, ko)))
self.keepouts.append(((w-ko, 0), (w, ko)))
self.keepouts.append(((0, h-ko), (ko, h)))
self.keepouts.append(((w-ko, h-ko), (w, h)))
self.generate()
def generate(self, unit=MM):
bbox = ((self.margin, self.margin), (self.w-self.margin, self.h-self.margin))
bbox = unit.convert_bounds_from(self.unit, bbox)
for obj in self.content.generate(bbox, (True, True, True, True), unit):
self.add(obj, keepout_errors='skip')
class PropLayout:
def __init__(self, content, direction, proportions):
self.content = list(content)
if direction not in ('h', 'v'):
raise ValueError('direction must be one of "h", or "v".')
self.direction = direction
self.proportions = list(proportions)
if len(content) != len(proportions):
raise ValueError('proportions and content must have same length')
def generate(self, bbox, border_text, unit=MM):
for i, (bbox, child) in enumerate(self.layout_2d(bbox, unit)):
first = bool(i == 0)
last = bool(i == len(self.content)-1)
yield from child.generate(bbox, (
border_text[0] and (last or self.direction == 'h'),
border_text[1] and (last or self.direction == 'v'),
border_text[2] and (first or self.direction == 'h'),
border_text[3] and (first or self.direction == 'v'),
), unit)
def fit_size(self, w, h, unit=MM):
widths = []
heights = []
for ((x_min, y_min), (x_max, y_max)), child in self.layout_2d(((0, 0), (w, h)), unit):
if not isinstance(child, EmptyProtoArea):
widths.append(x_max - x_min)
heights.append(y_max - y_min)
if self.direction == 'h':
return sum(widths), max(heights, default=0)
else:
return max(widths, default=0), sum(heights)
def layout_2d(self, bbox, unit=MM):
(x, y), (w, h) = bbox
w, h = w-x, h-y
actual_l = 0
target_l = 0
for l, child in zip(self.layout(w if self.direction == 'h' else h, unit), self.content):
this_x, this_y = x, y
this_w, this_h = w, h
target_l += l
if self.direction == 'h':
this_w = target_l - actual_l
else:
this_h = target_l - actual_l
this_w, this_h = child.fit_size(this_w, this_h, unit)
if self.direction == 'h':
x += this_w
actual_l += this_w
this_h = h
else:
y += this_h
actual_l += this_h
this_w = w
yield ((this_x, this_y), (this_x+this_w, this_y+this_h)), child
def layout(self, length, unit=MM):
out = [ eval_value(value, MM(length, unit)) for value in self.proportions ]
total_length = sum(value for value in out if value is not None)
if length - total_length < -1e-6:
raise ValueError(f'Proportions sum to {total_length} mm, which is greater than the available space of {length} mm.')
leftover = length - total_length
sum_props = sum( (value or 1.0) for value in self.proportions if not isinstance(value, str) )
return [ unit(leftover * (value or 1.0) / sum_props if not isinstance(value, str) else calculated, MM)
for value, calculated in zip(self.proportions, out) ]
@property
def single_sided(self):
return all(elem.single_sided for elem in self.content)
def __str__(self):
children = ', '.join( f'{elem}:{width}' for elem, width in zip(self.content, self.proportions))
return f'PropLayout[{self.direction.upper()}]({children})'
class TwoSideLayout:
def __init__(self, top, bottom):
self.top, self.bottom = top, bottom
if not top.single_sided or not bottom.single_sided:
warnings.warn('Two-sided pattern used on one side of a TwoSideLayout')
def fit_size(self, w, h, unit=MM):
w1, h1 = self.top.fit_size(w, h, unit)
w2, h2 = self.bottom.fit_size(w, h, unit)
if isinstance(self.top, EmptyProtoArea):
if isinstance(self.bottom, EmptyProtoArea):
return w1, h1
return w2, h2
if isinstance(self.bottom, EmptyProtoArea):
return w1, h1
return max(w1, w2), max(h1, h2)
def generate(self, bbox, border_text, unit=MM):
yield from self.top.generate(bbox, border_text, unit)
for obj in self.bottom.generate(bbox, border_text, unit):
obj.side = 'bottom'
yield obj
def numeric(start=1):
def gen():
nonlocal start
for i in itertools.count(start):
yield str(i)
return gen
def alphabetic(case='upper'):
if case not in ('lower', 'upper'):
raise ValueError('case must be one of "lower" or "upper".')
index = string.ascii_lowercase if case == 'lower' else string.ascii_uppercase
def gen():
nonlocal index
for i in itertools.count():
if i<26:
yield index[i]
continue
i -= 26
if i<26*26:
yield index[i//26] + index[i%26]
continue
i -= 26*26
if i<26*26*26:
yield index[i//(26*26)] + index[(i//26)%26] + index[i%26]
else:
raise ValueError('row/column index out of range')
return gen
class PatternProtoArea:
def __init__(self, pitch_x, pitch_y=None, obj=None, numbers=True, font_size=None, font_stroke=None, number_x_gen=alphabetic(), number_y_gen=numeric(), interval_x=5, interval_y=None, margin=0, unit=MM):
self.pitch_x = pitch_x
self.pitch_y = pitch_y or pitch_x
self.margin = margin
self.obj = obj
self.unit = unit
self.numbers = numbers
self.font_size = font_size or unit(1.0, MM)
self.font_stroke = font_stroke or unit(0.2, MM)
self.interval_x = interval_x
self.interval_y = interval_y or (1 if MM(self.pitch_y, unit) >= 2.0 else 5)
self.number_x_gen, self.number_y_gen = number_x_gen, number_y_gen
def fit_size(self, w, h, unit=MM):
(min_x, min_y), (max_x, max_y) = self.fit_rect(((0, 0), (max(0, w-2*self.margin), max(0, h-2*self.margin))))
return max_x-min_x + 2*self.margin, max_y-min_y + 2*self.margin
def fit_rect(self, bbox, unit=MM):
(x, y), (w, h) = bbox
x, y = x+self.margin, y+self.margin
w, h = w-x-self.margin, h-y-self.margin
w_mod = round((w + 5e-7) % unit(self.pitch_x, self.unit), 6)
h_mod = round((h + 5e-7) % unit(self.pitch_y, self.unit), 6)
w_fit, h_fit = round(w - w_mod, 6), round(h - h_mod, 6)
x = x + (w-w_fit)/2
y = y + (h-h_fit)/2
return (x, y), (x+w_fit, y+h_fit)
def generate(self, bbox, border_text, unit=MM):
(x, y), (w, h) = bbox
w, h = w-x, h-y
n_x = int(w//unit(self.pitch_x, self.unit))
n_y = int(h//unit(self.pitch_y, self.unit))
off_x = (w % unit(self.pitch_x, self.unit)) / 2
off_y = (h % unit(self.pitch_y, self.unit)) / 2
if self.numbers:
for i, lno_i in list(zip(range(n_y), self.number_y_gen())):
if i == 0 or i == n_y - 1 or (i+1) % self.interval_y == 0:
t_y = off_y + y + (n_y - 1 - i + 0.5) * self.pitch_y
if border_text[3]:
t_x = x + off_x
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'right', 'middle', unit=self.unit)
if not self.single_sided:
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'right', 'middle', side='bottom', unit=self.unit)
if border_text[1]:
t_x = x + w - off_x
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'left', 'middle', unit=self.unit)
if not self.single_sided:
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'left', 'middle', side='bottom', unit=self.unit)
for i, lno_i in zip(range(n_x), self.number_x_gen()):
if i == 0 or i == n_x - 1 or (i+1) % self.interval_x == 0:
t_x = off_x + x + (i + 0.5) * self.pitch_x
if border_text[2]:
t_y = y + off_y
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'top', unit=self.unit)
if not self.single_sided:
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'top', side='bottom', unit=self.unit)
if border_text[0]:
t_y = y + h - off_y
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'bottom', unit=self.unit)
if not self.single_sided:
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'bottom', side='bottom', unit=self.unit)
for i in range(n_x):
for j in range(n_y):
if hasattr(self.obj, 'inst'):
inst = self.obj.inst(i, j, i == n_x-1, j == n_y-1)
if not inst:
continue
else:
inst = copy(self.obj)
inst.x = inst.unit(off_x + x, unit) + (i + 0.5) * inst.unit(self.pitch_x, self.unit)
inst.y = inst.unit(off_y + y, unit) + (j + 0.5) * inst.unit(self.pitch_y, self.unit)
yield inst
@property
def single_sided(self):
return self.obj.single_sided
class EmptyProtoArea:
def __init__(self, copper_fill=False):
self.copper_fill = copper_fill
def fit_size(self, w, h, unit=MM):
return w, h
def generate(self, bbox, border_text, unit=MM):
if self.copper_fill:
(min_x, min_y), (max_x, max_y) = bbox
group = ObjectGroup(0, 0, top_copper=[Region([(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)],
unit=unit, polarity_dark=True)])
group.bounding_box = lambda *args, **kwargs: None
yield group
@property
def single_sided(self):
return True
class ManhattanPads(ObjectGroup):
def __init__(self, w, h=None, gap=0.2, unit=MM):
super().__init__(0, 0)
h = h or w
self.gap = gap
self.unit = unit
p = (w-2*gap)/2
q = (h-2*gap)/2
small_ap = RectangleAperture(p, q, unit=unit)
s = min(w, h) / 2 / math.sqrt(2)
large_ap = RectangleAperture(s, s, unit=unit).rotated(math.pi/4)
large_ap_neg = RectangleAperture(s+2*gap, s+2*gap, unit=unit).rotated(math.pi/4)
a = gap/2 + p/2
b = gap/2 + q/2
self.top_copper.append(Flash(-a, -b, aperture=small_ap, unit=unit))
self.top_copper.append(Flash(-a, b, aperture=small_ap, unit=unit))
self.top_copper.append(Flash( a, -b, aperture=small_ap, unit=unit))
self.top_copper.append(Flash( a, b, aperture=small_ap, unit=unit))
self.top_copper.append(Flash(0, 0, aperture=large_ap_neg, polarity_dark=False, unit=unit))
self.top_copper.append(Flash(0, 0, aperture=large_ap, unit=unit))
self.top_mask = self.top_copper
class RFGroundProto(ObjectGroup):
def __init__(self, pitch=None, drill=None, clearance=None, via_dia=None, via_drill=None, pad_dia=None, trace_width=None, unit=MM):
super().__init__(0, 0)
self.unit = unit
self.pitch = pitch = pitch or unit(2.54, MM)
self.drill = drill = drill or unit(0.9, MM)
self.clearance = clearance = clearance or unit(0.3, MM)
self.via_drill = via_drill = via_drill or unit(0.4, MM)
self.via_dia = via_dia = via_dia or unit(0.8, MM)
if pad_dia is None:
self.trace_width = trace_width = trace_width or unit(0.3, MM)
pad_dia = pitch - trace_width - 2*clearance
elif trace_width is None:
trace_width = pitch - pad_dia - 2*clearance
self.pad_dia = pad_dia
via_ap = RectangleAperture(via_dia, via_dia, unit=unit).rotated(math.pi/4)
pad_ap = CircleAperture(pad_dia, unit=unit)
pad_neg_ap = CircleAperture(pad_dia+2*clearance, unit=unit)
ground_ap = RectangleAperture(pitch + unit(0.01, MM), pitch + unit(0.01, MM), unit=unit)
pad_drill = ExcellonTool(drill, plated=True, unit=unit)
via_drill = ExcellonTool(via_drill, plated=True, unit=unit)
self.top_copper.append(Flash(0, 0, aperture=ground_ap, unit=unit))
self.top_copper.append(Flash(0, 0, aperture=pad_neg_ap, polarity_dark=False, unit=unit))
self.top_copper.append(Flash(0, 0, aperture=pad_ap, unit=unit))
self.top_mask.append(Flash(0, 0, aperture=pad_ap, unit=unit))
self.top_copper.append(Flash(pitch/2, pitch/2, aperture=via_ap, unit=unit))
self.top_mask.append(Flash(pitch/2, pitch/2, aperture=via_ap, unit=unit))
self.drill_pth.append(Flash(0, 0, aperture=pad_drill, unit=unit))
self.drill_pth.append(Flash(pitch/2, pitch/2, aperture=via_drill, unit=unit))
self.bottom_copper = self.top_copper
self.bottom_mask = self.top_mask
def inst(self, x, y, border_x, border_y):
inst = copy(self)
if border_x or border_y:
inst.drill_pth = inst.drill_pth[:-1]
inst.top_copper = inst.bottom_copper = inst.top_copper[:-1]
inst.top_mask = inst.bottom_mask = inst.top_mask[:-1]
return inst
class THTFlowerProto(ObjectGroup):
def __init__(self, pitch=None, drill=None, diameter=None, unit=MM):
super().__init__(0, 0, unit=unit)
self.pitch = pitch = pitch or unit(2.54, MM)
drill = drill or unit(0.9, MM)
diameter = diameter or unit(2.0, MM)
p = pitch / 2
self.objects.append(THTPad.circle(-p, 0, drill, diameter, paste=False, unit=unit))
self.objects.append(THTPad.circle( p, 0, drill, diameter, paste=False, unit=unit))
self.objects.append(THTPad.circle(0, -p, drill, diameter, paste=False, unit=unit))
self.objects.append(THTPad.circle(0, p, drill, diameter, paste=False, unit=unit))
middle_ap = CircleAperture(diameter, unit=unit)
self.top_copper.append(Flash(0, 0, aperture=middle_ap, unit=unit))
self.bottom_copper = self.top_mask = self.bottom_mask = self.top_copper
def inst(self, x, y, border_x, border_y):
if (x % 2 == 0) and (y % 2 == 0):
return copy(self)
if (x % 2 == 1) and (y % 2 == 1):
return copy(self)
return None
def bounding_box(self, unit=MM):
x, y, rotation = self.abs_pos
p = self.pitch/2
return unit.convert_bounds_from(self.unit, ((x-p, y-p), (x+p, y+p)))
class PoweredProto(ObjectGroup):
""" Cell primitive for "powered" THT breadboards. This cell type is based on regular THT pads in a 100 mil grid, but
adds small SMD pads diagonally between the THT pads. These SMD pads are interconnected with traces and vias in such
a way that every second one is inter-linked, forming two fully connected grids. Next to every THT pad you have one
pad of each grid, so this layout is awesome for distributing power throughout the board.
This design is based on one that Yajima Manufacturing Akizuki Denshi, Akihabara's finest electronics store sells for
next to nothing. Sadly, they don't ship internationally and they don't even have an English website, but if you ever
are in Akihabara, Tokyo, Japan I can *highly* recommend a visit. The ones Yajima make are better than what this will
produce since the Yajima ones use a two-colored silkscreen to visually distinguish the two power pad grids.
Links:
Akizuki Denshi product page: https://akizukidenshi.com/catalog/g/gP-07214/
Yajima Manufacturing Corporation website: http://www.yajima-works.co.jp/index.html
"""
def __init__(self, pitch=None, drill=None, clearance=None, power_pad_dia=None, via_size=None, trace_width=None, unit=MM):
super().__init__(0, 0)
self.unit = unit
self.pitch = pitch = pitch or unit(2.54, MM)
self.drill = drill = drill or unit(0.9, MM)
self.clearance = clearance = clearance or unit(0.3, MM)
self.trace_width = trace_width = trace_width or unit(0.3, MM)
self.via_size = via_size = via_size or unit(0.4, MM)
main_pad_dia = pitch - trace_width - 2*clearance
power_pad_dia_max = math.sqrt(2)*pitch - main_pad_dia - 2*clearance
if power_pad_dia is None:
power_pad_dia = power_pad_dia_max - clearance # reduce some more to give the user more room
elif power_pad_dia > power_pad_dia_max:
warnings.warn(f'Power pad diameter {power_pad_dia} > {power_pad_dia_max} violates pad-to-pad clearance')
self.power_pad_dia = power_pad_dia
main_ap = CircleAperture(main_pad_dia, unit=unit)
power_ap = CircleAperture(self.power_pad_dia, unit=unit)
for l in [self.top_copper, self.bottom_copper]:
l.append(Flash(0, 0, aperture=main_ap, unit=unit))
l.append(Flash(-pitch/2, -pitch/2, aperture=power_ap, unit=unit))
l.append(Flash(-pitch/2, pitch/2, aperture=power_ap, unit=unit))
l.append(Flash( pitch/2, -pitch/2, aperture=power_ap, unit=unit))
l.append(Flash( pitch/2, pitch/2, aperture=power_ap, unit=unit))
self.drill_pth.append(Flash(0, 0, ExcellonTool(drill, plated=True, unit=unit), unit=unit))
self.drill_pth.append(Flash(-pitch/2, -pitch/2, ExcellonTool(via_size, plated=True, unit=unit), unit=unit))
self.top_mask = copy(self.top_copper)
self.bottom_mask = copy(self.bottom_copper)
self.line_ap = CircleAperture(trace_width, unit=unit)
self.top_copper.append(Line(-pitch/2, -pitch/2, -pitch/2, pitch/2, aperture=self.line_ap, unit=unit))
self.top_copper.append(Line(pitch/2, -pitch/2, pitch/2, pitch/2, aperture=self.line_ap, unit=unit))
self.bottom_copper.append(Line(-pitch/2, -pitch/2, pitch/2, -pitch/2, aperture=self.line_ap, unit=unit))
self.bottom_copper.append(Line(-pitch/2, pitch/2, pitch/2, pitch/2, aperture=self.line_ap, unit=unit))
def inst(self, x, y, border_x, border_y):
inst = copy(self)
if (x + y) % 2 == 0:
inst.drill_pth = inst.drill_pth[:-1]
c = self.power_pad_dia/2 + self.clearance
p = self.pitch/2
if x == 1:
inst.top_silk = [Line(-p, -p+c, -p, p-c, aperture=self.line_ap, unit=self.unit)]
elif x % 2 == 0:
inst.top_silk = [Line(p, -p+c, p, p-c, aperture=self.line_ap, unit=self.unit)]
if y == 0:
inst.bottom_silk = [Line(-p+c, -p, p-c, -p, aperture=self.line_ap, unit=self.unit)]
elif y % 2 == 1:
inst.bottom_silk = [Line(-p+c, p, p-c, p, aperture=self.line_ap, unit=self.unit)]
return inst
def bounding_box(self, unit=MM):
x, y, rotation = self.abs_pos
p = self.pitch/2
return unit.convert_bounds_from(self.unit, ((x-p, y-p), (x+p, y+p)))
class SpikyProto(ObjectGroup):
""" Cell primitive for the "spiky" protoboard designed by @electroniceel and published on github at the URL below.
This layout has small-ish standard THT pads, but in between these pads it puts a grid of SMD pads that are designed
for easy solder bridging to allow for the construction of traces from solder bridging.
Github URL: https://github.com/electroniceel/protoboard
"""
def __init__(self, pitch=None, drill=None, clearance=None, power_pad_dia=None, via_size=None, trace_width=None, unit=MM):
super().__init__(0, 0, unit=unit)
res = importlib.resources.files(package_data)
self.fp_center = kfp.Footprint.load(res.joinpath('center-pad-spikes.kicad_mod').read_text(encoding='utf-8'))
self.corner_pad = kfp.FootprintInstance(1.27, 1.27, self.fp_center, unit=MM)
self.pad = kfp.Footprint.load(res.joinpath('tht-0.8.kicad_mod').read_text(encoding='utf-8'))
self.center_pad = kfp.FootprintInstance(0, 0, self.pad, unit=MM)
self.fp_between = kfp.Footprint.load(res.joinpath('pad-between-spiked.kicad_mod').read_text(encoding='utf-8'))
self.right_pad = kfp.FootprintInstance(1.27, 0, self.fp_between, unit=MM)
self.top_pad = kfp.FootprintInstance(0, 1.27, self.fp_between, rotation=math.pi/2, unit=MM)
@property
def objects(self):
return [x for x in (self.center_pad, self.corner_pad, self.right_pad, self.top_pad) if x is not None]
@objects.setter
def objects(self, value):
pass
def inst(self, x, y, border_x, border_y):
inst = copy(self)
if border_x:
inst.corner_pad = inst.right_pad = None
if border_y:
inst.corner_pad = inst.top_pad = None
return inst
class AlioCell(ObjectGroup):
""" Cell primitive for the ALio protoboard designed by arief ibrahim adha and published on hackaday.io at the URL
below. Similar to electroniceel's spiky protoboard, this layout has small-ish standard THT pads, but in between
these pads it puts a grid of SMD pads that are designed for easy solder bridging to allow for the construction of
traces from solder bridging.
Hackaday.io URL: https://hackaday.io/project/28570/
"""
def __init__(self, pitch=None, drill=None, clearance=None, link_pad_width=None, link_trace_width=None, via_size=None, unit=MM):
super().__init__(0, 0, unit=unit)
self.pitch = pitch or unit(2.54, MM)
self.drill = drill or unit(0.9, MM)
self.clearance = clearance or unit(0.3, MM)
self.link_pad_width = link_pad_width or unit(1.1, MM)
self.link_trace_width = link_trace_width or unit(0.5, MM)
self.via_size = via_size or unit(0.4, MM)
self.border_x, self.border_y = False, False
self.inst_x, self.inst_y = None, None
@property
def single_sided(self):
return False
def inst(self, x, y, border_x, border_y):
inst = copy(self)
inst.border_x, inst.border_y = border_x, border_y
inst.inst_x, inst.inst_y = x, y
return inst
def bounding_box(self, unit):
x, y, rotation = self.abs_pos
# FIXME hack
return self.unit.convert_bounds_to(unit, ((x-self.pitch/2, y-self.pitch/2), (x+self.pitch/2, y+self.pitch/2)))
def render(self, layer_stack, cache=None):
x, y, rotation = self.abs_pos
def xf(fe):
fe = copy(fe)
fe.rotate(rotation)
fe.offset(x, y, self.unit)
return fe
var = VariableExpression
# parameters: [1: total height = pad width, 2: pitch, 3: trace width, 4: corner radius, 5: rotation, 6: clearance]
alio_main_macro = ApertureMacro('ALIOM', (
amp.CenterLine(MM, 1, var(2)-var(6), var(2)-var(3)-2*var(6), 0, 0, var(5)),
amp.Outline(MM, 0, 5, (
-var(2)/2, -var(2)/2,
-var(2)/2, -(var(7)-var(8)),
-var(7), -(var(7)-var(8)),
-(var(7)-var(8)), -var(7),
-(var(7)-var(8)), -var(2)/2,
-var(2)/2, -var(2)/2,
), var(5)),
amp.Outline(MM, 0, 5, (
-var(2)/2, var(2)/2,
-var(2)/2, (var(7)-var(8)),
-var(7), (var(7)-var(8)),
-(var(7)-var(8)), var(7),
-(var(7)-var(8)), var(2)/2,
-var(2)/2, var(2)/2,
), var(5)),
amp.Outline(MM, 0, 5, (
var(2)/2, -var(2)/2,
var(2)/2, -(var(7)-var(8)),
var(7), -(var(7)-var(8)),
(var(7)-var(8)), -var(7),
(var(7)-var(8)), -var(2)/2,
var(2)/2, -var(2)/2,
), var(5)),
amp.Outline(MM, 0, 5, (
var(2)/2, var(2)/2,
var(2)/2, (var(7)-var(8)),
var(7), (var(7)-var(8)),
(var(7)-var(8)), var(7),
(var(7)-var(8)), var(2)/2,
var(2)/2, var(2)/2,
), var(5)),
amp.Circle(MM, 0, 2*var(8), -var(7), -var(7), var(5)),
amp.Circle(MM, 0, 2*var(8), -var(7), var(7), var(5)),
amp.Circle(MM, 0, 2*var(8), var(7), -var(7), var(5)),
amp.Circle(MM, 0, 2*var(8), var(7), var(7), var(5)),
), (
None, # 1
None, # 2
None, # 3
None, # 4
None, # 5
None, # 6
var(2)/2 - var(1)/2 + var(4), # 7
var(4)+var(6), # 8
))
corner_radius = (self.link_pad_width - self.link_trace_width)/3
main_ap = ApertureMacroInstance(alio_main_macro, (self.link_pad_width, # 1
self.pitch, # 2
self.link_trace_width, # 3
corner_radius, # 4
rotation, # 5
self.clearance), unit=MM) # 6
main_ap_90 = ApertureMacroInstance(alio_main_macro, (self.link_pad_width, # 1
self.pitch, # 2
self.link_trace_width, # 3
corner_radius, # 4
rotation-90, # 5
self.clearance), unit=MM) # 6
main_drill = ExcellonTool(self.drill, plated=True, unit=self.unit)
via_drill = ExcellonTool(self.via_size, plated=True, unit=self.unit)
# parameters: [1: total height = pad width, 2: total width, 3: trace width, 4: corner radius, 5: rotation]
alio_macro = ApertureMacro('ALIOP', (
amp.CenterLine(MM, 1, var(1)-2*var(4), var(1), 0, 0, var(5)),
amp.CenterLine(MM, 1, var(1), var(1)-2*var(4), 0, 0, var(5)),
amp.Circle(MM, 1, 2*var(4), -var(1)/2+var(4), -var(1)/2+var(4), var(5)),
amp.Circle(MM, 1, 2*var(4), -var(1)/2+var(4), var(1)/2-var(4), var(5)),
amp.Circle(MM, 1, 2*var(4), var(1)/2-var(4), -var(1)/2+var(4), var(5)),
amp.Circle(MM, 1, 2*var(4), var(1)/2-var(4), var(1)/2-var(4), var(5)),
amp.CenterLine(MM, 1, var(2), var(3), -var(2)/2 + var(1)/2, 0, var(5)),
))
alio_dark = ApertureMacroInstance(alio_macro, (self.link_pad_width, # 1
self.pitch-self.clearance, # 2
self.link_trace_width, # 3
corner_radius, # 4
rotation), unit=MM) # 5
alio_dark_90 = ApertureMacroInstance(alio_macro, (self.link_pad_width, # 1
self.pitch-self.clearance, # 2
self.link_trace_width, # 3
corner_radius, # 4
rotation+90), unit=MM) # 5
# all layers are identical here
for side, use in (('top', 'copper'), ('top', 'mask'), ('bottom', 'copper'), ('bottom', 'mask')):
if side == 'top':
layer_stack[side, use].objects.insert(0, xf(Flash(0, 0, aperture=main_ap, unit=self.unit)))
if not self.border_y:
layer_stack[side, use].objects.append(xf(Flash(self.pitch/2, self.pitch/2, aperture=alio_dark, unit=self.unit)))
else:
layer_stack[side, use].objects.insert(0, xf(Flash(0, 0, aperture=main_ap_90, unit=self.unit)))
if not self.border_x:
layer_stack[side, use].objects.append(xf(Flash(self.pitch/2, self.pitch/2, aperture=alio_dark_90, unit=self.unit)))
layer_stack.drill_pth.append(Flash(x, y, aperture=main_drill, unit=self.unit))
if not (self.border_x or self.border_y):
layer_stack.drill_pth.append(xf(Flash(self.pitch/2, self.pitch/2, aperture=via_drill, unit=self.unit)))
def convert_to_mm(value, unit):
unitl = unit.lower()
if unitl == 'mm':
return value
elif unitl == 'cm':
return value*10
elif unitl == 'in':
return value*25.4
elif unitl == 'mil':
return value/1000*25.4
else:
raise ValueError(f'Invalid unit {unit}, allowed units are mm, cm, in, and mil.')
_VALUE_RE = re.compile('([0-9]*\.?[0-9]+)(cm|mm|in|mil|%)')
def eval_value(value, total_length=None):
if not isinstance(value, str):
return None
m = _VALUE_RE.match(value.lower())
number, unit = m.groups()
if unit == '%':
if total_length is None:
raise ValueError('Percentages are not allowed for this value')
return total_length * float(number) / 100
return convert_to_mm(float(number), unit)
def _demo():
#pattern1 = PatternProtoArea(2.54, obj=THTPad.circle(0, 0, 0.9, 1.8, paste=False))
#pattern1 = PatternProtoArea(2.54, 2.54, obj=SpikyProto())
#pattern2 = PatternProtoArea(1.2, 2.0, obj=SMDPad.rect(0, 0, 1.0, 1.8, paste=False))
#pattern3 = PatternProtoArea(2.54, 1.27, obj=SMDPad.rect(0, 0, 2.3, 1.0, paste=False))
#pattern3 = EmptyProtoArea(copper_fill=True)
#stack = TwoSideLayout(pattern2, pattern3)
#pattern2 = PatternProtoArea(2.54, obj=PoweredProto(), margin=1)
pattern = PatternProtoArea(2.54, obj=RFGroundProto())
#stack = PropLayout([pattern2, pattern3], 'h', [0.5, 0.5])
#pattern = PropLayout([pattern1, stack], 'h', [0.5, 0.5])
#pattern = PatternProtoArea(2.54, obj=ManhattanPads(2.54))
#pattern = PatternProtoArea(2.54*1.5, obj=THTFlowerProto())
#pattern = PatternProtoArea(2.54, obj=THTPad.circle(0, 0, 0.9, 1.8, paste=False))
#pattern = PatternProtoArea(2.54, obj=PoweredProto())
#pattern = PatternProtoArea(2.54, obj=AlioCell(), margin=2)
pb = ProtoBoard(50, 47, pattern, mounting_hole_dia=3.2, mounting_hole_offset=5)
#pb = ProtoBoard(10, 10, pattern1)
print(pb.pretty_svg())
pb.layer_stack().save_to_directory('/tmp/testdir')
if __name__ == '__main__':
_demo()
#cnt = alphabetic()()
#for _ in range(32):
# for _ in range(26):
# print(f'{next(cnt):>2}', end=' ', file=sys.stderr)
# print(file=sys.stderr)

View file

@ -8,7 +8,6 @@ from quart import Quart, request, Response, send_file, abort
from . import protoboard as pb from . import protoboard as pb
from . import protoserve_data from . import protoserve_data
from .primitives import SMDStack
from ..utils import MM, Inch from ..utils import MM, Inch
@ -26,7 +25,7 @@ def extract_importlib(package):
else: else:
assert item.is_dir() assert item.is_dir()
item_out.mkdir() item_out.mkdir()
stack.append((item, item_out)) stack.push((item, item_out))
return root return root
@ -63,10 +62,10 @@ def deserialize(obj, unit):
case 'smd': case 'smd':
match obj['pad_shape']: match obj['pad_shape']:
case 'rect': case 'rect':
stack = SMDStack.rect(pitch_x-clearance, pitch_y-clearance, paste=False, unit=unit) pad = pb.SMDPad.rect(0, 0, pitch_x-clearance, pitch_y-clearance, paste=False, unit=unit)
case 'circle': case 'circle':
stack = SMDStack.circle(min(pitch_x, pitch_y)-clearance, paste=False, unit=unit) pad = pb.SMDPad.circle(0, 0, min(pitch_x, pitch_y)-clearance, paste=False, unit=unit)
return pb.PatternProtoArea(pitch_x, pitch_y, obj=stack, unit=unit) return pb.PatternProtoArea(pitch_x, pitch_y, obj=pad, unit=unit)
case 'tht': case 'tht':
hole_dia = mil(float(obj['hole_dia'])) hole_dia = mil(float(obj['hole_dia']))
@ -80,11 +79,11 @@ def deserialize(obj, unit):
match obj['pad_shape']: match obj['pad_shape']:
case 'rect': case 'rect':
pad = pb.THTPad.rect(hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit) pad = pb.THTPad.rect(0, 0, hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
case 'circle': case 'circle':
pad = pb.THTPad.circle(hole_dia, min(pitch_x, pitch_y)-clearance, paste=False, plated=plated, unit=unit) pad = pb.THTPad.circle(0, 0, hole_dia, min(pitch_x, pitch_y)-clearance, paste=False, plated=plated, unit=unit)
case 'obround': case 'obround':
pad = pb.THTPad.obround(hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit) pad = pb.THTPad.obround(0, 0, hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
if oneside: if oneside:
pad.pad_bottom = None pad.pad_bottom = None
@ -107,8 +106,7 @@ def deserialize(obj, unit):
pitch = mil(float(obj.get('pitch', 2.54))) pitch = mil(float(obj.get('pitch', 2.54)))
hole_dia = mil(float(obj['hole_dia'])) hole_dia = mil(float(obj['hole_dia']))
pattern_dia = mil(float(obj['pattern_dia'])) pattern_dia = mil(float(obj['pattern_dia']))
clearance = mil(float(obj['clearance'])) return pb.PatternProtoArea(2*pitch, 2*pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, unit=unit), unit=unit)
return pb.PatternProtoArea(pitch, pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, clearance, unit=unit), unit=unit)
case 'spiky': case 'spiky':
return pb.PatternProtoArea(2.54, 2.54, pb.SpikyProto(), unit=unit) return pb.PatternProtoArea(2.54, 2.54, pb.SpikyProto(), unit=unit)
@ -129,20 +127,6 @@ def deserialize(obj, unit):
via_size=via_size via_size=via_size
), margin=unit(1.5, MM), unit=unit) ), margin=unit(1.5, MM), unit=unit)
case 'breadboard':
horizontal = obj.get('direction', 'v') == 'h'
drill = float(obj.get('hole_dia', 0.9))
return pb.BreadboardArea(clearance=clearance, drill=drill, horizontal=horizontal, unit=unit)
case 'starburst':
trace_width_x = float(obj.get('trace_width_x', 1.8))
trace_width_y = float(obj.get('trace_width_y', 1.8))
drill = float(obj.get('hole_dia', 0.9))
annular_ring = float(obj.get('annular', 1.2))
clearance = float(obj.get('clearance', 0.4))
mask_width = float(obj.get('mask_width', 0.5))
return pb.PatternProtoArea(pitch_x, pitch_y, pb.StarburstPad(pitch_x, pitch_y, trace_width_x, trace_width_y, clearance, mask_width, drill, annular_ring, unit=unit), unit=unit)
case 'rf': case 'rf':
pitch = float(obj.get('pitch', 2.54)) pitch = float(obj.get('pitch', 2.54))
hole_dia = float(obj['hole_dia']) hole_dia = float(obj['hole_dia'])
@ -155,7 +139,6 @@ def to_board(obj):
w = float(obj.get('width', unit(100, MM))) w = float(obj.get('width', unit(100, MM)))
h = float(obj.get('height', unit(80, MM))) h = float(obj.get('height', unit(80, MM)))
corner_radius = float(obj.get('round_corners', {}).get('radius', unit(1.5, MM))) corner_radius = float(obj.get('round_corners', {}).get('radius', unit(1.5, MM)))
margin = float(obj.get('margin', unit(2.0, MM)))
holes = obj.get('mounting_holes', {}) holes = obj.get('mounting_holes', {})
mounting_hole_dia = float(holes.get('diameter', unit(3.2, MM))) mounting_hole_dia = float(holes.get('diameter', unit(3.2, MM)))
mounting_hole_offset = float(holes.get('offset', unit(5, MM))) mounting_hole_offset = float(holes.get('offset', unit(5, MM)))
@ -172,14 +155,13 @@ def to_board(obj):
corner_radius=corner_radius, corner_radius=corner_radius,
mounting_hole_dia=mounting_hole_dia, mounting_hole_dia=mounting_hole_dia,
mounting_hole_offset=mounting_hole_offset, mounting_hole_offset=mounting_hole_offset,
margin=margin,
unit=unit) unit=unit)
@app.route('/preview_<side>.svg', methods=['POST']) @app.route('/preview.svg', methods=['POST'])
async def preview(side): async def preview():
obj = await request.get_json() obj = await request.get_json()
board = to_board(obj) board = to_board(obj)
return Response(str(board.pretty_svg(side=side)), mimetype='image/svg+xml') return Response(str(board.pretty_svg()), mimetype='image/svg+xml')
@app.route('/gerbers.zip', methods=['POST']) @app.route('/gerbers.zip', methods=['POST'])
async def gerbers(): async def gerbers():
@ -190,10 +172,7 @@ async def gerbers():
board.layer_stack().save_to_zipfile(f) board.layer_stack().save_to_zipfile(f)
return Response(f.read_bytes(), mimetype='image/svg+xml') return Response(f.read_bytes(), mimetype='image/svg+xml')
def main():
app.run()
if __name__ == '__main__': if __name__ == '__main__':
main() app.run()

View file

@ -177,14 +177,11 @@ input[type="text"]:focus:valid {
position: relative; position: relative;
grid-area: main; grid-area: main;
padding: 20px; padding: 20px;
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: stretch;
} }
#preview > img { #preview-image {
flex-grow: 1; width: 100%;
height: 100%;
object-fit: contain; object-fit: contain;
} }
@ -283,12 +280,6 @@ input[type="text"]:focus:valid {
<span class="unit us">inch</span> <span class="unit us">inch</span>
</label> </label>
<label>Margin
<input type="text" placeholder="margin" name="margin" value="2.0" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
<div class="group expand" data-group="round_corners"> <div class="group expand" data-group="round_corners">
<label>Round corners <label>Round corners
<input name="enabled" type="checkbox" checked/> <input name="enabled" type="checkbox" checked/>
@ -325,8 +316,7 @@ input[type="text"]:focus:valid {
</form> </form>
</div> </div>
<div id="preview"> <div id="preview">
<img id="preview-image-top" alt="Automatically generated top side preview image"/> <img id="preview-image" alt="Automatically generated preview image"/>
<img id="preview-image-bottom" alt="Automatically generated bottom side preview image"/>
<div id="preview-message"></div> <div id="preview-message"></div>
</div> </div>
<div id="links"> <div id="links">
@ -411,8 +401,6 @@ input[type="text"]:focus:valid {
<a href="#" data-placeholder="rf" class="double-sided-only">RF THT area</a> <a href="#" data-placeholder="rf" class="double-sided-only">RF THT area</a>
<a href="#" data-placeholder="spiky" class="double-sided-only">Spiky hybrid area</a> <a href="#" data-placeholder="spiky" class="double-sided-only">Spiky hybrid area</a>
<a href="#" data-placeholder="alio" class="double-sided-only">ALio hybrid area</a> <a href="#" data-placeholder="alio" class="double-sided-only">ALio hybrid area</a>
<a href="#" data-placeholder="starburst" class="double-sided-only">THT starburst area</a>
<a href="#" data-placeholder="breadboard" class="double-sided-only">Permanent breadboard area</a>
</div> </div>
</div> </div>
</template> </template>
@ -480,7 +468,7 @@ input[type="text"]:focus:valid {
<span class="unit us">mil</span> <span class="unit us">mil</span>
</label> </label>
<label>Plating <label>Plating
<select name="plating" value="plated"> <select name="plating" value="through">
<option value="plated">Double-sided, through-plated</option> <option value="plated">Double-sided, through-plated</option>
<option value="nonplated">Double-sided, non-plated</option> <option value="nonplated">Double-sided, non-plated</option>
<option value="singleside">Single-sided, non-plated</option> <option value="singleside">Single-sided, non-plated</option>
@ -506,34 +494,6 @@ input[type="text"]:focus:valid {
</div> </div>
</template> </template>
<template id="tpl-g-breadboard">
<div data-type="breadboard" class="group breadboard">
<h4>Permanent breadboard area</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
</label>
<h5>Area Settings</h5>
<label>Direction
<select name="direction" value="v">
<option value="v">Vertical</option>
<option value="h">Horizontal</option>
</select>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Hole diameter
<input type="text" name="hole_dia" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</div>
</template>
<template id="tpl-g-manhattan"> <template id="tpl-g-manhattan">
<div data-type="manhattan" class="group manhattan"> <div data-type="manhattan" class="group manhattan">
<h4>Manhattan area</h4> <h4>Manhattan area</h4>
@ -739,58 +699,6 @@ input[type="text"]:focus:valid {
</div> </div>
</template> </template>
<template id="tpl-g-starburst">
<div data-type="starburst" class="group starburst">
<h4>Starburst area</h4>
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
<label class="proportion">Proportion
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
</label>
<h5>Area Settings</h5>
<label>Pitch X
<input type="text" name="pitch_x" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Pitch Y
<input type="text" name="pitch_y" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Drill diameter
<input type="text" name="drill" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Annular ring
<input type="text" name="annular" placeholder="length" value="1.2" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Pad clearance
<input type="text" name="clearance" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Soldermask wall
<input type="text" name="mask_width" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Trace width X
<input type="text" name="trace_width_x" placeholder="length" value="1.40" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Trace width Y
<input type="text" name="trace_width_y" placeholder="length" value="1.40" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</div>
</template>
<script> <script>
document.querySelectorAll('.expand').forEach((elem) => { document.querySelectorAll('.expand').forEach((elem) => {
const checkbox = elem.querySelector(':first-child > input'); const checkbox = elem.querySelector(':first-child > input');
@ -1077,43 +985,26 @@ input[type="text"]:focus:valid {
} }
} }
let previewTopBlobURL = null; let previewBlobURL = null;
let previewBotBlobURL = null;
previewReloader = new RateLimiter(async () => { previewReloader = new RateLimiter(async () => {
if (document.querySelector('form').checkValidity()) { if (document.querySelector('form').checkValidity()) {
document.querySelector('#preview-message').textContent = 'Reloading...'; document.querySelector('#preview-message').textContent = 'Reloading...';
document.querySelector('#preview-message').classList.add('loading'); document.querySelector('#preview-message').classList.add('loading');
const response = await fetch('preview.svg', {
const response_top = await fetch('preview_top.svg', {
method: 'POST', method: 'POST',
mode: 'same-origin', mode: 'same-origin',
cache: 'no-cache', cache: 'no-cache',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: serialize(), body: serialize(),
}); });
const data_top = await response_top.blob(); const data = await response.blob();
if (previewTopBlobURL) { if (previewBlobURL) {
URL.revokeObjectURL(previewTopBlobURL); URL.revokeObjectURL(previewBlobURL);
} }
previewTopBlobURL = URL.createObjectURL(data_top); previewBlobURL = URL.createObjectURL(data);
document.querySelector('#preview-image-top').src = previewTopBlobURL; document.querySelector('#preview-image').src = previewBlobURL;
document.querySelector('#preview-message').textContent = ''; document.querySelector('#preview-message').textContent = '';
document.querySelector('#preview-message').classList.remove('loading'); document.querySelector('#preview-message').classList.remove('loading');
const response_bot = await fetch('preview_bottom.svg', {
method: 'POST',
mode: 'same-origin',
cache: 'no-cache',
headers: {'Content-Type': 'application/json'},
body: serialize(),
});
const data_bot = await response_bot.blob();
if (previewBotBlobURL) {
URL.revokeObjectURL(previewBotBlobURL);
}
previewBotBlobURL = URL.createObjectURL(data_bot);
document.querySelector('#preview-image-bottom').src = previewBotBlobURL;
} else { } else {
document.querySelector('#preview-message').classList.add('loading'); document.querySelector('#preview-message').classList.add('loading');
document.querySelector('#preview-message').textContent = 'Please correct any invalid fields.'; document.querySelector('#preview-message').textContent = 'Please correct any invalid fields.';

View file

@ -26,7 +26,7 @@ import shutil
from pathlib import Path from pathlib import Path
from functools import cached_property 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_primitives as gp
from . import graphic_objects as go from . import graphic_objects as go
@ -54,15 +54,6 @@ class FileSettings:
zeros : bool = None zeros : bool = None
#: Number format. ``(integer, decimal)`` tuple of number of integer and decimal digits. At most ``(6,7)`` by spec. #: Number format. ``(integer, decimal)`` tuple of number of integer and decimal digits. At most ``(6,7)`` by spec.
number_format : tuple = (None, None) number_format : tuple = (None, None)
#: At least the aperture macro implementations of gerbv and whatever JLCPCB uses are severely broken and simply
#: ignore parentheses in numeric expressions without throwing an error or a warning, leading to broken rendering.
#: To avoid trouble with severely broken software like this, we just calculate out all macros by default.
#: 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 # input validation
def __setattr__(self, name, value): def __setattr__(self, name, value):
@ -351,24 +342,6 @@ class CamFile:
return sum_bounds(( p.bounding_box(unit) for p in self.objects ), default=default) 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): def to_excellon(self):
""" Convert to a :py:class:`.ExcellonFile`. Returns ``self`` if it already is one. """ """ Convert to a :py:class:`.ExcellonFile`. Returns ``self`` if it already is one. """
raise NotImplementedError() raise NotImplementedError()

View file

@ -23,10 +23,7 @@ import dataclasses
import re import re
import warnings import warnings
import json import json
import sys
import itertools import itertools
import webbrowser
import warnings
from pathlib import Path from pathlib import Path
from .utils import MM, Inch from .utils import MM, Inch
@ -34,23 +31,8 @@ from .cam import FileSettings
from .rs274x import GerberFile from .rs274x import GerberFile
from . import layers as lyr from . import layers as lyr
from . import __version__ from . import __version__
from .cad.kicad import schematic as kc_schematic
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): def _print_version(ctx, param, value):
if value and not ctx.resilient_parsing: if value and not ctx.resilient_parsing:
click.echo(f'Version {__version__}') click.echo(f'Version {__version__}')
@ -146,44 +128,6 @@ def cli():
well as sets of those files """ well as sets of those files """
pass pass
@cli.group('protoboard')
def protoboard_group():
pass
@protoboard_group.command()
@click.option('-h', '--host', default=None, help='Hostname to listen on. Defaults to localhost.')
@click.option('-p', '--port', type=int, default=1337, help='Port to listen on. Defaults to 1337')
def interactive(host, port):
''' Launch gerbonar's interactive protoboard designer in your browser '''
if host is None:
@protoserve.app.before_serving
async def open_browser():
webbrowser.open_new(f'http://localhost:{port}/')
protoserve.app.run(host=host, port=port, use_reloader=False, debug=False)
@cli.group('kicad')
def kicad_group():
pass
@kicad_group.group('schematic')
def schematic_group():
pass
@schematic_group.command()
@click.argument('inpath', type=click.Path(exists=True))
@click.argument('theme', type=click.Path(exists=True))
@click.argument('outfile', type=click.File('w'), default='-')
def render(inpath, theme, outfile):
sch = kc_schematic.Schematic.open(inpath)
cs = tmtheme.TmThemeSchematic(Path(theme).read_text())
with outfile as f:
f.write(str(sch.to_svg(cs)))
@cli.command() @cli.command()
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default', @click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
@ -198,17 +142,12 @@ def render(inpath, theme, outfile):
rules and use only rules given by --input-map''') rules and use only rules given by --input-map''')
@click.option('--force-zip', is_flag=True, help='''Force treating input path as a zip file (default: guess file type @click.option('--force-zip', is_flag=True, help='''Force treating input path as a zip file (default: guess file type
from extension and contents)''') from extension and contents)''')
@click.option('--top', 'side', flag_value='top', help='Render top side') @click.option('--top/--bottom', default=True, help='Which side of the board to render')
@click.option('--bottom', 'side', flag_value='bottom', help='Render top side')
@click.option('--command-line-units', type=Unit(), help='''Units for values given in other options. Default: @click.option('--command-line-units', type=Unit(), help='''Units for values given in other options. Default:
millimeter''') millimeter''')
@click.option('--margin', type=float, default=0.0, help='Add space around the board inside the viewport') @click.option('--margin', type=float, default=0.0, help='Add space around the board inside the viewport')
@click.option('--force-bounds', help='Force SVG bounding box to value given as "min_x,min_y,max_x,max_y"') @click.option('--force-bounds', help='Force SVG bounding box to value given as "min_x,min_y,max_x,max_y"')
@click.option('--inkscape/--standard-svg', default=True, help='Export in Inkscape SVG format with layers and stuff.') @click.option('--inkscape/--standard-svg', default=True, help='Export in Inkscape SVG format with layers and stuff.')
@click.option('--pretty/--no-filters', default=True, help='''Export pseudo-realistic render using filters (default) or
just stack up layers using given colorscheme. In "--no-filters" mode, by default all layers are exported
unless either "--top" or "--bottom" is given.''')
@click.option('--drills/--no-drills', default=True, help='''Include (default) or exclude drills ("--no-filters" only!)''')
@click.option('--colorscheme', type=click.Path(exists=True, path_type=Path), help='''Load colorscheme from given JSON @click.option('--colorscheme', type=click.Path(exists=True, path_type=Path), help='''Load colorscheme from given JSON
file. The JSON file must contain a single dict with keys copper, silk, mask, paste, drill and outline. file. The JSON file must contain a single dict with keys copper, silk, mask, paste, drill and outline.
Each key must map to a string containing either a normal 6-digit hex color with leading hash sign, or an Each key must map to a string containing either a normal 6-digit hex color with leading hash sign, or an
@ -216,8 +155,8 @@ def render(inpath, theme, outfile):
with FF being completely opaque, and 00 being invisibly transparent.''') with FF being completely opaque, and 00 being invisibly transparent.''')
@click.argument('inpath', type=click.Path(exists=True)) @click.argument('inpath', type=click.Path(exists=True))
@click.argument('outfile', type=click.File('w'), default='-') @click.argument('outfile', type=click.File('w'), default='-')
def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, force_zip, side, drills, def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, force_zip, top, command_line_units,
command_line_units, margin, force_bounds, inkscape, pretty, colorscheme): margin, force_bounds, inkscape, colorscheme):
""" Render a gerber file, or a directory or zip of gerber files into an SVG file. """ """ Render a gerber file, or a directory or zip of gerber files into an SVG file. """
overrides = json.loads(input_map.read_bytes()) if input_map else None overrides = json.loads(input_map.read_bytes()) if input_map else None
@ -235,14 +174,9 @@ def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules,
if colorscheme: if colorscheme:
colorscheme = json.loads(colorscheme.read_text()) colorscheme = json.loads(colorscheme.read_text())
if pretty: outfile.write(str(stack.to_pretty_svg(side='top' if top else 'bottom', margin=margin,
svg = stack.to_pretty_svg(side='bottom' if side == 'bottom' else 'top', margin=margin, arg_unit=(command_line_units or MM),
arg_unit=(command_line_units or MM), svg_unit=MM, force_bounds=force_bounds, inkscape=inkscape, colors=colorscheme)))
svg_unit=MM, force_bounds=force_bounds, inkscape=inkscape, colors=colorscheme)
else:
svg = stack.to_svg(side_re=side or '.*', margin=margin, drills=drills, arg_unit=(command_line_units or MM),
svg_unit=MM, force_bounds=force_bounds, colors=colorscheme)
outfile.write(str(svg))
@cli.command() @cli.command()
@ -338,9 +272,9 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio
scheme instead of keeping the old file names.''') scheme instead of keeping the old file names.''')
@click.argument('transform') @click.argument('transform')
@click.argument('inpath') @click.argument('inpath')
@click.argument('outpath', type=click.Path(path_type=Path)) @click.argument('outpath')
def transform(transform, units, output_format, inpath, outpath, format_warnings, input_map, use_builtin_name_rules, def transform(transform, units, output_format, inpath, outpath,
output_naming_scheme, number_format, force_zip): 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. """ 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 In the python transformation script you have access to the functions translate(x, y), scale(factor) and
@ -355,26 +289,16 @@ def transform(transform, units, output_format, inpath, outpath, format_warnings,
with warnings.catch_warnings(): with warnings.catch_warnings():
warnings.simplefilter(format_warnings) warnings.simplefilter(format_warnings)
if force_zip: 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: 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) _apply_transform(transform, units, stack)
output_format = None if output_format == 'reuse' else FileSettings.defaults() output_format = None if output_format == 'reuse' else FileSettings.defaults()
if number_format: stack.save_to_directory(outpath, naming_scheme=output_naming_scheme or {},
if output_format is None: gerber_settings=output_format,
output_format = FileSettings.defaults() excellon_settings=dataclasses.replace(output_format, zeros=None))
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))
@cli.command() @cli.command()
@ -455,7 +379,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('--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', @click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
help='''Enable or disable file format warnings during parsing (default: on)''') 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-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-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') @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 .cam import CamFile, FileSettings
from .graphic_objects import Flash, Line, Arc 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 from .utils import Inch, MM, to_unit, InterpMode, RegexMatcher
class ExcellonContext: class ExcellonContext:
""" Internal helper class used for tracking graphics state when writing Excellon. """ """ 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`. """ """ Counterpart to :py:meth:`~.rs274x.GerberFile.to_excellon`. Does nothing and returns :py:obj:`self`. """
return 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`. """ """ Convert this excellon file into a :py:class:`~.rs274x.GerberFile`. """
from .rs274x import GerberFile
out = GerberFile() out = GerberFile()
out.comments = self.comments out.comments = self.comments
apertures = {} apertures = {}
for obj in self.objects: for obj in self.objects:
if not (ap := apertures.get(obj.tool)): if not (ap := apertures[obj.tool]):
ap = apertures[obj.tool] = CircleAperture(obj.tool.diameter, unit=obj.aperture.unit) ap = apertures[obj.tool] = CircleAperture(obj.tool.diameter)
out.objects.append(dataclasses.replace(obj, aperture=ap)) out.objects.append(dataclasses.replace(obj, aperture=ap))
return out
@property @property
def generator(self): def generator(self):
@ -328,7 +325,7 @@ class ExcellonFile(CamFile):
for fn in 'nc_param.txt', 'ncdrill.log': for fn in 'nc_param.txt', 'ncdrill.log':
if (param_file := filename.parent / fn).is_file(): if (param_file := filename.parent / fn).is_file():
settings = parse_allegro_ncparam(param_file.read_text()) 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 break
# Parse Zuken log file for settings # Parse Zuken log file for settings
@ -336,7 +333,7 @@ class ExcellonFile(CamFile):
logfile = filename.with_suffix('.fdl') logfile = filename.with_suffix('.fdl')
if logfile.is_file(): if logfile.is_file():
settings = parse_zuken_logfile(logfile.read_text()) 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: if external_tools is None:
# Parse allegro log files for tools. # 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) mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1)
if mixed_plating: if mixed_plating:
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.', SyntaxWarning) warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.')
defined_tools = {} defined_tools = {}
tool_indices = {} tool_indices = {}
@ -569,8 +566,6 @@ class ExcellonParser(object):
self.filename = None self.filename = None
self.external_tools = external_tools or {} self.external_tools = external_tools or {}
self.found_kicad_format_comment = False self.found_kicad_format_comment = False
self.allegro_eof_toolchange_hack = False
self.allegro_eof_toolchange_hack_index = 1
def warn(self, msg): def warn(self, msg):
warnings.warn(f'{self.filename}:{self.lineno} "{self.line}": {msg}', SyntaxWarning) warnings.warn(f'{self.filename}:{self.lineno} "{self.line}": {msg}', SyntaxWarning)
@ -611,25 +606,18 @@ class ExcellonParser(object):
exprs = RegexMatcher() exprs = RegexMatcher()
# NOTE: These must be kept before the generic comment handler at the end of this class so they match first. # 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): def parse_allegro_tooldef(self, match):
# NOTE: We ignore the given tolerances here since they are non-standard. # 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.program_state = ProgramState.HEADER # TODO is this needed? we need a test file.
self.generator_hints.append('allegro') self.generator_hints.append('allegro')
index = int(match['index2']) if (index := int(match['index1'])) != int(match['index2']): # index1 has leading zeros, index2 not.
if match['index1'] and index != int(match['index1']): # 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!') 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: if index in self.tools:
self.warn('Re-definition of tool index {index}, overwriting old definition.') 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 # 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. # 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')) is_plated = None if match['plated'] is None else (match['plated'] in ('PLATED', 'OPTIONAL'))
@ -642,19 +630,13 @@ class ExcellonParser(object):
else: else:
unit = MM unit = MM
if self.settings.unit is None: if unit != self.settings.unit:
self.settings.unit = unit
elif unit != self.settings.unit:
self.warn('Allegro Excellon drill file tool definitions in {unit.name}, but file parameters say the ' 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, ' '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.') 'please raise an issue on our issue tracker.')
self.tools[index] = ExcellonTool(diameter=diameter, plated=is_plated, unit=unit) 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. # 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)') @exprs.match(';Holesize (?P<index>[0-9]+) = (?P<diameter>[.0-9]+) (?P<unit>INCH|inch|METRIC|mm)')
def parse_easyeda_tooldef(self, match): def parse_easyeda_tooldef(self, match):
@ -771,12 +753,6 @@ class ExcellonParser(object):
def handle_end_of_program(self, match): def handle_end_of_program(self, match):
if self.program_state in (None, ProgramState.HEADER): if self.program_state in (None, ProgramState.HEADER):
self.warn('M30 statement found before end of 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 self.program_state = ProgramState.FINISHED
# TODO: maybe add warning if this is followed by other commands. # TODO: maybe add warning if this is followed by other commands.
@ -786,17 +762,14 @@ class ExcellonParser(object):
def do_move(self, coord_groups): def do_move(self, coord_groups):
x_s, x, y_s, y = 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): if self.settings.number_format == (None, None) and '.' not in x:
self.settings._file_has_fixed_width_coordinates = True # TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else.
if x != '00':
if self.settings.number_format == (None, None): raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro '
# TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else. 'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as '
if x != '00': 'it, because Allegro does not include this critical information in their Excellon output. If you '
raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro ' 'call this through ExcellonFile.from_string, you must manually supply from_string with a '
'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as ' 'FileSettings object from excellon.parse_allegro_ncparam.')
'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) x = self.settings.parse_gerber_value(x)
if x_s: if x_s:
@ -890,17 +863,12 @@ class ExcellonParser(object):
# from https://math.stackexchange.com/a/1781546 # from https://math.stackexchange.com/a/1781546
if a_s: if a_s:
raise ValueError('Negative arc radius given') raise ValueError('Negative arc radius given')
r = self.settings.parse_gerber_value(a) r = settings.parse_gerber_value(a)
x1, y1 = start x1, y1 = start
x2, y2 = end x2, y2 = end
dx, dy = (x2-x1)/2, (y2-y1)/2 dx, dy = (x2-x1)/2, (y2-y1)/2
x0, y0 = x1+dx, y1+dy x0, y0 = x1+dx, y1+dy
d = math.hypot(dx, dy) f = math.hypot(dx, dy) / math.sqrt(r**2 - a**2)
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
if clockwise: if clockwise:
cx = x0 + f*dy cx = x0 + f*dy
cy = y0 - f*dx cy = y0 - f*dx
@ -910,16 +878,16 @@ class ExcellonParser(object):
i, j = cx-start[0], cy-start[1] i, j = cx-start[0], cy-start[1]
else: # explicit center given else: # explicit center given
i = self.settings.parse_gerber_value(i) or 0 i = settings.parse_gerber_value(i)
if i_s: if i_s:
i = -i i = -i
j = self.settings.parse_gerber_value(j) or 0 j = settings.parse_gerber_value(j)
if j_s: 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): def parse_easyeda_format(self, match):
metric = match[1] in ('METRIC', 'M71') metric = match[1] in ('METRIC', 'M71')
@ -932,10 +900,7 @@ class ExcellonParser(object):
# This is used by newer autodesk eagles, fritzing and diptrace # This is used by newer autodesk eagles, fritzing and diptrace
if match[3]: if match[3]:
integer, _, fractional = match[3][1:].partition('.') integer, _, fractional = match[3][1:].partition('.')
if integer.strip('0') or fractional.strip('0'): self.settings.number_format = len(integer), len(fractional)
self.settings.number_format = int(integer), int(fractional)
else:
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: 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.') 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]*)') @exprs.match('(FMAT|VER),?([0-9]*)')
def handle_command_format(self, match): def handle_command_format(self, match):
if match[1] == 'FMAT': if match[1] == 'FMAT':
# We only use FMAT as a compatibility marker. Version 1 drill files encountered in the wild still use the # We do not support integer/fractional decimals specification via FMAT because that's stupid. If you need this,
# same coordinate and routing statements that we already support, so rejecting the header unconditionally # please raise an issue on our issue tracker, provide a sample file and tell us where on earth you found that
# needlessly breaks otherwise parseable files. # file.
if match[2] not in ('', '1', '2'): if match[2] not in ('', '2'):
raise SyntaxError(f'Unsupported FMAT format version {match[2]}') raise SyntaxError(f'Unsupported FMAT format version {match[2]}')
else: # VER else: # VER
@ -993,19 +958,6 @@ class ExcellonParser(object):
else: else:
self.warn('Bare coordinate after end of file') 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') @exprs.match(r'DETECT,ON|ATC,ON|M06')
def parse_zuken_legacy_statements(self, match): def parse_zuken_legacy_statements(self, match):
self.generator_hints.append('zuken') self.generator_hints.append('zuken')

View file

@ -19,9 +19,9 @@
import math import math
import copy import copy
from dataclasses import dataclass, astuple, field, fields from dataclasses import dataclass, astuple, field, fields
from itertools import zip_longest, pairwise, islice, cycle from itertools import zip_longest
from .utils import MM, InterpMode, to_unit, rotate_point, sum_bounds, approximate_arc, sweep_angle from .utils import MM, InterpMode, to_unit, rotate_point, sum_bounds
from . import graphic_primitives as gp from . import graphic_primitives as gp
from .aperture_macros import primitive as amp from .aperture_macros import primitive as amp
@ -278,15 +278,9 @@ class Region(GraphicObject):
* A region is always exactly one connected component. * A region is always exactly one connected component.
* A region must not overlap itself anywhere. * A region must not overlap itself anywhere.
* A region cannot have holes. * A region cannot have holes.
* The last outline point of the region must be equal to the first.
There is one exception from the last two rules: To emulate a region with a hole in it, *cut-ins* are allowed. At a There is one exception from the last two rules: To emulate a region with a hole in it, *cut-ins* are allowed. At a
cut-in, the region is allowed to touch (but never overlap!) itself. cut-in, the region is allowed to touch (but never overlap!) itself.
When ``arc_centers`` is empty, this region has only straight outline segments. When ``arc_centers`` is not empty,
the i-th entry defines the i-th outline segment, with a ``None`` entry designating a straight line segment.
An arc is defined by a ``(clockwise, (cx, cy))`` tuple, where ``clockwise`` can be ``True`` for a clockwise arc, or
``False`` for a counter-clockwise arc. ``cx`` and ``cy`` are the absolute coordinates of the arc's center.
""" """
def __init__(self, outline=None, arc_centers=None, *, unit=MM, polarity_dark=True): def __init__(self, outline=None, arc_centers=None, *, unit=MM, polarity_dark=True):
@ -294,7 +288,6 @@ class Region(GraphicObject):
self.polarity_dark = polarity_dark self.polarity_dark = polarity_dark
self.outline = [] if outline is None else outline self.outline = [] if outline is None else outline
self.arc_centers = [] if arc_centers is None else arc_centers self.arc_centers = [] if arc_centers is None else arc_centers
self.close()
def __len__(self): def __len__(self):
return len(self.outline) return len(self.outline)
@ -307,13 +300,12 @@ class Region(GraphicObject):
def _offset(self, dx, dy): def _offset(self, dx, dy):
self.outline = [ (x+dx, y+dy) for x, y in self.outline ] 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): def _rotate(self, angle, cx=0, cy=0):
self.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.outline ] self.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.outline ]
self.arc_centers = [ self.arc_centers = [
(arc[0], gp.rotate_point(*arc[1], angle, cx, cy)) if arc else None (arc[0], gp.rotate_point(*arc[1], angle, cx-p[0], cy-p[1])) if arc else None
for arc in self.arc_centers ] for p, arc in zip_longest(self.outline, self.arc_centers) ]
def _scale(self, factor): def _scale(self, factor):
self.outline = [ (x*factor, y*factor) for x, y in self.outline ] self.outline = [ (x*factor, y*factor) for x, y in self.outline ]
@ -321,12 +313,6 @@ class Region(GraphicObject):
(arc[0], (arc[1][0]*factor, arc[1][1]*factor)) if arc else None (arc[0], (arc[1][0]*factor, arc[1][1]*factor)) if arc else None
for p, arc in zip_longest(self.outline, self.arc_centers) ] for p, arc in zip_longest(self.outline, self.arc_centers) ]
def close(self):
if self.outline and self.outline[-1] != self.outline[0]:
self.outline.append(self.outline[0])
if self.arc_centers:
self.arc_centers.append((None, (None, None)))
@classmethod @classmethod
def from_rectangle(kls, x, y, w, h, unit=MM): def from_rectangle(kls, x, y, w, h, unit=MM):
return kls([ return kls([
@ -336,11 +322,6 @@ class Region(GraphicObject):
(x, y+h), (x, y+h),
], unit=unit) ], 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 append(self, obj): def append(self, obj):
if obj.unit != self.unit: if obj.unit != self.unit:
obj = obj.converted(self.unit) obj = obj.converted(self.unit)
@ -350,52 +331,47 @@ class Region(GraphicObject):
self.outline.append(obj.p2) self.outline.append(obj.p2)
if isinstance(obj, Arc): if isinstance(obj, Arc):
self.arc_centers.append((obj.clockwise, obj.center)) self.arc_centers.append((obj.clockwise, obj.center_relative))
else: else:
self.arc_centers.append(None) self.arc_centers.append(None)
def iter_segments(self, tolerance=1e-6): def close(self):
for points, arc in zip_longest(pairwise(self.outline), self.arc_centers): if not self.outline:
if arc: return
if points:
yield *points, arc
else:
yield self.outline[-1], self.outline[0], arc
return
else:
if not points:
break
yield *points, (None, (None, None))
# Close outline if necessary. if self.outline[-1] != self.outline[0]:
if math.dist(self.outline[0], self.outline[-1]) > tolerance: self.outline.append(self.outline[0])
yield self.outline[-1], self.outline[0], (None, (None, None))
def outline_objects(self, aperture=None): def outline_objects(self, aperture=None):
for p1, p2, (clockwise, center) in self.iter_segments(): for p1, p2, arc in zip_longest(self.outline, self.outline[1:] + self.outline[:1], self.arc_centers):
if clockwise is not None: if arc:
yield Arc(*p1, *p2, *center, clockwise, aperture=aperture, unit=self.unit, polarity_dark=self.polarity_dark) clockwise, pc = arc
yield Arc(*p1, *p2, *pc, clockwise, aperture=aperture, unit=self.unit, polarity_dark=self.polarity_dark)
else: else:
yield Line(*p1, *p2, aperture=aperture, unit=self.unit, polarity_dark=self.polarity_dark) yield Line(*p1, *p2, aperture=aperture, unit=self.unit, polarity_dark=self.polarity_dark)
def _aperture_macro_primitives(self, max_error=1e-2, clip_max_error=True, unit=MM): def _aperture_macro_primitives(self, max_error=1e-2, unit=MM):
# unit is only for max_error, the resulting primitives will always be in MM # unit is only for max_error, the resulting primitives will always be in MM
if len(self.outline) < 2: if len(self.outline) < 2:
return return
points = [] points = [self.outline[0]]
for p1, p2, (clockwise, center) in self.iter_segments(): for p1, p2, arc in zip_longest(self.outline[:-1], self.outline[1:], self.arc_centers):
if clockwise is not None: if arc:
for p in approximate_arc(*center, *p1, *p2, clockwise, clockwise, pc = arc
max_error=max_error, clip_max_error=clip_max_error): #r = math.hypot(*pc) # arc center is relative to p1.
points.append(p) #d = math.dist(p1, p2)
points.pop() #err = r - math.sqrt(r**2 - (d/(2*n))**2)
else: #n = math.ceil(1/(2*math.sqrt(r**2 - (r - max_err)**2)/d))
points.append(p1) arc = Arc(*p1, *p2, *pc, clockwise, unit=self.unit, polarity_dark=self.polarity_dark, aperture=None)
points.append(p2) for line in arc.approximate(max_error=max_error, unit=unit):
points.append(line.p2)
if points[0] != points[-1]: else:
points.append(p2)
if points[-1] != points[0]:
points.append(points[0]) points.append(points[0])
yield amp.Outline(self.unit, int(self.polarity_dark), len(points)-1, tuple(coord for p in points for coord in p)) yield amp.Outline(self.unit, int(self.polarity_dark), len(points)-1, tuple(coord for p in points for coord in p))
@ -413,9 +389,6 @@ class Region(GraphicObject):
yield gp.ArcPoly(conv_outline, conv_arc, polarity_dark=self.polarity_dark) yield gp.ArcPoly(conv_outline, conv_arc, polarity_dark=self.polarity_dark)
def to_statements(self, gs): def to_statements(self, gs):
if len(self.outline) < 3:
return
yield from gs.set_polarity(self.polarity_dark) yield from gs.set_polarity(self.polarity_dark)
yield 'G36*' yield 'G36*'
# Repeat interpolation mode at start of region statement to work around gerbv bug. Without this, gerbv will # Repeat interpolation mode at start of region statement to work around gerbv bug. Without this, gerbv will
@ -425,24 +398,32 @@ class Region(GraphicObject):
yield from gs.set_current_point(self.outline[0], unit=self.unit) yield from gs.set_current_point(self.outline[0], unit=self.unit)
for previous_point, point, (clockwise, center) in self.iter_segments(): for point, arc_center in zip_longest(self.outline[1:], self.arc_centers):
if point is None and center is None: if point is None and arc_center is None:
break break
x = gs.file_settings.write_gerber_value(point[0], self.unit) if arc_center is None:
y = gs.file_settings.write_gerber_value(point[1], self.unit)
if clockwise is None:
yield from gs.set_interpolation_mode(InterpMode.LINEAR) yield from gs.set_interpolation_mode(InterpMode.LINEAR)
x = gs.file_settings.write_gerber_value(point[0], self.unit)
y = gs.file_settings.write_gerber_value(point[1], self.unit)
yield f'X{x}Y{y}D01*' yield f'X{x}Y{y}D01*'
gs.update_point(*point, unit=self.unit)
else: else:
clockwise, (cx, cy) = arc_center
x2, y2 = point
yield from gs.set_interpolation_mode(InterpMode.CIRCULAR_CW if clockwise else InterpMode.CIRCULAR_CCW) yield from gs.set_interpolation_mode(InterpMode.CIRCULAR_CW if clockwise else InterpMode.CIRCULAR_CCW)
i = gs.file_settings.write_gerber_value(center[0]-previous_point[0], self.unit)
j = gs.file_settings.write_gerber_value(center[1]-previous_point[1], self.unit) x = gs.file_settings.write_gerber_value(x2, self.unit)
y = gs.file_settings.write_gerber_value(y2, self.unit)
# TODO are these coordinates absolute or relative now?!
i = gs.file_settings.write_gerber_value(cx, self.unit)
j = gs.file_settings.write_gerber_value(cy, self.unit)
yield f'X{x}Y{y}I{i}J{j}D01*' yield f'X{x}Y{y}I{i}J{j}D01*'
gs.update_point(*point, unit=self.unit) gs.update_point(x2, y2, unit=self.unit)
yield 'G37*' yield 'G37*'
@ -624,8 +605,22 @@ class Arc(GraphicObject):
:returns: Angle in clockwise radian between ``0`` and ``2*math.pi`` :returns: Angle in clockwise radian between ``0`` and ``2*math.pi``
:rtype: float :rtype: float
""" """
cx, cy = self.cx + self.x1, self.cy + self.y1
x1, y1 = self.x1 - cx, self.y1 - cy
x2, y2 = self.x2 - cx, self.y2 - cy
return sweep_angle(self.cx+self.x1, self.cy+self.y1, self.x1, self.y1, self.x2, self.y2, self.clockwise) a1, a2 = math.atan2(y1, x1), math.atan2(y2, x2)
f = abs(a2 - a1)
if not self.clockwise:
if a2 > a1:
return a2 - a1
else:
return 2*math.pi - abs(a2 - a1)
else:
if a1 > a2:
return a1 - a2
else:
return 2*math.pi - abs(a1 - a2)
@property @property
def p1(self): def p1(self):
@ -682,16 +677,34 @@ class Arc(GraphicObject):
:returns: list of :py:class:`~.graphic_objects.Line` instances. :returns: list of :py:class:`~.graphic_objects.Line` instances.
:rtype: list :rtype: list
""" """
# TODO the max_angle calculation below is a bit off -- we over-estimate the error, and thus produce finer
# results than necessary. Fix this.
r = math.hypot(self.cx, self.cy)
max_error = self.unit(max_error, unit) max_error = self.unit(max_error, unit)
return [Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit) if clip_max_error:
for p1, p2 in pairwise(approximate_arc( # 1 - math.sqrt(1 - 0.5*math.sqrt(2))
self.cx+self.x1, self.cy+self.y1, max_error = min(max_error, r*0.4588038998538031)
self.x1, self.y1,
self.x2, self.y2, elif max_error >= r:
self.clockwise, return [Line(*self.p1, *self.p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit)]
max_error=max_error,
clip_max_error=clip_max_error))] # see https://www.mathopenref.com/sagitta.html
l = math.sqrt(r**2 - (r - max_error)**2)
angle_max = math.asin(l/r)
sweep_angle = self.sweep_angle()
num_segments = math.ceil(sweep_angle / angle_max)
angle = sweep_angle / num_segments
if not self.clockwise:
angle = -angle
cx, cy = self.center
points = [ rotate_point(self.x1, self.y1, i*angle, cx, cy) for i in range(num_segments + 1) ]
return [ Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit)
for p1, p2 in zip(points[0::], points[1::]) ]
def _rotate(self, rotation, cx=0, cy=0): def _rotate(self, rotation, cx=0, cy=0):
# rotate center first since we need old x1, y1 here # rotate center first since we need old x1, y1 here
@ -713,7 +726,7 @@ class Arc(GraphicObject):
w = self.aperture.equivalent_width(unit) if self.aperture else 0 w = self.aperture.equivalent_width(unit) if self.aperture else 0
return gp.Arc(x1=conv.x1, y1=conv.y1, return gp.Arc(x1=conv.x1, y1=conv.y1,
x2=conv.x2, y2=conv.y2, x2=conv.x2, y2=conv.y2,
cx=conv.cx+conv.x1, cy=conv.cy+conv.y1, cx=conv.cx, cy=conv.cy,
clockwise=self.clockwise, clockwise=self.clockwise,
width=w, width=w,
polarity_dark=self.polarity_dark) polarity_dark=self.polarity_dark)

View file

@ -19,7 +19,7 @@
import math import math
import itertools import itertools
from dataclasses import dataclass, replace, field from dataclasses import dataclass, replace
from .utils import * from .utils import *
@ -62,12 +62,6 @@ class GraphicPrimitive:
raise NotImplementedError() raise NotImplementedError()
def is_zero_size(self):
""" Return whether this primitive is zero size
:rtype: bool
"""
@dataclass(frozen=True) @dataclass(frozen=True)
class Circle(GraphicPrimitive): class Circle(GraphicPrimitive):
@ -85,14 +79,6 @@ class Circle(GraphicPrimitive):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), fill=color) return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), fill=color)
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)
@dataclass(frozen=True) @dataclass(frozen=True)
class ArcPoly(GraphicPrimitive): class ArcPoly(GraphicPrimitive):
@ -102,51 +88,28 @@ class ArcPoly(GraphicPrimitive):
#: connected. #: connected.
outline : list outline : list
#: Must be either None (all segments are straight lines) or same length as outline. #: Must be either None (all segments are straight lines) or same length as outline.
#: Straight line segments have None entry. Arc segments have (clockwise, (cx, cy)) tuple with cx, cy being absolute #: Straight line segments have None entry.
#: coords. arc_centers : list = None
arc_centers : list = field(default_factory=list)
@property @property
def segments(self): def segments(self):
""" Return an iterator through all *segments* of this polygon. For each outline segment (line or arc), this """ Return an iterator through all *segments* of this polygon. For each outline segment (line or arc), this
iterator will yield a ``(p1, p2, (clockwise, center))`` tuple. If the segment is a straight line, ``clockwise`` iterator will yield a ``(p1, p2, center)`` tuple. If the segment is a straight line, ``center`` will be
will be ``None``. ``None``.
""" """
for points, arc in itertools.zip_longest(itertools.pairwise(self.outline), self.arc_centers): ol = self.outline
if arc: return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers or [])
if points:
yield *points, arc
else:
yield self.outline[-1], self.outline[0], arc
return
else:
if not points:
break
yield *points, (None, (None, None))
# Close outline if necessary.
if math.dist(self.outline[0], self.outline[-1]) > 1e-6:
yield self.outline[-1], self.outline[0], (None, (None, None))
def approximate_arcs(self, max_error=1e-2, clip_max_error=True):
outline = []
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
if clockwise is None:
outline.append((x1, y1))
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)
def bounding_box(self): def bounding_box(self):
bbox = (None, None), (None, None) bbox = (None, None), (None, None)
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments: for (x1, y1), (x2, y2), arc in self.segments:
if clockwise is None: if arc:
clockwise, (cx, cy) = arc
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
else:
line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2)) line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2))
bbox = add_bounds(bbox, line_bounds) bbox = add_bounds(bbox, line_bounds)
else:
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
return bbox return bbox
@classmethod @classmethod
@ -175,33 +138,17 @@ class ArcPoly(GraphicPrimitive):
yield f'M {float(self.outline[0][0]):.6} {float(self.outline[0][1]):.6}' yield f'M {float(self.outline[0][0]):.6} {float(self.outline[0][1]):.6}'
for old, new, (clockwise, center) in self.segments: for old, new, arc in self.segments:
if clockwise is None: if not arc:
yield f'L {float(new[0]):.6} {float(new[1]):.6}' yield f'L {float(new[0]):.6} {float(new[1]):.6}'
else: else:
clockwise, center = arc
yield svg_arc(old, new, center, clockwise) yield svg_arc(old, new, center, clockwise)
def to_svg(self, fg='black', bg='white', tag=Tag): def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
return tag('path', d=' '.join(self.path_d()), fill=color) return tag('path', d=' '.join(self.path_d()), fill=color)
def to_arc_poly(self):
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) @dataclass(frozen=True)
class Line(GraphicPrimitive): class Line(GraphicPrimitive):
@ -242,34 +189,7 @@ class Line(GraphicPrimitive):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm' width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} L {float(self.x2):.6} {float(self.y2):.6}', return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} L {float(self.x2):.6} {float(self.y2):.6}',
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),
], [
(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)
@dataclass(frozen=True) @dataclass(frozen=True)
class Arc(GraphicPrimitive): class Arc(GraphicPrimitive):
@ -282,9 +202,9 @@ class Arc(GraphicPrimitive):
x2 : float x2 : float
#: End Y coodinate #: End Y coodinate
y2 : float y2 : float
#: Center X coordinate (absolute) #: Center X coordinate relative to ``x1``
cx : float cx : float
#: Center Y coordinate (absolute) #: Center Y coordinate relative to ``y1``
cy : float cy : float
#: ``True`` if this arc is clockwise from start to end. Selects between the large arc and the small arc given this #: ``True`` if this arc is clockwise from start to end. Selects between the large arc and the small arc given this
#: start, end and center #: start, end and center
@ -292,12 +212,9 @@ class Arc(GraphicPrimitive):
#: Line width of this arc. #: Line width of this arc.
width : float width : float
@property
def is_circle(self):
return math.isclose(self.x1, self.x2, abs_tol=1e-6) and math.isclose(self.y1, self.y2, abs_tol=1e-6)
def flip(self): def flip(self):
return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1, clockwise=not self.clockwise) return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1,
cx=(self.x1 + self.cx) - self.x2, cy=(self.y1 + self.cy) - self.y2, clockwise=not self.clockwise)
def bounding_box(self): def bounding_box(self):
r = self.width/2 r = self.width/2
@ -309,36 +226,7 @@ class Arc(GraphicPrimitive):
arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise) arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise)
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm' width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} {arc}', 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)),
(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
@dataclass(frozen=True) @dataclass(frozen=True)
class Rectangle(GraphicPrimitive): class Rectangle(GraphicPrimitive):
@ -366,7 +254,7 @@ class Rectangle(GraphicPrimitive):
(x - (cw+sh), y + (ch+sw)), (x - (cw+sh), y + (ch+sw)),
(x + (cw+sh), y + (ch+sw)), (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): def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
@ -374,6 +262,3 @@ class Rectangle(GraphicPrimitive):
return tag('rect', x=prec(x), y=prec(y), width=prec(self.w), height=prec(self.h), return tag('rect', x=prec(x), y=prec(y), width=prec(self.w), height=prec(self.h),
**svg_rotation(self.rotation, self.x, self.y), fill=color) **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 'bottom paste': r'.*_boardoutline\.\w+', # FIXME verify this
'drill plated': r'.*\.(drl)', # diptrace has unplated drills on the outline layer '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 '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': { 'target': {
@ -152,25 +151,22 @@ MATCH_RULES = {
'allegro': { 'allegro': {
# Allegro doesn't have any widespread convention, so we rely heavily on the layer name auto-guesser here. # Allegro doesn't have any widespread convention, so we rely heavily on the layer name auto-guesser here.
'drill plated': r'.*\.(drl)', 'drill mech': r'.*\.(drl|rou)',
'drill nonplated': r'.*\.(rou)', 'generic gerber': r'.*\.art',
'other unknown': r'.*(place|assembly|keep.?in|keep.?out).*\.art',
'autoguess': r'.*\.art',
'excellon params': r'nc_param\.txt|ncdrill\.log|ncroute\.log', 'excellon params': r'nc_param\.txt|ncdrill\.log|ncroute\.log',
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples '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': {
# Pads also does not seem to have a factory-default naming schema. Or it has one but everyone ignores it. # Pads also does not seem to have a factory-default naming schema. Or it has one but everyone ignores it.
'autoguess': r'.*\.pho', 'generic gerber': r'.*\.pho',
'drill plated': r'.*\.drl', 'drill mech': r'.*\.drl',
}, },
'zuken': { 'zuken': {
'autoguess': r'.*\.fph', 'generic gerber': r'.*\.fph',
'gerber params': r'.*\.fpl', 'gerber params': r'.*\.fpl',
'drill unknown': r'.*\.fdr', 'drill mech': r'.*\.fdr',
'excellon params': r'.*\.fdl', 'excellon params': r'.*\.fdl',
'other netlist': r'.*\.ipc', 'other netlist': r'.*\.ipc',
'ipc-2581': r'.*\.xml', 'ipc-2581': r'.*\.xml',

View file

@ -39,7 +39,6 @@ from .cam import FileSettings, LazyCamFile
from .layer_rules import MATCH_RULES from .layer_rules import MATCH_RULES
from .utils import sum_bounds, setup_svg, MM, Tag, convex_hull from .utils import sum_bounds, setup_svg, MM, Tag, convex_hull
from . import graphic_objects as go from . import graphic_objects as go
from . import apertures as ap
from . import graphic_primitives as gp from . import graphic_primitives as gp
@ -66,27 +65,27 @@ DEFAULT_COLORS = {
class NamingScheme: class NamingScheme:
kicad = { kicad = {
'top copper': '{board_name}-F_Cu.gbr', 'top copper': '{board_name}-F.Cu.gbr',
'top mask': '{board_name}-F_Mask.gbr', 'top mask': '{board_name}-F.Mask.gbr',
'top silk': '{board_name}-F_SilkS.gbr', 'top silk': '{board_name}-F.SilkS.gbr',
'top paste': '{board_name}-F_Paste.gbr', 'top paste': '{board_name}-F.Paste.gbr',
'bottom copper': '{board_name}-B_Cu.gbr', 'bottom copper': '{board_name}-B.Cu.gbr',
'bottom mask': '{board_name}-B_Mask.gbr', 'bottom mask': '{board_name}-B.Mask.gbr',
'bottom silk': '{board_name}-B_SilkS.gbr', 'bottom silk': '{board_name}-B.SilkS.gbr',
'bottom paste': '{board_name}-B_Paste.gbr', 'bottom paste': '{board_name}-B.Paste.gbr',
'inner copper': '{board_name}-In{layer_number}_Cu.gbr', 'inner copper': '{board_name}-In{layer_number}.Cu.gbr',
'mechanical outline': '{board_name}-Edge_Cuts.gbr', 'mechanical outline': '{board_name}-Edge.Cuts.gbr',
'drill unknown': '{board_name}.drl', 'drill unknown': '{board_name}.drl',
'drill plated': '{board_name}-PTH.drl', 'drill plated': '{board_name}-PTH.drl',
'drill nonplated': '{board_name}-NPTH.drl', 'drill nonplated': '{board_name}-NPTH.drl',
'other comments': '{board_name}-Cmts_User.gbr', 'other comments': '{board_name}-Cmts.User.gbr',
'other drawings': '{board_name}-Dwgs_User.gbr', 'other drawings': '{board_name}-Dwgs.User.gbr',
'top fabrication': '{board_name}-F_Fab.gbr', 'top fabrication': '{board_name}-F.Fab.gbr',
'bottom fabrication': '{board_name}-B_Fab.gbr', 'bottom fabrication': '{board_name}-B.Fab.gbr',
'top adhesive': '{board_name}-F_Adhes.gbr', 'top adhesive': '{board_name}-F.Adhes.gbr',
'bottom adhesive': '{board_name}-B_Adhes.gbr', 'bottom adhesive': '{board_name}-B.Adhes.gbr',
'top courtyard': '{board_name}-F_CrtYd.gbr', 'top courtyard': '{board_name}-F.CrtYd.gbr',
'bottom courtyard': '{board_name}-B_CrtYd.gbr', 'bottom courtyard': '{board_name}-B.CrtYd.gbr',
'other netlist': '{board_name}.d356', '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 _match_files(filenames):
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):
matches = {} matches = {}
for generator, rules in MATCH_RULES.items(): 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: if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)):
return generator, candidate 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])) matches = sorted(matches.items(), key=lambda pair: len(pair[1]))
generator, files = matches[-1] generator, files = matches[-1]
return generator, files return generator, files
@ -274,7 +243,7 @@ def _layername_autoguesser(fn):
elif re.search('film', fn): elif re.search('film', fn):
use = 'copper' use = 'copper'
elif re.search('out(line)?|board.?geom(etry)?', fn): elif re.search('out(line)?', fn):
use = 'outline' use = 'outline'
side = 'mechanical' side = 'mechanical'
@ -304,9 +273,6 @@ def _sort_layername(val):
assert side.startswith('inner_') assert side.startswith('inner_')
return int(side[len('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: class LayerStack:
""" :py:class:`LayerStack` represents a set of Gerber files that describe different layers of the same board. """ :py:class:`LayerStack` represents a set of Gerber files that describe different layers of the same board.
@ -419,7 +385,7 @@ class LayerStack:
with ZipFile(file) as f: with ZipFile(file) as f:
f.extractall(path=tmp_indir) 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.tmpdir = tmpdir
inst.original_path = Path(original_path or file) inst.original_path = Path(original_path or file)
inst.was_zipped = True inst.was_zipped = True
@ -455,7 +421,6 @@ class LayerStack:
given value. given value.
:rtype: :py:class:`LayerStack` :rtype: :py:class:`LayerStack`
""" """
print_layermap = False
if autoguess: if autoguess:
generator, filemap = _best_match(files) generator, filemap = _best_match(files)
@ -480,51 +445,14 @@ class LayerStack:
filemap[layer].remove(fn) filemap[layer].remove(fn)
filemap[layer] = filemap.get(layer, []) + [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: if sum(len(files) for files in filemap.values()) < 6 and autoguess:
warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.') warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
generator = None generator = None
print_layermap = True
filemap = _do_autoguess(files) filemap = _do_autoguess(files)
if len(filemap) < 6: if len(filemap) < 6:
raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap) raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap)
excellon_settings, external_tools = None, None excellon_settings, external_tools = None, None
automatch_drill_scale = False
if generator == 'geda': if generator == 'geda':
# geda is written by geniuses who waste no bytes of unnecessary output so it doesn't actually include the # 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 # 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())): if (external_tools := parse_allegro_logfile(file.read_text())):
break break
del filemap['excellon params'] del filemap['excellon params']
else: # Ignore if we can't find the param file -- maybe the user has convinced Allegro to actually put this
# 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.
# 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)
filemap = _do_autoguess([ f for files in filemap.values() for f in files ])
if len(filemap) < 6: if len(filemap) < 6:
raise SystemError('Cannot figure out gerber file mapping') raise SystemError('Cannot figure out gerber file mapping')
# FIXME use layer metadata from comments and ipc file if available # FIXME use layer metadata from comments and ipc file if available
elif generator == 'zuken': elif generator == 'zuken':
filemap = _do_autoguess([ f for files in filemap.values() for f in files ])
if len(filemap) < 6: if len(filemap) < 6:
raise SystemError('Cannot figure out gerber file mapping') raise SystemError('Cannot figure out gerber file mapping')
# FIXME use layer metadata from comments and ipc file if available # FIXME use layer metadata from comments and ipc file if available
@ -581,12 +503,7 @@ class LayerStack:
else: else:
excellon_settings = None excellon_settings = None
ambiguous = [ f'{key} ({", ".join(x.name for x in value)})' 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 ]
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']
if ambiguous: if ambiguous:
raise SystemError(f'Ambiguous layer names: {", ".join(ambiguous)}') raise SystemError(f'Ambiguous layer names: {", ".join(ambiguous)}')
@ -595,11 +512,8 @@ class LayerStack:
netlist = None netlist = None
layers = {} # { tuple(key.split()): None for key in STANDARD_LAYERS } layers = {} # { tuple(key.split()): None for key in STANDARD_LAYERS }
for key, paths in filemap.items(): for key, paths in filemap.items():
if len(paths) > 1 and\ if len(paths) > 1 and not 'drill' in key:
not 'drill' in key and\ raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(value)}')
not 'excellon' in key and\
not key == 'other unknown':
raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(map(str, value))}')
for path in paths: for path in paths:
id_result = identify_file(path.read_text()) 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)
board_name = re.sub(r'\W+$', '', board_name) board_name = re.sub(r'\W+$', '', board_name)
if automatch_drill_scale: return kls(layers, drill_pth, drill_npth, drill_layers, board_name=board_name,
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,
original_path=original_path, was_zipped=was_zipped, generator=[*all_generator_hints, None][0]) original_path=original_path, was_zipped=was_zipped, generator=[*all_generator_hints, None][0])
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={}, def save_to_zipfile(self, path, prefix='', overwrite_existing=True, board_name=None, naming_scheme={},
gerber_settings=None, excellon_settings=None): gerber_settings=None, excellon_settings=None):
""" Save this board into a zip file at the given path. For other options, see """ Save this board into a zip file at the given path. For other options, see
@ -738,7 +589,10 @@ class LayerStack:
:param prefix: Store output files under the given prefix inside the zip file :param prefix: Store output files under the given prefix inside the zip file
""" """
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') raise ValueError('output zip file already exists and overwrite_existing is False')
if gerber_settings and not excellon_settings: if gerber_settings and not excellon_settings:
@ -845,7 +699,7 @@ class LayerStack:
def __repr__(self): def __repr__(self):
return str(self) return str(self)
def to_svg(self, margin=0, side_re='.*', drills=True, arg_unit=MM, svg_unit=MM, force_bounds=None, colors=None, tag=Tag): def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, color_map=None, tag=Tag):
""" Convert this layer stack to a plain SVG string. This is intended for use cases where the resulting SVG will """ Convert this layer stack to a plain SVG string. This is intended for use cases where the resulting SVG will
be processed by other tools, and thus styling with colors or extra markup like Inkscape layer information are be processed by other tools, and thus styling with colors or extra markup like Inkscape layer information are
unwanted. If you want to instead generate a nice-looking preview image for display or graphical editing in tools unwanted. If you want to instead generate a nice-looking preview image for display or graphical editing in tools
@ -855,9 +709,6 @@ class LayerStack:
mirrored vertically. mirrored vertically.
:param margin: Export SVG file with given margin around the board's bounding box. :param margin: Export SVG file with given margin around the board's bounding box.
:param side_re: A regex, such as ``'top'``, ``'bottom'``, or ``'.*'`` (default). Selects which layers to export.
The default includes inner layers.
:param drills: :py:obj:`bool` setting if drills are included (default) or not.
:param arg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit ``margin`` and :param arg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit ``margin`` and
``force_bounds`` are specified in. Default: mm ``force_bounds`` are specified in. Default: mm
:param svg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit to use inside the SVG file. :param svg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit to use inside the SVG file.
@ -865,7 +716,6 @@ class LayerStack:
:param force_bounds: Use bounds given as :py:obj:`((min_x, min_y), (max_x, max_y))` tuple for the output SVG :param force_bounds: Use bounds given as :py:obj:`((min_x, min_y), (max_x, max_y))` tuple for the output SVG
file instead of deriving them from this board's bounding box and ``margin``. Note that this file instead of deriving them from this board's bounding box and ``margin``. Note that this
will not scale or move the board, but instead will only crop the viewport. will not scale or move the board, but instead will only crop the viewport.
:param colors: Dict mapping ``f'{side} {use}'`` strings to SVG colors.
:param tag: Extension point to support alternative XML serializers in addition to the built-in one. :param tag: Extension point to support alternative XML serializers in addition to the built-in one.
:rtype: :py:obj:`str` :rtype: :py:obj:`str`
""" """
@ -876,29 +726,29 @@ class LayerStack:
stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'} stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'}
if colors is None: if color_map is None:
colors = defaultdict(lambda: 'black') color_map = default_dict(lambda: 'black')
tags = [] tags = []
layer_transform = f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)'
for (side, use), layer in reversed(self.graphic_layers.items()): for (side, use), layer in reversed(self.graphic_layers.items()):
if re.fullmatch(side_re, side) and (fg := colors.get(f'{side} {use}')): fg = color_map[(side, use)]
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)), tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
**stroke_attrs, id=f'l-{side}-{use}', transform=layer_transform)) **stroke_attrs, id=f'l-{side}-{use}'))
if drills: if self.drill_pth:
if self.drill_pth and (fg := colors.get('drill pth')): fg = color_map[('drill', 'pth')]
tags.append(tag('g', list(self.drill_pth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)), tags.append(tag('g', list(self.drill_pth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
**stroke_attrs, id=f'l-drill-pth', transform=layer_transform)) **stroke_attrs, id=f'l-drill-pth'))
if self.drill_npth and (fg := colors.get('drill npth')): if self.drill_npth:
tags.append(tag('g', list(self.drill_npth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)), fg = color_map[('drill', 'npth')]
**stroke_attrs, id=f'l-drill-npth', transform=layer_transform)) tags.append(tag('g', list(self.drill_npth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
**stroke_attrs, id=f'l-drill-npth'))
if (fg := colors.get('drill unknown')): for i, layer in enumerate(self._drill_layers):
for i, layer in enumerate(self._drill_layers): fg = color_map[('drill', 'unknown')]
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)), tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
**stroke_attrs, id=f'l-drill-{i}', transform=layer_transform)) **stroke_attrs, id=f'l-drill-{i}'))
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, tag=tag) return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, tag=tag)
@ -969,7 +819,6 @@ class LayerStack:
inkscape_attrs = lambda label: dict(inkscape__groupmode='layer', inkscape__label=label) if inkscape else {} inkscape_attrs = lambda label: dict(inkscape__groupmode='layer', inkscape__label=label) if inkscape else {}
stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'} stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'}
layer_transform=f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)'
use_defs = [] use_defs = []
@ -979,7 +828,7 @@ class LayerStack:
warnings.warn(f'Layer "{side} {use}" not found. Found layers: {", ".join(side + " " + use for side, use in self.graphic_layers)}') warnings.warn(f'Layer "{side} {use}" not found. Found layers: {", ".join(side + " " + use for side, use in self.graphic_layers)}')
continue continue
layer = self[(side, use)].instance layer = self[(side, use)]
fg, bg = ('white', 'black') if use != 'mask' else ('black', 'white') fg, bg = ('white', 'black') if use != 'mask' else ('black', 'white')
default_fill = {'copper': fg, 'mask': fg, 'silk': 'none', 'paste': fg}[use] default_fill = {'copper': fg, 'mask': fg, 'silk': 'none', 'paste': fg}[use]
@ -1012,25 +861,18 @@ class LayerStack:
if use == 'mask': if use == 'mask':
objects.insert(0, tag('path', id='outline-path', d=self.outline_svg_d(unit=svg_unit), fill='white')) objects.insert(0, tag('path', id='outline-path', d=self.outline_svg_d(unit=svg_unit), fill='white'))
layers.append(tag('g', objects, id=f'l-{side}-{use}', filter=f'url(#f-{use})', 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)) **inkscape_attrs(f'{side} {use}')))
for i, layer in enumerate(self.drill_layers): for i, layer in enumerate(self.drill_layers):
layers.append(tag('g', list(layer.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)), layers.append(tag('g', list(layer.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)),
id=f'l-drill-{i}', filter=f'url(#f-drill)', **stroke_attrs, **inkscape_attrs(f'drill-{i}'), id=f'l-drill-{i}', filter=f'url(#f-drill)', **stroke_attrs, **inkscape_attrs(f'drill-{i}')))
transform=layer_transform))
if self.outline: if self.outline:
layers.append(tag('g', list(self.outline.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)), layers.append(tag('g', list(self.outline.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)),
id=f'l-mechanical-outline', **stroke_attrs, **inkscape_attrs(f'outline'), id=f'l-mechanical-outline', **stroke_attrs, **inkscape_attrs(f'outline')))
transform=layer_transform))
sc_y, tl_y = 1, 0 layer_group = tag('g', layers, transform=f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)')
if side == 'bottom':
sc_x, tl_x = -1, (bounds[0][0] + bounds[1][0])
else:
sc_x, tl_x = 1, 0
layer_group = tag('g', layers, transform=f'translate({tl_x} {tl_y}) scale({sc_x} {sc_y})')
tags = [tag('defs', filter_defs + use_defs), layer_group] tags = [tag('defs', filter_defs + use_defs), layer_group]
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor="white", tag=tag, inkscape=inkscape) return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor="white", tag=tag, inkscape=inkscape)
@ -1260,6 +1102,22 @@ class LayerStack:
polys.append(' '.join(poly.path_d()) + ' Z') polys.append(' '.join(poly.path_d()) + ' Z')
return ' '.join(polys) return ' '.join(polys)
def outline_convex_hull(self, tol=0.01, unit=MM):
points = []
for obj in self.outline.instance.objects:
if isinstance(obj, go.Line):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
elif isinstance(obj, go.Arc):
for obj in obj.approximate(tol, unit):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
return convex_hull(points)
def outline_polygons(self, tol=0.01, unit=MM): def outline_polygons(self, tol=0.01, unit=MM):
""" Iterator yielding this boards outline as a list of ordered :py:class:`~.graphic_objects.Arc` and """ Iterator yielding this boards outline as a list of ordered :py:class:`~.graphic_objects.Arc` and
:py:class:`~.graphic_objects.Line` objects. This method first sorts all lines and arcs on the outline layer into :py:class:`~.graphic_objects.Line` objects. This method first sorts all lines and arcs on the outline layer into
@ -1274,29 +1132,14 @@ class LayerStack:
:param tol: :py:obj:`float` setting the tolerance below which two points are considered equal :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 :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 = [] polygons = []
lines = [ obj.as_primitive(unit) for obj in self.outline.instance.objects if isinstance(obj, (go.Line, go.Arc)) ] 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]) 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 dist_sq = lambda x1, y1, x2, y2: (x2-x1)**2 + (y2-y1)**2
joins = {} joins = {}
for cur in lines: for cur in lines:
# Special case: An arc may describe a complete circle, in which case we have to return it as-is since it
# is the only primitive that can join itself.
if isinstance(cur, gp.Arc) and cur.is_circle:
yield [cur]
continue
for (i, x, y) in [(0, cur.x1, cur.y1), (1, cur.x2, cur.y2)]: for (i, x, y) in [(0, cur.x1, cur.y1), (1, cur.x2, cur.y2)]:
x_left = bisect.bisect_left (by_x, x, key=lambda elem: elem[0] + tol) x_left = bisect.bisect_left (by_x, x, key=lambda elem: elem[0] + tol)
x_right = bisect.bisect_right(by_x, x, key=lambda elem: elem[0] - tol) x_right = bisect.bisect_right(by_x, x, key=lambda elem: elem[0] - tol)
@ -1311,14 +1154,13 @@ class LayerStack:
j = 0 if d1 < d2 else 1 j = 0 if d1 < d2 else 1
if (nearest, j) in joins and joins[(nearest, j)] != (cur, i): if (nearest, j) in joins and joins[(nearest, j)] != (cur, i):
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}') 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.')
yield list(convex_hull_to_lines(self.outline.instance.convex_hull(tol, unit), unit)) return self.outline_convex_hull(tol, unit)
return
if (cur, i) in joins and joins[(cur, i)] != (nearest, j): 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}') 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.')
yield list(convex_hull_to_lines(self.outline.instance.convex_hull(tol, unit), unit)) return self.outline_convex_hull(tol, unit)
return
joins[(cur, i)] = (nearest, j) joins[(cur, i)] = (nearest, j)
joins[(nearest, j)] = (cur, i) joins[(nearest, j)] = (cur, i)

99
gerbonara/newstroke.py Normal file
View file

@ -0,0 +1,99 @@
#!/usr/bin/env python
from pathlib import Path
import unicodedata
import re
import ast
from functools import lru_cache
from importlib.resources import files
from . import data
STROKE_FONT_SCALE = 1/21
FONT_OFFSET = -10
DEFAULT_SPACE_WIDTH = 0.6
DEFAULT_CHAR_GAP = 0.2
_dec = lambda c: ord(c)-ord('R')
class Newstroke:
def __init__(self, newstroke_cpp=None):
if newstroke_cpp is None:
newstroke_cpp = files(data).joinpath('newstroke_font.cpp').read_bytes()
self.glyphs = dict(self.load_font(newstroke_cpp))
@classmethod
@lru_cache
def load(kls):
return kls()
def render(self, text, size=1.0, space_width=DEFAULT_SPACE_WIDTH, char_gap=DEFAULT_CHAR_GAP):
text = unicodedata.normalize('NFC', text)
missing_glyph = self.glyphs['?']
x = 0
for c in text:
if c == ' ':
x += space_width*size
continue
width, strokes = self.glyphs.get(c, missing_glyph)
glyph_w = max(width, max(x for st in strokes for x, _y in st))
for st in strokes:
yield self.transform_stroke(st, translate=(x, 0), scale=(size, size))
x += glyph_w*size
@classmethod
def transform_stroke(kls, stroke, translate, scale):
dx, dy = translate
sx, sy = scale
return [(x*sx+dx, y*sy+dy) for x, y in stroke]
def load_font(self, newstroke_cpp):
e = []
for char, (width, strokes) in self.load_glyphs(newstroke_cpp):
yield char, (width, strokes)
@classmethod
def decode_stroke(kls, stroke, start_x):
for i in range(0, len(stroke), 2):
x = (stroke[i]-0x52-start_x)*STROKE_FONT_SCALE
y = (stroke[i+1]-0x52+FONT_OFFSET)*STROKE_FONT_SCALE
yield (x, y)
@classmethod
def decode_glyph(kls, data):
start_x, end_x = data[0]-0x52, data[1]-0x52
width = end_x - start_x
strokes = tuple(tuple(kls.decode_stroke(st, start_x)) for st in data[2:].split(b' R'))
return width*STROKE_FONT_SCALE, strokes
@classmethod
def load_glyphs(kls, newstroke_cpp):
it = iter(newstroke_cpp.splitlines())
for line in it:
if re.search(rb'char.*\*', line):
break
charcode = 0x20
for line in it:
if (match := re.search(rb'".*"', line)):
yield chr(charcode), kls.decode_glyph(match.group(0)[1:-1].replace(b'\\\\', b'\\'))
charcode += 1
else:
if b'}' in line:
break
if __name__ == '__main__':
import time
t1 = time.time()
Newstroke()
t2 = time.time()
print((t2-t1)*1000)

View file

@ -21,7 +21,6 @@
import re import re
import math import math
import copy
import warnings import warnings
from pathlib import Path from pathlib import Path
import dataclasses import dataclasses
@ -132,7 +131,7 @@ class GerberFile(CamFile):
nonlocal cache, settings nonlocal cache, settings
if isinstance(aperture, apertures.ApertureMacroInstance): if isinstance(aperture, apertures.ApertureMacroInstance):
macro = aperture.macro macro = aperture.macro
macro_def = macro.to_gerber(settings) macro_def = macro.to_gerber(unit=settings.unit)
if macro_def not in cache: if macro_def not in cache:
cache[macro_def] = macro cache[macro_def] = macro
@ -152,7 +151,7 @@ class GerberFile(CamFile):
self.map_apertures(lookup) 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 """ Convert this excellon file into a :py:class:`~.excellon.ExcellonFile`. This will convert interpolated lines
into slots, and circular aperture flashes into holes. Other features such as ``G36`` polygons or flashes with into slots, and circular aperture flashes into holes. Other features such as ``G36`` polygons or flashes with
non-circular apertures will result in a :py:obj:`ValueError`. You can, of course, programmatically remove such non-circular apertures will result in a :py:obj:`ValueError`. You can, of course, programmatically remove such
@ -160,10 +159,7 @@ class GerberFile(CamFile):
new_objs = [] new_objs = []
new_tools = {} new_tools = {}
for obj in self.objects: for obj in self.objects:
if holes_only and not isinstance(obj, go.Flash): if not (isinstance(obj, go.Line) or isinstance(obj, go.Arc) or isinstance(obj, go.Flash)) or \
continue
if not isinstance(obj, (go.Line, go.Arc, go.Flash)) or \
not isinstance(getattr(obj, 'aperture', None), apertures.CircleAperture): not isinstance(getattr(obj, 'aperture', None), apertures.CircleAperture):
if errors == 'raise': if errors == 'raise':
raise ValueError(f'Cannot convert {obj} to excellon.') raise ValueError(f'Cannot convert {obj} to excellon.')
@ -287,24 +283,13 @@ class GerberFile(CamFile):
self.dedup_apertures() self.dedup_apertures()
am_stmt = lambda macro: f'%AM{macro.name}*\n{macro.to_gerber(settings)}*\n%' am_stmt = lambda macro: f'%AM{macro.name}*\n{macro.to_gerber(unit=settings.unit)}*\n%'
for macro in self.aperture_macros():
yield am_stmt(macro)
aperture_map = {ap: num for num, ap in enumerate(self.apertures(), start=10)} aperture_map = {ap: num for num, ap in enumerate(self.apertures(), start=10)}
for aperture, number in aperture_map.items():
if settings.calculate_out_all_aperture_macros: yield f'%ADD{number}{aperture.to_gerber(settings)}*%'
adds = []
for aperture, number in aperture_map.items():
if isinstance(aperture, apertures.ApertureMacroInstance):
aperture = aperture.calculate_out(settings.unit, macro_name=f'CALCM{number}')
yield am_stmt(aperture.macro)
adds.append(f'%ADD{number}{aperture.to_gerber(settings)}*%')
yield from adds
else:
for macro in self.aperture_macros():
yield am_stmt(macro)
for aperture, number in aperture_map.items():
yield f'%ADD{number}{aperture.to_gerber(settings)}*%'
def warn(msg, kls=SyntaxWarning): def warn(msg, kls=SyntaxWarning):
warnings.warn(msg, kls) warnings.warn(msg, kls)
@ -375,8 +360,8 @@ class GerberFile(CamFile):
def invert_polarity(self): def invert_polarity(self):
""" Invert the polarity (color) of each object in this file. """ """ Invert the polarity (color) of each object in this file. """
for obj in self.objects: for obj in self.objects:
obj.polarity_dark = not obj.polarity_dark obj.polarity_dark = not p.polarity_dark
class GraphicsState: class GraphicsState:
""" Internal class used to track Gerber processing state during import and export. """ Internal class used to track Gerber processing state during import and export.
@ -463,7 +448,7 @@ class GraphicsState:
obj = go.Flash(*self.map_coord(*self.point), self.aperture, obj = go.Flash(*self.map_coord(*self.point), self.aperture,
polarity_dark=self._polarity_dark, polarity_dark=self._polarity_dark,
unit=self.unit, unit=self.unit,
attrs=copy.copy(self.object_attrs)) attrs=self.object_attrs)
return obj return obj
def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False): def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False):
@ -489,13 +474,13 @@ class GraphicsState:
raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)") 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, 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: else:
if i is None and j is None: if i is None and j is None:
self.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values') 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, 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: else:
if i is None: if i is None:
@ -512,7 +497,7 @@ class GraphicsState:
if not multi_quadrant: if not multi_quadrant:
return go.Arc(*old_point, *new_point, *self.map_coord(i, j, relative=True), return go.Arc(*old_point, *new_point, *self.map_coord(i, j, relative=True),
clockwise=clockwise, aperture=(self.aperture if aperture else None), 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: else:
if math.isclose(old_point[0], new_point[0]) and math.isclose(old_point[1], new_point[1]): if math.isclose(old_point[0], new_point[0]) and math.isclose(old_point[1], new_point[1]):
@ -525,7 +510,7 @@ class GraphicsState:
arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy, arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy,
clockwise=clockwise, aperture=aperture, 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 = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ]
arcs = sorted(arcs, key=lambda a: a.numeric_error()) arcs = sorted(arcs, key=lambda a: a.numeric_error())
@ -587,9 +572,9 @@ class GraphicsState:
def interpolation_mode_statement(self): def interpolation_mode_statement(self):
return { return {
InterpMode.LINEAR: 'G01*', InterpMode.LINEAR: 'G01',
InterpMode.CIRCULAR_CW: 'G02*', InterpMode.CIRCULAR_CW: 'G02',
InterpMode.CIRCULAR_CCW: 'G03*'}[self.interpolation_mode] InterpMode.CIRCULAR_CCW: 'G03'}[self.interpolation_mode]
class GerberParser: class GerberParser:
@ -599,8 +584,6 @@ class GerberParser:
NUMBER = r"[\+-]?\d+" NUMBER = r"[\+-]?\d+"
DECIMAL = r"[\+-]?\d+([.]?\d+)?" DECIMAL = r"[\+-]?\d+([.]?\d+)?"
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+" NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
MAX_STEP_REPEAT_INSTANCES = 100000
MAX_STEP_REPEAT_RESULT_OBJECTS = 100000
STATEMENT_REGEXES = { STATEMENT_REGEXES = {
'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \ 'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \
@ -608,7 +591,6 @@ class GerberParser:
fr"(?:D0?([123]))?$", fr"(?:D0?([123]))?$",
'region_start': r'G36$', 'region_start': r'G36$',
'region_end': r'G37$', 'region_end': r'G37$',
'eof': r"(D02)?M0?[02]", # P-CAD 2006 files have a spurious D02 before M02 as in "D02M02"
'aperture': r"(G54|G55)?\s*D(?P<number>\d+)", 'aperture': r"(G54|G55)?\s*D(?P<number>\d+)",
# Allegro combines format spec and unit into one long illegal extended command. # Allegro combines format spec and unit into one long illegal extended command.
'allegro_format_spec': r"FS(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*\*MO(?P<unit>IN|MM)", 'allegro_format_spec': r"FS(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*\*MO(?P<unit>IN|MM)",
@ -629,9 +611,9 @@ class GerberParser:
'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})(,(?P<modifiers>[^,%]*))?$", '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>[^%]*)", 'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)",
'siemens_garbage': r'^ICAS$', '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_unit':r'(?P<mode>G7[01])',
'old_notation': r'(?P<mode>G9[01])', 'old_notation': r'(?P<mode>G9[01])',
'eof': r"M0?[02]",
'ignored': r"(?P<stmt>M01)", 'ignored': r"(?P<stmt>M01)",
# NOTE: The official spec says names can be empty or contain commas. I think that doesn't make sense. # NOTE: The official spec says names can be empty or contain commas. I think that doesn't make sense.
'attribute': r"(?P<eagle_garbage>G04 #@! %)?(?P<type>TF|TA|TO|TD)(?P<name>[._$a-zA-Z][._$a-zA-Z0-9]*)?(,(?P<value>.*))?", 'attribute': r"(?P<eagle_garbage>G04 #@! %)?(?P<type>TF|TA|TO|TD)(?P<name>[._$a-zA-Z][._$a-zA-Z0-9]*)?(,(?P<value>.*))?",
@ -649,8 +631,6 @@ class GerberParser:
self.aperture_map = {} self.aperture_map = {}
self.aperture_macros = {} self.aperture_macros = {}
self.current_region = None self.current_region = None
self.step_repeat_coords = None
self.step_repeat_objects = None
self.eof_found = False self.eof_found = False
self.multi_quadrant_mode = None # used only for syntax checking self.multi_quadrant_mode = None # used only for syntax checking
self.macros = {} self.macros = {}
@ -793,10 +773,7 @@ class GerberParser:
# in multi-quadrant mode this may return None if start and end point of the arc are the same. # 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) obj = self.graphics_state.interpolate(x, y, i, j, multi_quadrant=self.multi_quadrant_mode)
if obj is not None: if obj is not None:
if self.step_repeat_objects: self.target.objects.append(obj)
self.step_repeat_objects.append(obj)
else:
self.target.objects.append(obj)
else: else:
obj = self.graphics_state.interpolate(x, y, i, j, aperture=False, multi_quadrant=self.multi_quadrant_mode) obj = self.graphics_state.interpolate(x, y, i, j, aperture=False, multi_quadrant=self.multi_quadrant_mode)
if obj is not None: if obj is not None:
@ -807,21 +784,14 @@ class GerberParser:
if self.current_region: if self.current_region:
# Start a new region for every outline. As gerber has no concept of fill rules or winding numbers, # 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. # it does not make a graphical difference, and it makes the implementation slightly easier.
if self.step_repeat_objects: self.target.objects.append(self.current_region)
self.step_repeat_objects.append(self.current_region)
else:
self.target.objects.append(self.current_region)
self.current_region = go.Region( self.current_region = go.Region(
polarity_dark=self.graphics_state.polarity_dark, polarity_dark=self.graphics_state.polarity_dark,
unit=self.file_settings.unit) unit=self.file_settings.unit)
elif op == '3': elif op == '3':
if self.current_region is None: if self.current_region is None:
obj = self.graphics_state.flash(x, y) self.target.objects.append(self.graphics_state.flash(x, y))
if self.step_repeat_objects:
self.step_repeat_objects.append(obj)
else:
self.target.objects.append(obj)
else: else:
raise SyntaxError('DO3 flash statement inside region') raise SyntaxError('DO3 flash statement inside region')
@ -862,11 +832,6 @@ class GerberParser:
if match['shape'] in 'RO' and (math.isclose(modifiers[0], 0) or math.isclose(modifiers[1], 0)): 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.' ) 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()), new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=tuple(self.aperture_attrs.items()),
original_number=number) original_number=number)
@ -1083,40 +1048,11 @@ class GerberParser:
else: else:
target = {'TF': self.file_attrs, 'TO': self.graphics_state.object_attrs, 'TA': self.aperture_attrs}[match['type']] 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']] = match['value'].split(',')
if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']: if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']:
self.generator_hints.append('eagle') 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): def _parse_eof(self, match):
self.eof_found = True self.eof_found = True

View file

@ -0,0 +1,63 @@
import os
from pathlib import Path
import pytest
from .image_support import ImageDifference, run_cargo_cmd
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 not hasattr(session.config, 'workerinput'): # on worker
return
# on coordinator
for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')):
f.unlink()
try:
run_cargo_cmd('resvg', '--help')
except FileNotFoundError:
pytest.exit('resvg binary not found, aborting test.', 2)
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()
if not lib_dir.is_dir():
raise ValueError(f'Path "{lib_dir}" given by KICAD_SYMBOLS environment variable does not exist or is not a directory.')
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.')
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()
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.')
mod_files = list(lib_dir.glob('**/*.kicad_mod'))
else:
raise ValueError('Either --kicad-footprint-files command line parameter or KICAD_FOOTPRINTS environment variable must be given.')
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,308 @@
#!/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
from functools import total_ordering
import shutil
import bs4
from contextlib import contextmanager
import hashlib
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:
pretty_dir = mod_file.parent
fp_name = mod_file.name[:-len('.kicad_mod')]
cmd = ['kicad-cli', 'fp', 'export', 'svg', '--output', tmpdir, '--footprint', fp_name, pretty_dir]
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)
@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