Compare commits
368 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
736107f7a4 | ||
|
|
e3674de08d | ||
|
|
516a9d337f | ||
|
|
2451b517e8 | ||
|
|
bdd4008ab9 | ||
|
|
6f006e2782 | ||
|
|
8df709f55f | ||
|
|
985e263cfe | ||
|
|
5ccfd7a259 | ||
|
|
bcc4aeefa7 | ||
|
|
0c15111463 | ||
|
|
1a0f519720 | ||
|
|
3d0ddc3dc8 | ||
|
|
575046a60c | ||
|
|
8de776616c | ||
|
|
e19dec20b6 | ||
|
|
97513df5d9 | ||
|
|
2ce55ebdca | ||
|
|
f3c95a42d4 | ||
|
|
8a2599f5f4 | ||
|
|
2647709215 | ||
|
|
87413855bf | ||
|
|
af4fb2668f | ||
|
|
a47a694ba0 | ||
|
|
dd8ad98f13 | ||
|
|
a1ea416269 | ||
|
|
a7f0324506 | ||
|
|
6ab4640099 | ||
|
|
04a088c3e8 | ||
|
|
df4895abfa | ||
|
|
42dfd1be7f | ||
|
|
fd6880640d | ||
|
|
6a8bd8dc3f | ||
|
|
46df184358 | ||
|
|
c2679260a0 | ||
|
|
9d2d635eee | ||
|
|
75905f7d0c | ||
|
|
7073b6e33f | ||
|
|
718de2b338 | ||
|
|
379cf273cb | ||
|
|
da6d8349fa | ||
|
|
1f5a9261e1 | ||
|
|
3ae6b9be61 | ||
|
|
a6434f16cd | ||
|
|
63faf4280d | ||
|
|
7fb8215a1e | ||
|
|
81ae51d4be | ||
|
|
8ffa7c1b76 | ||
|
|
be8371c7bc | ||
|
|
1a854b1812 | ||
|
|
091ee84910 | ||
|
|
fd63c44314 | ||
|
|
cbe8dfa252 | ||
|
|
67ce4af957 | ||
|
|
eadd250ee3 | ||
|
|
5f5bbccd05 | ||
|
|
ea2664219d | ||
|
|
be25b860a9 | ||
|
|
e42b7462c9 | ||
|
|
754c9557e5 | ||
|
|
d7efa57732 | ||
|
|
689ce748db | ||
|
|
8e337c0506 | ||
|
|
b23cafbfd4 | ||
|
|
78ffb61aee | ||
|
|
58d5784903 | ||
|
|
1ecb7be6f9 | ||
|
|
ef9d61ffd5 | ||
|
|
79f555a465 | ||
|
|
501882ea3d | ||
|
|
e3a6716187 | ||
|
|
8950a593f8 | ||
|
|
344825c5da | ||
|
|
f1b3ab5e72 | ||
|
|
2092b86431 | ||
|
|
885ce36fd3 | ||
|
|
edaf246b9d | ||
|
|
b1d0260c70 | ||
|
|
a2c6e2d64a | ||
|
|
1d5f4c8193 | ||
|
|
30878adfb1 | ||
|
|
66811af966 | ||
|
|
ec85d6c169 | ||
|
|
f447b12571 | ||
|
|
93fd764482 | ||
|
|
26e85279dc | ||
|
|
1ed127e3b3 | ||
|
|
1ee6b6587a | ||
|
|
e98f3f3ace | ||
|
|
6f8d4bb999 | ||
|
|
4aab344a18 | ||
|
|
21218239e4 | ||
|
|
a1d6ebf79f | ||
|
|
b0274a93c0 | ||
|
|
0150c318bb | ||
|
|
6de138bf7c | ||
|
|
224a666219 | ||
|
|
cee355ff57 | ||
|
|
4c3815b25a | ||
|
|
ef3b5d5e1c | ||
|
|
552f30c15d | ||
|
|
f721692bf3 | ||
|
|
04c4b3ff0c | ||
|
|
227d4ed1cd | ||
|
|
ea4c28e307 | ||
|
|
51ef4882a1 | ||
|
|
df75a2fddb | ||
|
|
11325b213b | ||
|
|
74fb384c4c | ||
|
|
9af0713445 | ||
|
|
09e3731b74 | ||
|
|
37b6b8f8d2 | ||
|
|
07362c592f | ||
|
|
2f5f7719c6 | ||
|
|
cb1d3eb3fb | ||
|
|
4ee5c51f22 | ||
|
|
53788354e8 | ||
|
|
10962ae2f4 | ||
|
|
a19d307a7d | ||
|
|
165e101dda | ||
|
|
187c44555c | ||
|
|
36da1fd68b | ||
|
|
9624e46147 | ||
|
|
a35125b123 | ||
|
|
31af2b260c | ||
|
|
dd49698df9 | ||
|
|
ef2b53325c | ||
|
|
5d3cd4694d | ||
|
|
3631871a40 | ||
|
|
b710462419 | ||
|
|
313aa7dd26 | ||
|
|
1ce02a9d25 | ||
|
|
0ae13de322 | ||
|
|
c212663bb2 | ||
|
|
2a9c91b025 | ||
|
|
2d4c40c0f7 | ||
|
|
a11f144c67 | ||
|
|
e78e939a13 | ||
|
|
4ea1b26293 | ||
|
|
cc59a6567e | ||
|
|
a2d1429036 | ||
|
|
f793f12edb | ||
|
|
78f5bf965f | ||
|
|
84f7e5d25b | ||
|
|
ba689e632e | ||
|
|
4400dc361e | ||
|
|
fdaf3c7e93 | ||
|
|
6e12adb07e | ||
|
|
cff22b9e08 | ||
|
|
f711c1d91c | ||
|
|
61e591b5b8 | ||
|
|
95da482033 | ||
|
|
d2143bdf4d | ||
|
|
5f1350d4f4 | ||
|
|
5ff40e0ad1 | ||
|
|
7d21dafd6a | ||
|
|
301601e81d | ||
|
|
3e47e7c2da | ||
|
|
ba4cafa3a4 | ||
|
|
2f0a21abf9 | ||
|
|
eb5c01ddd0 | ||
|
|
6e7337cca5 | ||
|
|
eb20595e00 | ||
|
|
e4dcbe177f | ||
|
|
8cb91dabdb | ||
|
|
91b99a0452 | ||
|
|
09c9d26728 | ||
|
|
21ca5f7f5c | ||
|
|
a39af853c8 | ||
|
|
b69e9fded4 | ||
|
|
60674ab5b3 | ||
|
|
bdbdf7f586 | ||
|
|
a1b8cbf861 | ||
|
|
58142cb0c7 | ||
|
|
08c4091e57 | ||
|
|
860fa4c53b | ||
|
|
b2729a46ac | ||
|
|
572486aa25 | ||
|
|
0799cc55ad | ||
|
|
9f2f1f97f9 | ||
|
|
cb188ac593 | ||
|
|
a5087636ab | ||
|
|
fb1d392831 | ||
|
|
8f7b2893dc | ||
|
|
e696c09eac | ||
|
|
3bc92876b4 | ||
|
|
34fae0a7c2 | ||
|
|
2eb88e8127 | ||
|
|
95728fb33c | ||
|
|
0920af4149 | ||
|
|
7a95a0dde7 | ||
|
|
88642a1803 | ||
|
|
56d55fda5d | ||
|
|
467e482bf4 | ||
|
|
dd8507d202 | ||
|
|
d43308c5cc | ||
|
|
c29802c2b9 | ||
|
|
a92177904e | ||
|
|
0148db6249 | ||
|
|
5178eba26f | ||
|
|
ddd0641649 | ||
|
|
e349c2c08b | ||
|
|
96fde32c0b | ||
|
|
86276490eb | ||
|
|
070494a1c3 | ||
|
|
1d0ba4da70 | ||
|
|
3561817903 | ||
|
|
03f2ec0a30 | ||
|
|
732c58f70b | ||
|
|
4bac77d0b4 | ||
|
|
b1e189eed2 | ||
|
|
af7b04f510 | ||
|
|
b50587d6ad | ||
|
|
26c2460490 | ||
|
|
af3458b1e2 | ||
|
|
73a48f1dcb | ||
|
|
9ffc96cbe5 | ||
|
|
fda2433154 | ||
|
|
f674f5d9af | ||
|
|
fb61b4fa12 | ||
|
|
e4a459368c | ||
|
|
176252b564 | ||
|
|
fdb07ea26e | ||
|
|
8d5403260b | ||
|
|
778e819745 | ||
|
|
958b47ab47 | ||
|
|
38f766dc42 | ||
|
|
549a33d386 | ||
|
|
8409fbb908 | ||
|
|
9f74fad6a2 | ||
|
|
e98ed31255 | ||
|
|
1f841ad71b | ||
|
|
59fe7b3b83 | ||
|
|
1dbe7f1f73 | ||
|
|
5a41d96fe3 | ||
|
|
bda404c18b | ||
|
|
aaaf96e8d9 | ||
|
|
a93d118773 | ||
|
|
5ce88e4d1b | ||
|
|
240e5569aa | ||
|
|
2c6c9a5cbc | ||
|
|
263033c9bd | ||
|
|
0421e03717 | ||
|
|
390579850b | ||
|
|
2eefb9cc7d | ||
|
|
5c7bfb2744 | ||
|
|
5ea1491704 | ||
|
|
4bd1097fc1 | ||
|
|
3556dc081b | ||
|
|
fba0507a55 | ||
|
|
2400ff8e5f | ||
|
|
b43e4e2eec | ||
|
|
138f6504e7 | ||
|
|
b0bc7971bc | ||
|
|
8181651a75 | ||
|
|
888ae71889 | ||
|
|
fba189c695 | ||
|
|
e18dbb11f8 | ||
|
|
b1b39cd65c | ||
|
|
6fe3def9d6 | ||
|
|
0dcd281406 | ||
|
|
ba92060431 | ||
|
|
24577464ee | ||
|
|
60e893c82d | ||
|
|
d9b3fafa80 | ||
|
|
666e385cb4 | ||
|
|
a1efd9d09a | ||
|
|
506f61ae84 | ||
|
|
2f04847426 | ||
|
|
ae1f522862 | ||
|
|
44ca8349eb | ||
|
|
ce8d045178 | ||
|
|
07b2628dbb | ||
|
|
387ff3de76 | ||
|
|
a95aacac48 | ||
|
|
d0894b2522 | ||
|
|
dcb31f3131 | ||
|
|
33a35f796d | ||
|
|
0295440770 | ||
|
|
800827b2c5 | ||
|
|
a85a7d426e | ||
|
|
ec0ecdeb68 | ||
|
|
900de13d8c | ||
|
|
b2ba39e1eb | ||
|
|
1f1d81533a | ||
|
|
1c9dcc1a9f | ||
|
|
ba5bf67235 | ||
|
|
f7aa6657e7 | ||
|
|
b5e6a48d54 | ||
|
|
a9931c469b | ||
|
|
9d9b47842f | ||
|
|
cba1c9a8a2 | ||
|
|
19bcd5ce96 | ||
|
|
1aaac3936f | ||
|
|
8b40d15dab | ||
|
|
d43eff8b49 | ||
|
|
70179a4178 | ||
|
|
866eafb4eb | ||
|
|
16f1247fda | ||
|
|
67dfad8418 | ||
|
|
6231f67139 | ||
|
|
9a6bc691cb | ||
|
|
a374483998 | ||
|
|
f64b03efc7 | ||
|
|
fb52e10408 | ||
|
|
fec4cf0057 | ||
|
|
8f4cdd8810 | ||
|
|
791eca7679 | ||
|
|
163c30663f | ||
|
|
f558f66bc0 | ||
|
|
b75404efce | ||
|
|
7bdbe66dc7 | ||
|
|
71e996e874 | ||
|
|
39d7d693ee | ||
|
|
218f9d9b1f | ||
|
|
ee233317f1 | ||
|
|
6752dab125 | ||
|
|
acf2747e86 | ||
|
|
2f4e52d31e | ||
|
|
fa089d32ca | ||
|
|
a169ee43f8 | ||
|
|
3aeea67f37 | ||
|
|
95d0b60490 | ||
|
|
6491f1cf44 | ||
|
|
432524caa6 | ||
|
|
0f70b225cc | ||
|
|
4bd438e9cc | ||
|
|
8ddeb65b3f | ||
|
|
2766b447fa | ||
|
|
cb0c84d36c | ||
|
|
99eec1e092 | ||
|
|
a1eb3afa75 | ||
|
|
6369b5ddb6 | ||
|
|
9ca9731110 | ||
|
|
bff588ff75 | ||
|
|
3c4b7b1de3 | ||
|
|
7ded0d6d6f | ||
|
|
f1ac559eb3 | ||
|
|
748ab7ccf2 | ||
|
|
45d41af3aa | ||
|
|
dfaf23b718 | ||
|
|
4b38d0905e | ||
|
|
a88364b7a9 | ||
|
|
fa7a526883 | ||
|
|
6833bf8657 | ||
|
|
9e898ceefb | ||
|
|
0d967895af | ||
|
|
94289b7af4 | ||
|
|
230418705f | ||
|
|
1f50a881ca | ||
|
|
89f3aeeac7 | ||
|
|
b1be792c52 | ||
|
|
460ea625af | ||
|
|
e422243a6e | ||
|
|
c127c89fa3 | ||
|
|
45cd00387e | ||
|
|
a1de37d83f | ||
|
|
b42b0e85fa | ||
|
|
766c4eb4b3 | ||
|
|
4d1f4c709d | ||
|
|
48a890e7f4 | ||
|
|
39e2eda95d | ||
|
|
14027c552d | ||
|
|
02a5f92af1 | ||
|
|
a939ab016e | ||
|
|
e03e39b421 | ||
|
|
a91d760c08 | ||
|
|
cac0ef4240 |
|
|
@ -1,8 +0,0 @@
|
|||
[run]
|
||||
branch = True
|
||||
source = gerber
|
||||
|
||||
[report]
|
||||
ignore_errors = True
|
||||
omit =
|
||||
gerber/tests/*
|
||||
45
.github/workflows/pcb-tools.yml
vendored
|
|
@ -1,45 +0,0 @@
|
|||
name: pcb-tools
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [3.5, 3.6, 3.7, 3.8]
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements-dev.txt
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
pytest
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.8
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements-dev.txt
|
||||
- name: Run coverage
|
||||
run: |
|
||||
make test-coverage
|
||||
- uses: codecov/codecov-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ./coverage.xml
|
||||
flags: unittest
|
||||
2
.gitignore
vendored
|
|
@ -3,3 +3,5 @@ gerbonara_test_failures
|
|||
__pycache__
|
||||
.tox
|
||||
docs/_build/
|
||||
build
|
||||
dist
|
||||
|
|
|
|||
|
|
@ -13,32 +13,62 @@ build:archlinux:
|
|||
variables:
|
||||
GIT_SUBMODULE_STRATEGY: none
|
||||
script:
|
||||
- pip3 install --user wheel
|
||||
- python3 setup.py bdist_wheel
|
||||
- git config --global --add safe.directory "$CI_PROJECT_DIR"
|
||||
- uv build
|
||||
artifacts:
|
||||
name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
|
||||
paths:
|
||||
- dist/*.whl
|
||||
- dist/*
|
||||
|
||||
test:archlinux:
|
||||
stage: test
|
||||
image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
|
||||
script:
|
||||
- pip3 install --user pytest
|
||||
- ~/.local/bin/pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*'
|
||||
dependencies:
|
||||
- build:archlinux
|
||||
artifacts:
|
||||
name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
|
||||
when: on_failure
|
||||
paths:
|
||||
- gerbonara_test_failures/*
|
||||
# FIXME: disable tests since (a) currenty kicad-cli is broken (aborts on start), and the workaround of using an older
|
||||
# version from the KiCad project's kicad-cli containers does not work in gitlab CI. Pain.
|
||||
#test:archlinux:
|
||||
# stage: test
|
||||
# image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
|
||||
# script:
|
||||
# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-symbols
|
||||
# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-footprints
|
||||
# - env KICAD_SYMBOLS=kicad-symbols KICAD_FOOTPRINTS=kicad-footprints pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*'
|
||||
# dependencies:
|
||||
# - build:archlinux
|
||||
# cache:
|
||||
# key: test-image-cache
|
||||
# paths:
|
||||
# - gerbonara/tests/image_cache/*.svg
|
||||
# - gerbonara/tests/image_cache/*.png
|
||||
# artifacts:
|
||||
# name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
|
||||
# when: on_failure
|
||||
# paths:
|
||||
# - gerbonara_test_failures/*
|
||||
#
|
||||
#test:ubuntu-rolling:
|
||||
# stage: test
|
||||
# image: "registry.gitlab.com/gerbolyze/build-containers/ubuntu:rolling"
|
||||
# script:
|
||||
# - python3 -m pip install --break-system-packages pytest beautifulsoup4 pillow numpy slugify lxml click scipy
|
||||
# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-symbols
|
||||
# - git clone --depth 1 https://gitlab.com/kicad/libraries/kicad-footprints
|
||||
# - env KICAD_SYMBOLS=kicad-symbols KICAD_FOOTPRINTS=kicad-footprints python3 -m pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*'
|
||||
# dependencies:
|
||||
# - build:archlinux
|
||||
# cache:
|
||||
# key: test-image-cache
|
||||
# paths:
|
||||
# - gerbonara/tests/image_cache/*.svg
|
||||
# - gerbonara/tests/image_cache/*.png
|
||||
# artifacts:
|
||||
# name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
|
||||
# when: on_failure
|
||||
# paths:
|
||||
# - gerbonara_test_failures/*
|
||||
|
||||
docs:archlinux:
|
||||
stage: test
|
||||
image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
|
||||
script:
|
||||
- ~/.local/bin/sphinx-build -E docs docs/_build
|
||||
- git config --global --add safe.directory "$CI_PROJECT_DIR"
|
||||
- sphinx-build -E docs docs/_build
|
||||
dependencies:
|
||||
- build:archlinux
|
||||
artifacts:
|
||||
|
|
@ -54,7 +84,8 @@ publish:gerbonara:
|
|||
cache: {}
|
||||
script:
|
||||
- export TWINE_USERNAME TWINE_PASSWORD
|
||||
- ~/.local/bin/twine upload dist/*
|
||||
- pip3 install --user --break-system-packages twine rich
|
||||
- twine upload dist/*
|
||||
dependencies:
|
||||
- build:archlinux
|
||||
only:
|
||||
|
|
@ -66,12 +97,13 @@ pages:
|
|||
GIT_SUBMODULE_STRATEGY: none
|
||||
image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
|
||||
script:
|
||||
- ~/.local/bin/sphinx-build -E docs docs/_build
|
||||
- git config --global --add safe.directory "$CI_PROJECT_DIR"
|
||||
- sphinx-build -E docs public
|
||||
dependencies:
|
||||
- build:archlinux
|
||||
artifacts:
|
||||
paths:
|
||||
- docs/_build
|
||||
- public
|
||||
only:
|
||||
- /^v.*$/
|
||||
|
||||
|
|
|
|||
12
.pypirc
|
|
@ -1,12 +0,0 @@
|
|||
[distutils]
|
||||
index-servers =
|
||||
pypi
|
||||
testpypi
|
||||
|
||||
[pypi]
|
||||
username = __token__
|
||||
password = ${env.PYPI_TOKEN}
|
||||
|
||||
[testpypi]
|
||||
username = __token__
|
||||
password = ${env.TESTPYPI_TOKEN}
|
||||
11
MANIFEST.in
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
include README.md
|
||||
include LICENSE
|
||||
include MANIFEST.in
|
||||
include setup.py
|
||||
graft gerbonara
|
||||
graft docs
|
||||
graft examples
|
||||
|
||||
prune gerbonara/tests
|
||||
prune **/__pycache__
|
||||
prune docs/_build
|
||||
34
Makefile
|
|
@ -1,45 +1,45 @@
|
|||
PYTHON ?= python
|
||||
PYTEST ?= pytest
|
||||
SPHINX_BUILD ?= sphinx-build
|
||||
|
||||
PYTHON ?= python
|
||||
PYTEST ?= pytest
|
||||
SPHINX_BUILD ?= sphinx-build
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: clean docs test test-coverage install sdist bdist_wheel upload testupload help
|
||||
|
||||
all: docs sdist bdist_wheel
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
clean: ## Clean up project directory
|
||||
find . -name '*.pyc' -delete
|
||||
rm -rf *.egg-info
|
||||
rm -f .coverage
|
||||
rm -f coverage.xml
|
||||
rm -rf docs/_build
|
||||
|
||||
.PHONY: docs
|
||||
docs:
|
||||
docs: ## Generate documentation
|
||||
sphinx-build -E docs docs/_build
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
test: ## Run tests
|
||||
$(PYTEST)
|
||||
|
||||
.PHONY: test-coverage
|
||||
test-coverage:
|
||||
test-coverage: ## Generate coverage
|
||||
rm -f .coverage
|
||||
rm -f coverage.xml
|
||||
$(PYTEST) --cov=./ --cov-report=xml
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
install: ## Install locally
|
||||
PYTHONPATH=. $(PYTHON) setup.py install
|
||||
|
||||
sdist:
|
||||
sdist: ## Build source distribution
|
||||
python3 setup.py sdist
|
||||
|
||||
bdist_wheel:
|
||||
bdist_wheel: ## Build binary distribution
|
||||
python3 setup.py bdist_wheel
|
||||
|
||||
upload: sdist bdist_wheel
|
||||
upload: sdist bdist_wheel ## Upload Python package to PyPI
|
||||
twine upload -s -i gerbonara@jaseg.de --config-file ~/.pypirc --skip-existing --repository pypi dist/*
|
||||
|
||||
testupload: sdist bdist_wheel
|
||||
testupload: sdist bdist_wheel ## Upload Python package to test PyPI
|
||||
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}'
|
||||
|
|
|
|||
26
README.md
|
|
@ -1,5 +1,5 @@
|
|||
[](https://gitlab.com/gerbonara/gerbonara/commits/master)
|
||||
[](https://gitlab.com/gerbonara/gerbonara/commits/master)
|
||||
[](https://gitlab.com/gerbolyze/gerbonara/commits/master)
|
||||
[](https://gitlab.com/gerbolyze/gerbonara/commits/master)
|
||||
[](https://pypi.org/project/gerbonara/)
|
||||
[](https://aur.archlinux.org/packages/python-gerbonara/)
|
||||
|
||||
|
|
@ -23,28 +23,20 @@ yay -S python-gerbonara
|
|||
Python:
|
||||
|
||||
```
|
||||
pip install gerbonara
|
||||
pipx install gerbonara
|
||||
```
|
||||
|
||||
# Usage
|
||||
# Documentation and Examples
|
||||
|
||||
Here's a simple example:
|
||||
Documentation can be found at:
|
||||
|
||||
```python
|
||||
import gerbonara
|
||||
from gerbonara.render import GerberCairoContext
|
||||
https://gerbolyze.gitlab.io/gerbonara
|
||||
|
||||
# Read gerber and Excellon files
|
||||
top_copper = gerbonara.read('example.GTL')
|
||||
nc_drill = gerbonara.read('example.txt')
|
||||
# Issues
|
||||
|
||||
# Rendering context
|
||||
ctx = GerberCairoContext()
|
||||
Please file any bugs at our issue tracker:
|
||||
|
||||
# Create SVG image
|
||||
top_copper.render(ctx)
|
||||
nc_drill.render(ctx, 'composite.svg')
|
||||
```
|
||||
https://gitlab.com/gerbolyze/gerbonara/-/issues
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
373
docs/cli.rst
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
.. _cli-doc:
|
||||
|
||||
Gerbonara's Command-Line Interface
|
||||
==================================
|
||||
|
||||
Gerbonara comes with a built-in command-line interface that has functions for analyzing, rendering, modifying, and
|
||||
merging Gerber files.
|
||||
|
||||
Invocation
|
||||
----------
|
||||
|
||||
There are two ways to call gerbonara's command-line interface:
|
||||
|
||||
.. :code:
|
||||
|
||||
$ gerbonara
|
||||
$ python -m gerbonara
|
||||
|
||||
For the first to work, make sure the installation's ``bin`` dir is in your ``$PATH``. If you installed gerbonara
|
||||
system-wide, that should be the case already, since the binary should end up in ``/usr/bin``. If you installed gerbonara
|
||||
using ``pip install --user``, make sure you have your user's ``~/.local/bin`` in your ``$PATH``.
|
||||
|
||||
Commands and their usage
|
||||
------------------------
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ gerbonara --help
|
||||
Usage: gerbonara [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
The gerbonara CLI allows you to analyze, render, modify and merge both
|
||||
individual Gerber or Excellon files as well as sets of those files
|
||||
|
||||
Options:
|
||||
--version
|
||||
--help Show this message and exit.
|
||||
|
||||
Commands:
|
||||
bounding-box Print the bounding box of a gerber file in "[x_min]...
|
||||
layers Read layers from a directory or zip with Gerber files and...
|
||||
merge Merge multiple single Gerber or Excellon files, or...
|
||||
meta Extract layer mapping and print it along with layer...
|
||||
render Render a gerber file, or a directory or zip of gerber...
|
||||
rewrite Parse a single gerber file, apply transformations, and...
|
||||
transform Transform all gerber files in a given directory or zip...
|
||||
|
||||
Rendering
|
||||
~~~~~~~~~
|
||||
|
||||
Gerbonara can render single Gerber (:py:class:`~.rs274x.GerberFile`) or Excellon (:py:class:`~.excellon.ExcellonFile`)
|
||||
layers, or whole board stacks (:py:class:`~.layers.LayerStack`) to SVG.
|
||||
|
||||
``gerbonara render``
|
||||
********************
|
||||
.. program:: gerbonara render
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ gerbonara render [OPTIONS] INPATH [OUTFILE]
|
||||
|
||||
``gerbonara render`` renders one or more Gerber or Excellon files as a single SVG file. It can read single files,
|
||||
directorys of files, and ZIP files. To read directories or zips, it applies gerbonara's layer filename matching rules.
|
||||
These built-in rules should work with common settings in a wide variety of CAD tools.
|
||||
|
||||
.. option:: --warnings [default|ignore|once]
|
||||
|
||||
Enable or disable file format warnings during parsing (default: on)
|
||||
|
||||
|
||||
.. option:: -m, --input-map <json_file>
|
||||
|
||||
Extend or override layer name mapping with name map from JSON file. The JSON file must contain a single JSON dict
|
||||
with an arbitrary number of string: string entries. The keys are interpreted as regexes applied to the filenames via
|
||||
re.fullmatch, and each value must either be the string ``ignore`` to remove this layer from previous automatic guesses,
|
||||
or a gerbonara layer name such as ``top copper``, ``inner_2 copper`` or ``bottom silk``.
|
||||
|
||||
.. option:: --use-builtin-name-rules / --no-builtin-name-rules
|
||||
|
||||
Disable built-in layer name rules and use only rules given by :option:`--input-map`
|
||||
|
||||
|
||||
.. option:: --force-zip
|
||||
|
||||
Force treating input path as a zip file (default: guess file type from extension and contents)
|
||||
|
||||
.. option:: --top, --bottom
|
||||
|
||||
Which side of the board to render
|
||||
|
||||
.. option:: --command-line-units <metric|us-customary>
|
||||
|
||||
Units for values given in other options. Default: millimeter
|
||||
|
||||
.. option:: --margin <float>
|
||||
|
||||
Add space around the board inside the viewport
|
||||
|
||||
.. option:: --force-bounds <min_x,min_y,max_x,max_y>
|
||||
|
||||
Force SVG bounding box to the given value.
|
||||
|
||||
.. option:: --inkscape, --standard-svg
|
||||
|
||||
Export in Inkscape SVG format with layers and stuff instead of plain SVG.
|
||||
|
||||
.. option:: --colorscheme <json_file>
|
||||
|
||||
Load colorscheme from given JSON file. The JSON file must contain a single dict with keys ``copper``, ``silk``,
|
||||
``mask``, ``paste``, ``drill`` and ``outline``. Each key must map to a string containing either a normal 6-digit hex
|
||||
color with leading hash sign, or an 8-digit hex color with leading hash sign, where the last two digits set the
|
||||
layer's alpha value (opacity), with ``ff`` being completely opaque, and ``00`` being invisibly transparent.
|
||||
|
||||
Modification
|
||||
~~~~~~~~~~~~
|
||||
|
||||
``gerbonara rewrite``
|
||||
*********************
|
||||
|
||||
.. program:: gerbonara rewrite
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
gerbonara rewrite [OPTIONS] INFILE OUTFILE
|
||||
|
||||
Parse a single gerber file, apply transformations, and re-serialize it into a new gerber file. Without transformations,
|
||||
this command can be used to convert a gerber file to use different settings (e.g. units, precision), but can also be
|
||||
used to "normalize" gerber files in a weird format into a more standards-compatible one as gerbonara's gerber parser is
|
||||
significantly more robust for weird inputs than others.
|
||||
|
||||
.. option:: --warnings <default|ignore|once>
|
||||
|
||||
Enable or disable file format warnings during parsing (default: on)
|
||||
|
||||
.. option:: -t, --transform <code>
|
||||
|
||||
Execute python transformation script on input. You have access to the functions ``translate(x, y)``,
|
||||
``scale(factor)`` and ``rotate(angle, center_x?, center_y?)``, the bounding box variables ``x_min``, ``y_min``,
|
||||
``x_max``, ``y_max``, ``width`` and ``height``, and everything from python's built-in math module (e.g. ``pi``,
|
||||
``sqrt``, ``sin``). As convenience methods, ``center()`` and ``origin()`` are provided to center the board
|
||||
respectively move its bottom-left corner to the origin. Coordinates are given in ``--command-line-units``, angles in
|
||||
degrees, and scale as a scale factor (as opposed to a percentage). Example: ``translate(-10, 0); rotate(45, 0, 5)``
|
||||
|
||||
.. option:: --command-line-units <metric|us-customary>
|
||||
|
||||
Units for values given in other options. Default: millimeter
|
||||
|
||||
.. option:: -n, --number-format <decimal.fractional>
|
||||
|
||||
Override number format to use during export in ``[integer digits].[decimal digits]`` notation, e.g. ``2.6``.
|
||||
|
||||
.. option:: -u, --units <metric|us-customary>
|
||||
|
||||
Override export file units
|
||||
|
||||
.. option:: -z, --zero-suppression <off|leading|trailing>
|
||||
|
||||
Override export zero suppression setting. Note: The meaning of this value is like in the Gerber spec for both Gerber
|
||||
and Excellon files!
|
||||
|
||||
.. option:: --keep-comments, --drop-comments
|
||||
|
||||
Keep gerber comments. Note: Comments will be prepended to the start of file, and will not occur in their old
|
||||
position.
|
||||
|
||||
.. option:: --default-settings, --reuse-input-settings
|
||||
|
||||
Use sensible defaults for the output file format settings (default) or use the same export settings as the input file
|
||||
instead of sensible defaults.
|
||||
|
||||
.. option:: --input-number-format <decimal.fractional>
|
||||
|
||||
Override number format of input file (mostly useful for Excellon files)
|
||||
|
||||
.. option:: --input-units <metric|us-customary>
|
||||
|
||||
Override units of input file
|
||||
|
||||
.. option:: --input-zero-suppression <off|leading|trailing>
|
||||
|
||||
Override zero suppression setting of input file
|
||||
|
||||
|
||||
``gerbonara transform``
|
||||
***********************
|
||||
|
||||
.. program:: gerbonara transform
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
gerbonara transform [OPTIONS] TRANSFORM INPATH OUTPATH
|
||||
|
||||
Transform all gerber files in a given directory or zip file using the given python transformation script.
|
||||
|
||||
In the python transformation script you have access to the functions ``translate(x, y)``, ``scale(factor)`` and
|
||||
``rotate(angle, center_x?, center_y?)``, the bounding box variables ``x_min``, ``y_min``, ``x_max``, ``y_max``,
|
||||
``width`` and ``height``, and everything from python's built-in math module (e.g. ``pi``, ``sqrt``, ``sin``). As
|
||||
convenience methods, ``center()`` and ``origin()`` are provided to center the board resp. move its bottom-left corner to
|
||||
the origin. Coordinates are given in --command-line-units, angles in degrees, and scale as a scale factor (as opposed to
|
||||
a percentage). Example: ``translate(-10, 0); rotate(45, 0, 5)``
|
||||
|
||||
.. option:: -m, --input-map <json_file>
|
||||
|
||||
Extend or override layer name mapping with name map from JSON file. The JSON file must contain a single JSON dict
|
||||
with an arbitrary number of string: string entries. The keys are interpreted as regexes applied to the filenames via
|
||||
re.fullmatch, and each value must either be the string ``ignore`` to remove this layer from previous automatic
|
||||
guesses, or a gerbonara layer name such as ``top copper``, ``inner_2 copper`` or ``bottom silk``.
|
||||
|
||||
.. option:: --use-builtin-name-rules, --no-builtin-name-rules
|
||||
|
||||
Disable built-in layer name rules and use only rules given by ``--input-map``
|
||||
|
||||
.. option:: --warnings <default|ignore|once>
|
||||
|
||||
Enable or disable file format warnings during parsing (default: on)
|
||||
|
||||
.. option:: --units <metric|us-customary>
|
||||
|
||||
Units for values given in other options. Default: millimeter
|
||||
|
||||
.. option:: -n, --number-format <decimal.fractional>
|
||||
|
||||
Override number format to use during export in ``[integer digits].[decimal digits]`` notation, e.g. ``2.6``.
|
||||
|
||||
.. option:: --default-settings, --reuse-input-settings
|
||||
|
||||
Use sensible defaults for the output file format settings (default) or use the same export settings as the input file
|
||||
instead of sensible defaults.
|
||||
|
||||
.. option:: --force-zip
|
||||
|
||||
Force treating input path as a zip file (default: guess file type from extension and contents)
|
||||
|
||||
.. option:: --output-naming-scheme <altium|kicad>
|
||||
|
||||
Name output files according to the selected naming scheme instead of keeping the old file names.
|
||||
|
||||
|
||||
``gerbonara merge``
|
||||
*******************
|
||||
|
||||
.. program:: gerbonara merge
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ gerbonara merge [OPTIONS] [INPATH]... OUTPATH
|
||||
|
||||
Merge multiple single Gerber or Excellon files, or multiple stacks of Gerber files, into one.
|
||||
|
||||
.. note::
|
||||
When used with only one input, this command *normalizes* the input, converting all files to a well-defined, widely
|
||||
supported Gerber subset with sane settings. When a ``--output-naming-scheme`` is given, it additionally renames all
|
||||
files to a standardized naming convention.
|
||||
|
||||
.. option:: --command-line-units <metric|us-customary>
|
||||
|
||||
Units for values given in --transform. Default: millimeter
|
||||
|
||||
.. option:: --warnings <default|ignore|once>
|
||||
|
||||
Enable or disable file format warnings during parsing (default: on)
|
||||
|
||||
.. option:: --offset <COORDINATE>
|
||||
|
||||
Offset for the n'th file as a ``x,y`` string in unit given by ``--command-line-units`` (default: millimeter). Can be
|
||||
given multiple times, and the first option affects the first input, the second option affects the second input, and
|
||||
so on.
|
||||
|
||||
.. option:: --rotation <ROTATION>
|
||||
|
||||
Rotation for the n'th file in degrees clockwise, optionally followed by comma- separated rotation center X and Y
|
||||
coordinates. Can be given multiple times, and the first option affects the first input, the second option affects the
|
||||
second input, and so on.
|
||||
|
||||
.. option:: -m, --input-map <json_file>
|
||||
|
||||
Extend or override layer name mapping with name map from JSON file. This option can be given multiple times, in which
|
||||
case the n'th option affects only the n'th input, like with ``--offset`` and ``--rotation``. The JSON file must
|
||||
contain a single JSON dict with an arbitrary number of string: string entries. The keys are interpreted as regexes
|
||||
applied to the filenames via re.fullmatch, and each value must either be the string "ignore" to remove this layer
|
||||
from previous automatic guesses, or a gerbonara layer name such as ``top copper``, ``inner_2 copper`` or ``bottom
|
||||
silk``.
|
||||
|
||||
.. option:: --default-settings, --reuse-input-settings
|
||||
|
||||
Use sensible defaults for the output file format settings (default) or use the same export settings as the input file
|
||||
instead of sensible defaults.
|
||||
|
||||
.. option:: --output-naming-scheme <altium|kicad>
|
||||
|
||||
Name output files according to the selected naming scheme instead of keeping the old file names of the first input.
|
||||
|
||||
.. option:: --output-board-name <TEXT>
|
||||
|
||||
Override board name used with ``--output-naming-scheme``
|
||||
|
||||
.. option:: --use-builtin-name-rules, --no-builtin-name-rules
|
||||
|
||||
Disable built-in layer name rules and use only rules given by --input-map
|
||||
|
||||
File analysis
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
``gerbonara bounding-box``
|
||||
**************************
|
||||
|
||||
.. program:: gerbonara bounding-box
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
gerbonara bounding-box [OPTIONS] INFILE
|
||||
|
||||
Print the bounding box of a gerber file in ``[x_min] [y_min] [x_max] [y_max]`` format. The bounding box contains all
|
||||
graphic objects in this file, so e.g. a 100 mm by 100 mm square drawn with a 1mm width circular aperture will result in
|
||||
an 101 mm by 101 mm bounding box.
|
||||
|
||||
.. option:: --warnings <default|ignore|once>
|
||||
|
||||
Enable or disable file format warnings during parsing (default: on)
|
||||
|
||||
.. option:: --units <metric|us-customary>
|
||||
|
||||
Output bounding box in this unit (default: millimeter)
|
||||
|
||||
.. option:: --input-number-format <decimal.fractional>
|
||||
|
||||
Override number format of input file (mostly useful for Excellon files)
|
||||
|
||||
.. option:: --input-units <metric|us-customary>
|
||||
|
||||
Override units of input file
|
||||
|
||||
.. option:: --input-zero-suppression <off|leading|trailing>
|
||||
|
||||
Override zero suppression setting of input file
|
||||
|
||||
|
||||
``gerbonara meta``
|
||||
******************
|
||||
.. program:: gerbonara meta
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
gerbonara meta [OPTIONS] PATH
|
||||
|
||||
Read a board from a folder or zip, and print the found layer mapping along with layer metadata as JSON to stdout. A
|
||||
machine-readable variant of the :program:`gerbonara render` command. All lengths in the JSON are given in millimeter.
|
||||
|
||||
.. option:: --warnings <default|ignore|once>
|
||||
|
||||
Enable or disable file format warnings during parsing (default: on)
|
||||
|
||||
.. option:: --force-zip
|
||||
|
||||
Force treating input path as zip file (default: guess file type from extension and contents)
|
||||
|
||||
|
||||
``gerbonara layers``
|
||||
********************
|
||||
.. program:: gerbonara render
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ gerbonara layers [OPTIONS] PATH
|
||||
|
||||
Prints a layer-by-layer description of the board found under the given path. The path can be a directory or zip file.
|
||||
|
||||
.. option:: --warnings <default|ignore|once>
|
||||
|
||||
Enable or disable file format warnings during parsing (default: on)
|
||||
|
||||
.. option:: --force-zip
|
||||
|
||||
Force treating input path as zip file (default: guess file type from extension and contents)
|
||||
|
|
@ -10,6 +10,12 @@
|
|||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
|
||||
import subprocess
|
||||
def get_version():
|
||||
res = subprocess.run(['git', 'describe', '--tags', '--match', 'v*'], capture_output=True, check=True, text=True)
|
||||
version, _, _rest = res.stdout.strip()[1:].partition('-')
|
||||
return version
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.absolute()))
|
||||
|
|
@ -21,7 +27,7 @@ copyright = '2022, Jan Götte'
|
|||
author = 'jaseg'
|
||||
|
||||
# The full version, including alpha/beta/rc tags
|
||||
release = '0.9.0'
|
||||
release = get_version()
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
|
|
|||
BIN
docs/ex-mask-islands.png
Normal file
|
After Width: | Height: | Size: 243 KiB |
64
docs/examples.rst
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
.. _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')
|
||||
|
|
@ -12,10 +12,6 @@ syntactic hints, and can automatically match all files in a folder to their appr
|
|||
|
||||
:py:class:`.CamFile` is the common base class for all layer types.
|
||||
|
||||
|
||||
.. autoclass:: gerbonara.layers.LayerStack
|
||||
:members:
|
||||
|
||||
.. autoclass:: gerbonara.cam.CamFile
|
||||
:members:
|
||||
|
||||
|
|
@ -28,3 +24,6 @@ syntactic hints, and can automatically match all files in a folder to their appr
|
|||
.. autoclass:: gerbonara.ipc356.Netlist
|
||||
:members:
|
||||
|
||||
.. autoclass:: gerbonara.layers.LayerStack
|
||||
:members:
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ Features
|
|||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
cli
|
||||
api-concepts
|
||||
examples
|
||||
file-api
|
||||
object-api
|
||||
apertures
|
||||
|
|
@ -57,7 +59,36 @@ Features
|
|||
Quick Start
|
||||
===========
|
||||
|
||||
First, install gerbonara from PyPI using pip:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
pip install --user gerbonara
|
||||
|
||||
Then, you are ready to read and write gerber files:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from gerbonara import LayerStack
|
||||
|
||||
stack = LayerStack.open('output/gerber')
|
||||
w, h = stack.outline.size('mm')
|
||||
print(f'Board size is {w:.1f} mm x {h:.1f} mm')
|
||||
|
||||
You can find some more elaborate examples in this doc's :ref:`Examples section<examples-doc>`.
|
||||
|
||||
Command-Line Interface
|
||||
======================
|
||||
|
||||
Gerbonara comes with a :ref:`built-in command-line interface<cli-doc>` that has functions for analyzing, rendering,
|
||||
modifying, and merging Gerber files. To access it, use either the ``gerbonara`` command that is part of the python
|
||||
package, or run ``python -m gerbonara``. For a list of functions or help on their usage, you can use:
|
||||
|
||||
.. code:: console
|
||||
|
||||
$ python -m gerbonara --help
|
||||
[...]
|
||||
$ python -m gerbonara render --help
|
||||
|
||||
Development
|
||||
===========
|
||||
|
|
@ -74,7 +105,11 @@ Our issue tracker is also on Gitlab:
|
|||
|
||||
https://gitlab.com/gerbolyze/gerbonara/-/issues
|
||||
|
||||
With Gebronara, we aim to support as many different format variants as possible. If you have a file that Gerbonara can't
|
||||
A copy of this documentation can also be found at gitlab:
|
||||
|
||||
https://gerbolyze.gitlab.io/gerbonara/
|
||||
|
||||
With Gerbonara, we aim to support as many different format variants as possible. If you have a file that Gerbonara can't
|
||||
open, please file an issue on our issue tracker. Even if Gerbonara can open all your files, for regression testing we
|
||||
are very interested in example files generated by any CAD or CAM tool that is not already on the list of supported
|
||||
tools.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from gerbonara.utils import MM
|
|||
from gerbonara.utils import rotate_point
|
||||
|
||||
def highlight_outline(input_dir, output_dir):
|
||||
stack = LayerStack.from_directory(input_dir)
|
||||
stack = LayerStack.open(input_dir)
|
||||
|
||||
outline = []
|
||||
for obj in stack.outline.objects:
|
||||
|
|
@ -28,7 +28,6 @@ def highlight_outline(input_dir, output_dir):
|
|||
marker_nx, marker_ny = math.sin(marker_angle), math.cos(marker_angle)
|
||||
|
||||
ap = CircleAperture(0.1, unit=MM)
|
||||
stack['top silk'].apertures.append(ap)
|
||||
|
||||
for line in outline:
|
||||
cx, cy = (line.x1 + line.x2)/2, (line.y1 + line.y2)/2
|
||||
|
|
|
|||
|
|
@ -7,5 +7,5 @@ if __name__ == '__main__':
|
|||
args = parser.parse_args()
|
||||
|
||||
import gerbonara
|
||||
print(gerbonara.LayerStack.from_directory(args.input))
|
||||
print(gerbonara.LayerStack.open(args.input))
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import math
|
||||
|
||||
from gerbonara.utils import MM
|
||||
from gerbonara.graphic_objects import Arc
|
||||
from gerbonara.graphic_objects import rotate_point
|
||||
|
||||
|
|
@ -22,7 +23,8 @@ def approx_test():
|
|||
x1, y1 = rotate_point(0, -1, start_angle*eps)
|
||||
x2, y2 = rotate_point(x1, y1, sweep_angle*eps*(-1 if clockwise else 1))
|
||||
|
||||
arc = Arc(x1+cx, y1+cy, x2+cx, y2+cy, -x1, -y1, clockwise=clockwise, aperture=None, polarity_dark=True)
|
||||
arc = Arc(x1+cx, y1+cy, x2+cx, y2+cy, -x1, -y1, clockwise=clockwise, aperture=None,
|
||||
polarity_dark=True, unit=MM)
|
||||
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=' ')
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
#! /usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2013-2014 Paulo Henrique Silva <ph.silva@gmail.com>
|
||||
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under
|
||||
# the License.
|
||||
|
||||
import os
|
||||
import argparse
|
||||
from .render import available_renderers
|
||||
from .render import theme
|
||||
from .pcb import PCB
|
||||
from . import load_layer
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Render gerber files to image',
|
||||
prog='gerber-render'
|
||||
)
|
||||
parser.add_argument(
|
||||
'filenames', metavar='FILENAME', type=str, nargs='+',
|
||||
help='Gerber files to render. If a directory is provided, it should '
|
||||
'be provided alone and should contain the gerber files for a '
|
||||
'single PCB.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--outfile', '-o', type=str, nargs='?', default='out',
|
||||
help="Output Filename (extension will be added automatically)"
|
||||
)
|
||||
parser.add_argument(
|
||||
'--backend', '-b', choices=available_renderers.keys(), default='cairo',
|
||||
help='Choose the backend to use to generate the output.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--theme', '-t', choices=theme.THEMES.keys(), default='default',
|
||||
help='Select render theme.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--width', type=int, default=1920, help='Maximum width.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--height', type=int, default=1080, help='Maximum height.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose', '-v', action='store_true', default=False,
|
||||
help='Increase verbosity of the output.'
|
||||
)
|
||||
# parser.add_argument(
|
||||
# '--quick', '-q', action='store_true', default=False,
|
||||
# help='Skip longer running rendering steps to produce lower quality'
|
||||
# ' output faster. This only has an effect for the freecad backend.'
|
||||
# )
|
||||
# parser.add_argument(
|
||||
# '--nox', action='store_true', default=False,
|
||||
# help='Run without using any GUI elements. This may produce suboptimal'
|
||||
# 'output. For the freecad backend, colors, transparancy, and '
|
||||
# 'visibility cannot be set without a GUI instance.'
|
||||
# )
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
renderer = available_renderers[args.backend]()
|
||||
|
||||
if args.backend in ['cairo', ]:
|
||||
outext = 'png'
|
||||
else:
|
||||
outext = None
|
||||
|
||||
if os.path.exists(args.filenames[0]) and os.path.isdir(args.filenames[0]):
|
||||
directory = args.filenames[0]
|
||||
pcb = PCB.from_directory(directory)
|
||||
|
||||
if args.backend in ['cairo', ]:
|
||||
top = pcb.top_layers
|
||||
bottom = pcb.bottom_layers
|
||||
copper = pcb.copper_layers
|
||||
|
||||
outline = pcb.outline_layer
|
||||
if outline:
|
||||
top = [outline] + top
|
||||
bottom = [outline] + bottom
|
||||
copper = [outline] + copper + pcb.drill_layers
|
||||
|
||||
renderer.render_layers(
|
||||
layers=top, theme=theme.THEMES[args.theme],
|
||||
max_height=args.height, max_width=args.width,
|
||||
filename='{0}.top.{1}'.format(args.outfile, outext)
|
||||
)
|
||||
renderer.render_layers(
|
||||
layers=bottom, theme=theme.THEMES[args.theme],
|
||||
max_height=args.height, max_width=args.width,
|
||||
filename='{0}.bottom.{1}'.format(args.outfile, outext)
|
||||
)
|
||||
renderer.render_layers(
|
||||
layers=copper, theme=theme.THEMES['Transparent Multilayer'],
|
||||
max_height=args.height, max_width=args.width,
|
||||
filename='{0}.copper.{1}'.format(args.outfile, outext))
|
||||
else:
|
||||
pass
|
||||
else:
|
||||
filenames = args.filenames
|
||||
for filename in filenames:
|
||||
layer = load_layer(filename)
|
||||
settings = theme.THEMES[args.theme].get(layer.layer_class, None)
|
||||
renderer.render_layer(layer, settings=settings)
|
||||
renderer.dump(filename='{0}.{1}'.format(args.outfile, outext))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2021 Jan Götte <gerbonara@jaseg.de>
|
||||
|
||||
import operator
|
||||
import re
|
||||
import ast
|
||||
|
||||
from ..utils import MM, Inch, MILLIMETERS_PER_INCH
|
||||
|
||||
|
||||
def expr(obj):
|
||||
return obj if isinstance(obj, Expression) else ConstantExpression(obj)
|
||||
|
||||
|
||||
class Expression:
|
||||
def optimized(self, variable_binding={}):
|
||||
return self
|
||||
|
||||
def __str__(self):
|
||||
return f'<{self.to_gerber()}>'
|
||||
|
||||
def __repr__(self):
|
||||
return f'<E {self.to_gerber()}>'
|
||||
|
||||
def converted(self, unit):
|
||||
return self
|
||||
|
||||
def calculate(self, variable_binding={}, unit=None):
|
||||
expr = self.converted(unit).optimized(variable_binding)
|
||||
if not isinstance(expr, ConstantExpression):
|
||||
raise IndexError(f'Cannot fully resolve expression due to unresolved variables: {expr} with variables {variable_binding}')
|
||||
return expr.value
|
||||
|
||||
def __add__(self, other):
|
||||
return OperatorExpression(operator.add, self, expr(other)).optimized()
|
||||
|
||||
def __radd__(self, other):
|
||||
return expr(other) + self
|
||||
|
||||
def __sub__(self, other):
|
||||
return OperatorExpression(operator.sub, self, expr(other)).optimized()
|
||||
|
||||
def __rsub__(self, other):
|
||||
return expr(other) - self
|
||||
|
||||
def __mul__(self, other):
|
||||
return OperatorExpression(operator.mul, self, expr(other)).optimized()
|
||||
|
||||
def __rmul__(self, other):
|
||||
return expr(other) * self
|
||||
|
||||
def __truediv__(self, other):
|
||||
return OperatorExpression(operator.truediv, self, expr(other)).optimized()
|
||||
|
||||
def __rtruediv__(self, other):
|
||||
return expr(other) / self
|
||||
|
||||
def __neg__(self):
|
||||
return 0 - self
|
||||
|
||||
def __pos__(self):
|
||||
return self
|
||||
|
||||
class UnitExpression(Expression):
|
||||
def __init__(self, expr, unit):
|
||||
self._expr = expr
|
||||
self.unit = unit
|
||||
|
||||
def to_gerber(self, unit=None):
|
||||
return self.converted(unit).optimized().to_gerber()
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(other) == type(self) and \
|
||||
self.unit == other.unit and\
|
||||
self._expr == other._expr
|
||||
|
||||
def __str__(self):
|
||||
return f'<{self._expr.to_gerber()} {self.unit}>'
|
||||
|
||||
def __repr__(self):
|
||||
return f'<UE {self._expr.to_gerber()} {self.unit}>'
|
||||
|
||||
def converted(self, unit):
|
||||
if self.unit is None or unit is None or self.unit == unit:
|
||||
return self._expr
|
||||
|
||||
elif MM == unit:
|
||||
return self._expr * MILLIMETERS_PER_INCH
|
||||
|
||||
elif Inch == unit:
|
||||
return self._expr / MILLIMETERS_PER_INCH
|
||||
|
||||
else:
|
||||
raise ValueError(f'invalid unit {unit}, must be "inch" or "mm".')
|
||||
|
||||
def __add__(self, other):
|
||||
if not isinstance(other, UnitExpression):
|
||||
raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.')
|
||||
|
||||
if self.unit == other.unit or self.unit is None or other.unit is None:
|
||||
return UnitExpression(self._expr + other._expr, self.unit)
|
||||
|
||||
if other.unit == 'mm': # -> and self.unit == 'inch'
|
||||
return UnitExpression(self._expr + (other._expr / MILLIMETERS_PER_INCH), self.unit)
|
||||
else: # other.unit == 'inch' and self.unit == 'mm'
|
||||
return UnitExpression(self._expr + (other._expr * MILLIMETERS_PER_INCH), self.unit)
|
||||
|
||||
def __radd__(self, other):
|
||||
# left hand side cannot have been an UnitExpression or __radd__ would not have been called
|
||||
raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.')
|
||||
|
||||
def __sub__(self, other):
|
||||
return (self + (-other)).optimize()
|
||||
|
||||
def __rsub__(self, other):
|
||||
# see __radd__ above
|
||||
raise ValueError('Unit mismatch: Can only add/subtract UnitExpression from UnitExpression, not scalar.')
|
||||
|
||||
def __mul__(self, other):
|
||||
return UnitExpression(self._expr * other, self.unit)
|
||||
|
||||
def __rmul__(self, other):
|
||||
return UnitExpression(other * self._expr, self.unit)
|
||||
|
||||
def __truediv__(self, other):
|
||||
return UnitExpression(self._expr / other, self.unit)
|
||||
|
||||
def __rtruediv__(self, other):
|
||||
return UnitExpression(other / self._expr, self.unit)
|
||||
|
||||
def __neg__(self):
|
||||
return UnitExpression(-self._expr, self.unit)
|
||||
|
||||
def __pos__(self):
|
||||
return self
|
||||
|
||||
|
||||
class ConstantExpression(Expression):
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __float__(self):
|
||||
return float(self.value)
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(self) == type(other) and self.value == other.value
|
||||
|
||||
def to_gerber(self, _unit=None):
|
||||
return f'{self.value:.6f}'.rstrip('0').rstrip('.')
|
||||
|
||||
|
||||
class VariableExpression(Expression):
|
||||
def __init__(self, number):
|
||||
self.number = number
|
||||
|
||||
def optimized(self, variable_binding={}):
|
||||
if self.number in variable_binding:
|
||||
return ConstantExpression(variable_binding[self.number])
|
||||
return self
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(self) == type(other) and \
|
||||
self.number == other.number
|
||||
|
||||
def to_gerber(self, _unit=None):
|
||||
return f'${self.number}'
|
||||
|
||||
|
||||
class OperatorExpression(Expression):
|
||||
def __init__(self, op, l, r):
|
||||
self.op = op
|
||||
self.l = ConstantExpression(l) if isinstance(l, (int, float)) else l
|
||||
self.r = ConstantExpression(r) if isinstance(r, (int, float)) else r
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(self) == type(other) and \
|
||||
self.op == other.op and \
|
||||
self.l == other.l and \
|
||||
self.r == other.r
|
||||
|
||||
def optimized(self, variable_binding={}):
|
||||
l = self.l.optimized(variable_binding)
|
||||
r = self.r.optimized(variable_binding)
|
||||
|
||||
if self.op in (operator.add, operator.mul):
|
||||
if id(r) < id(l):
|
||||
l, r = r, l
|
||||
|
||||
if isinstance(l, ConstantExpression) and isinstance(r, ConstantExpression):
|
||||
return ConstantExpression(self.op(float(l), float(r)))
|
||||
|
||||
return OperatorExpression(self.op, l, r)
|
||||
|
||||
def to_gerber(self, unit=None):
|
||||
lval = self.l.to_gerber(unit)
|
||||
rval = self.r.to_gerber(unit)
|
||||
|
||||
if isinstance(self.l, OperatorExpression):
|
||||
lval = f'({lval})'
|
||||
if isinstance(self.r, OperatorExpression):
|
||||
rval = f'({rval})'
|
||||
|
||||
op = {operator.add: '+',
|
||||
operator.sub: '-',
|
||||
operator.mul: 'X',
|
||||
operator.truediv: '/'} [self.op]
|
||||
|
||||
return f'{lval}{op}{rval}'
|
||||
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2021 Jan Götte <gerbonara@jaseg.de>
|
||||
|
||||
import operator
|
||||
import re
|
||||
import ast
|
||||
import copy
|
||||
import math
|
||||
|
||||
from . import primitive as ap
|
||||
from .expression import *
|
||||
from ..utils import MM
|
||||
|
||||
def rad_to_deg(x):
|
||||
return (x / math.pi) * 180
|
||||
|
||||
def _map_expression(node):
|
||||
if isinstance(node, ast.Num):
|
||||
return ConstantExpression(node.n)
|
||||
|
||||
elif isinstance(node, ast.BinOp):
|
||||
op_map = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}
|
||||
return OperatorExpression(op_map[type(node.op)], _map_expression(node.left), _map_expression(node.right))
|
||||
|
||||
elif isinstance(node, ast.UnaryOp):
|
||||
if type(node.op) == ast.UAdd:
|
||||
return _map_expression(node.operand)
|
||||
else:
|
||||
return OperatorExpression(operator.sub, ConstantExpression(0), _map_expression(node.operand))
|
||||
|
||||
elif isinstance(node, ast.Name):
|
||||
return VariableExpression(int(node.id[3:])) # node.id has format var[0-9]+
|
||||
|
||||
else:
|
||||
raise SyntaxError('Invalid aperture macro expression')
|
||||
|
||||
def _parse_expression(expr):
|
||||
expr = expr.lower().replace('x', '*')
|
||||
expr = re.sub(r'\$([0-9]+)', r'var\1', expr)
|
||||
try:
|
||||
parsed = ast.parse(expr, mode='eval').body
|
||||
except SyntaxError as e:
|
||||
raise SyntaxError('Invalid aperture macro expression') from e
|
||||
return _map_expression(parsed)
|
||||
|
||||
class ApertureMacro:
|
||||
def __init__(self, name=None, primitives=None, variables=None):
|
||||
self._name = name
|
||||
self.comments = []
|
||||
self.variables = variables or {}
|
||||
self.primitives = primitives or []
|
||||
|
||||
@classmethod
|
||||
def parse_macro(cls, name, body, unit):
|
||||
macro = cls(name)
|
||||
|
||||
blocks = re.sub(r'\s', '', body).split('*')
|
||||
for block in blocks:
|
||||
if not (block := block.strip()): # empty block
|
||||
continue
|
||||
|
||||
if block[0:1] == '0 ': # comment
|
||||
macro.comments.append(Comment(block[2:]))
|
||||
|
||||
if block[0] == '$': # variable definition
|
||||
name, expr = block.partition('=')
|
||||
number = int(name[1:])
|
||||
if number in macro.variables:
|
||||
raise SyntaxError(f'Re-definition of aperture macro variable {number} inside macro')
|
||||
macro.variables[number] = _parse_expression(expr)
|
||||
|
||||
else: # primitive
|
||||
primitive, *args = block.split(',')
|
||||
args = [ _parse_expression(arg) for arg in args ]
|
||||
primitive = ap.PRIMITIVE_CLASSES[int(primitive)](unit=unit, args=args)
|
||||
macro.primitives.append(primitive)
|
||||
|
||||
return macro
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
if self._name is not None:
|
||||
return self._name
|
||||
else:
|
||||
return f'gn_{hash(self)}'
|
||||
|
||||
@name.setter
|
||||
def name(self, name):
|
||||
self._name = name
|
||||
|
||||
def __str__(self):
|
||||
return f'<Aperture macro {self.name}, variables {str(self.variables)}, primitives {self.primitives}>'
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
def __eq__(self, other):
|
||||
return hasattr(other, 'to_gerber') and self.to_gerber() == other.to_gerber()
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.to_gerber())
|
||||
|
||||
def dilated(self, offset, unit=MM):
|
||||
dup = copy.deepcopy(self)
|
||||
new_primitives = []
|
||||
for primitive in dup.primitives:
|
||||
try:
|
||||
if primitive.exposure.calculate():
|
||||
primitive.dilate(offset, unit)
|
||||
new_primitives.append(primitive)
|
||||
except IndexError:
|
||||
warnings.warn('Cannot dilate aperture macro primitive with exposure value computed from macro variable.')
|
||||
pass
|
||||
dup.primitives = new_primitives
|
||||
return dup
|
||||
|
||||
def to_gerber(self, unit=None):
|
||||
comments = [ c.to_gerber() for c in self.comments ]
|
||||
variable_defs = [ f'${var.to_gerber(unit)}={expr}' for var, expr in self.variables.items() ]
|
||||
primitive_defs = [ prim.to_gerber(unit) for prim in self.primitives ]
|
||||
return '*\n'.join(comments + variable_defs + primitive_defs)
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None, polarity_dark=True):
|
||||
variables = dict(self.variables)
|
||||
for number, value in enumerate(parameters, start=1):
|
||||
if number in variables:
|
||||
raise SyntaxError(f'Re-definition of aperture macro variable {i} through parameter {value}')
|
||||
variables[number] = value
|
||||
|
||||
for primitive in self.primitives:
|
||||
yield from primitive.to_graphic_primitives(offset, rotation, variables, unit, polarity_dark)
|
||||
|
||||
def rotated(self, angle):
|
||||
dup = copy.deepcopy(self)
|
||||
for primitive in dup.primitives:
|
||||
# aperture macro primitives use degree counter-clockwise, our API uses radians clockwise
|
||||
primitive.rotation -= rad_to_deg(angle)
|
||||
return dup
|
||||
|
||||
|
||||
var = VariableExpression
|
||||
deg_per_rad = 180 / math.pi
|
||||
|
||||
class GenericMacros:
|
||||
|
||||
_generic_hole = lambda n: [
|
||||
ap.Circle('mm', [0, var(n), 0, 0]),
|
||||
ap.CenterLine('mm', [0, var(n), var(n+1), 0, 0, var(n+2) * -deg_per_rad])]
|
||||
|
||||
# NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing
|
||||
# API.
|
||||
circle = ApertureMacro('GNC', [
|
||||
ap.Circle('mm', [1, var(1), 0, 0, var(4) * -deg_per_rad]),
|
||||
*_generic_hole(2)])
|
||||
|
||||
rect = ApertureMacro('GNR', [
|
||||
ap.CenterLine('mm', [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]),
|
||||
*_generic_hole(3) ])
|
||||
|
||||
# w must be larger than h
|
||||
obround = ApertureMacro('GNO', [
|
||||
ap.CenterLine('mm', [1, var(1), var(2), 0, 0, var(5) * -deg_per_rad]),
|
||||
ap.Circle('mm', [1, var(2), +var(1)/2, 0, var(5) * -deg_per_rad]),
|
||||
ap.Circle('mm', [1, var(2), -var(1)/2, 0, var(5) * -deg_per_rad]),
|
||||
*_generic_hole(3) ])
|
||||
|
||||
polygon = ApertureMacro('GNP', [
|
||||
ap.Polygon('mm', [1, var(2), 0, 0, var(1), var(3) * -deg_per_rad]),
|
||||
ap.Circle('mm', [0, var(4), 0, 0])])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
#for line in sys.stdin:
|
||||
#expr = _parse_expression(line.strip())
|
||||
#print(expr, '->', expr.optimized())
|
||||
|
||||
for primitive in parse_macro(sys.stdin.read(), 'mm'):
|
||||
print(primitive)
|
||||
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
|
||||
# Copyright 2022 Jan Götte <gerbonara@jaseg.de>
|
||||
|
||||
import warnings
|
||||
import contextlib
|
||||
import math
|
||||
|
||||
from .expression import Expression, UnitExpression, ConstantExpression, expr
|
||||
|
||||
from .. import graphic_primitives as gp
|
||||
|
||||
|
||||
def point_distance(a, b):
|
||||
x1, y1 = a
|
||||
x2, y2 = b
|
||||
return math.sqrt((x2 - x1)**2 + (y2 - y1)**2)
|
||||
|
||||
def deg_to_rad(a):
|
||||
return (a / 180) * math.pi
|
||||
|
||||
class Primitive:
|
||||
def __init__(self, unit, args):
|
||||
self.unit = unit
|
||||
|
||||
if len(args) > len(type(self).__annotations__):
|
||||
raise ValueError(f'Too many arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
|
||||
|
||||
for arg, (name, fieldtype) in zip(args, type(self).__annotations__.items()):
|
||||
arg = expr(arg) # convert int/float to Expression object
|
||||
|
||||
if fieldtype == UnitExpression:
|
||||
setattr(self, name, UnitExpression(arg, unit))
|
||||
else:
|
||||
setattr(self, name, arg)
|
||||
|
||||
for name in type(self).__annotations__:
|
||||
if not hasattr(self, name):
|
||||
raise ValueError(f'Too few arguments ({len(args)}) for aperture macro primitive {self.code} ({type(self)})')
|
||||
|
||||
def to_gerber(self, unit=None):
|
||||
return f'{self.code},' + ','.join(
|
||||
getattr(self, name).to_gerber(unit) for name in type(self).__annotations__)
|
||||
|
||||
def __str__(self):
|
||||
attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__)
|
||||
return f'<{type(self).__name__} {attrs}>'
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
class Calculator:
|
||||
def __init__(self, instance, variable_binding={}, unit=None):
|
||||
self.instance = instance
|
||||
self.variable_binding = variable_binding
|
||||
self.unit = unit
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, _type, _value, _traceback):
|
||||
pass
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.instance, name).calculate(self.variable_binding, self.unit)
|
||||
|
||||
def __call__(self, expr):
|
||||
return expr.calculate(self.variable_binding, self.unit)
|
||||
|
||||
|
||||
class Circle(Primitive):
|
||||
code = 1
|
||||
exposure : Expression
|
||||
diameter : UnitExpression
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
rotation : Expression = None
|
||||
|
||||
def __init__(self, unit, args):
|
||||
super().__init__(unit, args)
|
||||
if self.rotation is None:
|
||||
self.rotation = ConstantExpression(0)
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
x, y = gp.rotate_point(calc.x, calc.y, deg_to_rad(calc.rotation) + rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
self.diameter += UnitExpression(offset, unit)
|
||||
|
||||
class VectorLine(Primitive):
|
||||
code = 20
|
||||
exposure : Expression
|
||||
width : UnitExpression
|
||||
start_x : UnitExpression
|
||||
start_y : UnitExpression
|
||||
end_x : UnitExpression
|
||||
end_y : UnitExpression
|
||||
rotation : Expression
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
center_x = (calc.end_x + calc.start_x) / 2
|
||||
center_y = (calc.end_y + calc.start_y) / 2
|
||||
delta_x = calc.end_x - calc.start_x
|
||||
delta_y = calc.end_y - calc.start_y
|
||||
length = point_distance((calc.start_x, calc.start_y), (calc.end_x, calc.end_y))
|
||||
|
||||
center_x, center_y = center_x+offset[0], center_y+offset[1]
|
||||
rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x)
|
||||
|
||||
return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation,
|
||||
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
self.width += UnitExpression(2*offset, unit)
|
||||
|
||||
|
||||
class CenterLine(Primitive):
|
||||
code = 21
|
||||
exposure : Expression
|
||||
width : UnitExpression
|
||||
height : UnitExpression
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
rotation : Expression
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
w, h = calc.width, calc.height
|
||||
|
||||
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
self.width += UnitExpression(2*offset, unit)
|
||||
|
||||
|
||||
class Polygon(Primitive):
|
||||
code = 5
|
||||
exposure : Expression
|
||||
n_vertices : Expression
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
diameter : UnitExpression
|
||||
rotation : Expression
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
return [ gp.ArcPoly.from_regular_polygon(calc.x, calc.y, calc.diameter/2, calc.n_vertices, rotation,
|
||||
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
self.diameter += UnitExpression(2*offset, unit)
|
||||
|
||||
|
||||
class Thermal(Primitive):
|
||||
code = 7
|
||||
exposure : Expression
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
d_outer : UnitExpression
|
||||
d_inner : UnitExpression
|
||||
gap_w : UnitExpression
|
||||
rotation : Expression
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
|
||||
dark = (bool(calc.exposure) == polarity_dark)
|
||||
|
||||
return [
|
||||
gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark),
|
||||
gp.Circle(x, y, calc.d_inner/2, polarity_dark=not dark),
|
||||
gp.Rectangle(x, y, d_outer, gap_w, rotation=rotation, polarity_dark=not dark),
|
||||
gp.Rectangle(x, y, gap_w, d_outer, rotation=rotation, polarity_dark=not dark),
|
||||
]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
# I'd rather print a warning and produce graphically slightly incorrect output in these few cases here than
|
||||
# producing macros that may evaluate to primitives with negative values.
|
||||
warnings.warn('Attempted dilation of macro aperture thermal primitive. This is not supported.')
|
||||
|
||||
|
||||
class Outline(Primitive):
|
||||
code = 4
|
||||
|
||||
def __init__(self, unit, args):
|
||||
if len(args) < 11:
|
||||
raise ValueError(f'Invalid aperture macro outline primitive, not enough parameters ({len(args)}).')
|
||||
if len(args) > 5004:
|
||||
raise ValueError(f'Invalid aperture macro outline primitive, too many points ({len(args)//2-2}).')
|
||||
|
||||
self.exposure = args.pop(0)
|
||||
|
||||
# length arg must not contain variables (that would not make sense)
|
||||
length_arg = args.pop(0).calculate()
|
||||
|
||||
if length_arg != len(args)//2-1:
|
||||
raise ValueError(f'Invalid aperture macro outline primitive, given size {length_arg} does not match length of coordinate list({len(args)//2-1}).')
|
||||
|
||||
if len(args) % 2 == 1:
|
||||
self.rotation = args.pop()
|
||||
else:
|
||||
self.rotation = ConstantExpression(0.0)
|
||||
|
||||
if args[0] != args[-2] or args[1] != args[-1]:
|
||||
raise ValueError(f'Invalid aperture macro outline primitive, polygon is not closed {args[2:4], args[-3:-1]}')
|
||||
|
||||
self.coords = [(UnitExpression(x, unit), UnitExpression(y, unit)) for x, y in zip(args[0::2], args[1::2])]
|
||||
|
||||
def __str__(self):
|
||||
return f'<Outline {len(self.coords)} points>'
|
||||
|
||||
def to_gerber(self, unit=None):
|
||||
coords = ','.join(coord.to_gerber(unit) for xy in self.coords for coord in xy)
|
||||
return f'{self.code},{self.exposure.to_gerber()},{len(self.coords)-1},{coords},{self.rotation.to_gerber()}'
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
rotation += deg_to_rad(calc.rotation)
|
||||
bound_coords = [ gp.rotate_point(calc(x), calc(y), rotation, 0, 0) for x, y in self.coords ]
|
||||
bound_coords = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ]
|
||||
bound_radii = [None] * len(bound_coords)
|
||||
return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
# we would need a whole polygon offset/clipping library here
|
||||
warnings.warn('Attempted dilation of macro aperture outline primitive. This is not supported.')
|
||||
|
||||
|
||||
class Comment:
|
||||
code = 0
|
||||
|
||||
def __init__(self, comment):
|
||||
self.comment = comment
|
||||
|
||||
def to_gerber(self, unit=None):
|
||||
return f'0 {self.comment}'
|
||||
|
||||
PRIMITIVE_CLASSES = {
|
||||
**{cls.code: cls for cls in [
|
||||
Comment,
|
||||
Circle,
|
||||
VectorLine,
|
||||
CenterLine,
|
||||
Outline,
|
||||
Polygon,
|
||||
Thermal,
|
||||
]},
|
||||
# alternative codes
|
||||
2: VectorLine,
|
||||
}
|
||||
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from argparse import PARSER
|
||||
|
||||
# Copyright 2015 Garret Fick <garret@ficksworkshop.com>
|
||||
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Excellon Settings Definition File module
|
||||
====================
|
||||
**Excellon file classes**
|
||||
|
||||
This module provides Excellon file classes and parsing utilities
|
||||
"""
|
||||
|
||||
import re
|
||||
try:
|
||||
from cStringIO import StringIO
|
||||
except(ImportError):
|
||||
from io import StringIO
|
||||
|
||||
from .cam import FileSettings
|
||||
|
||||
def loads(data):
|
||||
""" Read settings file information and return an FileSettings
|
||||
Parameters
|
||||
----------
|
||||
data : string
|
||||
string containing Excellon settings file contents
|
||||
|
||||
Returns
|
||||
-------
|
||||
file settings: FileSettings
|
||||
|
||||
"""
|
||||
|
||||
return ExcellonSettingsParser().parse_raw(data)
|
||||
|
||||
def map_coordinates(value):
|
||||
if value == 'ABSOLUTE':
|
||||
return 'absolute'
|
||||
return 'relative'
|
||||
|
||||
def map_units(value):
|
||||
if value == 'ENGLISH':
|
||||
return 'inch'
|
||||
return 'metric'
|
||||
|
||||
def map_boolean(value):
|
||||
return value == 'YES'
|
||||
|
||||
SETTINGS_KEYS = {
|
||||
'INTEGER-PLACES': (int, 'format-int'),
|
||||
'DECIMAL-PLACES': (int, 'format-dec'),
|
||||
'COORDINATES': (map_coordinates, 'notation'),
|
||||
'OUTPUT-UNITS': (map_units, 'units'),
|
||||
}
|
||||
|
||||
class ExcellonSettingsParser(object):
|
||||
"""Excellon Settings PARSER
|
||||
|
||||
Parameters
|
||||
----------
|
||||
None
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.values = {}
|
||||
self.settings = None
|
||||
|
||||
def parse_raw(self, data):
|
||||
for line in StringIO(data):
|
||||
self._parse(line.strip())
|
||||
|
||||
# Create the FileSettings object
|
||||
self.settings = FileSettings(
|
||||
notation=self.values['notation'],
|
||||
units=self.values['units'],
|
||||
format=(self.values['format-int'], self.values['format-dec'])
|
||||
)
|
||||
|
||||
return self.settings
|
||||
|
||||
def _parse(self, line):
|
||||
|
||||
line_items = line.split()
|
||||
if len(line_items) == 2:
|
||||
|
||||
item_type_info = SETTINGS_KEYS.get(line_items[0])
|
||||
if item_type_info:
|
||||
# Convert the value to the expected type
|
||||
item_value = item_type_info[0](line_items[1])
|
||||
|
||||
self.values[item_type_info[1]] = item_value
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2022 Jan Götte <code@jaseg.de>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import math
|
||||
import itertools
|
||||
|
||||
from dataclasses import dataclass, KW_ONLY, replace
|
||||
|
||||
from .utils import *
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphicPrimitive:
|
||||
_ : KW_ONLY
|
||||
polarity_dark : bool = True
|
||||
|
||||
def bounding_box(self):
|
||||
""" Return the axis-aligned bounding box of this feature.
|
||||
|
||||
:returns: ``((min_x, min_Y), (max_x, max_y))``
|
||||
:rtype: tuple
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
""" Render this primitive into its SVG representation.
|
||||
|
||||
:param str fg: Foreground color. Must be an SVG color name.
|
||||
:param str bg: Background color. Must be an SVG color name.
|
||||
:param function tag: Tag constructor to use.
|
||||
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Circle(GraphicPrimitive):
|
||||
#: Center X coordinate
|
||||
x : float
|
||||
#: Center y coordinate
|
||||
y : float
|
||||
#: Radius, not diameter like in :py:class:`.apertures.CircleAperture`
|
||||
r : float # Here, we use radius as common in modern computer graphics, not diameter as gerber uses.
|
||||
|
||||
def bounding_box(self):
|
||||
return ((self.x-self.r, self.y-self.r), (self.x+self.r, self.y+self.r))
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
return tag('circle', cx=self.x, cy=self.y, r=self.r, style=f'fill: {color}')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArcPoly(GraphicPrimitive):
|
||||
""" Polygon whose sides may be either straight lines or circular arcs. """
|
||||
|
||||
#: list of (x : float, y : float) tuples. Describes closed outline, i.e. the first and last point are considered
|
||||
#: connected.
|
||||
outline : list
|
||||
#: Must be either None (all segments are straight lines) or same length as outline.
|
||||
#: Straight line segments have None entry.
|
||||
arc_centers : list = None
|
||||
|
||||
@property
|
||||
def segments(self):
|
||||
""" Return an iterator through all *segments* of this polygon. For each outline segment (line or arc), this
|
||||
iterator will yield a ``(p1, p2, center)`` tuple. If the segment is a straight line, ``center`` will be
|
||||
``None``.
|
||||
"""
|
||||
ol = self.outline
|
||||
return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers or [])
|
||||
|
||||
def bounding_box(self):
|
||||
bbox = (None, None), (None, None)
|
||||
for (x1, y1), (x2, y2), arc in self.segments:
|
||||
if arc:
|
||||
clockwise, (cx, cy) = arc
|
||||
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
|
||||
|
||||
else:
|
||||
line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2))
|
||||
bbox = add_bounds(bbox, line_bounds)
|
||||
return bbox
|
||||
|
||||
@classmethod
|
||||
def from_regular_polygon(kls, x:float, y:float, r:float, n:int, rotation:float=0, polarity_dark:bool=True):
|
||||
""" Convert an n-sided gerber polygon to a normal ArcPoly defined by outline """
|
||||
|
||||
delta = 2*math.pi / n
|
||||
|
||||
return kls([
|
||||
(x + math.cos(rotation + i*delta) * r,
|
||||
y + math.sin(rotation + i*delta) * r)
|
||||
for i in range(n) ], polarity_dark=polarity_dark)
|
||||
|
||||
def __len__(self):
|
||||
""" Return the number of points on this polygon's outline (which is also the number of segments because the
|
||||
polygon is closed). """
|
||||
return len(self.outline)
|
||||
|
||||
def __bool__(self):
|
||||
""" Return ``True`` if this polygon has any outline points. """
|
||||
return bool(len(self))
|
||||
|
||||
def _path_d(self):
|
||||
if len(self.outline) == 0:
|
||||
return
|
||||
|
||||
yield f'M {self.outline[0][0]:.6} {self.outline[0][1]:.6}'
|
||||
|
||||
for old, new, arc in self.segments:
|
||||
if not arc:
|
||||
yield f'L {new[0]:.6} {new[1]:.6}'
|
||||
else:
|
||||
clockwise, center = arc
|
||||
yield svg_arc(old, new, center, clockwise)
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
return tag('path', d=' '.join(self._path_d()), style=f'fill: {color}')
|
||||
|
||||
|
||||
@dataclass
|
||||
class Line(GraphicPrimitive):
|
||||
""" Straight line with round end caps. """
|
||||
#: Start X coordinate. As usual in modern graphics APIs, this is at the center of the half-circle capping off this
|
||||
#: line.
|
||||
x1 : float
|
||||
#: Start Y coordinate
|
||||
y1 : float
|
||||
#: End X coordinate
|
||||
x2 : float
|
||||
#: End Y coordinate
|
||||
y2 : float
|
||||
#: Line width
|
||||
width : float
|
||||
|
||||
@classmethod
|
||||
def from_obround(kls, x:float, y:float, w:float, h:float, rotation:float=0, polarity_dark:bool=True):
|
||||
""" Convert a gerber obround into a :py:class:`~.graphic_primitives.Line`. """
|
||||
if w > h:
|
||||
w, a, b = h, w-h, 0
|
||||
else:
|
||||
w, a, b = w, 0, h-w
|
||||
|
||||
return kls(
|
||||
*rotate_point(x-a/2, y-b/2, rotation, x, y),
|
||||
*rotate_point(x+a/2, y+b/2, rotation, x, y),
|
||||
w, polarity_dark=polarity_dark)
|
||||
|
||||
def bounding_box(self):
|
||||
r = self.width / 2
|
||||
return add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box())
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||
return tag('path', d=f'M {self.x1:.6} {self.y1:.6} L {self.x2:.6} {self.y2:.6}',
|
||||
style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round')
|
||||
|
||||
@dataclass
|
||||
class Arc(GraphicPrimitive):
|
||||
""" Circular arc with line width ``width`` going from ``(x1, y1)`` to ``(x2, y2)`` around center at ``(cx, cy)``. """
|
||||
#: Start X coodinate
|
||||
x1 : float
|
||||
#: Start Y coodinate
|
||||
y1 : float
|
||||
#: End X coodinate
|
||||
x2 : float
|
||||
#: End Y coodinate
|
||||
y2 : float
|
||||
#: Center X coordinate relative to ``x1``
|
||||
cx : float
|
||||
#: Center Y coordinate relative to ``y1``
|
||||
cy : float
|
||||
#: ``True`` if this arc is clockwise from start to end. Selects between the large arc and the small arc given this
|
||||
#: start, end and center
|
||||
clockwise : bool
|
||||
#: Line width of this arc.
|
||||
width : float
|
||||
|
||||
def bounding_box(self):
|
||||
r = self.width/2
|
||||
endpoints = add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box())
|
||||
|
||||
arc_r = math.dist((self.cx, self.cy), (self.x1, self.y1))
|
||||
|
||||
# extend C -> P1 line by line width / 2 along radius
|
||||
dx, dy = self.x1 - self.cx, self.y1 - self.cy
|
||||
x1 = self.x1 + dx/arc_r * r
|
||||
y1 = self.y1 + dy/arc_r * r
|
||||
|
||||
# same for C -> P2
|
||||
dx, dy = self.x2 - self.cx, self.y2 - self.cy
|
||||
x2 = self.x2 + dx/arc_r * r
|
||||
y2 = self.y2 + dy/arc_r * r
|
||||
|
||||
arc = arc_bounds(x1, y1, x2, y2, self.cx, self.cy, self.clockwise)
|
||||
return add_bounds(endpoints, arc) # FIXME add "include_center" switch
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise)
|
||||
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||
return tag('path', d=f'M {self.x1:.6} {self.y1:.6} {arc}',
|
||||
style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linecap: round; fill: none')
|
||||
|
||||
@dataclass
|
||||
class Rectangle(GraphicPrimitive):
|
||||
#: **Center** X coordinate
|
||||
x : float
|
||||
#: **Center** Y coordinate
|
||||
y : float
|
||||
#: width
|
||||
w : float
|
||||
#: height
|
||||
h : float
|
||||
#: rotation around center in radians
|
||||
rotation : float
|
||||
|
||||
def bounding_box(self):
|
||||
return self.to_arc_poly().bounding_box()
|
||||
|
||||
def to_arc_poly(self):
|
||||
sin, cos = math.sin(self.rotation), math.cos(self.rotation)
|
||||
sw, cw = sin*self.w/2, cos*self.w/2
|
||||
sh, ch = sin*self.h/2, cos*self.h/2
|
||||
x, y = self.x, self.y
|
||||
return ArcPoly([
|
||||
(x - (cw+sh), y - (ch+sw)),
|
||||
(x - (cw+sh), y + (ch+sw)),
|
||||
(x + (cw+sh), y + (ch+sw)),
|
||||
(x + (cw+sh), y - (ch+sw)),
|
||||
])
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
x, y = self.x - self.w/2, self.y - self.h/2
|
||||
return tag('rect', x=x, y=y, width=self.w, height=self.h,
|
||||
transform=svg_rotation(self.rotation, self.x, self.y), style=f'fill: {color}')
|
||||
|
||||
|
|
@ -1,584 +0,0 @@
|
|||
#! /usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
|
||||
# Copyright 2022 Jan Götte <code@jaseg.de>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import os
|
||||
import re
|
||||
import warnings
|
||||
import copy
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
|
||||
from .excellon import ExcellonFile, parse_allegro_ncparam, parse_allegro_logfile
|
||||
from .rs274x import GerberFile
|
||||
from .ipc356 import Netlist
|
||||
from .cam import FileSettings
|
||||
from .layer_rules import MATCH_RULES
|
||||
|
||||
|
||||
STANDARD_LAYERS = [
|
||||
'mechanical outline',
|
||||
'top copper',
|
||||
'top mask',
|
||||
'top silk',
|
||||
'top paste',
|
||||
'bottom copper',
|
||||
'bottom mask',
|
||||
'bottom silk',
|
||||
'bottom paste',
|
||||
]
|
||||
|
||||
class NamingScheme:
|
||||
kicad = {
|
||||
'top copper': '{board_name}-F.Cu.gbr',
|
||||
'top mask': '{board_name}-F.Mask.gbr',
|
||||
'top silk': '{board_name}-F.SilkS.gbr',
|
||||
'top paste': '{board_name}-F.Paste.gbr',
|
||||
'bottom copper': '{board_name}-B.Cu.gbr',
|
||||
'bottom mask': '{board_name}-B.Mask.gbr',
|
||||
'bottom silk': '{board_name}-B.SilkS.gbr',
|
||||
'bottom paste': '{board_name}-B.Paste.gbr',
|
||||
'inner copper': '{board_name}-In{layer_number}.Cu.gbr',
|
||||
'mechanical outline': '{board_name}-Edge.Cuts.gbr',
|
||||
'drill unknown': '{board_name}.drl',
|
||||
'other netlist': '{board_name}.d356',
|
||||
}
|
||||
|
||||
|
||||
def match_files(filenames):
|
||||
matches = {}
|
||||
for generator, rules in MATCH_RULES.items():
|
||||
gen = {}
|
||||
matches[generator] = gen
|
||||
for layer, regex in rules.items():
|
||||
for fn in filenames:
|
||||
if (m := re.fullmatch(regex, fn.name, re.IGNORECASE)):
|
||||
if layer == 'inner copper':
|
||||
target = 'inner_' + ''.join(e or '' for e in m.groups()) + ' copper'
|
||||
else:
|
||||
target = layer
|
||||
gen[target] = gen.get(target, []) + [fn]
|
||||
return matches
|
||||
|
||||
|
||||
def best_match(filenames):
|
||||
matches = match_files(filenames)
|
||||
matches = sorted(matches.items(), key=lambda pair: len(pair[1]))
|
||||
generator, files = matches[-1]
|
||||
return generator, files
|
||||
|
||||
|
||||
def identify_file(data):
|
||||
if 'M48' in data:
|
||||
return 'excellon'
|
||||
|
||||
if 'G90' in data and ';LEADER:' in data: # yet another allegro special case
|
||||
return 'excellon'
|
||||
|
||||
if 'FSLAX' in data or 'FSTAX' in data:
|
||||
return 'gerber'
|
||||
|
||||
if 'UNITS CUST' in data:
|
||||
return 'ipc356'
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def common_prefix(l):
|
||||
out = []
|
||||
for cand in l:
|
||||
score = lambda n: sum(elem.startswith(cand[:n]) for elem in l)
|
||||
baseline = score(1)
|
||||
if len(l) - baseline > 5:
|
||||
continue
|
||||
for n in range(2, len(cand)):
|
||||
if len(l) - score(n) > 5:
|
||||
break
|
||||
out.append(cand[:n-1])
|
||||
|
||||
if not out:
|
||||
return ''
|
||||
|
||||
return sorted(out, key=len)[-1]
|
||||
|
||||
def autoguess(filenames):
|
||||
prefix = common_prefix([f.name for f in filenames])
|
||||
|
||||
matches = {}
|
||||
for f in filenames:
|
||||
name = layername_autoguesser(f.name[len(prefix):] if f.name.startswith(prefix) else f.name)
|
||||
if name != 'unknown unknown':
|
||||
matches[name] = matches.get(name, []) + [f]
|
||||
|
||||
inner_layers = [ m for m in matches if 'inner' in m ]
|
||||
if len(inner_layers) >= 2 and 'copper top' not in matches and 'copper bottom' not in matches:
|
||||
if 'inner_01 copper' in matches:
|
||||
warnings.warn('Could not find copper layer. Re-assigning outermost inner layers to top/bottom copper.')
|
||||
matches['top copper'] = matches.pop('inner_01 copper')
|
||||
last_inner = sorted(inner_layers, key=lambda name: int(name.partition(' ')[0].partition('_')[2]))[-1]
|
||||
matches['bottom copper'] = matches.pop(last_inner)
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def layername_autoguesser(fn):
|
||||
fn, _, ext = fn.lower().rpartition('.')
|
||||
|
||||
if ext in ('log', 'err', 'fdl', 'py', 'sh', 'md', 'rst', 'zip', 'pdf', 'svg', 'ps', 'png', 'jpg', 'bmp'):
|
||||
return 'unknown unknown'
|
||||
|
||||
side, use = 'unknown', 'unknown'
|
||||
if re.search('top|front|pri?m?(ary)?', fn):
|
||||
side = 'top'
|
||||
use = 'copper'
|
||||
|
||||
if re.search('bot(tom)?|back|sec(ondary)?', fn):
|
||||
side = 'bottom'
|
||||
use = 'copper'
|
||||
|
||||
if re.search('silks?(creen)?|symbol', fn):
|
||||
use = 'silk'
|
||||
|
||||
elif re.search('(solder)?paste|metalmask', fn):
|
||||
use = 'paste'
|
||||
|
||||
elif re.search('(solder)?(mask|resist)', fn):
|
||||
use = 'mask'
|
||||
|
||||
elif re.search('drill|rout?e?', fn):
|
||||
use = 'drill'
|
||||
side = 'unknown'
|
||||
|
||||
if re.search(r'np(th|lt)?|(non|un)\W*plated|(non|un)\Wgalv', fn):
|
||||
side = 'nonplated'
|
||||
|
||||
elif re.search('pth|plated|galv|plt', fn):
|
||||
side = 'plated'
|
||||
|
||||
elif (m := re.search(r'(la?y?e?r?|in(ner)?|conduct(or|ive)?)\W*(?P<num>[0-9]+)', fn)):
|
||||
use = 'copper'
|
||||
side = f'inner_{int(m["num"]):02d}'
|
||||
|
||||
elif re.search('film', fn):
|
||||
use = 'copper'
|
||||
|
||||
elif re.search('out(line)?', fn):
|
||||
use = 'outline'
|
||||
side = 'mechanical'
|
||||
|
||||
elif 'ipc' in fn and '356' in fn:
|
||||
use = 'netlist'
|
||||
side = 'other'
|
||||
|
||||
elif 'netlist' in fn:
|
||||
use = 'netlist'
|
||||
side = 'other'
|
||||
|
||||
if side == 'unknown':
|
||||
if re.search(r'[^a-z0-9]a', fn):
|
||||
side = 'top'
|
||||
elif re.search(r'[^a-z0-9]b', fn):
|
||||
side = 'bottom'
|
||||
|
||||
return f'{side} {use}'
|
||||
|
||||
|
||||
class LayerStack:
|
||||
|
||||
def __init__(self, graphic_layers, drill_layers, netlist=None, board_name=None):
|
||||
self.graphic_layers = graphic_layers
|
||||
self.drill_layers = drill_layers
|
||||
self.board_name = board_name
|
||||
self.netlist = netlist
|
||||
|
||||
@classmethod
|
||||
def from_directory(kls, directory, board_name=None):
|
||||
|
||||
directory = Path(directory)
|
||||
if not directory.is_dir():
|
||||
raise FileNotFoundError(f'{directory} is not a directory')
|
||||
|
||||
files = [ path for path in directory.glob('**/*') if path.is_file() ]
|
||||
generator, filemap = best_match(files)
|
||||
|
||||
if sum(len(files) for files in filemap.values()) < 6:
|
||||
warnings.warn('Ambiguous gerber filenames. Trying last-resort autoguesser.')
|
||||
generator = None
|
||||
filemap = autoguess(files)
|
||||
if len(filemap) < 6:
|
||||
raise ValueError('Cannot figure out gerber file mapping. Partial map is: ', filemap)
|
||||
|
||||
excellon_settings, external_tools = None, None
|
||||
if generator == 'geda':
|
||||
# geda is written by geniuses who waste no bytes of unnecessary output so it doesn't actually include the
|
||||
# number format in files that use imperial units. Unfortunately it also doesn't include any hints that the
|
||||
# file was generated by geda, so we have to guess by context whether this is just geda being geda or
|
||||
# potential user error.
|
||||
excellon_settings = FileSettings(number_format=(2, 4))
|
||||
|
||||
elif generator == 'allegro':
|
||||
# Allegro puts information that is absolutely vital for parsing its excellon files... into another file,
|
||||
# next to the actual excellon file. Despite pretty much everyone else having figured out a way to put that
|
||||
# info into the excellon file itself, even if only as a comment.
|
||||
if 'excellon params' in filemap:
|
||||
excellon_settings = parse_allegro_ncparam(filemap['excellon params'][0].read_text())
|
||||
for file in filemap['excellon params']:
|
||||
if (external_tools := parse_allegro_logfile(file.read_text())):
|
||||
break
|
||||
del filemap['excellon params']
|
||||
# Ignore if we can't find the param file -- maybe the user has convinced Allegro to actually put this
|
||||
# information into a comment, or maybe they have made Allegro just use decimal points like XNC does.
|
||||
|
||||
filemap = autoguess([ f for files in filemap.values() for f in files ])
|
||||
if len(filemap) < 6:
|
||||
raise SystemError('Cannot figure out gerber file mapping')
|
||||
# FIXME use layer metadata from comments and ipc file if available
|
||||
|
||||
elif generator == 'zuken':
|
||||
filemap = autoguess([ f for files in filemap.values() for f in files ])
|
||||
if len(filemap) < 6:
|
||||
raise SystemError('Cannot figure out gerber file mapping')
|
||||
# FIXME use layer metadata from comments and ipc file if available
|
||||
|
||||
elif generator == 'altium':
|
||||
if 'mechanical outline' in filemap:
|
||||
# Use lowest-numbered mechanical layer as outline, ignore others.
|
||||
mechs = {}
|
||||
for layer in filemap['mechanical outline']:
|
||||
if layer.name.lower().endswith('gko'):
|
||||
filemap['mechanical outline'] = [layer]
|
||||
break
|
||||
|
||||
if (m := re.match(r'.*\.gm([0-9]+)', layer.name, re.IGNORECASE)):
|
||||
mechs[int(m[1])] = layer
|
||||
else:
|
||||
break
|
||||
else:
|
||||
filemap['mechanical outline'] = [sorted(mechs.items(), key=lambda x: x[0])[0][1]]
|
||||
|
||||
else:
|
||||
excellon_settings = None
|
||||
|
||||
ambiguous = [ f'{key} ({", ".join(x.name for x in value)})' for key, value in filemap.items() if len(value) > 1 and not 'drill' in key ]
|
||||
if ambiguous:
|
||||
raise SystemError(f'Ambiguous layer names: {", ".join(ambiguous)}')
|
||||
|
||||
drill_layers = []
|
||||
netlist = None
|
||||
layers = {} # { tuple(key.split()): None for key in STANDARD_LAYERS }
|
||||
for key, paths in filemap.items():
|
||||
if len(paths) > 1 and not 'drill' in key:
|
||||
raise ValueError(f'Multiple matching files found for {key} layer: {", ".join(value)}')
|
||||
|
||||
for path in paths:
|
||||
id_result = identify_file(path.read_text())
|
||||
|
||||
if 'netlist' in key:
|
||||
layer = Netlist.open(path)
|
||||
|
||||
elif ('outline' in key or 'drill' in key) and id_result != 'gerber':
|
||||
if id_result is None:
|
||||
# Since e.g. altium uses ".txt" as the extension for its drill files, we have to assume the
|
||||
# current file might not be a drill file after all.
|
||||
continue
|
||||
|
||||
if 'nonplated' in key:
|
||||
plated = False
|
||||
elif 'plated' in key:
|
||||
plated = True
|
||||
else:
|
||||
plated = None
|
||||
layer = ExcellonFile.open(path, plated=plated, settings=excellon_settings, external_tools=external_tools)
|
||||
else:
|
||||
|
||||
layer = GerberFile.open(path)
|
||||
|
||||
if key == 'mechanical outline':
|
||||
layers['mechanical', 'outline'] = layer
|
||||
|
||||
elif 'drill' in key:
|
||||
drill_layers.append(layer)
|
||||
|
||||
elif 'netlist' in key:
|
||||
if netlist:
|
||||
warnings.warn(f'Found multiple netlist files, using only first one. Have: {netlist.original_path.name}, got {path.name}')
|
||||
else:
|
||||
netlist = layer
|
||||
|
||||
else:
|
||||
side, _, use = key.partition(' ')
|
||||
layers[(side, use)] = layer
|
||||
|
||||
hints = set(layer.generator_hints) | { generator }
|
||||
if len(hints) > 1:
|
||||
warnings.warn('File identification returned ambiguous results. Please raise an issue on the gerbonara '
|
||||
'tracker and if possible please provide these input files for reference.')
|
||||
|
||||
board_name = common_prefix([l.original_path.name for l in layers.values() if l is not None])
|
||||
board_name = re.sub(r'^\W+', '', board_name)
|
||||
board_name = re.sub(r'\W+$', '', board_name)
|
||||
return kls(layers, drill_layers, netlist, board_name=board_name)
|
||||
|
||||
def save_to_directory(self, path, naming_scheme={}, overwrite_existing=True):
|
||||
outdir = Path(path)
|
||||
outdir.mkdir(parents=True, exist_ok=overwrite_existing)
|
||||
|
||||
def check_not_exists(path):
|
||||
if path.exists() and not overwrite_existing:
|
||||
raise SystemError(f'Path exists but overwrite_existing is False: {path}')
|
||||
|
||||
def get_name(layer_type, layer):
|
||||
nonlocal naming_scheme, overwrite_existing
|
||||
|
||||
if (m := re.match('inner_([0-9]*) copper', layer_type)):
|
||||
layer_type = 'inner copper'
|
||||
num = int(m[1])
|
||||
else:
|
||||
num = None
|
||||
|
||||
if layer_type in naming_scheme:
|
||||
path = outdir / naming_scheme[layer_type].format(layer_num=num, board_name=self.board_name)
|
||||
else:
|
||||
path = outdir / layer.original_path.name
|
||||
|
||||
check_not_exists(path)
|
||||
return path
|
||||
|
||||
for (side, use), layer in self.graphic_layers.items():
|
||||
outpath = get_name(f'{side} {use}', layer)
|
||||
layer.save(outpath)
|
||||
|
||||
if naming_scheme:
|
||||
self.normalize_drill_layers()
|
||||
|
||||
def save_layer(layer, layer_name):
|
||||
nonlocal self, outdir, drill_layers, check_not_exists
|
||||
path = outdir / drill_layers[layer_name].format(board_name=self.board_name)
|
||||
check_not_exists(path)
|
||||
layer.save(path)
|
||||
|
||||
drill_layers = { key.partition()[2]: value for key, value in naming_scheme if 'drill' in key }
|
||||
if set(drill_layers) == {'plated', 'nonplated', 'unknown'}:
|
||||
save_layer(self.drill_pth, 'plated')
|
||||
save_layer(self.drill_npth, 'nonplated')
|
||||
save_layer(self.drill_unknown, 'unknown')
|
||||
|
||||
elif 'plated' in drill_layers and len(drill_layers) == 2:
|
||||
save_layer(self.drill_pth, 'plated')
|
||||
merged = copy.copy(self.drill_npth)
|
||||
merged.merge(self.drill_unknown)
|
||||
save_layer(merged, list(set(drill_layers) - {'plated'})[0])
|
||||
|
||||
elif 'unknown' in drill_layers:
|
||||
merged = copy.copy(self.drill_pth)
|
||||
merged.merge(self.drill_npth)
|
||||
merged.merge(self.drill_unknown)
|
||||
save_layer(merged, 'unknown')
|
||||
|
||||
else:
|
||||
raise ValueError('Namin scheme does not specify unknown drill layer')
|
||||
|
||||
else:
|
||||
for layer in self.drill_layers:
|
||||
outpath = outdir / layer.original_path.name
|
||||
check_not_exists(outpath)
|
||||
layer.save(outpath)
|
||||
|
||||
if self.netlist:
|
||||
layer.save(get_name('other netlist', self.netlist))
|
||||
|
||||
def __str__(self):
|
||||
names = [ f'{side} {use}' for side, use in self.graphic_layers ]
|
||||
return f'<LayerStack {self.board_name} [{", ".join(names)}] and {len(self.drill_layers)} drill layers>'
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
def merge_drill_layers(self):
|
||||
target = ExcellonFile(comments='Drill files merged by gerbonara')
|
||||
|
||||
for layer in self.drill_layers:
|
||||
if isinstance(layer, GerberFile):
|
||||
layer = layer.to_excellon()
|
||||
|
||||
target.merge(layer)
|
||||
|
||||
self.drill_layers = [target]
|
||||
|
||||
def normalize_drill_layers(self):
|
||||
# TODO: maybe also separate into drill and route?
|
||||
drill_pth, drill_npth, drill_aux = [], [], []
|
||||
|
||||
for layer in self.drill_layers:
|
||||
if isinstance(layer, GerberFile):
|
||||
layer = layer.to_excellon()
|
||||
|
||||
if layer.is_plated:
|
||||
drill_pth.append(layer)
|
||||
elif layer.is_nonplated:
|
||||
drill_pth.append(layer)
|
||||
else:
|
||||
drill_aux.append(layer)
|
||||
|
||||
pth_out, *rest = drill_pth or [ExcellonFile()]
|
||||
for layer in rest:
|
||||
pth_out.merge(layer)
|
||||
|
||||
npth_out, *rest = drill_npth or [ExcellonFile()]
|
||||
for layer in rest:
|
||||
npth_out.merge(layer)
|
||||
|
||||
unknown_out = ExcellonFile()
|
||||
for layer in drill_aux:
|
||||
for obj in layer.objects:
|
||||
if obj.plated is None:
|
||||
unknown_out.append(obj)
|
||||
elif obj.plated:
|
||||
pth_out.append(obj)
|
||||
else:
|
||||
npth_out.append(obj)
|
||||
|
||||
self.drill_pth, self.drill_npth = pth_out, npth_out
|
||||
self.drill_unknown = unknown_out if unknown_out else None
|
||||
self._drill_layers = []
|
||||
|
||||
@property
|
||||
def drill_layers(self):
|
||||
if self._drill_layers:
|
||||
return self._drill_layers
|
||||
if self.drill_pth or self.drill_npth or self.drill_unknown:
|
||||
return [self.drill_pth, self.drill_npth, self.drill_unknown]
|
||||
return []
|
||||
|
||||
@drill_layers.setter
|
||||
def drill_layers(self, value):
|
||||
self._drill_layers = value
|
||||
self.drill_pth = self.drill_npth = self.drill_unknown = None
|
||||
|
||||
def __len__(self):
|
||||
return len(self.layers)
|
||||
|
||||
def get(self, index, default=None):
|
||||
if self.contains(key):
|
||||
return self[key]
|
||||
else:
|
||||
return default
|
||||
|
||||
def __contains__(self, index):
|
||||
if isinstance(index, str):
|
||||
side, _, use = index.partition(' ')
|
||||
return (side, use) in self.layers
|
||||
|
||||
elif isinstance(index, tuple):
|
||||
return index in self.graphic_layers
|
||||
|
||||
return index < len(self.copper_layers)
|
||||
|
||||
def __getitem__(self, index):
|
||||
if isinstance(index, str):
|
||||
side, _, use = index.partition(' ')
|
||||
return self.graphic_layers[(side, use)]
|
||||
|
||||
elif isinstance(index, tuple):
|
||||
return self.graphic_layers[index]
|
||||
|
||||
return self.copper_layers[index]
|
||||
|
||||
@property
|
||||
def copper_layers(self):
|
||||
copper_layers = [ (key, layer) for key, layer in self.layers.items() if key.endswith('copper') ]
|
||||
|
||||
def sort_layername(val):
|
||||
key, _layer = val
|
||||
if key.startswith('top'):
|
||||
return -1
|
||||
if key.startswith('bottom'):
|
||||
return 1e99
|
||||
assert key.startswith('inner_')
|
||||
return int(key[len('inner_'):])
|
||||
|
||||
return [ layer for _key, layer in sorted(copper_layers, key=sort_layername) ]
|
||||
|
||||
@property
|
||||
def top_side(self):
|
||||
return { key: self[key] for key in ('top copper', 'top mask', 'top silk', 'top paste', 'mechanical outline') }
|
||||
|
||||
@property
|
||||
def bottom_side(self):
|
||||
return { key: self[key] for key in ('bottom copper', 'bottom mask', 'bottom silk', 'bottom paste', 'mechanical outline') }
|
||||
|
||||
@property
|
||||
def outline(self):
|
||||
return self['mechanical outline']
|
||||
|
||||
def _merge_layer(self, target, source):
|
||||
if source is None:
|
||||
return
|
||||
|
||||
if self[target] is None:
|
||||
self[target] = source
|
||||
|
||||
else:
|
||||
self[target].merge(source)
|
||||
|
||||
def merge(self, other):
|
||||
all_keys = set(self.layers.keys()) | set(other.layers.keys())
|
||||
exclude = { key.split() for key in STANDARD_LAYERS }
|
||||
all_keys = { key for key in all_keys if key not in exclude }
|
||||
if all_keys:
|
||||
warnings.warn('Cannot merge unknown layer types: {" ".join(all_keys)}')
|
||||
|
||||
for side in 'top', 'bottom':
|
||||
for use in 'copper', 'mask', 'silk', 'paste':
|
||||
self._merge_layer((side, use), other[side, use])
|
||||
|
||||
our_inner, their_inner = self.copper_layers[1:-1], other.copper_layers[1:-1]
|
||||
|
||||
if bool(our_inner) != bool(their_inner):
|
||||
warnings.warn('Merging board without inner layers into board with inner layers, inner layers will be empty on first board.')
|
||||
|
||||
elif our_inner and their_inner:
|
||||
warnings.warn('Merging boards with different inner layer counts. Will fill inner layers starting at core.')
|
||||
|
||||
diff = len(our_inner) - len(their_inner)
|
||||
their_inner = ([None] * max(0, diff//2)) + their_inner + ([None] * max(0, diff//2))
|
||||
our_inner = ([None] * max(0, -diff//2)) + their_inner + ([None] * max(0, -diff//2))
|
||||
|
||||
new_inner = []
|
||||
for ours, theirs in zip(our_inner, their_inner):
|
||||
if ours is None:
|
||||
new_inner.append(theirs)
|
||||
elif theirs is None:
|
||||
new_inner.append(ours)
|
||||
else:
|
||||
ours.merge(theirs)
|
||||
new_inner.append(ours)
|
||||
|
||||
for i, layer in enumerate(new_inner, start=1):
|
||||
self[f'inner_{i} copper'] = layer
|
||||
|
||||
self._merge_layer('mechanical outline', other['mechanical outline'])
|
||||
|
||||
self.normalize_drill_layers()
|
||||
other.normalize_drill_layers()
|
||||
|
||||
self.drill_pth.merge(other.drill_pth)
|
||||
self.drill_npth.merge(other.drill_npth)
|
||||
self.drill_unknown.merge(other.drill_unknown)
|
||||
self.netlist.merge(other.netlist)
|
||||
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from .image_support import ImageDifference
|
||||
|
||||
def pytest_assertrepr_compare(op, left, right):
|
||||
if isinstance(left, ImageDifference) or isinstance(right, ImageDifference):
|
||||
diff = left if isinstance(left, ImageDifference) else right
|
||||
return [
|
||||
f'Image difference assertion failed.',
|
||||
f' Calculated difference: {diff}',
|
||||
f' Histogram: {diff.histogram}', ]
|
||||
|
||||
# store report in node object so tmp_gbr can determine if the test failed.
|
||||
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||
def pytest_runtest_makereport(item, call):
|
||||
outcome = yield
|
||||
rep = outcome.get_result()
|
||||
setattr(item, f'rep_{rep.when}', rep)
|
||||
|
||||
fail_dir = Path('gerbonara_test_failures')
|
||||
def pytest_sessionstart(session):
|
||||
if not hasattr(session.config, 'workerinput'): # on worker
|
||||
return
|
||||
|
||||
# on coordinator
|
||||
for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')):
|
||||
f.unlink()
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2022 Jan Götte <code@jaseg.de>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Based on https://github.com/tracespace/tracespace
|
||||
#
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import textwrap
|
||||
import os
|
||||
from functools import total_ordering
|
||||
import shutil
|
||||
import bs4
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
cachedir = Path(__file__).parent / 'image_cache'
|
||||
cachedir.mkdir(exist_ok=True)
|
||||
|
||||
@total_ordering
|
||||
class ImageDifference:
|
||||
def __init__(self, value, histogram):
|
||||
self.value = value
|
||||
self.histogram = histogram
|
||||
|
||||
def __float__(self):
|
||||
return float(self.value)
|
||||
|
||||
def __eq__(self, other):
|
||||
return float(self) == float(other)
|
||||
|
||||
def __lt__(self, other):
|
||||
return float(self) < float(other)
|
||||
|
||||
def __str__(self):
|
||||
return str(float(self))
|
||||
|
||||
@total_ordering
|
||||
class Histogram:
|
||||
def __init__(self, value, size):
|
||||
self.value, self.size = value, size
|
||||
|
||||
def __eq__(self, other):
|
||||
other = np.array(other)
|
||||
other[other == None] = self.value[other == None]
|
||||
return (self.value == other).all()
|
||||
|
||||
def __lt__(self, other):
|
||||
other = np.array(other)
|
||||
other[other == None] = self.value[other == None]
|
||||
return (self.value <= other).all()
|
||||
|
||||
def __getitem__(self, index):
|
||||
return self.value[index]
|
||||
|
||||
def __str__(self):
|
||||
return f'{list(self.value)} size={self.size}'
|
||||
|
||||
|
||||
def run_cargo_cmd(cmd, args, **kwargs):
|
||||
if cmd.upper() in os.environ:
|
||||
return subprocess.run([os.environ[cmd.upper()], *args], **kwargs)
|
||||
|
||||
try:
|
||||
return subprocess.run([cmd, *args], **kwargs)
|
||||
|
||||
except FileNotFoundError:
|
||||
return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs)
|
||||
|
||||
def svg_to_png(in_svg, out_png, dpi=100, bg=None):
|
||||
params = f'{dpi}{bg}'.encode()
|
||||
digest = hashlib.blake2b(Path(in_svg).read_bytes() + params).hexdigest()
|
||||
cachefile = cachedir / f'{digest}.png'
|
||||
|
||||
if not cachefile.is_file():
|
||||
bg = 'black' if bg is None else bg
|
||||
run_cargo_cmd('resvg', ['--background', bg, '--dpi', str(dpi), in_svg, cachefile], check=True, stdout=subprocess.DEVNULL)
|
||||
|
||||
shutil.copy(cachefile, out_png)
|
||||
|
||||
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
|
||||
|
||||
def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6), fg='#ffffff', bg='#000000', override_unit_spec=None):
|
||||
params = f'{origin}{size}{fg}{bg}'.encode()
|
||||
digest = hashlib.blake2b(Path(in_gbr).read_bytes() + params).hexdigest()
|
||||
cachefile = cachedir / f'{digest}.svg'
|
||||
|
||||
if not cachefile.is_file():
|
||||
# NOTE: gerbv seems to always export 'clear' polarity apertures as white, irrespective of --foreground, --background
|
||||
# and project file color settings.
|
||||
# TODO: File issue upstream.
|
||||
with tempfile.NamedTemporaryFile('w') as f:
|
||||
if override_unit_spec:
|
||||
units, zeros, digits = override_unit_spec
|
||||
print(f'{Path(in_gbr).name}: overriding excellon unit spec to {units=} {zeros=} {digits=}')
|
||||
units = 0 if units == 'inch' else 1
|
||||
zeros = {None: 0, 'leading': 1, 'trailing': 2}[zeros]
|
||||
unit_spec = textwrap.dedent(f'''(cons 'attribs (list
|
||||
(list 'autodetect 'Boolean 0)
|
||||
(list 'zero_suppression 'Enum {zeros})
|
||||
(list 'units 'Enum {units})
|
||||
(list 'digits 'Integer {digits})
|
||||
))''')
|
||||
else:
|
||||
unit_spec = ''
|
||||
|
||||
r, g, b = int(fg[1:3], 16), int(fg[3:5], 16), int(fg[5:], 16)
|
||||
color = f"(cons 'color #({r*257} {g*257} {b*257}))"
|
||||
f.write(f'''(gerbv-file-version! "2.0A")(define-layer! 0 (cons 'filename "{in_gbr}"){unit_spec}{color})''')
|
||||
f.flush()
|
||||
if override_unit_spec:
|
||||
shutil.copy(f.name, '/tmp/foo.gbv')
|
||||
|
||||
x, y = origin
|
||||
w, h = size
|
||||
cmd = ['gerbv', '-x', export_format,
|
||||
'--border=0',
|
||||
f'--origin={x:.6f}x{y:.6f}', f'--window_inch={w:.6f}x{h:.6f}',
|
||||
f'--background={bg}',
|
||||
f'--foreground={fg}',
|
||||
'-o', str(cachefile), '-p', f.name]
|
||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
shutil.copy(cachefile, out_svg)
|
||||
|
||||
@contextmanager
|
||||
def svg_soup(filename):
|
||||
with open(filename, 'r') as f:
|
||||
soup = bs4.BeautifulSoup(f.read(), 'xml')
|
||||
|
||||
yield soup
|
||||
|
||||
with open(filename, 'w') as f:
|
||||
f.write(str(soup))
|
||||
|
||||
def cleanup_gerbv_svg(soup):
|
||||
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:
|
||||
soup.find('g', attrs={'id': 'surface1'})['transform'] = svg_transform
|
||||
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')
|
||||
|
||||
with svg_soup(ref1_svg.name) as soup1:
|
||||
if svg_transform1 is not None:
|
||||
soup1.find('g', attrs={'id': 'surface1'})['transform'] = svg_transform1
|
||||
cleanup_gerbv_svg(soup1)
|
||||
|
||||
with svg_soup(ref2_svg.name) as soup2:
|
||||
if svg_transform2 is not None:
|
||||
soup2.find('g', attrs={'id': 'surface1'})['transform'] = svg_transform2
|
||||
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))
|
||||
|
||||
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
%FSLAX46Y46*%
|
||||
%MOMM*%
|
||||
%ADD10C,0.150000*%
|
||||
%ADD11C,0.100000*%
|
||||
%ADD12C,0.600000*%
|
||||
%ADD13C,0.120000*%
|
||||
|
||||
%LPD*%
|
||||
G54D10*X168523809Y-90902380D02*X168523809Y-89902380D01*X168285714Y-89902380D01*X168142857Y-89950000D01*X168047619Y-90045238D01*X168000000Y-90140476D01*X167952380Y-90330952D01*X167952380Y-90473809D01*X168000000Y-90664285D01*X168047619Y-90759523D01*X168142857Y-90854761D01*X168285714Y-90902380D01*X168523809Y-90902380D01*X167571428Y-90616666D02*X167095238Y-90616666D01*X167666666Y-90902380D02*X167333333Y-89902380D01*X167000000Y-90902380D01*X166809523Y-89902380D02*X166238095Y-89902380D01*X166523809Y-90902380D02*X166523809Y-89902380D01*X165904761Y-90378571D02*X165571428Y-90378571D01*X165428571Y-90902380D02*X165904761Y-90902380D01*X165904761Y-89902380D01*X165428571Y-89902380D01*X168509523Y-78304761D02*X168366666Y-78352380D01*X168128571Y-78352380D01*X168033333Y-78304761D01*X167985714Y-78257142D01*X167938095Y-78161904D01*X167938095Y-78066666D01*X167985714Y-77971428D01*X168033333Y-77923809D01*X168128571Y-77876190D01*X168319047Y-77828571D01*X168414285Y-77780952D01*X168461904Y-77733333D01*X168509523Y-77638095D01*X168509523Y-77542857D01*X168461904Y-77447619D01*X168414285Y-77400000D01*X168319047Y-77352380D01*X168080952Y-77352380D01*X167938095Y-77400000D01*X167509523Y-78352380D02*X167509523Y-77352380D01*X166938095Y-78352380D01*X166938095Y-77352380D01*G54D11*G36*X168500000Y-89450000D02*G01X128500000Y-89450000D01*X128500000Y-78950000D01*X168500000Y-78950000D01*X168500000Y-89450000D01*G37*X168500000Y-89450000D02*X128500000Y-89450000D01*X128500000Y-78950000D01*X168500000Y-78950000D01*X168500000Y-89450000D01*G54D12*X131250000Y-58357142D02*X130678571Y-58357142D01*X130392857Y-58500000D01*X130107142Y-58785714D01*X129964285Y-59357142D01*X129964285Y-60357142D01*X130107142Y-60928571D01*X130392857Y-61214285D01*X130678571Y-61357142D01*X131250000Y-61357142D01*X131535714Y-61214285D01*X131821428Y-60928571D01*X131964285Y-60357142D01*X131964285Y-59357142D01*X131821428Y-58785714D01*X131535714Y-58500000D01*X131250000Y-58357142D01*X128678571Y-58357142D02*X128678571Y-60785714D01*X128535714Y-61071428D01*X128392857Y-61214285D01*X128107142Y-61357142D01*X127535714Y-61357142D01*X127250000Y-61214285D01*X127107142Y-61071428D01*X126964285Y-60785714D01*X126964285Y-58357142D01*X125964285Y-58357142D02*X124250000Y-58357142D01*X125107142Y-61357142D02*X125107142Y-58357142D01*X150071428Y-61357142D02*X150071428Y-58357142D01*X148642857Y-61357142D02*X148642857Y-58357142D01*X146928571Y-61357142D01*X146928571Y-58357142D01*G54D13*X117000000Y-76450000D02*X117000000Y-77150000D01*X118200000Y-77150000D02*X118200000Y-76450000D01*G54D10*X120242857Y-77157142D02*X120290476Y-77204761D01*X120433333Y-77252380D01*X120528571Y-77252380D01*X120671428Y-77204761D01*X120766666Y-77109523D01*X120814285Y-77014285D01*X120861904Y-76823809D01*X120861904Y-76680952D01*X120814285Y-76490476D01*X120766666Y-76395238D01*X120671428Y-76300000D01*X120528571Y-76252380D01*X120433333Y-76252380D01*X120290476Y-76300000D01*X120242857Y-76347619D01*X119290476Y-77252380D02*X119861904Y-77252380D01*X119576190Y-77252380D02*X119576190Y-76252380D01*X119671428Y-76395238D01*X119766666Y-76490476D01*X119861904Y-76538095D01*X118338095Y-77252380D02*X118909523Y-77252380D01*X118623809Y-77252380D02*X118623809Y-76252380D01*X118719047Y-76395238D01*X118814285Y-76490476D01*X118909523Y-76538095D01*X0Y0D02*M00*
|
||||
10
podman/arch-testenv
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
|
||||
FROM docker.io/archlinux:latest
|
||||
MAINTAINER gerbolyze@jaseg.de
|
||||
RUN pacman --noconfirm -Syu
|
||||
RUN pacman --noconfirm -Sy git python python-pip base-devel python-numpy python-slugify python-lxml python-click python-pillow librsvg python-scipy python-sphinx python-pytest twine python-beautifulsoup4 gerbv rustup cargo rsync
|
||||
RUN python3 -m pip install pytest-parallel
|
||||
RUN rustup install stable
|
||||
RUN rustup default stable
|
||||
RUN cargo install usvg resvg
|
||||
|
||||
11
podman/debian-testenv
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
|
||||
FROM docker.io/debian:latest
|
||||
MAINTAINER gerbolyze@jaseg.de
|
||||
RUN env DEBIAN_FRONTEND=noninteractive apt update -y
|
||||
RUN env DEBIAN_FRONTEND=noninteractive apt install -y libopencv-dev libpugixml-dev libpangocairo-1.0-0 libpango1.0-dev libcairo2-dev clang make python3 git python3-wheel curl python3-pip python3-venv
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
RUN sh -c '. ~/.cargo/env && rustup install stable'
|
||||
RUN sh -c '. ~/.cargo/env && rustup default stable'
|
||||
RUN sh -c '. ~/.cargo/env && cargo install usvg'
|
||||
|
||||
7
podman/fedora-testenv
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
|
||||
FROM docker.io/fedora:latest
|
||||
MAINTAINER gerbolyze@jaseg.de
|
||||
RUN dnf update --refresh -y
|
||||
RUN dnf install -y python3 make clang opencv-devel pugixml-devel pango-devel cairo-devel rust cargo
|
||||
RUN cargo install usvg
|
||||
|
||||
1
podman/testdata/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
git
|
||||
7
podman/testdata/testscript.sh
vendored
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
rsync -av /data/git git
|
||||
cd git
|
||||
|
||||
python3 -m pytest $@
|
||||
8
podman/ubuntu-testenv
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
FROM docker.io/ubuntu:latest
|
||||
MAINTAINER gerbolyze@jaseg.de
|
||||
RUN env DEBIAN_FRONTEND=noninteractive apt update -y
|
||||
RUN env DEBIAN_FRONTEND=noninteractive apt install -y python3 git python3-wheel curl python3-pip python3-venv cargo gerbv
|
||||
RUN cargo install usvg resvg
|
||||
RUN python3 -m pip install numpy slugify lxml click pillow scipy sphinx pytest beautifulsoup4 pytest-parallel
|
||||
RUN env DEBIAN_FRONTEND=noninteractive apt install -y rsync
|
||||
72
pyproject.toml
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
[project]
|
||||
name = "gerbonara"
|
||||
version = "1.6.2"
|
||||
description = "Tools to handle Gerber and Excellon files in Python"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["click", "rtree", "quart"]
|
||||
|
||||
authors = [
|
||||
{ name = "jaseg" },
|
||||
{ name = "XenGi" },
|
||||
]
|
||||
|
||||
maintainers = [
|
||||
{ name = "Gerbonara maintainers", email = "gerbonara@jaseg.de" },
|
||||
]
|
||||
|
||||
keywords = ["gerber", "excellon", "pcb", "RS274x", "EDA"]
|
||||
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Information Technology",
|
||||
"Intended Audience :: Manufacturing",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Topic :: Artistic Software",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
"Topic :: Printing",
|
||||
"Topic :: Scientific/Engineering",
|
||||
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
|
||||
"Topic :: Scientific/Engineering :: Image Processing",
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://jaseg.de/projects/gerbonara/"
|
||||
Documentation = "https://gerbolyze.gitlab.io/gerbonara/"
|
||||
Source = "https://git.jaseg.de/gerbonara.git"
|
||||
Tracker = "https://gitlab.com/gerbolyze/gerbonara/issues"
|
||||
|
||||
[project.scripts]
|
||||
gerbonara = "gerbonara.cli:cli"
|
||||
protoserve = "gerbonara.cad.protoserve:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest",
|
||||
"pytest-xdist",
|
||||
"numpy",
|
||||
"scipy",
|
||||
"tqdm",
|
||||
"beautifulsoup4",
|
||||
"lxml",
|
||||
"pillow"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv-build"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.pytest]
|
||||
testpaths = ["tests"]
|
||||
norecursedirs = ["*"]
|
||||
kicad_symbols_tag = "9.0.6"
|
||||
kicad_footprints_tag = "9.0.6"
|
||||
kicad_source_tag = "9.0.6"
|
||||
# Tag to use for container for footprint svg export
|
||||
# For a list of available tags, see https://hub.docker.com/r/kicad/kicad/tags
|
||||
kicad_container_tag = "9.0.6-full"
|
||||
33
run-tests.sh
Executable file
|
|
@ -0,0 +1,33 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--parallel)
|
||||
CONTAINER_ARGS="--workers auto $CONTAINER_ARGS"
|
||||
shift;;
|
||||
-x)
|
||||
CONTAINER_ARGS="-x $CONTAINER_ARGS"
|
||||
shift;;
|
||||
--no-cache)
|
||||
NO_CACHE=--no-cache
|
||||
shift;;
|
||||
*)
|
||||
echo "Unknown argument \"$1\""
|
||||
exit 1
|
||||
shift;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p podman/testdata/git
|
||||
git ls-tree --full-tree -r HEAD --name-only | rsync -lptgoDv --delete . --files-from - podman/testdata/git/
|
||||
#git clone --depth 1 . podman/testdata/git
|
||||
|
||||
for distro in ubuntu-old ubuntu arch
|
||||
do
|
||||
podman build $NO_CACHE -t gerbonara-$distro-testenv -f podman/$distro-testenv
|
||||
mkdir -p /tmp/gerbonara-test-out
|
||||
podman run --mount type=bind,src=podman/testdata,dst=/data,ro --mount type=bind,src=/tmp/gerbonara-test-out,dst=/out gerbonara-$distro-testenv /data/testscript.sh $CONTAINER_ARGS
|
||||
done
|
||||
|
||||
62
setup.py
|
|
@ -1,62 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from pathlib import Path
|
||||
from setuptools import setup, find_packages
|
||||
import subprocess
|
||||
|
||||
def version():
|
||||
res = subprocess.run(['git', 'describe', '--tags', '--match', 'v*'], capture_output=True, check=True, text=True)
|
||||
version, _, _rest = res.stdout.strip()[1:].partition('-')
|
||||
return version
|
||||
|
||||
setup(
|
||||
name='gerbonara',
|
||||
version=version(),
|
||||
author='jaseg, XenGi',
|
||||
author_email='gerbonara@jaseg.de',
|
||||
description='Tools to handle Gerber and Excellon files in Python',
|
||||
long_description=Path('README.md').read_text(),
|
||||
long_description_content_type='text/markdown',
|
||||
url='https://gitlab.com/gerbolyze/gerbonara',
|
||||
project_urls={
|
||||
# 'Documentation': 'https://packaging.python.org/tutorials/distributing-packages/',
|
||||
# 'Funding': 'https://donate.pypi.org',
|
||||
# 'Say Thanks!': 'http://saythanks.io/to/example',
|
||||
'Source': 'https://gitlab.com/gerbonara/gerbonara',
|
||||
'Tracker': 'https://gitlab.com/gerbonara/gerbonara/issues',
|
||||
},
|
||||
packages=find_packages(exclude=['tests']),
|
||||
install_requires=['click'],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'gerbonara = gerbonara.cli:cli',
|
||||
],
|
||||
},
|
||||
classifiers=[
|
||||
'Development Status :: 4 - Beta',
|
||||
#'Development Status :: 5 - Production/Stable',
|
||||
'Environment :: Console',
|
||||
'Intended Audience :: Developers',
|
||||
'Intended Audience :: Information Technology',
|
||||
'Intended Audience :: Manufacturing',
|
||||
'Intended Audience :: Science/Research',
|
||||
'License :: OSI Approved :: Apache Software License',
|
||||
'Natural Language :: English',
|
||||
'Operating System :: POSIX :: Linux',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3 :: Only',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Topic :: Artistic Software',
|
||||
'Topic :: Multimedia :: Graphics',
|
||||
'Topic :: Printing',
|
||||
'Topic :: Scientific/Engineering',
|
||||
'Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)',
|
||||
'Topic :: Scientific/Engineering :: Image Processing',
|
||||
'Topic :: Utilities',
|
||||
'Typing :: Typed',
|
||||
],
|
||||
keywords='gerber excellon pcb',
|
||||
python_requires='>=3.8',
|
||||
)
|
||||
|
|
@ -13,6 +13,7 @@ To do
|
|||
[X] Handle upverter output correctly: Upverter puts drils in a file called "design_export.xln" that actually contains
|
||||
Gerber, not Excellon
|
||||
[X] Add standard comment/attribute support for Gerber and Excellon
|
||||
[ ] Add attribute support to gerber output
|
||||
[X] Add file/lineno info to all warnings and syntax errors
|
||||
[X] Make sure we handle arcs with co-inciding start/end points correctly (G74: no arc, G75: full circle)
|
||||
[ ] Add allegro drill test files with different zero suppression settings
|
||||
|
|
@ -40,3 +41,4 @@ To do
|
|||
[ ] Add "number of parameters" property to ApertureMacro
|
||||
[ ] Aperture macro outline: Warn if first and last point are not the same.
|
||||
[ ] Make sure incremental mode actually works for gerber import
|
||||
[ ] Add text rendering function
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2022 Jan Götte <code@jaseg.de>
|
||||
# Copyright 2022 Jan Sebastian Götte <code@jaseg.de>
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
|
|
@ -20,12 +20,16 @@
|
|||
Gerbonara
|
||||
=========
|
||||
|
||||
gerbonara provides utilities for working with Gerber (RS-274X) and Excellon files in python.
|
||||
gerbonara provides utilities for working with PCB artwork files in Gerber/RS274-X, XNC/Excellon and IPC-356 formats. It
|
||||
includes convenience functions to match file names to layer types that match the default settings of a number of common
|
||||
EDA tools.
|
||||
"""
|
||||
|
||||
from .rs274x import GerberFile
|
||||
from .excellon import ExcellonFile
|
||||
from .ipc356 import Netlist
|
||||
from .layers import LayerStack
|
||||
from .utils import MM, Inch
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = '0.9.0'
|
||||
__version__ = version('gerbonara')
|
||||
42
src/gerbonara/__main__.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import click
|
||||
from zipfile import is_zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from .layers import LayerStack
|
||||
from .rs274x import GerberFile
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
@cli.command(help='Render a folder or zip of Gerber and Excellon files to a pretty, semi-photorealistic SVG.')
|
||||
@click.option('-t' ,'--top', help='Render board top side.', is_flag=True)
|
||||
@click.option('-b' ,'--bottom', help='Render board bottom side.', is_flag=True)
|
||||
@click.argument('input_zip_or_dir', type=click.Path(exists=True, path_type=Path))
|
||||
@click.argument('output_svg', required=False, default='-', type=click.File('w'))
|
||||
def pretty(input_zip_or_dir, output_svg, top, bottom):
|
||||
if (bool(top) + bool(bottom)) != 1:
|
||||
raise click.UsageError('Excactly one of --top or --bottom must be given when rendering a dir or zip of gerbers.')
|
||||
|
||||
stack = LayerStack.open(input_zip_or_dir, lazy=True)
|
||||
print(f'Loaded {stack}')
|
||||
|
||||
svg = stack.to_pretty_svg(side=('top' if top else 'bottom'))
|
||||
|
||||
output_svg.write(str(svg))
|
||||
|
||||
@cli.command(help='Render an individual Gerber or Excellon file to SVG')
|
||||
@click.option('-f', '--foreground', default='black', help='Foreground color')
|
||||
@click.option('-b', '--background', default='white', help='Background color used for "clear" areas.')
|
||||
@click.argument('input_gerber', type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.argument('output_svg', required=False, default='-', type=click.File('w'))
|
||||
def render(input_gerber, output_svg, foreground, background):
|
||||
layer = GerberFile.open(input_gerber)
|
||||
output_svg.write(str(layer.to_svg(fg=foreground, bg=background)))
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
||||
|
||||
18
src/gerbonara/aperture_macros/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
|
||||
from .parse import ApertureMacro, GenericMacros
|
||||
from .expression import (Expression,
|
||||
UnitExpression,
|
||||
ConstantExpression,
|
||||
VariableExpression,
|
||||
ParameterExpression,
|
||||
NegatedExpression,
|
||||
OperatorExpression)
|
||||
from .primitive import (Comment,
|
||||
Circle,
|
||||
VectorLine,
|
||||
CenterLine,
|
||||
Outline,
|
||||
Polygon,
|
||||
Moire,
|
||||
Thermal)
|
||||
|
||||
379
src/gerbonara/aperture_macros/expression.py
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
|
||||
|
||||
from dataclasses import dataclass
|
||||
import operator
|
||||
import re
|
||||
import ast
|
||||
import math
|
||||
|
||||
from ..utils import LengthUnit, MM, Inch, MILLIMETERS_PER_INCH
|
||||
|
||||
|
||||
def expr(obj):
|
||||
return obj if isinstance(obj, Expression) else ConstantExpression(obj)
|
||||
_make_expr = expr
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
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 parameters: residual expression {expr} under parameters {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 NegatedExpression(self).optimized()
|
||||
|
||||
def __pos__(self):
|
||||
return self
|
||||
|
||||
def parameters(self):
|
||||
return tuple()
|
||||
|
||||
@property
|
||||
def _operator(self):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnitExpression(Expression):
|
||||
expr: Expression
|
||||
unit: LengthUnit
|
||||
|
||||
def __init__(self, expr, unit):
|
||||
expr = _make_expr(expr)
|
||||
if isinstance(expr, UnitExpression):
|
||||
expr = expr.converted(unit)
|
||||
object.__setattr__(self, 'expr', expr)
|
||||
object.__setattr__(self, 'unit', unit)
|
||||
|
||||
def to_gerber(self, register_variable=None, unit=None):
|
||||
return self.converted(unit).optimized().to_gerber(register_variable)
|
||||
|
||||
def __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)).optimized()
|
||||
|
||||
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
|
||||
|
||||
def parameters(self):
|
||||
return self.expr.parameters()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConstantExpression(Expression):
|
||||
value: float
|
||||
|
||||
def __float__(self):
|
||||
return float(self.value)
|
||||
|
||||
def __eq__(self, other):
|
||||
try:
|
||||
return math.isclose(self.value, float(other), abs_tol=1e-9)
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
def to_gerber(self, register_variable=None, unit=None):
|
||||
if self == 0: # Avoid producing "-0" for negative floating point zeros
|
||||
return '0'
|
||||
return f'{self.value:.6f}'.rstrip('0').rstrip('.')
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VariableExpression(Expression):
|
||||
''' An expression that encapsulates some other complex expression and will replace all occurences of it with a newly
|
||||
allocated variable at export time.
|
||||
'''
|
||||
expr: Expression
|
||||
|
||||
def optimized(self, variable_binding={}):
|
||||
opt = self.expr.optimized(variable_binding)
|
||||
if isinstance(opt, OperatorExpression):
|
||||
return self
|
||||
else:
|
||||
return opt
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(self) == type(other) and self.expr == other.expr
|
||||
|
||||
def to_gerber(self, register_variable=None, unit=None):
|
||||
if register_variable is None:
|
||||
return self.expr.to_gerber(None, unit)
|
||||
else:
|
||||
num = register_variable(self.expr.converted(unit).optimized())
|
||||
return f'${num}'
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ParameterExpression(Expression):
|
||||
''' An expression that refers to a macro variable or parameter '''
|
||||
number: int
|
||||
|
||||
def optimized(self, variable_binding={}):
|
||||
if self.number in variable_binding:
|
||||
return expr(variable_binding[self.number]).optimized(variable_binding)
|
||||
return self
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(self) == type(other) and \
|
||||
self.number == other.number
|
||||
|
||||
def to_gerber(self, register_variable=None, unit=None):
|
||||
return f'${self.number}'
|
||||
|
||||
def parameters(self):
|
||||
yield self
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NegatedExpression(Expression):
|
||||
value: Expression
|
||||
|
||||
def optimized(self, variable_binding={}):
|
||||
match self.value.optimized(variable_binding):
|
||||
# -(-x) == x
|
||||
case NegatedExpression(inner_value):
|
||||
return inner_value
|
||||
# -(x) == -x
|
||||
case ConstantExpression(inner_value):
|
||||
return ConstantExpression(-inner_value)
|
||||
# -(x-y) == y-x
|
||||
case OperatorExpression(operator.sub, l, r):
|
||||
return OperatorExpression(operator.sub, r, l)
|
||||
# Round very small values and negative floating point zeros to a (positive) zero
|
||||
case 0:
|
||||
return expr(0)
|
||||
# Default case
|
||||
case x:
|
||||
return NegatedExpression(x)
|
||||
|
||||
@property
|
||||
def _operator(self):
|
||||
return self.value._operator
|
||||
|
||||
def __eq__(self, other):
|
||||
return type(self) == type(other) and \
|
||||
self.value == other.value
|
||||
|
||||
def to_gerber(self, register_variable=None, unit=None):
|
||||
val_str = self.value.to_gerber(register_variable, unit)
|
||||
if isinstance(self.value, (VariableExpression, ParameterExpression)):
|
||||
return f'-{val_str}'
|
||||
else:
|
||||
return f'-({val_str})'
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperatorExpression(Expression):
|
||||
op: str
|
||||
l: Expression
|
||||
r: Expression
|
||||
|
||||
def __init__(self, op, l, r):
|
||||
object.__setattr__(self, 'op', op)
|
||||
object.__setattr__(self, 'l', expr(l))
|
||||
object.__setattr__(self, 'r', expr(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
|
||||
|
||||
@property
|
||||
def _operator(self):
|
||||
return self.op
|
||||
|
||||
def optimized(self, variable_binding={}):
|
||||
l = self.l.optimized(variable_binding)
|
||||
r = self.r.optimized(variable_binding)
|
||||
|
||||
match (l, self.op, r):
|
||||
case (ConstantExpression(), op, ConstantExpression()):
|
||||
return ConstantExpression(self.op(float(l), float(r)))
|
||||
|
||||
# Minimize operations with neutral elements and zeros
|
||||
# 0 + x == x
|
||||
case (0, operator.add, r):
|
||||
return r
|
||||
# x + 0 == x
|
||||
case (l, operator.add, 0):
|
||||
return l
|
||||
# 0 * x == 0
|
||||
case (0, operator.mul, r):
|
||||
return expr(0)
|
||||
# x * 0 == 0
|
||||
case (l, operator.mul, 0):
|
||||
return expr(0)
|
||||
# x * 1 == x
|
||||
case (l, operator.mul, 1):
|
||||
return l
|
||||
# 1 * x == x
|
||||
case (1, operator.mul, r):
|
||||
return r
|
||||
# x * -1 == -x
|
||||
case (l, operator.mul, -1):
|
||||
rv = -l
|
||||
# -1 * x == -x
|
||||
case (-1, operator.mul, r):
|
||||
rv = -r
|
||||
# x - 0 == x
|
||||
case (l, operator.sub, 0):
|
||||
return l
|
||||
# 0 - x == -x (unary minus)
|
||||
case (0, operator.sub, r):
|
||||
rv = -r
|
||||
# x - x == 0
|
||||
case (l, operator.sub, r) if l == r:
|
||||
return expr(0)
|
||||
# x - -y == x + y
|
||||
case (l, operator.sub, NegatedExpression(r)):
|
||||
rv = (l + r)
|
||||
# x / 1 == x
|
||||
case (l, operator.truediv, 1):
|
||||
return l
|
||||
# x / -1 == -x
|
||||
case (l, operator.truediv, -1):
|
||||
rv = -l
|
||||
# x / x == 1
|
||||
case (l, operator.truediv, r) if l == r:
|
||||
return expr(1)
|
||||
# -x [*/] -y == x [*/] y
|
||||
case (NegatedExpression(l), (operator.truediv | operator.mul) as op, NegatedExpression(r)):
|
||||
rv = op(l, r)
|
||||
# -x [*/] y == -(x [*/] y)
|
||||
case (NegatedExpression(l), (operator.truediv | operator.mul) as op, r):
|
||||
rv = NegatedExpression(op(l, r))
|
||||
# x [*/] -y == -(x [*/] y)
|
||||
case (l, (operator.truediv | operator.mul) as op, NegatedExpression(r)):
|
||||
rv = NegatedExpression(op(l, r))
|
||||
# x + -y == x - y
|
||||
case (l, operator.add, NegatedExpression(r)):
|
||||
rv = l-r
|
||||
# -x + y == y - x
|
||||
case (NegatedExpression(l), operator.add, r):
|
||||
rv = r-l
|
||||
|
||||
case _: # default
|
||||
return OperatorExpression(self.op, l, r)
|
||||
|
||||
return expr(rv).optimized(variable_binding)
|
||||
|
||||
def to_gerber(self, register_variable=None, unit=None):
|
||||
lval = self.l.to_gerber(register_variable, unit)
|
||||
rval = self.r.to_gerber(register_variable, unit)
|
||||
|
||||
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}'
|
||||
|
||||
def parameters(self):
|
||||
yield from self.l.parameters()
|
||||
yield from self.r.parameters()
|
||||
|
||||
463
src/gerbonara/aperture_macros/parse.py
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2021 Jan Sebastian Götte <gerbonara@jaseg.de>
|
||||
|
||||
from dataclasses import dataclass, field, replace, fields
|
||||
import operator
|
||||
import re
|
||||
import ast
|
||||
import copy
|
||||
import warnings
|
||||
import math
|
||||
|
||||
from . import primitive as ap
|
||||
from .expression import *
|
||||
from ..apertures import ApertureMacroInstance
|
||||
from ..utils import MM
|
||||
|
||||
# we make our own here instead of using math.degrees to make sure this works with expressions, too.
|
||||
def rad_to_deg(x):
|
||||
return (x / math.pi) * 180
|
||||
|
||||
def _map_expression(node, variables={}, parameters=set()):
|
||||
if isinstance(node, ast.Constant):
|
||||
return ConstantExpression(node.value)
|
||||
|
||||
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, variables, parameters),
|
||||
_map_expression(node.right, variables, parameters))
|
||||
|
||||
elif isinstance(node, ast.UnaryOp):
|
||||
if type(node.op) == ast.UAdd:
|
||||
return _map_expression(node.operand, variables, parameters)
|
||||
else:
|
||||
return NegatedExpression(_map_expression(node.operand, variables, parameters))
|
||||
|
||||
elif isinstance(node, ast.Name):
|
||||
num = int(node.id[3:]) # node.id has format var[0-9]+
|
||||
if num in variables:
|
||||
return VariableExpression(variables[num])
|
||||
else:
|
||||
parameters.add(num)
|
||||
return ParameterExpression(num)
|
||||
|
||||
else:
|
||||
raise SyntaxError('Invalid aperture macro expression')
|
||||
|
||||
def _parse_expression(expr, variables, parameters):
|
||||
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, variables, parameters)
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApertureMacro:
|
||||
""" Definition of an aperture macro in a Gerber file.
|
||||
|
||||
An aperture macro is a collection of shape primitives that are flashed all at once. The properties of these
|
||||
primitives such as their relative position and size can be given explicitly, or can be given as a basic
|
||||
arithmetic expression (so +/-/*/:, no higher functions) based on parameters. After the macro is defined in the
|
||||
Gerber file, it is *bound* to a particular set of parameter values in an aperture definition. One macro can be
|
||||
used by zero, or by multiple aperture definitions. To flash a macro, you must first bind it in an aperture
|
||||
definition, which can then be flash'ed.
|
||||
|
||||
Gerbonara calls these apertures that bind a macro :py:class:`~..apertures.ApertureMacroInst`. You can bind a
|
||||
macro to a set of parameters by calling it:
|
||||
|
||||
.. code-block: python
|
||||
|
||||
# am is some instance of ApertureMacro
|
||||
aperture_def = am(1, 2, 3)
|
||||
gerber.objects.append(Flash(x=12, y=34, aperture=aperture_def))
|
||||
|
||||
Internally, the aperture macro API uses millimeters though most functions allow you to pass an unit parameter.
|
||||
|
||||
When you want to programmatically create aperture macros, we recommend using :py:meth:`~.ApertureMacro.map` on a
|
||||
dataclass-like class definition. Have a look at this code from :py:class:`~.GenericMacros`:
|
||||
|
||||
.. code-block: python
|
||||
|
||||
@ApertureMacro.map('GNR')
|
||||
class rect:
|
||||
w: float # width
|
||||
h: float # height
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
|
||||
# rect now is an instance of ApertureMacro
|
||||
|
||||
After this, you can bind this macro to an aperture by calling it. When you use this dataclass-like syntax,
|
||||
keyword arguments are supported, and default values work like with normal dataclasses:
|
||||
|
||||
.. code-block: python
|
||||
|
||||
# returns an instance of ApertureMacroInstance containing the given parameters
|
||||
my_rect = GenericMacros.rect(w=12, h=34)
|
||||
|
||||
gerber.objects.append(Flash(x=12, y=34, aperture=my_rect))
|
||||
|
||||
.. important::
|
||||
Use your own programmatically defined aperture macros sparingly. While support is getting better, many
|
||||
tools, including the expensive, commercial tools that PCB manufacturers use, still have bugs when handling
|
||||
aperture macros. When using advanced macros with many primitives or with complex, embedded arithmetic
|
||||
expressions, make sure to carefully check the manufacturing files provided by your PCB fab.
|
||||
|
||||
gerbonara currently handles embedded arithmetic expressions by *always* calculating them out since we have
|
||||
recently seen high-end commercial tooling failing at issues as basic as operator precedence. This increases
|
||||
file sizes very very slightly, but it makes sure that you get correct results.
|
||||
|
||||
This means that you can use gerbonara to calculate out aperture macros and hard-bake their values into the
|
||||
gerber source. This can be useful if you have a file that includes complex macros that some manufacturer's
|
||||
tooling can't handle on its own.
|
||||
"""
|
||||
|
||||
name: str = field(default=None, hash=False, compare=False)
|
||||
num_parameters: int = 0
|
||||
primitives: tuple = ()
|
||||
comments: tuple = field(default=(), hash=False, compare=False)
|
||||
_param_dataclass: object = field(default=None, hash=False, compare=False)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.name is None or re.match(r'GNX[0-9A-F]{16}', self.name):
|
||||
# We can't use field(default_factory=...) here because that factory doesn't get a reference to the instance.
|
||||
self._reset_name()
|
||||
|
||||
def _reset_name(self):
|
||||
object.__setattr__(self, 'name', f'GNX{hash(self)&0xffffffffffffffff:016X}')
|
||||
|
||||
@classmethod
|
||||
def map(our_kls, macro_name=None):
|
||||
def wrapper(kls):
|
||||
nonlocal our_kls, macro_name
|
||||
dc = dataclass(kls)
|
||||
|
||||
# Construct a mock instance of the dataclass with every field bound to its correpsonding ParameterExpression,
|
||||
# then draw() it to get a list of bound macro primitives.
|
||||
primitives = tuple(dc(*[ParameterExpression(i+1) for i in range(len(fields(dc)))]).draw())
|
||||
name = macro_name if macro_name else f'GNM{kls.__name__}'
|
||||
|
||||
# Python allows a lot more unicode in class names than the Gerber spec allows in aperture macro names
|
||||
if not re.fullmatch('[._$a-zA-Z][._$a-zA-Z0-9]{0,126}', name):
|
||||
raise ValueError(f'Name {name!r} is invalid as an aperture macro name')
|
||||
|
||||
return our_kls(
|
||||
name = name,
|
||||
num_parameters = len(fields(dc)),
|
||||
primitives = primitives,
|
||||
comments = [l.strip() for l in dc.__doc__.strip().splitlines()],
|
||||
_param_dataclass = dc)
|
||||
return wrapper
|
||||
|
||||
def __call__(self, *args, unit=MM, **kwargs):
|
||||
if self._param_dataclass:
|
||||
# Above, in map(), we construct the dataclass with the ParameterExpression(i) as params to draw the macro
|
||||
# primitives. Here, we construct it with the user's supplied concrete numeric parameters instead, and then
|
||||
# extract a list of these parameters. This should work great as long as the user doesn't get too fancy with
|
||||
# dataclass metaprogramming hackery.
|
||||
bound = self._param_dataclass(*args, **kwargs)
|
||||
return ApertureMacroInstance(macro=self, parameters=tuple(getattr(bound, f.name) or 0 for f in fields(bound)), unit=unit)
|
||||
|
||||
@classmethod
|
||||
def parse_macro(kls, macro_name, body, unit):
|
||||
comments = []
|
||||
variables = {}
|
||||
parameters = set()
|
||||
primitives = []
|
||||
|
||||
blocks = body.split('*')
|
||||
for block in blocks:
|
||||
if not (block := block.strip()): # empty block
|
||||
continue
|
||||
|
||||
if block.startswith('0 '): # comment
|
||||
comments.append(block[2:])
|
||||
continue
|
||||
|
||||
block = re.sub(r'\s', '', block)
|
||||
|
||||
if block[0] == '$': # variable definition
|
||||
try:
|
||||
name, _, expr = block.partition('=')
|
||||
number = int(name[1:])
|
||||
if number in variables:
|
||||
warnings.warn(f'Re-definition of aperture macro variable ${number} inside aperture macro "{macro_name}". Previous definition of ${number} was ${variables[number]}.')
|
||||
variables[number] = _parse_expression(expr, variables, parameters)
|
||||
except Exception as e:
|
||||
raise SyntaxError(f'Error parsing variable definition {block!r}') from e
|
||||
|
||||
else: # primitive
|
||||
primitive, *args = block.split(',')
|
||||
args = [ _parse_expression(arg, variables, parameters) for arg in args ]
|
||||
try:
|
||||
primitives.append(ap.PRIMITIVE_CLASSES[int(primitive)].from_arglist(unit, args))
|
||||
except KeyError as e:
|
||||
raise SyntaxError(f'Unknown aperture macro primitive code {int(primitive)}')
|
||||
|
||||
return kls(macro_name, max(parameters, default=0), tuple(primitives), tuple(comments))
|
||||
|
||||
def __str__(self):
|
||||
return f'<Aperture macro {self.name}, primitives {self.primitives}>'
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
def dilated(self, offset, unit=MM):
|
||||
new_primitives = []
|
||||
for primitive in self.primitives:
|
||||
try:
|
||||
if primitive.exposure.calculate():
|
||||
new_primitives += primitive.dilated(offset, unit)
|
||||
except IndexError:
|
||||
warnings.warn('Cannot dilate aperture macro primitive with exposure value computed from macro variable.')
|
||||
pass
|
||||
return replace(self, primitives=tuple(new_primitives))
|
||||
|
||||
def substitute_params(self, params, unit=None, macro_name=None):
|
||||
params = dict(enumerate(params, start=1))
|
||||
return replace(self,
|
||||
num_parameters=0,
|
||||
name=macro_name,
|
||||
primitives=tuple(p.substitute_params(params, unit) for p in self.primitives),
|
||||
comments=(f'Fully substituted instance of {self.name} macro',
|
||||
f'Original parameters: {"X".join(map(str, params.values())) if params else "none"}'))
|
||||
|
||||
def to_gerber(self, settings):
|
||||
""" Serialize this macro's content (without the name) into Gerber using the given file unit """
|
||||
comments = [ f'0 {c.replace("*", "_").replace("%", "_")}' for c in self.comments ]
|
||||
|
||||
subexpression_variables = {}
|
||||
def register_variable(expr):
|
||||
expr_str = expr.to_gerber(register_variable, settings.unit)
|
||||
if expr_str not in subexpression_variables:
|
||||
subexpression_variables[expr_str] = self.num_parameters + 1 + len(subexpression_variables)
|
||||
return subexpression_variables[expr_str]
|
||||
|
||||
primitive_defs = [prim.to_gerber(register_variable, settings) for prim in self.primitives]
|
||||
variable_defs = [f'${num}={expr_str}' for expr_str, num in subexpression_variables.items()]
|
||||
return '*\n'.join(comments + variable_defs + primitive_defs)
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None, polarity_dark=True):
|
||||
parameters = dict(enumerate(parameters, start=1))
|
||||
for primitive in self.primitives:
|
||||
yield from primitive.to_graphic_primitives(offset, rotation, parameters, unit, polarity_dark)
|
||||
|
||||
def rotated(self, angle):
|
||||
# aperture macro primitives use degree counter-clockwise, our API uses radians clockwise
|
||||
return replace(self, primitives=tuple(
|
||||
replace(primitive, rotation=primitive.rotation - rad_to_deg(angle)) for primitive in self.primitives))
|
||||
|
||||
def scaled(self, scale):
|
||||
return replace(self, primitives=tuple(
|
||||
primitive.scaled(scale) for primitive in self.primitives))
|
||||
|
||||
|
||||
var = ParameterExpression
|
||||
deg_per_rad = 180 / math.pi
|
||||
|
||||
class GenericMacros:
|
||||
"""NOTE:
|
||||
All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing API.
|
||||
"""
|
||||
|
||||
@ApertureMacro.map('GNC')
|
||||
class circle:
|
||||
""" Filled circle macro with an optional round hole
|
||||
|
||||
:param float diameter: Diameter of the circle
|
||||
:param hole_dia: Diameter of the hole (optional)
|
||||
"""
|
||||
diameter: float
|
||||
hole_dia: float = 0
|
||||
|
||||
def draw(self):
|
||||
yield ap.Circle('mm', 1, self.diameter, 0, 0)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
|
||||
@ApertureMacro.map('GNR')
|
||||
class rect:
|
||||
""" Axis-aligned rectangle with an optional round center hole.
|
||||
|
||||
:param float w: Width
|
||||
:param float h: Height
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float # width
|
||||
h: float # height
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
yield ap.CenterLine('mm', 1, self.w, self.h, 0, 0, self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
|
||||
@ApertureMacro.map('GRR')
|
||||
class rounded_rect:
|
||||
""" Rectangle with circular arc corners and an optional round center hole.
|
||||
|
||||
:param float w: Width
|
||||
:param float h: Height
|
||||
:param float r: Corner radius
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float # width
|
||||
h: float # height
|
||||
r: float # Corner radius
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
yield ap.CenterLine('mm', 1, self.w-2*self.r, self.h, 0, 0, self.rotation * -deg_per_rad)
|
||||
yield ap.CenterLine('mm', 1, self.w, self.h-2*self.r, 0, 0, self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 1, self.r*2, +(self.w/2-self.r), +(self.h/2-self.r), self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 1, self.r*2, +(self.w/2-self.r), -(self.h/2-self.r), self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 1, self.r*2, -(self.w/2-self.r), +(self.h/2-self.r), self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 1, self.r*2, -(self.w/2-self.r), -(self.h/2-self.r), self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
|
||||
@ApertureMacro.map('GTR')
|
||||
class isosceles_trapezoid:
|
||||
""" Isosceles trapezoid with a wider bottom edge and narrower top edge, with an optional round center hole.
|
||||
|
||||
:param float w: Width of the bottom (wider) edge
|
||||
:param float h: Height
|
||||
:param float d: Length difference between bottom and top edges; top width = w - d
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float # width
|
||||
h: float # height
|
||||
d: float # length difference between narrow side (top) and wide side (bottom)
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
yield ap.Outline('mm', 1, 4,
|
||||
(self.w/-2, self.h/-2,
|
||||
self.w/-2+self.d/2, self.h/2,
|
||||
self.w/2-self.d/2, self.h/2,
|
||||
self.w/2, self.h/-2,
|
||||
self.w/-2, self.h/-2,),
|
||||
self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
|
||||
@ApertureMacro.map('GRTR')
|
||||
class rounded_isosceles_trapezoid:
|
||||
""" Isosceles trapezoid with rounded corners and an optional round center hole. Unlike the rounded rectangle, the shape is defined by first defining a non-rounded trapezoid, which is then offet to the outside by the given margin.
|
||||
|
||||
:param float w: Width of the bottom (wider) edge
|
||||
:param float h: Height
|
||||
:param float d: Length difference between bottom and top edges; top width = w - d
|
||||
:param float margin: Corner rounding radius
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float
|
||||
h: float
|
||||
d: float # length difference between narrow side (top) and wide side (bottom)
|
||||
margin: float
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
rot = self.rotation * -deg_per_rad
|
||||
yield ap.Outline('mm', 1, 4,
|
||||
(self.w/-2, self.h/-2,
|
||||
self.w/-2+self.d/2, self.h/2,
|
||||
self.w/2-self.d/2, self.h/2,
|
||||
self.w/2, self.h/-2,
|
||||
self.w/-2, self.h/-2,),
|
||||
rot)
|
||||
|
||||
yield ap.VectorLine('mm', 1, self.margin*2,
|
||||
self.w/-2, self.h/-2,
|
||||
self.w/-2+self.d/2, self.h/2,
|
||||
rot)
|
||||
yield ap.VectorLine('mm', 1, self.margin*2,
|
||||
self.w/-2+self.d/2, self.h/2,
|
||||
self.w/2-self.d/2, self.h/2,
|
||||
rot)
|
||||
yield ap.VectorLine('mm', 1, self.margin*2,
|
||||
self.w/2-self.d/2, self.h/2,
|
||||
self.w/2, self.h/-2,
|
||||
rot)
|
||||
yield ap.VectorLine('mm', 1, self.margin*2,
|
||||
self.w/2, self.h/-2,
|
||||
self.w/-2, self.h/-2,
|
||||
rot)
|
||||
|
||||
yield ap.Circle('mm', 1, self.margin*2,
|
||||
self.w/-2, self.h/-2,
|
||||
rot)
|
||||
yield ap.Circle('mm', 1, self.margin*2,
|
||||
self.w/-2+self.d/2, self.h/2,
|
||||
rot)
|
||||
yield ap.Circle('mm', 1, self.margin*2,
|
||||
self.w/2-self.d/2, self.h/2,
|
||||
rot)
|
||||
yield ap.Circle('mm', 1, self.margin*2,
|
||||
self.w/2, self.h/-2,
|
||||
rot)
|
||||
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
|
||||
@ApertureMacro.map('GNO')
|
||||
class obround:
|
||||
""" Rectangle with semicircular end caps (stadium shape), with an optional round center hole. The long axis is along the X axis when rotation is zero.
|
||||
|
||||
:param float w: Total width including end caps; must satisfy w >= h
|
||||
:param float h: Height, equal to the end cap diameter
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
w: float
|
||||
h: float
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
rot = self.rotation * -deg_per_rad
|
||||
yield ap.CenterLine('mm', 1, self.w - self.h, self.h, 0, 0, rot)
|
||||
yield ap.Circle('mm', 1, self.h, +(self.w-self.h)/2, 0, rot)
|
||||
yield ap.Circle('mm', 1, self.h, -(self.w-self.h)/2, 0, rot)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 0, 0)
|
||||
|
||||
@ApertureMacro.map('GNP')
|
||||
class polygon:
|
||||
""" Regular n-sided polygon with an optional round center hole.
|
||||
|
||||
:param int n: Number of sides
|
||||
:param float diameter: Diameter of the circumscribed circle
|
||||
:param float hole_dia: Diameter of the round hole (optional)
|
||||
:param float rotation: Rotation in clockwise radians (optional)
|
||||
"""
|
||||
n: int
|
||||
diameter: float
|
||||
hole_dia: float = 0
|
||||
rotation: float = 0
|
||||
|
||||
def draw(self):
|
||||
yield ap.Polygon('mm', 1, self.diameter, 0, 0, self.n, self.rotation * -deg_per_rad)
|
||||
yield ap.Circle('mm', 0, self.hole_dia, 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)
|
||||
|
||||
446
src/gerbonara/aperture_macros/primitive.py
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
#!/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 dataclasses import dataclass, fields, replace
|
||||
|
||||
from .expression import Expression, UnitExpression, ConstantExpression, expr
|
||||
|
||||
from .. import graphic_primitives as gp
|
||||
from .. import graphic_objects as go
|
||||
from ..utils import rotate_point, LengthUnit, MM
|
||||
|
||||
|
||||
def point_distance(a, b):
|
||||
x1, y1 = a
|
||||
x2, y2 = b
|
||||
return math.sqrt((x2 - x1)**2 + (y2 - y1)**2)
|
||||
|
||||
|
||||
# we make our own here instead of using math.degrees to make sure this works with expressions, too.
|
||||
def deg_to_rad(a):
|
||||
return a * (math.pi / 180)
|
||||
|
||||
|
||||
def rad_to_deg(a):
|
||||
return a * (180 / math.pi)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Primitive:
|
||||
unit: LengthUnit
|
||||
|
||||
def __post_init__(self):
|
||||
for field in fields(self):
|
||||
if field.type == UnitExpression:
|
||||
value = getattr(self, field.name)
|
||||
if not isinstance(value, UnitExpression):
|
||||
value = UnitExpression(expr(value), self.unit)
|
||||
object.__setattr__(self, field.name, value)
|
||||
elif field.type == Expression:
|
||||
object.__setattr__(self, field.name, expr(getattr(self, field.name)))
|
||||
|
||||
def to_gerber(self, register_variable=None, settings=None):
|
||||
return f'{self.code},' + ','.join(
|
||||
getattr(self, field.name).optimized().to_gerber(register_variable, settings.unit)
|
||||
for field in fields(self) if issubclass(field.type, Expression))
|
||||
|
||||
def substitute_params(self, binding, unit):
|
||||
out = replace(self, unit=unit, **{
|
||||
field.name: getattr(self, field.name).calculate(binding, unit)
|
||||
for field in fields(self) if issubclass(field.type, Expression)})
|
||||
return out
|
||||
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
def from_arglist(kls, unit, arglist):
|
||||
return kls(unit, *arglist)
|
||||
|
||||
def parameters(self):
|
||||
for field in fields(self):
|
||||
if issubclass(field.type, Expression):
|
||||
yield from getattr(self, field.name).parameters()
|
||||
|
||||
class Calculator:
|
||||
def __init__(self, instance, variable_binding={}, unit=None):
|
||||
self.instance = instance
|
||||
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)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Circle(Primitive):
|
||||
code = 1
|
||||
exposure : Expression
|
||||
diameter : UnitExpression
|
||||
# center x/y
|
||||
x : UnitExpression = 0
|
||||
y : UnitExpression = 0
|
||||
rotation : Expression = 0
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
x, y = rotate_point(calc.x, calc.y, -(deg_to_rad(calc.rotation) + rotation), 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
|
||||
if math.isclose(calc.diameter, 0):
|
||||
return []
|
||||
|
||||
return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def substitute_params(self, binding, unit):
|
||||
with self.Calculator(self, binding, unit) as calc:
|
||||
x, y = rotate_point(calc.x, calc.y, -deg_to_rad(calc.rotation), 0, 0)
|
||||
new = Circle(unit, self.exposure, calc.diameter, x, y)
|
||||
return new
|
||||
|
||||
def dilated(self, offset, unit):
|
||||
return replace(self, diameter=self.diameter + UnitExpression(offset, unit))
|
||||
|
||||
def scaled(self, scale):
|
||||
return replace(self, x=self.x * UnitExpression(scale), y=self.y * UnitExpression(scale),
|
||||
diameter=self.diameter * UnitExpression(scale))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VectorLine(Primitive):
|
||||
code = 20
|
||||
exposure : Expression
|
||||
width : UnitExpression
|
||||
start_x : UnitExpression
|
||||
start_y : UnitExpression
|
||||
end_x : UnitExpression
|
||||
end_y : UnitExpression
|
||||
rotation : Expression = 0
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
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 = rotate_point(center_x, center_y, -(deg_to_rad(calc.rotation) + rotation), 0, 0)
|
||||
center_x, center_y = center_x+offset[0], center_y+offset[1]
|
||||
rotation += deg_to_rad(calc.rotation) + math.atan2(delta_y, delta_x)
|
||||
|
||||
if math.isclose(calc.width, 0):
|
||||
return []
|
||||
|
||||
return [ gp.Rectangle(center_x, center_y, length, calc.width, rotation=rotation,
|
||||
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def substitute_params(self, binding, unit):
|
||||
with self.Calculator(self, binding, unit) as calc:
|
||||
x1, y1 = rotate_point(calc.start_x, calc.start_y, -deg_to_rad(calc.rotation), 0, 0)
|
||||
x2, y2 = rotate_point(calc.end_x, calc.end_y, -deg_to_rad(calc.rotation), 0, 0)
|
||||
return VectorLine(unit, calc.exposure, calc.width, x1, y1, x2, y2)
|
||||
|
||||
def dilated(self, offset, unit):
|
||||
return replace(self, width=self.width + UnitExpression(2*offset, unit))
|
||||
|
||||
def scaled(self, scale):
|
||||
return replace(self,
|
||||
start_x=self.start_x * UnitExpression(scale),
|
||||
start_y=self.start_y * UnitExpression(scale),
|
||||
end_x=self.end_x * UnitExpression(scale),
|
||||
end_y=self.end_y * UnitExpression(scale))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CenterLine(Primitive):
|
||||
code = 21
|
||||
exposure : Expression
|
||||
width : UnitExpression
|
||||
height : UnitExpression
|
||||
# center x/y
|
||||
x : UnitExpression = 0
|
||||
y : UnitExpression = 0
|
||||
rotation : Expression = 0
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = gp.rotate_point(calc.x, calc.y, -rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
w, h = calc.width, calc.height
|
||||
|
||||
if math.isclose(calc.width, 0) or math.isclose(calc.height, 0):
|
||||
return []
|
||||
|
||||
return [ gp.Rectangle(x, y, w, h, rotation, polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def substitute_params(self, binding, unit):
|
||||
with self.Calculator(self, binding, unit) as calc:
|
||||
x1, y1 = rotate_point(calc.x, calc.y-calc.height/2, -deg_to_rad(calc.rotation), 0, 0)
|
||||
x2, y2 = rotate_point(calc.x, calc.y+calc.height/2, -deg_to_rad(calc.rotation), 0, 0)
|
||||
return VectorLine(unit, calc.exposure, calc.width, x1, y1, x2, y2)
|
||||
|
||||
def dilated(self, offset, unit):
|
||||
return replace(self, width=self.width + UnitExpression(2*offset, unit))
|
||||
|
||||
def scaled(self, scale):
|
||||
return replace(self,
|
||||
width=self.width * UnitExpression(scale),
|
||||
height=self.height * UnitExpression(scale),
|
||||
x=self.x * UnitExpression(scale),
|
||||
y=self.y * UnitExpression(scale))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Polygon(Primitive):
|
||||
code = 5
|
||||
exposure : Expression
|
||||
n_vertices : Expression
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
diameter : UnitExpression
|
||||
rotation : Expression = 0
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
print('xy', calc.x, calc.y)
|
||||
return [ gp.ArcPoly.from_regular_polygon(x, y, calc.diameter/2, int(calc.n_vertices), rotation,
|
||||
polarity_dark=(bool(calc.exposure) == polarity_dark)) ]
|
||||
|
||||
def dilated(self, offset, unit):
|
||||
return replace(self, diameter=self.diameter + UnitExpression(2*offset, unit))
|
||||
|
||||
def scale(self, scale):
|
||||
return replace(self,
|
||||
diameter=self.diameter * UnitExpression(scale),
|
||||
x=self.x * UnitExpression(scale),
|
||||
y=self.y * UnitExpression(scale))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Moire(Primitive):
|
||||
""" Deprecated, but still found in some really old gerber files. """
|
||||
code = 6
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
d_outer : UnitExpression
|
||||
line_thickness : UnitExpression
|
||||
gap_w : UnitExpression
|
||||
num_circles : Expression
|
||||
crosshair_thickness : UnitExpression = 0
|
||||
crosshair_length : UnitExpression =0
|
||||
rotation : Expression = 0
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
|
||||
if math.isclose(calc.d_outer, 0):
|
||||
return []
|
||||
|
||||
pitch = calc.line_thickness + calc.gap_w
|
||||
for i in range(int(round(calc.num_circles))):
|
||||
yield gp.Circle(x, y, calc.d_outer/2 - i*pitch, polarity_dark=True)
|
||||
yield gp.Circle(x, y, calc.d_inner/2 - i*pitch - calc.line_thickness, polarity_dark=False)
|
||||
|
||||
if math.isclose(calc.crosshair_thickness, 0, abs_tol=1e-6) or\
|
||||
math.isclose(calc.crosshair_length, 0, abs_tol=1e-6):
|
||||
return
|
||||
|
||||
yield gp.Rectangle(x, y, crosshair_length, crosshair_thickness, rotation=rotation, polarity_dark=True)
|
||||
yield gp.Rectangle(x, y, crosshair_thickness, crosshair_length, rotation=rotation, polarity_dark=True)
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
# I'd rather print a warning and produce graphically slightly incorrect output in these few cases here than
|
||||
# producing macros that may evaluate to primitives with negative values.
|
||||
warnings.warn('Attempted dilation of macro aperture thermal primitive. This is not supported.')
|
||||
|
||||
def scale(self, scale):
|
||||
return replace(self,
|
||||
d_outer=self.d_outer * UnitExpression(scale),
|
||||
d_inner=self.d_inner * UnitExpression(scale),
|
||||
gap_w=self.gap_w * UnitExpression(scale),
|
||||
x=self.x * UnitExpression(scale),
|
||||
y=self.y * UnitExpression(scale))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Thermal(Primitive):
|
||||
code = 7
|
||||
# Note: Thermal primitives according to spec don't have an exposure variable
|
||||
# center x/y
|
||||
x : UnitExpression
|
||||
y : UnitExpression
|
||||
d_outer : UnitExpression
|
||||
d_inner : UnitExpression
|
||||
gap_w : UnitExpression
|
||||
rotation : Expression = 0
|
||||
|
||||
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True):
|
||||
with self.Calculator(self, variable_binding, unit) as calc:
|
||||
rotation += deg_to_rad(calc.rotation)
|
||||
x, y = rotate_point(calc.x, calc.y, -rotation, 0, 0)
|
||||
x, y = x+offset[0], y+offset[1]
|
||||
|
||||
dark = True
|
||||
|
||||
if math.isclose(calc.d_outer, 0):
|
||||
return []
|
||||
|
||||
return [
|
||||
gp.Circle(x, y, calc.d_outer/2, polarity_dark=dark),
|
||||
gp.Circle(x, y, calc.d_inner/2, polarity_dark=not dark),
|
||||
gp.Rectangle(x, y, calc.d_outer, calc.gap_w, rotation=rotation, polarity_dark=not dark),
|
||||
gp.Rectangle(x, y, calc.gap_w, calc.d_outer, rotation=rotation, polarity_dark=not dark),
|
||||
]
|
||||
|
||||
def dilate(self, offset, unit):
|
||||
# I'd rather print a warning and produce graphically slightly incorrect output in these few cases here than
|
||||
# producing macros that may evaluate to primitives with negative values.
|
||||
warnings.warn('Attempted dilation of macro aperture thermal primitive. This is not supported.')
|
||||
|
||||
def scale(self, scale):
|
||||
return replace(self,
|
||||
d_outer=self.d_outer * UnitExpression(scale),
|
||||
d_inner=self.d_inner * UnitExpression(scale),
|
||||
gap_w=self.gap_w * UnitExpression(scale),
|
||||
x=self.x * UnitExpression(scale),
|
||||
y=self.y * UnitExpression(scale))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Outline(Primitive):
|
||||
code = 4
|
||||
exposure : Expression
|
||||
length: Expression
|
||||
coords: tuple
|
||||
rotation: Expression = 0
|
||||
|
||||
def __post_init__(self):
|
||||
if self.length is None:
|
||||
object.__setattr__(self, 'length', expr(len(self.coords)//2-1))
|
||||
else:
|
||||
object.__setattr__(self, 'length', expr(self.length))
|
||||
object.__setattr__(self, 'rotation', expr(self.rotation))
|
||||
object.__setattr__(self, 'exposure', expr(self.exposure))
|
||||
|
||||
if self.length.calculate() != len(self.coords)//2-1:
|
||||
raise ValueError('length must exactly equal number of segments, which is the number of points minus one')
|
||||
|
||||
if self.coords[-2:] != self.coords[:2]:
|
||||
raise ValueError('Last point must equal first point')
|
||||
|
||||
object.__setattr__(self, 'coords', tuple(
|
||||
UnitExpression(coord, self.unit) for coord in self.coords))
|
||||
|
||||
@property
|
||||
def points(self):
|
||||
for x, y in zip(self.coords[0::2], self.coords[1::2]):
|
||||
yield x, y
|
||||
|
||||
@classmethod
|
||||
def from_arglist(kls, unit, arglist):
|
||||
if len(arglist[2:]) % 2 == 0:
|
||||
return kls(unit=unit, exposure=arglist[0], length=arglist[1], coords=arglist[2:], rotation=0)
|
||||
else:
|
||||
return kls(unit=unit, exposure=arglist[0], length=arglist[1], coords=arglist[2:-1], rotation=arglist[-1])
|
||||
|
||||
def __str__(self):
|
||||
return f'<Outline {len(self.coords)} points>'
|
||||
|
||||
def to_gerber(self, register_variable=None, settings=None):
|
||||
rotation = self.rotation.optimized()
|
||||
coords = ','.join(coord.optimized().to_gerber(register_variable, settings.unit) for coord in self.coords)
|
||||
return f'{self.code},{self.exposure.optimized().to_gerber(register_variable)},{len(self.coords)//2-1},{coords},{rotation.to_gerber(register_variable)}'
|
||||
|
||||
def substitute_params(self, binding, unit):
|
||||
with self.Calculator(self, binding, unit) as calc:
|
||||
rotation = calc.rotation
|
||||
coords = [ rotate_point(x.calculate(binding, unit), y.calculate(binding, unit), -deg_to_rad(rotation), 0, 0)
|
||||
for x, y in self.points ]
|
||||
coords = [ e for point in coords for e in point ]
|
||||
return Outline(unit, calc.exposure, calc.length, coords)
|
||||
|
||||
def parameters(self):
|
||||
yield from Primitive.parameters(self)
|
||||
|
||||
for expr in self.coords:
|
||||
yield from expr.parameters()
|
||||
|
||||
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 = [ rotate_point(calc(x), calc(y), -rotation, 0, 0) for x, y in self.points ]
|
||||
bound_coords = [ (x+offset[0], y+offset[1]) for x, y in bound_coords ]
|
||||
bound_radii = [None] * len(bound_coords)
|
||||
|
||||
if len(bound_coords) < 3:
|
||||
return []
|
||||
|
||||
return [gp.ArcPoly(bound_coords, bound_radii, polarity_dark=(bool(calc.exposure) == polarity_dark))]
|
||||
|
||||
def dilated(self, offset, unit):
|
||||
# we would need a whole polygon offset/clipping library here
|
||||
warnings.warn('Attempted dilation of macro aperture outline primitive. This is not supported.')
|
||||
|
||||
def scaled(self, scale):
|
||||
return replace(self, coords=tuple(x*scale for x in self.coords))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Comment:
|
||||
code = 0
|
||||
comment: str
|
||||
|
||||
def to_gerber(self, register_variable=None, settings=None):
|
||||
return f'0 {self.comment}'
|
||||
|
||||
def dilated(self, offset, unit):
|
||||
return self
|
||||
|
||||
def scaled(self, scale):
|
||||
return self
|
||||
|
||||
|
||||
PRIMITIVE_CLASSES = {
|
||||
**{cls.code: cls for cls in [
|
||||
Comment,
|
||||
Circle,
|
||||
VectorLine,
|
||||
CenterLine,
|
||||
Outline,
|
||||
Polygon,
|
||||
Moire,
|
||||
Thermal,
|
||||
]},
|
||||
# alternative codes
|
||||
2: VectorLine,
|
||||
}
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2022 Jan Götte <code@jaseg.de>
|
||||
# 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.
|
||||
|
|
@ -16,21 +16,18 @@
|
|||
# limitations under the License.
|
||||
#
|
||||
|
||||
import warnings
|
||||
import math
|
||||
from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY
|
||||
from functools import lru_cache
|
||||
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
from .utils import MM, Inch
|
||||
from .utils import LengthUnit, MM, Inch, sum_bounds
|
||||
|
||||
from . import graphic_primitives as gp
|
||||
|
||||
|
||||
def _flash_hole(self, x, y, unit=None, polarity_dark=True):
|
||||
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:
|
||||
if self.hole_dia is not None:
|
||||
return [*self._primitives(x, y, unit, polarity_dark),
|
||||
gp.Circle(x, y, self.unit.convert_to(unit, self.hole_dia/2), polarity_dark=(not polarity_dark))]
|
||||
else:
|
||||
|
|
@ -40,7 +37,7 @@ def _strip_right(*args):
|
|||
args = list(args)
|
||||
while args and args[-1] is None:
|
||||
args.pop()
|
||||
return args
|
||||
return tuple(args)
|
||||
|
||||
def _none_close(a, b):
|
||||
if a is None and b is None:
|
||||
|
|
@ -57,29 +54,14 @@ class Length:
|
|||
def __init__(self, obj_type):
|
||||
self.type = obj_type
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Aperture:
|
||||
""" Base class for all apertures. """
|
||||
_ : KW_ONLY
|
||||
#: :py:class:`gerbonara.utils.LengthUnit` used for all length fields of this aperture.
|
||||
unit : str = 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`.
|
||||
attrs : dict = 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.
|
||||
original_number : int = None
|
||||
|
||||
@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
|
||||
unit: LengthUnit = None
|
||||
attrs: tuple = None
|
||||
original_number: int = field(default=None, hash=False, compare=False)
|
||||
_bounding_box: tuple = field(default=None, hash=False, compare=False)
|
||||
|
||||
def _params(self, unit=None):
|
||||
out = []
|
||||
|
|
@ -108,6 +90,12 @@ class Aperture:
|
|||
"""
|
||||
return self._primitives(x, y, unit, polarity_dark)
|
||||
|
||||
def bounding_box(self, unit=None):
|
||||
if self._bounding_box is None:
|
||||
object.__setattr__(self, '_bounding_box',
|
||||
sum_bounds((prim.bounding_box() for prim in self.flash(0, 0, MM, True))))
|
||||
return MM.convert_bounds_to(unit, self._bounding_box)
|
||||
|
||||
def equivalent_width(self, unit=None):
|
||||
""" Get the width of a line interpolated using this aperture in the given :py:class:`~.LengthUnit`.
|
||||
|
||||
|
|
@ -120,16 +108,12 @@ class Aperture:
|
|||
|
||||
:rtype: str
|
||||
"""
|
||||
# Hack: The standard aperture shapes C, R, O do not have a rotation parameter. To make this API easier to use,
|
||||
# we emulate this parameter. Our circle, rectangle and oblong classes below have a rotation parameter. Only at
|
||||
# export time during to_gerber, this parameter is evaluated.
|
||||
unit = settings.unit if settings else None
|
||||
actual_inst = self._rotated()
|
||||
params = 'X'.join(f'{float(par):.4}' for par in actual_inst._params(unit) if par is not None)
|
||||
params = 'X'.join(f'{float(par):.4}' for par in self._params(unit) if par is not None)
|
||||
if params:
|
||||
return f'{actual_inst._gerber_shape_code},{params}'
|
||||
return f'{self._gerber_shape_code},{params}'
|
||||
else:
|
||||
return actual_inst._gerber_shape_code
|
||||
return self._gerber_shape_code
|
||||
|
||||
def to_macro(self):
|
||||
""" Convert this :py:class:`.Aperture` into an :py:class:`.ApertureMacro` inside an
|
||||
|
|
@ -137,24 +121,10 @@ class Aperture:
|
|||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
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)
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExcellonTool(Aperture):
|
||||
""" Special Aperture_ subclass for use in :py:class:`.ExcellonFile`. Similar to :py:class:`.CircleAperture`, but
|
||||
does not have :py:attr:`.CircleAperture.hole_dia` or :py:attr:`.CircleAperture.hole_rect_h`, and has the additional
|
||||
:py:attr:`plated` attribute.
|
||||
does not have :py:attr:`.CircleAperture.hole_dia`, and has the additional :py:attr:`plated` attribute.
|
||||
"""
|
||||
_gerber_shape_code = 'C'
|
||||
_human_readable_shape = 'drill'
|
||||
|
|
@ -170,18 +140,6 @@ class ExcellonTool(Aperture):
|
|||
def to_xnc(self, settings):
|
||||
return 'C' + settings.write_excellon_value(self.diameter, self.unit)
|
||||
|
||||
def __eq__(self, other):
|
||||
""" Compare two :py:class:`.ExcellonTool` instances. They are considered equal if their diameter and plating
|
||||
match.
|
||||
"""
|
||||
if not isinstance(other, ExcellonTool):
|
||||
return False
|
||||
|
||||
if not self.plated == other.plated:
|
||||
return False
|
||||
|
||||
return _none_close(self.diameter, self.unit(other.diameter, other.unit))
|
||||
|
||||
def __str__(self):
|
||||
plated = '' if self.plated is None else (' plated' if self.plated else ' non-plated')
|
||||
return f'<Excellon Tool d={self.diameter:.3f}{plated} [{self.unit}]>'
|
||||
|
|
@ -192,19 +150,23 @@ class ExcellonTool(Aperture):
|
|||
# Internal use, for layer dilation.
|
||||
def dilated(self, offset, unit=MM):
|
||||
offset = unit(offset, self.unit)
|
||||
if math.isclose(offset, 0, abs_tol=1e-6):
|
||||
return self
|
||||
return replace(self, diameter=self.diameter+2*offset)
|
||||
|
||||
def _rotated(self):
|
||||
@lru_cache()
|
||||
def rotated(self, angle=0):
|
||||
return self
|
||||
|
||||
def to_macro(self):
|
||||
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
|
||||
def to_macro(self, rotation=0):
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM), unit=MM)
|
||||
|
||||
def _params(self, unit=None):
|
||||
return [self.unit.convert_to(unit, self.diameter)]
|
||||
return (self.unit.convert_to(unit, self.diameter),)
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CircleAperture(Aperture):
|
||||
""" Besides flashing circles or rings, CircleApertures are used to set the width of a
|
||||
:py:class:`~.graphic_objects.Line` or :py:class:`~.graphic_objects.Arc`.
|
||||
|
|
@ -215,10 +177,6 @@ class CircleAperture(Aperture):
|
|||
diameter : Length(float)
|
||||
#: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole.
|
||||
hole_dia : Length(float) = None
|
||||
#: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole.
|
||||
hole_rect_h : Length(float) = None
|
||||
# float with radians. This is only used for rectangular holes (as circles are rotationally symmetric).
|
||||
rotation : float = 0
|
||||
|
||||
def _primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2), polarity_dark=polarity_dark) ]
|
||||
|
|
@ -233,26 +191,34 @@ class CircleAperture(Aperture):
|
|||
|
||||
def dilated(self, offset, unit=MM):
|
||||
offset = self.unit(offset, unit)
|
||||
return replace(self, diameter=self.diameter+2*offset, hole_dia=None, hole_rect_h=None)
|
||||
|
||||
def _rotated(self):
|
||||
if math.isclose(self.rotation % (2*math.pi), 0) or self.hole_rect_h is None:
|
||||
if math.isclose(offset, 0, abs_tol=1e-6):
|
||||
return self
|
||||
else:
|
||||
return self.to_macro(self.rotation)
|
||||
return replace(self, diameter=self.diameter+2*offset, hole_dia=None)
|
||||
|
||||
def to_macro(self):
|
||||
return ApertureMacroInstance(GenericMacros.circle, self._params(unit=MM))
|
||||
@lru_cache()
|
||||
def rotated(self, angle=0):
|
||||
return self
|
||||
|
||||
def scaled(self, scale):
|
||||
return replace(self,
|
||||
diameter=self.diameter*scale,
|
||||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||
|
||||
def to_macro(self, rotation=0):
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return GenericMacros.circle(MM(self.diameter, self.unit),
|
||||
MM(self.hole_dia, self.unit))
|
||||
|
||||
def _params(self, unit=None):
|
||||
return _strip_right(
|
||||
self.unit.convert_to(unit, self.diameter),
|
||||
self.unit.convert_to(unit, self.hole_dia),
|
||||
self.unit.convert_to(unit, self.hole_rect_h))
|
||||
self.unit.convert_to(unit, self.hole_dia))
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RectangleAperture(Aperture):
|
||||
""" Gerber rectangle aperture. Can only be used for flashes, since the line width of an interpolation of a rectangle
|
||||
aperture is not well-defined and there is no tool that implements it in a geometrically correct way. """
|
||||
_gerber_shape_code = 'R'
|
||||
_human_readable_shape = 'rect'
|
||||
#: float with the width of the rectangle in :py:attr:`unit` units.
|
||||
|
|
@ -261,14 +227,10 @@ class RectangleAperture(Aperture):
|
|||
h : Length(float)
|
||||
#: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole.
|
||||
hole_dia : Length(float) = None
|
||||
#: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole.
|
||||
hole_rect_h : Length(float) = None
|
||||
# Rotation in radians. This rotates both the aperture and the rectangular hole if it has one.
|
||||
rotation : float = 0 # radians
|
||||
|
||||
def _primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
return [ gp.Rectangle(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h),
|
||||
rotation=self.rotation, polarity_dark=polarity_dark) ]
|
||||
rotation=0, polarity_dark=polarity_dark) ]
|
||||
|
||||
def __str__(self):
|
||||
return f'<rect aperture {self.w:.3}x{self.h:.3} [{self.unit}]>'
|
||||
|
|
@ -280,33 +242,40 @@ class RectangleAperture(Aperture):
|
|||
|
||||
def dilated(self, offset, unit=MM):
|
||||
offset = self.unit(offset, unit)
|
||||
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None)
|
||||
|
||||
def _rotated(self):
|
||||
if math.isclose(self.rotation % math.pi, 0):
|
||||
if math.isclose(offset, 0, abs_tol=1e-6):
|
||||
return self
|
||||
elif math.isclose(self.rotation % math.pi, math.pi/2):
|
||||
return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0)
|
||||
else: # odd angle
|
||||
return self.to_macro()
|
||||
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None)
|
||||
|
||||
def to_macro(self):
|
||||
return ApertureMacroInstance(GenericMacros.rect,
|
||||
[MM(self.w, self.unit),
|
||||
MM(self.h, self.unit),
|
||||
MM(self.hole_dia, self.unit) or 0,
|
||||
MM(self.hole_rect_h, self.unit) or 0,
|
||||
self.rotation])
|
||||
@lru_cache()
|
||||
def rotated(self, angle=0):
|
||||
if math.isclose(angle % math.pi, 0, abs_tol=1e-6):
|
||||
return self
|
||||
elif math.isclose(angle % math.pi, math.pi/2, abs_tol=1e-6):
|
||||
return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
|
||||
else: # odd angle
|
||||
return self.to_macro(angle)
|
||||
|
||||
def scaled(self, scale):
|
||||
return replace(self,
|
||||
w=self.w*scale,
|
||||
h=self.h*scale,
|
||||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||
|
||||
def to_macro(self, rotation=0):
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return GenericMacros.rect(MM(self.w, self.unit),
|
||||
MM(self.h, self.unit),
|
||||
MM(self.hole_dia, self.unit),
|
||||
rotation)
|
||||
|
||||
def _params(self, unit=None):
|
||||
return _strip_right(
|
||||
self.unit.convert_to(unit, self.w),
|
||||
self.unit.convert_to(unit, self.h),
|
||||
self.unit.convert_to(unit, self.hole_dia),
|
||||
self.unit.convert_to(unit, self.hole_rect_h))
|
||||
self.unit.convert_to(unit, self.hole_dia))
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObroundAperture(Aperture):
|
||||
""" Aperture whose shape is the convex hull of two circles of equal radii.
|
||||
|
||||
|
|
@ -322,14 +291,10 @@ class ObroundAperture(Aperture):
|
|||
h : Length(float)
|
||||
#: float with the hole diameter of this aperture in :py:attr:`unit` units. ``0`` for no hole.
|
||||
hole_dia : Length(float) = None
|
||||
#: float or None. If not None, specifies a rectangular hole of size `hole_dia * hole_rect_h` instead of a round hole.
|
||||
hole_rect_h : Length(float) = None
|
||||
#: Rotation in radians. This rotates both the aperture and the rectangular hole if it has one.
|
||||
rotation : float = 0
|
||||
|
||||
def _primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
return [ gp.Line.from_obround(x, y, self.unit.convert_to(unit, self.w), self.unit.convert_to(unit, self.h),
|
||||
rotation=self.rotation, polarity_dark=polarity_dark) ]
|
||||
polarity_dark=polarity_dark) ]
|
||||
|
||||
def __str__(self):
|
||||
return f'<obround aperture {self.w:.3}x{self.h:.3} [{self.unit}]>'
|
||||
|
|
@ -338,35 +303,47 @@ class ObroundAperture(Aperture):
|
|||
|
||||
def dilated(self, offset, unit=MM):
|
||||
offset = self.unit(offset, unit)
|
||||
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None, hole_rect_h=None)
|
||||
|
||||
def _rotated(self):
|
||||
if math.isclose(self.rotation % math.pi, 0):
|
||||
if math.isclose(offset, 0, abs_tol=1e-6):
|
||||
return self
|
||||
elif math.isclose(self.rotation % math.pi, math.pi/2):
|
||||
return replace(self, w=self.h, h=self.w, **self._rotate_hole_90(), rotation=0)
|
||||
else:
|
||||
return self.to_macro()
|
||||
return replace(self, w=self.w+2*offset, h=self.h+2*offset, hole_dia=None)
|
||||
|
||||
def to_macro(self):
|
||||
@lru_cache()
|
||||
def rotated(self, angle=0):
|
||||
if math.isclose(angle % math.pi, 0, abs_tol=1e-6):
|
||||
return self
|
||||
elif math.isclose(angle % math.pi, math.pi/2, abs_tol=1e-6):
|
||||
return replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
|
||||
else:
|
||||
return self.to_macro(angle)
|
||||
|
||||
def scaled(self, scale):
|
||||
return replace(self,
|
||||
w=self.w*scale,
|
||||
h=self.h*scale,
|
||||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||
|
||||
def to_macro(self, rotation=0):
|
||||
# generic macro only supports w > h so flip x/y if h > w
|
||||
inst = self if self.w > self.h else replace(self, w=self.h, h=self.w, **_rotate_hole_90(self), rotation=self.rotation-90)
|
||||
return ApertureMacroInstance(GenericMacros.obround,
|
||||
[MM(inst.w, self.unit),
|
||||
MM(ints.h, self.unit),
|
||||
MM(inst.hole_dia, self.unit),
|
||||
MM(inst.hole_rect_h, self.unit),
|
||||
inst.rotation])
|
||||
if self.w > self.h:
|
||||
inst = self
|
||||
else:
|
||||
rotation -= -math.pi/2
|
||||
inst = replace(self, w=self.h, h=self.w, hole_dia=self.hole_dia)
|
||||
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return GenericMacros.obround(MM(inst.w, self.unit),
|
||||
MM(inst.h, self.unit),
|
||||
MM(inst.hole_dia, self.unit) or 0,
|
||||
rotation)
|
||||
|
||||
def _params(self, unit=None):
|
||||
return _strip_right(
|
||||
self.unit.convert_to(unit, self.w),
|
||||
self.unit.convert_to(unit, self.h),
|
||||
self.unit.convert_to(unit, self.hole_dia),
|
||||
self.unit.convert_to(unit, self.hole_rect_h))
|
||||
self.unit.convert_to(unit, self.hole_dia))
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolygonAperture(Aperture):
|
||||
""" Aperture whose shape is a regular n-sided polygon (e.g. pentagon, hexagon etc.). Note that this only supports
|
||||
round holes.
|
||||
|
|
@ -383,7 +360,7 @@ class PolygonAperture(Aperture):
|
|||
hole_dia : Length(float) = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.n_vertices = int(self.n_vertices)
|
||||
object.__setattr__(self, 'n_vertices', int(self.n_vertices))
|
||||
|
||||
def _primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
return [ gp.ArcPoly.from_regular_polygon(x, y, self.unit.convert_to(unit, self.diameter)/2, self.n_vertices,
|
||||
|
|
@ -394,26 +371,46 @@ class PolygonAperture(Aperture):
|
|||
|
||||
def dilated(self, offset, unit=MM):
|
||||
offset = self.unit(offset, unit)
|
||||
if math.isclose(offset, 0, abs_tol=1e-6):
|
||||
return self
|
||||
return replace(self, diameter=self.diameter+2*offset, hole_dia=None)
|
||||
|
||||
flash = _flash_hole
|
||||
|
||||
def _rotated(self):
|
||||
return self
|
||||
@lru_cache()
|
||||
def rotated(self, angle=0):
|
||||
if angle != 0:
|
||||
return replace(self, rotation=self.rotation + angle)
|
||||
else:
|
||||
return self
|
||||
|
||||
def scaled(self, scale):
|
||||
return replace(self,
|
||||
diameter=self.diameter*scale,
|
||||
hole_dia=None if self.hole_dia is None else self.hole_dia*scale)
|
||||
|
||||
def to_macro(self):
|
||||
return ApertureMacroInstance(GenericMacros.polygon, self._params(MM))
|
||||
from .aperture_macros.parse import GenericMacros
|
||||
return GenericMacros.polygon(self.n_vertices,
|
||||
MM(self.diameter, self.unit),
|
||||
MM(self.hole_dia, self.unit),
|
||||
self.rotation)
|
||||
|
||||
def _params(self, unit=None):
|
||||
rotation = self.rotation % (2*math.pi / self.n_vertices) if self.rotation is not None else None
|
||||
rotation = self.rotation % (2*math.pi / self.n_vertices)
|
||||
if math.isclose(rotation, 0, abs_tol=1e-6):
|
||||
rotation = None
|
||||
else:
|
||||
rotation = math.degrees(rotation)
|
||||
|
||||
if self.hole_dia is not None:
|
||||
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation, self.unit.convert_to(unit, self.hole_dia)
|
||||
elif rotation is not None and not math.isclose(rotation, 0):
|
||||
elif rotation is not None and not math.isclose(rotation, 0, abs_tol=1e-6):
|
||||
return self.unit.convert_to(unit, self.diameter), self.n_vertices, rotation
|
||||
else:
|
||||
return self.unit.convert_to(unit, self.diameter), self.n_vertices
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApertureMacroInstance(Aperture):
|
||||
""" One instance of an aperture macro. An aperture macro defined with an ``AM`` statement can be instantiated by
|
||||
multiple ``AD`` aperture definition statements using different parameters. An :py:class:`.ApertureMacroInstance` is
|
||||
|
|
@ -425,10 +422,7 @@ class ApertureMacroInstance(Aperture):
|
|||
macro : object
|
||||
#: The parameters to the :py:class:`.ApertureMacro`. All elements should be floats or ints. The first item in the
|
||||
#: list is parameter ``$1``, the second is ``$2`` etc.
|
||||
parameters : list
|
||||
#: Aperture rotation in radians. When saving, a copy of the :py:class:`.ApertureMacro` is re-written with this
|
||||
#: rotation.
|
||||
rotation : float = 0
|
||||
parameters : tuple = ()
|
||||
|
||||
@property
|
||||
def _gerber_shape_code(self):
|
||||
|
|
@ -436,30 +430,39 @@ class ApertureMacroInstance(Aperture):
|
|||
|
||||
def _primitives(self, x, y, unit=None, polarity_dark=True):
|
||||
out = list(self.macro.to_graphic_primitives(
|
||||
offset=(x, y), rotation=self.rotation,
|
||||
offset=(x, y), rotation=0,
|
||||
parameters=self.parameters, unit=unit, polarity_dark=polarity_dark))
|
||||
return out
|
||||
|
||||
def dilated(self, offset, unit=MM):
|
||||
if math.isclose(offset, 0, abs_tol=1e-6):
|
||||
return self
|
||||
return replace(self, macro=self.macro.dilated(offset, unit))
|
||||
|
||||
def _rotated(self):
|
||||
if math.isclose(self.rotation % (2*math.pi), 0):
|
||||
@lru_cache()
|
||||
def rotated(self, angle=0.0):
|
||||
if math.isclose(angle % (2*math.pi), 0, abs_tol=1e-6):
|
||||
return self
|
||||
else:
|
||||
return self.to_macro()
|
||||
return self.to_macro(angle)
|
||||
|
||||
def to_macro(self):
|
||||
return replace(self, macro=self.macro.rotated(self.rotation), rotation=0)
|
||||
def to_macro(self, rotation=0.0):
|
||||
return replace(self, macro=self.macro.rotated(rotation))
|
||||
|
||||
def __eq__(self, other):
|
||||
return hasattr(other, 'macro') and self.macro == other.macro and \
|
||||
hasattr(other, 'params') and self.params == other.params and \
|
||||
hasattr(other, 'rotation') and self.rotation == other.rotation
|
||||
def scaled(self, scale):
|
||||
return replace(self, macro=self.macro.scaled(scale))
|
||||
|
||||
def calculate_out(self, unit=None, macro_name=None):
|
||||
return replace(self,
|
||||
parameters=tuple(),
|
||||
macro=self.macro.substitute_params(self._params(unit), unit, macro_name))
|
||||
|
||||
def _params(self, unit=None):
|
||||
# We ignore "unit" here as we convert the actual macro, not this instantiation.
|
||||
# We do this because here we do not have information about which parameter has which physical units.
|
||||
return tuple(self.parameters)
|
||||
|
||||
parameters = self.parameters
|
||||
if len(parameters) > self.macro.num_parameters:
|
||||
warnings.warn(f'Aperture definition using macro {self.macro.name} has more parameters than the macro uses.')
|
||||
parameters = parameters[:self.macro.num_parameters]
|
||||
return tuple(parameters)
|
||||
|
||||
138
src/gerbonara/cad/breakout.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..utils import MM
|
||||
from .primitives import *
|
||||
|
||||
|
||||
@dataclass
|
||||
class PadRing(Positioned):
|
||||
w: int
|
||||
h: int
|
||||
pitch: float = 2.54
|
||||
clearance: float = 0.2
|
||||
rows: int = 2
|
||||
trace_width: float = 0.4
|
||||
drill_dia: float = 0.9
|
||||
stagger: bool = False
|
||||
|
||||
def ports(self):
|
||||
x, y, rotation = self.abs_pos
|
||||
|
||||
x += self.pitch/2
|
||||
y += self.pitch/2
|
||||
|
||||
x += self.pitch * self.rows
|
||||
y += self.pitch * self.rows
|
||||
|
||||
pad_dia = self.pitch - 2*self.clearance - self.trace_width
|
||||
offset = pad_dia/2 - self.trace_width/2
|
||||
|
||||
for i in range(1, self.w):
|
||||
yield (x+self.pitch/2 + i*self.pitch, y+offset)
|
||||
yield (x+self.pitch/2 + i*self.pitch, y+(self.h+1)*self.pitch-offset)
|
||||
|
||||
for i in range(0, self.w):
|
||||
yield (x + (i+1)*self.pitch, y+offset)
|
||||
yield (x + (i+1)*self.pitch, y+(self.h+1)*self.pitch-offset)
|
||||
|
||||
for i in range(1, self.h):
|
||||
yield (x+offset, y+self.pitch/2 + i*self.pitch)
|
||||
yield (x+(self.w+1)*self.pitch-offset, y+self.pitch/2 + i*self.pitch)
|
||||
|
||||
for i in range(0, self.h):
|
||||
yield (x+offset, y + (i+1)*self.pitch)
|
||||
yield (x+(self.w+1)*self.pitch-offset, y + (i+1)*self.pitch)
|
||||
|
||||
|
||||
def generate(self, bbox, border_text, unit=MM):
|
||||
x, y, rotation = self.abs_pos
|
||||
|
||||
x += self.pitch/2
|
||||
y += self.pitch/2
|
||||
|
||||
x += self.pitch * self.rows
|
||||
y += self.pitch * self.rows
|
||||
|
||||
pad_dia = self.pitch - 2*self.clearance - self.trace_width
|
||||
|
||||
for i in range(self.w + 2 + 2*(self.rows-1)):
|
||||
for j in range(self.rows):
|
||||
yield THTPad.circle(x + (i - (self.rows - 1))*self.pitch, y - j*self.pitch, self.drill_dia, pad_dia, paste=False)
|
||||
yield THTPad.circle(x + (i - (self.rows - 1))*self.pitch, y + (self.h + 1 + j)*self.pitch, self.drill_dia, pad_dia, paste=False)
|
||||
|
||||
if self.rows >= 2 and 1 <= i < self.w:
|
||||
yield Trace(self.trace_width, start=(x+i*self.pitch, y-self.pitch), end=(x+(i + 0.5)*self.pitch, y+pad_dia/2 - self.trace_width/2))
|
||||
yield Trace(self.trace_width, start=(x+i*self.pitch, y+(self.h+2)*self.pitch), end=(x+(i + 0.5)*self.pitch, y+(self.h+1)*self.pitch -pad_dia/2 + self.trace_width/2), orientation=('cw',))
|
||||
|
||||
for i in range(1, self.h+1):
|
||||
for j in range(self.rows):
|
||||
yield THTPad.circle(x - j*self.pitch, y + i*self.pitch, self.drill_dia, pad_dia, paste=False)
|
||||
yield THTPad.circle(x + (self.w + 1 + j)*self.pitch, y + i*self.pitch, self.drill_dia, pad_dia, paste=False)
|
||||
|
||||
for i in range(1, self.h):
|
||||
yield (x+offset, y+self.pitch/2 + i*self.pitch)
|
||||
yield (x+(self.w+1)*self.pitch-offset, y+self.pitch/2 + i*self.pitch)
|
||||
|
||||
|
||||
def generate(self, bbox, border_text, unit=MM):
|
||||
x, y, rotation = self.abs_pos
|
||||
|
||||
x += self.pitch/2
|
||||
y += self.pitch/2
|
||||
|
||||
x += self.pitch * self.rows
|
||||
y += self.pitch * self.rows
|
||||
|
||||
pad_dia = self.pitch - 2*self.clearance - self.trace_width
|
||||
|
||||
for i in range(self.w + 2 + 2*(self.rows-1)):
|
||||
for j in range(self.rows):
|
||||
yield THTPad.circle(x + (i - (self.rows - 1))*self.pitch, y - j*self.pitch, self.drill_dia, pad_dia, paste=False)
|
||||
yield THTPad.circle(x + (i - (self.rows - 1))*self.pitch, y + (self.h + 1 + j)*self.pitch, self.drill_dia, pad_dia, paste=False)
|
||||
|
||||
if self.rows >= 2 and 1 <= i < self.w:
|
||||
yield Trace(self.trace_width, start=(x+i*self.pitch, y-self.pitch), end=(x+(i + 0.5)*self.pitch, y+pad_dia/2 - self.trace_width/2))
|
||||
yield Trace(self.trace_width, start=(x+i*self.pitch, y+(self.h+2)*self.pitch), end=(x+(i + 0.5)*self.pitch, y+(self.h+1)*self.pitch -pad_dia/2 + self.trace_width/2), orientation=('cw',))
|
||||
|
||||
for i in range(1, self.h+1):
|
||||
for j in range(self.rows):
|
||||
yield THTPad.circle(x - j*self.pitch, y + i*self.pitch, self.drill_dia, pad_dia, paste=False)
|
||||
yield THTPad.circle(x + (self.w + 1 + j)*self.pitch, y + i*self.pitch, self.drill_dia, pad_dia, paste=False)
|
||||
|
||||
if self.rows >= 2 and i < self.h:
|
||||
yield Trace(self.trace_width,
|
||||
start=(
|
||||
x-self.pitch,
|
||||
y+i*self.pitch),
|
||||
end=(
|
||||
x+pad_dia/2 - self.trace_width/2,
|
||||
y+(i + 0.5)*self.pitch),
|
||||
orientation=('cw',))
|
||||
yield Trace(self.trace_width,
|
||||
start=(
|
||||
x+(self.w+2)*self.pitch,
|
||||
y+i*self.pitch),
|
||||
end=(
|
||||
x+(self.w+1)*self.pitch -pad_dia/2 + self.trace_width/2,
|
||||
y+(i + 0.5)*self.pitch))
|
||||
|
||||
|
||||
def _breakout_demo():
|
||||
b = Board(100, 80)
|
||||
|
||||
ring = PadRing(5, 5, 8, 12)
|
||||
for obj in ring.generate(None, None):
|
||||
b.add(obj)
|
||||
|
||||
for x, y in ring.ports():
|
||||
b.add(Trace(0.1, start=(23, 27), end=(x, y)))
|
||||
|
||||
with open('/tmp/test.svg', 'w') as f:
|
||||
f.write(str(b.pretty_svg()))
|
||||
b.layer_stack().save_to_directory('/tmp/testdir')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_breakout_demo()
|
||||
|
||||
0
src/gerbonara/cad/data/__init__.py
Normal file
16
src/gerbonara/cad/data/center-pad-spikes.kicad_mod
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
(module center-pad-spikes (layer F.Cu) (tedit 5B6B1C50)
|
||||
(fp_text reference REF** (at 0 -1.4) (layer F.SilkS) hide
|
||||
(effects (font (size 1 1) (thickness 0.15)))
|
||||
)
|
||||
(fp_text value center-pad (at 0.1 -2.7) (layer F.Fab) hide
|
||||
(effects (font (size 1 1) (thickness 0.15)))
|
||||
)
|
||||
(pad 1 smd custom (at -0.06 -0.085) (size 0.47 0.52) (layers *.Cu *.Mask)
|
||||
(options (clearance outline) (anchor rect))
|
||||
(primitives
|
||||
(gr_poly (pts
|
||||
(xy -0.585 0.085) (xy 0.06 -0.56) (xy 0.357 -0.263) (xy 0.583 -0.263) (xy 0.235 0.085)
|
||||
(xy 0.582 0.432) (xy 0.407 0.432) (xy 0.407 0.607) (xy 0.06 0.26) (xy -0.288 0.608)
|
||||
(xy -0.288 0.382)) (width 0.001))
|
||||
))
|
||||
)
|
||||
24
src/gerbonara/cad/data/pad-between-spiked.kicad_mod
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
(module pad-between-spiked (layer F.Cu) (tedit 5B6B1D89)
|
||||
(descr "Through hole pin header")
|
||||
(tags "pin header")
|
||||
(fp_text reference REF** (at 0 -5.1) (layer F.SilkS) hide
|
||||
(effects (font (size 1 1) (thickness 0.15)))
|
||||
)
|
||||
(fp_text value pad-between (at 0 -3.1) (layer F.Fab) hide
|
||||
(effects (font (size 1 1) (thickness 0.15)))
|
||||
)
|
||||
(pad 1 smd custom (at 0 -0.06) (size 0.7 0.85) (layers *.Cu *.Mask)
|
||||
(options (clearance outline) (anchor rect))
|
||||
(primitives
|
||||
(gr_poly (pts
|
||||
(xy -0.55 -0.44) (xy -0.325 -0.44) (xy 0 -0.765) (xy 0.325 -0.44) (xy 0.55 -0.44)
|
||||
(xy 0.35 -0.24) (xy 0.35 0.36) (xy 0.55 0.56) (xy 0.338 0.56) (xy 0.338 0.763)
|
||||
(xy 0 0.425) (xy -0.01 0.425) (xy -0.348 0.763) (xy -0.348 0.56) (xy -0.55 0.56)
|
||||
(xy -0.35 0.36) (xy -0.35 -0.24)) (width 0.001))
|
||||
))
|
||||
(model Pin_Headers.3dshapes/Pin_Header_Straight_1x01.wrl
|
||||
(at (xyz 0 0 0))
|
||||
(scale (xyz 1 1 1))
|
||||
(rotate (xyz 0 0 90))
|
||||
)
|
||||
)
|
||||
16
src/gerbonara/cad/data/tht-0.8.kicad_mod
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
(module tht-0.8 (layer F.Cu) (tedit 58D96FE6)
|
||||
(descr "Through hole pin header")
|
||||
(tags "pin header")
|
||||
(fp_text reference REF** (at 0 -5.1) (layer F.SilkS) hide
|
||||
(effects (font (size 1 1) (thickness 0.15)))
|
||||
)
|
||||
(fp_text value tht-0.8 (at 0 -3.1) (layer F.Fab) hide
|
||||
(effects (font (size 1 1) (thickness 0.15)))
|
||||
)
|
||||
(pad 1 thru_hole circle (at 0 0) (size 1.4 1.4) (drill 0.8) (layers *.Cu *.Mask))
|
||||
(model Pin_Headers.3dshapes/Pin_Header_Straight_1x01.wrl
|
||||
(at (xyz 0 0 0))
|
||||
(scale (xyz 1 1 1))
|
||||
(rotate (xyz 0 0 90))
|
||||
)
|
||||
)
|
||||
1
src/gerbonara/cad/kicad/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
630
src/gerbonara/cad/kicad/base_types.py
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
import string
|
||||
import time
|
||||
from dataclasses import field, replace
|
||||
import math
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from itertools import cycle
|
||||
|
||||
from .sexp import *
|
||||
from .sexp_mapper import *
|
||||
from ...newstroke import Newstroke
|
||||
from ...utils import rotate_point, sum_bounds, Tag, MM
|
||||
from ...layers import LayerStack
|
||||
from ... import apertures as ap
|
||||
from ... import graphic_objects as go
|
||||
|
||||
|
||||
LAYER_MAP_K2G = {
|
||||
'F.Cu': ('top', 'copper'),
|
||||
'B.Cu': ('bottom', 'copper'),
|
||||
'F.SilkS': ('top', 'silk'),
|
||||
'B.SilkS': ('bottom', 'silk'),
|
||||
'F.Paste': ('top', 'paste'),
|
||||
'B.Paste': ('bottom', 'paste'),
|
||||
'F.Mask': ('top', 'mask'),
|
||||
'B.Mask': ('bottom', 'mask'),
|
||||
'B.CrtYd': ('bottom', 'courtyard'),
|
||||
'F.CrtYd': ('top', 'courtyard'),
|
||||
'B.Fab': ('bottom', 'fabrication'),
|
||||
'F.Fab': ('top', 'fabrication'),
|
||||
'B.Adhes': ('bottom', 'adhesive'),
|
||||
'F.Adhes': ('top', 'adhesive'),
|
||||
'Dwgs.User': ('mechanical', 'drawings'),
|
||||
'Cmts.User': ('mechanical', 'comments'),
|
||||
'Edge.Cuts': ('mechanical', 'outline'),
|
||||
}
|
||||
|
||||
LAYER_MAP_G2K = {v: k for k, v in LAYER_MAP_K2G.items()}
|
||||
|
||||
|
||||
class BBoxMixin:
|
||||
def bounding_box(self, unit=MM):
|
||||
if not hasattr(self, '_bounding_box'):
|
||||
(min_x, min_y), (max_x, max_y) = sum_bounds(fe.bounding_box(unit) for fe in self.render())
|
||||
# Convert back from gerbonara's coordinates to kicad coordinates.
|
||||
self._bounding_box = (min_x, -max_y), (max_x, -min_y)
|
||||
|
||||
return self._bounding_box
|
||||
|
||||
|
||||
@sexp_type('uuid')
|
||||
class UUID:
|
||||
value: str = field(default_factory=uuid.uuid4)
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
return UUID()
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.value = str(self.value)
|
||||
|
||||
def before_sexp(self):
|
||||
self.value = str(self.value)
|
||||
|
||||
def bump(self):
|
||||
self.value = uuid.uuid4()
|
||||
|
||||
|
||||
@sexp_type('group')
|
||||
class Group:
|
||||
locked: Flag() = False
|
||||
name: str = ""
|
||||
id: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
members: Named(Array(str)) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('color')
|
||||
class Color:
|
||||
r: int = None
|
||||
g: int = None
|
||||
b: int = None
|
||||
a: float = None
|
||||
|
||||
def __bool__(self):
|
||||
return self.r or self.b or self.g or not math.isclose(self.a, 0, abs_tol=1e-3)
|
||||
|
||||
def svg(self, default=None):
|
||||
if default and not self:
|
||||
return default
|
||||
|
||||
return f'rgba({self.r} {self.g} {self.b} {self.a})'
|
||||
|
||||
|
||||
@sexp_type('stroke')
|
||||
class Stroke:
|
||||
width: Named(float) = 0.254
|
||||
type: Named(AtomChoice(Atom.dash, Atom.dot, Atom.dash_dot_dot, Atom.dash_dot, Atom.default, Atom.solid)) = Atom.default
|
||||
color: Color = None
|
||||
|
||||
def svg_color(self, default=None):
|
||||
if self.color:
|
||||
return self.color.svg(default)
|
||||
else:
|
||||
return default
|
||||
|
||||
def svg_attrs(self, default_color=None):
|
||||
w = self.width
|
||||
if not (color := self.color or default_color):
|
||||
return {}
|
||||
|
||||
attrs = {'stroke': color,
|
||||
'stroke_linecap': 'round',
|
||||
'stroke_linejoin': 'round',
|
||||
'stroke_width': self.width or 0.254}
|
||||
|
||||
if self.type not in (Atom.default, Atom.solid):
|
||||
attrs['stroke_dasharray'] = {
|
||||
Atom.dash: f'{w*5:.3f},{w*5:.3f}',
|
||||
Atom.dot: f'{w*2:.3f},{w*2:.3f}',
|
||||
Atom.dash_dot: f'{w*5:.3f},{w*3:.3f}{w:.3f},{w*3:.3f}',
|
||||
Atom.dash_dot_dot: f'{w*5:.3f},{w*3:.3f}{w:.3f},{w*3:.3f}{w:.3f},{w*3:.3f}',
|
||||
}[self.type]
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
@sexp_type('fill')
|
||||
class Fill:
|
||||
type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background, Atom.color)) = Atom.none
|
||||
color: Color = None
|
||||
|
||||
|
||||
class WidthMixin:
|
||||
def __post_init__(self):
|
||||
if self.width is not None:
|
||||
self.stroke = Stroke(self.width)
|
||||
|
||||
|
||||
class Dasher:
|
||||
def __init__(self, obj):
|
||||
if obj.stroke:
|
||||
w = obj.stroke.width if obj.stroke.width not in (None, 0, 0.0) else 0.254
|
||||
t = obj.stroke.type
|
||||
else:
|
||||
w = obj.width or 0
|
||||
t = Atom.solid
|
||||
|
||||
self.width = w
|
||||
gap = 4*w
|
||||
dot = 0
|
||||
dash = 11*w
|
||||
self.pattern = {
|
||||
Atom.dash: [dash, gap],
|
||||
Atom.dot: [dot, gap],
|
||||
Atom.dash_dot_dot: [dash, gap, dot, gap, dot, gap],
|
||||
Atom.dash_dot: [dash, gap, dot, gap],
|
||||
Atom.default: [1e99],
|
||||
Atom.solid: [1e99]}[t]
|
||||
self.solid = t in (Atom.default, Atom.solid)
|
||||
self.start_x, self.start_y = None, None
|
||||
self.cur_x, self.cur_y = None, None
|
||||
self.segments = []
|
||||
|
||||
def move(self, x, y):
|
||||
if self.cur_x is None:
|
||||
self.start_x, self.start_y = x, y
|
||||
self.cur_x, self.cur_y = x, y
|
||||
|
||||
def line(self, x, y):
|
||||
if x is None or y is None:
|
||||
raise ValueError('line() called before move()')
|
||||
self.segments.append((self.cur_x, self.cur_y, x, y))
|
||||
self.cur_x, self.cur_y = x, y
|
||||
|
||||
def close(self):
|
||||
self.segments.append((self.cur_x, self.cur_y, self.start_x, self.start_y))
|
||||
self.cur_x, self.cur_y = None, None
|
||||
|
||||
@staticmethod
|
||||
def _interpolate(x1, y1, x2, y2, length):
|
||||
dx, dy = x2-x1, y2-y1
|
||||
total = math.hypot(dx, dy)
|
||||
if total == 0:
|
||||
return x2, y2
|
||||
frac = length / total
|
||||
return x1 + dx*frac, y1 + dy*frac
|
||||
|
||||
def __iter__(self):
|
||||
it = iter(self.segments)
|
||||
segment_remaining, segment_pos = 0, 0
|
||||
|
||||
if self.width is None or self.width < 1e-3:
|
||||
return
|
||||
|
||||
for length, stroked in cycle(zip(self.pattern, cycle([True, False]))):
|
||||
length = max(1e-12, length)
|
||||
while length > 0:
|
||||
if segment_remaining == 0:
|
||||
try:
|
||||
x1, y1, x2, y2 = next(it)
|
||||
except StopIteration:
|
||||
return
|
||||
dx, dy = x2-x1, y2-y1
|
||||
lx, ly = x1, y1
|
||||
segment_remaining = math.hypot(dx, dy)
|
||||
segment_pos = 0
|
||||
|
||||
if segment_remaining > length:
|
||||
segment_pos += length
|
||||
ix, iy = self._interpolate(x1, y1, x2, y2, segment_pos)
|
||||
segment_remaining -= length
|
||||
if stroked:
|
||||
yield lx, ly, ix, iy
|
||||
lx, ly = ix, iy
|
||||
break
|
||||
|
||||
else:
|
||||
length -= segment_remaining
|
||||
segment_remaining = 0
|
||||
if stroked:
|
||||
yield lx, ly, x2, y2
|
||||
|
||||
def svg(self, **kwargs):
|
||||
if 'fill' not in kwargs:
|
||||
kwargs['fill'] = 'none'
|
||||
if 'stroke' not in kwargs:
|
||||
kwargs['stroke'] = 'black'
|
||||
if 'stroke_width' not in kwargs:
|
||||
kwargs['stroke_width'] = 0.254
|
||||
if 'stroke_linecap' not in kwargs:
|
||||
kwargs['stroke_linecap'] = 'round'
|
||||
|
||||
d = ' '.join(f'M {x1:.3f} {y1:.3f} L {x2:.3f} {y2:.3f}' for x1, y1, x2, y2 in self)
|
||||
return Tag('path', d=d, **kwargs)
|
||||
|
||||
|
||||
@sexp_type('xy')
|
||||
class XYCoord:
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
|
||||
def __init__(self, x=None, y=None):
|
||||
if x is None:
|
||||
self.x, self.y = None, None
|
||||
elif isinstance(x, XYCoord):
|
||||
self.x, self.y = x.x, x.y
|
||||
elif isinstance(x, (tuple, list)):
|
||||
self.x, self.y = x
|
||||
elif hasattr(x, 'abs_pos'):
|
||||
self.x, self.y, _1, _2 = x.abs_pos
|
||||
elif hasattr(x, 'at'):
|
||||
self.x, self.y = x.at.x, x.at.y
|
||||
else:
|
||||
self.x, self.y = x, y
|
||||
|
||||
def __iter__(self):
|
||||
return iter((self.x, self.y))
|
||||
|
||||
def __getitem__(self, index):
|
||||
return (self.x, self.y)[index]
|
||||
|
||||
def __setitem__(self, index, value):
|
||||
if index == 0:
|
||||
self.x = value
|
||||
elif index == 1:
|
||||
self.y = value
|
||||
else:
|
||||
raise IndexError(f'Invalid 2D point coordinate index {index}')
|
||||
|
||||
def within_distance(self, x, y, dist):
|
||||
return math.dist((x, y), (self.x, self.y)) < dist
|
||||
|
||||
def isclose(self, other, tol=1e-3):
|
||||
return math.isclose(self.x, other.x, tol) and math.isclose(self.y, other.y, tol)
|
||||
|
||||
def with_offset(self, x=0, y=0):
|
||||
return replace(self, x=self.x+x, y=self.y+y)
|
||||
|
||||
def with_rotation(self, angle, cx=0, cy=0):
|
||||
x, y = rotate_point(self.x, self.y, angle, cx, cy)
|
||||
return replace(self, x=x, y=y)
|
||||
|
||||
|
||||
@sexp_type('pts')
|
||||
class PointList:
|
||||
@classmethod
|
||||
def __map__(kls, obj, parent=None, path=''):
|
||||
_tag, *values = obj
|
||||
return [map_sexp(XYCoord, elem, parent=parent, path=path) for elem in values]
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
yield [kls.name_atom, *(e for elem in value for e in elem.__sexp__(elem))]
|
||||
|
||||
|
||||
@sexp_type('arc')
|
||||
class Arc:
|
||||
start: Rename(XYCoord) = None
|
||||
mid: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
|
||||
|
||||
@sexp_type('pts')
|
||||
class ArcPointList:
|
||||
@classmethod
|
||||
def __map__(kls, obj, parent=None, path=''):
|
||||
_tag, *values = obj
|
||||
return [map_sexp((XYCoord if elem[0] == 'xy' else Arc), elem, parent=parent, path=path) for elem in values]
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
yield [kls.name_atom, *(e for elem in value for e in elem.__sexp__(elem))]
|
||||
|
||||
|
||||
@sexp_type('net')
|
||||
class Net:
|
||||
index: int = 0
|
||||
name: str = ''
|
||||
|
||||
|
||||
class NetMixin:
|
||||
def reset_net(self):
|
||||
self.net = Net()
|
||||
|
||||
@property
|
||||
def net_index(self):
|
||||
if self.net is None:
|
||||
return 0
|
||||
return self.net.index
|
||||
|
||||
@property
|
||||
def net_name(self):
|
||||
if self.net is None:
|
||||
return ''
|
||||
return self.net.name
|
||||
|
||||
|
||||
@sexp_type('xyz')
|
||||
class XYZCoord:
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
z: float = 0
|
||||
|
||||
|
||||
@sexp_type('at')
|
||||
class AtPos(XYCoord):
|
||||
x: float = 0 # in millimeter
|
||||
y: float = 0 # in millimeter
|
||||
rotation: int = 0 # in degrees, can only be 0, 90, 180 or 270.
|
||||
unlocked: Flag() = True
|
||||
|
||||
def __before_sexp__(self):
|
||||
self.rotation = int(round(self.rotation % 360))
|
||||
|
||||
@property
|
||||
def rotation_rad(self):
|
||||
return math.radians(self.rotation)
|
||||
|
||||
@rotation_rad.setter
|
||||
def rotation_rad(self, value):
|
||||
self.rotation = math.degrees(value)
|
||||
|
||||
def with_rotation(self, angle, cx=0, cy=0):
|
||||
obj = super().with_rotation(angle, cx, cy)
|
||||
return replace(obj, rotation=self.rotation + angle)
|
||||
|
||||
|
||||
@sexp_type('font')
|
||||
class FontSpec:
|
||||
face: Named(str) = None
|
||||
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(1.27, 1.27))
|
||||
thickness: Named(float) = None
|
||||
bold: OmitDefault(Named(LegacyCompatibleFlag())) = False
|
||||
italic: OmitDefault(Named(LegacyCompatibleFlag())) = False
|
||||
line_spacing: Named(float) = None
|
||||
|
||||
|
||||
@sexp_type('justify')
|
||||
class Justify:
|
||||
h: AtomChoice(Atom.left, Atom.right) = None
|
||||
v: AtomChoice(Atom.top, Atom.bottom) = None
|
||||
mirror: Flag() = False
|
||||
|
||||
@property
|
||||
def h_str(self):
|
||||
if self.h is None:
|
||||
return 'center'
|
||||
else:
|
||||
return str(self.h)
|
||||
|
||||
@property
|
||||
def v_str(self):
|
||||
if self.v is None:
|
||||
return 'middle'
|
||||
else:
|
||||
return str(self.v)
|
||||
|
||||
|
||||
@sexp_type('effects')
|
||||
class TextEffect:
|
||||
font: FontSpec = field(default_factory=FontSpec)
|
||||
justify: OmitDefault(Justify) = field(default_factory=Justify)
|
||||
hide: OmitDefault(Named(LegacyCompatibleFlag())) = False
|
||||
|
||||
|
||||
class TextMixin:
|
||||
@property
|
||||
def size(self):
|
||||
return self.effects.font.size.y or 1.27
|
||||
|
||||
@size.setter
|
||||
def size(self, value):
|
||||
self.effects.font.size.x = self.effects.font.size.y = value
|
||||
|
||||
@property
|
||||
def line_width(self):
|
||||
return self.effects.font.thickness or 0.254
|
||||
|
||||
@line_width.setter
|
||||
def line_width(self, value):
|
||||
self.effects.font.thickness = value
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
if not self.text or not self.text.strip():
|
||||
return default
|
||||
|
||||
lines = list(self.render())
|
||||
x1 = min(min(l.x1, l.x2) for l in lines)
|
||||
y1 = min(min(l.y1, l.y2) for l in lines)
|
||||
x2 = max(max(l.x1, l.x2) for l in lines)
|
||||
y2 = max(max(l.y1, l.y2) for l in lines)
|
||||
r = self.effects.font.thickness/2
|
||||
return (x1-r, -(y1-r)), (x2+r, -(y2+r))
|
||||
|
||||
def svg_path_data(self):
|
||||
for line in self.render():
|
||||
yield f'M {line.x1:.3f} {line.y1:.3f} L {line.x2:.3f} {line.y2:.3f}'
|
||||
|
||||
@property
|
||||
def default_v_align(self):
|
||||
return 'bottom'
|
||||
|
||||
@property
|
||||
def h_align(self):
|
||||
return 'left' if self.effects.justify.h else 'center'
|
||||
|
||||
@property
|
||||
def mirrored(self):
|
||||
return False, False
|
||||
|
||||
def to_svg(self, color='black', variables={}):
|
||||
if not self.effects or self.effects.hide or not self.effects.font:
|
||||
return
|
||||
|
||||
font = Newstroke.load()
|
||||
text = string.Template(self.text).safe_substitute(variables)
|
||||
aperture = ap.CircleAperture(self.line_width or 0.2, unit=MM)
|
||||
rot = self.rotation
|
||||
h_align = self.h_align
|
||||
mx, my = self.mirrored
|
||||
if rot in (90, 270):
|
||||
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
|
||||
rot = (rot+180)%360
|
||||
elif rot == 180:
|
||||
rot = 0
|
||||
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
|
||||
|
||||
if my and rot in (0, 180):
|
||||
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
|
||||
rot = (rot+180)%360
|
||||
if mx and rot in (90, 270):
|
||||
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
|
||||
rot = (rot+180)%360
|
||||
if rot == 180:
|
||||
rot = 0
|
||||
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
|
||||
if rot == 90:
|
||||
rot = 270
|
||||
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
|
||||
|
||||
yield font.render_svg(text,
|
||||
size=self.size or 1.27,
|
||||
h_align=h_align,
|
||||
v_align=self.effects.justify.v or self.default_v_align,
|
||||
stroke=color,
|
||||
stroke_width=f'{self.line_width:.3f}',
|
||||
scale=(1,1),
|
||||
rotation=0,
|
||||
transform=f'translate({self.at.x:.3f} {self.at.y:.3f}) rotate({rot})',
|
||||
)
|
||||
|
||||
@property
|
||||
def _text_offset(self):
|
||||
return (0, 0)
|
||||
|
||||
@property
|
||||
def rotation(self):
|
||||
return self.at.rotation
|
||||
|
||||
def render(self, variables={}):
|
||||
if not self.effects or self.effects.hide or not self.effects.font:
|
||||
return
|
||||
|
||||
font = Newstroke.load()
|
||||
text = string.Template(self.text).safe_substitute(variables)
|
||||
aperture = ap.CircleAperture(self.line_width or 0.2, unit=MM)
|
||||
for stroke in font.render(text,
|
||||
x0=self.at.x, y=self.at.y,
|
||||
size=self.size or 1.27,
|
||||
h_align=self.effects.justify.h_str,
|
||||
v_align=self.effects.justify.v_str,
|
||||
rotation=self.at.rotation,
|
||||
):
|
||||
|
||||
points = []
|
||||
for x, y in stroke:
|
||||
x, y = x+offx, y+offy
|
||||
x, y = rotate_point(x, y, math.radians(-rot or 0))
|
||||
x, y = x+self.at.x, y+self.at.y
|
||||
points.append((x, -y))
|
||||
|
||||
for p1, p2 in zip(points[:-1], points[1:]):
|
||||
yield go.Line(*p1, *p2, aperture=aperture, unit=MM)
|
||||
|
||||
|
||||
|
||||
@sexp_type('tstamp')
|
||||
class Timestamp:
|
||||
value: str = field(default_factory=uuid.uuid4)
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
return Timestamp()
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.value = str(self.value)
|
||||
|
||||
def before_sexp(self):
|
||||
self.value = Atom(str(self.value))
|
||||
|
||||
def bump(self):
|
||||
self.value = uuid.uuid4()
|
||||
|
||||
|
||||
@sexp_type('tedit')
|
||||
class EditTime:
|
||||
value: str = field(default_factory=time.time)
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
return EditTime()
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.value = int(str(self.value), 16)
|
||||
|
||||
def __before_sexp__(self):
|
||||
self.value = Atom(f'{int(self.value):08X}')
|
||||
|
||||
def bump(self):
|
||||
self.value = time.time()
|
||||
|
||||
|
||||
@sexp_type('paper')
|
||||
class PageSettings:
|
||||
page_format: str = 'A4'
|
||||
width: float = None
|
||||
height: float = None
|
||||
portrait: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('property')
|
||||
class Property:
|
||||
key: str = ''
|
||||
value: str = ''
|
||||
|
||||
|
||||
@sexp_type('property')
|
||||
class DrawnProperty(TextMixin):
|
||||
key: str = None
|
||||
value: str = None
|
||||
id: Named(int) = None
|
||||
at: AtPos = None
|
||||
unlocked: OmitDefault(Named(YesNoAtom())) = True
|
||||
layer: Named(str) = None
|
||||
hide: OmitDefault(Named(YesNoAtom())) = False
|
||||
uuid: UUID = None
|
||||
tstamp: Timestamp = None
|
||||
effects: OmitDefault(TextEffect) = field(default_factory=TextEffect)
|
||||
_ : SEXP_END = None
|
||||
parent: object = None
|
||||
|
||||
def __after_parse(self, parent=None):
|
||||
self.parent = parent
|
||||
|
||||
# Alias value for text mixin
|
||||
@property
|
||||
def text(self):
|
||||
return self.value
|
||||
|
||||
@text.setter
|
||||
def text(self, value):
|
||||
self.value = value
|
||||
|
||||
|
||||
@sexp_type('chamfer')
|
||||
class Chamfer:
|
||||
top_left: Flag() = False
|
||||
top_right: Flag() = False
|
||||
bottom_left: Flag() = False
|
||||
bottom_right: Flag() = False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
class Foo:
|
||||
pass
|
||||
|
||||
foo = Foo()
|
||||
foo.stroke = troke(0.01, Atom.dash_dot_dot)
|
||||
d = Dasher(foo)
|
||||
#d = Dasher(Stroke(0.01, Atom.solid))
|
||||
d.move(1, 1)
|
||||
d.line(1, 2)
|
||||
d.line(3, 2)
|
||||
d.line(3, 1)
|
||||
d.close()
|
||||
|
||||
print('<?xml version="1.0" standalone="no"?>')
|
||||
print('<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">')
|
||||
print('<svg version="1.1" width="4cm" height="3cm" viewBox="0 0 4 3" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">')
|
||||
for x1, y1, x2, y2 in d:
|
||||
print(f'<path fill="none" stroke="black" stroke-width="0.01" stroke-linecap="round" d="M {x1},{y1} L {x2},{y2}"/>')
|
||||
print('</svg>')
|
||||
996
src/gerbonara/cad/kicad/footprints.py
Normal file
|
|
@ -0,0 +1,996 @@
|
|||
"""
|
||||
Library for handling KiCad's footprint files (`*.kicad_mod`).
|
||||
"""
|
||||
|
||||
import re
|
||||
import copy
|
||||
import enum
|
||||
import string
|
||||
import datetime
|
||||
import math
|
||||
import time
|
||||
import fnmatch
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from dataclasses import field, replace
|
||||
|
||||
from .sexp import *
|
||||
from .base_types import *
|
||||
from .primitives import *
|
||||
from . import graphical_primitives as gr
|
||||
|
||||
from ..primitives import Positioned
|
||||
|
||||
from ... import __version__
|
||||
from ... import graphic_primitives as gp
|
||||
from ... import graphic_objects as go
|
||||
from ... import apertures as ap
|
||||
from ...layers import LayerStack
|
||||
from ...newstroke import Newstroke
|
||||
from ...utils import MM, rotate_point, offset_bounds, sum_bounds
|
||||
from ...aperture_macros.parse import GenericMacros, ApertureMacro
|
||||
from ...aperture_macros import primitive as amp
|
||||
|
||||
|
||||
class _MISSING:
|
||||
pass
|
||||
|
||||
def angle_difference(a, b):
|
||||
return (b - a + math.pi) % (2*math.pi) - math.pi
|
||||
|
||||
@sexp_type('attr')
|
||||
class Attribute:
|
||||
type: AtomChoice(Atom.smd, Atom.through_hole) = None
|
||||
board_only: Flag() = False
|
||||
virtual: Flag() = False # prior to 20208026
|
||||
exclude_from_pos_files: Flag() = False
|
||||
exclude_from_bom: Flag() = False
|
||||
allow_missing_courtyard: Flag() = False
|
||||
allow_soldermask_bridges: Flag() = False
|
||||
dnp: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('fp_text')
|
||||
class Text:
|
||||
type: AtomChoice(Atom.reference, Atom.value, Atom.user) = Atom.user
|
||||
text: str = ""
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
unlocked: OmitDefault(Named(YesNoAtom())) = False
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
hide: Flag() = False
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables={}, cache=None):
|
||||
if self.hide: # why
|
||||
return
|
||||
|
||||
yield from gr.Text.render(self, variables=variables)
|
||||
|
||||
|
||||
@sexp_type('fp_text_box')
|
||||
class TextBox:
|
||||
locked: Flag() = False
|
||||
text: str = None
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
margins: Rename(gr.Margins) = None
|
||||
pts: PointList = None
|
||||
angle: Named(float) = 0.0
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
border: Named(YesNoAtom()) = False
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
render_cache: RenderCache = None
|
||||
|
||||
def render(self, variables={}, cache=None):
|
||||
yield from gr.TextBox.render(self, variables=variables)
|
||||
|
||||
|
||||
@sexp_type('fp_line')
|
||||
class Line:
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = None
|
||||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def to_graphical_primitive(self, flip=False):
|
||||
# FIXME flip
|
||||
return gr.Line(self.start, self.end, self.layer, self.width, self.stroke, self.tstamp)
|
||||
|
||||
def render(self, variables=None, cache=None):
|
||||
dasher = Dasher(self)
|
||||
dasher.move(self.start.x, self.start.y)
|
||||
dasher.line(self.end.x, self.end.y)
|
||||
|
||||
for x1, y1, x2, y2 in dasher:
|
||||
yield go.Line(x1, -y1, x2, -y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
|
||||
|
||||
|
||||
@sexp_type('fp_rect')
|
||||
class Rectangle:
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = None
|
||||
fill: gr.FillMode = None
|
||||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None, cache=None):
|
||||
x1, y1 = self.start.x, self.start.y
|
||||
x2, y2 = self.end.x, self.end.y
|
||||
x1, x2 = min(x1, x2), max(x1, x2)
|
||||
y1, y2 = min(y1, y2), max(y1, y2)
|
||||
w, h = x2-x1, y2-y1
|
||||
|
||||
if self.fill == Atom.solid:
|
||||
yield go.Region.from_rectangle(x1, -y1, w, h, unit=MM)
|
||||
|
||||
dasher = Dasher(self)
|
||||
dasher.move(x1, y1)
|
||||
dasher.line(x1, y2)
|
||||
dasher.line(x2, y2)
|
||||
dasher.line(x2, y1)
|
||||
dasher.close()
|
||||
|
||||
aperture = ap.CircleAperture(dasher.width, unit=MM)
|
||||
for x1, y1, x2, y2 in dasher:
|
||||
yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM)
|
||||
|
||||
|
||||
@sexp_type('fp_circle')
|
||||
class Circle:
|
||||
center: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = None
|
||||
fill: gr.FillMode = None
|
||||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None, cache=None):
|
||||
x, y = self.center.x, self.center.y
|
||||
r = math.dist((x, y), (self.end.x, self.end.y)) # insane
|
||||
|
||||
dasher = Dasher(self)
|
||||
aperture = ap.CircleAperture(dasher.width or 0, unit=MM)
|
||||
|
||||
circle = go.Arc.from_circle(x, -y, r, aperture=aperture, unit=MM)
|
||||
|
||||
if self.fill == Atom.solid:
|
||||
yield circle.to_region()
|
||||
|
||||
if dasher.solid:
|
||||
yield circle
|
||||
|
||||
else: # pain
|
||||
for line in circle.approximate(): # TODO precision settings
|
||||
dasher.segments.append((line.x1, line.y1, line.x2, line.y2))
|
||||
|
||||
aperture = ap.CircleAperture(dasher.width, unit=MM)
|
||||
for x1, y1, x2, y2 in dasher:
|
||||
yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM)
|
||||
|
||||
|
||||
@sexp_type('fp_arc')
|
||||
class Arc:
|
||||
start: Rename(XYCoord) = None
|
||||
mid: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
width: Named(float) = None
|
||||
angle: Named(float) = None
|
||||
stroke: Stroke = None
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def to_graphical_primitive(self, flip=False):
|
||||
# FIXME flip
|
||||
return gr.Arc(self.start, self.mid, self.end, self.layer, self.width, self.stroke, self.tstamp)
|
||||
|
||||
def render(self, variables=None, cache=None):
|
||||
mx, my = self.mid.x, self.mid.y
|
||||
x1, y1 = self.start.x, self.start.y
|
||||
x2, y2 = self.end.x, self.end.y
|
||||
dasher = Dasher(self)
|
||||
aperture = ap.CircleAperture(dasher.width, unit=MM)
|
||||
|
||||
if math.isclose(x1, x2, abs_tol=1e-6) and math.isclose(y1, y2, abs_tol=1e-6):
|
||||
cx = (x1 + mx) / 2
|
||||
cy = (y1 + my) / 2
|
||||
arc = go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), clockwise=True, aperture=aperture, unit=MM)
|
||||
if dasher.solid:
|
||||
yield arc
|
||||
|
||||
else:
|
||||
# use approximation from graphic object arc class
|
||||
for line in arc.approximate():
|
||||
dasher.segments.append((line.x1, line.y1, line.x2, line.y2))
|
||||
|
||||
for line in dasher:
|
||||
yield go.Line(x1, -y1, x2, -y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
|
||||
|
||||
else:
|
||||
# https://stackoverflow.com/questions/56224824/how-do-i-find-the-circumcenter-of-the-triangle-using-python-without-external-lib
|
||||
d = 2 * (x1 * (y2 - my) + x2 * (my - y1) + mx * (y1 - y2))
|
||||
cx = ((x1 * x1 + y1 * y1) * (y2 - my) + (x2 * x2 + y2 * y2) * (my - y1) + (mx * mx + my * my) * (y1 - y2)) / d
|
||||
cy = ((x1 * x1 + y1 * y1) * (mx - x2) + (x2 * x2 + y2 * y2) * (x1 - mx) + (mx * mx + my * my) * (x2 - x1)) / d
|
||||
|
||||
# KiCad only has clockwise arcs.
|
||||
arc = go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), clockwise=True, aperture=aperture, unit=MM)
|
||||
if dasher.solid:
|
||||
yield arc
|
||||
|
||||
else:
|
||||
# use approximation from graphic object arc class
|
||||
for line in arc.approximate():
|
||||
dasher.segments.append((line.x1, line.y1, line.x2, line.y2))
|
||||
|
||||
for line in dasher:
|
||||
yield go.Line(x1, -y1, x2, -y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
|
||||
|
||||
|
||||
@sexp_type('fp_poly')
|
||||
class Polygon:
|
||||
pts: PointList = field(default_factory=list)
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = None
|
||||
fill: gr.FillMode = None
|
||||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None, cache=None):
|
||||
if len(self.pts) < 2:
|
||||
return
|
||||
|
||||
dasher = Dasher(self)
|
||||
start = self.pts[0]
|
||||
dasher.move(start.x, start.y)
|
||||
for point in self.pts[1:]:
|
||||
dasher.line(point.x, point.y)
|
||||
|
||||
if dasher.width > 0:
|
||||
aperture = ap.CircleAperture(dasher.width, unit=MM)
|
||||
for x1, y1, x2, y2 in dasher:
|
||||
yield go.Line(x1, -y1, x2, -y2, aperture=aperture, unit=MM)
|
||||
|
||||
if self.fill == Atom.solid:
|
||||
yield go.Region([(pt.x, -pt.y) for pt in self.pts], unit=MM)
|
||||
|
||||
|
||||
@sexp_type('fp_curve')
|
||||
class Curve:
|
||||
pts: PointList = field(default_factory=list)
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = None
|
||||
locked: Flag() = False
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None, cache=None):
|
||||
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
|
||||
|
||||
|
||||
@sexp_type('drill')
|
||||
class Drill:
|
||||
oval: Flag() = False
|
||||
diameter: float = 0
|
||||
width: float = None
|
||||
offset: Rename(XYCoord) = None
|
||||
|
||||
|
||||
@sexp_type('options')
|
||||
class CustomPadOptions:
|
||||
clearance: Named(AtomChoice(Atom.outline, Atom.convexhull)) = Atom.outline
|
||||
anchor: Named(AtomChoice(Atom.rect, Atom.circle)) = Atom.rect
|
||||
|
||||
|
||||
@sexp_type('primitives')
|
||||
class CustomPadPrimitives:
|
||||
annotation_bboxes: List(gr.AnnotationBBox) = field(default_factory=list)
|
||||
lines: List(gr.Line) = field(default_factory=list)
|
||||
rectangles: List(gr.Rectangle) = field(default_factory=list)
|
||||
circles: List(gr.Circle) = field(default_factory=list)
|
||||
arcs: List(gr.Arc) = field(default_factory=list)
|
||||
polygons: List(gr.Polygon) = field(default_factory=list)
|
||||
curves: List(gr.Curve) = field(default_factory=list)
|
||||
width: Named(float) = None
|
||||
fill: gr.FillMode = True
|
||||
|
||||
def all(self):
|
||||
yield from self.lines
|
||||
yield from self.rectangles
|
||||
yield from self.circles
|
||||
yield from self.arcs
|
||||
yield from self.polygons
|
||||
yield from self.curves
|
||||
|
||||
|
||||
@sexp_type('pad')
|
||||
class Pad(NetMixin):
|
||||
number: str = None
|
||||
type: AtomChoice(Atom.thru_hole, Atom.smd, Atom.connect, Atom.np_thru_hole) = None
|
||||
shape: AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom) = None
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
locked: Flag() = False
|
||||
size: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
drill: Drill = None
|
||||
layers: Named(Array(str)) = field(default_factory=list)
|
||||
properties: List(Property) = field(default_factory=list)
|
||||
remove_unused_layers: Named(YesNoAtom()) = False
|
||||
keep_end_layers: Named(YesNoAtom()) = False
|
||||
zone_layer_connections: Named(Array(str)) = field(default_factory=list)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
rect_delta: Rename(XYCoord) = None
|
||||
roundrect_rratio: Named(float) = None
|
||||
thermal_bridge_angle: Named(int) = 45
|
||||
thermal_bridge_width: Named(float) = 0.5
|
||||
chamfer_ratio: Named(float) = None
|
||||
chamfer: Chamfer = None
|
||||
net: Net = None
|
||||
tstamp: Timestamp = None
|
||||
pin_function: Named(str) = None
|
||||
pintype: Named(str) = None
|
||||
pinfunction: Named(str) = None
|
||||
teardrops: gr.TeardropSpec = None
|
||||
die_length: Named(float) = None
|
||||
solder_mask_margin: Named(float) = None
|
||||
solder_paste_margin: Named(float) = None
|
||||
solder_paste_margin_ratio: Named(float) = None
|
||||
clearance: Named(float) = None
|
||||
zone_connect: Named(int) = None
|
||||
thermal_width: Named(float) = None
|
||||
thermal_gap: Named(float) = None
|
||||
options: OmitDefault(CustomPadOptions) = None
|
||||
padstack: gr.PadStack = None
|
||||
primitives: OmitDefault(CustomPadPrimitives) = None
|
||||
_: SEXP_END = None
|
||||
footprint: object = field(repr=False, default=None)
|
||||
|
||||
def __after_parse__(self, parent=None):
|
||||
self.layers = unfuck_layers(self.layers)
|
||||
|
||||
def __before_sexp__(self):
|
||||
self.layers = fuck_layers(self.layers)
|
||||
|
||||
@property
|
||||
def abs_pos(self):
|
||||
if self.footprint:
|
||||
px, py, pr = self.footprint.at.x, self.footprint.at.y, self.footprint.at.rotation
|
||||
else:
|
||||
px, py, pr = 0, 0, 0
|
||||
|
||||
x, y = rotate_point(self.at.x, self.at.y, math.radians(pr))
|
||||
return x+px, y+py, self.at.rotation, False
|
||||
|
||||
@property
|
||||
def layer_mask(self):
|
||||
return layer_mask(self.layers)
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.at = self.at.with_offset(x, y)
|
||||
|
||||
def find_connected_footprints(self, **filters):
|
||||
""" Find footprints connected to the same net as this pad """
|
||||
return self.footprint.board.find_footprints(net=self.net.name, **filters)
|
||||
|
||||
def find_same_net(self, include_vias=True):
|
||||
""" Find traces and vias of the same net as this pad. """
|
||||
return self.footprint.board.find_traces(self.net.name, include_vias=include_vias)
|
||||
|
||||
def render(self, variables=None, margin=None, cache=None):
|
||||
#if self.type in (Atom.connect, Atom.np_thru_hole):
|
||||
# return
|
||||
if self.drill and self.drill.offset:
|
||||
ox, oy = rotate_point(self.drill.offset.x, self.drill.offset.y, math.radians(self.at.rotation))
|
||||
else:
|
||||
ox, oy = 0, 0
|
||||
|
||||
cache_key = id(self), margin
|
||||
if cache and cache_key in cache:
|
||||
aperture = cache[cache_key]
|
||||
|
||||
elif cache is not None:
|
||||
aperture = cache[cache_key] = self.aperture(margin)
|
||||
|
||||
else:
|
||||
aperture = self.aperture(margin)
|
||||
|
||||
yield go.Flash(self.at.x+ox, -(self.at.y+oy), aperture, unit=MM)
|
||||
|
||||
def aperture(self, margin=None):
|
||||
rotation = math.radians(self.at.rotation)
|
||||
margin = margin or 0
|
||||
|
||||
if self.shape == Atom.circle:
|
||||
return ap.CircleAperture(self.size.x+2*margin, unit=MM)
|
||||
|
||||
elif self.shape == Atom.rect:
|
||||
if margin > 0:
|
||||
return GenericMacros.rounded_rect(self.size.x+2*margin,
|
||||
self.size.y+2*margin,
|
||||
margin,
|
||||
0, # no hole
|
||||
rotation)
|
||||
else:
|
||||
return ap.RectangleAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation)
|
||||
|
||||
elif self.shape == Atom.oval:
|
||||
return ap.ObroundAperture(self.size.x+2*margin, self.size.y+2*margin, unit=MM).rotated(-rotation)
|
||||
|
||||
elif self.shape == Atom.trapezoid:
|
||||
# KiCad's trapezoid aperture "rect_delta" param is just weird to the point that I think it's probably
|
||||
# bugged. If you have a size of 2mm by 2mm, and set this param to 1mm, the resulting pad extends past the
|
||||
# original bounding box, and the trapezoid's base and tip length are 3mm and 1mm.
|
||||
|
||||
x, y = self.size.x, self.size.y
|
||||
if self.rect_delta:
|
||||
dx, dy = self.rect_delta.x, self.rect_delta.y
|
||||
else: # RF_Antenna/Pulse_W3011 has trapezoid pads w/o rect_delta, which KiCad renders as plain rects.
|
||||
dx, dy = 0, 0
|
||||
|
||||
if dx != 0:
|
||||
x, y = y, x
|
||||
dy = dx
|
||||
rotation += math.pi/2
|
||||
|
||||
if margin <= 0:
|
||||
# Note: KiCad already uses MM units, so no conversion needed here.
|
||||
|
||||
alpha = math.atan(y / dy) if dy > 0 else 0
|
||||
return GenericMacros.isosceles_trapezoid(x+dy+2*margin*math.cos(alpha),
|
||||
y+2*margin,
|
||||
2*dy,
|
||||
0, # no hole
|
||||
-rotation + math.pi)
|
||||
|
||||
else:
|
||||
return GenericMacros.rounded_isosceles_trapezoid(x+dy,
|
||||
y,
|
||||
2*dy,
|
||||
margin,
|
||||
0, # no hole
|
||||
-rotation + math.pi)
|
||||
|
||||
elif self.shape == Atom.roundrect:
|
||||
x, y = self.size.x, self.size.y
|
||||
r = min(x, y) * self.roundrect_rratio
|
||||
if margin > -r:
|
||||
return GenericMacros.rounded_rect(x+2*margin,
|
||||
y+2*margin,
|
||||
r+margin,
|
||||
0, # no hole
|
||||
rotation)
|
||||
else:
|
||||
return ap.RectangleAperture(x+margin, y+margin, unit=MM).rotated(-rotation)
|
||||
|
||||
elif self.shape == Atom.custom:
|
||||
primitives = []
|
||||
|
||||
# One round trip through the Gerbonara APIs, please!
|
||||
for obj in self.primitives.all():
|
||||
for gn_obj in obj.render():
|
||||
if margin and isinstance(gn_obj, (go.Line, go.Arc)):
|
||||
gn_obj = replace(gn_obj, aperture=gn_obj.aperture.dilated(margin))
|
||||
|
||||
if isinstance(gn_obj, go.Region) and margin > 0:
|
||||
for line in gn_obj.outline_objects(ap.CircleAperture(2*margin, unit=MM)):
|
||||
primitives += line._aperture_macro_primitives()
|
||||
|
||||
new_primitives = list(gn_obj._aperture_macro_primitives()) # todo: precision params
|
||||
primitives += new_primitives
|
||||
|
||||
# inexact, only works with convex shapes. But whatever, the only other way to do this would require
|
||||
# an entire polygon clipping/offsetting library. Probably a bad choice to put something this complex
|
||||
# into a file format.
|
||||
if isinstance(gn_obj, go.Region) and margin < 0:
|
||||
for line in gn_obj.outline_objects(ap.CircleAperture(2*margin, unit=MM)):
|
||||
line.polarity_dark = False
|
||||
primitives += line._aperture_macro_primitives()
|
||||
|
||||
if self.options:
|
||||
if self.options.anchor == Atom.rect and self.size.x > 0 and self.size.y > 0:
|
||||
if margin <= 0:
|
||||
primitives.append(amp.CenterLine(MM, 1, self.size.x+2*margin, self.size.y+2*margin, 0, 0, 0))
|
||||
|
||||
else: # margin > 0
|
||||
primitives.append(amp.CenterLine(MM, 1, self.size.x+2*margin, self.size.y, 0, 0, 0))
|
||||
primitives.append(amp.CenterLine(MM, 1, self.size.x, self.size.y+2*margin, 0, 0, 0))
|
||||
primitives.append(amp.Circle(MM, 1, 2*margin, -self.size.x/2, -self.size.y/2))
|
||||
primitives.append(amp.Circle(MM, 1, 2*margin, -self.size.x/2, +self.size.y/2))
|
||||
primitives.append(amp.Circle(MM, 1, 2*margin, +self.size.x/2, -self.size.y/2))
|
||||
primitives.append(amp.Circle(MM, 1, 2*margin, +self.size.x/2, +self.size.y/2))
|
||||
|
||||
elif self.options.anchor == Atom.circle and self.size.x > 0:
|
||||
primitives.append(amp.Circle(MM, 1, self.size.x+2*margin, 0, 0, 0))
|
||||
|
||||
macro = ApertureMacro(primitives=tuple(primitives)).rotated(-rotation)
|
||||
return ap.ApertureMacroInstance(macro, unit=MM)
|
||||
|
||||
def render_drill(self):
|
||||
if not self.drill:
|
||||
return
|
||||
|
||||
plated = self.type != Atom.np_thru_hole
|
||||
if self.drill.oval:
|
||||
dia = self.drill.diameter
|
||||
w = self.drill.width
|
||||
|
||||
if self.drill.offset:
|
||||
ox, oy = self.drill.offset.x, self.drill.offset.y
|
||||
else:
|
||||
ox, oy = 0, 0
|
||||
|
||||
if w > dia:
|
||||
dx = 0
|
||||
dy = (w-dia)/2
|
||||
else:
|
||||
dx = (dia-w)/2
|
||||
dy = 0
|
||||
|
||||
aperture = ap.ExcellonTool(min(dia, w), plated=plated, unit=MM)
|
||||
l = go.Line(ox-dx, -(oy-dy), ox+dx, -(oy+dy), aperture=aperture, unit=MM)
|
||||
l.rotate(math.radians(self.at.rotation))
|
||||
l.offset(self.at.x, -self.at.y)
|
||||
yield l
|
||||
|
||||
else:
|
||||
aperture = ap.ExcellonTool(self.drill.diameter, plated=plated, unit=MM)
|
||||
yield go.Flash(self.at.x, -self.at.y, aperture=aperture, unit=MM)
|
||||
|
||||
|
||||
@sexp_type('model')
|
||||
class Model:
|
||||
name: str = ''
|
||||
hide: Flag() = False
|
||||
at: Named(XYZCoord) = field(default_factory=XYZCoord)
|
||||
offset: Named(XYZCoord) = field(default_factory=XYZCoord)
|
||||
opacity: Named(float) = None
|
||||
scale: Named(XYZCoord) = field(default_factory=XYZCoord)
|
||||
rotate: Named(XYZCoord) = field(default_factory=XYZCoord)
|
||||
|
||||
|
||||
@sexp_type('component_classes')
|
||||
class FootprintComponentClasses:
|
||||
classes: List(Named(str, name='class')) = field(default_factory=list)
|
||||
|
||||
|
||||
SUPPORTED_FILE_FORMAT_VERSIONS = [20210108, 20211014, 20221018, 20230517]
|
||||
@sexp_type('footprint')
|
||||
class Footprint:
|
||||
name: str = None
|
||||
_version: Named(int, name='version') = 20221018
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
generator: Named(str) = Atom.gerbonara
|
||||
generator_version: Named(str) = __version__
|
||||
locked: Flag() = False
|
||||
placed: Flag() = False
|
||||
layer: Named(str) = 'F.Cu'
|
||||
tedit: EditTime = field(default_factory=EditTime)
|
||||
tstamp: Timestamp = None
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
descr: Named(str) = None
|
||||
tags: Named(str) = None
|
||||
properties: List(DrawnProperty) = field(default_factory=list)
|
||||
component_classes: FootprintComponentClasses = None
|
||||
path: Named(str) = None
|
||||
sheetname: Named(str) = None
|
||||
sheetfile: Named(str) = None
|
||||
autoplace_cost90: Named(float) = None
|
||||
autoplace_cost180: Named(float) = None
|
||||
solder_mask_margin: Named(float) = None
|
||||
solder_paste_margin_ratio: Named(float) = None
|
||||
solder_paste_margin: Named(float) = None
|
||||
solder_paste_ratio: Named(float) = None
|
||||
clearance: Named(float) = None
|
||||
zone_connect: Named(int) = None
|
||||
thermal_width: Named(float) = None
|
||||
thermal_gap: Named(float) = None
|
||||
attributes: Attribute = field(default_factory=Attribute)
|
||||
private_layers: Named(str) = None
|
||||
net_tie_pad_groups: Named(Array(str)) = None
|
||||
texts: List(Text) = field(default_factory=list)
|
||||
text_boxes: List(TextBox) = field(default_factory=list)
|
||||
lines: List(Line) = field(default_factory=list)
|
||||
rectangles: List(Rectangle) = field(default_factory=list)
|
||||
circles: List(Circle) = field(default_factory=list)
|
||||
arcs: List(Arc) = field(default_factory=list)
|
||||
polygons: List(Polygon) = field(default_factory=list)
|
||||
curves: List(Curve) = field(default_factory=list)
|
||||
dimensions: List(gr.Dimension) = field(default_factory=list)
|
||||
pads: List(Pad) = field(default_factory=list)
|
||||
zones: List(Zone) = field(default_factory=list)
|
||||
groups: List(Group) = field(default_factory=list)
|
||||
embedded_fonts: Named(YesNoAtom()) = False
|
||||
models: List(Model) = field(default_factory=list)
|
||||
_ : SEXP_END = None
|
||||
original_filename: str = None
|
||||
board: object = field(repr=False, default=None)
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
for pad in self.pads:
|
||||
pad.footprint = self
|
||||
|
||||
def property_value(self, key, default=_MISSING):
|
||||
for prop in self.properties:
|
||||
if prop.key == key:
|
||||
return prop.value
|
||||
|
||||
if default is not _MISSING:
|
||||
return default
|
||||
|
||||
raise IndexError(f'Footprint has no property named "{key}"')
|
||||
|
||||
def set_property(self, key, value, x=0, y=0, rotation=0, layer='F.Fab', hide=True, effects=None):
|
||||
for prop in self.properties:
|
||||
if prop.key == key:
|
||||
old_value, prop.value = prop.value, value
|
||||
return old_value
|
||||
|
||||
if effects is None:
|
||||
effects = TextEffect()
|
||||
|
||||
self.properties.append(DrawnProperty(key, value,
|
||||
at=AtPos(x, y, rotation, unlocked=True),
|
||||
layer=layer,
|
||||
hide=hide,
|
||||
effects=effects))
|
||||
|
||||
def make_standard_properties(self):
|
||||
if not self.property_value('Reference', None):
|
||||
self.set_property('Reference', 'REF**', 0, 0, 0, 'F.SilkS')
|
||||
|
||||
if not self.property_value('Value', None):
|
||||
self.set_property('Value', self.name or 'VAL**', 0, 0, 0, hide=False)
|
||||
|
||||
if not self.property_value('Footprint', None):
|
||||
self.set_property('Footprint', '', 0, 0, 0)
|
||||
|
||||
if not self.property_value('Datasheet', None):
|
||||
self.set_property('Datasheet', '', 0, 0, 0)
|
||||
|
||||
if not self.property_value('Description', None):
|
||||
self.set_property('Description', self.descr or '', 0, 0, 0)
|
||||
|
||||
def reset_nets(self):
|
||||
for pad in self.pads:
|
||||
pad.reset_net()
|
||||
|
||||
@property
|
||||
def pads_by_number(self):
|
||||
return {(int(pad.number) if pad.number.isnumeric() else pad.number): pad for pad in self.pads if pad.number}
|
||||
|
||||
def find_pads(self, number=None, net=None):
|
||||
for pad in self.pads:
|
||||
if number is not None and pad.number == str(number):
|
||||
yield pad
|
||||
elif isinstance(net, str) and fnmatch.fnmatch(pad.net.name, net):
|
||||
yield pad
|
||||
elif net is not None and pad.net.number == net:
|
||||
yield pad
|
||||
|
||||
def pad(self, number=None, net=None):
|
||||
candidates = list(self.find_pads(number=number, net=net))
|
||||
if not candidates:
|
||||
raise IndexError(f'No such pad "{number or net}"')
|
||||
|
||||
if len(candidates) > 1:
|
||||
raise IndexError(f'Ambiguous pad "{number or net}", {len(candidates)} matching pads.')
|
||||
|
||||
return candidates[0]
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.at = self.at.with_offset(x, y)
|
||||
|
||||
def copy_placement(self, template):
|
||||
# Fix up rotation of pads - KiCad saves each pad's rotation in *absolute* coordinates, not relative to the
|
||||
# footprint. Because we overwrite the footprint's rotation below, we have to first fix all pads to match the
|
||||
# new rotation.
|
||||
self.rotate(math.radians(template.at.rotation - self.at.rotation))
|
||||
self.at = copy.copy(template.at)
|
||||
self.side = template.side
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return self._version
|
||||
|
||||
@version.setter
|
||||
def version(self, value):
|
||||
if value not in SUPPORTED_FILE_FORMAT_VERSIONS:
|
||||
raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.')
|
||||
|
||||
@property
|
||||
def reference(self):
|
||||
return self.property_value('Reference')
|
||||
|
||||
@reference.setter
|
||||
def reference(self, value):
|
||||
self.set_property('Reference', value)
|
||||
|
||||
@property
|
||||
def parsed_reference(self):
|
||||
ref = self.reference
|
||||
if (m := re.match(r'^.*[^0-9]([0-9]+)$', ref)):
|
||||
return m.group(0), int(m.group(1))
|
||||
else:
|
||||
return ref
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self.property_value('Value')
|
||||
|
||||
@value.setter
|
||||
def value(self, value):
|
||||
self.set_property('Value', value)
|
||||
|
||||
def write(self, filename=None):
|
||||
with open(filename or self.original_filename, 'w') as f:
|
||||
f.write(self.serialize())
|
||||
|
||||
def serialize(self):
|
||||
return build_sexp(sexp(type(self), self)[0])
|
||||
|
||||
@classmethod
|
||||
def open_pretty(kls, pretty_dir, fp_name, *args, **kwargs):
|
||||
pretty_dir = Path(pretty_dir) / f'{fp_name}.kicad_mod'
|
||||
return kls.open_mod(pretty_dir / mod_name, *args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def open_mod(kls, mod_file, *args, **kwargs):
|
||||
return kls.load(Path(mod_file).read_text(), *args, **kwargs, original_filename=mod_file)
|
||||
|
||||
@classmethod
|
||||
def open_system(kls, fp_path):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def open_download(kls, fp_path):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def load(kls, data, *args, **kwargs):
|
||||
return kls.parse(data, *args, **kwargs)
|
||||
|
||||
@property
|
||||
def side(self):
|
||||
return 'front' if self.layer == 'F.Cu' else 'back'
|
||||
|
||||
@side.setter
|
||||
def side(self, value):
|
||||
if value not in ('front', 'back'):
|
||||
raise ValueError(f'side must be either "front" or "back", not {side!r}')
|
||||
|
||||
if self.side != value:
|
||||
self.flip()
|
||||
|
||||
def flip(self):
|
||||
def flip_layer(name):
|
||||
if name.startswith('F.'):
|
||||
return f'B.{name[2:]}'
|
||||
elif name.startswith('B.'):
|
||||
return f'F.{name[2:]}'
|
||||
else:
|
||||
return name
|
||||
|
||||
self.layer = flip_layer(self.layer)
|
||||
for obj in self.objects():
|
||||
if getattr(obj, 'layer', None) is not None:
|
||||
obj.layer = flip_layer(obj.layer)
|
||||
|
||||
if hasattr(obj, 'layers'):
|
||||
obj.layers = [flip_layer(name) for name in obj.layers]
|
||||
|
||||
for obj in chain(self.texts, self.text_boxes):
|
||||
obj.effects.justify.mirror = not obj.effects.justify.mirror
|
||||
|
||||
for obj in self.properties:
|
||||
if obj.layer is not None:
|
||||
obj.effects.justify.mirror = not obj.effects.justify.mirror
|
||||
obj.layer = flip_layer(obj.layer)
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def face(self, direction, pad=None, net=None):
|
||||
if not net and not pad:
|
||||
pad = '1'
|
||||
|
||||
candidates = list(self.find_pads(net=net, number=pad))
|
||||
if len(candidates) == 0:
|
||||
raise KeyError(f'Reference pad "{net or pad}" not found.')
|
||||
|
||||
if len(candidates) > 1:
|
||||
raise KeyError(f'Reference pad "{net or pad}" is ambiguous, {len(candidates)} matching pads found.')
|
||||
|
||||
pad = candidates[0]
|
||||
pad_angle = math.atan2(pad.at.y, pad.at.x)
|
||||
|
||||
target_angle = {
|
||||
'right': 0,
|
||||
'top right': math.pi/4,
|
||||
'top': math.pi/2,
|
||||
'top left': 3*math.pi/4,
|
||||
'left': math.pi,
|
||||
'bottom left': -3*math.pi/4,
|
||||
'bottom': -math.pi/2,
|
||||
'bottom right': -math.pi/4}.get(direction, direction)
|
||||
|
||||
delta = angle_difference(target_angle, pad_angle)
|
||||
adj = round(delta / (math.pi/2)) * math.pi/2
|
||||
self.set_rotation(adj)
|
||||
|
||||
def rotate(self, angle=None, cx=None, cy=None, **reference_pad):
|
||||
""" Rotate this footprint by the given angle in radians, counter-clockwise. When (cx, cy) are given, rotate
|
||||
around the given coordinates in the global coordinate space. Otherwise rotate around the footprint's origin. """
|
||||
if (cx, cy) != (None, None):
|
||||
x, y = self.at.x-cx, self.at.y-cy
|
||||
self.at.x = math.cos(-angle)*x - math.sin(-angle)*y + cx
|
||||
self.at.y = math.sin(-angle)*x + math.cos(-angle)*y + cy
|
||||
|
||||
self.at.rotation = (self.at.rotation + math.degrees(angle)) % 360
|
||||
|
||||
for pad in self.pads:
|
||||
pad.at.rotation = (pad.at.rotation + math.degrees(angle)) % 360
|
||||
|
||||
for prop in self.properties:
|
||||
if prop.at is not None:
|
||||
prop.at.rotation = (prop.at.rotation + math.degrees(angle)) % 360
|
||||
|
||||
for text in self.texts:
|
||||
text.at.rotation = (text.at.rotation + math.degrees(angle)) % 360
|
||||
|
||||
def set_rotation(self, angle):
|
||||
old_deg = self.at.rotation
|
||||
new_deg = self.at.rotation = -math.degrees(angle)
|
||||
delta = new_deg - old_deg
|
||||
|
||||
for pad in self.pads:
|
||||
pad.at.rotation = (pad.at.rotation + delta) % 360
|
||||
|
||||
for prop in self.properties:
|
||||
if prop.at is not None:
|
||||
prop.at.rotation = (prop.at.rotation + delta) % 360
|
||||
|
||||
for text in self.texts:
|
||||
text.at.rotation = (text.at.rotation + delta) % 360
|
||||
|
||||
def objects(self, text=False, pads=True, groups=True, zones=True):
|
||||
return chain(
|
||||
(self.texts if text else []),
|
||||
(self.text_boxes if text else []),
|
||||
self.lines,
|
||||
self.rectangles,
|
||||
self.circles,
|
||||
self.arcs,
|
||||
self.polygons,
|
||||
self.curves,
|
||||
(self.dimensions if text else []),
|
||||
(self.pads if pads else []),
|
||||
(self.zones if zones else []),
|
||||
self.groups if groups else [])
|
||||
|
||||
def render(self, layer_stack, layer_map=None, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None):
|
||||
x += self.at.x
|
||||
y += self.at.y
|
||||
rotation += math.radians(self.at.rotation)
|
||||
|
||||
if layer_map is None:
|
||||
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in layer_stack}
|
||||
|
||||
for obj in self.objects(pads=False, text=text, zones=False, groups=False):
|
||||
if not (layer := layer_map.get(obj.layer)):
|
||||
continue
|
||||
|
||||
for fe in obj.render(variables=variables):
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, y, MM)
|
||||
layer_stack[layer].objects.append(fe)
|
||||
|
||||
for obj in self.pads:
|
||||
if self.solder_mask_margin is not None:
|
||||
solder_mask_margin = self.solder_mask_margin
|
||||
elif obj.solder_mask_margin is not None:
|
||||
solder_mask_margin = obj.solder_mask_margin
|
||||
else:
|
||||
solder_mask_margin = None
|
||||
|
||||
if self.solder_paste_margin is not None:
|
||||
solder_paste_margin = self.solder_paste_margin
|
||||
elif obj.solder_paste_margin_ratio is not None:
|
||||
solder_paste_margin = max(obj.size.x, obj.size.y) * obj.solder_paste_margin_ratio
|
||||
elif obj.solder_paste_margin is not None:
|
||||
solder_paste_margin = obj.solder_paste_margin
|
||||
else:
|
||||
solder_paste_margin = None
|
||||
|
||||
for glob in obj.layers or []:
|
||||
for layer in fnmatch.filter(layer_map, glob):
|
||||
|
||||
if layer.endswith('.Mask'):
|
||||
margin = solder_mask_margin
|
||||
elif layer.endswith('.Paste'):
|
||||
margin = solder_paste_margin
|
||||
else:
|
||||
margin = None
|
||||
|
||||
for fe in obj.render(margin=margin, cache=cache):
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, y, MM)
|
||||
if isinstance(fe, go.Flash) and fe.aperture:
|
||||
fe.aperture = fe.aperture.rotated(rotation)
|
||||
layer_stack[layer_map[layer]].objects.append(fe)
|
||||
|
||||
for obj in self.pads:
|
||||
for fe in obj.render_drill():
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, y, MM)
|
||||
|
||||
if obj.type == Atom.np_thru_hole:
|
||||
layer_stack.drill_npth.append(fe)
|
||||
else:
|
||||
layer_stack.drill_pth.append(fe)
|
||||
|
||||
def bounding_box(self, unit=MM):
|
||||
if not hasattr(self, '_bounding_box'):
|
||||
stack = LayerStack()
|
||||
self.render(stack, layer_map=None, x=0, y=0, rotation=0, flip=False, text=False, variables={})
|
||||
self._bounding_box = stack.bounding_box(unit)
|
||||
return self._bounding_box
|
||||
|
||||
|
||||
@dataclass
|
||||
class FootprintInstance(Positioned):
|
||||
sexp: Footprint = None
|
||||
hide_text: bool = True
|
||||
reference: str = 'REF**'
|
||||
value: str = None
|
||||
variables: dict = field(default_factory=lambda: {})
|
||||
|
||||
def render(self, layer_stack, cache=None):
|
||||
x, y, rotation, flip= self.abs_pos
|
||||
x, y = MM(x, self.unit), MM(y, self.unit)
|
||||
|
||||
variables = dict(self.variables)
|
||||
|
||||
if self.reference is not None:
|
||||
variables['REFERENCE'] = str(self.reference)
|
||||
|
||||
if self.value is not None:
|
||||
variables['VALUE'] = str(self.value)
|
||||
|
||||
self.sexp.render(layer_stack, layer_map=None,
|
||||
x=x, y=y, rotation=rotation,
|
||||
flip=flip,
|
||||
text=(not self.hide_text),
|
||||
variables=variables, cache=cache)
|
||||
|
||||
def bounding_box(self, unit=MM):
|
||||
return offset_bounds(self.sexp.bounding_box(unit), unit(self.x, self.unit), unit(self.y, self.unit))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
from ...layers import LayerStack
|
||||
fp = Footprint.open_mod(sys.argv[1])
|
||||
stack = LayerStack()
|
||||
FootprintInstance(0, 0, fp, unit=MM).render(stack)
|
||||
print(stack.to_pretty_svg())
|
||||
stack.save_to_directory('/tmp/testdir')
|
||||
|
||||
462
src/gerbonara/cad/kicad/graphical_primitives.py
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
|
||||
import string
|
||||
import math
|
||||
import base64
|
||||
import textwrap
|
||||
|
||||
from .sexp import *
|
||||
from .base_types import *
|
||||
from .primitives import *
|
||||
|
||||
from ... import graphic_objects as go
|
||||
from ... import apertures as ap
|
||||
from ...newstroke import Newstroke
|
||||
from ...utils import rotate_point, MM, arc_bounds
|
||||
|
||||
@sexp_type('layer')
|
||||
class TextLayer:
|
||||
layer: str = ''
|
||||
knockout: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('gr_text')
|
||||
class Text(TextMixin, BBoxMixin):
|
||||
locked: Flag() = False
|
||||
text: str = ''
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
layer: TextLayer = field(default_factory=TextLayer)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
render_cache: RenderCache = None
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.at = self.at.with_offset(x, y)
|
||||
|
||||
|
||||
@sexp_type('gr_text_box')
|
||||
class TextBox(BBoxMixin):
|
||||
locked: Flag() = False
|
||||
text: str = ''
|
||||
start: Named(XYCoord) = None
|
||||
end: Named(XYCoord) = None
|
||||
margins: Margins = None
|
||||
pts: PointList = field(default_factory=list)
|
||||
angle: OmitDefault(Named(float)) = 0.0
|
||||
layer: Named(str) = ""
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
border: Named(YesNoAtom()) = False
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
render_cache: RenderCache = None
|
||||
|
||||
def render(self, variables={}):
|
||||
text = string.Template(self.text).safe_substitute(variables)
|
||||
if text != self.text:
|
||||
raise ValueError('Rendering of vector font text with variables not yet supported')
|
||||
|
||||
if not render_cache or not render_cache.polygons:
|
||||
raise ValueError('Vector font text with empty render cache')
|
||||
|
||||
for poly in render_cache.polygons:
|
||||
reg = go.Region([(p.x, -p.y) for p in poly.pts], unit=MM)
|
||||
|
||||
if self.stroke:
|
||||
if self.stroke.type not in (None, Atom.default, Atom.solid):
|
||||
raise ValueError('Dashed strokes are not supported on vector text')
|
||||
|
||||
yield from reg.outline_objects(aperture=ap.CircleAperture(self.stroke.width, unit=MM))
|
||||
|
||||
yield reg
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.start = self.start.with_offset(x, y)
|
||||
self.end = self.end.with_offset(x, y)
|
||||
|
||||
|
||||
@sexp_type('gr_line')
|
||||
class Line(WidthMixin):
|
||||
locked: Flag() = False
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
angle: Named(float) = None # wat
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def rotate(self, angle, cx=None, cy=None):
|
||||
self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
|
||||
self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy)
|
||||
|
||||
def render(self, variables=None):
|
||||
if self.angle:
|
||||
raise NotImplementedError('Angles on lines are not implemented. Please raise an issue and provide an example file.')
|
||||
|
||||
dasher = Dasher(self)
|
||||
dasher.move(self.start.x, self.start.y)
|
||||
dasher.line(self.end.x, self.end.y)
|
||||
|
||||
for x1, y1, x2, y2 in dasher:
|
||||
yield go.Line(x1, -y1, x2, -y2, aperture=ap.CircleAperture(dasher.width, unit=MM), unit=MM)
|
||||
# FIXME render all primitives using dasher, maybe share code w/ fp_ prefix primitives
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.start = self.start.with_offset(x, y)
|
||||
self.end = self.end.with_offset(x, y)
|
||||
|
||||
def bounding_box(self, unit=MM):
|
||||
x_min, x_max = min(self.start.x, self.end.x), max(self.start.x, self.end.x)
|
||||
y_min, y_max = min(self.start.y, self.end.y), max(self.start.y, self.end.y)
|
||||
w = self.stroke.width if self.stroke else self.width
|
||||
return (x_min-w, y_max-w), (x_max+w, y_max+w)
|
||||
|
||||
|
||||
@sexp_type('target')
|
||||
class Target(WidthMixin):
|
||||
shape: AtomChoice(Atom.x, Atom.plus) = 'plus'
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
size: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
width: Named(float) = None
|
||||
layer: Named(str) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None):
|
||||
raise NotImplementedError('Target objects are not implemented yet')
|
||||
|
||||
|
||||
@sexp_type('fill')
|
||||
class FillMode:
|
||||
# Needed for compatibility with weird files
|
||||
fill: AtomChoice(Atom.solid, Atom.yes, Atom.no, Atom.none) = False
|
||||
|
||||
@classmethod
|
||||
def __map__(kls, obj, parent=None, path=''):
|
||||
return obj[1] in (Atom.solid, Atom.yes)
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
yield [Atom.fill, Atom.solid if value else Atom.none]
|
||||
|
||||
@sexp_type('gr_rect')
|
||||
class Rectangle(BBoxMixin, WidthMixin):
|
||||
locked: Flag() = False
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: FillMode = False
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None):
|
||||
rect = go.Region.from_rectangle(self.start.x, -self.start.y,
|
||||
self.end.x-self.start.x, -(self.end.y-self.start.y),
|
||||
unit=MM)
|
||||
|
||||
if self.fill:
|
||||
yield rect
|
||||
|
||||
if (w := self.stroke.width if self.stroke else self.width):
|
||||
# FIXME stroke support
|
||||
yield from rect.outline_objects(aperture=ap.CircleAperture(w, unit=MM))
|
||||
|
||||
@property
|
||||
def top_left(self):
|
||||
return ((min(self.start.x, self.end.x), min(self.start.y, self.end.y)),
|
||||
(max(self.start.x, self.end.x), max(self.start.y, self.end.y)))
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.start = self.start.with_offset(x, y)
|
||||
self.end = self.end.with_offset(x, y)
|
||||
|
||||
|
||||
@sexp_type('gr_circle')
|
||||
class Circle(BBoxMixin, WidthMixin):
|
||||
locked: Flag() = False
|
||||
center: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: FillMode = False
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None):
|
||||
r = math.dist((self.center.x, -self.center.y), (self.end.x, -self.end.y))
|
||||
w = self.stroke.width if self.stroke else self.width
|
||||
aperture = ap.CircleAperture(w or 0, unit=MM)
|
||||
arc = go.Arc.from_circle(self.center.x, -self.center.y, r, aperture=aperture, unit=MM)
|
||||
|
||||
if w:
|
||||
# FIXME stroke support
|
||||
yield arc
|
||||
|
||||
if self.fill:
|
||||
yield arc.to_region()
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.center = self.center.with_offset(x, y)
|
||||
self.end = self.end.with_offset(x, y)
|
||||
|
||||
def rotate(self, angle, cx=0, cy=0):
|
||||
self.center = self.center.with_rotation(angle, cx, cy)
|
||||
self.end = self.end.with_rotation(angle, cx, cy)
|
||||
|
||||
|
||||
@sexp_type('gr_arc')
|
||||
class Arc(WidthMixin, BBoxMixin):
|
||||
locked: Flag() = False
|
||||
start: Rename(XYCoord) = None
|
||||
mid: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
angle: Named(float) = None
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
_: SEXP_END = None
|
||||
center: XYCoord = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.start = XYCoord(self.start)
|
||||
self.end = XYCoord(self.end)
|
||||
if self.mid or self.center is None:
|
||||
self.mid = XYCoord(self.mid)
|
||||
elif self.center:
|
||||
self.mid = center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
|
||||
self.center = None
|
||||
|
||||
def render(self, variables=None):
|
||||
if not (w := self.stroke.width if self.stroke else self.width):
|
||||
return
|
||||
|
||||
aperture = ap.CircleAperture(w, unit=MM)
|
||||
x1, y1 = self.start.x, self.start.y
|
||||
x2, y2 = self.end.x, self.end.y
|
||||
(cx, cy), _r, clockwise = kicad_mid_to_center_arc(self.mid, self.start, self.end)
|
||||
yield go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), aperture=aperture, clockwise=not clockwise, unit=MM)
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.start = self.start.with_offset(x, y)
|
||||
self.mid = self.mid.with_offset(x, y)
|
||||
self.end = self.end.with_offset(x, y)
|
||||
|
||||
def rotate(self, angle, cx=None, cy=None):
|
||||
self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
|
||||
self.mid.x, self.mid.y = rotate_point(self.mid.x, self.mid.y, angle, cx, cy)
|
||||
self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy)
|
||||
|
||||
|
||||
@sexp_type('gr_poly')
|
||||
class Polygon(BBoxMixin, WidthMixin):
|
||||
pts: ArcPointList = field(default_factory=list)
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: FillMode = True
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None):
|
||||
points = []
|
||||
centers = []
|
||||
for point_or_arc in self.pts:
|
||||
if points:
|
||||
centers.append((None, (None, None)))
|
||||
|
||||
if isinstance(point_or_arc, XYCoord):
|
||||
points.append((point_or_arc.x, -point_or_arc.y))
|
||||
|
||||
else: # base_types.Arc
|
||||
points.append((point_or_arc.start.x, -point_or_arc.start.y))
|
||||
points.append((point_or_arc.end.x, -point_or_arc.end.y))
|
||||
(cx, cy), _r, clockwise = kicad_mid_to_center_arc(point_or_arc.mid, point_or_arc.start, point_or_arc.end)
|
||||
centers.append((not clockwise, (cx, -cy)))
|
||||
|
||||
reg = go.Region(points, centers, unit=MM)
|
||||
reg.close()
|
||||
|
||||
w = self.stroke.width if self.stroke else self.width
|
||||
# FIXME stroke support
|
||||
if w and w >= 0.005:
|
||||
yield from reg.outline_objects(aperture=ap.CircleAperture(w, unit=MM))
|
||||
|
||||
if self.fill:
|
||||
yield reg
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.pts = [pt.with_offset(x, y) for pt in self.pts]
|
||||
|
||||
def rotate(self, angle, cx=0, cy=0):
|
||||
self.pts = [pt.with_rotation(angle, cx, cy) for pt in self.pts]
|
||||
|
||||
|
||||
@sexp_type('gr_curve')
|
||||
class Curve(BBoxMixin, WidthMixin):
|
||||
locked: Flag() = False
|
||||
pts: PointList = field(default_factory=list)
|
||||
layer: Named(str) = None
|
||||
width: Named(float) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
def render(self, variables=None):
|
||||
raise NotImplementedError('Bezier rendering is not yet supported. Please raise an issue and provide an example file.')
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.pts =[pt.with_offset(x, y) for pt in self.pts]
|
||||
|
||||
|
||||
@sexp_type('gr_bbox')
|
||||
class AnnotationBBox:
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
width: Named(float) = None
|
||||
fill: FillMode = False
|
||||
|
||||
def render(self, variables=None):
|
||||
return []
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.start = self.start.with_offset(x, y)
|
||||
self.end = self.end.with_offset(x, y)
|
||||
|
||||
|
||||
@sexp_type('format')
|
||||
class DimensionFormat:
|
||||
prefix: Named(str) = None
|
||||
suffix: Named(str) = None
|
||||
units: Named(int) = 2
|
||||
units_format: Named(int) = 1
|
||||
precision: Named(int) = 7
|
||||
override_value: Named(str) = None
|
||||
suppress_zeros: Flag() = False
|
||||
suppress_zeroes: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('style')
|
||||
class DimensionStyle:
|
||||
thickness: Named(float) = 0.1
|
||||
arrow_length: Named(float) = 1.27
|
||||
text_position_mode: Named(int) = 0
|
||||
arrow_direction: Named(AtomChoice(Atom.inward, Atom.outward)) = None
|
||||
extension_height: Named(float) = None
|
||||
text_frame: Named(float) = None
|
||||
extension_offset: Named(float) = None
|
||||
keep_text_aligned: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('data')
|
||||
class Base64Blob:
|
||||
@classmethod
|
||||
def __map__(kls, obj, parent=None, path=''):
|
||||
_data, *content = obj
|
||||
for x in content[:10]:
|
||||
print(str(x))
|
||||
return base64.b64decode(''.join(map(str, content)))
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
encoded = base64.b64encode(value).decode()
|
||||
yield [Atom.data, *textwrap.wrap(encoded, 76)]
|
||||
|
||||
|
||||
@sexp_type('image')
|
||||
class Image:
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
scale: Named(float) = None
|
||||
layer: Named(str) = None
|
||||
locked: Flag() = False
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
data: Base64Blob = ''
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.at = self.at.with_offset(x, y)
|
||||
|
||||
|
||||
@sexp_type('dimension')
|
||||
class Dimension:
|
||||
value: float = None
|
||||
locked: Flag() = False
|
||||
dimension_type: Named(AtomChoice(Atom.aligned, Atom.leader, Atom.center, Atom.orthogonal, Atom.radial), name='type') = Atom.aligned
|
||||
layer: Named(str) = 'Dwgs.User'
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = field(default_factory=Timestamp)
|
||||
pts: PointList = field(default_factory=list)
|
||||
height: Named(float) = None
|
||||
width: Named(float) = None
|
||||
orientation: Named(int) = None
|
||||
leader_length: Named(float) = None
|
||||
gr_text: Text = None
|
||||
dimension_format: OmitDefault(DimensionFormat) = field(default_factory=DimensionFormat)
|
||||
dimension_style: OmitDefault(DimensionStyle) = field(default_factory=DimensionStyle)
|
||||
|
||||
def render(self, variables=None):
|
||||
raise NotImplementedError('Dimension rendering is not yet supported. Please raise an issue.')
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.pts = [pt.with_offset(x, y) for pt in self.pts]
|
||||
|
||||
|
||||
@sexp_type('options')
|
||||
class PadStackLayerOptions:
|
||||
anchor: AtomChoice(Atom.rect, Atom.circle) = Atom.circle
|
||||
|
||||
|
||||
@sexp_type('primitives')
|
||||
class PadStackPrimitives:
|
||||
vectors: Rename(Line, name='gr_vector') = field(default_factory=list)
|
||||
lines: List(Line) = field(default_factory=list)
|
||||
bboxes: List(AnnotationBBox) = field(default_factory=list)
|
||||
arcs: List(Arc) = field(default_factory=list)
|
||||
circles: List(Circle) = field(default_factory=list)
|
||||
curves: List(Curve) = field(default_factory=list)
|
||||
polygons:List(Polygon) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('layer')
|
||||
class PadStackLayer:
|
||||
layer: str = ''
|
||||
shape: Named(AtomChoice(Atom.circle, Atom.rect, Atom.oval, Atom.trapezoid, Atom.roundrect, Atom.custom)) = Atom.circle
|
||||
size: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
rect_delta: Rename(XYCoord) = None
|
||||
offset: Rename(XYCoord) = None
|
||||
roundrect_rratio: Named(float) = None
|
||||
chamfer_ratio: Named(float) = None
|
||||
chamfer: Chamfer = None
|
||||
primitives: PadStackPrimitives = None
|
||||
options: PadStackLayerOptions = None
|
||||
thermal_bridge_angle: Named(float) = None
|
||||
thermal_gap: Named(float) = None
|
||||
thermal_bridge_width: Named(float) = None
|
||||
clearance: Named(float) = None
|
||||
zone_connect: Named(int) = None
|
||||
|
||||
|
||||
@sexp_type('padstack')
|
||||
class PadStack:
|
||||
mode: Named(AtomChoice('front_inner_back', 'custom')) = Atom.front_inner_back
|
||||
layers: List(PadStackLayer) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('teardrops')
|
||||
class TeardropSpec:
|
||||
best_length_ratio: Named(float) = 1.0
|
||||
max_length: Named(float) = 2.0
|
||||
best_width_ratio: Named(float) = 1.0
|
||||
max_width: Named(float) = 2.0
|
||||
curve_points: Named(int) = 0
|
||||
filter_ratio: Named(float) = 0.9
|
||||
enabled: Named(YesNoAtom()) = True
|
||||
allow_two_segments: Named(YesNoAtom()) = True
|
||||
prefer_zone_connections: Named(YesNoAtom()) = True
|
||||
|
||||
|
||||
70
src/gerbonara/cad/kicad/layer_colors.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
|
||||
# Maps KiCad layer IDs to (r, g, b, a) color tuples. R, G, B are ints in [0...255], a is a float in [0...1]
|
||||
KICAD_LAYER_COLORS = {
|
||||
'F.Cu': (200, 52, 52, 1),
|
||||
'In1.Cu': (127, 200, 127, 1),
|
||||
'In2.Cu': (206, 125, 44, 1),
|
||||
'In3.Cu': (79, 203, 203, 1),
|
||||
'In4.Cu': (219, 98, 139, 1),
|
||||
'In5.Cu': (167, 165, 198, 1),
|
||||
'In6.Cu': (40, 204, 217, 1),
|
||||
'In7.Cu': (232, 178, 167, 1),
|
||||
'In8.Cu': (242, 237, 161, 1),
|
||||
'In9.Cu': (141, 203, 129, 1),
|
||||
'In10.Cu': (237, 124, 51, 1),
|
||||
'In11.Cu': (91, 195, 235, 1),
|
||||
'In12.Cu': (247, 111, 142, 1),
|
||||
'In13.Cu': (167, 165, 198, 1),
|
||||
'In14.Cu': (40, 204, 217, 1),
|
||||
'In15.Cu': (232, 178, 167, 1),
|
||||
'In16.Cu': (242, 237, 161, 1),
|
||||
'In17.Cu': (237, 124, 51, 1),
|
||||
'In18.Cu': (91, 195, 235, 1),
|
||||
'In19.Cu': (247, 111, 142, 1),
|
||||
'In20.Cu': (167, 165, 198, 1),
|
||||
'In21.Cu': (40, 204, 217, 1),
|
||||
'In22.Cu': (232, 178, 167, 1),
|
||||
'In23.Cu': (242, 237, 161, 1),
|
||||
'In24.Cu': (237, 124, 51, 1),
|
||||
'In25.Cu': (91, 195, 235, 1),
|
||||
'In26.Cu': (247, 111, 142, 1),
|
||||
'In27.Cu': (167, 165, 198, 1),
|
||||
'In28.Cu': (40, 204, 217, 1),
|
||||
'In29.Cu': (232, 178, 167, 1),
|
||||
'In30.Cu': (242, 237, 161, 1),
|
||||
'B.Cu': (77, 127, 196, 1),
|
||||
'B.Adhes': (0, 0, 132, 1),
|
||||
'F.Adhes': (132, 0, 132, 1),
|
||||
'B.Paste': (0, 194, 194, 0.9),
|
||||
'F.Paste': (180, 160, 154, 0.9),
|
||||
'B.SilkS': (232, 178, 167, 1),
|
||||
'F.SilkS': (242, 237, 161, 1),
|
||||
'B.Mask': (2, 255, 238, 0.4),
|
||||
'F.Mask': (216, 100, 255, 0.4),
|
||||
'Dwgs.User': (194, 194, 194, 1),
|
||||
'Cmts.User': (89, 148, 220, 1),
|
||||
'Eco1.User': (180, 219, 210, 1),
|
||||
'Eco2.User': (216, 200, 82, 1),
|
||||
'Edge.Cuts': (208, 210, 205, 1),
|
||||
'Margin': (255, 38, 226, 1),
|
||||
'B.CrtYd': (38, 233, 255, 1),
|
||||
'F.CrtYd': (255, 38, 226, 1),
|
||||
'B.Fab': (88, 93, 132, 1),
|
||||
'F.Fab': (175, 175, 175, 1),
|
||||
'User.1': (194, 194, 194, 1),
|
||||
'User.2': (89, 148, 220, 1),
|
||||
'User.3': (180, 219, 210, 1),
|
||||
'User.4': (216, 200, 82, 1),
|
||||
'User.5': (194, 194, 194, 1),
|
||||
'User.6': (89, 148, 220, 1),
|
||||
'User.7': (180, 219, 210, 1),
|
||||
'User.8': (216, 200, 82, 1),
|
||||
'User.9': (232, 178, 167, 1),
|
||||
}
|
||||
|
||||
KICAD_DRILL_COLORS = {
|
||||
('drill', 'pth'): (194, 194, 0, 1),
|
||||
('drill', 'npth'): (26, 196, 210, 1),
|
||||
('drill', 'via'): (227, 183, 46, 1),
|
||||
}
|
||||
|
||||
848
src/gerbonara/cad/kicad/pcb.py
Normal file
|
|
@ -0,0 +1,848 @@
|
|||
"""
|
||||
Library for handling KiCad's PCB files (`*.kicad_mod`).
|
||||
"""
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from dataclasses import field, KW_ONLY, fields
|
||||
from itertools import chain
|
||||
import re
|
||||
import fnmatch
|
||||
import functools
|
||||
|
||||
from .sexp import *
|
||||
from .base_types import *
|
||||
from .primitives import *
|
||||
from .footprints import Footprint, Pad
|
||||
from . import graphical_primitives as gr
|
||||
import rtree.index
|
||||
|
||||
from .. import primitives as cad_pr
|
||||
|
||||
from ... import graphic_primitives as gp
|
||||
from ... import graphic_objects as go
|
||||
from ... import apertures as ap
|
||||
from ...layers import LayerStack
|
||||
from ...newstroke import Newstroke
|
||||
from ...utils import MM, rotate_point
|
||||
|
||||
|
||||
def match_filter(f, value):
|
||||
if isinstance(f, str) and re.fullmatch(f, value):
|
||||
return True
|
||||
return value in f
|
||||
|
||||
def gn_side_to_kicad(side, layer='Cu'):
|
||||
if side == 'top':
|
||||
return f'F.{layer}'
|
||||
elif side == 'bottom':
|
||||
return f'B.{layer}'
|
||||
elif side.startswith('inner'):
|
||||
return f'In{int(side[5:])}.{layer}'
|
||||
else:
|
||||
raise ValueError(f'Cannot parse gerbonara side name "{side}"')
|
||||
|
||||
def gn_layer_to_kicad(layer, flip=False):
|
||||
side = 'B' if flip else 'F'
|
||||
if layer == 'silk':
|
||||
return f'{side}.SilkS'
|
||||
elif layer == 'mask':
|
||||
return f'{side}.Mask'
|
||||
elif layer == 'paste':
|
||||
return f'{side}.Paste'
|
||||
elif layer == 'copper':
|
||||
return f'{side}.Cu'
|
||||
else:
|
||||
raise ValueError('Cannot translate gerbonara layer name "{layer}" to KiCad')
|
||||
|
||||
|
||||
@sexp_type('general')
|
||||
class GeneralSection:
|
||||
thickness: Named(float) = 1.60
|
||||
legacy_teardrops: Named(YesNoAtom()) = False
|
||||
drawings: Named(int) = None
|
||||
tracks: Named(int) = None
|
||||
zones: Named(int) = None
|
||||
modules: Named(int) = None
|
||||
nets: Named(int) = None
|
||||
links: Named(int) = None
|
||||
no_connects: Named(int) = None
|
||||
area: Named(Array(float)) = None
|
||||
|
||||
|
||||
@sexp_type('layers')
|
||||
class LayerSettings:
|
||||
index: int = 0
|
||||
canonical_name: str = None
|
||||
layer_type: AtomChoice(Atom.jumper, Atom.mixed, Atom.power, Atom.signal, Atom.user, Atom.auxiliary) = Atom.signal
|
||||
custom_name: str = None
|
||||
|
||||
|
||||
@sexp_type('layer')
|
||||
class LayerStackupSettings:
|
||||
dielectric: Flag() = False
|
||||
name: str = None
|
||||
index: int = None
|
||||
layer_type: Named(str, name='type') = ''
|
||||
color: Color = None
|
||||
thickness: Named(float) = None
|
||||
material: Named(str) = None
|
||||
epsilon_r: Named(float) = None
|
||||
loss_tangent: Named(float) = None
|
||||
|
||||
|
||||
@sexp_type('stackup')
|
||||
class StackupSettings:
|
||||
layers: List(LayerStackupSettings) = field(default_factory=list)
|
||||
copper_finish: Named(str) = None
|
||||
dielectric_constraints: Named(YesNoAtom()) = None
|
||||
edge_connector: Named(AtomChoice(Atom.yes, Atom.bevelled)) = None
|
||||
castellated_pads: Named(YesNoAtom()) = None
|
||||
edge_plating: Named(YesNoAtom()) = None
|
||||
|
||||
@sexp_type('setup')
|
||||
class BoardSetup:
|
||||
@classmethod
|
||||
def __map__(kls, obj, parent=None, path=''):
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
yield value
|
||||
|
||||
|
||||
@sexp_type('segment')
|
||||
class TrackSegment(BBoxMixin):
|
||||
start: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
end: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
width: Named(float) = 0.5
|
||||
locked: Flag() = False
|
||||
layer: Named(str) = 'F.Cu'
|
||||
extra_layers: Named(Array(str), name='layers') = field(default_factory=list)
|
||||
solder_mask_margin: Named(float) = None
|
||||
net: Named(int) = 0
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
@classmethod
|
||||
def from_footprint_line(kls, line, flip=False):
|
||||
# FIXME flip
|
||||
return kls(line.start, line.end, line.width or line.stroke.width, line.layer, line.locked, tstamp=line.tstamp)
|
||||
|
||||
def __post_init__(self):
|
||||
self.start = XYCoord(self.start)
|
||||
self.end = XYCoord(self.end)
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
if self.extra_layers:
|
||||
self.layer, *self.extra_layers = self.extra_layers
|
||||
|
||||
def __before_sexp__(self):
|
||||
if self.extra_layers:
|
||||
self.extra_layers.insert(0, self.layer)
|
||||
self.layer = None
|
||||
|
||||
@property
|
||||
def layer_mask(self):
|
||||
return layer_mask([self.layer])
|
||||
|
||||
def render(self, variables=None, cache=None):
|
||||
if not self.width:
|
||||
return
|
||||
|
||||
aperture = ap.CircleAperture(self.width, unit=MM)
|
||||
yield go.Line(self.start.x, -self.start.y, self.end.x, -self.end.y, aperture=aperture, unit=MM)
|
||||
|
||||
def rotate(self, angle, cx=None, cy=None):
|
||||
if cx is None or cy is None:
|
||||
cx, cy = self.start.x, self.start.y
|
||||
|
||||
self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
|
||||
self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy)
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.start = self.start.with_offset(x, y)
|
||||
self.end = self.end.with_offset(x, y)
|
||||
|
||||
|
||||
@sexp_type('arc')
|
||||
class TrackArc(BBoxMixin):
|
||||
start: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
mid: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
end: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
width: Named(float) = 0.5
|
||||
layer: Named(str) = 'F.Cu'
|
||||
locked: Flag() = False
|
||||
net: Named(int) = 0
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
_: SEXP_END = None
|
||||
center: XYCoord = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.start = XYCoord(self.start)
|
||||
self.end = XYCoord(self.end)
|
||||
self.mid = XYCoord(self.mid) if self.center is None else center_arc_to_kicad_mid(XYCoord(self.center), self.start, self.end)
|
||||
self.center = None
|
||||
|
||||
@property
|
||||
def layer_mask(self):
|
||||
return layer_mask([self.layer])
|
||||
|
||||
def render(self, variables=None, cache=None):
|
||||
if not self.width:
|
||||
return
|
||||
|
||||
aperture = ap.CircleAperture(self.width, unit=MM)
|
||||
cx, cy = self.mid.x, self.mid.y
|
||||
x1, y1 = self.start.x, self.start.y
|
||||
x2, y2 = self.end.x, self.end.y
|
||||
yield go.Arc(x1, -y1, x2, -y2, cx-x1, -(cy-y1), aperture=aperture, clockwise=True, unit=MM)
|
||||
|
||||
def rotate(self, angle, cx=None, cy=None):
|
||||
self.start.x, self.start.y = rotate_point(self.start.x, self.start.y, angle, cx, cy)
|
||||
self.mid.x, self.mid.y = rotate_point(self.mid.x, self.mid.y, angle, cx, cy)
|
||||
self.end.x, self.end.y = rotate_point(self.end.x, self.end.y, angle, cx, cy)
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.start = self.start.with_offset(x, y)
|
||||
self.mid = self.mid.with_offset(x, y)
|
||||
self.end = self.end.with_offset(x, y)
|
||||
|
||||
|
||||
@sexp_type('tenting')
|
||||
class Tenting:
|
||||
front: Flag() = False
|
||||
back: Flag() = False
|
||||
none: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('via')
|
||||
class Via(BBoxMixin):
|
||||
via_type: AtomChoice(Atom.blind, Atom.micro) = None
|
||||
locked: Flag() = False
|
||||
at: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
size: Named(float) = 0.8
|
||||
drill: Named(float) = 0.4
|
||||
layers: Named(Array(str)) = field(default_factory=lambda: ['F.Cu', 'B.Cu'])
|
||||
teardrops: gr.TeardropSpec = None
|
||||
tenting: Tenting = None
|
||||
padstack: gr.PadStack = None
|
||||
remove_unused_layers: Flag() = False
|
||||
keep_end_layers: Flag() = False
|
||||
free: Named(YesNoAtom()) = False
|
||||
zone_layer_connections: Named(Array(str)) = field(default_factory=list)
|
||||
net: Named(int) = 0
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
|
||||
@classmethod
|
||||
def from_pad(kls, pad):
|
||||
if pad.type != Atom.thru_hole or pad.shape != Atom.circle:
|
||||
raise ValueError('Can only convert circular through-hole pads to vias.')
|
||||
|
||||
if pad.drill and (pad.drill.oval or pad.drill.offset):
|
||||
raise ValueError('Can only convert pads with centered, circular drills to vias.')
|
||||
|
||||
x, y, rot, _flip = pad.abs_pos
|
||||
return kls(locked=pad.locked,
|
||||
at=XYCoord(x, y),
|
||||
size=max(pad.size.x, pad.size.y),
|
||||
drill=pad.drill.diameter if pad.drill else 0,
|
||||
layers=[l for l in pad.layers if l.endswith('.Cu')],
|
||||
free=True,
|
||||
net=pad.net.number if pad.net else 0,
|
||||
tstamp=pad.tstamp)
|
||||
|
||||
@property
|
||||
def abs_pos(self):
|
||||
return self.at.x, self.at.y, 0, False
|
||||
|
||||
@property
|
||||
def layer_mask(self):
|
||||
return layer_mask(self.layers)
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
return self.size
|
||||
|
||||
def __post_init__(self):
|
||||
self.at = XYCoord(self.at)
|
||||
|
||||
def render_drill(self):
|
||||
aperture = ap.ExcellonTool(self.drill, plated=True, unit=MM)
|
||||
yield go.Flash(self.at.x, -self.at.y, aperture=aperture, unit=MM)
|
||||
|
||||
def render(self, variables=None, cache=None):
|
||||
aperture = ap.CircleAperture(self.size, unit=MM)
|
||||
yield go.Flash(self.at.x, -self.at.y, aperture, unit=MM)
|
||||
|
||||
def rotate(self, angle, cx=None, cy=None):
|
||||
if cx is None or cy is None:
|
||||
return
|
||||
|
||||
self.at.x, self.at.y = rotate_point(self.at.x, self.at.y, angle, cx, cy)
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.at = self.at.with_offset(x, y)
|
||||
|
||||
|
||||
@sexp_type('net_class')
|
||||
class LegacyNetclass:
|
||||
name: str = ''
|
||||
description: str = ''
|
||||
clearance: Named(float) = None
|
||||
trace_width: Named(float) = None
|
||||
via_dia: Named(float) = None
|
||||
via_drill: Named(float) = None
|
||||
uvia_dia: Named(float) = None
|
||||
uvia_drill: Named(float) = None
|
||||
diff_pair_width: Named(float) = None
|
||||
diff_pair_gap: Named(float) = None
|
||||
nets: Rename(List(Named(str)), name='add_net') = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('generated')
|
||||
class GeneratedPatterns:
|
||||
type: Named(Atom) = ''
|
||||
name: Named(str) = ''
|
||||
layer: Named(str) = ''
|
||||
locked: Flag() = False
|
||||
members: Named(Array(Atom), name='members') = field(default_factory=list)
|
||||
_ : SEXP_END = None
|
||||
params: dict = field(default_factory=dict)
|
||||
|
||||
def __catchall__(self, sexp_value, path=''):
|
||||
key, value = sexp_value
|
||||
self.params[key] = value
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
return [kls.name_atom,
|
||||
['type', value.type],
|
||||
['name', value.name],
|
||||
['layer', value.layer],
|
||||
['locked', ('true' if value.locked else 'false')],
|
||||
*[[k, v] for k, v in value.params.items()],
|
||||
['members', *value.members]]
|
||||
|
||||
|
||||
|
||||
SUPPORTED_FILE_FORMAT_VERSIONS = [20200119, 20200512, 20210108, 20211014, 20220621, 20221018, 20230517, 20240706, 20240922, 20241229]
|
||||
@sexp_type('kicad_pcb')
|
||||
class Board:
|
||||
_version: Named(int, name='version') = 20230517
|
||||
generator: Named(str) = Atom.gerbonara
|
||||
generator_version: Named(str) = Atom.gerbonara
|
||||
legacy_generator: Named(Array(str), name='host') = None
|
||||
general: GeneralSection = None
|
||||
paper: PageSettings = None
|
||||
legacy_page: Rename(PageSettings, 'page') = None
|
||||
title_block: TitleBlock = None
|
||||
layers: Named(Array(Untagged(LayerSettings))) = field(default_factory=list)
|
||||
setup: BoardSetup = field(default_factory=BoardSetup)
|
||||
properties: List(Property) = field(default_factory=list)
|
||||
nets: List(Net) = field(default_factory=list)
|
||||
legacy_netclasses: List(LegacyNetclass) = field(default_factory=list)
|
||||
footprints: List(Footprint) = field(default_factory=list)
|
||||
legacy_footprints: Rename(List(Footprint), 'module') = field(default_factory=list)
|
||||
# Graphical elements
|
||||
texts: List(gr.Text) = field(default_factory=list)
|
||||
text_boxes: List(gr.TextBox) = field(default_factory=list)
|
||||
lines: List(gr.Line) = field(default_factory=list)
|
||||
targets: List(gr.Target) = field(default_factory=list)
|
||||
rectangles: List(gr.Rectangle) = field(default_factory=list)
|
||||
circles: List(gr.Circle) = field(default_factory=list)
|
||||
arcs: List(gr.Arc) = field(default_factory=list)
|
||||
polygons: List(gr.Polygon) = field(default_factory=list)
|
||||
curves: List(gr.Curve) = field(default_factory=list)
|
||||
dimensions: List(gr.Dimension) = field(default_factory=list)
|
||||
images: List(gr.Image) = field(default_factory=list)
|
||||
# Tracks
|
||||
track_segments: List(TrackSegment) = field(default_factory=list)
|
||||
track_arcs: List(TrackArc) = field(default_factory=list)
|
||||
vias: List(Via) = field(default_factory=list)
|
||||
# Other stuff
|
||||
zones: List(Zone) = field(default_factory=list)
|
||||
groups: List(Group) = field(default_factory=list)
|
||||
generated_patterns: List(GeneratedPatterns) = field(default_factory=list)
|
||||
embedded_fonts: Named(YesNoAtom()) = False
|
||||
|
||||
_ : SEXP_END = None
|
||||
original_filename: str = None
|
||||
_trace_index: rtree.index.Index = None
|
||||
_trace_index_map: dict = None
|
||||
|
||||
|
||||
@classmethod
|
||||
def empty_board(kls, inner_layers=0, **kwargs):
|
||||
if 'setup' not in kwargs:
|
||||
kwargs['setup'] = None
|
||||
b = Board(**kwargs)
|
||||
b.init_default_layers(inner_layers)
|
||||
b.__after_parse__(None)
|
||||
return b
|
||||
|
||||
|
||||
def init_default_layers(self, inner_layers=0):
|
||||
inner = [(i, f'In{i}.Cu', 'signal', None) for i in range(1, inner_layers+1)]
|
||||
self.layers = [LayerSettings(idx, name, Atom(ltype)) for idx, name, ltype, cname in [
|
||||
(0, 'F.Cu', 'signal', None),
|
||||
*inner,
|
||||
(31, 'B.Cu', 'signal', None),
|
||||
(32, 'B.Adhes', 'user', 'B.Adhesive'),
|
||||
(33, 'F.Adhes', 'user', 'F.Adhesive'),
|
||||
(34, 'B.Paste', 'user', None),
|
||||
(35, 'F.Paste', 'user', None),
|
||||
(36, 'B.SilkS', 'user', 'B.Silkscreen'),
|
||||
(37, 'F.SilkS', 'user', 'F.Silkscreen'),
|
||||
(38, 'B.Mask', 'user', None),
|
||||
(39, 'F.Mask', 'user', None),
|
||||
(40, 'Dwgs.User', 'user', 'User.Drawings'),
|
||||
(41, 'Cmts.User', 'user', 'User.Comments'),
|
||||
(42, 'Eco1.User', 'user', 'User.Eco1'),
|
||||
(43, 'Eco2.User', 'user', 'User.Eco2'),
|
||||
(44, 'Edge.Cuts', 'user', None),
|
||||
(45, 'Margin', 'user', None),
|
||||
(46, 'B.CrtYd', 'user', 'B.Courtyard'),
|
||||
(47, 'F.CrtYd', 'user', 'F.Courtyard'),
|
||||
(48, 'B.Fab', 'user', None),
|
||||
(49, 'F.Fab', 'user', None),
|
||||
(50, 'User.1', 'auxiliary', None),
|
||||
(51, 'User.2', 'auxiliary', None),
|
||||
(52, 'User.3', 'auxiliary', None),
|
||||
(53, 'User.4', 'auxiliary', None),
|
||||
(54, 'User.5', 'auxiliary', None),
|
||||
(55, 'User.6', 'auxiliary', None),
|
||||
(56, 'User.7', 'auxiliary', None),
|
||||
(57, 'User.8', 'auxiliary', None),
|
||||
(58, 'User.9', 'auxiliary', None)]]
|
||||
|
||||
|
||||
def rebuild_trace_index(self):
|
||||
idx = self._trace_index = rtree.index.Index()
|
||||
id_map = self._trace_index_map = {}
|
||||
for obj in chain(self.track_segments, self.track_arcs):
|
||||
for i, field in enumerate(('start', 'end')):
|
||||
obj_id = id(obj) + i
|
||||
coord = getattr(obj, field)
|
||||
id_map[obj_id] = obj, field, obj.width, obj.layer_mask
|
||||
idx.insert(obj_id, (coord.x, coord.y, coord.x, coord.y))
|
||||
|
||||
for fp in self.footprints:
|
||||
for pad in fp.pads:
|
||||
obj_id = id(pad)
|
||||
id_map[obj_id] = pad, 'at', 0, pad.layer_mask
|
||||
idx.insert(obj_id, (pad.at.x, pad.at.y, pad.at.x, pad.at.y))
|
||||
|
||||
for via in self.vias:
|
||||
obj_id = id(via)
|
||||
id_map[obj_id] = via, 'at', via.size, via.layer_mask
|
||||
idx.insert(obj_id, (via.at.x, via.at.y, via.at.x, via.at.y))
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _require_trace_index(fun):
|
||||
@functools.wraps(fun)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if self._trace_index is None:
|
||||
self.rebuild_trace_index()
|
||||
|
||||
return fun(self, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
@_require_trace_index
|
||||
def query_trace_index_nearest(self, point, layers='*.Cu', n=1):
|
||||
layers = layer_mask(layers)
|
||||
|
||||
x, y = point
|
||||
for obj_id in self._trace_index.nearest((x, y, x, y), n):
|
||||
entry = obj, attr, size, mask = self._trace_index_map[obj_id]
|
||||
if layers & mask:
|
||||
yield entry
|
||||
|
||||
|
||||
@_require_trace_index
|
||||
def query_trace_index_tolerance(self, point, layers='*.Cu', tol=10e-6):
|
||||
layers = layer_mask(layers)
|
||||
|
||||
x, y = point
|
||||
for obj_id in self._trace_index.intersection((x-tol, y-tol, x+tol, y+tol)):
|
||||
entry = obj, attr, size, mask = self._trace_index_map[obj_id]
|
||||
attr = getattr(obj, attr)
|
||||
if layers & mask and math.dist((attr.x, attr.y), (x, y)) <= tol:
|
||||
yield entry
|
||||
|
||||
|
||||
def find_connected_traces(self, obj, layers='*.Cu', tol=10e-6):
|
||||
search_frontier = []
|
||||
visited = set()
|
||||
def enqueue(obj):
|
||||
visited.add(id(obj))
|
||||
|
||||
if isinstance(obj, (TrackSegment, TrackArc)):
|
||||
search_frontier.append((obj.start, obj.width, obj.layer_mask))
|
||||
search_frontier.append((obj.end, obj.width, obj.layer_mask))
|
||||
|
||||
elif isinstance(obj, Via):
|
||||
search_frontier.append((obj.at, obj.size, obj.layer_mask))
|
||||
|
||||
elif isinstance(obj, Pad):
|
||||
search_frontier.append((obj.at, max(obj.size.x, obj.size.y), obj.layer_mask))
|
||||
|
||||
elif isinstance(obj, (Footprint)):
|
||||
for pad in obj.pads:
|
||||
search_frontier.append((pad.at, max(pad.size.x, pad.size.y), pad.layer_mask))
|
||||
|
||||
else:
|
||||
raise TypeError(f'Finding connected traces for {type(obj)} objects is not (yet) supported.')
|
||||
|
||||
enqueue(obj)
|
||||
yield obj
|
||||
|
||||
filter_layers = layer_mask(layers)
|
||||
while search_frontier:
|
||||
coord, size, layers = search_frontier.pop()
|
||||
x, y = coord.x, coord.y
|
||||
|
||||
# First, find all bounding box intersections
|
||||
found = []
|
||||
for cand, attr, cand_size, cand_mask in self.query_trace_index_tolerance((x, y), layers&filter_layers, size):
|
||||
cand_coord = getattr(cand, attr)
|
||||
dist = math.dist((x, y), (cand_coord.x, cand_coord.y))
|
||||
if dist <= size/2 + cand_size/2 and layers&cand_mask:
|
||||
found.append((dist, cand))
|
||||
|
||||
if not found:
|
||||
continue
|
||||
|
||||
# Second, filter to match only objects that are within tolerance of closest
|
||||
min_dist = min(e[0] for e in found)
|
||||
for dist, cand in found:
|
||||
if dist < min_dist+tol and id(cand) not in visited:
|
||||
enqueue(cand)
|
||||
yield cand
|
||||
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.properties = {prop.key: prop.value for prop in self.properties}
|
||||
|
||||
for fp in self.footprints:
|
||||
fp.board = self
|
||||
|
||||
self.nets = {net.index: net.name for net in self.nets}
|
||||
if self.legacy_page:
|
||||
self.paper, self.legacy_page = self.legacy_page, None
|
||||
|
||||
|
||||
def __before_sexp__(self):
|
||||
self.properties = [Property(key, value) for key, value in self.properties.items()]
|
||||
self.nets = [Net(index, name) for index, name in self.nets.items()]
|
||||
|
||||
|
||||
def remove(self, obj):
|
||||
match obj:
|
||||
case gr.Text():
|
||||
self.texts.remove(obj)
|
||||
case gr.TextBox():
|
||||
self.text_boxes.remove(obj)
|
||||
case gr.Line():
|
||||
self.lines.remove(obj)
|
||||
case gr.Rectangle():
|
||||
self.rectangles.remove(obj)
|
||||
case gr.Circle():
|
||||
self.circles.remove(obj)
|
||||
case gr.Arc():
|
||||
self.arcs.remove(obj)
|
||||
case gr.Polygon():
|
||||
self.polygons.remove(obj)
|
||||
case gr.Curve():
|
||||
self.curves.remove(obj)
|
||||
case gr.Dimension():
|
||||
self.dimensions.remove(obj)
|
||||
case gr.Image():
|
||||
self.images.remove(obj)
|
||||
case TrackSegment():
|
||||
self.track_segments.remove(obj)
|
||||
case TrackArc():
|
||||
self.track_arcs.remove(obj)
|
||||
case Via():
|
||||
self.vias.remove(obj)
|
||||
case Zone():
|
||||
self.zones.remove(obj)
|
||||
case Group():
|
||||
self.groups.remove(obj)
|
||||
case Footprint():
|
||||
self.footprints.remove(obj)
|
||||
case _:
|
||||
raise TypeError('Can only remove KiCad objects, cannot map generic gerbonara.cad objects for removal')
|
||||
|
||||
|
||||
def remove_many(self, iterable):
|
||||
iterable = {id(obj) for obj in iterable}
|
||||
for field in fields(self):
|
||||
if field.default_factory is list and field.name not in ('nets', 'properties'):
|
||||
setattr(self, field.name, [obj for obj in getattr(self, field.name) if id(obj) not in iterable])
|
||||
|
||||
|
||||
def add(self, obj):
|
||||
match obj:
|
||||
case gr.Text():
|
||||
self.texts.append(obj)
|
||||
case gr.TextBox():
|
||||
self.text_boxes.append(obj)
|
||||
case gr.Line():
|
||||
self.lines.append(obj)
|
||||
case gr.Rectangle():
|
||||
self.rectangles.append(obj)
|
||||
case gr.Circle():
|
||||
self.circles.append(obj)
|
||||
case gr.Arc():
|
||||
self.arcs.append(obj)
|
||||
case gr.Polygon():
|
||||
self.polygons.append(obj)
|
||||
case gr.Curve():
|
||||
self.curves.append(obj)
|
||||
case gr.Dimension():
|
||||
self.dimensions.append(obj)
|
||||
case gr.Image():
|
||||
self.images.append(obj)
|
||||
case TrackSegment():
|
||||
self.track_segments.append(obj)
|
||||
case TrackArc():
|
||||
self.track_arcs.append(obj)
|
||||
case Via():
|
||||
self.vias.append(obj)
|
||||
case Zone():
|
||||
self.zones.append(obj)
|
||||
case Group():
|
||||
self.groups.append(obj)
|
||||
case Footprint():
|
||||
self.footprints.append(obj)
|
||||
obj.board = self
|
||||
case _:
|
||||
for elem in self.map_gn_cad(obj):
|
||||
self.add(elem)
|
||||
|
||||
|
||||
def map_gn_cad(self, obj, locked=False, net_name=None):
|
||||
match obj:
|
||||
case cad_pr.Trace():
|
||||
for elem in obj.to_graphic_objects():
|
||||
elem.convert_to(MM)
|
||||
match elem:
|
||||
case go.Arc(x1, y1, x2, y2, xc, yc, cw, ap):
|
||||
yield TrackArc(
|
||||
start=XYCoord(x1, y1),
|
||||
mid=XYCoord(x1+xc, y1+yc),
|
||||
end=XYCoord(x2, y2),
|
||||
width=ap.equivalent_width(MM),
|
||||
layer=gn_side_to_kicad(obj.side),
|
||||
locked=locked,
|
||||
net=self.net_id(net_name))
|
||||
|
||||
case go.Line(x1, y1, x2, y2, ap):
|
||||
yield TrackSegment(
|
||||
start=XYCoord(x1, y1),
|
||||
end=XYCoord(x2, y2),
|
||||
width=ap.equivalent_width(MM),
|
||||
layer=gn_side_to_kicad(obj.side),
|
||||
locked=locked,
|
||||
net=self.net_id(net_name))
|
||||
|
||||
case cad_pr.Via(pad_stack=cad_pr.ThroughViaStack(hole, dia, unit=st_unit)):
|
||||
x, y, _a, _f = obj.abs_pos
|
||||
x, y = MM(x, st_unit), MM(y, obj.unit)
|
||||
yield Via(
|
||||
locked=locked,
|
||||
at=XYCoord(x, y),
|
||||
size=MM(dia, st_unit),
|
||||
drill=MM(hole, st_unit),
|
||||
layers='*.Cu',
|
||||
net=self.net_id(net_name))
|
||||
|
||||
case cad_pr.Text(_x, _y, text, font_size, stroke_width, h_align, v_align, layer, dark):
|
||||
x, y, a, flip = obj.abs_pos
|
||||
x, y = MM(x, st_unit), MM(y, st_unit)
|
||||
size = MM(size, unit)
|
||||
yield gr.Text(
|
||||
text,
|
||||
AtPos(x, y, -math.degrees(a)),
|
||||
layer=gr.TextLayer(gn_layer_to_kicad(layer, flip), not dark),
|
||||
effects=TextEffect(font=FontSpec(
|
||||
size=XYCoord(size, size),
|
||||
thickness=stroke_width),
|
||||
justify=Justify(h=Atom(h_align) if h_align != 'center' else None,
|
||||
v=Atom(v_align) if v_align != 'middle' else None,
|
||||
mirror=flip)))
|
||||
|
||||
|
||||
def unfill_zones(self):
|
||||
for zone in self.zones:
|
||||
zone.unfill()
|
||||
|
||||
|
||||
def find_pads(self, net=None):
|
||||
for fp in self.footprints:
|
||||
for pad in fp.pads:
|
||||
if net and not match_filter(net, pad.net.name):
|
||||
continue
|
||||
yield pad
|
||||
|
||||
|
||||
def find_footprints(self, value=None, reference=None, name=None, net=None, sheetname=None, sheetfile=None):
|
||||
for fp in self.footprints:
|
||||
if name and not match_filter(name, fp.name):
|
||||
continue
|
||||
if value and not match_filter(value, fp.value):
|
||||
continue
|
||||
if reference and not match_filter(reference, fp.reference):
|
||||
continue
|
||||
if net and not any(pad.net and match_filter(net, pad.net.name) for pad in fp.pads):
|
||||
continue
|
||||
if sheetname and not match_filter(sheetname, fp.sheetname):
|
||||
continue
|
||||
if sheetfile and not match_filter(sheetfile, fp.sheetfile):
|
||||
continue
|
||||
yield fp
|
||||
|
||||
|
||||
def find_traces(self, net=None, include_vias=True):
|
||||
net_id = self.net_id(net, create=False)
|
||||
match = lambda obj: obj.net == net_id
|
||||
for obj in chain(self.track_segments, self.track_arcs, self.vias):
|
||||
if obj.net == net_id:
|
||||
yield obj
|
||||
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return self._version
|
||||
|
||||
|
||||
@version.setter
|
||||
def version(self, value):
|
||||
if value not in SUPPORTED_FILE_FORMAT_VERSIONS:
|
||||
raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.')
|
||||
|
||||
|
||||
def write(self, filename=None):
|
||||
with open(filename or self.original_filename, 'w') as f:
|
||||
f.write(self.serialize())
|
||||
|
||||
|
||||
def serialize(self):
|
||||
return build_sexp(sexp(type(self), self)[0])
|
||||
|
||||
|
||||
@classmethod
|
||||
def open(kls, pcb_file, *args, **kwargs):
|
||||
return kls.load(Path(pcb_file).read_text(), *args, **kwargs, original_filename=pcb_file)
|
||||
|
||||
|
||||
@classmethod
|
||||
def load(kls, data, *args, **kwargs):
|
||||
return kls.parse(data, *args, **kwargs)
|
||||
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
def net_id(self, name, create=True):
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
for i, n in self.nets.items():
|
||||
if n == name:
|
||||
return i
|
||||
|
||||
if create:
|
||||
index = max(self.nets.keys()) + 1
|
||||
self.nets[index] = name
|
||||
return index
|
||||
|
||||
else:
|
||||
raise IndexError(f'No such net: "{name}"')
|
||||
|
||||
|
||||
# FIXME vvv
|
||||
def graphic_objects(self, text=False, images=False):
|
||||
return chain(
|
||||
(self.texts if text else []),
|
||||
(self.text_boxes if text else []),
|
||||
self.lines,
|
||||
self.rectangles,
|
||||
self.circles,
|
||||
self.arcs,
|
||||
self.polygons,
|
||||
self.curves,
|
||||
(self.dimensions if text else []),
|
||||
(self.images if images else []))
|
||||
|
||||
|
||||
def tracks(self, vias=True):
|
||||
return chain(self.track_segments, self.track_arcs, (self.vias if vias else []))
|
||||
|
||||
|
||||
def objects(self, vias=True, text=False, images=False):
|
||||
return chain(self.graphic_objects(text=text, images=images), self.tracks(vias=vias), self.footprints, self.zones, self.groups)
|
||||
|
||||
|
||||
def render(self, layer_stack, layer_map, x=0, y=0, rotation=0, text=False, flip=False, variables={}, cache=None):
|
||||
for obj in self.objects(images=False, vias=False, text=text):
|
||||
if not (layer := layer_map.get(obj.layer)):
|
||||
continue
|
||||
|
||||
for fe in obj.render(variables=variables):
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, -y, MM)
|
||||
layer_stack[layer].objects.append(fe)
|
||||
|
||||
for obj in self.vias:
|
||||
for glob in obj.layers or []:
|
||||
for layer in fnmatch.filter(layer_map, glob):
|
||||
for fe in obj.render(cache=cache):
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, -y, MM)
|
||||
fe.aperture = fe.aperture.rotated(rotation)
|
||||
layer_stack[layer_map[layer]].objects.append(fe)
|
||||
|
||||
for fe in obj.render_drill():
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, -y, MM)
|
||||
layer_stack.drill_pth.append(fe)
|
||||
|
||||
@dataclass
|
||||
class BoardInstance(cad_pr.Positioned):
|
||||
sexp: Board = None
|
||||
variables: dict = field(default_factory=lambda: {})
|
||||
|
||||
def render(self, layer_stack, cache=None):
|
||||
x, y, rotation, flip = self.abs_pos
|
||||
x, y = MM(x, self.unit), MM(y, self.unit)
|
||||
|
||||
variables = dict(self.variables)
|
||||
|
||||
layer_map = {kc_id: gn_id for kc_id, gn_id in LAYER_MAP_K2G.items() if gn_id in layer_stack}
|
||||
|
||||
self.sexp.render(layer_stack, layer_map,
|
||||
x=x, y=y, rotation=rotation,
|
||||
flip=flip,
|
||||
variables=variables, cache=cache)
|
||||
|
||||
def bounding_box(self, unit=MM):
|
||||
return offset_bounds(self.sexp.bounding_box(unit), unit(self.x, self.unit), unit(self.y, self.unit))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
from ...layers import LayerStack
|
||||
fp = Board.open(sys.argv[1])
|
||||
stack = LayerStack()
|
||||
BoardInstance(0, 0, fp, unit=MM).render(stack)
|
||||
print(stack.to_pretty_svg())
|
||||
stack.save_to_directory('/tmp/testdir')
|
||||
|
||||
267
src/gerbonara/cad/kicad/primitives.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
|
||||
import enum
|
||||
import math
|
||||
import re
|
||||
|
||||
from .sexp import *
|
||||
from .base_types import *
|
||||
|
||||
|
||||
def unfuck_layers(layers):
|
||||
if layers and layers[0] == 'F&B.Cu':
|
||||
return ['F.Cu', 'B.Cu', *layers[1:]]
|
||||
else:
|
||||
return layers
|
||||
|
||||
|
||||
def fuck_layers(layers):
|
||||
if layers and 'F.Cu' in layers and 'B.Cu' in layers and not any(re.match(r'^In[0-9]+\.Cu$', l) for l in layers):
|
||||
return ['F&B.Cu', *(l for l in layers if l not in ('F.Cu', 'B.Cu'))]
|
||||
else:
|
||||
return layers
|
||||
|
||||
|
||||
def layer_mask(layers):
|
||||
if isinstance(layers, int):
|
||||
return layers
|
||||
|
||||
if isinstance(layers, str):
|
||||
layers = [l.strip() for l in layers.split(',')]
|
||||
|
||||
mask = 0
|
||||
for layer in layers:
|
||||
match layer:
|
||||
case '*.Cu':
|
||||
return 0xffffffff
|
||||
case 'F.Cu':
|
||||
mask |= 1<<0
|
||||
case 'B.Cu':
|
||||
mask |= 1<<31
|
||||
case _:
|
||||
if (m := re.match(fr'In([0-9]+)\.Cu', layer)):
|
||||
mask |= 1<<int(m.group(1))
|
||||
return mask
|
||||
|
||||
|
||||
def center_arc_to_kicad_mid(center, start, end):
|
||||
# Convert normal p1/p2/center notation to the insanity that is kicad's midpoint notation
|
||||
cx, cy = center.x, center.y
|
||||
x1, y1 = start.x - cx, start.y - cy
|
||||
x2, y2 = end.x - cx, end.y - cy
|
||||
# Get a vector pointing from the center to the "mid" point.
|
||||
dx, dy = x1 - x2, y1 - y2 # Get a vector pointing from "end" to "start"
|
||||
dx, dy = -dy, dx # rotate by 90 degrees counter-clockwise
|
||||
# normalize vector, and multiply by radius to get final point
|
||||
r = math.hypot(x1, y1)
|
||||
l = math.hypot(dx, dy)
|
||||
mx = cx + dx / l * r
|
||||
my = cy + dy / l * r
|
||||
return XYCoord(mx, my)
|
||||
|
||||
|
||||
def kicad_mid_to_center_arc(mid, start, end):
|
||||
""" Convert kicad's slightly insane midpoint notation to standrad center/p1/p2 notation.
|
||||
|
||||
returns a ((center_x, center_y), radius, clockwise) tuple in KiCad coordinates.
|
||||
|
||||
Returns the center and radius of the circle passing the given 3 points.
|
||||
In case the 3 points form a line, raises a ValueError.
|
||||
"""
|
||||
# https://stackoverflow.com/questions/28910718/give-3-points-and-a-plot-circle
|
||||
p1, p2, p3 = start, mid, end
|
||||
|
||||
temp = p2[0] * p2[0] + p2[1] * p2[1]
|
||||
bc = (p1[0] * p1[0] + p1[1] * p1[1] - temp) / 2
|
||||
cd = (temp - p3[0] * p3[0] - p3[1] * p3[1]) / 2
|
||||
det = (p1[0] - p2[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p2[1])
|
||||
|
||||
if abs(det) < 1.0e-6:
|
||||
raise ValueError()
|
||||
|
||||
# Center of circle
|
||||
cx = (bc*(p2[1] - p3[1]) - cd*(p1[1] - p2[1])) / det
|
||||
cy = ((p1[0] - p2[0]) * cd - (p2[0] - p3[0]) * bc) / det
|
||||
|
||||
radius = math.sqrt((cx - p1[0])**2 + (cy - p1[1])**2)
|
||||
return (cx, cy), radius, det < 0
|
||||
|
||||
|
||||
@sexp_type('hatch')
|
||||
class Hatch:
|
||||
style: AtomChoice(Atom.none, Atom.edge, Atom.full) = Atom.edge
|
||||
pitch: float = 0.5
|
||||
|
||||
|
||||
@sexp_type('connect_pads')
|
||||
class PadConnection:
|
||||
type: AtomChoice(Atom.yes, Atom.thru_hole_only, Atom.full, Atom.no) = None
|
||||
clearance: Named(float) = 0
|
||||
|
||||
|
||||
@sexp_type('keepout')
|
||||
class ZoneKeepout:
|
||||
tracks_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='tracks') = True
|
||||
vias_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='vias') = True
|
||||
pads_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='pads') = True
|
||||
copperpour_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='copperpour') = True
|
||||
footprints_allowed: Named(YesNoAtom(yes=Atom.allowed, no=Atom.not_allowed), name='footprints') = True
|
||||
|
||||
|
||||
@sexp_type('smoothing')
|
||||
class ZoneSmoothing:
|
||||
style: AtomChoice(Atom.chamfer, Atom.fillet) = Atom.chamfer
|
||||
radius: Named(float) = None
|
||||
|
||||
|
||||
@sexp_type('fill')
|
||||
class ZoneFill:
|
||||
yes: Flag() = False
|
||||
mode: Named(Flag(atom=Atom.hatch)) = False
|
||||
thermal_gap: Named(float) = 0.508
|
||||
thermal_bridge_width: Named(float) = 0.508
|
||||
smoothing: ZoneSmoothing = None
|
||||
radius: Named(float) = 0.125
|
||||
island_removal_mode: Named(int) = None
|
||||
island_area_min: Named(float) = None
|
||||
hatch_thickness: Named(float) = None
|
||||
hatch_gap: Named(float) = None
|
||||
hatch_orientation: Named(int) = None
|
||||
hatch_smoothing_level: Named(int) = None
|
||||
hatch_smoothing_value: Named(float) = None
|
||||
hatch_border_algorithm: Named(AtomChoice(Atom.hatch_thickness, Atom.min_thickness)) = None
|
||||
hatch_min_hole_area: Named(float) = None
|
||||
|
||||
|
||||
@sexp_type('filled_polygon')
|
||||
class FillPolygon:
|
||||
layer: Named(str) = ""
|
||||
island: Wrap(Flag()) = False
|
||||
pts: ArcPointList = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('fill_segments')
|
||||
class FillSegment:
|
||||
layer: Named(str) = ""
|
||||
pts: ArcPointList = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('polygon')
|
||||
class ZonePolygon:
|
||||
pts: ArcPointList = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('placement')
|
||||
class ZonePlacement:
|
||||
enabled: Named(YesNoAtom()) = False
|
||||
sheetname: Named(str) = ''
|
||||
|
||||
|
||||
@sexp_type('teardrop')
|
||||
class ZoneTeardropSpec:
|
||||
type: Named(AtomChoice(Atom.padvia, Atom.track_end)) = Atom.padvia
|
||||
|
||||
|
||||
@sexp_type('attr')
|
||||
class ZoneAttr:
|
||||
teardrop: ZoneTeardropSpec = None
|
||||
|
||||
|
||||
@sexp_type('zone')
|
||||
class Zone:
|
||||
locked: Flag() = False
|
||||
net: Named(int) = 0
|
||||
net_name: Named(str) = ""
|
||||
layer: Named(str) = None
|
||||
layers: Named(Array(str)) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
tstamp: Timestamp = None
|
||||
name: Named(str) = None
|
||||
hatch: Hatch = None
|
||||
priority: OmitDefault(Named(int)) = 0
|
||||
attr: ZoneAttr = None
|
||||
connect_pads: PadConnection = field(default_factory=PadConnection)
|
||||
min_thickness: Named(float) = 0.254
|
||||
filled_areas_thickness: Named(YesNoAtom()) = True
|
||||
keepout: ZoneKeepout = None
|
||||
placement: ZonePlacement = None
|
||||
fill: ZoneFill = field(default_factory=ZoneFill)
|
||||
polygon: ZonePolygon = field(default_factory=ZonePolygon)
|
||||
fill_polygons: List(FillPolygon) = field(default_factory=list)
|
||||
fill_segments: List(FillSegment) = field(default_factory=list)
|
||||
|
||||
def __after_parse__(self, parent=None):
|
||||
self.layers = unfuck_layers(self.layers)
|
||||
|
||||
def __before_sexp__(self):
|
||||
self.layers = fuck_layers(self.layers)
|
||||
|
||||
def unfill(self):
|
||||
self.fill.yes = False
|
||||
self.fill_polygons = []
|
||||
self.fill_segments = []
|
||||
|
||||
def rotate(self, angle, cx=None, cy=None):
|
||||
self.unfill()
|
||||
self.polygon.pts = [pt.with_rotation(angle, cx, cy) for pt in self.polygon.pts]
|
||||
|
||||
def offset(self, x=0, y=0):
|
||||
self.unfill()
|
||||
self.polygon.pts = [pt.with_offset(x, y) for pt in self.polygon.pts]
|
||||
|
||||
|
||||
def bounding_box(self):
|
||||
min_x = min(pt.x for pt in self.polygon.pts)
|
||||
min_y = min(pt.y for pt in self.polygon.pts)
|
||||
max_x = max(pt.x for pt in self.polygon.pts)
|
||||
max_y = max(pt.y for pt in self.polygon.pts)
|
||||
return (min_x, min_y), (max_x, max_y)
|
||||
|
||||
|
||||
@sexp_type('polygon')
|
||||
class RenderCachePolygon:
|
||||
pts: PointList = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('render_cache')
|
||||
class RenderCache:
|
||||
text: str = None
|
||||
rotation: int = 0
|
||||
polygons: List(RenderCachePolygon) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('margins')
|
||||
class Margins:
|
||||
left: float = 0.0
|
||||
top: float = 0.0
|
||||
right: float = 0.0
|
||||
bottom: float = 0.0
|
||||
|
||||
|
||||
@sexp_type('comment')
|
||||
class TitleComment:
|
||||
@classmethod
|
||||
def __map__(kls, obj, parent=None, path=''):
|
||||
lines = []
|
||||
for lineno, content in zip(obj[1::2], obj[2::2]):
|
||||
while lineno > len(lines):
|
||||
lines.append('')
|
||||
lines[lineno-1] = content
|
||||
|
||||
@classmethod
|
||||
def __sexp__(kls, value):
|
||||
l = [Atom.comment]
|
||||
for i, line in enumerate(value.splitlines(), start=1):
|
||||
l.append(i)
|
||||
l.append(line.rstrip('\n'))
|
||||
return l
|
||||
|
||||
|
||||
@sexp_type('title_block')
|
||||
class TitleBlock:
|
||||
title: Named(str) = ''
|
||||
date: Named(str) = ''
|
||||
rev: Named(str) = ''
|
||||
company: Named(str) = ''
|
||||
comment: TitleComment = None
|
||||
|
||||
|
||||
867
src/gerbonara/cad/kicad/schematic.py
Normal file
|
|
@ -0,0 +1,867 @@
|
|||
"""
|
||||
Library for handling KiCad's schematic files (`*.kicad_sch`).
|
||||
"""
|
||||
|
||||
import math
|
||||
import string
|
||||
from pathlib import Path
|
||||
from dataclasses import field, KW_ONLY
|
||||
from itertools import chain
|
||||
import re
|
||||
import fnmatch
|
||||
import os.path
|
||||
import warnings
|
||||
|
||||
from .sexp import *
|
||||
from .base_types import *
|
||||
from .primitives import *
|
||||
from .symbols import Symbol
|
||||
from . import graphical_primitives as gr
|
||||
|
||||
from .. import primitives as cad_pr
|
||||
from ... import __version__
|
||||
|
||||
from ... import graphic_primitives as gp
|
||||
from ... import graphic_objects as go
|
||||
from ... import apertures as ap
|
||||
from ...layers import LayerStack
|
||||
from ...newstroke import Newstroke
|
||||
from ...utils import MM, rotate_point, Tag, setup_svg
|
||||
from .schematic_colors import *
|
||||
|
||||
|
||||
KICAD_PAPER_SIZES = {
|
||||
'A5': (210, 148),
|
||||
'A4': (297, 210),
|
||||
'A3': (420, 297),
|
||||
'A2': (594, 420),
|
||||
'A1': (841, 594),
|
||||
'A0': (1189, 841),
|
||||
'A': (11*25.4, 8.5*25.4),
|
||||
'B': (17*25.4, 11*15.4),
|
||||
'C': (22*25.4, 17*25.4),
|
||||
'D': (34*25.4, 22*25.4),
|
||||
'E': (44*25.4, 34*25.4),
|
||||
'USLetter': (11*25.4, 8.5*25.4),
|
||||
'USLegal': (14*25.4, 8.5*25.4),
|
||||
'USLedger': (17*25.4, 11*25.4),
|
||||
}
|
||||
|
||||
@sexp_type('path')
|
||||
class SheetPath:
|
||||
path: str = '/'
|
||||
page: Named(str) = '1'
|
||||
|
||||
|
||||
@sexp_type('junction')
|
||||
class Junction:
|
||||
at: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
diameter: Named(float) = 0
|
||||
color: Color = field(default_factory=lambda: Color(0, 0, 0, 0))
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
r = (self.diameter/2 or 0.5)
|
||||
return (self.at.x - r, self.at.y - r), (self.at.x + r, self.at.y + r)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield Tag('circle', cx=f'{self.at.x:.3f}', cy=f'{self.at.y:.3f}', r=(self.diameter/2 or 0.5),
|
||||
fill=self.color.svg(colorscheme.wire))
|
||||
|
||||
|
||||
@sexp_type('no_connect')
|
||||
class NoConnect:
|
||||
at: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
r = 0.635
|
||||
return (self.at.x - r, self.at.y - r), (self.at.x + r, self.at.y + r)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
r = 0.635
|
||||
x, y = self.at.x, self.at.y
|
||||
yield Tag('path', d=f'M {x-r:.3f} {y-r:.3f} L {x+r:.3f} {y+r:.3f} M {x-r:.3f} {y+r:.3f} L {x+r:.3f} {y-r:.3f}',
|
||||
fill='none', stroke_width='0.254', stroke=colorscheme.no_connect)
|
||||
|
||||
|
||||
@sexp_type('bus_alias')
|
||||
class BusAlias:
|
||||
name: str = ''
|
||||
members: Named(Array(str)) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('bus_entry')
|
||||
class BusEntry:
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(2.54, 2.54))
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
r = math.hypot(self.size.x, self.size.y)
|
||||
x1, y1 = self.at.x, self.at.y
|
||||
x2, y2 = rotate_point(x1+r, y1+r, self.at.rotation or 0)
|
||||
x1, x2 = min(x1, x2), max(x1, x2)
|
||||
y1, y2 = min(y1, y2), max(y1, y2)
|
||||
|
||||
r = (self.stroke.width or 0.254) / 2
|
||||
return (x1-r, y1-r), (x2+r, y2+r)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield Tag('path', d='M {self.at.x} {self.at.y} l {self.size.x} {self.size.y}',
|
||||
transform=f'rotate({self.at.rotation or 0})',
|
||||
fill='none', stroke=self.stroke.svg_color(colorscheme.bus), width=self.stroke.width or '0.254')
|
||||
|
||||
|
||||
def _polyline_svg(self, default_color):
|
||||
da = Dasher(self)
|
||||
if len(self.points.xy) < 2:
|
||||
warnings.warn(f'Schematic {type(self)} with less than two points')
|
||||
|
||||
p0, *rest = self.points.xy
|
||||
da.move(p0.x, p0.y)
|
||||
for pn in rest:
|
||||
da.line(pn.x, pn.y)
|
||||
|
||||
return da.svg(stroke=self.stroke.svg_color(default_color))
|
||||
|
||||
|
||||
def _polyline_bounds(self):
|
||||
x1 = min(pt.x for pt in self.points)
|
||||
y1 = min(pt.y for pt in self.points)
|
||||
x2 = max(pt.x for pt in self.points)
|
||||
y2 = max(pt.y for pt in self.points)
|
||||
|
||||
r = (self.stroke.width or 0.254) / 2
|
||||
return (x1-r, y1-r), (x2+r, y2+r)
|
||||
|
||||
|
||||
@sexp_type('wire')
|
||||
class Wire:
|
||||
points: PointList = field(default_factory=list)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
return _polyline_bounds(self)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield _polyline_svg(self, colorscheme.wire)
|
||||
|
||||
|
||||
@sexp_type('bus')
|
||||
class Bus:
|
||||
points: PointList = field(default_factory=list)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
return _polyline_bounds(self)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield _polyline_svg(self, colorscheme.bus)
|
||||
|
||||
|
||||
@sexp_type('polyline')
|
||||
class Polyline:
|
||||
points: PointList = field(default_factory=list)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: OmitDefault(Fill) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
return _polyline_bounds(self)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield _polyline_svg(self, colorscheme.lines)
|
||||
|
||||
|
||||
@sexp_type('circle')
|
||||
class Circle:
|
||||
center: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
radius: Named(float) = 0.0
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: OmitDefault(Fill) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
|
||||
@sexp_type('rectangle')
|
||||
class Rectangle:
|
||||
start: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
end: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: OmitDefault(Fill) = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
|
||||
def label_shape_path_d(shape, w, h):
|
||||
l, r = {
|
||||
Atom.input: '<]',
|
||||
Atom.output: '[>',
|
||||
Atom.bidirectional: '<>',
|
||||
Atom.tri_state: '<>',
|
||||
Atom.passive: '[]'}.get(shape, '<]')
|
||||
r = h/2
|
||||
|
||||
if l == '[':
|
||||
d = f'M {r:.3f} {r:.3f} L 0 {r:.3f} L 0 {-r:.3f} L {r:.3f} {-r:.3f}'
|
||||
else:
|
||||
d = f'M {r:.3f} {r:.3f} L 0 0 L {r:.3f} {-r:.3f}'
|
||||
|
||||
e = w+r
|
||||
d += f' L {e:.3f} {-r:.3f}'
|
||||
|
||||
if l == '[':
|
||||
return d + f'L {e+r:.3f} {-r:.3f} L {e+r:.3f} {r:.3f} L {e:.3f} {r:.3f} Z'
|
||||
else:
|
||||
return d + f'L {e+r:.3f} {0:.3f} L {e:.3f} {r:.3f} Z'
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextLabel(TextMixin):
|
||||
text: str = ''
|
||||
shape: Named(AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.dot, Atom.round, Atom.diamond, Atom.rectangle)) = Atom.passive
|
||||
exclude_from_sim: Named(YesNoAtom()) = False
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
fields_autoplaced: Named(YesNoAtom()) = False
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
properties: List(DrawnProperty) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('text')
|
||||
class Text(TextLabel):
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield from TextMixin.to_svg(self, colorscheme.text)
|
||||
|
||||
|
||||
@sexp_type('label')
|
||||
class LocalLabel(TextLabel):
|
||||
@property
|
||||
def _text_offset(self):
|
||||
return (0, -2*self.line_width)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield from TextMixin.to_svg(self, colorscheme.labels)
|
||||
|
||||
|
||||
@sexp_type('global_label')
|
||||
class GlobalLabel(TextLabel):
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
text = super(TextMixin, self).to_svg(colorscheme.labels),
|
||||
text.attrs['transform'] = f'translate({self.size*0.6:.3f} 0)'
|
||||
(x1, y1), (x2, y2) = self.bounding_box()
|
||||
frame = Tag('path', fill='none', stroke_width=0.254, stroke=colorscheme.lines,
|
||||
d=label_shape_path_d(self.shape, self.size*0.2 + y2-y1, self.size*1.2 + 0.254))
|
||||
yield Tag('g', children=[frame, text])
|
||||
|
||||
|
||||
@sexp_type('hierarchical_label')
|
||||
class HierarchicalLabel(TextLabel):
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
text, = TextMixin.to_svg(self, colorscheme.labels),
|
||||
text.attrs['transform'] = f'translate({self.size*1.2:.3f} 0)'
|
||||
frame = Tag('path', fill='none', stroke_width=0.254, stroke=colorscheme.lines,
|
||||
d=label_shape_path_d(self.shape, self.size, self.size))
|
||||
yield Tag('g', children=[frame, text])
|
||||
|
||||
|
||||
@sexp_type('netclass_flag')
|
||||
class NetclassFlag(TextLabel):
|
||||
length: Named(float) = 2.54
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
# FIXME
|
||||
yield from TextMixin.to_svg(self, colorscheme.text)
|
||||
|
||||
|
||||
@sexp_type('pin')
|
||||
class Pin:
|
||||
name: str = '1'
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
alternate: Named(str) = None
|
||||
|
||||
|
||||
# Suddenly, we're doing syntax like this is yaml or something.
|
||||
@sexp_type('path')
|
||||
class SymbolCrosslinkSheet:
|
||||
path: str = ''
|
||||
reference: Named(str) = ''
|
||||
unit: Named(int) = 1
|
||||
value: OmitDefault(Named(str)) = None
|
||||
footprint: OmitDefault(Named(str)) = None
|
||||
|
||||
|
||||
@sexp_type('project')
|
||||
class SymbolCrosslinkProject:
|
||||
project_name: str = ''
|
||||
instances: List(SymbolCrosslinkSheet) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('mirror')
|
||||
class MirrorFlags:
|
||||
x: Flag() = False
|
||||
y: Flag() = False
|
||||
|
||||
|
||||
@sexp_type('property')
|
||||
class DrawnProperty(TextMixin):
|
||||
key: str = None
|
||||
value: str = None
|
||||
id: Named(int) = None
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
hide: Flag() = False
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
_: SEXP_END = None
|
||||
parent: object = None
|
||||
|
||||
def __after_parse__(self, parent=None):
|
||||
self.parent = parent
|
||||
|
||||
# Alias value for text mixin
|
||||
@property
|
||||
def text(self):
|
||||
if self.key == 'Reference' and self.parent.unit > 0:
|
||||
return f'{self.value}{string.ascii_uppercase[self.parent.unit-1]}'
|
||||
else:
|
||||
return self.value
|
||||
|
||||
@text.setter
|
||||
def text(self, value):
|
||||
self.value = value
|
||||
|
||||
@property
|
||||
def default_v_align(self):
|
||||
return 'middle'
|
||||
|
||||
@property
|
||||
def h_align(self):
|
||||
align = self.effects.justify.h_str
|
||||
if self.rotation in (90, 270):
|
||||
align = {'left': 'right', 'right': 'left'}.get(align, align)
|
||||
return align
|
||||
|
||||
@property
|
||||
def rotation(self):
|
||||
rot = self.at.rotation
|
||||
rot += getattr(self.parent.at, 'rotation', 0)
|
||||
return rot%360
|
||||
|
||||
@property
|
||||
def mirrored(self):
|
||||
if hasattr(self.parent, 'mirror'):
|
||||
return self.parent.mirror.x, self.parent.mirror.y
|
||||
return False, False
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
if not self.hide:
|
||||
yield from TextMixin.to_svg(self, colorscheme.values)
|
||||
|
||||
|
||||
@sexp_type('default_instance')
|
||||
class DefaultSymbolInstance:
|
||||
reference: Named(str) = ''
|
||||
unit: Named(int) = 1
|
||||
value: Named(str) = ''
|
||||
footprint: Named(str) = ''
|
||||
|
||||
|
||||
@sexp_type('symbol')
|
||||
class SymbolInstance:
|
||||
name: str = None
|
||||
lib_name: Named(str) = ''
|
||||
lib_id: Named(str) = ''
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
mirror: OmitDefault(MirrorFlags) = field(default_factory=MirrorFlags)
|
||||
unit: Named(int) = 1
|
||||
exclude_from_sim: Named(YesNoAtom()) = False
|
||||
in_bom: Named(YesNoAtom()) = True
|
||||
on_board: Named(YesNoAtom()) = True
|
||||
dnp: Named(YesNoAtom()) = True
|
||||
fields_autoplaced: Named(YesNoAtom()) = True
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
default_instance: DefaultSymbolInstance = None
|
||||
properties: List(DrawnProperty) = field(default_factory=list)
|
||||
# AFAICT this property is completely redundant.
|
||||
pins: List(Pin) = field(default_factory=list)
|
||||
# AFAICT this property, too, is completely redundant. It ultimately just lists paths and references of at most
|
||||
# three other uses of the same symbol in this schematic.
|
||||
instances: Named(Array(SymbolCrosslinkProject)) = field(default_factory=list)
|
||||
_ : SEXP_END = None
|
||||
schematic: object = field(repr=False, default=None)
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.schematic = parent
|
||||
|
||||
@property
|
||||
def reference(self):
|
||||
return self['Reference'].value
|
||||
|
||||
@reference.setter
|
||||
def reference(self, value):
|
||||
self['Reference'].value = value
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self['Value'].value
|
||||
|
||||
@value.setter
|
||||
def value(self, value):
|
||||
self['Value'].value = value
|
||||
|
||||
@property
|
||||
def footprint(self):
|
||||
return self['Footprint'].value
|
||||
|
||||
@footprint.setter
|
||||
def footprint(self, value):
|
||||
self['Footprint'].value = value
|
||||
|
||||
def __getitem__(self, key):
|
||||
for prop in self.properties:
|
||||
if prop.key == key:
|
||||
return prop
|
||||
|
||||
@property
|
||||
def rotation(self):
|
||||
return self.at.rotation
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
children = []
|
||||
rot = self.at.rotation
|
||||
|
||||
sym = self.schematic.lookup_symbol(self.lib_name, self.lib_id)
|
||||
|
||||
units = [unit for unit in sym.units if unit.unit_global or unit.unit_index == self.unit]
|
||||
|
||||
at_xform = xform = f'translate({self.at.x:.3f} {self.at.y:.3f})'
|
||||
if self.mirror.y:
|
||||
xform += f'scale(-1 -1)'
|
||||
elif self.mirror.x:
|
||||
xform += f'scale(1 1)'
|
||||
else:
|
||||
xform += f'scale(1 -1)'
|
||||
if rot:
|
||||
xform += f'rotate({rot})'
|
||||
|
||||
children = [foo for unit in units for elem in unit.graphical_elements for foo in elem.to_svg(colorscheme)]
|
||||
yield Tag('g', children=children, transform=xform, fill=colorscheme.fill, stroke=colorscheme.lines)
|
||||
|
||||
children = [foo for unit in units for pin in unit.pins for foo in pin.to_svg(colorscheme, self.mirror, rot)]
|
||||
yield Tag('g', children=children, transform=at_xform, fill=colorscheme.fill, stroke=colorscheme.lines)
|
||||
|
||||
for prop in self.properties:
|
||||
yield from prop.to_svg(colorscheme)
|
||||
|
||||
|
||||
@sexp_type('path')
|
||||
class SubsheetCrosslinkSheet:
|
||||
path: str = ''
|
||||
page: Named(str) = ''
|
||||
|
||||
|
||||
@sexp_type('project')
|
||||
class SubsheetCrosslinkProject:
|
||||
project_name: str = ''
|
||||
instances: List(SymbolCrosslinkSheet) = field(default_factory=list)
|
||||
|
||||
|
||||
@sexp_type('pin')
|
||||
class SubsheetPin:
|
||||
name: str = '1'
|
||||
shape: AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive) = Atom.input
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
_ : SEXP_END = None
|
||||
subsheet: object = None
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.subsheet = parent
|
||||
|
||||
def to_svg(self):
|
||||
size = self.effects.font.size.y or 1.27
|
||||
yield Tag('path', fill='none', d=label_shape_path_d(self.shape, 0, size+0.5),
|
||||
transform=f'translate({self.at.x:.3f} {self.at.y:.3f}) rotate({180-self.at.rotation})')
|
||||
|
||||
lx, ly = self.at.x, self.at.y
|
||||
dx, dy = rotate_point(-(size+1), 0, math.radians(self.at.rotation))
|
||||
lx += dx
|
||||
ly += dy
|
||||
frot = self.at.rotation
|
||||
h_align = 'right'
|
||||
if frot == 180:
|
||||
frot = 0
|
||||
h_align = 'left'
|
||||
|
||||
font = Newstroke.load()
|
||||
yield font.render_svg(self.name,
|
||||
size=size,
|
||||
x0=0,
|
||||
y0=0,
|
||||
h_align=h_align,
|
||||
v_align='middle',
|
||||
rotation=-frot,
|
||||
transform=f'translate({lx:.3f} {ly:.3f})',
|
||||
scale=(1, 1),
|
||||
mirror=(False, False),
|
||||
)
|
||||
|
||||
@sexp_type('fill')
|
||||
class SubsheetFill:
|
||||
color: Color = field(default_factory=lambda: Color(0, 0, 0, 0))
|
||||
|
||||
|
||||
@sexp_type('sheet')
|
||||
class Subsheet:
|
||||
at: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
size: Rename(XYCoord) = field(default_factory=lambda: XYCoord(2.54, 2.54))
|
||||
exclude_from_sim: Named(YesNoAtom()) = False
|
||||
in_bom: Named(YesNoAtom()) = False
|
||||
on_board: Named(YesNoAtom()) = False
|
||||
dnp: Named(YesNoAtom()) = False
|
||||
fields_autoplaced: Named(YesNoAtom()) = True
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: SubsheetFill = field(default_factory=SubsheetFill)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
_properties: List(DrawnProperty) = field(default_factory=list)
|
||||
pins: List(SubsheetPin) = field(default_factory=list)
|
||||
# AFAICT this is completely redundant, just like the one in SymbolInstance
|
||||
instances: Named(List(SubsheetCrosslinkProject)) = field(default_factory=list)
|
||||
_ : SEXP_END = None
|
||||
sheet_name: object = field(default_factory=lambda: DrawnProperty('Sheetname', ''))
|
||||
file_name: object = field(default_factory=lambda: DrawnProperty('Sheetfile', ''))
|
||||
schematic: object = field(repr=False, default=None)
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.sheet_name, self.file_name, *_extra_params = self._properties
|
||||
self.schematic = parent
|
||||
|
||||
def __before_sexp__(self):
|
||||
self._properties = [self.sheet_name, self.file_name]
|
||||
|
||||
@property
|
||||
def rotation(self):
|
||||
return 0
|
||||
|
||||
def open(self, search_dir=None, safe=True):
|
||||
if search_dir is None:
|
||||
if not self.schematic.original_filename:
|
||||
raise FileNotFoundError('No search path given and path of parent schematic unknown')
|
||||
else:
|
||||
search_dir = Path(self.schematic.original_filename).parent
|
||||
else:
|
||||
search_dir = Path(search_dir)
|
||||
|
||||
resolved = search_dir / self.file_name.value
|
||||
if safe and os.path.commonprefix((search_dir.parts, resolved.parts)) != search_dir.parts:
|
||||
raise ValueError('Subsheet path traversal to parent directory attempted in Subsheet.open(..., safe=True)')
|
||||
|
||||
return Schematic.open(resolved)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
children = []
|
||||
|
||||
for prop in self._properties:
|
||||
yield from prop.to_svg(colorscheme)
|
||||
|
||||
yield Tag('rect', x=f'{self.at.x:.3f}', y=f'{self.at.y:.3f}',
|
||||
width=f'{self.size.x:.3f}', height=f'{self.size.y:.3f}',
|
||||
**self.stroke.svg_attrs(colorscheme.lines), fill=self.fill.color.svg(colorscheme.fill))
|
||||
|
||||
children = []
|
||||
for pin in self.pins:
|
||||
children += pin.to_svg()
|
||||
|
||||
#xform = f'translate({self.at.x:.3f} {self.at.y:.3f})'
|
||||
yield Tag('g', children=children, #transform=xform,
|
||||
fill=self.fill.color.svg(colorscheme.fill),
|
||||
**self.stroke.svg_attrs(colorscheme.lines))
|
||||
|
||||
|
||||
@sexp_type('rule_area')
|
||||
class RuleArea:
|
||||
polyline: Polyline = None
|
||||
|
||||
|
||||
@sexp_type('text_box')
|
||||
class TextBox(TextMixin):
|
||||
text: str = None
|
||||
exclude_from_sim: Named(YesNoAtom()) = False
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
size: Rename(XYCoord) = None
|
||||
margins: Rename(gr.Margins) = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: OmitDefault(Fill) = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
|
||||
def render(self, variables={}, cache=None):
|
||||
yield from gr.TextBox.render(self, variables=variables)
|
||||
|
||||
|
||||
@sexp_type('lib_symbols')
|
||||
class LocalLibrary:
|
||||
symbols: List(Symbol) = field(default_factory=list)
|
||||
|
||||
|
||||
SUPPORTED_FILE_FORMAT_VERSIONS = [20230620]
|
||||
@sexp_type('kicad_sch')
|
||||
class Schematic:
|
||||
_version: Named(int, name='version') = 20230620
|
||||
generator: Named(str) = 'gerbonara'
|
||||
generator_version: Named(str) = __version__
|
||||
legacy_generator: Named(Array(str), name='host') = None
|
||||
uuid: UUID = field(default_factory=UUID)
|
||||
page_settings: PageSettings = field(default_factory=PageSettings)
|
||||
legacy_page: Named(Array(int), name='page') = None
|
||||
legacy_paper: Named(str, name='paper') = None
|
||||
title_block: TitleBlock = None
|
||||
# The doc says this is expected, but eeschema barfs when it's there.
|
||||
# path: SheetPath = field(default_factory=SheetPath)
|
||||
lib_symbols: LocalLibrary = field(default_factory=list)
|
||||
junctions: List(Junction) = field(default_factory=list)
|
||||
no_connects: List(NoConnect) = field(default_factory=list)
|
||||
rule_areas: List(RuleArea) = field(default_factory=list)
|
||||
netclass_flags: List(NetclassFlag) = field(default_factory=list)
|
||||
bus_aliases: List(BusAlias) = field(default_factory=list)
|
||||
bus_entries: List(BusEntry) = field(default_factory=list)
|
||||
wires: List(Wire) = field(default_factory=list)
|
||||
buses: List(Bus) = field(default_factory=list)
|
||||
images: List(gr.Image) = field(default_factory=list)
|
||||
polylines: List(Polyline) = field(default_factory=list)
|
||||
circles: List(Circle) = field(default_factory=list)
|
||||
rectangles: List(Rectangle) = field(default_factory=list)
|
||||
texts: List(Text) = field(default_factory=list)
|
||||
text_boxes: List(TextBox) = field(default_factory=list)
|
||||
local_labels: List(LocalLabel) = field(default_factory=list)
|
||||
global_labels: List(GlobalLabel) = field(default_factory=list)
|
||||
hierarchical_labels: List(HierarchicalLabel) = field(default_factory=list)
|
||||
symbols: List(SymbolInstance) = field(default_factory=list)
|
||||
subsheets: List(Subsheet) = field(default_factory=list)
|
||||
sheet_instances: Named(Array(SubsheetCrosslinkSheet)) = field(default_factory=list)
|
||||
symbol_instances: Named(Array(SymbolCrosslinkProject)) = field(default_factory=list)
|
||||
embedded_fonts: Named(YesNoAtom()) = False
|
||||
_ : SEXP_END = None
|
||||
original_filename: str = None
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return self._version
|
||||
|
||||
@version.setter
|
||||
def version(self, value):
|
||||
if value not in SUPPORTED_FILE_FORMAT_VERSIONS:
|
||||
raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.')
|
||||
|
||||
|
||||
def lookup_symbol(self, lib_name, lib_id):
|
||||
key = lib_name or lib_id
|
||||
for sym in self.lib_symbols.symbols:
|
||||
if sym.name == key or sym.raw_name == key:
|
||||
return sym
|
||||
raise KeyError(f'Symbol with {lib_name=} {lib_id=} not found')
|
||||
|
||||
def write(self, filename=None):
|
||||
with open(filename or self.original_filename, 'w') as f:
|
||||
f.write(self.serialize())
|
||||
|
||||
def serialize(self):
|
||||
return build_sexp(sexp(type(self), self)[0])
|
||||
|
||||
@classmethod
|
||||
def open(kls, pcb_file, *args, **kwargs):
|
||||
return kls.load(Path(pcb_file).read_text(), *args, **kwargs, original_filename=pcb_file)
|
||||
|
||||
@classmethod
|
||||
def load(kls, data, *args, **kwargs):
|
||||
return kls.parse(data, *args, **kwargs)
|
||||
|
||||
@property
|
||||
def elements(self):
|
||||
yield from self.subsheets
|
||||
yield from self.images
|
||||
yield from self.polylines
|
||||
yield from self.symbols
|
||||
yield from self.junctions
|
||||
yield from self.no_connects
|
||||
yield from self.bus_entries
|
||||
yield from self.wires
|
||||
yield from self.buses
|
||||
yield from self.texts
|
||||
yield from self.local_labels
|
||||
yield from self.global_labels
|
||||
yield from self.hierarchical_labels
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
children = []
|
||||
for elem in self.elements:
|
||||
children += elem.to_svg(colorscheme)
|
||||
w, h = KICAD_PAPER_SIZES[self.page_settings.page_format]
|
||||
return setup_svg(children, ((0, 0), (w, h)), pagecolor=colorscheme.background)
|
||||
|
||||
|
||||
|
||||
# From: https://jakevdp.github.io/blog/2012/10/07/xkcd-style-plots-in-matplotlib/
|
||||
#def xkcd_line(x, y, xlim=None, ylim=None, mag=1.0, f1=30, f2=0.05, f3=15):
|
||||
def xkcd_line(x, y, xlim=None, ylim=None, mag=1.0, f1=10, f2=0.05, f3=5):
|
||||
"""
|
||||
Mimic a hand-drawn line from (x, y) data
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x, y : array_like
|
||||
arrays to be modified
|
||||
xlim, ylim : data range
|
||||
the assumed plot range for the modification. If not specified,
|
||||
they will be guessed from the data
|
||||
mag : float
|
||||
magnitude of distortions
|
||||
f1, f2, f3 : int, float, int
|
||||
filtering parameters. f1 gives the size of the window, f2 gives
|
||||
the high-frequency cutoff, f3 gives the size of the filter
|
||||
|
||||
Returns
|
||||
-------
|
||||
x, y : ndarrays
|
||||
The modified lines
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy import interpolate, signal
|
||||
|
||||
x = np.asarray(x)
|
||||
y = np.asarray(y)
|
||||
|
||||
# get limits for rescaling
|
||||
if xlim is None:
|
||||
xlim = (x.min(), x.max())
|
||||
if ylim is None:
|
||||
ylim = (y.min(), y.max())
|
||||
|
||||
if xlim[1] == xlim[0]:
|
||||
xlim = ylim
|
||||
|
||||
if ylim[1] == ylim[0]:
|
||||
ylim = xlim
|
||||
|
||||
# scale the data
|
||||
x_scaled = (x - xlim[0]) * 1. / (xlim[1] - xlim[0])
|
||||
y_scaled = (y - ylim[0]) * 1. / (ylim[1] - ylim[0])
|
||||
|
||||
# compute the total distance along the path
|
||||
dx = x_scaled[1:] - x_scaled[:-1]
|
||||
dy = y_scaled[1:] - y_scaled[:-1]
|
||||
dist_tot = np.sum(np.sqrt(dx * dx + dy * dy))
|
||||
|
||||
# number of interpolated points is proportional to the distance
|
||||
Nu = int(50 * dist_tot)
|
||||
u = np.arange(-1, Nu + 1) * 1. / (Nu - 1)
|
||||
|
||||
# interpolate curve at sampled points
|
||||
k = min(3, len(x) - 1)
|
||||
res = interpolate.splprep([x_scaled, y_scaled], s=0, k=k)
|
||||
x_int, y_int = interpolate.splev(u, res[0])
|
||||
|
||||
# we'll perturb perpendicular to the drawn line
|
||||
dx = x_int[2:] - x_int[:-2]
|
||||
dy = y_int[2:] - y_int[:-2]
|
||||
dist = np.sqrt(dx * dx + dy * dy)
|
||||
|
||||
# create a filtered perturbation
|
||||
coeffs = mag * np.random.normal(0, 0.01, len(x_int) - 2)
|
||||
b = signal.firwin(f1, f2 * dist_tot, window=('kaiser', f3))
|
||||
response = signal.lfilter(b, 1, coeffs)
|
||||
|
||||
x_int[1:-1] += response * dy / dist
|
||||
y_int[1:-1] += response * dx / dist
|
||||
|
||||
# un-scale data
|
||||
x_int = x_int[1:-1] * (xlim[1] - xlim[0]) + xlim[0]
|
||||
y_int = y_int[1:-1] * (ylim[1] - ylim[0]) + ylim[0]
|
||||
|
||||
return x_int, y_int
|
||||
|
||||
def wonkify(path):
|
||||
out = []
|
||||
for segment in path.attrs['d'].split('M')[1:]:
|
||||
if 'A' in segment:
|
||||
out.append(segment)
|
||||
continue
|
||||
|
||||
points = segment.split('L')
|
||||
if points[-1].rstrip().endswith('Z'):
|
||||
closed = True
|
||||
points[-1] = points[-1].rstrip()[:-1].rstrip()
|
||||
points.append(points[0])
|
||||
else:
|
||||
closed = False
|
||||
|
||||
pts = []
|
||||
lx, ly = None, None
|
||||
for pt in points:
|
||||
x, y = pt.strip().split()
|
||||
x, y = float(x), float(y)
|
||||
if (x, y) == (lx, ly):
|
||||
continue
|
||||
|
||||
lx, ly = x, y
|
||||
pts.append((x, y))
|
||||
|
||||
if len(pts) == 2:
|
||||
segs = [pts]
|
||||
|
||||
else:
|
||||
seg = [pts[0]]
|
||||
segs = [seg]
|
||||
for p0, p1, p2 in zip(pts[0::], pts[1::], pts[2::]):
|
||||
dx1, dy1 = p1[0] - p0[0], p1[1] - p0[1]
|
||||
dx2, dy2 = p2[0] - p1[0], p2[1] - p1[1]
|
||||
l1, l2 = math.hypot(dx1, dy1), math.hypot(dx2, dy2)
|
||||
a1, a2 = math.atan2(dy1, dx1), math.atan2(dy2, dx2)
|
||||
da = (a2 - a1 + math.pi) % (2*math.pi) - math.pi
|
||||
if abs(da) > math.pi/4 and l1+l2 > 3:
|
||||
seg.append(p1)
|
||||
seg = [p1, p2]
|
||||
segs.append(seg)
|
||||
seg.append(p1)
|
||||
seg.append(p2)
|
||||
|
||||
for seg in segs:
|
||||
xs, ys = [x for x, y in seg], [y for x, y in seg]
|
||||
xs, ys = xkcd_line(xs, ys)
|
||||
d = ' L '.join(f'{x:.3f} {y:.3f}' for x, y in zip(xs, ys))
|
||||
if closed:
|
||||
d += ' Z'
|
||||
out.append(d)
|
||||
|
||||
path.attrs['d'] = ' '.join(f'M {seg}' for seg in out)
|
||||
|
||||
|
||||
def postprocess(tag):
|
||||
if tag.name == 'path':
|
||||
wonkify(tag)
|
||||
else:
|
||||
for child in tag.children:
|
||||
postprocess(child)
|
||||
return tag
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
from ...layers import LayerStack
|
||||
from .tmtheme import *
|
||||
sch = Schematic.open(sys.argv[1])
|
||||
print('Loaded schematic with', len(sch.wires), 'wires and', len(sch.symbols), 'symbols.')
|
||||
for subsh in sch.subsheets:
|
||||
subsh = subsh.open()
|
||||
print('Loaded sub-sheet with', len(subsh.wires), 'wires and', len(subsh.symbols), 'symbols.')
|
||||
|
||||
sch.write('/tmp/test.kicad_sch')
|
||||
for p in Path('/tmp').glob('*.tmTheme'):
|
||||
cs = TmThemeSchematic(p.read_text())
|
||||
Path(f'/tmp/test-{p.stem}.svg').write_text(str(postprocess(sch.to_svg(cs))))
|
||||
for p in Path('/tmp').glob('*.sublime-color-scheme'):
|
||||
cs = SublimeSchematic(p.read_text())
|
||||
Path(f'/tmp/test-{p.stem}.svg').write_text(str(postprocess(sch.to_svg(cs))))
|
||||
|
||||
13
src/gerbonara/cad/kicad/schematic_colors.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
|
||||
class Colorscheme:
|
||||
class KiCad:
|
||||
wire = 'black'
|
||||
bus = 'black'
|
||||
lines = 'black'
|
||||
no_connect = 'black'
|
||||
text = 'black'
|
||||
values = 'black'
|
||||
labels = 'black'
|
||||
fill = '#cccccc'
|
||||
background = 'white'
|
||||
|
||||
152
src/gerbonara/cad/kicad/sexp.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import math
|
||||
import re
|
||||
import functools
|
||||
from typing import Any, Optional
|
||||
import uuid
|
||||
from dataclasses import dataclass, fields, field
|
||||
from copy import deepcopy
|
||||
|
||||
|
||||
class SexpError(ValueError):
|
||||
""" Low-level error parsing S-Expression format """
|
||||
pass
|
||||
|
||||
|
||||
class FormatError(ValueError):
|
||||
""" Semantic error in S-Expression structure """
|
||||
pass
|
||||
|
||||
|
||||
class AtomType(type):
|
||||
def __getattr__(cls, key):
|
||||
return cls(key)
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class Atom(metaclass=AtomType):
|
||||
def __init__(self, obj=''):
|
||||
if isinstance(obj, str):
|
||||
self.value = obj
|
||||
elif isinstance(obj, Atom):
|
||||
self.value = obj.value
|
||||
else:
|
||||
raise TypeError(f'Atom argument must be str, not {type(obj)}')
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
def __repr__(self):
|
||||
return f'@{self.value}'
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.value)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, (Atom, str)):
|
||||
return self.value == other
|
||||
return self.value == str(other)
|
||||
|
||||
def __lt__(self, other):
|
||||
if not isinstance(other, (Atom, str)):
|
||||
raise TypeError(f'Cannot compare Atom and {type(other)}')
|
||||
return self.value < str(other)
|
||||
|
||||
def __gt__(self, other):
|
||||
if not isinstance(other, (Atom, str)):
|
||||
raise TypeError(f'Cannot compare Atom and {type(other)}')
|
||||
return self.value > str(other)
|
||||
|
||||
|
||||
term_regex = r"""(?mx)
|
||||
\s*(?:
|
||||
"((?:\\\\|\\"|[^"])*)"|
|
||||
(\()|
|
||||
(\))|
|
||||
([+-]?\d+\.\d+(?=[\s\)]))|
|
||||
(\-?\d+(?=[\s\)]))|
|
||||
([^"\s()][^"\s)]*)
|
||||
)"""
|
||||
|
||||
|
||||
def parse_sexp(sexp: str) -> Any:
|
||||
re_iter = re.finditer(term_regex, sexp)
|
||||
rv = list(_parse_sexp_internal(re_iter))
|
||||
|
||||
for leftover in re_iter:
|
||||
quoted_str, lparen, rparen, *rest = leftover.groups()
|
||||
if quoted_str or lparen or any(rest):
|
||||
raise SexpError(f'Leftover garbage after end of expression at position {leftover.start()}') # noqa: E501
|
||||
|
||||
elif rparen:
|
||||
raise SexpError(f'Unbalanced closing parenthesis at position {leftover.start()}')
|
||||
|
||||
if len(rv) == 0:
|
||||
raise SexpError('No or empty expression')
|
||||
|
||||
if len(rv) > 1:
|
||||
print(rv[0])
|
||||
print(rv[1])
|
||||
raise SexpError('Missing initial opening parenthesis')
|
||||
|
||||
return rv[0]
|
||||
|
||||
|
||||
def _parse_sexp_internal(re_iter) -> Any:
|
||||
for match in re_iter:
|
||||
quoted_str, lparen, rparen, float_num, integer_num, bare_str = match.groups()
|
||||
|
||||
if lparen:
|
||||
yield list(_parse_sexp_internal(re_iter))
|
||||
elif rparen:
|
||||
break
|
||||
elif bare_str is not None:
|
||||
yield Atom(bare_str)
|
||||
elif quoted_str is not None:
|
||||
yield quoted_str.replace('\\"', '"')
|
||||
elif float_num:
|
||||
yield float(float_num)
|
||||
elif integer_num:
|
||||
yield int(integer_num)
|
||||
|
||||
|
||||
def build_sexp(exp, indent=' ') -> str:
|
||||
# Special case for multi-values
|
||||
if isinstance(exp, (list, tuple)):
|
||||
joined = '('
|
||||
for i, elem in enumerate(exp):
|
||||
if 1 <= i <= 5 and len(joined) < 120 and not isinstance(elem, (list, tuple)):
|
||||
joined += ' '
|
||||
elif i >= 1:
|
||||
joined += '\n' + indent
|
||||
joined += build_sexp(elem, indent=f'{indent} ')
|
||||
return joined + ')'
|
||||
|
||||
if exp == '':
|
||||
return '""'
|
||||
|
||||
if isinstance(exp, str):
|
||||
exp = exp.replace('"', r'\"')
|
||||
return f'"{exp}"'
|
||||
|
||||
if isinstance(exp, float):
|
||||
# python whyyyy
|
||||
val = f'{exp:.6f}'
|
||||
val = val.rstrip('0')
|
||||
if val[-1] == '.':
|
||||
val += '0'
|
||||
return val
|
||||
else:
|
||||
return str(exp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sexp = """ ( ( Winson_GM-402B_5x5mm_P1.27mm data "quoted data" 123 4.5)
|
||||
(data "with \\"escaped quotes\\"")
|
||||
(data (123 (4.5) "(more" "data)")))"""
|
||||
|
||||
print("Input S-expression:")
|
||||
print(sexp)
|
||||
parsed = parse_sexp(sexp)
|
||||
print("\nParsed to Python:", parsed)
|
||||
|
||||
print("\nThen back to: '%s'" % build_sexp(parsed))
|
||||
404
src/gerbonara/cad/kicad/sexp_mapper.py
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
|
||||
import textwrap
|
||||
|
||||
import copy
|
||||
from dataclasses import MISSING, replace, fields
|
||||
from .sexp import *
|
||||
|
||||
|
||||
SEXP_END = type('SEXP_END', (), {})
|
||||
|
||||
|
||||
class AtomChoice:
|
||||
def __init__(self, *choices):
|
||||
self.choices = choices
|
||||
|
||||
def __contains__(self, value):
|
||||
return value in self.choices
|
||||
|
||||
def __atoms__(self):
|
||||
return self.choices
|
||||
|
||||
def __map__(self, obj, parent=None):
|
||||
obj, = obj
|
||||
if obj not in self:
|
||||
raise TypeError(f'Invalid atom {obj} for {type(self)}, valid choices are: {", ".join(map(str, self.choices))}')
|
||||
return obj
|
||||
|
||||
def __sexp__(self, value):
|
||||
yield value
|
||||
|
||||
def __str__(self):
|
||||
choices = '|'.join(map(str, self.choices))
|
||||
return f'AtomChoice({choices})'
|
||||
|
||||
|
||||
class Flag:
|
||||
def __init__(self, atom=None, invert=None):
|
||||
self.atom, self.invert = atom, invert
|
||||
|
||||
def __bind_field__(self, field):
|
||||
if self.atom is None:
|
||||
self.atom = Atom(field.name)
|
||||
if self.invert is None:
|
||||
self.invert = bool(field.default)
|
||||
|
||||
def __atoms__(self):
|
||||
return [self.atom]
|
||||
|
||||
def __map__(self, obj, parent=None):
|
||||
return not self.invert
|
||||
|
||||
def __sexp__(self, value):
|
||||
if bool(value) == (not self.invert):
|
||||
yield self.atom
|
||||
|
||||
def __str__(self):
|
||||
if self.invert is not None:
|
||||
return f'Flag({self.atom}/{self.invert})'
|
||||
return f'Flag({self.atom})'
|
||||
|
||||
|
||||
def sexp(t, v):
|
||||
try:
|
||||
if v is None:
|
||||
return []
|
||||
elif t in (int, float, str, Atom):
|
||||
return [t(v)]
|
||||
elif hasattr(t, '__sexp__'):
|
||||
return list(t.__sexp__(v))
|
||||
elif isinstance(t, list):
|
||||
t, = t
|
||||
return [sexp(t, elem) for elem in v]
|
||||
else:
|
||||
raise TypeError(f'Python type {t} of value {v!r} has no defined s-expression serialization')
|
||||
|
||||
except MappingError as e:
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
raise MappingError(f'Error trying to serialize {textwrap.shorten(str(v), width=120)} into type {t}', t, v) from e
|
||||
|
||||
|
||||
class MappingError(TypeError):
|
||||
def __init__(self, msg, t, sexp):
|
||||
super().__init__(msg)
|
||||
self.t, self.sexp = t, sexp
|
||||
|
||||
def map_sexp(t, v, parent=None, path=''):
|
||||
try:
|
||||
if t is not Atom and hasattr(t, '__map__'):
|
||||
return t.__map__(v, parent=parent)
|
||||
|
||||
elif t in (int, float, str, Atom):
|
||||
v, = v
|
||||
if not isinstance(v, t):
|
||||
types = set({type(v), t})
|
||||
if types == {int, float} or types == {str, Atom}:
|
||||
v = t(v)
|
||||
else:
|
||||
raise TypeError(f'Cannot map s-expression value {v} of type {type(v)} to Python type {t}')
|
||||
return v
|
||||
|
||||
elif isinstance(t, list):
|
||||
t, = t
|
||||
return [map_sexp(t, elem, parent=parent, path=f'{path}/{t}') for elem in v]
|
||||
|
||||
else:
|
||||
raise TypeError(f'Python type {t} has no defined s-expression deserialization')
|
||||
|
||||
except MappingError as e:
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
raise MappingError(f'Error at {path} trying to map {textwrap.shorten(str(v), width=60)} into type {t}', t, v) from e
|
||||
|
||||
|
||||
class WrapperType:
|
||||
def __init__(self, next_type):
|
||||
self.next_type = next_type
|
||||
|
||||
def __bind_field__(self, field):
|
||||
self.field = field
|
||||
if self.next_type is not Atom:
|
||||
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
|
||||
|
||||
def __atoms__(self):
|
||||
if hasattr(self, 'name_atom'):
|
||||
return [self.name_atom]
|
||||
elif self.next_type is Atom:
|
||||
return []
|
||||
else:
|
||||
return getattr(self.next_type, '__atoms__', lambda: [])()
|
||||
|
||||
class Named(WrapperType):
|
||||
def __init__(self, next_type, name=None, omit_empty=True):
|
||||
super().__init__(next_type)
|
||||
self.name_atom = Atom(name) if name else None
|
||||
self.omit_empty = omit_empty
|
||||
|
||||
def __bind_field__(self, field):
|
||||
if self.next_type is not Atom:
|
||||
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
|
||||
if self.name_atom is None:
|
||||
self.name_atom = Atom(field.name)
|
||||
|
||||
def __map__(self, obj, parent=None, path=''):
|
||||
k, *obj = obj
|
||||
if self.next_type in (int, float, str, Atom) or isinstance(self.next_type, AtomChoice):
|
||||
return map_sexp(self.next_type, [*obj], parent=parent, path=f'{path}/{self.name_atom}')
|
||||
else:
|
||||
return map_sexp(self.next_type, obj, parent=parent, path=f'{path}/{self.name_atom}')
|
||||
|
||||
def __sexp__(self, value):
|
||||
value = sexp(self.next_type, value)
|
||||
if value is None:
|
||||
return
|
||||
|
||||
if self.omit_empty and not value:
|
||||
return
|
||||
|
||||
yield [self.name_atom, *value]
|
||||
|
||||
def __str__(self):
|
||||
return f'Named={self.name_atom}({self.next_type})'
|
||||
|
||||
|
||||
class Rename(WrapperType):
|
||||
def __init__(self, next_type, name=None):
|
||||
super().__init__(next_type)
|
||||
self.name_atom = Atom(name) if name else None
|
||||
|
||||
def __bind_field__(self, field):
|
||||
if self.name_atom is None:
|
||||
self.name_atom = Atom(field.name)
|
||||
if hasattr(self.next_type, '__bind_field__'):
|
||||
self.next_type.__bind_field__(field)
|
||||
|
||||
def __map__(self, obj, parent=None, path=''):
|
||||
return map_sexp(self.next_type, obj, parent=parent, path=f'{path}/{self.name_atom}')
|
||||
|
||||
def __sexp__(self, value):
|
||||
value, = sexp(self.next_type, value)
|
||||
if self.next_type in (str, float, int, Atom):
|
||||
yield [self.name_atom, *value]
|
||||
else:
|
||||
key, *rest = value
|
||||
yield [self.name_atom, *rest]
|
||||
|
||||
def __str__(self):
|
||||
return f'Rename={self.name_atom}({self.next_type})'
|
||||
|
||||
|
||||
class OmitDefault(WrapperType):
|
||||
def __bind_field__(self, field):
|
||||
getattr(self.next_type, '__bind_field__', lambda x: None)(field)
|
||||
if field.default_factory != MISSING:
|
||||
self.default = field.default_factory()
|
||||
else:
|
||||
self.default = field.default
|
||||
|
||||
def __map__(self, obj, parent=None, path=''):
|
||||
return map_sexp(self.next_type, obj, parent=parent, path=path)
|
||||
|
||||
def __sexp__(self, value):
|
||||
if value != self.default:
|
||||
yield from sexp(self.next_type, value)
|
||||
|
||||
def __str__(self):
|
||||
return f'OmitDefault({self.field})'
|
||||
|
||||
|
||||
class YesNoAtom:
|
||||
def __init__(self, yes=Atom.yes, no=Atom.no):
|
||||
self.yes, self.no = yes, no
|
||||
|
||||
def __map__(self, value, parent=None):
|
||||
if not value: # compatibility with legacy flag style
|
||||
return False
|
||||
value, = value
|
||||
return value == self.yes
|
||||
|
||||
def __sexp__(self, value):
|
||||
yield self.yes if value else self.no
|
||||
|
||||
|
||||
class LegacyCompatibleFlag:
|
||||
'''Variant of YesNoAtom that accepts both the `(flag <yes/no>)` variant and the bare `flag` variant for compatibility.'''
|
||||
|
||||
def __init__(self, yes=Atom.yes, no=Atom.no, value_when_empty=True):
|
||||
self.yes, self.no = yes, no
|
||||
self.value_when_empty = value_when_empty
|
||||
|
||||
def __map__(self, value, parent=None):
|
||||
if value == []:
|
||||
return self.value_when_empty
|
||||
|
||||
value, = value
|
||||
return value == self.yes
|
||||
|
||||
def __sexp__(self, value):
|
||||
yield self.yes if value else self.no
|
||||
|
||||
|
||||
class Wrap(WrapperType):
|
||||
def __map__(self, value, parent=None, path=''):
|
||||
value, = value
|
||||
return map_sexp(self.next_type, value, parent=parent, path=path)
|
||||
|
||||
def __sexp__(self, value):
|
||||
for inner in sexp(self.next_type, value):
|
||||
yield [inner]
|
||||
|
||||
def __str__(self):
|
||||
return f'Wrap({self.next_type})'
|
||||
|
||||
|
||||
class Array(WrapperType):
|
||||
def __map__(self, value, parent=None, path=''):
|
||||
return [map_sexp(self.next_type, [elem], parent=parent, path=path) for elem in value]
|
||||
|
||||
def __sexp__(self, value):
|
||||
for e in value:
|
||||
yield from sexp(self.next_type, e)
|
||||
|
||||
def __str__(self):
|
||||
return f'Array({self.next_type})'
|
||||
|
||||
|
||||
class Untagged(WrapperType):
|
||||
def __map__(self, value, parent=None, path=''):
|
||||
value, = value
|
||||
return self.next_type.__map__([self.next_type.name_atom, *value], parent=parent, path=path)
|
||||
|
||||
def __sexp__(self, value):
|
||||
for inner in sexp(self.next_type, value):
|
||||
_tag, *rest = inner
|
||||
yield rest
|
||||
|
||||
def __str__(self):
|
||||
return f'Untagged({self.next_type})'
|
||||
|
||||
class List(WrapperType):
|
||||
def __bind_field__(self, field):
|
||||
self.attr = field.name
|
||||
|
||||
def __map__(self, value, parent, path=''):
|
||||
l = getattr(parent, self.attr, [])
|
||||
mapped = map_sexp(self.next_type, value, parent=parent, path=f'{path}/{self.attr}')
|
||||
l.append(mapped)
|
||||
setattr(parent, self.attr, l)
|
||||
|
||||
def __sexp__(self, value):
|
||||
for elem in value:
|
||||
yield from sexp(self.next_type, elem)
|
||||
|
||||
def __str__(self):
|
||||
return f'List@{self.attr}({self.next_type})'
|
||||
|
||||
|
||||
class _SexpTemplate:
|
||||
@staticmethod
|
||||
def __atoms__(kls):
|
||||
return [kls.name_atom]
|
||||
|
||||
@staticmethod
|
||||
def __map__(kls, value, *args, parent=None, path='', **kwargs):
|
||||
positional = iter(kls.positional)
|
||||
inst = kls(*args, **kwargs)
|
||||
|
||||
for v in value[1:]: # skip key
|
||||
if isinstance(v, Atom) and v in kls.keys:
|
||||
name, etype = kls.keys[v]
|
||||
mapped = map_sexp(etype, [v], parent=inst, path=f'{path}/{kls.name_atom}')
|
||||
if mapped is not None:
|
||||
setattr(inst, name, mapped)
|
||||
|
||||
elif isinstance(v, list):
|
||||
key = v[0]
|
||||
if key in kls.keys:
|
||||
name, etype = kls.keys[key]
|
||||
mapped = map_sexp(etype, v, parent=inst, path=f'{path}/{kls.name_atom}')
|
||||
if mapped is not None:
|
||||
setattr(inst, name, mapped)
|
||||
|
||||
elif hasattr(inst, '__catchall__'):
|
||||
inst.__catchall__(v, path=f'{path}/{kls.name_atom}')
|
||||
|
||||
else:
|
||||
#print('class has keys:')
|
||||
#print('\n'.join(map(str, kls.keys)))
|
||||
raise TypeError(f'Unhandled keyed argument {v!r} while parsing {kls}')
|
||||
|
||||
else:
|
||||
try:
|
||||
pos_key = next(positional)
|
||||
setattr(inst, pos_key.name, v)
|
||||
except StopIteration:
|
||||
raise TypeError(f'Unhandled positional argument {v!r} while parsing {kls}')
|
||||
|
||||
getattr(inst, '__after_parse__', lambda x: None)(parent)
|
||||
return inst
|
||||
|
||||
@staticmethod
|
||||
def __sexp__(kls, value):
|
||||
getattr(value, '__before_sexp__', lambda: None)()
|
||||
|
||||
out = [kls.name_atom]
|
||||
for f in fields(kls):
|
||||
if f.type is SEXP_END:
|
||||
break
|
||||
out += sexp(f.type, getattr(value, f.name))
|
||||
yield out
|
||||
|
||||
@staticmethod
|
||||
def parse(kls, data, *args, **kwargs):
|
||||
return kls.__map__(parse_sexp(data), *args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def sexp(self):
|
||||
return next(self.__sexp__(self))
|
||||
|
||||
@staticmethod
|
||||
def __deepcopy__(self, memo):
|
||||
return replace(self, **{f.name: copy.deepcopy(getattr(self, f.name), memo) for f in fields(self) if not f.kw_only})
|
||||
|
||||
@staticmethod
|
||||
def __copy__(self):
|
||||
# Even during a shallow copy, we need to deep copy any fields whose types have a __before_sexp__ method to avoid
|
||||
# those from being called more than once on the same object.
|
||||
return replace(self, **{f.name: copy.copy(getattr(self, f.name)) for f in fields(self) if not f.kw_only and hasattr(f.type, '__before_sexp__')})
|
||||
|
||||
def sexp_type(name=None):
|
||||
def register(cls):
|
||||
cls = dataclass(cls)
|
||||
cls.name_atom = Atom(name) if name is not None else None
|
||||
for key in '__sexp__', '__map__', '__atoms__', 'parse':
|
||||
if not hasattr(cls, key):
|
||||
setattr(cls, key, classmethod(getattr(_SexpTemplate, key)))
|
||||
|
||||
for key in 'sexp', '__deepcopy__', '__copy__':
|
||||
if not hasattr(cls, key):
|
||||
setattr(cls, key, getattr(_SexpTemplate, key))
|
||||
|
||||
cls.positional = []
|
||||
cls.keys = {}
|
||||
for f in fields(cls):
|
||||
f_type = f.type
|
||||
if f_type is SEXP_END:
|
||||
break
|
||||
|
||||
if hasattr(f_type, '__bind_field__'):
|
||||
f_type.__bind_field__(f)
|
||||
|
||||
atoms = getattr(f_type, '__atoms__', lambda: [])
|
||||
atoms = list(atoms())
|
||||
for atom in atoms:
|
||||
cls.keys[atom] = (f.name, f_type)
|
||||
if not atoms:
|
||||
cls.positional.append(f)
|
||||
|
||||
return cls
|
||||
return register
|
||||
|
||||
|
||||
631
src/gerbonara/cad/kicad/symbols.py
Normal file
|
|
@ -0,0 +1,631 @@
|
|||
"""
|
||||
Library for processing KiCad's symbol files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import string
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import itertools
|
||||
from fnmatch import fnmatch
|
||||
from collections import defaultdict
|
||||
from dataclasses import field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .sexp import *
|
||||
from .sexp_mapper import *
|
||||
from .base_types import *
|
||||
from ...utils import rotate_point, Tag, arc_bounds
|
||||
from ... import __version__
|
||||
from ...newstroke import Newstroke
|
||||
from .schematic_colors import *
|
||||
from .primitives import kicad_mid_to_center_arc, Margins
|
||||
|
||||
|
||||
PIN_ETYPE = AtomChoice(Atom.input, Atom.output, Atom.bidirectional, Atom.tri_state, Atom.passive, Atom.free,
|
||||
Atom.unspecified, Atom.power_in, Atom.power_out, Atom.open_collector, Atom.open_emitter,
|
||||
Atom.no_connect, Atom.unconnected)
|
||||
|
||||
|
||||
PIN_STYLE = AtomChoice(Atom.line, Atom.inverted, Atom.clock, Atom.inverted_clock, Atom.input_low, Atom.clock_low,
|
||||
Atom.output_low, Atom.edge_clock_high, Atom.non_logic)
|
||||
|
||||
|
||||
@sexp_type('alternate')
|
||||
class AltFunction:
|
||||
name: str = None
|
||||
etype: PIN_ETYPE = Atom.unspecified
|
||||
shape: PIN_STYLE = Atom.line
|
||||
|
||||
|
||||
@sexp_type('__styled_text')
|
||||
class StyledText:
|
||||
value: str = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
|
||||
|
||||
@sexp_type('pin')
|
||||
class Pin:
|
||||
etype: PIN_ETYPE = Atom.unspecified
|
||||
style: PIN_STYLE = Atom.line
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
length: Named(float) = 2.54
|
||||
hide: OmitDefault(Named(YesNoAtom())) = False
|
||||
name: Rename(StyledText) = field(default_factory=StyledText)
|
||||
number: Rename(StyledText) = field(default_factory=StyledText)
|
||||
alternates: List(AltFunction) = field(default_factory=list)
|
||||
_: SEXP_END = None
|
||||
unit: object = None
|
||||
|
||||
def __after_parse__(self, parent=None):
|
||||
self.unit = parent
|
||||
|
||||
@property
|
||||
def direction(self):
|
||||
return {0: 'R', 90: 'U', 180: 'L', 270: 'D'}.get(self.at.rotation, 'R')
|
||||
|
||||
@direction.setter
|
||||
def direction(self, value):
|
||||
self.at.rotation = {0: 'R', 90: 'U', 180: 'L', 270: 'D'}[value[0].upper()]
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
font = Newstroke.load()
|
||||
strokes = list(font.render(self.name, size=2.54))
|
||||
min_x = min(x for st in strokes for x, y in st)
|
||||
min_y = min(y for st in strokes for x, y in st)
|
||||
max_x = max(x for st in strokes for x, y in st)
|
||||
max_y = max(y for st in strokes for x, y in st)
|
||||
w, h = max_x - min_x, max_y - min_y
|
||||
l = self.length + 0.2 + w
|
||||
|
||||
x1, y1 = x2, y2 = self.at.x, self.at.y
|
||||
if self.at.rotation == 0:
|
||||
x2 += w
|
||||
y1 -= h/2
|
||||
y2 += h/2
|
||||
if self.at.rotation == 90:
|
||||
y2 += w
|
||||
x1 -= h/2
|
||||
x2 += h/2
|
||||
if self.at.rotation == 180:
|
||||
x1 -= w
|
||||
y1 -= h/2
|
||||
y2 += h/2
|
||||
if self.at.rotation == 270:
|
||||
y1 -= w
|
||||
x1 -= h/2
|
||||
x2 += h/2
|
||||
else:
|
||||
raise ValueError(f'Invalid pin rotation {self.at.rotation}')
|
||||
|
||||
return (x1, y1), (x2, y2)
|
||||
|
||||
def to_svg(self, colorscheme, p_mirror, p_rotation):
|
||||
if self.hide:
|
||||
return
|
||||
|
||||
psx, psy = (-1 if p_mirror.x else 1), (-1 if p_mirror.y else 1)
|
||||
x1, y1 = self.at.x, self.at.y
|
||||
x2, y2 = self.at.x+self.length, self.at.y
|
||||
if p_mirror.y:
|
||||
p_xf = f'scale(-1 -1)'
|
||||
elif p_mirror.x:
|
||||
p_xf = f'scale(1 1)'
|
||||
else:
|
||||
p_xf = f'scale(1 -1)'
|
||||
p_xf += f'rotate({p_rotation})'
|
||||
xform = {'transform': f'{p_xf} translate({self.at.x:.3f} {self.at.y:.3f}) rotate({self.at.rotation})'}
|
||||
style = {'stroke_width': 0.254, 'stroke': colorscheme.lines, 'stroke_linecap': 'round'}
|
||||
|
||||
yield Tag('path', **xform, **style, d=f'M 0 0 L {self.length:.3f} 0')
|
||||
|
||||
eps = 1
|
||||
for tag in {
|
||||
'line': [],
|
||||
'inverted': [
|
||||
Tag('circle', **xform, **style, cx=x2-eps/3-0.2, cy=y2, r=eps/3)],
|
||||
'clock': [
|
||||
Tag('path', **xform, **style, d=f'M {x2} {y2-eps/2} L {x2+eps/2} {y2} L {x2} {y2+eps/2}')],
|
||||
'inverted_clock': [
|
||||
Tag('circle', **xform, **style, cx=x2-eps/3-0.2, cy=y2, r=eps/3),
|
||||
Tag('path', **xform, **style, d=f'M {x2} {y2-eps/2} L {x2+eps/2} {y2} L {x2} {y2+eps/2}')],
|
||||
'input_low': [
|
||||
Tag('path', **xform, **style, d=f'M {x2} {y2} L {x2-eps} {y2-eps} L {x2-eps} {y2}')],
|
||||
'clock_low': [
|
||||
Tag('path', **xform, **style, d=f'M {x2} {y2} L {x2-eps} {y2-eps} L {x2-eps} {y2}'),
|
||||
Tag('path', **xform, **style, d=f'M {x2} {y2-eps/2} L {x2+eps/2} {y2} L {x2} {y2+eps/2}')],
|
||||
'output_low': [
|
||||
Tag('path', **xform, **style, d=f'M {x2} {y2-eps} L {x2-eps} {y2}')],
|
||||
'edge_clock_high': [
|
||||
Tag('path', **xform, **style, d=f'M {x2} {y2} L {x2-eps} {y2-eps} L {x2-eps} {y2}'),
|
||||
Tag('path', **xform, **style, d=f'M {x2} {y2-eps/2} L {x2+eps/2} {y2} L {x2} {y2+eps/2}')],
|
||||
'non_logic': [
|
||||
Tag('path', **xform, **style, d=f'M {x2-eps/2} {y2-eps/2} L {x2+eps/2} {y2+eps/2}'),
|
||||
Tag('path', **xform, **style, d=f'M {x2-eps/2} {y2+eps/2} L {x2+eps/2} {y2-eps/2}')],
|
||||
# FIXME...
|
||||
}.get(self.style, []):
|
||||
yield tag
|
||||
|
||||
rot = self.at.rotation + p_rotation
|
||||
trot = self.at.rotation
|
||||
ax, ay = self.length+0.2, 0
|
||||
ax, ay = rotate_point(ax, ay, math.radians(-self.at.rotation))
|
||||
|
||||
#lx, ly = self.at.x, -self.at.y
|
||||
#lx, ly = rotate_point(lx, ly, math.radians(p_rotation))
|
||||
#if p_mirror.y:
|
||||
# lx, ly = -lx, ly
|
||||
#elif p_mirror.x:
|
||||
# lx, ly = lx, -ly
|
||||
#yield Tag('circle', cx=lx, cy=ly, r='0.5', stroke='blue', stroke_width='0.1', fill='none', z_index='100')
|
||||
|
||||
lx, ly = self.at.x + ax, -self.at.y - ay
|
||||
lx, ly = rotate_point(lx, ly, math.radians(p_rotation))
|
||||
if p_mirror.y:
|
||||
lx, ly = -lx, ly
|
||||
elif p_mirror.x:
|
||||
lx, ly = lx, -ly
|
||||
#yield Tag('circle', cx=lx, cy=ly, r='0.5', stroke='red', stroke_width='0.1', fill='none', z_index='100')
|
||||
|
||||
h_align = 'left'
|
||||
if p_mirror.y:
|
||||
if trot in (0, 180):
|
||||
trot = 180 - trot
|
||||
elif p_mirror.x:
|
||||
if p_rotation == 0:
|
||||
if trot in (90, 270):
|
||||
trot = 360-trot
|
||||
else:
|
||||
if trot in (0, 180):
|
||||
trot = 180 - trot
|
||||
frot = (trot + p_rotation)%360
|
||||
sx, sy = 1, 1
|
||||
|
||||
if frot == 180:
|
||||
frot = 0
|
||||
h_align = 'right'
|
||||
elif frot == 270:
|
||||
frot = 90
|
||||
h_align = 'right'
|
||||
|
||||
font = Newstroke.load()
|
||||
if self.name.value != '~' and not self.unit.symbol.pin_names.hide:
|
||||
yield font.render_svg(self.name.value,
|
||||
size=self.name.effects.font.size.y or 1.27,
|
||||
x0=0,
|
||||
y0=0,
|
||||
h_align=h_align,
|
||||
v_align='middle',
|
||||
rotation=-frot,
|
||||
stroke=colorscheme.pin_names,
|
||||
transform=f'translate({lx:.3f} {ly:.3f})',
|
||||
scale=(sx, sy),
|
||||
mirror=(False, False),
|
||||
)
|
||||
|
||||
if self.number.value != '~' and not self.unit.symbol.pin_numbers.hide:
|
||||
yield font.render_svg(self.number.value,
|
||||
size=self.number.effects.font.size.y or 1.27,
|
||||
x0=-0.4 if h_align == 'left' else 0.4,
|
||||
y0=-0.4,
|
||||
h_align={'left': 'right', 'right': 'left'}[h_align],
|
||||
v_align='bottom',
|
||||
rotation=-frot,
|
||||
stroke=colorscheme.pin_numbers,
|
||||
scale=(sx, sy),
|
||||
transform=f'translate({lx:.3f} {ly:.3f})',
|
||||
mirror=(False, False),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@sexp_type('fill')
|
||||
class Fill:
|
||||
type: Named(AtomChoice(Atom.none, Atom.outline, Atom.background)) = Atom.none
|
||||
|
||||
def svg(self, fg, bg):
|
||||
if self.type == 'outline':
|
||||
return fg
|
||||
elif self.type == 'background':
|
||||
return bg
|
||||
else:
|
||||
return 'none'
|
||||
|
||||
|
||||
@sexp_type('circle')
|
||||
class Circle:
|
||||
center: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
radius: Named(float) = 0.0
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: Fill = field(default_factory=Fill)
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
x, y, r = self.center.x, self.center.y, self.radius
|
||||
return (x-r, y-r), (x+r, y+r)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield Tag('circle', cx=f'{self.center.x:.3f}', cy=f'{self.center.y:.3f}', r=f'{self.radius:.3f}',
|
||||
fill=self.fill.svg(colorscheme.lines, colorscheme.fill),
|
||||
**self.stroke.svg_attrs(colorscheme.lines))
|
||||
|
||||
|
||||
@sexp_type('radius')
|
||||
class ArcRadius:
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
length: Named(float) = 0.0
|
||||
angles: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
|
||||
|
||||
@sexp_type('arc')
|
||||
class Arc:
|
||||
start: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
mid: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
end: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
radius: ArcRadius = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: Fill = field(default_factory=Fill)
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
(cx, cy), r = kicad_mid_to_center_arc(self.mid, self.start, self.end)
|
||||
x1, y1 = self.start.x, self.start.y
|
||||
x2, y2 = self.mid.x-x1, self.mid.y-x2
|
||||
x3, y3 = (self.end.x - x1)/2, (self.end.y - y1)/2
|
||||
clockwise = math.atan2(x2*y3-x3*y2, x2*x3+y2*y3) > 0
|
||||
return arc_bounds(x1, y1, self.end.x, self.end.y, cx, cy, clockwise)
|
||||
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
(cx, cy), r = kicad_mid_to_center_arc(self.mid, self.start, self.end)
|
||||
|
||||
x1r = self.start.x - cx
|
||||
y1r = self.start.y - cy
|
||||
x2r = self.end.x - cx
|
||||
y2r = self.end.y - cy
|
||||
a1 = math.atan2(x1r, y1r)
|
||||
a2 = math.atan2(x2r, y2r)
|
||||
da = (a2 - a1 + math.pi) % (2*math.pi) - math.pi
|
||||
|
||||
large_arc = int(da > math.pi)
|
||||
d = f'M {self.start.x:.3f} {self.start.y:.3f} A {r:.3f} {r:.3f} 0 {large_arc} 0 {self.end.x:.3f} {self.end.y:.3f}'
|
||||
yield Tag('path', d=d, fill=self.fill.svg(colorscheme.lines, colorscheme.fill),
|
||||
**self.stroke.svg_attrs(colorscheme.lines))
|
||||
|
||||
|
||||
@sexp_type('polyline')
|
||||
class Polyline:
|
||||
pts: PointList = field(default_factory=PointList)
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: Fill = field(default_factory=Fill)
|
||||
|
||||
@property
|
||||
def points(self):
|
||||
return self.pts.xy
|
||||
|
||||
@points.setter
|
||||
def points(self, value):
|
||||
self.pts.xy = value
|
||||
|
||||
@property
|
||||
def closed(self):
|
||||
# if the last and first point are the same, we consider the polyline closed
|
||||
# a closed triangle will have 4 points (A-B-C-A) stored in the list of points
|
||||
return len(self.points) > 3 and self.points[0].isclose(self.points[-1])
|
||||
|
||||
def bounding_box(self, default=None):
|
||||
if not self.points:
|
||||
return default
|
||||
|
||||
return (min(p.x for p in self.points), min(p.y for p in self.points)), \
|
||||
(max(p.x for p in self.points), max(p.y for p in self.points))
|
||||
|
||||
def as_rectangle(self):
|
||||
(maxx, maxy, minx, miny) = self.bbox()
|
||||
return Rectangle(minx, maxy, maxx, miny, self.stroke, self.fill)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
p0, *rest = self.points
|
||||
if not rest:
|
||||
return
|
||||
|
||||
d = ' '.join([f'M {p0.x:.3f} {p0.y:.3f}', *(f'L {pn.x:.3f} {pn.y:.3f}' for pn in rest)])
|
||||
yield Tag('path', d=d, fill=self.fill.svg(colorscheme.lines, colorscheme.fill), **self.stroke.svg_attrs(colorscheme.lines))
|
||||
|
||||
def is_rectangle(self):
|
||||
# A rectangle has 5 points and is closed
|
||||
if len(self.points) != 5 or not self.is_closed():
|
||||
return False
|
||||
|
||||
# Check that we have all four corners present
|
||||
(x1, y1), (x2, y2) = self.bbox()
|
||||
if not all(any(cand.isclose(pt) for cand in self.points[:-1]) for pt in
|
||||
[(x1, y1), (x1, y2), (x2, y2), (x2, y1)]):
|
||||
return False
|
||||
|
||||
# Check that we only have horizontal or vertical lines
|
||||
if any(x2-x1 and y2-y1 for (x1, y1), (x2, y2) in zip(self.points[:-1], self.points[1:])):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@sexp_type('at')
|
||||
class TextPos(XYCoord):
|
||||
x: float = 0 # in millimeter
|
||||
y: float = 0 # in millimeter
|
||||
rotation: int = 0 # in degrees
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.rotation = self.rotation / 10
|
||||
|
||||
def __before_sexp__(self):
|
||||
self.rotation = round((self.rotation % 360) * 10)
|
||||
|
||||
@property
|
||||
def rotation_rad(self):
|
||||
return math.radians(self.rotation)
|
||||
|
||||
@rotation_rad.setter
|
||||
def rotation_rad(self, value):
|
||||
self.rotation = math.degrees(value)
|
||||
|
||||
|
||||
@sexp_type('text')
|
||||
class Text(TextMixin):
|
||||
text: str = None
|
||||
at: TextPos = field(default_factory=TextPos)
|
||||
rotation: float = None
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield from TextMixin.to_svg(self, colorscheme.text)
|
||||
|
||||
|
||||
@sexp_type('rectangle')
|
||||
class Rectangle:
|
||||
# Some v6 symbols use rectangles, newer ones encode them as polylines.
|
||||
# At some point in time we can most likely remove this class since its not used anymore
|
||||
|
||||
start: Rename(XYCoord) = None
|
||||
end: Rename(XYCoord) = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: Fill = field(default_factory=Fill)
|
||||
|
||||
def to_polyline(self):
|
||||
x1, y1 = self.start.x, self.start.y
|
||||
x2, y2 = self.end.x, self.end.y
|
||||
return Polyline(PointList([XYCoord(x1, y1), XYCoord(x2, y1), XYCoord(x2, y2), XYCoord(x1, y2), XYCoord(x1, y1)]),
|
||||
self.stroke, self.fill)
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
return self.to_polyline().to_svg(colorscheme)
|
||||
|
||||
|
||||
@sexp_type('property')
|
||||
class Property(TextMixin):
|
||||
private: Flag() = False
|
||||
name: str = None
|
||||
value: str = None
|
||||
id: Named(int) = None
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
show_name: Flag() = False
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
|
||||
# Alias value for text mixin
|
||||
@property
|
||||
def text(self):
|
||||
return self.value
|
||||
|
||||
@text.setter
|
||||
def text(self, value):
|
||||
self.value = value
|
||||
|
||||
def to_svg(self, colorscheme=Colorscheme.KiCad):
|
||||
yield from TextMixin.to_svg(self, colorscheme.text)
|
||||
|
||||
|
||||
@sexp_type('pin_numbers')
|
||||
class PinNumberSpec:
|
||||
hide: Named(YesNoAtom()) = False
|
||||
|
||||
|
||||
@sexp_type('pin_names')
|
||||
class PinNameSpec:
|
||||
offset: OmitDefault(Named(float)) = 0.508
|
||||
hide: OmitDefault(Named(YesNoAtom())) = False
|
||||
|
||||
@sexp_type('text_box')
|
||||
class TextBox:
|
||||
text: str = ''
|
||||
exclude_from_sim: OmitDefault(Named(YesNoAtom())) = False
|
||||
at: AtPos = field(default_factory=AtPos)
|
||||
size: Rename(XYCoord) = field(default_factory=XYCoord)
|
||||
margins: Margins = None
|
||||
stroke: Stroke = field(default_factory=Stroke)
|
||||
fill: Fill = field(default_factory=Fill)
|
||||
effects: TextEffect = field(default_factory=TextEffect)
|
||||
|
||||
|
||||
@sexp_type('symbol')
|
||||
class Unit:
|
||||
name: str = None
|
||||
circles: List(Circle) = field(default_factory=list)
|
||||
arcs: List(Arc) = field(default_factory=list)
|
||||
polylines: List(Polyline) = field(default_factory=list)
|
||||
rectangles: List(Rectangle) = field(default_factory=list)
|
||||
texts: List(Text) = field(default_factory=list)
|
||||
text_boxes: List(TextBox) = field(default_factory=list)
|
||||
pins: List(Pin) = field(default_factory=list)
|
||||
unit_name: Named(str) = None
|
||||
_ : SEXP_END = None
|
||||
unit_global: Flag() = False
|
||||
style_global: Flag() = False
|
||||
demorgan_style: int = 1
|
||||
unit_index: int = 1
|
||||
symbol = None
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.symbol = parent
|
||||
|
||||
if not (m := re.fullmatch(r'(.*)_([0-9]+)_([0-9]+)', self.name)):
|
||||
raise FormatError(f'Invalid unit name "{self.name}"')
|
||||
sym_name, unit_index, demorgan_style = m.groups()
|
||||
if sym_name != self.symbol.raw_name.rpartition(':')[2]:
|
||||
raise FormatError(f'Unit name "{self.name}" does not match symbol name "{self.symbol.name}"')
|
||||
self.demorgan_style = int(demorgan_style)
|
||||
self.unit_index = int(unit_index)
|
||||
self.style_global = self.demorgan_style == 0
|
||||
self.unit_global = self.unit_index == 0
|
||||
|
||||
@property
|
||||
def graphical_elements(self):
|
||||
yield from self.rectangles
|
||||
yield from self.polylines
|
||||
yield from self.circles
|
||||
yield from self.arcs
|
||||
yield from self.texts
|
||||
|
||||
def __before_sexp__(self):
|
||||
self.name = f'{self.symbol.name}_{self.unit_index}_{self.demorgan_style}'
|
||||
|
||||
def pin_stacks(self):
|
||||
stacks = defaultdict(lambda: set())
|
||||
for pin in self.all_pins():
|
||||
stacks[(pin.at.x, pin.at.y)].add(pin)
|
||||
return stacks
|
||||
|
||||
|
||||
@sexp_type('symbol')
|
||||
class Symbol:
|
||||
raw_name: str = None
|
||||
extends: Named(str) = None
|
||||
power: Wrap(Flag()) = False
|
||||
pin_numbers: OmitDefault(PinNumberSpec) = field(default_factory=PinNumberSpec)
|
||||
pin_names: OmitDefault(PinNameSpec) = field(default_factory=PinNameSpec)
|
||||
exclude_from_sim: OmitDefault(Named(YesNoAtom())) = False
|
||||
exclude_from_sim: Named(YesNoAtom()) = False
|
||||
in_bom: Named(YesNoAtom()) = True
|
||||
on_board: Named(YesNoAtom()) = True
|
||||
properties: List(Property) = field(default_factory=list)
|
||||
units: List(Unit) = field(default_factory=list)
|
||||
embedded_fonts: Named(YesNoAtom()) = False
|
||||
_ : SEXP_END = None
|
||||
library = None
|
||||
name: str = None
|
||||
library_name: str = None
|
||||
|
||||
def __after_parse__(self, parent):
|
||||
self.library = parent
|
||||
|
||||
self.library_name, _, self.name = self.raw_name.rpartition(':')
|
||||
|
||||
if self.extends:
|
||||
self.in_bom = None
|
||||
self.on_board = None
|
||||
|
||||
self.properties = {prop.name: prop for prop in self.properties}
|
||||
if (prop := self.properties.get('ki_fp_filters')):
|
||||
prop.value = prop.value.split() if prop.value else []
|
||||
|
||||
def __before_sexp__(self):
|
||||
if (prop := self.properties.get('ki_fp_filters')):
|
||||
if not isinstance(prop.value, str):
|
||||
prop.value = ' '.join(prop.value)
|
||||
self.properties = list(self.properties.values())
|
||||
|
||||
def default_properties(self):
|
||||
for i, (name, value, hide) in enumerate([
|
||||
('Reference', 'U', False),
|
||||
('Value', None, False),
|
||||
('Footprint', None, True),
|
||||
('Datasheet', None, True),
|
||||
('ki_locked', None, True),
|
||||
('ki_keywords', None, True),
|
||||
('ki_description', None, True),
|
||||
('ki_fp_filters', None, False),
|
||||
]):
|
||||
self.properties[name] = Property(name=name, value=value, id=i, effects=TextEffect(hide=hide))
|
||||
|
||||
def resolve(self):
|
||||
if self.extends:
|
||||
return self.library[self.extends]
|
||||
else:
|
||||
return self
|
||||
|
||||
def is_graphic_symbol(self):
|
||||
return self.extends is None and (
|
||||
not self.pins or self.get_property("Reference").value == "#SYM"
|
||||
)
|
||||
|
||||
def pins_by_name(self, demorgan_style=None):
|
||||
pins = defaultdict(lambda: set())
|
||||
for unit in self.units(demorgan_style):
|
||||
for pin in unit.all_pins:
|
||||
pins[pin.name].add(pin)
|
||||
return pins
|
||||
|
||||
def pins_by_number(self, demorgan_style=None):
|
||||
pins = defaultdict(lambda: set())
|
||||
for unit in self.units(demorgan_style):
|
||||
for pin in unit.all_pins:
|
||||
pins[pin.number].add(pin)
|
||||
return pins
|
||||
|
||||
def filter_pins(self, name=None, direction=None, electrical_type=None):
|
||||
for pin in self.all_pins:
|
||||
if name and not fnmatch(pin.name, name):
|
||||
continue
|
||||
if direction and not pin.direction in direction:
|
||||
continue
|
||||
if electrical_type and not pin.etype in electical_type:
|
||||
continue
|
||||
yield pin
|
||||
|
||||
def heuristically_small(self):
|
||||
""" Heuristically try to determine whether this is a "small" component like a resistor, capacitor, LED, diode,
|
||||
or transistor etc. When we have at most two pins, or there is no filled rectangle as symbol outline and we have
|
||||
3 or 4 pins, we assume this is a small symbol.
|
||||
"""
|
||||
if len(self.all_pins) <= 2:
|
||||
return True
|
||||
if len(self.all_pins) > 4:
|
||||
return False
|
||||
return bool(self.get_center_rectangle(range(self.unit_count)))
|
||||
|
||||
|
||||
SUPPORTED_FILE_FORMAT_VERSIONS = [20211014, 20220914]
|
||||
@sexp_type('kicad_symbol_lib')
|
||||
class Library:
|
||||
_version: Named(int, name='version') = 20211014
|
||||
generator: Named(str) = Atom.gerbonara
|
||||
generator_version: Named(str) = __version__
|
||||
symbols: List(Symbol) = field(default_factory=list)
|
||||
_ : SEXP_END = None
|
||||
original_filename: str = None
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return self._version
|
||||
|
||||
@version.setter
|
||||
def version(self, value):
|
||||
if value not in SUPPORTED_FILE_FORMAT_VERSIONS:
|
||||
raise FormatError(f'File format version {value} is not supported. Supported versions are {", ".join(map(str, SUPPORTED_FILE_FORMAT_VERSIONS))}.')
|
||||
|
||||
@classmethod
|
||||
def open(cls, filename: str):
|
||||
with open(filename) as f:
|
||||
return cls.parse(f.read())
|
||||
|
||||
def write(self, filename=None):
|
||||
with open(filename or self.original_filename, 'w') as f:
|
||||
f.write(build_sexp(sexp(self)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) >= 2:
|
||||
a = Library.open(sys.argv[1])
|
||||
print(build_sexp(sexp(a)))
|
||||
else:
|
||||
print("pass a .kicad_sym file please")
|
||||
88
src/gerbonara/cad/kicad/tmtheme.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
|
||||
from xml.etree import ElementTree
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def _map_primitive(element):
|
||||
match element.tag:
|
||||
case 'data':
|
||||
return base64.b64decode(element.text)
|
||||
case 'date':
|
||||
return element.text
|
||||
case 'true':
|
||||
return True
|
||||
case 'false':
|
||||
return False
|
||||
case 'real':
|
||||
return float(element.text)
|
||||
case 'integer':
|
||||
return int(element.text)
|
||||
case 'string':
|
||||
return element.text
|
||||
case 'array':
|
||||
return [_map_primitive(child) for child in element]
|
||||
case 'dict':
|
||||
children = list(element)
|
||||
return {k.text: _map_primitive(v) for k, v in zip(children[0::2], children[1::2])}
|
||||
|
||||
|
||||
def parse_shitty_json(data):
|
||||
# Parse apple plist XML
|
||||
root = ElementTree.fromstring(data)
|
||||
return _map_primitive(root[0])
|
||||
|
||||
|
||||
class _SublimeColorschemeSuper:
|
||||
def __init__(self, s, by_scope):
|
||||
def lookup(default, *scopes):
|
||||
for scope in scopes:
|
||||
if not (elem := by_scope.get(scope)):
|
||||
continue
|
||||
|
||||
if 'foreground' not in elem:
|
||||
continue
|
||||
|
||||
return elem['foreground']
|
||||
return default
|
||||
|
||||
self.background = s.get('background', 'white')
|
||||
fg = s.get('foreground', 'black')
|
||||
self.bus = lookup(fg, 'constant.other', 'storage.type')
|
||||
self.wire = self.lines = lookup(fg, 'constant.other')
|
||||
self.no_connect = lookup(fg, 'constant.language', 'variable')
|
||||
self.text = lookup(fg, 'constant.numeric', 'constant.numeric.hex', 'storage.type.number')
|
||||
self.pin_names = lookup(fg, 'constant.character', 'constant.other')
|
||||
self.pin_numbers = fg
|
||||
self.values = lookup(fg, 'constant.character.format.placeholder', 'constant.other.placeholder', 'entity.name.tag', 'support.type', 'support.class', 'entity.other.inherited-class')
|
||||
self.labels = lookup(fg, 'constant.numeric', 'constant.numeric.hex', 'storage.type.number')
|
||||
self.fill = s.get('background')
|
||||
|
||||
|
||||
class TmThemeSchematic(_SublimeColorschemeSuper):
|
||||
def __init__(self, data):
|
||||
self.theme = parse_shitty_json(data)
|
||||
s = self.theme['settings'][0]['settings']
|
||||
by_scope = {}
|
||||
for elem in self.theme['settings']:
|
||||
if 'scope' not in elem:
|
||||
continue
|
||||
for scope in elem['scope'].split(','):
|
||||
by_scope[scope.strip()] = elem.get('settings', {})
|
||||
super().__init__(s, by_scope)
|
||||
|
||||
|
||||
class SublimeSchematic(_SublimeColorschemeSuper):
|
||||
def __init__(self, data):
|
||||
self.theme = json.loads(data)
|
||||
s = self.theme['globals']
|
||||
by_scope = {}
|
||||
for elem in self.theme['rules']:
|
||||
for scope in elem['scope'].split(','):
|
||||
by_scope[scope.strip()] = elem
|
||||
super().__init__(s, by_scope)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(parse_shitty_json(Path('/tmp/witchhazelhypercolor.tmTheme').read_text()))
|
||||
|
||||
840
src/gerbonara/cad/primitives.py
Normal file
|
|
@ -0,0 +1,840 @@
|
|||
|
||||
import sys
|
||||
import math
|
||||
import warnings
|
||||
from copy import copy
|
||||
from itertools import zip_longest, chain
|
||||
from dataclasses import dataclass, field, replace, KW_ONLY
|
||||
from collections import defaultdict
|
||||
|
||||
from ..utils import LengthUnit, MM, rotate_point, svg_arc, sum_bounds, bbox_intersect, Tag, offset_bounds
|
||||
from ..layers import LayerStack
|
||||
from ..graphic_objects import Line, Arc, Flash
|
||||
from ..apertures import Aperture, CircleAperture, ObroundAperture, RectangleAperture, ExcellonTool
|
||||
from ..newstroke import Newstroke
|
||||
|
||||
|
||||
class UNDEFINED:
|
||||
pass
|
||||
|
||||
def sgn(x):
|
||||
return -1 if x < 0 else 1
|
||||
|
||||
|
||||
class KeepoutError(ValueError):
|
||||
def __init__(self, obj, keepout, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.obj = obj
|
||||
self.keepout = keepout
|
||||
|
||||
|
||||
newstroke_font = None
|
||||
|
||||
|
||||
class Board:
|
||||
def __init__(self, w=None, h=None, corner_radius=1.5, center=False, default_via_hole=0.4, default_via_diameter=0.8, x=0, y=0, rotation=0, unit=MM):
|
||||
self.x, self.y = x, y
|
||||
self.rotation = 0
|
||||
self.objects = []
|
||||
self.outline = []
|
||||
self.extra_silk_top = []
|
||||
self.extra_silk_bottom = []
|
||||
self.keepouts = []
|
||||
self.default_via_hole = MM(default_via_hole, unit)
|
||||
self.default_via_diameter = MM(default_via_diameter, unit)
|
||||
self.unit = unit
|
||||
if w or h:
|
||||
if w and h:
|
||||
self.rounded_rect_outline(w, h, r=corner_radius, center=center)
|
||||
self.w, self.h = w, h
|
||||
else:
|
||||
raise ValueError('Either both, w and h, or neither of them must be given.')
|
||||
else:
|
||||
self.w = self.h = None
|
||||
|
||||
@property
|
||||
def abs_pos(self):
|
||||
return self.x, self.y, self.rotation, False
|
||||
|
||||
def add_silk(self, side, obj):
|
||||
if side not in ('top', 'bottom'):
|
||||
raise ValueError('side must be one of "top" or "bottom".')
|
||||
|
||||
if side == 'top':
|
||||
self.extra_silk_top.append(obj)
|
||||
else:
|
||||
self.extra_silk_bottom.append(obj)
|
||||
|
||||
def add_text(self, *args, **kwargs):
|
||||
self.objects.append(Text(*args, **kwargs))
|
||||
|
||||
def add_keepout(self, bbox, unit=MM):
|
||||
((_x_min, _y_min), (_x_max, _y_max)) = bbox
|
||||
self.keepouts.append(MM.convert_bounds_from(unit, bbox))
|
||||
|
||||
def add(self, obj, keepout_errors='raise'):
|
||||
if keepout_errors not in ('ignore', 'raise', 'warn', 'skip'):
|
||||
raise ValueError('keepout_errors must be one of "ignore", "raise", "warn" or "skip".')
|
||||
|
||||
if keepout_errors != 'ignore':
|
||||
for ko in self.keepouts:
|
||||
if obj.overlaps(ko, unit=MM):
|
||||
if keepout_errors == 'warn':
|
||||
warnings.warn(f'Object with bounds {obj.bounding_box(MM)} [mm] hits one or more keepout areas')
|
||||
elif keepout_errors == 'raise':
|
||||
raise KeepoutError(obj, ko, msg)
|
||||
return
|
||||
|
||||
obj.parent = self
|
||||
self.objects.append(obj)
|
||||
|
||||
def via(self, x, y, diameter=None, hole=None, keepout_errors='raise', unit=MM):
|
||||
diameter = diameter or unit(self.default_via_dia, MM)
|
||||
hole = hole or unit(self.default_via_hole, MM)
|
||||
obj = Via(x, y, diameter, hole, unit=unit, keepout_errors=keepout_errors)
|
||||
self.add(obj)
|
||||
return obj
|
||||
|
||||
def rounded_rect_outline(self, w, h, r=0, x0=None, y0=None, center=False, unit=MM):
|
||||
if x0 is None:
|
||||
x0 = -w/2 if center else 0
|
||||
if y0 is None:
|
||||
y0 = -h/2 if center else 0
|
||||
|
||||
ap = CircleAperture(0.05, unit=MM)
|
||||
|
||||
self.outline.append(Line(x0+r, y0, x0+w-r, y0, ap, unit=unit))
|
||||
if r:
|
||||
self.outline.append(Arc(x0+w-r, y0, x0+w, y0+r, 0, r, False, ap, unit=unit))
|
||||
self.outline.append(Line(x0+w, y0+r, x0+w, y0+h-r, ap, unit=unit))
|
||||
if r:
|
||||
self.outline.append(Arc(x0+w, y0+h-r, x0+w-r, y0+h, -r, 0, False, ap, unit=unit))
|
||||
self.outline.append(Line(x0+w-r, y0+h, x0+r, y0+h, ap, unit=unit))
|
||||
if r:
|
||||
self.outline.append(Arc(x0+r, y0+h, x0, y0+h-r, 0, -r, False, ap, unit=unit))
|
||||
self.outline.append(Line(x0, y0+h-r, x0, y0+r, ap, unit=unit))
|
||||
if r:
|
||||
self.outline.append(Arc(x0, y0+r, x0+r, y0, r, 0, False, ap, unit=unit))
|
||||
|
||||
def layer_stack(self, layer_stack=None):
|
||||
if layer_stack is None:
|
||||
layer_stack = LayerStack(board_name='proto')
|
||||
|
||||
cache = {}
|
||||
for obj in chain(self.objects):
|
||||
obj.render(layer_stack, cache)
|
||||
|
||||
layer_stack['mechanical', 'outline'].objects.extend(self.outline)
|
||||
layer_stack['top', 'silk'].objects.extend(self.extra_silk_top)
|
||||
layer_stack['bottom', 'silk'].objects.extend(self.extra_silk_bottom)
|
||||
|
||||
return layer_stack
|
||||
|
||||
def svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None):
|
||||
return self.layer_stack().to_svg(margin=margin, arg_unit=arg_unit, svg_unit=svg_unit,
|
||||
force_bounds=force_bounds)
|
||||
|
||||
def pretty_svg(self, side='top', margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, inkscape=False, colors=None):
|
||||
return self.layer_stack().to_pretty_svg(side=side, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit,
|
||||
force_bounds=force_bounds, inkscape=inkscape, colors=colors)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Positioned:
|
||||
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):
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class Graphics(Positioned):
|
||||
top_copper: list = field(default_factory=list)
|
||||
top_mask: list = field(default_factory=list)
|
||||
top_silk: list = field(default_factory=list)
|
||||
top_paste: list = field(default_factory=list)
|
||||
bottom_copper: list = field(default_factory=list)
|
||||
bottom_mask: list = field(default_factory=list)
|
||||
bottom_silk: list = field(default_factory=list)
|
||||
bottom_paste: list = field(default_factory=list)
|
||||
drill_npth: list = field(default_factory=list)
|
||||
drill_pth: list = field(default_factory=list)
|
||||
|
||||
def render(self, layer_stack, cache=None):
|
||||
x, y, rotation, flip = self.abs_pos
|
||||
top, bottom = ('bottom', 'top') if flip else ('top', 'bottom')
|
||||
|
||||
for target, source in [
|
||||
(layer_stack[top, 'copper'], self.top_copper),
|
||||
(layer_stack[top, 'mask'], self.top_mask),
|
||||
(layer_stack[top, 'silk'], self.top_silk),
|
||||
(layer_stack[top, 'paste'], self.top_paste),
|
||||
(layer_stack[bottom, 'copper'], self.bottom_copper),
|
||||
(layer_stack[bottom, 'mask'], self.bottom_mask),
|
||||
(layer_stack[bottom, 'silk'], self.bottom_silk),
|
||||
(layer_stack[bottom, 'paste'], self.bottom_paste),
|
||||
(layer_stack.drill_pth, self.drill_pth),
|
||||
(layer_stack.drill_npth, self.drill_npth)]:
|
||||
|
||||
for fe in source:
|
||||
fe = copy(fe)
|
||||
fe.rotate(rotation)
|
||||
fe.offset(x, y, self.unit)
|
||||
target.objects.append(fe)
|
||||
|
||||
def bounding_box(self, unit=MM):
|
||||
if math.isclose(self.rotation, 0, abs_tol=1e-3):
|
||||
return offset_bounds(sum_bounds((obj.bounding_box(unit=unit) for obj in chain(
|
||||
self.top_copper,
|
||||
self.top_mask,
|
||||
self.top_silk,
|
||||
self.top_paste,
|
||||
self.bottom_copper,
|
||||
self.bottom_mask,
|
||||
self.bottom_silk,
|
||||
self.bottom_paste,
|
||||
self.drill_npth,
|
||||
self.drill_pth,
|
||||
))), unit(self.x, self.unit), unit(self.y, self.unit))
|
||||
else:
|
||||
return super().bounding_box(unit)
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
any_top = self.top_copper or self.top_mask or self.top_paste or self.top_silk
|
||||
any_bottom = self.bottom_copper or self.bottom_mask or self.bottom_paste or self.bottom_silk
|
||||
any_drill = self.drill_npth or self.drill_pth
|
||||
return not (any_drill or (any_top and any_bottom))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObjectGroup(Positioned):
|
||||
objects: list = field(default_factory=list)
|
||||
|
||||
def render(self, layer_stack, cache=None):
|
||||
for obj in self.objects:
|
||||
if not isinstance(obj, Positioned):
|
||||
raise ValueError(f'ObjectGroup members must be children of Positioned, not {type(obj)}')
|
||||
|
||||
obj.parent = self
|
||||
obj.render(layer_stack, cache=cache)
|
||||
|
||||
def bounding_box(self, unit=MM):
|
||||
if math.isclose(self.rotation, 0, abs_tol=1e-3):
|
||||
return offset_bounds(sum_bounds((obj.bounding_box(unit=unit) for obj in self.objects)),
|
||||
unit(self.x, self.unit), unit(self.y, self.unit))
|
||||
else:
|
||||
return super().bounding_box(unit)
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
return all(obj.single_sided for obj in self.objects)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Text(Positioned):
|
||||
text: str
|
||||
font_size: float = 2.5
|
||||
stroke_width: float = 0.25
|
||||
h_align: str = 'left'
|
||||
v_align: str = 'bottom'
|
||||
layer: str = 'silk'
|
||||
polarity_dark: bool = True
|
||||
|
||||
def render(self, layer_stack, cache=None):
|
||||
obj_x, obj_y, rotation, flip = self.abs_pos
|
||||
global newstroke_font
|
||||
|
||||
if newstroke_font is None:
|
||||
newstroke_font = Newstroke()
|
||||
|
||||
strokes = list(newstroke_font.render(self.text, size=self.font_size))
|
||||
if not strokes:
|
||||
return
|
||||
|
||||
xs = [x for points in strokes for x, _y in points]
|
||||
ys = [y for points in strokes for _x, y in points]
|
||||
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
|
||||
h = self.font_size + self.stroke_width # (max_y - min_y)
|
||||
|
||||
if self.h_align == 'left':
|
||||
x0 = 0
|
||||
elif self.h_align == 'center':
|
||||
x0 = -max_x/2
|
||||
elif self.h_align == 'right':
|
||||
x0 = -max_x
|
||||
else:
|
||||
raise ValueError('h_align must be one of "left", "center", or "right".')
|
||||
|
||||
if self.v_align == 'bottom':
|
||||
y0 = h
|
||||
elif self.v_align == 'middle':
|
||||
y0 = h/2
|
||||
elif self.v_align == 'top':
|
||||
y0 = 0
|
||||
else:
|
||||
raise ValueError('v_align must be one of "top", "middle", or "bottom".')
|
||||
|
||||
if self.flip:
|
||||
x0 += min_x + max_x
|
||||
x_sign = -1
|
||||
else:
|
||||
x_sign = 1
|
||||
|
||||
ap = CircleAperture(self.stroke_width, unit=self.unit)
|
||||
|
||||
for stroke in strokes:
|
||||
for (x1, y1), (x2, y2) in zip(stroke[:-1], stroke[1:]):
|
||||
obj = Line(x0+x_sign*x1, y0+y1, x0+x_sign*x2, y0+y2, aperture=ap, unit=self.unit, polarity_dark=self.polarity_dark)
|
||||
obj.rotate(rotation)
|
||||
obj.offset(obj_x, obj_y)
|
||||
layer_stack['bottom' if flip else 'top', self.layer].objects.append(obj)
|
||||
|
||||
def bounding_box(self, unit=MM):
|
||||
approx_w = len(self.text)*self.font_size*0.75 + self.stroke_width
|
||||
approx_h = self.font_size + self.stroke_width
|
||||
|
||||
if self.h_align == 'left':
|
||||
x0 = 0
|
||||
elif self.h_align == 'center':
|
||||
x0 = -approx_w/2
|
||||
elif self.h_align == 'right':
|
||||
x0 = -approx_w
|
||||
|
||||
if self.v_align == 'top':
|
||||
y0 = 0
|
||||
elif self.v_align == 'middle':
|
||||
y0 = -approx_h/2
|
||||
elif self.v_align == 'bottom':
|
||||
y0 = -approx_h
|
||||
|
||||
return (self.x+x0, self.y+y0), (self.x+x0+approx_w, self.y+y0+approx_h)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PadStackAperture:
|
||||
aperture: Aperture
|
||||
side: str
|
||||
layer: str
|
||||
offset_x: float = 0 # in PadStack units
|
||||
offset_y: float = 0
|
||||
rotation: float = 0
|
||||
invert: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PadStack:
|
||||
_: KW_ONLY
|
||||
unit: LengthUnit = MM
|
||||
|
||||
@property
|
||||
def apertures(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def flashes(self, x, y, rotation: float = 0, flip: bool = False):
|
||||
for ap in self.apertures:
|
||||
aperture = ap.aperture.rotated(ap.rotation + rotation)
|
||||
fl = Flash(ap.offset_x, ap.offset_y, aperture, polarity_dark=not ap.invert, unit=self.unit)
|
||||
fl.rotate(rotation)
|
||||
fl.offset(x, y)
|
||||
side = ap.side
|
||||
if flip:
|
||||
side = {'top': 'bottom', 'bottom': 'top'}.get(side, side)
|
||||
yield side, ap.layer, fl
|
||||
|
||||
def render(self, layer_stack, x, y, rotation: float = 0, flip: bool = False):
|
||||
for side, layer, flash in self.flashes(x, y, rotation, flip):
|
||||
if side == 'drill' and layer == 'plated':
|
||||
layer_stack.drill_pth.objects.append(flash)
|
||||
|
||||
elif side == 'drill' and layer == 'nonplated':
|
||||
layer_stack.drill_npth.objects.append(flash)
|
||||
|
||||
elif (side, layer) in layer_stack:
|
||||
layer_stack[side, layer].objects.append(flash)
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
return len({ap.side for ap in self.apertures}) <= 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SMDStack(PadStack):
|
||||
aperture: Aperture
|
||||
mask_expansion: float = 0.0
|
||||
paste_expansion: float = 0.0
|
||||
paste: bool = True
|
||||
flip: bool = False
|
||||
|
||||
@property
|
||||
def side(self):
|
||||
return 'bottom' if self.flip else 'top'
|
||||
|
||||
@property
|
||||
def apertures(self):
|
||||
yield PadStackAperture(self.aperture, self.side, 'copper')
|
||||
yield PadStackAperture(self.aperture.dilated(self.mask_expansion, self.unit), self.side, 'mask')
|
||||
if self.paste:
|
||||
yield PadStackAperture(self.aperture.dilated(self.paste_expansion, self.unit), self.side, 'paste')
|
||||
|
||||
@classmethod
|
||||
def rect(kls, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, flip=False, unit=MM):
|
||||
ap = RectangleAperture(w, h, unit=unit).rotated(rotation)
|
||||
return kls(ap, mask_expansion, paste_expansion, paste, flip, unit=unit)
|
||||
|
||||
@classmethod
|
||||
def circle(kls, dia, mask_expansion=0.0, paste_expansion=0.0, paste=True, flip=False, unit=MM):
|
||||
return kls(CircleAperture(dia, unit=unit), mask_expansion, paste_expansion, paste, flip, unit=unit)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MechanicalHoleStack(PadStack):
|
||||
drill_dia: float
|
||||
mask_expansion: float = 0.0
|
||||
mask_aperture = None
|
||||
|
||||
@property
|
||||
def apertures(self):
|
||||
mask_aperture = self.mask_aperture or CircleAperture(self.drill_dia + self.mask_expansion, unit=self.unit)
|
||||
yield PadStackAperture(mask_aperture, 'top', 'mask')
|
||||
yield PadStackAperture(mask_aperture, 'bottom', 'mask')
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class THTPad(PadStack):
|
||||
drill_dia: float
|
||||
pad_top: SMDStack
|
||||
pad_bottom: SMDStack = None
|
||||
aperture_inner: Aperture = UNDEFINED
|
||||
plated: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
if self.pad_bottom is None:
|
||||
object.__setattr__(self, 'pad_bottom', replace(self.pad_top, flip=True))
|
||||
|
||||
if self.aperture_inner is UNDEFINED:
|
||||
object.__setattr__(self, 'aperture_inner', self.pad_top.aperture)
|
||||
|
||||
if self.pad_top.flip:
|
||||
raise ValueError('top pad cannot be flipped')
|
||||
|
||||
@property
|
||||
def plating(self):
|
||||
return 'plated' if self.plated else 'nonplated'
|
||||
|
||||
@property
|
||||
def apertures(self):
|
||||
yield from self.pad_top.apertures
|
||||
yield from self.pad_bottom.apertures
|
||||
if self.aperture_inner is not None:
|
||||
yield PadStackAperture(self.aperture_inner, 'inner', 'copper')
|
||||
yield PadStackAperture(ExcellonTool(self.drill_dia, plated=self.plated, unit=self.unit), 'drill', self.plating)
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def rect(kls, drill_dia, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
|
||||
pad = SMDStack.rect(w, h, rotation, mask_expansion, paste_expansion, paste, unit=unit)
|
||||
return kls(drill_dia, pad, plated=plated)
|
||||
|
||||
@classmethod
|
||||
def circle(kls, drill_dia, dia, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
|
||||
pad = SMDStack.circle(dia, mask_expansion, paste_expansion, paste, unit=unit)
|
||||
return kls(drill_dia, pad, plated=plated)
|
||||
|
||||
@classmethod
|
||||
def obround(kls, drill_dia, w, h, rotation=0, mask_expansion=0.0, paste_expansion=0.0, paste=True, plated=True, unit=MM):
|
||||
ap = ObroundAperture(w, h, unit=unit).rotated(rotation)
|
||||
pad = SMDStack(ap, mask_expansion, paste_expansion, paste, unit=unit)
|
||||
return kls(drill_dia, pad, plated=plated)
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ThroughViaStack(PadStack):
|
||||
hole: float
|
||||
dia: float = None
|
||||
tented: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
if self.dia == None:
|
||||
object.__setattr__(self, 'dia', self.hole*2)
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def apertures(self):
|
||||
copper_aperture = CircleAperture(self.dia, unit=self.unit)
|
||||
yield PadStackAperture(copper_aperture, 'top', 'copper')
|
||||
yield PadStackAperture(copper_aperture, 'bottom', 'copper')
|
||||
yield PadStackAperture(copper_aperture, 'inner', 'copper')
|
||||
if self.tented:
|
||||
yield PadStackAperture(copper_aperture, 'top', 'mask')
|
||||
yield PadStackAperture(copper_aperture, 'bottom', 'mask')
|
||||
yield PadStackAperture(ExcellonTool(self.hole, plated=True, unit=self.unit), 'drill', 'plated')
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Via(FrozenPositioned):
|
||||
pad_stack: PadStack
|
||||
|
||||
def render(self, layer_stack, cache=None):
|
||||
x, y, rotation, flip = self.abs_pos
|
||||
self.pad_stack.render(layer_stack, x, y, rotation, flip)
|
||||
|
||||
@classmethod
|
||||
def at(kls, x, y, hole, dia=None, tented=True, unit=MM):
|
||||
return kls(x, y, ThroughViaStack(hole, dia, tented, unit=unit), unit=unit)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pad(Positioned):
|
||||
pad_stack: PadStack
|
||||
|
||||
def render(self, layer_stack, cache=None):
|
||||
x, y, rotation, flip = self.abs_pos
|
||||
self.pad_stack.render(layer_stack, x, y, rotation, flip)
|
||||
|
||||
@property
|
||||
def single_sided(self):
|
||||
return self.pad_stack.single_sided
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trace:
|
||||
width: float
|
||||
start: object = None
|
||||
end: object = None
|
||||
waypoints: [(float, float)] = field(default_factory=list)
|
||||
style: str = 'oblique'
|
||||
orientation: [str] = tuple() # 'cw' or 'ccw'
|
||||
roundover: float = 0
|
||||
side: str = 'top'
|
||||
unit: LengthUnit = MM
|
||||
parent: object = None
|
||||
|
||||
DIRECT = 'direct'
|
||||
OBLIQUE = 'oblique'
|
||||
ORTHO = 'ortho'
|
||||
|
||||
CW = 'cw'
|
||||
CCW = 'ccw'
|
||||
|
||||
def _route(self, p1, p2, orientation):
|
||||
x1, y1 = p1
|
||||
x2, y2 = p2
|
||||
dx = x2-x1
|
||||
dy = y2-y1
|
||||
|
||||
yield p1
|
||||
|
||||
if self.style == 'direct' or \
|
||||
math.isclose(x1, x2, abs_tol=1e-6) or math.isclose(y1, y2, abs_tol=1e-6) or \
|
||||
(self.style == 'oblique' and math.isclose(dx, dy, abs_tol=1e-6)):
|
||||
return
|
||||
|
||||
p = (abs(dy) > abs(dx)) == ((dx >= 0) == (dy >= 0))
|
||||
if self.style == 'oblique':
|
||||
if p == (orientation == 'cw'):
|
||||
if abs(dy) > abs(dx):
|
||||
yield (x1, y1+sgn(dy)*(abs(dy)-abs(dx)))
|
||||
else:
|
||||
yield (x1+sgn(dx)*(abs(dx)-abs(dy)), y1)
|
||||
else:
|
||||
if abs(dy) > abs(dx):
|
||||
yield (x2, y1+sgn(dy)*abs(dx))
|
||||
else:
|
||||
yield (x1+sgn(dx)*abs(dy), y2)
|
||||
|
||||
else: # self.style == 'ortho'
|
||||
if p == (orientation == 'cw'):
|
||||
if abs(dy) > abs(dx):
|
||||
yield (x1, y2)
|
||||
else:
|
||||
yield (x2, y1)
|
||||
else:
|
||||
if abs(dy) > abs(dx):
|
||||
yield (x2, y1)
|
||||
else:
|
||||
yield (x1, y2)
|
||||
|
||||
@classmethod
|
||||
def _midpoint(kls, p1, p2):
|
||||
x1, y1 = p1
|
||||
x2, y2 = p2
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
xm = x1 + dx / 2
|
||||
ym = y1 + dy / 2
|
||||
return (xm, ym)
|
||||
|
||||
@classmethod
|
||||
def _point_on_line(kls, p1, p2, dist_from_p1):
|
||||
x1, y1 = p1
|
||||
x2, y2 = p2
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
dist = math.dist(p1, p2)
|
||||
if math.isclose(dist, 0, abs_tol=1e-6):
|
||||
return p2
|
||||
xm = x1 + dx / dist * dist_from_p1
|
||||
ym = y1 + dy / dist * dist_from_p1
|
||||
return (xm, ym)
|
||||
|
||||
@classmethod
|
||||
def _angle_between(kls, p1, p2, p3):
|
||||
x1, y1 = p1
|
||||
x2, y2 = p2
|
||||
x3, y3 = p3
|
||||
x1, y1 = x1 - x2, y1 - y2
|
||||
x3, y3 = x3 - x2, y3 - y2
|
||||
dot_product = x1*x3 + y1*y3
|
||||
l1 = math.hypot(x1, y1)
|
||||
l2 = math.hypot(x3, y3)
|
||||
norm = dot_product / l1 / l2
|
||||
return math.acos(min(1, max(-1, norm)))
|
||||
|
||||
def _round_over(self, points, aperture):
|
||||
if math.isclose(self.roundover, 0, abs_tol=1e-6) or len(points) <= 2:
|
||||
import sys
|
||||
for p1, p2 in zip(points[:-1], points[1:]):
|
||||
yield Line(*p1, *p2, aperture=aperture, unit=self.unit)
|
||||
return
|
||||
# here: len(points) >= 3
|
||||
|
||||
line_b = Line(*points[0], *self._midpoint(points[0], points[1]), aperture=aperture, unit=self.unit)
|
||||
|
||||
for p1, p2, p3 in zip(points[:-2], points[1:-1], points[2:]):
|
||||
x1, y1 = p1
|
||||
x2, y2 = p2
|
||||
x3, y3 = p3
|
||||
xa, ya = pa = self._midpoint(p1, p2)
|
||||
xb, yb = pb = self._midpoint(p2, p3)
|
||||
la = math.dist(pa, p2)
|
||||
lb = math.dist(p2, pb)
|
||||
|
||||
alpha = self._angle_between(p1, p2, p3)
|
||||
if alpha == 0:
|
||||
l = Line(line_b.x1, line_b.y1, *p2, aperture=aperture, unit=self.unit)
|
||||
line_b = Line(*p2, *pb, aperture=aperture, unit=self.unit)
|
||||
yield l
|
||||
continue
|
||||
tr = self.roundover/math.tan(alpha/2)
|
||||
t = min(la, lb, tr)
|
||||
r = t*math.tan(alpha/2)
|
||||
|
||||
xs, ys = ps = self._point_on_line(p2, pa, t)
|
||||
xe, ye = pe = self._point_on_line(p2, pb, t)
|
||||
|
||||
if math.isclose(t, la, abs_tol=1e-6):
|
||||
if not math.isclose(line_b.curve_length(), 0, abs_tol=1e-6):
|
||||
yield line_b
|
||||
xs, ys = ps = pa
|
||||
else:
|
||||
yield Line(line_b.x1, line_b.y1, xs, ys, aperture=aperture, unit=self.unit)
|
||||
|
||||
if math.isclose(t, lb, abs_tol=1e-6):
|
||||
xe, ye = pe = pb
|
||||
line_b = Line(*pe, *pb, aperture=aperture, unit=self.unit)
|
||||
|
||||
if math.isclose(r, 0, abs_tol=1e-6):
|
||||
continue
|
||||
|
||||
xc = -(y2 - ys) / t * r
|
||||
yc = +(x2 - xs) / t * r
|
||||
|
||||
xsr = xs - x2
|
||||
ysr = ys - y2
|
||||
xer = xe - x2
|
||||
yer = ye - y2
|
||||
cross_product_z = xsr * yer - ysr * xer
|
||||
|
||||
clockwise = cross_product_z > 0
|
||||
if clockwise:
|
||||
xc, yc = -xc, -yc
|
||||
|
||||
yield Arc(*ps, *pe, xc, yc, clockwise, 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):
|
||||
start, end = self.start, self.end
|
||||
|
||||
if not isinstance(start, tuple):
|
||||
*start, _rotation, _flip = start.abs_pos
|
||||
if not isinstance(end, tuple):
|
||||
*end, _rotation, _flip = end.abs_pos
|
||||
|
||||
aperture = CircleAperture(diameter=self.width, unit=self.unit)
|
||||
|
||||
points_in = [start, *self.waypoints, end]
|
||||
|
||||
points = []
|
||||
for p1, p2, orientation in zip_longest(points_in[:-1], points_in[1:], self.orientation):
|
||||
points.extend(self._route(p1, p2, orientation))
|
||||
points.append(p2)
|
||||
|
||||
return self._round_over(points, aperture)
|
||||
|
||||
def render(self, layer_stack, cache=None):
|
||||
layer_stack[self.side, 'copper'].objects.extend(self.to_graphic_objects())
|
||||
|
||||
def _route_demo():
|
||||
from ..utils import setup_svg, Tag
|
||||
|
||||
def pd_obj(objs):
|
||||
objs = list(objs)
|
||||
yield f'M {objs[0].x1}, {objs[0].y1}'
|
||||
for obj in objs:
|
||||
if isinstance(obj, Line):
|
||||
yield f'L {obj.x2}, {obj.y2}'
|
||||
else:
|
||||
assert isinstance(obj, Arc)
|
||||
yield svg_arc(obj.p1, obj.p2, obj.center_relative, obj.clockwise)
|
||||
|
||||
pd = lambda points: f'M {points[0][0]}, {points[0][1]} ' + ' '.join(f'L {x}, {y}' for x, y in points[1:])
|
||||
|
||||
font = Newstroke()
|
||||
|
||||
tags = []
|
||||
for n in range(0, 8*6):
|
||||
theta = 2*math.pi / (8*6) * n
|
||||
dx, dy = math.cos(theta), math.sin(theta)
|
||||
|
||||
strokes = list(font.render(f'α={n/(8*6)*360}', size=0.2))
|
||||
xs = [x for st in strokes for x, _y in st]
|
||||
ys = [y for st in strokes for _x, y in st]
|
||||
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
xf = f'translate({n//6*1.1 + 0.1} {n%6*1.3 + 0.3}) scale(0.5 0.5) translate(1 1)'
|
||||
txf = f'{xf} translate(0 -1.2) translate({-(max_x-min_x)/2} {-max_y})'
|
||||
|
||||
tags.append(Tag('circle', cx='0', cy='0', r='1',
|
||||
fill='none', stroke='black', opacity='0.5', stroke_width='0.01',
|
||||
transform=xf))
|
||||
tags.append(Tag('path',
|
||||
fill='none',
|
||||
stroke='black', opacity='0.5', stroke_width='0.02', stroke_linejoin='round', stroke_linecap='round',
|
||||
transform=txf, d=' '.join(pd(points) for points in strokes)))
|
||||
|
||||
#for r in [0.0, 0.1, 0.2, 0.3]:
|
||||
for r in [0, 0.2]:
|
||||
#tr = Trace(0.1, style='ortho', roundover=r, start=(0, 0), end=(dx, dy))
|
||||
tr = Trace(0.1, style='oblique', roundover=r, start=(dx, dy), end=(0, 0))
|
||||
#points_cw = list(tr._route((0, 0), (dx, dy), 'cw')) + [(dx, dy)]
|
||||
#points_ccw = list(tr._route((0, 0), (dx, dy), 'ccw')) + [(dx, dy)]
|
||||
tr.orientation = ['cw']
|
||||
objs_cw = tr._to_graphic_objects()
|
||||
tr.orientation = ['ccw']
|
||||
objs_ccw = tr._to_graphic_objects()
|
||||
|
||||
tags.append(Tag('path',
|
||||
fill='none',
|
||||
stroke='red', stroke_width='0.01', stroke_linecap='round',
|
||||
transform=xf, d=' '.join(pd_obj(objs_cw))))
|
||||
tags.append(Tag('path',
|
||||
fill='none',
|
||||
stroke='blue', stroke_width='0.01', stroke_linecap='round',
|
||||
transform=xf, d=' '.join(pd_obj(objs_ccw))))
|
||||
#tags.append(Tag('path',
|
||||
# fill='none',
|
||||
# stroke='red', stroke_width='0.01', stroke_linecap='round',
|
||||
# transform=xf, d=pd(points_cw)))
|
||||
#tags.append(Tag('path',
|
||||
# fill='none',
|
||||
# stroke='blue', stroke_width='0.01', stroke_linecap='round',
|
||||
# transform=xf, d=pd(points_ccw)))
|
||||
|
||||
|
||||
print(setup_svg([Tag('g', tags, transform='scale(20 20)')], [(0, 0), (20*10*1.1 + 0.1, 20*10*1.3 + 0.1)]))
|
||||
|
||||
|
||||
def _board_demo():
|
||||
b = Board(100, 80)
|
||||
p1 = THTPad.rect(10, 10, 0.9, 1.8)
|
||||
b.add(p1)
|
||||
p2 = THTPad.rect(20, 15, 0.9, 1.8)
|
||||
b.add(p2)
|
||||
b.add(Trace(0.5, p1, p2, style='ortho', roundover=1.5))
|
||||
b.add_text(50, 50, 'Foobar')
|
||||
print(b.pretty_svg())
|
||||
b.layer_stack().save_to_directory('/tmp/testdir')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_board_demo()
|
||||
#_route_demo()
|
||||
|
||||
1218
src/gerbonara/cad/protoboard.py
Normal file
199
src/gerbonara/cad/protoserve.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
import importlib.resources
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Quart, request, Response, send_file, abort
|
||||
|
||||
from . import protoboard as pb
|
||||
from . import protoserve_data
|
||||
from .primitives import SMDStack
|
||||
from ..utils import MM, Inch
|
||||
|
||||
|
||||
def extract_importlib(package):
|
||||
root = TemporaryDirectory()
|
||||
|
||||
stack = [(importlib.resources.files(package), Path(root.name))]
|
||||
while stack:
|
||||
res, out = stack.pop()
|
||||
|
||||
for item in res.iterdir():
|
||||
item_out = out / item.name
|
||||
if item.is_file():
|
||||
item_out.write_bytes(item.read_bytes())
|
||||
else:
|
||||
assert item.is_dir()
|
||||
item_out.mkdir()
|
||||
stack.append((item, item_out))
|
||||
|
||||
return root
|
||||
|
||||
static_folder = extract_importlib(protoserve_data)
|
||||
app = Quart(__name__, static_folder=static_folder.name)
|
||||
|
||||
@app.route('/')
|
||||
async def index():
|
||||
return await app.send_static_file('protoserve.html')
|
||||
|
||||
def deserialize(obj, unit):
|
||||
pitch_x = float(obj.get('pitch_x', 1.27))
|
||||
pitch_y = float(obj.get('pitch_y', 1.27))
|
||||
clearance = float(obj.get('clearance', 0.2))
|
||||
|
||||
mil = lambda x: x/1000 if unit == Inch else x
|
||||
|
||||
match obj['type']:
|
||||
case 'layout':
|
||||
if not obj.get('children'):
|
||||
return pb.EmptyProtoArea()
|
||||
|
||||
proportions = [float(child['layout_prop']) for child in obj['children']]
|
||||
content = [deserialize(child, unit) for child in obj['children']]
|
||||
return pb.PropLayout(content, obj['direction'], proportions)
|
||||
|
||||
case 'twoside':
|
||||
top, bottom = obj['children']
|
||||
return pb.TwoSideLayout(deserialize(top, unit), deserialize(bottom, unit))
|
||||
|
||||
case 'placeholder':
|
||||
return pb.EmptyProtoArea()
|
||||
|
||||
case 'smd':
|
||||
match obj['pad_shape']:
|
||||
case 'rect':
|
||||
stack = SMDStack.rect(pitch_x-clearance, pitch_y-clearance, paste=False, unit=unit)
|
||||
case 'circle':
|
||||
stack = SMDStack.circle(min(pitch_x, pitch_y)-clearance, paste=False, unit=unit)
|
||||
return pb.PatternProtoArea(pitch_x, pitch_y, obj=stack, unit=unit)
|
||||
|
||||
case 'tht':
|
||||
hole_dia = mil(float(obj['hole_dia']))
|
||||
match obj['plating']:
|
||||
case 'plated':
|
||||
oneside, plated = False, True
|
||||
case 'nonplated':
|
||||
oneside, plated = False, False
|
||||
case 'singleside':
|
||||
oneside, plated = True, False
|
||||
|
||||
match obj['pad_shape']:
|
||||
case 'rect':
|
||||
pad = pb.THTPad.rect(hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
|
||||
case 'circle':
|
||||
pad = pb.THTPad.circle(hole_dia, min(pitch_x, pitch_y)-clearance, paste=False, plated=plated, unit=unit)
|
||||
case 'obround':
|
||||
pad = pb.THTPad.obround(hole_dia, pitch_x-clearance, pitch_y-clearance, paste=False, plated=plated, unit=unit)
|
||||
|
||||
if oneside:
|
||||
pad.pad_bottom = None
|
||||
|
||||
return pb.PatternProtoArea(pitch_x, pitch_y, obj=pad, unit=unit)
|
||||
|
||||
case 'manhattan':
|
||||
return pb.PatternProtoArea(pitch_x, pitch_y, obj=pb.ManhattanPads(pitch_x, pitch_y, clearance, unit=unit), unit=unit)
|
||||
|
||||
case 'powered':
|
||||
pitch = mil(float(obj.get('pitch', 2.54)))
|
||||
hole_dia = mil(float(obj['hole_dia']))
|
||||
via_drill = mil(float(obj['via_hole_dia']))
|
||||
via_dia = mil(float(obj['via_dia']))
|
||||
trace_width = mil(float(obj['trace_width']))
|
||||
# Force 1mm margin to avoid shorts when adjacent to planes such as that one in the RF THT proto.
|
||||
return pb.PatternProtoArea(pitch, pitch, pb.PoweredProto(pitch, hole_dia, clearance, via_size=via_drill, power_pad_dia=via_dia, trace_width=trace_width, unit=unit), margin=unit(1.0, MM), unit=unit)
|
||||
|
||||
case 'flower':
|
||||
pitch = mil(float(obj.get('pitch', 2.54)))
|
||||
hole_dia = mil(float(obj['hole_dia']))
|
||||
pattern_dia = mil(float(obj['pattern_dia']))
|
||||
clearance = mil(float(obj['clearance']))
|
||||
return pb.PatternProtoArea(pitch, pitch, pb.THTFlowerProto(pitch, hole_dia, pattern_dia, clearance, unit=unit), unit=unit)
|
||||
|
||||
case 'spiky':
|
||||
return pb.PatternProtoArea(2.54, 2.54, pb.SpikyProto(), unit=unit)
|
||||
|
||||
case 'alio':
|
||||
pitch = mil(float(obj.get('pitch', 2.54)))
|
||||
drill = mil(float(obj.get('hole_dia', 0.9)))
|
||||
clearance = mil(float(obj.get('clearance', 0.3)))
|
||||
link_pad_width = mil(float(obj.get('link_pad_width', 1.1)))
|
||||
link_trace_width = mil(float(obj.get('link_trace_width', 0.5)))
|
||||
via_size = mil(float(obj.get('via_hole_dia', 0.4)))
|
||||
return pb.PatternProtoArea(pitch, pitch, pb.AlioCell(
|
||||
pitch=pitch,
|
||||
drill=drill,
|
||||
clearance=clearance,
|
||||
link_pad_width=link_pad_width,
|
||||
link_trace_width=link_trace_width,
|
||||
via_size=via_size
|
||||
), margin=unit(1.5, MM), unit=unit)
|
||||
|
||||
case 'breadboard':
|
||||
horizontal = obj.get('direction', 'v') == 'h'
|
||||
drill = float(obj.get('hole_dia', 0.9))
|
||||
return pb.BreadboardArea(clearance=clearance, drill=drill, horizontal=horizontal, unit=unit)
|
||||
|
||||
case 'starburst':
|
||||
trace_width_x = float(obj.get('trace_width_x', 1.8))
|
||||
trace_width_y = float(obj.get('trace_width_y', 1.8))
|
||||
drill = float(obj.get('hole_dia', 0.9))
|
||||
annular_ring = float(obj.get('annular', 1.2))
|
||||
clearance = float(obj.get('clearance', 0.4))
|
||||
mask_width = float(obj.get('mask_width', 0.5))
|
||||
return pb.PatternProtoArea(pitch_x, pitch_y, pb.StarburstPad(pitch_x, pitch_y, trace_width_x, trace_width_y, clearance, mask_width, drill, annular_ring, unit=unit), unit=unit)
|
||||
|
||||
case 'rf':
|
||||
pitch = float(obj.get('pitch', 2.54))
|
||||
hole_dia = float(obj['hole_dia'])
|
||||
via_dia = float(obj['via_dia'])
|
||||
via_drill = float(obj['via_hole_dia'])
|
||||
return pb.PatternProtoArea(pitch, pitch, pb.RFGroundProto(pitch, hole_dia, clearance, via_dia, via_drill, unit=MM), unit=MM)
|
||||
|
||||
def to_board(obj):
|
||||
unit = Inch if obj.get('units' == 'us') else MM
|
||||
w = float(obj.get('width', unit(100, MM)))
|
||||
h = float(obj.get('height', unit(80, MM)))
|
||||
corner_radius = float(obj.get('round_corners', {}).get('radius', unit(1.5, MM)))
|
||||
margin = float(obj.get('margin', unit(2.0, MM)))
|
||||
holes = obj.get('mounting_holes', {})
|
||||
mounting_hole_dia = float(holes.get('diameter', unit(3.2, MM)))
|
||||
mounting_hole_offset = float(holes.get('offset', unit(5, MM)))
|
||||
|
||||
if obj.get('children'):
|
||||
try:
|
||||
content = deserialize(obj['children'][0], unit)
|
||||
except ValueError:
|
||||
return abort(400)
|
||||
else:
|
||||
content = [pb.EmptyProtoArea()]
|
||||
|
||||
return pb.ProtoBoard(w, h, content,
|
||||
corner_radius=corner_radius,
|
||||
mounting_hole_dia=mounting_hole_dia,
|
||||
mounting_hole_offset=mounting_hole_offset,
|
||||
margin=margin,
|
||||
unit=unit)
|
||||
|
||||
@app.route('/preview_<side>.svg', methods=['POST'])
|
||||
async def preview(side):
|
||||
obj = await request.get_json()
|
||||
board = to_board(obj)
|
||||
return Response(str(board.pretty_svg(side=side)), mimetype='image/svg+xml')
|
||||
|
||||
@app.route('/gerbers.zip', methods=['POST'])
|
||||
async def gerbers():
|
||||
obj = await request.get_json()
|
||||
board = to_board(obj)
|
||||
with NamedTemporaryFile(suffix='.zip') as f:
|
||||
f = Path(f.name)
|
||||
board.layer_stack().save_to_zipfile(f)
|
||||
return Response(f.read_bytes(), mimetype='image/svg+xml')
|
||||
|
||||
def main():
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
0
src/gerbonara/cad/protoserve_data/__init__.py
Normal file
1163
src/gerbonara/cad/protoserve_data/protoserve.html
Normal file
|
|
@ -2,7 +2,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
|
||||
# Copyright 2022 Jan Götte <code@jaseg.de>
|
||||
# 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.
|
||||
|
|
@ -22,8 +22,11 @@ from dataclasses import dataclass
|
|||
from copy import deepcopy
|
||||
from enum import Enum
|
||||
import string
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from functools import cached_property
|
||||
|
||||
from .utils import LengthUnit, MM, Inch, Tag
|
||||
from .utils import LengthUnit, MM, Inch, Tag, sum_bounds, setup_svg, convex_hull
|
||||
from . import graphic_primitives as gp
|
||||
from . import graphic_objects as go
|
||||
|
||||
|
|
@ -41,17 +44,29 @@ class FileSettings:
|
|||
#: (relative) mode is technically still supported, but exceedingly rare in the wild.
|
||||
notation : str = 'absolute'
|
||||
#: Export unit. :py:attr:`~.utilities.MM` or :py:attr:`~.utilities.Inch`
|
||||
unit : LengthUnit = MM
|
||||
unit : LengthUnit = None
|
||||
#: Angle unit. Should be ``'degree'`` unless you really know what you're doing.
|
||||
angle_unit : str = 'degree'
|
||||
#: Zero suppression settings. See note at :py:class:`.FileSettings` for meaning.
|
||||
#: Zero suppression settings. Must be one of ``None``, ``'leading'`` or ``'trailing'``. See note at
|
||||
#: :py:class:`.FileSettings` for meaning in Excellon files. ``None`` will produce explicit decimal points, which
|
||||
#: should work for most tools. For Gerber files, the other settings are fine, but for Excellon files, which lack a
|
||||
#: standardized way to indicate number format, explicit decimal points are the best way to avoid mis-parsing.
|
||||
zeros : bool = None
|
||||
#: Number format. ``(integer, decimal)`` tuple of number of integer and decimal digits. At most ``(6,7)`` by spec.
|
||||
number_format : tuple = (2, 5)
|
||||
number_format : tuple = (None, None)
|
||||
#: At least the aperture macro implementations of gerbv and whatever JLCPCB uses are severely broken and simply
|
||||
#: ignore parentheses in numeric expressions without throwing an error or a warning, leading to broken rendering.
|
||||
#: To avoid trouble with severely broken software like this, we just calculate out all macros by default.
|
||||
#: If you want to export the macros with their original formulaic expressions (which is completely fine by the
|
||||
#: Gerber standard, btw), set this parameter to ``False`` before exporting.
|
||||
calculate_out_all_aperture_macros: bool = True
|
||||
#: Internal field used to communicate if only decimal coordinates were found inside an Excellon file, or if it
|
||||
#: contained at least some coordinates in fixed-width notation.
|
||||
_file_has_fixed_width_coordinates: bool = False
|
||||
|
||||
# input validation
|
||||
def __setattr__(self, name, value):
|
||||
if name == 'unit' and value not in [MM, Inch]:
|
||||
if name == 'unit' and value not in [None, MM, Inch]:
|
||||
raise ValueError(f'Unit must be either Inch or MM, not {value}')
|
||||
elif name == 'notation' and value not in ['absolute', 'incremental']:
|
||||
raise ValueError(f'Notation must be either "absolute" or "incremental", not {value}')
|
||||
|
|
@ -72,6 +87,13 @@ class FileSettings:
|
|||
num = self.number_format[1 if self.zeros == 'leading' else 0] or 0
|
||||
self._pad = '0'*num
|
||||
|
||||
@classmethod
|
||||
def defaults(kls):
|
||||
""" Return a set of good default settings that will work for all gerber or excellon files. These default
|
||||
settings are metric units, 4 integer digits (for up to 10 m by 10 m size), 5 fractional digits (for 10 µm
|
||||
resolution) and :py:obj:`None` zero suppression, meaning that explicit decimal points are going to be used."""
|
||||
return FileSettings(unit=MM, number_format=(4,5), zeros=None)
|
||||
|
||||
def to_radian(self, value):
|
||||
""" Convert a given numeric string or a given float from file units into radians. """
|
||||
value = float(value)
|
||||
|
|
@ -110,13 +132,16 @@ class FileSettings:
|
|||
|
||||
@property
|
||||
def is_metric(self):
|
||||
""" Return true if this :py:class:`.FileSettings` has a defined unit, and that unit is :py:attr:`~.utilities.MM` """
|
||||
return self.unit == MM
|
||||
|
||||
@property
|
||||
def is_inch(self):
|
||||
""" Return true if this :py:class:`.FileSettings` has a defined unit, and that unit is :py:attr:`~.utilities.Inch` """
|
||||
return self.unit == Inch
|
||||
|
||||
def copy(self):
|
||||
""" Create a deep copy of this FileSettings """
|
||||
return deepcopy(self)
|
||||
|
||||
def __str__(self):
|
||||
|
|
@ -138,8 +163,8 @@ class FileSettings:
|
|||
|
||||
if '.' in value or value == '00':
|
||||
return float(value)
|
||||
|
||||
integer_digits, decimal_digits = self.number_format
|
||||
|
||||
integer_digits, decimal_digits = self.number_format or (2, 5)
|
||||
|
||||
if self.zeros == 'leading':
|
||||
value = self._pad + value # pad with zeros to ensure we have enough decimals
|
||||
|
|
@ -155,7 +180,7 @@ class FileSettings:
|
|||
if unit is not None:
|
||||
value = self.unit(value, unit)
|
||||
|
||||
integer_digits, decimal_digits = self.number_format
|
||||
integer_digits, decimal_digits = self.number_format or (2, 5)
|
||||
if integer_digits is None:
|
||||
integer_digits = 3
|
||||
if decimal_digits is None:
|
||||
|
|
@ -185,7 +210,7 @@ class FileSettings:
|
|||
if unit is not None:
|
||||
value = self.unit(value, unit)
|
||||
|
||||
integer_digits, decimal_digits = self.number_format
|
||||
integer_digits, decimal_digits = self.number_format or (2, 5)
|
||||
if integer_digits is None:
|
||||
integer_digits = 2
|
||||
if decimal_digits is None:
|
||||
|
|
@ -231,9 +256,11 @@ class Polyline:
|
|||
return None
|
||||
|
||||
(x0, y0), *rest = self.coords
|
||||
d = f'M {x0:.6} {y0:.6} ' + ' '.join(f'L {x:.6} {y:.6}' for x, y in rest)
|
||||
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||
return tag('path', d=d, style=f'fill: none; stroke: {color}; stroke-width: {width}; stroke-linejoin: round; stroke-linecap: round')
|
||||
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'
|
||||
return tag('path', d=d,
|
||||
fill='none', stroke=color, stroke_linecap='round', stroke_linejoin='round',
|
||||
stroke_width=width)
|
||||
|
||||
|
||||
class CamFile:
|
||||
|
|
@ -246,77 +273,57 @@ class CamFile:
|
|||
self.layer_name = layer_name
|
||||
self.import_settings = import_settings
|
||||
|
||||
@property
|
||||
def is_lazy(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def instance(self):
|
||||
return self
|
||||
|
||||
def to_svg(self, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, fg='black', bg='white', tag=Tag):
|
||||
|
||||
if force_bounds is None:
|
||||
(min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit, default=((0, 0), (0, 0)))
|
||||
if force_bounds:
|
||||
bounds = svg_unit.convert_bounds_from(arg_unit, force_bounds)
|
||||
else:
|
||||
(min_x, min_y), (max_x, max_y) = force_bounds
|
||||
min_x = svg_unit(min_x, arg_unit)
|
||||
min_y = svg_unit(min_y, arg_unit)
|
||||
max_x = svg_unit(max_x, arg_unit)
|
||||
max_y = svg_unit(max_y, arg_unit)
|
||||
bounds = self.bounding_box(svg_unit, default=((0, 0), (0, 0)))
|
||||
|
||||
content_min_x, content_min_y = min_x, min_y
|
||||
content_w, content_h = max_x - min_x, max_y - min_y
|
||||
if margin:
|
||||
margin = svg_unit(margin, arg_unit)
|
||||
min_x -= margin
|
||||
min_y -= margin
|
||||
max_x += margin
|
||||
max_y += margin
|
||||
tags = list(self.svg_objects(svg_unit=svg_unit, tag=tag, fg=fg, bg=bg))
|
||||
|
||||
w, h = max_x - min_x, max_y - min_y
|
||||
w = 1.0 if math.isclose(w, 0.0) else w
|
||||
h = 1.0 if math.isclose(h, 0.0) else h
|
||||
# setup viewport transform flipping y axis
|
||||
(content_min_x, content_min_y), (content_max_x, content_max_y) = bounds
|
||||
content_min_x, content_min_y = float(content_min_x), float(content_min_y)
|
||||
content_max_x, content_max_y = float(content_max_x), float(content_max_y)
|
||||
content_w, content_h = content_max_x - content_min_x, content_max_y - content_min_y
|
||||
xform = f'translate({float(content_min_x):.6} {float(content_min_y+content_h):.6}) scale(1 -1) translate({-float(content_min_x):.6} {-float(content_min_y):.6})'
|
||||
tags = [tag('g', tags, transform=xform)]
|
||||
|
||||
view = tag('sodipodi:namedview', [], id='namedview1', pagecolor=bg,
|
||||
inkscape__document_units=svg_unit.shorthand)
|
||||
return setup_svg(tags, bounds, margin=margin, arg_unit=arg_unit, svg_unit=svg_unit,
|
||||
pagecolor=bg, tag=tag)
|
||||
|
||||
tags = []
|
||||
def svg_objects(self, svg_unit=MM, fg='black', bg='white', aperture_map={}, tag=Tag):
|
||||
pl = None
|
||||
for i, obj in enumerate(self.objects):
|
||||
#if isinstance(obj, go.Flash):
|
||||
# if pl:
|
||||
# tags.append(pl.to_svg(tag, fg, bg))
|
||||
# pl = None
|
||||
if isinstance(obj, go.Flash) and id(obj.aperture) in aperture_map:
|
||||
yield tag('use', href='#'+aperture_map[id(obj.aperture)],
|
||||
x=f'{svg_unit(obj.x, obj.unit):.3f}',
|
||||
y=f'{svg_unit(obj.y, obj.unit):.3f}')
|
||||
|
||||
# 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:
|
||||
else:
|
||||
for primitive in obj.to_primitives(unit=svg_unit):
|
||||
if isinstance(primitive, gp.Line):
|
||||
if not pl:
|
||||
pl = Polyline(primitive)
|
||||
else:
|
||||
if not pl.append(primitive):
|
||||
tags.append(pl.to_svg(fg, bg, tag=tag))
|
||||
yield pl.to_svg(fg, bg, tag=tag)
|
||||
pl = Polyline(primitive)
|
||||
else:
|
||||
if pl:
|
||||
tags.append(pl.to_svg(fg, bg, tag=tag))
|
||||
yield pl.to_svg(fg, bg, tag=tag)
|
||||
pl = None
|
||||
tags.append(primitive.to_svg(fg, bg, tag=tag))
|
||||
yield primitive.to_svg(fg, bg, tag=tag)
|
||||
if pl:
|
||||
tags.append(pl.to_svg(fg, bg, tag=tag))
|
||||
|
||||
# setup viewport transform flipping y axis
|
||||
xform = f'translate({content_min_x} {content_min_y+content_h}) scale(1 -1) translate({-content_min_x} {-content_min_y})'
|
||||
|
||||
svg_unit = 'in' if svg_unit == 'inch' else 'mm'
|
||||
# TODO export apertures as <uses> where reasonable.
|
||||
return tag('svg', [view, tag('g', tags, transform=xform)],
|
||||
width=f'{w}{svg_unit}', height=f'{h}{svg_unit}',
|
||||
viewBox=f'{min_x} {min_y} {w} {h}',
|
||||
xmlns="http://www.w3.org/2000/svg",
|
||||
xmlns__xlink="http://www.w3.org/1999/xlink",
|
||||
xmlns__sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
|
||||
xmlns__inkscape='http://www.inkscape.org/namespaces/inkscape',
|
||||
root=True)
|
||||
yield pl.to_svg(fg, bg, tag=tag)
|
||||
|
||||
def size(self, unit=MM):
|
||||
""" Get the dimensions of the file's axis-aligned bounding box, i.e. the difference in x- and y-direction
|
||||
|
|
@ -342,16 +349,25 @@ class CamFile:
|
|||
:rtype: tuple
|
||||
"""
|
||||
|
||||
bounds = [ p.bounding_box(unit) for p in self.objects ]
|
||||
if not bounds:
|
||||
return default
|
||||
return sum_bounds(( p.bounding_box(unit) for p in self.objects ), default=default)
|
||||
|
||||
min_x = min(x0 for (x0, y0), (x1, y1) in bounds)
|
||||
min_y = min(y0 for (x0, y0), (x1, y1) in bounds)
|
||||
max_x = max(x1 for (x0, y0), (x1, y1) in bounds)
|
||||
max_y = max(y1 for (x0, y0), (x1, y1) in bounds)
|
||||
def convex_hull(self, tol=0.01, unit=None):
|
||||
unit = unit or self.unit
|
||||
points = []
|
||||
|
||||
return ((min_x, min_y), (max_x, max_y))
|
||||
for obj in self.objects:
|
||||
if isinstance(obj, go.Line):
|
||||
line = obj.as_primitive(unit)
|
||||
points.append((line.x1, line.y1))
|
||||
points.append((line.x2, line.y2))
|
||||
|
||||
elif isinstance(obj, go.Arc):
|
||||
for obj in obj.approximate(tol, unit):
|
||||
line = obj.as_primitive(unit)
|
||||
points.append((line.x1, line.y1))
|
||||
points.append((line.x2, line.y2))
|
||||
|
||||
return convex_hull(points)
|
||||
|
||||
def to_excellon(self):
|
||||
""" Convert to a :py:class:`.ExcellonFile`. Returns ``self`` if it already is one. """
|
||||
|
|
@ -364,7 +380,7 @@ class CamFile:
|
|||
def merge(self, other):
|
||||
""" Merge ``other`` into ``self``, i.e. add all objects that are in ``other`` to ``self``. This resets
|
||||
:py:attr:`.import_settings` and :py:attr:`~.CamFile.generator`. Units and other file-specific settings are
|
||||
automatically handled.
|
||||
handled automatically.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
|
@ -405,6 +421,16 @@ class CamFile:
|
|||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def scale(self, factor, unit=MM):
|
||||
""" Scale all objects in this file by the given factor. Only uniform scaling using a single factor in both
|
||||
directions is supported as for both Gerber and Excellon files, nonuniform scaling would distort circular
|
||||
flashes, which would lead to garbage results.
|
||||
|
||||
:param float factor: Scale factor
|
||||
:param unit: :py:class:`.LengthUnit` or str (``'mm'`` or ``'inch'``). Unit ``cx`` and ``cy`` are passed in. Default: mm
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def is_empty(self):
|
||||
""" Check if there are any objects in this file. """
|
||||
|
|
@ -419,3 +445,30 @@ class CamFile:
|
|||
""" Test if this file contains any objects """
|
||||
return not self.is_empty
|
||||
|
||||
class LazyCamFile:
|
||||
""" Helper class for :py:class:`~.layers.LayerStack` that holds a path to an input file without loading it right
|
||||
away. This class'es :py:method:`save` method will just copy the input file instead of parsing and re-serializing
|
||||
it."""
|
||||
def __init__(self, klass, path, *args, **kwargs):
|
||||
self._class = klass
|
||||
self.original_path = Path(path)
|
||||
self._args = args
|
||||
self._kwargs = kwargs
|
||||
|
||||
@cached_property
|
||||
def instance(self):
|
||||
""" Load the input file if necessary, and return the loaded object. Will only load the file once, and cache the
|
||||
result. """
|
||||
return self._class.open(self.original_path, *self._args, **self._kwargs)
|
||||
|
||||
@property
|
||||
def is_lazy(self):
|
||||
return True
|
||||
|
||||
def save(self, filename, *args, **kwargs):
|
||||
""" Copy this Gerber file to the new path. """
|
||||
if 'instance' in self.__dict__: # instance has been loaded, and might have been modified
|
||||
self.instance.save(filename, *args, **kwargs)
|
||||
else:
|
||||
shutil.copy(self.original_path, filename)
|
||||
|
||||
601
src/gerbonara/cli.py
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
#! /usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2023 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.
|
||||
#
|
||||
|
||||
|
||||
import math
|
||||
import click
|
||||
import dataclasses
|
||||
import re
|
||||
import warnings
|
||||
import json
|
||||
import sys
|
||||
import itertools
|
||||
import webbrowser
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
from .utils import MM, Inch
|
||||
from .cam import FileSettings
|
||||
from .rs274x import GerberFile
|
||||
from . import layers as lyr
|
||||
from . import __version__
|
||||
from .cad.kicad import schematic as kc_schematic
|
||||
from .cad.kicad import tmtheme
|
||||
from .cad import protoserve
|
||||
|
||||
|
||||
def _showwarning(message, category, filename, lineno, file=None, line=None):
|
||||
if file is None:
|
||||
file = sys.stderr
|
||||
|
||||
filename = Path(filename)
|
||||
gerbonara_module_install_location = Path(__file__).parent.parent
|
||||
if filename.is_relative_to(gerbonara_module_install_location):
|
||||
filename = filename.relative_to(gerbonara_module_install_location)
|
||||
|
||||
print(f'{filename}:{lineno}: {message}', file=file)
|
||||
warnings.showwarning = _showwarning
|
||||
|
||||
def _print_version(ctx, param, value):
|
||||
if value and not ctx.resilient_parsing:
|
||||
click.echo(f'Version {__version__}')
|
||||
ctx.exit()
|
||||
|
||||
|
||||
def _apply_transform(transform, unit, layer_or_stack):
|
||||
def translate(x, y):
|
||||
layer_or_stack.offset(x, y, unit)
|
||||
|
||||
def scale(factor):
|
||||
""" Scale layer by a given factor, e.g. 1.0 for no change, 2.0 to double all coordinates in both axes. Note that
|
||||
we only offer uniform scaling with a single factor applied along both coordinate axes because anything else
|
||||
would not be possible with arbitrary Gerber apertures, and definitely mess up holes. We could still do this, but
|
||||
the result would almost certainly not be what the user is looking for.
|
||||
|
||||
The main reason why this function might make sense is to fix up boards exported as G-code by programs that
|
||||
aren't EDA tools and that for whatever reason ended up exporting in a weird unit."""
|
||||
layer_or_stack.scale(factor)
|
||||
|
||||
def rotate(angle, cx=0, cy=0):
|
||||
layer_or_stack.rotate(math.radians(angle), cx, cy, unit)
|
||||
|
||||
(x_min, y_min), (x_max, y_max) = layer_or_stack.bounding_box(unit, default=((0, 0), (0, 0)))
|
||||
width, height = x_max - x_min, y_max - y_min
|
||||
|
||||
def origin():
|
||||
translate(-x_min, -y_min)
|
||||
|
||||
def center():
|
||||
translate(-x_min-width/2, -y_min-height/2)
|
||||
|
||||
exec(transform, {key: value for key, value in math.__dict__.items() if not key.startswith('_')}, locals())
|
||||
|
||||
|
||||
class Coordinate(click.ParamType):
|
||||
name = 'coordinate'
|
||||
|
||||
def __init__(self, dimension=2):
|
||||
self.dimension = dimension
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
try:
|
||||
coords = [float(e) for e in value.split(',')]
|
||||
if len(coords) != self.dimension:
|
||||
raise ValueError()
|
||||
return coords
|
||||
|
||||
except ValueError:
|
||||
self.fail(f'{value!r} is not a valid coordinate. A coordinate consists of exactly {self.dimension} comma-separate floating-point numbers.')
|
||||
|
||||
class Rotation(click.ParamType):
|
||||
name = 'rotation'
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
try:
|
||||
coords = [float(e) for e in value.split(',')]
|
||||
if len(coords) not in (1, 3):
|
||||
raise ValueError()
|
||||
|
||||
theta, x, y, *_rest = *coords, 0, 0
|
||||
return theta, x, y
|
||||
|
||||
except ValueError:
|
||||
self.fail(f'{value!r} is not a valid rotation. A rotation is either a floating point angle ("[theta]"), or the same angle followed by comma-separated X and Y coordinates of the rotation center ("[theta],[cx],[cy]").')
|
||||
|
||||
|
||||
class Unit(click.Choice):
|
||||
name = 'unit'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(['metric', 'us-customary'])
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
value = super().convert(value, param, ctx)
|
||||
return MM if value == 'metric' else Inch
|
||||
|
||||
|
||||
class NamingScheme(click.Choice):
|
||||
name = 'naming_scheme'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__([n for n in dir(lyr.NamingScheme) if not n.startswith('_')])
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
return getattr(lyr.NamingScheme, super().convert(value, param, ctx))
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
|
||||
def cli():
|
||||
""" The gerbonara CLI allows you to analyze, render, modify and merge both individual Gerber or Excellon files as
|
||||
well as sets of those files """
|
||||
pass
|
||||
|
||||
@cli.group('protoboard')
|
||||
def protoboard_group():
|
||||
pass
|
||||
|
||||
|
||||
@protoboard_group.command()
|
||||
@click.option('-h', '--host', default=None, help='Hostname to listen on. Defaults to localhost.')
|
||||
@click.option('-p', '--port', type=int, default=1337, help='Port to listen on. Defaults to 1337')
|
||||
def interactive(host, port):
|
||||
''' Launch gerbonar's interactive protoboard designer in your browser '''
|
||||
|
||||
if host is None:
|
||||
@protoserve.app.before_serving
|
||||
async def open_browser():
|
||||
webbrowser.open_new(f'http://localhost:{port}/')
|
||||
protoserve.app.run(host=host, port=port, use_reloader=False, debug=False)
|
||||
|
||||
|
||||
@cli.group('kicad')
|
||||
def kicad_group():
|
||||
pass
|
||||
|
||||
|
||||
@kicad_group.group('schematic')
|
||||
def schematic_group():
|
||||
pass
|
||||
|
||||
|
||||
@schematic_group.command()
|
||||
@click.argument('inpath', type=click.Path(exists=True))
|
||||
@click.argument('theme', type=click.Path(exists=True))
|
||||
@click.argument('outfile', type=click.File('w'), default='-')
|
||||
def render(inpath, theme, outfile):
|
||||
sch = kc_schematic.Schematic.open(inpath)
|
||||
cs = tmtheme.TmThemeSchematic(Path(theme).read_text())
|
||||
with outfile as f:
|
||||
f.write(str(sch.to_svg(cs)))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
|
||||
help='''Enable or disable file format warnings during parsing (default: on)''')
|
||||
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
|
||||
@click.option('-m', '--input-map', type=click.Path(exists=True, path_type=Path), help='''Extend or override layer name
|
||||
mapping with name map from JSON file. The JSON file must contain a single JSON dict with an arbitrary
|
||||
number of string: string entries. The keys are interpreted as regexes applied to the filenames via
|
||||
re.fullmatch, and each value must either be the string "ignore" to remove this layer from previous
|
||||
automatic guesses, or a gerbonara layer name such as "top copper", "inner_2 copper" or "bottom silk".''')
|
||||
@click.option('--use-builtin-name-rules/--no-builtin-name-rules', default=True, help='''Disable built-in layer name
|
||||
rules and use only rules given by --input-map''')
|
||||
@click.option('--force-zip', is_flag=True, help='''Force treating input path as a zip file (default: guess file type
|
||||
from extension and contents)''')
|
||||
@click.option('--top', 'side', flag_value='top', help='Render top side')
|
||||
@click.option('--bottom', 'side', flag_value='bottom', help='Render top side')
|
||||
@click.option('--command-line-units', type=Unit(), help='''Units for values given in other options. Default:
|
||||
millimeter''')
|
||||
@click.option('--margin', type=float, default=0.0, help='Add space around the board inside the viewport')
|
||||
@click.option('--force-bounds', help='Force SVG bounding box to value given as "min_x,min_y,max_x,max_y"')
|
||||
@click.option('--inkscape/--standard-svg', default=True, help='Export in Inkscape SVG format with layers and stuff.')
|
||||
@click.option('--pretty/--no-filters', default=True, help='''Export pseudo-realistic render using filters (default) or
|
||||
just stack up layers using given colorscheme. In "--no-filters" mode, by default all layers are exported
|
||||
unless either "--top" or "--bottom" is given.''')
|
||||
@click.option('--drills/--no-drills', default=True, help='''Include (default) or exclude drills ("--no-filters" only!)''')
|
||||
@click.option('--colorscheme', type=click.Path(exists=True, path_type=Path), help='''Load colorscheme from given JSON
|
||||
file. The JSON file must contain a single dict with keys copper, silk, mask, paste, drill and outline.
|
||||
Each key must map to a string containing either a normal 6-digit hex color with leading hash sign, or an
|
||||
8-digit hex color with leading hash sign, where the last two digits set the layer's alpha value (opacity),
|
||||
with FF being completely opaque, and 00 being invisibly transparent.''')
|
||||
@click.argument('inpath', type=click.Path(exists=True))
|
||||
@click.argument('outfile', type=click.File('w'), default='-')
|
||||
def render(inpath, outfile, format_warnings, input_map, use_builtin_name_rules, force_zip, side, drills,
|
||||
command_line_units, margin, force_bounds, inkscape, pretty, colorscheme):
|
||||
""" 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
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter(format_warnings)
|
||||
if force_zip:
|
||||
stack = lyr.LayerStack.open_zip(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
else:
|
||||
stack = lyr.LayerStack.open(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
|
||||
if force_bounds:
|
||||
min_x, min_y, max_x, max_y = list(map(float, force_bounds.split(',')))
|
||||
force_bounds = (min_x, min_y), (max_x, max_y)
|
||||
|
||||
if colorscheme:
|
||||
colorscheme = json.loads(colorscheme.read_text())
|
||||
|
||||
if pretty:
|
||||
svg = stack.to_pretty_svg(side='bottom' if side == 'bottom' else 'top', margin=margin,
|
||||
arg_unit=(command_line_units or MM),
|
||||
svg_unit=MM, force_bounds=force_bounds, inkscape=inkscape, colors=colorscheme)
|
||||
else:
|
||||
svg = stack.to_svg(side_re=side or '.*', margin=margin, drills=drills, arg_unit=(command_line_units or MM),
|
||||
svg_unit=MM, force_bounds=force_bounds, colors=colorscheme)
|
||||
outfile.write(str(svg))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
|
||||
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
|
||||
help='''Enable or disable file format warnings during parsing (default: on)''')
|
||||
@click.option('-t', '--transform', help='''Execute python transformation script on input. You have access to the
|
||||
functions translate(x, y), scale(factor) and rotate(angle, center_x?, center_y?), the bounding box
|
||||
variables x_min, y_min, x_max, y_max, width and height, and everything from python\'s built-in math module
|
||||
(e.g. pi, sqrt, sin). As convenience methods, center() and origin() are provided to center the board resp.
|
||||
move its bottom-left corner to the origin. Coordinates are given in --command-line-units, angles in
|
||||
degrees, and scale as a scale factor (as opposed to a percentage). Example: "translate(-10, 0); rotate(45,
|
||||
0, 5)"''')
|
||||
@click.option('--command-line-units', type=Unit(), help='''Units for values given in other options. Default:
|
||||
millimeter''')
|
||||
@click.option('-n', '--number-format', help='''Override number format to use during export in "[integer digits].[decimal
|
||||
digits]" notation, e.g. "2.6".''')
|
||||
@click.option('-u', '--units', type=Unit(), help='Override export file units')
|
||||
@click.option('-z', '--zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='''Override export
|
||||
zero suppression setting. Note: The meaning of this value is like in the Gerber spec for both Gerber and
|
||||
Excellon files!''')
|
||||
@click.option('--keep-comments/--drop-comments', help='''Keep gerber comments. Note: Comments will be prepended to the
|
||||
start of file, and will not occur in their old position.''')
|
||||
@click.option('--reuse-input-settings', 'output_format', flag_value='reuse', help='''Use the same export settings as the
|
||||
input file instead of sensible defaults.''')
|
||||
@click.option('--default-settings', 'output_format', default=True, flag_value='defaults', help='''Use sensible defaults
|
||||
for the output file format settings (default).''')
|
||||
@click.option('--input-number-format', help='Override number format of input file (mostly useful for Excellon files)')
|
||||
@click.option('--input-units', type=Unit(), help='Override units of input file')
|
||||
@click.option('--input-zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='''Override zero
|
||||
suppression setting of input file''')
|
||||
@click.argument('infile')
|
||||
@click.argument('outfile')
|
||||
def rewrite(transform, command_line_units, number_format, units, zero_suppression, keep_comments, output_format,
|
||||
input_number_format, input_units, input_zero_suppression, infile, outfile, format_warnings):
|
||||
""" Parse a single gerber file, apply transformations, and re-serialize it into a new gerber file. Without
|
||||
transformations, this command can be used to convert a gerber file to use different settings (e.g. units,
|
||||
precision), but can also be used to "normalize" gerber files in a weird format into a more standards-compatible one
|
||||
as gerbonara's gerber parser is significantly more robust for weird inputs than others. """
|
||||
|
||||
input_settings = FileSettings()
|
||||
if input_number_format:
|
||||
a, _, b = input_number_format.partition('.')
|
||||
input_settings.number_format = (int(a), int(b))
|
||||
|
||||
if input_zero_suppression:
|
||||
input_settings.zeros = None if input_zero_suppression == 'off' else input_zero_suppression
|
||||
|
||||
input_settings.unit = input_units
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter(format_warnings)
|
||||
f = GerberFile.open(infile, override_settings=input_settings)
|
||||
|
||||
if transform:
|
||||
_apply_transform(transform, command_line_units or MM, f)
|
||||
|
||||
output_format = f.import_settings if output_format == 'reuse' else FileSettings.defaults()
|
||||
if number_format:
|
||||
a, _, b = number_format.partition('.')
|
||||
output_format.number_format = (int(a), int(b))
|
||||
|
||||
if units:
|
||||
output_format.unit = units
|
||||
|
||||
if zero_suppression:
|
||||
output_format.zeros = None if zero_suppression == 'off' else zero_suppression
|
||||
|
||||
f.save(outfile, output_format, not keep_comments)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
|
||||
@click.option('-m', '--input-map', type=click.Path(exists=True, path_type=Path), help='''Extend or override layer name
|
||||
mapping with name map from JSON file. The JSON file must contain a single JSON dict with an arbitrary
|
||||
number of string: string entries. The keys are interpreted as regexes applied to the filenames via
|
||||
re.fullmatch, and each value must either be the string "ignore" to remove this layer from previous
|
||||
automatic guesses, or a gerbonara layer name such as "top copper", "inner_2 copper" or "bottom silk".''')
|
||||
@click.option('--use-builtin-name-rules/--no-builtin-name-rules', default=True, help='''Disable built-in layer name
|
||||
rules and use only rules given by --input-map''')
|
||||
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
|
||||
help='''Enable or disable file format warnings during parsing (default: on)''')
|
||||
@click.option('--units', type=Unit(), help='Units for values given in other options. Default: millimeter')
|
||||
@click.option('-n', '--number-format', help='''Override number format to use during export in
|
||||
"[integer digits].[decimal digits]" notation, e.g. "2.6".''')
|
||||
@click.option('--reuse-input-settings', 'output_format', flag_value='reuse', help='''Use the same export settings as the
|
||||
input file instead of sensible defaults.''')
|
||||
@click.option('--default-settings', 'output_format', default=True, flag_value='defaults', help='''Use sensible defaults
|
||||
for the output file format settings (default).''')
|
||||
@click.option('--force-zip', is_flag=True, help='''Force treating input path as a zip file (default: guess file type
|
||||
from extension and contents)''')
|
||||
@click.option('--output-naming-scheme', type=NamingScheme(), help=f'''Name output files according to the selected naming
|
||||
scheme instead of keeping the old file names.''')
|
||||
@click.argument('transform')
|
||||
@click.argument('inpath')
|
||||
@click.argument('outpath', type=click.Path(path_type=Path))
|
||||
def transform(transform, units, output_format, inpath, outpath, format_warnings, input_map, use_builtin_name_rules,
|
||||
output_naming_scheme, number_format, force_zip):
|
||||
""" Transform all gerber files in a given directory or zip file using the given python transformation script.
|
||||
|
||||
In the python transformation script you have access to the functions translate(x, y), scale(factor) and
|
||||
rotate(angle, center_x?, center_y?), the bounding box variables x_min, y_min, x_max, y_max, width and height,
|
||||
and everything from python\'s built-in math module (e.g. pi, sqrt, sin). As convenience methods, center() and
|
||||
origin() are provided to center the board resp. move its bottom-left corner to the origin. Coordinates are given
|
||||
in --command-line-units, angles in degrees, and scale as a scale factor (as opposed to a percentage). Example:
|
||||
"translate(-10, 0); rotate(45, 0, 5)"''')
|
||||
"""
|
||||
|
||||
overrides = json.loads(input_map.read_bytes()) if input_map else None
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter(format_warnings)
|
||||
if force_zip:
|
||||
stack = lyr.LayerStack.open_zip(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
else:
|
||||
stack = lyr.LayerStack.open(inpath, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
|
||||
_apply_transform(transform, units, stack)
|
||||
|
||||
output_format = None if output_format == 'reuse' else FileSettings.defaults()
|
||||
if number_format:
|
||||
if output_format is None:
|
||||
output_format = FileSettings.defaults()
|
||||
a, _, b = number_format.partition('.')
|
||||
output_format.number_format = (int(a), int(b))
|
||||
if outpath.is_file() or outpath.suffix.lower() == '.zip':
|
||||
stack.save_to_zipfile(outpath, naming_scheme=output_naming_scheme or {},
|
||||
gerber_settings=output_format,
|
||||
excellon_settings=dataclasses.replace(output_format, zeros=None))
|
||||
else:
|
||||
stack.save_to_directory(outpath, naming_scheme=output_naming_scheme or {},
|
||||
gerber_settings=output_format,
|
||||
excellon_settings=dataclasses.replace(output_format, zeros=None))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
|
||||
@click.option('--command-line-units', type=Unit(), help='''Units for values given in --transform. Default:
|
||||
millimeter''')
|
||||
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
|
||||
help='''Enable or disable file format warnings during parsing (default: on)''')
|
||||
@click.option('--offset', multiple=True, type=Coordinate(), help="""Offset for the n'th file as a "x,y" string in unit
|
||||
given by --command-line-units (default: millimeter). Can be given multiple times, and the first option
|
||||
affects the first input, the second option affects the second input, and so on.""")
|
||||
@click.option('--rotation', multiple=True, type=Rotation(), help="""Rotation for the n'th file in degrees clockwise,
|
||||
optionally followed by comma-separated rotation center X and Y coordinates. Can be given multiple times,
|
||||
and the first option affects the first input, the second option affects the second input, and so on.""")
|
||||
@click.option('-m', '--input-map', type=click.Path(exists=True, path_type=Path), multiple=True, help='''Extend or
|
||||
override layer name mapping with name map from JSON file. This option can be given multiple times, in
|
||||
which case the n'th option affects only the n'th input, like with --offset and --rotation. The JSON file
|
||||
must contain a single JSON dict with an arbitrary number of string: string entries. The keys are
|
||||
interpreted as regexes applied to the filenames via re.fullmatch, and each value must either be the string
|
||||
"ignore" to remove this layer from previous automatic guesses, or a gerbonara layer name such as "top
|
||||
copper", "inner_2 copper" or "bottom silk".''')
|
||||
@click.option('--reuse-input-settings', 'output_format', flag_value='reuse', help='''Use the same export settings as the
|
||||
input file instead of sensible defaults.''')
|
||||
@click.option('--default-settings', 'output_format', default=True, flag_value='defaults', help='''Use sensible defaults
|
||||
for the output file format settings (default).''')
|
||||
@click.option('--output-naming-scheme', type=NamingScheme(), help=f'''Name output files according to the selected naming
|
||||
scheme instead of keeping the old file names of the first input.''')
|
||||
@click.option('--output-board-name', help=f'''Override board name used with --output-naming-scheme''')
|
||||
@click.option('--use-builtin-name-rules/--no-builtin-name-rules', default=True, help='''Disable built-in layer name
|
||||
rules and use only rules given by --input-map''')
|
||||
@click.argument('inpath', nargs=-1, type=click.Path(exists=True, path_type=Path))
|
||||
@click.argument('outpath', type=click.Path(path_type=Path))
|
||||
def merge(inpath, outpath, offset, rotation, input_map, command_line_units, output_format, output_naming_scheme,
|
||||
output_board_name, format_warnings, use_builtin_name_rules):
|
||||
""" Merge multiple single Gerber or Excellon files, or multiple stacks of Gerber files, into one. Hint: When used
|
||||
with only one input, this command "normalizes" the input, converting all files to a well-defined, widely supported
|
||||
Gerber subset with sane settings. When a --output-naming-scheme is given, it additionally renames all files to a
|
||||
standardized naming convention. """
|
||||
if not inpath:
|
||||
return
|
||||
|
||||
target = None
|
||||
for p, offset, rotation, input_map in itertools.zip_longest(inpath, offset, rotation, input_map):
|
||||
if p is None:
|
||||
raise click.UsageError('More --offset, --rotation or --input-map options than input files')
|
||||
|
||||
offset = offset or (0, 0)
|
||||
theta, cx, cy = rotation or (0, 0, 0)
|
||||
|
||||
overrides = json.loads(input_map.read_bytes()) if input_map else None
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter(format_warnings)
|
||||
|
||||
stack = lyr.LayerStack.open(p, overrides=overrides, autoguess=use_builtin_name_rules)
|
||||
|
||||
if not math.isclose(offset[0], 0, abs_tol=1e-3) and math.isclose(offset[1], 0, abs_tol=1e-3):
|
||||
stack.offset(*offset, command_line_units or MM)
|
||||
|
||||
if not math.isclose(theta, 0, abs_tol=1e-2):
|
||||
stack.rotate(theta, cx, cy)
|
||||
|
||||
if target is None:
|
||||
target = stack
|
||||
else:
|
||||
target.merge(stack)
|
||||
|
||||
if output_board_name:
|
||||
if not output_naming_scheme:
|
||||
warnings.warn('--output-board-name given without --output-naming-scheme. This will be ignored.')
|
||||
target.board_name = output_board_name
|
||||
output_format = None if output_format == 'reuse' else FileSettings.defaults()
|
||||
target.save_to_directory(outpath, naming_scheme=output_naming_scheme or {},
|
||||
gerber_settings=output_format,
|
||||
excellon_settings=dataclasses.replace(output_format, zeros=None))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
|
||||
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
|
||||
help='''Enable or disable file format warnings during parsing (default: on)''')
|
||||
@click.option('--units', type=Unit(), default='metric', help='Output bounding box in this unit (default: millimeter)')
|
||||
@click.option('--input-number-format', help='Override number format of input file (mostly useful for Excellon files)')
|
||||
@click.option('--input-units', type=Unit(), help='Override units of input file')
|
||||
@click.option('--input-zero-suppression', type=click.Choice(['off', 'leading', 'trailing']), help='Override zero suppression setting of input file')
|
||||
@click.argument('infile')
|
||||
def bounding_box(infile, format_warnings, input_number_format, input_units, input_zero_suppression, units):
|
||||
""" Print the bounding box of a gerber file in "[x_min] [y_min] [x_max] [y_max]" format. The bounding box contains
|
||||
all graphic objects in this file, so e.g. a 100 mm by 100 mm square drawn with a 1mm width circular aperture will
|
||||
result in an 101 mm by 101 mm bounding box.
|
||||
"""
|
||||
|
||||
input_settings = FileSettings()
|
||||
if input_number_format:
|
||||
a, _, b = input_number_format.partition('.')
|
||||
input_settings.number_format = (int(a), int(b))
|
||||
|
||||
if input_zero_suppression:
|
||||
input_settings.zeros = None if input_zero_suppression == 'off' else input_zero_suppression
|
||||
|
||||
input_settings.unit = input_units
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter(format_warnings)
|
||||
f = GerberFile.open(infile, override_settings=input_settings)
|
||||
|
||||
(x_min, y_min), (x_max, y_max) = f.bounding_box(unit=units)
|
||||
print(f'{x_min:.6f} {y_min:.6f} {x_max:.6f} {y_max:.6f} [{units}]')
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--version', is_flag=True, callback=_print_version, expose_value=False, is_eager=True)
|
||||
@click.option('--warnings', 'format_warnings', type=click.Choice(['default', 'ignore', 'once']), default='default',
|
||||
help='''Enable or disable file format warnings during parsing (default: on)''')
|
||||
@click.option('--force-zip', is_flag=True, help='Force treating input path as zip file (default: guess file type from extension and contents)')
|
||||
@click.argument('path', type=click.Path(exists=True))
|
||||
def layers(path, force_zip, format_warnings):
|
||||
""" Read layers from a directory or zip with Gerber files and list the found layer / path assignment. """
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter(format_warnings)
|
||||
if force_zip:
|
||||
stack = lyr.LayerStack.open_zip(path)
|
||||
else:
|
||||
stack = lyr.LayerStack.open(path)
|
||||
|
||||
print(f'Detected board name: {stack.board_name}')
|
||||
print(f'Probably exported by: {stack.generator or "Unknown"}')
|
||||
print(f'Board bounding box: {stack.bounding_box()} [mm]')
|
||||
|
||||
if stack.netlist:
|
||||
print(f'Found netlist at {stack.netlist.original_path}')
|
||||
else:
|
||||
print('No netlist found')
|
||||
|
||||
print('Graphical layers:')
|
||||
for (side, function), layer in stack.graphic_layers.items():
|
||||
print(f'{side} {function}: {layer}')
|
||||
if not stack.graphic_layers:
|
||||
print('(no graphical layers)')
|
||||
|
||||
print('Drill layers:')
|
||||
for layer in stack.drill_layers:
|
||||
print(layer)
|
||||
if not stack.drill_layers:
|
||||
print('(no drill layers)')
|
||||
|
||||
|
||||
@cli.command()
|
||||
@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']), help='''Enable or
|
||||
disable file format warnings during parsing (default: on)''')
|
||||
@click.option('--force-zip', is_flag=True, help='Force treating input path as zip file (default: guess file type from extension and contents)')
|
||||
@click.argument('path', type=click.Path(exists=True))
|
||||
def meta(path, force_zip, format_warnings):
|
||||
""" Extract layer mapping and print it along with layer metadata as JSON to stdout. A machine-readable variant of
|
||||
the "layers" command. All lengths in the JSON are given in millimeter. """
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter(format_warnings)
|
||||
if force_zip:
|
||||
stack = lyr.LayerStack.open_zip(path)
|
||||
else:
|
||||
stack = lyr.LayerStack.open(path)
|
||||
|
||||
out = {}
|
||||
out['board_name'] = stack.board_name
|
||||
out['generator'] = stack.generator
|
||||
(min_x, min_y), (max_x, max_y) = stack.bounding_box(default=((None, None), (None, None)))
|
||||
out['bounding_box'] = {'min_x': min_x, 'min_y': min_y, 'max_x': max_x, 'max_y': max_y}
|
||||
out['path'] = str(stack.original_path)
|
||||
|
||||
if stack.netlist:
|
||||
out['netlist'] = {
|
||||
'format': 'IPC-356',
|
||||
'path': str(stack.netlist.original_path),
|
||||
'records': len(stack.netlist.test_records),
|
||||
'conductors': len(stack.netlist.conductors),
|
||||
'outlines': len(stack.netlist.outlines),
|
||||
}
|
||||
|
||||
out['graphical_layers'] = {}
|
||||
for (side, function), layer in stack.graphic_layers.items():
|
||||
d = out['graphical_layers'][side] = out['graphical_layers'].get(side, {})
|
||||
(min_x, min_y), (max_x, max_y) = layer.bounding_box(default=((None, None), (None, None)))
|
||||
|
||||
if layer.import_settings:
|
||||
numf = layer.import_settings.number_format
|
||||
format_settings = {
|
||||
'unit': str(layer.import_settings.unit),
|
||||
'number_format': f'{numf[0]}.{numf[1]}' if numf else None,
|
||||
'zero_suppression': str(layer.import_settings.zeros),
|
||||
}
|
||||
|
||||
d[function] = {
|
||||
'format': 'Gerber',
|
||||
'path': str(layer.original_path),
|
||||
'apertures': len(list(layer.apertures())),
|
||||
'objects': len(layer.objects),
|
||||
'bounding_box': {'min_x': min_x, 'min_y': min_y, 'max_x': max_x, 'max_y': max_y},
|
||||
'format_settings': format_settings,
|
||||
}
|
||||
|
||||
out['drill_layers'] = []
|
||||
for layer in stack.drill_layers:
|
||||
if layer.import_settings:
|
||||
numf = layer.import_settings.number_format
|
||||
format_settings = {
|
||||
'unit': str(layer.import_settings.unit),
|
||||
'number_format': f'{numf[0]}.{numf[1]}' if numf else None,
|
||||
'zero_suppression': str(layer.import_settings.zeros),
|
||||
}
|
||||
|
||||
out['drill_layers'].append({
|
||||
'format': 'Excellon',
|
||||
'path': str(layer.original_path),
|
||||
'plating': layer.plating_type,
|
||||
'format_settings': format_settings,
|
||||
})
|
||||
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
||||
|
||||
0
src/gerbonara/data/__init__.py
Normal file
65743
src/gerbonara/data/newstroke_font.cpp
Normal file
|
|
@ -2,7 +2,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
|
||||
# Copyright 2022 Jan Götte <code@jaseg.de>
|
||||
# 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.
|
||||
|
|
@ -30,9 +30,10 @@ from pathlib import Path
|
|||
|
||||
from .cam import CamFile, FileSettings
|
||||
from .graphic_objects import Flash, Line, Arc
|
||||
from .apertures import ExcellonTool
|
||||
from .apertures import ExcellonTool, CircleAperture
|
||||
from .utils import Inch, MM, to_unit, InterpMode, RegexMatcher
|
||||
|
||||
|
||||
class ExcellonContext:
|
||||
""" Internal helper class used for tracking graphics state when writing Excellon. """
|
||||
|
||||
|
|
@ -46,13 +47,15 @@ class ExcellonContext:
|
|||
|
||||
def select_tool(self, tool):
|
||||
""" Select the current tool. Retract drill first if necessary. """
|
||||
if self.current_tool != tool:
|
||||
current_id = self.tools.get(self.current_tool)
|
||||
new_id = self.tools[tool]
|
||||
if new_id != current_id:
|
||||
if self.drill_down:
|
||||
yield 'M16' # drill up
|
||||
self.drill_down = False
|
||||
|
||||
self.current_tool = tool
|
||||
yield f'T{self.tools[id(tool)]:02d}'
|
||||
yield f'T{new_id:02d}'
|
||||
|
||||
def drill_mode(self):
|
||||
""" Enter drill mode. """
|
||||
|
|
@ -162,6 +165,8 @@ def parse_allegro_logfile(data):
|
|||
return found_tools
|
||||
|
||||
def parse_zuken_logfile(data):
|
||||
""" Internal function to parse Excellon format information out of Zuken's nonstandard textual log files that their
|
||||
tools generate along with the Excellon file. """
|
||||
lines = [ line.strip() for line in data.splitlines() ]
|
||||
if '***** DRILL LIST *****' not in lines:
|
||||
return # likely not a Zuken CR-8000 logfile
|
||||
|
|
@ -207,19 +212,22 @@ class ExcellonFile(CamFile):
|
|||
|
||||
def __str__(self):
|
||||
name = f'{self.original_path.name} ' if self.original_path else ''
|
||||
if self.is_plated:
|
||||
plating = 'plated'
|
||||
elif self.is_nonplated:
|
||||
plating = 'nonplated'
|
||||
elif self.is_mixed_plating:
|
||||
plating = 'mixed plating'
|
||||
else:
|
||||
plating = 'unknown plating'
|
||||
return f'<ExcellonFile {name}{plating} with {len(list(self.drills()))} drills, {len(list(self.slots()))} slots using {len(self.drill_sizes())} tools>'
|
||||
return f'<ExcellonFile {name}{self.plating_type} with {len(list(self.drills()))} drills, {len(list(self.slots()))} slots using {len(self.drill_sizes())} tools>'
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
@property
|
||||
def plating_type(self):
|
||||
if self.is_plated:
|
||||
return 'plated'
|
||||
elif self.is_nonplated:
|
||||
return 'nonplated'
|
||||
elif self.is_mixed_plating:
|
||||
return 'mixed plating'
|
||||
else:
|
||||
return 'unknown plating'
|
||||
|
||||
@property
|
||||
def is_plated(self):
|
||||
""" Test if *all* holes or slots in this file are plated. """
|
||||
|
|
@ -240,41 +248,57 @@ class ExcellonFile(CamFile):
|
|||
""" Test if there are multiple plating values used in this file. """
|
||||
return len({obj.plated for obj in self.objects}) > 1
|
||||
|
||||
@property
|
||||
def is_plated_tristate(self):
|
||||
if self.is_plated:
|
||||
return True
|
||||
|
||||
if self.is_nonplated:
|
||||
return False
|
||||
|
||||
return None
|
||||
|
||||
def append(self, obj_or_comment):
|
||||
""" Add a :py:class:`.GraphicObject` or a comment (str) to this file. """
|
||||
if isinstnace(obj_or_comment, str):
|
||||
if isinstance(obj_or_comment, str):
|
||||
self.comments.append(obj_or_comment)
|
||||
else:
|
||||
self.objects.append(obj_or_comment)
|
||||
|
||||
def to_excellon(self):
|
||||
def to_excellon(self, plated=None, errors='raise'):
|
||||
""" Counterpart to :py:meth:`~.rs274x.GerberFile.to_excellon`. Does nothing and returns :py:obj:`self`. """
|
||||
return self
|
||||
|
||||
def to_gerber(self):
|
||||
apertures = {}
|
||||
def to_gerber(self, errors='raise'):
|
||||
""" Convert this excellon file into a :py:class:`~.rs274x.GerberFile`. """
|
||||
from .rs274x import GerberFile
|
||||
out = GerberFile()
|
||||
out.comments = self.comments
|
||||
|
||||
apertures = {}
|
||||
for obj in self.objects:
|
||||
if id(obj.tool) not in apertures:
|
||||
apertures[id(obj.tool)] = CircleAperture(obj.tool.diameter)
|
||||
if not (ap := apertures.get(obj.tool)):
|
||||
ap = apertures[obj.tool] = CircleAperture(obj.tool.diameter, unit=obj.aperture.unit)
|
||||
|
||||
out.objects.append(dataclasses.replace(obj, aperture=apertures[id(obj.tool)]))
|
||||
|
||||
out.apertures = list(apertures.values())
|
||||
out.objects.append(dataclasses.replace(obj, aperture=ap))
|
||||
return out
|
||||
|
||||
@property
|
||||
def generator(self):
|
||||
return self.generator_hints[0] if self.generator_hints else None
|
||||
|
||||
def merge(self, other):
|
||||
def merge(self, other, mode='ignored', keep_settings=False):
|
||||
if other is None:
|
||||
return
|
||||
|
||||
if not isinstance(other, ExcellonFile):
|
||||
other = other.to_excellon(plated=self.is_plated_tristate)
|
||||
|
||||
self.objects += other.objects
|
||||
self.comments += other.comments
|
||||
self.generator_hints = None
|
||||
self.import_settings = None
|
||||
if not keep_settings:
|
||||
self.import_settings = None
|
||||
|
||||
@classmethod
|
||||
def open(kls, filename, plated=None, settings=None, external_tools=None):
|
||||
|
|
@ -304,7 +328,7 @@ class ExcellonFile(CamFile):
|
|||
for fn in 'nc_param.txt', 'ncdrill.log':
|
||||
if (param_file := filename.parent / fn).is_file():
|
||||
settings = parse_allegro_ncparam(param_file.read_text())
|
||||
warnings.warn(f'Loaded allegro-style excellon settings file {param_file}')
|
||||
warnings.warn(f'Loaded allegro-style excellon settings file {param_file}', SyntaxWarning)
|
||||
break
|
||||
|
||||
# Parse Zuken log file for settings
|
||||
|
|
@ -312,7 +336,7 @@ class ExcellonFile(CamFile):
|
|||
logfile = filename.with_suffix('.fdl')
|
||||
if logfile.is_file():
|
||||
settings = parse_zuken_logfile(logfile.read_text())
|
||||
warnings.warn(f'Loaded zuken-style excellon log file {logfile}: {settings}')
|
||||
warnings.warn(f'Loaded zuken-style excellon log file {logfile}: {settings}', SyntaxWarning)
|
||||
|
||||
if external_tools is None:
|
||||
# Parse allegro log files for tools.
|
||||
|
|
@ -350,27 +374,36 @@ class ExcellonFile(CamFile):
|
|||
yield 'METRIC' if settings.unit == MM else 'INCH'
|
||||
|
||||
# Build tool index
|
||||
tool_map = { id(obj.tool): obj.tool for obj in self.objects }
|
||||
tool_map = { 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 = { tool_id: index for index, (tool_id, _tool) in enumerate(tools, start=1) }
|
||||
# FIXME dedup tools
|
||||
|
||||
mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1)
|
||||
if mixed_plating:
|
||||
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.')
|
||||
warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.', SyntaxWarning)
|
||||
|
||||
if tools and max(tools.values()) >= 100:
|
||||
warnings.warn('More than 99 tools defined. Some programs may not like three-digit tool indices.', SyntaxWarning)
|
||||
defined_tools = {}
|
||||
tool_indices = {}
|
||||
index = 1
|
||||
for tool_id, tool in tools:
|
||||
xnc = tool.to_xnc(settings)
|
||||
if (tool.plated, xnc) in defined_tools:
|
||||
tool_indices[tool_id] = defined_tools[(tool.plated, xnc)]
|
||||
|
||||
for tool_id, index in tools.items():
|
||||
tool = tool_map[tool_id]
|
||||
if mixed_plating:
|
||||
yield ';TYPE=PLATED' if tool.plated else ';TYPE=NON_PLATED'
|
||||
yield f'T{index:02d}' + tool.to_xnc(settings)
|
||||
else:
|
||||
if mixed_plating:
|
||||
yield ';TYPE=PLATED' if tool.plated else ';TYPE=NON_PLATED'
|
||||
|
||||
yield f'T{index:02d}' + xnc
|
||||
|
||||
tool_indices[tool_id] = defined_tools[(tool.plated, xnc)] = index
|
||||
index += 1
|
||||
|
||||
if index >= 100:
|
||||
warnings.warn('More than 99 tools defined. Some programs may not like three-digit tool indices.', SyntaxWarning)
|
||||
|
||||
yield '%'
|
||||
|
||||
ctx = ExcellonContext(settings, tools)
|
||||
ctx = ExcellonContext(settings, tool_indices)
|
||||
|
||||
# Export objects
|
||||
for obj in self.objects:
|
||||
|
|
@ -378,7 +411,7 @@ class ExcellonFile(CamFile):
|
|||
|
||||
yield 'M30'
|
||||
|
||||
def generate_excellon(self, settings=None, drop_comments=True):
|
||||
def write_to_bytes(self, settings=None, drop_comments=True):
|
||||
""" Export to Excellon format. This function always generates XNC, which is a well-defined subset of Excellon.
|
||||
Uses sane default settings if you don't give any.
|
||||
|
||||
|
|
@ -393,17 +426,17 @@ class ExcellonFile(CamFile):
|
|||
if settings is None:
|
||||
if self.import_settings:
|
||||
settings = self.import_settings.copy()
|
||||
settings.zeros = None
|
||||
settings.number_format = (3,5)
|
||||
else:
|
||||
settings = FileSettings()
|
||||
settings.zeros = None
|
||||
settings.number_format = (3,5)
|
||||
return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments))
|
||||
settings = FileSettings.defaults()
|
||||
return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments)).encode('utf-8')
|
||||
|
||||
def save(self, filename, settings=None, drop_comments=True):
|
||||
""" Save this Excellon file to the file system. See :py:meth:`~.ExcellonFile.generate_excellon` for the meaning
|
||||
of the arguments. """
|
||||
with open(filename, 'w') as f:
|
||||
f.write(self.generate_excellon(settings, drop_comments=drop_comments))
|
||||
with open(filename, 'wb') as f:
|
||||
f.write(self.write_to_bytes(settings, drop_comments=drop_comments))
|
||||
|
||||
def offset(self, x=0, y=0, unit=MM):
|
||||
for obj in self.objects:
|
||||
|
|
@ -536,6 +569,8 @@ class ExcellonParser(object):
|
|||
self.filename = None
|
||||
self.external_tools = external_tools or {}
|
||||
self.found_kicad_format_comment = False
|
||||
self.allegro_eof_toolchange_hack = False
|
||||
self.allegro_eof_toolchange_hack_index = 1
|
||||
|
||||
def warn(self, msg):
|
||||
warnings.warn(f'{self.filename}:{self.lineno} "{self.line}": {msg}', SyntaxWarning)
|
||||
|
|
@ -576,18 +611,25 @@ class ExcellonParser(object):
|
|||
exprs = RegexMatcher()
|
||||
|
||||
# NOTE: These must be kept before the generic comment handler at the end of this class so they match first.
|
||||
@exprs.match(r';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]+')
|
||||
@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]+')
|
||||
def parse_allegro_tooldef(self, match):
|
||||
# NOTE: We ignore the given tolerances here since they are non-standard.
|
||||
self.program_state = ProgramState.HEADER # TODO is this needed? we need a test file.
|
||||
self.generator_hints.append('allegro')
|
||||
|
||||
if (index := int(match['index1'])) != int(match['index2']): # index1 has leading zeros, index2 not.
|
||||
index = int(match['index2'])
|
||||
|
||||
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!')
|
||||
|
||||
if index in self.tools:
|
||||
self.warn('Re-definition of tool index {index}, overwriting old definition.')
|
||||
|
||||
if not match['index1_prefix']:
|
||||
# This is a really nasty orcad file without tool change commands, that instead just puts all holes in order
|
||||
# of the hole size definitions with M00's in between.
|
||||
self.allegro_eof_toolchange_hack = True
|
||||
|
||||
# NOTE: We map "optionally" plated holes to plated holes for API simplicity. If you hit a case where that's a
|
||||
# problem, please raise an issue on our issue tracker, explain why you need this and provide an example file.
|
||||
is_plated = None if match['plated'] is None else (match['plated'] in ('PLATED', 'OPTIONAL'))
|
||||
|
|
@ -600,13 +642,19 @@ class ExcellonParser(object):
|
|||
else:
|
||||
unit = MM
|
||||
|
||||
if unit != self.settings.unit:
|
||||
if self.settings.unit is None:
|
||||
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 '
|
||||
'file should be in {settings.unit.name}. Please double-check that this is correct, and if it is, '
|
||||
'please raise an issue on our issue tracker.')
|
||||
|
||||
self.tools[index] = ExcellonTool(diameter=diameter, plated=is_plated, unit=unit)
|
||||
|
||||
if self.allegro_eof_toolchange_hack and self.active_tool is None:
|
||||
self.active_tool = self.tools[index]
|
||||
|
||||
# Searching Github I found that EasyEDA has two different variants of the unit specification here.
|
||||
@exprs.match(';Holesize (?P<index>[0-9]+) = (?P<diameter>[.0-9]+) (?P<unit>INCH|inch|METRIC|mm)')
|
||||
def parse_easyeda_tooldef(self, match):
|
||||
|
|
@ -723,6 +771,12 @@ class ExcellonParser(object):
|
|||
def handle_end_of_program(self, match):
|
||||
if self.program_state in (None, ProgramState.HEADER):
|
||||
self.warn('M30 statement found before end of header.')
|
||||
|
||||
if self.allegro_eof_toolchange_hack:
|
||||
self.allegro_eof_toolchange_hack_index = min(max(self.tools), self.allegro_eof_toolchange_hack_index + 1)
|
||||
self.active_tool = self.tools[self.allegro_eof_toolchange_hack_index]
|
||||
return
|
||||
|
||||
self.program_state = ProgramState.FINISHED
|
||||
# TODO: maybe add warning if this is followed by other commands.
|
||||
|
||||
|
|
@ -732,14 +786,17 @@ class ExcellonParser(object):
|
|||
def do_move(self, coord_groups):
|
||||
x_s, x, y_s, y = coord_groups
|
||||
|
||||
if self.settings.number_format == (None, None) and '.' not in x:
|
||||
# TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else.
|
||||
if x != '00':
|
||||
raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro '
|
||||
'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as '
|
||||
'it, because Allegro does not include this critical information in their Excellon output. If you '
|
||||
'call this through ExcellonFile.from_string, you must manually supply from_string with a '
|
||||
'FileSettings object from excellon.parse_allegro_ncparam.')
|
||||
if (x is not None and '.' not in x) or (y is not None and '.' not in y):
|
||||
self.settings._file_has_fixed_width_coordinates = True
|
||||
|
||||
if self.settings.number_format == (None, None):
|
||||
# TARGET3001! exports zeros as "00" even when it uses an explicit decimal point everywhere else.
|
||||
if x != '00':
|
||||
raise SyntaxError('No number format set and value does not contain a decimal point. If this is an Allegro '
|
||||
'Excellon drill file make sure either nc_param.txt or ncdrill.log ends up in the same folder as '
|
||||
'it, because Allegro does not include this critical information in their Excellon output. If you '
|
||||
'call this through ExcellonFile.from_string, you must manually supply from_string with a '
|
||||
'FileSettings object from excellon.parse_allegro_ncparam.')
|
||||
|
||||
x = self.settings.parse_gerber_value(x)
|
||||
if x_s:
|
||||
|
|
@ -833,12 +890,17 @@ class ExcellonParser(object):
|
|||
# from https://math.stackexchange.com/a/1781546
|
||||
if a_s:
|
||||
raise ValueError('Negative arc radius given')
|
||||
r = settings.parse_gerber_value(a)
|
||||
r = self.settings.parse_gerber_value(a)
|
||||
x1, y1 = start
|
||||
x2, y2 = end
|
||||
dx, dy = (x2-x1)/2, (y2-y1)/2
|
||||
x0, y0 = x1+dx, y1+dy
|
||||
f = math.hypot(dx, dy) / math.sqrt(r**2 - a**2)
|
||||
d = math.hypot(dx, dy)
|
||||
if d == 0:
|
||||
raise ValueError('Arc radius notation requires distinct start and end points')
|
||||
if r < d:
|
||||
raise ValueError('Arc radius too small for endpoint distance')
|
||||
f = math.sqrt(r**2 - d**2) / d
|
||||
if clockwise:
|
||||
cx = x0 + f*dy
|
||||
cy = y0 - f*dx
|
||||
|
|
@ -848,16 +910,16 @@ class ExcellonParser(object):
|
|||
i, j = cx-start[0], cy-start[1]
|
||||
|
||||
else: # explicit center given
|
||||
i = settings.parse_gerber_value(i)
|
||||
i = self.settings.parse_gerber_value(i) or 0
|
||||
if i_s:
|
||||
i = -i
|
||||
j = settings.parse_gerber_value(j)
|
||||
j = self.settings.parse_gerber_value(j) or 0
|
||||
if j_s:
|
||||
j = -i
|
||||
j = -j
|
||||
|
||||
self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit))
|
||||
self.objects.append(Arc(*start, *end, i, j, clockwise, self.active_tool, unit=self.settings.unit))
|
||||
|
||||
@exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,0*\.0*)?')
|
||||
@exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,([0-9]+\.[0-9]+|0*\.0*))?')
|
||||
def parse_easyeda_format(self, match):
|
||||
metric = match[1] in ('METRIC', 'M71')
|
||||
|
||||
|
|
@ -870,7 +932,10 @@ class ExcellonParser(object):
|
|||
# This is used by newer autodesk eagles, fritzing and diptrace
|
||||
if match[3]:
|
||||
integer, _, fractional = match[3][1:].partition('.')
|
||||
self.settings.number_format = len(integer), len(fractional)
|
||||
if integer.strip('0') or fractional.strip('0'):
|
||||
self.settings.number_format = int(integer), int(fractional)
|
||||
else:
|
||||
self.settings.number_format = len(integer), len(fractional)
|
||||
|
||||
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.')
|
||||
|
|
@ -896,10 +961,10 @@ class ExcellonParser(object):
|
|||
@exprs.match('(FMAT|VER),?([0-9]*)')
|
||||
def handle_command_format(self, match):
|
||||
if match[1] == 'FMAT':
|
||||
# We do not support integer/fractional decimals specification via FMAT because that's stupid. If you need this,
|
||||
# please raise an issue on our issue tracker, provide a sample file and tell us where on earth you found that
|
||||
# file.
|
||||
if match[2] not in ('', '2'):
|
||||
# We only use FMAT as a compatibility marker. Version 1 drill files encountered in the wild still use the
|
||||
# same coordinate and routing statements that we already support, so rejecting the header unconditionally
|
||||
# needlessly breaks otherwise parseable files.
|
||||
if match[2] not in ('', '1', '2'):
|
||||
raise SyntaxError(f'Unsupported FMAT format version {match[2]}')
|
||||
|
||||
else: # VER
|
||||
|
|
@ -928,6 +993,19 @@ class ExcellonParser(object):
|
|||
else:
|
||||
self.warn('Bare coordinate after end of file')
|
||||
|
||||
@exprs.match(xy_coord + 'G85' + xy_coord)
|
||||
def handle_g85_slot(self, match):
|
||||
if self.program_state == ProgramState.HEADER:
|
||||
return
|
||||
|
||||
self.do_move(match.groups()[:4])
|
||||
start, end = self.do_move(match.groups()[4:])
|
||||
|
||||
if not self.ensure_active_tool():
|
||||
return
|
||||
|
||||
self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit))
|
||||
|
||||
@exprs.match(r'DETECT,ON|ATC,ON|M06')
|
||||
def parse_zuken_legacy_statements(self, match):
|
||||
self.generator_hints.append('zuken')
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2022 Jan Götte <code@jaseg.de>
|
||||
# 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.
|
||||
|
|
@ -18,10 +18,12 @@
|
|||
|
||||
import math
|
||||
import copy
|
||||
from dataclasses import dataclass, KW_ONLY, astuple, replace, 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
|
||||
from .utils import MM, InterpMode, to_unit, rotate_point, sum_bounds, approximate_arc, sweep_angle
|
||||
from . import graphic_primitives as gp
|
||||
from .aperture_macros import primitive as amp
|
||||
|
||||
|
||||
def convert(value, src, dst):
|
||||
|
|
@ -40,26 +42,35 @@ class Length:
|
|||
# This makes the automatically generated method signatures in the Sphinx docs look nice
|
||||
return 'float'
|
||||
|
||||
@dataclass
|
||||
class GraphicObject:
|
||||
""" Base class for the graphic objects that make up a :py:class:`.GerberFile` or :py:class:`.ExcellonFile`. """
|
||||
_ : KW_ONLY
|
||||
|
||||
#: bool representing the *color* of this feature: whether this is a *dark* or *clear* feature. Clear and dark are
|
||||
#: meant in the sense that they are used in the Gerber spec and refer to whether the transparency film that this
|
||||
#: file describes ends up black or clear at this spot. In a standard green PCB, a *polarity_dark=True* line will
|
||||
#: show up as copper on the copper layer, white ink on the silkscreen layer, or an opening on the soldermask layer.
|
||||
#: Clear features erase dark features, they are not transparent in the colloquial meaning. This property is ignored
|
||||
#: for features of an :py:class:`.ExcellonFile`.
|
||||
polarity_dark : bool = True
|
||||
# hackety hack: Work around python < 3.10 not having dataclasses.KW_ONLY. Once we drop python 3.8 and 3.9, we can
|
||||
# get rid of this, just set these as normal fields, and decorate GraphicObject with @dataclass.
|
||||
#
|
||||
# See also: apertures.py, graphic_primitives.py
|
||||
def __init_subclass__(cls):
|
||||
#: bool representing the *color* of this feature: whether this is a *dark* or *clear* feature. Clear and dark are
|
||||
#: meant in the sense that they are used in the Gerber spec and refer to whether the transparency film that this
|
||||
#: file describes ends up black or clear at this spot. In a standard green PCB, a *polarity_dark=True* line will
|
||||
#: show up as copper on the copper layer, white ink on the silkscreen layer, or an opening on the soldermask layer.
|
||||
#: Clear features erase dark features, they are not transparent in the colloquial meaning. This property is ignored
|
||||
#: for features of an :py:class:`.ExcellonFile`.
|
||||
cls.polarity_dark = True
|
||||
|
||||
#: :py:class:`.LengthUnit` used for all coordinate fields of this object (such as ``x`` or ``y``).
|
||||
unit : str = None
|
||||
#: :py:class:`.LengthUnit` used for all coordinate fields of this object (such as ``x`` or ``y``).
|
||||
cls.unit = None
|
||||
|
||||
#: `dict` containing GerberX2 attributes attached to this feature. Note that this does not include file attributes,
|
||||
#: which are stored in the :py:class:`.GerberFile` object instead.
|
||||
cls.attrs = field(default_factory=dict)
|
||||
|
||||
d = {'polarity_dark' : bool, 'unit' : str, 'attrs': dict}
|
||||
if hasattr(cls, '__annotations__'):
|
||||
cls.__annotations__.update(d)
|
||||
else:
|
||||
cls.__annotations__ = d
|
||||
|
||||
#: `dict` containing GerberX2 attributes attached to this feature. Note that this does not include file attributes,
|
||||
#: which are stored in the :py:class:`.GerberFile` object instead.
|
||||
attrs : dict = field(default_factory=dict)
|
||||
|
||||
def converted(self, unit):
|
||||
""" Convert this gerber object to another :py:class:`.LengthUnit`.
|
||||
|
|
@ -96,6 +107,20 @@ class GraphicObject:
|
|||
dx, dy = self.unit(dx, unit), self.unit(dy, unit)
|
||||
self._offset(dx, dy)
|
||||
|
||||
def scale(self, factor, unit=MM):
|
||||
""" Scale this feature in both its dimensions and location.
|
||||
|
||||
.. note:: The scale factor is a scalar, and the unit argument is irrelevant, but is kept for API consistency.
|
||||
|
||||
.. note:: If this object references an aperture, this aperture is not modified. You will have to transform this
|
||||
aperture yourself.
|
||||
|
||||
:param float factor: Scale factor, 1 to keep the object as is, larger values to enlarge, smaller values to
|
||||
shrink. Negative values are permitted.
|
||||
"""
|
||||
|
||||
self._scale(factor)
|
||||
|
||||
def rotate(self, rotation, cx=0, cy=0, unit=MM):
|
||||
""" Rotate this object. The center of rotation can be given in either unit, and is automatically converted into
|
||||
this object's local unit.
|
||||
|
|
@ -103,6 +128,9 @@ class GraphicObject:
|
|||
.. note:: The center's Y coordinate as well as the angle's polarity are flipped compared to computer graphics
|
||||
convention since Gerber uses a bottom-to-top Y axis.
|
||||
|
||||
.. note:: If this object references an aperture, this aperture is not modified. You will have to transform this
|
||||
aperture yourself.
|
||||
|
||||
:param float rotation: rotation in radians clockwise.
|
||||
:param float cx: X coordinate of center of rotation in *unit* units.
|
||||
:param float cy: Y coordinate of center of rotation. (0,0) is at the bottom left of the image.
|
||||
|
|
@ -124,12 +152,7 @@ class GraphicObject:
|
|||
:returns: tuple of tuples of floats: ``(min_x, min_y), (max_x, max_y)``
|
||||
"""
|
||||
|
||||
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))
|
||||
return sum_bounds(p.bounding_box() for p in self.to_primitives(unit))
|
||||
|
||||
def to_primitives(self, unit=None):
|
||||
""" Render this object into low-level graphical primitives (subclasses of :py:class:`.GraphicPrimitive`). This
|
||||
|
|
@ -191,6 +214,11 @@ class Flash(GraphicObject):
|
|||
def tool(self, value):
|
||||
self.aperture = value
|
||||
|
||||
def bounding_box(self, unit=None):
|
||||
(min_x, min_y), (max_x, max_y) = self.aperture.bounding_box(unit)
|
||||
x, y = self.unit.convert_to(unit, self.x), self.unit.convert_to(unit, self.y)
|
||||
return (min_x+x, min_y+y), (max_x+x, max_y+y)
|
||||
|
||||
@property
|
||||
def plated(self):
|
||||
""" (Excellon only) Returns if this is a plated hole. ``True`` (plated), ``False`` (non-plated) or ``None``
|
||||
|
|
@ -205,6 +233,10 @@ class Flash(GraphicObject):
|
|||
def _rotate(self, rotation, cx=0, cy=0):
|
||||
self.x, self.y = gp.rotate_point(self.x, self.y, rotation, cx, cy)
|
||||
|
||||
def _scale(self, factor):
|
||||
self.x *= factor
|
||||
self.y *= factor
|
||||
|
||||
def to_primitives(self, unit=None):
|
||||
conv = self.converted(unit)
|
||||
yield from self.aperture.flash(conv.x, conv.y, unit, self.polarity_dark)
|
||||
|
|
@ -246,62 +278,144 @@ class Region(GraphicObject):
|
|||
* A region is always exactly one connected component.
|
||||
* A region must not overlap itself anywhere.
|
||||
* A region cannot have holes.
|
||||
* The last outline point of the region must be equal to the first.
|
||||
|
||||
There is one exception from the last two rules: To emulate a region with a hole in it, *cut-ins* are allowed. At a
|
||||
cut-in, the region is allowed to touch (but never overlap!) itself.
|
||||
|
||||
:attr poly: :py:class:`~.graphic_primitives.ArcPoly` describing the actual outline of this Region. The coordinates of
|
||||
this poly are in the unit of this instance's :py:attr:`unit` field.
|
||||
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, polarity_dark):
|
||||
super().__init__(unit=unit, polarity_dark=polarity_dark)
|
||||
outline = [] if outline is None else outline
|
||||
arc_centers = [] if arc_centers is None else arc_centers
|
||||
self.poly = gp.ArcPoly(outline, arc_centers)
|
||||
def __init__(self, outline=None, arc_centers=None, *, unit=MM, polarity_dark=True):
|
||||
self.unit = unit
|
||||
self.polarity_dark = polarity_dark
|
||||
self.outline = [] if outline is None else outline
|
||||
self.arc_centers = [] if arc_centers is None else arc_centers
|
||||
self.close()
|
||||
|
||||
def __len__(self):
|
||||
return len(self.poly)
|
||||
return len(self.outline)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.poly)
|
||||
return bool(self.outline)
|
||||
|
||||
def __str__(self):
|
||||
return f'<Region with {len(self.outline)} points and {sum(1 if c else 0 for c in self.arc_centers)} arc segments at {hex(id(self))}'
|
||||
|
||||
def _offset(self, dx, dy):
|
||||
self.poly.outline = [ (x+dx, y+dy) for x, y in self.poly.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):
|
||||
self.poly.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.poly.outline ]
|
||||
self.poly.arc_centers = [
|
||||
(arc[0], gp.rotate_point(*arc[1], angle, cx-p[0], cy-p[1])) if arc else None
|
||||
for p, arc in zip(self.poly.outline, self.poly.arc_centers) ]
|
||||
self.outline = [ gp.rotate_point(x, y, angle, cx, cy) for x, y in self.outline ]
|
||||
self.arc_centers = [
|
||||
(arc[0], gp.rotate_point(*arc[1], angle, cx, cy)) if arc else None
|
||||
for arc in self.arc_centers ]
|
||||
|
||||
def _scale(self, factor):
|
||||
self.outline = [ (x*factor, y*factor) for x, y in self.outline ]
|
||||
self.arc_centers = [
|
||||
(arc[0], (arc[1][0]*factor, arc[1][1]*factor)) if arc else None
|
||||
for p, arc in zip_longest(self.outline, self.arc_centers) ]
|
||||
|
||||
def close(self):
|
||||
if self.outline and self.outline[-1] != self.outline[0]:
|
||||
self.outline.append(self.outline[0])
|
||||
if self.arc_centers:
|
||||
self.arc_centers.append((None, (None, None)))
|
||||
|
||||
@classmethod
|
||||
def from_rectangle(kls, x, y, w, h, unit=MM):
|
||||
return kls([
|
||||
(x, y),
|
||||
(x+w, y),
|
||||
(x+w, y+h),
|
||||
(x, y+h),
|
||||
], unit=unit)
|
||||
|
||||
@classmethod
|
||||
def from_arc_poly(kls, arc_poly, polarity_dark=None, unit=MM):
|
||||
polarity = arc_poly.polarity_dark if polarity_dark is None else polarity_dark
|
||||
return kls(arc_poly.outline, arc_poly.arc_centers, polarity_dark=polarity, unit=unit)
|
||||
|
||||
def append(self, obj):
|
||||
if obj.unit != self.unit:
|
||||
obj = obj.converted(self.unit)
|
||||
|
||||
if not self.poly.outline:
|
||||
self.poly.outline.append(obj.p1)
|
||||
self.poly.outline.append(obj.p2)
|
||||
if not self.outline:
|
||||
self.outline.append(obj.p1)
|
||||
self.outline.append(obj.p2)
|
||||
|
||||
if isinstance(obj, Arc):
|
||||
self.poly.arc_centers.append((obj.clockwise, obj.center_relative))
|
||||
self.arc_centers.append((obj.clockwise, obj.center))
|
||||
else:
|
||||
self.poly.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):
|
||||
self.poly.polarity_dark = self.polarity_dark # FIXME: is this the right spot to do this?
|
||||
if unit == self.unit:
|
||||
yield self.poly
|
||||
yield gp.ArcPoly(outline=self.outline, arc_centers=self.arc_centers, polarity_dark=self.polarity_dark)
|
||||
|
||||
else:
|
||||
to = lambda value: self.unit.convert_to(unit, value)
|
||||
conv_outline = [ (to(x), to(y)) for x, y in self.poly.outline ]
|
||||
conv_outline = [ (to(x), to(y)) for x, y in self.outline ]
|
||||
convert_entry = lambda entry: (entry[0], (to(entry[1][0]), to(entry[1][1])))
|
||||
conv_arc = [ None if entry is None else convert_entry(entry) for entry in self.poly.arc_centers ]
|
||||
conv_arc = [ None if entry is None else convert_entry(entry) for entry in self.arc_centers ]
|
||||
|
||||
yield gp.ArcPoly(conv_outline, conv_arc, polarity_dark=self.polarity_dark)
|
||||
|
||||
def to_statements(self, gs):
|
||||
if len(self.outline) < 3:
|
||||
return
|
||||
|
||||
yield from gs.set_polarity(self.polarity_dark)
|
||||
yield 'G36*'
|
||||
# Repeat interpolation mode at start of region statement to work around gerbv bug. Without this, gerbv will
|
||||
|
|
@ -309,31 +423,26 @@ class Region(GraphicObject):
|
|||
# TODO report gerbv issue upstream
|
||||
yield gs.interpolation_mode_statement() + '*'
|
||||
|
||||
yield from gs.set_current_point(self.poly.outline[0], unit=self.unit)
|
||||
yield from gs.set_current_point(self.outline[0], unit=self.unit)
|
||||
|
||||
for point, arc_center in zip(self.poly.outline[1:], self.poly.arc_centers):
|
||||
if arc_center is None:
|
||||
for previous_point, point, (clockwise, center) in self.iter_segments():
|
||||
if point is None and center is None:
|
||||
break
|
||||
|
||||
x = gs.file_settings.write_gerber_value(point[0], self.unit)
|
||||
y = gs.file_settings.write_gerber_value(point[1], self.unit)
|
||||
|
||||
if clockwise is None:
|
||||
yield from gs.set_interpolation_mode(InterpMode.LINEAR)
|
||||
|
||||
x = gs.file_settings.write_gerber_value(point[0], self.unit)
|
||||
y = gs.file_settings.write_gerber_value(point[1], self.unit)
|
||||
yield f'X{x}Y{y}D01*'
|
||||
|
||||
gs.update_point(*point, unit=self.unit)
|
||||
|
||||
else:
|
||||
clockwise, (cx, cy) = arc_center
|
||||
x2, y2 = point
|
||||
yield from gs.set_interpolation_mode(InterpMode.CIRCULAR_CW if clockwise else InterpMode.CIRCULAR_CCW)
|
||||
|
||||
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)
|
||||
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)
|
||||
yield f'X{x}Y{y}I{i}J{j}D01*'
|
||||
|
||||
gs.update_point(x2, y2, unit=self.unit)
|
||||
gs.update_point(*point, unit=self.unit)
|
||||
|
||||
yield 'G37*'
|
||||
|
||||
|
|
@ -374,6 +483,12 @@ class Line(GraphicObject):
|
|||
self.x1, self.y1 = gp.rotate_point(self.x1, self.y1, rotation, cx, cy)
|
||||
self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy)
|
||||
|
||||
def _scale(self, factor):
|
||||
self.x1 *= factor
|
||||
self.y1 *= factor
|
||||
self.x2 *= factor
|
||||
self.y2 *= factor
|
||||
|
||||
@property
|
||||
def p1(self):
|
||||
""" Convenience alias for ``(self.x1, self.y1)`` returning start point of the line. """
|
||||
|
|
@ -400,10 +515,20 @@ class Line(GraphicObject):
|
|||
"""
|
||||
return self.tool.plated
|
||||
|
||||
def to_primitives(self, unit=None):
|
||||
def as_primitive(self, unit=None):
|
||||
conv = self.converted(unit)
|
||||
w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging
|
||||
yield gp.Line(*conv.p1, *conv.p2, w, polarity_dark=self.polarity_dark)
|
||||
return gp.Line(*conv.p1, *conv.p2, w, polarity_dark=self.polarity_dark)
|
||||
|
||||
def to_primitives(self, unit=None):
|
||||
yield self.as_primitive(unit=unit)
|
||||
|
||||
def _aperture_macro_primitives(self):
|
||||
obj = self.converted(MM) # Gerbonara aperture macros use MM units.
|
||||
width = obj.aperture.equivalent_width(MM)
|
||||
yield amp.VectorLine(MM, int(self.polarity_dark), width, obj.x1, obj.y1, obj.x2, obj.y2, 0)
|
||||
yield amp.Circle(MM, int(self.polarity_dark), width, obj.x1, obj.y1)
|
||||
yield amp.Circle(MM, int(self.polarity_dark), width, obj.x2, obj.y2)
|
||||
|
||||
def to_statements(self, gs):
|
||||
yield from gs.set_polarity(self.polarity_dark)
|
||||
|
|
@ -463,6 +588,10 @@ class Arc(GraphicObject):
|
|||
#: Aperture for this arc. Should be a subclass of :py:class:`.CircleAperture`, whose diameter determines the line
|
||||
#: width.
|
||||
aperture : object
|
||||
|
||||
@classmethod
|
||||
def from_circle(kls, cx, cy, r, aperture, unit=MM):
|
||||
return kls(cx-r, cy, cx-r, cy, r, 0, aperture=aperture, clockwise=True, unit=MM)
|
||||
|
||||
def _offset(self, dx, dy):
|
||||
self.x1 += dx
|
||||
|
|
@ -495,22 +624,8 @@ class Arc(GraphicObject):
|
|||
:returns: Angle in clockwise radian between ``0`` and ``2*math.pi``
|
||||
:rtype: float
|
||||
"""
|
||||
cx, cy = self.cx + self.x1, self.cy + self.y1
|
||||
x1, y1 = self.x1 - cx, self.y1 - cy
|
||||
x2, y2 = self.x2 - cx, self.y2 - cy
|
||||
|
||||
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)
|
||||
return sweep_angle(self.cx+self.x1, self.cy+self.y1, self.x1, self.y1, self.x2, self.y2, self.clockwise)
|
||||
|
||||
@property
|
||||
def p1(self):
|
||||
|
|
@ -567,34 +682,16 @@ class Arc(GraphicObject):
|
|||
:returns: list of :py:class:`~.graphic_objects.Line` instances.
|
||||
:rtype: list
|
||||
"""
|
||||
# TODO the max_angle calculation below is a bit off -- we over-estimate the error, and thus produce finer
|
||||
# results than necessary. Fix this.
|
||||
|
||||
r = math.hypot(self.cx, self.cy)
|
||||
|
||||
max_error = self.unit(max_error, unit)
|
||||
if clip_max_error:
|
||||
# 1 - math.sqrt(1 - 0.5*math.sqrt(2))
|
||||
max_error = min(max_error, r*0.4588038998538031)
|
||||
|
||||
elif max_error >= r:
|
||||
return [Line(*self.p1, *self.p2, aperture=self.aperture, polarity_dark=self.polarity_dark)]
|
||||
|
||||
# see https://www.mathopenref.com/sagitta.html
|
||||
l = math.sqrt(r**2 - (r - max_error)**2)
|
||||
|
||||
angle_max = math.asin(l/r)
|
||||
sweep_angle = self.sweep_angle()
|
||||
num_segments = math.ceil(sweep_angle / angle_max)
|
||||
angle = sweep_angle / num_segments
|
||||
|
||||
if not self.clockwise:
|
||||
angle = -angle
|
||||
|
||||
cx, cy = self.center
|
||||
points = [ rotate_point(self.x1, self.y1, i*angle, cx, cy) for i in range(num_segments + 1) ]
|
||||
return [ Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark)
|
||||
for p1, p2 in zip(points[0::], points[1::]) ]
|
||||
return [Line(*p1, *p2, aperture=self.aperture, polarity_dark=self.polarity_dark, unit=self.unit)
|
||||
for p1, p2 in pairwise(approximate_arc(
|
||||
self.cx+self.x1, self.cy+self.y1,
|
||||
self.x1, self.y1,
|
||||
self.x2, self.y2,
|
||||
self.clockwise,
|
||||
max_error=max_error,
|
||||
clip_max_error=clip_max_error))]
|
||||
|
||||
def _rotate(self, rotation, cx=0, cy=0):
|
||||
# rotate center first since we need old x1, y1 here
|
||||
|
|
@ -603,16 +700,38 @@ class Arc(GraphicObject):
|
|||
self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy)
|
||||
self.cx, self.cy = new_cx - self.x1, new_cy - self.y1
|
||||
|
||||
def to_primitives(self, unit=None):
|
||||
def _scale(self, factor):
|
||||
self.x1 *= factor
|
||||
self.y1 *= factor
|
||||
self.x2 *= factor
|
||||
self.y2 *= factor
|
||||
self.cx *= factor
|
||||
self.cy *= factor
|
||||
|
||||
def as_primitive(self, unit=None):
|
||||
conv = self.converted(unit)
|
||||
w = self.aperture.equivalent_width(unit) if self.aperture else 0.1 # for debugging
|
||||
yield gp.Arc(x1=conv.x1, y1=conv.y1,
|
||||
w = self.aperture.equivalent_width(unit) if self.aperture else 0
|
||||
return gp.Arc(x1=conv.x1, y1=conv.y1,
|
||||
x2=conv.x2, y2=conv.y2,
|
||||
cx=conv.cx, cy=conv.cy,
|
||||
cx=conv.cx+conv.x1, cy=conv.cy+conv.y1,
|
||||
clockwise=self.clockwise,
|
||||
width=w,
|
||||
polarity_dark=self.polarity_dark)
|
||||
|
||||
def to_primitives(self, unit=None):
|
||||
yield self.as_primitive(unit=unit)
|
||||
|
||||
def to_region(self):
|
||||
reg = Region(unit=self.unit, polarity_dark=self.polarity_dark)
|
||||
reg.append(self)
|
||||
reg.close()
|
||||
return reg
|
||||
|
||||
def _aperture_macro_primitives(self, max_error=1e-2, unit=MM):
|
||||
# unit is only for max_error, the resulting primitives will always be in MM
|
||||
for line in self.approximate(max_error=max_error, unit=unit):
|
||||
yield from line._aperture_macro_primitives()
|
||||
|
||||
def to_statements(self, gs):
|
||||
yield from gs.set_polarity(self.polarity_dark)
|
||||
yield from gs.set_aperture(self.aperture)
|
||||
379
src/gerbonara/graphic_primitives.py
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
#!/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.
|
||||
#
|
||||
|
||||
import math
|
||||
import itertools
|
||||
|
||||
from dataclasses import dataclass, replace, field
|
||||
|
||||
from .utils import *
|
||||
|
||||
prec = lambda x: f'{float(x):.6}'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphicPrimitive:
|
||||
|
||||
# hackety hack: Work around python < 3.10 not having dataclasses.KW_ONLY.
|
||||
#
|
||||
# For details, refer to graphic_objects.py
|
||||
def __init_subclass__(cls):
|
||||
cls.polarity_dark = True
|
||||
|
||||
d = {'polarity_dark': bool}
|
||||
if hasattr(cls, '__annotations__'):
|
||||
cls.__annotations__.update(d)
|
||||
else:
|
||||
cls.__annotations__ = d
|
||||
|
||||
def bounding_box(self):
|
||||
""" Return the axis-aligned bounding box of this feature.
|
||||
|
||||
:returns: ``((min_x, min_Y), (max_x, max_y))``
|
||||
:rtype: tuple
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
""" Render this primitive into its SVG representation.
|
||||
|
||||
:param str fg: Foreground color. Must be an SVG color name.
|
||||
:param str bg: Background color. Must be an SVG color name.
|
||||
:param function tag: Tag constructor to use.
|
||||
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
def is_zero_size(self):
|
||||
""" Return whether this primitive is zero size
|
||||
|
||||
:rtype: bool
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Circle(GraphicPrimitive):
|
||||
#: Center X coordinate
|
||||
x : float
|
||||
#: Center y coordinate
|
||||
y : float
|
||||
#: Radius, not diameter like in :py:class:`.apertures.CircleAperture`
|
||||
r : float # Here, we use radius as common in modern computer graphics, not diameter as gerber uses.
|
||||
|
||||
def bounding_box(self):
|
||||
return ((self.x-self.r, self.y-self.r), (self.x+self.r, self.y+self.r))
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
return tag('circle', cx=prec(self.x), cy=prec(self.y), r=prec(self.r), fill=color)
|
||||
|
||||
def to_arc_poly(self):
|
||||
return ArcPoly([(self.x-self.r, self.y), (self.x+self.r, self.y)],
|
||||
[(True, (self.x, self.y)), (True, (self.x, self.y))],
|
||||
polarity_dark=self.polarity_dark)
|
||||
|
||||
def is_zero_size(self):
|
||||
return math.isclose(self.r, 0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArcPoly(GraphicPrimitive):
|
||||
""" Polygon whose sides may be either straight lines or circular arcs. """
|
||||
|
||||
#: list of (x : float, y : float) tuples. Describes closed outline, i.e. the first and last point are considered
|
||||
#: connected.
|
||||
outline : list
|
||||
#: Must be either None (all segments are straight lines) or same length as outline.
|
||||
#: Straight line segments have None entry. Arc segments have (clockwise, (cx, cy)) tuple with cx, cy being absolute
|
||||
#: coords.
|
||||
arc_centers : list = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def segments(self):
|
||||
""" Return an iterator through all *segments* of this polygon. For each outline segment (line or arc), this
|
||||
iterator will yield a ``(p1, p2, (clockwise, center))`` tuple. If the segment is a straight line, ``clockwise``
|
||||
will be ``None``.
|
||||
"""
|
||||
for points, arc in itertools.zip_longest(itertools.pairwise(self.outline), self.arc_centers):
|
||||
if arc:
|
||||
if points:
|
||||
yield *points, arc
|
||||
else:
|
||||
yield self.outline[-1], self.outline[0], arc
|
||||
return
|
||||
else:
|
||||
if not points:
|
||||
break
|
||||
yield *points, (None, (None, None))
|
||||
|
||||
# Close outline if necessary.
|
||||
if math.dist(self.outline[0], self.outline[-1]) > 1e-6:
|
||||
yield self.outline[-1], self.outline[0], (None, (None, None))
|
||||
|
||||
def approximate_arcs(self, max_error=1e-2, clip_max_error=True):
|
||||
outline = []
|
||||
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
|
||||
if clockwise is None:
|
||||
outline.append((x1, y1))
|
||||
else:
|
||||
outline.extend(approximate_arc(cx, cy, x1, y1, x2, y2, clockwise,
|
||||
max_error=max_error, clip_max_error=clip_max_error))
|
||||
outline.pop() # remove arc end point
|
||||
return type(self)(outline, polarity_dark=self.polarity_dark)
|
||||
|
||||
def bounding_box(self):
|
||||
bbox = (None, None), (None, None)
|
||||
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
|
||||
if clockwise is None:
|
||||
line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2))
|
||||
bbox = add_bounds(bbox, line_bounds)
|
||||
else:
|
||||
bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, cx, cy, clockwise))
|
||||
return bbox
|
||||
|
||||
@classmethod
|
||||
def from_regular_polygon(kls, x:float, y:float, r:float, n:int, rotation:float=0, polarity_dark:bool=True):
|
||||
""" Convert an n-sided gerber polygon to a normal ArcPoly defined by outline """
|
||||
|
||||
delta = 2*math.pi / n
|
||||
|
||||
return kls([
|
||||
(x + math.cos(rotation + i*delta) * r,
|
||||
y + math.sin(rotation + i*delta) * r)
|
||||
for i in range(n) ], polarity_dark=polarity_dark)
|
||||
|
||||
def __len__(self):
|
||||
""" Return the number of points on this polygon's outline (which is also the number of segments because the
|
||||
polygon is closed). """
|
||||
return len(self.outline)
|
||||
|
||||
def __bool__(self):
|
||||
""" Return ``True`` if this polygon has any outline points. """
|
||||
return bool(len(self))
|
||||
|
||||
def path_d(self):
|
||||
if len(self.outline) == 0:
|
||||
return
|
||||
|
||||
yield f'M {float(self.outline[0][0]):.6} {float(self.outline[0][1]):.6}'
|
||||
|
||||
for old, new, (clockwise, center) in self.segments:
|
||||
if clockwise is None:
|
||||
yield f'L {float(new[0]):.6} {float(new[1]):.6}'
|
||||
else:
|
||||
yield svg_arc(old, new, center, clockwise)
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
return tag('path', d=' '.join(self.path_d()), fill=color)
|
||||
|
||||
def to_arc_poly(self):
|
||||
return self
|
||||
|
||||
|
||||
def is_zero_size(self):
|
||||
for (x1, y1), (x2, y2), (clockwise, (cx, cy)) in self.segments:
|
||||
if clockwise is not None: # arc
|
||||
if math.isclose(cx, x1) and math.isclose(cy, y1):
|
||||
continue
|
||||
|
||||
if math.isclose(x1, x2) and math.isclose(y1, y2):
|
||||
return False
|
||||
|
||||
if math.isclose(polygon_area(self.outline), 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Line(GraphicPrimitive):
|
||||
""" Straight line with round end caps. """
|
||||
#: Start X coordinate. As usual in modern graphics APIs, this is at the center of the half-circle capping off this
|
||||
#: line.
|
||||
x1 : float
|
||||
#: Start Y coordinate
|
||||
y1 : float
|
||||
#: End X coordinate
|
||||
x2 : float
|
||||
#: End Y coordinate
|
||||
y2 : float
|
||||
#: Line width
|
||||
width : float
|
||||
|
||||
def flip(self):
|
||||
return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1)
|
||||
|
||||
@classmethod
|
||||
def from_obround(kls, x:float, y:float, w:float, h:float, rotation:float=0, polarity_dark:bool=True):
|
||||
""" Convert a gerber obround into a :py:class:`~.graphic_primitives.Line`. """
|
||||
if w > h:
|
||||
w, a, b = h, w-h, 0
|
||||
else:
|
||||
w, a, b = w, 0, h-w
|
||||
|
||||
return kls(
|
||||
*rotate_point(x-a/2, y-b/2, rotation, x, y),
|
||||
*rotate_point(x+a/2, y+b/2, rotation, x, y),
|
||||
w, polarity_dark=polarity_dark)
|
||||
|
||||
def bounding_box(self):
|
||||
r = self.width / 2
|
||||
return add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box())
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} L {float(self.x2):.6} {float(self.y2):.6}',
|
||||
fill='none', stroke=color, stroke_width=str(width), stroke_linecap='round')
|
||||
|
||||
def to_arc_poly(self):
|
||||
l = math.dist((self.x1, self.y1), (self.x2, self.y2))
|
||||
if math.isclose(l, 0):
|
||||
# degenerate case: a zero-length line becomes a circle.
|
||||
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
|
||||
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
|
||||
polarity_dark=self.polarity_dark)
|
||||
|
||||
dx, dy = self.x2-self.x1, self.y2-self.y1
|
||||
nx, ny = -dy/l, dx/l
|
||||
rx, ry = nx*self.width/2, ny*self.width/2
|
||||
return ArcPoly([
|
||||
(self.x2+rx, self.y2+ry),
|
||||
(self.x2-rx, self.y2-ry),
|
||||
(self.x1-rx, self.y1-ry),
|
||||
(self.x1+rx, self.y1+ry),
|
||||
], [
|
||||
(True, (self.x2, self.y2)),
|
||||
None,
|
||||
(True, (self.x1, self.y1)),
|
||||
None,
|
||||
], polarity_dark=self.polarity_dark)
|
||||
|
||||
def is_zero_size(self):
|
||||
return math.isclose(self.x1, self.x2) and math.isclose(self.y1, self.y2)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Arc(GraphicPrimitive):
|
||||
""" Circular arc with line width ``width`` going from ``(x1, y1)`` to ``(x2, y2)`` around center at ``(cx, cy)``. """
|
||||
#: Start X coodinate
|
||||
x1 : float
|
||||
#: Start Y coodinate
|
||||
y1 : float
|
||||
#: End X coodinate
|
||||
x2 : float
|
||||
#: End Y coodinate
|
||||
y2 : float
|
||||
#: Center X coordinate (absolute)
|
||||
cx : float
|
||||
#: Center Y coordinate (absolute)
|
||||
cy : float
|
||||
#: ``True`` if this arc is clockwise from start to end. Selects between the large arc and the small arc given this
|
||||
#: start, end and center
|
||||
clockwise : bool
|
||||
#: Line width of this arc.
|
||||
width : float
|
||||
|
||||
@property
|
||||
def is_circle(self):
|
||||
return math.isclose(self.x1, self.x2, abs_tol=1e-6) and math.isclose(self.y1, self.y2, abs_tol=1e-6)
|
||||
|
||||
def flip(self):
|
||||
return replace(self, x1=self.x2, y1=self.y2, x2=self.x1, y2=self.y1, clockwise=not self.clockwise)
|
||||
|
||||
def bounding_box(self):
|
||||
r = self.width/2
|
||||
(min_x, min_y), (max_x, max_y) = arc_bounds(self.x1, self.y1, self.x2, self.y2, self.cx, self.cy, self.clockwise)
|
||||
return (min_x-r, min_y-r), (max_x+r, max_y+r)
|
||||
|
||||
def to_svg(self, fg='black', bg='white', tag=Tag):
|
||||
color = fg if self.polarity_dark else bg
|
||||
arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise)
|
||||
width = f'{self.width:.6}' if not math.isclose(self.width, 0) else '0.01mm'
|
||||
return tag('path', d=f'M {float(self.x1):.6} {float(self.y1):.6} {arc}',
|
||||
fill='none', stroke=color, stroke_width=width, stroke_linecap='round')
|
||||
|
||||
def to_arc_poly(self):
|
||||
r = math.dist((self.x1, self.y1), (self.cx, self.cy))
|
||||
|
||||
if math.isclose(r, 0):
|
||||
# degenerate case: a zero-radius arc becomes a circle.
|
||||
return ArcPoly([(self.x1-self.width/2, self.y1), (self.x1+self.width/2, self.y1)],
|
||||
[(True, (self.x1, self.y1)), (True, (self.x1, self.y1))],
|
||||
polarity_dark=self.polarity_dark)
|
||||
|
||||
dx1, dy1 = self.x1-self.cx, self.y1-self.cy
|
||||
nx1, ny1 = dx1/r * self.width/2, dy1/r * self.width/2
|
||||
dx2, dy2 = self.x2-self.cx, self.y2-self.cy
|
||||
nx2, ny2 = dx2/r * self.width/2, dy2/r * self.width/2
|
||||
return ArcPoly([ # vertices
|
||||
(self.x1+nx1, self.y1+ny1),
|
||||
(self.x1-nx1, self.y1-ny1),
|
||||
(self.x2-nx2, self.y2-ny2),
|
||||
(self.x2+nx2, self.y2+ny2),
|
||||
], [ # arc segments (direction, center)
|
||||
(not self.clockwise, (self.x1, self.y1)),
|
||||
(self.clockwise, (self.cx, self.cy)),
|
||||
(self.clockwise, (self.x2, self.y2)),
|
||||
(not self.clockwise, (self.cx, self.cy)),
|
||||
], polarity_dark=self.polarity_dark)
|
||||
|
||||
def is_zero_size(self):
|
||||
return False # an arc with identical start and end points is defined as a circle
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rectangle(GraphicPrimitive):
|
||||
#: **Center** X coordinate
|
||||
x : float
|
||||
#: **Center** Y coordinate
|
||||
y : float
|
||||
#: width
|
||||
w : float
|
||||
#: height
|
||||
h : float
|
||||
#: rotation around center in radians
|
||||
rotation : float
|
||||
|
||||
def bounding_box(self):
|
||||
return self.to_arc_poly().bounding_box()
|
||||
|
||||
def to_arc_poly(self):
|
||||
sin, cos = math.sin(self.rotation), math.cos(self.rotation)
|
||||
sw, cw = sin*self.w/2, cos*self.w/2
|
||||
sh, ch = sin*self.h/2, cos*self.h/2
|
||||
x, y = self.x, self.y
|
||||
return ArcPoly([
|
||||
(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):
|
||||
color = fg if self.polarity_dark else bg
|
||||
x, y = self.x - self.w/2, self.y - self.h/2
|
||||
return tag('rect', x=prec(x), y=prec(y), width=prec(self.w), height=prec(self.h),
|
||||
**svg_rotation(self.rotation, self.x, self.y), fill=color)
|
||||
|
||||
def is_zero_size(self):
|
||||
return math.isclose(self.w, 0) or math.isclose(self.h, 0)
|
||||
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
#
|
||||
# copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
|
||||
# Modified from parser.py by Paulo Henrique Silva <ph.silva@gmail.com>
|
||||
# Copyright 2022 Jan Götte <code@jaseg.de>
|
||||
# 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.
|
||||
|
|
@ -23,7 +23,7 @@ import math
|
|||
import re
|
||||
from enum import Enum
|
||||
import warnings
|
||||
from dataclasses import dataclass, KW_ONLY
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .cam import CamFile, FileSettings
|
||||
|
|
@ -120,15 +120,15 @@ class Netlist(CamFile):
|
|||
return parser.parse(data, Path(filename))
|
||||
|
||||
def save(self, filename, settings=None, drop_comments=True):
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
f.write(self.to_ipc356(settings, drop_comments=drop_comments))
|
||||
with open(filename, 'wb') as f:
|
||||
f.write(self.write_to_bytes(settings, drop_comments=drop_comments))
|
||||
|
||||
def to_ipc356(self, settings=None, drop_comments=True, job_name=None):
|
||||
def write_to_bytes(self, settings=None, drop_comments=True, job_name=None):
|
||||
if settings is None:
|
||||
settings = self.import_settings.copy() or FileSettings()
|
||||
settings.zeros = None
|
||||
settings.number_format = (5,6)
|
||||
return '\n'.join(self._generate_lines(settings, drop_comments=drop_comments))
|
||||
return '\n'.join(self._generate_lines(settings, drop_comments=drop_comments)).encode('utf-8')
|
||||
|
||||
def _generate_lines(self, settings, drop_comments, job_name=None):
|
||||
yield 'C IPC-D-356 generated by Gerbonara'
|
||||
|
|
@ -326,17 +326,17 @@ class NetlistParser(object):
|
|||
|
||||
if name == 'UNITS':
|
||||
if value in ('CUST', 'CUST 0'):
|
||||
self.settings.units = Inch
|
||||
self.settings.unit = Inch
|
||||
self.settings.angle_unit = 'degree'
|
||||
self.has_unit = True
|
||||
|
||||
elif value == 'CUST 1':
|
||||
self.settings.units = MM
|
||||
self.settings.unit = MM
|
||||
self.settings.angle_unit = 'degree'
|
||||
self.has_unit = True
|
||||
|
||||
elif value == 'CUST 2':
|
||||
self.settings.units = Inch
|
||||
self.settings.unit = Inch
|
||||
self.settings.angle_unit = 'radian'
|
||||
self.has_unit = True
|
||||
|
||||
|
|
@ -414,7 +414,6 @@ class TestRecord:
|
|||
rotation : float = 0
|
||||
solder_mask : SoldermaskInfo = None
|
||||
lefover : str = None
|
||||
_ : KW_ONLY
|
||||
unit : LengthUnit = None
|
||||
|
||||
def __str__(self):
|
||||
|
|
@ -563,7 +562,6 @@ def format_coord_chain(line, settings, coords, cont, unit):
|
|||
class Outline:
|
||||
outline_type : OutlineType
|
||||
outline : [(float,)]
|
||||
_ : KW_ONLY
|
||||
unit : LengthUnit = None
|
||||
|
||||
@classmethod
|
||||
|
|
@ -596,7 +594,6 @@ class Conductor:
|
|||
layer : int
|
||||
aperture : (float,)
|
||||
coords : [(float,)]
|
||||
_ : KW_ONLY
|
||||
unit : LengthUnit = None
|
||||
|
||||
@classmethod
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2022 Jan Götte <code@jaseg.de>
|
||||
# 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.
|
||||
|
|
@ -39,15 +39,17 @@ MATCH_RULES = {
|
|||
'kicad': {
|
||||
'top copper': r'.*\.gtl|.*f.cu.(gbr|gtl)',
|
||||
'top mask': r'.*\.gts|.*f.mask.(gbr|gts)',
|
||||
'top silk': r'.*\.gto|.*f.silks.(gbr|gto)',
|
||||
'top silk': r'.*\.gto|.*f.silks(creen)?.(gbr|gto)',
|
||||
'top paste': r'.*\.gtp|.*f.paste.(gbr|gtp)',
|
||||
'bottom copper': r'.*\.gbl|.*b.cu.(gbr|gbl)',
|
||||
'bottom mask': r'.*\.gbs|.*b.mask.(gbr|gbs)',
|
||||
'bottom silk': r'.*\.gbo|.*b.silks.(gbr|gbo)',
|
||||
'bottom silk': r'.*\.gbo|.*b.silks(creen)?.(gbr|gbo)',
|
||||
'bottom paste': r'.*\.gbp|.*b.paste.(gbr|gbp)',
|
||||
'inner copper': r'.*\.gp?([0-9]+)|.*inn?e?r?([0-9]+).cu.(?:gbr|g[0-9]+)',
|
||||
'mechanical outline': r'.*\.(gm[0-9]+)|.*edge.cuts.(gbr|gm1)',
|
||||
'drill plated': r'.*\.(drl)',
|
||||
'drill nonplated': r'.*\-NPTH.(drl)',
|
||||
'drill plated': r'.*\-PTH.(drl)',
|
||||
'drill unknown': r'.*\.(drl)',
|
||||
'other netlist': r'.*\.d356',
|
||||
},
|
||||
|
||||
|
|
@ -80,6 +82,7 @@ MATCH_RULES = {
|
|||
'bottom paste': r'.*_boardoutline\.\w+', # FIXME verify this
|
||||
'drill plated': r'.*\.(drl)', # diptrace has unplated drills on the outline layer
|
||||
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
|
||||
'header regex': [['sufficient', r'top .*|bottom .*', r'G04 DipTrace [.-0-9a-z]*\*']],
|
||||
},
|
||||
|
||||
'target': {
|
||||
|
|
@ -149,22 +152,25 @@ MATCH_RULES = {
|
|||
|
||||
'allegro': {
|
||||
# Allegro doesn't have any widespread convention, so we rely heavily on the layer name auto-guesser here.
|
||||
'drill mech': r'.*\.(drl|rou)',
|
||||
'generic gerber': r'.*\.art',
|
||||
'drill plated': r'.*\.(drl)',
|
||||
'drill nonplated': r'.*\.(rou)',
|
||||
'other unknown': r'.*(place|assembly|keep.?in|keep.?out).*\.art',
|
||||
'autoguess': r'.*\.art',
|
||||
'excellon params': r'nc_param\.txt|ncdrill\.log|ncroute\.log',
|
||||
'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
|
||||
'header regex': [['required,sufficient', r'.*\.art', r'G04 File Origin:\s+Cadence Allegro [0-9]+\.[0-9]+[-a-zA-Z0-9]*']],
|
||||
},
|
||||
|
||||
'pads': {
|
||||
# Pads also does not seem to have a factory-default naming schema. Or it has one but everyone ignores it.
|
||||
'generic gerber': r'.*\.pho',
|
||||
'drill mech': r'.*\.drl',
|
||||
'autoguess': r'.*\.pho',
|
||||
'drill plated': r'.*\.drl',
|
||||
},
|
||||
|
||||
'zuken': {
|
||||
'generic gerber': r'.*\.fph',
|
||||
'autoguess': r'.*\.fph',
|
||||
'gerber params': r'.*\.fpl',
|
||||
'drill mech': r'.*\.fdr',
|
||||
'drill unknown': r'.*\.fdr',
|
||||
'excellon params': r'.*\.fdl',
|
||||
'other netlist': r'.*\.ipc',
|
||||
'ipc-2581': r'.*\.xml',
|
||||
1412
src/gerbonara/layers.py
Normal file
167
src/gerbonara/newstroke.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
from pathlib import Path
|
||||
import unicodedata
|
||||
import re
|
||||
import ast
|
||||
from functools import lru_cache
|
||||
import math
|
||||
from importlib.resources import files
|
||||
|
||||
from . import data
|
||||
from .utils import rotate_point, Tag
|
||||
|
||||
|
||||
STROKE_FONT_SCALE = 1/21
|
||||
FONT_OFFSET = -10
|
||||
DEFAULT_SPACE_WIDTH = 0.6
|
||||
DEFAULT_CHAR_GAP = 0.2
|
||||
|
||||
_dec = lambda c: ord(c)-ord('R')
|
||||
|
||||
|
||||
class Newstroke:
|
||||
def __init__(self, newstroke_cpp=None):
|
||||
if newstroke_cpp is None:
|
||||
newstroke_cpp = files(data).joinpath('newstroke_font.cpp').read_bytes()
|
||||
self.glyphs = dict(self.load_font(newstroke_cpp))
|
||||
|
||||
@classmethod
|
||||
@lru_cache
|
||||
def load(kls):
|
||||
return kls()
|
||||
|
||||
def render(self, text, size=1.0, x0=0, y0=0, rotation=0, h_align='left', v_align='bottom', space_width=DEFAULT_SPACE_WIDTH, char_gap=DEFAULT_CHAR_GAP, scale=(1, 1), mirror=(False, False)):
|
||||
text = unicodedata.normalize('NFC', text)
|
||||
missing_glyph = self.glyphs['?']
|
||||
sx, sy = scale
|
||||
mx, my = mirror
|
||||
x = 0
|
||||
|
||||
if rotation >= 180:
|
||||
rotation -= 180
|
||||
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
|
||||
x0, y0 = -x0, y0
|
||||
|
||||
# if mx:
|
||||
# y0 = -y0
|
||||
# if rotation == 0:
|
||||
# v_align = {'top': 'bottom', 'bottom': 'top'}.get(v_align, v_align)
|
||||
# else:
|
||||
# h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
|
||||
|
||||
x0, y0 = rotate_point(x0, y0, math.radians(-rotation))
|
||||
|
||||
alx, aly = 0, 0
|
||||
(minx, miny), (maxx, maxy) = bbox = self.bounding_box(text, size, space_width, char_gap)
|
||||
w = maxx - minx
|
||||
|
||||
if my:
|
||||
if rotation == 0:
|
||||
sx = -1
|
||||
h_align = {'left': 'right', 'right': 'left'}.get(h_align, h_align)
|
||||
else:
|
||||
sy = -sy
|
||||
|
||||
if h_align != 'left':
|
||||
if h_align == 'right':
|
||||
alx = -w
|
||||
elif h_align == 'center':
|
||||
alx = -w/2
|
||||
else:
|
||||
raise ValueError(f'Invalid h_align value "{h_align}"')
|
||||
|
||||
if v_align == 'top':
|
||||
aly = sy*1.2*size
|
||||
elif v_align == 'middle':
|
||||
aly = sy*1.2*size/2
|
||||
elif v_align != 'bottom':
|
||||
raise ValueError(f'Invalid v_align value "{v_align}"')
|
||||
|
||||
for c in text:
|
||||
if c == ' ':
|
||||
x += space_width
|
||||
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 [rotate_point((px+x)*sx*size+alx+x0, py*sy*size+aly+y0, math.radians(-rotation), x0, y0) for px, py in st]
|
||||
|
||||
x += glyph_w
|
||||
|
||||
def render_svg(self, text, size=1.0, x0=0, y0=0, rotation=0, h_align='left', v_align='bottom', space_width=DEFAULT_SPACE_WIDTH, char_gap=DEFAULT_CHAR_GAP, scale=(1, -1), mirror=(False, False), **svg_attrs):
|
||||
if 'stroke_linecap' not in svg_attrs:
|
||||
svg_attrs['stroke_linecap'] = 'round'
|
||||
if 'stroke_linejoin' not in svg_attrs:
|
||||
svg_attrs['stroke_linejoin'] = 'round'
|
||||
if 'stroke_width' not in svg_attrs:
|
||||
svg_attrs['stroke_width'] = f'{0.2*size:.3f}'
|
||||
svg_attrs['fill'] = 'none'
|
||||
|
||||
strokes = ['M ' + ' L '.join(f'{x:.3f} {y:.3f}' for x, y in stroke)
|
||||
for stroke in self.render(text, size=size, x0=x0, y0=y0, rotation=rotation, h_align=h_align,
|
||||
v_align=v_align, mirror=mirror, space_width=space_width, char_gap=char_gap,
|
||||
scale=scale)]
|
||||
return Tag('path', d=' '.join(strokes), **svg_attrs)
|
||||
|
||||
def bounding_box(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))
|
||||
x += glyph_w*size
|
||||
|
||||
return (0, -0.2*size), (x, 1.2*size)
|
||||
|
||||
def load_font(self, newstroke_cpp):
|
||||
e = []
|
||||
for char, (width, strokes) in self.load_glyphs(newstroke_cpp):
|
||||
yield char, (width, strokes)
|
||||
|
||||
@classmethod
|
||||
def decode_stroke(kls, stroke, start_x):
|
||||
for i in range(0, len(stroke), 2):
|
||||
x = (stroke[i]-0x52-start_x)*STROKE_FONT_SCALE
|
||||
y = (stroke[i+1]-0x52+FONT_OFFSET)*STROKE_FONT_SCALE
|
||||
yield (x, y)
|
||||
|
||||
@classmethod
|
||||
def decode_glyph(kls, data):
|
||||
start_x, end_x = data[0]-0x52, data[1]-0x52
|
||||
width = end_x - start_x
|
||||
|
||||
strokes = tuple(tuple(kls.decode_stroke(st, start_x)) for st in data[2:].split(b' R'))
|
||||
return width*STROKE_FONT_SCALE, strokes
|
||||
|
||||
@classmethod
|
||||
def load_glyphs(kls, newstroke_cpp):
|
||||
it = iter(newstroke_cpp.splitlines())
|
||||
|
||||
for line in it:
|
||||
if re.search(rb'char.*\*', line):
|
||||
break
|
||||
|
||||
charcode = 0x20
|
||||
for line in it:
|
||||
if (match := re.search(rb'".*"', line)):
|
||||
yield chr(charcode), kls.decode_glyph(match.group(0)[1:-1].replace(b'\\\\', b'\\'))
|
||||
charcode += 1
|
||||
else:
|
||||
if b'}' in line:
|
||||
break
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import time
|
||||
t1 = time.time()
|
||||
Newstroke()
|
||||
t2 = time.time()
|
||||
print((t2-t1)*1000)
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
# Modified from parser.py by Paulo Henrique Silva <ph.silva@gmail.com>
|
||||
# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
|
||||
# Copyright 2019 Hiroshi Murayama <opiopan@gmail.com>
|
||||
# Copyright 2022 Jan Götte <code@jaseg.de>
|
||||
# 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.
|
||||
|
|
@ -21,9 +21,11 @@
|
|||
|
||||
import re
|
||||
import math
|
||||
import copy
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
import dataclasses
|
||||
import functools
|
||||
|
||||
from .cam import CamFile, FileSettings
|
||||
from .utils import MM, Inch, units, InterpMode, UnknownStatementWarning
|
||||
|
|
@ -46,6 +48,19 @@ def points_close(a, b):
|
|||
|
||||
class GerberFile(CamFile):
|
||||
""" A single gerber file.
|
||||
|
||||
:ivar objects: List of objects in this Gerber file. All elements must be subclasses of :py:class:`.GraphicObject`.
|
||||
:ivar comments: List of string with textual comments in the source Gerber file. These are not saved by default, but
|
||||
when you call :py:meth:`.GerberFile.save` with ``drop_comments=False``, the contents of this list
|
||||
will be included as comments at the top of the output file.
|
||||
:ivar generator_hints: List of strings indicating which EDA tool generated this file. Hints are added to this list
|
||||
during file parsing whenever the parser encounters an idiosyncratic file format variation.
|
||||
:ivar import_settings: File format settings used in the original file. This can be empty if this
|
||||
:py:class:`.GerberFile` was generated programatically.
|
||||
:ivar layer_hints: Similar to ``generator_hints``, this is a list containing hints which layer type this file could
|
||||
belong to. Usually, this will be empty, but some EDA tools automatically include layer
|
||||
information inside tool-specific comments in the Gerber files they generate.
|
||||
:ivar file_attrs: List of strings with Gerber X3 file attributes. Each list item corresponds to one file attribute.
|
||||
"""
|
||||
|
||||
def __init__(self, objects=None, comments=None, import_settings=None, original_path=None, generator_hints=None,
|
||||
|
|
@ -56,101 +71,169 @@ class GerberFile(CamFile):
|
|||
self.generator_hints = generator_hints or []
|
||||
self.layer_hints = layer_hints or []
|
||||
self.import_settings = import_settings
|
||||
self.apertures = [] # FIXME get rid of this? apertures are already in the objects.
|
||||
self.file_attrs = file_attrs or {}
|
||||
|
||||
def to_excellon(self):
|
||||
def apertures(self):
|
||||
""" Iterate through all apertures in this layer. """
|
||||
found = set()
|
||||
for obj in self.objects:
|
||||
if hasattr(obj, 'aperture'):
|
||||
ap = obj.aperture
|
||||
if ap not in found:
|
||||
found.add(ap)
|
||||
yield ap
|
||||
|
||||
def aperture_macros(self):
|
||||
found = set()
|
||||
for aperture in self.apertures():
|
||||
if isinstance(aperture, apertures.ApertureMacroInstance):
|
||||
macro = aperture.macro
|
||||
if (macro.name, macro) not in found:
|
||||
found.add((macro.name, macro))
|
||||
yield macro
|
||||
|
||||
def map_apertures(self, map_or_callable, cache=True):
|
||||
""" Replace all apertures in all objects in this layer according to the given map or callable.
|
||||
|
||||
When a map is passed, apertures that are not in the map are left alone. When a callable is given, it is called
|
||||
with the old aperture as its argument.
|
||||
|
||||
:param map_or_callable: A dict-like object, or a callable mapping old to new apertures
|
||||
:param cache: When True (default) and a callable is passed, caches the output of callable, only calling it once
|
||||
for each old aperture.
|
||||
"""
|
||||
|
||||
if callable(map_or_callable):
|
||||
if cache:
|
||||
map_or_callable = functools.cache(map_or_callable)
|
||||
else:
|
||||
d = map_or_callable
|
||||
map_or_callable = lambda ap: d.get(ap, ap)
|
||||
|
||||
for obj in self.objects:
|
||||
if (aperture := getattr(obj, 'aperture', None)):
|
||||
obj.aperture = map_or_callable(aperture)
|
||||
|
||||
def dedup_apertures(self, settings=None):
|
||||
""" Merge all apertures and aperture macros in this layer that result in the same Gerber definition under the
|
||||
given :py:class:~.FileSettings:.
|
||||
|
||||
When no explicit settings are given, uses Gerbonara's default settings.
|
||||
|
||||
:param settings: settings under which to de-duplicate the apertures.
|
||||
"""
|
||||
|
||||
if settings is None:
|
||||
settings = FileSettings.defaults()
|
||||
|
||||
cache = {}
|
||||
macro_names = set()
|
||||
def lookup(aperture):
|
||||
nonlocal cache, settings
|
||||
if isinstance(aperture, apertures.ApertureMacroInstance):
|
||||
macro = aperture.macro
|
||||
macro_def = macro.to_gerber(settings)
|
||||
if macro_def not in cache:
|
||||
cache[macro_def] = macro
|
||||
|
||||
if macro.name in macro_names:
|
||||
macro._reset_name()
|
||||
macro_names.add(macro.name)
|
||||
|
||||
else:
|
||||
macro = cache[macro_def]
|
||||
aperture = dataclasses.replace(aperture, macro=macro)
|
||||
|
||||
code = aperture.to_gerber(settings)
|
||||
if code not in cache:
|
||||
cache[code] = aperture
|
||||
|
||||
return cache[code]
|
||||
|
||||
self.map_apertures(lookup)
|
||||
|
||||
def to_excellon(self, plated=None, errors='raise', holes_only=False):
|
||||
""" Convert this excellon file into a :py:class:`~.excellon.ExcellonFile`. This will convert interpolated lines
|
||||
into slots, and circular aperture flashes into holes. Other features such as ``G36`` polygons or flashes with
|
||||
non-circular apertures will result in a :py:obj:`ValueError`. You can, of course, programmatically remove such
|
||||
features from a :py:class:`GerberFile` before conversion. """
|
||||
new_objs = []
|
||||
new_tools = {}
|
||||
for obj in self.objects:
|
||||
if not isinstance(obj, Line) or isinstance(obj, Arc) or isinstance(obj, Flash) or \
|
||||
not isinstance(obj.aperture, CircleAperture):
|
||||
raise ValueError('Cannot convert {type(obj)} to excellon!')
|
||||
if holes_only and not isinstance(obj, go.Flash):
|
||||
continue
|
||||
|
||||
if not (new_tool := new_tools.get(id(obj.aperture))):
|
||||
if not isinstance(obj, (go.Line, go.Arc, go.Flash)) or \
|
||||
not isinstance(getattr(obj, 'aperture', None), apertures.CircleAperture):
|
||||
if errors == 'raise':
|
||||
raise ValueError(f'Cannot convert {obj} to excellon.')
|
||||
elif errors == 'warn':
|
||||
warnings.warn(f'Gerber to Excellon conversion: Cannot convert {obj} to excellon.')
|
||||
continue
|
||||
elif errors == 'ignore':
|
||||
continue
|
||||
else:
|
||||
raise ValueError('Invalid "errors" parameter. Allowed values: "raise", "warn" or "ignore".')
|
||||
|
||||
if not (new_tool := new_tools.get(obj.aperture)):
|
||||
# TODO plating?
|
||||
new_tool = new_tools[id(obj.aperture)] = ExcellonTool(obj.aperture.diameter)
|
||||
new_obj = dataclasses.replace(obj, aperture=new_tool)
|
||||
new_tool = new_tools[obj.aperture] = apertures.ExcellonTool(obj.aperture.diameter, plated=plated, unit=obj.aperture.unit)
|
||||
new_objs.append(dataclasses.replace(obj, aperture=new_tool))
|
||||
|
||||
return ExcellonFile(objects=new_objs, comments=self.comments)
|
||||
|
||||
def to_gerber(self):
|
||||
return
|
||||
def to_gerber(self, errors='raise'):
|
||||
""" Counterpart to :py:meth:`~.excellon.ExcellonFile.to_gerber`. Does nothing and returns :py:obj:`self`. """
|
||||
return self
|
||||
|
||||
def merge(self, other):
|
||||
def merge(self, other, mode='above', keep_settings=False):
|
||||
""" Merge ``other`` into ``self``, i.e. add all objects that are in ``other`` to ``self``. This resets
|
||||
:py:attr:`.import_settings` and :py:attr:`~.GerberFile.generator`. Units and other file-specific settings are
|
||||
handled automatically.
|
||||
|
||||
:param mode: One of the strings :py:obj:`"above"` (default) or :py:obj:`"below"`, specifying whether the other
|
||||
layer's objects will be placed above this layer's objects (placing them towards the end of the file), or
|
||||
below this layer's objects (placing them towards the beginning of the file). This setting is only relevant
|
||||
when there are overlapping objects of different polarity, otherwise the rendered result will be the same
|
||||
either way.
|
||||
"""
|
||||
if other is None:
|
||||
return
|
||||
|
||||
self.import_settings = None
|
||||
other = other.to_gerber()
|
||||
|
||||
if not keep_settings:
|
||||
self.import_settings = None
|
||||
self.comments += other.comments
|
||||
|
||||
# dedup apertures
|
||||
new_apertures = {}
|
||||
replace_apertures = {}
|
||||
mock_settings = FileSettings()
|
||||
for ap in self.apertures + other.apertures:
|
||||
gbr = ap.to_gerber(mock_settings)
|
||||
if gbr not in new_apertures:
|
||||
new_apertures[gbr] = ap
|
||||
else:
|
||||
replace_apertures[id(ap)] = new_apertures[gbr]
|
||||
self.apertures = list(new_apertures.values())
|
||||
# Join objects
|
||||
if mode == 'below':
|
||||
self.objects = other.objects + self.objects
|
||||
elif mode == 'above':
|
||||
self.objects += other.objects
|
||||
else:
|
||||
raise ValueError(f'Invalid mode "{mode}", must be one of "above" or "below".')
|
||||
|
||||
self.objects += other.objects
|
||||
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)
|
||||
self.dedup_apertures()
|
||||
|
||||
def dilate(self, offset, unit=MM, polarity_dark=True):
|
||||
# TODO add tests for this
|
||||
self.apertures = [ aperture.dilated(offset, unit) for aperture in self.apertures ]
|
||||
self.map_apertures(lambda ap: ap.dilated(offset, unit))
|
||||
|
||||
offset_circle = CircleAperture(offset, unit=unit)
|
||||
self.apertures.append(offset_circle)
|
||||
|
||||
new_primitives = []
|
||||
for p in self.primitives:
|
||||
|
||||
p.polarity_dark = polarity_dark
|
||||
offset_circle = apertures.CircleAperture(offset, unit=unit)
|
||||
new_objects = []
|
||||
for obj in self.objects:
|
||||
obj.polarity_dark = polarity_dark
|
||||
|
||||
# Ignore Line, Arc, Flash. Their actual dilation has already been done by dilating the apertures above.
|
||||
if isinstance(p, Region):
|
||||
ol = p.poly.outline
|
||||
for start, end, arc_center in zip(ol, ol[1:] + ol[0], p.poly.arc_centers):
|
||||
if arc_center is not None:
|
||||
new_primitives.append(Arc(*start, *end, *arc_center,
|
||||
polarity_dark=polarity_dark, unit=p.unit, aperture=offset_circle))
|
||||
|
||||
else:
|
||||
new_primitives.append(Line(*start, *end,
|
||||
polarity_dark=polarity_dark, unit=p.unit, aperture=offset_circle))
|
||||
if isinstance(obj, Region):
|
||||
new_objects.extend(obj.outline_objects(offset_circle))
|
||||
|
||||
# it's safe to append these at the end since we compute a logical OR of opaque areas anyway.
|
||||
self.primitives.extend(new_primitives)
|
||||
self.objects.extend(new_objects)
|
||||
|
||||
@classmethod
|
||||
def open(kls, filename, enable_includes=False, enable_include_dir=None):
|
||||
def open(kls, filename, enable_includes=False, enable_include_dir=None, override_settings=None):
|
||||
""" Load a Gerber file from the file system. The Gerber standard contains this wonderful and totally not
|
||||
insecure "include file" setting. We disable it by default and do not parse Gerber includes because a) nobody
|
||||
actually uses them, and b) they're a bad idea from a security point of view. In case you actually want these,
|
||||
|
|
@ -166,19 +249,21 @@ class GerberFile(CamFile):
|
|||
with open(filename, "r") as f:
|
||||
if enable_includes and enable_include_dir is None:
|
||||
enable_include_dir = filename.parent
|
||||
return kls.from_string(f.read(), enable_include_dir, filename=filename)
|
||||
return kls.from_string(f.read(), enable_include_dir, filename=filename, override_settings=override_settings)
|
||||
|
||||
@classmethod
|
||||
def from_string(kls, data, enable_include_dir=None, filename=None):
|
||||
def from_string(kls, data, enable_include_dir=None, filename=None, override_settings=None):
|
||||
""" Parse given string as Gerber file content. For the meaning of the parameters, see
|
||||
:py:meth:`~.GerberFile.open`. """
|
||||
# filename arg is for error messages
|
||||
obj = kls()
|
||||
GerberParser(obj, include_dir=enable_include_dir).parse(data, filename=filename)
|
||||
parser = GerberParser(obj, include_dir=enable_include_dir, override_settings=override_settings)
|
||||
parser.parse(data, filename=filename)
|
||||
return obj
|
||||
|
||||
def _generate_statements(self, settings, drop_comments=True):
|
||||
""" Export this file as Gerber code, yields one str per line. """
|
||||
|
||||
yield 'G04 Gerber file generated by Gerbonara*'
|
||||
for name, value in self.file_attrs.items():
|
||||
attrdef = ','.join([name, *map(str, value)])
|
||||
|
|
@ -187,8 +272,10 @@ class GerberFile(CamFile):
|
|||
|
||||
zeros = 'T' if settings.zeros == 'trailing' else 'L' # default to leading if "None" is specified
|
||||
notation = 'I' if settings.notation == 'incremental' else 'A' # default to absolute
|
||||
number_format = str(settings.number_format[0]) + str(settings.number_format[1])
|
||||
yield f'%FS{zeros}{notation}X{number_format}Y{number_format}*%'
|
||||
num_int, num_frac = settings.number_format or (4,5)
|
||||
assert 1 <= num_int <= 9
|
||||
assert 1 <= num_frac <= 9
|
||||
yield f'%FS{zeros}{notation}X{num_int}{num_frac}Y{num_int}{num_frac}*%'
|
||||
yield '%IPPOS*%'
|
||||
yield 'G75'
|
||||
yield '%LPD*%'
|
||||
|
|
@ -198,26 +285,26 @@ class GerberFile(CamFile):
|
|||
for cmt in self.comments:
|
||||
yield f'G04{cmt}*'
|
||||
|
||||
# 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)
|
||||
self.dedup_apertures()
|
||||
|
||||
processed_macros = set()
|
||||
aperture_map = {}
|
||||
for number, aperture in enumerate(self.apertures, start=10):
|
||||
am_stmt = lambda macro: f'%AM{macro.name}*\n{macro.to_gerber(settings)}*\n%'
|
||||
aperture_map = {ap: num for num, ap in enumerate(self.apertures(), start=10)}
|
||||
|
||||
if isinstance(aperture, apertures.ApertureMacroInstance):
|
||||
macro_def = am_stmt(aperture._rotated().macro)
|
||||
if macro_def not in processed_macros:
|
||||
processed_macros.add(macro_def)
|
||||
yield macro_def
|
||||
if settings.calculate_out_all_aperture_macros:
|
||||
adds = []
|
||||
for aperture, number in aperture_map.items():
|
||||
if isinstance(aperture, apertures.ApertureMacroInstance):
|
||||
aperture = aperture.calculate_out(settings.unit, macro_name=f'CALCM{number}')
|
||||
yield am_stmt(aperture.macro)
|
||||
adds.append(f'%ADD{number}{aperture.to_gerber(settings)}*%')
|
||||
yield from adds
|
||||
|
||||
yield f'%ADD{number}{aperture.to_gerber(settings)}*%'
|
||||
else:
|
||||
for macro in self.aperture_macros():
|
||||
yield am_stmt(macro)
|
||||
|
||||
aperture_map[id(aperture)] = number
|
||||
for aperture, number in aperture_map.items():
|
||||
yield f'%ADD{number}{aperture.to_gerber(settings)}*%'
|
||||
|
||||
def warn(msg, kls=SyntaxWarning):
|
||||
warnings.warn(msg, kls)
|
||||
|
|
@ -230,7 +317,7 @@ class GerberFile(CamFile):
|
|||
|
||||
def __str__(self):
|
||||
name = f'{self.original_path.name} ' if self.original_path else ''
|
||||
return f'<GerberFile {name}with {len(self.apertures)} apertures, {len(self.objects)} objects>'
|
||||
return f'<GerberFile {name}with {len(list(self.apertures()))} apertures, {len(self.objects)} objects>'
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
|
@ -238,10 +325,10 @@ class GerberFile(CamFile):
|
|||
def save(self, filename, settings=None, drop_comments=True):
|
||||
""" Save this Gerber file to the file system. See :py:meth:`~.GerberFile.generate_gerber` for the meaning
|
||||
of the arguments. """
|
||||
with open(filename, 'w', encoding='utf-8') as f: # Encoding is specified as UTF-8 by spec.
|
||||
f.write(self.generate_gerber(settings, drop_comments=drop_comments))
|
||||
with open(filename, 'wb') as f: # Encoding is specified as UTF-8 by spec.
|
||||
f.write(self.write_to_bytes(settings, drop_comments=drop_comments))
|
||||
|
||||
def generate_gerber(self, settings=None, drop_comments=True):
|
||||
def write_to_bytes(self, settings=None, drop_comments=True):
|
||||
""" Export to Gerber format. Uses either the file's original settings or sane default settings if you don't give
|
||||
any.
|
||||
|
||||
|
|
@ -253,39 +340,47 @@ class GerberFile(CamFile):
|
|||
:rtype: str
|
||||
"""
|
||||
if settings is None:
|
||||
settings = self.import_settings.copy() or FileSettings()
|
||||
settings.zeros = None
|
||||
settings.number_format = (5,6)
|
||||
return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments))
|
||||
if self.import_settings:
|
||||
settings = self.import_settings.copy()
|
||||
settings.zeros = None
|
||||
else:
|
||||
settings = FileSettings.defaults()
|
||||
return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments)).encode('utf-8')
|
||||
|
||||
def __len__(self):
|
||||
return len(self.objects)
|
||||
|
||||
def scale(self, factor, unit=MM):
|
||||
scaled_apertures = {}
|
||||
|
||||
self.map_apertures(lambda ap: ap.scaled(factor))
|
||||
|
||||
for obj in self.objects:
|
||||
obj.scale(factor)
|
||||
|
||||
def offset(self, dx=0, dy=0, unit=MM):
|
||||
# TODO round offset to file resolution
|
||||
for obj in self.objects:
|
||||
obj.offset(dx, dy, unit)
|
||||
|
||||
def rotate(self, angle:'radian', center=(0,0), unit=MM):
|
||||
def rotate(self, angle:'radian', cx=0, cy=0, unit=MM):
|
||||
if math.isclose(angle % (2*math.pi), 0):
|
||||
return
|
||||
|
||||
# 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
|
||||
self.map_apertures(lambda ap: ap.rotated(angle))
|
||||
|
||||
for obj in self.objects:
|
||||
obj.rotate(angle, *center, unit)
|
||||
obj.rotate(angle, cx, cy, unit)
|
||||
|
||||
def invert_polarity(self):
|
||||
""" Invert the polarity (color) of each object in this file. """
|
||||
for obj in self.objects:
|
||||
obj.polarity_dark = not p.polarity_dark
|
||||
|
||||
obj.polarity_dark = not obj.polarity_dark
|
||||
|
||||
|
||||
class GraphicsState:
|
||||
""" Internal class used to track Gerber processing state during import and export. """
|
||||
""" Internal class used to track Gerber processing state during import and export.
|
||||
"""
|
||||
|
||||
def __init__(self, warn, file_settings=None, aperture_map=None):
|
||||
self.image_polarity = 'positive' # IP image polarity; deprecated
|
||||
|
|
@ -368,7 +463,7 @@ class GraphicsState:
|
|||
obj = go.Flash(*self.map_coord(*self.point), self.aperture,
|
||||
polarity_dark=self._polarity_dark,
|
||||
unit=self.unit,
|
||||
attrs=self.object_attrs)
|
||||
attrs=copy.copy(self.object_attrs))
|
||||
return obj
|
||||
|
||||
def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False):
|
||||
|
|
@ -394,13 +489,13 @@ class GraphicsState:
|
|||
raise SyntaxError("i/j coordinates given for linear D01 operation (which doesn't take i/j)")
|
||||
|
||||
return go.Line(*old_point, *self.map_coord(*self.point), aperture,
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
|
||||
|
||||
else:
|
||||
if i is None and j is None:
|
||||
self.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values')
|
||||
return go.Line(*old_point, *self.map_coord(*self.point), aperture,
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
|
||||
|
||||
else:
|
||||
if i is None:
|
||||
|
|
@ -417,7 +512,7 @@ class GraphicsState:
|
|||
if not multi_quadrant:
|
||||
return go.Arc(*old_point, *new_point, *self.map_coord(i, j, relative=True),
|
||||
clockwise=clockwise, aperture=(self.aperture if aperture else None),
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
|
||||
|
||||
else:
|
||||
if math.isclose(old_point[0], new_point[0]) and math.isclose(old_point[1], new_point[1]):
|
||||
|
|
@ -430,7 +525,7 @@ class GraphicsState:
|
|||
|
||||
arc = lambda cx, cy: go.Arc(*old_point, *new_point, cx, cy,
|
||||
clockwise=clockwise, aperture=aperture,
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=self.object_attrs)
|
||||
polarity_dark=self._polarity_dark, unit=unit, attrs=copy.copy(self.object_attrs))
|
||||
arcs = [ arc(cx, cy), arc(-cx, cy), arc(cx, -cy), arc(-cx, -cy) ]
|
||||
arcs = sorted(arcs, key=lambda a: a.numeric_error())
|
||||
|
||||
|
|
@ -469,9 +564,11 @@ class GraphicsState:
|
|||
yield '%LPD*%' if polarity_dark else '%LPC*%'
|
||||
|
||||
def set_aperture(self, aperture):
|
||||
if self.aperture != aperture:
|
||||
ap_id = self.aperture_map[aperture]
|
||||
old_ap_id = self.aperture_map.get(self.aperture, None)
|
||||
if ap_id != old_ap_id:
|
||||
self.aperture = aperture
|
||||
yield f'D{self.aperture_map[id(aperture)]}*'
|
||||
yield f'D{ap_id}*'
|
||||
|
||||
def set_current_point(self, point, unit=None):
|
||||
point_mm = MM(point[0], unit), MM(point[1], unit)
|
||||
|
|
@ -490,17 +587,20 @@ class GraphicsState:
|
|||
|
||||
def interpolation_mode_statement(self):
|
||||
return {
|
||||
InterpMode.LINEAR: 'G01',
|
||||
InterpMode.CIRCULAR_CW: 'G02',
|
||||
InterpMode.CIRCULAR_CCW: 'G03'}[self.interpolation_mode]
|
||||
InterpMode.LINEAR: 'G01*',
|
||||
InterpMode.CIRCULAR_CW: 'G02*',
|
||||
InterpMode.CIRCULAR_CCW: 'G03*'}[self.interpolation_mode]
|
||||
|
||||
|
||||
class GerberParser:
|
||||
""" Internal class that contains all of the actual Gerber parsing magic. """
|
||||
""" Internal class that contains all of the actual Gerber parsing magic.
|
||||
"""
|
||||
|
||||
NUMBER = r"[\+-]?\d+"
|
||||
DECIMAL = r"[\+-]?\d+([.]?\d+)?"
|
||||
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
|
||||
MAX_STEP_REPEAT_INSTANCES = 100000
|
||||
MAX_STEP_REPEAT_RESULT_OBJECTS = 100000
|
||||
|
||||
STATEMENT_REGEXES = {
|
||||
'coord': fr"(G0?[123]|G74|G75|G54|G55)?\s*(?:X\+?(-?)({NUMBER}))?(?:Y\+?(-?)({NUMBER}))?" \
|
||||
|
|
@ -508,6 +608,7 @@ class GerberParser:
|
|||
fr"(?:D0?([123]))?$",
|
||||
'region_start': r'G36$',
|
||||
'region_end': r'G37$',
|
||||
'eof': r"(D02)?M0?[02]", # P-CAD 2006 files have a spurious D02 before M02 as in "D02M02"
|
||||
'aperture': r"(G54|G55)?\s*D(?P<number>\d+)",
|
||||
# Allegro combines format spec and unit into one long illegal extended command.
|
||||
'allegro_format_spec': r"FS(?P<zero>(L|T|D))?(?P<notation>(A|I))[NG0-9]*X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])[DM0-9]*\*MO(?P<unit>IN|MM)",
|
||||
|
|
@ -528,26 +629,28 @@ class GerberParser:
|
|||
'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})(,(?P<modifiers>[^,%]*))?$",
|
||||
'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)",
|
||||
'siemens_garbage': r'^ICAS$',
|
||||
'step_repeat': fr'^SR(?P<coords>X(?P<X>[0-9]+)Y(?P<Y>[0-9]+)I(?P<I>{DECIMAL})J(?P<J>{DECIMAL}))?$',
|
||||
'old_unit':r'(?P<mode>G7[01])',
|
||||
'old_notation': r'(?P<mode>G9[01])',
|
||||
'eof': r"M0?[02]",
|
||||
'ignored': r"(?P<stmt>M01)",
|
||||
# NOTE: The official spec says names can be empty or contain commas. I think that doesn't make sense.
|
||||
'attribute': r"(?P<eagle_garbage>G04 #@! %)?(?P<type>TF|TA|TO|TD)(?P<name>[._$a-zA-Z][._$a-zA-Z0-9]*)(,(?P<value>.*))",
|
||||
'attribute': r"(?P<eagle_garbage>G04 #@! %)?(?P<type>TF|TA|TO|TD)(?P<name>[._$a-zA-Z][._$a-zA-Z0-9]*)?(,(?P<value>.*))?",
|
||||
# Eagle file attributes handled above.
|
||||
'comment': r"G0?4(?P<comment>[^*]*)",
|
||||
}
|
||||
|
||||
def __init__(self, target, include_dir=None):
|
||||
def __init__(self, target, include_dir=None, override_settings=None):
|
||||
""" Pass an include dir to enable IF include statements (potentially DANGEROUS!). """
|
||||
self.target = target
|
||||
self.include_dir = include_dir
|
||||
self.include_stack = []
|
||||
self.file_settings = FileSettings()
|
||||
self.file_settings = override_settings or FileSettings()
|
||||
self.graphics_state = GraphicsState(warn=self.warn, file_settings=self.file_settings)
|
||||
self.aperture_map = {}
|
||||
self.aperture_macros = {}
|
||||
self.current_region = None
|
||||
self.step_repeat_coords = None
|
||||
self.step_repeat_objects = None
|
||||
self.eof_found = False
|
||||
self.multi_quadrant_mode = None # used only for syntax checking
|
||||
self.macros = {}
|
||||
|
|
@ -609,7 +712,6 @@ class GerberParser:
|
|||
self.warn(f'Unknown statement found: "{self._shorten_line()}", ignoring.', UnknownStatementWarning)
|
||||
self.target.comments.append(f'Unknown statement found: "{self._shorten_line()}", ignoring.')
|
||||
|
||||
self.target.apertures = list(self.aperture_map.values())
|
||||
self.target.import_settings = self.file_settings
|
||||
self.target.unit = self.file_settings.unit
|
||||
self.target.file_attrs = self.file_attrs
|
||||
|
|
@ -691,7 +793,10 @@ class GerberParser:
|
|||
# in multi-quadrant mode this may return None if start and end point of the arc are the same.
|
||||
obj = self.graphics_state.interpolate(x, y, i, j, multi_quadrant=self.multi_quadrant_mode)
|
||||
if obj is not None:
|
||||
self.target.objects.append(obj)
|
||||
if self.step_repeat_objects:
|
||||
self.step_repeat_objects.append(obj)
|
||||
else:
|
||||
self.target.objects.append(obj)
|
||||
else:
|
||||
obj = self.graphics_state.interpolate(x, y, i, j, aperture=False, multi_quadrant=self.multi_quadrant_mode)
|
||||
if obj is not None:
|
||||
|
|
@ -702,14 +807,21 @@ class GerberParser:
|
|||
if self.current_region:
|
||||
# Start a new region for every outline. As gerber has no concept of fill rules or winding numbers,
|
||||
# it does not make a graphical difference, and it makes the implementation slightly easier.
|
||||
self.target.objects.append(self.current_region)
|
||||
if self.step_repeat_objects:
|
||||
self.step_repeat_objects.append(self.current_region)
|
||||
else:
|
||||
self.target.objects.append(self.current_region)
|
||||
self.current_region = go.Region(
|
||||
polarity_dark=self.graphics_state.polarity_dark,
|
||||
unit=self.file_settings.unit)
|
||||
|
||||
elif op == '3':
|
||||
if self.current_region is None:
|
||||
self.target.objects.append(self.graphics_state.flash(x, y))
|
||||
obj = self.graphics_state.flash(x, y)
|
||||
if self.step_repeat_objects:
|
||||
self.step_repeat_objects.append(obj)
|
||||
else:
|
||||
self.target.objects.append(obj)
|
||||
else:
|
||||
raise SyntaxError('DO3 flash statement inside region')
|
||||
|
||||
|
|
@ -750,12 +862,17 @@ class GerberParser:
|
|||
if match['shape'] in 'RO' and (math.isclose(modifiers[0], 0) or math.isclose(modifiers[1], 0)):
|
||||
self.warn('Definition of zero-width and/or zero-height rectangle or obround aperture. This is invalid according to spec.' )
|
||||
|
||||
new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=self.aperture_attrs.copy(),
|
||||
# Polygon aperture rotation is specified in degrees, but radians are easier to work with
|
||||
if match['shape'] == 'P':
|
||||
if len(modifiers) > 2:
|
||||
modifiers[2] = math.radians(modifiers[2])
|
||||
|
||||
new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=tuple(self.aperture_attrs.items()),
|
||||
original_number=number)
|
||||
|
||||
elif (macro := self.aperture_macros.get(match['shape'])):
|
||||
new_aperture = apertures.ApertureMacroInstance(macro, modifiers, unit=self.file_settings.unit,
|
||||
attrs=self.aperture_attrs.copy(), original_number=number)
|
||||
new_aperture = apertures.ApertureMacroInstance(macro, tuple(modifiers), unit=self.file_settings.unit,
|
||||
attrs=tuple(self.aperture_attrs.items()), original_number=number)
|
||||
|
||||
else:
|
||||
raise ValueError(f'Aperture shape "{match["shape"]}" is unknown')
|
||||
|
|
@ -767,19 +884,30 @@ class GerberParser:
|
|||
match['name'], match['macro'], self.file_settings.unit)
|
||||
|
||||
def _parse_format_spec(self, match):
|
||||
# This is a common problem in Eagle files, so just suppress it
|
||||
self.file_settings.zeros = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading')
|
||||
if self.file_settings.zeros is not None:
|
||||
self.warn('Re-definition of zero suppression setting. Ignoring.')
|
||||
else:
|
||||
# This is a common problem in Eagle files, so just suppress it
|
||||
self.file_settings.zeros = {'L': 'leading', 'T': 'trailing'}.get(match['zero'], 'leading')
|
||||
|
||||
self.file_settings.notation = 'incremental' if match['notation'] == 'I' else 'absolute'
|
||||
|
||||
if match['x'] != match['y']:
|
||||
raise SyntaxError(f'FS specifies different coordinate formats for X and Y ({match["x"]} != {match["y"]})')
|
||||
self.file_settings.number_format = int(match['x'][0]), int(match['x'][1])
|
||||
|
||||
if self.file_settings.number_format != (None, None):
|
||||
self.warn('Re-definition of number format setting. Ignoring.')
|
||||
else:
|
||||
self.file_settings.number_format = int(match['x'][0]), int(match['x'][1])
|
||||
|
||||
def _parse_unit_mode(self, match):
|
||||
if match['unit'] == 'MM':
|
||||
self.graphics_state.unit = self.file_settings.unit = MM
|
||||
if self.file_settings.unit is not None:
|
||||
self.warn('Re-definition of file units. Ignoring.')
|
||||
else:
|
||||
self.graphics_state.unit = self.file_settings.unit = Inch
|
||||
if match['unit'] == 'MM':
|
||||
self.graphics_state.unit = self.file_settings.unit = MM
|
||||
else:
|
||||
self.graphics_state.unit = self.file_settings.unit = Inch
|
||||
|
||||
def _parse_allegro_format_spec(self, match):
|
||||
self._parse_format_spec(match)
|
||||
|
|
@ -955,11 +1083,40 @@ class GerberParser:
|
|||
|
||||
else:
|
||||
target = {'TF': self.file_attrs, 'TO': self.graphics_state.object_attrs, 'TA': self.aperture_attrs}[match['type']]
|
||||
target[match['name']] = match['value'].split(',')
|
||||
target[match['name']] = tuple(match['value'].split(',')) if match['value'] else ()
|
||||
|
||||
if 'EAGLE' in self.file_attrs.get('.GenerationSoftware', []) or match['eagle_garbage']:
|
||||
self.generator_hints.append('eagle')
|
||||
|
||||
def _parse_step_repeat(self, match):
|
||||
if match['coords']:
|
||||
if self.step_repeat_coords:
|
||||
raise SyntaxError('SR step-repeat called inside ongoing SR step-repeat')
|
||||
|
||||
x, y = int(match['X']), int(match['Y'])
|
||||
i, j = float(match['I']), float(match['J'])
|
||||
if x < 1 or y < 1:
|
||||
raise SyntaxError('SR step-repeat X and Y values must be at least 1')
|
||||
if x * y > self.MAX_STEP_REPEAT_INSTANCES:
|
||||
raise SyntaxError('SR step-repeat expands to too many instances')
|
||||
|
||||
self.step_repeat_coords = (x, y, i, j)
|
||||
self.step_repeat_objects = []
|
||||
|
||||
else:
|
||||
x, y, i, j = self.step_repeat_coords
|
||||
if len(self.step_repeat_objects) * x * y > self.MAX_STEP_REPEAT_RESULT_OBJECTS:
|
||||
raise SyntaxError('SR step-repeat expands to too many objects')
|
||||
|
||||
for obj in self.step_repeat_objects:
|
||||
for nx in range(x):
|
||||
for ny in range(y):
|
||||
new_obj = copy.copy(obj)
|
||||
new_obj.offset(i * nx, j * ny)
|
||||
self.target.objects.append(new_obj)
|
||||
self.step_repeat_coords = None
|
||||
self.step_repeat_objects = None
|
||||
|
||||
def _parse_eof(self, match):
|
||||
self.eof_found = True
|
||||
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
|
||||
# Copyright 2022 Jan Götte <code@jaseg.de>
|
||||
# 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.
|
||||
|
|
@ -25,9 +25,11 @@ gerber.utils
|
|||
This module provides utility functions for working with Gerber and Excellon files.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import re
|
||||
import textwrap
|
||||
from functools import reduce
|
||||
from enum import Enum
|
||||
import math
|
||||
|
||||
|
|
@ -56,6 +58,7 @@ class RegexMatcher:
|
|||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LengthUnit:
|
||||
""" Convenience length unit class. Used in :py:class:`.GraphicObject` and :py:class:`.Aperture` to store lenght
|
||||
information. Provides a number of useful unit conversion functions.
|
||||
|
|
@ -63,10 +66,9 @@ class LengthUnit:
|
|||
Singleton, use only global instances ``utils.MM`` and ``utils.Inch``.
|
||||
"""
|
||||
|
||||
def __init__(self, name, shorthand, this_in_mm):
|
||||
self.name = name
|
||||
self.shorthand = shorthand
|
||||
self.factor = this_in_mm
|
||||
name: str
|
||||
shorthand: str
|
||||
this_in_mm: float
|
||||
|
||||
def convert_from(self, unit, value):
|
||||
""" Convert ``value`` from ``unit`` into this unit.
|
||||
|
|
@ -82,7 +84,7 @@ class LengthUnit:
|
|||
if unit == self or unit is None or value is None:
|
||||
return value
|
||||
|
||||
return value * unit.factor / self.factor
|
||||
return value * unit.this_in_mm / self.this_in_mm
|
||||
|
||||
def convert_to(self, unit, value):
|
||||
""" :py:meth:`.LengthUnit.convert_from` but in reverse. """
|
||||
|
|
@ -95,6 +97,32 @@ class LengthUnit:
|
|||
|
||||
return unit.convert_from(self, value)
|
||||
|
||||
def convert_bounds_from(self, unit, value):
|
||||
""" :py:meth:`.LengthUnit.convert_from` but for ((min_x, min_y), (max_x, max_y)) bounding box tuples. """
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
(min_x, min_y), (max_x, max_y) = value
|
||||
min_x = self.convert_from(unit, min_x)
|
||||
min_y = self.convert_from(unit, min_y)
|
||||
max_x = self.convert_from(unit, max_x)
|
||||
max_y = self.convert_from(unit, max_y)
|
||||
return (min_x, min_y), (max_x, max_y)
|
||||
|
||||
def convert_bounds_to(self, unit, value):
|
||||
""" :py:meth:`.LengthUnit.convert_to` but for ((min_x, min_y), (max_x, max_y)) bounding box tuples. """
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
(min_x, min_y), (max_x, max_y) = value
|
||||
min_x = self.convert_to(unit, min_x)
|
||||
min_y = self.convert_to(unit, min_y)
|
||||
max_x = self.convert_to(unit, max_x)
|
||||
max_y = self.convert_to(unit, max_y)
|
||||
return (min_x, min_y), (max_x, max_y)
|
||||
|
||||
def format(self, value):
|
||||
""" Return a human-readdable string representing value in this unit.
|
||||
|
||||
|
|
@ -216,6 +244,59 @@ def rotate_point(x, y, angle, cx=0, cy=0):
|
|||
cy + (x - cx) * math.sin(-angle) + (y - cy) * math.cos(-angle))
|
||||
|
||||
|
||||
def sweep_angle(cx, cy, x1, y1, x2, y2, clockwise):
|
||||
""" Calculate absolute sweep angle of arc. This is always a positive number.
|
||||
|
||||
:returns: Angle in clockwise radian between ``0`` and ``2*math.pi``
|
||||
:rtype: float
|
||||
"""
|
||||
x1, y1 = x1-cx, y1-cy
|
||||
x2, y2 = x2-cx, y2-cy
|
||||
|
||||
a1, a2 = math.atan2(y1, x1), math.atan2(y2, x2)
|
||||
f = abs(a2 - a1)
|
||||
if not 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)
|
||||
|
||||
|
||||
def approximate_arc(cx, cy, x1, y1, x2, y2, clockwise, max_error=1e-2, clip_max_error=True):
|
||||
# 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.dist((x1, y1), (cx, cy))
|
||||
|
||||
if clip_max_error:
|
||||
# 1 - math.sqrt(1 - 0.5*math.sqrt(2))
|
||||
max_error = min(max_error, r*0.4588038998538031)
|
||||
|
||||
elif max_error >= r:
|
||||
yield (x1, y1)
|
||||
yield (x2, y2)
|
||||
return
|
||||
|
||||
# see https://www.mathopenref.com/sagitta.html
|
||||
l = math.sqrt(r**2 - (r - max_error)**2)
|
||||
|
||||
angle_max = math.asin(l/r)
|
||||
alpha = sweep_angle(cx, cy, x1, y1, x2, y2, clockwise)
|
||||
num_segments = math.ceil(alpha / angle_max)
|
||||
angle = alpha / num_segments
|
||||
|
||||
if not clockwise:
|
||||
angle = -angle
|
||||
|
||||
for i in range(num_segments + 1):
|
||||
yield rotate_point(x1, y1, i*angle, cx, cy)
|
||||
|
||||
|
||||
def min_none(a, b):
|
||||
""" Like the ``min(..)`` builtin, but if either value is ``None``, returns the other. """
|
||||
if a is None:
|
||||
|
|
@ -235,7 +316,7 @@ def max_none(a, b):
|
|||
|
||||
|
||||
def add_bounds(b1, b2):
|
||||
""" Add/union two bounding boxes.
|
||||
""" Add/union multiple bounding boxes.
|
||||
|
||||
:param tuple b1: ``((min_x, min_y), (max_x, max_y))``
|
||||
:param tuple b2: ``((min_x, min_y), (max_x, max_y))``
|
||||
|
|
@ -244,10 +325,34 @@ def add_bounds(b1, b2):
|
|||
:rtype: tuple
|
||||
"""
|
||||
|
||||
(min_x_1, min_y_1), (max_x_1, max_y_1) = b1
|
||||
(min_x_2, min_y_2), (max_x_2, max_y_2) = b2
|
||||
min_x, min_y = min_none(min_x_1, min_x_2), min_none(min_y_1, min_y_2)
|
||||
max_x, max_y = max_none(max_x_1, max_x_2), max_none(max_y_1, max_y_2)
|
||||
return sum_bounds((b1, b2))
|
||||
|
||||
|
||||
def offset_bounds(bounds, dx=0, dy=0):
|
||||
(min_x, min_y), (max_x, max_y) = bounds
|
||||
return (min_x+dx, min_y+dy), (max_x+dx, max_y+dy)
|
||||
|
||||
|
||||
def sum_bounds(bounds, *, default=None):
|
||||
""" Add/union multiple bounding boxes.
|
||||
|
||||
:param bounds: each arg is one bounding box in ``((min_x, min_y), (max_x, max_y))`` format
|
||||
|
||||
:returns: ``((min_x, min_y), (max_x, max_y))``
|
||||
:rtype: tuple
|
||||
"""
|
||||
|
||||
bounds = iter([ b for b in bounds if b is not None ])
|
||||
|
||||
for (min_x, min_y), (max_x, max_y) in bounds:
|
||||
break
|
||||
else:
|
||||
return default
|
||||
|
||||
for (min_x_2, min_y_2), (max_x_2, max_y_2) in bounds:
|
||||
min_x, min_y = min_none(min_x, min_x_2), min_none(min_y, min_y_2)
|
||||
max_x, max_y = max_none(max_x, max_x_2), max_none(max_y, max_y_2)
|
||||
|
||||
return ((min_x, min_y), (max_x, max_y))
|
||||
|
||||
|
||||
|
|
@ -256,6 +361,10 @@ class Tag:
|
|||
own implementation by passing a ``tag`` parameter. """
|
||||
|
||||
def __init__(self, name, children=None, root=False, **attrs):
|
||||
if (fill := attrs.get('fill')) and isinstance(fill, tuple):
|
||||
attrs['fill'], attrs['fill-opacity'] = fill
|
||||
if (stroke := attrs.get('stroke')) and isinstance(stroke, tuple):
|
||||
attrs['stroke'], attrs['stroke-opacity'] = stroke
|
||||
self.name, self.attrs = name, attrs
|
||||
self.children = children or []
|
||||
self.root = root
|
||||
|
|
@ -284,11 +393,9 @@ def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
|
|||
# This solution manages to handle circular arcs given in gerber format (with explicit center and endpoints, plus
|
||||
# sweep direction instead of a format with e.g. angles and radius) without any trigonometric functions (e.g. atan2).
|
||||
#
|
||||
# cx, cy are relative to p1.
|
||||
# cx, cy are in absolute coordinates.
|
||||
|
||||
# Center arc on cx, cy
|
||||
cx += x1
|
||||
cy += y1
|
||||
x1 -= cx
|
||||
x2 -= cx
|
||||
y1 -= cy
|
||||
|
|
@ -298,6 +405,10 @@ def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
|
|||
# Calculate radius
|
||||
r = math.sqrt(x1**2 + y1**2)
|
||||
|
||||
# Special case: Gerber defines an arc with p1 == p2 as a full circle.
|
||||
if math.isclose(x1, x2) and math.isclose(y1, y2):
|
||||
return (cx-r, cy-r), (cx+r, cy+r)
|
||||
|
||||
# Calculate in which half-planes (north/south, west/east) P1 and P2 lie.
|
||||
# Note that we assume the y axis points upwards, as in Gerber and maths.
|
||||
# SVG has its y axis pointing downwards.
|
||||
|
|
@ -342,7 +453,7 @@ def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
|
|||
# Since both points are on the arc (at same radius) in one halfplane, we can use the y coord as a proxy for
|
||||
# angle comparisons.
|
||||
small_arc_is_north_to_south = y1 > y2
|
||||
small_arc_is_clockwise = small_arc_is_north_to_south == p1_west
|
||||
small_arc_is_clockwise = small_arc_is_north_to_south != p1_west
|
||||
if small_arc_is_clockwise != clockwise:
|
||||
min_y, max_y = -r, r # intersect aabb with both north and south
|
||||
|
||||
|
|
@ -361,6 +472,33 @@ def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
|
|||
return (min_x+cx, min_y+cy), (max_x+cx, max_y+cy)
|
||||
|
||||
|
||||
def convex_hull(points):
|
||||
'''
|
||||
Returns points on convex hull in CCW order according to Graham's scan algorithm.
|
||||
By Tom Switzer <thomas.switzer@gmail.com>.
|
||||
'''
|
||||
# https://gist.github.com/arthur-e/5cf52962341310f438e96c1f3c3398b8
|
||||
TURN_LEFT, TURN_RIGHT, TURN_NONE = (1, -1, 0)
|
||||
|
||||
def cmp(a, b):
|
||||
return (a > b) - (a < b)
|
||||
|
||||
def turn(p, q, r):
|
||||
return cmp((q[0] - p[0])*(r[1] - p[1]) - (r[0] - p[0])*(q[1] - p[1]), 0)
|
||||
|
||||
def keep_left(hull, r):
|
||||
while len(hull) > 1 and turn(hull[-2], hull[-1], r) != TURN_LEFT:
|
||||
hull.pop()
|
||||
if not len(hull) or hull[-1] != r:
|
||||
hull.append(r)
|
||||
return hull
|
||||
|
||||
points = sorted(points)
|
||||
l = reduce(keep_left, points, [])
|
||||
u = reduce(keep_left, reversed(points), [])
|
||||
return l.extend(u[i] for i in range(1, len(u) - 1)) or l
|
||||
|
||||
|
||||
def point_line_distance(l1, l2, p):
|
||||
""" Calculate distance between infinite line through l1 and l2, and point p. """
|
||||
# https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
|
||||
|
|
@ -374,29 +512,133 @@ def point_line_distance(l1, l2, p):
|
|||
|
||||
|
||||
def svg_arc(old, new, center, clockwise):
|
||||
""" Format an SVG circular arc "A" path data entry given an arc in Gerber notation (i.e. with center relative to
|
||||
first point).
|
||||
""" Format an SVG circular arc "A" path data entry given an arc in Gerber notation (but with center in absolute
|
||||
coordinates).
|
||||
|
||||
:rtype: str
|
||||
"""
|
||||
r = math.hypot(*center)
|
||||
r = float(math.dist(old, center))
|
||||
# invert sweep flag since the svg y axis is mirrored
|
||||
sweep_flag = int(not clockwise)
|
||||
# In the degenerate case where old == new, we always take the long way around. To represent this "full-circle arc"
|
||||
# in SVG, we have to split it into two.
|
||||
if math.isclose(math.dist(old, new), 0):
|
||||
intermediate = old[0] + 2*center[0], old[1] + 2*center[1]
|
||||
intermediate = old[0] + 2*(center[0]-old[0]), old[1] + 2*(center[1]-old[1])
|
||||
# Note that we have to preserve the sweep flag to avoid causing self-intersections by flipping the direction of
|
||||
# a circular cutin
|
||||
return f'A {r:.6} {r:.6} 0 1 {sweep_flag} {intermediate[0]:.6} {intermediate[1]:.6} ' +\
|
||||
f'A {r:.6} {r:.6} 0 1 {sweep_flag} {new[0]:.6} {new[1]:.6}'
|
||||
return f'A {r:.6} {r:.6} 0 1 {sweep_flag} {float(intermediate[0]):.6} {float(intermediate[1]):.6} ' +\
|
||||
f'A {r:.6} {r:.6} 0 1 {sweep_flag} {float(new[0]):.6} {float(new[1]):.6}'
|
||||
|
||||
else: # normal case
|
||||
d = point_line_distance(old, new, (old[0]+center[0], old[1]+center[1]))
|
||||
d = point_line_distance(old, new, (center[0], center[1]))
|
||||
large_arc = int((d < 0) == clockwise)
|
||||
return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {new[0]:.6} {new[1]:.6}'
|
||||
return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {float(new[0]):.6} {float(new[1]):.6}'
|
||||
|
||||
|
||||
def svg_rotation(angle_rad, cx=0, cy=0):
|
||||
return f'rotate({float(math.degrees(angle_rad)):.4} {float(cx):.6} {float(cy):.6})'
|
||||
if math.isclose(angle_rad, 0.0, abs_tol=1e-3):
|
||||
return {}
|
||||
else:
|
||||
return {'transform': f'rotate({float(math.degrees(angle_rad)):.4} {float(cx):.6} {float(cy):.6})'}
|
||||
|
||||
def setup_svg(tags, bounds, margin=0, arg_unit=MM, svg_unit=MM, pagecolor='white', tag=Tag, inkscape=False):
|
||||
(min_x, min_y), (max_x, max_y) = bounds
|
||||
|
||||
if margin:
|
||||
margin = svg_unit(margin, arg_unit)
|
||||
min_x -= margin
|
||||
min_y -= margin
|
||||
max_x += margin
|
||||
max_y += margin
|
||||
|
||||
w, h = max_x - min_x, max_y - min_y
|
||||
w = 1.0 if math.isclose(w, 0.0) else w
|
||||
h = 1.0 if math.isclose(h, 0.0) else h
|
||||
|
||||
if inkscape:
|
||||
tags.insert(0, tag('sodipodi:namedview', [], id='namedview1', pagecolor=pagecolor,
|
||||
inkscape__document_units=svg_unit.shorthand))
|
||||
namespaces = dict(
|
||||
xmlns="http://www.w3.org/2000/svg",
|
||||
xmlns__xlink="http://www.w3.org/1999/xlink",
|
||||
xmlns__sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
|
||||
xmlns__inkscape='http://www.inkscape.org/namespaces/inkscape')
|
||||
|
||||
else:
|
||||
namespaces = dict(
|
||||
xmlns="http://www.w3.org/2000/svg",
|
||||
xmlns__xlink="http://www.w3.org/1999/xlink")
|
||||
|
||||
svg_unit = 'in' if svg_unit == 'inch' else 'mm'
|
||||
# TODO export apertures as <uses> where reasonable.
|
||||
return tag('svg', tags,
|
||||
width=f'{w}{svg_unit}', height=f'{h}{svg_unit}',
|
||||
viewBox=f'{min_x} {min_y} {w} {h}',
|
||||
style=f'background-color:{pagecolor}',
|
||||
**namespaces,
|
||||
root=True)
|
||||
|
||||
|
||||
def point_in_polygon(point, poly):
|
||||
# https://stackoverflow.com/questions/217578/how-can-i-determine-whether-a-2d-point-is-within-a-polygon
|
||||
# https://wrfranklin.org/Research/Short_Notes/pnpoly.html
|
||||
|
||||
if not poly:
|
||||
return False
|
||||
|
||||
res = False
|
||||
tx, ty = point
|
||||
xp, yp = poly[-1]
|
||||
for x, y in poly:
|
||||
if yp == ty == y and ((x > tx) != (xp > tx)): # test point on horizontal segment
|
||||
return True
|
||||
if xp == tx == x and ((y > ty) != (yp > ty)): # test point on vertical segment
|
||||
return True
|
||||
if ((y > ty) != (yp > ty)):
|
||||
tmp = ((xp-x) * (ty-y) / (yp-y) + x)
|
||||
if tx == tmp: # test point on diagonal segment
|
||||
return True
|
||||
elif tx < tmp:
|
||||
res = not res
|
||||
xp, yp = x, y
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def polygon_area(poly):
|
||||
# https://en.wikipedia.org/wiki/Shoelace_formula
|
||||
|
||||
if not poly or len(poly) < 3:
|
||||
return 0
|
||||
|
||||
acc = 0
|
||||
for (x1, y1), (x2, y2) in zip(poly, poly[-1:] + poly):
|
||||
acc += (y1 + y2) * (x1 - x2)
|
||||
return acc/2
|
||||
|
||||
|
||||
def bbox_intersect(a, b):
|
||||
if a is None or b is None:
|
||||
return False
|
||||
|
||||
(xa_min, ya_min), (xa_max, ya_max) = a
|
||||
(xb_min, yb_min), (xb_max, yb_max) = b
|
||||
|
||||
x_overlap = not (xa_max < xb_min or xb_max < xa_min)
|
||||
y_overlap = not (ya_max < yb_min or yb_max < ya_min)
|
||||
|
||||
return x_overlap and y_overlap
|
||||
|
||||
|
||||
def bbox_contains(outer, inner):
|
||||
if outer is None or inner is None:
|
||||
return False
|
||||
|
||||
(xa_min, ya_min), (xa_max, ya_max) = outer
|
||||
(xb_min, yb_min), (xb_max, yb_max) = inner
|
||||
|
||||
contained_x = xa_min < xb_min and xb_max < xa_max
|
||||
contained_y = ya_min < yb_min and yb_max < ya_max
|
||||
|
||||
return contained_x and contained_y
|
||||
|
||||
53
test2.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from gerbonara import *
|
||||
from shapely import *
|
||||
|
||||
stack = layers.LayerStack.open('/home/jaseg/proj/ihsm-strain-gage-controller-hw/pcb/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 not obj.polarity_dark:
|
||||
continue
|
||||
|
||||
if isinstance(obj, graphic_objects.Region):
|
||||
regions = []
|
||||
else:
|
||||
regions = [graphic_objects.Region.from_arc_poly(prim.to_arc_poly())
|
||||
for prim in obj.to_primitives(unit=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('/tmp/out')
|
||||
1
tests/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
image_cache
|
||||
0
tests/__init__.py
Normal file
172
tests/conftest.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tqdm
|
||||
import multiprocessing.pool
|
||||
import subprocess
|
||||
from itertools import chain
|
||||
|
||||
import pytest
|
||||
|
||||
from .image_support import ImageDifference, run_cargo_cmd, ImageSupport
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kicad_container(request):
|
||||
return request.config.kicad_container
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def kicad_footprints_libdir(request):
|
||||
return request.config.kicad_footprints_libdir
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def kicad_symbols_libdir(request):
|
||||
return request.config.kicad_symbols_libdir
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def img_support(request):
|
||||
return request.config.image_support
|
||||
|
||||
|
||||
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 'PYTEST_XDIST_WORKER' in os.environ: # only run this on the controller
|
||||
return
|
||||
|
||||
for f in chain(fail_dir.glob('*.gbr'), fail_dir.glob('*.png')):
|
||||
f.unlink()
|
||||
|
||||
try:
|
||||
run_cargo_cmd('resvg', '--help', stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except FileNotFoundError:
|
||||
pytest.exit('resvg binary not found, aborting test.', 2)
|
||||
|
||||
|
||||
def _update_repo_cache(lib_dir, repo_url, tag):
|
||||
if not lib_dir.is_dir():
|
||||
print(f'Checking out KiCad footprint repo tag {tag}')
|
||||
subprocess.run(['git', '-c', 'advice.detachedHead=false', 'clone', '--branch', tag, '--depth', '1', repo_url, str(lib_dir)], check=True)
|
||||
return True
|
||||
|
||||
else:
|
||||
print(f'Found cached KiCad footprint checkout, updating to {tag}')
|
||||
res = subprocess.run(['git', '-C', str(lib_dir), 'rev-parse', 'HEAD', f'{tag}^{{commit}}'], check=True, capture_output=True, text=True)
|
||||
head_commit, tag_commit = res.stdout.strip().splitlines()
|
||||
print('got commits', head_commit, tag_commit)
|
||||
if head_commit != tag_commit:
|
||||
subprocess.run(['git', '-C', str(lib_dir), 'fetch', '--depth', '1', 'origin', tag], check=True)
|
||||
subprocess.run(['git', '-c', 'advice.detachedHead=false', '-C', str(lib_dir), 'reset', '--hard', tag], check=True)
|
||||
subprocess.run(['git', '-C', str(lib_dir), 'clean', '--force', '-d', '-x'], check=True)
|
||||
return True
|
||||
else:
|
||||
print('Up to date, only cleaning.')
|
||||
subprocess.run(['git', '-C', str(lib_dir), 'clean', '--force', '-d', '-x'], check=True)
|
||||
return False
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addini('kicad_footprints_tag', 'git tag or branch for KiCad footprint library repo used as testdata', default='main')
|
||||
parser.addini('kicad_symbols_tag', 'git tag or branch for KiCad symbol library repo used as testdata', default='main')
|
||||
parser.addini('kicad_container_tag', 'docker hub tag for the KiCad container to use for exporting footprint images', default='main')
|
||||
parser.addini('kicad_source_tag', 'git tag for the KiCad source repo whose demos directory is used as testdata', default='main')
|
||||
parser.addoption("--use-cached-data", action="store_true", help="Do not re-check git repo caches and podman image")
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
os.nice(20)
|
||||
# Resvg can sometimes consume a lot of memory. Make sure we don't kill the user's session.
|
||||
if (oom_adj := Path('/proc/self/oom_adj')).is_file():
|
||||
oom_adj.write_text('15\n')
|
||||
|
||||
if (lib_dir := os.environ.get('KICAD_FOOTPRINTS')):
|
||||
config.kicad_footprints_libdir = Path(lib_dir).expanduser()
|
||||
else:
|
||||
config.kicad_footprints_libdir = config.cache.mkdir('kicad-footprints') / 'repo'
|
||||
|
||||
if (lib_dir := os.environ.get('KICAD_SYMBOLS')):
|
||||
config.kicad_symbols_libdir = Path(lib_dir).expanduser()
|
||||
else:
|
||||
config.kicad_symbols_libdir = config.cache.mkdir('kicad-symbols') / 'repo'
|
||||
|
||||
if (lib_dir := os.environ.get('KICAD_SOURCE')):
|
||||
config.kicad_source_dir = Path(lib_dir).expanduser()
|
||||
else:
|
||||
config.kicad_source_dir = config.cache.mkdir('kicad-source') / 'repo'
|
||||
|
||||
did_updates = False
|
||||
is_pytest_controller = 'PYTEST_XDIST_WORKER' not in os.environ
|
||||
if is_pytest_controller and not config.getoption("--use-cached-data"):
|
||||
# Update cached library repos unless they are overridden from outside.
|
||||
if not os.environ.get('KICAD_FOOTPRINTS'):
|
||||
tag = config.getini('kicad_footprints_tag')
|
||||
did_updates |= _update_repo_cache(config.kicad_footprints_libdir, 'https://gitlab.com/kicad/libraries/kicad-footprints', tag)
|
||||
|
||||
if not os.environ.get('KICAD_SYMBOLS'):
|
||||
tag = config.getini('kicad_symbols_tag')
|
||||
did_updates |= _update_repo_cache(config.kicad_symbols_libdir, 'https://gitlab.com/kicad/libraries/kicad-symbols', tag)
|
||||
|
||||
if not os.environ.get('KICAD_SOURCE'):
|
||||
tag = config.getini('kicad_source_tag')
|
||||
did_updates |= _update_repo_cache(config.kicad_source_dir, 'https://gitlab.com/kicad/code/kicad', tag)
|
||||
|
||||
tag = config.getini("kicad_container_tag")
|
||||
config.kicad_container = os.environ.get('KICAD_CONTAINER', f'registry.hub.docker.com/kicad/kicad:{tag}')
|
||||
|
||||
if is_pytest_controller and not config.getoption("--use-cached-data"):
|
||||
print('Checking podman image')
|
||||
res = subprocess.run(['podman', 'image', 'exists', config.kicad_container])
|
||||
if res.returncode:
|
||||
print('Updating podman image')
|
||||
subprocess.run(['podman', 'pull', config.kicad_container], check=True)
|
||||
did_updates = True
|
||||
else:
|
||||
print('Up to date.')
|
||||
|
||||
config.image_support = ImageSupport(config.cache.mkdir('image_cache'), config.kicad_container)
|
||||
|
||||
if is_pytest_controller and did_updates and not config.getoption("--use-cached-data"):
|
||||
print('Checking KiCad footprint library render cache')
|
||||
with multiprocessing.pool.ThreadPool() as pool: # use thread pool here since we're only monitoring podman processes
|
||||
lib_dirs = list(config.kicad_footprints_libdir.glob('*.pretty'))
|
||||
res = list(tqdm.tqdm(pool.imap(lambda path: config.image_support.bulk_populate_kicad_fp_export_cache(path), lib_dirs), total=len(lib_dirs)))
|
||||
|
||||
|
||||
def pytest_generate_tests(metafunc):
|
||||
if 'kicad_library_file' in metafunc.fixturenames:
|
||||
library_files = list(metafunc.config.kicad_symbols_libdir.glob('*.kicad_sym'))
|
||||
metafunc.parametrize('kicad_library_file', library_files, ids=list(map(str, library_files)))
|
||||
|
||||
if 'kicad_mod_file' in metafunc.fixturenames:
|
||||
mod_files = list(metafunc.config.kicad_footprints_libdir.glob('*.pretty/*.kicad_mod'))
|
||||
metafunc.parametrize('kicad_mod_file', mod_files, ids=list(map(str, mod_files)))
|
||||
|
||||
if 'kicad_sch_file' in metafunc.fixturenames:
|
||||
files = list(metafunc.config.kicad_source_dir.glob('demos/*.kicad_sch'))
|
||||
files += list(metafunc.config.kicad_source_dir.glob('qa/data/**/*.kicad_sch'))
|
||||
metafunc.parametrize('kicad_sch_file', files, ids=list(map(str, files)))
|
||||
|
||||
if 'kicad_pcb_file' in metafunc.fixturenames:
|
||||
files = list(metafunc.config.kicad_source_dir.glob('demos/*.kicad_pcb'))
|
||||
files += list(metafunc.config.kicad_source_dir.glob('qa/data/**/*.kicad_pcb'))
|
||||
metafunc.parametrize('kicad_pcb_file', files, ids=list(map(str, files)))
|
||||
|
||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |