Compare commits

..

29 commits
main ... wip

Author SHA1 Message Date
jaseg
b60ae26db2 protoserve: Fix bugs, make gerber link more visible 2023-04-09 18:46:36 +02:00
jaseg
f74bd30c0f protoserve: Gerber download works 2023-04-09 17:53:54 +02:00
jaseg
c9dff5450f protogen web interface works 2023-04-09 17:24:50 +02:00
jaseg
3b5fb41ecb protoserve WIP 2023-04-07 19:27:48 +02:00
jaseg
10cd29b96c protoboard webthing WIP 2023-04-06 22:19:59 +02:00
jaseg
48d4aeee94 Make SVG export even smaller 2023-04-06 16:41:10 +02:00
jaseg
6378a91f36 Make generated pretty SVGs smaller 2023-04-06 16:27:57 +02:00
jaseg
ef2864cfb3 Copper fill WIP 2023-04-06 15:17:37 +02:00
jaseg
0a059353d7 Improve protoboard row/column numbering 2023-04-05 18:56:29 +02:00
jaseg
51327ccfeb cad: Add pad numbering for protoboards 2023-04-05 17:44:31 +02:00
jaseg
c10616094c Add RF protoboard 2023-04-05 16:36:30 +02:00
jaseg
4c558f8111 Add missing protoboard file, add powered proto layout 2023-04-05 16:06:03 +02:00
jaseg
ee0c1d38e6 Fix aperture macro multiplication syntax 2023-04-05 14:15:33 +02:00
jaseg
513f6ebf1b Fix rectangle aperture rotation 2023-04-05 14:15:22 +02:00
jaseg
5cf9837484 Add more protoboard layouts 2023-04-05 14:01:40 +02:00
jaseg
d437e06325 Initial protoboard generation working 2023-04-05 01:29:33 +02:00
jaseg
495ae6e932 cad: Fix outline reconstruction and add text feature 2023-04-04 20:06:16 +02:00
jaseg
15867450d9 cad: Finish initial board support 2023-04-04 19:06:37 +02:00
jaseg
82fcc24456 Various convenience improvements, and make board name guessing really smart 2023-04-04 19:06:04 +02:00
jaseg
a877261256 cad: Add trace corner rounding function 2023-04-04 14:05:54 +02:00
jaseg
db2bacebc7 Add missing WIP changes 2023-04-04 01:35:38 +02:00
jaseg
8d4430ea61 Add beginnings of CAD module 2023-04-04 01:31:19 +02:00
jaseg
909766a3a0 Fix extraneous tool selection codes in merged Excellon files 2023-03-31 22:34:28 +02:00
jaseg
845224e2d6 Fix failing tests 2023-03-31 22:31:19 +02:00
jaseg
0ae72f3159 Improve layer stack handling 2023-03-31 16:31:44 +02:00
jaseg
84ec7b26e6 Add convex hull and point in polygon functions 2023-03-31 14:12:26 +02:00
jaseg
36e355cbd8 Improve drill layer handling
Now, drill_pth and drill_npth contain those layers where they match, and
everything else is put in _drill_layers. The @property drill_layers now
returns everything.
2023-03-31 14:11:30 +02:00
jaseg
0037195543 Dedup both Excellon and Gerber tools during write 2023-03-24 00:12:50 +01:00
jaseg
2a3deb6c00 Fix crash in gerber to excellon conversion 2023-03-23 23:51:36 +01:00
584 changed files with 2928 additions and 250891 deletions

2
.gitignore vendored
View file

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

View file

@ -14,54 +14,69 @@ 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:
- 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
- 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:ubuntu2004:
stage: test
image: "registry.gitlab.com/gerbolyze/build-containers/ubuntu:20.04"
script:
- python3 -m pip install pytest beautifulsoup4 pillow numpy slugify lxml click scipy
- 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 +99,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

View file

