Compare commits
29 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60ae26db2 | ||
|
|
f74bd30c0f | ||
|
|
c9dff5450f | ||
|
|
3b5fb41ecb | ||
|
|
10cd29b96c | ||
|
|
48d4aeee94 | ||
|
|
6378a91f36 | ||
|
|
ef2864cfb3 | ||
|
|
0a059353d7 | ||
|
|
51327ccfeb | ||
|
|
c10616094c | ||
|
|
4c558f8111 | ||
|
|
ee0c1d38e6 | ||
|
|
513f6ebf1b | ||
|
|
5cf9837484 | ||
|
|
d437e06325 | ||
|
|
495ae6e932 | ||
|
|
15867450d9 | ||
|
|
82fcc24456 | ||
|
|
a877261256 | ||
|
|
db2bacebc7 | ||
|
|
8d4430ea61 | ||
|
|
909766a3a0 | ||
|
|
845224e2d6 | ||
|
|
0ae72f3159 | ||
|
|
84ec7b26e6 | ||
|
|
36e355cbd8 | ||
|
|
0037195543 | ||
|
|
2a3deb6c00 |
2
.gitignore
vendored
|
|
@ -3,5 +3,3 @@ gerbonara_test_failures
|
||||||
__pycache__
|
__pycache__
|
||||||
.tox
|
.tox
|
||||||
docs/_build/
|
docs/_build/
|
||||||
build
|
|
||||||
dist
|
|
||||||
|
|
|
||||||
103
.gitlab-ci.yml
|
|
@ -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 setuptools
|
||||||
|
- python3 setup.py sdist bdist_wheel
|
||||||
artifacts:
|
artifacts:
|
||||||
name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
|
name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
|
||||||
paths:
|
paths:
|
||||||
- dist/*
|
- dist/*
|
||||||
|
|
||||||
# FIXME: disable tests since (a) currenty kicad-cli is broken (aborts on start), and the workaround of using an older
|
test:archlinux:
|
||||||
# version from the KiCad project's kicad-cli containers does not work in gitlab CI. Pain.
|
stage: test
|
||||||
#test:archlinux:
|
image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
|
||||||
# stage: test
|
script:
|
||||||
# image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
|
- 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,7 @@ publish:gerbonara:
|
||||||
cache: {}
|
cache: {}
|
||||||
script:
|
script:
|
||||||
- export TWINE_USERNAME TWINE_PASSWORD
|
- export TWINE_USERNAME TWINE_PASSWORD
|
||||||
- pip3 install --user --break-system-packages twine rich
|
- pip3 install --user twine rich
|
||||||
- twine upload dist/*
|
- twine upload dist/*
|
||||||
dependencies:
|
dependencies:
|
||||||
- build:archlinux
|
- build:archlinux
|
||||||
|
|
|
||||||
34
Makefile
|
|
@ -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}'
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[](https://gitlab.com/gerbolyze/gerbonara/commits/master)
|
[](https://gitlab.com/gerbonara/gerbonara/commits/master)
|
||||||
[](https://gitlab.com/gerbolyze/gerbonara/commits/master)
|
[](https://gitlab.com/gerbonara/gerbonara/commits/master)
|
||||||
[](https://pypi.org/project/gerbonara/)
|
[](https://pypi.org/project/gerbonara/)
|
||||||
[](https://aur.archlinux.org/packages/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
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,6 @@ layers, or whole board stacks (:py:class:`~.layers.LayerStack`) to SVG.
|
||||||
|
|
||||||
``gerbonara render`` renders one or more Gerber or Excellon files as a single SVG file. It can read single files,
|
``gerbonara render`` renders one or more Gerber or Excellon files as a single SVG file. It can read single files,
|
||||||
directorys of files, and ZIP files. To read directories or zips, it applies gerbonara's layer filename matching rules.
|
directorys of files, and ZIP files. To read directories or zips, it applies gerbonara's layer filename matching rules.
|
||||||
These built-in rules should work with common settings in a wide variety of CAD tools.
|
|
||||||
|
|
||||||
.. option:: --warnings [default|ignore|once]
|
.. option:: --warnings [default|ignore|once]
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 243 KiB |
|
|
@ -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')
|
|
||||||
|
|
@ -48,7 +48,6 @@ Features
|
||||||
|
|
||||||
cli
|
cli
|
||||||
api-concepts
|
api-concepts
|
||||||
examples
|
|
||||||
file-api
|
file-api
|
||||||
object-api
|
object-api
|
||||||
apertures
|
apertures
|
||||||
|
|
@ -71,12 +70,10 @@ Then, you are ready to read and write gerber files:
|
||||||
|
|
||||||
from gerbonara import LayerStack
|
from gerbonara import LayerStack
|
||||||
|
|
||||||
stack = LayerStack.open('output/gerber')
|
stack = LayerStack.from_directory('output/gerber')
|
||||||
w, h = stack.outline.size('mm')
|
w, h = stack.outline.size('mm')
|
||||||
print(f'Board size is {w:.1f} mm x {h:.1f} mm')
|
print(f'Board size is {w:.1f} mm x {h:.1f} mm')
|
||||||
|
|
||||||
You can find some more elaborate examples in this doc's :ref:`Examples section<examples-doc>`.
|
|
||||||
|
|
||||||
Command-Line Interface
|
Command-Line Interface
|
||||||
======================
|
======================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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=' ')
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,5 @@ 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.13.0'
|
||||||
9
gerbonara/__main__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from .cli import cli
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cli()
|
||||||
|
|
||||||
211
gerbonara/aperture_macros/expression.py
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
|
||||||
|
|
||||||
|
import operator
|
||||||
|
import re
|
||||||
|
import ast
|
||||||
|
|
||||||
|
from ..utils import MM, Inch, MILLIMETERS_PER_INCH
|
||||||
|
|
||||||
|
|
||||||
|
def expr(obj):
|
||||||
|
return obj if isinstance(obj, Expression) else ConstantExpression(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class Expression:
|
||||||
|
def optimized(self, variable_binding={}):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'<{self.to_gerber()}>'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<E {self.to_gerber()}>'
|
||||||
|
|
||||||
|
def converted(self, unit):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def calculate(self, variable_binding={}, unit=None):
|
||||||
|
expr = self.converted(unit).optimized(variable_binding)
|
||||||
|
if not isinstance(expr, ConstantExpression):
|
||||||
|
raise IndexError(f'Cannot fully resolve expression due to unresolved variables: {expr} with variables {variable_binding}')
|
||||||
|
return expr.value
|
||||||
|
|
||||||
|
def __add__(self, other):
|
||||||
|
return OperatorExpression(operator.add, self, expr(other)).optimized()
|
||||||
|
|
||||||
|
def __radd__(self, other):
|
||||||
|
return expr(other) + self
|
||||||
|
|
||||||
|
def __sub__(self, other):
|
||||||
|
return OperatorExpression(operator.sub, self, expr(other)).optimized()
|
||||||
|
|
||||||
|
def __rsub__(self, other):
|
||||||
|
return expr(other) - self
|
||||||
|
|
||||||
|
def __mul__(self, other):
|
||||||
|
return OperatorExpression(operator.mul, self, expr(other)).optimized()
|
||||||
|
|
||||||
|
def __rmul__(self, other):
|
||||||
|
return expr(other) * self
|
||||||
|
|
||||||
|
def __truediv__(self, other):
|
||||||
|
return OperatorExpression(operator.truediv, self, expr(other)).optimized()
|
||||||
|
|
||||||
|
def __rtruediv__(self, other):
|
||||||
|
return expr(other) / self
|
||||||
|
|
||||||
|
def __neg__(self):
|
||||||
|
return 0 - self
|
||||||
|
|
||||||
|
def __pos__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
class UnitExpression(Expression):
|
||||||
|
def __init__(self, expr, unit):
|
||||||
|
self._expr = expr
|
||||||
|
self.unit = unit
|
||||||
|
|
||||||
|
def to_gerber(self, unit=None):
|
||||||
|
return self.converted(unit).optimized().to_gerber()
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return type(other) == type(self) and \
|
||||||
|
self.unit == other.unit and\
|
||||||
|
self._expr == other._expr
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'<{self._expr.to_gerber()} {self.unit}>'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<UE {self._expr.to_gerber()} {self.unit}>'
|
||||||
|
|
||||||
|
def converted(self, unit):
|
||||||
|
if self.unit is None or unit is None or self.unit == unit:
|
||||||
|
return self._expr
|
||||||
|
|
||||||
|
elif MM == unit:
|
||||||
|
return self._expr * MILLIMETERS_PER_INCH
|
||||||
|
|
||||||
|
elif Inch == unit:
|
||||||
|
return self._expr / MILLIMETERS_PER_INCH
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f'invalid unit {unit}, must be "inch" or "mm".')
|
||||||
|
|
||||||
|
def __add__(self, other):
|
||||||
|
if not isinstance(other, UnitExpression):
|
||||||
|
raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.')
|
||||||
|
|
||||||
|
if self.unit == other.unit or self.unit is None or other.unit is None:
|
||||||
|
return UnitExpression(self._expr + other._expr, self.unit)
|
||||||
|
|
||||||
|
if other.unit == 'mm': # -> and self.unit == 'inch'
|
||||||
|
return UnitExpression(self._expr + (other._expr / MILLIMETERS_PER_INCH), self.unit)
|
||||||
|
else: # other.unit == 'inch' and self.unit == 'mm'
|
||||||
|
return UnitExpression(self._expr + (other._expr * MILLIMETERS_PER_INCH), self.unit)
|
||||||
|
|
||||||
|
def __radd__(self, other):
|
||||||
|
# left hand side cannot have been an UnitExpression or __radd__ would not have been called
|
||||||
|
raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.')
|
||||||
|
|
||||||
|
def __sub__(self, other):
|
||||||
|
return (self + (-other)).optimize()
|
||||||
|
|
||||||
|
def __rsub__(self, other):
|
||||||
|
# see __radd__ above
|
||||||
|
raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.')
|
||||||
|
|
||||||
|
def __mul__(self, other):
|
||||||
|
return UnitExpression(self._expr * other, self.unit)
|
||||||
|
|
||||||
|
def __rmul__(self, other):
|
||||||
|
return UnitExpression(other * self._expr, self.unit)
|
||||||
|
|
||||||
|
def __truediv__(self, other):
|
||||||
|
return UnitExpression(self._expr / other, self.unit)
|
||||||
|
|
||||||
|
def __rtruediv__(self, other):
|
||||||
|
return UnitExpression(other / self._expr, self.unit)
|
||||||
|
|
||||||
|
def __neg__(self):
|
||||||
|
return UnitExpression(-self._expr, self.unit)
|
||||||
|
|
||||||
|
def __pos__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ConstantExpression(Expression):
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
def __float__(self):
|
||||||
|
return float(self.value)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return type(self) == type(other) and self.value == other.value
|
||||||
|
|
||||||
|
def to_gerber(self, _unit=None):
|
||||||
|
return f'{self.value:.6f}'.rstrip('0').rstrip('.')
|
||||||
|
|
||||||
|
|
||||||
|
class VariableExpression(Expression):
|
||||||
|
def __init__(self, number):
|
||||||
|
self.number = number
|
||||||
|
|
||||||
|
def optimized(self, variable_binding={}):
|
||||||
|
if self.number in variable_binding:
|
||||||
|
return ConstantExpression(variable_binding[self.number])
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return type(self) == type(other) and \
|
||||||
|
self.number == other.number
|
||||||
|
|
||||||
|
def to_gerber(self, _unit=None):
|
||||||
|
return f'${self.number}'
|
||||||
|
|
||||||
|
|
||||||
|
class OperatorExpression(Expression):
|
||||||
|
def __init__(self, op, l, r):
|
||||||
|
self.op = op
|
||||||
|
self.l = ConstantExpression(l) if isinstance(l, (int, float)) else l
|
||||||
|
self.r = ConstantExpression(r) if isinstance(r, (int, float)) else r
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return type(self) == type(other) and \
|
||||||
|
self.op == other.op and \
|
||||||
|
self.l == other.l and \
|
||||||
|
self.r == other.r
|
||||||
|
|
||||||
|
def optimized(self, variable_binding={}):
|
||||||
|
l = self.l.optimized(variable_binding)
|
||||||
|
r = self.r.optimized(variable_binding)
|
||||||
|
|
||||||
|
if self.op in (operator.add, operator.mul):
|
||||||
|
if id(r) < id(l):
|
||||||
|
l, r = r, l
|
||||||
|
|
||||||
|
if isinstance(l, ConstantExpression) and isinstance(r, ConstantExpression):
|
||||||
|
return ConstantExpression(self.op(float(l), float(r)))
|
||||||
|
|
||||||
|
return OperatorExpression(self.op, l, r)
|
||||||
|
|
||||||
|
def to_gerber(self, unit=None):
|
||||||
|
lval = self.l.to_gerber(unit)
|
||||||
|
rval = self.r.to_gerber(unit)
|
||||||
|
|
||||||
|
if isinstance(self.l, OperatorExpression):
|
||||||
|
lval = f'({lval})'
|
||||||
|
if isinstance(self.r, OperatorExpression):
|
||||||
|
rval = f'({rval})'
|
||||||
|
|
||||||
|
op = {operator.add: '+',
|
||||||
|
operator.sub: '-',
|
||||||
|
operator.mul: 'x',
|
||||||
|
operator.truediv: '/'} [self.op]
|
||||||
|
|
||||||
|
return f'{lval}{op}{rval}'
|
||||||
|
|
||||||
201
gerbonara/aperture_macros/parse.py
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
|
||||||
|
|
||||||
|
import operator
|
||||||
|
import re
|
||||||
|
import ast
|
||||||
|
import copy
|
||||||
|
import math
|
||||||
|
|
||||||
|
from . import primitive as ap
|
||||||
|
from .expression import *
|
||||||
|
from ..utils import MM
|
||||||
|
|
||||||
|
def rad_to_deg(x):
|
||||||
|
return (x / math.pi) * 180
|
||||||
|
|
||||||
|
def _map_expression(node):
|
||||||
|
if isinstance(node, ast.Num):
|
||||||
|
return ConstantExpression(node.n)
|
||||||
|
|
||||||
|
elif isinstance(node, ast.BinOp):
|
||||||
|
op_map = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}
|
||||||
|
return OperatorExpression(op_map[type(node.op)], _map_expression(node.left), _map_expression(node.right))
|
||||||
|
|
||||||
|
elif isinstance(node, ast.UnaryOp):
|
||||||
|
if type(node.op) == ast.UAdd:
|
||||||
|
return _map_expression(node.operand)
|
||||||
|
else:
|
||||||
|
return OperatorExpression(operator.sub, ConstantExpression(0), _map_expression(node.operand))
|
||||||
|
|
||||||
|
elif isinstance(node, ast.Name):
|
||||||
|
return VariableExpression(int(node.id[3:])) # node.id has format var[0-9]+
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise SyntaxError('Invalid aperture macro expression')
|
||||||
|
|
||||||
|
def _parse_expression(expr):
|
||||||
|
expr = expr.lower().replace('x', '*')
|
||||||
|
expr = re.sub(r'\$([0-9]+)', r'var\1', expr)
|
||||||
|
try:
|
||||||
|
parsed = ast.parse(expr, mode='eval').body
|
||||||
|
except SyntaxError as e:
|
||||||
|
raise SyntaxError('Invalid aperture macro expression') from e
|
||||||
|
return _map_expression(parsed)
|
||||||
|
|
||||||
|
class ApertureMacro:
|
||||||
|
def __init__(self, name=None, primitives=None, variables=None):
|
||||||
|
self._name = name
|
||||||
|
self.comments = []
|
||||||
|
self.variables = variables or {}
|
||||||
|
self.primitives = primitives or []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse_macro(cls, name, body, unit):
|
||||||
|
macro = cls(name)
|
||||||
|
|
||||||
|
blocks = body.split('*')
|
||||||
|
for block in blocks:
|
||||||
|
if not (block := block.strip()): # empty block
|
||||||
|
continue
|
||||||
|
|
||||||
|
if block.startswith('0 '): # comment
|
||||||
|
macro.comments.append(block[2:])
|
||||||
|
continue
|
||||||
|
|
||||||
|
block = re.sub(r'\s', '', block)
|
||||||
|
|
||||||
|
if block[0] == '$': # variable definition
|
||||||
|
name, expr = block.partition('=')
|
||||||
|
number = int(name[1:])
|
||||||
|
if number in macro.variables:
|
||||||
|
raise SyntaxError(f'Re-definition of aperture macro variable {number} inside macro')
|
||||||
|
macro.variables[number] = _parse_expression(expr)
|
||||||
|
|
||||||
|
else: # primitive
|
||||||
|
primitive, *args = block.split(',')
|
||||||
|
args = [ _parse_expression(arg) for arg in args ]
|
||||||
|
primitive = ap.PRIMITIVE_CLASSES[int(primitive)](unit=unit, args=args)
|
||||||
|
macro.primitives.append(primitive)
|
||||||
|
|
||||||
|
return macro
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
if self._name is not None:
|
||||||
|
return self._name
|
||||||
|
else:
|
||||||
|
return f'gn_{hash(self)}'
|
||||||
|
|
||||||
|
@name.setter
|
||||||
|
def name(self, name):
|
||||||
|
self._name = name
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'<Aperture macro {self.name}, variables {str(self.variables)}, primitives {self.primitives}>'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return str(self)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return hasattr(other, 'to_gerber') and self.to_gerber() == other.to_gerber()
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self.to_gerber())
|
||||||
|
|
||||||
|
def dilated(self, offset, unit=MM):
|
||||||
|
dup = copy.deepcopy(self)
|
||||||
|
new_primitives = []
|
||||||
|
for primitive in dup.primitives:
|
||||||
|
try:
|
||||||
|
if primitive.exposure.calculate():
|
||||||
|
primitive.dilate(offset, unit)
|
||||||
|
new_primitives.append(primitive)
|
||||||
|
except IndexError:
|
||||||
|
warnings.warn('Cannot dilate aperture macro primitive with exposure value computed from macro variable.')
|
||||||
|
pass
|
||||||
|
dup.primitives = new_primitives
|
||||||
|
return dup
|
||||||
|
|
||||||
|
def to_gerber(self, unit=None):
|
||||||
|
comments = [ c.to_gerber() for c in self.comments ]
|
||||||
|
variable_defs = [ f'${var.to_gerber(unit)}={expr}' for var, expr in self.variables.items() ]
|
||||||
|
primitive_defs = [ prim.to_gerber(unit) for prim in self.primitives ]
|
||||||
|
return '*\n'.join(comments + variable_defs + primitive_defs)
|
||||||
|
|
||||||
|
def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None, polarity_dark=True):
|
||||||
|
variables = dict(self.variables)
|
||||||
|
for number, value in enumerate(parameters, start=1):
|
||||||
|
if number in variables:
|
||||||
|
raise SyntaxError(f'Re-definition of aperture macro variable {i} through parameter {value}')
|
||||||
|
variables[number] = value
|
||||||
|
|
||||||
|
for primitive in self.primitives:
|
||||||
|
yield from primitive.to_graphic_primitives(offset, rotation, variables, unit, polarity_dark)
|
||||||
|
|
||||||
|
def rotated(self, angle):
|
||||||
|
dup = copy.deepcopy(self)
|
||||||
|
for primitive in dup.primitives:
|
||||||
|
# aperture macro primitives use degree counter-clockwise, our API uses radians clockwise
|
||||||
|
primitive.rotation -= rad_to_deg(angle)
|
||||||
|
return dup
|
||||||
|
|
||||||
|
def scaled(self, scale):
|
||||||
|
dup = copy.deepcopy(self)
|
||||||
|
for primitive in dup.primitives:
|
||||||
|
primitive.scale(scale)
|
||||||
|
return dup
|
||||||
|
|
||||||
|
|
||||||
|
var = VariableExpression
|
||||||
|
deg_per_rad = 180 / math.pi
|
||||||
|
|
||||||
|
class GenericMacros:
|
||||||
|
|
||||||
|
_generic_hole = lambda n: [
|
||||||
|
ap.Circle('mm', [0, var(n), 0, 0]),
|
||||||
|
ap.CenterLine('mm', [0, var(n), var(n+1), 0, 0, var(n+2) * -deg_per_rad])]
|
||||||
|
|
||||||
|
# NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing
|
||||||
|
# API.
|
||||||
|
circle = ApertureMacro('GNC', [
|
||||||
|
ap.Circle('mm', [1, var(1), 0, 0, var(4) * -deg_per_rad]),
|
||||||
|
*_generic_hole(2)])
|
||||||
|
|
||||||
|
rect = ApertureMacro('GNR', [
|
||||||
|
ap.CenterLine('mm', [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]),
|
||||||
|
*_generic_hole(3)])
|
||||||
|
|
||||||
|
# params: width, height, corner radius, *hole, rotation
|
||||||
|
rounded_rect = ApertureMacro('GRR', [
|
||||||
|
ap.CenterLine('mm', [1, var(1)-2*var(3), var(2), 0, 0, var(6) * -deg_per_rad]),
|
||||||
|
ap.CenterLine('mm', [1, var(1), var(2)-2*var(3), 0, 0, var(6) * -deg_per_rad]),
|
||||||
|
ap.Circle('mm', [1, var(3)*2, +(var(1)/2-var(3)), +(var(2)/2-var(3)), 0]),
|
||||||
|
ap.Circle('mm', [1, var(3)*2, +(var(1)/2-var(3)), -(var(2)/2-var(3)), 0]),
|
||||||
|
ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), +(var(2)/2-var(3)), 0]),
|
||||||
|
ap.Circle('mm', [1, var(3)*2, -(var(1)/2-var(3)), -(var(2)/2-var(3)), 0]),
|
||||||
|
*_generic_hole(4)])
|
||||||
|
|
||||||
|
# w must be larger than h
|
||||||
|
obround = ApertureMacro('GNO', [
|
||||||
|
ap.CenterLine('mm', [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]),
|
||||||
|
ap.Circle('mm', [1, var(2), +var(1)/2, 0, var(5) * -deg_per_rad]),
|
||||||
|
ap.Circle('mm', [1, var(2), -var(1)/2, 0, var(5) * -deg_per_rad]),
|
||||||
|
*_generic_hole(3) ])
|
||||||
|
|
||||||
|
polygon = ApertureMacro('GNP', [
|
||||||
|
ap.Polygon('mm', [1, var(2), 0, 0, var(1), var(3) * -deg_per_rad]),
|
||||||
|
ap.Circle('mm', [0, var(4), 0, 0])])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
#for line in sys.stdin:
|
||||||
|
#expr = _parse_expression(line.strip())
|
||||||
|
#print(expr, '->', expr.optimized())
|
||||||
|
|
||||||
|
for primitive in parse_macro(sys.stdin.read(), 'mm'):
|
||||||
|
print(primitive)
|
||||||
|
|
||||||
306
gerbonara/aperture_macros/primitive.py
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
|
||||||
|
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de>
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
import contextlib
|
||||||
|
import math
|
||||||
|
|
||||||
|
from .expression import Expression, UnitExpression, ConstantExpression, expr
|
||||||
|
|
||||||
|
from .. import graphic_primitives as gp
|
||||||
|
|
||||||
|
|
||||||
|
def point_distance(a, b):
|
||||||
|
x1, y1 = a
|
||||||
|
x2, y2 = b
|
||||||
|
return math.sqrt((x2 - x1)**2 + (y2 - y1)**2)
|
||||||
|
|
||||||
|
def deg_to_rad(a):
|
||||||
|
return (a / 180) * math.pi
|
||||||
|
|
||||||
|
class Primitive:
|
||||||
|
def __init__(self, unit, args):
|
||||||
|
self.unit = unit
|
||||||
|
|
||||||
|
if len(args) > len(type(self).__annotations__):
|
||||||
|
raise ValueError(f'Too many arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
|
||||||
|
|
||||||
|
for arg, (name, fieldtype) in zip(args, type(self).__annotations__.items()):
|
||||||
|
arg = expr(arg) # convert int/float to Expression object
|
||||||
|
|
||||||
|
if fieldtype == UnitExpression:
|
||||||
|
setattr(self, name, UnitExpression(arg, unit))
|
||||||
|
else:
|
||||||
|
setattr(self, name, arg)
|
||||||
|
|
||||||
|
for name in type(self).__annotations__:
|
||||||
|
if not hasattr(self, name):
|
||||||
|
raise ValueError(f'Too few arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
|
||||||
|
|
||||||
|
def to_gerber(self, unit=None):
|
||||||
|
return f'{self.code},' + ','.join(
|
||||||
|
getattr(self, name).to_gerber(unit) for name in type(self).__annotations__)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__)
|
||||||
|
return f'<{type(self).__name__} {attrs}>'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return str(self)
|
||||||
|
|
||||||
|
class Calculator:
|
||||||
|
def __init__(self, instance, variable_binding={}, unit=None):
|
||||||
|
self.instance = instance
|
||||||
|
self.variable_binding = variable_binding
|
||||||
|
self.unit = unit
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, _type, _value, _traceback):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self.instance, name).calculate(self.variable_binding, self.unit)
|
||||||
|
|
||||||
|
def __call__(self, expr):
|
||||||
|
return expr.calculate(self.variable_binding, self.unit)
|
||||||
|
|
||||||
|
|
||||||
|
class Circle(Primitive):
|
||||||
|
code = 1
|
||||||
|
exposure : Expression
|
||||||
|
diameter : UnitExpression
|
||||||
|
# center x/y
|
||||||
|
x : UnitExpression
|
||||||
|
y : UnitExpression
|
||||||
|
rotation : Expression = None
|
||||||
|
|
||||||
|
def __init__(self, unit, args):
|
||||||
|
super().__init__(unit, args)
|
||||||
|
if self.rotation is None:
|
||||||
|
self.rotation = ConstantExpression(0)
|
||||||
|
|
||||||
|
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||||
|
with self.Calculator(self, variable_binding, unit) as calc:
|
||||||
|
x, y = gp.rotate_point(calc.x, calc.y, deg_to_rad(calc.rotation) + rotation, 0, 0)
|
||||||
|
x, y = x+offset[0], y+offset[1]
|
||||||
|
return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||||
|
|
||||||
|
def dilate(self, offset, unit):
|
||||||
|
self.diameter += UnitExpression(offset, unit)
|
||||||
|
|
||||||
|
def scale(self, scale):
|
||||||
|
self.x *= UnitExpression(scale)
|
||||||
|
self.y *= UnitExpression(scale)
|
||||||
|
self.diameter *= UnitExpression(scale)
|
||||||
|
|
||||||
|
|
||||||
|
class VectorLine(Primitive):
|
||||||
|
code = 20
|
||||||
|
exposure : Expression
|
||||||
|
width : UnitExpression
|
||||||
|
start_x : UnitExpression
|
||||||
|
start_y : UnitExpression
|
||||||
|
end_x : UnitExpression
|
||||||
|
end_y : UnitExpression
|
||||||
|
rotation : Expression
|
||||||
|
|
||||||
|
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||||
|
with self.Calculator(self, variable_binding, unit) as calc:
|
||||||
|
center_x = (calc.end_x + calc.start_x) / 2
|
||||||
|
center_y = (calc.end_y + calc.start_y) / 2
|
||||||
|
delta_x = calc.end_x - calc.start_x
|
||||||
|
delta_y = calc.end_y - calc.start_y
|
||||||
|
length = point_distance((calc.start_x, calc.start_y), (calc.end_x, calc.end_y))
|
||||||
|
|
||||||
|
center_x, center_y = center_x+offset[0], center_y+offset[1]
|
||||||
|
rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x)
|
||||||
|
|
||||||
|
return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation,
|
||||||
|
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||||
|
|
||||||
|
def dilate(self, offset, unit):
|
||||||
|
self.width += UnitExpression(2*offset, unit)
|
||||||
|
|
||||||
|
def scale(self, scale):
|
||||||
|
self.start_x *= UnitExpression(scale)
|
||||||
|
self.start_y *= UnitExpression(scale)
|
||||||
|
self.end_x *= UnitExpression(scale)
|
||||||
|
self.end_y *= UnitExpression(scale)
|
||||||
|
|
||||||
|
|
||||||
|
class CenterLine(Primitive):
|
||||||
|
code = 21
|
||||||
|
exposure : Expression
|
||||||
|
width : UnitExpression
|
||||||
|
height : UnitExpression
|
||||||
|
# center x/y
|
||||||
|
x : UnitExpression
|
||||||
|
y : UnitExpression
|
||||||
|
rotation : Expression
|
||||||
|
|
||||||
|
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||||
|
with self.Calculator(self, variable_binding, unit) as calc:
|
||||||
|
rotation += deg_to_rad(calc.rotation)
|
||||||
|
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
|
||||||
|
x, y = x+offset[0], y+offset[1]
|
||||||
|
w, h = calc.width, calc.height
|
||||||
|
|
||||||
|
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||||
|
|
||||||
|
def dilate(self, offset, unit):
|
||||||
|
self.width += UnitExpression(2*offset, unit)
|
||||||
|
|
||||||
|
def scale(self, scale):
|
||||||
|
self.width *= UnitExpression(scale)
|
||||||
|
self.height *= UnitExpression(scale)
|
||||||
|
self.x *= UnitExpression(scale)
|
||||||
|
self.y *= UnitExpression(scale)
|
||||||
|
|
||||||
|
|
||||||
|
class Polygon(Primitive):
|
||||||
|
code = 5
|
||||||
|
exposure : Expression
|
||||||
|
n_vertices : Expression
|
||||||
|
# center x/y
|
||||||
|
x : UnitExpression
|
||||||
|
y : UnitExpression
|
||||||
|
diameter : UnitExpression
|
||||||
|
rotation : Expression
|
||||||
|
|
||||||
|
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||||
|
with self.Calculator(self, variable_binding, unit) as calc:
|
||||||
|
rotation += deg_to_rad(calc.rotation)
|
||||||
|
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
|
||||||
|
x, y = x+offset[0], y+offset[1]
|
||||||
|
return [ gp.ArcPoly.from_regular_polygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation,
|
||||||
|
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||||
|
|
||||||
|
def dilate(self, offset, unit):
|
||||||
|
self.diameter += UnitExpression(2*offset, unit)
|
||||||
|
|
||||||
|
def scale(self, scale):
|
||||||
|
self.diameter *= UnitExpression(scale)
|
||||||
|
self.x *= UnitExpression(scale)
|
||||||
|
self.y *= UnitExpression(scale)
|
||||||
|
|
||||||
|
|
||||||
|
class Thermal(Primitive):
|
||||||
|
code = 7
|
||||||
|
exposure : Expression
|
||||||
|
# center x/y
|
||||||
|
x : UnitExpression
|
||||||
|
y : UnitExpression
|
||||||
|
d_outer : UnitExpression
|
||||||
|
d_inner : UnitExpression
|
||||||
|
gap_w : UnitExpression
|
||||||
|
rotation : Expression
|
||||||
|
|
||||||
|
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||||
|
with self.Calculator(self, variable_binding, unit) as calc:
|
||||||
|
rotation += deg_to_rad(calc.rotation)
|
||||||
|
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
|
||||||
|
x, y = x+offset[0], y+offset[1]
|
||||||
|
|
||||||
|
dark = (bool(calc.exposure) == polarity_dark)
|
||||||
|
|
||||||
|
return [
|
||||||
|
gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark),
|
||||||
|
gp.Circle(x, y, calc.d_inner/2, polarity_dark=not dark),
|
||||||
|
gp.Rectangle(x, y, d_outer, gap_w, rotation=rotation, polarity_dark=not dark),
|
||||||
|
gp.Rectangle(x, y, gap_w, d_outer, rotation=rotation, polarity_dark=not dark),
|
||||||
|
]
|
||||||
|
|
||||||
|
def dilate(self, offset, unit):
|
||||||
|
# I'd rather print a warning and produce graphically slightly incorrect output in these few cases here than
|
||||||
|
# producing macros that may evaluate to primitives with negative values.
|
||||||
|
warnings.warn('Attempted dilation of macro aperture thermal primitive. This is not supported.')
|
||||||
|
|
||||||
|
def scale(self, scale):
|
||||||
|
self.d_outer *= UnitExpression(scale)
|
||||||
|
self.d_inner *= UnitExpression(scale)
|
||||||
|
self.gap_w *= UnitExpression(scale)
|
||||||
|
self.x *= UnitExpression(scale)
|
||||||
|
self.y *= UnitExpression(scale)
|
||||||
|
|
||||||
|
|
||||||
|
class Outline(Primitive):
|
||||||
|
code = 4
|
||||||
|
|
||||||
|
def __init__(self, unit, args):
|
||||||
|
if len(args) < 11:
|
||||||
|
raise ValueError(f'Invalid aperture macro outline primitive, not enough parameters ({len(args)}).')
|
||||||
|
if len(args) > 5004:
|
||||||
|
raise ValueError(f'Invalid aperture macro outline primitive, too many points ({len(args)//2-2}).')
|
||||||
|
|
||||||
|
self.exposure = args.pop(0)
|
||||||
|
|
||||||
|
# length arg must not contain variables (that would not make sense)
|
||||||
|
length_arg = args.pop(0).calculate()
|
||||||
|
|
||||||
|
if length_arg != len(args)//2-1:
|
||||||
|
raise ValueError(f'Invalid aperture macro outline primitive, given size {length_arg} does not match length of coordinate list({len(args)//2-1}).')
|
||||||
|
|
||||||
|
if len(args) % 2 == 1:
|
||||||
|
self.rotation = args.pop()
|
||||||
|
else:
|
||||||
|
self.rotation = ConstantExpression(0.0)
|
||||||
|
|
||||||
|
if args[0] != args[-2] or args[1] != args[-1]:
|
||||||
|
raise ValueError(f'Invalid aperture macro outline primitive, polygon is not closed {args[2:4], args[-3:-1]}')
|
||||||
|
|
||||||
|
self.coords = [(UnitExpression(x, unit), UnitExpression(y, unit)) for x, y in zip(args[0::2], args[1::2])]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'<Outline {len(self.coords)} points>'
|
||||||
|
|
||||||
|
def to_gerber(self, unit=None):
|
||||||
|
coords = ','.join(coord.to_gerber(unit) for xy in self.coords for coord in xy)
|
||||||
|
return f'{self.code},{self.exposure.to_gerber()},{len(self.coords)-1},{coords},{self.rotation.to_gerber()}'
|
||||||
|
|
||||||
|
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||||
|
with self.Calculator(self, variable_binding, unit) as calc:
|
||||||
|
rotation += deg_to_rad(calc.rotation)
|
||||||
|
bound_coords = [ gp.rotate_point(calc(x), calc(y), rotation, 0, 0) for x, y in self.coords ]
|
||||||
|
bound_coords = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ]
|
||||||
|
bound_radii = [None] * len(bound_coords)
|
||||||
|
return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))]
|
||||||
|
|
||||||
|
def dilate(self, offset, unit):
|
||||||
|
# we would need a whole polygon offset/clipping library here
|
||||||
|
warnings.warn('Attempted dilation of macro aperture outline primitive. This is not supported.')
|
||||||
|
|
||||||
|
def scale(self, scale):
|
||||||
|
self.coords = [(x*UnitExpression(scale), y*UnitExpression(scale)) for x, y in self.coords]
|
||||||
|
|
||||||
|
|
||||||
|
class Comment:
|
||||||
|
code = 0
|
||||||
|
|
||||||
|
def __init__(self, comment):
|
||||||
|
self.comment = comment
|
||||||
|
|
||||||
|
def to_gerber(self, unit=None):
|
||||||
|
return f'0 {self.comment}'
|
||||||
|
|
||||||
|
def scale(self, scale):
|
||||||
|
pass
|
||||||
|
|
||||||
|
PRIMITIVE_CLASSES = {
|
||||||
|
**{cls.code: cls for cls in [
|
||||||
|
Comment,
|
||||||
|
Circle,
|
||||||
|
VectorLine,
|
||||||
|
CenterLine,
|
||||||
|
Outline,
|
||||||
|
Polygon,
|
||||||
|
Thermal,
|
||||||
|
]},
|
||||||
|
# alternative codes
|
||||||
|
2: VectorLine,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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, angle=0):
|
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,31 +243,31 @@ 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)
|
||||||
return self
|
|
||||||
return replace(self, diameter=self.diameter+2*offset, hole_dia=None)
|
|
||||||
|
|
||||||
@lru_cache()
|
|
||||||
def rotated(self, angle=0):
|
def rotated(self, angle=0):
|
||||||
return self
|
if math.isclose((self.rotation+angle) % (2*math.pi), 0, abs_tol=1e-6) or self.hole_rect_h is None:
|
||||||
|
return self
|
||||||
|
else:
|
||||||
|
return self.to_macro(self.rotation+angle)
|
||||||
|
|
||||||
def scaled(self, scale):
|
def scaled(self, scale):
|
||||||
return replace(self,
|
return replace(self,
|
||||||
diameter=self.diameter*scale,
|
diameter=self.diameter*scale,
|
||||||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
|
||||||
|
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
|
||||||
|
|
||||||
def to_macro(self, rotation=0):
|
def to_macro(self):
|
||||||
from .aperture_macros.parse import GenericMacros
|
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
|
||||||
return GenericMacros.circle(MM(self.diameter, self.unit),
|
|
||||||
MM(self.hole_dia, self.unit))
|
|
||||||
|
|
||||||
def _params(self, unit=None):
|
def _params(self, unit=None):
|
||||||
return _strip_right(
|
return _strip_right(
|
||||||
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
|
""" 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. """
|
aperture is not well-defined and there is no tool that implements it in a geometrically correct way. """
|
||||||
|
|
@ -227,10 +279,14 @@ class RectangleAperture(Aperture):
|
||||||
h : Length(float)
|
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 +298,42 @@ 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, angle=0):
|
def rotated(self, angle=0):
|
||||||
if math.isclose(angle % math.pi, 0, abs_tol=1e-6):
|
self.rotation += angle
|
||||||
|
if math.isclose(self.rotation % math.pi, 0):
|
||||||
|
self.rotation = 0
|
||||||
return self
|
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 scaled(self, scale):
|
||||||
return replace(self,
|
return replace(self,
|
||||||
w=self.w*scale,
|
w=self.w*scale,
|
||||||
h=self.h*scale,
|
h=self.h*scale,
|
||||||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
|
||||||
|
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
|
||||||
|
|
||||||
def to_macro(self, rotation=0):
|
def to_macro(self, rotation=0):
|
||||||
from .aperture_macros.parse import GenericMacros
|
return ApertureMacroInstance(GenericMacros.rect,
|
||||||
return GenericMacros.rect(MM(self.w, self.unit),
|
[MM(self.w, self.unit),
|
||||||
MM(self.h, self.unit),
|
MM(self.h, self.unit),
|
||||||
MM(self.hole_dia, self.unit),
|
MM(self.hole_dia, self.unit) or 0,
|
||||||
rotation)
|
MM(self.hole_rect_h, self.unit) or 0,
|
||||||
|
self.rotation + 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 +349,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,16 +365,13 @@ 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, angle=0):
|
def rotated(self, angle=0):
|
||||||
if math.isclose(angle % math.pi, 0, abs_tol=1e-6):
|
if math.isclose((angle + self.rotation) % math.pi, 0, abs_tol=1e-6):
|
||||||
return self
|
return self
|
||||||
elif math.isclose(angle % math.pi, math.pi/2, abs_tol=1e-6):
|
elif math.isclose((angle + self.rotation) % math.pi, math.pi/2, abs_tol=1e-6):
|
||||||
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(angle)
|
||||||
|
|
||||||
|
|
@ -320,30 +379,32 @@ class ObroundAperture(Aperture):
|
||||||
return replace(self,
|
return replace(self,
|
||||||
w=self.w*scale,
|
w=self.w*scale,
|
||||||
h=self.h*scale,
|
h=self.h*scale,
|
||||||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
hole_dia=None if self.hole_dia is None else self.hole_dia*scale,
|
||||||
|
hole_rect_h=None if self.hole_rect_h is None else self.hole_rect_h*scale)
|
||||||
|
|
||||||
def to_macro(self, rotation=0):
|
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:
|
if self.w > self.h:
|
||||||
inst = self
|
inst = self
|
||||||
else:
|
else:
|
||||||
rotation -= -math.pi/2
|
inst = replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=rotation+self.rotation-90)
|
||||||
inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
|
|
||||||
|
|
||||||
from .aperture_macros.parse import GenericMacros
|
return ApertureMacroInstance(GenericMacros.obround,
|
||||||
return GenericMacros.obround(MM(inst.w, self.unit),
|
[MM(inst.w, self.unit),
|
||||||
MM(inst.h, self.unit),
|
MM(inst.h, self.unit),
|
||||||
MM(inst.hole_dia, self.unit) or 0,
|
MM(inst.hole_dia, self.unit),
|
||||||
rotation)
|
MM(inst.hole_rect_h, self.unit),
|
||||||
|
inst.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 +421,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,16 +432,13 @@ 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, angle=0):
|
def rotated(self, angle=0):
|
||||||
if angle != 0:
|
if angle != 0:
|
||||||
return replace(self, rotation=self.rotation + angle)
|
return replace(self, rotatio=self.rotation + angle)
|
||||||
else:
|
else:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -390,27 +448,21 @@ class PolygonAperture(Aperture):
|
||||||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||||
|
|
||||||
def to_macro(self):
|
def to_macro(self):
|
||||||
from .aperture_macros.parse import GenericMacros
|
return ApertureMacroInstance(GenericMacros.polygon, self._params(MM))
|
||||||
return GenericMacros.polygon(self.n_vertices,
|
|
||||||
MM(self.diameter, self.unit),
|
|
||||||
MM(self.hole_dia, self.unit),
|
|
||||||
self.rotation)
|
|
||||||
|
|
||||||
def _params(self, unit=None):
|
def _params(self, unit=None):
|
||||||
rotation = self.rotation % (2*math.pi / self.n_vertices)
|
rotation = self.rotation % (2*math.pi / self.n_vertices)
|
||||||
if math.isclose(rotation, 0, abs_tol=1e-6):
|
if math.isclose(rotation, 0, abs_tol=1-e6):
|
||||||
rotation = None
|
rotation = None
|
||||||
else:
|
|
||||||
rotation = math.degrees(rotation)
|
|
||||||
|
|
||||||
if self.hole_dia is not None:
|
if self.hole_dia is not None:
|
||||||
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia)
|
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia)
|
||||||
elif rotation is not None and not math.isclose(rotation, 0, abs_tol=1e-6):
|
elif rotation is not None and not math.isclose(rotation, 0):
|
||||||
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation
|
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation
|
||||||
else:
|
else:
|
||||||
return self.unit.convert_to(unit, self.diameter), self.n_vertices
|
return self.unit.convert_to(unit, self.diameter), self.n_vertices
|
||||||
|
|
||||||
@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 +474,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 +485,33 @@ 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, angle=0):
|
||||||
def rotated(self, angle=0.0):
|
if math.isclose((self.rotation+angle) % (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(angle)
|
||||||
|
|
||||||
def to_macro(self, rotation=0.0):
|
def to_macro(self, rotation=0):
|
||||||
return replace(self, macro=self.macro.rotated(rotation))
|
return replace(self, macro=self.macro.rotated(self.rotation+rotation), rotation=0)
|
||||||
|
|
||||||
def scaled(self, scale):
|
def scaled(self, scale):
|
||||||
return replace(self, macro=self.macro.scaled(scale))
|
return replace(self, macro=self.macro.scaled(scale))
|
||||||
|
|
||||||
def calculate_out(self, unit=None, macro_name=None):
|
def __eq__(self, other):
|
||||||
return replace(self,
|
return hasattr(other, 'macro') and self.macro == other.macro and \
|
||||||
parameters=tuple(),
|
hasattr(other, 'parameters') and self.parameters == other.parameters and \
|
||||||
macro=self.macro.substitute_params(self._params(unit), unit, macro_name))
|
hasattr(other, 'rotation') and self.rotation == other.rotation
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
@ -4,19 +4,16 @@ import math
|
||||||
import warnings
|
import warnings
|
||||||
from copy import copy
|
from copy import copy
|
||||||
from itertools import zip_longest, chain
|
from itertools import zip_longest, chain
|
||||||
from dataclasses import dataclass, field, replace, KW_ONLY
|
from dataclasses import dataclass, field, KW_ONLY
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from ..utils import LengthUnit, MM, rotate_point, svg_arc, sum_bounds, bbox_intersect, Tag, offset_bounds
|
from ..utils import LengthUnit, MM, rotate_point, svg_arc, sum_bounds, bbox_intersect, Tag
|
||||||
from ..layers import LayerStack
|
from ..layers import LayerStack
|
||||||
from ..graphic_objects import Line, Arc, Flash
|
from ..graphic_objects import Line, Arc, Flash
|
||||||
from ..apertures import Aperture, CircleAperture, ObroundAperture, RectangleAperture, ExcellonTool
|
from ..apertures import Aperture, CircleAperture, RectangleAperture, ExcellonTool
|
||||||
from ..newstroke import Newstroke
|
from ..newstroke import Newstroke
|
||||||
|
|
||||||
|
|
||||||
class UNDEFINED:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def sgn(x):
|
def sgn(x):
|
||||||
return -1 if x < 0 else 1
|
return -1 if x < 0 else 1
|
||||||
|
|
||||||
|
|
@ -54,7 +51,7 @@ class Board:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def abs_pos(self):
|
def abs_pos(self):
|
||||||
return self.x, self.y, self.rotation, False
|
return self.x, self.y, self.rotation
|
||||||
|
|
||||||
def add_silk(self, side, obj):
|
def add_silk(self, side, obj):
|
||||||
if side not in ('top', 'bottom'):
|
if side not in ('top', 'bottom'):
|
||||||
|
|
@ -80,7 +77,7 @@ class Board:
|
||||||
for ko in self.keepouts:
|
for ko in self.keepouts:
|
||||||
if obj.overlaps(ko, unit=MM):
|
if obj.overlaps(ko, unit=MM):
|
||||||
if keepout_errors == 'warn':
|
if keepout_errors == 'warn':
|
||||||
warnings.warn(f'Object with bounds {obj.bounding_box(MM)} [mm] hits one or more keepout areas')
|
warnings.warn(msg)
|
||||||
elif keepout_errors == 'raise':
|
elif keepout_errors == 'raise':
|
||||||
raise KeepoutError(obj, ko, msg)
|
raise KeepoutError(obj, ko, msg)
|
||||||
return
|
return
|
||||||
|
|
@ -118,11 +115,10 @@ class Board:
|
||||||
|
|
||||||
def layer_stack(self, layer_stack=None):
|
def layer_stack(self, layer_stack=None):
|
||||||
if layer_stack is None:
|
if layer_stack is None:
|
||||||
layer_stack = LayerStack(board_name='proto')
|
layer_stack = LayerStack()
|
||||||
|
|
||||||
cache = {}
|
|
||||||
for obj in chain(self.objects):
|
for obj in chain(self.objects):
|
||||||
obj.render(layer_stack, cache)
|
obj.render(layer_stack)
|
||||||
|
|
||||||
layer_stack['mechanical', 'outline'].objects.extend(self.outline)
|
layer_stack['mechanical', 'outline'].objects.extend(self.outline)
|
||||||
layer_stack['top', 'silk'].objects.extend(self.extra_silk_top)
|
layer_stack['top', 'silk'].objects.extend(self.extra_silk_top)
|
||||||
|
|
@ -145,56 +141,21 @@ class Positioned:
|
||||||
y: float
|
y: float
|
||||||
_: KW_ONLY
|
_: KW_ONLY
|
||||||
rotation: float = 0.0
|
rotation: float = 0.0
|
||||||
flip: bool = False
|
side: str = 'top'
|
||||||
unit: LengthUnit = MM
|
unit: LengthUnit = MM
|
||||||
parent: object = None
|
parent: object = None
|
||||||
|
|
||||||
|
def flip(self):
|
||||||
|
self.side = 'top' if self.side == 'bottom' else 'bottom'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def abs_pos(self):
|
def abs_pos(self):
|
||||||
if self.parent is None:
|
if self.parent is None:
|
||||||
px, py, pa, pf = 0, 0, 0, False
|
px, py, pa = 0, 0, 0
|
||||||
else:
|
else:
|
||||||
px, py, pa, pf = self.parent.abs_pos
|
px, py, pa = self.parent.abs_pos
|
||||||
|
|
||||||
return self.x+px, self.y+py, self.rotation+pa, (bool(self.flip) != bool(pf))
|
return self.x+px, self.y+py, self.rotation+pa
|
||||||
|
|
||||||
def bounding_box(self, unit=MM):
|
|
||||||
stack = LayerStack()
|
|
||||||
self.render(stack)
|
|
||||||
objects = chain(*(l.objects for l in stack.graphic_layers.values()),
|
|
||||||
stack.drill_pth.objects, stack.drill_npth.objects)
|
|
||||||
objects = list(objects)
|
|
||||||
#print('foo', type(self).__name__,
|
|
||||||
# [(type(obj).__name__, [prim.bounding_box() for prim in obj.to_primitives(unit)]) for obj in objects], file=sys.stderr)
|
|
||||||
return sum_bounds(prim.bounding_box() for obj in objects for prim in obj.to_primitives(unit))
|
|
||||||
|
|
||||||
def overlaps(self, bbox, unit=MM):
|
|
||||||
return bbox_intersect(self.bounding_box(unit), bbox)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def single_sided(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# The dataclass API is slightly idiotic here, so we have to duplicate the entire thing.
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class FrozenPositioned:
|
|
||||||
x: float
|
|
||||||
y: float
|
|
||||||
_: KW_ONLY
|
|
||||||
rotation: float = 0.0
|
|
||||||
flip: bool = False
|
|
||||||
unit: LengthUnit = MM
|
|
||||||
parent: object = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def abs_pos(self):
|
|
||||||
if self.parent is None:
|
|
||||||
px, py, pa, pf = 0, 0, 0, False
|
|
||||||
else:
|
|
||||||
px, py, pa, pf = self.parent.abs_pos
|
|
||||||
|
|
||||||
return self.x+px, self.y+py, self.rotation+pa, (bool(self.flip) != bool(pf))
|
|
||||||
|
|
||||||
def bounding_box(self, unit=MM):
|
def bounding_box(self, unit=MM):
|
||||||
stack = LayerStack()
|
stack = LayerStack()
|
||||||
|
|
@ -215,7 +176,7 @@ class FrozenPositioned:
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Graphics(Positioned):
|
class ObjectGroup(Positioned):
|
||||||
top_copper: list = field(default_factory=list)
|
top_copper: list = field(default_factory=list)
|
||||||
top_mask: list = field(default_factory=list)
|
top_mask: list = field(default_factory=list)
|
||||||
top_silk: list = field(default_factory=list)
|
top_silk: list = field(default_factory=list)
|
||||||
|
|
@ -226,10 +187,15 @@ class Graphics(Positioned):
|
||||||
bottom_paste: list = field(default_factory=list)
|
bottom_paste: list = field(default_factory=list)
|
||||||
drill_npth: list = field(default_factory=list)
|
drill_npth: list = field(default_factory=list)
|
||||||
drill_pth: list = field(default_factory=list)
|
drill_pth: list = field(default_factory=list)
|
||||||
|
objects: list = field(default_factory=list)
|
||||||
|
|
||||||
def render(self, layer_stack, cache=None):
|
def render(self, layer_stack):
|
||||||
x, y, rotation, flip = self.abs_pos
|
x, y, rotation = self.abs_pos
|
||||||
top, bottom = ('bottom', 'top') if flip else ('top', 'bottom')
|
top, bottom = ('bottom', 'top') if self.side == 'bottom' else ('top', 'bottom')
|
||||||
|
|
||||||
|
for obj in self.objects:
|
||||||
|
obj.parent = self
|
||||||
|
obj.render(layer_stack)
|
||||||
|
|
||||||
for target, source in [
|
for target, source in [
|
||||||
(layer_stack[top, 'copper'], self.top_copper),
|
(layer_stack[top, 'copper'], self.top_copper),
|
||||||
|
|
@ -249,23 +215,6 @@ class Graphics(Positioned):
|
||||||
fe.offset(x, y, self.unit)
|
fe.offset(x, y, self.unit)
|
||||||
target.objects.append(fe)
|
target.objects.append(fe)
|
||||||
|
|
||||||
def bounding_box(self, unit=MM):
|
|
||||||
if math.isclose(self.rotation, 0, abs_tol=1e-3):
|
|
||||||
return offset_bounds(sum_bounds((obj.bounding_box(unit=unit) for obj in chain(
|
|
||||||
self.top_copper,
|
|
||||||
self.top_mask,
|
|
||||||
self.top_silk,
|
|
||||||
self.top_paste,
|
|
||||||
self.bottom_copper,
|
|
||||||
self.bottom_mask,
|
|
||||||
self.bottom_silk,
|
|
||||||
self.bottom_paste,
|
|
||||||
self.drill_npth,
|
|
||||||
self.drill_pth,
|
|
||||||
))), unit(self.x, self.unit), unit(self.y, self.unit))
|
|
||||||
else:
|
|
||||||
return super().bounding_box(unit)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def single_sided(self):
|
def single_sided(self):
|
||||||
any_top = self.top_copper or self.top_mask or self.top_paste or self.top_silk
|
any_top = self.top_copper or self.top_mask or self.top_paste or self.top_silk
|
||||||
|
|
@ -274,30 +223,6 @@ class Graphics(Positioned):
|
||||||
return not (any_drill or (any_top and any_bottom))
|
return not (any_drill or (any_top and any_bottom))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ObjectGroup(Positioned):
|
|
||||||
objects: list = field(default_factory=list)
|
|
||||||
|
|
||||||
def render(self, layer_stack, cache=None):
|
|
||||||
for obj in self.objects:
|
|
||||||
if not isinstance(obj, Positioned):
|
|
||||||
raise ValueError(f'ObjectGroup members must be children of Positioned, not {type(obj)}')
|
|
||||||
|
|
||||||
obj.parent = self
|
|
||||||
obj.render(layer_stack, cache=cache)
|
|
||||||
|
|
||||||
def bounding_box(self, unit=MM):
|
|
||||||
if math.isclose(self.rotation, 0, abs_tol=1e-3):
|
|
||||||
return offset_bounds(sum_bounds((obj.bounding_box(unit=unit) for obj in self.objects)),
|
|
||||||
unit(self.x, self.unit), unit(self.y, self.unit))
|
|
||||||
else:
|
|
||||||
return super().bounding_box(unit)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def single_sided(self):
|
|
||||||
return all(obj.single_sided for obj in self.objects)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Text(Positioned):
|
class Text(Positioned):
|
||||||
text: str
|
text: str
|
||||||
|
|
@ -308,8 +233,8 @@ class Text(Positioned):
|
||||||
layer: str = 'silk'
|
layer: str = 'silk'
|
||||||
polarity_dark: bool = True
|
polarity_dark: bool = True
|
||||||
|
|
||||||
def render(self, layer_stack, cache=None):
|
def render(self, layer_stack):
|
||||||
obj_x, obj_y, rotation, flip = self.abs_pos
|
obj_x, obj_y, rotation = self.abs_pos
|
||||||
global newstroke_font
|
global newstroke_font
|
||||||
|
|
||||||
if newstroke_font is None:
|
if newstroke_font is None:
|
||||||
|
|
@ -322,7 +247,6 @@ class Text(Positioned):
|
||||||
xs = [x for points in strokes for x, _y in points]
|
xs = [x for points in strokes for x, _y in points]
|
||||||
ys = [y for points in strokes for _x, y in points]
|
ys = [y for points in strokes for _x, y in points]
|
||||||
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
|
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
|
||||||
h = self.font_size + self.stroke_width # (max_y - min_y)
|
|
||||||
|
|
||||||
if self.h_align == 'left':
|
if self.h_align == 'left':
|
||||||
x0 = 0
|
x0 = 0
|
||||||
|
|
@ -333,16 +257,16 @@ class Text(Positioned):
|
||||||
else:
|
else:
|
||||||
raise ValueError('h_align must be one of "left", "center", or "right".')
|
raise ValueError('h_align must be one of "left", "center", or "right".')
|
||||||
|
|
||||||
if self.v_align == 'bottom':
|
if self.v_align == 'top':
|
||||||
y0 = h
|
y0 = -(max_y - min_y)
|
||||||
elif self.v_align == 'middle':
|
elif self.v_align == 'middle':
|
||||||
y0 = h/2
|
y0 = -(max_y - min_y)/2
|
||||||
elif self.v_align == 'top':
|
elif self.v_align == 'bottom':
|
||||||
y0 = 0
|
y0 = 0
|
||||||
else:
|
else:
|
||||||
raise ValueError('v_align must be one of "top", "middle", or "bottom".')
|
raise ValueError('v_align must be one of "top", "middle", or "bottom".')
|
||||||
|
|
||||||
if self.flip:
|
if self.side == 'bottom':
|
||||||
x0 += min_x + max_x
|
x0 += min_x + max_x
|
||||||
x_sign = -1
|
x_sign = -1
|
||||||
else:
|
else:
|
||||||
|
|
@ -352,225 +276,162 @@ class Text(Positioned):
|
||||||
|
|
||||||
for stroke in strokes:
|
for stroke in strokes:
|
||||||
for (x1, y1), (x2, y2) in zip(stroke[:-1], stroke[1:]):
|
for (x1, y1), (x2, y2) in zip(stroke[:-1], stroke[1:]):
|
||||||
obj = Line(x0+x_sign*x1, y0+y1, x0+x_sign*x2, y0+y2, aperture=ap, unit=self.unit, polarity_dark=self.polarity_dark)
|
obj = Line(x0+x_sign*x1, y0-y1, x0+x_sign*x2, y0-y2, aperture=ap, unit=self.unit, polarity_dark=self.polarity_dark)
|
||||||
obj.rotate(rotation)
|
obj.rotate(rotation)
|
||||||
obj.offset(obj_x, obj_y)
|
obj.offset(obj_x, obj_y)
|
||||||
layer_stack['bottom' if flip else 'top', self.layer].objects.append(obj)
|
layer_stack[self.side, self.layer].objects.append(obj)
|
||||||
|
|
||||||
def bounding_box(self, unit=MM):
|
|
||||||
approx_w = len(self.text)*self.font_size*0.75 + self.stroke_width
|
|
||||||
approx_h = self.font_size + self.stroke_width
|
|
||||||
|
|
||||||
if self.h_align == 'left':
|
|
||||||
x0 = 0
|
|
||||||
elif self.h_align == 'center':
|
|
||||||
x0 = -approx_w/2
|
|
||||||
elif self.h_align == 'right':
|
|
||||||
x0 = -approx_w
|
|
||||||
|
|
||||||
if self.v_align == 'top':
|
|
||||||
y0 = 0
|
|
||||||
elif self.v_align == 'middle':
|
|
||||||
y0 = -approx_h/2
|
|
||||||
elif self.v_align == 'bottom':
|
|
||||||
y0 = -approx_h
|
|
||||||
|
|
||||||
return (self.x+x0, self.y+y0), (self.x+x0+approx_w, self.y+y0+approx_h)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class PadStackAperture:
|
|
||||||
aperture: Aperture
|
|
||||||
side: str
|
|
||||||
layer: str
|
|
||||||
offset_x: float = 0 # in PadStack units
|
|
||||||
offset_y: float = 0
|
|
||||||
rotation: float = 0
|
|
||||||
invert: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class PadStack:
|
|
||||||
_: KW_ONLY
|
|
||||||
unit: LengthUnit = MM
|
|
||||||
|
|
||||||
@property
|
|
||||||
def apertures(self):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def flashes(self, x, y, rotation: float = 0, flip: bool = False):
|
|
||||||
for ap in self.apertures:
|
|
||||||
aperture = ap.aperture.rotated(ap.rotation + rotation)
|
|
||||||
fl = Flash(ap.offset_x, ap.offset_y, aperture, polarity_dark=not ap.invert, unit=self.unit)
|
|
||||||
fl.rotate(rotation)
|
|
||||||
fl.offset(x, y)
|
|
||||||
side = ap.side
|
|
||||||
if flip:
|
|
||||||
side = {'top': 'bottom', 'bottom': 'top'}.get(side, side)
|
|
||||||
yield side, ap.layer, fl
|
|
||||||
|
|
||||||
def render(self, layer_stack, x, y, rotation: float = 0, flip: bool = False):
|
|
||||||
for side, layer, flash in self.flashes(x, y, rotation, flip):
|
|
||||||
if side == 'drill' and layer == 'plated':
|
|
||||||
layer_stack.drill_pth.objects.append(flash)
|
|
||||||
|
|
||||||
elif side == 'drill' and layer == 'nonplated':
|
|
||||||
layer_stack.drill_npth.objects.append(flash)
|
|
||||||
|
|
||||||
elif (side, layer) in layer_stack:
|
|
||||||
layer_stack[side, layer].objects.append(flash)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def single_sided(self):
|
|
||||||
return len({ap.side for ap in self.apertures}) <= 1
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class SMDStack(PadStack):
|
|
||||||
aperture: Aperture
|
|
||||||
mask_expansion: float = 0.0
|
|
||||||
paste_expansion: float = 0.0
|
|
||||||
paste: bool = True
|
|
||||||
flip: bool = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def side(self):
|
|
||||||
return 'bottom' if self.flip else 'top'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def apertures(self):
|
|
||||||
yield PadStackAperture(self.aperture, self.side, 'copper')
|
|
||||||
yield PadStackAperture(self.aperture.dilated(self.mask_expansion, self.unit), self.side, 'mask')
|
|
||||||
if self.paste:
|
|
||||||
yield PadStackAperture(self.aperture.dilated(self.paste_expansion, self.unit), self.side, 'paste')
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def rect(kls, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, flip=False, unit=MM):
|
|
||||||
ap = RectangleAperture(w, h, unit=unit).rotated(rotation)
|
|
||||||
return kls(ap, mask_expansion, paste_expansion, paste, flip, unit=unit)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def circle(kls, dia, mask_expansion=0.0, paste_expansion=0.0, paste=True, flip=False, unit=MM):
|
|
||||||
return kls(CircleAperture(dia, unit=unit), mask_expansion, paste_expansion, paste, flip, unit=unit)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class MechanicalHoleStack(PadStack):
|
|
||||||
drill_dia: float
|
|
||||||
mask_expansion: float = 0.0
|
|
||||||
mask_aperture = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def apertures(self):
|
|
||||||
mask_aperture = self.mask_aperture or CircleAperture(self.drill_dia + self.mask_expansion, unit=self.unit)
|
|
||||||
yield PadStackAperture(mask_aperture, 'top', 'mask')
|
|
||||||
yield PadStackAperture(mask_aperture, 'bottom', 'mask')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def single_sided(self):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class THTPad(PadStack):
|
|
||||||
drill_dia: float
|
|
||||||
pad_top: SMDStack
|
|
||||||
pad_bottom: SMDStack = None
|
|
||||||
aperture_inner: Aperture = UNDEFINED
|
|
||||||
plated: bool = True
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.pad_bottom is None:
|
|
||||||
object.__setattr__(self, 'pad_bottom', replace(self.pad_top, flip=True))
|
|
||||||
|
|
||||||
if self.aperture_inner is UNDEFINED:
|
|
||||||
object.__setattr__(self, 'aperture_inner', self.pad_top.aperture)
|
|
||||||
|
|
||||||
if self.pad_top.flip:
|
|
||||||
raise ValueError('top pad cannot be flipped')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def plating(self):
|
|
||||||
return 'plated' if self.plated else 'nonplated'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def apertures(self):
|
|
||||||
yield from self.pad_top.apertures
|
|
||||||
yield from self.pad_bottom.apertures
|
|
||||||
if self.aperture_inner is not None:
|
|
||||||
yield PadStackAperture(self.aperture_inner, 'inner', 'copper')
|
|
||||||
yield PadStackAperture(ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), 'drill', self.plating)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def single_sided(self):
|
|
||||||
return False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def rect(kls, drill_dia, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
|
|
||||||
pad = SMDStack.rect(w, h, rotation, mask_expansion, paste_expansion, paste, unit=unit)
|
|
||||||
return kls(drill_dia, pad, plated=plated)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def circle(kls, drill_dia, dia, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
|
|
||||||
pad = SMDStack.circle(dia, mask_expansion, paste_expansion, paste, unit=unit)
|
|
||||||
return kls(drill_dia, pad, plated=plated)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def obround(kls, drill_dia, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
|
|
||||||
ap = ObroundAperture(w, h, unit=unit).rotated(rotation)
|
|
||||||
pad = SMDStack(ap, mask_expansion, paste_expansion, paste, unit=unit)
|
|
||||||
return kls(drill_dia, pad, plated=plated)
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ThroughViaStack(PadStack):
|
|
||||||
hole: float
|
|
||||||
dia: float = None
|
|
||||||
tented: bool = True
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.dia == None:
|
|
||||||
object.__setattr__(self, 'dia', self.hole*2)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def single_sided(self):
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def apertures(self):
|
|
||||||
copper_aperture = CircleAperture(self.dia, unit=self.unit)
|
|
||||||
yield PadStackAperture(copper_aperture, 'top', 'copper')
|
|
||||||
yield PadStackAperture(copper_aperture, 'bottom', 'copper')
|
|
||||||
yield PadStackAperture(copper_aperture, 'inner', 'copper')
|
|
||||||
if self.tented:
|
|
||||||
yield PadStackAperture(copper_aperture, 'top', 'mask')
|
|
||||||
yield PadStackAperture(copper_aperture, 'bottom', 'mask')
|
|
||||||
yield PadStackAperture(ExcellonTool(self.hole, plated=True, unit=self.unit), 'drill', 'plated')
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Via(FrozenPositioned):
|
|
||||||
pad_stack: PadStack
|
|
||||||
|
|
||||||
def render(self, layer_stack, cache=None):
|
|
||||||
x, y, rotation, flip = self.abs_pos
|
|
||||||
self.pad_stack.render(layer_stack, x, y, rotation, flip)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def at(kls, x, y, hole, dia=None, tented=True, unit=MM):
|
|
||||||
return kls(x, y, ThroughViaStack(hole, dia, tented, unit=unit), unit=unit)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Pad(Positioned):
|
class Pad(Positioned):
|
||||||
pad_stack: PadStack
|
pass
|
||||||
|
|
||||||
def render(self, layer_stack, cache=None):
|
|
||||||
x, y, rotation, flip = self.abs_pos
|
@dataclass
|
||||||
self.pad_stack.render(layer_stack, x, y, rotation, flip)
|
class SMDPad(Pad):
|
||||||
|
copper_aperture: Aperture
|
||||||
|
mask_aperture: Aperture
|
||||||
|
paste_aperture: Aperture
|
||||||
|
silk_features: list = field(default_factory=list)
|
||||||
|
|
||||||
|
def render(self, layer_stack):
|
||||||
|
x, y, rotation = self.abs_pos
|
||||||
|
layer_stack[self.side, 'copper'].objects.append(Flash(x, y, self.copper_aperture.rotated(rotation), unit=self.unit))
|
||||||
|
layer_stack[self.side, 'mask' ].objects.append(Flash(x, y, self.mask_aperture.rotated(rotation), unit=self.unit))
|
||||||
|
if self.paste_aperture:
|
||||||
|
layer_stack[self.side, 'paste' ].objects.append(Flash(x, y, self.paste_aperture.rotated(rotation), unit=self.unit))
|
||||||
|
layer_stack[self.side, 'silk' ].objects.extend([copy(feature).rotate(rotation).offset(x, y, self.unit)
|
||||||
|
for feature in self.silk_features])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def rect(kls, x, y, w, h, rotation=0, side='top', mask_expansion=0.0, paste_expansion=0.0, paste=True, unit=MM):
|
||||||
|
ap_c = RectangleAperture(w, h, unit=unit)
|
||||||
|
ap_m = RectangleAperture(w+2*mask_expansion, h+2*mask_expansion, unit=unit)
|
||||||
|
ap_p = RectangleAperture(w+2*paste_expansion, h+2*paste_expansion, unit=unit) if paste else None
|
||||||
|
return kls(x, y, side=side, copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, rotation=rotation,
|
||||||
|
unit=unit)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def circle(kls, x, y, dia, side='top', mask_expansion=0.0, paste_expansion=0.0, paste=True, unit=MM):
|
||||||
|
ap_c = CircleAperture(dia, unit=unit)
|
||||||
|
ap_m = CircleAperture(dia+2*mask_expansion, unit=unit)
|
||||||
|
ap_p = CircleAperture(dia+2*paste_expansion, unit=unit) if paste else None
|
||||||
|
return kls(x, y, side=side, copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, unit=unit)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class THTPad(Pad):
|
||||||
|
drill_dia: float
|
||||||
|
pad_top: SMDPad
|
||||||
|
pad_bottom: SMDPad = None
|
||||||
|
aperture_inner: Aperture = None
|
||||||
|
plated: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.pad_bottom is None:
|
||||||
|
import sys
|
||||||
|
self.pad_bottom = copy(self.pad_top)
|
||||||
|
self.pad_bottom.flip()
|
||||||
|
|
||||||
|
self.pad_top.parent = self.pad_bottom.parent = self
|
||||||
|
|
||||||
|
if (self.pad_top.side, self.pad_bottom.side) != ('top', 'bottom'):
|
||||||
|
raise ValueError(f'The top and bottom pads must have side set to top and bottom, respectively. Currently, the top pad side is set to "{self.pad_top.side}" and the bottom pad side to "{self.pad_bottom.side}".')
|
||||||
|
|
||||||
|
def render(self, layer_stack):
|
||||||
|
x, y, rotation = self.abs_pos
|
||||||
|
self.pad_top.parent = self
|
||||||
|
self.pad_top.render(layer_stack)
|
||||||
|
if self.pad_bottom:
|
||||||
|
self.pad_bottom.parent = self
|
||||||
|
self.pad_bottom.render(layer_stack)
|
||||||
|
|
||||||
|
if self.aperture_inner is None:
|
||||||
|
(x_min, y_min), (x_max, y_max) = self.pad_top.bounding_box(MM)
|
||||||
|
w_top = x_max - x_min
|
||||||
|
h_top = y_max - y_min
|
||||||
|
if self.pad_bottom:
|
||||||
|
(x_min, y_min), (x_max, y_max) = self.pad_bottom.bounding_box(MM)
|
||||||
|
w_bottom = x_max - x_min
|
||||||
|
h_bottom = y_max - y_min
|
||||||
|
w_top = min(w_top, w_bottom)
|
||||||
|
h_top = min(h_top, h_bottom)
|
||||||
|
self.aperture_inner = CircleAperture(min(w_top, h_top), unit=MM)
|
||||||
|
|
||||||
|
for (side, use), layer in layer_stack.inner_layers:
|
||||||
|
layer.objects.append(Flash(x, y, self.aperture_inner.rotated(rotation), unit=self.unit))
|
||||||
|
|
||||||
|
hole = Flash(x, y, ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), unit=self.unit)
|
||||||
|
if self.plated:
|
||||||
|
layer_stack.drill_pth.objects.append(hole)
|
||||||
|
else:
|
||||||
|
layer_stack.drill_npth.objects.append(hole)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def single_sided(self):
|
def single_sided(self):
|
||||||
return self.pad_stack.single_sided
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def rect(kls, x, y, hole_dia, w, h=None, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
|
||||||
|
if h is None:
|
||||||
|
h = w
|
||||||
|
pad = SMDPad.rect(0, 0, w, h, mask_expansion=mask_expansion, paste_expansion=paste_expansion, paste=paste, unit=unit)
|
||||||
|
return kls(x, y, hole_dia, pad, rotation=rotation, plated=plated, unit=unit)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def circle(kls, x, y, hole_dia, dia, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
|
||||||
|
pad = SMDPad.circle(0, 0, dia, mask_expansion=mask_expansion, paste_expansion=paste_expansion, paste=paste, unit=unit)
|
||||||
|
return kls(x, y, hole_dia, pad, plated=plated, unit=unit)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def obround(kls, x, y, hole_dia, w, h, rotation=0, mask_expansion=0.0, paste_expanson=0.0, paste=True, plated=True, unit=MM):
|
||||||
|
ap_c = CircleAperture(dia, unit=unit)
|
||||||
|
ap_m = CircleAperture(dia+2*mask_expansion, unit=unit)
|
||||||
|
ap_p = CircleAperture(dia+2*paste_expansion, unit=unit) if paste else None
|
||||||
|
pad = SMDPad(0, 0, side='top', copper_aperture=ap_c, mask_aperture=ap_m, paste_aperture=ap_p, unit=unit)
|
||||||
|
return kls(x, y, hole_dia, pad, rotation=rotation, plated=plated, unit=unit)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Hole(Positioned):
|
||||||
|
diameter: float
|
||||||
|
mask_copper_margin: float = 0.2
|
||||||
|
|
||||||
|
def render(self, layer_stack):
|
||||||
|
x, y, rotation = self.abs_pos
|
||||||
|
|
||||||
|
hole = Flash(x, y, ExcellonTool(self.diameter, plated=False, unit=self.unit), unit=self.unit)
|
||||||
|
layer_stack.drill_npth.objects.append(hole)
|
||||||
|
|
||||||
|
if self.mask_copper_margin > 0:
|
||||||
|
mask = Flash(x, y, CircleAperture(self.mask_copper_margin, unit=self.unit), polarity_dark=False, unit=self.unit)
|
||||||
|
layer_stack['top', 'copper'].objects.append(mask)
|
||||||
|
layer_stack['bottom', 'copper'].objects.append(mask)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def single_sided(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Via(Positioned):
|
||||||
|
diameter: float
|
||||||
|
hole: float
|
||||||
|
|
||||||
|
def render(self, layer_stack):
|
||||||
|
x, y, rotation = self.abs_pos
|
||||||
|
|
||||||
|
aperture = CircleAperture(diameter=self.diameter, unit=self.unit)
|
||||||
|
tool = ExcellonTool(diameter=self.hole, unit=self.unit)
|
||||||
|
|
||||||
|
for (side, use), layer in layer_stack.copper_layers:
|
||||||
|
layer.objects.append(Flash(x, y, aperture, unit=self.unit))
|
||||||
|
|
||||||
|
layer_stack.drill_pth.objects.append(Flash(x, y, tool, unit=self.unit))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def single_sided(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -580,9 +441,8 @@ class Trace:
|
||||||
end: object = None
|
end: object = None
|
||||||
waypoints: [(float, float)] = field(default_factory=list)
|
waypoints: [(float, float)] = field(default_factory=list)
|
||||||
style: str = 'oblique'
|
style: str = 'oblique'
|
||||||
orientation: [str] = tuple() # 'cw' or 'ccw'
|
orientation: [str] = tuple() # 'top' or 'bottom'
|
||||||
roundover: float = 0
|
roundover: float = 0
|
||||||
side: str = 'top'
|
|
||||||
unit: LengthUnit = MM
|
unit: LengthUnit = MM
|
||||||
parent: object = None
|
parent: object = None
|
||||||
|
|
||||||
|
|
@ -730,13 +590,13 @@ class Trace:
|
||||||
|
|
||||||
yield Line(line_b.x1, line_b.y1, x3, y3, aperture=aperture, unit=self.unit)
|
yield Line(line_b.x1, line_b.y1, x3, y3, aperture=aperture, unit=self.unit)
|
||||||
|
|
||||||
def to_graphic_objects(self):
|
def _to_graphic_objects(self):
|
||||||
start, end = self.start, self.end
|
start, end = self.start, self.end
|
||||||
|
|
||||||
if not isinstance(start, tuple):
|
if not isinstance(start, tuple):
|
||||||
*start, _rotation, _flip = start.abs_pos
|
*start, _rotation = start.abs_pos
|
||||||
if not isinstance(end, tuple):
|
if not isinstance(end, tuple):
|
||||||
*end, _rotation, _flip = end.abs_pos
|
*end, _rotation = end.abs_pos
|
||||||
|
|
||||||
aperture = CircleAperture(diameter=self.width, unit=self.unit)
|
aperture = CircleAperture(diameter=self.width, unit=self.unit)
|
||||||
|
|
||||||
|
|
@ -749,8 +609,8 @@ class Trace:
|
||||||
|
|
||||||
return self._round_over(points, aperture)
|
return self._round_over(points, aperture)
|
||||||
|
|
||||||
def render(self, layer_stack, cache=None):
|
def render(self, layer_stack):
|
||||||
layer_stack[self.side, 'copper'].objects.extend(self.to_graphic_objects())
|
layer_stack[self.side, 'copper'].objects.extend(self._to_graphic_objects())
|
||||||
|
|
||||||
def _route_demo():
|
def _route_demo():
|
||||||
from ..utils import setup_svg, Tag
|
from ..utils import setup_svg, Tag
|
||||||
525
gerbonara/cad/protoboard.py
Normal file
|
|
@ -0,0 +1,525 @@
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import math
|
||||||
|
import string
|
||||||
|
import itertools
|
||||||
|
from copy import copy, deepcopy
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from .primitives import *
|
||||||
|
from ..graphic_objects import Region
|
||||||
|
from ..apertures import RectangleAperture, CircleAperture
|
||||||
|
|
||||||
|
|
||||||
|
class ProtoBoard(Board):
|
||||||
|
def __init__(self, w, h, content, margin=None, corner_radius=None, mounting_hole_dia=None, mounting_hole_offset=None, unit=MM):
|
||||||
|
corner_radius = corner_radius or unit(1.5, MM)
|
||||||
|
super().__init__(w, h, corner_radius, unit=unit)
|
||||||
|
self.margin = margin or unit(2, MM)
|
||||||
|
self.content = content
|
||||||
|
|
||||||
|
if mounting_hole_dia:
|
||||||
|
mounting_hole_offset = mounting_hole_offset or mounting_hole_dia*2
|
||||||
|
ko = mounting_hole_offset*2
|
||||||
|
|
||||||
|
self.add(Hole(mounting_hole_offset, mounting_hole_offset, mounting_hole_dia, unit=unit))
|
||||||
|
self.add(Hole(w-mounting_hole_offset, mounting_hole_offset, mounting_hole_dia, unit=unit))
|
||||||
|
self.add(Hole(mounting_hole_offset, h-mounting_hole_offset, mounting_hole_dia, unit=unit))
|
||||||
|
self.add(Hole(w-mounting_hole_offset, h-mounting_hole_offset, mounting_hole_dia, unit=unit))
|
||||||
|
|
||||||
|
self.keepouts.append(((0, 0), (ko, ko)))
|
||||||
|
self.keepouts.append(((w-ko, 0), (w, ko)))
|
||||||
|
self.keepouts.append(((0, h-ko), (ko, h)))
|
||||||
|
self.keepouts.append(((w-ko, h-ko), (w, h)))
|
||||||
|
|
||||||
|
self.generate()
|
||||||
|
|
||||||
|
def generate(self, unit=MM):
|
||||||
|
bbox = ((self.margin, self.margin), (self.w-self.margin, self.h-self.margin))
|
||||||
|
bbox = unit.convert_bounds_from(self.unit, bbox)
|
||||||
|
for obj in self.content.generate(bbox, (True, True, True, True), unit):
|
||||||
|
self.add(obj, keepout_errors='skip')
|
||||||
|
|
||||||
|
|
||||||
|
class PropLayout:
|
||||||
|
def __init__(self, content, direction, proportions):
|
||||||
|
self.content = list(content)
|
||||||
|
if direction not in ('h', 'v'):
|
||||||
|
raise ValueError('direction must be one of "h", or "v".')
|
||||||
|
self.direction = direction
|
||||||
|
self.proportions = list(proportions)
|
||||||
|
if len(content) != len(proportions):
|
||||||
|
raise ValueError('proportions and content must have same length')
|
||||||
|
|
||||||
|
def generate(self, bbox, border_text, unit=MM):
|
||||||
|
for i, (bbox, child) in enumerate(self.layout_2d(bbox, unit)):
|
||||||
|
first = bool(i == 0)
|
||||||
|
last = bool(i == len(self.content)-1)
|
||||||
|
yield from child.generate(bbox, (
|
||||||
|
border_text[0] and (last or self.direction == 'h'),
|
||||||
|
border_text[1] and (last or self.direction == 'v'),
|
||||||
|
border_text[2] and (first or self.direction == 'h'),
|
||||||
|
border_text[3] and (first or self.direction == 'v'),
|
||||||
|
), unit)
|
||||||
|
|
||||||
|
def fit_size(self, w, h, unit=MM):
|
||||||
|
widths = []
|
||||||
|
heights = []
|
||||||
|
for ((x_min, y_min), (x_max, y_max)), child in self.layout_2d(((0, 0), (w, h)), unit):
|
||||||
|
if not isinstance(child, EmptyProtoArea):
|
||||||
|
widths.append(x_max - x_min)
|
||||||
|
heights.append(y_max - y_min)
|
||||||
|
if self.direction == 'h':
|
||||||
|
return sum(widths), max(heights)
|
||||||
|
else:
|
||||||
|
return max(widths), sum(heights)
|
||||||
|
|
||||||
|
def layout_2d(self, bbox, unit=MM):
|
||||||
|
(x, y), (w, h) = bbox
|
||||||
|
w, h = w-x, h-y
|
||||||
|
|
||||||
|
actual_l = 0
|
||||||
|
target_l = 0
|
||||||
|
|
||||||
|
for l, child in zip(self.layout(w if self.direction == 'h' else h, unit), self.content):
|
||||||
|
this_x, this_y = x, y
|
||||||
|
this_w, this_h = w, h
|
||||||
|
target_l += l
|
||||||
|
|
||||||
|
if self.direction == 'h':
|
||||||
|
this_w = target_l - actual_l
|
||||||
|
else:
|
||||||
|
this_h = target_l - actual_l
|
||||||
|
|
||||||
|
this_w, this_h = child.fit_size(this_w, this_h, unit)
|
||||||
|
|
||||||
|
if self.direction == 'h':
|
||||||
|
x += this_w
|
||||||
|
actual_l += this_w
|
||||||
|
this_h = h
|
||||||
|
else:
|
||||||
|
y += this_h
|
||||||
|
actual_l += this_h
|
||||||
|
this_w = w
|
||||||
|
|
||||||
|
yield ((this_x, this_y), (this_x+this_w, this_y+this_h)), child
|
||||||
|
|
||||||
|
def layout(self, length, unit=MM):
|
||||||
|
out = [ eval_value(value, MM(length, unit)) for value in self.proportions ]
|
||||||
|
total_length = sum(value for value in out if value is not None)
|
||||||
|
if length - total_length < -1e-6:
|
||||||
|
raise ValueError(f'Proportions sum to {total_length} mm, which is greater than the available space of {length} mm.')
|
||||||
|
|
||||||
|
leftover = length - total_length
|
||||||
|
sum_props = sum( (value or 1.0) for value in self.proportions if not isinstance(value, str) )
|
||||||
|
return [ unit(leftover * (value or 1.0) / sum_props if not isinstance(value, str) else calculated, MM)
|
||||||
|
for value, calculated in zip(self.proportions, out) ]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def single_sided(self):
|
||||||
|
return all(elem.single_sided for elem in self.content)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
children = ', '.join( f'{elem}:{width}' for elem, width in zip(self.content, self.proportions))
|
||||||
|
return f'PropLayout[{self.direction.upper()}]({children})'
|
||||||
|
|
||||||
|
|
||||||
|
class TwoSideLayout:
|
||||||
|
def __init__(self, top, bottom):
|
||||||
|
self.top, self.bottom = top, bottom
|
||||||
|
|
||||||
|
if not top.single_sided or not bottom.single_sided:
|
||||||
|
warnings.warn('Two-sided pattern used on one side of a TwoSideLayout')
|
||||||
|
|
||||||
|
def fit_size(self, w, h, unit=MM):
|
||||||
|
w1, h1 = self.top.fit_size(w, h, unit)
|
||||||
|
w2, h2 = self.bottom.fit_size(w, h, unit)
|
||||||
|
if isinstance(self.top, EmptyProtoArea):
|
||||||
|
if isinstance(self.bottom, EmptyProtoArea):
|
||||||
|
return w1, h1
|
||||||
|
return w2, h2
|
||||||
|
if isinstance(self.bottom, EmptyProtoArea):
|
||||||
|
return w1, h1
|
||||||
|
return max(w1, w2), max(h1, h2)
|
||||||
|
|
||||||
|
def generate(self, bbox, border_text, unit=MM):
|
||||||
|
yield from self.top.generate(bbox, border_text, unit)
|
||||||
|
for obj in self.bottom.generate(bbox, border_text, unit):
|
||||||
|
obj.side = 'bottom'
|
||||||
|
yield obj
|
||||||
|
|
||||||
|
|
||||||
|
def numeric(start=1):
|
||||||
|
def gen():
|
||||||
|
nonlocal start
|
||||||
|
for i in itertools.count(start):
|
||||||
|
yield str(i)
|
||||||
|
|
||||||
|
return gen
|
||||||
|
|
||||||
|
|
||||||
|
def alphabetic(case='upper'):
|
||||||
|
if case not in ('lower', 'upper'):
|
||||||
|
raise ValueError('case must be one of "lower" or "upper".')
|
||||||
|
|
||||||
|
index = string.ascii_lowercase if case == 'lower' else string.ascii_uppercase
|
||||||
|
|
||||||
|
def gen():
|
||||||
|
nonlocal index
|
||||||
|
|
||||||
|
for i in itertools.count():
|
||||||
|
if i<26:
|
||||||
|
yield index[i]
|
||||||
|
continue
|
||||||
|
|
||||||
|
i -= 26
|
||||||
|
if i<26*26:
|
||||||
|
yield index[i//26] + index[i%26]
|
||||||
|
continue
|
||||||
|
|
||||||
|
i -= 26*26
|
||||||
|
if i<26*26*26:
|
||||||
|
yield index[i//(26*26)] + index[(i//26)%26] + index[i%26]
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError('row/column index out of range')
|
||||||
|
|
||||||
|
return gen
|
||||||
|
|
||||||
|
|
||||||
|
class PatternProtoArea:
|
||||||
|
def __init__(self, pitch_x, pitch_y=None, obj=None, numbers=True, font_size=None, font_stroke=None, number_x_gen=alphabetic(), number_y_gen=numeric(), interval_x=5, interval_y=None, unit=MM):
|
||||||
|
self.pitch_x = pitch_x
|
||||||
|
self.pitch_y = pitch_y or pitch_x
|
||||||
|
self.obj = obj
|
||||||
|
self.unit = unit
|
||||||
|
self.numbers = numbers
|
||||||
|
self.font_size = font_size or unit(1.0, MM)
|
||||||
|
self.font_stroke = font_stroke or unit(0.2, MM)
|
||||||
|
self.interval_x = interval_x
|
||||||
|
self.interval_y = interval_y or (1 if MM(self.pitch_y, unit) >= 2.0 else 5)
|
||||||
|
self.number_x_gen, self.number_y_gen = number_x_gen, number_y_gen
|
||||||
|
|
||||||
|
def fit_size(self, w, h, unit=MM):
|
||||||
|
(min_x, min_y), (max_x, max_y) = self.fit_rect(((0, 0), (w, h)))
|
||||||
|
return max_x-min_x, max_y-min_y
|
||||||
|
|
||||||
|
def fit_rect(self, bbox, unit=MM):
|
||||||
|
(x, y), (w, h) = bbox
|
||||||
|
w, h = w-x, h-y
|
||||||
|
|
||||||
|
w_mod = round((w + 5e-7) % unit(self.pitch_x, self.unit), 6)
|
||||||
|
h_mod = round((h + 5e-7) % unit(self.pitch_y, self.unit), 6)
|
||||||
|
w_fit, h_fit = round(w - w_mod, 6), round(h - h_mod, 6)
|
||||||
|
|
||||||
|
x = x + (w-w_fit)/2
|
||||||
|
y = y + (h-h_fit)/2
|
||||||
|
return (x, y), (x+w_fit, y+h_fit)
|
||||||
|
|
||||||
|
def generate(self, bbox, border_text, unit=MM):
|
||||||
|
(x, y), (w, h) = bbox
|
||||||
|
w, h = w-x, h-y
|
||||||
|
|
||||||
|
n_x = int(w//unit(self.pitch_x, self.unit))
|
||||||
|
n_y = int(h//unit(self.pitch_y, self.unit))
|
||||||
|
off_x = (w % unit(self.pitch_x, self.unit)) / 2
|
||||||
|
off_y = (h % unit(self.pitch_y, self.unit)) / 2
|
||||||
|
|
||||||
|
if self.numbers:
|
||||||
|
for i, lno_i in list(zip(range(n_y), self.number_y_gen())):
|
||||||
|
if i == 0 or i == n_y - 1 or (i+1) % self.interval_y == 0:
|
||||||
|
t_y = off_y + y + (n_y - 1 - i + 0.5) * self.pitch_y
|
||||||
|
|
||||||
|
if border_text[3]:
|
||||||
|
t_x = x + off_x
|
||||||
|
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'right', 'middle', unit=self.unit)
|
||||||
|
if not self.single_sided:
|
||||||
|
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'right', 'middle', side='bottom', unit=self.unit)
|
||||||
|
|
||||||
|
if border_text[1]:
|
||||||
|
t_x = x + w - off_x
|
||||||
|
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'left', 'middle', unit=self.unit)
|
||||||
|
if not self.single_sided:
|
||||||
|
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'left', 'middle', side='bottom', unit=self.unit)
|
||||||
|
|
||||||
|
for i, lno_i in zip(range(n_x), self.number_x_gen()):
|
||||||
|
if i == 0 or i == n_x - 1 or (i+1) % self.interval_x == 0:
|
||||||
|
t_x = off_x + x + (i + 0.5) * self.pitch_x
|
||||||
|
|
||||||
|
if border_text[2]:
|
||||||
|
t_y = y + off_y
|
||||||
|
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'top', unit=self.unit)
|
||||||
|
if not self.single_sided:
|
||||||
|
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'top', side='bottom', unit=self.unit)
|
||||||
|
|
||||||
|
if border_text[0]:
|
||||||
|
t_y = y + h - off_y
|
||||||
|
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'bottom', unit=self.unit)
|
||||||
|
if not self.single_sided:
|
||||||
|
yield Text(t_x, t_y, lno_i, self.font_size, self.font_stroke, 'center', 'bottom', side='bottom', unit=self.unit)
|
||||||
|
|
||||||
|
|
||||||
|
for i in range(n_x):
|
||||||
|
for j in range(n_y):
|
||||||
|
if hasattr(self.obj, 'inst'):
|
||||||
|
inst = self.obj.inst(i, j, i == n_x-1, j == n_y-1)
|
||||||
|
if not inst:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
inst = copy(self.obj)
|
||||||
|
|
||||||
|
inst.x = inst.unit(off_x + x, unit) + (i + 0.5) * inst.unit(self.pitch_x, self.unit)
|
||||||
|
inst.y = inst.unit(off_y + y, unit) + (j + 0.5) * inst.unit(self.pitch_y, self.unit)
|
||||||
|
yield inst
|
||||||
|
|
||||||
|
@property
|
||||||
|
def single_sided(self):
|
||||||
|
return self.obj.single_sided
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyProtoArea:
|
||||||
|
def __init__(self, copper_fill=False):
|
||||||
|
self.copper_fill = copper_fill
|
||||||
|
|
||||||
|
def fit_size(self, w, h, unit=MM):
|
||||||
|
return w, h
|
||||||
|
|
||||||
|
def generate(self, bbox, border_text, unit=MM):
|
||||||
|
if self.copper_fill:
|
||||||
|
(min_x, min_y), (max_x, max_y) = bbox
|
||||||
|
group = ObjectGroup(0, 0, top_copper=[Region([(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)],
|
||||||
|
unit=unit, polarity_dark=True)])
|
||||||
|
group.bounding_box = lambda *args, **kwargs: None
|
||||||
|
yield group
|
||||||
|
|
||||||
|
@property
|
||||||
|
def single_sided(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class ManhattanPads(ObjectGroup):
|
||||||
|
def __init__(self, w, h=None, gap=0.2, unit=MM):
|
||||||
|
super().__init__(0, 0)
|
||||||
|
h = h or w
|
||||||
|
self.gap = gap
|
||||||
|
self.unit = unit
|
||||||
|
|
||||||
|
p = (w-2*gap)/2
|
||||||
|
q = (h-2*gap)/2
|
||||||
|
small_ap = RectangleAperture(p, q, unit=unit)
|
||||||
|
|
||||||
|
s = min(w, h) / 2 / math.sqrt(2)
|
||||||
|
large_ap = RectangleAperture(s, s, rotation=math.pi/4, unit=unit)
|
||||||
|
large_ap_neg = RectangleAperture(s+2*gap, s+2*gap, rotation=math.pi/4, unit=unit)
|
||||||
|
|
||||||
|
a = gap/2 + p/2
|
||||||
|
b = gap/2 + q/2
|
||||||
|
|
||||||
|
self.top_copper.append(Flash(-a, -b, aperture=small_ap, unit=unit))
|
||||||
|
self.top_copper.append(Flash(-a, b, aperture=small_ap, unit=unit))
|
||||||
|
self.top_copper.append(Flash( a, -b, aperture=small_ap, unit=unit))
|
||||||
|
self.top_copper.append(Flash( a, b, aperture=small_ap, unit=unit))
|
||||||
|
self.top_copper.append(Flash(0, 0, aperture=large_ap_neg, polarity_dark=False, unit=unit))
|
||||||
|
self.top_copper.append(Flash(0, 0, aperture=large_ap, unit=unit))
|
||||||
|
self.top_mask = self.top_copper
|
||||||
|
|
||||||
|
|
||||||
|
class RFGroundProto(ObjectGroup):
|
||||||
|
def __init__(self, pitch=None, drill=None, clearance=None, via_dia=None, via_drill=None, pad_dia=None, trace_width=None, unit=MM):
|
||||||
|
super().__init__(0, 0)
|
||||||
|
self.unit = unit
|
||||||
|
self.pitch = pitch = pitch or unit(2.54, MM)
|
||||||
|
self.drill = drill = drill or unit(0.9, MM)
|
||||||
|
self.clearance = clearance = clearance or unit(0.3, MM)
|
||||||
|
self.via_drill = via_drill = via_drill or unit(0.4, MM)
|
||||||
|
self.via_dia = via_dia = via_dia or unit(0.8, MM)
|
||||||
|
|
||||||
|
if pad_dia is None:
|
||||||
|
self.trace_width = trace_width = trace_width or unit(0.3, MM)
|
||||||
|
pad_dia = pitch - trace_width - 2*clearance
|
||||||
|
elif trace_width is None:
|
||||||
|
trace_width = pitch - pad_dia - 2*clearance
|
||||||
|
self.pad_dia = pad_dia
|
||||||
|
|
||||||
|
via_ap = RectangleAperture(via_dia, via_dia, rotation=math.pi/4, unit=unit)
|
||||||
|
pad_ap = CircleAperture(pad_dia, unit=unit)
|
||||||
|
pad_neg_ap = CircleAperture(pad_dia+2*clearance, unit=unit)
|
||||||
|
ground_ap = RectangleAperture(pitch + unit(0.01, MM), pitch + unit(0.01, MM), unit=unit)
|
||||||
|
pad_drill = ExcellonTool(drill, plated=True, unit=unit)
|
||||||
|
via_drill = ExcellonTool(via_drill, plated=True, unit=unit)
|
||||||
|
|
||||||
|
self.top_copper.append(Flash(0, 0, aperture=ground_ap, unit=unit))
|
||||||
|
self.top_copper.append(Flash(0, 0, aperture=pad_neg_ap, polarity_dark=False, unit=unit))
|
||||||
|
self.top_copper.append(Flash(0, 0, aperture=pad_ap, unit=unit))
|
||||||
|
self.top_mask.append(Flash(0, 0, aperture=pad_ap, unit=unit))
|
||||||
|
self.top_copper.append(Flash(pitch/2, pitch/2, aperture=via_ap, unit=unit))
|
||||||
|
self.top_mask.append(Flash(pitch/2, pitch/2, aperture=via_ap, unit=unit))
|
||||||
|
self.drill_pth.append(Flash(0, 0, aperture=pad_drill, unit=unit))
|
||||||
|
self.drill_pth.append(Flash(pitch/2, pitch/2, aperture=via_drill, unit=unit))
|
||||||
|
|
||||||
|
self.bottom_copper = self.top_copper
|
||||||
|
self.bottom_mask = self.top_mask
|
||||||
|
|
||||||
|
def inst(self, x, y, border_x, border_y):
|
||||||
|
inst = copy(self)
|
||||||
|
if border_x or border_y:
|
||||||
|
inst.drill_pth = inst.drill_pth[:-1]
|
||||||
|
inst.top_copper = inst.bottom_copper = inst.top_copper[:-1]
|
||||||
|
inst.top_mask = inst.bottom_mask = inst.top_mask[:-1]
|
||||||
|
return inst
|
||||||
|
|
||||||
|
|
||||||
|
class THTFlowerProto(ObjectGroup):
|
||||||
|
def __init__(self, pitch=None, drill=None, diameter=None, unit=MM):
|
||||||
|
super().__init__(0, 0, unit=unit)
|
||||||
|
self.pitch = pitch = pitch or unit(2.54, MM)
|
||||||
|
drill = drill or unit(0.9, MM)
|
||||||
|
diameter = diameter or unit(2.0, MM)
|
||||||
|
|
||||||
|
p = pitch / 2
|
||||||
|
self.objects.append(THTPad.circle(-p, 0, drill, diameter, paste=False, unit=unit))
|
||||||
|
self.objects.append(THTPad.circle( p, 0, drill, diameter, paste=False, unit=unit))
|
||||||
|
self.objects.append(THTPad.circle(0, -p, drill, diameter, paste=False, unit=unit))
|
||||||
|
self.objects.append(THTPad.circle(0, p, drill, diameter, paste=False, unit=unit))
|
||||||
|
|
||||||
|
middle_ap = CircleAperture(diameter, unit=unit)
|
||||||
|
self.top_copper.append(Flash(0, 0, aperture=middle_ap, unit=unit))
|
||||||
|
self.bottom_copper = self.top_mask = self.bottom_mask = self.top_copper
|
||||||
|
|
||||||
|
def inst(self, x, y, border_x, border_y):
|
||||||
|
if (x % 2 == 0) and (y % 2 == 0):
|
||||||
|
return copy(self)
|
||||||
|
|
||||||
|
if (x % 2 == 1) and (y % 2 == 1):
|
||||||
|
return copy(self)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def bounding_box(self, unit=MM):
|
||||||
|
x, y, rotation = self.abs_pos
|
||||||
|
p = self.pitch/2
|
||||||
|
return unit.convert_bounds_from(self.unit, ((x-p, y-p), (x+p, y+p)))
|
||||||
|
|
||||||
|
class PoweredProto(ObjectGroup):
|
||||||
|
def __init__(self, pitch=None, drill=None, clearance=None, power_pad_dia=None, via_size=None, trace_width=None, unit=MM):
|
||||||
|
super().__init__(0, 0)
|
||||||
|
self.unit = unit
|
||||||
|
self.pitch = pitch = pitch or unit(2.54, MM)
|
||||||
|
self.drill = drill = drill or unit(0.9, MM)
|
||||||
|
self.clearance = clearance = clearance or unit(0.3, MM)
|
||||||
|
self.trace_width = trace_width = trace_width or unit(0.3, MM)
|
||||||
|
self.via_size = via_size = via_size or unit(0.4, MM)
|
||||||
|
|
||||||
|
main_pad_dia = pitch - trace_width - 2*clearance
|
||||||
|
power_pad_dia_max = math.sqrt(2)*pitch - main_pad_dia - 2*clearance
|
||||||
|
if power_pad_dia is None:
|
||||||
|
power_pad_dia = power_pad_dia_max - clearance # reduce some more to give the user more room
|
||||||
|
elif power_pad_dia > power_pad_dia_max:
|
||||||
|
warnings.warn(f'Power pad diameter {power_pad_dia} > {power_pad_dia_max} violates pad-to-pad clearance')
|
||||||
|
self.power_pad_dia = power_pad_dia
|
||||||
|
|
||||||
|
main_ap = CircleAperture(main_pad_dia, unit=unit)
|
||||||
|
power_ap = CircleAperture(self.power_pad_dia, unit=unit)
|
||||||
|
|
||||||
|
for l in [self.top_copper, self.bottom_copper]:
|
||||||
|
l.append(Flash(0, 0, aperture=main_ap, unit=unit))
|
||||||
|
|
||||||
|
l.append(Flash(-pitch/2, -pitch/2, aperture=power_ap, unit=unit))
|
||||||
|
l.append(Flash(-pitch/2, pitch/2, aperture=power_ap, unit=unit))
|
||||||
|
l.append(Flash( pitch/2, -pitch/2, aperture=power_ap, unit=unit))
|
||||||
|
l.append(Flash( pitch/2, pitch/2, aperture=power_ap, unit=unit))
|
||||||
|
|
||||||
|
self.drill_pth.append(Flash(0, 0, ExcellonTool(drill, plated=True, unit=unit), unit=unit))
|
||||||
|
self.drill_pth.append(Flash(-pitch/2, -pitch/2, ExcellonTool(via_size, plated=True, unit=unit), unit=unit))
|
||||||
|
|
||||||
|
self.top_mask = copy(self.top_copper)
|
||||||
|
self.bottom_mask = copy(self.bottom_copper)
|
||||||
|
|
||||||
|
self.line_ap = CircleAperture(trace_width, unit=unit)
|
||||||
|
self.top_copper.append(Line(-pitch/2, -pitch/2, -pitch/2, pitch/2, aperture=self.line_ap, unit=unit))
|
||||||
|
self.top_copper.append(Line(pitch/2, -pitch/2, pitch/2, pitch/2, aperture=self.line_ap, unit=unit))
|
||||||
|
self.bottom_copper.append(Line(-pitch/2, -pitch/2, pitch/2, -pitch/2, aperture=self.line_ap, unit=unit))
|
||||||
|
self.bottom_copper.append(Line(-pitch/2, pitch/2, pitch/2, pitch/2, aperture=self.line_ap, unit=unit))
|
||||||
|
|
||||||
|
def inst(self, x, y, border_x, border_y):
|
||||||
|
inst = copy(self)
|
||||||
|
if (x + y) % 2 == 0:
|
||||||
|
inst.drill_pth = inst.drill_pth[:-1]
|
||||||
|
|
||||||
|
c = self.power_pad_dia/2 + self.clearance
|
||||||
|
p = self.pitch/2
|
||||||
|
|
||||||
|
if x == 1:
|
||||||
|
inst.top_silk = [Line(-p, -p+c, -p, p-c, aperture=self.line_ap, unit=self.unit)]
|
||||||
|
elif x % 2 == 0:
|
||||||
|
inst.top_silk = [Line(p, -p+c, p, p-c, aperture=self.line_ap, unit=self.unit)]
|
||||||
|
|
||||||
|
if y == 0:
|
||||||
|
inst.bottom_silk = [Line(-p+c, -p, p-c, -p, aperture=self.line_ap, unit=self.unit)]
|
||||||
|
elif y % 2 == 1:
|
||||||
|
inst.bottom_silk = [Line(-p+c, p, p-c, p, aperture=self.line_ap, unit=self.unit)]
|
||||||
|
|
||||||
|
return inst
|
||||||
|
|
||||||
|
def bounding_box(self, unit=MM):
|
||||||
|
x, y, rotation = self.abs_pos
|
||||||
|
p = self.pitch/2
|
||||||
|
return unit.convert_bounds_from(self.unit, ((x-p, y-p), (x+p, y+p)))
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_mm(value, unit):
|
||||||
|
unitl = unit.lower()
|
||||||
|
if unitl == 'mm':
|
||||||
|
return value
|
||||||
|
elif unitl == 'cm':
|
||||||
|
return value*10
|
||||||
|
elif unitl == 'in':
|
||||||
|
return value*25.4
|
||||||
|
elif unitl == 'mil':
|
||||||
|
return value/1000*25.4
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Invalid unit {unit}, allowed units are mm, cm, in, and mil.')
|
||||||
|
|
||||||
|
|
||||||
|
_VALUE_RE = re.compile('([0-9]*\.?[0-9]+)(cm|mm|in|mil|%)')
|
||||||
|
def eval_value(value, total_length=None):
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
m = _VALUE_RE.match(value.lower())
|
||||||
|
number, unit = m.groups()
|
||||||
|
if unit == '%':
|
||||||
|
if total_length is None:
|
||||||
|
raise ValueError('Percentages are not allowed for this value')
|
||||||
|
return total_length * float(number) / 100
|
||||||
|
return convert_to_mm(float(number), unit)
|
||||||
|
|
||||||
|
|
||||||
|
def _demo():
|
||||||
|
pattern1 = PatternProtoArea(2.54, obj=THTPad.circle(0, 0, 0.9, 1.8, paste=False))
|
||||||
|
pattern2 = PatternProtoArea(1.2, 2.0, obj=SMDPad.rect(0, 0, 1.0, 1.8, paste=False))
|
||||||
|
pattern3 = PatternProtoArea(2.54, 1.27, obj=SMDPad.rect(0, 0, 2.3, 1.0, paste=False))
|
||||||
|
#pattern3 = EmptyProtoArea(copper_fill=True)
|
||||||
|
#stack = TwoSideLayout(pattern2, pattern3)
|
||||||
|
stack = PropLayout([pattern2, pattern3], 'v', [0.5, 0.5])
|
||||||
|
pattern = PropLayout([pattern1, stack], 'h', [0.5, 0.5])
|
||||||
|
#pattern = PatternProtoArea(2.54, obj=ManhattanPads(2.54))
|
||||||
|
#pattern = PatternProtoArea(2.54, obj=PoweredProto())
|
||||||
|
#pattern = PatternProtoArea(2.54, obj=RFGroundProto())
|
||||||
|
#pattern = PatternProtoArea(2.54*1.5, obj=THTFlowerProto())
|
||||||
|
#pattern = PatternProtoArea(2.54, obj=THTPad.circle(0, 0, 0.9, 1.8, paste=False))
|
||||||
|
#pattern = PatternProtoArea(2.54, obj=PoweredProto())
|
||||||
|
pb = ProtoBoard(100, 80, pattern, mounting_hole_dia=3.2, mounting_hole_offset=5)
|
||||||
|
print(pb.pretty_svg())
|
||||||
|
pb.layer_stack().save_to_directory('/tmp/testdir')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
_demo()
|
||||||
|
#cnt = alphabetic()()
|
||||||
|
#for _ in range(32):
|
||||||
|
# for _ in range(26):
|
||||||
|
# print(f'{next(cnt):>2}', end=' ', file=sys.stderr)
|
||||||
|
# print(file=sys.stderr)
|
||||||
|
|
||||||
|
|
@ -4,11 +4,10 @@ import importlib.resources
|
||||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Quart, request, Response, send_file, abort
|
from quart import Quart, request, Response, send_file
|
||||||
|
|
||||||
from . import protoboard as pb
|
from . import protoboard as pb
|
||||||
from . import protoserve_data
|
from . import protoserve_data
|
||||||
from .primitives import SMDStack
|
|
||||||
from ..utils import MM, Inch
|
from ..utils import MM, Inch
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -26,7 +25,7 @@ def extract_importlib(package):
|
||||||
else:
|
else:
|
||||||
assert item.is_dir()
|
assert item.is_dir()
|
||||||
item_out.mkdir()
|
item_out.mkdir()
|
||||||
stack.append((item, item_out))
|
stack.push((item, item_out))
|
||||||
|
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
@ -63,10 +62,10 @@ def deserialize(obj, unit):
|
||||||
case 'smd':
|
case 'smd':
|
||||||
match obj['pad_shape']:
|
match obj['pad_shape']:
|
||||||
case 'rect':
|
case 'rect':
|
||||||
stack = SMDStack.rect(pitch_x-clearance, pitch_y-clearance, paste=False, unit=unit)
|
pad = pb.SMDPad.rect(0, 0, pitch_x-clearance, pitch_y-clearance, paste=False, unit=unit)
|
||||||
case 'circle':
|
case 'circle':
|
||||||
stack = SMDStack.circle(min(pitch_x, pitch_y)-clearance, paste=False, unit=unit)
|
pad = pb.SMDPad.circle(0, 0, min(pitch_x, pitch_y)-clearance, paste=False, unit=unit)
|
||||||
return pb.PatternProtoArea(pitch_x, pitch_y, obj=stack, unit=unit)
|
return pb.PatternProtoArea(pitch_x, pitch_y, obj=pad, unit=unit)
|
||||||
|
|
||||||
case 'tht':
|
case 'tht':
|
||||||
hole_dia = mil(float(obj['hole_dia']))
|
hole_dia = mil(float(obj['hole_dia']))
|
||||||
|
|
@ -80,11 +79,11 @@ def deserialize(obj, unit):
|
||||||
|
|
||||||
match obj['pad_shape']:
|
match obj['pad_shape']:
|
||||||
case 'rect':
|
case 'rect':
|
||||||
pad = pb.THTPad.rect(hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
|
pad = pb.THTPad.rect(0, 0, hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
|
||||||
case 'circle':
|
case 'circle':
|
||||||
pad = pb.THTPad.circle(hole_dia, min(pitch_x, pitch_y)-clearance, paste=False, plated=plated, unit=unit)
|
pad = pb.THTPad.circle(0, 0, hole_dia, min(pitch_x, pitch_y)-clearance, paste=False, plated=plated, unit=unit)
|
||||||
case 'obround':
|
case 'obround':
|
||||||
pad = pb.THTPad.obround(hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
|
pad = pb.THTPad.obround(0, 0, hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
|
||||||
|
|
||||||
if oneside:
|
if oneside:
|
||||||
pad.pad_bottom = None
|
pad.pad_bottom = None
|
||||||
|
|
@ -98,50 +97,14 @@ def deserialize(obj, unit):
|
||||||
pitch = mil(float(obj.get('pitch', 2.54)))
|
pitch = mil(float(obj.get('pitch', 2.54)))
|
||||||
hole_dia = mil(float(obj['hole_dia']))
|
hole_dia = mil(float(obj['hole_dia']))
|
||||||
via_drill = mil(float(obj['via_hole_dia']))
|
via_drill = mil(float(obj['via_hole_dia']))
|
||||||
via_dia = mil(float(obj['via_dia']))
|
|
||||||
trace_width = mil(float(obj['trace_width']))
|
trace_width = mil(float(obj['trace_width']))
|
||||||
# Force 1mm margin to avoid shorts when adjacent to planes such as that one in the RF THT proto.
|
return pb.PatternProtoArea(pitch, pitch, pb.PoweredProto(pitch, hole_dia, clearance, via_size=via_drill, trace_width=trace_width, unit=unit), unit=unit)
|
||||||
return pb.PatternProtoArea(pitch, pitch, pb.PoweredProto(pitch, hole_dia, clearance, via_size=via_drill, power_pad_dia=via_dia, trace_width=trace_width, unit=unit), margin=unit(1.0, MM), unit=unit)
|
|
||||||
|
|
||||||
case 'flower':
|
case 'flower':
|
||||||
pitch = mil(float(obj.get('pitch', 2.54)))
|
pitch = mil(float(obj.get('pitch', 2.54)))
|
||||||
hole_dia = mil(float(obj['hole_dia']))
|
hole_dia = mil(float(obj['hole_dia']))
|
||||||
pattern_dia = mil(float(obj['pattern_dia']))
|
pattern_dia = mil(float(obj['pattern_dia']))
|
||||||
clearance = mil(float(obj['clearance']))
|
return pb.PatternProtoArea(2*pitch, 2*pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, unit=unit), unit=unit)
|
||||||
return pb.PatternProtoArea(pitch, pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, clearance, unit=unit), unit=unit)
|
|
||||||
|
|
||||||
case 'spiky':
|
|
||||||
return pb.PatternProtoArea(2.54, 2.54, pb.SpikyProto(), unit=unit)
|
|
||||||
|
|
||||||
case 'alio':
|
|
||||||
pitch = mil(float(obj.get('pitch', 2.54)))
|
|
||||||
drill = mil(float(obj.get('hole_dia', 0.9)))
|
|
||||||
clearance = mil(float(obj.get('clearance', 0.3)))
|
|
||||||
link_pad_width = mil(float(obj.get('link_pad_width', 1.1)))
|
|
||||||
link_trace_width = mil(float(obj.get('link_trace_width', 0.5)))
|
|
||||||
via_size = mil(float(obj.get('via_hole_dia', 0.4)))
|
|
||||||
return pb.PatternProtoArea(pitch, pitch, pb.AlioCell(
|
|
||||||
pitch=pitch,
|
|
||||||
drill=drill,
|
|
||||||
clearance=clearance,
|
|
||||||
link_pad_width=link_pad_width,
|
|
||||||
link_trace_width=link_trace_width,
|
|
||||||
via_size=via_size
|
|
||||||
), margin=unit(1.5, MM), unit=unit)
|
|
||||||
|
|
||||||
case 'breadboard':
|
|
||||||
horizontal = obj.get('direction', 'v') == 'h'
|
|
||||||
drill = float(obj.get('hole_dia', 0.9))
|
|
||||||
return pb.BreadboardArea(clearance=clearance, drill=drill, horizontal=horizontal, unit=unit)
|
|
||||||
|
|
||||||
case 'starburst':
|
|
||||||
trace_width_x = float(obj.get('trace_width_x', 1.8))
|
|
||||||
trace_width_y = float(obj.get('trace_width_y', 1.8))
|
|
||||||
drill = float(obj.get('hole_dia', 0.9))
|
|
||||||
annular_ring = float(obj.get('annular', 1.2))
|
|
||||||
clearance = float(obj.get('clearance', 0.4))
|
|
||||||
mask_width = float(obj.get('mask_width', 0.5))
|
|
||||||
return pb.PatternProtoArea(pitch_x, pitch_y, pb.StarburstPad(pitch_x, pitch_y, trace_width_x, trace_width_y, clearance, mask_width, drill, annular_ring, unit=unit), unit=unit)
|
|
||||||
|
|
||||||
case 'rf':
|
case 'rf':
|
||||||
pitch = float(obj.get('pitch', 2.54))
|
pitch = float(obj.get('pitch', 2.54))
|
||||||
|
|
@ -155,16 +118,12 @@ def to_board(obj):
|
||||||
w = float(obj.get('width', unit(100, MM)))
|
w = float(obj.get('width', unit(100, MM)))
|
||||||
h = float(obj.get('height', unit(80, MM)))
|
h = float(obj.get('height', unit(80, MM)))
|
||||||
corner_radius = float(obj.get('round_corners', {}).get('radius', unit(1.5, MM)))
|
corner_radius = float(obj.get('round_corners', {}).get('radius', unit(1.5, MM)))
|
||||||
margin = float(obj.get('margin', unit(2.0, MM)))
|
|
||||||
holes = obj.get('mounting_holes', {})
|
holes = obj.get('mounting_holes', {})
|
||||||
mounting_hole_dia = float(holes.get('diameter', unit(3.2, MM)))
|
mounting_hole_dia = float(holes.get('diameter', unit(3.2, MM)))
|
||||||
mounting_hole_offset = float(holes.get('offset', unit(5, MM)))
|
mounting_hole_offset = float(holes.get('offset', unit(5, MM)))
|
||||||
|
|
||||||
if obj.get('children'):
|
if obj.get('children'):
|
||||||
try:
|
content = deserialize(obj['children'][0], unit)
|
||||||
content = deserialize(obj['children'][0], unit)
|
|
||||||
except ValueError:
|
|
||||||
return abort(400)
|
|
||||||
else:
|
else:
|
||||||
content = [pb.EmptyProtoArea()]
|
content = [pb.EmptyProtoArea()]
|
||||||
|
|
||||||
|
|
@ -172,14 +131,13 @@ def to_board(obj):
|
||||||
corner_radius=corner_radius,
|
corner_radius=corner_radius,
|
||||||
mounting_hole_dia=mounting_hole_dia,
|
mounting_hole_dia=mounting_hole_dia,
|
||||||
mounting_hole_offset=mounting_hole_offset,
|
mounting_hole_offset=mounting_hole_offset,
|
||||||
margin=margin,
|
|
||||||
unit=unit)
|
unit=unit)
|
||||||
|
|
||||||
@app.route('/preview_<side>.svg', methods=['POST'])
|
@app.route('/preview.svg', methods=['POST'])
|
||||||
async def preview(side):
|
async def preview():
|
||||||
obj = await request.get_json()
|
obj = await request.get_json()
|
||||||
board = to_board(obj)
|
board = to_board(obj)
|
||||||
return Response(str(board.pretty_svg(side=side)), mimetype='image/svg+xml')
|
return Response(str(board.pretty_svg()), mimetype='image/svg+xml')
|
||||||
|
|
||||||
@app.route('/gerbers.zip', methods=['POST'])
|
@app.route('/gerbers.zip', methods=['POST'])
|
||||||
async def gerbers():
|
async def gerbers():
|
||||||
|
|
@ -190,10 +148,7 @@ async def gerbers():
|
||||||
board.layer_stack().save_to_zipfile(f)
|
board.layer_stack().save_to_zipfile(f)
|
||||||
return Response(f.read_bytes(), mimetype='image/svg+xml')
|
return Response(f.read_bytes(), mimetype='image/svg+xml')
|
||||||
|
|
||||||
def main():
|
|
||||||
app.run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
app.run()
|
||||||
|
|
||||||
|
|
@ -97,11 +97,6 @@ input {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group > .attribution, .group > .usage {
|
|
||||||
grid-column-start: 1;
|
|
||||||
grid-column-end: span 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group > div > .proportion {
|
.group > div > .proportion {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
@ -145,14 +140,6 @@ input {
|
||||||
margin: 0 5px 0 5px;
|
margin: 0 5px 0 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"]:invalid {
|
|
||||||
background: rgba(255 0 0 / 30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"]:focus:valid {
|
|
||||||
background: rgba(0 192 64 / 30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group.expand {
|
.group.expand {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -174,39 +161,16 @@ input[type="text"]:focus:valid {
|
||||||
}
|
}
|
||||||
|
|
||||||
#preview {
|
#preview {
|
||||||
position: relative;
|
|
||||||
grid-area: main;
|
grid-area: main;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#preview > img {
|
#preview-image {
|
||||||
flex-grow: 1;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
#preview-message {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: hsla(0 0% 50% / 30%);
|
|
||||||
display: none;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 18pt;
|
|
||||||
font-weight: bold;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
#preview-message.loading {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
#links {
|
#links {
|
||||||
grid-area: links;
|
grid-area: links;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -261,73 +225,63 @@ input[type="text"]:focus:valid {
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="controls">
|
<div id="controls">
|
||||||
<form>
|
<div class="group board">
|
||||||
<div class="group board">
|
<h4>Board settings</h4>
|
||||||
<h4>Board settings</h4>
|
<label>Units
|
||||||
<label>Units
|
<select name='units' value="metric">
|
||||||
<select name='units' value="metric">
|
<option value="metric">Metric</option>
|
||||||
<option value="metric">Metric</option>
|
<option value="us">US Customary</option>
|
||||||
<option value="us">US Customary</option>
|
</select>
|
||||||
</select>
|
</label>
|
||||||
|
|
||||||
|
<label>Board width
|
||||||
|
<input name="width" type="text" placeholder="width" value="100">
|
||||||
|
<span class="unit metric">mm</span>
|
||||||
|
<span class="unit us">inch</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Board height
|
||||||
|
<input name="height" type="text" placeholder="height" value="80">
|
||||||
|
<span class="unit metric">mm</span>
|
||||||
|
<span class="unit us">inch</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="group expand" data-group="round_corners">
|
||||||
|
<label>Round corners
|
||||||
|
<input name="enabled" type="checkbox" checked>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>Board width
|
<label>Radius
|
||||||
<input name="width" type="text" placeholder="width" value="100" pattern="[0-9]+\.?[0-9]*"/>
|
<input name="radius" type="text" placeholder="radius" value="1.5">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">inch</span>
|
<span class="unit us">inch</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>Board height
|
|
||||||
<input name="height" type="text" placeholder="height" value="80" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">inch</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>Margin
|
|
||||||
<input type="text" placeholder="margin" name="margin" value="2.0" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">inch</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="group expand" data-group="round_corners">
|
|
||||||
<label>Round corners
|
|
||||||
<input name="enabled" type="checkbox" checked/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>Radius
|
|
||||||
<input name="radius" type="text" placeholder="radius" value="1.5" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">inch</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="group expand" data-group="mounting_holes">
|
|
||||||
<label>Mounting holes
|
|
||||||
<input name="enabled" type="checkbox" name="has_holes" checked/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>Diameter
|
|
||||||
<input type="text" placeholder="diameter" name="diameter" value="3.2" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">inch</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>Board edge to hole center
|
|
||||||
<input type="text" placeholder="distance" name="offset" value="5" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">inch</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4>Content</h4>
|
|
||||||
<div class="group placeholder"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<div class="group expand" data-group="mounting_holes">
|
||||||
|
<label>Mounting holes
|
||||||
|
<input name="enabled" type="checkbox" name="has_holes" checked>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Diameter
|
||||||
|
<input type="text" placeholder="diameter" name="diameter" value="3.2"></input>
|
||||||
|
<span class="unit metric">mm</span>
|
||||||
|
<span class="unit us">inch</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Board edge to hole center
|
||||||
|
<input type="text" placeholder="distance" name="offset" value="5"></input>
|
||||||
|
<span class="unit metric">mm</span>
|
||||||
|
<span class="unit us">inch</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Content</h4>
|
||||||
|
<div class="group placeholder"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="preview">
|
<div id="preview">
|
||||||
<img id="preview-image-top" alt="Automatically generated top side preview image"/>
|
<img id="preview-image" alt="Automatically generated preview image"/>
|
||||||
<img id="preview-image-bottom" alt="Automatically generated bottom side preview image"/>
|
|
||||||
<div id="preview-message"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="links">
|
<div id="links">
|
||||||
<a class="narrow-only" href="#controls">Settings</a>
|
<a class="narrow-only" href="#controls">Settings</a>
|
||||||
|
|
@ -355,7 +309,7 @@ input[type="text"]:focus:valid {
|
||||||
<h4>Proportional Layout</h4>
|
<h4>Proportional Layout</h4>
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
||||||
<label class="proportion">Proportion
|
<label class="proportion">Proportion
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="layout_prop" value="1">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h5>Layout settings</h4>
|
<h5>Layout settings</h4>
|
||||||
|
|
@ -381,7 +335,7 @@ input[type="text"]:focus:valid {
|
||||||
<h4>Split front and back</h4>
|
<h4>Split front and back</h4>
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
||||||
<label class="proportion">Proportion
|
<label class="proportion">Proportion
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="layout_prop" value="1">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h5>Front</h5>
|
<h5>Front</h5>
|
||||||
|
|
@ -396,7 +350,7 @@ input[type="text"]:focus:valid {
|
||||||
<h4>Empty area</h4>
|
<h4>Empty area</h4>
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
||||||
<label class="proportion">Proportion
|
<label class="proportion">Proportion
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="layout_prop" value="1">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|
@ -406,13 +360,9 @@ input[type="text"]:focus:valid {
|
||||||
<a href="#" data-placeholder="smd">SMD area</a>
|
<a href="#" data-placeholder="smd">SMD area</a>
|
||||||
<a href="#" data-placeholder="tht" class="double-sided-only">THT area</a>
|
<a href="#" data-placeholder="tht" class="double-sided-only">THT area</a>
|
||||||
<a href="#" data-placeholder="manhattan">Manhattan area</a>
|
<a href="#" data-placeholder="manhattan">Manhattan area</a>
|
||||||
<a href="#" data-placeholder="flower" class="double-sided-only">THT Flower area</a>
|
<a href="#" data-placeholder="flower"class="double-sided-only">THT Flower area</a>
|
||||||
<a href="#" data-placeholder="powered" class="double-sided-only">Powered THT area</a>
|
<a href="#" data-placeholder="powered"class="double-sided-only">Powered THT area</a>
|
||||||
<a href="#" data-placeholder="rf" class="double-sided-only">RF THT area</a>
|
<a href="#" data-placeholder="rf"class="double-sided-only">RF THT area</a>
|
||||||
<a href="#" data-placeholder="spiky" class="double-sided-only">Spiky hybrid area</a>
|
|
||||||
<a href="#" data-placeholder="alio" class="double-sided-only">ALio hybrid area</a>
|
|
||||||
<a href="#" data-placeholder="starburst" class="double-sided-only">THT starburst area</a>
|
|
||||||
<a href="#" data-placeholder="breadboard" class="double-sided-only">Permanent breadboard area</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -422,22 +372,22 @@ input[type="text"]:focus:valid {
|
||||||
<h4>SMD area</h4>
|
<h4>SMD area</h4>
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
||||||
<label class="proportion">Proportion
|
<label class="proportion">Proportion
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="layout_prop" value="1">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h5>Area Settings</h5>
|
<h5>Area Settings</h5>
|
||||||
<label>Pitch X
|
<label>Pitch X
|
||||||
<input type="text" name="pitch_x" placeholder="length" value="1.27" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="pitch_x" placeholder="length" value="1.27">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Pitch Y
|
<label>Pitch Y
|
||||||
<input type="text" name="pitch_y" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="pitch_y" placeholder="length" value="2.54">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Clearance
|
<label>Clearance
|
||||||
<input type="text" name="clearance" placeholder="length" value="0.3" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="clearance" placeholder="length" value="0.3">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -445,10 +395,11 @@ input[type="text"]:focus:valid {
|
||||||
<select name="pad_shape" value="rect">
|
<select name="pad_shape" value="rect">
|
||||||
<option value="rect">(Rounded) Rectangle</option>
|
<option value="rect">(Rounded) Rectangle</option>
|
||||||
<option value="circle">Circle</option>
|
<option value="circle">Circle</option>
|
||||||
|
<option value="obround">Obround</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="only-shape rect">Corner radius
|
<label class="only-shape rect">Corner radius
|
||||||
<input type="text" name="pad_h" placeholder="length" value="0" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="pad_h" placeholder="length" value="0">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -460,34 +411,34 @@ input[type="text"]:focus:valid {
|
||||||
<h4>THT area</h4>
|
<h4>THT area</h4>
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
||||||
<label class="proportion">Proportion
|
<label class="proportion">Proportion
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="layout_prop" value="1">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h5>Area Settings</h5>
|
<h5>Area Settings</h5>
|
||||||
<label>Pitch X
|
<label>Pitch X
|
||||||
<input type="text" name="pitch_x" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="pitch_x" placeholder="length" value="2.54">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Pitch Y
|
<label>Pitch Y
|
||||||
<input type="text" name="pitch_y" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="pitch_y" placeholder="length" value="2.54">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Clearance
|
<label>Clearance
|
||||||
<input type="text" name="clearance" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="clearance" placeholder="length" value="0.5">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Plating
|
<label>Plating
|
||||||
<select name="plating" value="plated">
|
<select name="plating" value="through">
|
||||||
<option value="plated">Double-sided, through-plated</option>
|
<option value="plated">Double-sided, through-plated</option>
|
||||||
<option value="nonplated">Double-sided, non-plated</option>
|
<option value="nonplated">Double-sided, non-plated</option>
|
||||||
<option value="singleside">Single-sided, non-plated</option>
|
<option value="singleside">Single-sided, non-plated</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Hole diameter
|
<label>Hole diameter
|
||||||
<input type="text" name="hole_dia" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="hole_dia" placeholder="length" value="0.9">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -499,35 +450,7 @@ input[type="text"]:focus:valid {
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="only-shape rect">Corner radius
|
<label class="only-shape rect">Corner radius
|
||||||
<input type="text" name="pad_h" placeholder="length" value="0" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="pad_h" placeholder="length" value="0">
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template id="tpl-g-breadboard">
|
|
||||||
<div data-type="breadboard" class="group breadboard">
|
|
||||||
<h4>Permanent breadboard area</h4>
|
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
|
||||||
<label class="proportion">Proportion
|
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<h5>Area Settings</h5>
|
|
||||||
<label>Direction
|
|
||||||
<select name="direction" value="v">
|
|
||||||
<option value="v">Vertical</option>
|
|
||||||
<option value="h">Horizontal</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>Clearance
|
|
||||||
<input type="text" name="clearance" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Hole diameter
|
|
||||||
<input type="text" name="hole_dia" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -539,22 +462,22 @@ input[type="text"]:focus:valid {
|
||||||
<h4>Manhattan area</h4>
|
<h4>Manhattan area</h4>
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
||||||
<label class="proportion">Proportion
|
<label class="proportion">Proportion
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="layout_prop" value="1">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h5>Area Settings</h5>
|
<h5>Area Settings</h5>
|
||||||
<label>Pitch X
|
<label>Pitch X
|
||||||
<input type="text" name="pitch_x" placeholder="length" value="5.08" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="pitch_x" placeholder="length" value="5.08">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Pitch Y
|
<label>Pitch Y
|
||||||
<input type="text" name="pitch_y" placeholder="length" value="5.08" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="pitch_y" placeholder="length" value="5.08">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Clearance
|
<label>Clearance
|
||||||
<input type="text" name="clearance" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="clearance" placeholder="length" value="0.5">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -566,27 +489,27 @@ input[type="text"]:focus:valid {
|
||||||
<h4>THT flower area</h4>
|
<h4>THT flower area</h4>
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
||||||
<label class="proportion">Proportion
|
<label class="proportion">Proportion
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="layout_prop" value="1">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h5>Area Settings</h5>
|
<h5>Area Settings</h5>
|
||||||
<label>Pitch
|
<label>Pitch
|
||||||
<input type="text" name="pitch" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="pitch" placeholder="length" value="2.54">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Pattern diameter
|
<label>Pattern diameter
|
||||||
<input type="text" name="pattern_dia" placeholder="length" value="2.0" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="pattern_dia" placeholder="length" value="2.0">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Hole diameter
|
<label>Hole diameter
|
||||||
<input type="text" name="hole_dia" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="hole_dia" placeholder="length" value="0.9">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Clearance
|
<label>Clearance
|
||||||
<input type="text" name="clearance" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="clearance" placeholder="length" value="0.5">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -598,37 +521,32 @@ input[type="text"]:focus:valid {
|
||||||
<h4>Powered THT area</h4>
|
<h4>Powered THT area</h4>
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
||||||
<label class="proportion">Proportion
|
<label class="proportion">Proportion
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="layout_prop" value="1">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h5>Area Settings</h5>
|
<h5>Area Settings</h5>
|
||||||
<label>Pitch
|
<label>Pitch
|
||||||
<input type="text" name="pitch" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="pitch" placeholder="length" value="2.54">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Hole diameter
|
<label>Hole diameter
|
||||||
<input type="text" name="hole_dia" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="hole_dia" placeholder="length" value="0.9">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Via drill
|
<label>Via drill
|
||||||
<input type="text" name="via_hole_dia" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="via_hole_dia" placeholder="length" value="0.9">
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Via diameter
|
|
||||||
<input type="text" name="via_dia" placeholder="length" value="1.1" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Trace width
|
<label>Trace width
|
||||||
<input type="text" name="trace_width" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="trace_width" placeholder="length" value="0.5">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Clearance
|
<label>Clearance
|
||||||
<input type="text" name="clearance" placeholder="length" value="0.2" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="clearance" placeholder="length" value="0.5">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -640,151 +558,37 @@ input[type="text"]:focus:valid {
|
||||||
<h4>THT area with RF ground</h4>
|
<h4>THT area with RF ground</h4>
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
||||||
<label class="proportion">Proportion
|
<label class="proportion">Proportion
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="layout_prop" value="1">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h5>Area Settings</h5>
|
<h5>Area Settings</h5>
|
||||||
<label>Pitch
|
<label>Pitch
|
||||||
<input type="text" name="pitch" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="pitch" placeholder="length" value="2.54">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Hole diameter
|
<label>Hole diameter
|
||||||
<input type="text" name="hole_dia" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="hole_dia" placeholder="length" value="0.9">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Trace width
|
<label>Trace width
|
||||||
<input type="text" name="trace_width" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="trace_width" placeholder="length" value="0.5">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Via diameter
|
<label>Via diameter
|
||||||
<input type="text" name="via_dia" placeholder="length" value="0.8" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="via_dia" placeholder="length" value="0.8">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Via drill
|
<label>Via drill
|
||||||
<input type="text" name="via_hole_dia" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="via_hole_dia" placeholder="length" value="0.4">
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Clearance
|
<label>Clearance
|
||||||
<input type="text" name="clearance" placeholder="length" value="0.3" pattern="[0-9]+\.?[0-9]*"/>
|
<input type="text" name="clearance" placeholder="length" value="0.5">
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template id="tpl-g-spiky">
|
|
||||||
<div data-type="spiky" class="group spiky">
|
|
||||||
<h4>Spiky hybrid area</h4>
|
|
||||||
<div class="attribution">
|
|
||||||
Layout by <a href="https://social.treehouse.systems/@electronic_eel">electroniceel</a> (<a href="https://github.com/electroniceel/protoboard">github</a>)
|
|
||||||
</div>
|
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
|
||||||
<label class="proportion">Proportion
|
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<h5>Area Settings</h5>
|
|
||||||
<div class="usage">This area has a fixed 100 mil / 2.54 mm pitch.</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template id="tpl-g-alio">
|
|
||||||
<div data-type="alio" class="group alio">
|
|
||||||
<h4>ALio hybrid area</h4>
|
|
||||||
<div class="attribution">
|
|
||||||
Layout by arief ibrahim adha (<a href="https://hackaday.io/project/28570-alio-new-hardware-prototyping-platform">hackaday.io</a>).
|
|
||||||
Top and bottom have opposed orientation of the SMD pads.
|
|
||||||
</div>
|
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
|
||||||
<label class="proportion">Proportion
|
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<h5>Area Settings</h5>
|
|
||||||
<label>Pitch
|
|
||||||
<input type="text" name="pitch" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Hole diameter
|
|
||||||
<input type="text" name="hole_dia" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Link trace width
|
|
||||||
<input type="text" name="link_trace_width" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Via pad width
|
|
||||||
<input type="text" name="link_pad_width" placeholder="length" value="0.8" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Via drill
|
|
||||||
<input type="text" name="via_hole_dia" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Clearance
|
|
||||||
<input type="text" name="clearance" placeholder="length" value="0.3" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template id="tpl-g-starburst">
|
|
||||||
<div data-type="starburst" class="group starburst">
|
|
||||||
<h4>Starburst area</h4>
|
|
||||||
<span class="content area-controls">(<a href="#" class="area-remove">Remove</a><a href="#" class="area-move">Move</a>)</span>
|
|
||||||
<label class="proportion">Proportion
|
|
||||||
<input type="text" name="layout_prop" value="1" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<h5>Area Settings</h5>
|
|
||||||
<label>Pitch X
|
|
||||||
<input type="text" name="pitch_x" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Pitch Y
|
|
||||||
<input type="text" name="pitch_y" placeholder="length" value="2.54" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Drill diameter
|
|
||||||
<input type="text" name="drill" placeholder="length" value="0.9" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Annular ring
|
|
||||||
<input type="text" name="annular" placeholder="length" value="1.2" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Pad clearance
|
|
||||||
<input type="text" name="clearance" placeholder="length" value="0.4" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Soldermask wall
|
|
||||||
<input type="text" name="mask_width" placeholder="length" value="0.5" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Trace width X
|
|
||||||
<input type="text" name="trace_width_x" placeholder="length" value="1.40" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
|
||||||
<span class="unit us">mil</span>
|
|
||||||
</label>
|
|
||||||
<label>Trace width Y
|
|
||||||
<input type="text" name="trace_width_y" placeholder="length" value="1.40" pattern="[0-9]+\.?[0-9]*"/>
|
|
||||||
<span class="unit metric">mm</span>
|
<span class="unit metric">mm</span>
|
||||||
<span class="unit us">mil</span>
|
<span class="unit us">mil</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -1077,47 +881,21 @@ input[type="text"]:focus:valid {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let previewTopBlobURL = null;
|
let previewBlobURL = null;
|
||||||
let previewBotBlobURL = null;
|
|
||||||
previewReloader = new RateLimiter(async () => {
|
previewReloader = new RateLimiter(async () => {
|
||||||
if (document.querySelector('form').checkValidity()) {
|
const response = await fetch('preview.svg', {
|
||||||
document.querySelector('#preview-message').textContent = 'Reloading...';
|
method: 'POST',
|
||||||
document.querySelector('#preview-message').classList.add('loading');
|
mode: 'same-origin',
|
||||||
|
cache: 'no-cache',
|
||||||
const response_top = await fetch('preview_top.svg', {
|
headers: {'Content-Type': 'application/json'},
|
||||||
method: 'POST',
|
body: serialize(),
|
||||||
mode: 'same-origin',
|
});
|
||||||
cache: 'no-cache',
|
const data = await response.blob();
|
||||||
headers: {'Content-Type': 'application/json'},
|
if (previewBlobURL) {
|
||||||
body: serialize(),
|
URL.revokeObjectURL(previewBlobURL);
|
||||||
});
|
|
||||||
const data_top = await response_top.blob();
|
|
||||||
if (previewTopBlobURL) {
|
|
||||||
URL.revokeObjectURL(previewTopBlobURL);
|
|
||||||
}
|
|
||||||
previewTopBlobURL = URL.createObjectURL(data_top);
|
|
||||||
document.querySelector('#preview-image-top').src = previewTopBlobURL;
|
|
||||||
|
|
||||||
document.querySelector('#preview-message').textContent = '';
|
|
||||||
document.querySelector('#preview-message').classList.remove('loading');
|
|
||||||
|
|
||||||
const response_bot = await fetch('preview_bottom.svg', {
|
|
||||||
method: 'POST',
|
|
||||||
mode: 'same-origin',
|
|
||||||
cache: 'no-cache',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: serialize(),
|
|
||||||
});
|
|
||||||
const data_bot = await response_bot.blob();
|
|
||||||
if (previewBotBlobURL) {
|
|
||||||
URL.revokeObjectURL(previewBotBlobURL);
|
|
||||||
}
|
|
||||||
previewBotBlobURL = URL.createObjectURL(data_bot);
|
|
||||||
document.querySelector('#preview-image-bottom').src = previewBotBlobURL;
|
|
||||||
} else {
|
|
||||||
document.querySelector('#preview-message').classList.add('loading');
|
|
||||||
document.querySelector('#preview-message').textContent = 'Please correct any invalid fields.';
|
|
||||||
}
|
}
|
||||||
|
previewBlobURL = URL.createObjectURL(data);
|
||||||
|
document.querySelector('#preview-image').src = previewBlobURL;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
document.querySelector('div.placeholder').replaceWith(createPlaceholder());
|
document.querySelector('div.placeholder').replaceWith(createPlaceholder());
|
||||||
|
|
@ -26,7 +26,7 @@ import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg, convex_hull
|
from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg
|
||||||
from . import graphic_primitives as gp
|
from . import graphic_primitives as gp
|
||||||
from . import graphic_objects as go
|
from . import graphic_objects as go
|
||||||
|
|
||||||
|
|
@ -54,15 +54,6 @@ class FileSettings:
|
||||||
zeros : bool = None
|
zeros : bool = None
|
||||||
#: Number format. ``(integer, decimal)`` tuple of number of integer and decimal digits. At most ``(6,7)`` by spec.
|
#: Number format. ``(integer, decimal)`` tuple of number of integer and decimal digits. At most ``(6,7)`` by spec.
|
||||||
number_format : tuple = (None, None)
|
number_format : tuple = (None, None)
|
||||||
#: At least the aperture macro implementations of gerbv and whatever JLCPCB uses are severely broken and simply
|
|
||||||
#: ignore parentheses in numeric expressions without throwing an error or a warning, leading to broken rendering.
|
|
||||||
#: To avoid trouble with severely broken software like this, we just calculate out all macros by default.
|
|
||||||
#: If you want to export the macros with their original formulaic expressions (which is completely fine by the
|
|
||||||
#: Gerber standard, btw), set this parameter to ``False`` before exporting.
|
|
||||||
calculate_out_all_aperture_macros: bool = True
|
|
||||||
#: Internal field used to communicate if only decimal coordinates were found inside an Excellon file, or if it
|
|
||||||
#: contained at least some coordinates in fixed-width notation.
|
|
||||||
_file_has_fixed_width_coordinates: bool = False
|
|
||||||
|
|
||||||
# input validation
|
# input validation
|
||||||
def __setattr__(self, name, value):
|
def __setattr__(self, name, value):
|
||||||
|
|
@ -257,10 +248,10 @@ class Polyline:
|
||||||
|
|
||||||
(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 {float(x0):.6} {float(y0):.6} ' + ' '.join(f'L {float(x):.6} {float(y):.6}' for x, y in rest)
|
||||||
width = f'{float(self.width):.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||||
return tag('path', d=d,
|
return tag('path', d=d,
|
||||||
fill='none', stroke=color, stroke_linecap='round', stroke_linejoin='round',
|
fill='none', stroke=color,
|
||||||
stroke_width=width)
|
stroke_width=f'{float(width):.6}')
|
||||||
|
|
||||||
|
|
||||||
class CamFile:
|
class CamFile:
|
||||||
|
|
@ -300,15 +291,21 @@ class CamFile:
|
||||||
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 +348,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()
|
||||||
|
|
@ -23,10 +23,7 @@ import dataclasses
|
||||||
import re
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
import json
|
import json
|
||||||
import sys
|
|
||||||
import itertools
|
import itertools
|
||||||
import webbrowser
|
|
||||||
import warnings
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .utils import MM, Inch
|
from .utils import MM, Inch
|
||||||
|
|
@ -34,23 +31,8 @@ from .cam import FileSettings
|
||||||
from .rs274x import GerberFile
|
from .rs274x import GerberFile
|
||||||
from . import layers as lyr
|
from . import layers as lyr
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .cad.kicad import schematic as kc_schematic
|
|
||||||
from .cad.kicad import tmtheme
|
|
||||||
from .cad import protoserve
|
|
||||||
|
|
||||||
|
|
||||||
def _showwarning(message, category, filename, lineno, file=None, line=None):
|
|
||||||
if file is None:
|
|
||||||
file = sys.stderr
|
|
||||||
|
|
||||||
filename = Path(filename)
|
|
||||||
gerbonara_module_install_location = Path(__file__).parent.parent
|
|
||||||
if filename.is_relative_to(gerbonara_module_install_location):
|
|
||||||
filename = filename.relative_to(gerbonara_module_install_location)
|
|
||||||
|
|
||||||
print(f'{filename}:{lineno}: {message}', file=file)
|
|
||||||
warnings.showwarning = _showwarning
|
|
||||||
|
|
||||||
def _print_version(ctx, param, value):
|
def _print_version(ctx, param, value):
|
||||||
if value and not ctx.resilient_parsing:
|
if value and not ctx.resilient_parsing:
|
||||||
click.echo(f'Version {__version__}')
|
click.echo(f'Version {__version__}')
|
||||||
|
|
@ -146,44 +128,6 @@ def cli():
|
||||||
well as sets of those files """
|
well as sets of those files """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@cli.group('protoboard')
|
|
||||||
def protoboard_group():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@protoboard_group.command()
|
|
||||||
@click.option('-h', '--host', default=None, help='Hostname to listen on. Defaults to localhost.')
|
|
||||||
@click.option('-p', '--port', type=int, default=1337, help='Port to listen on. Defaults to 1337')
|
|
||||||
def interactive(host, port):
|
|
||||||
''' Launch gerbonar's interactive protoboard designer in your browser '''
|
|
||||||
|
|
||||||
if host is None:
|
|
||||||
@protoserve.app.before_serving
|
|
||||||
async def open_browser():
|
|
||||||
webbrowser.open_new(f'http://localhost:{port}/')
|
|
||||||
protoserve.app.run(host=host, port=port, use_reloader=False, debug=False)
|
|
||||||
|
|
||||||
|
|
||||||
@cli.group('kicad')
|
|
||||||
def kicad_group():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@kicad_group.group('schematic')
|
|
||||||
def schematic_group():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@schematic_group.command()
|
|
||||||
@click.argument('inpath', type=click.Path(exists=True))
|
|
||||||
@click.argument('theme', type=click.Path(exists=True))
|
|
||||||
@click.argument('outfile', type=click.File('w'), default='-')
|
|
||||||
def render(inpath, theme, outfile):
|
|
||||||
sch = kc_schematic.Schematic.open(inpath)
|
|
||||||
cs = tmtheme.TmThemeSchematic(Path(theme).read_text())
|
|
||||||
with outfile as f:
|
|
||||||
f.write(str(sch.to_svg(cs)))
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
|
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
|
||||||
|
|
@ -198,17 +142,12 @@ def render(inpath, theme, outfile):
|
||||||
rules and use only rules given by --input-map''')
|
rules and use only rules given by --input-map''')
|
||||||
@click.option('--force-zip', is_flag=True, help='''Force treating input path as a zip file (default: guess file type
|
@click.option('--force-zip', is_flag=True, help='''Force treating input path as a zip file (default: guess file type
|
||||||
from extension and contents)''')
|
from extension and contents)''')
|
||||||
@click.option('--top', 'side', flag_value='top', help='Render top side')
|
@click.option('--top/--bottom', default=True, help='Which side of the board to render')
|
||||||
@click.option('--bottom', 'side', flag_value='bottom', help='Render top side')
|
|
||||||
@click.option('--command-line-units', type=Unit(), help='''Units for values given in other options. Default:
|
@click.option('--command-line-units', type=Unit(), help='''Units for values given in other options. Default:
|
||||||
millimeter''')
|
millimeter''')
|
||||||
@click.option('--margin', type=float, default=0.0, help='Add space around the board inside the viewport')
|
@click.option('--margin', type=float, default=0.0, help='Add space around the board inside the viewport')
|
||||||
@click.option('--force-bounds', help='Force SVG bounding box to value given as "min_x,min_y,max_x,max_y"')
|
@click.option('--force-bounds', help='Force SVG bounding box to value given as "min_x,min_y,max_x,max_y"')
|
||||||
@click.option('--inkscape/--standard-svg', default=True, help='Export in Inkscape SVG format with layers and stuff.')
|
@click.option('--inkscape/--standard-svg', default=True, help='Export in Inkscape SVG format with layers and stuff.')
|
||||||
@click.option('--pretty/--no-filters', default=True, help='''Export pseudo-realistic render using filters (default) or
|
|
||||||
just stack up layers using given colorscheme. In "--no-filters" mode, by default all layers are exported
|
|
||||||
unless either "--top" or "--bottom" is given.''')
|
|
||||||
@click.option('--drills/--no-drills', default=True, help='''Include (default) or exclude drills ("--no-filters" only!)''')
|
|
||||||
@click.option('--colorscheme', type=click.Path(exists=True, path_type=Path), help='''Load colorscheme from given JSON
|
@click.option('--colorscheme', type=click.Path(exists=True, path_type=Path), help='''Load colorscheme from given JSON
|
||||||
file. The JSON file must contain a single dict with keys copper, silk, mask, paste, drill and outline.
|
file. The JSON file must contain a single dict with keys copper, silk, mask, paste, drill and outline.
|
||||||
Each key must map to a string containing either a normal 6-digit hex color with leading hash sign, or an
|
Each key must map to a string containing either a normal 6-digit hex color with leading hash sign, or an
|
||||||
|
|
@ -216,8 +155,8 @@ def render(inpath, theme, outfile):
|
||||||
with FF being completely opaque, and 00 being invisibly transparent.''')
|
with FF being completely opaque, and 00 being invisibly transparent.''')
|
||||||
@click.argument('inpath', type=click.Path(exists=True))
|
@click.argument('inpath', type=click.Path(exists=True))
|
||||||
@click.argument('outfile', type=click.File('w'), default='-')
|
@click.argument('outfile', type=click.File('w'), default='-')
|
||||||
def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, force_zip, side, drills,
|
def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, force_zip, top, command_line_units,
|
||||||
command_line_units, margin, force_bounds, inkscape, pretty, colorscheme):
|
margin, force_bounds, inkscape, colorscheme):
|
||||||
""" Render a gerber file, or a directory or zip of gerber files into an SVG file. """
|
""" Render a gerber file, or a directory or zip of gerber files into an SVG file. """
|
||||||
|
|
||||||
overrides = json.loads(input_map.read_bytes()) if input_map else None
|
overrides = json.loads(input_map.read_bytes()) if input_map else None
|
||||||
|
|
@ -235,14 +174,9 @@ def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules,
|
||||||
if colorscheme:
|
if colorscheme:
|
||||||
colorscheme = json.loads(colorscheme.read_text())
|
colorscheme = json.loads(colorscheme.read_text())
|
||||||
|
|
||||||
if pretty:
|
outfile.write(str(stack.to_pretty_svg(side='top' if top else 'bottom', margin=margin,
|
||||||
svg = stack.to_pretty_svg(side='bottom' if side == 'bottom' else 'top', margin=margin,
|
arg_unit=(command_line_units or MM),
|
||||||
arg_unit=(command_line_units or MM),
|
svg_unit=MM, force_bounds=force_bounds, inkscape=inkscape, colors=colorscheme)))
|
||||||
svg_unit=MM, force_bounds=force_bounds, inkscape=inkscape, colors=colorscheme)
|
|
||||||
else:
|
|
||||||
svg = stack.to_svg(side_re=side or '.*', margin=margin, drills=drills, arg_unit=(command_line_units or MM),
|
|
||||||
svg_unit=MM, force_bounds=force_bounds, colors=colorscheme)
|
|
||||||
outfile.write(str(svg))
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
|
|
@ -338,9 +272,9 @@ def rewrite(transform, command_line_units, number_format, units, zero_suppressio
|
||||||
scheme instead of keeping the old file names.''')
|
scheme instead of keeping the old file names.''')
|
||||||
@click.argument('transform')
|
@click.argument('transform')
|
||||||
@click.argument('inpath')
|
@click.argument('inpath')
|
||||||
@click.argument('outpath', type=click.Path(path_type=Path))
|
@click.argument('outpath')
|
||||||
def transform(transform, units, output_format, inpath, outpath, format_warnings, input_map, use_builtin_name_rules,
|
def transform(transform, units, output_format, inpath, outpath,
|
||||||
output_naming_scheme, number_format, force_zip):
|
format_warnings, input_map, use_builtin_name_rules, output_naming_scheme):
|
||||||
""" Transform all gerber files in a given directory or zip file using the given python transformation script.
|
""" Transform all gerber files in a given directory or zip file using the given python transformation script.
|
||||||
|
|
||||||
In the python transformation script you have access to the functions translate(x, y), scale(factor) and
|
In the python transformation script you have access to the functions translate(x, y), scale(factor) and
|
||||||
|
|
@ -355,26 +289,16 @@ def transform(transform, units, output_format, inpath, outpath, format_warnings,
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.simplefilter(format_warnings)
|
warnings.simplefilter(format_warnings)
|
||||||
if force_zip:
|
if force_zip:
|
||||||
stack = lyr.LayerStack.open_zip(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
|
stack = lyr.LayerStack.open_zip(path, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||||
else:
|
else:
|
||||||
stack = lyr.LayerStack.open(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
|
stack = lyr.LayerStack.open(path, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||||
|
|
||||||
_apply_transform(transform, units, stack)
|
_apply_transform(transform, units, stack)
|
||||||
|
|
||||||
output_format = None if output_format == 'reuse' else FileSettings.defaults()
|
output_format = None if output_format == 'reuse' else FileSettings.defaults()
|
||||||
if number_format:
|
stack.save_to_directory(outpath, naming_scheme=output_naming_scheme or {},
|
||||||
if output_format is None:
|
gerber_settings=output_format,
|
||||||
output_format = FileSettings.defaults()
|
excellon_settings=dataclasses.replace(output_format, zeros=None))
|
||||||
a, _, b = number_format.partition('.')
|
|
||||||
output_format.number_format = (int(a), int(b))
|
|
||||||
if outpath.is_file() or outpath.suffix.lower() == '.zip':
|
|
||||||
stack.save_to_zipfile(outpath, naming_scheme=output_naming_scheme or {},
|
|
||||||
gerber_settings=output_format,
|
|
||||||
excellon_settings=dataclasses.replace(output_format, zeros=None))
|
|
||||||
else:
|
|
||||||
stack.save_to_directory(outpath, naming_scheme=output_naming_scheme or {},
|
|
||||||
gerber_settings=output_format,
|
|
||||||
excellon_settings=dataclasses.replace(output_format, zeros=None))
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
|
|
@ -455,7 +379,7 @@ def merge(inpath, outpath, offset, rotation, input_map, command_line_units, outp
|
||||||
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
|
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
|
||||||
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
|
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
|
||||||
help='''Enable or disable file format warnings during parsing (default: on)''')
|
help='''Enable or disable file format warnings during parsing (default: on)''')
|
||||||
@click.option('--units', type=Unit(), default='metric', help='Output bounding box in this unit (default: millimeter)')
|
@click.option('--units', type=Unit(), help='Output bounding box in this unit (default: millimeter)')
|
||||||
@click.option('--input-number-format', help='Override number format of input file (mostly useful for Excellon files)')
|
@click.option('--input-number-format', help='Override number format of input file (mostly useful for Excellon files)')
|
||||||
@click.option('--input-units', type=Unit(), help='Override units of input file')
|
@click.option('--input-units', type=Unit(), help='Override units of input file')
|
||||||
@click.option('--input-zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='Override zero suppression setting of input file')
|
@click.option('--input-zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='Override zero suppression setting of input file')
|
||||||
|
|
@ -570,7 +494,7 @@ def meta(path, force_zip, format_warnings):
|
||||||
d[function] = {
|
d[function] = {
|
||||||
'format': 'Gerber',
|
'format': 'Gerber',
|
||||||
'path': str(layer.original_path),
|
'path': str(layer.original_path),
|
||||||
'apertures': len(list(layer.apertures())),
|
'apertures': len(layer.apertures),
|
||||||
'objects': len(layer.objects),
|
'objects': len(layer.objects),
|
||||||
'bounding_box': {'min_x': min_x, 'min_y': min_y, 'max_x': max_x, 'max_y': max_y},
|
'bounding_box': {'min_x': min_x, 'min_y': min_y, 'max_x': max_x, 'max_y': max_y},
|
||||||
'format_settings': format_settings,
|
'format_settings': format_settings,
|
||||||
|
|
@ -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,8 +46,8 @@ 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)
|
current_id = self.tools.get(id(self.current_tool))
|
||||||
new_id = self.tools[tool]
|
new_id = self.tools[id(tool)]
|
||||||
if new_id != current_id:
|
if new_id != current_id:
|
||||||
if self.drill_down:
|
if self.drill_down:
|
||||||
yield 'M16' # drill up
|
yield 'M16' # drill up
|
||||||
|
|
@ -269,19 +268,19 @@ class ExcellonFile(CamFile):
|
||||||
""" Counterpart to :py:meth:`~.rs274x.GerberFile.to_excellon`. Does nothing and returns :py:obj:`self`. """
|
""" Counterpart to :py:meth:`~.rs274x.GerberFile.to_excellon`. Does nothing and returns :py:obj:`self`. """
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def to_gerber(self, errors='raise'):
|
def to_gerber(self, errros='raise'):
|
||||||
""" Convert this excellon file into a :py:class:`~.rs274x.GerberFile`. """
|
""" Convert this excellon file into a :py:class:`~.rs274x.GerberFile`. """
|
||||||
from .rs274x import GerberFile
|
apertures = {}
|
||||||
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):
|
||||||
|
|
@ -328,7 +327,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 +335,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,12 +373,12 @@ 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))
|
||||||
|
|
||||||
mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1)
|
mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1)
|
||||||
if mixed_plating:
|
if mixed_plating:
|
||||||
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.', SyntaxWarning)
|
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.')
|
||||||
|
|
||||||
defined_tools = {}
|
defined_tools = {}
|
||||||
tool_indices = {}
|
tool_indices = {}
|
||||||
|
|
@ -569,8 +568,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 +608,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 +632,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 +755,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 +764,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 +865,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 +880,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 +902,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 +928,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 +960,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')
|
||||||
|
|
@ -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):
|
||||||
|
|
@ -152,7 +150,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 +217,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``
|
||||||
|
|
@ -278,23 +276,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 +293,20 @@ 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):
|
def _scale(self, factor):
|
||||||
self.outline = [ (x*factor, y*factor) for x, y in self.outline ]
|
self.outline = [ (x*factor, y*factor) for x, y in self.outline ]
|
||||||
self.arc_centers = [
|
self.arc_centers = [
|
||||||
(arc[0], (arc[1][0]*factor, arc[1][1]*factor)) if arc else None
|
(arc[0], (arc[1][0]*factor, arc[1][1]*factor)) if arc else None
|
||||||
for p, arc in zip_longest(self.outline, self.arc_centers) ]
|
for p, arc in zip(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 +317,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 +334,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 +343,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*'
|
||||||
|
|
||||||
|
|
@ -523,13 +446,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)
|
||||||
|
|
@ -589,10 +505,6 @@ class Arc(GraphicObject):
|
||||||
#: 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
|
||||||
self.y1 += dy
|
self.y1 += dy
|
||||||
|
|
@ -624,8 +536,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 +608,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
|
||||||
|
|
@ -710,10 +654,10 @@ class Arc(GraphicObject):
|
||||||
|
|
||||||
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 +665,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)
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
import math
|
import math
|
||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
from dataclasses import dataclass, replace, field
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
from .utils import *
|
from .utils import *
|
||||||
|
|
||||||
|
|
@ -62,12 +62,6 @@ class GraphicPrimitive:
|
||||||
|
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def is_zero_size(self):
|
|
||||||
""" Return whether this primitive is zero size
|
|
||||||
|
|
||||||
:rtype: bool
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Circle(GraphicPrimitive):
|
class Circle(GraphicPrimitive):
|
||||||
|
|
@ -85,14 +79,6 @@ class Circle(GraphicPrimitive):
|
||||||
color = fg if self.polarity_dark else bg
|
color = fg if self.polarity_dark else bg
|
||||||
return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), fill=color)
|
return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), fill=color)
|
||||||
|
|
||||||
def to_arc_poly(self):
|
|
||||||
return ArcPoly([(self.x-self.r, self.y), (self.x+self.r, self.y)],
|
|
||||||
[(True, (self.x, self.y)), (True, (self.x, self.y))],
|
|
||||||
polarity_dark=self.polarity_dark)
|
|
||||||
|
|
||||||
def is_zero_size(self):
|
|
||||||
return math.isclose(self.r, 0)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ArcPoly(GraphicPrimitive):
|
class ArcPoly(GraphicPrimitive):
|
||||||
|
|
@ -102,51 +88,28 @@ class ArcPoly(GraphicPrimitive):
|
||||||
#: connected.
|
#: connected.
|
||||||
outline : list
|
outline : list
|
||||||
#: Must be either None (all segments are straight lines) or same length as outline.
|
#: Must be either None (all segments are straight lines) or same length as outline.
|
||||||
#: Straight line segments have None entry. Arc segments have (clockwise, (cx, cy)) tuple with cx, cy being absolute
|
#: Straight line segments have None entry.
|
||||||
#: coords.
|
arc_centers : list = None
|
||||||
arc_centers : list = field(default_factory=list)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def segments(self):
|
def segments(self):
|
||||||
""" Return an iterator through all *segments* of this polygon. For each outline segment (line or arc), this
|
""" Return an iterator through all *segments* of this polygon. For each outline segment (line or arc), this
|
||||||
iterator will yield a ``(p1, p2, (clockwise, center))`` tuple. If the segment is a straight line, ``clockwise``
|
iterator will yield a ``(p1, p2, center)`` tuple. If the segment is a straight line, ``center`` will be
|
||||||
will be ``None``.
|
``None``.
|
||||||
"""
|
"""
|
||||||
for points, arc in itertools.zip_longest(itertools.pairwise(self.outline), self.arc_centers):
|
ol = self.outline
|
||||||
if arc:
|
return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers or [])
|
||||||
if points:
|
|
||||||
yield *points, arc
|
|
||||||
else:
|
|
||||||
yield self.outline[-1], self.outline[0], arc
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
if not points:
|
|
||||||
break
|
|
||||||
yield *points, (None, (None, None))
|
|
||||||
|
|
||||||
# Close outline if necessary.
|
|
||||||
if math.dist(self.outline[0], self.outline[-1]) > 1e-6:
|
|
||||||
yield self.outline[-1], self.outline[0], (None, (None, None))
|
|
||||||
|
|
||||||
def approximate_arcs(self, max_error=1e-2, clip_max_error=True):
|
|
||||||
outline = []
|
|
||||||
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
|
|
||||||
if clockwise is None:
|
|
||||||
outline.append((x1, y1))
|
|
||||||
else:
|
|
||||||
outline.extend(approximate_arc(cx, cy, x1, y1, x2, y2, clockwise,
|
|
||||||
max_error=max_error, clip_max_error=clip_max_error))
|
|
||||||
outline.pop() # remove arc end point
|
|
||||||
return type(self)(outline, polarity_dark=self.polarity_dark)
|
|
||||||
|
|
||||||
def bounding_box(self):
|
def bounding_box(self):
|
||||||
bbox = (None, None), (None, None)
|
bbox = (None, None), (None, None)
|
||||||
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
|
for (x1, y1), (x2, y2), arc in self.segments:
|
||||||
if clockwise is None:
|
if arc:
|
||||||
|
clockwise, (cx, cy) = arc
|
||||||
|
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
|
||||||
|
|
||||||
|
else:
|
||||||
line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2))
|
line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2))
|
||||||
bbox = add_bounds(bbox, line_bounds)
|
bbox = add_bounds(bbox, line_bounds)
|
||||||
else:
|
|
||||||
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
|
|
||||||
return bbox
|
return bbox
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -175,33 +138,17 @@ class ArcPoly(GraphicPrimitive):
|
||||||
|
|
||||||
yield f'M {float(self.outline[0][0]):.6} {float(self.outline[0][1]):.6}'
|
yield f'M {float(self.outline[0][0]):.6} {float(self.outline[0][1]):.6}'
|
||||||
|
|
||||||
for old, new, (clockwise, center) in self.segments:
|
for old, new, arc in self.segments:
|
||||||
if clockwise is None:
|
if not arc:
|
||||||
yield f'L {float(new[0]):.6} {float(new[1]):.6}'
|
yield f'L {float(new[0]):.6} {float(new[1]):.6}'
|
||||||
else:
|
else:
|
||||||
|
clockwise, center = arc
|
||||||
yield svg_arc(old, new, center, clockwise)
|
yield svg_arc(old, new, center, clockwise)
|
||||||
|
|
||||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||||
color = fg if self.polarity_dark else bg
|
color = fg if self.polarity_dark else bg
|
||||||
return tag('path', d=' '.join(self.path_d()), fill=color)
|
return tag('path', d=' '.join(self.path_d()), fill=color)
|
||||||
|
|
||||||
def to_arc_poly(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
def is_zero_size(self):
|
|
||||||
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
|
|
||||||
if clockwise is not None: # arc
|
|
||||||
if math.isclose(cx, x1) and math.isclose(cy, y1):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if math.isclose(x1, x2) and math.isclose(y1, y2):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if math.isclose(polygon_area(self.outline), 0):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Line(GraphicPrimitive):
|
class Line(GraphicPrimitive):
|
||||||
|
|
@ -242,34 +189,7 @@ class Line(GraphicPrimitive):
|
||||||
color = fg if self.polarity_dark else bg
|
color = fg if self.polarity_dark else bg
|
||||||
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||||
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} L {float(self.x2):.6} {float(self.y2):.6}',
|
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} L {float(self.x2):.6} {float(self.y2):.6}',
|
||||||
fill='none', stroke=color, stroke_width=str(width), stroke_linecap='round')
|
fill='none', stroke=color, stroke_width=str(width))
|
||||||
|
|
||||||
def to_arc_poly(self):
|
|
||||||
l = math.dist((self.x1, self.y1), (self.x2, self.y2))
|
|
||||||
if math.isclose(l, 0):
|
|
||||||
# degenerate case: a zero-length line becomes a circle.
|
|
||||||
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
|
|
||||||
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
|
|
||||||
polarity_dark=self.polarity_dark)
|
|
||||||
|
|
||||||
dx, dy = self.x2-self.x1, self.y2-self.y1
|
|
||||||
nx, ny = -dy/l, dx/l
|
|
||||||
rx, ry = nx*self.width/2, ny*self.width/2
|
|
||||||
return ArcPoly([
|
|
||||||
(self.x2+rx, self.y2+ry),
|
|
||||||
(self.x2-rx, self.y2-ry),
|
|
||||||
(self.x1-rx, self.y1-ry),
|
|
||||||
(self.x1+rx, self.y1+ry),
|
|
||||||
], [
|
|
||||||
(True, (self.x2, self.y2)),
|
|
||||||
None,
|
|
||||||
(True, (self.x1, self.y1)),
|
|
||||||
None,
|
|
||||||
], polarity_dark=self.polarity_dark)
|
|
||||||
|
|
||||||
def is_zero_size(self):
|
|
||||||
return math.isclose(self.x1, self.x2) and math.isclose(self.y1, self.y2)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Arc(GraphicPrimitive):
|
class Arc(GraphicPrimitive):
|
||||||
|
|
@ -282,9 +202,9 @@ class Arc(GraphicPrimitive):
|
||||||
x2 : float
|
x2 : float
|
||||||
#: End Y coodinate
|
#: End Y coodinate
|
||||||
y2 : float
|
y2 : float
|
||||||
#: Center X coordinate (absolute)
|
#: Center X coordinate relative to ``x1``
|
||||||
cx : float
|
cx : float
|
||||||
#: Center Y coordinate (absolute)
|
#: Center Y coordinate relative to ``y1``
|
||||||
cy : float
|
cy : float
|
||||||
#: ``True`` if this arc is clockwise from start to end. Selects between the large arc and the small arc given this
|
#: ``True`` if this arc is clockwise from start to end. Selects between the large arc and the small arc given this
|
||||||
#: start, end and center
|
#: start, end and center
|
||||||
|
|
@ -292,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.x1 + self.cx) - self.x2, cy=(self.y1 + self.cy) - self.y2, clockwise=not self.clockwise)
|
||||||
|
|
||||||
def bounding_box(self):
|
def bounding_box(self):
|
||||||
r = self.width/2
|
r = self.width/2
|
||||||
(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 {float(self.x1):.6} {float(self.y1):.6} {arc}',
|
||||||
fill='none', stroke=color, stroke_width=width, stroke_linecap='round')
|
fill='none', stroke=color, stroke_width=width)
|
||||||
|
|
||||||
def to_arc_poly(self):
|
|
||||||
r = math.dist((self.x1, self.y1), (self.cx, self.cy))
|
|
||||||
|
|
||||||
if math.isclose(r, 0):
|
|
||||||
# degenerate case: a zero-radius arc becomes a circle.
|
|
||||||
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
|
|
||||||
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
|
|
||||||
polarity_dark=self.polarity_dark)
|
|
||||||
|
|
||||||
dx1, dy1 = self.x1-self.cx, self.y1-self.cy
|
|
||||||
nx1, ny1 = dx1/r * self.width/2, dy1/r * self.width/2
|
|
||||||
dx2, dy2 = self.x2-self.cx, self.y2-self.cy
|
|
||||||
nx2, ny2 = dx2/r * self.width/2, dy2/r * self.width/2
|
|
||||||
return ArcPoly([ # vertices
|
|
||||||
(self.x1+nx1, self.y1+ny1),
|
|
||||||
(self.x1-nx1, self.y1-ny1),
|
|
||||||
(self.x2-nx2, self.y2-ny2),
|
|
||||||
(self.x2+nx2, self.y2+ny2),
|
|
||||||
], [ # arc segments (direction, center)
|
|
||||||
(not self.clockwise, (self.x1, self.y1)),
|
|
||||||
(self.clockwise, (self.cx, self.cy)),
|
|
||||||
(self.clockwise, (self.x2, self.y2)),
|
|
||||||
(not self.clockwise, (self.cx, self.cy)),
|
|
||||||
], polarity_dark=self.polarity_dark)
|
|
||||||
|
|
||||||
def is_zero_size(self):
|
|
||||||
return False # an arc with identical start and end points is defined as a circle
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Rectangle(GraphicPrimitive):
|
class Rectangle(GraphicPrimitive):
|
||||||
|
|
@ -366,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)
|
*svg_rotation(self.rotation, self.x, self.y), fill=color)
|
||||||
|
|
||||||
def is_zero_size(self):
|
|
||||||
return math.isclose(self.w, 0) or math.isclose(self.h, 0)
|
|
||||||
|
|
||||||
|
|
@ -82,7 +82,6 @@ MATCH_RULES = {
|
||||||
'bottom paste': r'.*_boardoutline\.\w+', # FIXME verify this
|
'bottom paste': r'.*_boardoutline\.\w+', # FIXME verify this
|
||||||
'drill plated': r'.*\.(drl)', # diptrace has unplated drills on the outline layer
|
'drill plated': r'.*\.(drl)', # diptrace has unplated drills on the outline layer
|
||||||
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
|
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
|
||||||
'header regex': [['sufficient', r'top .*|bottom .*', r'G04 DipTrace [.-0-9a-z]*\*']],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'target': {
|
'target': {
|
||||||
|
|
@ -152,25 +151,22 @@ MATCH_RULES = {
|
||||||
|
|
||||||
'allegro': {
|
'allegro': {
|
||||||
# Allegro doesn't have any widespread convention, so we rely heavily on the layer name auto-guesser here.
|
# Allegro doesn't have any widespread convention, so we rely heavily on the layer name auto-guesser here.
|
||||||
'drill plated': r'.*\.(drl)',
|
'drill mech': r'.*\.(drl|rou)',
|
||||||
'drill nonplated': r'.*\.(rou)',
|
'generic gerber': r'.*\.art',
|
||||||
'other unknown': r'.*(place|assembly|keep.?in|keep.?out).*\.art',
|
|
||||||
'autoguess': r'.*\.art',
|
|
||||||
'excellon params': r'nc_param\.txt|ncdrill\.log|ncroute\.log',
|
'excellon params': r'nc_param\.txt|ncdrill\.log|ncroute\.log',
|
||||||
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
|
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
|
||||||
'header regex': [['required,sufficient', r'.*\.art', r'G04 File Origin:\s+Cadence Allegro [0-9]+\.[0-9]+[-a-zA-Z0-9]*']],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'pads': {
|
'pads': {
|
||||||
# Pads also does not seem to have a factory-default naming schema. Or it has one but everyone ignores it.
|
# Pads also does not seem to have a factory-default naming schema. Or it has one but everyone ignores it.
|
||||||
'autoguess': r'.*\.pho',
|
'generic gerber': r'.*\.pho',
|
||||||
'drill plated': r'.*\.drl',
|
'drill mech': r'.*\.drl',
|
||||||
},
|
},
|
||||||
|
|
||||||
'zuken': {
|
'zuken': {
|
||||||
'autoguess': r'.*\.fph',
|
'generic gerber': r'.*\.fph',
|
||||||
'gerber params': r'.*\.fpl',
|
'gerber params': r'.*\.fpl',
|
||||||
'drill unknown': r'.*\.fdr',
|
'drill mech': r'.*\.fdr',
|
||||||
'excellon params': r'.*\.fdl',
|
'excellon params': r'.*\.fdl',
|
||||||
'other netlist': r'.*\.ipc',
|
'other netlist': r'.*\.ipc',
|
||||||
'ipc-2581': r'.*\.xml',
|
'ipc-2581': r'.*\.xml',
|
||||||
|
|
@ -29,7 +29,6 @@ import itertools
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from zipfile import ZipFile, is_zipfile
|
from zipfile import ZipFile, is_zipfile
|
||||||
from collections import defaultdict
|
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from .excellon import ExcellonFile, parse_allegro_ncparam, parse_allegro_logfile
|
from .excellon import ExcellonFile, parse_allegro_ncparam, parse_allegro_logfile
|
||||||
|
|
@ -39,7 +38,6 @@ from .cam import FileSettings, LazyCamFile
|
||||||
from .layer_rules import MATCH_RULES
|
from .layer_rules import MATCH_RULES
|
||||||
from .utils import sum_bounds, setup_svg, MM, Tag, convex_hull
|
from .utils import sum_bounds, setup_svg, MM, Tag, convex_hull
|
||||||
from . import graphic_objects as go
|
from . import graphic_objects as go
|
||||||
from . import apertures as ap
|
|
||||||
from . import graphic_primitives as gp
|
from . import graphic_primitives as gp
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,27 +64,25 @@ DEFAULT_COLORS = {
|
||||||
|
|
||||||
class NamingScheme:
|
class NamingScheme:
|
||||||
kicad = {
|
kicad = {
|
||||||
'top copper': '{board_name}-F_Cu.gbr',
|
'top copper': '{board_name}-F.Cu.gbr',
|
||||||
'top mask': '{board_name}-F_Mask.gbr',
|
'top mask': '{board_name}-F.Mask.gbr',
|
||||||
'top silk': '{board_name}-F_SilkS.gbr',
|
'top silk': '{board_name}-F.SilkS.gbr',
|
||||||
'top paste': '{board_name}-F_Paste.gbr',
|
'top paste': '{board_name}-F.Paste.gbr',
|
||||||
'bottom copper': '{board_name}-B_Cu.gbr',
|
'bottom copper': '{board_name}-B.Cu.gbr',
|
||||||
'bottom mask': '{board_name}-B_Mask.gbr',
|
'bottom mask': '{board_name}-B.Mask.gbr',
|
||||||
'bottom silk': '{board_name}-B_SilkS.gbr',
|
'bottom silk': '{board_name}-B.SilkS.gbr',
|
||||||
'bottom paste': '{board_name}-B_Paste.gbr',
|
'bottom paste': '{board_name}-B.Paste.gbr',
|
||||||
'inner copper': '{board_name}-In{layer_number}_Cu.gbr',
|
'inner copper': '{board_name}-In{layer_number}.Cu.gbr',
|
||||||
'mechanical outline': '{board_name}-Edge_Cuts.gbr',
|
'mechanical outline': '{board_name}-Edge.Cuts.gbr',
|
||||||
'drill unknown': '{board_name}.drl',
|
'drill unknown': '{board_name}.drl',
|
||||||
'drill plated': '{board_name}-PTH.drl',
|
'drill plated': '{board_name}-PTH.drl',
|
||||||
'drill nonplated': '{board_name}-NPTH.drl',
|
'drill nonplated': '{board_name}-NPTH.drl',
|
||||||
'other comments': '{board_name}-Cmts_User.gbr',
|
'other comments': '{board_name}-Cmts.User.gbr',
|
||||||
'other drawings': '{board_name}-Dwgs_User.gbr',
|
'other drawings': '{board_name}-Dwgs.User.gbr',
|
||||||
'top fabrication': '{board_name}-F_Fab.gbr',
|
'top fabrication': '{board_name}-F.Fab.gbr',
|
||||||
'bottom fabrication': '{board_name}-B_Fab.gbr',
|
'bottom fabrication': '{board_name}-B.Fab.gbr',
|
||||||
'top adhesive': '{board_name}-F_Adhes.gbr',
|
'top courtyard': '{board_name}-F.CrtYd.gbr',
|
||||||
'bottom adhesive': '{board_name}-B_Adhes.gbr',
|
'bottom courtyard': '{board_name}-B.CrtYd.gbr',
|
||||||
'top courtyard': '{board_name}-F_CrtYd.gbr',
|
|
||||||
'bottom courtyard': '{board_name}-B_CrtYd.gbr',
|
|
||||||
'other netlist': '{board_name}.d356',
|
'other netlist': '{board_name}.d356',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,61 +109,31 @@ class NamingScheme:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def apply_rules(filenames, rules):
|
|
||||||
certain = False
|
|
||||||
gen = {}
|
|
||||||
already_matched = set()
|
|
||||||
header_regex = rules.pop('header regex', [])
|
|
||||||
header_regex_matched = [False] * len(header_regex)
|
|
||||||
|
|
||||||
file_headers = {}
|
def _match_files(filenames):
|
||||||
def get_header(path):
|
|
||||||
if path not in file_headers:
|
|
||||||
with open(path) as f:
|
|
||||||
file_headers[path] = f.read(16384)
|
|
||||||
return file_headers[path]
|
|
||||||
|
|
||||||
for layer, regex in rules.items():
|
|
||||||
for fn in filenames:
|
|
||||||
if fn in already_matched:
|
|
||||||
continue
|
|
||||||
|
|
||||||
target = None
|
|
||||||
if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)):
|
|
||||||
if layer == 'inner copper':
|
|
||||||
target = 'inner_' + ''.join(e or '' for e in m.groups()) + ' copper'
|
|
||||||
else:
|
|
||||||
target = layer
|
|
||||||
|
|
||||||
gen[target] = gen.get(target, []) + [fn]
|
|
||||||
already_matched.add(fn)
|
|
||||||
|
|
||||||
for i, (match_type, layer_match, header_match) in enumerate(header_regex):
|
|
||||||
if re.fullmatch(layer_match, fn.name, re.IGNORECASE) or (
|
|
||||||
target is not None and re.fullmatch(layer_match, target, re.IGNORECASE)):
|
|
||||||
if re.search(header_match, get_header(fn)):
|
|
||||||
|
|
||||||
if 'sufficient' in match_type:
|
|
||||||
certain = True
|
|
||||||
|
|
||||||
header_regex_matched[i] = True
|
|
||||||
|
|
||||||
if any('required' in match_type and not match
|
|
||||||
for match, (match_type, *_) in zip(header_regex_matched, header_regex)):
|
|
||||||
return False, {}
|
|
||||||
|
|
||||||
return certain, gen
|
|
||||||
|
|
||||||
def _best_match(filenames):
|
|
||||||
matches = {}
|
matches = {}
|
||||||
for generator, rules in MATCH_RULES.items():
|
for generator, rules in MATCH_RULES.items():
|
||||||
certain, candidate = apply_rules(filenames, rules)
|
already_matched = set()
|
||||||
|
gen = {}
|
||||||
|
matches[generator] = gen
|
||||||
|
for layer, regex in rules.items():
|
||||||
|
for fn in filenames:
|
||||||
|
if fn in already_matched:
|
||||||
|
continue
|
||||||
|
|
||||||
if certain:
|
if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)):
|
||||||
return generator, candidate
|
if layer == 'inner copper':
|
||||||
|
target = 'inner_' + ''.join(e or '' for e in m.groups()) + ' copper'
|
||||||
|
else:
|
||||||
|
target = layer
|
||||||
|
|
||||||
matches[generator] = candidate
|
gen[target] = gen.get(target, []) + [fn]
|
||||||
|
already_matched.add(fn)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _best_match(filenames):
|
||||||
|
matches = _match_files(filenames)
|
||||||
matches = sorted(matches.items(), key=lambda pair: len(pair[1]))
|
matches = sorted(matches.items(), key=lambda pair: len(pair[1]))
|
||||||
generator, files = matches[-1]
|
generator, files = matches[-1]
|
||||||
return generator, files
|
return generator, files
|
||||||
|
|
@ -274,7 +240,7 @@ def _layername_autoguesser(fn):
|
||||||
elif re.search('film', fn):
|
elif re.search('film', fn):
|
||||||
use = 'copper'
|
use = 'copper'
|
||||||
|
|
||||||
elif re.search('out(line)?|board.?geom(etry)?', fn):
|
elif re.search('out(line)?', fn):
|
||||||
use = 'outline'
|
use = 'outline'
|
||||||
side = 'mechanical'
|
side = 'mechanical'
|
||||||
|
|
||||||
|
|
@ -304,9 +270,6 @@ def _sort_layername(val):
|
||||||
assert side.startswith('inner_')
|
assert side.startswith('inner_')
|
||||||
return int(side[len('inner_'):])
|
return int(side[len('inner_'):])
|
||||||
|
|
||||||
def convex_hull_to_lines(points, unit=MM):
|
|
||||||
for (x1, y1), (x2, y2) in zip(points, points[1:] + points):
|
|
||||||
yield go.Line(x1, y1, x2, y2, aperture=ap.CircleAperture(unit(0.1, MM), unit=unit), unit=unit)
|
|
||||||
|
|
||||||
class LayerStack:
|
class LayerStack:
|
||||||
""" :py:class:`LayerStack` represents a set of Gerber files that describe different layers of the same board.
|
""" :py:class:`LayerStack` represents a set of Gerber files that describe different layers of the same board.
|
||||||
|
|
@ -326,30 +289,13 @@ class LayerStack:
|
||||||
:py:obj:`"altium"`
|
:py:obj:`"altium"`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, graphic_layers=None, drill_pth=None, drill_npth=None, drill_layers=(), netlist=None,
|
def __init__(self, graphic_layers=None, drill_pth=None, drill_npth=None, drill_layers=(), netlist=None, board_name=None, original_path=None, was_zipped=False, generator=None):
|
||||||
board_name=None, original_path=None, was_zipped=False, generator=None, courtyard=False,
|
|
||||||
fabrication=False, adhesive=False):
|
|
||||||
if not drill_layers and (graphic_layers, drill_pth, drill_npth) == (None, None, None):
|
if not drill_layers and (graphic_layers, drill_pth, drill_npth) == (None, None, None):
|
||||||
graphic_layers = {tuple(layer.split()): GerberFile()
|
graphic_layers = {tuple(layer.split()): GerberFile()
|
||||||
for layer in ('top paste', 'top silk', 'top mask', 'top copper',
|
for layer in ('top paste', 'top silk', 'top mask', 'top copper',
|
||||||
'bottom copper', 'bottom mask', 'bottom silk', 'bottom paste',
|
'bottom copper', 'bottom mask', 'bottom silk', 'bottom paste',
|
||||||
'mechanical outline')}
|
'mechanical outline')}
|
||||||
|
|
||||||
if courtyard:
|
|
||||||
graphic_layers = {('top', 'courtyard'): GerberFile(),
|
|
||||||
**graphic_layers,
|
|
||||||
('bottom', 'courtyard'): GerberFile()}
|
|
||||||
|
|
||||||
if fabrication:
|
|
||||||
graphic_layers = {('top', 'fabrication'): GerberFile(),
|
|
||||||
**graphic_layers,
|
|
||||||
('bottom', 'fabrication'): GerberFile()}
|
|
||||||
|
|
||||||
if adhesive:
|
|
||||||
graphic_layers = {('top', 'adhesive'): GerberFile(),
|
|
||||||
**graphic_layers,
|
|
||||||
('bottom', 'adhesive'): GerberFile()}
|
|
||||||
|
|
||||||
drill_pth = ExcellonFile()
|
drill_pth = ExcellonFile()
|
||||||
drill_npth = ExcellonFile()
|
drill_npth = ExcellonFile()
|
||||||
|
|
||||||
|
|
@ -419,7 +365,7 @@ class LayerStack:
|
||||||
with ZipFile(file) as f:
|
with ZipFile(file) as f:
|
||||||
f.extractall(path=tmp_indir)
|
f.extractall(path=tmp_indir)
|
||||||
|
|
||||||
inst = kls.open_dir(tmp_indir, board_name=board_name, lazy=lazy, overrides=overrides, autoguess=autoguess)
|
inst = kls.open_dir(tmp_indir, board_name=board_name, lazy=lazy)
|
||||||
inst.tmpdir = tmpdir
|
inst.tmpdir = tmpdir
|
||||||
inst.original_path = Path(original_path or file)
|
inst.original_path = Path(original_path or file)
|
||||||
inst.was_zipped = True
|
inst.was_zipped = True
|
||||||
|
|
@ -455,7 +401,6 @@ class LayerStack:
|
||||||
given value.
|
given value.
|
||||||
:rtype: :py:class:`LayerStack`
|
:rtype: :py:class:`LayerStack`
|
||||||
"""
|
"""
|
||||||
print_layermap = False
|
|
||||||
|
|
||||||
if autoguess:
|
if autoguess:
|
||||||
generator, filemap = _best_match(files)
|
generator, filemap = _best_match(files)
|
||||||
|
|
@ -480,51 +425,14 @@ class LayerStack:
|
||||||
filemap[layer].remove(fn)
|
filemap[layer].remove(fn)
|
||||||
filemap[layer] = filemap.get(layer, []) + [fn]
|
filemap[layer] = filemap.get(layer, []) + [fn]
|
||||||
|
|
||||||
if 'autoguess' in filemap:
|
|
||||||
warnings.warn(f'This generator ({generator}) often exports ambiguous filenames. Falling back to autoguesser for some files. Use at your own peril. Autoguessed files: {", ".join(f.name for f in filemap["autoguess"])}')
|
|
||||||
print_layermap = True
|
|
||||||
autoguess_filenames = filemap.pop('autoguess')
|
|
||||||
|
|
||||||
matched = set()
|
|
||||||
for key, values in _do_autoguess(autoguess_filenames).items():
|
|
||||||
filemap[key] = filemap.get(key, []) + values
|
|
||||||
matched |= set(values)
|
|
||||||
|
|
||||||
if generator == 'allegro':
|
|
||||||
# Allegro gerbers often contain the inner layers with completely random filenames and no indication of
|
|
||||||
# layer ordering except for drawings in the mechanical files. We fall back to alphabetic ordering.
|
|
||||||
for fn in autoguess_filenames:
|
|
||||||
if fn not in matched:
|
|
||||||
with open(fn) as f:
|
|
||||||
header = f.read(16384)
|
|
||||||
if re.search(r'G04 Layer:\s*ETCH/.*\*', header):
|
|
||||||
filemap['unknown copper'] = filemap.get('unknown copper', []) + [fn]
|
|
||||||
|
|
||||||
if (unk := filemap.pop('unknown copper', None)):
|
|
||||||
unk = sorted(unk, key=str)
|
|
||||||
if 'top copper' not in filemap:
|
|
||||||
filemap['top copper'], *unk = [unk]
|
|
||||||
if 'bottom copper' not in filemap:
|
|
||||||
*unk, filemap['bottom copper'] = [unk]
|
|
||||||
|
|
||||||
i = 1
|
|
||||||
while unk and i < 128:
|
|
||||||
key = f'inner_{i:02d} copper'
|
|
||||||
if key not in filemap:
|
|
||||||
filemap[key] = [unk.pop(0)]
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if sum(len(files) for files in filemap.values()) < 6 and autoguess:
|
if sum(len(files) for files in filemap.values()) < 6 and autoguess:
|
||||||
warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
|
warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
|
||||||
generator = None
|
generator = None
|
||||||
print_layermap = True
|
|
||||||
filemap = _do_autoguess(files)
|
filemap = _do_autoguess(files)
|
||||||
if len(filemap) < 6:
|
if len(filemap) < 6:
|
||||||
raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap)
|
raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap)
|
||||||
|
|
||||||
excellon_settings, external_tools = None, None
|
excellon_settings, external_tools = None, None
|
||||||
automatch_drill_scale = False
|
|
||||||
|
|
||||||
if generator == 'geda':
|
if generator == 'geda':
|
||||||
# geda is written by geniuses who waste no bytes of unnecessary output so it doesn't actually include the
|
# geda is written by geniuses who waste no bytes of unnecessary output so it doesn't actually include the
|
||||||
# number format in files that use imperial units. Unfortunately it also doesn't include any hints that the
|
# number format in files that use imperial units. Unfortunately it also doesn't include any hints that the
|
||||||
|
|
@ -542,22 +450,16 @@ class LayerStack:
|
||||||
if (external_tools := parse_allegro_logfile(file.read_text())):
|
if (external_tools := parse_allegro_logfile(file.read_text())):
|
||||||
break
|
break
|
||||||
del filemap['excellon params']
|
del filemap['excellon params']
|
||||||
else:
|
# Ignore if we can't find the param file -- maybe the user has convinced Allegro to actually put this
|
||||||
# Ignore if we can't find the param file -- maybe the user has convinced Allegro to actually put this
|
# information into a comment, or maybe they have made Allegro just use decimal points like XNC does.
|
||||||
# information into a comment, or maybe they have made Allegro just use decimal points like XNC does.
|
|
||||||
# We'll run an automatic scale matching later.
|
|
||||||
excellon_settings = FileSettings(number_format=(2, 4))
|
|
||||||
automatch_drill_scale = True
|
|
||||||
|
|
||||||
print('remaining filemap')
|
|
||||||
import pprint
|
|
||||||
pprint.pprint(filemap)
|
|
||||||
|
|
||||||
|
filemap = _do_autoguess([ f for files in filemap.values() for f in files ])
|
||||||
if len(filemap) < 6:
|
if len(filemap) < 6:
|
||||||
raise SystemError('Cannot figure out gerber file mapping')
|
raise SystemError('Cannot figure out gerber file mapping')
|
||||||
# FIXME use layer metadata from comments and ipc file if available
|
# FIXME use layer metadata from comments and ipc file if available
|
||||||
|
|
||||||
elif generator == 'zuken':
|
elif generator == 'zuken':
|
||||||
|
filemap = _do_autoguess([ f for files in filemap.values() for f in files ])
|
||||||
if len(filemap) < 6:
|
if len(filemap) < 6:
|
||||||
raise SystemError('Cannot figure out gerber file mapping')
|
raise SystemError('Cannot figure out gerber file mapping')
|
||||||
# FIXME use layer metadata from comments and ipc file if available
|
# FIXME use layer metadata from comments and ipc file if available
|
||||||
|
|
@ -581,12 +483,7 @@ class LayerStack:
|
||||||
else:
|
else:
|
||||||
excellon_settings = None
|
excellon_settings = None
|
||||||
|
|
||||||
ambiguous = [ f'{key} ({", ".join(x.name for x in value)})'
|
ambiguous = [ f'{key} ({", ".join(x.name for x in value)})' for key, value in filemap.items() if len(value) > 1 and not 'drill' in key ]
|
||||||
for key, value in filemap.items()
|
|
||||||
if len(value) > 1 and\
|
|
||||||
not 'drill' in key and\
|
|
||||||
not 'excellon' in key and\
|
|
||||||
not key == 'other unknown']
|
|
||||||
if ambiguous:
|
if ambiguous:
|
||||||
raise SystemError(f'Ambiguous layer names: {", ".join(ambiguous)}')
|
raise SystemError(f'Ambiguous layer names: {", ".join(ambiguous)}')
|
||||||
|
|
||||||
|
|
@ -595,11 +492,8 @@ class LayerStack:
|
||||||
netlist = None
|
netlist = None
|
||||||
layers = {} # { tuple(key.split()): None for key in STANDARD_LAYERS }
|
layers = {} # { tuple(key.split()): None for key in STANDARD_LAYERS }
|
||||||
for key, paths in filemap.items():
|
for key, paths in filemap.items():
|
||||||
if len(paths) > 1 and\
|
if len(paths) > 1 and not 'drill' in key:
|
||||||
not 'drill' in key and\
|
raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(value)}')
|
||||||
not 'excellon' in key and\
|
|
||||||
not key == 'other unknown':
|
|
||||||
raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(map(str, value))}')
|
|
||||||
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
id_result = identify_file(path.read_text())
|
id_result = identify_file(path.read_text())
|
||||||
|
|
@ -660,72 +554,9 @@ class LayerStack:
|
||||||
board_name = re.sub(r'^\W+', '', board_name)
|
board_name = re.sub(r'^\W+', '', board_name)
|
||||||
board_name = re.sub(r'\W+$', '', board_name)
|
board_name = re.sub(r'\W+$', '', board_name)
|
||||||
|
|
||||||
if automatch_drill_scale:
|
return kls(layers, drill_pth, drill_npth, drill_layers, board_name=board_name,
|
||||||
top_copper = layers[('top', 'copper')].to_excellon(errors='ignore', holes_only=True)
|
|
||||||
|
|
||||||
# precision is matching precision in mm
|
|
||||||
def map_coords(obj, precision=0.01, scale=1):
|
|
||||||
obj = obj.converted(MM)
|
|
||||||
return round(obj.x*scale/precision), round(obj.y*scale/precision)
|
|
||||||
|
|
||||||
aper_coords = {map_coords(obj) for obj in top_copper.drills()}
|
|
||||||
|
|
||||||
for drill_file in [drill_pth, drill_npth, *drill_layers]:
|
|
||||||
if not drill_file or not drill_pth.import_settings._file_has_fixed_width_coordinates:
|
|
||||||
continue
|
|
||||||
|
|
||||||
scale_matches = {}
|
|
||||||
for exp in range(-6, 6):
|
|
||||||
scale = 10**exp
|
|
||||||
hole_coords = {map_coords(obj, scale=scale) for obj in drill_file.drills()}
|
|
||||||
|
|
||||||
scale_matches[scale] = len(aper_coords - hole_coords), len(hole_coords - aper_coords)
|
|
||||||
scales_out = [(max(a, b), scale) for scale, (a, b) in scale_matches.items()]
|
|
||||||
_matches, scale = sorted(scales_out)[0]
|
|
||||||
warnings.warn(f'Performing automatic alignment of poorly exported drill layer. Scale matching results: {scale_matches}. Chosen scale: {scale}')
|
|
||||||
|
|
||||||
# Note: This is only used with allegro files, which use decimal points and explicit units in their tool
|
|
||||||
# definitions. Thus, we only scale object coordinates, and not apertures.
|
|
||||||
for obj in drill_file.objects:
|
|
||||||
obj.scale(scale)
|
|
||||||
|
|
||||||
stack = kls(layers, drill_pth, drill_npth, drill_layers, board_name=board_name,
|
|
||||||
original_path=original_path, was_zipped=was_zipped, generator=[*all_generator_hints, None][0])
|
original_path=original_path, was_zipped=was_zipped, generator=[*all_generator_hints, None][0])
|
||||||
|
|
||||||
if print_layermap:
|
|
||||||
warnings.warn('Auto-guessed layer map:\n' + stack.format_layer_map())
|
|
||||||
return stack
|
|
||||||
|
|
||||||
def format_layer_map(self):
|
|
||||||
lines = []
|
|
||||||
def print_layer(prefix, file):
|
|
||||||
nonlocal lines
|
|
||||||
if file is None:
|
|
||||||
lines.append(f'{prefix} <not found>')
|
|
||||||
else:
|
|
||||||
lines.append(f'{prefix} {file.original_path.name} {file}')
|
|
||||||
|
|
||||||
lines.append(' Drill files:')
|
|
||||||
print_layer(' Plated holes:', self.drill_pth)
|
|
||||||
print_layer(' Nonplated holes:', self.drill_npth)
|
|
||||||
for i, l in enumerate(self._drill_layers):
|
|
||||||
print_layer(f' Additional drill layer {i}:', l)
|
|
||||||
|
|
||||||
print_layer(' Board outline:', self.get('mechanical outline'))
|
|
||||||
|
|
||||||
lines.append(' Soldermask:')
|
|
||||||
print_layer(' Top:', self.get('top mask'))
|
|
||||||
print_layer(' Bottom:', self.get('bottom mask'))
|
|
||||||
|
|
||||||
lines.append(' Silkscreen:')
|
|
||||||
print_layer(' Top:', self.get('top silk'))
|
|
||||||
print_layer(' Bottom:', self.get('bottom silk'))
|
|
||||||
|
|
||||||
lines.append(' Copper:')
|
|
||||||
for (side, _use), layer in self.copper_layers:
|
|
||||||
print_layer(f' {side}:', layer)
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
def save_to_zipfile(self, path, prefix='', overwrite_existing=True, board_name=None, naming_scheme={},
|
def save_to_zipfile(self, path, prefix='', overwrite_existing=True, board_name=None, naming_scheme={},
|
||||||
gerber_settings=None, excellon_settings=None):
|
gerber_settings=None, excellon_settings=None):
|
||||||
""" Save this board into a zip file at the given path. For other options, see
|
""" Save this board into a zip file at the given path. For other options, see
|
||||||
|
|
@ -738,7 +569,10 @@ class LayerStack:
|
||||||
|
|
||||||
:param prefix: Store output files under the given prefix inside the zip file
|
:param prefix: Store output files under the given prefix inside the zip file
|
||||||
"""
|
"""
|
||||||
if path.is_file() and not overwrite_existing:
|
if path.is_file():
|
||||||
|
if overwrite_existing:
|
||||||
|
path.unlink()
|
||||||
|
else:
|
||||||
raise ValueError('output zip file already exists and overwrite_existing is False')
|
raise ValueError('output zip file already exists and overwrite_existing is False')
|
||||||
|
|
||||||
if gerber_settings and not excellon_settings:
|
if gerber_settings and not excellon_settings:
|
||||||
|
|
@ -845,7 +679,7 @@ class LayerStack:
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return str(self)
|
return str(self)
|
||||||
|
|
||||||
def to_svg(self, margin=0, side_re='.*', drills=True, arg_unit=MM, svg_unit=MM, force_bounds=None, colors=None, tag=Tag):
|
def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag):
|
||||||
""" Convert this layer stack to a plain SVG string. This is intended for use cases where the resulting SVG will
|
""" Convert this layer stack to a plain SVG string. This is intended for use cases where the resulting SVG will
|
||||||
be processed by other tools, and thus styling with colors or extra markup like Inkscape layer information are
|
be processed by other tools, and thus styling with colors or extra markup like Inkscape layer information are
|
||||||
unwanted. If you want to instead generate a nice-looking preview image for display or graphical editing in tools
|
unwanted. If you want to instead generate a nice-looking preview image for display or graphical editing in tools
|
||||||
|
|
@ -855,9 +689,6 @@ class LayerStack:
|
||||||
mirrored vertically.
|
mirrored vertically.
|
||||||
|
|
||||||
:param margin: Export SVG file with given margin around the board's bounding box.
|
:param margin: Export SVG file with given margin around the board's bounding box.
|
||||||
:param side_re: A regex, such as ``'top'``, ``'bottom'``, or ``'.*'`` (default). Selects which layers to export.
|
|
||||||
The default includes inner layers.
|
|
||||||
:param drills: :py:obj:`bool` setting if drills are included (default) or not.
|
|
||||||
:param arg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit ``margin`` and
|
:param arg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit ``margin`` and
|
||||||
``force_bounds`` are specified in. Default: mm
|
``force_bounds`` are specified in. Default: mm
|
||||||
:param svg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit to use inside the SVG file.
|
:param svg_unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Which unit to use inside the SVG file.
|
||||||
|
|
@ -865,7 +696,6 @@ class LayerStack:
|
||||||
:param force_bounds: Use bounds given as :py:obj:`((min_x, min_y), (max_x, max_y))` tuple for the output SVG
|
:param force_bounds: Use bounds given as :py:obj:`((min_x, min_y), (max_x, max_y))` tuple for the output SVG
|
||||||
file instead of deriving them from this board's bounding box and ``margin``. Note that this
|
file instead of deriving them from this board's bounding box and ``margin``. Note that this
|
||||||
will not scale or move the board, but instead will only crop the viewport.
|
will not scale or move the board, but instead will only crop the viewport.
|
||||||
:param colors: Dict mapping ``f'{side} {use}'`` strings to SVG colors.
|
|
||||||
:param tag: Extension point to support alternative XML serializers in addition to the built-in one.
|
:param tag: Extension point to support alternative XML serializers in addition to the built-in one.
|
||||||
:rtype: :py:obj:`str`
|
:rtype: :py:obj:`str`
|
||||||
"""
|
"""
|
||||||
|
|
@ -876,34 +706,19 @@ class LayerStack:
|
||||||
|
|
||||||
stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'}
|
stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'}
|
||||||
|
|
||||||
if colors is None:
|
|
||||||
colors = defaultdict(lambda: 'black')
|
|
||||||
|
|
||||||
tags = []
|
tags = []
|
||||||
layer_transform = f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)'
|
for (side, use), layer in self.graphic_layers.items():
|
||||||
for (side, use), layer in reversed(self.graphic_layers.items()):
|
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg='black', bg="white", tag=Tag)),
|
||||||
if re.fullmatch(side_re, side) and (fg := colors.get(f'{side} {use}')):
|
**stroke_attrs, id=f'l-{side}-{use}'))
|
||||||
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
|
|
||||||
**stroke_attrs, id=f'l-{side}-{use}', transform=layer_transform))
|
|
||||||
|
|
||||||
if drills:
|
for i, layer in enumerate(self.drill_layers):
|
||||||
if self.drill_pth and (fg := colors.get('drill pth')):
|
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg='black', bg="white", tag=Tag)),
|
||||||
tags.append(tag('g', list(self.drill_pth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
|
**stroke_attrs, id=f'l-drill-{i}'))
|
||||||
**stroke_attrs, id=f'l-drill-pth', transform=layer_transform))
|
|
||||||
|
|
||||||
if self.drill_npth and (fg := colors.get('drill npth')):
|
|
||||||
tags.append(tag('g', list(self.drill_npth.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
|
|
||||||
**stroke_attrs, id=f'l-drill-npth', transform=layer_transform))
|
|
||||||
|
|
||||||
if (fg := colors.get('drill unknown')):
|
|
||||||
for i, layer in enumerate(self._drill_layers):
|
|
||||||
tags.append(tag('g', list(layer.svg_objects(svg_unit=svg_unit, fg=fg, bg="white", tag=Tag)),
|
|
||||||
**stroke_attrs, id=f'l-drill-{i}', transform=layer_transform))
|
|
||||||
|
|
||||||
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, tag=tag)
|
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, tag=tag)
|
||||||
|
|
||||||
def to_pretty_svg(self, side='top', margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag, inkscape=False,
|
def to_pretty_svg(self, side='top', margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, tag=Tag, inkscape=False,
|
||||||
colors=None, use=True):
|
colors=None):
|
||||||
""" Convert this layer stack to a pretty SVG string that is suitable for display or for editing in tools such as
|
""" Convert this layer stack to a pretty SVG string that is suitable for display or for editing in tools such as
|
||||||
Inkscape. If you want to process the resulting SVG in other tools, consider using
|
Inkscape. If you want to process the resulting SVG in other tools, consider using
|
||||||
:py:meth:`~layers.LayerStack.to_svg` instead, which produces output without color styling or blending based on
|
:py:meth:`~layers.LayerStack.to_svg` instead, which produces output without color styling or blending based on
|
||||||
|
|
@ -929,12 +744,10 @@ class LayerStack:
|
||||||
:py:obj:`"bottom"`, and :py:obj:`"mechanical"` as well as :py:obj:`"inner1"`, :py:obj:`"inner2"`
|
:py:obj:`"bottom"`, and :py:obj:`"mechanical"` as well as :py:obj:`"inner1"`, :py:obj:`"inner2"`
|
||||||
etc. for internal layers. Valid use values are :py:obj:`"mask"`, :py:obj:`"silk"`,
|
etc. for internal layers. Valid use values are :py:obj:`"mask"`, :py:obj:`"silk"`,
|
||||||
:py:obj:`"paste"`, and :py:obj:`"copper"`. For internal layers, only :py:obj:`"copper"` is valid.
|
:py:obj:`"paste"`, and :py:obj:`"copper"`. For internal layers, only :py:obj:`"copper"` is valid.
|
||||||
:param use: Enable/disable ``<use>`` tags for aperture flashes. Defaults to :py:obj:`True` (enabled).
|
|
||||||
:rtype: :py:obj:`str`
|
:rtype: :py:obj:`str`
|
||||||
"""
|
"""
|
||||||
if colors is None:
|
if colors is None:
|
||||||
colors = DEFAULT_COLORS
|
colors = DEFAULT_COLORS
|
||||||
use_use = use
|
|
||||||
|
|
||||||
colors_alpha = {}
|
colors_alpha = {}
|
||||||
for layer, color in colors.items():
|
for layer, color in colors.items():
|
||||||
|
|
@ -969,9 +782,6 @@ class LayerStack:
|
||||||
|
|
||||||
inkscape_attrs = lambda label: dict(inkscape__groupmode='layer', inkscape__label=label) if inkscape else {}
|
inkscape_attrs = lambda label: dict(inkscape__groupmode='layer', inkscape__label=label) if inkscape else {}
|
||||||
stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'}
|
stroke_attrs = {'stroke_linejoin': 'round', 'stroke_linecap': 'round'}
|
||||||
layer_transform=f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)'
|
|
||||||
|
|
||||||
use_defs = []
|
|
||||||
|
|
||||||
layers = []
|
layers = []
|
||||||
for use in ['copper', 'mask', 'silk', 'paste']:
|
for use in ['copper', 'mask', 'silk', 'paste']:
|
||||||
|
|
@ -979,25 +789,14 @@ class LayerStack:
|
||||||
warnings.warn(f'Layer "{side} {use}" not found. Found layers: {", ".join(side + " " + use for side, use in self.graphic_layers)}')
|
warnings.warn(f'Layer "{side} {use}" not found. Found layers: {", ".join(side + " " + use for side, use in self.graphic_layers)}')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
layer = self[(side, use)].instance
|
layer = self[(side, use)]
|
||||||
|
|
||||||
fg, bg = ('white', 'black') if use != 'mask' else ('black', 'white')
|
fg, bg = ('white', 'black') if use != 'mask' else ('black', 'white')
|
||||||
|
|
||||||
default_fill = {'copper': fg, 'mask': fg, 'silk': 'none', 'paste': fg}[use]
|
default_fill = {'copper': fg, 'mask': fg, 'silk': 'none', 'paste': fg}[use]
|
||||||
default_stroke = {'copper': 'none', 'mask': 'none', 'silk': fg, 'paste': 'none'}[use]
|
default_stroke = {'copper': 'none', 'mask': 'none', 'silk': fg, 'paste': 'none'}[use]
|
||||||
|
|
||||||
use_map = {}
|
|
||||||
if use_use:
|
|
||||||
layer.dedup_apertures()
|
|
||||||
for obj in layer.objects:
|
|
||||||
if hasattr(obj, 'aperture') and obj.polarity_dark and obj.aperture not in use_map:
|
|
||||||
children = [prim.to_svg(fg, bg, tag=tag)
|
|
||||||
for prim in obj.aperture.flash(0, 0, svg_unit, polarity_dark=True)]
|
|
||||||
use_id = f'a{len(use_defs)}'
|
|
||||||
use_defs.append(tag('g', children, id=use_id))
|
|
||||||
use_map[obj.aperture] = use_id
|
|
||||||
|
|
||||||
objects = []
|
objects = []
|
||||||
for obj in layer.instance.svg_objects(svg_unit=svg_unit, fg=fg, bg=bg, aperture_map=use_map, tag=Tag):
|
for obj in layer.instance.svg_objects(svg_unit=svg_unit, fg=fg, bg=bg, tag=Tag):
|
||||||
if obj.attrs.get('fill') == default_fill:
|
if obj.attrs.get('fill') == default_fill:
|
||||||
del obj.attrs['fill']
|
del obj.attrs['fill']
|
||||||
elif 'fill' not in obj.attrs:
|
elif 'fill' not in obj.attrs:
|
||||||
|
|
@ -1012,26 +811,19 @@ class LayerStack:
|
||||||
if use == 'mask':
|
if use == 'mask':
|
||||||
objects.insert(0, tag('path', id='outline-path', d=self.outline_svg_d(unit=svg_unit), fill='white'))
|
objects.insert(0, tag('path', id='outline-path', d=self.outline_svg_d(unit=svg_unit), fill='white'))
|
||||||
layers.append(tag('g', objects, id=f'l-{side}-{use}', filter=f'url(#f-{use})',
|
layers.append(tag('g', objects, id=f'l-{side}-{use}', filter=f'url(#f-{use})',
|
||||||
fill=default_fill, stroke=default_stroke, **stroke_attrs, fill_rule='evenodd',
|
fill=default_fill, stroke=default_stroke, **stroke_attrs,
|
||||||
**inkscape_attrs(f'{side} {use}'), transform=layer_transform))
|
**inkscape_attrs(f'{side} {use}')))
|
||||||
|
|
||||||
for i, layer in enumerate(self.drill_layers):
|
for i, layer in enumerate(self.drill_layers):
|
||||||
layers.append(tag('g', list(layer.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)),
|
layers.append(tag('g', list(layer.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)),
|
||||||
id=f'l-drill-{i}', filter=f'url(#f-drill)', **stroke_attrs, **inkscape_attrs(f'drill-{i}'),
|
id=f'g-drill-{i}', filter=f'url(#f-drill)', **stroke_attrs, **inkscape_attrs(f'drill-{i}')))
|
||||||
transform=layer_transform))
|
|
||||||
|
|
||||||
if self.outline:
|
if self.outline:
|
||||||
layers.append(tag('g', list(self.outline.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)),
|
layers.append(tag('g', list(self.outline.instance.svg_objects(svg_unit=svg_unit, fg='white', bg='black', tag=Tag)),
|
||||||
id=f'l-mechanical-outline', **stroke_attrs, **inkscape_attrs(f'outline'),
|
id=f'g-outline-{i}', **stroke_attrs, **inkscape_attrs(f'outline-{i}')))
|
||||||
transform=layer_transform))
|
|
||||||
|
|
||||||
sc_y, tl_y = 1, 0
|
layer_group = tag('g', layers, transform=f'translate(0 {bounds[0][1] + bounds[1][1]}) scale(1 -1)')
|
||||||
if side == 'bottom':
|
tags = [tag('defs', filter_defs), layer_group]
|
||||||
sc_x, tl_x = -1, (bounds[0][0] + bounds[1][0])
|
|
||||||
else:
|
|
||||||
sc_x, tl_x = 1, 0
|
|
||||||
layer_group = tag('g', layers, transform=f'translate({tl_x} {tl_y}) scale({sc_x} {sc_y})')
|
|
||||||
tags = [tag('defs', filter_defs + use_defs), layer_group]
|
|
||||||
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor="white", tag=tag, inkscape=inkscape)
|
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit, pagecolor="white", tag=tag, inkscape=inkscape)
|
||||||
|
|
||||||
def bounding_box(self, unit=MM, default=None):
|
def bounding_box(self, unit=MM, default=None):
|
||||||
|
|
@ -1200,20 +992,6 @@ class LayerStack:
|
||||||
|
|
||||||
return self.copper_layers[index][1]
|
return self.copper_layers[index][1]
|
||||||
|
|
||||||
def __setitem__(self, index, value):
|
|
||||||
if isinstance(index, str):
|
|
||||||
side, _, use = index.partition(' ')
|
|
||||||
self.graphic_layers[(side, use)] = value
|
|
||||||
|
|
||||||
elif isinstance(index, tuple):
|
|
||||||
self.graphic_layers[index] = value
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise IndexError('Layer {index} not found. Valid layer indices are "{side} {use}" strings or (side, use) tuples.')
|
|
||||||
|
|
||||||
def add_layer(self, index):
|
|
||||||
self[index] = GerberFile()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def copper_layers(self):
|
def copper_layers(self):
|
||||||
""" Return all copper layers of this board as a list of ((side, use), layer) tuples. Returns an empty list if
|
""" Return all copper layers of this board as a list of ((side, use), layer) tuples. Returns an empty list if
|
||||||
|
|
@ -1260,6 +1038,22 @@ class LayerStack:
|
||||||
polys.append(' '.join(poly.path_d()) + ' Z')
|
polys.append(' '.join(poly.path_d()) + ' Z')
|
||||||
return ' '.join(polys)
|
return ' '.join(polys)
|
||||||
|
|
||||||
|
def outline_convex_hull(self, tol=0.01, unit=MM):
|
||||||
|
points = []
|
||||||
|
for obj in self.outline.instance.objects:
|
||||||
|
if isinstance(obj, go.Line):
|
||||||
|
line = obj.as_primitive(unit)
|
||||||
|
points.append((line.x1, line.y1))
|
||||||
|
points.append((line.x2, line.y2))
|
||||||
|
|
||||||
|
elif isinstance(obj, go.Arc):
|
||||||
|
for obj in obj.approximate(tol, unit):
|
||||||
|
line = obj.as_primitive(unit)
|
||||||
|
points.append((line.x1, line.y1))
|
||||||
|
points.append((line.x2, line.y2))
|
||||||
|
|
||||||
|
return convex_hull(points)
|
||||||
|
|
||||||
def outline_polygons(self, tol=0.01, unit=MM):
|
def outline_polygons(self, tol=0.01, unit=MM):
|
||||||
""" Iterator yielding this boards outline as a list of ordered :py:class:`~.graphic_objects.Arc` and
|
""" Iterator yielding this boards outline as a list of ordered :py:class:`~.graphic_objects.Arc` and
|
||||||
:py:class:`~.graphic_objects.Line` objects. This method first sorts all lines and arcs on the outline layer into
|
:py:class:`~.graphic_objects.Line` objects. This method first sorts all lines and arcs on the outline layer into
|
||||||
|
|
@ -1274,29 +1068,14 @@ class LayerStack:
|
||||||
:param tol: :py:obj:`float` setting the tolerance below which two points are considered equal
|
:param tol: :py:obj:`float` setting the tolerance below which two points are considered equal
|
||||||
:param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). SVG document unit. Default: mm
|
:param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). SVG document unit. Default: mm
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not self.outline:
|
|
||||||
warnings.warn("Board has no outline layer, or the outline layer could not be identified by file name. Using the copper layers' convex hull instead.")
|
|
||||||
points = sum((layer.instance.convex_hull(tol, unit) for (_side, _use), layer in self.copper_layers), start=[])
|
|
||||||
yield list(convex_hull_to_lines(convex_hull(points), unit))
|
|
||||||
return
|
|
||||||
|
|
||||||
maybe_allegro_hint = '' if self.generator != 'allegro' else ' This file looks like it was generated by Allegro/OrCAD. These tools produce quite mal-formed gerbers, and often export text on the outline layer. If you generated this file yourself, maybe try twiddling with the export settings.'
|
|
||||||
polygons = []
|
polygons = []
|
||||||
lines = [ obj.as_primitive(unit) for obj in self.outline.instance.objects if isinstance(obj, (go.Line, go.Arc)) ]
|
lines = [ obj.as_primitive(unit) for obj in self.outline.instance.objects if isinstance(obj, (go.Line, go.Arc)) ]
|
||||||
lines = [ prim for prim in lines if not prim.is_zero_size() ]
|
|
||||||
|
|
||||||
by_x = sorted([ (obj.x1, obj) for obj in lines ] + [ (obj.x2, obj) for obj in lines ], key=lambda x: x[0])
|
by_x = sorted([ (obj.x1, obj) for obj in lines ] + [ (obj.x2, obj) for obj in lines ], key=lambda x: x[0])
|
||||||
dist_sq = lambda x1, y1, x2, y2: (x2-x1)**2 + (y2-y1)**2
|
dist_sq = lambda x1, y1, x2, y2: (x2-x1)**2 + (y2-y1)**2
|
||||||
|
|
||||||
joins = {}
|
joins = {}
|
||||||
for cur in lines:
|
for cur in lines:
|
||||||
# Special case: An arc may describe a complete circle, in which case we have to return it as-is since it
|
|
||||||
# is the only primitive that can join itself.
|
|
||||||
if isinstance(cur, gp.Arc) and cur.is_circle:
|
|
||||||
yield [cur]
|
|
||||||
continue
|
|
||||||
|
|
||||||
for (i, x, y) in [(0, cur.x1, cur.y1), (1, cur.x2, cur.y2)]:
|
for (i, x, y) in [(0, cur.x1, cur.y1), (1, cur.x2, cur.y2)]:
|
||||||
x_left = bisect.bisect_left (by_x, x, key=lambda elem: elem[0] + tol)
|
x_left = bisect.bisect_left (by_x, x, key=lambda elem: elem[0] + tol)
|
||||||
x_right = bisect.bisect_right(by_x, x, key=lambda elem: elem[0] - tol)
|
x_right = bisect.bisect_right(by_x, x, key=lambda elem: elem[0] - tol)
|
||||||
|
|
@ -1311,14 +1090,13 @@ class LayerStack:
|
||||||
j = 0 if d1 < d2 else 1
|
j = 0 if d1 < d2 else 1
|
||||||
|
|
||||||
if (nearest, j) in joins and joins[(nearest, j)] != (cur, i):
|
if (nearest, j) in joins and joins[(nearest, j)] != (cur, i):
|
||||||
warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.{maybe_allegro_hint}')
|
warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.')
|
||||||
yield list(convex_hull_to_lines(self.outline.instance.convex_hull(tol, unit), unit))
|
return self.outline_convex_hull(tol, unit)
|
||||||
return
|
|
||||||
|
|
||||||
if (cur, i) in joins and joins[(cur, i)] != (nearest, j):
|
if (cur, i) in joins and joins[(cur, i)] != (nearest, j):
|
||||||
warnings.warn(f'Three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(cur, i)]}. Falling back to returning the convex hull of the outline layer.{maybe_allegro_hint}')
|
warnings.warn(f'three-way intersection on outline layer at: {(nearest, j)}; {(cur, i)}; and {joins[(nearest, j)]}. Falling back to returning the convex hull of the outline layer.')
|
||||||
yield list(convex_hull_to_lines(self.outline.instance.convex_hull(tol, unit), unit))
|
return self.outline_convex_hull(tol, unit)
|
||||||
return
|
|
||||||
|
|
||||||
joins[(cur, i)] = (nearest, j)
|
joins[(cur, i)] = (nearest, j)
|
||||||
joins[(nearest, j)] = (cur, i)
|
joins[(nearest, j)] = (cur, i)
|
||||||
93
gerbonara/newstroke.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import unicodedata
|
||||||
|
import re
|
||||||
|
import ast
|
||||||
|
from importlib.resources import files
|
||||||
|
|
||||||
|
from . import data
|
||||||
|
|
||||||
|
|
||||||
|
STROKE_FONT_SCALE = 1/21
|
||||||
|
FONT_OFFSET = -10
|
||||||
|
DEFAULT_SPACE_WIDTH = 0.6
|
||||||
|
DEFAULT_CHAR_GAP = 0.2
|
||||||
|
|
||||||
|
_dec = lambda c: ord(c)-ord('R')
|
||||||
|
|
||||||
|
|
||||||
|
class Newstroke:
|
||||||
|
def __init__(self, newstroke_cpp=None):
|
||||||
|
if newstroke_cpp is None:
|
||||||
|
newstroke_cpp = files(data).joinpath('newstroke_font.cpp').read_bytes()
|
||||||
|
self.glyphs = dict(self.load(newstroke_cpp))
|
||||||
|
|
||||||
|
def render(self, text, size=1.0, space_width=DEFAULT_SPACE_WIDTH, char_gap=DEFAULT_CHAR_GAP):
|
||||||
|
text = unicodedata.normalize('NFC', text)
|
||||||
|
missing_glyph = self.glyphs['?']
|
||||||
|
x = 0
|
||||||
|
for c in text:
|
||||||
|
if c == ' ':
|
||||||
|
x += space_width*size
|
||||||
|
continue
|
||||||
|
|
||||||
|
width, strokes = self.glyphs.get(c, missing_glyph)
|
||||||
|
glyph_w = max(width, max(x for st in strokes for x, _y in st))
|
||||||
|
|
||||||
|
for st in strokes:
|
||||||
|
yield self.transform_stroke(st, translate=(x, 0), scale=(size, size))
|
||||||
|
|
||||||
|
x += glyph_w*size
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def transform_stroke(kls, stroke, translate, scale):
|
||||||
|
dx, dy = translate
|
||||||
|
sx, sy = scale
|
||||||
|
return [(x*sx+dx, y*sy+dy) for x, y in stroke]
|
||||||
|
|
||||||
|
|
||||||
|
def load(self, newstroke_cpp):
|
||||||
|
e = []
|
||||||
|
for char, (width, strokes) in self.load_glyphs(newstroke_cpp):
|
||||||
|
yield char, (width, strokes)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def decode_stroke(kls, stroke, start_x):
|
||||||
|
for i in range(0, len(stroke), 2):
|
||||||
|
x = (stroke[i]-0x52-start_x)*STROKE_FONT_SCALE
|
||||||
|
y = (stroke[i+1]-0x52+FONT_OFFSET)*STROKE_FONT_SCALE
|
||||||
|
yield (x, y)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def decode_glyph(kls, data):
|
||||||
|
start_x, end_x = data[0]-0x52, data[1]-0x52
|
||||||
|
width = end_x - start_x
|
||||||
|
|
||||||
|
strokes = tuple(tuple(kls.decode_stroke(st, start_x)) for st in data[2:].split(b' R'))
|
||||||
|
return width*STROKE_FONT_SCALE, strokes
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_glyphs(kls, newstroke_cpp):
|
||||||
|
it = iter(newstroke_cpp.splitlines())
|
||||||
|
|
||||||
|
for line in it:
|
||||||
|
if re.search(rb'char.*\*', line):
|
||||||
|
break
|
||||||
|
|
||||||
|
charcode = 0x20
|
||||||
|
for line in it:
|
||||||
|
if (match := re.search(rb'".*"', line)):
|
||||||
|
yield chr(charcode), kls.decode_glyph(match.group(0)[1:-1].replace(b'\\\\', b'\\'))
|
||||||
|
charcode += 1
|
||||||
|
else:
|
||||||
|
if b'}' in line:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import time
|
||||||
|
t1 = time.time()
|
||||||
|
Newstroke()
|
||||||
|
t2 = time.time()
|
||||||
|
print((t2-t1)*1000)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -60,6 +58,7 @@ class GerberFile(CamFile):
|
||||||
:ivar layer_hints: Similar to ``generator_hints``, this is a list containing hints which layer type this file could
|
: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
|
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.
|
information inside tool-specific comments in the Gerber files they generate.
|
||||||
|
:ivar apertures: List of apertures used in this file. Make sure you keep this in sync when adding new objects.
|
||||||
:ivar file_attrs: List of strings with Gerber X3 file attributes. Each list item corresponds to one file attribute.
|
:ivar file_attrs: List of strings with Gerber X3 file attributes. Each list item corresponds to one file attribute.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -71,88 +70,13 @@ class GerberFile(CamFile):
|
||||||
self.generator_hints = generator_hints or []
|
self.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 sync_apertures(self):
|
||||||
""" Iterate through all apertures in this layer. """
|
self.apertures = list({id(obj.aperture): obj.aperture for obj in self.objects if hasattr(obj, 'aperture')}.values())
|
||||||
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):
|
def to_excellon(self, plated=None, errors='raise'):
|
||||||
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
|
""" Convert this excellon file into a :py:class:`~.excellon.ExcellonFile`. This will convert interpolated lines
|
||||||
into slots, and circular aperture flashes into holes. Other features such as ``G36`` polygons or flashes with
|
into slots, and circular aperture flashes into holes. Other features such as ``G36`` polygons or flashes with
|
||||||
non-circular apertures will result in a :py:obj:`ValueError`. You can, of course, programmatically remove such
|
non-circular apertures will result in a :py:obj:`ValueError`. You can, of course, programmatically remove such
|
||||||
|
|
@ -160,11 +84,8 @@ class GerberFile(CamFile):
|
||||||
new_objs = []
|
new_objs = []
|
||||||
new_tools = {}
|
new_tools = {}
|
||||||
for obj in self.objects:
|
for obj in self.objects:
|
||||||
if holes_only and not isinstance(obj, go.Flash):
|
if not (isinstance(obj, go.Line) or isinstance(obj, go.Arc) or isinstance(obj, go.Flash)) or \
|
||||||
continue
|
not isinstance(obj.aperture, apertures.CircleAperture):
|
||||||
|
|
||||||
if not isinstance(obj, (go.Line, go.Arc, go.Flash)) or \
|
|
||||||
not isinstance(getattr(obj, 'aperture', None), apertures.CircleAperture):
|
|
||||||
if errors == 'raise':
|
if errors == 'raise':
|
||||||
raise ValueError(f'Cannot convert {obj} to excellon.')
|
raise ValueError(f'Cannot convert {obj} to excellon.')
|
||||||
elif errors == 'warn':
|
elif errors == 'warn':
|
||||||
|
|
@ -175,9 +96,9 @@ class GerberFile(CamFile):
|
||||||
else:
|
else:
|
||||||
raise ValueError('Invalid "errors" parameter. Allowed values: "raise", "warn" or "ignore".')
|
raise ValueError('Invalid "errors" parameter. Allowed values: "raise", "warn" or "ignore".')
|
||||||
|
|
||||||
if not (new_tool := new_tools.get(obj.aperture)):
|
if not (new_tool := new_tools.get(id(obj.aperture))):
|
||||||
# TODO plating?
|
# 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)
|
||||||
|
|
@ -206,6 +127,18 @@ class GerberFile(CamFile):
|
||||||
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.defaults()
|
||||||
|
for ap in self.apertures + other.apertures:
|
||||||
|
gbr = ap.to_gerber(mock_settings)
|
||||||
|
if gbr not in new_apertures:
|
||||||
|
new_apertures[gbr] = ap
|
||||||
|
else:
|
||||||
|
replace_apertures[id(ap)] = new_apertures[gbr]
|
||||||
|
self.apertures = list(new_apertures.values())
|
||||||
|
|
||||||
# Join objects
|
# Join objects
|
||||||
if mode == 'below':
|
if mode == 'below':
|
||||||
self.objects = other.objects + self.objects
|
self.objects = other.objects + self.objects
|
||||||
|
|
@ -214,23 +147,57 @@ class GerberFile(CamFile):
|
||||||
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".')
|
||||||
|
|
||||||
self.dedup_apertures()
|
for obj in self.objects:
|
||||||
|
# If object has an aperture attribute, replace that aperture.
|
||||||
|
if (ap := replace_apertures.get(id(getattr(obj, 'aperture', None)))):
|
||||||
|
obj.aperture = ap
|
||||||
|
|
||||||
|
# dedup aperture macros
|
||||||
|
macros = { m.to_gerber(): m
|
||||||
|
for m in [ GenericMacros.circle, GenericMacros.rect, GenericMacros.obround, GenericMacros.polygon] }
|
||||||
|
for ap in new_apertures.values():
|
||||||
|
if isinstance(ap, apertures.ApertureMacroInstance):
|
||||||
|
macro_grb = ap.macro.to_gerber() # use native unit to compare macros
|
||||||
|
if macro_grb in macros:
|
||||||
|
ap.macro = macros[macro_grb]
|
||||||
|
else:
|
||||||
|
macros[macro_grb] = ap.macro
|
||||||
|
|
||||||
|
# make macro names unique
|
||||||
|
seen_macro_names = set()
|
||||||
|
for macro in macros.values():
|
||||||
|
i = 2
|
||||||
|
while (new_name := f'{macro.name}{i}') in seen_macro_names:
|
||||||
|
i += 1
|
||||||
|
macro.name = new_name
|
||||||
|
seen_macro_names.add(new_name)
|
||||||
|
|
||||||
def dilate(self, offset, unit=MM, polarity_dark=True):
|
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, override_settings=None):
|
||||||
|
|
@ -263,6 +230,7 @@ class GerberFile(CamFile):
|
||||||
|
|
||||||
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. """
|
||||||
|
self.sync_apertures()
|
||||||
|
|
||||||
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():
|
||||||
|
|
@ -285,26 +253,34 @@ 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 = {}
|
||||||
|
defined_apertures = {}
|
||||||
|
number = 10
|
||||||
|
for aperture in self.apertures:
|
||||||
|
|
||||||
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:
|
ap_def = aperture.to_gerber(settings)
|
||||||
for macro in self.aperture_macros():
|
if ap_def in defined_apertures:
|
||||||
yield am_stmt(macro)
|
aperture_map[id(aperture)] = defined_apertures[ap_def]
|
||||||
|
|
||||||
for aperture, number in aperture_map.items():
|
else:
|
||||||
yield f'%ADD{number}{aperture.to_gerber(settings)}*%'
|
yield f'%ADD{number}{ap_def}*%'
|
||||||
|
defined_apertures[ap_def] = number
|
||||||
|
aperture_map[id(aperture)] = number
|
||||||
|
number += 1
|
||||||
|
|
||||||
def warn(msg, kls=SyntaxWarning):
|
def warn(msg, kls=SyntaxWarning):
|
||||||
warnings.warn(msg, kls)
|
warnings.warn(msg, kls)
|
||||||
|
|
@ -317,7 +293,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)
|
||||||
|
|
@ -353,11 +329,17 @@ class GerberFile(CamFile):
|
||||||
def scale(self, factor, unit=MM):
|
def scale(self, factor, unit=MM):
|
||||||
scaled_apertures = {}
|
scaled_apertures = {}
|
||||||
|
|
||||||
self.map_apertures(lambda ap: ap.scaled(factor))
|
for ap in self.apertures:
|
||||||
|
scaled_apertures[id(ap)] = ap.scaled(factor)
|
||||||
|
|
||||||
for obj in self.objects:
|
for obj in self.objects:
|
||||||
obj.scale(factor)
|
obj.scale(factor)
|
||||||
|
|
||||||
|
if (obj_ap := getattr(obj, 'aperture', None)):
|
||||||
|
obj.aperture = scaled_apertures[id(obj_ap)]
|
||||||
|
|
||||||
|
self.apertures = list(scaled_apertures.values())
|
||||||
|
|
||||||
def offset(self, dx=0, dy=0, unit=MM):
|
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:
|
||||||
|
|
@ -367,7 +349,10 @@ class GerberFile(CamFile):
|
||||||
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, cx, cy, unit)
|
||||||
|
|
@ -375,7 +360,7 @@ class GerberFile(CamFile):
|
||||||
def invert_polarity(self):
|
def invert_polarity(self):
|
||||||
""" Invert the polarity (color) of each object in this file. """
|
""" Invert the polarity (color) of each object in this file. """
|
||||||
for obj in self.objects:
|
for obj in self.objects:
|
||||||
obj.polarity_dark = not obj.polarity_dark
|
obj.polarity_dark = not p.polarity_dark
|
||||||
|
|
||||||
|
|
||||||
class GraphicsState:
|
class GraphicsState:
|
||||||
|
|
@ -463,7 +448,7 @@ class GraphicsState:
|
||||||
obj = go.Flash(*self.map_coord(*self.point), self.aperture,
|
obj = go.Flash(*self.map_coord(*self.point), self.aperture,
|
||||||
polarity_dark=self._polarity_dark,
|
polarity_dark=self._polarity_dark,
|
||||||
unit=self.unit,
|
unit=self.unit,
|
||||||
attrs=copy.copy(self.object_attrs))
|
attrs=self.object_attrs)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False):
|
def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False):
|
||||||
|
|
@ -489,13 +474,13 @@ class GraphicsState:
|
||||||
raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)")
|
raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)")
|
||||||
|
|
||||||
return go.Line(*old_point, *self.map_coord(*self.point), aperture,
|
return go.Line(*old_point, *self.map_coord(*self.point), aperture,
|
||||||
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
|
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if i is None and j is None:
|
if i is None and j is None:
|
||||||
self.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values')
|
self.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values')
|
||||||
return go.Line(*old_point, *self.map_coord(*self.point), aperture,
|
return go.Line(*old_point, *self.map_coord(*self.point), aperture,
|
||||||
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
|
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if i is None:
|
if i is None:
|
||||||
|
|
@ -512,7 +497,7 @@ class GraphicsState:
|
||||||
if not multi_quadrant:
|
if not multi_quadrant:
|
||||||
return go.Arc(*old_point, *new_point, *self.map_coord(i, j, relative=True),
|
return go.Arc(*old_point, *new_point, *self.map_coord(i, j, relative=True),
|
||||||
clockwise=clockwise, aperture=(self.aperture if aperture else None),
|
clockwise=clockwise, aperture=(self.aperture if aperture else None),
|
||||||
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
|
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if math.isclose(old_point[0], new_point[0]) and math.isclose(old_point[1], new_point[1]):
|
if math.isclose(old_point[0], new_point[0]) and math.isclose(old_point[1], new_point[1]):
|
||||||
|
|
@ -525,7 +510,7 @@ class GraphicsState:
|
||||||
|
|
||||||
arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy,
|
arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy,
|
||||||
clockwise=clockwise, aperture=aperture,
|
clockwise=clockwise, aperture=aperture,
|
||||||
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
|
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
|
||||||
arcs = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ]
|
arcs = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ]
|
||||||
arcs = sorted(arcs, key=lambda a: a.numeric_error())
|
arcs = sorted(arcs, key=lambda a: a.numeric_error())
|
||||||
|
|
||||||
|
|
@ -564,8 +549,8 @@ 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]
|
ap_id = self.aperture_map[id(aperture)]
|
||||||
old_ap_id = self.aperture_map.get(self.aperture, None)
|
old_ap_id = self.aperture_map.get(id(self.aperture), None)
|
||||||
if ap_id != old_ap_id:
|
if ap_id != old_ap_id:
|
||||||
self.aperture = aperture
|
self.aperture = aperture
|
||||||
yield f'D{ap_id}*'
|
yield f'D{ap_id}*'
|
||||||
|
|
@ -587,9 +572,9 @@ class GraphicsState:
|
||||||
|
|
||||||
def interpolation_mode_statement(self):
|
def interpolation_mode_statement(self):
|
||||||
return {
|
return {
|
||||||
InterpMode.LINEAR: 'G01*',
|
InterpMode.LINEAR: 'G01',
|
||||||
InterpMode.CIRCULAR_CW: 'G02*',
|
InterpMode.CIRCULAR_CW: 'G02',
|
||||||
InterpMode.CIRCULAR_CCW: 'G03*'}[self.interpolation_mode]
|
InterpMode.CIRCULAR_CCW: 'G03'}[self.interpolation_mode]
|
||||||
|
|
||||||
|
|
||||||
class GerberParser:
|
class GerberParser:
|
||||||
|
|
@ -599,8 +584,6 @@ class GerberParser:
|
||||||
NUMBER = r"[\+-]?\d+"
|
NUMBER = r"[\+-]?\d+"
|
||||||
DECIMAL = r"[\+-]?\d+([.]?\d+)?"
|
DECIMAL = r"[\+-]?\d+([.]?\d+)?"
|
||||||
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
|
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
|
||||||
MAX_STEP_REPEAT_INSTANCES = 100000
|
|
||||||
MAX_STEP_REPEAT_RESULT_OBJECTS = 100000
|
|
||||||
|
|
||||||
STATEMENT_REGEXES = {
|
STATEMENT_REGEXES = {
|
||||||
'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \
|
'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \
|
||||||
|
|
@ -608,7 +591,6 @@ class GerberParser:
|
||||||
fr"(?:D0?([123]))?$",
|
fr"(?:D0?([123]))?$",
|
||||||
'region_start': r'G36$',
|
'region_start': r'G36$',
|
||||||
'region_end': r'G37$',
|
'region_end': r'G37$',
|
||||||
'eof': r"(D02)?M0?[02]", # P-CAD 2006 files have a spurious D02 before M02 as in "D02M02"
|
|
||||||
'aperture': r"(G54|G55)?\s*D(?P<number>\d+)",
|
'aperture': r"(G54|G55)?\s*D(?P<number>\d+)",
|
||||||
# Allegro combines format spec and unit into one long illegal extended command.
|
# Allegro combines format spec and unit into one long illegal extended command.
|
||||||
'allegro_format_spec': r"FS(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*\*MO(?P<unit>IN|MM)",
|
'allegro_format_spec': r"FS(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*\*MO(?P<unit>IN|MM)",
|
||||||
|
|
@ -629,9 +611,9 @@ class GerberParser:
|
||||||
'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})(,(?P<modifiers>[^,%]*))?$",
|
'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})(,(?P<modifiers>[^,%]*))?$",
|
||||||
'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)",
|
'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)",
|
||||||
'siemens_garbage': r'^ICAS$',
|
'siemens_garbage': r'^ICAS$',
|
||||||
'step_repeat': fr'^SR(?P<coords>X(?P<X>[0-9]+)Y(?P<Y>[0-9]+)I(?P<I>{DECIMAL})J(?P<J>{DECIMAL}))?$',
|
|
||||||
'old_unit':r'(?P<mode>G7[01])',
|
'old_unit':r'(?P<mode>G7[01])',
|
||||||
'old_notation': r'(?P<mode>G9[01])',
|
'old_notation': r'(?P<mode>G9[01])',
|
||||||
|
'eof': r"M0?[02]",
|
||||||
'ignored': r"(?P<stmt>M01)",
|
'ignored': r"(?P<stmt>M01)",
|
||||||
# NOTE: The official spec says names can be empty or contain commas. I think that doesn't make sense.
|
# NOTE: The official spec says names can be empty or contain commas. I think that doesn't make sense.
|
||||||
'attribute': r"(?P<eagle_garbage>G04 #@! %)?(?P<type>TF|TA|TO|TD)(?P<name>[._$a-zA-Z][._$a-zA-Z0-9]*)?(,(?P<value>.*))?",
|
'attribute': r"(?P<eagle_garbage>G04 #@! %)?(?P<type>TF|TA|TO|TD)(?P<name>[._$a-zA-Z][._$a-zA-Z0-9]*)?(,(?P<value>.*))?",
|
||||||
|
|
@ -649,8 +631,6 @@ class GerberParser:
|
||||||
self.aperture_map = {}
|
self.aperture_map = {}
|
||||||
self.aperture_macros = {}
|
self.aperture_macros = {}
|
||||||
self.current_region = None
|
self.current_region = None
|
||||||
self.step_repeat_coords = None
|
|
||||||
self.step_repeat_objects = None
|
|
||||||
self.eof_found = False
|
self.eof_found = False
|
||||||
self.multi_quadrant_mode = None # used only for syntax checking
|
self.multi_quadrant_mode = None # used only for syntax checking
|
||||||
self.macros = {}
|
self.macros = {}
|
||||||
|
|
@ -712,6 +692,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 +774,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 +785,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 +833,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')
|
||||||
|
|
@ -1083,40 +1049,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
|
||||||
|
|
||||||
35
gerbonara/tests/conftest.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .image_support import ImageDifference, run_cargo_cmd
|
||||||
|
|
||||||
|
def pytest_assertrepr_compare(op, left, right):
|
||||||
|
if isinstance(left, ImageDifference) or isinstance(right, ImageDifference):
|
||||||
|
diff = left if isinstance(left, ImageDifference) else right
|
||||||
|
return [
|
||||||
|
f'Image difference assertion failed.',
|
||||||
|
f' Calculated difference: {diff}',
|
||||||
|
f' Histogram: {diff.histogram}', ]
|
||||||
|
|
||||||
|
# store report in node object so tmp_gbr can determine if the test failed.
|
||||||
|
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||||
|
def pytest_runtest_makereport(item, call):
|
||||||
|
outcome = yield
|
||||||
|
rep = outcome.get_result()
|
||||||
|
setattr(item, f'rep_{rep.when}', rep)
|
||||||
|
|
||||||
|
fail_dir = Path('gerbonara_test_failures')
|
||||||
|
def pytest_sessionstart(session):
|
||||||
|
if not hasattr(session.config, 'workerinput'): # on worker
|
||||||
|
return
|
||||||
|
|
||||||
|
# on coordinator
|
||||||
|
for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')):
|
||||||
|
f.unlink()
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_cargo_cmd('resvg', '--help')
|
||||||
|
except FileNotFoundError:
|
||||||
|
pytest.exit('resvg binary not found, aborting test.', 2)
|
||||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 556 B After Width: | Height: | Size: 556 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
285
gerbonara/tests/image_support.py
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright 2022 Jan Sebastian Götte <gerbonara@jaseg.de>
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# Based on https://github.com/tracespace/tracespace
|
||||||
|
#
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import os
|
||||||
|
from functools import total_ordering
|
||||||
|
import shutil
|
||||||
|
import bs4
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
cachedir = Path(__file__).parent / 'image_cache'
|
||||||
|
cachedir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
@total_ordering
|
||||||
|
class ImageDifference:
|
||||||
|
def __init__(self, value, histogram):
|
||||||
|
self.value = value
|
||||||
|
self.histogram = histogram
|
||||||
|
|
||||||
|
def __float__(self):
|
||||||
|
return float(self.value)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return float(self) == float(other)
|
||||||
|
|
||||||
|
def __lt__(self, other):
|
||||||
|
return float(self) < float(other)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(float(self))
|
||||||
|
|
||||||
|
@total_ordering
|
||||||
|
class Histogram:
|
||||||
|
def __init__(self, value, size):
|
||||||
|
self.value, self.size = value, size
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
other = np.array(other)
|
||||||
|
other[other == None] = self.value[other == None]
|
||||||
|
return (self.value == other).all()
|
||||||
|
|
||||||
|
def __lt__(self, other):
|
||||||
|
other = np.array(other)
|
||||||
|
other[other == None] = self.value[other == None]
|
||||||
|
return (self.value <= other).all()
|
||||||
|
|
||||||
|
def __getitem__(self, index):
|
||||||
|
return self.value[index]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{list(self.value)} size={self.size}'
|
||||||
|
|
||||||
|
|
||||||
|
def run_cargo_cmd(cmd, args, **kwargs):
|
||||||
|
if cmd.upper() in os.environ:
|
||||||
|
return subprocess.run([os.environ[cmd.upper()], *args], **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return subprocess.run([cmd, *args], **kwargs)
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs)
|
||||||
|
|
||||||
|
def svg_to_png(in_svg, out_png, dpi=100, bg=None):
|
||||||
|
params = f'{dpi}{bg}'.encode()
|
||||||
|
digest = hashlib.blake2b(Path(in_svg).read_bytes() + params).hexdigest()
|
||||||
|
cachefile = cachedir / f'{digest}.png'
|
||||||
|
|
||||||
|
if not cachefile.is_file():
|
||||||
|
bg = 'black' if bg is None else bg
|
||||||
|
run_cargo_cmd('resvg', ['--background', bg, '--dpi', str(dpi), in_svg, cachefile], check=True, stdout=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
shutil.copy(cachefile, out_png)
|
||||||
|
|
||||||
|
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
|
||||||
|
|
||||||
|
def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6), fg='#ffffff', bg='#000000', override_unit_spec=None):
|
||||||
|
params = f'{origin}{size}{fg}{bg}'.encode()
|
||||||
|
digest = hashlib.blake2b(Path(in_gbr).read_bytes() + params).hexdigest()
|
||||||
|
cachefile = cachedir / f'{digest}.svg'
|
||||||
|
|
||||||
|
if not cachefile.is_file():
|
||||||
|
print(f'Building cache for {Path(in_gbr).name}')
|
||||||
|
# NOTE: gerbv seems to always export 'clear' polarity apertures as white, irrespective of --foreground, --background
|
||||||
|
# and project file color settings.
|
||||||
|
# TODO: File issue upstream.
|
||||||
|
with tempfile.NamedTemporaryFile('w') as f:
|
||||||
|
if override_unit_spec:
|
||||||
|
units, zeros, digits = override_unit_spec
|
||||||
|
print(f'{Path(in_gbr).name}: overriding excellon unit spec to {units=} {zeros=} {digits=}')
|
||||||
|
units = 0 if units == 'inch' else 1
|
||||||
|
zeros = {None: 0, 'leading': 1, 'trailing': 2}[zeros]
|
||||||
|
unit_spec = textwrap.dedent(f'''(cons 'attribs (list
|
||||||
|
(list 'autodetect 'Boolean 0)
|
||||||
|
(list 'zero_suppression 'Enum {zeros})
|
||||||
|
(list 'units 'Enum {units})
|
||||||
|
(list 'digits 'Integer {digits})
|
||||||
|
))''')
|
||||||
|
else:
|
||||||
|
unit_spec = ''
|
||||||
|
|
||||||
|
r, g, b = int(fg[1:3], 16), int(fg[3:5], 16), int(fg[5:], 16)
|
||||||
|
color = f"(cons 'color #({r*257} {g*257} {b*257}))"
|
||||||
|
f.write(f'''(gerbv-file-version! "2.0A")(define-layer! 0 (cons 'filename "{in_gbr}"){unit_spec}{color})''')
|
||||||
|
f.flush()
|
||||||
|
if override_unit_spec:
|
||||||
|
shutil.copy(f.name, '/tmp/foo.gbv')
|
||||||
|
|
||||||
|
x, y = origin
|
||||||
|
w, h = size
|
||||||
|
cmd = ['gerbv', '-x', export_format,
|
||||||
|
'--border=0',
|
||||||
|
f'--origin={x:.6f}x{y:.6f}', f'--window_inch={w:.6f}x{h:.6f}',
|
||||||
|
f'--background={bg}',
|
||||||
|
f'--foreground={fg}',
|
||||||
|
'-o', str(cachefile), '-p', f.name]
|
||||||
|
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
else:
|
||||||
|
print(f'Re-using cache for {Path(in_gbr).name}')
|
||||||
|
shutil.copy(cachefile, out_svg)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def svg_soup(filename):
|
||||||
|
with open(filename, 'r') as f:
|
||||||
|
soup = bs4.BeautifulSoup(f.read(), 'xml')
|
||||||
|
|
||||||
|
yield soup
|
||||||
|
|
||||||
|
with open(filename, 'w') as f:
|
||||||
|
f.write(str(soup))
|
||||||
|
|
||||||
|
def cleanup_gerbv_svg(soup):
|
||||||
|
width = soup.svg["width"]
|
||||||
|
height = soup.svg["height"]
|
||||||
|
width = width[:-2] if width.endswith('pt') else width
|
||||||
|
height = height[:-2] if height.endswith('pt') else height
|
||||||
|
soup.svg['width'] = f'{float(width)/72*25.4:.4f}mm'
|
||||||
|
soup.svg['height'] = f'{float(height)/72*25.4:.4f}mm'
|
||||||
|
for group in soup.find_all('g'):
|
||||||
|
# gerbv uses Cairo's SVG canvas. Cairo's SVG canvas is kind of broken. It has no support for unit
|
||||||
|
# handling at all, which means the output files just end up being in pixels at 72 dpi. Further, it
|
||||||
|
# seems gerbv's aperture macro rendering interacts poorly with Cairo's SVG export. gerbv renders
|
||||||
|
# aperture macros into a new surface, which for some reason gets clipped by Cairo to the given
|
||||||
|
# canvas size. This is just wrong, so we just nuke the clip path from these SVG groups here.
|
||||||
|
#
|
||||||
|
# Apart from being graphically broken, this additionally causes very bad rendering performance.
|
||||||
|
del group['clip-path']
|
||||||
|
|
||||||
|
def gerber_difference(reference, actual, diff_out=None, svg_transform=None, size=(10,10), ref_unit_spec=None):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
|
||||||
|
tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg:
|
||||||
|
|
||||||
|
gerbv_export(reference, ref_svg.name, size=size, export_format='svg', override_unit_spec=ref_unit_spec)
|
||||||
|
gerbv_export(actual, act_svg.name, size=size, export_format='svg')
|
||||||
|
|
||||||
|
with svg_soup(ref_svg.name) as soup:
|
||||||
|
if svg_transform is not None:
|
||||||
|
svg = soup.svg
|
||||||
|
children = list(svg.children)
|
||||||
|
g = soup.new_tag('g', attrs={'transform': svg_transform})
|
||||||
|
for c in children:
|
||||||
|
g.append(c.extract())
|
||||||
|
svg.append(g)
|
||||||
|
|
||||||
|
cleanup_gerbv_svg(soup)
|
||||||
|
|
||||||
|
with svg_soup(act_svg.name) as soup:
|
||||||
|
cleanup_gerbv_svg(soup)
|
||||||
|
|
||||||
|
return svg_difference(ref_svg.name, act_svg.name, diff_out=diff_out)
|
||||||
|
|
||||||
|
def gerber_difference_merge(ref1, ref2, actual, diff_out=None, composite_out=None, svg_transform1=None, svg_transform2=None, size=(10,10)):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
|
||||||
|
tempfile.NamedTemporaryFile(suffix='.svg') as ref1_svg,\
|
||||||
|
tempfile.NamedTemporaryFile(suffix='.svg') as ref2_svg:
|
||||||
|
|
||||||
|
gerbv_export(ref1, ref1_svg.name, size=size, export_format='svg')
|
||||||
|
gerbv_export(ref2, ref2_svg.name, size=size, export_format='svg')
|
||||||
|
gerbv_export(actual, act_svg.name, size=size, export_format='svg')
|
||||||
|
for var in ['ref1_svg', 'ref2_svg', 'act_svg']:
|
||||||
|
print(f'=== {var} ===')
|
||||||
|
print(Path(locals()[var].name).read_text().splitlines()[1])
|
||||||
|
|
||||||
|
with svg_soup(ref1_svg.name) as soup1:
|
||||||
|
if svg_transform1 is not None:
|
||||||
|
svg = soup1.svg
|
||||||
|
children = list(svg.children)
|
||||||
|
g = soup1.new_tag('g', attrs={'transform': svg_transform1})
|
||||||
|
for c in children:
|
||||||
|
g.append(c.extract())
|
||||||
|
svg.append(g)
|
||||||
|
cleanup_gerbv_svg(soup1)
|
||||||
|
|
||||||
|
with svg_soup(ref2_svg.name) as soup2:
|
||||||
|
if svg_transform2 is not None:
|
||||||
|
svg = soup2.svg
|
||||||
|
children = list(svg.children)
|
||||||
|
g = soup2.new_tag('g', attrs={'transform': svg_transform2})
|
||||||
|
for c in children:
|
||||||
|
g.append(c.extract())
|
||||||
|
svg.append(g)
|
||||||
|
cleanup_gerbv_svg(soup2)
|
||||||
|
|
||||||
|
defs1 = soup1.find('defs')
|
||||||
|
if not defs1:
|
||||||
|
defs1 = soup1.new_tag('defs')
|
||||||
|
soup1.find('svg').insert(0, defs1)
|
||||||
|
|
||||||
|
defs2 = soup2.find('defs')
|
||||||
|
if defs2:
|
||||||
|
defs2 = defs2.extract()
|
||||||
|
# explicitly convert .contents into list here and below because else bs4 stumbles over itself
|
||||||
|
# iterating because we modify the tree in the loop body.
|
||||||
|
for c in list(defs2.contents):
|
||||||
|
if hasattr(c, 'attrs'):
|
||||||
|
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
|
||||||
|
defs1.append(c)
|
||||||
|
|
||||||
|
for use in soup2.find_all('use', recursive=True):
|
||||||
|
if (href := use.get('xlink:href', '')).startswith('#'):
|
||||||
|
use['xlink:href'] = f'#gn-merge-b-{href[1:]}'
|
||||||
|
|
||||||
|
svg1 = soup1.find('svg')
|
||||||
|
for c in list(soup2.find('svg').contents):
|
||||||
|
if hasattr(c, 'attrs'):
|
||||||
|
c['id'] = 'gn-merge-b-' + c.attrs.get('id', str(id(c)))
|
||||||
|
svg1.append(c)
|
||||||
|
|
||||||
|
if composite_out:
|
||||||
|
shutil.copyfile(ref1_svg.name, composite_out)
|
||||||
|
|
||||||
|
with svg_soup(act_svg.name) as soup:
|
||||||
|
cleanup_gerbv_svg(soup)
|
||||||
|
|
||||||
|
return svg_difference(ref1_svg.name, act_svg.name, diff_out=diff_out)
|
||||||
|
|
||||||
|
def svg_difference(reference, actual, diff_out=None, background=None):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='-ref.png') as ref_png,\
|
||||||
|
tempfile.NamedTemporaryFile(suffix='-act.png') as act_png:
|
||||||
|
|
||||||
|
svg_to_png(reference, ref_png.name, bg=background)
|
||||||
|
svg_to_png(actual, act_png.name, bg=background)
|
||||||
|
|
||||||
|
return image_difference(ref_png.name, act_png.name, diff_out=diff_out)
|
||||||
|
|
||||||
|
def image_difference(reference, actual, diff_out=None):
|
||||||
|
ref = np.array(Image.open(reference)).astype(float)
|
||||||
|
out = np.array(Image.open(actual)).astype(float)
|
||||||
|
|
||||||
|
ref, out = ref.mean(axis=2), out.mean(axis=2) # convert to grayscale
|
||||||
|
# TODO blur images here before comparison to mitigate aliasing issue
|
||||||
|
delta = np.abs(out - ref).astype(float) / 255
|
||||||
|
if diff_out:
|
||||||
|
Image.fromarray((delta*255).astype(np.uint8), mode='L').save(diff_out)
|
||||||
|
|
||||||
|
hist, _bins = np.histogram(delta, bins=10, range=(0, 1))
|
||||||
|
return (ImageDifference(delta.mean(), hist),
|
||||||
|
ImageDifference(delta.max(), hist),
|
||||||
|
Histogram(hist, out.size))
|
||||||
|
|
||||||
|
|
||||||