Compare commits

..

No commits in common. "main" and "v0.11.2" have entirely different histories.

585 changed files with 2834 additions and 320711 deletions

2
.gitignore vendored
View file

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

View file

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

View file

@ -1,3 +1,5 @@
global-exclude *
include README.md include README.md
include LICENSE include LICENSE
include MANIFEST.in include MANIFEST.in

View file

@ -1,45 +1,45 @@
PYTHON ?= python
PYTEST ?= pytest
SPHINX_BUILD ?= sphinx-build
.DEFAULT_GOAL := help PYTHON ?= python
PYTEST ?= pytest
.PHONY: clean docs test test-coverage install sdist bdist_wheel upload testupload help SPHINX_BUILD ?= sphinx-build
all: docs sdist bdist_wheel all: docs sdist bdist_wheel
clean: ## Clean up project directory .PHONY: clean
clean:
find . -name '*.pyc' -delete find . -name '*.pyc' -delete
rm -rf *.egg-info rm -rf *.egg-info
rm -f .coverage rm -f .coverage
rm -f coverage.xml rm -f coverage.xml
rm -rf docs/_build rm -rf docs/_build
docs: ## Generate documentation .PHONY: docs
docs:
sphinx-build -E docs docs/_build sphinx-build -E docs docs/_build
test: ## Run tests .PHONY: test
test:
$(PYTEST) $(PYTEST)
test-coverage: ## Generate coverage .PHONY: test-coverage
test-coverage:
rm -f .coverage rm -f .coverage
rm -f coverage.xml rm -f coverage.xml
$(PYTEST) --cov=./ --cov-report=xml $(PYTEST) --cov=./ --cov-report=xml
install: ## Install locally .PHONY: install
install:
PYTHONPATH=. $(PYTHON) setup.py install PYTHONPATH=. $(PYTHON) setup.py install
sdist: ## Build source distribution sdist:
python3 setup.py sdist python3 setup.py sdist
bdist_wheel: ## Build binary distribution bdist_wheel:
python3 setup.py 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/* 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/* 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) [![pipeline status](https://gitlab.com/gerbonara/gerbonara/badges/master/pipeline.svg)](https://gitlab.com/gerbonara/gerbonara/commits/master)
[![coverage report](https://gitlab.com/gerbolyze/gerbonara/badges/master/coverage.svg)](https://gitlab.com/gerbolyze/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/) [![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/) [![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: Python:
``` ```
pipx install gerbonara pip install --user gerbonara
``` ```
# Documentation and Examples # Documentation and Examples

View file

@ -1,373 +0,0 @@
.. _cli-doc:
Gerbonara's Command-Line Interface
==================================
Gerbonara comes with a built-in command-line interface that has functions for analyzing, rendering, modifying, and
merging Gerber files.
Invocation
----------
There are two ways to call gerbonara's command-line interface:
.. :code:
$ gerbonara
$ python -m gerbonara
For the first to work, make sure the installation's ``bin`` dir is in your ``$PATH``. If you installed gerbonara
system-wide, that should be the case already, since the binary should end up in ``/usr/bin``. If you installed gerbonara
using ``pip install --user``, make sure you have your user's ``~/.local/bin`` in your ``$PATH``.
Commands and their usage
------------------------
.. code-block:: console
$ gerbonara --help
Usage: gerbonara [OPTIONS] COMMAND [ARGS]...
The gerbonara CLI allows you to analyze, render, modify and merge both
individual Gerber or Excellon files as well as sets of those files
Options:
--version
--help Show this message and exit.
Commands:
bounding-box Print the bounding box of a gerber file in "[x_min]...
layers Read layers from a directory or zip with Gerber files and...
merge Merge multiple single Gerber or Excellon files, or...
meta Extract layer mapping and print it along with layer...
render Render a gerber file, or a directory or zip of gerber...
rewrite Parse a single gerber file, apply transformations, and...
transform Transform all gerber files in a given directory or zip...
Rendering
~~~~~~~~~
Gerbonara can render single Gerber (:py:class:`~.rs274x.GerberFile`) or Excellon (:py:class:`~.excellon.ExcellonFile`)
layers, or whole board stacks (:py:class:`~.layers.LayerStack`) to SVG.
``gerbonara render``
********************
.. program:: gerbonara render
.. code-block:: console
$ gerbonara render [OPTIONS] INPATH [OUTFILE]
``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]
Enable or disable file format warnings during parsing (default: on)
.. option:: -m, --input-map <json_file>
Extend or override layer name mapping with name map from JSON file. The JSON file must contain a single JSON dict
with an arbitrary number of string: string entries. The keys are interpreted as regexes applied to the filenames via
re.fullmatch, and each value must either be the string ``ignore`` to remove this layer from previous automatic guesses,
or a gerbonara layer name such as ``top copper``, ``inner_2 copper`` or ``bottom silk``.
.. option:: --use-builtin-name-rules / --no-builtin-name-rules
Disable built-in layer name rules and use only rules given by :option:`--input-map`
.. option:: --force-zip
Force treating input path as a zip file (default: guess file type from extension and contents)
.. option:: --top, --bottom
Which side of the board to render
.. option:: --command-line-units <metric|us-customary>
Units for values given in other options. Default: millimeter
.. option:: --margin <float>
Add space around the board inside the viewport
.. option:: --force-bounds <min_x,min_y,max_x,max_y>
Force SVG bounding box to the given value.
.. option:: --inkscape, --standard-svg
Export in Inkscape SVG format with layers and stuff instead of plain SVG.
.. option:: --colorscheme <json_file>
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 8-digit hex color with leading hash sign, where the last two digits set the
layer's alpha value (opacity), with ``ff`` being completely opaque, and ``00`` being invisibly transparent.
Modification
~~~~~~~~~~~~
``gerbonara rewrite``
*********************
.. program:: gerbonara rewrite
.. code-block:: console
gerbonara rewrite [OPTIONS] INFILE OUTFILE
Parse a single gerber file, apply transformations, and re-serialize it into a new gerber file. Without transformations,
this command can be used to convert a gerber file to use different settings (e.g. units, precision), but can also be
used to "normalize" gerber files in a weird format into a more standards-compatible one as gerbonara's gerber parser is
significantly more robust for weird inputs than others.
.. option:: --warnings <default|ignore|once>
Enable or disable file format warnings during parsing (default: on)
.. option:: -t, --transform <code>
Execute python transformation script on input. You have access to the functions ``translate(x, y)``,
``scale(factor)`` and ``rotate(angle, center_x?, center_y?)``, the bounding box variables ``x_min``, ``y_min``,
``x_max``, ``y_max``, ``width`` and ``height``, and everything from python's built-in math module (e.g. ``pi``,
``sqrt``, ``sin``). As convenience methods, ``center()`` and ``origin()`` are provided to center the board
respectively move its bottom-left corner to the origin. Coordinates are given in ``--command-line-units``, angles in
degrees, and scale as a scale factor (as opposed to a percentage). Example: ``translate(-10, 0); rotate(45, 0, 5)``
.. option:: --command-line-units <metric|us-customary>
Units for values given in other options. Default: millimeter
.. option:: -n, --number-format <decimal.fractional>
Override number format to use during export in ``[integer digits].[decimal digits]`` notation, e.g. ``2.6``.
.. option:: -u, --units <metric|us-customary>
Override export file units
.. option:: -z, --zero-suppression <off|leading|trailing>
Override export zero suppression setting. Note: The meaning of this value is like in the Gerber spec for both Gerber
and Excellon files!
.. option:: --keep-comments, --drop-comments
Keep gerber comments. Note: Comments will be prepended to the start of file, and will not occur in their old
position.
.. option:: --default-settings, --reuse-input-settings
Use sensible defaults for the output file format settings (default) or use the same export settings as the input file
instead of sensible defaults.
.. option:: --input-number-format <decimal.fractional>
Override number format of input file (mostly useful for Excellon files)
.. option:: --input-units <metric|us-customary>
Override units of input file
.. option:: --input-zero-suppression <off|leading|trailing>
Override zero suppression setting of input file
``gerbonara transform``
***********************
.. program:: gerbonara transform
.. code-block:: console
gerbonara transform [OPTIONS] TRANSFORM INPATH OUTPATH
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
``rotate(angle, center_x?, center_y?)``, the bounding box variables ``x_min``, ``y_min``, ``x_max``, ``y_max``,
``width`` and ``height``, and everything from python's built-in math module (e.g. ``pi``, ``sqrt``, ``sin``). As
convenience methods, ``center()`` and ``origin()`` are provided to center the board resp. move its bottom-left corner to
the origin. Coordinates are given in --command-line-units, angles in degrees, and scale as a scale factor (as opposed to
a percentage). Example: ``translate(-10, 0); rotate(45, 0, 5)``
.. option:: -m, --input-map <json_file>
Extend or override layer name mapping with name map from JSON file. The JSON file must contain a single JSON dict
with an arbitrary number of string: string entries. The keys are interpreted as regexes applied to the filenames via
re.fullmatch, and each value must either be the string ``ignore`` to remove this layer from previous automatic
guesses, or a gerbonara layer name such as ``top copper``, ``inner_2 copper`` or ``bottom silk``.
.. option:: --use-builtin-name-rules, --no-builtin-name-rules
Disable built-in layer name rules and use only rules given by ``--input-map``
.. option:: --warnings <default|ignore|once>
Enable or disable file format warnings during parsing (default: on)
.. option:: --units <metric|us-customary>
Units for values given in other options. Default: millimeter
.. option:: -n, --number-format <decimal.fractional>
Override number format to use during export in ``[integer digits].[decimal digits]`` notation, e.g. ``2.6``.
.. option:: --default-settings, --reuse-input-settings
Use sensible defaults for the output file format settings (default) or use the same export settings as the input file
instead of sensible defaults.
.. option:: --force-zip
Force treating input path as a zip file (default: guess file type from extension and contents)
.. option:: --output-naming-scheme <altium|kicad>
Name output files according to the selected naming scheme instead of keeping the old file names.
``gerbonara merge``
*******************
.. program:: gerbonara merge
.. code-block:: console
$ gerbonara merge [OPTIONS] [INPATH]... OUTPATH
Merge multiple single Gerber or Excellon files, or multiple stacks of Gerber files, into one.
.. note::
When used with only one input, this command *normalizes* the input, converting all files to a well-defined, widely
supported Gerber subset with sane settings. When a ``--output-naming-scheme`` is given, it additionally renames all
files to a standardized naming convention.
.. option:: --command-line-units <metric|us-customary>
Units for values given in --transform. Default: millimeter
.. option:: --warnings <default|ignore|once>
Enable or disable file format warnings during parsing (default: on)
.. option:: --offset <COORDINATE>
Offset for the n'th file as a ``x,y`` string in unit given by ``--command-line-units`` (default: millimeter). Can be
given multiple times, and the first option affects the first input, the second option affects the second input, and
so on.
.. option:: --rotation <ROTATION>
Rotation for the n'th file in degrees clockwise, optionally followed by comma- separated rotation center X and Y
coordinates. Can be given multiple times, and the first option affects the first input, the second option affects the
second input, and so on.
.. option:: -m, --input-map <json_file>
Extend or override layer name mapping with name map from JSON file. This option can be given multiple times, in which
case the n'th option affects only the n'th input, like with ``--offset`` and ``--rotation``. The JSON file must
contain a single JSON dict with an arbitrary number of string: string entries. The keys are interpreted as regexes
applied to the filenames via re.fullmatch, and each value must either be the string "ignore" to remove this layer
from previous automatic guesses, or a gerbonara layer name such as ``top copper``, ``inner_2 copper`` or ``bottom
silk``.
.. option:: --default-settings, --reuse-input-settings
Use sensible defaults for the output file format settings (default) or use the same export settings as the input file
instead of sensible defaults.
.. option:: --output-naming-scheme <altium|kicad>
Name output files according to the selected naming scheme instead of keeping the old file names of the first input.
.. option:: --output-board-name <TEXT>
Override board name used with ``--output-naming-scheme``
.. option:: --use-builtin-name-rules, --no-builtin-name-rules
Disable built-in layer name rules and use only rules given by --input-map
File analysis
~~~~~~~~~~~~~
``gerbonara bounding-box``
**************************
.. program:: gerbonara bounding-box
.. code-block:: console
gerbonara bounding-box [OPTIONS] INFILE
Print the bounding box of a gerber file in ``[x_min] [y_min] [x_max] [y_max]`` format. The bounding box contains all
graphic objects in this file, so e.g. a 100 mm by 100 mm square drawn with a 1mm width circular aperture will result in
an 101 mm by 101 mm bounding box.
.. option:: --warnings <default|ignore|once>
Enable or disable file format warnings during parsing (default: on)
.. option:: --units <metric|us-customary>
Output bounding box in this unit (default: millimeter)
.. option:: --input-number-format <decimal.fractional>
Override number format of input file (mostly useful for Excellon files)
.. option:: --input-units <metric|us-customary>
Override units of input file
.. option:: --input-zero-suppression <off|leading|trailing>
Override zero suppression setting of input file
``gerbonara meta``
******************
.. program:: gerbonara meta
.. code-block:: console
gerbonara meta [OPTIONS] PATH
Read a board from a folder or zip, and print the found layer mapping along with layer metadata as JSON to stdout. A
machine-readable variant of the :program:`gerbonara render` command. All lengths in the JSON are given in millimeter.
.. option:: --warnings <default|ignore|once>
Enable or disable file format warnings during parsing (default: on)
.. option:: --force-zip
Force treating input path as zip file (default: guess file type from extension and contents)
``gerbonara layers``
********************
.. program:: gerbonara render
.. code-block:: console
$ gerbonara layers [OPTIONS] PATH
Prints a layer-by-layer description of the board found under the given path. The path can be a directory or zip file.
.. option:: --warnings <default|ignore|once>
Enable or disable file format warnings during parsing (default: on)
.. option:: --force-zip
Force treating input path as zip file (default: guess file type from extension and contents)

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

@ -12,6 +12,10 @@ syntactic hints, and can automatically match all files in a folder to their appr
:py:class:`.CamFile` is the common base class for all layer types. :py:class:`.CamFile` is the common base class for all layer types.
.. autoclass:: gerbonara.layers.LayerStack
:members:
.. autoclass:: gerbonara.cam.CamFile .. autoclass:: gerbonara.cam.CamFile
:members: :members:
@ -24,6 +28,3 @@ syntactic hints, and can automatically match all files in a folder to their appr
.. autoclass:: gerbonara.ipc356.Netlist .. autoclass:: gerbonara.ipc356.Netlist
:members: :members:
.. autoclass:: gerbonara.layers.LayerStack
:members:

View file

@ -46,9 +46,7 @@ Features
:maxdepth: 2 :maxdepth: 2
:caption: Contents: :caption: Contents:
cli
api-concepts api-concepts
examples
file-api file-api
object-api object-api
apertures apertures
@ -71,24 +69,10 @@ Then, you are ready to read and write gerber files:
from gerbonara import LayerStack from gerbonara import LayerStack
stack = LayerStack.open('output/gerber') stack = LayerStack.from_directory('output/gerber')
w, h = stack.outline.size('mm') w, h = stack.outline.size('mm')
print(f'Board size is {w:.1f} mm x {h:.1f} mm') print(f'Board size is {w:.1f} mm x {h:.1f} mm')
You can find some more elaborate examples in this doc's :ref:`Examples section<examples-doc>`.
Command-Line Interface
======================
Gerbonara comes with a :ref:`built-in command-line interface<cli-doc>` that has functions for analyzing, rendering,
modifying, and merging Gerber files. To access it, use either the ``gerbonara`` command that is part of the python
package, or run ``python -m gerbonara``. For a list of functions or help on their usage, you can use:
.. code:: console
$ python -m gerbonara --help
[...]
$ python -m gerbonara render --help
Development Development
=========== ===========
@ -109,7 +93,7 @@ A copy of this documentation can also be found at gitlab:
https://gerbolyze.gitlab.io/gerbonara/ https://gerbolyze.gitlab.io/gerbonara/
With Gerbonara, we aim to support as many different format variants as possible. If you have a file that Gerbonara can't With Gebronara, we aim to support as many different format variants as possible. If you have a file that Gerbonara can't
open, please file an issue on our issue tracker. Even if Gerbonara can open all your files, for regression testing we open, please file an issue on our issue tracker. Even if Gerbonara can open all your files, for regression testing we
are very interested in example files generated by any CAD or CAM tool that is not already on the list of supported are very interested in example files generated by any CAD or CAM tool that is not already on the list of supported
tools. tools.

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# Copyright 2022 Jan Sebastian Götte <code@jaseg.de> # Copyright 2022 Jan Götte <code@jaseg.de>
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -20,16 +20,12 @@
Gerbonara Gerbonara
========= =========
gerbonara provides utilities for working with PCB artwork files in Gerber/RS274-X, XNC/Excellon and IPC-356 formats. It gerbonara provides utilities for working with Gerber (RS-274X) and Excellon files in python.
includes convenience functions to match file names to layer types that match the default settings of a number of common
EDA tools.
""" """
from .rs274x import GerberFile from .rs274x import GerberFile
from .excellon import ExcellonFile from .excellon import ExcellonFile
from .ipc356 import Netlist from .ipc356 import Netlist
from .layers import LayerStack from .layers import LayerStack
from .utils import MM, Inch
from importlib.metadata import version
__version__ = version('gerbonara') __version__ = '0.9.0'

25
gerbonara/__main__.py Normal file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env python3
import click
from .layers import LayerStack
@click.command()
@click.option('-t' ,'--top', help='Render board top side.', is_flag=True)
@click.option('-b' ,'--bottom', help='Render board bottom side.', is_flag=True)
@click.argument('gerber_dir_or_zip', type=click.Path(exists=True))
@click.argument('output_svg', required=False, default='-', type=click.File('w'))
def render(gerber_dir_or_zip, output_svg, top, bottom):
if (bool(top) + bool(bottom)) != 1:
raise click.UsageError('Excactly one of --top or --bottom must be given.')
stack = LayerStack.open(gerber_dir_or_zip, lazy=True)
print(f'Loaded {stack}')
svg = stack.to_pretty_svg(side=('top' if top else 'bottom'))
output_svg.write(str(svg))
if __name__ == '__main__':
render()

View file

@ -0,0 +1,211 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2021 Jan 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,185 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2021 Jan 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
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) ])
# 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,270 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
# Copyright 2022 Jan 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)
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)
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)
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)
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.')
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.')
class Comment:
code = 0
def __init__(self, comment):
self.comment = comment
def to_gerber(self, unit=None):
return f'0 {self.comment}'
PRIMITIVE_CLASSES = {
**{cls.code: cls for cls in [
Comment,
Circle,
VectorLine,
CenterLine,
Outline,
Polygon,
Thermal,
]},
# alternative codes
2: VectorLine,
}

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de> # Copyright 2022 Jan Götte <code@jaseg.de>
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -16,18 +16,21 @@
# limitations under the License. # limitations under the License.
# #
import warnings
import math import math
from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY from dataclasses import dataclass, replace, field, fields, InitVar
from functools import lru_cache
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 from . import graphic_primitives as gp
def _flash_hole(self, x, y, unit=None, polarity_dark=True): 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), 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))] gp.Circle(x, y, self.unit.convert_to(unit, self.hole_dia/2), polarity_dark=(not polarity_dark))]
else: else:
@ -37,7 +40,7 @@ def _strip_right(*args):
args = list(args) args = list(args)
while args and args[-1] is None: while args and args[-1] is None:
args.pop() args.pop()
return tuple(args) return args
def _none_close(a, b): def _none_close(a, b):
if a is None and b is None: if a is None and b is None:
@ -54,14 +57,39 @@ class Length:
def __init__(self, obj_type): def __init__(self, obj_type):
self.type = obj_type self.type = obj_type
@dataclass(frozen=True, slots=True) @dataclass
class Aperture: class Aperture:
""" Base class for all apertures. """ """ Base class for all apertures. """
_ : KW_ONLY
unit: LengthUnit = None # hackety hack: Work around python < 3.10 not having dataclasses.KW_ONLY.
attrs: tuple = None #
original_number: int = field(default=None, hash=False, compare=False) # For details, refer to graphic_objects.py
_bounding_box: tuple = field(default=None, hash=False, compare=False) 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): def _params(self, unit=None):
out = [] out = []
@ -90,12 +118,6 @@ class Aperture:
""" """
return self._primitives(x, y, unit, polarity_dark) 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): def equivalent_width(self, unit=None):
""" Get the width of a line interpolated using this aperture in the given :py:class:`~.LengthUnit`. """ Get the width of a line interpolated using this aperture in the given :py:class:`~.LengthUnit`.
@ -108,12 +130,16 @@ class Aperture:
:rtype: str :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 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: if params:
return f'{self._gerber_shape_code},{params}' return f'{actual_inst._gerber_shape_code},{params}'
else: else:
return self._gerber_shape_code return actual_inst._gerber_shape_code
def to_macro(self): def to_macro(self):
""" Convert this :py:class:`.Aperture` into an :py:class:`.ApertureMacro` inside an """ Convert this :py:class:`.Aperture` into an :py:class:`.ApertureMacro` inside an
@ -121,10 +147,24 @@ class Aperture:
""" """
raise NotImplementedError() 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): class ExcellonTool(Aperture):
""" Special Aperture_ subclass for use in :py:class:`.ExcellonFile`. Similar to :py:class:`.CircleAperture`, but """ 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' _gerber_shape_code = 'C'
_human_readable_shape = 'drill' _human_readable_shape = 'drill'
@ -140,6 +180,18 @@ class ExcellonTool(Aperture):
def to_xnc(self, settings): def to_xnc(self, settings):
return 'C' + settings.write_excellon_value(self.diameter, self.unit) 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): def __str__(self):
plated = '' if self.plated is None else (' plated' if self.plated else ' non-plated') 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}]>' return f'<Excellon Tool d={self.diameter:.3f}{plated} [{self.unit}]>'
@ -150,23 +202,19 @@ class ExcellonTool(Aperture):
# Internal use, for layer dilation. # Internal use, for layer dilation.
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
offset = unit(offset, self.unit) offset = unit(offset, self.unit)
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, diameter=self.diameter+2*offset) return replace(self, diameter=self.diameter+2*offset)
@lru_cache() def _rotated(self):
def rotated(self, angle=0):
return self return self
def to_macro(self, rotation=0): def to_macro(self):
from .aperture_macros.parse import GenericMacros return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM)
def _params(self, unit=None): def _params(self, unit=None):
return (self.unit.convert_to(unit, self.diameter),) return [self.unit.convert_to(unit, self.diameter)]
@dataclass(frozen=True, slots=True) @dataclass
class CircleAperture(Aperture): class CircleAperture(Aperture):
""" Besides flashing circles or rings, CircleApertures are used to set the width of a """ 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`. :py:class:`~.graphic_objects.Line` or :py:class:`~.graphic_objects.Arc`.
@ -177,6 +225,10 @@ class CircleAperture(Aperture):
diameter : Length(float) diameter : Length(float)
#: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole. #: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole.
hole_dia : Length(float) = None 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): 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) ] return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2), polarity_dark=polarity_dark) ]
@ -191,34 +243,26 @@ class CircleAperture(Aperture):
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit) offset = self.unit(offset, unit)
if math.isclose(offset, 0, abs_tol=1e-6): return replace(self, diameter=self.diameter+2*offset, hole_dia=None, hole_rect_h=None)
def _rotated(self):
if math.isclose(self.rotation % (2*math.pi), 0) or self.hole_rect_h is None:
return self return self
return replace(self, diameter=self.diameter+2*offset, hole_dia=None) else:
return self.to_macro(self.rotation)
@lru_cache() def to_macro(self):
def rotated(self, angle=0): return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
return self
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)
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 _params(self, unit=None): def _params(self, unit=None):
return _strip_right( return _strip_right(
self.unit.convert_to(unit, self.diameter), 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): 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. """
_gerber_shape_code = 'R' _gerber_shape_code = 'R'
_human_readable_shape = 'rect' _human_readable_shape = 'rect'
#: float with the width of the rectangle in :py:attr:`unit` units. #: float with the width of the rectangle in :py:attr:`unit` units.
@ -227,10 +271,14 @@ class RectangleAperture(Aperture):
h : Length(float) h : Length(float)
#: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole. #: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole.
hole_dia : Length(float) = None 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): 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), 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): def __str__(self):
return f'<rect aperture {self.w:.3}x{self.h:.3} [{self.unit}]>' return f'<rect aperture {self.w:.3}x{self.h:.3} [{self.unit}]>'
@ -242,40 +290,33 @@ class RectangleAperture(Aperture):
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit) offset = self.unit(offset, unit)
if math.isclose(offset, 0, abs_tol=1e-6): return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None)
return self
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None)
@lru_cache() def _rotated(self):
def rotated(self, angle=0): if math.isclose(self.rotation % math.pi, 0):
if math.isclose(angle % math.pi, 0, abs_tol=1e-6):
return self return self
elif math.isclose(angle % math.pi, math.pi/2, abs_tol=1e-6): elif math.isclose(self.rotation % math.pi, math.pi/2):
return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0)
else: # odd angle else: # odd angle
return self.to_macro(angle) return self.to_macro()
def scaled(self, scale): def to_macro(self):
return replace(self, return ApertureMacroInstance(GenericMacros.rect,
w=self.w*scale, [MM(self.w, self.unit),
h=self.h*scale, MM(self.h, self.unit),
hole_dia=None if self.hole_dia is None else self.hole_dia*scale) MM(self.hole_dia, self.unit) or 0,
MM(self.hole_rect_h, self.unit) or 0,
def to_macro(self, rotation=0): self.rotation])
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)
def _params(self, unit=None): def _params(self, unit=None):
return _strip_right( return _strip_right(
self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.w),
self.unit.convert_to(unit, self.h), 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): class ObroundAperture(Aperture):
""" Aperture whose shape is the convex hull of two circles of equal radii. """ Aperture whose shape is the convex hull of two circles of equal radii.
@ -291,10 +332,14 @@ class ObroundAperture(Aperture):
h : Length(float) h : Length(float)
#: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole. #: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole.
hole_dia : Length(float) = None 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): 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), 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): def __str__(self):
return f'<obround aperture {self.w:.3}x{self.h:.3} [{self.unit}]>' return f'<obround aperture {self.w:.3}x{self.h:.3} [{self.unit}]>'
@ -303,47 +348,35 @@ class ObroundAperture(Aperture):
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit) offset = self.unit(offset, unit)
if math.isclose(offset, 0, abs_tol=1e-6): return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None)
return self
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None)
@lru_cache() def _rotated(self):
def rotated(self, angle=0): if math.isclose(self.rotation % math.pi, 0):
if math.isclose(angle % math.pi, 0, abs_tol=1e-6):
return self return self
elif math.isclose(angle % math.pi, math.pi/2, abs_tol=1e-6): elif math.isclose(self.rotation % math.pi, math.pi/2):
return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0)
else: else:
return self.to_macro(angle) return self.to_macro()
def scaled(self, scale): def to_macro(self):
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)
def to_macro(self, rotation=0):
# generic macro only supports w > h so flip x/y if h > w # generic macro only supports w > h so flip x/y if h > w
if self.w > self.h: inst = self if self.w > self.h else replace(self, w=self.h, h=self.w, **_rotate_hole_90(self), rotation=self.rotation-90)
inst = self return ApertureMacroInstance(GenericMacros.obround,
else: [MM(inst.w, self.unit),
rotation -= -math.pi/2 MM(ints.h, self.unit),
inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia) MM(inst.hole_dia, self.unit),
MM(inst.hole_rect_h, self.unit),
from .aperture_macros.parse import GenericMacros inst.rotation])
return GenericMacros.obround(MM(inst.w, self.unit),
MM(inst.h, self.unit),
MM(inst.hole_dia, self.unit) or 0,
rotation)
def _params(self, unit=None): def _params(self, unit=None):
return _strip_right( return _strip_right(
self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.w),
self.unit.convert_to(unit, self.h), 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): class PolygonAperture(Aperture):
""" Aperture whose shape is a regular n-sided polygon (e.g. pentagon, hexagon etc.). Note that this only supports """ Aperture whose shape is a regular n-sided polygon (e.g. pentagon, hexagon etc.). Note that this only supports
round holes. round holes.
@ -360,7 +393,7 @@ class PolygonAperture(Aperture):
hole_dia : Length(float) = None hole_dia : Length(float) = None
def __post_init__(self): 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): 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, return [ gp.ArcPoly.from_regular_polygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices,
@ -371,46 +404,26 @@ class PolygonAperture(Aperture):
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
offset = self.unit(offset, unit) offset = self.unit(offset, unit)
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, diameter=self.diameter+2*offset, hole_dia=None) return replace(self, diameter=self.diameter+2*offset, hole_dia=None)
flash = _flash_hole flash = _flash_hole
@lru_cache() def _rotated(self):
def rotated(self, angle=0): return self
if angle != 0:
return replace(self, rotation=self.rotation + angle)
else:
return self
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)
def to_macro(self): def to_macro(self):
from .aperture_macros.parse import GenericMacros return ApertureMacroInstance(GenericMacros.polygon, self._params(MM))
return GenericMacros.polygon(self.n_vertices,
MM(self.diameter, self.unit),
MM(self.hole_dia, self.unit),
self.rotation)
def _params(self, unit=None): def _params(self, unit=None):
rotation = self.rotation % (2*math.pi / self.n_vertices) rotation = self.rotation % (2*math.pi / self.n_vertices) if self.rotation is not None else None
if math.isclose(rotation, 0, abs_tol=1e-6):
rotation = None
else:
rotation = math.degrees(rotation)
if self.hole_dia is not None: if self.hole_dia is not None:
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia) return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia)
elif rotation is not None and not math.isclose(rotation, 0, abs_tol=1e-6): elif rotation is not None and not math.isclose(rotation, 0):
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation
else: else:
return self.unit.convert_to(unit, self.diameter), self.n_vertices return self.unit.convert_to(unit, self.diameter), self.n_vertices
@dataclass(frozen=True, slots=True) @dataclass
class ApertureMacroInstance(Aperture): class ApertureMacroInstance(Aperture):
""" One instance of an aperture macro. An aperture macro defined with an ``AM`` statement can be instantiated by """ 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 multiple ``AD`` aperture definition statements using different parameters. An :py:class:`.ApertureMacroInstance` is
@ -422,7 +435,10 @@ class ApertureMacroInstance(Aperture):
macro : object macro : object
#: The parameters to the :py:class:`.ApertureMacro`. All elements should be floats or ints. The first item in the #: 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. #: 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 @property
def _gerber_shape_code(self): def _gerber_shape_code(self):
@ -430,39 +446,30 @@ class ApertureMacroInstance(Aperture):
def _primitives(self, x, y, unit=None, polarity_dark=True): def _primitives(self, x, y, unit=None, polarity_dark=True):
out = list(self.macro.to_graphic_primitives( 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)) parameters=self.parameters, unit=unit, polarity_dark=polarity_dark))
return out return out
def dilated(self, offset, unit=MM): def dilated(self, offset, unit=MM):
if math.isclose(offset, 0, abs_tol=1e-6):
return self
return replace(self, macro=self.macro.dilated(offset, unit)) return replace(self, macro=self.macro.dilated(offset, unit))
@lru_cache() def _rotated(self):
def rotated(self, angle=0.0): if math.isclose(self.rotation % (2*math.pi), 0):
if math.isclose(angle % (2*math.pi), 0, abs_tol=1e-6):
return self return self
else: else:
return self.to_macro(angle) return self.to_macro()
def to_macro(self, rotation=0.0): def to_macro(self):
return replace(self, macro=self.macro.rotated(rotation)) return replace(self, macro=self.macro.rotated(self.rotation), rotation=0)
def scaled(self, scale): def __eq__(self, other):
return replace(self, macro=self.macro.scaled(scale)) return hasattr(other, 'macro') and self.macro == other.macro and \
hasattr(other, 'parameters') and self.parameters == other.parameters and \
def calculate_out(self, unit=None, macro_name=None): hasattr(other, 'rotation') and self.rotation == other.rotation
return replace(self,
parameters=tuple(),
macro=self.macro.substitute_params(self._params(unit), unit, macro_name))
def _params(self, unit=None): def _params(self, unit=None):
# We ignore "unit" here as we convert the actual macro, not this instantiation. # We ignore "unit" here as we convert the actual macro, not this instantiation.
# We do this because here we do not have information about which parameter has which physical units. # We do this because here we do not have information about which parameter has which physical units.
parameters = self.parameters return tuple(self.parameters)
if len(parameters) > self.macro.num_parameters:
warnings.warn(f'Aperture definition using macro {self.macro.name} has more parameters than the macro uses.')
parameters = parameters[:self.macro.num_parameters]
return tuple(parameters)

View file

@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be> # Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de> # Copyright 2022 Jan Götte <code@jaseg.de>
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -26,7 +26,7 @@ import shutil
from pathlib import Path from pathlib import Path
from functools import cached_property from functools import cached_property
from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg, convex_hull from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg
from . import graphic_primitives as gp from . import graphic_primitives as gp
from . import graphic_objects as go from . import graphic_objects as go
@ -44,29 +44,17 @@ class FileSettings:
#: (relative) mode is technically still supported, but exceedingly rare in the wild. #: (relative) mode is technically still supported, but exceedingly rare in the wild.
notation : str = 'absolute' notation : str = 'absolute'
#: Export unit. :py:attr:`~.utilities.MM` or :py:attr:`~.utilities.Inch` #: Export unit. :py:attr:`~.utilities.MM` or :py:attr:`~.utilities.Inch`
unit : LengthUnit = None unit : LengthUnit = MM
#: Angle unit. Should be ``'degree'`` unless you really know what you're doing. #: Angle unit. Should be ``'degree'`` unless you really know what you're doing.
angle_unit : str = 'degree' angle_unit : str = 'degree'
#: Zero suppression settings. Must be one of ``None``, ``'leading'`` or ``'trailing'``. See note at #: Zero suppression settings. See note at :py:class:`.FileSettings` for meaning.
#: :py:class:`.FileSettings` for meaning in Excellon files. ``None`` will produce explicit decimal points, which
#: should work for most tools. For Gerber files, the other settings are fine, but for Excellon files, which lack a
#: standardized way to indicate number format, explicit decimal points are the best way to avoid mis-parsing.
zeros : bool = None zeros : bool = None
#: Number format. ``(integer, decimal)`` tuple of number of integer and decimal digits. At most ``(6,7)`` by spec. #: Number format. ``(integer, decimal)`` tuple of number of integer and decimal digits. At most ``(6,7)`` by spec.
number_format : tuple = (None, None) number_format : tuple = (2, 5)
#: At least the aperture macro implementations of gerbv and whatever JLCPCB uses are severely broken and simply
#: ignore parentheses in numeric expressions without throwing an error or a warning, leading to broken rendering.
#: To avoid trouble with severely broken software like this, we just calculate out all macros by default.
#: If you want to export the macros with their original formulaic expressions (which is completely fine by the
#: Gerber standard, btw), set this parameter to ``False`` before exporting.
calculate_out_all_aperture_macros: bool = True
#: Internal field used to communicate if only decimal coordinates were found inside an Excellon file, or if it
#: contained at least some coordinates in fixed-width notation.
_file_has_fixed_width_coordinates: bool = False
# input validation # input validation
def __setattr__(self, name, value): def __setattr__(self, name, value):
if name == 'unit' and value not in [None, MM, Inch]: if name == 'unit' and value not in [MM, Inch]:
raise ValueError(f'Unit must be either Inch or MM, not {value}') raise ValueError(f'Unit must be either Inch or MM, not {value}')
elif name == 'notation' and value not in ['absolute', 'incremental']: elif name == 'notation' and value not in ['absolute', 'incremental']:
raise ValueError(f'Notation must be either "absolute" or "incremental", not {value}') raise ValueError(f'Notation must be either "absolute" or "incremental", not {value}')
@ -87,13 +75,6 @@ class FileSettings:
num = self.number_format[1 if self.zeros == 'leading' else 0] or 0 num = self.number_format[1 if self.zeros == 'leading' else 0] or 0
self._pad = '0'*num self._pad = '0'*num
@classmethod
def defaults(kls):
""" Return a set of good default settings that will work for all gerber or excellon files. These default
settings are metric units, 4 integer digits (for up to 10 m by 10 m size), 5 fractional digits (for 10 µm
resolution) and :py:obj:`None` zero suppression, meaning that explicit decimal points are going to be used."""
return FileSettings(unit=MM, number_format=(4,5), zeros=None)
def to_radian(self, value): def to_radian(self, value):
""" Convert a given numeric string or a given float from file units into radians. """ """ Convert a given numeric string or a given float from file units into radians. """
value = float(value) value = float(value)
@ -132,16 +113,13 @@ class FileSettings:
@property @property
def is_metric(self): def is_metric(self):
""" Return true if this :py:class:`.FileSettings` has a defined unit, and that unit is :py:attr:`~.utilities.MM` """
return self.unit == MM return self.unit == MM
@property @property
def is_inch(self): def is_inch(self):
""" Return true if this :py:class:`.FileSettings` has a defined unit, and that unit is :py:attr:`~.utilities.Inch` """
return self.unit == Inch return self.unit == Inch
def copy(self): def copy(self):
""" Create a deep copy of this FileSettings """
return deepcopy(self) return deepcopy(self)
def __str__(self): def __str__(self):
@ -163,8 +141,8 @@ class FileSettings:
if '.' in value or value == '00': if '.' in value or value == '00':
return float(value) return float(value)
integer_digits, decimal_digits = self.number_format or (2, 5) integer_digits, decimal_digits = self.number_format
if self.zeros == 'leading': if self.zeros == 'leading':
value = self._pad + value # pad with zeros to ensure we have enough decimals value = self._pad + value # pad with zeros to ensure we have enough decimals
@ -180,7 +158,7 @@ class FileSettings:
if unit is not None: if unit is not None:
value = self.unit(value, unit) value = self.unit(value, unit)
integer_digits, decimal_digits = self.number_format or (2, 5) integer_digits, decimal_digits = self.number_format
if integer_digits is None: if integer_digits is None:
integer_digits = 3 integer_digits = 3
if decimal_digits is None: if decimal_digits is None:
@ -210,7 +188,7 @@ class FileSettings:
if unit is not None: if unit is not None:
value = self.unit(value, unit) value = self.unit(value, unit)
integer_digits, decimal_digits = self.number_format or (2, 5) integer_digits, decimal_digits = self.number_format
if integer_digits is None: if integer_digits is None:
integer_digits = 2 integer_digits = 2
if decimal_digits is None: if decimal_digits is None:
@ -256,11 +234,9 @@ class Polyline:
return None return None
(x0, y0), *rest = self.coords (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) d = f'M {x0:.6} {y0:.6} ' + ' '.join(f'L {x:.6} {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, return tag('path', d=d, style=f'fill: none; stroke: {color}; stroke-width: {width:.6}; stroke-linejoin: round; stroke-linecap: round')
fill='none', stroke=color, stroke_linecap='round', stroke_linejoin='round',
stroke_width=width)
class CamFile: class CamFile:
@ -294,21 +270,27 @@ class CamFile:
content_min_x, content_min_y = float(content_min_x), float(content_min_y) content_min_x, content_min_y = float(content_min_x), float(content_min_y)
content_max_x, content_max_y = float(content_max_x), float(content_max_y) content_max_x, content_max_y = float(content_max_x), float(content_max_y)
content_w, content_h = content_max_x - content_min_x, content_max_y - content_min_y content_w, content_h = content_max_x - content_min_x, content_max_y - content_min_y
xform = f'translate({float(content_min_x):.6} {float(content_min_y+content_h):.6}) scale(1 -1) translate({-float(content_min_x):.6} {-float(content_min_y):.6})' xform = f'translate({content_min_x:.6} {content_min_y+content_h:.6}) scale(1 -1) translate({-content_min_x:.6} {-content_min_y:.6})'
tags = [tag('g', tags, transform=xform)] tags = [tag('g', tags, transform=xform)]
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit,
pagecolor=bg, tag=tag) 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 pl = None
for i, obj in enumerate(self.objects): for i, obj in enumerate(self.objects):
if isinstance(obj, go.Flash) and id(obj.aperture) in aperture_map: #if isinstance(obj, go.Flash):
yield tag('use', href='#'+aperture_map[id(obj.aperture)], # if pl:
x=f'{svg_unit(obj.x, obj.unit):.3f}', # tags.append(pl.to_svg(tag, fg, bg))
y=f'{svg_unit(obj.y, obj.unit):.3f}') # 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): for primitive in obj.to_primitives(unit=svg_unit):
if isinstance(primitive, gp.Line): if isinstance(primitive, gp.Line):
if not pl: if not pl:
@ -351,24 +333,6 @@ class CamFile:
return sum_bounds(( p.bounding_box(unit) for p in self.objects ), default=default) return sum_bounds(( p.bounding_box(unit) for p in self.objects ), default=default)
def convex_hull(self, tol=0.01, unit=None):
unit = unit or self.unit
points = []
for obj in self.objects:
if isinstance(obj, go.Line):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
elif isinstance(obj, go.Arc):
for obj in obj.approximate(tol, unit):
line = obj.as_primitive(unit)
points.append((line.x1, line.y1))
points.append((line.x2, line.y2))
return convex_hull(points)
def to_excellon(self): def to_excellon(self):
""" Convert to a :py:class:`.ExcellonFile`. Returns ``self`` if it already is one. """ """ Convert to a :py:class:`.ExcellonFile`. Returns ``self`` if it already is one. """
raise NotImplementedError() raise NotImplementedError()
@ -380,7 +344,7 @@ class CamFile:
def merge(self, other): def merge(self, other):
""" Merge ``other`` into ``self``, i.e. add all objects that are in ``other`` to ``self``. This resets """ Merge ``other`` into ``self``, i.e. add all objects that are in ``other`` to ``self``. This resets
:py:attr:`.import_settings` and :py:attr:`~.CamFile.generator`. Units and other file-specific settings are :py:attr:`.import_settings` and :py:attr:`~.CamFile.generator`. Units and other file-specific settings are
handled automatically. automatically handled.
""" """
raise NotImplementedError() raise NotImplementedError()
@ -421,16 +385,6 @@ class CamFile:
""" """
raise NotImplementedError() raise NotImplementedError()
def scale(self, factor, unit=MM):
""" Scale all objects in this file by the given factor. Only uniform scaling using a single factor in both
directions is supported as for both Gerber and Excellon files, nonuniform scaling would distort circular
flashes, which would lead to garbage results.
:param float factor: Scale factor
:param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Unit ``cx`` and ``cy`` are passed in. Default: mm
"""
raise NotImplementedError()
@property @property
def is_empty(self): def is_empty(self):
""" Check if there are any objects in this file. """ """ Check if there are any objects in this file. """
@ -446,9 +400,6 @@ class CamFile:
return not self.is_empty return not self.is_empty
class LazyCamFile: class LazyCamFile:
""" Helper class for :py:class:`~.layers.LayerStack` that holds a path to an input file without loading it right
away. This class'es :py:method:`save` method will just copy the input file instead of parsing and re-serializing
it."""
def __init__(self, klass, path, *args, **kwargs): def __init__(self, klass, path, *args, **kwargs):
self._class = klass self._class = klass
self.original_path = Path(path) self.original_path = Path(path)
@ -457,8 +408,6 @@ class LazyCamFile:
@cached_property @cached_property
def instance(self): def instance(self):
""" Load the input file if necessary, and return the loaded object. Will only load the file once, and cache the
result. """
return self._class.open(self.original_path, *self._args, **self._kwargs) return self._class.open(self.original_path, *self._args, **self._kwargs)
@property @property
@ -467,8 +416,25 @@ class LazyCamFile:
def save(self, filename, *args, **kwargs): def save(self, filename, *args, **kwargs):
""" Copy this Gerber file to the new path. """ """ Copy this Gerber file to the new path. """
if 'instance' in self.__dict__: # instance has been loaded, and might have been modified shutil.copy(self.original_path, filename)
self.instance.save(filename, *args, **kwargs)
else: class CachedLazyCamFile:
shutil.copy(self.original_path, filename) def __init__(self, klass, data, original_path, *args, **kwargs):
self._class = klass
self._data = data
self.original_path = original_path
self._args = args
self._kwargs = kwargs
@cached_property
def instance(self):
return self._class.from_string(self._data, filename=self.original_path, *self._args, **self._kwargs)
@property
def is_lazy(self):
return True
def save(self, filename, *args, **kwargs):
""" Copy this Gerber file to the new path. """
Path(filename).write_text(self._data)

View file

@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be> # Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de> # Copyright 2022 Jan Götte <code@jaseg.de>
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -30,10 +30,9 @@ from pathlib import Path
from .cam import CamFile, FileSettings from .cam import CamFile, FileSettings
from .graphic_objects import Flash, Line, Arc from .graphic_objects import Flash, Line, Arc
from .apertures import ExcellonTool, CircleAperture from .apertures import ExcellonTool
from .utils import Inch, MM, to_unit, InterpMode, RegexMatcher from .utils import Inch, MM, to_unit, InterpMode, RegexMatcher
class ExcellonContext: class ExcellonContext:
""" Internal helper class used for tracking graphics state when writing Excellon. """ """ Internal helper class used for tracking graphics state when writing Excellon. """
@ -47,15 +46,13 @@ class ExcellonContext:
def select_tool(self, tool): def select_tool(self, tool):
""" Select the current tool. Retract drill first if necessary. """ """ Select the current tool. Retract drill first if necessary. """
current_id = self.tools.get(self.current_tool) if self.current_tool != tool:
new_id = self.tools[tool]
if new_id != current_id:
if self.drill_down: if self.drill_down:
yield 'M16' # drill up yield 'M16' # drill up
self.drill_down = False self.drill_down = False
self.current_tool = tool self.current_tool = tool
yield f'T{new_id:02d}' yield f'T{self.tools[id(tool)]:02d}'
def drill_mode(self): def drill_mode(self):
""" Enter drill mode. """ """ Enter drill mode. """
@ -165,8 +162,6 @@ def parse_allegro_logfile(data):
return found_tools return found_tools
def parse_zuken_logfile(data): def parse_zuken_logfile(data):
""" Internal function to parse Excellon format information out of Zuken's nonstandard textual log files that their
tools generate along with the Excellon file. """
lines = [ line.strip() for line in data.splitlines() ] lines = [ line.strip() for line in data.splitlines() ]
if '***** DRILL LIST *****' not in lines: if '***** DRILL LIST *****' not in lines:
return # likely not a Zuken CR-8000 logfile return # likely not a Zuken CR-8000 logfile
@ -212,22 +207,19 @@ class ExcellonFile(CamFile):
def __str__(self): def __str__(self):
name = f'{self.original_path.name} ' if self.original_path else '' name = f'{self.original_path.name} ' if self.original_path else ''
return f'<ExcellonFile {name}{self.plating_type} with {len(list(self.drills()))} drills, {len(list(self.slots()))} slots using {len(self.drill_sizes())} tools>' if self.is_plated:
plating = 'plated'
elif self.is_nonplated:
plating = 'nonplated'
elif self.is_mixed_plating:
plating = 'mixed plating'
else:
plating = 'unknown plating'
return f'<ExcellonFile {name}{plating} with {len(list(self.drills()))} drills, {len(list(self.slots()))} slots using {len(self.drill_sizes())} tools>'
def __repr__(self): def __repr__(self):
return str(self) return str(self)
@property
def plating_type(self):
if self.is_plated:
return 'plated'
elif self.is_nonplated:
return 'nonplated'
elif self.is_mixed_plating:
return 'mixed plating'
else:
return 'unknown plating'
@property @property
def is_plated(self): def is_plated(self):
""" Test if *all* holes or slots in this file are plated. """ """ Test if *all* holes or slots in this file are plated. """
@ -248,57 +240,41 @@ class ExcellonFile(CamFile):
""" Test if there are multiple plating values used in this file. """ """ Test if there are multiple plating values used in this file. """
return len({obj.plated for obj in self.objects}) > 1 return len({obj.plated for obj in self.objects}) > 1
@property
def is_plated_tristate(self):
if self.is_plated:
return True
if self.is_nonplated:
return False
return None
def append(self, obj_or_comment): def append(self, obj_or_comment):
""" Add a :py:class:`.GraphicObject` or a comment (str) to this file. """ """ Add a :py:class:`.GraphicObject` or a comment (str) to this file. """
if isinstance(obj_or_comment, str): if isinstnace(obj_or_comment, str):
self.comments.append(obj_or_comment) self.comments.append(obj_or_comment)
else: else:
self.objects.append(obj_or_comment) self.objects.append(obj_or_comment)
def to_excellon(self, plated=None, errors='raise'): def to_excellon(self):
""" Counterpart to :py:meth:`~.rs274x.GerberFile.to_excellon`. Does nothing and returns :py:obj:`self`. """
return self return self
def to_gerber(self, errors='raise'): def to_gerber(self):
""" Convert this excellon file into a :py:class:`~.rs274x.GerberFile`. """ apertures = {}
from .rs274x import GerberFile
out = GerberFile() out = GerberFile()
out.comments = self.comments out.comments = self.comments
apertures = {}
for obj in self.objects: for obj in self.objects:
if not (ap := apertures.get(obj.tool)): if id(obj.tool) not in apertures:
ap = apertures[obj.tool] = CircleAperture(obj.tool.diameter, unit=obj.aperture.unit) apertures[id(obj.tool)] = CircleAperture(obj.tool.diameter)
out.objects.append(dataclasses.replace(obj, aperture=ap)) out.objects.append(dataclasses.replace(obj, aperture=apertures[id(obj.tool)]))
return out
out.apertures = list(apertures.values())
@property @property
def generator(self): def generator(self):
return self.generator_hints[0] if self.generator_hints else None return self.generator_hints[0] if self.generator_hints else None
def merge(self, other, mode='ignored', keep_settings=False): def merge(self, other):
if other is None: if other is None:
return return
if not isinstance(other, ExcellonFile):
other = other.to_excellon(plated=self.is_plated_tristate)
self.objects += other.objects self.objects += other.objects
self.comments += other.comments self.comments += other.comments
self.generator_hints = None self.generator_hints = None
if not keep_settings: self.import_settings = None
self.import_settings = None
@classmethod @classmethod
def open(kls, filename, plated=None, settings=None, external_tools=None): def open(kls, filename, plated=None, settings=None, external_tools=None):
@ -328,7 +304,7 @@ class ExcellonFile(CamFile):
for fn in 'nc_param.txt', 'ncdrill.log': for fn in 'nc_param.txt', 'ncdrill.log':
if (param_file := filename.parent / fn).is_file(): if (param_file := filename.parent / fn).is_file():
settings = parse_allegro_ncparam(param_file.read_text()) settings = parse_allegro_ncparam(param_file.read_text())
warnings.warn(f'Loaded allegro-style excellon settings file {param_file}', SyntaxWarning) warnings.warn(f'Loaded allegro-style excellon settings file {param_file}')
break break
# Parse Zuken log file for settings # Parse Zuken log file for settings
@ -336,7 +312,7 @@ class ExcellonFile(CamFile):
logfile = filename.with_suffix('.fdl') logfile = filename.with_suffix('.fdl')
if logfile.is_file(): if logfile.is_file():
settings = parse_zuken_logfile(logfile.read_text()) settings = parse_zuken_logfile(logfile.read_text())
warnings.warn(f'Loaded zuken-style excellon log file {logfile}: {settings}', SyntaxWarning) warnings.warn(f'Loaded zuken-style excellon log file {logfile}: {settings}')
if external_tools is None: if external_tools is None:
# Parse allegro log files for tools. # Parse allegro log files for tools.
@ -374,36 +350,27 @@ class ExcellonFile(CamFile):
yield 'METRIC' if settings.unit == MM else 'INCH' yield 'METRIC' if settings.unit == MM else 'INCH'
# Build tool index # 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)) tools = sorted(tool_map.items(), key=lambda id_tool: (id_tool[1].plated, id_tool[1].diameter))
tools = { tool_id: index for index, (tool_id, _tool) in enumerate(tools, start=1) }
# FIXME dedup tools
mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1) mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1)
if mixed_plating: if mixed_plating:
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.', SyntaxWarning) warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.')
defined_tools = {} if tools and max(tools.values()) >= 100:
tool_indices = {} warnings.warn('More than 99 tools defined. Some programs may not like three-digit tool indices.', SyntaxWarning)
index = 1
for tool_id, tool in tools:
xnc = tool.to_xnc(settings)
if (tool.plated, xnc) in defined_tools:
tool_indices[tool_id] = defined_tools[(tool.plated, xnc)]
else: for tool_id, index in tools.items():
if mixed_plating: tool = tool_map[tool_id]
yield ';TYPE=PLATED' if tool.plated else ';TYPE=NON_PLATED' if mixed_plating:
yield ';TYPE=PLATED' if tool.plated else ';TYPE=NON_PLATED'
yield f'T{index:02d}' + xnc yield f'T{index:02d}' + tool.to_xnc(settings)
tool_indices[tool_id] = defined_tools[(tool.plated, xnc)] = index
index += 1
if index >= 100:
warnings.warn('More than 99 tools defined. Some programs may not like three-digit tool indices.', SyntaxWarning)
yield '%' yield '%'
ctx = ExcellonContext(settings, tool_indices) ctx = ExcellonContext(settings, tools)
# Export objects # Export objects
for obj in self.objects: for obj in self.objects:
@ -426,10 +393,10 @@ class ExcellonFile(CamFile):
if settings is None: if settings is None:
if self.import_settings: if self.import_settings:
settings = self.import_settings.copy() settings = self.import_settings.copy()
settings.zeros = None
settings.number_format = (3,5)
else: else:
settings = FileSettings.defaults() settings = FileSettings()
settings.zeros = None
settings.number_format = (3,5)
return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments)).encode('utf-8') return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments)).encode('utf-8')
def save(self, filename, settings=None, drop_comments=True): def save(self, filename, settings=None, drop_comments=True):
@ -569,8 +536,6 @@ class ExcellonParser(object):
self.filename = None self.filename = None
self.external_tools = external_tools or {} self.external_tools = external_tools or {}
self.found_kicad_format_comment = False self.found_kicad_format_comment = False
self.allegro_eof_toolchange_hack = False
self.allegro_eof_toolchange_hack_index = 1
def warn(self, msg): def warn(self, msg):
warnings.warn(f'{self.filename}:{self.lineno} "{self.line}": {msg}', SyntaxWarning) warnings.warn(f'{self.filename}:{self.lineno} "{self.line}": {msg}', SyntaxWarning)
@ -611,25 +576,18 @@ class ExcellonParser(object):
exprs = RegexMatcher() exprs = RegexMatcher()
# NOTE: These must be kept before the generic comment handler at the end of this class so they match first. # NOTE: These must be kept before the generic comment handler at the end of this class so they match first.
@exprs.match(r';(?P<index1_prefix>T(?P<index1>[0-9]+))?\s+Holesize (?P<index2>[0-9]+)\. = (?P<diameter>[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?P<plated>PLATED|NON_PLATED|OPTIONAL) (?P<unit>MILS|MM) Quantity = [0-9]+') @exprs.match(r';T(?P<index1>[0-9]+) Holesize (?P<index2>[0-9]+)\. = (?P<diameter>[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?P<plated>PLATED|NON_PLATED|OPTIONAL) (?P<unit>MILS|MM) Quantity = [0-9]+')
def parse_allegro_tooldef(self, match): def parse_allegro_tooldef(self, match):
# NOTE: We ignore the given tolerances here since they are non-standard. # NOTE: We ignore the given tolerances here since they are non-standard.
self.program_state = ProgramState.HEADER # TODO is this needed? we need a test file. self.program_state = ProgramState.HEADER # TODO is this needed? we need a test file.
self.generator_hints.append('allegro') self.generator_hints.append('allegro')
index = int(match['index2']) if (index := int(match['index1'])) != int(match['index2']): # index1 has leading zeros, index2 not.
if match['index1'] and index != int(match['index1']): # index1 has leading zeros, index2 not.
raise SyntaxError('BUG: Allegro excellon tool def has mismatching tool indices. Please file a bug report on our issue tracker and provide this file!') raise SyntaxError('BUG: Allegro excellon tool def has mismatching tool indices. Please file a bug report on our issue tracker and provide this file!')
if index in self.tools: if index in self.tools:
self.warn('Re-definition of tool index {index}, overwriting old definition.') self.warn('Re-definition of tool index {index}, overwriting old definition.')
if not match['index1_prefix']:
# This is a really nasty orcad file without tool change commands, that instead just puts all holes in order
# of the hole size definitions with M00's in between.
self.allegro_eof_toolchange_hack = True
# NOTE: We map "optionally" plated holes to plated holes for API simplicity. If you hit a case where that's a # NOTE: We map "optionally" plated holes to plated holes for API simplicity. If you hit a case where that's a
# problem, please raise an issue on our issue tracker, explain why you need this and provide an example file. # problem, please raise an issue on our issue tracker, explain why you need this and provide an example file.
is_plated = None if match['plated'] is None else (match['plated'] in ('PLATED', 'OPTIONAL')) is_plated = None if match['plated'] is None else (match['plated'] in ('PLATED', 'OPTIONAL'))
@ -642,19 +600,13 @@ class ExcellonParser(object):
else: else:
unit = MM unit = MM
if self.settings.unit is None: if unit != self.settings.unit:
self.settings.unit = unit
elif unit != self.settings.unit:
self.warn('Allegro Excellon drill file tool definitions in {unit.name}, but file parameters say the ' self.warn('Allegro Excellon drill file tool definitions in {unit.name}, but file parameters say the '
'file should be in {settings.unit.name}. Please double-check that this is correct, and if it is, ' 'file should be in {settings.unit.name}. Please double-check that this is correct, and if it is, '
'please raise an issue on our issue tracker.') 'please raise an issue on our issue tracker.')
self.tools[index] = ExcellonTool(diameter=diameter, plated=is_plated, unit=unit) self.tools[index] = ExcellonTool(diameter=diameter, plated=is_plated, unit=unit)
if self.allegro_eof_toolchange_hack and self.active_tool is None:
self.active_tool = self.tools[index]
# Searching Github I found that EasyEDA has two different variants of the unit specification here. # Searching Github I found that EasyEDA has two different variants of the unit specification here.
@exprs.match(';Holesize (?P<index>[0-9]+) = (?P<diameter>[.0-9]+) (?P<unit>INCH|inch|METRIC|mm)') @exprs.match(';Holesize (?P<index>[0-9]+) = (?P<diameter>[.0-9]+) (?P<unit>INCH|inch|METRIC|mm)')
def parse_easyeda_tooldef(self, match): def parse_easyeda_tooldef(self, match):
@ -771,12 +723,6 @@ class ExcellonParser(object):
def handle_end_of_program(self, match): def handle_end_of_program(self, match):
if self.program_state in (None, ProgramState.HEADER): if self.program_state in (None, ProgramState.HEADER):
self.warn('M30 statement found before end of header.') self.warn('M30 statement found before end of header.')
if self.allegro_eof_toolchange_hack:
self.allegro_eof_toolchange_hack_index = min(max(self.tools), self.allegro_eof_toolchange_hack_index + 1)
self.active_tool = self.tools[self.allegro_eof_toolchange_hack_index]
return
self.program_state = ProgramState.FINISHED self.program_state = ProgramState.FINISHED
# TODO: maybe add warning if this is followed by other commands. # TODO: maybe add warning if this is followed by other commands.
@ -786,17 +732,14 @@ class ExcellonParser(object):
def do_move(self, coord_groups): def do_move(self, coord_groups):
x_s, x, y_s, y = coord_groups x_s, x, y_s, y = coord_groups
if (x is not None and '.' not in x) or (y is not None and '.' not in y): if self.settings.number_format == (None, None) and '.' not in x:
self.settings._file_has_fixed_width_coordinates = True # TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else.
if x != '00':
if self.settings.number_format == (None, None): raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro '
# TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else. 'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as '
if x != '00': 'it, because Allegro does not include this critical information in their Excellon output. If you '
raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro ' 'call this through ExcellonFile.from_string, you must manually supply from_string with a '
'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as ' 'FileSettings object from excellon.parse_allegro_ncparam.')
'it, because Allegro does not include this critical information in their Excellon output. If you '
'call this through ExcellonFile.from_string, you must manually supply from_string with a '
'FileSettings object from excellon.parse_allegro_ncparam.')
x = self.settings.parse_gerber_value(x) x = self.settings.parse_gerber_value(x)
if x_s: if x_s:
@ -890,17 +833,12 @@ class ExcellonParser(object):
# from https://math.stackexchange.com/a/1781546 # from https://math.stackexchange.com/a/1781546
if a_s: if a_s:
raise ValueError('Negative arc radius given') raise ValueError('Negative arc radius given')
r = self.settings.parse_gerber_value(a) r = settings.parse_gerber_value(a)
x1, y1 = start x1, y1 = start
x2, y2 = end x2, y2 = end
dx, dy = (x2-x1)/2, (y2-y1)/2 dx, dy = (x2-x1)/2, (y2-y1)/2
x0, y0 = x1+dx, y1+dy x0, y0 = x1+dx, y1+dy
d = math.hypot(dx, dy) f = math.hypot(dx, dy) / math.sqrt(r**2 - a**2)
if d == 0:
raise ValueError('Arc radius notation requires distinct start and end points')
if r < d:
raise ValueError('Arc radius too small for endpoint distance')
f = math.sqrt(r**2 - d**2) / d
if clockwise: if clockwise:
cx = x0 + f*dy cx = x0 + f*dy
cy = y0 - f*dx cy = y0 - f*dx
@ -910,16 +848,16 @@ class ExcellonParser(object):
i, j = cx-start[0], cy-start[1] i, j = cx-start[0], cy-start[1]
else: # explicit center given else: # explicit center given
i = self.settings.parse_gerber_value(i) or 0 i = settings.parse_gerber_value(i)
if i_s: if i_s:
i = -i i = -i
j = self.settings.parse_gerber_value(j) or 0 j = settings.parse_gerber_value(j)
if j_s: if j_s:
j = -j j = -i
self.objects.append(Arc(*start, *end, i, j, clockwise, self.active_tool, unit=self.settings.unit)) self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit))
@exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,([0-9]+\.[0-9]+|0*\.0*))?') @exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,0*\.0*)?')
def parse_easyeda_format(self, match): def parse_easyeda_format(self, match):
metric = match[1] in ('METRIC', 'M71') metric = match[1] in ('METRIC', 'M71')
@ -932,10 +870,7 @@ class ExcellonParser(object):
# This is used by newer autodesk eagles, fritzing and diptrace # This is used by newer autodesk eagles, fritzing and diptrace
if match[3]: if match[3]:
integer, _, fractional = match[3][1:].partition('.') integer, _, fractional = match[3][1:].partition('.')
if integer.strip('0') or fractional.strip('0'): self.settings.number_format = len(integer), len(fractional)
self.settings.number_format = int(integer), int(fractional)
else:
self.settings.number_format = len(integer), len(fractional)
elif self.settings.number_format == (None, None) and not metric and not self.found_kicad_format_comment: elif self.settings.number_format == (None, None) and not metric and not self.found_kicad_format_comment:
self.warn('Using implicit number format from bare "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.') self.warn('Using implicit number format from bare "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.')
@ -961,10 +896,10 @@ class ExcellonParser(object):
@exprs.match('(FMAT|VER),?([0-9]*)') @exprs.match('(FMAT|VER),?([0-9]*)')
def handle_command_format(self, match): def handle_command_format(self, match):
if match[1] == 'FMAT': if match[1] == 'FMAT':
# We only use FMAT as a compatibility marker. Version 1 drill files encountered in the wild still use the # We do not support integer/fractional decimals specification via FMAT because that's stupid. If you need this,
# same coordinate and routing statements that we already support, so rejecting the header unconditionally # please raise an issue on our issue tracker, provide a sample file and tell us where on earth you found that
# needlessly breaks otherwise parseable files. # file.
if match[2] not in ('', '1', '2'): if match[2] not in ('', '2'):
raise SyntaxError(f'Unsupported FMAT format version {match[2]}') raise SyntaxError(f'Unsupported FMAT format version {match[2]}')
else: # VER else: # VER
@ -993,19 +928,6 @@ class ExcellonParser(object):
else: else:
self.warn('Bare coordinate after end of file') self.warn('Bare coordinate after end of file')
@exprs.match(xy_coord + 'G85' + xy_coord)
def handle_g85_slot(self, match):
if self.program_state == ProgramState.HEADER:
return
self.do_move(match.groups()[:4])
start, end = self.do_move(match.groups()[4:])
if not self.ensure_active_tool():
return
self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit))
@exprs.match(r'DETECT,ON|ATC,ON|M06') @exprs.match(r'DETECT,ON|ATC,ON|M06')
def parse_zuken_legacy_statements(self, match): def parse_zuken_legacy_statements(self, match):
self.generator_hints.append('zuken') self.generator_hints.append('zuken')

View file

@ -0,0 +1,105 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from argparse import PARSER
# Copyright 2015 Garret Fick <garret@ficksworkshop.com>
# 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.
"""
Excellon Settings Definition File module
====================
**Excellon file classes**
This module provides Excellon file classes and parsing utilities
"""
import re
try:
from cStringIO import StringIO
except(ImportError):
from io import StringIO
from .cam import FileSettings
def loads(data):
""" Read settings file information and return an FileSettings
Parameters
----------
data : string
string containing Excellon settings file contents
Returns
-------
file settings: FileSettings
"""
return ExcellonSettingsParser().parse_raw(data)
def map_coordinates(value):
if value == 'ABSOLUTE':
return 'absolute'
return 'relative'
def map_units(value):
if value == 'ENGLISH':
return 'inch'
return 'metric'
def map_boolean(value):
return value == 'YES'
SETTINGS_KEYS = {
'INTEGER-PLACES': (int, 'format-int'),
'DECIMAL-PLACES': (int, 'format-dec'),
'COORDINATES': (map_coordinates, 'notation'),
'OUTPUT-UNITS': (map_units, 'units'),
}
class ExcellonSettingsParser(object):
"""Excellon Settings PARSER
Parameters
----------
None
"""
def __init__(self):
self.values = {}
self.settings = None
def parse_raw(self, data):
for line in StringIO(data):
self._parse(line.strip())
# Create the FileSettings object
self.settings = FileSettings(
notation=self.values['notation'],
units=self.values['units'],
format=(self.values['format-int'], self.values['format-dec'])
)
return self.settings
def _parse(self, line):
line_items = line.split()
if len(line_items) == 2:
item_type_info = SETTINGS_KEYS.get(line_items[0])
if item_type_info:
# Convert the value to the expected type
item_value = item_type_info[0](line_items[1])
self.values[item_type_info[1]] = item_value

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de> # Copyright 2022 Jan Götte <code@jaseg.de>
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -19,11 +19,9 @@
import math import math
import copy import copy
from dataclasses import dataclass, astuple, field, fields from dataclasses import dataclass, astuple, field, fields
from itertools import zip_longest, pairwise, islice, cycle
from .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 . import graphic_primitives as gp
from .aperture_macros import primitive as amp
def convert(value, src, dst): def convert(value, src, dst):
@ -107,20 +105,6 @@ class GraphicObject:
dx, dy = self.unit(dx, unit), self.unit(dy, unit) dx, dy = self.unit(dx, unit), self.unit(dy, unit)
self._offset(dx, dy) self._offset(dx, dy)
def scale(self, factor, unit=MM):
""" Scale this feature in both its dimensions and location.
.. note:: The scale factor is a scalar, and the unit argument is irrelevant, but is kept for API consistency.
.. note:: If this object references an aperture, this aperture is not modified. You will have to transform this
aperture yourself.
:param float factor: Scale factor, 1 to keep the object as is, larger values to enlarge, smaller values to
shrink. Negative values are permitted.
"""
self._scale(factor)
def rotate(self, rotation, cx=0, cy=0, unit=MM): def rotate(self, rotation, cx=0, cy=0, unit=MM):
""" Rotate this object. The center of rotation can be given in either unit, and is automatically converted into """ Rotate this object. The center of rotation can be given in either unit, and is automatically converted into
this object's local unit. this object's local unit.
@ -128,9 +112,6 @@ class GraphicObject:
.. note:: The center's Y coordinate as well as the angle's polarity are flipped compared to computer graphics .. note:: The center's Y coordinate as well as the angle's polarity are flipped compared to computer graphics
convention since Gerber uses a bottom-to-top Y axis. convention since Gerber uses a bottom-to-top Y axis.
.. note:: If this object references an aperture, this aperture is not modified. You will have to transform this
aperture yourself.
:param float rotation: rotation in radians clockwise. :param float rotation: rotation in radians clockwise.
:param float cx: X coordinate of center of rotation in *unit* units. :param float cx: X coordinate of center of rotation in *unit* units.
:param float cy: Y coordinate of center of rotation. (0,0) is at the bottom left of the image. :param float cy: Y coordinate of center of rotation. (0,0) is at the bottom left of the image.
@ -152,7 +133,12 @@ class GraphicObject:
:returns: tuple of tuples of floats: ``(min_x, min_y), (max_x, max_y)`` :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): def to_primitives(self, unit=None):
""" Render this object into low-level graphical primitives (subclasses of :py:class:`.GraphicPrimitive`). This """ Render this object into low-level graphical primitives (subclasses of :py:class:`.GraphicPrimitive`). This
@ -214,11 +200,6 @@ class Flash(GraphicObject):
def tool(self, value): def tool(self, value):
self.aperture = 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 @property
def plated(self): def plated(self):
""" (Excellon only) Returns if this is a plated hole. ``True`` (plated), ``False`` (non-plated) or ``None`` """ (Excellon only) Returns if this is a plated hole. ``True`` (plated), ``False`` (non-plated) or ``None``
@ -233,10 +214,6 @@ class Flash(GraphicObject):
def _rotate(self, rotation, cx=0, cy=0): def _rotate(self, rotation, cx=0, cy=0):
self.x, self.y = gp.rotate_point(self.x, self.y, rotation, cx, cy) self.x, self.y = gp.rotate_point(self.x, self.y, rotation, cx, cy)
def _scale(self, factor):
self.x *= factor
self.y *= factor
def to_primitives(self, unit=None): def to_primitives(self, unit=None):
conv = self.converted(unit) conv = self.converted(unit)
yield from self.aperture.flash(conv.x, conv.y, unit, self.polarity_dark) yield from self.aperture.flash(conv.x, conv.y, unit, self.polarity_dark)
@ -278,23 +255,16 @@ class Region(GraphicObject):
* A region is always exactly one connected component. * A region is always exactly one connected component.
* A region must not overlap itself anywhere. * A region must not overlap itself anywhere.
* A region cannot have holes. * A region cannot have holes.
* The last outline point of the region must be equal to the first.
There is one exception from the last two rules: To emulate a region with a hole in it, *cut-ins* are allowed. At a There is one exception from the last two rules: To emulate a region with a hole in it, *cut-ins* are allowed. At a
cut-in, the region is allowed to touch (but never overlap!) itself. cut-in, the region is allowed to touch (but never overlap!) itself.
When ``arc_centers`` is empty, this region has only straight outline segments. When ``arc_centers`` is not empty,
the i-th entry defines the i-th outline segment, with a ``None`` entry designating a straight line segment.
An arc is defined by a ``(clockwise, (cx, cy))`` tuple, where ``clockwise`` can be ``True`` for a clockwise arc, or
``False`` for a counter-clockwise arc. ``cx`` and ``cy`` are the absolute coordinates of the arc's center.
""" """
def __init__(self, outline=None, arc_centers=None, *, unit=MM, polarity_dark=True): def __init__(self, outline=None, arc_centers=None, *, unit, polarity_dark):
self.unit = unit self.unit = unit
self.polarity_dark = polarity_dark self.polarity_dark = polarity_dark
self.outline = [] if outline is None else outline self.outline = [] if outline is None else outline
self.arc_centers = [] if arc_centers is None else arc_centers self.arc_centers = [] if arc_centers is None else arc_centers
self.close()
def __len__(self): def __len__(self):
return len(self.outline) return len(self.outline)
@ -302,44 +272,14 @@ class Region(GraphicObject):
def __bool__(self): def __bool__(self):
return bool(self.outline) 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): def _offset(self, dx, dy):
self.outline = [ (x+dx, y+dy) for x, y in self.outline ] self.outline = [ (x+dx, y+dy) for x, y in self.outline ]
self.arc_centers = [ (c[0], (c[1][0]+dx, c[1][1]+dy)) if c else None for c in self.arc_centers ]
def _rotate(self, angle, cx=0, cy=0): def _rotate(self, angle, cx=0, cy=0):
self.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.outline ] self.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.outline ]
self.arc_centers = [ self.arc_centers = [
(arc[0], gp.rotate_point(*arc[1], angle, cx, cy)) if arc else None (arc[0], gp.rotate_point(*arc[1], angle, cx-p[0], cy-p[1])) if arc else None
for arc in self.arc_centers ] for p, arc in zip(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)
def append(self, obj): def append(self, obj):
if obj.unit != self.unit: if obj.unit != self.unit:
@ -350,56 +290,10 @@ class Region(GraphicObject):
self.outline.append(obj.p2) self.outline.append(obj.p2)
if isinstance(obj, Arc): if isinstance(obj, Arc):
self.arc_centers.append((obj.clockwise, obj.center)) self.arc_centers.append((obj.clockwise, obj.center_relative))
else: else:
self.arc_centers.append(None) self.arc_centers.append(None)
def iter_segments(self, tolerance=1e-6):
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): def to_primitives(self, unit=None):
if unit == self.unit: if unit == self.unit:
yield gp.ArcPoly(outline=self.outline, arc_centers=self.arc_centers, polarity_dark=self.polarity_dark) yield gp.ArcPoly(outline=self.outline, arc_centers=self.arc_centers, polarity_dark=self.polarity_dark)
@ -413,9 +307,6 @@ class Region(GraphicObject):
yield gp.ArcPoly(conv_outline, conv_arc, polarity_dark=self.polarity_dark) yield gp.ArcPoly(conv_outline, conv_arc, polarity_dark=self.polarity_dark)
def to_statements(self, gs): def to_statements(self, gs):
if len(self.outline) < 3:
return
yield from gs.set_polarity(self.polarity_dark) yield from gs.set_polarity(self.polarity_dark)
yield 'G36*' yield 'G36*'
# Repeat interpolation mode at start of region statement to work around gerbv bug. Without this, gerbv will # Repeat interpolation mode at start of region statement to work around gerbv bug. Without this, gerbv will
@ -425,24 +316,29 @@ class Region(GraphicObject):
yield from gs.set_current_point(self.outline[0], unit=self.unit) yield from gs.set_current_point(self.outline[0], unit=self.unit)
for previous_point, point, (clockwise, center) in self.iter_segments(): for point, arc_center in zip(self.outline[1:], self.arc_centers):
if point is None and center is None: if arc_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:
yield from gs.set_interpolation_mode(InterpMode.LINEAR) yield from gs.set_interpolation_mode(InterpMode.LINEAR)
x = gs.file_settings.write_gerber_value(point[0], self.unit)
y = gs.file_settings.write_gerber_value(point[1], self.unit)
yield f'X{x}Y{y}D01*' yield f'X{x}Y{y}D01*'
gs.update_point(*point, unit=self.unit)
else: else:
clockwise, (cx, cy) = arc_center
x2, y2 = point
yield from gs.set_interpolation_mode(InterpMode.CIRCULAR_CW if clockwise else InterpMode.CIRCULAR_CCW) yield from gs.set_interpolation_mode(InterpMode.CIRCULAR_CW if clockwise else InterpMode.CIRCULAR_CCW)
i = gs.file_settings.write_gerber_value(center[0]-previous_point[0], self.unit)
j = gs.file_settings.write_gerber_value(center[1]-previous_point[1], self.unit) x = gs.file_settings.write_gerber_value(x2, self.unit)
y = gs.file_settings.write_gerber_value(y2, self.unit)
# TODO are these coordinates absolute or relative now?!
i = gs.file_settings.write_gerber_value(cx, self.unit)
j = gs.file_settings.write_gerber_value(cy, self.unit)
yield f'X{x}Y{y}I{i}J{j}D01*' yield f'X{x}Y{y}I{i}J{j}D01*'
gs.update_point(*point, unit=self.unit) gs.update_point(x2, y2, unit=self.unit)
yield 'G37*' yield 'G37*'
@ -483,12 +379,6 @@ class Line(GraphicObject):
self.x1, self.y1 = gp.rotate_point(self.x1, self.y1, rotation, cx, cy) self.x1, self.y1 = gp.rotate_point(self.x1, self.y1, rotation, cx, cy)
self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy) self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy)
def _scale(self, factor):
self.x1 *= factor
self.y1 *= factor
self.x2 *= factor
self.y2 *= factor
@property @property
def p1(self): def p1(self):
""" Convenience alias for ``(self.x1, self.y1)`` returning start point of the line. """ """ Convenience alias for ``(self.x1, self.y1)`` returning start point of the line. """
@ -523,13 +413,6 @@ class Line(GraphicObject):
def to_primitives(self, unit=None): def to_primitives(self, unit=None):
yield self.as_primitive(unit=unit) 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): def to_statements(self, gs):
yield from gs.set_polarity(self.polarity_dark) yield from gs.set_polarity(self.polarity_dark)
yield from gs.set_aperture(self.aperture) yield from gs.set_aperture(self.aperture)
@ -588,10 +471,6 @@ class Arc(GraphicObject):
#: Aperture for this arc. Should be a subclass of :py:class:`.CircleAperture`, whose diameter determines the line #: Aperture for this arc. Should be a subclass of :py:class:`.CircleAperture`, whose diameter determines the line
#: width. #: width.
aperture : object 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): def _offset(self, dx, dy):
self.x1 += dx self.x1 += dx
@ -624,8 +503,22 @@ class Arc(GraphicObject):
:returns: Angle in clockwise radian between ``0`` and ``2*math.pi`` :returns: Angle in clockwise radian between ``0`` and ``2*math.pi``
:rtype: float :rtype: float
""" """
cx, cy = self.cx + self.x1, self.cy + self.y1
x1, y1 = self.x1 - cx, self.y1 - cy
x2, y2 = self.x2 - cx, self.y2 - cy
return sweep_angle(self.cx+self.x1, self.cy+self.y1, self.x1, self.y1, self.x2, self.y2, self.clockwise) a1, a2 = math.atan2(y1, x1), math.atan2(y2, x2)
f = abs(a2 - a1)
if not self.clockwise:
if a2 > a1:
return a2 - a1
else:
return 2*math.pi - abs(a2 - a1)
else:
if a1 > a2:
return a1 - a2
else:
return 2*math.pi - abs(a1 - a2)
@property @property
def p1(self): def p1(self):
@ -682,16 +575,34 @@ class Arc(GraphicObject):
:returns: list of :py:class:`~.graphic_objects.Line` instances. :returns: list of :py:class:`~.graphic_objects.Line` instances.
:rtype: list :rtype: list
""" """
# TODO the max_angle calculation below is a bit off -- we over-estimate the error, and thus produce finer
# results than necessary. Fix this.
r = math.hypot(self.cx, self.cy)
max_error = self.unit(max_error, unit) max_error = self.unit(max_error, unit)
return [Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit) if clip_max_error:
for p1, p2 in pairwise(approximate_arc( # 1 - math.sqrt(1 - 0.5*math.sqrt(2))
self.cx+self.x1, self.cy+self.y1, max_error = min(max_error, r*0.4588038998538031)
self.x1, self.y1,
self.x2, self.y2, elif max_error >= r:
self.clockwise, return [Line(*self.p1, *self.p2, aperture=self.aperture, polarity_dark=self.polarity_dark)]
max_error=max_error,
clip_max_error=clip_max_error))] # see https://www.mathopenref.com/sagitta.html
l = math.sqrt(r**2 - (r - max_error)**2)
angle_max = math.asin(l/r)
sweep_angle = self.sweep_angle()
num_segments = math.ceil(sweep_angle / angle_max)
angle = sweep_angle / num_segments
if not self.clockwise:
angle = -angle
cx, cy = self.center
points = [ rotate_point(self.x1, self.y1, i*angle, cx, cy) for i in range(num_segments + 1) ]
return [ Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark)
for p1, p2 in zip(points[0::], points[1::]) ]
def _rotate(self, rotation, cx=0, cy=0): def _rotate(self, rotation, cx=0, cy=0):
# rotate center first since we need old x1, y1 here # rotate center first since we need old x1, y1 here
@ -700,20 +611,12 @@ class Arc(GraphicObject):
self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy) self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy)
self.cx, self.cy = new_cx - self.x1, new_cy - self.y1 self.cx, self.cy = new_cx - self.x1, new_cy - self.y1
def _scale(self, factor):
self.x1 *= factor
self.y1 *= factor
self.x2 *= factor
self.y2 *= factor
self.cx *= factor
self.cy *= factor
def as_primitive(self, unit=None): def as_primitive(self, unit=None):
conv = self.converted(unit) 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, return gp.Arc(x1=conv.x1, y1=conv.y1,
x2=conv.x2, y2=conv.y2, x2=conv.x2, y2=conv.y2,
cx=conv.cx+conv.x1, cy=conv.cy+conv.y1, cx=conv.cx, cy=conv.cy,
clockwise=self.clockwise, clockwise=self.clockwise,
width=w, width=w,
polarity_dark=self.polarity_dark) polarity_dark=self.polarity_dark)
@ -721,17 +624,6 @@ class Arc(GraphicObject):
def to_primitives(self, unit=None): def to_primitives(self, unit=None):
yield self.as_primitive(unit=unit) 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): def to_statements(self, gs):
yield from gs.set_polarity(self.polarity_dark) yield from gs.set_polarity(self.polarity_dark)
yield from gs.set_aperture(self.aperture) yield from gs.set_aperture(self.aperture)

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de> # Copyright 2022 Jan Götte <code@jaseg.de>
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -19,7 +19,7 @@
import math import math
import itertools import itertools
from dataclasses import dataclass, replace, field from dataclasses import dataclass, replace
from .utils import * from .utils import *
@ -62,12 +62,6 @@ class GraphicPrimitive:
raise NotImplementedError() raise NotImplementedError()
def is_zero_size(self):
""" Return whether this primitive is zero size
:rtype: bool
"""
@dataclass(frozen=True) @dataclass(frozen=True)
class Circle(GraphicPrimitive): class Circle(GraphicPrimitive):
@ -83,15 +77,7 @@ class Circle(GraphicPrimitive):
def to_svg(self, fg='black', bg='white', tag=Tag): def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), fill=color) return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), style=f'fill: {color}')
def to_arc_poly(self):
return ArcPoly([(self.x-self.r, self.y), (self.x+self.r, self.y)],
[(True, (self.x, self.y)), (True, (self.x, self.y))],
polarity_dark=self.polarity_dark)
def is_zero_size(self):
return math.isclose(self.r, 0)
@dataclass(frozen=True) @dataclass(frozen=True)
@ -102,51 +88,28 @@ class ArcPoly(GraphicPrimitive):
#: connected. #: connected.
outline : list outline : list
#: Must be either None (all segments are straight lines) or same length as outline. #: Must be either None (all segments are straight lines) or same length as outline.
#: Straight line segments have None entry. Arc segments have (clockwise, (cx, cy)) tuple with cx, cy being absolute #: Straight line segments have None entry.
#: coords. arc_centers : list = None
arc_centers : list = field(default_factory=list)
@property @property
def segments(self): def segments(self):
""" Return an iterator through all *segments* of this polygon. For each outline segment (line or arc), this """ Return an iterator through all *segments* of this polygon. For each outline segment (line or arc), this
iterator will yield a ``(p1, p2, (clockwise, center))`` tuple. If the segment is a straight line, ``clockwise`` iterator will yield a ``(p1, p2, center)`` tuple. If the segment is a straight line, ``center`` will be
will be ``None``. ``None``.
""" """
for points, arc in itertools.zip_longest(itertools.pairwise(self.outline), self.arc_centers): ol = self.outline
if arc: return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers or [])
if points:
yield *points, arc
else:
yield self.outline[-1], self.outline[0], arc
return
else:
if not points:
break
yield *points, (None, (None, None))
# Close outline if necessary.
if math.dist(self.outline[0], self.outline[-1]) > 1e-6:
yield self.outline[-1], self.outline[0], (None, (None, None))
def approximate_arcs(self, max_error=1e-2, clip_max_error=True):
outline = []
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
if clockwise is None:
outline.append((x1, y1))
else:
outline.extend(approximate_arc(cx, cy, x1, y1, x2, y2, clockwise,
max_error=max_error, clip_max_error=clip_max_error))
outline.pop() # remove arc end point
return type(self)(outline, polarity_dark=self.polarity_dark)
def bounding_box(self): def bounding_box(self):
bbox = (None, None), (None, None) bbox = (None, None), (None, None)
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments: for (x1, y1), (x2, y2), arc in self.segments:
if clockwise is None: if arc:
clockwise, (cx, cy) = arc
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
else:
line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2)) line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2))
bbox = add_bounds(bbox, line_bounds) bbox = add_bounds(bbox, line_bounds)
else:
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
return bbox return bbox
@classmethod @classmethod
@ -173,34 +136,18 @@ class ArcPoly(GraphicPrimitive):
if len(self.outline) == 0: if len(self.outline) == 0:
return return
yield f'M {float(self.outline[0][0]):.6} {float(self.outline[0][1]):.6}' yield f'M {self.outline[0][0]:.6} {self.outline[0][1]:.6}'
for old, new, (clockwise, center) in self.segments: for old, new, arc in self.segments:
if clockwise is None: if not arc:
yield f'L {float(new[0]):.6} {float(new[1]):.6}' yield f'L {new[0]:.6} {new[1]:.6}'
else: else:
clockwise, center = arc
yield svg_arc(old, new, center, clockwise) yield svg_arc(old, new, center, clockwise)
def to_svg(self, fg='black', bg='white', tag=Tag): def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
return tag('path', d=' '.join(self.path_d()), fill=color) return tag('path', d=' '.join(self.path_d()), style=f'fill: {color}')
def to_arc_poly(self):
return self
def is_zero_size(self):
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
if clockwise is not None: # arc
if math.isclose(cx, x1) and math.isclose(cy, y1):
continue
if math.isclose(x1, x2) and math.isclose(y1, y2):
return False
if math.isclose(polygon_area(self.outline), 0):
return True
return False
@dataclass(frozen=True) @dataclass(frozen=True)
@ -241,35 +188,8 @@ class Line(GraphicPrimitive):
def to_svg(self, fg='black', bg='white', tag=Tag): def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm' width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} L {float(self.x2):.6} {float(self.y2):.6}', return tag('path', d=f'M {self.x1:.6} {self.y1:.6} L {self.x2:.6} {self.y2:.6}',
fill='none', stroke=color, stroke_width=str(width), stroke_linecap='round') style=f'fill: none; stroke: {color}; stroke-width: {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)
@dataclass(frozen=True) @dataclass(frozen=True)
class Arc(GraphicPrimitive): class Arc(GraphicPrimitive):
@ -282,9 +202,9 @@ class Arc(GraphicPrimitive):
x2 : float x2 : float
#: End Y coodinate #: End Y coodinate
y2 : float y2 : float
#: Center X coordinate (absolute) #: Center X coordinate relative to ``x1``
cx : float cx : float
#: Center Y coordinate (absolute) #: Center Y coordinate relative to ``y1``
cy : float cy : float
#: ``True`` if this arc is clockwise from start to end. Selects between the large arc and the small arc given this #: ``True`` if this arc is clockwise from start to end. Selects between the large arc and the small arc given this
#: start, end and center #: start, end and center
@ -292,53 +212,36 @@ class Arc(GraphicPrimitive):
#: Line width of this arc. #: Line width of this arc.
width : float width : float
@property
def is_circle(self):
return math.isclose(self.x1, self.x2, abs_tol=1e-6) and math.isclose(self.y1, self.y2, abs_tol=1e-6)
def flip(self): def flip(self):
return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1, clockwise=not self.clockwise) return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1,
cx=(self.x + self.cx) - self.x2, cy=(self.y + self.cy) - self.y2, clockwise=not self.clockwise)
def bounding_box(self): def bounding_box(self):
r = self.width/2 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) endpoints = add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box())
return (min_x-r, min_y-r), (max_x+r, max_y+r)
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): def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise) arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise)
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm' width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} {arc}', return tag('path', d=f'M {self.x1:.6} {self.y1:.6} {arc}',
fill='none', stroke=color, stroke_width=width, stroke_linecap='round') style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round; fill: none')
def to_arc_poly(self):
r = math.dist((self.x1, self.y1), (self.cx, self.cy))
if math.isclose(r, 0):
# degenerate case: a zero-radius arc becomes a circle.
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
polarity_dark=self.polarity_dark)
dx1, dy1 = self.x1-self.cx, self.y1-self.cy
nx1, ny1 = dx1/r * self.width/2, dy1/r * self.width/2
dx2, dy2 = self.x2-self.cx, self.y2-self.cy
nx2, ny2 = dx2/r * self.width/2, dy2/r * self.width/2
return ArcPoly([ # vertices
(self.x1+nx1, self.y1+ny1),
(self.x1-nx1, self.y1-ny1),
(self.x2-nx2, self.y2-ny2),
(self.x2+nx2, self.y2+ny2),
], [ # arc segments (direction, center)
(not self.clockwise, (self.x1, self.y1)),
(self.clockwise, (self.cx, self.cy)),
(self.clockwise, (self.x2, self.y2)),
(not self.clockwise, (self.cx, self.cy)),
], polarity_dark=self.polarity_dark)
def is_zero_size(self):
return False # an arc with identical start and end points is defined as a circle
@dataclass(frozen=True) @dataclass(frozen=True)
class Rectangle(GraphicPrimitive): class Rectangle(GraphicPrimitive):
@ -366,14 +269,11 @@ class Rectangle(GraphicPrimitive):
(x - (cw+sh), y + (ch+sw)), (x - (cw+sh), y + (ch+sw)),
(x + (cw+sh), y + (ch+sw)), (x + (cw+sh), y + (ch+sw)),
(x + (cw+sh), y - (ch+sw)), (x + (cw+sh), y - (ch+sw)),
], polarity_dark=self.polarity_dark) ])
def to_svg(self, fg='black', bg='white', tag=Tag): def to_svg(self, fg='black', bg='white', tag=Tag):
color = fg if self.polarity_dark else bg color = fg if self.polarity_dark else bg
x, y = self.x - self.w/2, self.y - self.h/2 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), 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) transform=svg_rotation(self.rotation, self.x, self.y), style=f'fill: {color}')
def is_zero_size(self):
return math.isclose(self.w, 0) or math.isclose(self.h, 0)

View file

@ -3,7 +3,7 @@
# #
# copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be> # copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
# Modified from parser.py by Paulo Henrique Silva <ph.silva@gmail.com> # Modified from parser.py by Paulo Henrique Silva <ph.silva@gmail.com>
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de> # Copyright 2022 Jan Götte <code@jaseg.de>
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -326,17 +326,17 @@ class NetlistParser(object):
if name == 'UNITS': if name == 'UNITS':
if value in ('CUST', 'CUST 0'): if value in ('CUST', 'CUST 0'):
self.settings.unit = Inch self.settings.units = Inch
self.settings.angle_unit = 'degree' self.settings.angle_unit = 'degree'
self.has_unit = True self.has_unit = True
elif value == 'CUST 1': elif value == 'CUST 1':
self.settings.unit = MM self.settings.units = MM
self.settings.angle_unit = 'degree' self.settings.angle_unit = 'degree'
self.has_unit = True self.has_unit = True
elif value == 'CUST 2': elif value == 'CUST 2':
self.settings.unit = Inch self.settings.units = Inch
self.settings.angle_unit = 'radian' self.settings.angle_unit = 'radian'
self.has_unit = True self.has_unit = True

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de> # Copyright 2022 Jan Götte <code@jaseg.de>
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -47,9 +47,7 @@ MATCH_RULES = {
'bottom paste': r'.*\.gbp|.*b.paste.(gbr|gbp)', 'bottom paste': r'.*\.gbp|.*b.paste.(gbr|gbp)',
'inner copper': r'.*\.gp?([0-9]+)|.*inn?e?r?([0-9]+).cu.(?:gbr|g[0-9]+)', 'inner copper': r'.*\.gp?([0-9]+)|.*inn?e?r?([0-9]+).cu.(?:gbr|g[0-9]+)',
'mechanical outline': r'.*\.(gm[0-9]+)|.*edge.cuts.(gbr|gm1)', 'mechanical outline': r'.*\.(gm[0-9]+)|.*edge.cuts.(gbr|gm1)',
'drill nonplated': r'.*\-NPTH.(drl)', 'drill plated': r'.*\.(drl)',
'drill plated': r'.*\-PTH.(drl)',
'drill unknown': r'.*\.(drl)',
'other netlist': r'.*\.d356', 'other netlist': r'.*\.d356',
}, },
@ -82,7 +80,6 @@ MATCH_RULES = {
'bottom paste': r'.*_boardoutline\.\w+', # FIXME verify this 'bottom paste': r'.*_boardoutline\.\w+', # FIXME verify this
'drill plated': r'.*\.(drl)', # diptrace has unplated drills on the outline layer 'drill plated': r'.*\.(drl)', # diptrace has unplated drills on the outline layer
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples 'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
'header regex': [['sufficient', r'top .*|bottom .*', r'G04 DipTrace [.-0-9a-z]*\*']],
}, },
'target': { 'target': {
@ -152,25 +149,22 @@ MATCH_RULES = {
'allegro': { 'allegro': {
# Allegro doesn't have any widespread convention, so we rely heavily on the layer name auto-guesser here. # Allegro doesn't have any widespread convention, so we rely heavily on the layer name auto-guesser here.
'drill plated': r'.*\.(drl)', 'drill mech': r'.*\.(drl|rou)',
'drill nonplated': r'.*\.(rou)', 'generic gerber': r'.*\.art',
'other unknown': r'.*(place|assembly|keep.?in|keep.?out).*\.art',
'autoguess': r'.*\.art',
'excellon params': r'nc_param\.txt|ncdrill\.log|ncroute\.log', 'excellon params': r'nc_param\.txt|ncdrill\.log|ncroute\.log',
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples 'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
'header regex': [['required,sufficient', r'.*\.art', r'G04 File Origin:\s+Cadence Allegro [0-9]+\.[0-9]+[-a-zA-Z0-9]*']],
}, },
'pads': { 'pads': {
# Pads also does not seem to have a factory-default naming schema. Or it has one but everyone ignores it. # Pads also does not seem to have a factory-default naming schema. Or it has one but everyone ignores it.
'autoguess': r'.*\.pho', 'generic gerber': r'.*\.pho',
'drill plated': r'.*\.drl', 'drill mech': r'.*\.drl',
}, },
'zuken': { 'zuken': {
'autoguess': r'.*\.fph', 'generic gerber': r'.*\.fph',
'gerber params': r'.*\.fpl', 'gerber params': r'.*\.fpl',
'drill unknown': r'.*\.fdr', 'drill mech': r'.*\.fdr',
'excellon params': r'.*\.fdl', 'excellon params': r'.*\.fdl',
'other netlist': r'.*\.ipc', 'other netlist': r'.*\.ipc',
'ipc-2581': r'.*\.xml', 'ipc-2581': r'.*\.xml',

805
gerbonara/layers.py Normal file
View file

@ -0,0 +1,805 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
# Copyright 2022 Jan Götte <code@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.
#
import os
import io
import sys
import re
import warnings
import copy
import bisect
import textwrap
import itertools
from collections import namedtuple
from pathlib import Path
from zipfile import ZipFile, is_zipfile
import tempfile
from .excellon import ExcellonFile, parse_allegro_ncparam, parse_allegro_logfile
from .rs274x import GerberFile
from .ipc356 import Netlist
from .cam import FileSettings, LazyCamFile
from .layer_rules import MATCH_RULES
from .utils import sum_bounds, setup_svg, MM, Tag
from . import graphic_objects as go
from . import graphic_primitives as gp
STANDARD_LAYERS = [
'mechanical outline',
'top copper',
'top mask',
'top silk',
'top paste',
'bottom copper',
'bottom mask',
'bottom silk',
'bottom paste',
]
DEFAULT_COLORS = {
'copper': '#cccccc',
'mask': '#004200bf',
'paste': '#999999',
'silk': '#e0e0e0',
'drill': '#303030',
'outline': '#F0C000',
}
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',
'unknown drill': '{board_name}.drl',
'plated drill': '{board_name}.plated.drl',
'nonplated drill': '{board_name}.nonplated.drl',
'other netlist': '{board_name}.d356',
}
altium = {
'top copper': '{board_name}.gtl',
'top mask': '{board_name}.gts',
'top silk': '{board_name}.gto',
'top paste': '{board_name}.gtp',
'bottom copper': '{board_name}.gbl',
'bottom mask': '{board_name}.gbs',
'bottom silk': '{board_name}.gbo',
'bottom paste': '{board_name}.gbp',
'inner copper': '{board_name}.gp{layer_number}',
'mechanical outline': '{board_name}.gko',
'unknown drill': '{board_name}.drl',
'plated drill': '{board_name}.plated.drl',
'nonplated drill': '{board_name}.nonplated.drl',
}
def match_files(filenames):
matches = {}
for generator, rules in MATCH_RULES.items():
gen = {}
matches[generator] = gen
for layer, regex in rules.items():
for fn in filenames:
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]
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
def identify_file(data):
if 'M48' in data:
return 'excellon'
if 'G90' in data and ';LEADER:' in data: # yet another allegro special case
return 'excellon'
if 'FSLAX' in data or 'FSTAX' in data:
return 'gerber'
if 'UNITS CUST' in data:
return 'ipc356'
return None
def common_prefix(l):
out = []
for cand in l:
score = lambda n: sum(elem.startswith(cand[:n]) for elem in l)
baseline = score(1)
if len(l) - baseline > 5:
continue
for n in range(2, len(cand)):
if len(l) - score(n) > 5:
break
out.append(cand[:n-1])
if not out:
return ''
return sorted(out, key=len)[-1]
def autoguess(filenames):
prefix = common_prefix([f.name for f in filenames])
matches = {}
for f in filenames:
name = layername_autoguesser(f.name[len(prefix):] if f.name.startswith(prefix) else f.name)
if name != 'unknown unknown':
matches[name] = matches.get(name, []) + [f]
inner_layers = [ m for m in matches if 'inner' in m ]
if len(inner_layers) >= 2 and 'copper top' not in matches and 'copper bottom' not in matches:
if 'inner_01 copper' in matches:
warnings.warn('Could not find copper layer. Re-assigning outermost inner layers to top/bottom copper.')
matches['top copper'] = matches.pop('inner_01 copper')
last_inner = sorted(inner_layers, key=lambda name: int(name.partition(' ')[0].partition('_')[2]))[-1]
matches['bottom copper'] = matches.pop(last_inner)
return matches
def layername_autoguesser(fn):
fn, _, ext = fn.lower().rpartition('.')
if ext in ('log', 'err', 'fdl', 'py', 'sh', 'md', 'rst', 'zip', 'pdf', 'svg', 'ps', 'png', 'jpg', 'bmp'):
return 'unknown unknown'
side, use = 'unknown', 'unknown'
if re.search('top|front|pri?m?(ary)?', fn):
side = 'top'
use = 'copper'
if re.search('bot(tom)?|back|sec(ondary)?', fn):
side = 'bottom'
use = 'copper'
if re.search('silks?(creen)?|symbol', fn):
use = 'silk'
elif re.search('(solder)?paste|metalmask', fn):
use = 'paste'
elif re.search('(solder)?(mask|resist)', fn):
use = 'mask'
elif re.search('drill|rout?e?', fn):
use = 'drill'
side = 'unknown'
if re.search(r'np(th|lt)?|(non|un)\W*plated|(non|un)\Wgalv', fn):
side = 'nonplated'
elif re.search('pth|plated|galv|plt', fn):
side = 'plated'
elif (m := re.search(r'(la?y?e?r?|in(ner)?|conduct(or|ive)?)\W*(?P<num>[0-9]+)', fn)):
use = 'copper'
side = f'inner_{int(m["num"]):02d}'
elif re.search('film', fn):
use = 'copper'
elif re.search('out(line)?', fn):
use = 'outline'
side = 'mechanical'
elif 'ipc' in fn and '356' in fn:
use = 'netlist'
side = 'other'
elif 'netlist' in fn:
use = 'netlist'
side = 'other'
if side == 'unknown':
if re.search(r'[^a-z0-9]a', fn):
side = 'top'
elif re.search(r'[^a-z0-9]b', fn):
side = 'bottom'
return f'{side} {use}'
class LayerStack:
def __init__(self, graphic_layers, drill_layers, netlist=None, board_name=None, original_path=None, was_zipped=False):
self.graphic_layers = graphic_layers
self.drill_layers = drill_layers
self.board_name = board_name
self.netlist = netlist
self.original_path = original_path
self.was_zipped = was_zipped
@classmethod
def open(kls, path, board_name=None, lazy=False):
if str(path) == '-':
data_io = io.BytesIO(sys.stdin.buffer.read())
return kls.from_zip_data(data_io, original_path='<stdin>', board_name=board_name, lazy=lazy)
path = Path(path)
if path.is_dir():
return kls.open_dir(path, board_name=board_name, lazy=lazy)
elif path.suffix.lower() == '.zip' or is_zipfile(path):
return kls.open_zip(path, board_name=board_name, lazy=lazy)
else:
return kls.from_files([path], board_name=board_name, lazy=lazy)
@classmethod
def open_zip(kls, file, original_path=None, board_name=None, lazy=False):
tmpdir = tempfile.TemporaryDirectory()
tmp_indir = Path(tmpdir.name) / 'input'
tmp_indir.mkdir()
with ZipFile(file) as f:
f.extractall(path=tmp_indir)
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
return inst
@classmethod
def open_dir(kls, directory, board_name=None, lazy=False):
directory = Path(directory)
if not directory.is_dir():
raise FileNotFoundError(f'{directory} is not a directory')
files = [ path for path in directory.glob('**/*') if path.is_file() ]
return kls.from_files(files, board_name=board_name, lazy=lazy, original_path=directory)
inst.original_path = directory
return inst
@classmethod
def from_files(kls, files, board_name=None, lazy=False, original_path=None, was_zipped=False):
generator, filemap = best_match(files)
if sum(len(files) for files in filemap.values()) < 6:
warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
generator = None
filemap = autoguess(files)
if len(filemap) < 6:
raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap)
excellon_settings, external_tools = None, None
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
# file was generated by geda, so we have to guess by context whether this is just geda being geda or
# potential user error.
excellon_settings = FileSettings(number_format=(2, 4))
elif generator == 'allegro':
# Allegro puts information that is absolutely vital for parsing its excellon files... into another file,
# next to the actual excellon file. Despite pretty much everyone else having figured out a way to put that
# info into the excellon file itself, even if only as a comment.
if 'excellon params' in filemap:
excellon_settings = parse_allegro_ncparam(filemap['excellon params'][0].read_text())
for file in filemap['excellon params']:
if (external_tools := parse_allegro_logfile(file.read_text())):
break
del filemap['excellon params']
# 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 = 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 = 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 == 'altium':
if 'mechanical outline' in filemap:
# Use lowest-numbered mechanical layer as outline, ignore others.
mechs = {}
for layer in filemap['mechanical outline']:
if layer.name.lower().endswith('gko'):
filemap['mechanical outline'] = [layer]
break
if (m := re.match(r'.*\.gm([0-9]+)', layer.name, re.IGNORECASE)):
mechs[int(m[1])] = layer
else:
break
else:
filemap['mechanical outline'] = [sorted(mechs.items(), key=lambda x: x[0])[0][1]]
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 ]
if ambiguous:
raise SystemError(f'Ambiguous layer names: {", ".join(ambiguous)}')
drill_layers = []
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:
raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(value)}')
for path in paths:
id_result = identify_file(path.read_text())
if 'netlist' in key:
layer = LazyCamFile(Netlist, path)
elif ('outline' in key or 'drill' in key) and id_result != 'gerber':
if id_result is None:
# Since e.g. altium uses ".txt" as the extension for its drill files, we have to assume the
# current file might not be a drill file after all.
continue
if 'nonplated' in key:
plated = False
elif 'plated' in key:
plated = True
else:
plated = None
layer = LazyCamFile(ExcellonFile, path, plated=plated, settings=excellon_settings, external_tools=external_tools)
else:
layer = LazyCamFile(GerberFile, path)
if not lazy:
layer = layer.instance
if key == 'mechanical outline':
layers['mechanical', 'outline'] = layer
elif 'drill' in key:
drill_layers.append(layer)
elif 'netlist' in key:
if netlist:
warnings.warn(f'Found multiple netlist files, using only first one. Have: {netlist.original_path.name}, got {path.name}')
else:
netlist = layer
else:
side, _, use = key.partition(' ')
layers[(side, use)] = layer
if not lazy:
hints = set(layer.generator_hints) | { generator }
if len(hints) > 1:
warnings.warn('File identification returned ambiguous results. Please raise an issue on the '
'gerbonara tracker and if possible please provide these input files for reference.')
if not board_name:
board_name = common_prefix([l.original_path.name for l in layers.values() if l is not None])
board_name = re.sub(r'^\W+', '', board_name)
board_name = re.sub(r'\W+$', '', board_name)
return kls(layers, drill_layers, netlist, board_name=board_name,
original_path=original_path, was_zipped=was_zipped)
def save_to_zipfile(self, path, naming_scheme={}, overwrite_existing=True, prefix=''):
if path.is_file():
if overwrite_existing:
path.unlink()
else:
raise ValueError('output zip file already exists and overwrite_existing is False')
with ZipFile(path, 'w') as le_zip:
for path, layer in self._save_files_iter(naming_scheme=naming_scheme):
with le_zip.open(prefix + str(path), 'w') as out:
out.write(layer.instance.write_to_bytes())
def save_to_directory(self, path, naming_scheme={}, overwrite_existing=True):
outdir = Path(path)
outdir.mkdir(parents=True, exist_ok=overwrite_existing)
for path, layer in self._save_files_iter(naming_scheme=naming_scheme):
out = outdir / path
if out.exists() and not overwrite_existing:
raise SystemError(f'Path exists but overwrite_existing is False: {out}')
layer.instance.save(out)
def _save_files_iter(self, naming_scheme={}):
def get_name(layer_type, layer):
nonlocal naming_scheme
if (m := re.match('inner_([0-9]*) copper', layer_type)):
layer_type = 'inner copper'
num = int(m[1])
else:
num = None
if layer_type in naming_scheme:
path = naming_scheme[layer_type].format(layer_number=num, board_name=self.board_name)
elif layer.original_path.name:
path = layer.original_path.name
else:
path = f'{self.board_name}-{layer_type.replace(" ", "_")}.gbr'
return path
for (side, use), layer in self.graphic_layers.items():
yield get_name(f'{side} {use}', layer), layer
#self.normalize_drill_layers()
if self.drill_pth is not None:
yield get_name('plated drill', self.drill_pth), self.drill_pth
if self.drill_npth is not None:
yield get_name('nonplated drill', self.drill_npth), self.drill_npth
if self.drill_unknown is not None:
yield get_name('unknown drill', self.drill_unknown), self.drill_unknown
if self.netlist:
yield get_name('other netlist', self.netlist), self.netlist
def __str__(self):
names = [ f'{side} {use}' for side, use in self.graphic_layers ]
return f'<LayerStack {self.board_name} [{", ".join(names)}] and {len(self.drill_layers)} drill layers>'
def __repr__(self):
return str(self)
def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag, page_bg="white"):
if force_bounds:
bounds = svg_unit.convert_bounds_from(arg_unit, force_bounds)
else:
bounds = self.bounding_box(svg_unit, default=((0, 0), (0, 0)))
tags = []
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)),
id=f'l-{side}-{use}'))
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)),
id=f'l-drill-{i}'))
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor=page_bg, 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):
if colors is None:
colors = DEFAULT_COLORS
colors_alpha = {}
for layer, color in colors.items():
if isinstance(color, str):
if re.match(r'#[0-9a-fA-F]{8}', color):
colors_alpha[layer] = (color[:-2], int(color[-2:], 16)/255)
else:
colors_alpha[layer] = (color, 1)
else:
colors_alpha[layer] = color
if force_bounds:
bounds = svg_unit.convert_bounds_from(arg_unit, force_bounds)
else:
bounds = self.board_bounds(unit=svg_unit, default=((0, 0), (0, 0)))
filter_defs = []
for layer, (color, alpha) in colors_alpha.items():
filter_defs.append(textwrap.dedent(f'''
<filter id="f-{layer}">
<feFlood result="flood-black" flood-color="black" flood-opacity="1"/>
<feFlood result="flood-green" flood-color="{color}"/>
<feBlend in="SourceGraphic" in2="flood-black" result="overlay" mode="normal"/>
<feBlend in="overlay" in2="flood-green" result="colored" mode="multiply"/>
<feColorMatrix in="overlay" type="matrix" result="alphaOut" values="0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
{alpha} 0 0 0 0"/>
<feComposite in="colored" in2="alphaOut" operator="in"/>
</filter>'''.strip()))
inkscape_attrs = lambda label: dict(inkscape__groupmode='layer', inkscape__label=label) if inkscape else {}
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)]
fg, bg = ('white', 'black') if use != 'mask' else ('black', 'white')
objects = list(layer.instance.svg_objects(svg_unit=svg_unit, fg=fg, bg=bg, tag=Tag))
if use == 'mask':
objects.insert(0, tag('path', id='outline-path', d=self.outline_svg_d(unit=svg_unit), style='fill:white'))
layers.append(tag('g', objects, id=f'l-{side}-{use}', filter=f'url(#f-{use})', **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)', **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-outline-{i}', **inkscape_attrs(f'outline-{i}')))
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):
return sum_bounds(( layer.bounding_box(unit, default=default)
for layer in itertools.chain(self.graphic_layers.values(), self.drill_layers) ), default=default)
def board_bounds(self, unit=MM, default=None):
if self.outline:
return self.outline.instance.bounding_box(unit=unit, default=default)
else:
return self.bounding_box(unit=unit, default=default)
def merge_drill_layers(self):
target = ExcellonFile(comments=['Drill files merged by gerbonara'])
for layer in self.drill_layers:
if isinstance(layer, GerberFile):
layer = layer.to_excellon()
target.merge(layer)
self.drill_pth = self.drill_npth = None
self.drill_unknown = target
def normalize_drill_layers(self):
# TODO: maybe also separate into drill and route?
drill_pth, drill_npth, drill_aux = [], [], []
for layer in self.drill_layers:
if isinstance(layer, GerberFile):
layer = layer.to_excellon()
if layer.is_plated:
drill_pth.append(layer)
elif layer.is_nonplated:
drill_pth.append(layer)
else:
drill_aux.append(layer)
pth_out, *rest = drill_pth or [ExcellonFile()]
for layer in rest:
pth_out.merge(layer)
npth_out, *rest = drill_npth or [ExcellonFile()]
for layer in rest:
npth_out.merge(layer)
unknown_out = ExcellonFile()
for layer in drill_aux:
for obj in layer.objects:
if obj.plated is None:
unknown_out.append(obj)
elif obj.plated:
pth_out.append(obj)
else:
npth_out.append(obj)
self.drill_pth, self.drill_npth = pth_out, npth_out
self.drill_unknown = unknown_out if unknown_out else None
self._drill_layers = []
@property
def drill_layers(self):
if self._drill_layers:
return self._drill_layers
if self.drill_pth or self.drill_npth or self.drill_unknown:
return [self.drill_pth, self.drill_npth, self.drill_unknown]
return []
@drill_layers.setter
def drill_layers(self, value):
self._drill_layers = value
self.drill_pth = self.drill_npth = self.drill_unknown = None
def __len__(self):
return len(self.layers)
def get(self, index, default=None):
if self.contains(key):
return self[key]
else:
return default
def __contains__(self, index):
if isinstance(index, str):
side, _, use = index.partition(' ')
return (side, use) in self.layers
elif isinstance(index, tuple):
return index in self.graphic_layers
return index < len(self.copper_layers)
def __getitem__(self, index):
if isinstance(index, str):
side, _, use = index.partition(' ')
return self.graphic_layers[(side, use)]
elif isinstance(index, tuple):
return self.graphic_layers[index]
return self.copper_layers[index]
@property
def copper_layers(self):
copper_layers = [ (key, layer) for key, layer in self.layers.items() if key.endswith('copper') ]
def sort_layername(val):
key, _layer = val
if key.startswith('top'):
return -1
if key.startswith('bottom'):
return 1e99
assert key.startswith('inner_')
return int(key[len('inner_'):])
return [ layer for _key, layer in sorted(copper_layers, key=sort_layername) ]
@property
def top_side(self):
return { key: self[key] for key in ('top copper', 'top mask', 'top silk', 'top paste', 'mechanical outline') }
@property
def bottom_side(self):
return { key: self[key] for key in ('bottom copper', 'bottom mask', 'bottom silk', 'bottom paste', 'mechanical outline') }
@property
def outline(self):
return self['mechanical outline']
def outline_svg_d(self, tol=0.01, unit=MM):
chains = self.outline_polygons(tol, unit)
polys = []
for chain in chains:
outline = [ (chain[0].x1, chain[0].y1), *((elem.x2, elem.y2) for elem in chain) ]
arcs = [ (elem.clockwise, (elem.cx, elem.cy)) if isinstance(elem, gp.Arc) else None for elem in chain ]
poly = gp.ArcPoly(outline=outline, arc_centers=arcs)
polys.append(' '.join(poly.path_d()) + ' Z')
return ' '.join(polys)
def outline_polygons(self, tol=0.01, unit=MM):
polygons = []
lines = [ obj.as_primitive(unit) for obj in self.outline.instance.objects if isinstance(obj, (go.Line, go.Arc)) ]
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:
for i, (x, y) in enumerate([(cur.x1, cur.y1), (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)
selected = { elem for elem_x, elem in by_x[x_left:x_right] if elem != cur }
if not selected:
continue # loose end
nearest = sorted(selected, key=lambda elem: min(dist_sq(elem.x1, elem.y1, x, y), dist_sq(elem.x2, elem.y2, x, y)))[0]
d1, d2 = dist_sq(nearest.x1, nearest.y1, x, y), dist_sq(nearest.x2, nearest.y2, x, y)
j = 0 if d1 < d2 else 1
if (nearest, j) in joins and joins[(nearest, j)] != (cur, i):
raise ValueError(f'Error: three-way intersection of {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}')
if (cur, i) in joins and joins[(cur, i)] != (nearest, j):
raise ValueError(f'Error: three-way intersection of {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}')
joins[(cur, i)] = (nearest, j)
joins[(nearest, j)] = (cur, i)
def flip_if(obj, i):
if i:
c = copy.copy(obj)
c.flip()
return c
else:
return obj
while joins:
(first, i), (cur, j) = joins.popitem()
del joins[(cur, j)]
l = [ flip_if(first, not i), flip_if(cur, j) ]
while cur != first and (cur, not j) in joins:
cur, j = joins.pop((cur, not j))
del joins[(cur, j)]
l.append(flip_if(cur, j))
yield l
def _merge_layer(self, target, source):
if source is None:
return
if self[target] is None:
self[target] = source
else:
self[target].merge(source)
def merge(self, other):
all_keys = set(self.layers.keys()) | set(other.layers.keys())
exclude = { key.split() for key in STANDARD_LAYERS }
all_keys = { key for key in all_keys if key not in exclude }
if all_keys:
warnings.warn('Cannot merge unknown layer types: {" ".join(all_keys)}')
for side in 'top', 'bottom':
for use in 'copper', 'mask', 'silk', 'paste':
self._merge_layer((side, use), other[side, use])
our_inner, their_inner = self.copper_layers[1:-1], other.copper_layers[1:-1]
if bool(our_inner) != bool(their_inner):
warnings.warn('Merging board without inner layers into board with inner layers, inner layers will be empty on first board.')
elif our_inner and their_inner:
warnings.warn('Merging boards with different inner layer counts. Will fill inner layers starting at core.')
diff = len(our_inner) - len(their_inner)
their_inner = ([None] * max(0, diff//2)) + their_inner + ([None] * max(0, diff//2))
our_inner = ([None] * max(0, -diff//2)) + their_inner + ([None] * max(0, -diff//2))
new_inner = []
for ours, theirs in zip(our_inner, their_inner):
if ours is None:
new_inner.append(theirs)
elif theirs is None:
new_inner.append(ours)
else:
ours.merge(theirs)
new_inner.append(ours)
for i, layer in enumerate(new_inner, start=1):
self[f'inner_{i} copper'] = layer
self._merge_layer('mechanical outline', other['mechanical outline'])
self.normalize_drill_layers()
other.normalize_drill_layers()
self.drill_pth.merge(other.drill_pth)
self.drill_npth.merge(other.drill_npth)
self.drill_unknown.merge(other.drill_unknown)
self.netlist.merge(other.netlist)

View file

@ -4,7 +4,7 @@
# Modified from parser.py by Paulo Henrique Silva <ph.silva@gmail.com> # Modified from parser.py by Paulo Henrique Silva <ph.silva@gmail.com>
# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be> # Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com> # Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de> # Copyright 2022 Jan Götte <code@jaseg.de>
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -21,11 +21,9 @@
import re import re
import math import math
import copy
import warnings import warnings
from pathlib import Path from pathlib import Path
import dataclasses import dataclasses
import functools
from .cam import CamFile, FileSettings from .cam import CamFile, FileSettings
from .utils import MM, Inch, units, InterpMode, UnknownStatementWarning from .utils import MM, Inch, units, InterpMode, UnknownStatementWarning
@ -48,19 +46,6 @@ def points_close(a, b):
class GerberFile(CamFile): class GerberFile(CamFile):
""" A single gerber file. """ A single gerber file.
:ivar objects: List of objects in this Gerber file. All elements must be subclasses of :py:class:`.GraphicObject`.
:ivar comments: List of string with textual comments in the source Gerber file. These are not saved by default, but
when you call :py:meth:`.GerberFile.save` with ``drop_comments=False``, the contents of this list
will be included as comments at the top of the output file.
:ivar generator_hints: List of strings indicating which EDA tool generated this file. Hints are added to this list
during file parsing whenever the parser encounters an idiosyncratic file format variation.
:ivar import_settings: File format settings used in the original file. This can be empty if this
:py:class:`.GerberFile` was generated programatically.
: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 file_attrs: List of strings with Gerber X3 file attributes. Each list item corresponds to one file attribute.
""" """
def __init__(self, objects=None, comments=None, import_settings=None, original_path=None, generator_hints=None, def __init__(self, objects=None, comments=None, import_settings=None, original_path=None, generator_hints=None,
@ -71,141 +56,47 @@ class GerberFile(CamFile):
self.generator_hints = generator_hints or [] self.generator_hints = generator_hints or []
self.layer_hints = layer_hints or [] self.layer_hints = layer_hints or []
self.import_settings = import_settings self.import_settings = import_settings
self.apertures = [] # FIXME get rid of this? apertures are already in the objects.
self.file_attrs = file_attrs or {} self.file_attrs = file_attrs or {}
def apertures(self): def to_excellon(self, plated=None):
""" 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 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):
""" 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
features from a :py:class:`GerberFile` before conversion. """
new_objs = [] new_objs = []
new_tools = {} new_tools = {}
for obj in self.objects: for obj in self.objects:
if holes_only and not isinstance(obj, go.Flash): if (not isinstance(obj, go.Line) and isinstance(obj, go.Arc) and isinstance(obj, go.Flash)) or \
continue not isinstance(obj.aperture, apertures.CircleAperture):
raise ValueError(f'Cannot convert {obj} to excellon!')
if not isinstance(obj, (go.Line, go.Arc, go.Flash)) or \ if not (new_tool := new_tools.get(id(obj.aperture))):
not isinstance(getattr(obj, 'aperture', None), apertures.CircleAperture):
if errors == 'raise':
raise ValueError(f'Cannot convert {obj} to excellon.')
elif errors == 'warn':
warnings.warn(f'Gerber to Excellon conversion: Cannot convert {obj} to excellon.')
continue
elif errors == 'ignore':
continue
else:
raise ValueError('Invalid "errors" parameter. Allowed values: "raise", "warn" or "ignore".')
if not (new_tool := new_tools.get(obj.aperture)):
# TODO plating? # 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)) new_objs.append(dataclasses.replace(obj, aperture=new_tool))
return ExcellonFile(objects=new_objs, comments=self.comments) return ExcellonFile(objects=new_objs, comments=self.comments)
def to_gerber(self, errors='raise'): def to_gerber(self):
""" Counterpart to :py:meth:`~.excellon.ExcellonFile.to_gerber`. Does nothing and returns :py:obj:`self`. """ return
return self
def merge(self, other, mode='above', keep_settings=False): def merge(self, other, mode='above', keep_settings=False):
""" Merge ``other`` into ``self``, i.e. add all objects that are in ``other`` to ``self``. This resets
:py:attr:`.import_settings` and :py:attr:`~.GerberFile.generator`. Units and other file-specific settings are
handled automatically.
:param mode: One of the strings :py:obj:`"above"` (default) or :py:obj:`"below"`, specifying whether the other
layer's objects will be placed above this layer's objects (placing them towards the end of the file), or
below this layer's objects (placing them towards the beginning of the file). This setting is only relevant
when there are overlapping objects of different polarity, otherwise the rendered result will be the same
either way.
"""
if other is None: if other is None:
return return
other = other.to_gerber()
if not keep_settings: if not keep_settings:
self.import_settings = None self.import_settings = None
self.comments += other.comments self.comments += other.comments
# dedup apertures
new_apertures = {}
replace_apertures = {}
mock_settings = FileSettings()
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 # Join objects
if mode == 'below': if mode == 'below':
self.objects = other.objects + self.objects self.objects = other.objects + self.objects
@ -213,27 +104,60 @@ class GerberFile(CamFile):
self.objects += other.objects self.objects += other.objects
else: else:
raise ValueError(f'Invalid mode "{mode}", must be one of "above" or "below".') raise ValueError(f'Invalid mode "{mode}", must be one of "above" or "below".')
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
self.dedup_apertures() # 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): def dilate(self, offset, unit=MM, polarity_dark=True):
# TODO add tests for this # 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) offset_circle = apertures.CircleAperture(offset, unit=unit)
new_objects = [] self.apertures.append(offset_circle)
for obj in self.objects:
obj.polarity_dark = polarity_dark 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. # Ignore Line, Arc, Flash. Their actual dilation has already been done by dilating the apertures above.
if isinstance(obj, Region): if isinstance(p, Region):
new_objects.extend(obj.outline_objects(offset_circle)) 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. # 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 @classmethod
def open(kls, filename, enable_includes=False, enable_include_dir=None, override_settings=None): def open(kls, filename, enable_includes=False, enable_include_dir=None):
""" Load a Gerber file from the file system. The Gerber standard contains this wonderful and totally not """ Load a Gerber file from the file system. The Gerber standard contains this wonderful and totally not
insecure "include file" setting. We disable it by default and do not parse Gerber includes because a) nobody insecure "include file" setting. We disable it by default and do not parse Gerber includes because a) nobody
actually uses them, and b) they're a bad idea from a security point of view. In case you actually want these, actually uses them, and b) they're a bad idea from a security point of view. In case you actually want these,
@ -249,21 +173,19 @@ class GerberFile(CamFile):
with open(filename, "r") as f: with open(filename, "r") as f:
if enable_includes and enable_include_dir is None: if enable_includes and enable_include_dir is None:
enable_include_dir = filename.parent enable_include_dir = filename.parent
return kls.from_string(f.read(), enable_include_dir, filename=filename, override_settings=override_settings) return kls.from_string(f.read(), enable_include_dir, filename=filename)
@classmethod @classmethod
def from_string(kls, data, enable_include_dir=None, filename=None, override_settings=None): def from_string(kls, data, enable_include_dir=None, filename=None):
""" Parse given string as Gerber file content. For the meaning of the parameters, see """ Parse given string as Gerber file content. For the meaning of the parameters, see
:py:meth:`~.GerberFile.open`. """ :py:meth:`~.GerberFile.open`. """
# filename arg is for error messages # filename arg is for error messages
obj = kls() obj = kls()
parser = GerberParser(obj, include_dir=enable_include_dir, override_settings=override_settings) GerberParser(obj, include_dir=enable_include_dir).parse(data, filename=filename)
parser.parse(data, filename=filename)
return obj return obj
def _generate_statements(self, settings, drop_comments=True): def _generate_statements(self, settings, drop_comments=True):
""" Export this file as Gerber code, yields one str per line. """ """ Export this file as Gerber code, yields one str per line. """
yield 'G04 Gerber file generated by Gerbonara*' yield 'G04 Gerber file generated by Gerbonara*'
for name, value in self.file_attrs.items(): for name, value in self.file_attrs.items():
attrdef = ','.join([name, *map(str, value)]) attrdef = ','.join([name, *map(str, value)])
@ -272,10 +194,8 @@ class GerberFile(CamFile):
zeros = 'T' if settings.zeros == 'trailing' else 'L' # default to leading if "None" is specified zeros = 'T' if settings.zeros == 'trailing' else 'L' # default to leading if "None" is specified
notation = 'I' if settings.notation == 'incremental' else 'A' # default to absolute notation = 'I' if settings.notation == 'incremental' else 'A' # default to absolute
num_int, num_frac = settings.number_format or (4,5) number_format = str(settings.number_format[0]) + str(settings.number_format[1])
assert 1 <= num_int <= 9 yield f'%FS{zeros}{notation}X{number_format}Y{number_format}*%'
assert 1 <= num_frac <= 9
yield f'%FS{zeros}{notation}X{num_int}{num_frac}Y{num_int}{num_frac}*%'
yield '%IPPOS*%' yield '%IPPOS*%'
yield 'G75' yield 'G75'
yield '%LPD*%' yield '%LPD*%'
@ -285,26 +205,26 @@ class GerberFile(CamFile):
for cmt in self.comments: for cmt in self.comments:
yield f'G04{cmt}*' 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%' processed_macros = set()
aperture_map = {ap: num for num, ap in enumerate(self.apertures(), start=10)} aperture_map = {}
for number, aperture in enumerate(self.apertures, start=10):
if settings.calculate_out_all_aperture_macros: if isinstance(aperture, apertures.ApertureMacroInstance):
adds = [] macro_def = am_stmt(aperture._rotated().macro)
for aperture, number in aperture_map.items(): if macro_def not in processed_macros:
if isinstance(aperture, apertures.ApertureMacroInstance): processed_macros.add(macro_def)
aperture = aperture.calculate_out(settings.unit, macro_name=f'CALCM{number}') yield macro_def
yield am_stmt(aperture.macro)
adds.append(f'%ADD{number}{aperture.to_gerber(settings)}*%')
yield from adds
else: yield f'%ADD{number}{aperture.to_gerber(settings)}*%'
for macro in self.aperture_macros():
yield am_stmt(macro)
for aperture, number in aperture_map.items(): aperture_map[id(aperture)] = number
yield f'%ADD{number}{aperture.to_gerber(settings)}*%'
def warn(msg, kls=SyntaxWarning): def warn(msg, kls=SyntaxWarning):
warnings.warn(msg, kls) warnings.warn(msg, kls)
@ -317,7 +237,7 @@ class GerberFile(CamFile):
def __str__(self): def __str__(self):
name = f'{self.original_path.name} ' if self.original_path else '' 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): def __repr__(self):
return str(self) return str(self)
@ -340,47 +260,39 @@ class GerberFile(CamFile):
:rtype: str :rtype: str
""" """
if settings is None: if settings is None:
if self.import_settings: settings = self.import_settings.copy() or FileSettings()
settings = self.import_settings.copy() settings.zeros = None
settings.zeros = None settings.number_format = (5,6)
else:
settings = FileSettings.defaults()
return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments)).encode('utf-8') return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments)).encode('utf-8')
def __len__(self): def __len__(self):
return len(self.objects) return len(self.objects)
def scale(self, factor, unit=MM):
scaled_apertures = {}
self.map_apertures(lambda ap: ap.scaled(factor))
for obj in self.objects:
obj.scale(factor)
def offset(self, dx=0, dy=0, unit=MM): def offset(self, dx=0, dy=0, unit=MM):
# TODO round offset to file resolution # TODO round offset to file resolution
for obj in self.objects: for obj in self.objects:
obj.offset(dx, dy, unit) obj.offset(dx, dy, unit)
def rotate(self, angle:'radian', cx=0, cy=0, unit=MM): def rotate(self, angle:'radian', center=(0,0), unit=MM):
if math.isclose(angle % (2*math.pi), 0): if math.isclose(angle % (2*math.pi), 0):
return 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: for obj in self.objects:
obj.rotate(angle, cx, cy, unit) obj.rotate(angle, *center, unit)
def invert_polarity(self): def invert_polarity(self):
""" Invert the polarity (color) of each object in this file. """ """ Invert the polarity (color) of each object in this file. """
for obj in self.objects: for obj in self.objects:
obj.polarity_dark = not obj.polarity_dark obj.polarity_dark = not p.polarity_dark
class GraphicsState: class GraphicsState:
""" Internal class used to track Gerber processing state during import and export. """ Internal class used to track Gerber processing state during import and export. """
"""
def __init__(self, warn, file_settings=None, aperture_map=None): def __init__(self, warn, file_settings=None, aperture_map=None):
self.image_polarity = 'positive' # IP image polarity; deprecated self.image_polarity = 'positive' # IP image polarity; deprecated
@ -463,7 +375,7 @@ class GraphicsState:
obj = go.Flash(*self.map_coord(*self.point), self.aperture, obj = go.Flash(*self.map_coord(*self.point), self.aperture,
polarity_dark=self._polarity_dark, polarity_dark=self._polarity_dark,
unit=self.unit, unit=self.unit,
attrs=copy.copy(self.object_attrs)) attrs=self.object_attrs)
return obj return obj
def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False): def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False):
@ -489,13 +401,13 @@ class GraphicsState:
raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)") raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)")
return go.Line(*old_point, *self.map_coord(*self.point), aperture, return go.Line(*old_point, *self.map_coord(*self.point), aperture,
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs)) polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
else: else:
if i is None and j is None: if i is None and j is None:
self.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values') self.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values')
return go.Line(*old_point, *self.map_coord(*self.point), aperture, return go.Line(*old_point, *self.map_coord(*self.point), aperture,
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs)) polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
else: else:
if i is None: if i is None:
@ -512,7 +424,7 @@ class GraphicsState:
if not multi_quadrant: if not multi_quadrant:
return go.Arc(*old_point, *new_point, *self.map_coord(i, j, relative=True), return go.Arc(*old_point, *new_point, *self.map_coord(i, j, relative=True),
clockwise=clockwise, aperture=(self.aperture if aperture else None), clockwise=clockwise, aperture=(self.aperture if aperture else None),
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs)) polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
else: else:
if math.isclose(old_point[0], new_point[0]) and math.isclose(old_point[1], new_point[1]): if math.isclose(old_point[0], new_point[0]) and math.isclose(old_point[1], new_point[1]):
@ -525,7 +437,7 @@ class GraphicsState:
arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy, arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy,
clockwise=clockwise, aperture=aperture, clockwise=clockwise, aperture=aperture,
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs)) polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
arcs = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ] arcs = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ]
arcs = sorted(arcs, key=lambda a: a.numeric_error()) arcs = sorted(arcs, key=lambda a: a.numeric_error())
@ -564,11 +476,9 @@ class GraphicsState:
yield '%LPD*%' if polarity_dark else '%LPC*%' yield '%LPD*%' if polarity_dark else '%LPC*%'
def set_aperture(self, aperture): def set_aperture(self, aperture):
ap_id = self.aperture_map[aperture] if self.aperture != aperture:
old_ap_id = self.aperture_map.get(self.aperture, None)
if ap_id != old_ap_id:
self.aperture = aperture self.aperture = aperture
yield f'D{ap_id}*' yield f'D{self.aperture_map[id(aperture)]}*'
def set_current_point(self, point, unit=None): def set_current_point(self, point, unit=None):
point_mm = MM(point[0], unit), MM(point[1], unit) point_mm = MM(point[0], unit), MM(point[1], unit)
@ -587,20 +497,17 @@ class GraphicsState:
def interpolation_mode_statement(self): def interpolation_mode_statement(self):
return { return {
InterpMode.LINEAR: 'G01*', InterpMode.LINEAR: 'G01',
InterpMode.CIRCULAR_CW: 'G02*', InterpMode.CIRCULAR_CW: 'G02',
InterpMode.CIRCULAR_CCW: 'G03*'}[self.interpolation_mode] InterpMode.CIRCULAR_CCW: 'G03'}[self.interpolation_mode]
class GerberParser: class GerberParser:
""" Internal class that contains all of the actual Gerber parsing magic. """ Internal class that contains all of the actual Gerber parsing magic. """
"""
NUMBER = r"[\+-]?\d+" NUMBER = r"[\+-]?\d+"
DECIMAL = r"[\+-]?\d+([.]?\d+)?" DECIMAL = r"[\+-]?\d+([.]?\d+)?"
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+" NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
MAX_STEP_REPEAT_INSTANCES = 100000
MAX_STEP_REPEAT_RESULT_OBJECTS = 100000
STATEMENT_REGEXES = { STATEMENT_REGEXES = {
'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \ 'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \
@ -608,7 +515,6 @@ class GerberParser:
fr"(?:D0?([123]))?$", fr"(?:D0?([123]))?$",
'region_start': r'G36$', 'region_start': r'G36$',
'region_end': r'G37$', 'region_end': r'G37$',
'eof': r"(D02)?M0?[02]", # P-CAD 2006 files have a spurious D02 before M02 as in "D02M02"
'aperture': r"(G54|G55)?\s*D(?P<number>\d+)", 'aperture': r"(G54|G55)?\s*D(?P<number>\d+)",
# Allegro combines format spec and unit into one long illegal extended command. # Allegro combines format spec and unit into one long illegal extended command.
'allegro_format_spec': r"FS(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*\*MO(?P<unit>IN|MM)", 'allegro_format_spec': r"FS(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*\*MO(?P<unit>IN|MM)",
@ -629,9 +535,9 @@ class GerberParser:
'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})(,(?P<modifiers>[^,%]*))?$", 'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})(,(?P<modifiers>[^,%]*))?$",
'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)", 'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)",
'siemens_garbage': r'^ICAS$', 'siemens_garbage': r'^ICAS$',
'step_repeat': fr'^SR(?P<coords>X(?P<X>[0-9]+)Y(?P<Y>[0-9]+)I(?P<I>{DECIMAL})J(?P<J>{DECIMAL}))?$',
'old_unit':r'(?P<mode>G7[01])', 'old_unit':r'(?P<mode>G7[01])',
'old_notation': r'(?P<mode>G9[01])', 'old_notation': r'(?P<mode>G9[01])',
'eof': r"M0?[02]",
'ignored': r"(?P<stmt>M01)", 'ignored': r"(?P<stmt>M01)",
# NOTE: The official spec says names can be empty or contain commas. I think that doesn't make sense. # NOTE: The official spec says names can be empty or contain commas. I think that doesn't make sense.
'attribute': r"(?P<eagle_garbage>G04 #@! %)?(?P<type>TF|TA|TO|TD)(?P<name>[._$a-zA-Z][._$a-zA-Z0-9]*)?(,(?P<value>.*))?", 'attribute': r"(?P<eagle_garbage>G04 #@! %)?(?P<type>TF|TA|TO|TD)(?P<name>[._$a-zA-Z][._$a-zA-Z0-9]*)?(,(?P<value>.*))?",
@ -639,18 +545,16 @@ class GerberParser:
'comment': r"G0?4(?P<comment>[^*]*)", 'comment': r"G0?4(?P<comment>[^*]*)",
} }
def __init__(self, target, include_dir=None, override_settings=None): def __init__(self, target, include_dir=None):
""" Pass an include dir to enable IF include statements (potentially DANGEROUS!). """ """ Pass an include dir to enable IF include statements (potentially DANGEROUS!). """
self.target = target self.target = target
self.include_dir = include_dir self.include_dir = include_dir
self.include_stack = [] self.include_stack = []
self.file_settings = override_settings or FileSettings() self.file_settings = FileSettings()
self.graphics_state = GraphicsState(warn=self.warn, file_settings=self.file_settings) self.graphics_state = GraphicsState(warn=self.warn, file_settings=self.file_settings)
self.aperture_map = {} self.aperture_map = {}
self.aperture_macros = {} self.aperture_macros = {}
self.current_region = None self.current_region = None
self.step_repeat_coords = None
self.step_repeat_objects = None
self.eof_found = False self.eof_found = False
self.multi_quadrant_mode = None # used only for syntax checking self.multi_quadrant_mode = None # used only for syntax checking
self.macros = {} self.macros = {}
@ -712,6 +616,7 @@ class GerberParser:
self.warn(f'Unknown statement found: "{self._shorten_line()}", ignoring.', UnknownStatementWarning) 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.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.import_settings = self.file_settings
self.target.unit = self.file_settings.unit self.target.unit = self.file_settings.unit
self.target.file_attrs = self.file_attrs self.target.file_attrs = self.file_attrs
@ -793,10 +698,7 @@ class GerberParser:
# in multi-quadrant mode this may return None if start and end point of the arc are the same. # in multi-quadrant mode this may return None if start and end point of the arc are the same.
obj = self.graphics_state.interpolate(x, y, i, j, multi_quadrant=self.multi_quadrant_mode) obj = self.graphics_state.interpolate(x, y, i, j, multi_quadrant=self.multi_quadrant_mode)
if obj is not None: if obj is not None:
if self.step_repeat_objects: self.target.objects.append(obj)
self.step_repeat_objects.append(obj)
else:
self.target.objects.append(obj)
else: else:
obj = self.graphics_state.interpolate(x, y, i, j, aperture=False, multi_quadrant=self.multi_quadrant_mode) obj = self.graphics_state.interpolate(x, y, i, j, aperture=False, multi_quadrant=self.multi_quadrant_mode)
if obj is not None: if obj is not None:
@ -807,21 +709,14 @@ class GerberParser:
if self.current_region: if self.current_region:
# Start a new region for every outline. As gerber has no concept of fill rules or winding numbers, # Start a new region for every outline. As gerber has no concept of fill rules or winding numbers,
# it does not make a graphical difference, and it makes the implementation slightly easier. # it does not make a graphical difference, and it makes the implementation slightly easier.
if self.step_repeat_objects: self.target.objects.append(self.current_region)
self.step_repeat_objects.append(self.current_region)
else:
self.target.objects.append(self.current_region)
self.current_region = go.Region( self.current_region = go.Region(
polarity_dark=self.graphics_state.polarity_dark, polarity_dark=self.graphics_state.polarity_dark,
unit=self.file_settings.unit) unit=self.file_settings.unit)
elif op == '3': elif op == '3':
if self.current_region is None: if self.current_region is None:
obj = self.graphics_state.flash(x, y) self.target.objects.append(self.graphics_state.flash(x, y))
if self.step_repeat_objects:
self.step_repeat_objects.append(obj)
else:
self.target.objects.append(obj)
else: else:
raise SyntaxError('DO3 flash statement inside region') raise SyntaxError('DO3 flash statement inside region')
@ -862,17 +757,12 @@ class GerberParser:
if match['shape'] in 'RO' and (math.isclose(modifiers[0], 0) or math.isclose(modifiers[1], 0)): if match['shape'] in 'RO' and (math.isclose(modifiers[0], 0) or math.isclose(modifiers[1], 0)):
self.warn('Definition of zero-width and/or zero-height rectangle or obround aperture. This is invalid according to spec.' ) self.warn('Definition of zero-width and/or zero-height rectangle or obround aperture. This is invalid according to spec.' )
# Polygon aperture rotation is specified in degrees, but radians are easier to work with new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=self.aperture_attrs.copy(),
if match['shape'] == 'P':
if len(modifiers) > 2:
modifiers[2] = math.radians(modifiers[2])
new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=tuple(self.aperture_attrs.items()),
original_number=number) original_number=number)
elif (macro := self.aperture_macros.get(match['shape'])): elif (macro := self.aperture_macros.get(match['shape'])):
new_aperture = apertures.ApertureMacroInstance(macro, tuple(modifiers), unit=self.file_settings.unit, new_aperture = apertures.ApertureMacroInstance(macro, modifiers, unit=self.file_settings.unit,
attrs=tuple(self.aperture_attrs.items()), original_number=number) attrs=self.aperture_attrs.copy(), original_number=number)
else: else:
raise ValueError(f'Aperture shape "{match["shape"]}" is unknown') raise ValueError(f'Aperture shape "{match["shape"]}" is unknown')
@ -884,30 +774,19 @@ class GerberParser:
match['name'], match['macro'], self.file_settings.unit) match['name'], match['macro'], self.file_settings.unit)
def _parse_format_spec(self, match): def _parse_format_spec(self, match):
if self.file_settings.zeros is not None: # This is a common problem in Eagle files, so just suppress it
self.warn('Re-definition of zero suppression setting. Ignoring.') self.file_settings.zeros = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading')
else:
# This is a common problem in Eagle files, so just suppress it
self.file_settings.zeros = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading')
self.file_settings.notation = 'incremental' if match['notation'] == 'I' else 'absolute' self.file_settings.notation = 'incremental' if match['notation'] == 'I' else 'absolute'
if match['x'] != match['y']: if match['x'] != match['y']:
raise SyntaxError(f'FS specifies different coordinate formats for X and Y ({match["x"]} != {match["y"]})') raise SyntaxError(f'FS specifies different coordinate formats for X and Y ({match["x"]} != {match["y"]})')
self.file_settings.number_format = int(match['x'][0]), int(match['x'][1])
if self.file_settings.number_format != (None, None):
self.warn('Re-definition of number format setting. Ignoring.')
else:
self.file_settings.number_format = int(match['x'][0]), int(match['x'][1])
def _parse_unit_mode(self, match): def _parse_unit_mode(self, match):
if self.file_settings.unit is not None: if match['unit'] == 'MM':
self.warn('Re-definition of file units. Ignoring.') self.graphics_state.unit = self.file_settings.unit = MM
else: else:
if match['unit'] == 'MM': self.graphics_state.unit = self.file_settings.unit = Inch
self.graphics_state.unit = self.file_settings.unit = MM
else:
self.graphics_state.unit = self.file_settings.unit = Inch
def _parse_allegro_format_spec(self, match): def _parse_allegro_format_spec(self, match):
self._parse_format_spec(match) self._parse_format_spec(match)
@ -1083,40 +962,11 @@ class GerberParser:
else: else:
target = {'TF': self.file_attrs, 'TO': self.graphics_state.object_attrs, 'TA': self.aperture_attrs}[match['type']] target = {'TF': self.file_attrs, 'TO': self.graphics_state.object_attrs, 'TA': self.aperture_attrs}[match['type']]
target[match['name']] = tuple(match['value'].split(',')) if match['value'] else () target[match['name']] = match['value'].split(',')
if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']: if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']:
self.generator_hints.append('eagle') self.generator_hints.append('eagle')
def _parse_step_repeat(self, match):
if match['coords']:
if self.step_repeat_coords:
raise SyntaxError('SR step-repeat called inside ongoing SR step-repeat')
x, y = int(match['X']), int(match['Y'])
i, j = float(match['I']), float(match['J'])
if x < 1 or y < 1:
raise SyntaxError('SR step-repeat X and Y values must be at least 1')
if x * y > self.MAX_STEP_REPEAT_INSTANCES:
raise SyntaxError('SR step-repeat expands to too many instances')
self.step_repeat_coords = (x, y, i, j)
self.step_repeat_objects = []
else:
x, y, i, j = self.step_repeat_coords
if len(self.step_repeat_objects) * x * y > self.MAX_STEP_REPEAT_RESULT_OBJECTS:
raise SyntaxError('SR step-repeat expands to too many objects')
for obj in self.step_repeat_objects:
for nx in range(x):
for ny in range(y):
new_obj = copy.copy(obj)
new_obj.offset(i * nx, j * ny)
self.target.objects.append(new_obj)
self.step_repeat_coords = None
self.step_repeat_objects = None
def _parse_eof(self, match): def _parse_eof(self, match):
self.eof_found = True self.eof_found = True

View file

@ -0,0 +1,30 @@
from pathlib import Path
import pytest
from .image_support import ImageDifference
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()

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,282 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2022 Jan Götte <code@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():
# 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)
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