@ -1,45 +1,45 @@
PYTHON ?= python
PYTEST ?= pytest
SPHINX_BUILD ?= sphinx-build
.DEFAULT_GOAL := help
.PHONY: clean docs test test-coverage install sdist bdist_wheel upload testupload help
PYTHON ?= python
PYTEST ?= pytest
SPHINX_BUILD ?= sphinx-build
all: docs sdist bdist_wheel
clean: ## Clean up project directory
.PHONY: clean
clean:
find . -name '*.pyc' -delete
rm -rf *.egg-info
rm -f .coverage
rm -f coverage.xml
rm -rf docs/_build
docs: ## Generate documentation
.PHONY: docs
docs:
sphinx-build -E docs docs/_build
test: ## Run tests
.PHONY: test
test:
$(PYTEST)
test-coverage: ## Generate coverage
.PHONY: test-coverage
test-coverage:
rm -f .coverage
rm -f coverage.xml
$(PYTEST) --cov=./ --cov-report=xml
install: ## Install locally
.PHONY: install
install:
PYTHONPATH=. $(PYTHON) setup.py install
sdist: ## Build source distribution
sdist:
python3 setup.py sdist
bdist_wheel: ## Build binary distribution
bdist_wheel:
python3 setup.py bdist_wheel
upload: sdist bdist_wheel ## Upload Python package to PyPI
upload: sdist bdist_wheel
twine upload -s -i gerbonara@jaseg.de --config-file ~/.pypirc --skip-existing --repository pypi dist/*
testupload: sdist bdist_wheel ## Upload Python package to test PyPI
testupload: sdist bdist_wheel
twine upload --config-file ~/.pypirc --skip-existing --repository testpypi dist/*
help: ## Display this help
@grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

View file

@ -1,5 +1,5 @@
[![pipeline status](https://gitlab.com/gerbolyze/gerbonara/badges/master/pipeline.svg)](https://gitlab.com/gerbolyze/gerbonara/commits/master)
[![coverage report](https://gitlab.com/gerbolyze/gerbonara/badges/master/coverage.svg)](https://gitlab.com/gerbolyze/gerbonara/commits/master)
[![pipeline status](https://gitlab.com/gerbonara/gerbonara/badges/master/pipeline.svg)](https://gitlab.com/gerbonara/gerbonara/commits/master)
[![coverage report](https://gitlab.com/gerbonara/gerbonara/badges/master/coverage.svg)](https://gitlab.com/gerbonara/gerbonara/commits/master)
[![pypi](https://img.shields.io/pypi/v/gerbonara)](https://pypi.org/project/gerbonara/)
[![aur](https://img.shields.io/aur/version/python-gerbonara)](https://aur.archlinux.org/packages/python-gerbonara/)
@ -23,7 +23,7 @@ yay -S python-gerbonara
Python:
```
pipx install gerbonara
pip install --user gerbonara
```
# Documentation and Examples

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

View file

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

View file

@ -48,7 +48,6 @@ Features
cli
api-concepts
examples
file-api
object-api
apertures
@ -71,12 +70,10 @@ 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')
You can find some more elaborate examples in this doc's :ref:`Examples section<examples-doc>`.
Command-Line Interface
======================

View file

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

View file

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

View file

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

View file

@ -29,7 +29,5 @@ from .rs274x import GerberFile
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__ = '0.13.0'

9
gerbonara/__main__.py Normal file
View file

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

View file

@ -0,0 +1,211 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
import operator
import re
import ast
from ..utils import MM, Inch, MILLIMETERS_PER_INCH
def expr(obj):
return obj if isinstance(obj, Expression) else ConstantExpression(obj)
class Expression:
def optimized(self, variable_binding={}):
return self
def __str__(self):
return f'<{self.to_gerber()}>'
def __repr__(self):
return f'<E {self.to_gerber()}>'
def converted(self, unit):
return self
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 variables: {expr} with variables {variable_binding}')
return expr.value
def __add__(self, other):
return OperatorExpression(operator.add, self, expr(other)).optimized()
def __radd__(self, other):
return expr(other) + self
def __sub__(self, other):
return OperatorExpression(operator.sub, self, expr(other)).optimized()
def __rsub__(self, other):
return expr(other) - self
def __mul__(self, other):
return OperatorExpression(operator.mul, self, expr(other)).optimized()
def __rmul__(self, other):
return expr(other) * self
def __truediv__(self, other):
return OperatorExpression(operator.truediv, self, expr(other)).optimized()
def __rtruediv__(self, other):
return expr(other) / self
def __neg__(self):
return 0 - self
def __pos__(self):
return self
class UnitExpression(Expression):
def __init__(self, expr, unit):
self._expr = expr
self.unit = unit
def to_gerber(self, unit=None):
return self.converted(unit).optimized().to_gerber()
def __eq__(self, other):
return type(other) == type(self) and \
self.unit == other.unit and\
self._expr == other._expr
def __str__(self):
return f'<{self._expr.to_gerber()} {self.unit}>'
def __repr__(self):
return f'<UE {self._expr.to_gerber()} {self.unit}>'
def converted(self, unit):
if self.unit is None or unit is None or self.unit == unit:
return self._expr
elif MM == unit:
return self._expr * MILLIMETERS_PER_INCH
elif Inch == unit:
return self._expr / MILLIMETERS_PER_INCH
else:
raise ValueError(f'invalid unit {unit}, must be "inch" or "mm".')
def __add__(self, other):
if not isinstance(other, UnitExpression):
raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.')
if self.unit == other.unit or self.unit is None or other.unit is None:
return UnitExpression(self._expr + other._expr, self.unit)
if other.unit == 'mm': # -> and self.unit == 'inch'
return UnitExpression(self._expr + (other._expr / MILLIMETERS_PER_INCH), self.unit)
else: # other.unit == 'inch' and self.unit == 'mm'
return UnitExpression(self._expr + (other._expr * MILLIMETERS_PER_INCH), self.unit)
def __radd__(self, other):
# left hand side cannot have been an UnitExpression or __radd__ would not have been called
raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.')
def __sub__(self, other):
return (self + (-other)).optimize()
def __rsub__(self, other):
# see __radd__ above
raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.')
def __mul__(self, other):
return UnitExpression(self._expr * other, self.unit)
def __rmul__(self, other):
return UnitExpression(other * self._expr, self.unit)
def __truediv__(self, other):
return UnitExpression(self._expr / other, self.unit)
def __rtruediv__(self, other):
return UnitExpression(other / self._expr, self.unit)
def __neg__(self):
return UnitExpression(-self._expr, self.unit)
def __pos__(self):
return self
class ConstantExpression(Expression):
def __init__(self, value):
self.value = value
def __float__(self):
return float(self.value)
def __eq__(self, other):
return type(self) == type(other) and self.value == other.value
def to_gerber(self, _unit=None):
return f'{self.value:.6f}'.rstrip('0').rstrip('.')
class VariableExpression(Expression):
def __init__(self, number):
self.number = number
def optimized(self, variable_binding={}):
if self.number in variable_binding:
return ConstantExpression(variable_binding[self.number])
return self
def __eq__(self, other):
return type(self) == type(other) and \
self.number == other.number
def to_gerber(self, _unit=None):
return f'${self.number}'
class OperatorExpression(Expression):
def __init__(self, op, l, r):
self.op = op
self.l = ConstantExpression(l) if isinstance(l, (int, float)) else l
self.r = ConstantExpression(r) if isinstance(r, (int, float)) else r
def __eq__(self, other):
return type(self) == type(other) and \
self.op == other.op and \
self.l == other.l and \
self.r == other.r
def optimized(self, variable_binding={}):
l = self.l.optimized(variable_binding)
r = self.r.optimized(variable_binding)
if self.op in (operator.add, operator.mul):
if id(r) < id(l):
l, r = r, l
if isinstance(l, ConstantExpression) and isinstance(r, ConstantExpression):
return ConstantExpression(self.op(float(l), float(r)))
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})'
if isinstance(self.r, OperatorExpression):
rval = f'({rval})'
op = {operator.add: '+',
operator.sub: '-',
operator.mul: 'x',
operator.truediv: '/'} [self.op]
return f'{lval}{op}{rval}'

View file

@ -0,0 +1,201 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
import operator
import re
import ast
import copy
import math
from . import primitive as ap
from .expression import *
from ..utils import MM
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)
class ApertureMacro:
def __init__(self, name=None, primitives=None, variables=None):
self._name = name
self.comments = []
self.variables = variables or {}
self.primitives = primitives or []
@classmethod
def parse_macro(cls, name, body, unit):
macro = cls(name)
blocks = body.split('*')
for block in blocks:
if not (block := block.strip()): # empty block
continue
if block.startswith('0 '): # comment
macro.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 macro.variables:
raise SyntaxError(f'Re-definition of aperture macro variable {number} inside macro')
macro.variables[number] = _parse_expression(expr)
else: # primitive
primitive, *args = block.split(',')
args = [ _parse_expression(arg) for arg in args ]
primitive = ap.PRIMITIVE_CLASSES[int(primitive)](unit=unit, args=args)
macro.primitives.append(primitive)
return macro
@property
def name(self):
if self._name is not None:
return self._name
else:
return f'gn_{hash(self)}'
@name.setter
def name(self, name):
self._name = name
def __str__(self):
return f'<Aperture macro {self.name}, variables {str(self.variables)}, primitives {self.primitives}>'
def __repr__(self):
return str(self)
def __eq__(self, other):
return hasattr(other, 'to_gerber') and self.to_gerber() == other.to_gerber()
def __hash__(self):
return hash(self.to_gerber())
def dilated(self, offset, unit=MM):
dup = copy.deepcopy(self)
new_primitives = []
for primitive in dup.primitives:
try:
if primitive.exposure.calculate():
primitive.dilate(offset, unit)
new_primitives.append(primitive)
except IndexError:
warnings.warn('Cannot dilate aperture macro primitive with exposure value computed from macro variable.')
pass
dup.primitives = new_primitives
return dup
def to_gerber(self, unit=None):
comments = [ c.to_gerber() for c in self.comments ]
variable_defs = [ f'${var.to_gerber(unit)}={expr}' for var, expr in self.variables.items() ]
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 = dict(self.variables)
for number, value in enumerate(parameters, start=1):
if number in variables:
raise SyntaxError(f'Re-definition of aperture macro variable {i} 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):
dup = copy.deepcopy(self)
for primitive in dup.primitives:
# aperture macro primitives use degree counter-clockwise, our API uses radians clockwise
primitive.rotation -= rad_to_deg(angle)
return dup
def scaled(self, scale):
dup = copy.deepcopy(self)
for primitive in dup.primitives:
primitive.scale(scale)
return dup
var = VariableExpression
deg_per_rad = 180 / math.pi
class GenericMacros:
_generic_hole = lambda n: [
ap.Circle('mm', [0, var(n), 0, 0]),
ap.CenterLine('mm', [0, var(n), var(n+1), 0, 0, var(n+2) * -deg_per_rad])]
# 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)), 0]),
ap.Circle('mm', [1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), 0]),
ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), 0]),
ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), 0]),
*_generic_hole(4)])
# w must be larger than h
obround = ApertureMacro('GNO', [
ap.CenterLine('mm', [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]),
ap.Circle('mm', [1, var(2), +var(1)/2, 0, var(5) * -deg_per_rad]),
ap.Circle('mm', [1, var(2), -var(1)/2, 0, var(5) * -deg_per_rad]),
*_generic_hole(3) ])
polygon = ApertureMacro('GNP', [
ap.Polygon('mm', [1, var(2), 0, 0, var(1), var(3) * -deg_per_rad]),
ap.Circle('mm', [0, var(4), 0, 0])])
if __name__ == '__main__':
import sys
#for line in sys.stdin:
#expr = _parse_expression(line.strip())
#print(expr, '->', expr.optimized())
for primitive in parse_macro(sys.stdin.read(), 'mm'):
print(primitive)

View file

@ -0,0 +1,306 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de>
import warnings
import contextlib
import math
from .expression import Expression, UnitExpression, ConstantExpression, expr
from .. import graphic_primitives as gp
def point_distance(a, b):
x1, y1 = a
x2, y2 = b
return math.sqrt((x2 - x1)**2 + (y2 - y1)**2)
def deg_to_rad(a):
return (a / 180) * math.pi
class Primitive:
def __init__(self, unit, args):
self.unit = unit
if len(args) > len(type(self).__annotations__):
raise ValueError(f'Too many arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
for arg, (name, fieldtype) in zip(args, type(self).__annotations__.items()):
arg = expr(arg) # convert int/float to Expression object
if fieldtype == UnitExpression:
setattr(self, name, UnitExpression(arg, unit))
else:
setattr(self, name, arg)
for name in type(self).__annotations__:
if not hasattr(self, name):
raise ValueError(f'Too few arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
def to_gerber(self, unit=None):
return f'{self.code},' + ','.join(
getattr(self, name).to_gerber(unit) for name in type(self).__annotations__)
def __str__(self):
attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__)
return f'<{type(self).__name__} {attrs}>'
def __repr__(self):
return str(self)
class Calculator:
def __init__(self, instance, variable_binding={}, unit=None):
self.instance = instance
self.variable_binding = variable_binding
self.unit = unit
def __enter__(self):
return self
def __exit__(self, _type, _value, _traceback):
pass
def __getattr__(self, name):
return getattr(self.instance, name).calculate(self.variable_binding, self.unit)
def __call__(self, expr):
return expr.calculate(self.variable_binding, self.unit)
class Circle(Primitive):
code = 1
exposure : Expression
diameter : UnitExpression
# center x/y
x : UnitExpression
y : UnitExpression
rotation : Expression = None
def __init__(self, unit, args):
super().__init__(unit, args)
if self.rotation is None:
self.rotation = ConstantExpression(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 = gp.rotate_point(calc.x, calc.y, deg_to_rad(calc.rotation) + rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def dilate(self, offset, unit):
self.diameter += UnitExpression(offset, unit)
def scale(self, scale):
self.x *= UnitExpression(scale)
self.y *= UnitExpression(scale)
self.diameter *= UnitExpression(scale)
class VectorLine(Primitive):
code = 20
exposure : Expression
width : UnitExpression
start_x : UnitExpression
start_y : UnitExpression
end_x : UnitExpression
end_y : UnitExpression
rotation : Expression
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
with self.Calculator(self, variable_binding, unit) as calc:
center_x = (calc.end_x + calc.start_x) / 2
center_y = (calc.end_y + calc.start_y) / 2
delta_x = calc.end_x - calc.start_x
delta_y = calc.end_y - calc.start_y
length = point_distance((calc.start_x, calc.start_y), (calc.end_x, calc.end_y))
center_x, center_y = center_x+offset[0], center_y+offset[1]
rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x)
return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation,
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def dilate(self, offset, unit):
self.width += UnitExpression(2*offset, unit)
def scale(self, scale):
self.start_x *= UnitExpression(scale)
self.start_y *= UnitExpression(scale)
self.end_x *= UnitExpression(scale)
self.end_y *= UnitExpression(scale)
class CenterLine(Primitive):
code = 21
exposure : Expression
width : UnitExpression
height : UnitExpression
# center x/y
x : UnitExpression
y : UnitExpression
rotation : Expression
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 = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
w, h = calc.width, calc.height
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
def dilate(self, offset, unit):
self.width += UnitExpression(2*offset, unit)
def scale(self, scale):
self.width *= UnitExpression(scale)
self.height *= UnitExpression(scale)
self.x *= UnitExpression(scale)
self.y *= UnitExpression(scale)
class Polygon(Primitive):
code = 5
exposure : Expression
n_vertices : Expression
# center x/y
x : UnitExpression
y : UnitExpression
diameter : UnitExpression
rotation : Expression
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 = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
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 dilate(self, offset, unit):
self.diameter += UnitExpression(2*offset, unit)
def scale(self, scale):
self.diameter *= UnitExpression(scale)
self.x *= UnitExpression(scale)
self.y *= UnitExpression(scale)
class Thermal(Primitive):
code = 7
exposure : Expression
# center x/y
x : UnitExpression
y : UnitExpression
d_outer : UnitExpression
d_inner : UnitExpression
gap_w : UnitExpression
rotation : Expression
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 = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
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, 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):
# 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):
self.d_outer *= UnitExpression(scale)
self.d_inner *= UnitExpression(scale)
self.gap_w *= UnitExpression(scale)
self.x *= UnitExpression(scale)
self.y *= UnitExpression(scale)
class Outline(Primitive):
code = 4
def __init__(self, unit, args):
if len(args) < 11:
raise ValueError(f'Invalid aperture macro outline primitive, not enough parameters ({len(args)}).')
if len(args) > 5004:
raise ValueError(f'Invalid aperture macro outline primitive, too many points ({len(args)//2-2}).')
self.exposure = args.pop(0)
# length arg must not contain variables (that would not make sense)
length_arg = args.pop(0).calculate()
if length_arg != len(args)//2-1:
raise ValueError(f'Invalid aperture macro outline primitive, given size {length_arg} does not match length of coordinate list({len(args)//2-1}).')
if len(args) % 2 == 1:
self.rotation = args.pop()
else:
self.rotation = ConstantExpression(0.0)
if args[0] != args[-2] or args[1] != args[-1]:
raise ValueError(f'Invalid aperture macro outline primitive, polygon is not closed {args[2:4], args[-3:-1]}')
self.coords = [(UnitExpression(x, unit), UnitExpression(y, unit)) for x, y in zip(args[0::2], args[1::2])]
def __str__(self):
return f'<Outline {len(self.coords)} points>'
def to_gerber(self, unit=None):
coords = ','.join(coord.to_gerber(unit) for xy in self.coords for coord in xy)
return f'{self.code},{self.exposure.to_gerber()},{len(self.coords)-1},{coords},{self.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:
rotation += deg_to_rad(calc.rotation)
bound_coords = [ gp.rotate_point(calc(x), calc(y), rotation, 0, 0) for x, y in self.coords ]
bound_coords = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ]
bound_radii = [None] * len(bound_coords)
return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))]
def dilate(self, offset, unit):
# we would need a whole polygon offset/clipping library here
warnings.warn('Attempted dilation of macro aperture outline primitive. This is not supported.')
def scale(self, scale):
self.coords = [(x*UnitExpression(scale), y*UnitExpression(scale)) for x, y in self.coords]
class Comment:
code = 0
def __init__(self, comment):
self.comment = comment
def to_gerber(self, unit=None):
return f'0 {self.comment}'
def scale(self, scale):
pass
PRIMITIVE_CLASSES = {
**{cls.code: cls for cls in [
Comment,
Circle,
VectorLine,
CenterLine,
Outline,
Polygon,
Thermal,
]},
# alternative codes
2: VectorLine,
}

View file

@ -16,18 +16,21 @@
# limitations under the License.
#
import warnings
import math
from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY
from functools import lru_cache
from dataclasses import dataclass, replace, field, fields, InitVar
from .utils import LengthUnit, MM, Inch, sum_bounds
from .aperture_macros.parse import GenericMacros
from .utils import MM, Inch
from . import graphic_primitives as gp
def _flash_hole(self, x, y, unit=None, polarity_dark=True):
if self.hole_dia is not None:
if getattr(self, 'hole_rect_h', None) is not None:
w, h = self.unit.convert_to(unit, self.hole_dia), self.unit.convert_to(unit, self.hole_rect_h)
return [*self._primitives(x, y, unit, polarity_dark),
gp.Rectangle(x, y, w, h, rotation=self.rotation, polarity_dark=(not polarity_dark))]
elif self.hole_dia is not None:
return [*self._primitives(x, y, unit, polarity_dark),
gp.Circle(x, y, self.unit.convert_to(unit, self.hole_dia/2), polarity_dark=(not polarity_dark))]
else:
@ -37,7 +40,7 @@ def _strip_right(*args):
args = list(args)
while args and args[-1] is None:
args.pop()
return tuple(args)
return args
def _none_close(a, b):
if a is None and b is None:
@ -54,14 +57,39 @@ class Length:
def __init__(self, obj_type):
self.type = obj_type
@dataclass(frozen=True, slots=True)
@dataclass
class Aperture:
""" Base class for all apertures. """
_ : KW_ONLY
unit: LengthUnit = None
attrs: tuple = None
original_number: int = field(default=None, hash=False, compare=False)
_bounding_box: tuple = field(default=None, hash=False, compare=False)
# hackety hack: Work around python < 3.10 not having dataclasses.KW_ONLY.
#
# For details, refer to graphic_objects.py
def __init_subclass__(cls):
#: :py:class:`gerbonara.utils.LengthUnit` used for all length fields of this aperture.
cls.unit = None
#: GerberX2 attributes of this aperture. Note that this will only contain aperture attributes, not file attributes.
#: File attributes are stored in the :py:attr:`~.GerberFile.attrs` of the :py:class:`.GerberFile`.
cls.attrs = field(default_factory=dict)
#: Aperture index this aperture had when it was read from the Gerber file. This field is purely informational since
#: apertures are de-duplicated and re-numbered when writing a Gerber file. For `D10`, this field would be `10`. When
#: you programmatically create a new aperture, you do not have to set this.
cls.original_number = None
d = {'unit': str, 'attrs': dict, 'original_number': int}
if hasattr(cls, '__annotations__'):
cls.__annotations__.update(d)
else:
cls.__annotations__ = d
@property
def hole_shape(self):
""" Get shape of hole based on :py:attr:`hole_dia` and :py:attr:`hole_rect_h`: "rect" or "circle" or None. """
if getattr(self, 'hole_rect_h') is not None:
return 'rect'
elif getattr(self, 'hole_dia') is not None:
return 'circle'
else:
return None
def _params(self, unit=None):
out = []
@ -90,12 +118,6 @@ class Aperture:
"""
return self._primitives(x, y, unit, polarity_dark)
def bounding_box(self, unit=None):
if self._bounding_box is None:
object.__setattr__(self, '_bounding_box',
sum_bounds((prim.bounding_box() for prim in self.flash(0, 0, MM, True))))
return MM.convert_bounds_to(unit, self._bounding_box)
def equivalent_width(self, unit=None):
""" Get the width of a line interpolated using this aperture in the given :py:class:`~.LengthUnit`.
@ -108,12 +130,16 @@ class Aperture:
:rtype: str
"""
# Hack: The standard aperture shapes C, R, O do not have a rotation parameter. To make this API easier to use,
# we emulate this parameter. Our circle, rectangle and oblong classes below have a rotation parameter. Only at
# export time during to_gerber, this parameter is evaluated.
unit = settings.unit if settings else None
params = 'X'.join(f'{float(par):.4}' for par in self._params(unit) if par is not None)
actual_inst = self.rotated()
params = 'X'.join(f'{float(par):.4}' for par in actual_inst._params(unit) if par is not None)
if params:
return f'{self._gerber_shape_code},{params}'
return f'{actual_inst._gerber_shape_code},{params}'
else:
return self._gerber_shape_code
return actual_inst._gerber_shape_code
def to_macro(self):
""" Convert this :py:class:`.Aperture` into an :py:class:`.ApertureMacro` inside an
@ -121,10 +147,24 @@ class Aperture:
"""
raise NotImplementedError()
@dataclass(frozen=True, slots=True)
def __eq__(self, other):
""" Compare two apertures. Apertures are compared based on their Gerber representation. Two apertures are
considered equal if their Gerber aperture definitions are identical.
"""
# We need to choose some unit here.
return hasattr(other, 'to_gerber') and self.to_gerber(MM) == other.to_gerber(MM)
def _rotate_hole_90(self):
if self.hole_rect_h is None:
return {'hole_dia': self.hole_dia, 'hole_rect_h': None}
else:
return {'hole_dia': self.hole_rect_h, 'hole_rect_h': self.hole_dia}
@dataclass(unsafe_hash=True)
class ExcellonTool(Aperture):
""" Special Aperture_ subclass for use in :py:class:`.ExcellonFile`. Similar to :py:class:`.CircleAperture`, but
does not have :py:attr:`.CircleAperture.hole_dia`, and has the additional :py:attr:`plated` attribute.
does not have :py:attr:`.CircleAperture.hole_dia` or :py:attr:`.CircleAperture.hole_rect_h`, and has the additional
:py:attr:`plated` attribute.
"""
_gerber_shape_code = 'C'
_human_readable_shape = 'drill'
@ -140,6 +180,18 @@ class ExcellonTool(Aperture):
def to_xnc(self, settings):
return 'C' + settings.write_excellon_value(self.diameter, self.unit)
def __eq__(self, other):
""" Compare two :py:class:`.ExcellonTool` instances. They are considered equal if their diameter and plating
match.
"""
if not isinstance(other, ExcellonTool):
return False
if not self.plated == other.plated:
return False
return _none_close(self.diameter, self.unit(other.diameter, other.unit))
def __str__(self):
plated = '' if self.plated is None else (' plated' if self.plated else ' non-plated')
return f'<Excellon Tool d={self.diameter:.3f}{plated} [{self.unit}]>'
@ -150,23 +202,19 @@ class ExcellonTool(Aperture):
# Internal use, for layer dilation.
def dilated(self, offset, unit=MM):
offset = unit(offset, self.unit)
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, diameter=self.diameter+2*offset)
@lru_cache()
def rotated(self, angle=0):
return self
def to_macro(self, rotation=0):
from .aperture_macros.parse import GenericMacros
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM)
def to_macro(self):
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
def _params(self, unit=None):
return (self.unit.convert_to(unit, self.diameter),)
return [self.unit.convert_to(unit, self.diameter)]
@dataclass(frozen=True, slots=True)
@dataclass
class CircleAperture(Aperture):
""" Besides flashing circles or rings, CircleApertures are used to set the width of a
:py:class:`~.graphic_objects.Line` or :py:class:`~.graphic_objects.Arc`.
@ -177,6 +225,10 @@ class CircleAperture(Aperture):
diameter : Length(float)
#: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole.
hole_dia : Length(float) = None
#: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole.
hole_rect_h : Length(float) = None
# float with radians. This is only used for rectangular holes (as circles are rotationally symmetric).
rotation : float = 0
def _primitives(self, x, y, unit=None, polarity_dark=True):
return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2), polarity_dark=polarity_dark) ]
@ -191,31 +243,31 @@ class CircleAperture(Aperture):
def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit)
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, diameter=self.diameter+2*offset, hole_dia=None)
return replace(self, diameter=self.diameter+2*offset, hole_dia=None, hole_rect_h=None)
@lru_cache()
def rotated(self, angle=0):
return self
if math.isclose((self.rotation+angle) % (2*math.pi), 0, abs_tol=1e-6) or self.hole_rect_h is None:
return self
else:
return self.to_macro(self.rotation+angle)
def scaled(self, scale):
return replace(self,
diameter=self.diameter*scale,
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*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))
def to_macro(self):
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
def _params(self, unit=None):
return _strip_right(
self.unit.convert_to(unit, self.diameter),
self.unit.convert_to(unit, self.hole_dia))
self.unit.convert_to(unit, self.hole_dia),
self.unit.convert_to(unit, self.hole_rect_h))
@dataclass(frozen=True, slots=True)
@dataclass
class RectangleAperture(Aperture):
""" Gerber rectangle aperture. Can only be used for flashes, since the line width of an interpolation of a rectangle
aperture is not well-defined and there is no tool that implements it in a geometrically correct way. """
@ -227,10 +279,14 @@ class RectangleAperture(Aperture):
h : Length(float)
#: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole.
hole_dia : Length(float) = None
#: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole.
hole_rect_h : Length(float) = None
# Rotation in radians. This rotates both the aperture and the rectangular hole if it has one.
rotation : float = 0 # radians
def _primitives(self, x, y, unit=None, polarity_dark=True):
return [ gp.Rectangle(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h),
rotation=0, polarity_dark=polarity_dark) ]
rotation=self.rotation, polarity_dark=polarity_dark) ]
def __str__(self):
return f'<rect aperture {self.w:.3}x{self.h:.3} [{self.unit}]>'
@ -242,40 +298,42 @@ class RectangleAperture(Aperture):
def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit)
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None)
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None)
@lru_cache()
def rotated(self, angle=0):
if math.isclose(angle % math.pi, 0, abs_tol=1e-6):
self.rotation += angle
if math.isclose(self.rotation % math.pi, 0):
self.rotation = 0
return self
elif math.isclose(angle % math.pi, math.pi/2, abs_tol=1e-6):
return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
elif math.isclose(self.rotation % math.pi, math.pi/2):
return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0)
else: # odd angle
return self.to_macro(angle)
return self.to_macro()
def scaled(self, scale):
return replace(self,
w=self.w*scale,
h=self.h*scale,
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*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,
MM(self.hole_rect_h, self.unit) or 0,
self.rotation + rotation])
def _params(self, unit=None):
return _strip_right(
self.unit.convert_to(unit, self.w),
self.unit.convert_to(unit, self.h),
self.unit.convert_to(unit, self.hole_dia))
self.unit.convert_to(unit, self.hole_dia),
self.unit.convert_to(unit, self.hole_rect_h))
@dataclass(frozen=True, slots=True)
@dataclass
class ObroundAperture(Aperture):
""" Aperture whose shape is the convex hull of two circles of equal radii.
@ -291,10 +349,14 @@ class ObroundAperture(Aperture):
h : Length(float)
#: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole.
hole_dia : Length(float) = None
#: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole.
hole_rect_h : Length(float) = None
#: Rotation in radians. This rotates both the aperture and the rectangular hole if it has one.
rotation : float = 0
def _primitives(self, x, y, unit=None, polarity_dark=True):
return [ gp.Line.from_obround(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h),
polarity_dark=polarity_dark) ]
rotation=self.rotation, polarity_dark=polarity_dark) ]
def __str__(self):
return f'<obround aperture {self.w:.3}x{self.h:.3} [{self.unit}]>'
@ -303,16 +365,13 @@ class ObroundAperture(Aperture):
def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit)
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None)
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None)
@lru_cache()
def rotated(self, angle=0):
if math.isclose(angle % math.pi, 0, abs_tol=1e-6):
if math.isclose((angle + self.rotation) % math.pi, 0, abs_tol=1e-6):
return self
elif math.isclose(angle % math.pi, math.pi/2, abs_tol=1e-6):
return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
elif math.isclose((angle + self.rotation) % math.pi, math.pi/2, abs_tol=1e-6):
return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0)
else:
return self.to_macro(angle)
@ -320,30 +379,32 @@ class ObroundAperture(Aperture):
return replace(self,
w=self.w*scale,
h=self.h*scale,
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
def to_macro(self, rotation=0):
# generic macro only supports w > h so flip x/y if h > w
if self.w > self.h:
inst = self
else:
rotation -= -math.pi/2
inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
inst = replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=rotation+self.rotation-90)
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),
MM(inst.hole_rect_h, self.unit),
inst.rotation])
def _params(self, unit=None):
return _strip_right(
self.unit.convert_to(unit, self.w),
self.unit.convert_to(unit, self.h),
self.unit.convert_to(unit, self.hole_dia))
self.unit.convert_to(unit, self.hole_dia),
self.unit.convert_to(unit, self.hole_rect_h))
@dataclass(frozen=True, slots=True)
@dataclass
class PolygonAperture(Aperture):
""" Aperture whose shape is a regular n-sided polygon (e.g. pentagon, hexagon etc.). Note that this only supports
round holes.
@ -360,7 +421,7 @@ class PolygonAperture(Aperture):
hole_dia : Length(float) = None
def __post_init__(self):
object.__setattr__(self, 'n_vertices', int(self.n_vertices))
self.n_vertices = int(self.n_vertices)
def _primitives(self, x, y, unit=None, polarity_dark=True):
return [ gp.ArcPoly.from_regular_polygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices,
@ -371,16 +432,13 @@ class PolygonAperture(Aperture):
def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit)
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, diameter=self.diameter+2*offset, hole_dia=None)
flash = _flash_hole
@lru_cache()
def rotated(self, angle=0):
if angle != 0:
return replace(self, rotation=self.rotation + angle)
return replace(self, rotatio=self.rotation + angle)
else:
return self
@ -390,27 +448,21 @@ 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):
if math.isclose(rotation, 0, abs_tol=1-e6):
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)
elif rotation is not None and not math.isclose(rotation, 0, abs_tol=1e-6):
elif rotation is not None and not math.isclose(rotation, 0):
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation
else:
return self.unit.convert_to(unit, self.diameter), self.n_vertices
@dataclass(frozen=True, slots=True)
@dataclass
class ApertureMacroInstance(Aperture):
""" One instance of an aperture macro. An aperture macro defined with an ``AM`` statement can be instantiated by
multiple ``AD`` aperture definition statements using different parameters. An :py:class:`.ApertureMacroInstance` is
@ -422,7 +474,10 @@ class ApertureMacroInstance(Aperture):
macro : object
#: The parameters to the :py:class:`.ApertureMacro`. All elements should be floats or ints. The first item in the
#: list is parameter ``$1``, the second is ``$2`` etc.
parameters : tuple = ()
parameters : list
#: Aperture rotation in radians. When saving, a copy of the :py:class:`.ApertureMacro` is re-written with this
#: rotation.
rotation : float = 0
@property
def _gerber_shape_code(self):
@ -430,39 +485,33 @@ class ApertureMacroInstance(Aperture):
def _primitives(self, x, y, unit=None, polarity_dark=True):
out = list(self.macro.to_graphic_primitives(
offset=(x, y), rotation=0,
offset=(x, y), rotation=self.rotation,
parameters=self.parameters, unit=unit, polarity_dark=polarity_dark))
return out
def dilated(self, offset, unit=MM):
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, macro=self.macro.dilated(offset, unit))
@lru_cache()
def rotated(self, angle=0.0):
if math.isclose(angle % (2*math.pi), 0, abs_tol=1e-6):
def rotated(self, angle=0):
if math.isclose((self.rotation+angle) % (2*math.pi), 0):
return self
else:
return self.to_macro(angle)
def to_macro(self, rotation=0.0):
return replace(self, macro=self.macro.rotated(rotation))
def to_macro(self, rotation=0):
return replace(self, macro=self.macro.rotated(self.rotation+rotation), rotation=0)
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 __eq__(self, other):
return hasattr(other, 'macro') and self.macro == other.macro and \
hasattr(other, 'parameters') and self.parameters == other.parameters and \
hasattr(other, 'rotation') and self.rotation == other.rotation
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)

View file

@ -4,19 +4,16 @@ 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
from ..utils import LengthUnit, MM, rotate_point, svg_arc, sum_bounds, bbox_intersect, Tag
from ..layers import LayerStack
from ..graphic_objects import Line, Arc, Flash
from ..apertures import Aperture, CircleAperture, ObroundAperture, RectangleAperture, ExcellonTool
from ..apertures import Aperture, CircleAperture, RectangleAperture, ExcellonTool
from ..newstroke import Newstroke
class UNDEFINED:
pass
def sgn(x):
return -1 if x < 0 else 1
@ -54,7 +51,7 @@ class Board:
@property
def abs_pos(self):
return self.x, self.y, self.rotation, False
return self.x, self.y, self.rotation
def add_silk(self, side, obj):
if side not in ('top', 'bottom'):
@ -80,7 +77,7 @@ class Board:
for ko in self.keepouts:
if obj.overlaps(ko, unit=MM):
if keepout_errors == 'warn':
warnings.warn(f'Object with bounds {obj.bounding_box(MM)} [mm] hits one or more keepout areas')
warnings.warn(msg)
elif keepout_errors == 'raise':
raise KeepoutError(obj, ko, msg)
return
@ -118,11 +115,10 @@ 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):
obj.render(layer_stack, cache)
obj.render(layer_stack)
layer_stack['mechanical', 'outline'].objects.extend(self.outline)
layer_stack['top', 'silk'].objects.extend(self.extra_silk_top)
@ -145,56 +141,21 @@ class Positioned:
y: float
_: KW_ONLY
rotation: float = 0.0
flip: bool = False
side: str = 'top'
unit: LengthUnit = MM
parent: object = None
def flip(self):
self.side = 'top' if self.side == 'bottom' else 'bottom'
@property
def abs_pos(self):
if self.parent is None:
px, py, pa, pf = 0, 0, 0, False
px, py, pa = 0, 0, 0
else:
px, py, pa, pf = self.parent.abs_pos
px, py, pa = self.parent.abs_pos
return self.x+px, self.y+py, self.rotation+pa, (bool(self.flip) != bool(pf))
def bounding_box(self, unit=MM):
stack = LayerStack()
self.render(stack)
objects = chain(*(l.objects for l in stack.graphic_layers.values()),
stack.drill_pth.objects, stack.drill_npth.objects)
objects = list(objects)
#print('foo', type(self).__name__,
# [(type(obj).__name__, [prim.bounding_box() for prim in obj.to_primitives(unit)]) for obj in objects], file=sys.stderr)
return sum_bounds(prim.bounding_box() for obj in objects for prim in obj.to_primitives(unit))
def overlaps(self, bbox, unit=MM):
return bbox_intersect(self.bounding_box(unit), bbox)
@property
def single_sided(self):
return True
# The dataclass API is slightly idiotic here, so we have to duplicate the entire thing.
@dataclass(frozen=True)
class FrozenPositioned:
x: float
y: float
_: KW_ONLY
rotation: float = 0.0
flip: bool = False
unit: LengthUnit = MM
parent: object = None
@property
def abs_pos(self):
if self.parent is None:
px, py, pa, pf = 0, 0, 0, False
else:
px, py, pa, pf = self.parent.abs_pos
return self.x+px, self.y+py, self.rotation+pa, (bool(self.flip) != bool(pf))
return self.x+px, self.y+py, self.rotation+pa
def bounding_box(self, unit=MM):
stack = LayerStack()
@ -215,7 +176,7 @@ class FrozenPositioned:
@dataclass
class Graphics(Positioned):
class ObjectGroup(Positioned):
top_copper: list = field(default_factory=list)
top_mask: list = field(default_factory=list)
top_silk: list = field(default_factory=list)
@ -226,10 +187,15 @@ class Graphics(Positioned):
bottom_paste: list = field(default_factory=list)
drill_npth: list = field(default_factory=list)
drill_pth: list = field(default_factory=list)
objects: list = field(default_factory=list)
def render(self, layer_stack, cache=None):
x, y, rotation, flip = self.abs_pos
top, bottom = ('bottom', 'top') if flip else ('top', 'bottom')
def render(self, layer_stack):
x, y, rotation = self.abs_pos
top, bottom = ('bottom', 'top') if self.side == 'bottom' else ('top', 'bottom')
for obj in self.objects:
obj.parent = self
obj.render(layer_stack)
for target, source in [
(layer_stack[top, 'copper'], self.top_copper),
@ -249,23 +215,6 @@ class Graphics(Positioned):
fe.offset(x, y, self.unit)
target.objects.append(fe)
def bounding_box(self, unit=MM):
if math.isclose(self.rotation, 0, abs_tol=1e-3):
return offset_bounds(sum_bounds((obj.bounding_box(unit=unit) for obj in chain(
self.top_copper,
self.top_mask,
self.top_silk,
self.top_paste,
self.bottom_copper,
self.bottom_mask,
self.bottom_silk,
self.bottom_paste,
self.drill_npth,
self.drill_pth,
))), unit(self.x, self.unit), unit(self.y, self.unit))
else:
return super().bounding_box(unit)
@property
def single_sided(self):
any_top = self.top_copper or self.top_mask or self.top_paste or self.top_silk
@ -274,30 +223,6 @@ class Graphics(Positioned):
return not (any_drill or (any_top and any_bottom))
@dataclass
class ObjectGroup(Positioned):
objects: list = field(default_factory=list)
def render(self, layer_stack, cache=None):
for obj in self.objects:
if not isinstance(obj, Positioned):
raise ValueError(f'ObjectGroup members must be children of Positioned, not {type(obj)}')
obj.parent = self
obj.render(layer_stack, cache=cache)
def bounding_box(self, unit=MM):
if math.isclose(self.rotation, 0, abs_tol=1e-3):
return offset_bounds(sum_bounds((obj.bounding_box(unit=unit) for obj in self.objects)),
unit(self.x, self.unit), unit(self.y, self.unit))
else:
return super().bounding_box(unit)
@property
def single_sided(self):
return all(obj.single_sided for obj in self.objects)
@dataclass
class Text(Positioned):
text: str
@ -308,8 +233,8 @@ class Text(Positioned):
layer: str = 'silk'
polarity_dark: bool = True
def render(self, layer_stack, cache=None):
obj_x, obj_y, rotation, flip = self.abs_pos
def render(self, layer_stack):
obj_x, obj_y, rotation = self.abs_pos
global newstroke_font
if newstroke_font is None:
@ -322,7 +247,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 +257,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,225 +276,162 @@ 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)
def bounding_box(self, unit=MM):
approx_w = len(self.text)*self.font_size*0.75 + self.stroke_width
approx_h = self.font_size + self.stroke_width
if self.h_align == 'left':
x0 = 0
elif self.h_align == 'center':
x0 = -approx_w/2
elif self.h_align == 'right':
x0 = -approx_w
if self.v_align == 'top':
y0 = 0
elif self.v_align == 'middle':
y0 = -approx_h/2
elif self.v_align == 'bottom':
y0 = -approx_h
return (self.x+x0, self.y+y0), (self.x+x0+approx_w, self.y+y0+approx_h)
@dataclass(frozen=True, slots=True)
class PadStackAperture:
aperture: Aperture
side: str
layer: str
offset_x: float = 0 # in PadStack units
offset_y: float = 0
rotation: float = 0
invert: bool = False
@dataclass(frozen=True, slots=True)
class PadStack:
_: KW_ONLY
unit: LengthUnit = MM
@property
def apertures(self):
raise NotImplementedError()
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.rotate(rotation)
fl.offset(x, y)
side = ap.side
if flip:
side = {'top': 'bottom', 'bottom': 'top'}.get(side, side)
yield side, ap.layer, fl
def render(self, layer_stack, x, y, rotation: float = 0, flip: bool = False):
for side, layer, flash in self.flashes(x, y, rotation, flip):
if side == 'drill' and layer == 'plated':
layer_stack.drill_pth.objects.append(flash)
elif side == 'drill' and layer == 'nonplated':
layer_stack.drill_npth.objects.append(flash)
elif (side, layer) in layer_stack:
layer_stack[side, layer].objects.append(flash)
@property
def single_sided(self):
return len({ap.side for ap in self.apertures}) <= 1
@dataclass(frozen=True, slots=True)
class SMDStack(PadStack):
aperture: Aperture
mask_expansion: float = 0.0
paste_expansion: float = 0.0
paste: bool = True
flip: bool = False
@property
def side(self):
return 'bottom' if self.flip else 'top'
@property
def apertures(self):
yield PadStackAperture(self.aperture, self.side, 'copper')
yield PadStackAperture(self.aperture.dilated(self.mask_expansion, self.unit), self.side, 'mask')
if self.paste:
yield PadStackAperture(self.aperture.dilated(self.paste_expansion, self.unit), self.side, 'paste')
@classmethod
def rect(kls, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, flip=False, unit=MM):
ap = RectangleAperture(w, h, unit=unit).rotated(rotation)
return kls(ap, mask_expansion, paste_expansion, paste, flip, unit=unit)
@classmethod
def circle(kls, dia, mask_expansion=0.0, paste_expansion=0.0, paste=True, flip=False, unit=MM):
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
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')
@property
def plating(self):
return 'plated' if self.plated else 'nonplated'
@property
def apertures(self):
yield from self.pad_top.apertures
yield from self.pad_bottom.apertures
if self.aperture_inner is not None:
yield PadStackAperture(self.aperture_inner, 'inner', 'copper')
yield PadStackAperture(ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), 'drill', self.plating)
@property
def single_sided(self):
return False
@classmethod
def rect(kls, drill_dia, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
pad = SMDStack.rect(w, h, rotation, mask_expansion, paste_expansion, paste, unit=unit)
return kls(drill_dia, pad, plated=plated)
@classmethod
def circle(kls, drill_dia, dia, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
pad = SMDStack.circle(dia, mask_expansion, paste_expansion, paste, unit=unit)
return kls(drill_dia, pad, plated=plated)
@classmethod
def obround(kls, drill_dia, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
ap = ObroundAperture(w, h, unit=unit).rotated(rotation)
pad = SMDStack(ap, mask_expansion, paste_expansion, paste, unit=unit)
return kls(drill_dia, pad, plated=plated)
@dataclass(frozen=True, slots=True)
class ThroughViaStack(PadStack):
hole: float
dia: float = None
tented: bool = True
def __post_init__(self):
if self.dia == None:
object.__setattr__(self, 'dia', self.hole*2)
@property
def single_sided(self):
return False
@property
def apertures(self):
copper_aperture = CircleAperture(self.dia, unit=self.unit)
yield PadStackAperture(copper_aperture, 'top', 'copper')
yield PadStackAperture(copper_aperture, 'bottom', 'copper')
yield PadStackAperture(copper_aperture, 'inner', 'copper')
if self.tented:
yield PadStackAperture(copper_aperture, 'top', 'mask')
yield PadStackAperture(copper_aperture, 'bottom', 'mask')
yield PadStackAperture(ExcellonTool(self.hole, plated=True, unit=self.unit), 'drill', 'plated')
@dataclass(frozen=True, slots=True)
class Via(FrozenPositioned):
pad_stack: PadStack
def render(self, layer_stack, cache=None):
x, y, rotation, flip = self.abs_pos
self.pad_stack.render(layer_stack, x, y, rotation, flip)
@classmethod
def at(kls, x, y, hole, dia=None, tented=True, unit=MM):
return kls(x, y, ThroughViaStack(hole, dia, tented, unit=unit), unit=unit)
layer_stack[self.side, self.layer].objects.append(obj)
@dataclass
class Pad(Positioned):
pad_stack: PadStack
pass
def render(self, layer_stack, cache=None):
x, y, rotation, flip = self.abs_pos
self.pad_stack.render(layer_stack, x, y, rotation, flip)
@dataclass
class SMDPad(Pad):
copper_aperture: Aperture
mask_aperture: Aperture
paste_aperture: Aperture
silk_features: list = field(default_factory=list)
def render(self, layer_stack):
x, y, rotation = self.abs_pos
layer_stack[self.side, 'copper'].objects.append(Flash(x, y, self.copper_aperture.rotated(rotation), unit=self.unit))
layer_stack[self.side, 'mask' ].objects.append(Flash(x, y, self.mask_aperture.rotated(rotation), unit=self.unit))
if self.paste_aperture:
layer_stack[self.side, 'paste' ].objects.append(Flash(x, y, self.paste_aperture.rotated(rotation), unit=self.unit))
layer_stack[self.side, 'silk' ].objects.extend([copy(feature).rotate(rotation).offset(x, y, self.unit)
for feature in self.silk_features])
@classmethod
def rect(kls, x, y, w, h, rotation=0, side='top', mask_expansion=0.0, paste_expansion=0.0, paste=True, unit=MM):
ap_c = RectangleAperture(w, h, unit=unit)
ap_m = RectangleAperture(w+2*mask_expansion, h+2*mask_expansion, unit=unit)
ap_p = RectangleAperture(w+2*paste_expansion, h+2*paste_expansion, unit=unit) if paste else None
return kls(x, y, side=side, copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, rotation=rotation,
unit=unit)
@classmethod
def circle(kls, x, y, dia, side='top', mask_expansion=0.0, paste_expansion=0.0, paste=True, unit=MM):
ap_c = CircleAperture(dia, unit=unit)
ap_m = CircleAperture(dia+2*mask_expansion, unit=unit)
ap_p = CircleAperture(dia+2*paste_expansion, unit=unit) if paste else None
return kls(x, y, side=side, copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, unit=unit)
@dataclass
class THTPad(Pad):
drill_dia: float
pad_top: SMDPad
pad_bottom: SMDPad = None
aperture_inner: Aperture = None
plated: bool = True
def __post_init__(self):
if self.pad_bottom is None:
import sys
self.pad_bottom = copy(self.pad_top)
self.pad_bottom.flip()
self.pad_top.parent = self.pad_bottom.parent = self
if (self.pad_top.side, self.pad_bottom.side) != ('top', 'bottom'):
raise ValueError(f'The top and bottom pads must have side set to top and bottom, respectively. Currently, the top pad side is set to "{self.pad_top.side}" and the bottom pad side to "{self.pad_bottom.side}".')
def render(self, layer_stack):
x, y, rotation = self.abs_pos
self.pad_top.parent = self
self.pad_top.render(layer_stack)
if self.pad_bottom:
self.pad_bottom.parent = self
self.pad_bottom.render(layer_stack)
if self.aperture_inner is None:
(x_min, y_min), (x_max, y_max) = self.pad_top.bounding_box(MM)
w_top = x_max - x_min
h_top = y_max - y_min
if self.pad_bottom:
(x_min, y_min), (x_max, y_max) = self.pad_bottom.bounding_box(MM)
w_bottom = x_max - x_min
h_bottom = y_max - y_min
w_top = min(w_top, w_bottom)
h_top = min(h_top, h_bottom)
self.aperture_inner = CircleAperture(min(w_top, h_top), unit=MM)
for (side, use), layer in layer_stack.inner_layers:
layer.objects.append(Flash(x, y, self.aperture_inner.rotated(rotation), unit=self.unit))
hole = Flash(x, y, ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), unit=self.unit)
if self.plated:
layer_stack.drill_pth.objects.append(hole)
else:
layer_stack.drill_npth.objects.append(hole)
@property
def single_sided(self):
return self.pad_stack.single_sided
return False
@classmethod
def rect(kls, x, y, hole_dia, w, h=None, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
if h is None:
h = w
pad = SMDPad.rect(0, 0, w, h, mask_expansion=mask_expansion, paste_expansion=paste_expansion, paste=paste, unit=unit)
return kls(x, y, hole_dia, pad, rotation=rotation, plated=plated, unit=unit)
@classmethod
def circle(kls, x, y, hole_dia, dia, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
pad = SMDPad.circle(0, 0, dia, mask_expansion=mask_expansion, paste_expansion=paste_expansion, paste=paste, unit=unit)
return kls(x, y, hole_dia, pad, plated=plated, unit=unit)
@classmethod
def obround(kls, x, y, hole_dia, w, h, rotation=0, mask_expansion=0.0, paste_expanson=0.0, paste=True, plated=True, unit=MM):
ap_c = CircleAperture(dia, unit=unit)
ap_m = CircleAperture(dia+2*mask_expansion, unit=unit)
ap_p = CircleAperture(dia+2*paste_expansion, unit=unit) if paste else None
pad = SMDPad(0, 0, side='top', copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, unit=unit)
return kls(x, y, hole_dia, pad, rotation=rotation, plated=plated, unit=unit)
@dataclass
class Hole(Positioned):
diameter: float
mask_copper_margin: float = 0.2
def render(self, layer_stack):
x, y, rotation = self.abs_pos
hole = Flash(x, y, ExcellonTool(self.diameter, plated=False, unit=self.unit), unit=self.unit)
layer_stack.drill_npth.objects.append(hole)
if self.mask_copper_margin > 0:
mask = Flash(x, y, CircleAperture(self.mask_copper_margin, unit=self.unit), polarity_dark=False, unit=self.unit)
layer_stack['top', 'copper'].objects.append(mask)
layer_stack['bottom', 'copper'].objects.append(mask)
@property
def single_sided(self):
return False
@dataclass
class Via(Positioned):
diameter: float
hole: float
def render(self, layer_stack):
x, y, rotation = self.abs_pos
aperture = CircleAperture(diameter=self.diameter, unit=self.unit)
tool = ExcellonTool(diameter=self.hole, unit=self.unit)
for (side, use), layer in layer_stack.copper_layers:
layer.objects.append(Flash(x, y, aperture, unit=self.unit))
layer_stack.drill_pth.objects.append(Flash(x, y, tool, unit=self.unit))
@property
def single_sided(self):
return False
@dataclass
@ -580,9 +441,8 @@ class Trace:
end: object = None
waypoints: [(float, float)] = field(default_factory=list)
style: str = 'oblique'
orientation: [str] = tuple() # 'cw' or 'ccw'
orientation: [str] = tuple() # 'top' or 'bottom'
roundover: float = 0
side: str = 'top'
unit: LengthUnit = MM
parent: object = None
@ -730,13 +590,13 @@ class Trace:
yield Line(line_b.x1, line_b.y1, x3, y3, aperture=aperture, unit=self.unit)
def to_graphic_objects(self):
def _to_graphic_objects(self):
start, end = self.start, self.end
if not isinstance(start, tuple):
*start, _rotation, _flip = start.abs_pos
*start, _rotation = start.abs_pos
if not isinstance(end, tuple):
*end, _rotation, _flip = end.abs_pos
*end, _rotation = end.abs_pos
aperture = CircleAperture(diameter=self.width, unit=self.unit)
@ -749,8 +609,8 @@ class Trace:
return self._round_over(points, aperture)
def render(self, layer_stack, cache=None):
layer_stack[self.side, 'copper'].objects.extend(self.to_graphic_objects())
def render(self, layer_stack):
layer_stack[self.side, 'copper'].objects.extend(self._to_graphic_objects())
def _route_demo():
from ..utils import setup_svg, Tag

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

@ -0,0 +1,525 @@
import sys
import re
import math
import string
import itertools
from copy import copy, deepcopy
import warnings
from .primitives import *
from ..graphic_objects import Region
from ..apertures import RectangleAperture, CircleAperture
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)
else:
return max(widths), 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, unit=MM):
self.pitch_x = pitch_x
self.pitch_y = pitch_y or pitch_x
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), (w, h)))
return max_x-min_x, max_y-min_y
def fit_rect(self, bbox, unit=MM):
(x, y), (w, h) = bbox
w, h = w-x, h-y
w_mod = round((w + 5e-7) % unit(self.pitch_x, self.unit), 6)
h_mod = round((h + 5e-7) % unit(self.pitch_y, self.unit), 6)
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, rotation=math.pi/4, unit=unit)
large_ap_neg = RectangleAperture(s+2*gap, s+2*gap, rotation=math.pi/4, unit=unit)
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, rotation=math.pi/4, unit=unit)
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):
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)))
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))
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)
stack = PropLayout([pattern2, pattern3], 'v', [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, obj=PoweredProto())
#pattern = PatternProtoArea(2.54, obj=RFGroundProto())
#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())
pb = ProtoBoard(100, 80, pattern, mounting_hole_dia=3.2, mounting_hole_offset=5)
print(pb.pretty_svg())
pb.layer_stack().save_to_directory('/tmp/testdir')
if __name__ == '__main__':
_demo()
#cnt = alphabetic()()
#for _ in range(32):
# for _ in range(26):
# print(f'{next(cnt):>2}', end=' ', file=sys.stderr)
# print(file=sys.stderr)

View file

@ -4,11 +4,10 @@ import importlib.resources
from tempfile import NamedTemporaryFile, TemporaryDirectory
from pathlib import Path
from quart import Quart, request, Response, send_file, abort
from quart import Quart, request, Response, send_file
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
@ -98,50 +97,14 @@ def deserialize(obj, unit):
pitch = mil(float(obj.get('pitch', 2.54)))
hole_dia = mil(float(obj['hole_dia']))
via_drill = mil(float(obj['via_hole_dia']))
via_dia = mil(float(obj['via_dia']))
trace_width = mil(float(obj['trace_width']))
# Force 1mm margin to avoid shorts when adjacent to planes such as that one in the RF THT proto.
return pb.PatternProtoArea(pitch, pitch, pb.PoweredProto(pitch, hole_dia, clearance, via_size=via_drill, power_pad_dia=via_dia, trace_width=trace_width, unit=unit), margin=unit(1.0, MM), unit=unit)
return pb.PatternProtoArea(pitch, pitch, pb.PoweredProto(pitch, hole_dia, clearance, via_size=via_drill, trace_width=trace_width, unit=unit), unit=unit)
case 'flower':
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)
case 'spiky':
return pb.PatternProtoArea(2.54, 2.54, pb.SpikyProto(), unit=unit)
case 'alio':
pitch = mil(float(obj.get('pitch', 2.54)))
drill = mil(float(obj.get('hole_dia', 0.9)))
clearance = mil(float(obj.get('clearance', 0.3)))
link_pad_width = mil(float(obj.get('link_pad_width', 1.1)))
link_trace_width = mil(float(obj.get('link_trace_width', 0.5)))
via_size = mil(float(obj.get('via_hole_dia', 0.4)))
return pb.PatternProtoArea(pitch, pitch, pb.AlioCell(
pitch=pitch,
drill=drill,
clearance=clearance,
link_pad_width=link_pad_width,
link_trace_width=link_trace_width,
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)
return pb.PatternProtoArea(2*pitch, 2*pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, unit=unit), unit=unit)
case 'rf':
pitch = float(obj.get('pitch', 2.54))
@ -155,16 +118,12 @@ 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)))
if obj.get('children'):
try:
content = deserialize(obj['children'][0], unit)
except ValueError:
return abort(400)
content = deserialize(obj['children'][0], unit)
else:
content = [pb.EmptyProtoArea()]
@ -172,14 +131,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 +148,7 @@ async def gerbers():
board.layer_stack().save_to_zipfile(f)
return Response(f.read_bytes(), mimetype='image/svg+xml')
def main():
app.run()
if __name__ == '__main__':
main()
app.run()

View file

@ -97,11 +97,6 @@ input {
text-align: center;
}
.group > .attribution, .group > .usage {
grid-column-start: 1;
grid-column-end: span 3;
}
.group > div > .proportion {
display: none;
}
@ -145,14 +140,6 @@ input {
margin: 0 5px 0 5px;
}
input[type="text"]:invalid {
background: rgba(255 0 0 / 30%);
}
input[type="text"]:focus:valid {
background: rgba(0 192 64 / 30%);
}
.group.expand {
border-radius: 0;
}
@ -174,39 +161,16 @@ input[type="text"]:focus:valid {
}
#preview {
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;
}
#preview-message {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: hsla(0 0% 50% / 30%);
display: none;
justify-content: center;
align-items: center;
font-size: 18pt;
font-weight: bold;
color: white;
}
#preview-message.loading {
display: flex;
}
#links {
grid-area: links;
display: flex;
@ -261,73 +225,63 @@ input[type="text"]:focus:valid {
</head>
<body>
<div id="controls">
<form>
<div class="group board">
<h4>Board settings</h4>
<label>Units
<select name='units' value="metric">
<option value="metric">Metric</option>
<option value="us">US Customary</option>
</select>
<div class="group board">
<h4>Board settings</h4>
<label>Units
<select name='units' value="metric">
<option value="metric">Metric</option>
<option value="us">US Customary</option>
</select>
</label>
<label>Board width
<input name="width" type="text" placeholder="width" value="100">
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
<label>Board height
<input name="height" type="text" placeholder="height" value="80">
<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>
</label>
<label>Board width
<input name="width" type="text" placeholder="width" value="100" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
<label>Radius
<input name="radius" type="text" placeholder="radius" value="1.5">
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
</div>
<div class="group expand" data-group="mounting_holes">
<label>Mounting holes
<input name="enabled" type="checkbox" name="has_holes" checked>
</label>
<label>Board height
<input name="height" type="text" placeholder="height" value="80" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
<label>Margin
<input type="text" placeholder="margin" name="margin" value="2.0" pattern="[0-9]+\.?[0-9]*"/>
<label>Diameter
<input type="text" placeholder="diameter" name="diameter" value="3.2"></input>
<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/>
</label>
<label>Radius
<input name="radius" type="text" placeholder="radius" value="1.5" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
</div>
<div class="group expand" data-group="mounting_holes">
<label>Mounting holes
<input name="enabled" type="checkbox" name="has_holes" checked/>
</label>
<label>Diameter
<input type="text" placeholder="diameter" name="diameter" value="3.2" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
<label>Board edge to hole center
<input type="text" placeholder="distance" name="offset" value="5" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
</div>
<h4>Content</h4>
<div class="group placeholder"></div>
<label>Board edge to hole center
<input type="text" placeholder="distance" name="offset" value="5"></input>
<span class="unit metric">mm</span>
<span class="unit us">inch</span>
</label>
</div>
</form>
<h4>Content</h4>
<div class="group placeholder"></div>
</div>
</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"/>
<div id="preview-message"></div>
<img id="preview-image" alt="Automatically generated preview image"/>
</div>
<div id="links">
<a class="narrow-only" href="#controls">Settings</a>
@ -355,7 +309,7 @@ input[type="text"]:focus:valid {
<h4>Proportional Layout</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]*"/>
<input type="text" name="layout_prop" value="1">
</label>
<h5>Layout settings</h4>
@ -381,7 +335,7 @@ input[type="text"]:focus:valid {
<h4>Split front and back</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]*"/>
<input type="text" name="layout_prop" value="1">
</label>
<h5>Front</h5>
@ -396,7 +350,7 @@ input[type="text"]:focus:valid {
<h4>Empty 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]*"/>
<input type="text" name="layout_prop" value="1">
</label>
<div class="content">
@ -406,13 +360,9 @@ input[type="text"]:focus:valid {
<a href="#" data-placeholder="smd">SMD area</a>
<a href="#" data-placeholder="tht" class="double-sided-only">THT area</a>
<a href="#" data-placeholder="manhattan">Manhattan area</a>
<a href="#" data-placeholder="flower" class="double-sided-only">THT Flower area</a>
<a href="#" data-placeholder="powered" class="double-sided-only">Powered THT area</a>
<a href="#" data-placeholder="rf" class="double-sided-only">RF THT area</a>
<a href="#" data-placeholder="spiky" class="double-sided-only">Spiky hybrid area</a>
<a href="#" data-placeholder="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>
<a href="#" data-placeholder="flower"class="double-sided-only">THT Flower area</a>
<a href="#" data-placeholder="powered"class="double-sided-only">Powered THT area</a>
<a href="#" data-placeholder="rf"class="double-sided-only">RF THT area</a>
</div>
</div>
</template>
@ -422,22 +372,22 @@ input[type="text"]:focus:valid {
<h4>SMD 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]*"/>
<input type="text" name="layout_prop" value="1">
</label>
<h5>Area Settings</h5>
<label>Pitch X
<input type="text" name="pitch_x" placeholder="length" value="1.27" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="pitch_x" placeholder="length" value="1.27">
<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]*"/>
<input type="text" name="pitch_y" placeholder="length" value="2.54">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.3" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="clearance" placeholder="length" value="0.3">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
@ -445,10 +395,11 @@ input[type="text"]:focus:valid {
<select name="pad_shape" value="rect">
<option value="rect">(Rounded) Rectangle</option>
<option value="circle">Circle</option>
<option value="obround">Obround</option>
</select>
</label>
<label class="only-shape rect">Corner radius
<input type="text" name="pad_h" placeholder="length" value="0" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="pad_h" placeholder="length" value="0">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
@ -460,34 +411,34 @@ input[type="text"]:focus:valid {
<h4>THT 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]*"/>
<input type="text" name="layout_prop" value="1">
</label>
<h5>Area Settings</h5>
<label>Pitch X
<input type="text" name="pitch_x" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="pitch_x" placeholder="length" value="2.54">
<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]*"/>
<input type="text" name="pitch_y" placeholder="length" value="2.54">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="clearance" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<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>
</select>
</label>
<label>Hole diameter
<input type="text" name="hole_dia" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="hole_dia" placeholder="length" value="0.9">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
@ -499,35 +450,7 @@ input[type="text"]:focus:valid {
</select>
</label>
<label class="only-shape rect">Corner radius
<input type="text" name="pad_h" placeholder="length" value="0" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</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]*"/>
<input type="text" name="pad_h" placeholder="length" value="0">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
@ -539,22 +462,22 @@ input[type="text"]:focus:valid {
<h4>Manhattan 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]*"/>
<input type="text" name="layout_prop" value="1">
</label>
<h5>Area Settings</h5>
<label>Pitch X
<input type="text" name="pitch_x" placeholder="length" value="5.08" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="pitch_x" placeholder="length" value="5.08">
<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="5.08" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="pitch_y" placeholder="length" value="5.08">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="clearance" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
@ -566,27 +489,27 @@ input[type="text"]:focus:valid {
<h4>THT flower 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]*"/>
<input type="text" name="layout_prop" value="1">
</label>
<h5>Area Settings</h5>
<label>Pitch
<input type="text" name="pitch" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="pitch" placeholder="length" value="2.54">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Pattern diameter
<input type="text" name="pattern_dia" placeholder="length" value="2.0" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="pattern_dia" placeholder="length" value="2.0">
<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]*"/>
<input type="text" name="hole_dia" placeholder="length" value="0.9">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="clearance" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
@ -598,37 +521,32 @@ input[type="text"]:focus:valid {
<h4>Powered THT 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]*"/>
<input type="text" name="layout_prop" value="1">
</label>
<h5>Area Settings</h5>
<label>Pitch
<input type="text" name="pitch" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="pitch" placeholder="length" value="2.54">
<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]*"/>
<input type="text" name="hole_dia" placeholder="length" value="0.9">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Via drill
<input type="text" name="via_hole_dia" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Via diameter
<input type="text" name="via_dia" placeholder="length" value="1.1" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="via_hole_dia" placeholder="length" value="0.9">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Trace width
<input type="text" name="trace_width" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="trace_width" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.2" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="clearance" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
@ -640,151 +558,37 @@ input[type="text"]:focus:valid {
<h4>THT area with RF ground</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]*"/>
<input type="text" name="layout_prop" value="1">
</label>
<h5>Area Settings</h5>
<label>Pitch
<input type="text" name="pitch" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="pitch" placeholder="length" value="2.54">
<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]*"/>
<input type="text" name="hole_dia" placeholder="length" value="0.9">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Trace width
<input type="text" name="trace_width" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="trace_width" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Via diameter
<input type="text" name="via_dia" placeholder="length" value="0.8" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="via_dia" placeholder="length" value="0.8">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Via drill
<input type="text" name="via_hole_dia" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
<input type="text" name="via_hole_dia" placeholder="length" value="0.4">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.3" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</div>
</template>
<template id="tpl-g-spiky">
<div data-type="spiky" class="group spiky">
<h4>Spiky hybrid area</h4>
<div class="attribution">
Layout by <a href="https://social.treehouse.systems/@electronic_eel">electroniceel</a> (<a href="https://github.com/electroniceel/protoboard">github</a>)
</div>
<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>
<div class="usage">This area has a fixed 100 mil / 2.54 mm pitch.</div>
</div>
</template>
<template id="tpl-g-alio">
<div data-type="alio" class="group alio">
<h4>ALio hybrid area</h4>
<div class="attribution">
Layout by arief ibrahim adha (<a href="https://hackaday.io/project/28570-alio-new-hardware-prototyping-platform">hackaday.io</a>).
Top and bottom have opposed orientation of the SMD pads.
</div>
<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
<input type="text" name="pitch" placeholder="length" value="2.54" 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>
<label>Link trace width
<input type="text" name="link_trace_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>Via pad width
<input type="text" name="link_pad_width" placeholder="length" value="0.8" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Via drill
<input type="text" name="via_hole_dia" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
<label>Clearance
<input type="text" name="clearance" placeholder="length" value="0.3" pattern="[0-9]+\.?[0-9]*"/>
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
</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]*"/>
<input type="text" name="clearance" placeholder="length" value="0.5">
<span class="unit metric">mm</span>
<span class="unit us">mil</span>
</label>
@ -1077,47 +881,21 @@ 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', {
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);
}
previewTopBlobURL = URL.createObjectURL(data_top);
document.querySelector('#preview-image-top').src = previewTopBlobURL;
document.querySelector('#preview-message').textContent = '';
document.querySelector('#preview-message').classList.remove('loading');
const response_bot = await fetch('preview_bottom.svg', {
method: 'POST',
mode: 'same-origin',
cache: 'no-cache',
headers: {'Content-Type': 'application/json'},
body: serialize(),
});
const data_bot = await response_bot.blob();
if (previewBotBlobURL) {
URL.revokeObjectURL(previewBotBlobURL);
}
previewBotBlobURL = URL.createObjectURL(data_bot);
document.querySelector('#preview-image-bottom').src = previewBotBlobURL;
} else {
document.querySelector('#preview-message').classList.add('loading');
document.querySelector('#preview-message').textContent = 'Please correct any invalid fields.';
const response = await fetch('preview.svg', {
method: 'POST',
mode: 'same-origin',
cache: 'no-cache',
headers: {'Content-Type': 'application/json'},
body: serialize(),
});
const data = await response.blob();
if (previewBlobURL) {
URL.revokeObjectURL(previewBlobURL);
}
previewBlobURL = URL.createObjectURL(data);
document.querySelector('#preview-image').src = previewBlobURL;
}, 1000);
document.querySelector('div.placeholder').replaceWith(createPlaceholder());

View file

@ -26,7 +26,7 @@ import shutil
from pathlib import Path
from functools import cached_property
from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg, convex_hull
from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg
from . import graphic_primitives as gp
from . import graphic_objects as go
@ -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):
@ -257,10 +248,10 @@ class Polyline:
(x0, y0), *rest = self.coords
d = f'M {float(x0):.6} {float(y0):.6} ' + ' '.join(f'L {float(x):.6} {float(y):.6}' for x, y in rest)
width = f'{float(self.width):.6}' if not math.isclose(self.width, 0) else '0.01mm'
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=d,
fill='none', stroke=color, stroke_linecap='round', stroke_linejoin='round',
stroke_width=width)
fill='none', stroke=color,
stroke_width=f'{float(width):.6}')
class CamFile:
@ -300,15 +291,21 @@ class CamFile:
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit,
pagecolor=bg, tag=tag)
def svg_objects(self, svg_unit=MM, fg='black', bg='white', aperture_map={}, tag=Tag):
def svg_objects(self, svg_unit=MM, fg='black', bg='white', tag=Tag):
pl = None
for i, obj in enumerate(self.objects):
if isinstance(obj, go.Flash) and id(obj.aperture) in aperture_map:
yield tag('use', href='#'+aperture_map[id(obj.aperture)],
x=f'{svg_unit(obj.x, obj.unit):.3f}',
y=f'{svg_unit(obj.y, obj.unit):.3f}')
#if isinstance(obj, go.Flash):
# if pl:
# tags.append(pl.to_svg(tag, fg, bg))
# pl = None
else:
# mask_tags = [ prim.to_svg(tag, 'white', 'black') for prim in obj.to_primitives(unit=svg_unit) ]
# mask_tags.insert(0, tag('rect', width='100%', height='100%', fill='black'))
# mask_id = f'mask{i}'
# tag('mask', mask_tags, id=mask_id)
# tag('rect', width='100%', height='100%', mask='url(#{mask_id})', fill=fg)
#else:
for primitive in obj.to_primitives(unit=svg_unit):
if isinstance(primitive, gp.Line):
if not pl:
@ -351,24 +348,6 @@ class CamFile:
return sum_bounds(( p.bounding_box(unit) for p in self.objects ), default=default)
def convex_hull(self, tol=0.01, unit=None):
unit = unit or self.unit
points = []
for obj in self.objects:
if isinstance(obj, go.Line):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
elif isinstance(obj, go.Arc):
for obj in obj.approximate(tol, unit):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
return convex_hull(points)
def to_excellon(self):
""" Convert to a :py:class:`.ExcellonFile`. Returns ``self`` if it already is one. """
raise NotImplementedError()

View file

@ -23,10 +23,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
@ -34,23 +31,8 @@ from .cam import FileSettings
from .rs274x import GerberFile
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,44 +128,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():
pass
@kicad_group.group('schematic')
def schematic_group():
pass
@schematic_group.command()
@click.argument('inpath', type=click.Path(exists=True))
@click.argument('theme', type=click.Path(exists=True))
@click.argument('outfile', type=click.File('w'), default='-')
def render(inpath, theme, outfile):
sch = kc_schematic.Schematic.open(inpath)
cs = tmtheme.TmThemeSchematic(Path(theme).read_text())
with outfile as f:
f.write(str(sch.to_svg(cs)))
@cli.command()
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
@ -198,17 +142,12 @@ def render(inpath, theme, outfile):
rules and use only rules given by --input-map''')
@click.option('--force-zip', is_flag=True, help='''Force treating input path as a zip file (default: guess file type
from extension and contents)''')
@click.option('--top', 'side', flag_value='top', help='Render top side')
@click.option('--bottom', 'side', flag_value='bottom', help='Render top side')
@click.option('--top/--bottom', default=True, help='Which side of the board to render')
@click.option('--command-line-units', type=Unit(), help='''Units for values given in other options. Default:
millimeter''')
@click.option('--margin', type=float, default=0.0, help='Add space around the board inside the viewport')
@click.option('--force-bounds', help='Force SVG bounding box to value given as "min_x,min_y,max_x,max_y"')
@click.option('--inkscape/--standard-svg', default=True, help='Export in Inkscape SVG format with layers and stuff.')
@click.option('--pretty/--no-filters', default=True, help='''Export pseudo-realistic render using filters (default) or
just stack up layers using given colorscheme. In "--no-filters" mode, by default all layers are exported
unless either "--top" or "--bottom" is given.''')
@click.option('--drills/--no-drills', default=True, help='''Include (default) or exclude drills ("--no-filters" only!)''')
@click.option('--colorscheme', type=click.Path(exists=True, path_type=Path), help='''Load colorscheme from given JSON
file. The JSON file must contain a single dict with keys copper, silk, mask, paste, drill and outline.
Each key must map to a string containing either a normal 6-digit hex color with leading hash sign, or an
@ -216,8 +155,8 @@ def render(inpath, theme, outfile):
with FF being completely opaque, and 00 being invisibly transparent.''')
@click.argument('inpath', type=click.Path(exists=True))
@click.argument('outfile', type=click.File('w'), default='-')
def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, force_zip, side, drills,
command_line_units, margin, force_bounds, inkscape, pretty, colorscheme):
def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, force_zip, top, command_line_units,
margin, force_bounds, inkscape, colorscheme):
""" Render a gerber file, or a directory or zip of gerber files into an SVG file. """
overrides = json.loads(input_map.read_bytes()) if input_map else None
@ -235,14 +174,9 @@ def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules,
if colorscheme:
colorscheme = json.loads(colorscheme.read_text())
if pretty:
svg = stack.to_pretty_svg(side='bottom' if side == 'bottom' else 'top', margin=margin,
arg_unit=(command_line_units or MM),
svg_unit=MM, force_bounds=force_bounds, inkscape=inkscape, colors=colorscheme)
else:
svg = stack.to_svg(side_re=side or '.*', margin=margin, drills=drills, arg_unit=(command_line_units or MM),
svg_unit=MM, force_bounds=force_bounds, colors=colorscheme)
outfile.write(str(svg))
outfile.write(str(stack.to_pretty_svg(side='top' if top else 'bottom', margin=margin,
arg_unit=(command_line_units or MM),
svg_unit=MM, force_bounds=force_bounds, inkscape=inkscape, colors=colorscheme)))
@cli.command()
@ -338,9 +272,9 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio
scheme instead of keeping the old file names.''')
@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 +289,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 +379,7 @@ def merge(inpath, outpath, offset, rotation, input_map, command_line_units, outp
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
@click.option('--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')
@ -570,7 +494,7 @@ def meta(path, force_zip, format_warnings):
d[function] = {
'format': 'Gerber',
'path': str(layer.original_path),
'apertures': len(list(layer.apertures())),
'apertures': len(layer.apertures),
'objects': len(layer.objects),
'bounding_box': {'min_x': min_x, 'min_y': min_y, 'max_x': max_x, 'max_y': max_y},
'format_settings': format_settings,

View file

@ -30,10 +30,9 @@ from pathlib import Path
from .cam import CamFile, FileSettings
from .graphic_objects import Flash, Line, Arc
from .apertures import ExcellonTool, CircleAperture
from .apertures import ExcellonTool
from .utils import Inch, MM, to_unit, InterpMode, RegexMatcher
class ExcellonContext:
""" Internal helper class used for tracking graphics state when writing Excellon. """
@ -47,8 +46,8 @@ class ExcellonContext:
def select_tool(self, tool):
""" Select the current tool. Retract drill first if necessary. """
current_id = self.tools.get(self.current_tool)
new_id = self.tools[tool]
current_id = self.tools.get(id(self.current_tool))
new_id = self.tools[id(tool)]
if new_id != current_id:
if self.drill_down:
yield 'M16' # drill up
@ -269,19 +268,19 @@ 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
apertures = {}
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 id(obj.tool) not in apertures:
apertures[id(obj.tool)] = CircleAperture(obj.tool.diameter)
out.objects.append(dataclasses.replace(obj, aperture=ap))
return out
out.objects.append(dataclasses.replace(obj, aperture=apertures[id(obj.tool)]))
out.apertures = list(apertures.values())
@property
def generator(self):
@ -328,7 +327,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 +335,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.
@ -374,12 +373,12 @@ class ExcellonFile(CamFile):
yield 'METRIC' if settings.unit == MM else 'INCH'
# Build tool index
tool_map = { obj.tool: obj.tool for obj in self.objects }
tool_map = { id(obj.tool): obj.tool for obj in self.objects }
tools = sorted(tool_map.items(), key=lambda id_tool: (id_tool[1].plated, id_tool[1].diameter))
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 +568,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 +608,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 +632,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 +755,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 +764,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 +865,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 +880,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 +902,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 +928,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 +960,6 @@ class ExcellonParser(object):
else:
self.warn('Bare coordinate after end of file')
@exprs.match(xy_coord + 'G85' + xy_coord)
def handle_g85_slot(self, match):
if self.program_state == ProgramState.HEADER:
return
self.do_move(match.groups()[:4])
start, end = self.do_move(match.groups()[4:])
if not self.ensure_active_tool():
return
self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit))
@exprs.match(r'DETECT,ON|ATC,ON|M06')
def parse_zuken_legacy_statements(self, match):
self.generator_hints.append('zuken')

View file

@ -19,11 +19,9 @@
import math
import copy
from dataclasses import dataclass, astuple, field, fields
from itertools import zip_longest, pairwise, islice, cycle
from .utils import MM, InterpMode, to_unit, rotate_point, sum_bounds, approximate_arc, sweep_angle
from .utils import MM, InterpMode, to_unit, rotate_point
from . import graphic_primitives as gp
from .aperture_macros import primitive as amp
def convert(value, src, dst):
@ -152,7 +150,12 @@ class GraphicObject:
:returns: tuple of tuples of floats: ``(min_x, min_y), (max_x, max_y)``
"""
return sum_bounds(p.bounding_box() for p in self.to_primitives(unit))
bboxes = [ p.bounding_box() for p in self.to_primitives(unit) ]
min_x = min(min_x for (min_x, _min_y), _ in bboxes)
min_y = min(min_y for (_min_x, min_y), _ in bboxes)
max_x = max(max_x for _, (max_x, _max_y) in bboxes)
max_y = max(max_y for _, (_max_x, max_y) in bboxes)
return ((min_x, min_y), (max_x, max_y))
def to_primitives(self, unit=None):
""" Render this object into low-level graphical primitives (subclasses of :py:class:`.GraphicPrimitive`). This
@ -214,11 +217,6 @@ class Flash(GraphicObject):
def tool(self, value):
self.aperture = value
def bounding_box(self, unit=None):
(min_x, min_y), (max_x, max_y) = self.aperture.bounding_box(unit)
x, y = self.unit.convert_to(unit, self.x), self.unit.convert_to(unit, self.y)
return (min_x+x, min_y+y), (max_x+x, max_y+y)
@property
def plated(self):
""" (Excellon only) Returns if this is a plated hole. ``True`` (plated), ``False`` (non-plated) or ``None``
@ -278,23 +276,16 @@ class Region(GraphicObject):
* A region is always exactly one connected component.
* A region must not overlap itself anywhere.
* A region cannot have holes.
* The last outline point of the region must be equal to the first.
There is one exception from the last two rules: To emulate a region with a hole in it, *cut-ins* are allowed. At a
cut-in, the region is allowed to touch (but never overlap!) itself.
When ``arc_centers`` is empty, this region has only straight outline segments. When ``arc_centers`` is not empty,
the i-th entry defines the i-th outline segment, with a ``None`` entry designating a straight line segment.
An arc is defined by a ``(clockwise, (cx, cy))`` tuple, where ``clockwise`` can be ``True`` for a clockwise arc, or
``False`` for a counter-clockwise arc. ``cx`` and ``cy`` are the absolute coordinates of the arc's center.
"""
def __init__(self, outline=None, arc_centers=None, *, unit=MM, polarity_dark=True):
def __init__(self, outline=None, arc_centers=None, *, unit, polarity_dark):
self.unit = unit
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)
@ -302,44 +293,20 @@ class Region(GraphicObject):
def __bool__(self):
return bool(self.outline)
def __str__(self):
return f'<Region with {len(self.outline)} points and {sum(1 if c else 0 for c in self.arc_centers)} arc segments at {hex(id(self))}'
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 ]
self.arc_centers = [
(arc[0], gp.rotate_point(*arc[1], angle, cx, cy)) if arc else None
for arc in self.arc_centers ]
(arc[0], gp.rotate_point(*arc[1], angle, cx-p[0], cy-p[1])) if arc else None
for p, arc in zip(self.outline, self.arc_centers) ]
def _scale(self, factor):
self.outline = [ (x*factor, y*factor) for x, y in self.outline ]
self.arc_centers = [
(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([
(x, y),
(x+w, y),
(x+w, y+h),
(x, y+h),
], 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)
for p, arc in zip(self.outline, self.arc_centers) ]
def append(self, obj):
if obj.unit != self.unit:
@ -350,56 +317,10 @@ class Region(GraphicObject):
self.outline.append(obj.p2)
if isinstance(obj, Arc):
self.arc_centers.append((obj.clockwise, obj.center))
self.arc_centers.append((obj.clockwise, obj.center_relative))
else:
self.arc_centers.append(None)
def iter_segments(self, tolerance=1e-6):
for points, arc in zip_longest(pairwise(self.outline), self.arc_centers):
if arc:
if points:
yield *points, arc
else:
yield self.outline[-1], self.outline[0], arc
return
else:
if not points:
break
yield *points, (None, (None, None))
# Close outline if necessary.
if math.dist(self.outline[0], self.outline[-1]) > tolerance:
yield self.outline[-1], self.outline[0], (None, (None, None))
def outline_objects(self, aperture=None):
for p1, p2, (clockwise, center) in self.iter_segments():
if clockwise is not None:
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)
def _aperture_macro_primitives(self, max_error=1e-2, clip_max_error=True, unit=MM):
# unit is only for max_error, the resulting primitives will always be in MM
if len(self.outline) < 2:
return
points = []
for p1, p2, (clockwise, center) in self.iter_segments():
if clockwise is not None:
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))
def to_primitives(self, unit=None):
if unit == self.unit:
yield gp.ArcPoly(outline=self.outline, arc_centers=self.arc_centers, polarity_dark=self.polarity_dark)
@ -413,9 +334,6 @@ class Region(GraphicObject):
yield gp.ArcPoly(conv_outline, conv_arc, polarity_dark=self.polarity_dark)
def to_statements(self, gs):
if len(self.outline) < 3:
return
yield from gs.set_polarity(self.polarity_dark)
yield 'G36*'
# Repeat interpolation mode at start of region statement to work around gerbv bug. Without this, gerbv will
@ -425,24 +343,29 @@ class Region(GraphicObject):
yield from gs.set_current_point(self.outline[0], unit=self.unit)
for previous_point, point, (clockwise, center) in self.iter_segments():
if point is None and center is None:
break
x = gs.file_settings.write_gerber_value(point[0], self.unit)
y = gs.file_settings.write_gerber_value(point[1], self.unit)
if clockwise is None:
for point, arc_center in zip(self.outline[1:], self.arc_centers):
if arc_center is None:
yield from gs.set_interpolation_mode(InterpMode.LINEAR)
x = gs.file_settings.write_gerber_value(point[0], self.unit)
y = gs.file_settings.write_gerber_value(point[1], self.unit)
yield f'X{x}Y{y}D01*'
gs.update_point(*point, unit=self.unit)
else:
clockwise, (cx, cy) = arc_center
x2, y2 = point
yield from gs.set_interpolation_mode(InterpMode.CIRCULAR_CW if clockwise else InterpMode.CIRCULAR_CCW)
i = gs.file_settings.write_gerber_value(center[0]-previous_point[0], self.unit)
j = gs.file_settings.write_gerber_value(center[1]-previous_point[1], self.unit)
x = gs.file_settings.write_gerber_value(x2, self.unit)
y = gs.file_settings.write_gerber_value(y2, self.unit)
# TODO are these coordinates absolute or relative now?!
i = gs.file_settings.write_gerber_value(cx, self.unit)
j = gs.file_settings.write_gerber_value(cy, self.unit)
yield f'X{x}Y{y}I{i}J{j}D01*'
gs.update_point(*point, unit=self.unit)
gs.update_point(x2, y2, unit=self.unit)
yield 'G37*'
@ -523,13 +446,6 @@ class Line(GraphicObject):
def to_primitives(self, unit=None):
yield self.as_primitive(unit=unit)
def _aperture_macro_primitives(self):
obj = self.converted(MM) # Gerbonara aperture macros use MM units.
width = obj.aperture.equivalent_width(MM)
yield amp.VectorLine(MM, int(self.polarity_dark), width, obj.x1, obj.y1, obj.x2, obj.y2, 0)
yield amp.Circle(MM, int(self.polarity_dark), width, obj.x1, obj.y1)
yield amp.Circle(MM, int(self.polarity_dark), width, obj.x2, obj.y2)
def to_statements(self, gs):
yield from gs.set_polarity(self.polarity_dark)
yield from gs.set_aperture(self.aperture)
@ -588,10 +504,6 @@ class Arc(GraphicObject):
#: Aperture for this arc. Should be a subclass of :py:class:`.CircleAperture`, whose diameter determines the line
#: width.
aperture : object
@classmethod
def from_circle(kls, cx, cy, r, aperture, unit=MM):
return kls(cx-r, cy, cx-r, cy, r, 0, aperture=aperture, clockwise=True, unit=MM)
def _offset(self, dx, dy):
self.x1 += dx
@ -624,8 +536,22 @@ class Arc(GraphicObject):
:returns: Angle in clockwise radian between ``0`` and ``2*math.pi``
:rtype: float
"""
cx, cy = self.cx + self.x1, self.cy + self.y1
x1, y1 = self.x1 - cx, self.y1 - cy
x2, y2 = self.x2 - cx, self.y2 - cy
return sweep_angle(self.cx+self.x1, self.cy+self.y1, self.x1, self.y1, self.x2, self.y2, self.clockwise)
a1, a2 = math.atan2(y1, x1), math.atan2(y2, x2)
f = abs(a2 - a1)
if not self.clockwise:
if a2 > a1:
return a2 - a1
else:
return 2*math.pi - abs(a2 - a1)
else:
if a1 > a2:
return a1 - a2
else:
return 2*math.pi - abs(a1 - a2)
@property
def p1(self):
@ -682,16 +608,34 @@ class Arc(GraphicObject):
:returns: list of :py:class:`~.graphic_objects.Line` instances.
:rtype: list
"""
# TODO the max_angle calculation below is a bit off -- we over-estimate the error, and thus produce finer
# results than necessary. Fix this.
r = math.hypot(self.cx, self.cy)
max_error = self.unit(max_error, unit)
return [Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit)
for p1, p2 in pairwise(approximate_arc(
self.cx+self.x1, self.cy+self.y1,
self.x1, self.y1,
self.x2, self.y2,
self.clockwise,
max_error=max_error,
clip_max_error=clip_max_error))]
if clip_max_error:
# 1 - math.sqrt(1 - 0.5*math.sqrt(2))
max_error = min(max_error, r*0.4588038998538031)
elif max_error >= r:
return [Line(*self.p1, *self.p2, aperture=self.aperture, polarity_dark=self.polarity_dark)]
# see https://www.mathopenref.com/sagitta.html
l = math.sqrt(r**2 - (r - max_error)**2)
angle_max = math.asin(l/r)
sweep_angle = self.sweep_angle()
num_segments = math.ceil(sweep_angle / angle_max)
angle = sweep_angle / num_segments
if not self.clockwise:
angle = -angle
cx, cy = self.center
points = [ rotate_point(self.x1, self.y1, i*angle, cx, cy) for i in range(num_segments + 1) ]
return [ Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark)
for p1, p2 in zip(points[0::], points[1::]) ]
def _rotate(self, rotation, cx=0, cy=0):
# rotate center first since we need old x1, y1 here
@ -710,10 +654,10 @@ class Arc(GraphicObject):
def as_primitive(self, unit=None):
conv = self.converted(unit)
w = self.aperture.equivalent_width(unit) if self.aperture else 0
w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging
return gp.Arc(x1=conv.x1, y1=conv.y1,
x2=conv.x2, y2=conv.y2,
cx=conv.cx+conv.x1, cy=conv.cy+conv.y1,
cx=conv.cx, cy=conv.cy,
clockwise=self.clockwise,
width=w,
polarity_dark=self.polarity_dark)
@ -721,17 +665,6 @@ class Arc(GraphicObject):
def to_primitives(self, unit=None):
yield self.as_primitive(unit=unit)
def to_region(self):
reg = Region(unit=self.unit, polarity_dark=self.polarity_dark)
reg.append(self)
reg.close()
return reg
def _aperture_macro_primitives(self, max_error=1e-2, unit=MM):
# unit is only for max_error, the resulting primitives will always be in MM
for line in self.approximate(max_error=max_error, unit=unit):
yield from line._aperture_macro_primitives()
def to_statements(self, gs):
yield from gs.set_polarity(self.polarity_dark)
yield from gs.set_aperture(self.aperture)

View file

@ -19,7 +19,7 @@
import math
import itertools
from dataclasses import dataclass, replace, field
from dataclasses import dataclass, replace
from .utils import *
@ -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):
@ -85,14 +79,6 @@ class Circle(GraphicPrimitive):
color = fg if self.polarity_dark else bg
return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), fill=color)
def to_arc_poly(self):
return ArcPoly([(self.x-self.r, self.y), (self.x+self.r, self.y)],
[(True, (self.x, self.y)), (True, (self.x, self.y))],
polarity_dark=self.polarity_dark)
def is_zero_size(self):
return math.isclose(self.r, 0)
@dataclass(frozen=True)
class ArcPoly(GraphicPrimitive):
@ -102,51 +88,28 @@ class ArcPoly(GraphicPrimitive):
#: connected.
outline : list
#: Must be either None (all segments are straight lines) or same length as outline.
#: Straight line segments have None entry. Arc segments have (clockwise, (cx, cy)) tuple with cx, cy being absolute
#: coords.
arc_centers : list = field(default_factory=list)
#: Straight line segments have None entry.
arc_centers : list = None
@property
def segments(self):
""" Return an iterator through all *segments* of this polygon. For each outline segment (line or arc), this
iterator will yield a ``(p1, p2, (clockwise, center))`` tuple. If the segment is a straight line, ``clockwise``
will be ``None``.
iterator will yield a ``(p1, p2, center)`` tuple. If the segment is a straight line, ``center`` will be
``None``.
"""
for points, arc in itertools.zip_longest(itertools.pairwise(self.outline), self.arc_centers):
if arc:
if points:
yield *points, arc
else:
yield self.outline[-1], self.outline[0], arc
return
else:
if not points:
break
yield *points, (None, (None, None))
# Close outline if necessary.
if math.dist(self.outline[0], self.outline[-1]) > 1e-6:
yield self.outline[-1], self.outline[0], (None, (None, None))
def approximate_arcs(self, max_error=1e-2, clip_max_error=True):
outline = []
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
if clockwise is None:
outline.append((x1, y1))
else:
outline.extend(approximate_arc(cx, cy, x1, y1, x2, y2, clockwise,
max_error=max_error, clip_max_error=clip_max_error))
outline.pop() # remove arc end point
return type(self)(outline, polarity_dark=self.polarity_dark)
ol = self.outline
return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers or [])
def bounding_box(self):
bbox = (None, None), (None, None)
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
if clockwise is None:
for (x1, y1), (x2, y2), arc in self.segments:
if arc:
clockwise, (cx, cy) = arc
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
else:
line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2))
bbox = add_bounds(bbox, line_bounds)
else:
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
return bbox
@classmethod
@ -175,33 +138,17 @@ class ArcPoly(GraphicPrimitive):
yield f'M {float(self.outline[0][0]):.6} {float(self.outline[0][1]):.6}'
for old, new, (clockwise, center) in self.segments:
if clockwise is None:
for old, new, arc in self.segments:
if not arc:
yield f'L {float(new[0]):.6} {float(new[1]):.6}'
else:
clockwise, center = arc
yield svg_arc(old, new, center, clockwise)
def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg
return tag('path', d=' '.join(self.path_d()), fill=color)
def to_arc_poly(self):
return self
def is_zero_size(self):
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
if clockwise is not None: # arc
if math.isclose(cx, x1) and math.isclose(cy, y1):
continue
if math.isclose(x1, x2) and math.isclose(y1, y2):
return False
if math.isclose(polygon_area(self.outline), 0):
return True
return False
@dataclass(frozen=True)
class Line(GraphicPrimitive):
@ -242,34 +189,7 @@ 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')
def to_arc_poly(self):
l = math.dist((self.x1, self.y1), (self.x2, self.y2))
if math.isclose(l, 0):
# degenerate case: a zero-length line becomes a circle.
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
polarity_dark=self.polarity_dark)
dx, dy = self.x2-self.x1, self.y2-self.y1
nx, ny = -dy/l, dx/l
rx, ry = nx*self.width/2, ny*self.width/2
return ArcPoly([
(self.x2+rx, self.y2+ry),
(self.x2-rx, self.y2-ry),
(self.x1-rx, self.y1-ry),
(self.x1+rx, self.y1+ry),
], [
(True, (self.x2, self.y2)),
None,
(True, (self.x1, self.y1)),
None,
], polarity_dark=self.polarity_dark)
def is_zero_size(self):
return math.isclose(self.x1, self.x2) and math.isclose(self.y1, self.y2)
fill='none', stroke=color, stroke_width=str(width))
@dataclass(frozen=True)
class Arc(GraphicPrimitive):
@ -282,9 +202,9 @@ class Arc(GraphicPrimitive):
x2 : float
#: End Y coodinate
y2 : float
#: Center X coordinate (absolute)
#: Center X coordinate relative to ``x1``
cx : float
#: Center Y coordinate (absolute)
#: Center Y coordinate relative to ``y1``
cy : float
#: ``True`` if this arc is clockwise from start to end. Selects between the large arc and the small arc given this
#: start, end and center
@ -292,53 +212,36 @@ class Arc(GraphicPrimitive):
#: Line width of this arc.
width : float
@property
def is_circle(self):
return math.isclose(self.x1, self.x2, abs_tol=1e-6) and math.isclose(self.y1, self.y2, abs_tol=1e-6)
def flip(self):
return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1, clockwise=not self.clockwise)
return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1,
cx=(self.x1 + self.cx) - self.x2, cy=(self.y1 + self.cy) - self.y2, clockwise=not self.clockwise)
def bounding_box(self):
r = self.width/2
(min_x, min_y), (max_x, max_y) = arc_bounds(self.x1, self.y1, self.x2, self.y2, self.cx, self.cy, self.clockwise)
return (min_x-r, min_y-r), (max_x+r, max_y+r)
endpoints = add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box())
arc_r = math.dist((self.cx, self.cy), (self.x1, self.y1))
# extend C -> P1 line by line width / 2 along radius
dx, dy = self.x1 - self.cx, self.y1 - self.cy
x1 = self.x1 + dx/arc_r * r
y1 = self.y1 + dy/arc_r * r
# same for C -> P2
dx, dy = self.x2 - self.cx, self.y2 - self.cy
x2 = self.x2 + dx/arc_r * r
y2 = self.y2 + dy/arc_r * r
arc = arc_bounds(x1, y1, x2, y2, self.cx, self.cy, self.clockwise)
return add_bounds(endpoints, arc) # FIXME add "include_center" switch
def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg
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')
def to_arc_poly(self):
r = math.dist((self.x1, self.y1), (self.cx, self.cy))
if math.isclose(r, 0):
# degenerate case: a zero-radius arc becomes a circle.
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
polarity_dark=self.polarity_dark)
dx1, dy1 = self.x1-self.cx, self.y1-self.cy
nx1, ny1 = dx1/r * self.width/2, dy1/r * self.width/2
dx2, dy2 = self.x2-self.cx, self.y2-self.cy
nx2, ny2 = dx2/r * self.width/2, dy2/r * self.width/2
return ArcPoly([ # vertices
(self.x1+nx1, self.y1+ny1),
(self.x1-nx1, self.y1-ny1),
(self.x2-nx2, self.y2-ny2),
(self.x2+nx2, self.y2+ny2),
], [ # arc segments (direction, center)
(not self.clockwise, (self.x1, self.y1)),
(self.clockwise, (self.cx, self.cy)),
(self.clockwise, (self.x2, self.y2)),
(not self.clockwise, (self.cx, self.cy)),
], polarity_dark=self.polarity_dark)
def is_zero_size(self):
return False # an arc with identical start and end points is defined as a circle
fill='none', stroke=color, stroke_width=width)
@dataclass(frozen=True)
class Rectangle(GraphicPrimitive):
@ -366,14 +269,11 @@ 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
x, y = self.x - self.w/2, self.y - self.h/2
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)
*svg_rotation(self.rotation, self.x, self.y), fill=color)

View file

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

View file

@ -29,7 +29,6 @@ import itertools
from collections import namedtuple
from pathlib import Path
from zipfile import ZipFile, is_zipfile
from collections import defaultdict
import tempfile
from .excellon import ExcellonFile, parse_allegro_ncparam, parse_allegro_logfile
@ -39,7 +38,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 +64,25 @@ 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 courtyard': '{board_name}-F.CrtYd.gbr',
'bottom courtyard': '{board_name}-B.CrtYd.gbr',
'other netlist': '{board_name}.d356',
}
@ -113,61 +109,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 +240,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 +270,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.
@ -326,30 +289,13 @@ class LayerStack:
:py:obj:`"altium"`
"""
def __init__(self, graphic_layers=None, drill_pth=None, drill_npth=None, drill_layers=(), netlist=None,
board_name=None, original_path=None, was_zipped=False, generator=None, courtyard=False,
fabrication=False, adhesive=False):
def __init__(self, graphic_layers=None, drill_pth=None, drill_npth=None, drill_layers=(), netlist=None, board_name=None, original_path=None, was_zipped=False, generator=None):
if not drill_layers and (graphic_layers, drill_pth, drill_npth) == (None, None, None):
graphic_layers = {tuple(layer.split()): GerberFile()
for layer in ('top paste', 'top silk', 'top mask', 'top copper',
'bottom copper', 'bottom mask', 'bottom silk', 'bottom paste',
'mechanical outline')}
if courtyard:
graphic_layers = {('top', 'courtyard'): GerberFile(),
**graphic_layers,
('bottom', 'courtyard'): GerberFile()}
if fabrication:
graphic_layers = {('top', 'fabrication'): GerberFile(),
**graphic_layers,
('bottom', 'fabrication'): GerberFile()}
if adhesive:
graphic_layers = {('top', 'adhesive'): GerberFile(),
**graphic_layers,
('bottom', 'adhesive'): GerberFile()}
drill_pth = ExcellonFile()
drill_npth = ExcellonFile()
@ -419,7 +365,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 +401,6 @@ class LayerStack:
given value.
:rtype: :py:class:`LayerStack`
"""
print_layermap = False
if autoguess:
generator, filemap = _best_match(files)
@ -480,51 +425,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 +450,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 +483,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 +492,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 +554,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 +569,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:
@ -845,7 +679,7 @@ class LayerStack:
def __repr__(self):
return str(self)
def to_svg(self, margin=0, side_re='.*', drills=True, arg_unit=MM, svg_unit=MM, force_bounds=None, colors=None, tag=Tag):
def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag):
""" Convert this layer stack to a plain SVG string. This is intended for use cases where the resulting SVG will
be processed by other tools, and thus styling with colors or extra markup like Inkscape layer information are
unwanted. If you want to instead generate a nice-looking preview image for display or graphical editing in tools
@ -855,9 +689,6 @@ class LayerStack:
mirrored vertically.
:param margin: Export SVG file with given margin around the board's bounding box.
:param side_re: A regex, such as ``'top'``, ``'bottom'``, or ``'.*'`` (default). Selects which layers to export.
The default includes inner layers.
:param drills: :py:obj:`bool` setting if drills are included (default) or not.
:param arg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit ``margin`` and
``force_bounds`` are specified in. Default: mm
:param svg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit to use inside the SVG file.
@ -865,7 +696,6 @@ class LayerStack:
:param force_bounds: Use bounds given as :py:obj:`((min_x, min_y), (max_x, max_y))` tuple for the output SVG
file instead of deriving them from this board's bounding box and ``margin``. Note that this
will not scale or move the board, but instead will only crop the viewport.
:param colors: Dict mapping ``f'{side} {use}'`` strings to SVG colors.
:param tag: Extension point to support alternative XML serializers in addition to the built-in one.
:rtype: :py:obj:`str`
"""
@ -876,34 +706,19 @@ class LayerStack:
stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'}
if colors is None:
colors = defaultdict(lambda: 'black')
tags = []
layer_transform = f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)'
for (side, use), layer in reversed(self.graphic_layers.items()):
if re.fullmatch(side_re, side) and (fg := colors.get(f'{side} {use}')):
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
**stroke_attrs, id=f'l-{side}-{use}', transform=layer_transform))
for (side, use), layer in self.graphic_layers.items():
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg='black', bg="white", tag=Tag)),
**stroke_attrs, id=f'l-{side}-{use}'))
if drills:
if self.drill_pth and (fg := colors.get('drill pth')):
tags.append(tag('g', list(self.drill_pth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
**stroke_attrs, id=f'l-drill-pth', transform=layer_transform))
if self.drill_npth and (fg := colors.get('drill npth')):
tags.append(tag('g', list(self.drill_npth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
**stroke_attrs, id=f'l-drill-npth', transform=layer_transform))
if (fg := colors.get('drill unknown')):
for i, layer in enumerate(self._drill_layers):
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
**stroke_attrs, id=f'l-drill-{i}', transform=layer_transform))
for i, layer in enumerate(self.drill_layers):
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg='black', bg="white", tag=Tag)),
**stroke_attrs, id=f'l-drill-{i}'))
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, tag=tag)
def to_pretty_svg(self, side='top', margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag, inkscape=False,
colors=None, use=True):
colors=None):
""" Convert this layer stack to a pretty SVG string that is suitable for display or for editing in tools such as
Inkscape. If you want to process the resulting SVG in other tools, consider using
:py:meth:`~layers.LayerStack.to_svg` instead, which produces output without color styling or blending based on
@ -929,12 +744,10 @@ class LayerStack:
:py:obj:`"bottom"`, and :py:obj:`"mechanical"` as well as :py:obj:`"inner1"`, :py:obj:`"inner2"`
etc. for internal layers. Valid use values are :py:obj:`"mask"`, :py:obj:`"silk"`,
:py:obj:`"paste"`, and :py:obj:`"copper"`. For internal layers, only :py:obj:`"copper"` is valid.
:param use: Enable/disable ``<use>`` tags for aperture flashes. Defaults to :py:obj:`True` (enabled).
:rtype: :py:obj:`str`
"""
if colors is None:
colors = DEFAULT_COLORS
use_use = use
colors_alpha = {}
for layer, color in colors.items():
@ -969,35 +782,21 @@ class LayerStack:
inkscape_attrs = lambda label: dict(inkscape__groupmode='layer', inkscape__label=label) if inkscape else {}
stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'}
layer_transform=f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)'
use_defs = []
layers = []
for use in ['copper', 'mask', 'silk', 'paste']:
if (side, use) not in self:
warnings.warn(f'Layer "{side} {use}" not found. Found layers: {", ".join(side + " " + use for side, use in self.graphic_layers)}')
continue
layer = self[(side, use)].instance
layer = self[(side, use)]
fg, bg = ('white', 'black') if use != 'mask' else ('black', 'white')
default_fill = {'copper': fg, 'mask': fg, 'silk': 'none', 'paste': fg}[use]
default_stroke = {'copper': 'none', 'mask': 'none', 'silk': fg, 'paste': 'none'}[use]
use_map = {}
if use_use:
layer.dedup_apertures()
for obj in layer.objects:
if hasattr(obj, 'aperture') and obj.polarity_dark and obj.aperture not in use_map:
children = [prim.to_svg(fg, bg, tag=tag)
for prim in obj.aperture.flash(0, 0, svg_unit, polarity_dark=True)]
use_id = f'a{len(use_defs)}'
use_defs.append(tag('g', children, id=use_id))
use_map[obj.aperture] = use_id
objects = []
for obj in layer.instance.svg_objects(svg_unit=svg_unit, fg=fg, bg=bg, aperture_map=use_map, tag=Tag):
for obj in layer.instance.svg_objects(svg_unit=svg_unit, fg=fg, bg=bg, tag=Tag):
if obj.attrs.get('fill') == default_fill:
del obj.attrs['fill']
elif 'fill' not in obj.attrs:
@ -1012,26 +811,19 @@ 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',
**inkscape_attrs(f'{side} {use}'), transform=layer_transform))
fill=default_fill, stroke=default_stroke, **stroke_attrs,
**inkscape_attrs(f'{side} {use}')))
for i, layer in enumerate(self.drill_layers):
layers.append(tag('g', list(layer.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)),
id=f'l-drill-{i}', filter=f'url(#f-drill)', **stroke_attrs, **inkscape_attrs(f'drill-{i}'),
transform=layer_transform))
id=f'g-drill-{i}', filter=f'url(#f-drill)', **stroke_attrs, **inkscape_attrs(f'drill-{i}')))
if self.outline:
layers.append(tag('g', list(self.outline.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)),
id=f'l-mechanical-outline', **stroke_attrs, **inkscape_attrs(f'outline'),
transform=layer_transform))
id=f'g-outline-{i}', **stroke_attrs, **inkscape_attrs(f'outline-{i}')))
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]
layer_group = tag('g', layers, transform=f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)')
tags = [tag('defs', filter_defs), layer_group]
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):
@ -1200,20 +992,6 @@ class LayerStack:
return self.copper_layers[index][1]
def __setitem__(self, index, value):
if isinstance(index, str):
side, _, use = index.partition(' ')
self.graphic_layers[(side, use)] = value
elif isinstance(index, tuple):
self.graphic_layers[index] = value
else:
raise IndexError('Layer {index} not found. Valid layer indices are "{side} {use}" strings or (side, use) tuples.')
def add_layer(self, index):
self[index] = GerberFile()
@property
def copper_layers(self):
""" Return all copper layers of this board as a list of ((side, use), layer) tuples. Returns an empty list if
@ -1260,6 +1038,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,29 +1068,14 @@ 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
joins = {}
for cur in lines:
# Special case: An arc may describe a complete circle, in which case we have to return it as-is since it
# is the only primitive that can join itself.
if isinstance(cur, gp.Arc) and cur.is_circle:
yield [cur]
continue
for (i, x, y) in [(0, cur.x1, cur.y1), (1, cur.x2, cur.y2)]:
x_left = bisect.bisect_left (by_x, x, key=lambda elem: elem[0] + tol)
x_right = bisect.bisect_right(by_x, x, key=lambda elem: elem[0] - tol)
@ -1311,14 +1090,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)

93
gerbonara/newstroke.py Normal file
View file

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

View file

@ -21,11 +21,9 @@
import re
import math
import copy
import warnings
from pathlib import Path
import dataclasses
import functools
from .cam import CamFile, FileSettings
from .utils import MM, Inch, units, InterpMode, UnknownStatementWarning
@ -60,6 +58,7 @@ class GerberFile(CamFile):
:ivar layer_hints: Similar to ``generator_hints``, this is a list containing hints which layer type this file could
belong to. Usually, this will be empty, but some EDA tools automatically include layer
information inside tool-specific comments in the Gerber files they generate.
:ivar apertures: List of apertures used in this file. Make sure you keep this in sync when adding new objects.
:ivar file_attrs: List of strings with Gerber X3 file attributes. Each list item corresponds to one file attribute.
"""
@ -71,88 +70,13 @@ class GerberFile(CamFile):
self.generator_hints = generator_hints or []
self.layer_hints = layer_hints or []
self.import_settings = import_settings
self.apertures = [] # FIXME get rid of this? apertures are already in the objects.
self.file_attrs = file_attrs or {}
def apertures(self):
""" Iterate through all apertures in this layer. """
found = set()
for obj in self.objects:
if hasattr(obj, 'aperture'):
ap = obj.aperture
if ap not in found:
found.add(ap)
yield ap
def sync_apertures(self):
self.apertures = list({id(obj.aperture): obj.aperture for obj in self.objects if hasattr(obj, 'aperture')}.values())
def aperture_macros(self):
found = set()
for aperture in self.apertures():
if isinstance(aperture, apertures.ApertureMacroInstance):
macro = aperture.macro
if (macro.name, macro) not in found:
found.add((macro.name, macro))
yield macro
def map_apertures(self, map_or_callable, cache=True):
""" Replace all apertures in all objects in this layer according to the given map or callable.
When a map is passed, apertures that are not in the map are left alone. When a callable is given, it is called
with the old aperture as its argument.
:param map_or_callable: A dict-like object, or a callable mapping old to new apertures
:param cache: When True (default) and a callable is passed, caches the output of callable, only calling it once
for each old aperture.
"""
if callable(map_or_callable):
if cache:
map_or_callable = functools.cache(map_or_callable)
else:
d = map_or_callable
map_or_callable = lambda ap: d.get(ap, ap)
for obj in self.objects:
if (aperture := getattr(obj, 'aperture', None)):
obj.aperture = map_or_callable(aperture)
def dedup_apertures(self, settings=None):
""" Merge all apertures and aperture macros in this layer that result in the same Gerber definition under the
given :py:class:~.FileSettings:.
When no explicit settings are given, uses Gerbonara's default settings.
:param settings: settings under which to de-duplicate the apertures.
"""
if settings is None:
settings = FileSettings.defaults()
cache = {}
macro_names = set()
def lookup(aperture):
nonlocal cache, settings
if isinstance(aperture, apertures.ApertureMacroInstance):
macro = aperture.macro
macro_def = macro.to_gerber(settings)
if macro_def not in cache:
cache[macro_def] = macro
if macro.name in macro_names:
macro._reset_name()
macro_names.add(macro.name)
else:
macro = cache[macro_def]
aperture = dataclasses.replace(aperture, macro=macro)
code = aperture.to_gerber(settings)
if code not in cache:
cache[code] = aperture
return cache[code]
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,11 +84,8 @@ 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 \
not isinstance(getattr(obj, 'aperture', None), apertures.CircleAperture):
if not (isinstance(obj, go.Line) or isinstance(obj, go.Arc) or isinstance(obj, go.Flash)) or \
not isinstance(obj.aperture, apertures.CircleAperture):
if errors == 'raise':
raise ValueError(f'Cannot convert {obj} to excellon.')
elif errors == 'warn':
@ -175,9 +96,9 @@ class GerberFile(CamFile):
else:
raise ValueError('Invalid "errors" parameter. Allowed values: "raise", "warn" or "ignore".')
if not (new_tool := new_tools.get(obj.aperture)):
if not (new_tool := new_tools.get(id(obj.aperture))):
# TODO plating?
new_tool = new_tools[obj.aperture] = apertures.ExcellonTool(obj.aperture.diameter, plated=plated, unit=obj.aperture.unit)
new_tool = new_tools[id(obj.aperture)] = apertures.ExcellonTool(obj.aperture.diameter, plated=plated, unit=obj.aperture.unit)
new_objs.append(dataclasses.replace(obj, aperture=new_tool))
return ExcellonFile(objects=new_objs, comments=self.comments)
@ -206,6 +127,18 @@ class GerberFile(CamFile):
self.import_settings = None
self.comments += other.comments
# dedup apertures
new_apertures = {}
replace_apertures = {}
mock_settings = FileSettings.defaults()
for ap in self.apertures + other.apertures:
gbr = ap.to_gerber(mock_settings)
if gbr not in new_apertures:
new_apertures[gbr] = ap
else:
replace_apertures[id(ap)] = new_apertures[gbr]
self.apertures = list(new_apertures.values())
# Join objects
if mode == 'below':
self.objects = other.objects + self.objects
@ -214,23 +147,57 @@ class GerberFile(CamFile):
else:
raise ValueError(f'Invalid mode "{mode}", must be one of "above" or "below".')
self.dedup_apertures()
for obj in self.objects:
# If object has an aperture attribute, replace that aperture.
if (ap := replace_apertures.get(id(getattr(obj, 'aperture', None)))):
obj.aperture = ap
# dedup aperture macros
macros = { m.to_gerber(): m
for m in [ GenericMacros.circle, GenericMacros.rect, GenericMacros.obround, GenericMacros.polygon] }
for ap in new_apertures.values():
if isinstance(ap, apertures.ApertureMacroInstance):
macro_grb = ap.macro.to_gerber() # use native unit to compare macros
if macro_grb in macros:
ap.macro = macros[macro_grb]
else:
macros[macro_grb] = ap.macro
# make macro names unique
seen_macro_names = set()
for macro in macros.values():
i = 2
while (new_name := f'{macro.name}{i}') in seen_macro_names:
i += 1
macro.name = new_name
seen_macro_names.add(new_name)
def dilate(self, offset, unit=MM, polarity_dark=True):
# TODO add tests for this
self.map_apertures(lambda ap: ap.dilated(offset, unit))
self.apertures = [ aperture.dilated(offset, unit) for aperture in self.apertures ]
offset_circle = apertures.CircleAperture(offset, unit=unit)
new_objects = []
for obj in self.objects:
obj.polarity_dark = polarity_dark
self.apertures.append(offset_circle)
new_primitives = []
for p in self.primitives:
p.polarity_dark = polarity_dark
# Ignore Line, Arc, Flash. Their actual dilation has already been done by dilating the apertures above.
if isinstance(obj, Region):
new_objects.extend(obj.outline_objects(offset_circle))
if isinstance(p, Region):
ol = p.poly.outline
for start, end, arc_center in zip(ol, ol[1:] + ol[0], p.poly.arc_centers):
if arc_center is not None:
new_primitives.append(Arc(*start, *end, *arc_center,
polarity_dark=polarity_dark, unit=p.unit, aperture=offset_circle))
else:
new_primitives.append(Line(*start, *end,
polarity_dark=polarity_dark, unit=p.unit, aperture=offset_circle))
# it's safe to append these at the end since we compute a logical OR of opaque areas anyway.
self.objects.extend(new_objects)
self.primitives.extend(new_primitives)
@classmethod
def open(kls, filename, enable_includes=False, enable_include_dir=None, override_settings=None):
@ -263,6 +230,7 @@ class GerberFile(CamFile):
def _generate_statements(self, settings, drop_comments=True):
""" Export this file as Gerber code, yields one str per line. """
self.sync_apertures()
yield 'G04 Gerber file generated by Gerbonara*'
for name, value in self.file_attrs.items():
@ -285,26 +253,34 @@ class GerberFile(CamFile):
for cmt in self.comments:
yield f'G04{cmt}*'
self.dedup_apertures()
# Always emit gerbonara's generic, rotation-capable aperture macro replacements for the standard C/R/O/P shapes.
# Unconditionally emitting these here is easier than first trying to figure out if we need them later,
# and they are only a few bytes anyway.
am_stmt = lambda macro: f'%AM{macro.name}*\n{macro.to_gerber(unit=settings.unit)}*\n%'
for macro in [ GenericMacros.circle, GenericMacros.rect, GenericMacros.obround, GenericMacros.polygon ]:
yield am_stmt(macro)
am_stmt = lambda macro: f'%AM{macro.name}*\n{macro.to_gerber(settings)}*\n%'
aperture_map = {ap: num for num, ap in enumerate(self.apertures(), start=10)}
processed_macros = set()
aperture_map = {}
defined_apertures = {}
number = 10
for aperture in self.apertures:
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
if isinstance(aperture, apertures.ApertureMacroInstance):
macro_def = am_stmt(aperture._rotated().macro)
if macro_def not in processed_macros:
processed_macros.add(macro_def)
yield macro_def
else:
for macro in self.aperture_macros():
yield am_stmt(macro)
ap_def = aperture.to_gerber(settings)
if ap_def in defined_apertures:
aperture_map[id(aperture)] = defined_apertures[ap_def]
for aperture, number in aperture_map.items():
yield f'%ADD{number}{aperture.to_gerber(settings)}*%'
else:
yield f'%ADD{number}{ap_def}*%'
defined_apertures[ap_def] = number
aperture_map[id(aperture)] = number
number += 1
def warn(msg, kls=SyntaxWarning):
warnings.warn(msg, kls)
@ -317,7 +293,7 @@ class GerberFile(CamFile):
def __str__(self):
name = f'{self.original_path.name} ' if self.original_path else ''
return f'<GerberFile {name}with {len(list(self.apertures()))} apertures, {len(self.objects)} objects>'
return f'<GerberFile {name}with {len(self.apertures)} apertures, {len(self.objects)} objects>'
def __repr__(self):
return str(self)
@ -353,11 +329,17 @@ class GerberFile(CamFile):
def scale(self, factor, unit=MM):
scaled_apertures = {}
self.map_apertures(lambda ap: ap.scaled(factor))
for ap in self.apertures:
scaled_apertures[id(ap)] = ap.scaled(factor)
for obj in self.objects:
obj.scale(factor)
if (obj_ap := getattr(obj, 'aperture', None)):
obj.aperture = scaled_apertures[id(obj_ap)]
self.apertures = list(scaled_apertures.values())
def offset(self, dx=0, dy=0, unit=MM):
# TODO round offset to file resolution
for obj in self.objects:
@ -367,7 +349,10 @@ class GerberFile(CamFile):
if math.isclose(angle % (2*math.pi), 0):
return
self.map_apertures(lambda ap: ap.rotated(angle))
# First, rotate apertures. We do this separately from rotating the individual objects below to rotate each
# aperture exactly once.
for ap in self.apertures:
ap.rotation += angle
for obj in self.objects:
obj.rotate(angle, cx, cy, unit)
@ -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())
@ -564,8 +549,8 @@ class GraphicsState:
yield '%LPD*%' if polarity_dark else '%LPC*%'
def set_aperture(self, aperture):
ap_id = self.aperture_map[aperture]
old_ap_id = self.aperture_map.get(self.aperture, None)
ap_id = self.aperture_map[id(aperture)]
old_ap_id = self.aperture_map.get(id(self.aperture), None)
if ap_id != old_ap_id:
self.aperture = aperture
yield f'D{ap_id}*'
@ -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 = {}
@ -712,6 +692,7 @@ class GerberParser:
self.warn(f'Unknown statement found: "{self._shorten_line()}", ignoring.', UnknownStatementWarning)
self.target.comments.append(f'Unknown statement found: "{self._shorten_line()}", ignoring.')
self.target.apertures = list(self.aperture_map.values())
self.target.import_settings = self.file_settings
self.target.unit = self.file_settings.unit
self.target.file_attrs = self.file_attrs
@ -793,10 +774,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 +785,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,17 +833,12 @@ 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()),
new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=self.aperture_attrs.copy(),
original_number=number)
elif (macro := self.aperture_macros.get(match['shape'])):
new_aperture = apertures.ApertureMacroInstance(macro, tuple(modifiers), unit=self.file_settings.unit,
attrs=tuple(self.aperture_attrs.items()), original_number=number)
new_aperture = apertures.ApertureMacroInstance(macro, modifiers, unit=self.file_settings.unit,
attrs=self.aperture_attrs.copy(), original_number=number)
else:
raise ValueError(f'Aperture shape "{match["shape"]}" is unknown')
@ -1083,40 +1049,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']] = match['value'].split(',')
if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']:
self.generator_hints.append('eagle')
def _parse_step_repeat(self, match):
if match['coords']:
if self.step_repeat_coords:
raise SyntaxError('SR step-repeat called inside ongoing SR step-repeat')
x, y = int(match['X']), int(match['Y'])
i, j = float(match['I']), float(match['J'])
if x < 1 or y < 1:
raise SyntaxError('SR step-repeat X and Y values must be at least 1')
if x * y > self.MAX_STEP_REPEAT_INSTANCES:
raise SyntaxError('SR step-repeat expands to too many instances')
self.step_repeat_coords = (x, y, i, j)
self.step_repeat_objects = []
else:
x, y, i, j = self.step_repeat_coords
if len(self.step_repeat_objects) * x * y > self.MAX_STEP_REPEAT_RESULT_OBJECTS:
raise SyntaxError('SR step-repeat expands to too many objects')
for obj in self.step_repeat_objects:
for nx in range(x):
for ny in range(y):
new_obj = copy.copy(obj)
new_obj.offset(i * nx, j * ny)
self.target.objects.append(new_obj)
self.step_repeat_coords = None
self.step_repeat_objects = None
def _parse_eof(self, match):
self.eof_found = True

View file

@ -0,0 +1,35 @@
from pathlib import Path
import pytest
from .image_support import ImageDifference, run_cargo_cmd
def pytest_assertrepr_compare(op, left, right):
if isinstance(left, ImageDifference) or isinstance(right, ImageDifference):
diff = left if isinstance(left, ImageDifference) else right
return [
f'Image difference assertion failed.',
f' Calculated difference: {diff}',
f' Histogram: {diff.histogram}', ]
# store report in node object so tmp_gbr can determine if the test failed.
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, f'rep_{rep.when}', rep)
fail_dir = Path('gerbonara_test_failures')
def pytest_sessionstart(session):
if not hasattr(session.config, 'workerinput'): # on worker
return
# on coordinator
for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')):
f.unlink()
try:
run_cargo_cmd('resvg', '--help')
except FileNotFoundError:
pytest.exit('resvg binary not found, aborting test.', 2)

View file

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 556 B

After

Width:  |  Height:  |  Size: 556 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

View file

@ -0,0 +1,285 @@
#!/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)
@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):
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)
svg_to_png(actual, act_png.name, bg=background)
return image_difference(ref_png.name, act_png.name, diff_out=diff_out)
def image_difference(reference, actual, diff_out=None):
ref = np.array(Image.open(reference)).astype(float)
out = np.array(Image.open(actual)).astype(float)
ref, out = ref.mean(axis=2), out.mean(axis=2) # convert to grayscale
# TODO blur images here before comparison to mitigate aliasing issue
delta = np.abs(out - ref).astype(float) / 255
if diff_out:
Image.fromarray((delta*255).astype(np.uint8), mode='L').save(diff_out)
hist, _bins = np.histogram(delta, bins=10, range=(0, 1))
return (ImageDifference(delta.mean(), hist),
ImageDifference(delta.max(), hist),
Histogram(hist, out.size))

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