Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7f58c6a54 | ||
|
|
b2f37f4b5d | ||
|
|
7ba1592e25 |
2
.gitignore
vendored
|
|
@ -3,5 +3,3 @@ gerbonara_test_failures
|
|||
__pycache__
|
||||
.tox
|
||||
docs/_build/
|
||||
build
|
||||
dist
|
||||
|
|
|
|||
|
|
@ -14,54 +14,53 @@ build:archlinux:
|
|||
GIT_SUBMODULE_STRATEGY: none
|
||||
script:
|
||||
- git config --global --add safe.directory "$CI_PROJECT_DIR"
|
||||
- uv build
|
||||
- pip3 install --user wheel setuptools
|
||||
- python3 setup.py sdist bdist_wheel
|
||||
artifacts:
|
||||
name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
|
||||
paths:
|
||||
- dist/*
|
||||
|
||||
# FIXME: disable tests since (a) currenty kicad-cli is broken (aborts on start), and the workaround of using an older
|
||||
# version from the KiCad project's kicad-cli containers does not work in gitlab CI. Pain.
|
||||
#test:archlinux:
|
||||
# stage: test
|
||||
# image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
|
||||
# script:
|
||||
# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-symbols
|
||||
# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-footprints
|
||||
# - env KICAD_SYMBOLS=kicad-symbols KICAD_FOOTPRINTS=kicad-footprints pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*'
|
||||
# dependencies:
|
||||
# - build:archlinux
|
||||
# cache:
|
||||
# key: test-image-cache
|
||||
# paths:
|
||||
# - gerbonara/tests/image_cache/*.svg
|
||||
# - gerbonara/tests/image_cache/*.png
|
||||
# artifacts:
|
||||
# name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
|
||||
# when: on_failure
|
||||
# paths:
|
||||
# - gerbonara_test_failures/*
|
||||
#
|
||||
#test:ubuntu-rolling:
|
||||
# stage: test
|
||||
# image: "registry.gitlab.com/gerbolyze/build-containers/ubuntu:rolling"
|
||||
# script:
|
||||
# - 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-symbols
|
||||
# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-footprints
|
||||
# - env KICAD_SYMBOLS=kicad-symbols KICAD_FOOTPRINTS=kicad-footprints python3 -m pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*'
|
||||
# dependencies:
|
||||
# - build:archlinux
|
||||
# cache:
|
||||
# key: test-image-cache
|
||||
# paths:
|
||||
# - gerbonara/tests/image_cache/*.svg
|
||||
# - gerbonara/tests/image_cache/*.png
|
||||
# artifacts:
|
||||
# name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
|
||||
# when: on_failure
|
||||
# paths:
|
||||
# - gerbonara_test_failures/*
|
||||
test:archlinux:
|
||||
stage: test
|
||||
image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
|
||||
script:
|
||||
- git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-symbols
|
||||
- git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-footprints
|
||||
- env KICAD_SYMBOLS=kicad-symbols KICAD_FOOTPRINTS=kicad-footprints pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*'
|
||||
dependencies:
|
||||
- build:archlinux
|
||||
cache:
|
||||
key: test-image-cache
|
||||
paths:
|
||||
- gerbonara/tests/image_cache/*.svg
|
||||
- gerbonara/tests/image_cache/*.png
|
||||
artifacts:
|
||||
name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
|
||||
when: on_failure
|
||||
paths:
|
||||
- gerbonara_test_failures/*
|
||||
|
||||
test:ubuntu2204:
|
||||
stage: test
|
||||
image: "registry.gitlab.com/gerbolyze/build-containers/ubuntu:22.04"
|
||||
script:
|
||||
- python3 -m pip install pytest beautifulsoup4 pillow numpy slugify lxml click scipy
|
||||
- git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-symbols
|
||||
- git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-footprints
|
||||
- env KICAD_SYMBOLS=kicad-symbols KICAD_FOOTPRINTS=kicad-footprints python3 -m pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*'
|
||||
dependencies:
|
||||
- build:archlinux
|
||||
cache:
|
||||
key: test-image-cache
|
||||
paths:
|
||||
- gerbonara/tests/image_cache/*.svg
|
||||
- gerbonara/tests/image_cache/*.png
|
||||
artifacts:
|
||||
name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
|
||||
when: on_failure
|
||||
paths:
|
||||
- gerbonara_test_failures/*
|
||||
|
||||
docs:archlinux:
|
||||
stage: test
|
||||
|
|
@ -84,7 +83,7 @@ publish:gerbonara:
|
|||
cache: {}
|
||||
script:
|
||||
- export TWINE_USERNAME TWINE_PASSWORD
|
||||
- pip3 install --user --break-system-packages twine rich
|
||||
- pip3 install --user twine rich
|
||||
- twine upload dist/*
|
||||
dependencies:
|
||||
- build:archlinux
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ layers, or whole board stacks (:py:class:`~.layers.LayerStack`) to SVG.
|
|||
|
||||
``gerbonara render`` renders one or more Gerber or Excellon files as a single SVG file. It can read single files,
|
||||
directorys of files, and ZIP files. To read directories or zips, it applies gerbonara's layer filename matching rules.
|
||||
These built-in rules should work with common settings in a wide variety of CAD tools.
|
||||
|
||||
.. option:: --warnings [default|ignore|once]
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ Then, you are ready to read and write gerber files:
|
|||
|
||||
from gerbonara import LayerStack
|
||||
|
||||
stack = LayerStack.open('output/gerber')
|
||||
stack = LayerStack.from_directory('output/gerber')
|
||||
w, h = stack.outline.size('mm')
|
||||
print(f'Board size is {w:.1f} mm x {h:.1f} mm')
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from gerbonara.utils import MM
|
|||
from gerbonara.utils import rotate_point
|
||||
|
||||
def highlight_outline(input_dir, output_dir):
|
||||
stack = LayerStack.open(input_dir)
|
||||
stack = LayerStack.from_directory(input_dir)
|
||||
|
||||
outline = []
|
||||
for obj in stack.outline.objects:
|
||||
|
|
@ -28,6 +28,7 @@ def highlight_outline(input_dir, output_dir):
|
|||
marker_nx, marker_ny = math.sin(marker_angle), math.cos(marker_angle)
|
||||
|
||||
ap = CircleAperture(0.1, unit=MM)
|
||||
stack['top silk'].apertures.append(ap)
|
||||
|
||||
for line in outline:
|
||||
cx, cy = (line.x1 + line.x2)/2, (line.y1 + line.y2)/2
|
||||
|
|
|
|||
|
|
@ -7,5 +7,5 @@ if __name__ == '__main__':
|
|||
args = parser.parse_args()
|
||||
|
||||
import gerbonara
|
||||
print(gerbonara.LayerStack.open(args.input))
|
||||
print(gerbonara.LayerStack.from_directory(args.input))
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import math
|
||||
|
||||
from gerbonara.utils import MM
|
||||
from gerbonara.graphic_objects import Arc
|
||||
from gerbonara.graphic_objects import rotate_point
|
||||
|
||||
|
|
@ -23,8 +22,7 @@ def approx_test():
|
|||
x1, y1 = rotate_point(0, -1, start_angle*eps)
|
||||
x2, y2 = rotate_point(x1, y1, sweep_angle*eps*(-1 if clockwise else 1))
|
||||
|
||||
arc = Arc(x1+cx, y1+cy, x2+cx, y2+cy, -x1, -y1, clockwise=clockwise, aperture=None,
|
||||
polarity_dark=True, unit=MM)
|
||||
arc = Arc(x1+cx, y1+cy, x2+cx, y2+cy, -x1, -y1, clockwise=clockwise, aperture=None, polarity_dark=True)
|
||||
lines = arc.approximate(max_error=max_error)
|
||||
|
||||
print(f'<path style="fill: {color}; stroke: none;" d="M {cx} {cy} L {lines[0].x1} {lines[0].y1}', end=' ')
|
||||
|
|
|
|||
|
|
@ -30,6 +30,5 @@ from .excellon import ExcellonFile
|
|||
from .ipc356 import Netlist
|
||||
from .layers import LayerStack
|
||||
from .utils import MM, Inch
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = version('gerbonara')
|
||||
__version__ = '1.0.2'
|
||||
9
gerbonara/__main__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import click
|
||||
|
||||
from .cli import cli
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
||||
|
||||
|
|
@ -7,7 +7,6 @@ from dataclasses import dataclass
|
|||
import operator
|
||||
import re
|
||||
import ast
|
||||
import math
|
||||
|
||||
from ..utils import LengthUnit, MM, Inch, MILLIMETERS_PER_INCH
|
||||
|
||||
|
|
@ -34,7 +33,7 @@ class Expression:
|
|||
def calculate(self, variable_binding={}, unit=None):
|
||||
expr = self.converted(unit).optimized(variable_binding)
|
||||
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
|
||||
|
||||
def __add__(self, other):
|
||||
|
|
@ -62,18 +61,11 @@ class Expression:
|
|||
return expr(other) / self
|
||||
|
||||
def __neg__(self):
|
||||
return NegatedExpression(self).optimized()
|
||||
return 0 - self
|
||||
|
||||
def __pos__(self):
|
||||
return self
|
||||
|
||||
def parameters(self):
|
||||
return tuple()
|
||||
|
||||
@property
|
||||
def _operator(self):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnitExpression(Expression):
|
||||
|
|
@ -87,8 +79,8 @@ class UnitExpression(Expression):
|
|||
object.__setattr__(self, 'expr', expr)
|
||||
object.__setattr__(self, 'unit', unit)
|
||||
|
||||
def to_gerber(self, register_variable=None, unit=None):
|
||||
return self.converted(unit).optimized().to_gerber(register_variable)
|
||||
def to_gerber(self, unit=None):
|
||||
return self.converted(unit).optimized().to_gerber()
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(other) == type(self) and \
|
||||
|
|
@ -155,10 +147,6 @@ class UnitExpression(Expression):
|
|||
def __pos__(self):
|
||||
return self
|
||||
|
||||
def parameters(self):
|
||||
return self.expr.parameters()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConstantExpression(Expression):
|
||||
value: float
|
||||
|
|
@ -167,44 +155,14 @@ class ConstantExpression(Expression):
|
|||
return float(self.value)
|
||||
|
||||
def __eq__(self, other):
|
||||
try:
|
||||
return math.isclose(self.value, float(other), abs_tol=1e-9)
|
||||
except TypeError:
|
||||
return False
|
||||
return type(self) == type(other) and self.value == other.value
|
||||
|
||||
def to_gerber(self, register_variable=None, unit=None):
|
||||
if self == 0: # Avoid producing "-0" for negative floating point zeros
|
||||
return '0'
|
||||
def to_gerber(self, _unit=None):
|
||||
return f'{self.value:.6f}'.rstrip('0').rstrip('.')
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VariableExpression(Expression):
|
||||
''' An expression that encapsulates some other complex expression and will replace all occurences of it with a newly
|
||||
allocated variable at export time.
|
||||
'''
|
||||
expr: Expression
|
||||
|
||||
def optimized(self, variable_binding={}):
|
||||
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
|
||||
|
||||
def optimized(self, variable_binding={}):
|
||||
|
|
@ -216,50 +174,9 @@ class ParameterExpression(Expression):
|
|||
return type(self) == type(other) and \
|
||||
self.number == other.number
|
||||
|
||||
def to_gerber(self, register_variable=None, unit=None):
|
||||
def to_gerber(self, _unit=None):
|
||||
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)
|
||||
class OperatorExpression(Expression):
|
||||
|
|
@ -278,88 +195,22 @@ class OperatorExpression(Expression):
|
|||
self.l == other.l and \
|
||||
self.r == other.r
|
||||
|
||||
@property
|
||||
def _operator(self):
|
||||
return self.op
|
||||
|
||||
def optimized(self, variable_binding={}):
|
||||
l = self.l.optimized(variable_binding)
|
||||
r = self.r.optimized(variable_binding)
|
||||
|
||||
match (l, self.op, r):
|
||||
case (ConstantExpression(), op, ConstantExpression()):
|
||||
return ConstantExpression(self.op(float(l), float(r)))
|
||||
#if self.op in (operator.add, operator.mul):
|
||||
# if id(r) < id(l):
|
||||
# l, r = r, l
|
||||
|
||||
# Minimize operations with neutral elements and zeros
|
||||
# 0 + x == x
|
||||
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
|
||||
if isinstance(l, ConstantExpression) and isinstance(r, ConstantExpression):
|
||||
return ConstantExpression(self.op(float(l), float(r)))
|
||||
|
||||
case _: # default
|
||||
return OperatorExpression(self.op, l, r)
|
||||
|
||||
return expr(rv).optimized(variable_binding)
|
||||
|
||||
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)
|
||||
return OperatorExpression(self.op, l, r)
|
||||
|
||||
def to_gerber(self, unit=None):
|
||||
lval = self.l.to_gerber(unit)
|
||||
rval = self.r.to_gerber(unit)
|
||||
|
||||
if isinstance(self.l, OperatorExpression):
|
||||
lval = f'({lval})'
|
||||
|
|
@ -373,7 +224,3 @@ class OperatorExpression(Expression):
|
|||
|
||||
return f'{lval}{op}{rval}'
|
||||
|
||||
def parameters(self):
|
||||
yield from self.l.parameters()
|
||||
yield from self.r.parameters()
|
||||
|
||||
231
gerbonara/aperture_macros/parse.py
Normal 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 = [ f'0 {c.replace("*", "_").replace("%", "_")}' 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)
|
||||
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
import warnings
|
||||
import contextlib
|
||||
import math
|
||||
from dataclasses import dataclass, fields, replace
|
||||
from dataclasses import dataclass, fields
|
||||
|
||||
from .expression import Expression, UnitExpression, ConstantExpression, expr
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ def rad_to_deg(a):
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class Primitive:
|
||||
unit: LengthUnit
|
||||
exposure : Expression
|
||||
|
||||
def __post_init__(self):
|
||||
for field in fields(self):
|
||||
|
|
@ -45,16 +46,9 @@ class Primitive:
|
|||
elif field.type == Expression:
|
||||
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(
|
||||
getattr(self, field.name).optimized().to_gerber(register_variable, settings.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
|
||||
getattr(self, field.name).to_gerber(unit) for field in fields(self) if field.name != 'unit')
|
||||
|
||||
def __str__(self):
|
||||
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):
|
||||
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:
|
||||
def __init__(self, instance, variable_binding={}, unit=None):
|
||||
self.instance = instance
|
||||
|
|
@ -94,29 +83,18 @@ class Primitive:
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class Circle(Primitive):
|
||||
code = 1
|
||||
exposure : Expression
|
||||
diameter : UnitExpression
|
||||
# center x/y
|
||||
x : UnitExpression = 0
|
||||
y : UnitExpression = 0
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
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:
|
||||
x, y = rotate_point(calc.x, calc.y, -(deg_to_rad(calc.rotation) + rotation), 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
|
||||
if math.isclose(calc.diameter, 0):
|
||||
return []
|
||||
|
||||
return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def substitute_params(self, binding, unit):
|
||||
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):
|
||||
return replace(self, diameter=self.diameter + UnitExpression(offset, unit))
|
||||
|
||||
|
|
@ -128,7 +106,6 @@ class Circle(Primitive):
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class VectorLine(Primitive):
|
||||
code = 20
|
||||
exposure : Expression
|
||||
width : UnitExpression
|
||||
start_x : UnitExpression
|
||||
start_y : UnitExpression
|
||||
|
|
@ -148,18 +125,9 @@ class VectorLine(Primitive):
|
|||
center_x, center_y = center_x+offset[0], center_y+offset[1]
|
||||
rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x)
|
||||
|
||||
if math.isclose(calc.width, 0):
|
||||
return []
|
||||
|
||||
return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation,
|
||||
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
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):
|
||||
return replace(self, width=self.width + UnitExpression(2*offset, unit))
|
||||
|
||||
|
|
@ -174,7 +142,6 @@ class VectorLine(Primitive):
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class CenterLine(Primitive):
|
||||
code = 21
|
||||
exposure : Expression
|
||||
width : UnitExpression
|
||||
height : UnitExpression
|
||||
# center x/y
|
||||
|
|
@ -189,17 +156,8 @@ class CenterLine(Primitive):
|
|||
x, y = x+offset[0], y+offset[1]
|
||||
w, h = calc.width, calc.height
|
||||
|
||||
if math.isclose(calc.width, 0) or math.isclose(calc.height, 0):
|
||||
return []
|
||||
|
||||
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def substitute_params(self, binding, unit):
|
||||
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):
|
||||
return replace(self, width=self.width + UnitExpression(2*offset, unit))
|
||||
|
||||
|
|
@ -214,7 +172,6 @@ class CenterLine(Primitive):
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class Polygon(Primitive):
|
||||
code = 5
|
||||
exposure : Expression
|
||||
n_vertices : Expression
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
|
|
@ -227,8 +184,7 @@ class Polygon(Primitive):
|
|||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
print('xy', calc.x, calc.y)
|
||||
return [ gp.ArcPoly.from_regular_polygon(x, y, calc.diameter/2, int(calc.n_vertices), rotation,
|
||||
return [ gp.ArcPoly.from_regular_polygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation,
|
||||
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def dilated(self, offset, unit):
|
||||
|
|
@ -241,60 +197,9 @@ class Polygon(Primitive):
|
|||
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)
|
||||
class Thermal(Primitive):
|
||||
code = 7
|
||||
# Note: Thermal primitives according to spec don't have an exposure variable
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
|
|
@ -309,16 +214,13 @@ class Thermal(Primitive):
|
|||
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
|
||||
dark = True
|
||||
|
||||
if math.isclose(calc.d_outer, 0):
|
||||
return []
|
||||
dark = (bool(calc.exposure) == polarity_dark)
|
||||
|
||||
return [
|
||||
gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark),
|
||||
gp.Circle(x, y, calc.d_inner/2, polarity_dark=not dark),
|
||||
gp.Rectangle(x, y, calc.d_outer, calc.gap_w, rotation=rotation, polarity_dark=not dark),
|
||||
gp.Rectangle(x, y, calc.gap_w, calc.d_outer, rotation=rotation, polarity_dark=not dark),
|
||||
gp.Rectangle(x, y, d_outer, gap_w, rotation=rotation, polarity_dark=not dark),
|
||||
gp.Rectangle(x, y, gap_w, d_outer, rotation=rotation, polarity_dark=not dark),
|
||||
]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
|
|
@ -338,7 +240,6 @@ class Thermal(Primitive):
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class Outline(Primitive):
|
||||
code = 4
|
||||
exposure : Expression
|
||||
length: Expression
|
||||
coords: tuple
|
||||
rotation: Expression = 0
|
||||
|
|
@ -375,24 +276,21 @@ class Outline(Primitive):
|
|||
def __str__(self):
|
||||
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()
|
||||
coords = ','.join(coord.optimized().to_gerber(register_variable, settings.unit) for coord in self.coords)
|
||||
return f'{self.code},{self.exposure.optimized().to_gerber(register_variable)},{len(self.coords)//2-1},{coords},{rotation.to_gerber(register_variable)}'
|
||||
|
||||
def substitute_params(self, binding, unit):
|
||||
with self.Calculator(self, binding, unit) as calc:
|
||||
rotation = calc.rotation
|
||||
coords = [ rotate_point(x.calculate(binding, unit), y.calculate(binding, unit), -deg_to_rad(rotation), 0, 0)
|
||||
for x, y in self.points ]
|
||||
coords = self.coords
|
||||
if isinstance(rotation, ConstantExpression):
|
||||
rotation = math.radians(rotation.value)
|
||||
# This will work even with variables in x and y, we just need to pass in cx and cy as UnitExpressions
|
||||
unit_zero = UnitExpression(expr(0), MM)
|
||||
coords = [ rotate_point(x, y, -rotation, cx=unit_zero, cy=unit_zero) for x, y in self.points ]
|
||||
coords = [ e for point in coords for e in point ]
|
||||
return Outline(unit, calc.exposure, calc.length, coords)
|
||||
|
||||
def parameters(self):
|
||||
yield from Primitive.parameters(self)
|
||||
rotation = ConstantExpression(0)
|
||||
|
||||
for expr in self.coords:
|
||||
yield from expr.parameters()
|
||||
coords = ','.join(coord.to_gerber(unit) for coord in coords)
|
||||
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):
|
||||
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 = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ]
|
||||
bound_radii = [None] * len(bound_coords)
|
||||
|
||||
if len(bound_coords) < 3:
|
||||
return []
|
||||
|
||||
return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))]
|
||||
|
||||
def dilated(self, offset, unit):
|
||||
|
|
@ -419,7 +313,7 @@ class Comment:
|
|||
code = 0
|
||||
comment: str
|
||||
|
||||
def to_gerber(self, register_variable=None, settings=None):
|
||||
def to_gerber(self, unit=None):
|
||||
return f'0 {self.comment}'
|
||||
|
||||
def dilated(self, offset, unit):
|
||||
|
|
@ -437,7 +331,6 @@ PRIMITIVE_CLASSES = {
|
|||
CenterLine,
|
||||
Outline,
|
||||
Polygon,
|
||||
Moire,
|
||||
Thermal,
|
||||
]},
|
||||
# alternative codes
|
||||
|
|
@ -16,11 +16,11 @@
|
|||
# limitations under the License.
|
||||
#
|
||||
|
||||
import warnings
|
||||
import math
|
||||
from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY
|
||||
from functools import lru_cache
|
||||
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
from .utils import LengthUnit, MM, Inch, sum_bounds
|
||||
|
||||
from . import graphic_primitives as gp
|
||||
|
|
@ -159,8 +159,7 @@ class ExcellonTool(Aperture):
|
|||
return self
|
||||
|
||||
def to_macro(self, rotation=0):
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM)
|
||||
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
|
||||
|
||||
def _params(self, unit=None):
|
||||
return (self.unit.convert_to(unit, self.diameter),)
|
||||
|
|
@ -205,9 +204,7 @@ class CircleAperture(Aperture):
|
|||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||
|
||||
def to_macro(self, rotation=0):
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return GenericMacros.circle(MM(self.diameter, self.unit),
|
||||
MM(self.hole_dia, self.unit))
|
||||
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
|
||||
|
||||
def _params(self, unit=None):
|
||||
return _strip_right(
|
||||
|
|
@ -262,11 +259,12 @@ class RectangleAperture(Aperture):
|
|||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||
|
||||
def to_macro(self, rotation=0):
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return GenericMacros.rect(MM(self.w, self.unit),
|
||||
MM(self.h, self.unit),
|
||||
MM(self.hole_dia, self.unit),
|
||||
rotation)
|
||||
return ApertureMacroInstance(GenericMacros.rect,
|
||||
(MM(self.w, self.unit),
|
||||
MM(self.h, self.unit),
|
||||
MM(self.hole_dia, self.unit) or 0,
|
||||
0,
|
||||
rotation))
|
||||
|
||||
def _params(self, unit=None):
|
||||
return _strip_right(
|
||||
|
|
@ -330,11 +328,12 @@ class ObroundAperture(Aperture):
|
|||
rotation -= -math.pi/2
|
||||
inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
|
||||
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return GenericMacros.obround(MM(inst.w, self.unit),
|
||||
MM(inst.h, self.unit),
|
||||
MM(inst.hole_dia, self.unit) or 0,
|
||||
rotation)
|
||||
return ApertureMacroInstance(GenericMacros.obround,
|
||||
(MM(inst.w, self.unit),
|
||||
MM(inst.h, self.unit),
|
||||
MM(inst.hole_dia, self.unit) or 0,
|
||||
0,
|
||||
rotation))
|
||||
|
||||
def _params(self, unit=None):
|
||||
return _strip_right(
|
||||
|
|
@ -390,18 +389,12 @@ class PolygonAperture(Aperture):
|
|||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||
|
||||
def to_macro(self):
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return GenericMacros.polygon(self.n_vertices,
|
||||
MM(self.diameter, self.unit),
|
||||
MM(self.hole_dia, self.unit),
|
||||
self.rotation)
|
||||
return ApertureMacroInstance(GenericMacros.polygon, self._params(MM))
|
||||
|
||||
def _params(self, unit=None):
|
||||
rotation = self.rotation % (2*math.pi / self.n_vertices)
|
||||
if math.isclose(rotation, 0, abs_tol=1e-6):
|
||||
rotation = None
|
||||
else:
|
||||
rotation = math.degrees(rotation)
|
||||
|
||||
if self.hole_dia is not None:
|
||||
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia)
|
||||
|
|
@ -452,17 +445,9 @@ class ApertureMacroInstance(Aperture):
|
|||
def scaled(self, 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):
|
||||
# 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.
|
||||
parameters = self.parameters
|
||||
if len(parameters) > self.macro.num_parameters:
|
||||
warnings.warn(f'Aperture definition using macro {self.macro.name} has more parameters than the macro uses.')
|
||||
parameters = parameters[:self.macro.num_parameters]
|
||||
return tuple(parameters)
|
||||
return tuple(self.parameters)
|
||||
|
||||
|
||||
|
|
@ -9,8 +9,7 @@ from itertools import cycle
|
|||
from .sexp import *
|
||||
from .sexp_mapper import *
|
||||
from ...newstroke import Newstroke
|
||||
from ...utils import rotate_point, sum_bounds, Tag, MM
|
||||
from ...layers import LayerStack
|
||||
from ...utils import rotate_point, Tag, MM
|
||||
from ... import apertures as ap
|
||||
from ... import graphic_objects as go
|
||||
|
||||
|
|
@ -38,40 +37,11 @@ LAYER_MAP_K2G = {
|
|||
LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()}
|
||||
|
||||
|
||||
class BBoxMixin:
|
||||
def bounding_box(self, unit=MM):
|
||||
if not hasattr(self, '_bounding_box'):
|
||||
(min_x, min_y), (max_x, max_y) = sum_bounds(fe.bounding_box(unit) for fe in self.render())
|
||||
# Convert back from gerbonara's coordinates to kicad coordinates.
|
||||
self._bounding_box = (min_x, -max_y), (max_x, -min_y)
|
||||
|
||||
return self._bounding_box
|
||||
|
||||
|
||||
@sexp_type('uuid')
|
||||
class UUID:
|
||||
value: str = field(default_factory=uuid.uuid4)
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
return UUID()
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.value = str(self.value)
|
||||
|
||||
def before_sexp(self):
|
||||
self.value = str(self.value)
|
||||
|
||||
def bump(self):
|
||||
self.value = uuid.uuid4()
|
||||
|
||||
|
||||
@sexp_type('group')
|
||||
class Group:
|
||||
locked: Flag() = False
|
||||
name: str = ""
|
||||
id: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
members: Named(Array(str)) = field(default_factory=list)
|
||||
id: Named(str) = ""
|
||||
members: Named(List(str)) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('color')
|
||||
|
|
@ -124,23 +94,10 @@ class Stroke:
|
|||
return attrs
|
||||
|
||||
|
||||
@sexp_type('fill')
|
||||
class Fill:
|
||||
type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background, Atom.color)) = Atom.none
|
||||
color: Color = None
|
||||
|
||||
|
||||
class WidthMixin:
|
||||
def __post_init__(self):
|
||||
if self.width is not None:
|
||||
self.stroke = Stroke(self.width)
|
||||
|
||||
|
||||
class Dasher:
|
||||
def __init__(self, obj):
|
||||
if obj.stroke:
|
||||
w = obj.stroke.width if obj.stroke.width not in (None, 0, 0.0) else 0.254
|
||||
t = obj.stroke.type
|
||||
w, t = obj.stroke.width or 0.254, obj.stroke.type
|
||||
else:
|
||||
w = obj.width or 0
|
||||
t = Atom.solid
|
||||
|
|
@ -253,20 +210,6 @@ class XYCoord:
|
|||
else:
|
||||
self.x, self.y = x, y
|
||||
|
||||
def __iter__(self):
|
||||
return iter((self.x, self.y))
|
||||
|
||||
def __getitem__(self, index):
|
||||
return (self.x, self.y)[index]
|
||||
|
||||
def __setitem__(self, index, value):
|
||||
if index == 0:
|
||||
self.x = value
|
||||
elif index == 1:
|
||||
self.y = value
|
||||
else:
|
||||
raise IndexError(f'Invalid 2D point coordinate index {index}')
|
||||
|
||||
def within_distance(self, x, y, dist):
|
||||
return math.dist((x, y), (self.x, self.y)) < dist
|
||||
|
||||
|
|
@ -283,14 +226,7 @@ class XYCoord:
|
|||
|
||||
@sexp_type('pts')
|
||||
class PointList:
|
||||
@classmethod
|
||||
def __map__(kls, obj, parent=None, path=''):
|
||||
_tag, *values = obj
|
||||
return [map_sexp(XYCoord, elem, parent=parent, path=path) for elem in values]
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
yield [kls.name_atom, *(e for elem in value for e in elem.__sexp__(elem))]
|
||||
xy : List(XYCoord) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('arc')
|
||||
|
|
@ -303,38 +239,15 @@ class Arc:
|
|||
@sexp_type('pts')
|
||||
class ArcPointList:
|
||||
@classmethod
|
||||
def __map__(kls, obj, parent=None, path=''):
|
||||
def __map__(kls, obj, parent=None):
|
||||
_tag, *values = obj
|
||||
return [map_sexp((XYCoord if elem[0] == 'xy' else Arc), elem, parent=parent, path=path) for elem in values]
|
||||
return [map_sexp((XYCoord if elem[0] == 'xy' else Arc), elem, parent=parent) for elem in values]
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
yield [kls.name_atom, *(e for elem in value for e in elem.__sexp__(elem))]
|
||||
|
||||
|
||||
@sexp_type('net')
|
||||
class Net:
|
||||
index: int = 0
|
||||
name: str = ''
|
||||
|
||||
|
||||
class NetMixin:
|
||||
def reset_net(self):
|
||||
self.net = Net()
|
||||
|
||||
@property
|
||||
def net_index(self):
|
||||
if self.net is None:
|
||||
return 0
|
||||
return self.net.index
|
||||
|
||||
@property
|
||||
def net_name(self):
|
||||
if self.net is None:
|
||||
return ''
|
||||
return self.net.name
|
||||
|
||||
|
||||
@sexp_type('xyz')
|
||||
class XYZCoord:
|
||||
x: float = 0
|
||||
|
|
@ -370,8 +283,8 @@ class FontSpec:
|
|||
face: Named(str) = None
|
||||
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(1.27, 1.27))
|
||||
thickness: Named(float) = None
|
||||
bold: OmitDefault(Named(LegacyCompatibleFlag())) = False
|
||||
italic: OmitDefault(Named(LegacyCompatibleFlag())) = False
|
||||
bold: Flag() = False
|
||||
italic: Flag() = False
|
||||
line_spacing: Named(float) = None
|
||||
|
||||
|
||||
|
|
@ -399,8 +312,8 @@ class Justify:
|
|||
@sexp_type('effects')
|
||||
class TextEffect:
|
||||
font: FontSpec = field(default_factory=FontSpec)
|
||||
hide: Flag() = False
|
||||
justify: OmitDefault(Justify) = field(default_factory=Justify)
|
||||
hide: OmitDefault(Named(LegacyCompatibleFlag())) = False
|
||||
|
||||
|
||||
class TextMixin:
|
||||
|
|
@ -541,6 +454,23 @@ class Timestamp:
|
|||
self.value = uuid.uuid4()
|
||||
|
||||
|
||||
@sexp_type('uuid')
|
||||
class UUID:
|
||||
value: str = field(default_factory=uuid.uuid4)
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
return UUID()
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.value = str(self.value)
|
||||
|
||||
def before_sexp(self):
|
||||
self.value = Atom(str(self.value))
|
||||
|
||||
def bump(self):
|
||||
self.value = uuid.uuid4()
|
||||
|
||||
|
||||
@sexp_type('tedit')
|
||||
class EditTime:
|
||||
value: str = field(default_factory=time.time)
|
||||
|
|
@ -577,13 +507,11 @@ class DrawnProperty(TextMixin):
|
|||
key: str = None
|
||||
value: str = None
|
||||
id: Named(int) = None
|
||||
at: AtPos = None
|
||||
unlocked: OmitDefault(Named(YesNoAtom())) = True
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
layer: Named(str) = None
|
||||
hide: OmitDefault(Named(YesNoAtom())) = False
|
||||
uuid: UUID = None
|
||||
hide: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
effects: OmitDefault(TextEffect) = field(default_factory=TextEffect)
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
_ : SEXP_END = None
|
||||
parent: object = None
|
||||
|
||||
|
|
@ -600,14 +528,6 @@ class DrawnProperty(TextMixin):
|
|||
self.value = value
|
||||
|
||||
|
||||
@sexp_type('chamfer')
|
||||
class Chamfer:
|
||||
top_left: Flag() = False
|
||||
top_right: Flag() = False
|
||||
bottom_left: Flag() = False
|
||||
bottom_right: Flag() = False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
class Foo:
|
||||
pass
|
||||
|
|
@ -21,7 +21,6 @@ from . import graphical_primitives as gr
|
|||
|
||||
from ..primitives import Positioned
|
||||
|
||||
from ... import __version__
|
||||
from ... import graphic_primitives as gp
|
||||
from ... import graphic_objects as go
|
||||
from ... import apertures as ap
|
||||
|
|
@ -55,9 +54,8 @@ class Text:
|
|||
type: AtomChoice(Atom.reference, Atom.value, Atom.user) = Atom.user
|
||||
text: str = ""
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
unlocked: OmitDefault(Named(YesNoAtom())) = False
|
||||
unlocked: Flag() = False
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
hide: Flag() = False
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
tstamp: Timestamp = None
|
||||
|
|
@ -74,15 +72,12 @@ class TextBox:
|
|||
locked: Flag() = False
|
||||
text: str = None
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
margins: Rename(gr.Margins) = None
|
||||
end: Named(XYCoord) = None
|
||||
pts: PointList = None
|
||||
angle: Named(float) = 0.0
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
border: Named(YesNoAtom()) = False
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
render_cache: RenderCache = None
|
||||
|
||||
|
|
@ -95,7 +90,6 @@ class Line:
|
|||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = None
|
||||
locked: Flag() = False
|
||||
|
|
@ -119,10 +113,9 @@ class Rectangle:
|
|||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = None
|
||||
fill: gr.FillMode = None
|
||||
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
|
||||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
|
|
@ -153,10 +146,9 @@ class Circle:
|
|||
center: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = None
|
||||
fill: gr.FillMode = None
|
||||
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
|
||||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
|
|
@ -190,10 +182,8 @@ class Arc:
|
|||
mid: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
width: Named(float) = None
|
||||
angle: Named(float) = None
|
||||
stroke: Stroke = None
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
|
|
@ -230,7 +220,7 @@ class Arc:
|
|||
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.
|
||||
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:
|
||||
yield arc
|
||||
|
||||
|
|
@ -245,39 +235,36 @@ class Arc:
|
|||
|
||||
@sexp_type('fp_poly')
|
||||
class Polygon:
|
||||
pts: PointList = field(default_factory=list)
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = None
|
||||
fill: gr.FillMode = None
|
||||
fill: Named(AtomChoice(Atom.solid, Atom.none)) = None
|
||||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None, cache=None):
|
||||
if len(self.pts) < 2:
|
||||
if len(self.pts.xy) < 2:
|
||||
return
|
||||
|
||||
dasher = Dasher(self)
|
||||
start = self.pts[0]
|
||||
dasher.move(start.x, start.y)
|
||||
for point in self.pts[1:]:
|
||||
start = self.pts.xy[0]
|
||||
dasher.move(start.x, -start.y)
|
||||
for point in self.pts.xy[1:]:
|
||||
dasher.line(point.x, point.y)
|
||||
|
||||
if dasher.width > 0:
|
||||
aperture = ap.CircleAperture(dasher.width, unit=MM)
|
||||
for x1, y1, x2, y2 in dasher:
|
||||
yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM)
|
||||
aperture = ap.CircleAperture(dasher.width, unit=MM)
|
||||
for x1, y1, x2, y2 in dasher:
|
||||
yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM)
|
||||
|
||||
if self.fill == Atom.solid:
|
||||
yield go.Region([(pt.x, -pt.y) for pt in self.pts], unit=MM)
|
||||
yield go.Region([(pt.x, -pt.y) for pt in self.pts.xy], unit=MM)
|
||||
|
||||
|
||||
@sexp_type('fp_curve')
|
||||
class Curve:
|
||||
pts: PointList = field(default_factory=list)
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = None
|
||||
locked: Flag() = False
|
||||
|
|
@ -287,6 +274,46 @@ class Curve:
|
|||
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
|
||||
|
||||
|
||||
@sexp_type('format')
|
||||
class DimensionFormat:
|
||||
prefix: Named(str) = None
|
||||
suffix: Named(str) = None
|
||||
units: Named(int) = 3
|
||||
units_format: Named(int) = 0
|
||||
precision: Named(int) = 3
|
||||
override_value: Named(str) = None
|
||||
suppress_zeros: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('style')
|
||||
class DimensionStyle:
|
||||
thickness: Named(float) = None
|
||||
arrow_length: Named(float) = None
|
||||
text_position_mode: Named(int) = 0
|
||||
extension_height: Named(float) = None
|
||||
text_frame: Named(int) = 0
|
||||
extension_offset: Named(str) = None
|
||||
keep_text_aligned: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('dimension')
|
||||
class Dimension:
|
||||
locked: Flag() = False
|
||||
type: AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial) = None
|
||||
layer: Named(str) = None
|
||||
tstamp: Timestamp = None
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
height: Named(float) = None
|
||||
orientation: Named(int) = 0
|
||||
leader_length: Named(float) = None
|
||||
gr_text: Named(Text) = None
|
||||
format: DimensionFormat = field(default_factory=DimensionFormat)
|
||||
style: DimensionStyle = field(default_factory=DimensionStyle)
|
||||
|
||||
def render(self, variables=None, cache=None):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@sexp_type('drill')
|
||||
class Drill:
|
||||
oval: Flag() = False
|
||||
|
|
@ -295,6 +322,12 @@ class Drill:
|
|||
offset: Rename(XYCoord) = None
|
||||
|
||||
|
||||
@sexp_type('net')
|
||||
class NetDef:
|
||||
number: int = None
|
||||
name: str = None
|
||||
|
||||
|
||||
@sexp_type('options')
|
||||
class CustomPadOptions:
|
||||
clearance: Named(AtomChoice(Atom.outline, Atom.convexhull)) = Atom.outline
|
||||
|
|
@ -311,7 +344,7 @@ class CustomPadPrimitives:
|
|||
polygons: List(gr.Polygon) = field(default_factory=list)
|
||||
curves: List(gr.Curve) = field(default_factory=list)
|
||||
width: Named(float) = None
|
||||
fill: gr.FillMode = True
|
||||
fill: Named(YesNoAtom()) = True
|
||||
|
||||
def all(self):
|
||||
yield from self.lines
|
||||
|
|
@ -322,8 +355,16 @@ class CustomPadPrimitives:
|
|||
yield from self.curves
|
||||
|
||||
|
||||
@sexp_type('chamfer')
|
||||
class Chamfer:
|
||||
top_left: Flag() = False
|
||||
top_right: Flag() = False
|
||||
bottom_left: Flag() = False
|
||||
bottom_right: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('pad')
|
||||
class Pad(NetMixin):
|
||||
class Pad:
|
||||
number: str = None
|
||||
type: AtomChoice(Atom.thru_hole, Atom.smd, Atom.connect, Atom.np_thru_hole) = None
|
||||
shape: AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom) = None
|
||||
|
|
@ -333,22 +374,19 @@ class Pad(NetMixin):
|
|||
drill: Drill = None
|
||||
layers: Named(Array(str)) = field(default_factory=list)
|
||||
properties: List(Property) = field(default_factory=list)
|
||||
remove_unused_layers: Named(YesNoAtom()) = False
|
||||
keep_end_layers: Named(YesNoAtom()) = False
|
||||
zone_layer_connections: Named(Array(str)) = field(default_factory=list)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
remove_unused_layers: Wrap(Flag()) = False
|
||||
keep_end_layers: Wrap(Flag()) = False
|
||||
rect_delta: Rename(XYCoord) = None
|
||||
roundrect_rratio: Named(float) = None
|
||||
thermal_bridge_angle: Named(int) = 45
|
||||
thermal_bridge_width: Named(float) = 0.5
|
||||
chamfer_ratio: Named(float) = None
|
||||
chamfer: Chamfer = None
|
||||
net: Net = None
|
||||
net: NetDef = None
|
||||
tstamp: Timestamp = None
|
||||
pin_function: Named(str) = None
|
||||
pintype: Named(str) = None
|
||||
pinfunction: Named(str) = None
|
||||
teardrops: gr.TeardropSpec = None
|
||||
die_length: Named(float) = None
|
||||
solder_mask_margin: Named(float) = None
|
||||
solder_paste_margin: Named(float) = None
|
||||
|
|
@ -358,10 +396,9 @@ class Pad(NetMixin):
|
|||
thermal_width: Named(float) = None
|
||||
thermal_gap: Named(float) = None
|
||||
options: OmitDefault(CustomPadOptions) = None
|
||||
padstack: gr.PadStack = None
|
||||
primitives: OmitDefault(CustomPadPrimitives) = None
|
||||
_: SEXP_END = None
|
||||
footprint: object = field(repr=False, default=None)
|
||||
footprint: object = None
|
||||
|
||||
def __after_parse__(self, parent=None):
|
||||
self.layers = unfuck_layers(self.layers)
|
||||
|
|
@ -423,16 +460,16 @@ class Pad(NetMixin):
|
|||
|
||||
elif self.shape == Atom.rect:
|
||||
if margin > 0:
|
||||
return GenericMacros.rounded_rect(self.size.x+2*margin,
|
||||
self.size.y+2*margin,
|
||||
margin,
|
||||
0, # no hole
|
||||
rotation)
|
||||
return ap.ApertureMacroInstance(GenericMacros.rounded_rect,
|
||||
(self.size.x+2*margin, self.size.y+2*margin,
|
||||
margin,
|
||||
0, 0, # no hole
|
||||
rotation), unit=MM)
|
||||
else:
|
||||
return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation)
|
||||
return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(rotation)
|
||||
|
||||
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:
|
||||
# KiCad's trapezoid aperture "rect_delta" param is just weird to the point that I think it's probably
|
||||
|
|
@ -454,31 +491,30 @@ class Pad(NetMixin):
|
|||
# Note: KiCad already uses MM units, so no conversion needed here.
|
||||
|
||||
alpha = math.atan(y / dy) if dy > 0 else 0
|
||||
return GenericMacros.isosceles_trapezoid(x+dy+2*margin*math.cos(alpha),
|
||||
y+2*margin,
|
||||
2*dy,
|
||||
0, # no hole
|
||||
-rotation + math.pi)
|
||||
return ap.ApertureMacroInstance(GenericMacros.isosceles_trapezoid,
|
||||
(x+dy+2*margin*math.cos(alpha), y+2*margin,
|
||||
2*dy,
|
||||
0, 0, # no hole
|
||||
rotation), unit=MM)
|
||||
|
||||
else:
|
||||
return GenericMacros.rounded_isosceles_trapezoid(x+dy,
|
||||
y,
|
||||
2*dy,
|
||||
margin,
|
||||
0, # no hole
|
||||
-rotation + math.pi)
|
||||
return ap.ApertureMacroInstance(GenericMacros.rounded_isosceles_trapezoid,
|
||||
(x+dy, y,
|
||||
2*dy, margin,
|
||||
0, 0, # no hole
|
||||
rotation), unit=MM)
|
||||
|
||||
elif self.shape == Atom.roundrect:
|
||||
x, y = self.size.x, self.size.y
|
||||
r = min(x, y) * self.roundrect_rratio
|
||||
if margin > -r:
|
||||
return GenericMacros.rounded_rect(x+2*margin,
|
||||
y+2*margin,
|
||||
r+margin,
|
||||
0, # no hole
|
||||
rotation)
|
||||
return ap.ApertureMacroInstance(GenericMacros.rounded_rect,
|
||||
(x+2*margin, y+2*margin,
|
||||
r+margin,
|
||||
0, 0, # no hole
|
||||
rotation), unit=MM)
|
||||
else:
|
||||
return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(-rotation)
|
||||
return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(rotation)
|
||||
|
||||
elif self.shape == Atom.custom:
|
||||
primitives = []
|
||||
|
|
@ -520,7 +556,7 @@ class Pad(NetMixin):
|
|||
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))
|
||||
|
||||
macro = ApertureMacro(primitives=tuple(primitives)).rotated(-rotation)
|
||||
macro = ApertureMacro(primitives=tuple(primitives)).rotated(rotation)
|
||||
return ap.ApertureMacroInstance(macro, unit=MM)
|
||||
|
||||
def render_drill(self):
|
||||
|
|
@ -558,27 +594,18 @@ class Pad(NetMixin):
|
|||
@sexp_type('model')
|
||||
class Model:
|
||||
name: str = ''
|
||||
hide: Flag() = False
|
||||
at: Named(XYZCoord) = field(default_factory=XYZCoord)
|
||||
offset: Named(XYZCoord) = field(default_factory=XYZCoord)
|
||||
opacity: Named(float) = None
|
||||
scale: Named(XYZCoord) = field(default_factory=XYZCoord)
|
||||
rotate: Named(XYZCoord) = field(default_factory=XYZCoord)
|
||||
|
||||
|
||||
@sexp_type('component_classes')
|
||||
class FootprintComponentClasses:
|
||||
classes: List(Named(str, name='class')) = field(default_factory=list)
|
||||
|
||||
|
||||
SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
|
||||
@sexp_type('footprint')
|
||||
class Footprint:
|
||||
name: str = None
|
||||
_version: Named(int, name='version') = 20221018
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
generator: Named(str) = Atom.gerbonara
|
||||
generator_version: Named(str) = __version__
|
||||
generator: Named(Atom) = Atom.gerbonara
|
||||
locked: Flag() = False
|
||||
placed: Flag() = False
|
||||
layer: Named(str) = 'F.Cu'
|
||||
|
|
@ -588,14 +615,12 @@ class Footprint:
|
|||
descr: Named(str) = None
|
||||
tags: Named(str) = None
|
||||
properties: List(DrawnProperty) = field(default_factory=list)
|
||||
component_classes: FootprintComponentClasses = None
|
||||
path: Named(str) = None
|
||||
sheetname: Named(str) = None
|
||||
sheetfile: Named(str) = None
|
||||
autoplace_cost90: Named(float) = None
|
||||
autoplace_cost180: Named(float) = None
|
||||
solder_mask_margin: Named(float) = None
|
||||
solder_paste_margin_ratio: Named(float) = None
|
||||
solder_paste_margin: Named(float) = None
|
||||
solder_paste_ratio: Named(float) = None
|
||||
clearance: Named(float) = None
|
||||
|
|
@ -613,15 +638,15 @@ class Footprint:
|
|||
arcs: List(Arc) = field(default_factory=list)
|
||||
polygons: List(Polygon) = field(default_factory=list)
|
||||
curves: List(Curve) = field(default_factory=list)
|
||||
dimensions: List(gr.Dimension) = field(default_factory=list)
|
||||
dimensions: List(Dimension) = field(default_factory=list)
|
||||
pads: List(Pad) = field(default_factory=list)
|
||||
zones: List(Zone) = field(default_factory=list)
|
||||
groups: List(Group) = field(default_factory=list)
|
||||
embedded_fonts: Named(YesNoAtom()) = False
|
||||
models: List(Model) = field(default_factory=list)
|
||||
_ : SEXP_END = None
|
||||
original_filename: str = None
|
||||
board: object = field(repr=False, default=None)
|
||||
_bounding_box: tuple = None
|
||||
board: object = None
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
for pad in self.pads:
|
||||
|
|
@ -668,10 +693,6 @@ class Footprint:
|
|||
if not self.property_value('Description', None):
|
||||
self.set_property('Description', self.descr or '', 0, 0, 0)
|
||||
|
||||
def reset_nets(self):
|
||||
for pad in self.pads:
|
||||
pad.reset_net()
|
||||
|
||||
@property
|
||||
def pads_by_number(self):
|
||||
return {(int(pad.number) if pad.number.isnumeric() else pad.number): pad for pad in self.pads if pad.number}
|
||||
|
|
@ -698,14 +719,6 @@ class Footprint:
|
|||
def offset(self, x=0, y=0):
|
||||
self.at = self.at.with_offset(x, y)
|
||||
|
||||
def copy_placement(self, template):
|
||||
# Fix up rotation of pads - KiCad saves each pad's rotation in *absolute* coordinates, not relative to the
|
||||
# footprint. Because we overwrite the footprint's rotation below, we have to first fix all pads to match the
|
||||
# new rotation.
|
||||
self.rotate(math.radians(template.at.rotation - self.at.rotation))
|
||||
self.at = copy.copy(template.at)
|
||||
self.side = template.side
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return self._version
|
||||
|
|
@ -790,7 +803,7 @@ class Footprint:
|
|||
|
||||
self.layer = flip_layer(self.layer)
|
||||
for obj in self.objects():
|
||||
if getattr(obj, 'layer', None) is not None:
|
||||
if hasattr(obj, 'layer'):
|
||||
obj.layer = flip_layer(obj.layer)
|
||||
|
||||
if hasattr(obj, 'layers'):
|
||||
|
|
@ -800,9 +813,8 @@ class Footprint:
|
|||
obj.effects.justify.mirror = not obj.effects.justify.mirror
|
||||
|
||||
for obj in self.properties:
|
||||
if obj.layer is not None:
|
||||
obj.effects.justify.mirror = not obj.effects.justify.mirror
|
||||
obj.layer = flip_layer(obj.layer)
|
||||
obj.effects.justify.mirror = not obj.effects.justify.mirror
|
||||
obj.layer = flip_layer(obj.layer)
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
|
|
@ -841,20 +853,19 @@ class Footprint:
|
|||
around the given coordinates in the global coordinate space. Otherwise rotate around the footprint's origin. """
|
||||
if (cx, cy) != (None, None):
|
||||
x, y = self.at.x-cx, self.at.y-cy
|
||||
self.at.x = math.cos(-angle)*x - math.sin(-angle)*y + cx
|
||||
self.at.y = math.sin(-angle)*x + math.cos(-angle)*y + cy
|
||||
self.at.x = math.cos(angle)*x - math.sin(angle)*y + cx
|
||||
self.at.y = math.sin(angle)*x + math.cos(angle)*y + cy
|
||||
|
||||
self.at.rotation = (self.at.rotation + math.degrees(angle)) % 360
|
||||
self.at.rotation = (self.at.rotation - math.degrees(angle)) % 360
|
||||
|
||||
for pad in self.pads:
|
||||
pad.at.rotation = (pad.at.rotation + math.degrees(angle)) % 360
|
||||
pad.at.rotation = (pad.at.rotation - math.degrees(angle)) % 360
|
||||
|
||||
for prop in self.properties:
|
||||
if prop.at is not None:
|
||||
prop.at.rotation = (prop.at.rotation + math.degrees(angle)) % 360
|
||||
prop.at.rotation = (prop.at.rotation - math.degrees(angle)) % 360
|
||||
|
||||
for text in self.texts:
|
||||
text.at.rotation = (text.at.rotation + math.degrees(angle)) % 360
|
||||
text.at.rotation = (text.at.rotation - math.degrees(angle)) % 360
|
||||
|
||||
def set_rotation(self, angle):
|
||||
old_deg = self.at.rotation
|
||||
|
|
@ -865,13 +876,12 @@ class Footprint:
|
|||
pad.at.rotation = (pad.at.rotation + delta) % 360
|
||||
|
||||
for prop in self.properties:
|
||||
if prop.at is not None:
|
||||
prop.at.rotation = (prop.at.rotation + delta) % 360
|
||||
prop.at.rotation = (prop.at.rotation + delta) % 360
|
||||
|
||||
for text in self.texts:
|
||||
text.at.rotation = (text.at.rotation + delta) % 360
|
||||
|
||||
def objects(self, text=False, pads=True, groups=True, zones=True):
|
||||
def objects(self, text=False, pads=True, groups=True):
|
||||
return chain(
|
||||
(self.texts if text else []),
|
||||
(self.text_boxes if text else []),
|
||||
|
|
@ -883,24 +893,21 @@ class Footprint:
|
|||
self.curves,
|
||||
(self.dimensions if text else []),
|
||||
(self.pads if pads else []),
|
||||
(self.zones if zones else []),
|
||||
self.zones,
|
||||
self.groups if groups else [])
|
||||
|
||||
def render(self, layer_stack, layer_map=None, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None):
|
||||
def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None):
|
||||
x += self.at.x
|
||||
y += self.at.y
|
||||
rotation += math.radians(self.at.rotation)
|
||||
|
||||
if layer_map is None:
|
||||
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in layer_stack}
|
||||
|
||||
for obj in self.objects(pads=False, text=text, zones=False, groups=False):
|
||||
for obj in self.objects(pads=False, text=text):
|
||||
if not (layer := layer_map.get(obj.layer)):
|
||||
continue
|
||||
|
||||
for fe in obj.render(variables=variables):
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, y, MM)
|
||||
fe.offset(x, -y, MM)
|
||||
layer_stack[layer].objects.append(fe)
|
||||
|
||||
for obj in self.pads:
|
||||
|
|
@ -932,7 +939,7 @@ class Footprint:
|
|||
|
||||
for fe in obj.render(margin=margin, cache=cache):
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, y, MM)
|
||||
fe.offset(x, -y, MM)
|
||||
if isinstance(fe, go.Flash) and fe.aperture:
|
||||
fe.aperture = fe.aperture.rotated(rotation)
|
||||
layer_stack[layer_map[layer]].objects.append(fe)
|
||||
|
|
@ -940,7 +947,7 @@ class Footprint:
|
|||
for obj in self.pads:
|
||||
for fe in obj.render_drill():
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, y, MM)
|
||||
fe.offset(x, -y, MM)
|
||||
|
||||
if obj.type == Atom.np_thru_hole:
|
||||
layer_stack.drill_npth.append(fe)
|
||||
|
|
@ -948,9 +955,10 @@ class Footprint:
|
|||
layer_stack.drill_pth.append(fe)
|
||||
|
||||
def bounding_box(self, unit=MM):
|
||||
if not hasattr(self, '_bounding_box'):
|
||||
if not self._bounding_box:
|
||||
stack = LayerStack()
|
||||
self.render(stack, layer_map=None, x=0, y=0, rotation=0, flip=False, text=False, variables={})
|
||||
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in stack}
|
||||
self.render(stack, layer_map, x=0, y=0, rotation=0, flip=False, text=False, variables={})
|
||||
self._bounding_box = stack.bounding_box(unit)
|
||||
return self._bounding_box
|
||||
|
||||
|
|
@ -975,7 +983,9 @@ class FootprintInstance(Positioned):
|
|||
if self.value is not None:
|
||||
variables['VALUE'] = str(self.value)
|
||||
|
||||
self.sexp.render(layer_stack, layer_map=None,
|
||||
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in layer_stack}
|
||||
|
||||
self.sexp.render(layer_stack, layer_map,
|
||||
x=x, y=y, rotation=rotation,
|
||||
flip=flip,
|
||||
text=(not self.hide_text),
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
|
||||
import string
|
||||
import math
|
||||
import base64
|
||||
import textwrap
|
||||
|
||||
from .sexp import *
|
||||
from .base_types import *
|
||||
|
|
@ -11,7 +9,7 @@ from .primitives import *
|
|||
from ... import graphic_objects as go
|
||||
from ... import apertures as ap
|
||||
from ...newstroke import Newstroke
|
||||
from ...utils import rotate_point, MM, arc_bounds
|
||||
from ...utils import rotate_point, MM
|
||||
|
||||
@sexp_type('layer')
|
||||
class TextLayer:
|
||||
|
|
@ -20,12 +18,10 @@ class TextLayer:
|
|||
|
||||
|
||||
@sexp_type('gr_text')
|
||||
class Text(TextMixin, BBoxMixin):
|
||||
locked: Flag() = False
|
||||
class Text(TextMixin):
|
||||
text: str = ''
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
layer: TextLayer = field(default_factory=TextLayer)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
render_cache: RenderCache = None
|
||||
|
|
@ -35,19 +31,16 @@ class Text(TextMixin, BBoxMixin):
|
|||
|
||||
|
||||
@sexp_type('gr_text_box')
|
||||
class TextBox(BBoxMixin):
|
||||
class TextBox:
|
||||
locked: Flag() = False
|
||||
text: str = ''
|
||||
start: Named(XYCoord) = None
|
||||
end: Named(XYCoord) = None
|
||||
margins: Margins = None
|
||||
pts: PointList = field(default_factory=list)
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
angle: OmitDefault(Named(float)) = 0.0
|
||||
layer: Named(str) = ""
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
border: Named(YesNoAtom()) = False
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
render_cache: RenderCache = None
|
||||
|
||||
|
|
@ -60,7 +53,7 @@ class TextBox(BBoxMixin):
|
|||
raise ValueError('Vector font text with empty render cache')
|
||||
|
||||
for poly in render_cache.polygons:
|
||||
reg = go.Region([(p.x, -p.y) for p in poly.pts], unit=MM)
|
||||
reg = go.Region([(p.x, -p.y) for p in poly.pts.xy], unit=MM)
|
||||
|
||||
if self.stroke:
|
||||
if self.stroke.type not in (None, Atom.default, Atom.solid):
|
||||
|
|
@ -76,15 +69,13 @@ class TextBox(BBoxMixin):
|
|||
|
||||
|
||||
@sexp_type('gr_line')
|
||||
class Line(WidthMixin):
|
||||
locked: Flag() = False
|
||||
class Line:
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
angle: Named(float) = None # wat
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def rotate(self, angle, cx=None, cy=None):
|
||||
|
|
@ -107,26 +98,6 @@ class Line(WidthMixin):
|
|||
self.start = self.start.with_offset(x, y)
|
||||
self.end = self.end.with_offset(x, y)
|
||||
|
||||
def bounding_box(self, unit=MM):
|
||||
x_min, x_max = min(self.start.x, self.end.x), max(self.start.x, self.end.x)
|
||||
y_min, y_max = min(self.start.y, self.end.y), max(self.start.y, self.end.y)
|
||||
w = self.stroke.width if self.stroke else self.width
|
||||
return (x_min-w, y_max-w), (x_max+w, y_max+w)
|
||||
|
||||
|
||||
@sexp_type('target')
|
||||
class Target(WidthMixin):
|
||||
shape: AtomChoice(Atom.x, Atom.plus) = 'plus'
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
size: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
width: Named(float) = None
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None):
|
||||
raise NotImplementedError('Target objects are not implemented yet')
|
||||
|
||||
|
||||
@sexp_type('fill')
|
||||
class FillMode:
|
||||
|
|
@ -134,7 +105,7 @@ class FillMode:
|
|||
fill: AtomChoice(Atom.solid, Atom.yes, Atom.no, Atom.none) = False
|
||||
|
||||
@classmethod
|
||||
def __map__(kls, obj, parent=None, path=''):
|
||||
def __map__(kls, obj, parent=None):
|
||||
return obj[1] in (Atom.solid, Atom.yes)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -142,15 +113,13 @@ class FillMode:
|
|||
yield [Atom.fill, Atom.solid if value else Atom.none]
|
||||
|
||||
@sexp_type('gr_rect')
|
||||
class Rectangle(BBoxMixin, WidthMixin):
|
||||
locked: Flag() = False
|
||||
class Rectangle:
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: FillMode = False
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None):
|
||||
|
|
@ -161,9 +130,9 @@ class Rectangle(BBoxMixin, WidthMixin):
|
|||
if self.fill:
|
||||
yield rect
|
||||
|
||||
if (w := self.stroke.width if self.stroke else self.width):
|
||||
if self.width:
|
||||
# FIXME stroke support
|
||||
yield from rect.outline_objects(aperture=ap.CircleAperture(w, unit=MM))
|
||||
yield from rect.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM))
|
||||
|
||||
@property
|
||||
def top_left(self):
|
||||
|
|
@ -176,24 +145,21 @@ class Rectangle(BBoxMixin, WidthMixin):
|
|||
|
||||
|
||||
@sexp_type('gr_circle')
|
||||
class Circle(BBoxMixin, WidthMixin):
|
||||
locked: Flag() = False
|
||||
class Circle:
|
||||
center: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: FillMode = False
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None):
|
||||
r = math.dist((self.center.x, -self.center.y), (self.end.x, -self.end.y))
|
||||
w = self.stroke.width if self.stroke else self.width
|
||||
aperture = ap.CircleAperture(w or 0, unit=MM)
|
||||
aperture = ap.CircleAperture(self.width or 0, unit=MM)
|
||||
arc = go.Arc.from_circle(self.center.x, -self.center.y, r, aperture=aperture, unit=MM)
|
||||
|
||||
if w:
|
||||
if self.width:
|
||||
# FIXME stroke support
|
||||
yield arc
|
||||
|
||||
|
|
@ -204,22 +170,15 @@ class Circle(BBoxMixin, WidthMixin):
|
|||
self.center = self.center.with_offset(x, y)
|
||||
self.end = self.end.with_offset(x, y)
|
||||
|
||||
def rotate(self, angle, cx=0, cy=0):
|
||||
self.center = self.center.with_rotation(angle, cx, cy)
|
||||
self.end = self.end.with_rotation(angle, cx, cy)
|
||||
|
||||
|
||||
@sexp_type('gr_arc')
|
||||
class Arc(WidthMixin, BBoxMixin):
|
||||
locked: Flag() = False
|
||||
class Arc:
|
||||
start: Rename(XYCoord) = None
|
||||
mid: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
angle: Named(float) = None
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
_: SEXP_END = None
|
||||
center: XYCoord = None
|
||||
|
|
@ -233,94 +192,69 @@ class Arc(WidthMixin, BBoxMixin):
|
|||
self.mid = center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
|
||||
self.center = None
|
||||
|
||||
def rotate(self, angle, cx=None, cy=None):
|
||||
self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
|
||||
self.mid.x, self.mid.y = rotate_point(self.mid.x, self.mid.y, angle, cx, cy)
|
||||
self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy)
|
||||
|
||||
def render(self, variables=None):
|
||||
if not (w := self.stroke.width if self.stroke else self.width):
|
||||
# FIXME stroke support
|
||||
if not self.width:
|
||||
return
|
||||
|
||||
aperture = ap.CircleAperture(w, unit=MM)
|
||||
aperture = ap.CircleAperture(self.width, unit=MM)
|
||||
cx, cy = self.mid.x, self.mid.y
|
||||
x1, y1 = self.start.x, self.start.y
|
||||
x2, y2 = self.end.x, self.end.y
|
||||
(cx, cy), _r, clockwise = kicad_mid_to_center_arc(self.mid, self.start, self.end)
|
||||
yield go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), aperture=aperture, clockwise=not clockwise, unit=MM)
|
||||
yield go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), aperture=aperture, clockwise=True, unit=MM)
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.start = self.start.with_offset(x, y)
|
||||
self.mid = self.mid.with_offset(x, y)
|
||||
self.end = self.end.with_offset(x, y)
|
||||
|
||||
def rotate(self, angle, cx=None, cy=None):
|
||||
self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
|
||||
self.mid.x, self.mid.y = rotate_point(self.mid.x, self.mid.y, angle, cx, cy)
|
||||
self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy)
|
||||
|
||||
|
||||
@sexp_type('gr_poly')
|
||||
class Polygon(BBoxMixin, WidthMixin):
|
||||
class Polygon:
|
||||
pts: ArcPointList = field(default_factory=list)
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: FillMode = True
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None):
|
||||
points = []
|
||||
centers = []
|
||||
for point_or_arc in self.pts:
|
||||
if points:
|
||||
centers.append((None, (None, None)))
|
||||
|
||||
if isinstance(point_or_arc, XYCoord):
|
||||
points.append((point_or_arc.x, -point_or_arc.y))
|
||||
|
||||
else: # base_types.Arc
|
||||
points.append((point_or_arc.start.x, -point_or_arc.start.y))
|
||||
points.append((point_or_arc.end.x, -point_or_arc.end.y))
|
||||
(cx, cy), _r, clockwise = kicad_mid_to_center_arc(point_or_arc.mid, point_or_arc.start, point_or_arc.end)
|
||||
centers.append((not clockwise, (cx, -cy)))
|
||||
|
||||
reg = go.Region(points, centers, unit=MM)
|
||||
reg.close()
|
||||
reg = go.Region([(pt.x, -pt.y) for pt in self.pts.xy], unit=MM)
|
||||
|
||||
w = self.stroke.width if self.stroke else self.width
|
||||
# FIXME stroke support
|
||||
if w and w >= 0.005:
|
||||
yield from reg.outline_objects(aperture=ap.CircleAperture(w, unit=MM))
|
||||
if self.width and self.width >= 0.005 or self.stroke.width and self.stroke.width > 0.005:
|
||||
yield from reg.outline_objects(aperture=ap.CircleAperture(self.width, unit=MM))
|
||||
|
||||
if self.fill:
|
||||
yield reg
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.pts = [pt.with_offset(x, y) for pt in self.pts]
|
||||
|
||||
def rotate(self, angle, cx=0, cy=0):
|
||||
self.pts = [pt.with_rotation(angle, cx, cy) for pt in self.pts]
|
||||
self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
|
||||
|
||||
|
||||
@sexp_type('gr_curve')
|
||||
class Curve(BBoxMixin, WidthMixin):
|
||||
locked: Flag() = False
|
||||
pts: PointList = field(default_factory=list)
|
||||
class Curve:
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None):
|
||||
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.pts =[pt.with_offset(x, y) for pt in self.pts]
|
||||
self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
|
||||
|
||||
|
||||
@sexp_type('gr_bbox')
|
||||
class AnnotationBBox:
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
width: Named(float) = None
|
||||
fill: FillMode = False
|
||||
|
||||
def render(self, variables=None):
|
||||
return []
|
||||
|
|
@ -339,7 +273,6 @@ class DimensionFormat:
|
|||
precision: Named(int) = 7
|
||||
override_value: Named(str) = None
|
||||
suppress_zeros: Flag() = False
|
||||
suppress_zeroes: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('style')
|
||||
|
|
@ -347,36 +280,19 @@ class DimensionStyle:
|
|||
thickness: Named(float) = 0.1
|
||||
arrow_length: Named(float) = 1.27
|
||||
text_position_mode: Named(int) = 0
|
||||
arrow_direction: Named(AtomChoice(Atom.inward, Atom.outward)) = None
|
||||
extension_height: Named(float) = None
|
||||
text_frame: Named(float) = None
|
||||
extension_offset: Named(float) = None
|
||||
keep_text_aligned: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('data')
|
||||
class Base64Blob:
|
||||
@classmethod
|
||||
def __map__(kls, obj, parent=None, path=''):
|
||||
_data, *content = obj
|
||||
for x in content[:10]:
|
||||
print(str(x))
|
||||
return base64.b64decode(''.join(map(str, content)))
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
encoded = base64.b64encode(value).decode()
|
||||
yield [Atom.data, *textwrap.wrap(encoded, 76)]
|
||||
|
||||
|
||||
@sexp_type('image')
|
||||
class Image:
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
scale: Named(float) = None
|
||||
layer: Named(str) = None
|
||||
locked: Flag() = False
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
data: Base64Blob = ''
|
||||
data: str = ''
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.at = self.at.with_offset(x, y)
|
||||
|
|
@ -384,15 +300,12 @@ class Image:
|
|||
|
||||
@sexp_type('dimension')
|
||||
class Dimension:
|
||||
value: float = None
|
||||
locked: Flag() = False
|
||||
dimension_type: Named(AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial), name='type') = Atom.aligned
|
||||
layer: Named(str) = 'Dwgs.User'
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = field(default_factory=Timestamp)
|
||||
pts: PointList = field(default_factory=list)
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
height: Named(float) = None
|
||||
width: Named(float) = None
|
||||
orientation: Named(int) = None
|
||||
leader_length: Named(float) = None
|
||||
gr_text: Text = None
|
||||
|
|
@ -403,60 +316,5 @@ class Dimension:
|
|||
raise NotImplementedError('Dimension rendering is not yet supported. Please raise an issue.')
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.pts = [pt.with_offset(x, y) for pt in self.pts]
|
||||
|
||||
|
||||
@sexp_type('options')
|
||||
class PadStackLayerOptions:
|
||||
anchor: AtomChoice(Atom.rect, Atom.circle) = Atom.circle
|
||||
|
||||
|
||||
@sexp_type('primitives')
|
||||
class PadStackPrimitives:
|
||||
vectors: Rename(Line, name='gr_vector') = field(default_factory=list)
|
||||
lines: List(Line) = field(default_factory=list)
|
||||
bboxes: List(AnnotationBBox) = field(default_factory=list)
|
||||
arcs: List(Arc) = field(default_factory=list)
|
||||
circles: List(Circle) = field(default_factory=list)
|
||||
curves: List(Curve) = field(default_factory=list)
|
||||
polygons:List(Polygon) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('layer')
|
||||
class PadStackLayer:
|
||||
layer: str = ''
|
||||
shape: Named(AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom)) = Atom.circle
|
||||
size: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
rect_delta: Rename(XYCoord) = None
|
||||
offset: Rename(XYCoord) = None
|
||||
roundrect_rratio: Named(float) = None
|
||||
chamfer_ratio: Named(float) = None
|
||||
chamfer: Chamfer = None
|
||||
primitives: PadStackPrimitives = None
|
||||
options: PadStackLayerOptions = None
|
||||
thermal_bridge_angle: Named(float) = None
|
||||
thermal_gap: Named(float) = None
|
||||
thermal_bridge_width: Named(float) = None
|
||||
clearance: Named(float) = None
|
||||
zone_connect: Named(int) = None
|
||||
|
||||
|
||||
@sexp_type('padstack')
|
||||
class PadStack:
|
||||
mode: Named(AtomChoice('front_inner_back', 'custom')) = Atom.front_inner_back
|
||||
layers: List(PadStackLayer) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('teardrops')
|
||||
class TeardropSpec:
|
||||
best_length_ratio: Named(float) = 1.0
|
||||
max_length: Named(float) = 2.0
|
||||
best_width_ratio: Named(float) = 1.0
|
||||
max_width: Named(float) = 2.0
|
||||
curve_points: Named(int) = 0
|
||||
filter_ratio: Named(float) = 0.9
|
||||
enabled: Named(YesNoAtom()) = True
|
||||
allow_two_segments: Named(YesNoAtom()) = True
|
||||
prefer_zone_connections: Named(YesNoAtom()) = True
|
||||
|
||||
self.pts = PointList([pt.with_offset(x, y) for pt in self.pts])
|
||||
|
||||
|
|
@ -59,22 +59,13 @@ def gn_layer_to_kicad(layer, flip=False):
|
|||
@sexp_type('general')
|
||||
class GeneralSection:
|
||||
thickness: Named(float) = 1.60
|
||||
legacy_teardrops: Named(YesNoAtom()) = False
|
||||
drawings: Named(int) = None
|
||||
tracks: Named(int) = None
|
||||
zones: Named(int) = None
|
||||
modules: Named(int) = None
|
||||
nets: Named(int) = None
|
||||
links: Named(int) = None
|
||||
no_connects: Named(int) = None
|
||||
area: Named(Array(float)) = None
|
||||
|
||||
|
||||
@sexp_type('layers')
|
||||
class LayerSettings:
|
||||
index: int = 0
|
||||
canonical_name: str = None
|
||||
layer_type: AtomChoice(Atom.jumper, Atom.mixed, Atom.power, Atom.signal, Atom.user, Atom.auxiliary) = Atom.signal
|
||||
layer_type: AtomChoice(Atom.jumper, Atom.mixed, Atom.power, Atom.signal, Atom.user) = Atom.signal
|
||||
custom_name: str = None
|
||||
|
||||
|
||||
|
|
@ -100,29 +91,76 @@ class StackupSettings:
|
|||
castellated_pads: Named(YesNoAtom()) = None
|
||||
edge_plating: Named(YesNoAtom()) = None
|
||||
|
||||
|
||||
TFBool = YesNoAtom(yes=Atom.true, no=Atom.false)
|
||||
|
||||
@sexp_type('pcbplotparams')
|
||||
class ExportSettings:
|
||||
layerselection: Named(Atom) = None
|
||||
plot_on_all_layers_selection: Named(Atom) = None
|
||||
disableapertmacros: Named(TFBool) = False
|
||||
usegerberextensions: Named(TFBool) = True
|
||||
usegerberattributes: Named(TFBool) = True
|
||||
usegerberadvancedattributes: Named(TFBool) = True
|
||||
creategerberjobfile: Named(TFBool) = True
|
||||
dashed_line_dash_ratio: Named(float) = 12.0
|
||||
dashed_line_gap_ratio: Named(float) = 3.0
|
||||
svguseinch: Named(TFBool) = False
|
||||
svgprecision: Named(float) = 4
|
||||
excludeedgelayer: Named(TFBool) = False
|
||||
plotframeref: Named(TFBool) = False
|
||||
viasonmask: Named(TFBool) = False
|
||||
mode: Named(int) = 1
|
||||
useauxorigin: Named(TFBool) = False
|
||||
hpglpennumber: Named(int) = 1
|
||||
hpglpenspeed: Named(int) = 20
|
||||
hpglpendiameter: Named(float) = 15.0
|
||||
pdf_front_fp_property_popups: Named(TFBool) = True
|
||||
pdf_back_fp_property_popups: Named(TFBool) = True
|
||||
dxfpolygonmode: Named(TFBool) = True
|
||||
dxfimperialunits: Named(TFBool) = False
|
||||
dxfusepcbnewfont: Named(TFBool) = True
|
||||
psnegative: Named(TFBool) = False
|
||||
psa4output: Named(TFBool) = False
|
||||
plotreference: Named(TFBool) = True
|
||||
plotvalue: Named(TFBool) = True
|
||||
plotinvisibletext: Named(TFBool) = False
|
||||
sketchpadsonfab: Named(TFBool) = False
|
||||
subtractmaskfromsilk: Named(TFBool) = False
|
||||
outputformat: Named(int) = 1
|
||||
mirror: Named(TFBool) = False
|
||||
drillshape: Named(int) = 0
|
||||
scaleselection: Named(int) = 1
|
||||
outputdirectory: Named(str) = "gerber"
|
||||
|
||||
|
||||
@sexp_type('setup')
|
||||
class BoardSetup:
|
||||
@classmethod
|
||||
def __map__(kls, obj, parent=None, path=''):
|
||||
return obj
|
||||
stackup: OmitDefault(StackupSettings) = field(default_factory=StackupSettings)
|
||||
pad_to_mask_clearance: Named(float) = None
|
||||
solder_mask_min_width: Named(float) = None
|
||||
pad_to_past_clearance: Named(float) = None
|
||||
pad_to_paste_clearance_ratio: Named(float) = None
|
||||
aux_axis_origin: Rename(XYCoord) = None
|
||||
grid_origin: Rename(XYCoord) = None
|
||||
export_settings: ExportSettings = field(default_factory=ExportSettings)
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
yield value
|
||||
|
||||
@sexp_type('net')
|
||||
class Net:
|
||||
index: int = 0
|
||||
name: str = ''
|
||||
|
||||
|
||||
@sexp_type('segment')
|
||||
class TrackSegment(BBoxMixin):
|
||||
class TrackSegment:
|
||||
start: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
end: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
width: Named(float) = 0.5
|
||||
locked: Flag() = False
|
||||
layer: Named(str) = 'F.Cu'
|
||||
extra_layers: Named(Array(str), name='layers') = field(default_factory=list)
|
||||
solder_mask_margin: Named(float) = None
|
||||
locked: Flag() = False
|
||||
net: Named(int) = 0
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
tstamp: Timestamp = field(default_factory=Timestamp)
|
||||
|
||||
@classmethod
|
||||
def from_footprint_line(kls, line, flip=False):
|
||||
|
|
@ -133,15 +171,6 @@ class TrackSegment(BBoxMixin):
|
|||
self.start = XYCoord(self.start)
|
||||
self.end = XYCoord(self.end)
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
if self.extra_layers:
|
||||
self.layer, *self.extra_layers = self.extra_layers
|
||||
|
||||
def __before_sexp__(self):
|
||||
if self.extra_layers:
|
||||
self.extra_layers.insert(0, self.layer)
|
||||
self.layer = None
|
||||
|
||||
@property
|
||||
def layer_mask(self):
|
||||
return layer_mask([self.layer])
|
||||
|
|
@ -166,7 +195,7 @@ class TrackSegment(BBoxMixin):
|
|||
|
||||
|
||||
@sexp_type('arc')
|
||||
class TrackArc(BBoxMixin):
|
||||
class TrackArc:
|
||||
start: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
mid: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
end: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
|
|
@ -174,15 +203,14 @@ class TrackArc(BBoxMixin):
|
|||
layer: Named(str) = 'F.Cu'
|
||||
locked: Flag() = False
|
||||
net: Named(int) = 0
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
tstamp: Timestamp = field(default_factory=Timestamp)
|
||||
_: SEXP_END = None
|
||||
center: XYCoord = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.start = XYCoord(self.start)
|
||||
self.end = XYCoord(self.end)
|
||||
self.mid = XYCoord(self.mid) if self.center is None else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
|
||||
self.mid = XYCoord(self.mid) if self.mid else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
|
||||
self.center = None
|
||||
|
||||
@property
|
||||
|
|
@ -210,31 +238,19 @@ class TrackArc(BBoxMixin):
|
|||
self.end = self.end.with_offset(x, y)
|
||||
|
||||
|
||||
@sexp_type('tenting')
|
||||
class Tenting:
|
||||
front: Flag() = False
|
||||
back: Flag() = False
|
||||
none: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('via')
|
||||
class Via(BBoxMixin):
|
||||
class Via:
|
||||
via_type: AtomChoice(Atom.blind, Atom.micro) = None
|
||||
locked: Flag() = False
|
||||
at: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
size: Named(float) = 0.8
|
||||
drill: Named(float) = 0.4
|
||||
layers: Named(Array(str)) = field(default_factory=lambda: ['F.Cu', 'B.Cu'])
|
||||
teardrops: gr.TeardropSpec = None
|
||||
tenting: Tenting = None
|
||||
padstack: gr.PadStack = None
|
||||
remove_unused_layers: Flag() = False
|
||||
keep_end_layers: Flag() = False
|
||||
free: Named(YesNoAtom()) = False
|
||||
zone_layer_connections: Named(Array(str)) = field(default_factory=list)
|
||||
free: Wrap(Flag()) = False
|
||||
net: Named(int) = 0
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
tstamp: Timestamp = field(default_factory=Timestamp)
|
||||
|
||||
@classmethod
|
||||
def from_pad(kls, pad):
|
||||
|
|
@ -287,70 +303,22 @@ class Via(BBoxMixin):
|
|||
self.at = self.at.with_offset(x, y)
|
||||
|
||||
|
||||
@sexp_type('net_class')
|
||||
class LegacyNetclass:
|
||||
name: str = ''
|
||||
description: str = ''
|
||||
clearance: Named(float) = None
|
||||
trace_width: Named(float) = None
|
||||
via_dia: Named(float) = None
|
||||
via_drill: Named(float) = None
|
||||
uvia_dia: Named(float) = None
|
||||
uvia_drill: Named(float) = None
|
||||
diff_pair_width: Named(float) = None
|
||||
diff_pair_gap: Named(float) = None
|
||||
nets: Rename(List(Named(str)), name='add_net') = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('generated')
|
||||
class GeneratedPatterns:
|
||||
type: Named(Atom) = ''
|
||||
name: Named(str) = ''
|
||||
layer: Named(str) = ''
|
||||
locked: Flag() = False
|
||||
members: Named(Array(Atom), name='members') = field(default_factory=list)
|
||||
_ : SEXP_END = None
|
||||
params: dict = field(default_factory=dict)
|
||||
|
||||
def __catchall__(self, sexp_value, path=''):
|
||||
key, value = sexp_value
|
||||
self.params[key] = value
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
return [kls.name_atom,
|
||||
['type', value.type],
|
||||
['name', value.name],
|
||||
['layer', value.layer],
|
||||
['locked', ('true' if value.locked else 'false')],
|
||||
*[[k, v] for k, v in value.params.items()],
|
||||
['members', *value.members]]
|
||||
|
||||
|
||||
|
||||
SUPPORTED_FILE_FORMAT_VERSIONS = [20200119, 20200512, 20210108, 20211014, 20220621, 20221018, 20230517, 20240706, 20240922, 20241229]
|
||||
SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
|
||||
@sexp_type('kicad_pcb')
|
||||
class Board:
|
||||
_version: Named(int, name='version') = 20230517
|
||||
generator: Named(str) = Atom.gerbonara
|
||||
generator_version: Named(str) = Atom.gerbonara
|
||||
legacy_generator: Named(Array(str), name='host') = None
|
||||
generator: Named(Atom) = Atom.gerbonara
|
||||
general: GeneralSection = None
|
||||
paper: PageSettings = None
|
||||
legacy_page: Rename(PageSettings, 'page') = None
|
||||
title_block: TitleBlock = None
|
||||
page: PageSettings = None
|
||||
layers: Named(Array(Untagged(LayerSettings))) = field(default_factory=list)
|
||||
setup: BoardSetup = field(default_factory=BoardSetup)
|
||||
properties: List(Property) = field(default_factory=list)
|
||||
nets: List(Net) = field(default_factory=list)
|
||||
legacy_netclasses: List(LegacyNetclass) = field(default_factory=list)
|
||||
footprints: List(Footprint) = field(default_factory=list)
|
||||
legacy_footprints: Rename(List(Footprint), 'module') = field(default_factory=list)
|
||||
# Graphical elements
|
||||
texts: List(gr.Text) = field(default_factory=list)
|
||||
text_boxes: List(gr.TextBox) = field(default_factory=list)
|
||||
lines: List(gr.Line) = field(default_factory=list)
|
||||
targets: List(gr.Target) = field(default_factory=list)
|
||||
rectangles: List(gr.Rectangle) = field(default_factory=list)
|
||||
circles: List(gr.Circle) = field(default_factory=list)
|
||||
arcs: List(gr.Arc) = field(default_factory=list)
|
||||
|
|
@ -365,11 +333,10 @@ class Board:
|
|||
# Other stuff
|
||||
zones: List(Zone) = field(default_factory=list)
|
||||
groups: List(Group) = field(default_factory=list)
|
||||
generated_patterns: List(GeneratedPatterns) = field(default_factory=list)
|
||||
embedded_fonts: Named(YesNoAtom()) = False
|
||||
|
||||
_ : SEXP_END = None
|
||||
original_filename: str = None
|
||||
_bounding_box: tuple = None
|
||||
_trace_index: rtree.index.Index = None
|
||||
_trace_index_map: dict = None
|
||||
|
||||
|
|
@ -408,15 +375,15 @@ class Board:
|
|||
(47, 'F.CrtYd', 'user', 'F.Courtyard'),
|
||||
(48, 'B.Fab', 'user', None),
|
||||
(49, 'F.Fab', 'user', None),
|
||||
(50, 'User.1', 'auxiliary', None),
|
||||
(51, 'User.2', 'auxiliary', None),
|
||||
(52, 'User.3', 'auxiliary', None),
|
||||
(53, 'User.4', 'auxiliary', None),
|
||||
(54, 'User.5', 'auxiliary', None),
|
||||
(55, 'User.6', 'auxiliary', None),
|
||||
(56, 'User.7', 'auxiliary', None),
|
||||
(57, 'User.8', 'auxiliary', None),
|
||||
(58, 'User.9', 'auxiliary', None)]]
|
||||
(50, 'User.1', 'user', None),
|
||||
(51, 'User.2', 'user', None),
|
||||
(52, 'User.3', 'user', None),
|
||||
(53, 'User.4', 'user', None),
|
||||
(54, 'User.5', 'user', None),
|
||||
(55, 'User.6', 'user', None),
|
||||
(56, 'User.7', 'user', None),
|
||||
(57, 'User.8', 'user', None),
|
||||
(58, 'User.9', 'user', None)]]
|
||||
|
||||
|
||||
def rebuild_trace_index(self):
|
||||
|
|
@ -532,8 +499,6 @@ class Board:
|
|||
fp.board = self
|
||||
|
||||
self.nets = {net.index: net.name for net in self.nets}
|
||||
if self.legacy_page:
|
||||
self.paper, self.legacy_page = self.legacy_page, None
|
||||
|
||||
|
||||
def __before_sexp__(self):
|
||||
|
|
@ -695,11 +660,11 @@ class Board:
|
|||
for fp in self.footprints:
|
||||
if name and not match_filter(name, fp.name):
|
||||
continue
|
||||
if value and not match_filter(value, fp.value):
|
||||
if value and not match_filter(value, fp.properties.get('value', '')):
|
||||
continue
|
||||
if reference and not match_filter(reference, fp.reference):
|
||||
if reference and not match_filter(reference, fp.properties.get('reference', '')):
|
||||
continue
|
||||
if net and not any(pad.net and match_filter(net, pad.net.name) for pad in fp.pads):
|
||||
if net and not any(match_filter(net, pad.net.name) for pad in fp.pads):
|
||||
continue
|
||||
if sheetname and not match_filter(sheetname, fp.sheetname):
|
||||
continue
|
||||
|
|
@ -815,6 +780,15 @@ class Board:
|
|||
fe.offset(x, -y, MM)
|
||||
layer_stack.drill_pth.append(fe)
|
||||
|
||||
def bounding_box(self, unit=MM):
|
||||
if not self._bounding_box:
|
||||
stack = LayerStack()
|
||||
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in stack}
|
||||
self.render(stack, layer_map, x=0, y=0, rotation=0, flip=False, text=False, variables={})
|
||||
self._bounding_box = stack.bounding_box(unit)
|
||||
return self._bounding_box
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoardInstance(cad_pr.Positioned):
|
||||
sexp: Board = None
|
||||
|
|
@ -38,7 +38,7 @@ def layer_mask(layers):
|
|||
case 'B.Cu':
|
||||
mask |= 1<<31
|
||||
case _:
|
||||
if (m := re.match(fr'In([0-9]+)\.Cu', layer)):
|
||||
if (m := re.match(f'In([0-9]+)\.Cu', layer)):
|
||||
mask |= 1<<int(m.group(1))
|
||||
return mask
|
||||
|
||||
|
|
@ -59,33 +59,6 @@ def center_arc_to_kicad_mid(center, start, end):
|
|||
return XYCoord(mx, my)
|
||||
|
||||
|
||||
def kicad_mid_to_center_arc(mid, start, end):
|
||||
""" Convert kicad's slightly insane midpoint notation to standrad center/p1/p2 notation.
|
||||
|
||||
returns a ((center_x, center_y), radius, clockwise) tuple in KiCad coordinates.
|
||||
|
||||
Returns the center and radius of the circle passing the given 3 points.
|
||||
In case the 3 points form a line, raises a ValueError.
|
||||
"""
|
||||
# https://stackoverflow.com/questions/28910718/give-3-points-and-a-plot-circle
|
||||
p1, p2, p3 = start, mid, end
|
||||
|
||||
temp = p2[0] * p2[0] + p2[1] * p2[1]
|
||||
bc = (p1[0] * p1[0] + p1[1] * p1[1] - temp) / 2
|
||||
cd = (temp - p3[0] * p3[0] - p3[1] * p3[1]) / 2
|
||||
det = (p1[0] - p2[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p2[1])
|
||||
|
||||
if abs(det) < 1.0e-6:
|
||||
raise ValueError()
|
||||
|
||||
# Center of circle
|
||||
cx = (bc*(p2[1] - p3[1]) - cd*(p1[1] - p2[1])) / det
|
||||
cy = ((p1[0] - p2[0]) * cd - (p2[0] - p3[0]) * bc) / det
|
||||
|
||||
radius = math.sqrt((cx - p1[0])**2 + (cy - p1[1])**2)
|
||||
return (cx, cy), radius, det < 0
|
||||
|
||||
|
||||
@sexp_type('hatch')
|
||||
class Hatch:
|
||||
style: AtomChoice(Atom.none, Atom.edge, Atom.full) = Atom.edge
|
||||
|
|
@ -94,7 +67,7 @@ class Hatch:
|
|||
|
||||
@sexp_type('connect_pads')
|
||||
class PadConnection:
|
||||
type: AtomChoice(Atom.yes, Atom.thru_hole_only, Atom.full, Atom.no) = None
|
||||
type: AtomChoice(Atom.thru_hole_only, Atom.full, Atom.no) = None
|
||||
clearance: Named(float) = 0
|
||||
|
||||
|
||||
|
|
@ -120,7 +93,6 @@ class ZoneFill:
|
|||
thermal_gap: Named(float) = 0.508
|
||||
thermal_bridge_width: Named(float) = 0.508
|
||||
smoothing: ZoneSmoothing = None
|
||||
radius: Named(float) = 0.125
|
||||
island_removal_mode: Named(int) = None
|
||||
island_area_min: Named(float) = None
|
||||
hatch_thickness: Named(float) = None
|
||||
|
|
@ -136,54 +108,34 @@ class ZoneFill:
|
|||
class FillPolygon:
|
||||
layer: Named(str) = ""
|
||||
island: Wrap(Flag()) = False
|
||||
pts: ArcPointList = field(default_factory=list)
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
|
||||
|
||||
@sexp_type('fill_segments')
|
||||
class FillSegment:
|
||||
layer: Named(str) = ""
|
||||
pts: ArcPointList = field(default_factory=list)
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
|
||||
|
||||
@sexp_type('polygon')
|
||||
class ZonePolygon:
|
||||
pts: ArcPointList = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('placement')
|
||||
class ZonePlacement:
|
||||
enabled: Named(YesNoAtom()) = False
|
||||
sheetname: Named(str) = ''
|
||||
|
||||
|
||||
@sexp_type('teardrop')
|
||||
class ZoneTeardropSpec:
|
||||
type: Named(AtomChoice(Atom.padvia, Atom.track_end)) = Atom.padvia
|
||||
|
||||
|
||||
@sexp_type('attr')
|
||||
class ZoneAttr:
|
||||
teardrop: ZoneTeardropSpec = None
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
|
||||
|
||||
@sexp_type('zone')
|
||||
class Zone:
|
||||
locked: Flag() = False
|
||||
net: Named(int) = 0
|
||||
net_name: Named(str) = ""
|
||||
layer: Named(str) = None
|
||||
layers: Named(Array(str)) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
name: Named(str) = None
|
||||
hatch: Hatch = None
|
||||
priority: OmitDefault(Named(int)) = 0
|
||||
attr: ZoneAttr = None
|
||||
connect_pads: PadConnection = field(default_factory=PadConnection)
|
||||
min_thickness: Named(float) = 0.254
|
||||
filled_areas_thickness: Named(YesNoAtom()) = True
|
||||
keepout: ZoneKeepout = None
|
||||
placement: ZonePlacement = None
|
||||
fill: ZoneFill = field(default_factory=ZoneFill)
|
||||
polygon: ZonePolygon = field(default_factory=ZonePolygon)
|
||||
fill_polygons: List(FillPolygon) = field(default_factory=list)
|
||||
|
|
@ -200,26 +152,10 @@ class Zone:
|
|||
self.fill_polygons = []
|
||||
self.fill_segments = []
|
||||
|
||||
def rotate(self, angle, cx=None, cy=None):
|
||||
self.unfill()
|
||||
self.polygon.pts = [pt.with_rotation(angle, cx, cy) for pt in self.polygon.pts]
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.unfill()
|
||||
self.polygon.pts = [pt.with_offset(x, y) for pt in self.polygon.pts]
|
||||
|
||||
|
||||
def bounding_box(self):
|
||||
min_x = min(pt.x for pt in self.polygon.pts)
|
||||
min_y = min(pt.y for pt in self.polygon.pts)
|
||||
max_x = max(pt.x for pt in self.polygon.pts)
|
||||
max_y = max(pt.y for pt in self.polygon.pts)
|
||||
return (min_x, min_y), (max_x, max_y)
|
||||
|
||||
|
||||
@sexp_type('polygon')
|
||||
class RenderCachePolygon:
|
||||
pts: PointList = field(default_factory=list)
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
|
||||
|
||||
@sexp_type('render_cache')
|
||||
|
|
@ -229,39 +165,4 @@ class RenderCache:
|
|||
polygons: List(RenderCachePolygon) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('margins')
|
||||
class Margins:
|
||||
left: float = 0.0
|
||||
top: float = 0.0
|
||||
right: float = 0.0
|
||||
bottom: float = 0.0
|
||||
|
||||
|
||||
@sexp_type('comment')
|
||||
class TitleComment:
|
||||
@classmethod
|
||||
def __map__(kls, obj, parent=None, path=''):
|
||||
lines = []
|
||||
for lineno, content in zip(obj[1::2], obj[2::2]):
|
||||
while lineno > len(lines):
|
||||
lines.append('')
|
||||
lines[lineno-1] = content
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
l = [Atom.comment]
|
||||
for i, line in enumerate(value.splitlines(), start=1):
|
||||
l.append(i)
|
||||
l.append(line.rstrip('\n'))
|
||||
return l
|
||||
|
||||
|
||||
@sexp_type('title_block')
|
||||
class TitleBlock:
|
||||
title: Named(str) = ''
|
||||
date: Named(str) = ''
|
||||
rev: Named(str) = ''
|
||||
company: Named(str) = ''
|
||||
comment: TitleComment = None
|
||||
|
||||
|
||||
|
|
@ -19,7 +19,6 @@ from .symbols import Symbol
|
|||
from . import graphical_primitives as gr
|
||||
|
||||
from .. import primitives as cad_pr
|
||||
from ... import __version__
|
||||
|
||||
from ... import graphic_primitives as gp
|
||||
from ... import graphic_objects as go
|
||||
|
|
@ -85,12 +84,6 @@ class NoConnect:
|
|||
fill='none', stroke_width='0.254', stroke=colorscheme.no_connect)
|
||||
|
||||
|
||||
@sexp_type('bus_alias')
|
||||
class BusAlias:
|
||||
name: str = ''
|
||||
members: Named(Array(str)) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('bus_entry')
|
||||
class BusEntry:
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
|
|
@ -139,7 +132,7 @@ def _polyline_bounds(self):
|
|||
|
||||
@sexp_type('wire')
|
||||
class Wire:
|
||||
points: PointList = field(default_factory=list)
|
||||
points: PointList = field(default_factory=PointList)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
|
|
@ -152,7 +145,7 @@ class Wire:
|
|||
|
||||
@sexp_type('bus')
|
||||
class Bus:
|
||||
points: PointList = field(default_factory=list)
|
||||
points: PointList = field(default_factory=PointList)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
|
|
@ -165,9 +158,8 @@ class Bus:
|
|||
|
||||
@sexp_type('polyline')
|
||||
class Polyline:
|
||||
points: PointList = field(default_factory=list)
|
||||
points: PointList = field(default_factory=PointList)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: OmitDefault(Fill) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
|
|
@ -177,23 +169,33 @@ class Polyline:
|
|||
yield _polyline_svg(self, colorscheme.lines)
|
||||
|
||||
|
||||
@sexp_type('circle')
|
||||
class Circle:
|
||||
center: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
radius: Named(float) = 0.0
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: OmitDefault(Fill) = None
|
||||
@sexp_type('text')
|
||||
class Text(TextMixin):
|
||||
text: str = ''
|
||||
exclude_from_sim: Named(YesNoAtom()) = True
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield from TextMixin.to_svg(self, colorscheme.text)
|
||||
|
||||
@sexp_type('rectangle')
|
||||
class Rectangle:
|
||||
start: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
end: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: OmitDefault(Fill) = None
|
||||
|
||||
@sexp_type('label')
|
||||
class LocalLabel(TextMixin):
|
||||
text: str = ''
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
fields_autoplaced: Wrap(Flag()) = False
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
@property
|
||||
def _text_offset(self):
|
||||
return (0, -2*self.line_width)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield from TextMixin.to_svg(self, colorscheme.labels)
|
||||
|
||||
|
||||
def label_shape_path_d(shape, w, h):
|
||||
l, r = {
|
||||
|
|
@ -218,36 +220,16 @@ def label_shape_path_d(shape, w, h):
|
|||
return d + f'L {e+r:.3f} {0:.3f} L {e:.3f} {r:.3f} Z'
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextLabel(TextMixin):
|
||||
@sexp_type('global_label')
|
||||
class GlobalLabel(TextMixin):
|
||||
text: str = ''
|
||||
shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.dot, Atom.round, Atom.diamond, Atom.rectangle)) = Atom.passive
|
||||
exclude_from_sim: Named(YesNoAtom()) = False
|
||||
shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive)) = Atom.input
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
fields_autoplaced: Named(YesNoAtom()) = False
|
||||
fields_autoplaced: Wrap(Flag()) = False
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
properties: List(DrawnProperty) = field(default_factory=list)
|
||||
properties: List(Property) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('text')
|
||||
class Text(TextLabel):
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield from TextMixin.to_svg(self, colorscheme.text)
|
||||
|
||||
|
||||
@sexp_type('label')
|
||||
class LocalLabel(TextLabel):
|
||||
@property
|
||||
def _text_offset(self):
|
||||
return (0, -2*self.line_width)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield from TextMixin.to_svg(self, colorscheme.labels)
|
||||
|
||||
|
||||
@sexp_type('global_label')
|
||||
class GlobalLabel(TextLabel):
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
text = super(TextMixin, self).to_svg(colorscheme.labels),
|
||||
text.attrs['transform'] = f'translate({self.size*0.6:.3f} 0)'
|
||||
|
|
@ -258,7 +240,14 @@ class GlobalLabel(TextLabel):
|
|||
|
||||
|
||||
@sexp_type('hierarchical_label')
|
||||
class HierarchicalLabel(TextLabel):
|
||||
class HierarchicalLabel(TextMixin):
|
||||
text: str = ''
|
||||
shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive)) = Atom.input
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
fields_autoplaced: Wrap(Flag()) = False
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
text, = TextMixin.to_svg(self, colorscheme.labels),
|
||||
text.attrs['transform'] = f'translate({self.size*1.2:.3f} 0)'
|
||||
|
|
@ -267,20 +256,10 @@ class HierarchicalLabel(TextLabel):
|
|||
yield Tag('g', children=[frame, text])
|
||||
|
||||
|
||||
@sexp_type('netclass_flag')
|
||||
class NetclassFlag(TextLabel):
|
||||
length: Named(float) = 2.54
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
# FIXME
|
||||
yield from TextMixin.to_svg(self, colorscheme.text)
|
||||
|
||||
|
||||
@sexp_type('pin')
|
||||
class Pin:
|
||||
name: str = '1'
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
alternate: Named(str) = None
|
||||
|
||||
|
||||
# Suddenly, we're doing syntax like this is yaml or something.
|
||||
|
|
@ -289,8 +268,6 @@ class SymbolCrosslinkSheet:
|
|||
path: str = ''
|
||||
reference: Named(str) = ''
|
||||
unit: Named(int) = 1
|
||||
value: OmitDefault(Named(str)) = None
|
||||
footprint: OmitDefault(Named(str)) = None
|
||||
|
||||
|
||||
@sexp_type('project')
|
||||
|
|
@ -309,7 +286,6 @@ class MirrorFlags:
|
|||
class DrawnProperty(TextMixin):
|
||||
key: str = None
|
||||
value: str = None
|
||||
id: Named(int) = None
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
hide: Flag() = False
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
|
|
@ -359,14 +335,6 @@ class DrawnProperty(TextMixin):
|
|||
yield from TextMixin.to_svg(self, colorscheme.values)
|
||||
|
||||
|
||||
@sexp_type('default_instance')
|
||||
class DefaultSymbolInstance:
|
||||
reference: Named(str) = ''
|
||||
unit: Named(int) = 1
|
||||
value: Named(str) = ''
|
||||
footprint: Named(str) = ''
|
||||
|
||||
|
||||
@sexp_type('symbol')
|
||||
class SymbolInstance:
|
||||
name: str = None
|
||||
|
|
@ -379,17 +347,16 @@ class SymbolInstance:
|
|||
in_bom: Named(YesNoAtom()) = True
|
||||
on_board: Named(YesNoAtom()) = True
|
||||
dnp: Named(YesNoAtom()) = True
|
||||
fields_autoplaced: Named(YesNoAtom()) = True
|
||||
fields_autoplaced: Wrap(Flag()) = True
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
default_instance: DefaultSymbolInstance = None
|
||||
properties: List(DrawnProperty) = field(default_factory=list)
|
||||
# AFAICT this property is completely redundant.
|
||||
pins: List(Pin) = field(default_factory=list)
|
||||
# AFAICT this property, too, is completely redundant. It ultimately just lists paths and references of at most
|
||||
# three other uses of the same symbol in this schematic.
|
||||
instances: Named(Array(SymbolCrosslinkProject)) = field(default_factory=list)
|
||||
instances: Named(List(SymbolCrosslinkProject)) = field(default_factory=list)
|
||||
_ : SEXP_END = None
|
||||
schematic: object = field(repr=False, default=None)
|
||||
schematic: object = None
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.schematic = parent
|
||||
|
|
@ -517,11 +484,7 @@ class SubsheetFill:
|
|||
class Subsheet:
|
||||
at: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(2.54, 2.54))
|
||||
exclude_from_sim: Named(YesNoAtom()) = False
|
||||
in_bom: Named(YesNoAtom()) = False
|
||||
on_board: Named(YesNoAtom()) = False
|
||||
dnp: Named(YesNoAtom()) = False
|
||||
fields_autoplaced: Named(YesNoAtom()) = True
|
||||
fields_autoplaced: Wrap(Flag()) = True
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: SubsheetFill = field(default_factory=SubsheetFill)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
|
@ -532,10 +495,10 @@ class Subsheet:
|
|||
_ : SEXP_END = None
|
||||
sheet_name: object = field(default_factory=lambda: DrawnProperty('Sheetname', ''))
|
||||
file_name: object = field(default_factory=lambda: DrawnProperty('Sheetfile', ''))
|
||||
schematic: object = field(repr=False, default=None)
|
||||
schematic: object = None
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.sheet_name, self.file_name, *_extra_params = self._properties
|
||||
self.sheet_name, self.file_name = self._properties
|
||||
self.schematic = parent
|
||||
|
||||
def __before_sexp__(self):
|
||||
|
|
@ -580,28 +543,6 @@ class Subsheet:
|
|||
**self.stroke.svg_attrs(colorscheme.lines))
|
||||
|
||||
|
||||
@sexp_type('rule_area')
|
||||
class RuleArea:
|
||||
polyline: Polyline = None
|
||||
|
||||
|
||||
@sexp_type('text_box')
|
||||
class TextBox(TextMixin):
|
||||
text: str = None
|
||||
exclude_from_sim: Named(YesNoAtom()) = False
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
size: Rename(XYCoord) = None
|
||||
margins: Rename(gr.Margins) = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: OmitDefault(Fill) = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
def render(self, variables={}, cache=None):
|
||||
yield from gr.TextBox.render(self, variables=variables)
|
||||
|
||||
|
||||
@sexp_type('lib_symbols')
|
||||
class LocalLibrary:
|
||||
symbols: List(Symbol) = field(default_factory=list)
|
||||
|
|
@ -611,39 +552,26 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20230620]
|
|||
@sexp_type('kicad_sch')
|
||||
class Schematic:
|
||||
_version: Named(int, name='version') = 20230620
|
||||
generator: Named(str) = 'gerbonara'
|
||||
generator_version: Named(str) = __version__
|
||||
legacy_generator: Named(Array(str), name='host') = None
|
||||
generator: Named(Atom) = Atom.gerbonara
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
page_settings: PageSettings = field(default_factory=PageSettings)
|
||||
legacy_page: Named(Array(int), name='page') = None
|
||||
legacy_paper: Named(str, name='paper') = None
|
||||
title_block: TitleBlock = None
|
||||
# The doc says this is expected, but eeschema barfs when it's there.
|
||||
# path: SheetPath = field(default_factory=SheetPath)
|
||||
lib_symbols: LocalLibrary = field(default_factory=list)
|
||||
junctions: List(Junction) = field(default_factory=list)
|
||||
no_connects: List(NoConnect) = field(default_factory=list)
|
||||
rule_areas: List(RuleArea) = field(default_factory=list)
|
||||
netclass_flags: List(NetclassFlag) = field(default_factory=list)
|
||||
bus_aliases: List(BusAlias) = field(default_factory=list)
|
||||
bus_entries: List(BusEntry) = field(default_factory=list)
|
||||
wires: List(Wire) = field(default_factory=list)
|
||||
buses: List(Bus) = field(default_factory=list)
|
||||
images: List(gr.Image) = field(default_factory=list)
|
||||
polylines: List(Polyline) = field(default_factory=list)
|
||||
circles: List(Circle) = field(default_factory=list)
|
||||
rectangles: List(Rectangle) = field(default_factory=list)
|
||||
texts: List(Text) = field(default_factory=list)
|
||||
text_boxes: List(TextBox) = field(default_factory=list)
|
||||
local_labels: List(LocalLabel) = field(default_factory=list)
|
||||
global_labels: List(GlobalLabel) = field(default_factory=list)
|
||||
hierarchical_labels: List(HierarchicalLabel) = field(default_factory=list)
|
||||
symbols: List(SymbolInstance) = field(default_factory=list)
|
||||
subsheets: List(Subsheet) = field(default_factory=list)
|
||||
sheet_instances: Named(Array(SubsheetCrosslinkSheet)) = field(default_factory=list)
|
||||
symbol_instances: Named(Array(SymbolCrosslinkProject)) = field(default_factory=list)
|
||||
embedded_fonts: Named(YesNoAtom()) = False
|
||||
sheet_instances: Named(List(SubsheetCrosslinkSheet)) = field(default_factory=list)
|
||||
_ : SEXP_END = None
|
||||
original_filename: str = None
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ term_regex = r"""(?mx)
|
|||
(\))|
|
||||
([+-]?\d+\.\d+(?=[\s\)]))|
|
||||
(\-?\d+(?=[\s\)]))|
|
||||
([^"\s()][^"\s)]*)
|
||||
([^0-9"\s()][^"\s)]*)
|
||||
)"""
|
||||
|
||||
|
||||
|
|
@ -28,10 +28,6 @@ class AtomChoice:
|
|||
def __sexp__(self, value):
|
||||
yield value
|
||||
|
||||
def __str__(self):
|
||||
choices = '|'.join(map(str, self.choices))
|
||||
return f'AtomChoice({choices})'
|
||||
|
||||
|
||||
class Flag:
|
||||
def __init__(self, atom=None, invert=None):
|
||||
|
|
@ -52,11 +48,6 @@ class Flag:
|
|||
def __sexp__(self, value):
|
||||
if bool(value) == (not self.invert):
|
||||
yield self.atom
|
||||
|
||||
def __str__(self):
|
||||
if self.invert is not None:
|
||||
return f'Flag({self.atom}/{self.invert})'
|
||||
return f'Flag({self.atom})'
|
||||
|
||||
|
||||
def sexp(t, v):
|
||||
|
|
@ -85,7 +76,7 @@ class MappingError(TypeError):
|
|||
super().__init__(msg)
|
||||
self.t, self.sexp = t, sexp
|
||||
|
||||
def map_sexp(t, v, parent=None, path=''):
|
||||
def map_sexp(t, v, parent=None):
|
||||
try:
|
||||
if t is not Atom and hasattr(t, '__map__'):
|
||||
return t.__map__(v, parent=parent)
|
||||
|
|
@ -102,7 +93,7 @@ def map_sexp(t, v, parent=None, path=''):
|
|||
|
||||
elif isinstance(t, list):
|
||||
t, = t
|
||||
return [map_sexp(t, elem, parent=parent, path=f'{path}/{t}') for elem in v]
|
||||
return [map_sexp(t, elem, parent=parent) for elem in v]
|
||||
|
||||
else:
|
||||
raise TypeError(f'Python type {t} has no defined s-expression deserialization')
|
||||
|
|
@ -111,7 +102,7 @@ def map_sexp(t, v, parent=None, path=''):
|
|||
raise e
|
||||
|
||||
except Exception as e:
|
||||
raise MappingError(f'Error at {path} trying to map {textwrap.shorten(str(v), width=60)} into type {t}', t, v) from e
|
||||
raise MappingError(f'Error trying to map {textwrap.shorten(str(v), width=120)} into type {t}', t, v) from e
|
||||
|
||||
|
||||
class WrapperType:
|
||||
|
|
@ -120,8 +111,7 @@ class WrapperType:
|
|||
|
||||
def __bind_field__(self, field):
|
||||
self.field = field
|
||||
if self.next_type is not Atom:
|
||||
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
|
||||
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
|
||||
|
||||
def __atoms__(self):
|
||||
if hasattr(self, 'name_atom'):
|
||||
|
|
@ -143,12 +133,12 @@ class Named(WrapperType):
|
|||
if self.name_atom is None:
|
||||
self.name_atom = Atom(field.name)
|
||||
|
||||
def __map__(self, obj, parent=None, path=''):
|
||||
def __map__(self, obj, parent=None):
|
||||
k, *obj = obj
|
||||
if self.next_type in (int, float, str, Atom) or isinstance(self.next_type, AtomChoice):
|
||||
return map_sexp(self.next_type, [*obj], parent=parent, path=f'{path}/{self.name_atom}')
|
||||
return map_sexp(self.next_type, [*obj], parent=parent)
|
||||
else:
|
||||
return map_sexp(self.next_type, obj, parent=parent, path=f'{path}/{self.name_atom}')
|
||||
return map_sexp(self.next_type, obj, parent=parent)
|
||||
|
||||
def __sexp__(self, value):
|
||||
value = sexp(self.next_type, value)
|
||||
|
|
@ -160,9 +150,6 @@ class Named(WrapperType):
|
|||
|
||||
yield [self.name_atom, *value]
|
||||
|
||||
def __str__(self):
|
||||
return f'Named={self.name_atom}({self.next_type})'
|
||||
|
||||
|
||||
class Rename(WrapperType):
|
||||
def __init__(self, next_type, name=None):
|
||||
|
|
@ -175,8 +162,8 @@ class Rename(WrapperType):
|
|||
if hasattr(self.next_type, '__bind_field__'):
|
||||
self.next_type.__bind_field__(field)
|
||||
|
||||
def __map__(self, obj, parent=None, path=''):
|
||||
return map_sexp(self.next_type, obj, parent=parent, path=f'{path}/{self.name_atom}')
|
||||
def __map__(self, obj, parent=None):
|
||||
return map_sexp(self.next_type, obj, parent=parent)
|
||||
|
||||
def __sexp__(self, value):
|
||||
value, = sexp(self.next_type, value)
|
||||
|
|
@ -186,9 +173,6 @@ class Rename(WrapperType):
|
|||
key, *rest = value
|
||||
yield [self.name_atom, *rest]
|
||||
|
||||
def __str__(self):
|
||||
return f'Rename={self.name_atom}({self.next_type})'
|
||||
|
||||
|
||||
class OmitDefault(WrapperType):
|
||||
def __bind_field__(self, field):
|
||||
|
|
@ -198,42 +182,19 @@ class OmitDefault(WrapperType):
|
|||
else:
|
||||
self.default = field.default
|
||||
|
||||
def __map__(self, obj, parent=None, path=''):
|
||||
return map_sexp(self.next_type, obj, parent=parent, path=path)
|
||||
def __map__(self, obj, parent=None):
|
||||
return map_sexp(self.next_type, obj, parent=parent)
|
||||
|
||||
def __sexp__(self, value):
|
||||
if value != self.default:
|
||||
yield from sexp(self.next_type, value)
|
||||
|
||||
def __str__(self):
|
||||
return f'OmitDefault({self.field})'
|
||||
|
||||
|
||||
class YesNoAtom:
|
||||
def __init__(self, yes=Atom.yes, no=Atom.no):
|
||||
self.yes, self.no = yes, no
|
||||
|
||||
def __map__(self, value, parent=None):
|
||||
if not value: # compatibility with legacy flag style
|
||||
return False
|
||||
value, = value
|
||||
return value == self.yes
|
||||
|
||||
def __sexp__(self, value):
|
||||
yield self.yes if value else self.no
|
||||
|
||||
|
||||
class LegacyCompatibleFlag:
|
||||
'''Variant of YesNoAtom that accepts both the `(flag <yes/no>)` variant and the bare `flag` variant for compatibility.'''
|
||||
|
||||
def __init__(self, yes=Atom.yes, no=Atom.no, value_when_empty=True):
|
||||
self.yes, self.no = yes, no
|
||||
self.value_when_empty = value_when_empty
|
||||
|
||||
def __map__(self, value, parent=None):
|
||||
if value == []:
|
||||
return self.value_when_empty
|
||||
|
||||
value, = value
|
||||
return value == self.yes
|
||||
|
||||
|
|
@ -242,50 +203,41 @@ class LegacyCompatibleFlag:
|
|||
|
||||
|
||||
class Wrap(WrapperType):
|
||||
def __map__(self, value, parent=None, path=''):
|
||||
def __map__(self, value, parent=None):
|
||||
value, = value
|
||||
return map_sexp(self.next_type, value, parent=parent, path=path)
|
||||
return map_sexp(self.next_type, value, parent=parent)
|
||||
|
||||
def __sexp__(self, value):
|
||||
for inner in sexp(self.next_type, value):
|
||||
yield [inner]
|
||||
|
||||
def __str__(self):
|
||||
return f'Wrap({self.next_type})'
|
||||
|
||||
|
||||
class Array(WrapperType):
|
||||
def __map__(self, value, parent=None, path=''):
|
||||
return [map_sexp(self.next_type, [elem], parent=parent, path=path) for elem in value]
|
||||
def __map__(self, value, parent=None):
|
||||
return [map_sexp(self.next_type, [elem], parent=parent) for elem in value]
|
||||
|
||||
def __sexp__(self, value):
|
||||
for e in value:
|
||||
yield from sexp(self.next_type, e)
|
||||
|
||||
def __str__(self):
|
||||
return f'Array({self.next_type})'
|
||||
|
||||
|
||||
class Untagged(WrapperType):
|
||||
def __map__(self, value, parent=None, path=''):
|
||||
def __map__(self, value, parent=None):
|
||||
value, = value
|
||||
return self.next_type.__map__([self.next_type.name_atom, *value], parent=parent, path=path)
|
||||
return self.next_type.__map__([self.next_type.name_atom, *value], parent=parent)
|
||||
|
||||
def __sexp__(self, value):
|
||||
for inner in sexp(self.next_type, value):
|
||||
_tag, *rest = inner
|
||||
yield rest
|
||||
|
||||
def __str__(self):
|
||||
return f'Untagged({self.next_type})'
|
||||
|
||||
class List(WrapperType):
|
||||
def __bind_field__(self, field):
|
||||
self.attr = field.name
|
||||
|
||||
def __map__(self, value, parent, path=''):
|
||||
def __map__(self, value, parent):
|
||||
l = getattr(parent, self.attr, [])
|
||||
mapped = map_sexp(self.next_type, value, parent=parent, path=f'{path}/{self.attr}')
|
||||
mapped = map_sexp(self.next_type, value, parent=parent)
|
||||
l.append(mapped)
|
||||
setattr(parent, self.attr, l)
|
||||
|
||||
|
|
@ -293,9 +245,6 @@ class List(WrapperType):
|
|||
for elem in value:
|
||||
yield from sexp(self.next_type, elem)
|
||||
|
||||
def __str__(self):
|
||||
return f'List@{self.attr}({self.next_type})'
|
||||
|
||||
|
||||
class _SexpTemplate:
|
||||
@staticmethod
|
||||
|
|
@ -303,32 +252,22 @@ class _SexpTemplate:
|
|||
return [kls.name_atom]
|
||||
|
||||
@staticmethod
|
||||
def __map__(kls, value, *args, parent=None, path='', **kwargs):
|
||||
def __map__(kls, value, *args, parent=None, **kwargs):
|
||||
positional = iter(kls.positional)
|
||||
inst = kls(*args, **kwargs)
|
||||
|
||||
for v in value[1:]: # skip key
|
||||
if isinstance(v, Atom) and v in kls.keys:
|
||||
name, etype = kls.keys[v]
|
||||
mapped = map_sexp(etype, [v], parent=inst, path=f'{path}/{kls.name_atom}')
|
||||
mapped = map_sexp(etype, [v], parent=inst)
|
||||
if mapped is not None:
|
||||
setattr(inst, name, mapped)
|
||||
|
||||
elif isinstance(v, list):
|
||||
key = v[0]
|
||||
if key in kls.keys:
|
||||
name, etype = kls.keys[key]
|
||||
mapped = map_sexp(etype, v, parent=inst, path=f'{path}/{kls.name_atom}')
|
||||
if mapped is not None:
|
||||
setattr(inst, name, mapped)
|
||||
|
||||
elif hasattr(inst, '__catchall__'):
|
||||
inst.__catchall__(v, path=f'{path}/{kls.name_atom}')
|
||||
|
||||
else:
|
||||
#print('class has keys:')
|
||||
#print('\n'.join(map(str, kls.keys)))
|
||||
raise TypeError(f'Unhandled keyed argument {v!r} while parsing {kls}')
|
||||
name, etype = kls.keys[v[0]]
|
||||
mapped = map_sexp(etype, v, parent=inst)
|
||||
if mapped is not None:
|
||||
setattr(inst, name, mapped)
|
||||
|
||||
else:
|
||||
try:
|
||||
|
|
@ -18,15 +18,13 @@ from .sexp import *
|
|||
from .sexp_mapper import *
|
||||
from .base_types import *
|
||||
from ...utils import rotate_point, Tag, arc_bounds
|
||||
from ... import __version__
|
||||
from ...newstroke import Newstroke
|
||||
from .schematic_colors import *
|
||||
from .primitives import kicad_mid_to_center_arc, Margins
|
||||
|
||||
|
||||
PIN_ETYPE = AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.free,
|
||||
Atom.unspecified, Atom.power_in, Atom.power_out, Atom.open_collector, Atom.open_emitter,
|
||||
Atom.no_connect, Atom.unconnected)
|
||||
Atom.no_connect)
|
||||
|
||||
|
||||
PIN_STYLE = AtomChoice(Atom.line, Atom.inverted, Atom.clock, Atom.inverted_clock, Atom.input_low, Atom.clock_low,
|
||||
|
|
@ -52,7 +50,7 @@ class Pin:
|
|||
style: PIN_STYLE = Atom.line
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
length: Named(float) = 2.54
|
||||
hide: OmitDefault(Named(YesNoAtom())) = False
|
||||
hide: Flag() = False
|
||||
name: Rename(StyledText) = field(default_factory=StyledText)
|
||||
number: Rename(StyledText) = field(default_factory=StyledText)
|
||||
alternates: List(AltFunction) = field(default_factory=list)
|
||||
|
|
@ -251,11 +249,26 @@ class Circle:
|
|||
**self.stroke.svg_attrs(colorscheme.lines))
|
||||
|
||||
|
||||
@sexp_type('radius')
|
||||
class ArcRadius:
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
length: Named(float) = 0.0
|
||||
angles: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
# https://stackoverflow.com/questions/28910718/give-3-points-and-a-plot-circle
|
||||
def define_circle(p1, p2, p3):
|
||||
"""
|
||||
Returns the center and radius of the circle passing the given 3 points.
|
||||
In case the 3 points form a line, raises a ValueError.
|
||||
"""
|
||||
temp = p2[0] * p2[0] + p2[1] * p2[1]
|
||||
bc = (p1[0] * p1[0] + p1[1] * p1[1] - temp) / 2
|
||||
cd = (temp - p3[0] * p3[0] - p3[1] * p3[1]) / 2
|
||||
det = (p1[0] - p2[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p2[1])
|
||||
|
||||
if abs(det) < 1.0e-6:
|
||||
raise ValueError()
|
||||
|
||||
# Center of circle
|
||||
cx = (bc*(p2[1] - p3[1]) - cd*(p1[1] - p2[1])) / det
|
||||
cy = ((p1[0] - p2[0]) * cd - (p2[0] - p3[0]) * bc) / det
|
||||
|
||||
radius = math.sqrt((cx - p1[0])**2 + (cy - p1[1])**2)
|
||||
return ((cx, cy), radius)
|
||||
|
||||
|
||||
@sexp_type('arc')
|
||||
|
|
@ -263,12 +276,11 @@ class Arc:
|
|||
start: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
mid: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
end: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
radius: ArcRadius = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: Fill = field(default_factory=Fill)
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
(cx, cy), r = kicad_mid_to_center_arc(self.mid, self.start, self.end)
|
||||
(cx, cy), r = define_circle((self.start.x, self.start.y), (self.mid.x, self.mid.y), (self.end.x, self.end.y))
|
||||
x1, y1 = self.start.x, self.start.y
|
||||
x2, y2 = self.mid.x-x1, self.mid.y-x2
|
||||
x3, y3 = (self.end.x - x1)/2, (self.end.y - y1)/2
|
||||
|
|
@ -277,7 +289,7 @@ class Arc:
|
|||
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
(cx, cy), r = kicad_mid_to_center_arc(self.mid, self.start, self.end)
|
||||
(cx, cy), r = define_circle((self.start.x, self.start.y), (self.mid.x, self.mid.y), (self.end.x, self.end.y))
|
||||
|
||||
x1r = self.start.x - cx
|
||||
y1r = self.start.y - cy
|
||||
|
|
@ -404,12 +416,10 @@ class Rectangle:
|
|||
|
||||
@sexp_type('property')
|
||||
class Property(TextMixin):
|
||||
private: Flag() = False
|
||||
name: str = None
|
||||
value: str = None
|
||||
id: Named(int) = None
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
show_name: Flag() = False
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
|
||||
# Alias value for text mixin
|
||||
|
|
@ -427,24 +437,13 @@ class Property(TextMixin):
|
|||
|
||||
@sexp_type('pin_numbers')
|
||||
class PinNumberSpec:
|
||||
hide: Named(YesNoAtom()) = False
|
||||
hide: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('pin_names')
|
||||
class PinNameSpec:
|
||||
offset: OmitDefault(Named(float)) = 0.508
|
||||
hide: OmitDefault(Named(YesNoAtom())) = False
|
||||
|
||||
@sexp_type('text_box')
|
||||
class TextBox:
|
||||
text: str = ''
|
||||
exclude_from_sim: OmitDefault(Named(YesNoAtom())) = False
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
size: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
margins: Margins = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: Fill = field(default_factory=Fill)
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
hide: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('symbol')
|
||||
|
|
@ -455,7 +454,6 @@ class Unit:
|
|||
polylines: List(Polyline) = field(default_factory=list)
|
||||
rectangles: List(Rectangle) = field(default_factory=list)
|
||||
texts: List(Text) = field(default_factory=list)
|
||||
text_boxes: List(TextBox) = field(default_factory=list)
|
||||
pins: List(Pin) = field(default_factory=list)
|
||||
unit_name: Named(str) = None
|
||||
_ : SEXP_END = None
|
||||
|
|
@ -504,12 +502,10 @@ class Symbol:
|
|||
pin_numbers: OmitDefault(PinNumberSpec) = field(default_factory=PinNumberSpec)
|
||||
pin_names: OmitDefault(PinNameSpec) = field(default_factory=PinNameSpec)
|
||||
exclude_from_sim: OmitDefault(Named(YesNoAtom())) = False
|
||||
exclude_from_sim: Named(YesNoAtom()) = False
|
||||
in_bom: Named(YesNoAtom()) = True
|
||||
on_board: Named(YesNoAtom()) = True
|
||||
properties: List(Property) = field(default_factory=list)
|
||||
units: List(Unit) = field(default_factory=list)
|
||||
embedded_fonts: Named(YesNoAtom()) = False
|
||||
_ : SEXP_END = None
|
||||
library = None
|
||||
name: str = None
|
||||
|
|
@ -598,8 +594,7 @@ SUPPORTED_FILE_FORMAT_VERSIONS = [20211014, 20220914]
|
|||
@sexp_type('kicad_symbol_lib')
|
||||
class Library:
|
||||
_version: Named(int, name='version') = 20211014
|
||||
generator: Named(str) = Atom.gerbonara
|
||||
generator_version: Named(str) = __version__
|
||||
generator: Named(Atom) = Atom.gerbonara
|
||||
symbols: List(Symbol) = field(default_factory=list)
|
||||
_ : SEXP_END = None
|
||||
original_filename: str = None
|
||||
|
|
@ -4,7 +4,7 @@ import math
|
|||
import warnings
|
||||
from copy import copy
|
||||
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 ..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
|
||||
|
||||
|
||||
class UNDEFINED:
|
||||
pass
|
||||
|
||||
def sgn(x):
|
||||
return -1 if x < 0 else 1
|
||||
|
||||
|
|
@ -118,7 +115,7 @@ class Board:
|
|||
|
||||
def layer_stack(self, layer_stack=None):
|
||||
if layer_stack is None:
|
||||
layer_stack = LayerStack(board_name='proto')
|
||||
layer_stack = LayerStack()
|
||||
|
||||
cache = {}
|
||||
for obj in chain(self.objects):
|
||||
|
|
@ -322,7 +319,6 @@ class Text(Positioned):
|
|||
xs = [x for points in strokes for x, _y in points]
|
||||
ys = [y for points in strokes for _x, y in points]
|
||||
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
|
||||
h = self.font_size + self.stroke_width # (max_y - min_y)
|
||||
|
||||
if self.h_align == 'left':
|
||||
x0 = 0
|
||||
|
|
@ -333,16 +329,16 @@ class Text(Positioned):
|
|||
else:
|
||||
raise ValueError('h_align must be one of "left", "center", or "right".')
|
||||
|
||||
if self.v_align == 'bottom':
|
||||
y0 = h
|
||||
if self.v_align == 'top':
|
||||
y0 = -(max_y - min_y)
|
||||
elif self.v_align == 'middle':
|
||||
y0 = h/2
|
||||
elif self.v_align == 'top':
|
||||
y0 = -(max_y - min_y)/2
|
||||
elif self.v_align == 'bottom':
|
||||
y0 = 0
|
||||
else:
|
||||
raise ValueError('v_align must be one of "top", "middle", or "bottom".')
|
||||
|
||||
if self.flip:
|
||||
if self.side == 'bottom':
|
||||
x0 += min_x + max_x
|
||||
x_sign = -1
|
||||
else:
|
||||
|
|
@ -352,7 +348,7 @@ class Text(Positioned):
|
|||
|
||||
for stroke in strokes:
|
||||
for (x1, y1), (x2, y2) in zip(stroke[:-1], stroke[1:]):
|
||||
obj = Line(x0+x_sign*x1, y0+y1, x0+x_sign*x2, y0+y2, aperture=ap, unit=self.unit, polarity_dark=self.polarity_dark)
|
||||
obj = Line(x0+x_sign*x1, y0-y1, x0+x_sign*x2, y0-y2, aperture=ap, unit=self.unit, polarity_dark=self.polarity_dark)
|
||||
obj.rotate(rotation)
|
||||
obj.offset(obj_x, obj_y)
|
||||
layer_stack['bottom' if flip else 'top', self.layer].objects.append(obj)
|
||||
|
|
@ -369,11 +365,11 @@ class Text(Positioned):
|
|||
x0 = -approx_w
|
||||
|
||||
if self.v_align == 'top':
|
||||
y0 = 0
|
||||
y0 = -approx_h
|
||||
elif self.v_align == 'middle':
|
||||
y0 = -approx_h/2
|
||||
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)
|
||||
|
||||
|
|
@ -386,7 +382,6 @@ class PadStackAperture:
|
|||
offset_x: float = 0 # in PadStack units
|
||||
offset_y: float = 0
|
||||
rotation: float = 0
|
||||
invert: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -401,20 +396,20 @@ class PadStack:
|
|||
def flashes(self, x, y, rotation: float = 0, flip: bool = False):
|
||||
for ap in self.apertures:
|
||||
aperture = ap.aperture.rotated(ap.rotation + rotation)
|
||||
fl = Flash(ap.offset_x, ap.offset_y, aperture, polarity_dark=not ap.invert, unit=self.unit)
|
||||
fl = Flash(ap.offset_x, ap.offset_y)
|
||||
fl.rotate(rotation)
|
||||
fl.offset(x, y)
|
||||
side = ap.side
|
||||
side = fl.side
|
||||
if flip:
|
||||
side = {'top': 'bottom', 'bottom': 'top'}.get(side, side)
|
||||
yield side, ap.layer, fl
|
||||
yield side, fl.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':
|
||||
if side == 'drill' and use == 'plated':
|
||||
layer_stack.drill_pth.objects.append(flash)
|
||||
|
||||
elif side == 'drill' and layer == 'nonplated':
|
||||
elif side == 'drill' and use == 'nonplated':
|
||||
layer_stack.drill_npth.objects.append(flash)
|
||||
|
||||
elif (side, layer) in layer_stack:
|
||||
|
|
@ -454,37 +449,17 @@ class SMDStack(PadStack):
|
|||
return kls(CircleAperture(dia, unit=unit), mask_expansion, paste_expansion, paste, flip, unit=unit)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MechanicalHoleStack(PadStack):
|
||||
drill_dia: float
|
||||
mask_expansion: float = 0.0
|
||||
mask_aperture = None
|
||||
|
||||
@property
|
||||
def apertures(self):
|
||||
mask_aperture = self.mask_aperture or CircleAperture(self.drill_dia + self.mask_expansion, unit=self.unit)
|
||||
yield PadStackAperture(mask_aperture, 'top', 'mask')
|
||||
yield PadStackAperture(mask_aperture, 'bottom', 'mask')
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class THTPad(PadStack):
|
||||
drill_dia: float
|
||||
pad_top: SMDStack
|
||||
pad_bottom: SMDStack = None
|
||||
aperture_inner: Aperture = UNDEFINED
|
||||
aperture_inner: Aperture = None
|
||||
plated: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
if self.pad_bottom is None:
|
||||
object.__setattr__(self, 'pad_bottom', replace(self.pad_top, flip=True))
|
||||
|
||||
if self.aperture_inner is UNDEFINED:
|
||||
object.__setattr__(self, 'aperture_inner', self.pad_top.aperture)
|
||||
|
||||
if self.pad_top.flip:
|
||||
raise ValueError('top pad cannot be flipped')
|
||||
|
|
@ -497,8 +472,7 @@ class THTPad(PadStack):
|
|||
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(self.aperture_inner, 'inner', 'copper')
|
||||
yield PadStackAperture(ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), 'drill', self.plating)
|
||||
|
||||
@property
|
||||
|
|
@ -512,7 +486,7 @@ class THTPad(PadStack):
|
|||
|
||||
@classmethod
|
||||
def circle(kls, drill_dia, dia, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
|
||||
pad = SMDStack.circle(dia, mask_expansion, paste_expansion, paste, unit=unit)
|
||||
pad = SMDStack.circle(dia, rotation, mask_expansion, paste_expansion, paste, unit=unit)
|
||||
return kls(drill_dia, pad, plated=plated)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -564,10 +538,6 @@ class Via(FrozenPositioned):
|
|||
class Pad(Positioned):
|
||||
pad_stack: PadStack
|
||||
|
||||
def render(self, layer_stack, cache=None):
|
||||
x, y, rotation, flip = self.abs_pos
|
||||
self.pad_stack.render(layer_stack, x, y, rotation, flip)
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
return self.pad_stack.single_sided
|
||||
739
gerbonara/cad/protoboard.py
Normal 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)
|
||||
#pattern3 = 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)
|
||||
|
||||
|
|
@ -8,7 +8,6 @@ from quart import Quart, request, Response, send_file, abort
|
|||
|
||||
from . import protoboard as pb
|
||||
from . import protoserve_data
|
||||
from .primitives import SMDStack
|
||||
from ..utils import MM, Inch
|
||||
|
||||
|
||||
|
|
@ -26,7 +25,7 @@ def extract_importlib(package):
|
|||
else:
|
||||
assert item.is_dir()
|
||||
item_out.mkdir()
|
||||
stack.append((item, item_out))
|
||||
stack.push((item, item_out))
|
||||
|
||||
return root
|
||||
|
||||
|
|
@ -63,10 +62,10 @@ def deserialize(obj, unit):
|
|||
case 'smd':
|
||||
match obj['pad_shape']:
|
||||
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':
|
||||
stack = SMDStack.circle(min(pitch_x, pitch_y)-clearance, paste=False, unit=unit)
|
||||
return pb.PatternProtoArea(pitch_x, pitch_y, obj=stack, 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=pad, unit=unit)
|
||||
|
||||
case 'tht':
|
||||
hole_dia = mil(float(obj['hole_dia']))
|
||||
|
|
@ -80,11 +79,11 @@ def deserialize(obj, unit):
|
|||
|
||||
match obj['pad_shape']:
|
||||
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':
|
||||
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':
|
||||
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:
|
||||
pad.pad_bottom = None
|
||||
|
|
@ -107,8 +106,7 @@ def deserialize(obj, unit):
|
|||
pitch = mil(float(obj.get('pitch', 2.54)))
|
||||
hole_dia = mil(float(obj['hole_dia']))
|
||||
pattern_dia = mil(float(obj['pattern_dia']))
|
||||
clearance = mil(float(obj['clearance']))
|
||||
return pb.PatternProtoArea(pitch, pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, clearance, unit=unit), unit=unit)
|
||||
return pb.PatternProtoArea(2*pitch, 2*pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, unit=unit), unit=unit)
|
||||
|
||||
case 'spiky':
|
||||
return pb.PatternProtoArea(2.54, 2.54, pb.SpikyProto(), unit=unit)
|
||||
|
|
@ -129,20 +127,6 @@ def deserialize(obj, unit):
|
|||
via_size=via_size
|
||||
), margin=unit(1.5, MM), unit=unit)
|
||||
|
||||
case 'breadboard':
|
||||
horizontal = obj.get('direction', 'v') == 'h'
|
||||
drill = float(obj.get('hole_dia', 0.9))
|
||||
return pb.BreadboardArea(clearance=clearance, drill=drill, horizontal=horizontal, unit=unit)
|
||||
|
||||
case 'starburst':
|
||||
trace_width_x = float(obj.get('trace_width_x', 1.8))
|
||||
trace_width_y = float(obj.get('trace_width_y', 1.8))
|
||||
drill = float(obj.get('hole_dia', 0.9))
|
||||
annular_ring = float(obj.get('annular', 1.2))
|
||||
clearance = float(obj.get('clearance', 0.4))
|
||||
mask_width = float(obj.get('mask_width', 0.5))
|
||||
return pb.PatternProtoArea(pitch_x, pitch_y, pb.StarburstPad(pitch_x, pitch_y, trace_width_x, trace_width_y, clearance, mask_width, drill, annular_ring, unit=unit), unit=unit)
|
||||
|
||||
case 'rf':
|
||||
pitch = float(obj.get('pitch', 2.54))
|
||||
hole_dia = float(obj['hole_dia'])
|
||||
|
|
@ -155,7 +139,6 @@ def to_board(obj):
|
|||
w = float(obj.get('width', unit(100, MM)))
|
||||
h = float(obj.get('height', unit(80, MM)))
|
||||
corner_radius = float(obj.get('round_corners', {}).get('radius', unit(1.5, MM)))
|
||||
margin = float(obj.get('margin', unit(2.0, MM)))
|
||||
holes = obj.get('mounting_holes', {})
|
||||
mounting_hole_dia = float(holes.get('diameter', unit(3.2, MM)))
|
||||
mounting_hole_offset = float(holes.get('offset', unit(5, MM)))
|
||||
|
|
@ -172,14 +155,13 @@ def to_board(obj):
|
|||
corner_radius=corner_radius,
|
||||
mounting_hole_dia=mounting_hole_dia,
|
||||
mounting_hole_offset=mounting_hole_offset,
|
||||
margin=margin,
|
||||
unit=unit)
|
||||
|
||||
@app.route('/preview_<side>.svg', methods=['POST'])
|
||||
async def preview(side):
|
||||
@app.route('/preview.svg', methods=['POST'])
|
||||
async def preview():
|
||||
obj = await request.get_json()
|
||||
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'])
|
||||
async def gerbers():
|
||||
|
|
@ -190,10 +172,7 @@ async def gerbers():
|
|||
board.layer_stack().save_to_zipfile(f)
|
||||
return Response(f.read_bytes(), mimetype='image/svg+xml')
|
||||
|
||||
def main():
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
app.run()
|
||||
|
||||
|
|
@ -177,14 +177,11 @@ input[type="text"]:focus:valid {
|
|||
position: relative;
|
||||
grid-area: main;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#preview > img {
|
||||
flex-grow: 1;
|
||||
#preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
|
|
@ -283,12 +280,6 @@ input[type="text"]:focus:valid {
|
|||
<span class="unit us">inch</span>
|
||||
</label>
|
||||
|
||||
<label>Margin
|
||||
<input type="text" placeholder="margin" name="margin" value="2.0" pattern="[0-9]+\.?[0-9]*"/>
|
||||
<span class="unit metric">mm</span>
|
||||
<span class="unit us">inch</span>
|
||||
</label>
|
||||
|
||||
<div class="group expand" data-group="round_corners">
|
||||
<label>Round corners
|
||||
<input name="enabled" type="checkbox" checked/>
|
||||
|
|
@ -325,8 +316,7 @@ input[type="text"]:focus:valid {
|
|||
</form>
|
||||
</div>
|
||||
<div id="preview">
|
||||
<img id="preview-image-top" alt="Automatically generated top side preview image"/>
|
||||
<img id="preview-image-bottom" alt="Automatically generated bottom side preview image"/>
|
||||
<img id="preview-image" alt="Automatically generated preview image"/>
|
||||
<div id="preview-message"></div>
|
||||
</div>
|
||||
<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="spiky" class="double-sided-only">Spiky hybrid area</a>
|
||||
<a href="#" data-placeholder="alio" class="double-sided-only">ALio hybrid area</a>
|
||||
<a href="#" data-placeholder="starburst" class="double-sided-only">THT starburst area</a>
|
||||
<a href="#" data-placeholder="breadboard" class="double-sided-only">Permanent breadboard area</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -480,7 +468,7 @@ input[type="text"]:focus:valid {
|
|||
<span class="unit us">mil</span>
|
||||
</label>
|
||||
<label>Plating
|
||||
<select name="plating" value="plated">
|
||||
<select name="plating" value="through">
|
||||
<option value="plated">Double-sided, through-plated</option>
|
||||
<option value="nonplated">Double-sided, non-plated</option>
|
||||
<option value="singleside">Single-sided, non-plated</option>
|
||||
|
|
@ -506,34 +494,6 @@ input[type="text"]:focus:valid {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-g-breadboard">
|
||||
<div data-type="breadboard" class="group breadboard">
|
||||
<h4>Permanent breadboard area</h4>
|
||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
||||
<label class="proportion">Proportion
|
||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
||||
</label>
|
||||
|
||||
<h5>Area Settings</h5>
|
||||
<label>Direction
|
||||
<select name="direction" value="v">
|
||||
<option value="v">Vertical</option>
|
||||
<option value="h">Horizontal</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Clearance
|
||||
<input type="text" name="clearance" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
|
||||
<span class="unit metric">mm</span>
|
||||
<span class="unit us">mil</span>
|
||||
</label>
|
||||
<label>Hole diameter
|
||||
<input type="text" name="hole_dia" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
|
||||
<span class="unit metric">mm</span>
|
||||
<span class="unit us">mil</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-g-manhattan">
|
||||
<div data-type="manhattan" class="group manhattan">
|
||||
<h4>Manhattan area</h4>
|
||||
|
|
@ -739,58 +699,6 @@ input[type="text"]:focus:valid {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-g-starburst">
|
||||
<div data-type="starburst" class="group starburst">
|
||||
<h4>Starburst area</h4>
|
||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
||||
<label class="proportion">Proportion
|
||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
||||
</label>
|
||||
|
||||
<h5>Area Settings</h5>
|
||||
<label>Pitch X
|
||||
<input type="text" name="pitch_x" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
|
||||
<span class="unit metric">mm</span>
|
||||
<span class="unit us">mil</span>
|
||||
</label>
|
||||
<label>Pitch Y
|
||||
<input type="text" name="pitch_y" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
|
||||
<span class="unit metric">mm</span>
|
||||
<span class="unit us">mil</span>
|
||||
</label>
|
||||
<label>Drill diameter
|
||||
<input type="text" name="drill" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
|
||||
<span class="unit metric">mm</span>
|
||||
<span class="unit us">mil</span>
|
||||
</label>
|
||||
<label>Annular ring
|
||||
<input type="text" name="annular" placeholder="length" value="1.2" pattern="[0-9]+\.?[0-9]*"/>
|
||||
<span class="unit metric">mm</span>
|
||||
<span class="unit us">mil</span>
|
||||
</label>
|
||||
<label>Pad clearance
|
||||
<input type="text" name="clearance" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
|
||||
<span class="unit metric">mm</span>
|
||||
<span class="unit us">mil</span>
|
||||
</label>
|
||||
<label>Soldermask wall
|
||||
<input type="text" name="mask_width" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
|
||||
<span class="unit metric">mm</span>
|
||||
<span class="unit us">mil</span>
|
||||
</label>
|
||||
<label>Trace width X
|
||||
<input type="text" name="trace_width_x" placeholder="length" value="1.40" pattern="[0-9]+\.?[0-9]*"/>
|
||||
<span class="unit metric">mm</span>
|
||||
<span class="unit us">mil</span>
|
||||
</label>
|
||||
<label>Trace width Y
|
||||
<input type="text" name="trace_width_y" placeholder="length" value="1.40" pattern="[0-9]+\.?[0-9]*"/>
|
||||
<span class="unit metric">mm</span>
|
||||
<span class="unit us">mil</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.expand').forEach((elem) => {
|
||||
const checkbox = elem.querySelector(':first-child > input');
|
||||
|
|
@ -1077,43 +985,26 @@ input[type="text"]:focus:valid {
|
|||
}
|
||||
}
|
||||
|
||||
let previewTopBlobURL = null;
|
||||
let previewBotBlobURL = null;
|
||||
let previewBlobURL = null;
|
||||
previewReloader = new RateLimiter(async () => {
|
||||
if (document.querySelector('form').checkValidity()) {
|
||||
document.querySelector('#preview-message').textContent = 'Reloading...';
|
||||
document.querySelector('#preview-message').classList.add('loading');
|
||||
|
||||
const response_top = await fetch('preview_top.svg', {
|
||||
const response = await fetch('preview.svg', {
|
||||
method: 'POST',
|
||||
mode: 'same-origin',
|
||||
cache: 'no-cache',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: serialize(),
|
||||
});
|
||||
const data_top = await response_top.blob();
|
||||
if (previewTopBlobURL) {
|
||||
URL.revokeObjectURL(previewTopBlobURL);
|
||||
const data = await response.blob();
|
||||
if (previewBlobURL) {
|
||||
URL.revokeObjectURL(previewBlobURL);
|
||||
}
|
||||
previewTopBlobURL = URL.createObjectURL(data_top);
|
||||
document.querySelector('#preview-image-top').src = previewTopBlobURL;
|
||||
|
||||
previewBlobURL = URL.createObjectURL(data);
|
||||
document.querySelector('#preview-image').src = previewBlobURL;
|
||||
document.querySelector('#preview-message').textContent = '';
|
||||
document.querySelector('#preview-message').classList.remove('loading');
|
||||
|
||||
const response_bot = await fetch('preview_bottom.svg', {
|
||||
method: 'POST',
|
||||
mode: 'same-origin',
|
||||
cache: 'no-cache',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: serialize(),
|
||||
});
|
||||
const data_bot = await response_bot.blob();
|
||||
if (previewBotBlobURL) {
|
||||
URL.revokeObjectURL(previewBotBlobURL);
|
||||
}
|
||||
previewBotBlobURL = URL.createObjectURL(data_bot);
|
||||
document.querySelector('#preview-image-bottom').src = previewBotBlobURL;
|
||||
} else {
|
||||
document.querySelector('#preview-message').classList.add('loading');
|
||||
document.querySelector('#preview-message').textContent = 'Please correct any invalid fields.';
|
||||
|
|
@ -26,7 +26,7 @@ import shutil
|
|||
from pathlib import Path
|
||||
from functools import cached_property
|
||||
|
||||
from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg, convex_hull
|
||||
from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg
|
||||
from . import graphic_primitives as gp
|
||||
from . import graphic_objects as go
|
||||
|
||||
|
|
@ -54,15 +54,6 @@ class FileSettings:
|
|||
zeros : bool = None
|
||||
#: Number format. ``(integer, decimal)`` tuple of number of integer and decimal digits. At most ``(6,7)`` by spec.
|
||||
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
|
||||
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)
|
||||
|
||||
def convex_hull(self, tol=0.01, unit=None):
|
||||
unit = unit or self.unit
|
||||
points = []
|
||||
|
||||
for obj in self.objects:
|
||||
if isinstance(obj, go.Line):
|
||||
line = obj.as_primitive(unit)
|
||||
points.append((line.x1, line.y1))
|
||||
points.append((line.x2, line.y2))
|
||||
|
||||
elif isinstance(obj, go.Arc):
|
||||
for obj in obj.approximate(tol, unit):
|
||||
line = obj.as_primitive(unit)
|
||||
points.append((line.x1, line.y1))
|
||||
points.append((line.x2, line.y2))
|
||||
|
||||
return convex_hull(points)
|
||||
|
||||
def to_excellon(self):
|
||||
""" Convert to a :py:class:`.ExcellonFile`. Returns ``self`` if it already is one. """
|
||||
raise NotImplementedError()
|
||||
|
|
@ -23,10 +23,7 @@ import dataclasses
|
|||
import re
|
||||
import warnings
|
||||
import json
|
||||
import sys
|
||||
import itertools
|
||||
import webbrowser
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
from .utils import MM, Inch
|
||||
|
|
@ -36,21 +33,8 @@ from . import layers as lyr
|
|||
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):
|
||||
if value and not ctx.resilient_parsing:
|
||||
click.echo(f'Version {__version__}')
|
||||
|
|
@ -146,23 +130,6 @@ def cli():
|
|||
well as sets of those files """
|
||||
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():
|
||||
|
|
@ -338,9 +305,9 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio
|
|||
scheme instead of keeping the old file names.''')
|
||||
@click.argument('transform')
|
||||
@click.argument('inpath')
|
||||
@click.argument('outpath', type=click.Path(path_type=Path))
|
||||
def transform(transform, units, output_format, inpath, outpath, format_warnings, input_map, use_builtin_name_rules,
|
||||
output_naming_scheme, number_format, force_zip):
|
||||
@click.argument('outpath')
|
||||
def transform(transform, units, output_format, inpath, outpath,
|
||||
format_warnings, input_map, use_builtin_name_rules, output_naming_scheme):
|
||||
""" Transform all gerber files in a given directory or zip file using the given python transformation script.
|
||||
|
||||
In the python transformation script you have access to the functions translate(x, y), scale(factor) and
|
||||
|
|
@ -355,26 +322,16 @@ def transform(transform, units, output_format, inpath, outpath, format_warnings,
|
|||
with warnings.catch_warnings():
|
||||
warnings.simplefilter(format_warnings)
|
||||
if force_zip:
|
||||
stack = lyr.LayerStack.open_zip(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
stack = lyr.LayerStack.open_zip(path, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
else:
|
||||
stack = lyr.LayerStack.open(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
stack = lyr.LayerStack.open(path, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
|
||||
_apply_transform(transform, units, stack)
|
||||
|
||||
output_format = None if output_format == 'reuse' else FileSettings.defaults()
|
||||
if number_format:
|
||||
if output_format is None:
|
||||
output_format = FileSettings.defaults()
|
||||
a, _, b = number_format.partition('.')
|
||||
output_format.number_format = (int(a), int(b))
|
||||
if outpath.is_file() or outpath.suffix.lower() == '.zip':
|
||||
stack.save_to_zipfile(outpath, naming_scheme=output_naming_scheme or {},
|
||||
gerber_settings=output_format,
|
||||
excellon_settings=dataclasses.replace(output_format, zeros=None))
|
||||
else:
|
||||
stack.save_to_directory(outpath, naming_scheme=output_naming_scheme or {},
|
||||
gerber_settings=output_format,
|
||||
excellon_settings=dataclasses.replace(output_format, zeros=None))
|
||||
stack.save_to_directory(outpath, naming_scheme=output_naming_scheme or {},
|
||||
gerber_settings=output_format,
|
||||
excellon_settings=dataclasses.replace(output_format, zeros=None))
|
||||
|
||||
|
||||
@cli.command()
|
||||
|
|
@ -455,7 +412,7 @@ def merge(inpath, outpath, offset, rotation, input_map, command_line_units, outp
|
|||
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
|
||||
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
|
||||
help='''Enable or disable file format warnings during parsing (default: on)''')
|
||||
@click.option('--units', type=Unit(), default='metric', help='Output bounding box in this unit (default: millimeter)')
|
||||
@click.option('--units', type=Unit(), help='Output bounding box in this unit (default: millimeter)')
|
||||
@click.option('--input-number-format', help='Override number format of input file (mostly useful for Excellon files)')
|
||||
@click.option('--input-units', type=Unit(), help='Override units of input file')
|
||||
@click.option('--input-zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='Override zero suppression setting of input file')
|
||||
|
|
@ -30,10 +30,9 @@ from pathlib import Path
|
|||
|
||||
from .cam import CamFile, FileSettings
|
||||
from .graphic_objects import Flash, Line, Arc
|
||||
from .apertures import ExcellonTool, CircleAperture
|
||||
from .apertures import ExcellonTool
|
||||
from .utils import Inch, MM, to_unit, InterpMode, RegexMatcher
|
||||
|
||||
|
||||
class ExcellonContext:
|
||||
""" Internal helper class used for tracking graphics state when writing Excellon. """
|
||||
|
||||
|
|
@ -269,19 +268,17 @@ class ExcellonFile(CamFile):
|
|||
""" Counterpart to :py:meth:`~.rs274x.GerberFile.to_excellon`. Does nothing and returns :py:obj:`self`. """
|
||||
return self
|
||||
|
||||
def to_gerber(self, errors='raise'):
|
||||
def to_gerber(self, errros='raise'):
|
||||
""" Convert this excellon file into a :py:class:`~.rs274x.GerberFile`. """
|
||||
from .rs274x import GerberFile
|
||||
out = GerberFile()
|
||||
out.comments = self.comments
|
||||
|
||||
apertures = {}
|
||||
for obj in self.objects:
|
||||
if not (ap := apertures.get(obj.tool)):
|
||||
ap = apertures[obj.tool] = CircleAperture(obj.tool.diameter, unit=obj.aperture.unit)
|
||||
if not (ap := apertures[obj.tool]):
|
||||
ap = apertures[obj.tool] = CircleAperture(obj.tool.diameter)
|
||||
|
||||
out.objects.append(dataclasses.replace(obj, aperture=ap))
|
||||
return out
|
||||
|
||||
@property
|
||||
def generator(self):
|
||||
|
|
@ -328,7 +325,7 @@ class ExcellonFile(CamFile):
|
|||
for fn in 'nc_param.txt', 'ncdrill.log':
|
||||
if (param_file := filename.parent / fn).is_file():
|
||||
settings = parse_allegro_ncparam(param_file.read_text())
|
||||
warnings.warn(f'Loaded allegro-style excellon settings file {param_file}', SyntaxWarning)
|
||||
warnings.warn(f'Loaded allegro-style excellon settings file {param_file}')
|
||||
break
|
||||
|
||||
# Parse Zuken log file for settings
|
||||
|
|
@ -336,7 +333,7 @@ class ExcellonFile(CamFile):
|
|||
logfile = filename.with_suffix('.fdl')
|
||||
if logfile.is_file():
|
||||
settings = parse_zuken_logfile(logfile.read_text())
|
||||
warnings.warn(f'Loaded zuken-style excellon log file {logfile}: {settings}', SyntaxWarning)
|
||||
warnings.warn(f'Loaded zuken-style excellon log file {logfile}: {settings}')
|
||||
|
||||
if external_tools is None:
|
||||
# Parse allegro log files for tools.
|
||||
|
|
@ -379,7 +376,7 @@ class ExcellonFile(CamFile):
|
|||
|
||||
mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1)
|
||||
if mixed_plating:
|
||||
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.', SyntaxWarning)
|
||||
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.')
|
||||
|
||||
defined_tools = {}
|
||||
tool_indices = {}
|
||||
|
|
@ -569,8 +566,6 @@ class ExcellonParser(object):
|
|||
self.filename = None
|
||||
self.external_tools = external_tools or {}
|
||||
self.found_kicad_format_comment = False
|
||||
self.allegro_eof_toolchange_hack = False
|
||||
self.allegro_eof_toolchange_hack_index = 1
|
||||
|
||||
def warn(self, msg):
|
||||
warnings.warn(f'{self.filename}:{self.lineno} "{self.line}": {msg}', SyntaxWarning)
|
||||
|
|
@ -611,25 +606,18 @@ class ExcellonParser(object):
|
|||
exprs = RegexMatcher()
|
||||
|
||||
# NOTE: These must be kept before the generic comment handler at the end of this class so they match first.
|
||||
@exprs.match(r';(?P<index1_prefix>T(?P<index1>[0-9]+))?\s+Holesize (?P<index2>[0-9]+)\. = (?P<diameter>[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?P<plated>PLATED|NON_PLATED|OPTIONAL) (?P<unit>MILS|MM) Quantity = [0-9]+')
|
||||
@exprs.match(r';T(?P<index1>[0-9]+) Holesize (?P<index2>[0-9]+)\. = (?P<diameter>[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?P<plated>PLATED|NON_PLATED|OPTIONAL) (?P<unit>MILS|MM) Quantity = [0-9]+')
|
||||
def parse_allegro_tooldef(self, match):
|
||||
# NOTE: We ignore the given tolerances here since they are non-standard.
|
||||
self.program_state = ProgramState.HEADER # TODO is this needed? we need a test file.
|
||||
self.generator_hints.append('allegro')
|
||||
|
||||
index = int(match['index2'])
|
||||
|
||||
if match['index1'] and index != int(match['index1']): # index1 has leading zeros, index2 not.
|
||||
if (index := int(match['index1'])) != int(match['index2']): # index1 has leading zeros, index2 not.
|
||||
raise SyntaxError('BUG: Allegro excellon tool def has mismatching tool indices. Please file a bug report on our issue tracker and provide this file!')
|
||||
|
||||
if index in self.tools:
|
||||
self.warn('Re-definition of tool index {index}, overwriting old definition.')
|
||||
|
||||
if not match['index1_prefix']:
|
||||
# This is a really nasty orcad file without tool change commands, that instead just puts all holes in order
|
||||
# of the hole size definitions with M00's in between.
|
||||
self.allegro_eof_toolchange_hack = True
|
||||
|
||||
# NOTE: We map "optionally" plated holes to plated holes for API simplicity. If you hit a case where that's a
|
||||
# problem, please raise an issue on our issue tracker, explain why you need this and provide an example file.
|
||||
is_plated = None if match['plated'] is None else (match['plated'] in ('PLATED', 'OPTIONAL'))
|
||||
|
|
@ -642,19 +630,13 @@ class ExcellonParser(object):
|
|||
else:
|
||||
unit = MM
|
||||
|
||||
if self.settings.unit is None:
|
||||
self.settings.unit = unit
|
||||
|
||||
elif unit != self.settings.unit:
|
||||
if unit != self.settings.unit:
|
||||
self.warn('Allegro Excellon drill file tool definitions in {unit.name}, but file parameters say the '
|
||||
'file should be in {settings.unit.name}. Please double-check that this is correct, and if it is, '
|
||||
'please raise an issue on our issue tracker.')
|
||||
|
||||
self.tools[index] = ExcellonTool(diameter=diameter, plated=is_plated, unit=unit)
|
||||
|
||||
if self.allegro_eof_toolchange_hack and self.active_tool is None:
|
||||
self.active_tool = self.tools[index]
|
||||
|
||||
# Searching Github I found that EasyEDA has two different variants of the unit specification here.
|
||||
@exprs.match(';Holesize (?P<index>[0-9]+) = (?P<diameter>[.0-9]+) (?P<unit>INCH|inch|METRIC|mm)')
|
||||
def parse_easyeda_tooldef(self, match):
|
||||
|
|
@ -771,12 +753,6 @@ class ExcellonParser(object):
|
|||
def handle_end_of_program(self, match):
|
||||
if self.program_state in (None, ProgramState.HEADER):
|
||||
self.warn('M30 statement found before end of header.')
|
||||
|
||||
if self.allegro_eof_toolchange_hack:
|
||||
self.allegro_eof_toolchange_hack_index = min(max(self.tools), self.allegro_eof_toolchange_hack_index + 1)
|
||||
self.active_tool = self.tools[self.allegro_eof_toolchange_hack_index]
|
||||
return
|
||||
|
||||
self.program_state = ProgramState.FINISHED
|
||||
# TODO: maybe add warning if this is followed by other commands.
|
||||
|
||||
|
|
@ -786,17 +762,14 @@ class ExcellonParser(object):
|
|||
def do_move(self, coord_groups):
|
||||
x_s, x, y_s, y = coord_groups
|
||||
|
||||
if (x is not None and '.' not in x) or (y is not None and '.' not in y):
|
||||
self.settings._file_has_fixed_width_coordinates = True
|
||||
|
||||
if self.settings.number_format == (None, None):
|
||||
# TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else.
|
||||
if x != '00':
|
||||
raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro '
|
||||
'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as '
|
||||
'it, because Allegro does not include this critical information in their Excellon output. If you '
|
||||
'call this through ExcellonFile.from_string, you must manually supply from_string with a '
|
||||
'FileSettings object from excellon.parse_allegro_ncparam.')
|
||||
if self.settings.number_format == (None, None) and '.' not in x:
|
||||
# TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else.
|
||||
if x != '00':
|
||||
raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro '
|
||||
'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as '
|
||||
'it, because Allegro does not include this critical information in their Excellon output. If you '
|
||||
'call this through ExcellonFile.from_string, you must manually supply from_string with a '
|
||||
'FileSettings object from excellon.parse_allegro_ncparam.')
|
||||
|
||||
x = self.settings.parse_gerber_value(x)
|
||||
if x_s:
|
||||
|
|
@ -890,17 +863,12 @@ class ExcellonParser(object):
|
|||
# from https://math.stackexchange.com/a/1781546
|
||||
if a_s:
|
||||
raise ValueError('Negative arc radius given')
|
||||
r = self.settings.parse_gerber_value(a)
|
||||
r = settings.parse_gerber_value(a)
|
||||
x1, y1 = start
|
||||
x2, y2 = end
|
||||
dx, dy = (x2-x1)/2, (y2-y1)/2
|
||||
x0, y0 = x1+dx, y1+dy
|
||||
d = math.hypot(dx, dy)
|
||||
if d == 0:
|
||||
raise ValueError('Arc radius notation requires distinct start and end points')
|
||||
if r < d:
|
||||
raise ValueError('Arc radius too small for endpoint distance')
|
||||
f = math.sqrt(r**2 - d**2) / d
|
||||
f = math.hypot(dx, dy) / math.sqrt(r**2 - a**2)
|
||||
if clockwise:
|
||||
cx = x0 + f*dy
|
||||
cy = y0 - f*dx
|
||||
|
|
@ -910,16 +878,16 @@ class ExcellonParser(object):
|
|||
i, j = cx-start[0], cy-start[1]
|
||||
|
||||
else: # explicit center given
|
||||
i = self.settings.parse_gerber_value(i) or 0
|
||||
i = settings.parse_gerber_value(i)
|
||||
if i_s:
|
||||
i = -i
|
||||
j = self.settings.parse_gerber_value(j) or 0
|
||||
j = settings.parse_gerber_value(j)
|
||||
if j_s:
|
||||
j = -j
|
||||
j = -i
|
||||
|
||||
self.objects.append(Arc(*start, *end, i, j, clockwise, self.active_tool, unit=self.settings.unit))
|
||||
self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit))
|
||||
|
||||
@exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,([0-9]+\.[0-9]+|0*\.0*))?')
|
||||
@exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,0*\.0*)?')
|
||||
def parse_easyeda_format(self, match):
|
||||
metric = match[1] in ('METRIC', 'M71')
|
||||
|
||||
|
|
@ -932,10 +900,7 @@ class ExcellonParser(object):
|
|||
# This is used by newer autodesk eagles, fritzing and diptrace
|
||||
if match[3]:
|
||||
integer, _, fractional = match[3][1:].partition('.')
|
||||
if integer.strip('0') or fractional.strip('0'):
|
||||
self.settings.number_format = int(integer), int(fractional)
|
||||
else:
|
||||
self.settings.number_format = len(integer), len(fractional)
|
||||
self.settings.number_format = len(integer), len(fractional)
|
||||
|
||||
elif self.settings.number_format == (None, None) and not metric and not self.found_kicad_format_comment:
|
||||
self.warn('Using implicit number format from bare "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.')
|
||||
|
|
@ -961,10 +926,10 @@ class ExcellonParser(object):
|
|||
@exprs.match('(FMAT|VER),?([0-9]*)')
|
||||
def handle_command_format(self, match):
|
||||
if match[1] == 'FMAT':
|
||||
# We only use FMAT as a compatibility marker. Version 1 drill files encountered in the wild still use the
|
||||
# same coordinate and routing statements that we already support, so rejecting the header unconditionally
|
||||
# needlessly breaks otherwise parseable files.
|
||||
if match[2] not in ('', '1', '2'):
|
||||
# We do not support integer/fractional decimals specification via FMAT because that's stupid. If you need this,
|
||||
# please raise an issue on our issue tracker, provide a sample file and tell us where on earth you found that
|
||||
# file.
|
||||
if match[2] not in ('', '2'):
|
||||
raise SyntaxError(f'Unsupported FMAT format version {match[2]}')
|
||||
|
||||
else: # VER
|
||||
|
|
@ -993,19 +958,6 @@ class ExcellonParser(object):
|
|||
else:
|
||||
self.warn('Bare coordinate after end of file')
|
||||
|
||||
@exprs.match(xy_coord + 'G85' + xy_coord)
|
||||
def handle_g85_slot(self, match):
|
||||
if self.program_state == ProgramState.HEADER:
|
||||
return
|
||||
|
||||
self.do_move(match.groups()[:4])
|
||||
start, end = self.do_move(match.groups()[4:])
|
||||
|
||||
if not self.ensure_active_tool():
|
||||
return
|
||||
|
||||
self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit))
|
||||
|
||||
@exprs.match(r'DETECT,ON|ATC,ON|M06')
|
||||
def parse_zuken_legacy_statements(self, match):
|
||||
self.generator_hints.append('zuken')
|
||||
|
|
@ -294,7 +294,6 @@ class Region(GraphicObject):
|
|||
self.polarity_dark = polarity_dark
|
||||
self.outline = [] if outline is None else outline
|
||||
self.arc_centers = [] if arc_centers is None else arc_centers
|
||||
self.close()
|
||||
|
||||
def __len__(self):
|
||||
return len(self.outline)
|
||||
|
|
@ -307,7 +306,6 @@ class Region(GraphicObject):
|
|||
|
||||
def _offset(self, dx, dy):
|
||||
self.outline = [ (x+dx, y+dy) for x, y in self.outline ]
|
||||
self.arc_centers = [ (c[0], (c[1][0]+dx, c[1][1]+dy)) if c else None for c in self.arc_centers ]
|
||||
|
||||
def _rotate(self, angle, cx=0, cy=0):
|
||||
self.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.outline ]
|
||||
|
|
@ -321,12 +319,6 @@ class Region(GraphicObject):
|
|||
(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) ]
|
||||
|
||||
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
|
||||
def from_rectangle(kls, x, y, w, h, unit=MM):
|
||||
return kls([
|
||||
|
|
@ -337,9 +329,8 @@ class Region(GraphicObject):
|
|||
], unit=unit)
|
||||
|
||||
@classmethod
|
||||
def from_arc_poly(kls, arc_poly, polarity_dark=None, unit=MM):
|
||||
polarity = arc_poly.polarity_dark if polarity_dark is None else polarity_dark
|
||||
return kls(arc_poly.outline, arc_poly.arc_centers, polarity_dark=polarity, unit=unit)
|
||||
def from_arc_poly(kls, arc_poly, polarity_dark=True, unit=MM):
|
||||
return kls(arc_poly.outline, arc_poly.arc_centers, polarity_dark=polarity_dark, unit=unit)
|
||||
|
||||
def append(self, obj):
|
||||
if obj.unit != self.unit:
|
||||
|
|
@ -373,7 +364,7 @@ class Region(GraphicObject):
|
|||
|
||||
def outline_objects(self, aperture=None):
|
||||
for p1, p2, (clockwise, center) in self.iter_segments():
|
||||
if clockwise is not None:
|
||||
if center:
|
||||
yield Arc(*p1, *p2, *center, clockwise, aperture=aperture, unit=self.unit, polarity_dark=self.polarity_dark)
|
||||
else:
|
||||
yield Line(*p1, *p2, aperture=aperture, unit=self.unit, polarity_dark=self.polarity_dark)
|
||||
|
|
@ -386,17 +377,13 @@ class Region(GraphicObject):
|
|||
|
||||
points = []
|
||||
for p1, p2, (clockwise, center) in self.iter_segments():
|
||||
if clockwise is not None:
|
||||
if center:
|
||||
for p in approximate_arc(*center, *p1, *p2, clockwise,
|
||||
max_error=max_error, clip_max_error=clip_max_error):
|
||||
points.append(p)
|
||||
points.pop()
|
||||
else:
|
||||
points.append(p1)
|
||||
points.append(p2)
|
||||
|
||||
if points[0] != points[-1]:
|
||||
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))
|
||||
|
||||
|
|
@ -62,12 +62,6 @@ class GraphicPrimitive:
|
|||
|
||||
raise NotImplementedError()
|
||||
|
||||
def is_zero_size(self):
|
||||
""" Return whether this primitive is zero size
|
||||
|
||||
:rtype: bool
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Circle(GraphicPrimitive):
|
||||
|
|
@ -87,11 +81,7 @@ class Circle(GraphicPrimitive):
|
|||
|
||||
def to_arc_poly(self):
|
||||
return ArcPoly([(self.x-self.r, self.y), (self.x+self.r, self.y)],
|
||||
[(True, (self.x, self.y)), (True, (self.x, self.y))],
|
||||
polarity_dark=self.polarity_dark)
|
||||
|
||||
def is_zero_size(self):
|
||||
return math.isclose(self.r, 0)
|
||||
[(True, (self.x, self.y)), (True, (self.x, self.y))])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -130,14 +120,14 @@ class ArcPoly(GraphicPrimitive):
|
|||
|
||||
def approximate_arcs(self, max_error=1e-2, clip_max_error=True):
|
||||
outline = []
|
||||
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
|
||||
for p1, p2, (clockwise, center) in self.segments():
|
||||
if clockwise is None:
|
||||
outline.append((x1, y1))
|
||||
outline.append(p1)
|
||||
else:
|
||||
outline.extend(approximate_arc(cx, cy, x1, y1, x2, y2, clockwise,
|
||||
max_error=max_error, clip_max_error=clip_max_error))
|
||||
outline.pop() # remove arc end point
|
||||
return type(self)(outline, polarity_dark=self.polarity_dark)
|
||||
return type(self)(outline)
|
||||
|
||||
def bounding_box(self):
|
||||
bbox = (None, None), (None, None)
|
||||
|
|
@ -189,20 +179,6 @@ class ArcPoly(GraphicPrimitive):
|
|||
return self
|
||||
|
||||
|
||||
def is_zero_size(self):
|
||||
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
|
||||
if clockwise is not None: # arc
|
||||
if math.isclose(cx, x1) and math.isclose(cy, y1):
|
||||
continue
|
||||
|
||||
if math.isclose(x1, x2) and math.isclose(y1, y2):
|
||||
return False
|
||||
|
||||
if math.isclose(polygon_area(self.outline), 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Line(GraphicPrimitive):
|
||||
""" Straight line with round end caps. """
|
||||
|
|
@ -242,33 +218,24 @@ class Line(GraphicPrimitive):
|
|||
color = fg if self.polarity_dark else bg
|
||||
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} L {float(self.x2):.6} {float(self.y2):.6}',
|
||||
fill='none', stroke=color, stroke_width=str(width), stroke_linecap='round')
|
||||
fill='none', stroke=color, stroke_width=str(width))
|
||||
|
||||
def to_arc_poly(self):
|
||||
l = math.dist((self.x1, self.y1), (self.x2, self.y2))
|
||||
if math.isclose(l, 0):
|
||||
# degenerate case: a zero-length line becomes a circle.
|
||||
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
|
||||
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
|
||||
polarity_dark=self.polarity_dark)
|
||||
|
||||
dx, dy = self.x2-self.x1, self.y2-self.y1
|
||||
nx, ny = -dy/l, dx/l
|
||||
rx, ry = nx*self.width/2, ny*self.width/2
|
||||
return ArcPoly([
|
||||
(self.x2+rx, self.y2+ry),
|
||||
(self.x2-rx, self.y2-ry),
|
||||
(self.x1-rx, self.y1-ry),
|
||||
(self.x1+rx, self.y1+ry),
|
||||
(self.x1-rx, self.y1-ry),
|
||||
(self.x2-rx, self.y2-ry),
|
||||
(self.x2+rx, self.y2+ry),
|
||||
], [
|
||||
(True, (self.x2, self.y2)),
|
||||
None,
|
||||
(True, (self.x1, self.y1)),
|
||||
None,
|
||||
], polarity_dark=self.polarity_dark)
|
||||
|
||||
def is_zero_size(self):
|
||||
return math.isclose(self.x1, self.x2) and math.isclose(self.y1, self.y2)
|
||||
(True, (self.x2, self.y2)),
|
||||
None,
|
||||
])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -309,35 +276,25 @@ class Arc(GraphicPrimitive):
|
|||
arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise)
|
||||
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} {arc}',
|
||||
fill='none', stroke=color, stroke_width=width, stroke_linecap='round')
|
||||
fill='none', stroke=color, stroke_width=width)
|
||||
|
||||
def to_arc_poly(self):
|
||||
r = math.dist((self.x1, self.y1), (self.cx, self.cy))
|
||||
|
||||
if math.isclose(r, 0):
|
||||
# degenerate case: a zero-radius arc becomes a circle.
|
||||
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
|
||||
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
|
||||
polarity_dark=self.polarity_dark)
|
||||
|
||||
dx1, dy1 = self.x1-self.cx, self.y1-self.cy
|
||||
nx1, ny1 = dx1/r * self.width/2, dy1/r * self.width/2
|
||||
dx2, dy2 = self.x2-self.cx, self.y2-self.cy
|
||||
nx2, ny2 = dx2/r * self.width/2, dy2/r * self.width/2
|
||||
return ArcPoly([ # vertices
|
||||
(self.x1+nx1, self.y1+ny1),
|
||||
(self.x1-nx1, self.y1-ny1),
|
||||
(self.x2-nx2, self.y2-ny2),
|
||||
(self.x2+nx2, self.y2+ny2),
|
||||
], [ # arc segments (direction, center)
|
||||
(not self.clockwise, (self.x1, self.y1)),
|
||||
return ArcPoly([
|
||||
(self.x1+nx1, self.y1+nx1),
|
||||
(self.x1-nx1, self.y1-nx1),
|
||||
(self.x2-nx2, self.y2-nx2),
|
||||
(self.x2+nx2, self.y2+nx2),
|
||||
], [
|
||||
(self.clockwise, (self.x1, self.y1)),
|
||||
(self.clockwise, (self.cx, self.cy)),
|
||||
(self.clockwise, (self.x2, self.y2)),
|
||||
(not self.clockwise, (self.cx, self.cy)),
|
||||
], polarity_dark=self.polarity_dark)
|
||||
|
||||
def is_zero_size(self):
|
||||
return False # an arc with identical start and end points is defined as a circle
|
||||
(self.clockwise, (self.cx, self.cy)),
|
||||
])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -366,7 +323,7 @@ class Rectangle(GraphicPrimitive):
|
|||
(x - (cw+sh), y + (ch+sw)),
|
||||
(x + (cw+sh), y + (ch+sw)),
|
||||
(x + (cw+sh), y - (ch+sw)),
|
||||
], polarity_dark=self.polarity_dark)
|
||||
])
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
|
|
@ -374,6 +331,3 @@ class Rectangle(GraphicPrimitive):
|
|||
return tag('rect', x=prec(x), y=prec(y), width=prec(self.w), height=prec(self.h),
|
||||
**svg_rotation(self.rotation, self.x, self.y), fill=color)
|
||||
|
||||
def is_zero_size(self):
|
||||
return math.isclose(self.w, 0) or math.isclose(self.h, 0)
|
||||
|
||||
|
|
@ -82,7 +82,6 @@ MATCH_RULES = {
|
|||
'bottom paste': r'.*_boardoutline\.\w+', # FIXME verify this
|
||||
'drill plated': r'.*\.(drl)', # diptrace has unplated drills on the outline layer
|
||||
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
|
||||
'header regex': [['sufficient', r'top .*|bottom .*', r'G04 DipTrace [.-0-9a-z]*\*']],
|
||||
},
|
||||
|
||||
'target': {
|
||||
|
|
@ -152,25 +151,22 @@ MATCH_RULES = {
|
|||
|
||||
'allegro': {
|
||||
# Allegro doesn't have any widespread convention, so we rely heavily on the layer name auto-guesser here.
|
||||
'drill plated': r'.*\.(drl)',
|
||||
'drill nonplated': r'.*\.(rou)',
|
||||
'other unknown': r'.*(place|assembly|keep.?in|keep.?out).*\.art',
|
||||
'autoguess': r'.*\.art',
|
||||
'drill mech': r'.*\.(drl|rou)',
|
||||
'generic gerber': r'.*\.art',
|
||||
'excellon params': r'nc_param\.txt|ncdrill\.log|ncroute\.log',
|
||||
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
|
||||
'header regex': [['required,sufficient', r'.*\.art', r'G04 File Origin:\s+Cadence Allegro [0-9]+\.[0-9]+[-a-zA-Z0-9]*']],
|
||||
},
|
||||
|
||||
'pads': {
|
||||
# Pads also does not seem to have a factory-default naming schema. Or it has one but everyone ignores it.
|
||||
'autoguess': r'.*\.pho',
|
||||
'drill plated': r'.*\.drl',
|
||||
'generic gerber': r'.*\.pho',
|
||||
'drill mech': r'.*\.drl',
|
||||
},
|
||||
|
||||
'zuken': {
|
||||
'autoguess': r'.*\.fph',
|
||||
'generic gerber': r'.*\.fph',
|
||||
'gerber params': r'.*\.fpl',
|
||||
'drill unknown': r'.*\.fdr',
|
||||
'drill mech': r'.*\.fdr',
|
||||
'excellon params': r'.*\.fdl',
|
||||
'other netlist': r'.*\.ipc',
|
||||
'ipc-2581': r'.*\.xml',
|
||||
|
|
@ -39,7 +39,6 @@ from .cam import FileSettings, LazyCamFile
|
|||
from .layer_rules import MATCH_RULES
|
||||
from .utils import sum_bounds, setup_svg, MM, Tag, convex_hull
|
||||
from . import graphic_objects as go
|
||||
from . import apertures as ap
|
||||
from . import graphic_primitives as gp
|
||||
|
||||
|
||||
|
|
@ -66,27 +65,27 @@ DEFAULT_COLORS = {
|
|||
|
||||
class NamingScheme:
|
||||
kicad = {
|
||||
'top copper': '{board_name}-F_Cu.gbr',
|
||||
'top mask': '{board_name}-F_Mask.gbr',
|
||||
'top silk': '{board_name}-F_SilkS.gbr',
|
||||
'top paste': '{board_name}-F_Paste.gbr',
|
||||
'bottom copper': '{board_name}-B_Cu.gbr',
|
||||
'bottom mask': '{board_name}-B_Mask.gbr',
|
||||
'bottom silk': '{board_name}-B_SilkS.gbr',
|
||||
'bottom paste': '{board_name}-B_Paste.gbr',
|
||||
'inner copper': '{board_name}-In{layer_number}_Cu.gbr',
|
||||
'mechanical outline': '{board_name}-Edge_Cuts.gbr',
|
||||
'top copper': '{board_name}-F.Cu.gbr',
|
||||
'top mask': '{board_name}-F.Mask.gbr',
|
||||
'top silk': '{board_name}-F.SilkS.gbr',
|
||||
'top paste': '{board_name}-F.Paste.gbr',
|
||||
'bottom copper': '{board_name}-B.Cu.gbr',
|
||||
'bottom mask': '{board_name}-B.Mask.gbr',
|
||||
'bottom silk': '{board_name}-B.SilkS.gbr',
|
||||
'bottom paste': '{board_name}-B.Paste.gbr',
|
||||
'inner copper': '{board_name}-In{layer_number}.Cu.gbr',
|
||||
'mechanical outline': '{board_name}-Edge.Cuts.gbr',
|
||||
'drill unknown': '{board_name}.drl',
|
||||
'drill plated': '{board_name}-PTH.drl',
|
||||
'drill nonplated': '{board_name}-NPTH.drl',
|
||||
'other comments': '{board_name}-Cmts_User.gbr',
|
||||
'other drawings': '{board_name}-Dwgs_User.gbr',
|
||||
'top fabrication': '{board_name}-F_Fab.gbr',
|
||||
'bottom fabrication': '{board_name}-B_Fab.gbr',
|
||||
'top adhesive': '{board_name}-F_Adhes.gbr',
|
||||
'bottom adhesive': '{board_name}-B_Adhes.gbr',
|
||||
'top courtyard': '{board_name}-F_CrtYd.gbr',
|
||||
'bottom courtyard': '{board_name}-B_CrtYd.gbr',
|
||||
'other comments': '{board_name}-Cmts.User.gbr',
|
||||
'other drawings': '{board_name}-Dwgs.User.gbr',
|
||||
'top fabrication': '{board_name}-F.Fab.gbr',
|
||||
'bottom fabrication': '{board_name}-B.Fab.gbr',
|
||||
'top adhesive': '{board_name}-F.Adhes.gbr',
|
||||
'bottom adhesive': '{board_name}-B.Adhes.gbr',
|
||||
'top courtyard': '{board_name}-F.CrtYd.gbr',
|
||||
'bottom courtyard': '{board_name}-B.CrtYd.gbr',
|
||||
'other netlist': '{board_name}.d356',
|
||||
}
|
||||
|
||||
|
|
@ -113,61 +112,31 @@ class NamingScheme:
|
|||
}
|
||||
|
||||
|
||||
def apply_rules(filenames, rules):
|
||||
certain = False
|
||||
gen = {}
|
||||
already_matched = set()
|
||||
header_regex = rules.pop('header regex', [])
|
||||
header_regex_matched = [False] * len(header_regex)
|
||||
|
||||
file_headers = {}
|
||||
def get_header(path):
|
||||
if path not in file_headers:
|
||||
with open(path) as f:
|
||||
file_headers[path] = f.read(16384)
|
||||
return file_headers[path]
|
||||
|
||||
for layer, regex in rules.items():
|
||||
for fn in filenames:
|
||||
if fn in already_matched:
|
||||
continue
|
||||
|
||||
target = None
|
||||
if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)):
|
||||
if layer == 'inner copper':
|
||||
target = 'inner_' + ''.join(e or '' for e in m.groups()) + ' copper'
|
||||
else:
|
||||
target = layer
|
||||
|
||||
gen[target] = gen.get(target, []) + [fn]
|
||||
already_matched.add(fn)
|
||||
|
||||
for i, (match_type, layer_match, header_match) in enumerate(header_regex):
|
||||
if re.fullmatch(layer_match, fn.name, re.IGNORECASE) or (
|
||||
target is not None and re.fullmatch(layer_match, target, re.IGNORECASE)):
|
||||
if re.search(header_match, get_header(fn)):
|
||||
|
||||
if 'sufficient' in match_type:
|
||||
certain = True
|
||||
|
||||
header_regex_matched[i] = True
|
||||
|
||||
if any('required' in match_type and not match
|
||||
for match, (match_type, *_) in zip(header_regex_matched, header_regex)):
|
||||
return False, {}
|
||||
|
||||
return certain, gen
|
||||
|
||||
def _best_match(filenames):
|
||||
def _match_files(filenames):
|
||||
matches = {}
|
||||
for generator, rules in MATCH_RULES.items():
|
||||
certain, candidate = apply_rules(filenames, rules)
|
||||
already_matched = set()
|
||||
gen = {}
|
||||
matches[generator] = gen
|
||||
for layer, regex in rules.items():
|
||||
for fn in filenames:
|
||||
if fn in already_matched:
|
||||
continue
|
||||
|
||||
if certain:
|
||||
return generator, candidate
|
||||
if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)):
|
||||
if layer == 'inner copper':
|
||||
target = 'inner_' + ''.join(e or '' for e in m.groups()) + ' copper'
|
||||
else:
|
||||
target = layer
|
||||
|
||||
matches[generator] = candidate
|
||||
gen[target] = gen.get(target, []) + [fn]
|
||||
already_matched.add(fn)
|
||||
return matches
|
||||
|
||||
|
||||
def _best_match(filenames):
|
||||
matches = _match_files(filenames)
|
||||
matches = sorted(matches.items(), key=lambda pair: len(pair[1]))
|
||||
generator, files = matches[-1]
|
||||
return generator, files
|
||||
|
|
@ -274,7 +243,7 @@ def _layername_autoguesser(fn):
|
|||
elif re.search('film', fn):
|
||||
use = 'copper'
|
||||
|
||||
elif re.search('out(line)?|board.?geom(etry)?', fn):
|
||||
elif re.search('out(line)?', fn):
|
||||
use = 'outline'
|
||||
side = 'mechanical'
|
||||
|
||||
|
|
@ -304,9 +273,6 @@ def _sort_layername(val):
|
|||
assert side.startswith('inner_')
|
||||
return int(side[len('inner_'):])
|
||||
|
||||
def convex_hull_to_lines(points, unit=MM):
|
||||
for (x1, y1), (x2, y2) in zip(points, points[1:] + points):
|
||||
yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(unit(0.1, MM), unit=unit), unit=unit)
|
||||
|
||||
class LayerStack:
|
||||
""" :py:class:`LayerStack` represents a set of Gerber files that describe different layers of the same board.
|
||||
|
|
@ -419,7 +385,7 @@ class LayerStack:
|
|||
with ZipFile(file) as f:
|
||||
f.extractall(path=tmp_indir)
|
||||
|
||||
inst = kls.open_dir(tmp_indir, board_name=board_name, lazy=lazy, overrides=overrides, autoguess=autoguess)
|
||||
inst = kls.open_dir(tmp_indir, board_name=board_name, lazy=lazy)
|
||||
inst.tmpdir = tmpdir
|
||||
inst.original_path = Path(original_path or file)
|
||||
inst.was_zipped = True
|
||||
|
|
@ -455,7 +421,6 @@ class LayerStack:
|
|||
given value.
|
||||
:rtype: :py:class:`LayerStack`
|
||||
"""
|
||||
print_layermap = False
|
||||
|
||||
if autoguess:
|
||||
generator, filemap = _best_match(files)
|
||||
|
|
@ -480,51 +445,14 @@ class LayerStack:
|
|||
filemap[layer].remove(fn)
|
||||
filemap[layer] = filemap.get(layer, []) + [fn]
|
||||
|
||||
if 'autoguess' in filemap:
|
||||
warnings.warn(f'This generator ({generator}) often exports ambiguous filenames. Falling back to autoguesser for some files. Use at your own peril. Autoguessed files: {", ".join(f.name for f in filemap["autoguess"])}')
|
||||
print_layermap = True
|
||||
autoguess_filenames = filemap.pop('autoguess')
|
||||
|
||||
matched = set()
|
||||
for key, values in _do_autoguess(autoguess_filenames).items():
|
||||
filemap[key] = filemap.get(key, []) + values
|
||||
matched |= set(values)
|
||||
|
||||
if generator == 'allegro':
|
||||
# Allegro gerbers often contain the inner layers with completely random filenames and no indication of
|
||||
# layer ordering except for drawings in the mechanical files. We fall back to alphabetic ordering.
|
||||
for fn in autoguess_filenames:
|
||||
if fn not in matched:
|
||||
with open(fn) as f:
|
||||
header = f.read(16384)
|
||||
if re.search(r'G04 Layer:\s*ETCH/.*\*', header):
|
||||
filemap['unknown copper'] = filemap.get('unknown copper', []) + [fn]
|
||||
|
||||
if (unk := filemap.pop('unknown copper', None)):
|
||||
unk = sorted(unk, key=str)
|
||||
if 'top copper' not in filemap:
|
||||
filemap['top copper'], *unk = [unk]
|
||||
if 'bottom copper' not in filemap:
|
||||
*unk, filemap['bottom copper'] = [unk]
|
||||
|
||||
i = 1
|
||||
while unk and i < 128:
|
||||
key = f'inner_{i:02d} copper'
|
||||
if key not in filemap:
|
||||
filemap[key] = [unk.pop(0)]
|
||||
i += 1
|
||||
|
||||
if sum(len(files) for files in filemap.values()) < 6 and autoguess:
|
||||
warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
|
||||
generator = None
|
||||
print_layermap = True
|
||||
filemap = _do_autoguess(files)
|
||||
if len(filemap) < 6:
|
||||
raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap)
|
||||
|
||||
excellon_settings, external_tools = None, None
|
||||
automatch_drill_scale = False
|
||||
|
||||
if generator == 'geda':
|
||||
# geda is written by geniuses who waste no bytes of unnecessary output so it doesn't actually include the
|
||||
# number format in files that use imperial units. Unfortunately it also doesn't include any hints that the
|
||||
|
|
@ -542,22 +470,16 @@ class LayerStack:
|
|||
if (external_tools := parse_allegro_logfile(file.read_text())):
|
||||
break
|
||||
del filemap['excellon params']
|
||||
else:
|
||||
# Ignore if we can't find the param file -- maybe the user has convinced Allegro to actually put this
|
||||
# information into a comment, or maybe they have made Allegro just use decimal points like XNC does.
|
||||
# We'll run an automatic scale matching later.
|
||||
excellon_settings = FileSettings(number_format=(2, 4))
|
||||
automatch_drill_scale = True
|
||||
|
||||
print('remaining filemap')
|
||||
import pprint
|
||||
pprint.pprint(filemap)
|
||||
# Ignore if we can't find the param file -- maybe the user has convinced Allegro to actually put this
|
||||
# information into a comment, or maybe they have made Allegro just use decimal points like XNC does.
|
||||
|
||||
filemap = _do_autoguess([ f for files in filemap.values() for f in files ])
|
||||
if len(filemap) < 6:
|
||||
raise SystemError('Cannot figure out gerber file mapping')
|
||||
# FIXME use layer metadata from comments and ipc file if available
|
||||
|
||||
elif generator == 'zuken':
|
||||
filemap = _do_autoguess([ f for files in filemap.values() for f in files ])
|
||||
if len(filemap) < 6:
|
||||
raise SystemError('Cannot figure out gerber file mapping')
|
||||
# FIXME use layer metadata from comments and ipc file if available
|
||||
|
|
@ -581,12 +503,7 @@ class LayerStack:
|
|||
else:
|
||||
excellon_settings = None
|
||||
|
||||
ambiguous = [ f'{key} ({", ".join(x.name for x in value)})'
|
||||
for key, value in filemap.items()
|
||||
if len(value) > 1 and\
|
||||
not 'drill' in key and\
|
||||
not 'excellon' in key and\
|
||||
not key == 'other unknown']
|
||||
ambiguous = [ f'{key} ({", ".join(x.name for x in value)})' for key, value in filemap.items() if len(value) > 1 and not 'drill' in key ]
|
||||
if ambiguous:
|
||||
raise SystemError(f'Ambiguous layer names: {", ".join(ambiguous)}')
|
||||
|
||||
|
|
@ -595,11 +512,8 @@ class LayerStack:
|
|||
netlist = None
|
||||
layers = {} # { tuple(key.split()): None for key in STANDARD_LAYERS }
|
||||
for key, paths in filemap.items():
|
||||
if len(paths) > 1 and\
|
||||
not 'drill' in key and\
|
||||
not 'excellon' in key and\
|
||||
not key == 'other unknown':
|
||||
raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(map(str, value))}')
|
||||
if len(paths) > 1 and not 'drill' in key:
|
||||
raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(value)}')
|
||||
|
||||
for path in paths:
|
||||
id_result = identify_file(path.read_text())
|
||||
|
|
@ -660,72 +574,9 @@ class LayerStack:
|
|||
board_name = re.sub(r'^\W+', '', board_name)
|
||||
board_name = re.sub(r'\W+$', '', board_name)
|
||||
|
||||
if automatch_drill_scale:
|
||||
top_copper = layers[('top', 'copper')].to_excellon(errors='ignore', holes_only=True)
|
||||
|
||||
# precision is matching precision in mm
|
||||
def map_coords(obj, precision=0.01, scale=1):
|
||||
obj = obj.converted(MM)
|
||||
return round(obj.x*scale/precision), round(obj.y*scale/precision)
|
||||
|
||||
aper_coords = {map_coords(obj) for obj in top_copper.drills()}
|
||||
|
||||
for drill_file in [drill_pth, drill_npth, *drill_layers]:
|
||||
if not drill_file or not drill_pth.import_settings._file_has_fixed_width_coordinates:
|
||||
continue
|
||||
|
||||
scale_matches = {}
|
||||
for exp in range(-6, 6):
|
||||
scale = 10**exp
|
||||
hole_coords = {map_coords(obj, scale=scale) for obj in drill_file.drills()}
|
||||
|
||||
scale_matches[scale] = len(aper_coords - hole_coords), len(hole_coords - aper_coords)
|
||||
scales_out = [(max(a, b), scale) for scale, (a, b) in scale_matches.items()]
|
||||
_matches, scale = sorted(scales_out)[0]
|
||||
warnings.warn(f'Performing automatic alignment of poorly exported drill layer. Scale matching results: {scale_matches}. Chosen scale: {scale}')
|
||||
|
||||
# Note: This is only used with allegro files, which use decimal points and explicit units in their tool
|
||||
# definitions. Thus, we only scale object coordinates, and not apertures.
|
||||
for obj in drill_file.objects:
|
||||
obj.scale(scale)
|
||||
|
||||
stack = kls(layers, drill_pth, drill_npth, drill_layers, board_name=board_name,
|
||||
return kls(layers, drill_pth, drill_npth, drill_layers, board_name=board_name,
|
||||
original_path=original_path, was_zipped=was_zipped, generator=[*all_generator_hints, None][0])
|
||||
|
||||
if print_layermap:
|
||||
warnings.warn('Auto-guessed layer map:\n' + stack.format_layer_map())
|
||||
return stack
|
||||
|
||||
def format_layer_map(self):
|
||||
lines = []
|
||||
def print_layer(prefix, file):
|
||||
nonlocal lines
|
||||
if file is None:
|
||||
lines.append(f'{prefix} <not found>')
|
||||
else:
|
||||
lines.append(f'{prefix} {file.original_path.name} {file}')
|
||||
|
||||
lines.append(' Drill files:')
|
||||
print_layer(' Plated holes:', self.drill_pth)
|
||||
print_layer(' Nonplated holes:', self.drill_npth)
|
||||
for i, l in enumerate(self._drill_layers):
|
||||
print_layer(f' Additional drill layer {i}:', l)
|
||||
|
||||
print_layer(' Board outline:', self.get('mechanical outline'))
|
||||
|
||||
lines.append(' Soldermask:')
|
||||
print_layer(' Top:', self.get('top mask'))
|
||||
print_layer(' Bottom:', self.get('bottom mask'))
|
||||
|
||||
lines.append(' Silkscreen:')
|
||||
print_layer(' Top:', self.get('top silk'))
|
||||
print_layer(' Bottom:', self.get('bottom silk'))
|
||||
|
||||
lines.append(' Copper:')
|
||||
for (side, _use), layer in self.copper_layers:
|
||||
print_layer(f' {side}:', layer)
|
||||
return '\n'.join(lines)
|
||||
|
||||
def save_to_zipfile(self, path, prefix='', overwrite_existing=True, board_name=None, naming_scheme={},
|
||||
gerber_settings=None, excellon_settings=None):
|
||||
""" Save this board into a zip file at the given path. For other options, see
|
||||
|
|
@ -738,7 +589,10 @@ class LayerStack:
|
|||
|
||||
:param prefix: Store output files under the given prefix inside the zip file
|
||||
"""
|
||||
if path.is_file() and not overwrite_existing:
|
||||
if path.is_file():
|
||||
if overwrite_existing:
|
||||
path.unlink()
|
||||
else:
|
||||
raise ValueError('output zip file already exists and overwrite_existing is False')
|
||||
|
||||
if gerber_settings and not excellon_settings:
|
||||
|
|
@ -1012,7 +866,7 @@ class LayerStack:
|
|||
if use == 'mask':
|
||||
objects.insert(0, tag('path', id='outline-path', d=self.outline_svg_d(unit=svg_unit), fill='white'))
|
||||
layers.append(tag('g', objects, id=f'l-{side}-{use}', filter=f'url(#f-{use})',
|
||||
fill=default_fill, stroke=default_stroke, **stroke_attrs, fill_rule='evenodd',
|
||||
fill=default_fill, stroke=default_stroke, **stroke_attrs,
|
||||
**inkscape_attrs(f'{side} {use}'), transform=layer_transform))
|
||||
|
||||
for i, layer in enumerate(self.drill_layers):
|
||||
|
|
@ -1025,13 +879,7 @@ class LayerStack:
|
|||
id=f'l-mechanical-outline', **stroke_attrs, **inkscape_attrs(f'outline'),
|
||||
transform=layer_transform))
|
||||
|
||||
sc_y, tl_y = 1, 0
|
||||
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), *layers]
|
||||
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor="white", tag=tag, inkscape=inkscape)
|
||||
|
||||
def bounding_box(self, unit=MM, default=None):
|
||||
|
|
@ -1260,6 +1108,22 @@ class LayerStack:
|
|||
polys.append(' '.join(poly.path_d()) + ' Z')
|
||||
return ' '.join(polys)
|
||||
|
||||
def outline_convex_hull(self, tol=0.01, unit=MM):
|
||||
points = []
|
||||
for obj in self.outline.instance.objects:
|
||||
if isinstance(obj, go.Line):
|
||||
line = obj.as_primitive(unit)
|
||||
points.append((line.x1, line.y1))
|
||||
points.append((line.x2, line.y2))
|
||||
|
||||
elif isinstance(obj, go.Arc):
|
||||
for obj in obj.approximate(tol, unit):
|
||||
line = obj.as_primitive(unit)
|
||||
points.append((line.x1, line.y1))
|
||||
points.append((line.x2, line.y2))
|
||||
|
||||
return convex_hull(points)
|
||||
|
||||
def outline_polygons(self, tol=0.01, unit=MM):
|
||||
""" Iterator yielding this boards outline as a list of ordered :py:class:`~.graphic_objects.Arc` and
|
||||
:py:class:`~.graphic_objects.Line` objects. This method first sorts all lines and arcs on the outline layer into
|
||||
|
|
@ -1274,17 +1138,8 @@ class LayerStack:
|
|||
:param tol: :py:obj:`float` setting the tolerance below which two points are considered equal
|
||||
:param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). SVG document unit. Default: mm
|
||||
"""
|
||||
|
||||
if not self.outline:
|
||||
warnings.warn("Board has no outline layer, or the outline layer could not be identified by file name. Using the copper layers' convex hull instead.")
|
||||
points = sum((layer.instance.convex_hull(tol, unit) for (_side, _use), layer in self.copper_layers), start=[])
|
||||
yield list(convex_hull_to_lines(convex_hull(points), unit))
|
||||
return
|
||||
|
||||
maybe_allegro_hint = '' if self.generator != 'allegro' else ' This file looks like it was generated by Allegro/OrCAD. These tools produce quite mal-formed gerbers, and often export text on the outline layer. If you generated this file yourself, maybe try twiddling with the export settings.'
|
||||
polygons = []
|
||||
lines = [ obj.as_primitive(unit) for obj in self.outline.instance.objects if isinstance(obj, (go.Line, go.Arc)) ]
|
||||
lines = [ prim for prim in lines if not prim.is_zero_size() ]
|
||||
|
||||
by_x = sorted([ (obj.x1, obj) for obj in lines ] + [ (obj.x2, obj) for obj in lines ], key=lambda x: x[0])
|
||||
dist_sq = lambda x1, y1, x2, y2: (x2-x1)**2 + (y2-y1)**2
|
||||
|
|
@ -1311,14 +1166,13 @@ class LayerStack:
|
|||
j = 0 if d1 < d2 else 1
|
||||
|
||||
if (nearest, j) in joins and joins[(nearest, j)] != (cur, i):
|
||||
warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.{maybe_allegro_hint}')
|
||||
yield list(convex_hull_to_lines(self.outline.instance.convex_hull(tol, unit), unit))
|
||||
return
|
||||
warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.')
|
||||
return self.outline_convex_hull(tol, unit)
|
||||
|
||||
if (cur, i) in joins and joins[(cur, i)] != (nearest, j):
|
||||
warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(cur, i)]}. Falling back to returning the convex hull of the outline layer.{maybe_allegro_hint}')
|
||||
yield list(convex_hull_to_lines(self.outline.instance.convex_hull(tol, unit), unit))
|
||||
return
|
||||
warnings.warn(f'three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.')
|
||||
return self.outline_convex_hull(tol, unit)
|
||||
|
||||
|
||||
joins[(cur, i)] = (nearest, j)
|
||||
joins[(nearest, j)] = (cur, i)
|
||||
|
|
@ -21,7 +21,6 @@
|
|||
|
||||
import re
|
||||
import math
|
||||
import copy
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
import dataclasses
|
||||
|
|
@ -132,7 +131,7 @@ class GerberFile(CamFile):
|
|||
nonlocal cache, settings
|
||||
if isinstance(aperture, apertures.ApertureMacroInstance):
|
||||
macro = aperture.macro
|
||||
macro_def = macro.to_gerber(settings)
|
||||
macro_def = macro.to_gerber(unit=settings.unit)
|
||||
if macro_def not in cache:
|
||||
cache[macro_def] = macro
|
||||
|
||||
|
|
@ -152,7 +151,7 @@ class GerberFile(CamFile):
|
|||
|
||||
self.map_apertures(lookup)
|
||||
|
||||
def to_excellon(self, plated=None, errors='raise', holes_only=False):
|
||||
def to_excellon(self, plated=None, errors='raise'):
|
||||
""" Convert this excellon file into a :py:class:`~.excellon.ExcellonFile`. This will convert interpolated lines
|
||||
into slots, and circular aperture flashes into holes. Other features such as ``G36`` polygons or flashes with
|
||||
non-circular apertures will result in a :py:obj:`ValueError`. You can, of course, programmatically remove such
|
||||
|
|
@ -160,10 +159,7 @@ class GerberFile(CamFile):
|
|||
new_objs = []
|
||||
new_tools = {}
|
||||
for obj in self.objects:
|
||||
if holes_only and not isinstance(obj, go.Flash):
|
||||
continue
|
||||
|
||||
if not isinstance(obj, (go.Line, go.Arc, go.Flash)) or \
|
||||
if not (isinstance(obj, go.Line) or isinstance(obj, go.Arc) or isinstance(obj, go.Flash)) or \
|
||||
not isinstance(getattr(obj, 'aperture', None), apertures.CircleAperture):
|
||||
if errors == 'raise':
|
||||
raise ValueError(f'Cannot convert {obj} to excellon.')
|
||||
|
|
@ -287,24 +283,13 @@ class GerberFile(CamFile):
|
|||
|
||||
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)}
|
||||
|
||||
if settings.calculate_out_all_aperture_macros:
|
||||
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)}*%'
|
||||
for aperture, number in aperture_map.items():
|
||||
yield f'%ADD{number}{aperture.to_gerber(settings)}*%'
|
||||
|
||||
def warn(msg, kls=SyntaxWarning):
|
||||
warnings.warn(msg, kls)
|
||||
|
|
@ -375,8 +360,8 @@ class GerberFile(CamFile):
|
|||
def invert_polarity(self):
|
||||
""" Invert the polarity (color) of each object in this file. """
|
||||
for obj in self.objects:
|
||||
obj.polarity_dark = not obj.polarity_dark
|
||||
|
||||
obj.polarity_dark = not p.polarity_dark
|
||||
|
||||
|
||||
class GraphicsState:
|
||||
""" Internal class used to track Gerber processing state during import and export.
|
||||
|
|
@ -463,7 +448,7 @@ class GraphicsState:
|
|||
obj = go.Flash(*self.map_coord(*self.point), self.aperture,
|
||||
polarity_dark=self._polarity_dark,
|
||||
unit=self.unit,
|
||||
attrs=copy.copy(self.object_attrs))
|
||||
attrs=self.object_attrs)
|
||||
return obj
|
||||
|
||||
def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False):
|
||||
|
|
@ -489,13 +474,13 @@ class GraphicsState:
|
|||
raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)")
|
||||
|
||||
return go.Line(*old_point, *self.map_coord(*self.point), aperture,
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
|
||||
|
||||
else:
|
||||
if i is None and j is None:
|
||||
self.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values')
|
||||
return go.Line(*old_point, *self.map_coord(*self.point), aperture,
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
|
||||
|
||||
else:
|
||||
if i is None:
|
||||
|
|
@ -512,7 +497,7 @@ class GraphicsState:
|
|||
if not multi_quadrant:
|
||||
return go.Arc(*old_point, *new_point, *self.map_coord(i, j, relative=True),
|
||||
clockwise=clockwise, aperture=(self.aperture if aperture else None),
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
|
||||
|
||||
else:
|
||||
if math.isclose(old_point[0], new_point[0]) and math.isclose(old_point[1], new_point[1]):
|
||||
|
|
@ -525,7 +510,7 @@ class GraphicsState:
|
|||
|
||||
arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy,
|
||||
clockwise=clockwise, aperture=aperture,
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
|
||||
arcs = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ]
|
||||
arcs = sorted(arcs, key=lambda a: a.numeric_error())
|
||||
|
||||
|
|
@ -587,9 +572,9 @@ class GraphicsState:
|
|||
|
||||
def interpolation_mode_statement(self):
|
||||
return {
|
||||
InterpMode.LINEAR: 'G01*',
|
||||
InterpMode.CIRCULAR_CW: 'G02*',
|
||||
InterpMode.CIRCULAR_CCW: 'G03*'}[self.interpolation_mode]
|
||||
InterpMode.LINEAR: 'G01',
|
||||
InterpMode.CIRCULAR_CW: 'G02',
|
||||
InterpMode.CIRCULAR_CCW: 'G03'}[self.interpolation_mode]
|
||||
|
||||
|
||||
class GerberParser:
|
||||
|
|
@ -599,8 +584,6 @@ class GerberParser:
|
|||
NUMBER = r"[\+-]?\d+"
|
||||
DECIMAL = r"[\+-]?\d+([.]?\d+)?"
|
||||
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
|
||||
MAX_STEP_REPEAT_INSTANCES = 100000
|
||||
MAX_STEP_REPEAT_RESULT_OBJECTS = 100000
|
||||
|
||||
STATEMENT_REGEXES = {
|
||||
'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \
|
||||
|
|
@ -608,7 +591,6 @@ class GerberParser:
|
|||
fr"(?:D0?([123]))?$",
|
||||
'region_start': r'G36$',
|
||||
'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+)",
|
||||
# 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)",
|
||||
|
|
@ -629,9 +611,9 @@ class GerberParser:
|
|||
'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})(,(?P<modifiers>[^,%]*))?$",
|
||||
'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)",
|
||||
'siemens_garbage': r'^ICAS$',
|
||||
'step_repeat': fr'^SR(?P<coords>X(?P<X>[0-9]+)Y(?P<Y>[0-9]+)I(?P<I>{DECIMAL})J(?P<J>{DECIMAL}))?$',
|
||||
'old_unit':r'(?P<mode>G7[01])',
|
||||
'old_notation': r'(?P<mode>G9[01])',
|
||||
'eof': r"M0?[02]",
|
||||
'ignored': r"(?P<stmt>M01)",
|
||||
# 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>.*))?",
|
||||
|
|
@ -649,8 +631,6 @@ class GerberParser:
|
|||
self.aperture_map = {}
|
||||
self.aperture_macros = {}
|
||||
self.current_region = None
|
||||
self.step_repeat_coords = None
|
||||
self.step_repeat_objects = None
|
||||
self.eof_found = False
|
||||
self.multi_quadrant_mode = None # used only for syntax checking
|
||||
self.macros = {}
|
||||
|
|
@ -793,10 +773,7 @@ class GerberParser:
|
|||
# in multi-quadrant mode this may return None if start and end point of the arc are the same.
|
||||
obj = self.graphics_state.interpolate(x, y, i, j, multi_quadrant=self.multi_quadrant_mode)
|
||||
if obj is not None:
|
||||
if self.step_repeat_objects:
|
||||
self.step_repeat_objects.append(obj)
|
||||
else:
|
||||
self.target.objects.append(obj)
|
||||
self.target.objects.append(obj)
|
||||
else:
|
||||
obj = self.graphics_state.interpolate(x, y, i, j, aperture=False, multi_quadrant=self.multi_quadrant_mode)
|
||||
if obj is not None:
|
||||
|
|
@ -807,21 +784,14 @@ class GerberParser:
|
|||
if self.current_region:
|
||||
# Start a new region for every outline. As gerber has no concept of fill rules or winding numbers,
|
||||
# it does not make a graphical difference, and it makes the implementation slightly easier.
|
||||
if self.step_repeat_objects:
|
||||
self.step_repeat_objects.append(self.current_region)
|
||||
else:
|
||||
self.target.objects.append(self.current_region)
|
||||
self.target.objects.append(self.current_region)
|
||||
self.current_region = go.Region(
|
||||
polarity_dark=self.graphics_state.polarity_dark,
|
||||
unit=self.file_settings.unit)
|
||||
|
||||
elif op == '3':
|
||||
if self.current_region is None:
|
||||
obj = self.graphics_state.flash(x, y)
|
||||
if self.step_repeat_objects:
|
||||
self.step_repeat_objects.append(obj)
|
||||
else:
|
||||
self.target.objects.append(obj)
|
||||
self.target.objects.append(self.graphics_state.flash(x, y))
|
||||
else:
|
||||
raise SyntaxError('DO3 flash statement inside region')
|
||||
|
||||
|
|
@ -862,11 +832,6 @@ class GerberParser:
|
|||
if match['shape'] in 'RO' and (math.isclose(modifiers[0], 0) or math.isclose(modifiers[1], 0)):
|
||||
self.warn('Definition of zero-width and/or zero-height rectangle or obround aperture. This is invalid according to spec.' )
|
||||
|
||||
# Polygon aperture rotation is specified in degrees, but radians are easier to work with
|
||||
if match['shape'] == 'P':
|
||||
if len(modifiers) > 2:
|
||||
modifiers[2] = math.radians(modifiers[2])
|
||||
|
||||
new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=tuple(self.aperture_attrs.items()),
|
||||
original_number=number)
|
||||
|
||||
|
|
@ -1083,40 +1048,11 @@ class GerberParser:
|
|||
|
||||
else:
|
||||
target = {'TF': self.file_attrs, 'TO': self.graphics_state.object_attrs, 'TA': self.aperture_attrs}[match['type']]
|
||||
target[match['name']] = tuple(match['value'].split(',')) if match['value'] else ()
|
||||
target[match['name']] = tuple(match['value'].split(','))
|
||||
|
||||
if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']:
|
||||
self.generator_hints.append('eagle')
|
||||
|
||||
def _parse_step_repeat(self, match):
|
||||
if match['coords']:
|
||||
if self.step_repeat_coords:
|
||||
raise SyntaxError('SR step-repeat called inside ongoing SR step-repeat')
|
||||
|
||||
x, y = int(match['X']), int(match['Y'])
|
||||
i, j = float(match['I']), float(match['J'])
|
||||
if x < 1 or y < 1:
|
||||
raise SyntaxError('SR step-repeat X and Y values must be at least 1')
|
||||
if x * y > self.MAX_STEP_REPEAT_INSTANCES:
|
||||
raise SyntaxError('SR step-repeat expands to too many instances')
|
||||
|
||||
self.step_repeat_coords = (x, y, i, j)
|
||||
self.step_repeat_objects = []
|
||||
|
||||
else:
|
||||
x, y, i, j = self.step_repeat_coords
|
||||
if len(self.step_repeat_objects) * x * y > self.MAX_STEP_REPEAT_RESULT_OBJECTS:
|
||||
raise SyntaxError('SR step-repeat expands to too many objects')
|
||||
|
||||
for obj in self.step_repeat_objects:
|
||||
for nx in range(x):
|
||||
for ny in range(y):
|
||||
new_obj = copy.copy(obj)
|
||||
new_obj.offset(i * nx, j * ny)
|
||||
self.target.objects.append(new_obj)
|
||||
self.step_repeat_coords = None
|
||||
self.step_repeat_objects = None
|
||||
|
||||
def _parse_eof(self, match):
|
||||
self.eof_found = True
|
||||
|
||||
64
gerbonara/tests/conftest.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from itertools import chain
|
||||
|
||||
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('*.pretty/*.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)))
|
||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 556 B After Width: | Height: | Size: 556 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
308
gerbonara/tests/image_support.py
Normal 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))
|
||||
|
||||
|
||||