Compare commits

...

371 commits
v0.9.2 ... main

Author SHA1 Message Date
Flavien Solt
736107f7a4 Reject oversized step-repeat expansions 2026-04-25 12:04:51 +02:00
Flavien Solt
e3674de08d Accept valueless Gerber attributes 2026-04-25 12:04:44 +02:00
Flavien Solt
516a9d337f Improve Excellon compatibility parsing 2026-04-21 11:13:15 +08:00
jaseg
2451b517e8 Add aperture macro expression tests 2026-03-22 13:22:11 +01:00
jaseg
bdd4008ab9 Fix macro polygon offset handling and add regression test
Applies github PR #1. Thanks to @SaumyaShah08 on github!
2026-03-22 11:46:47 +01:00
jaseg
6f006e2782 Start work on proper aperture macro tests 2026-03-21 13:24:50 +01:00
jaseg
8df709f55f Bump version to v1.6.2 2026-03-09 17:22:02 +01:00
jaseg
985e263cfe Add missing test data 2026-03-09 17:11:34 +01:00
jaseg
5ccfd7a259 Fix handling of zero-length line segments during pretty SVG export
Fixess gitlab issue #8
2026-03-09 17:10:15 +01:00
jaseg
bcc4aeefa7 doc: State more explicitly the nature of gerbonara's file name matching rules 2026-03-09 16:34:09 +01:00
jaseg
0c15111463 Add pretty_svg test, fix some bugs. 2026-03-09 16:31:08 +01:00
jaseg
1a0f519720 Add invert polarity test
Fixes gitlab issue #6
2026-03-09 16:07:37 +01:00
jaseg
3d0ddc3dc8 Update layer naming to track modern kicad 2026-03-08 16:49:26 +01:00
jaseg
575046a60c Fix ExcellonFile.to_gerber and add a unit test 2026-03-08 15:12:58 +01:00
jaseg
8de776616c Fix aperture macro thermal primitive
Fixes gitlab issues #10 and #11
2026-03-08 15:03:11 +01:00
jaseg
e19dec20b6 Fix ArcPoly primitive approximate_arcs 2026-03-08 14:38:13 +01:00
jaseg
97513df5d9 Improve regression test for gitlab issue #17
Fixes gitlab issue #17
2026-03-08 14:29:56 +01:00
jaseg
2ce55ebdca Fix arcpoly conversion bugs 2026-03-08 14:19:41 +01:00
jaseg
f3c95a42d4 Fix bug in rs274x Region close
Fixes gitlab issue #18
2026-03-08 13:23:29 +01:00
jaseg
8a2599f5f4 Add missing degree/radian conversion for polygon aperture rotation 2026-03-08 13:17:16 +01:00
jaseg
2647709215 Fix arc gerber export bug 2026-03-08 11:24:46 +01:00
jaseg
87413855bf tests WIP 2026-02-22 14:50:03 +01:00
Nitsan-Suron
af4fb2668f fixed invert_polarity 2026-02-22 12:42:22 +01:00
Matthew Mets
a47a694ba0 Convert polygonal aperture rotations to radians
Polygonal aperture rotations are specified in degrees, however
gerbonara wants radians. This is a fix for issue #12 :

https://gitlab.com/gerbolyze/gerbonara/-/issues/12
2025-12-10 13:32:10 +00:00
jaseg
dd8ad98f13 Bump version to v1.6.1 2025-12-10 14:23:14 +01:00
jaseg
a1ea416269 Fix uv build for newer container versions 2025-12-10 14:23:14 +01:00
jaseg
a7f0324506 Add protoserve entrypoint 2025-12-10 14:13:00 +01:00
jaseg
6ab4640099 Bump version to v1.6.0 2025-12-08 12:13:14 +01:00
jaseg
04a088c3e8 Update gitlab CI for uv build 2025-12-08 12:13:11 +01:00
jaseg
df4895abfa kicad: make footprint render layer_map argument optional 2025-12-08 11:52:42 +01:00
jaseg
42dfd1be7f Bring kicad PCB file format up to 9.0.5 2025-11-19 12:26:03 +01:00
jaseg
fd6880640d Add missing test file 2025-11-19 11:00:38 +01:00
jaseg
6a8bd8dc3f Kicad 9.0.5 PCB compatibility WIP 2025-11-18 23:43:29 +01:00
jaseg
46df184358 Fix remaining failing tests
This brings schematic i/o compatiblity up to 9.0.5.
2025-11-18 23:11:22 +01:00
jaseg
c2679260a0 Make test logic resource updating smarter
Only update repos/podman images if they're not as expected. Also only
run footprint export on changes.
2025-11-18 22:13:45 +01:00
jaseg
9d2d635eee kiacd 9.0 compatibility WIP 2025-11-17 17:38:59 +01:00
jaseg
75905f7d0c Fix failing tests
This adds basic support for kicad 9.0 library i/o
2025-11-17 12:07:15 +01:00
jaseg
7073b6e33f Finish transition to uv 2025-11-16 21:19:13 +01:00
jaseg
718de2b338 WIP move project to uv 2025-11-16 19:37:48 +01:00
jaseg
379cf273cb Fix out-of-date documentation 2025-08-22 13:24:34 +02:00
jaseg
da6d8349fa Bump version to v1.5.0 2025-07-09 18:46:10 +02:00
jaseg
1f5a9261e1 cli: Fix transform cli 2025-07-09 18:45:16 +02:00
jaseg
3ae6b9be61 kicad: fix for newer format versions 2025-04-02 17:49:13 +02:00
jaseg
a6434f16cd Fix clobbered object attributes 2025-04-01 15:52:53 +02:00
jaseg
63faf4280d Fix failing tests 2024-12-21 18:54:00 +01:00
jaseg
7fb8215a1e Add recent fusion-360 files as test resource 2024-12-21 17:32:16 +01:00
jaseg
81ae51d4be Improve allegro inner layer matching 2024-11-08 12:26:02 +01:00
jaseg
8ffa7c1b76 Fix svg orientation and improve OrCAD rendering 2024-11-08 11:41:11 +01:00
jaseg
be8371c7bc Improve allegro/orcad import 2024-11-06 14:49:50 +01:00
jaseg
1a854b1812 Tests: Make kicad docker image configurable 2024-11-06 14:49:36 +01:00
jaseg
091ee84910 kicad: add rotation method to circles and polygons 2024-07-19 19:28:15 +02:00
jaseg
fd63c44314 Kicad: add missing zone connection Atom 2024-07-19 19:23:20 +02:00
jaseg
cbe8dfa252 kicad: small syntax fix 2024-07-19 19:23:11 +02:00
jaseg
67ce4af957 kicad: Fix bug in footprint search 2024-07-19 19:22:56 +02:00
jaseg
eadd250ee3 kicad: Fix footprint rotation
Previously, footprint rotation was mirrored compared with everything
else in the kicad API
2024-07-19 19:22:26 +02:00
jaseg
5f5bbccd05 kicad: Add copy_placement method to footprints
Moving footprints around is ugly because of kicad's really weird way of
specifying sub-object coordinates and rotations. This commit adds a
helper method to deal with that.
2024-07-19 19:21:24 +02:00
jaseg
ea2664219d kicad: Make reprs more reasonable
This excludes parent back-links from reprs, which would previously blow
up the repr of many objects.
2024-07-19 19:20:51 +02:00
jaseg
be25b860a9 kicad: Improve net access
This adds net_name and net_index properties to a bunch of objects that
automatically look into the (net s-expr of the object
2024-07-19 19:20:09 +02:00
jaseg
e42b7462c9 kicad: Add nicer width access
This commit straightens up the mess a bit with kicad's two conflicting
"width" fields in different file format versions. The new variant is
preferred when saving, but the old variant is accepted as API
2024-07-19 19:18:55 +02:00
jaseg
754c9557e5 kicad: Make point lists more ergonomic
This commit removes the unnecessary "PointList" wrapper class, and just
puts regular python lists in its place.
2024-07-19 19:17:46 +02:00
jaseg
d7efa57732 kicad: Add bounding box support to lots of s-expr objects 2024-07-19 19:15:52 +02:00
jaseg
689ce748db kicad: Update empty PCB template for 8.99 2024-07-19 14:09:40 +02:00
jaseg
8e337c0506 kicad: Small fixes to 8.99 compatibility 2024-07-19 14:07:29 +02:00
jaseg
b23cafbfd4 tests: add status messages 2024-07-19 14:04:24 +02:00
jaseg
78ffb61aee kicad: Fixes for latest git version 2024-07-19 12:25:13 +02:00
jaseg
58d5784903 rs274x: Add support for SR step-repeat command 2024-07-18 16:39:58 +02:00
jaseg
1ecb7be6f9 Improve aperture macros doc 2024-07-18 16:39:38 +02:00
jaseg
ef9d61ffd5 Fix single contour example syntax errors 2024-07-18 14:19:31 +02:00
jaseg
79f555a465 macro parser: improve parameter number warning message 2024-07-18 14:19:17 +02:00
jaseg
501882ea3d Fix ast deprecation warnings 2024-07-18 14:19:04 +02:00
jaseg
e3a6716187 Fix symbol round trip tests 2024-07-18 13:50:01 +02:00
jaseg
8950a593f8 Fix bug in SVG bounding box calculation 2024-07-18 13:49:38 +02:00
jaseg
344825c5da kicad: Fix bug when searching pads with empty nets 2024-07-18 11:36:16 +02:00
jaseg
f1b3ab5e72 kicad: Improve compatibility with old symbol files 2024-07-18 11:36:13 +02:00
jaseg
2092b86431 tests: Always update kicad nightly container before run 2024-07-18 11:35:03 +02:00
jaseg
885ce36fd3 tests: Adjust nice and oom_adj values on linux
The testsuite calls resvg, and that sometimes can use up *a lot* of
memory. We adjust the test process tree's nice values and oom_adj values
to make sure the test processes get killed during an OOM condition
before anything else on the system.
2024-07-18 11:33:46 +02:00
jaseg
edaf246b9d Update KiCad PCB format support to 8.99 2024-07-17 10:20:35 +02:00
jaseg
b1d0260c70 Bump version to v1.4.0 2024-07-08 18:11:18 +02:00
jaseg
a2c6e2d64a Add missing package data 2024-07-08 18:10:57 +02:00
jaseg
1d5f4c8193 protoboard: Fix bug with empty points in breadboard rendering 2024-07-08 18:10:57 +02:00
jaseg
30878adfb1 Bump version to v1.3.0 2024-07-08 17:47:02 +02:00
jaseg
66811af966 kicad: Bring file format up to current 8.99 nightly 2024-07-08 16:29:57 +02:00
jaseg
ec85d6c169 tests: Speed up tests by a lot by bulk-caching kicad footprint renders 2024-07-08 16:29:35 +02:00
jaseg
f447b12571 tests: Fix compatibility with recent kicad nightly containers 2024-07-08 16:29:10 +02:00
jaseg
93fd764482 protoboard: Add CLI 2024-07-07 22:48:19 +02:00
jaseg
26e85279dc protoboard: improve permanent breadboard rail rendering 2024-07-07 21:57:29 +02:00
jaseg
1ed127e3b3 protoboard: improve permanent breadboard rendering 2024-07-07 21:55:17 +02:00
jaseg
1ee6b6587a protoboard: Add permanent breadboard rendering 2024-07-07 21:42:10 +02:00
jaseg
e98f3f3ace Add starburst pattern 2024-07-07 19:07:58 +02:00
jaseg
6f8d4bb999 protoboard: Finish up sides of Alio better 2024-07-07 17:08:06 +02:00
jaseg
4aab344a18 protoboard: add split front/back view in webthing 2024-07-07 16:52:24 +02:00
jaseg
21218239e4 protoboard: Fix alio and two-sided SMD rendering 2024-07-07 16:37:34 +02:00
jaseg
a1d6ebf79f protoboard: Improve layout distribution and index rendering 2024-07-07 16:25:01 +02:00
jaseg
b0274a93c0 protoboard: Improve layout packing 2024-07-07 16:08:32 +02:00
jaseg
0150c318bb protoboard: Improve row/column numbering 2024-07-07 15:48:36 +02:00
jaseg
6de138bf7c protoboard: reduce hole keepout margins 2024-07-07 15:16:01 +02:00
jaseg
224a666219 protoboard: improve border handling 2024-07-07 15:14:29 +02:00
jaseg
cee355ff57 protoboard: fix column label alignment 2024-07-07 14:40:27 +02:00
jaseg
4c3815b25a Fix THT flower proto area 2024-07-07 14:36:41 +02:00
jaseg
ef3b5d5e1c Spiky proto also works now 2024-07-07 00:52:38 +02:00
jaseg
552f30c15d Protoboard: All layouts except for spiky proto work 2024-07-07 00:32:17 +02:00
jaseg
f721692bf3 Protoboard generator WIP 2024-07-06 15:51:08 +02:00
jaseg
04c4b3ff0c kicad_sch render: Fix nightly import and wire rendering 2024-05-28 18:07:09 +02:00
jaseg
227d4ed1cd Bump version to v1.2.0 2023-11-14 21:56:03 +01:00
jaseg
ea4c28e307 Make new test files pass 2023-11-14 21:54:04 +01:00
jaseg
51ef4882a1 Fix failing tests 2023-11-14 21:54:04 +01:00
jaseg
df75a2fddb Small bugfix 2023-11-14 21:52:12 +01:00
jaseg
11325b213b Calculate out all aperture macros by default.
There are just too many severely buggy implementations around. Today I
ran into problems with both gerbv and with whatever JLC uses. You can
still export macros with raw expressions by setting a flag in the export
FileSettings.
2023-11-14 21:52:12 +01:00
jaseg
74fb384c4c aperture macros: work around gerbv/jlc wonkiness 2023-11-14 21:52:12 +01:00
jaseg
9af0713445 Remove debug print 2023-11-14 21:52:12 +01:00
jaseg
09e3731b74 aperture macros: Add expression simplification 2023-11-14 21:52:12 +01:00
jaseg
37b6b8f8d2 Aperture macro expression simplification WIP 2023-11-14 21:52:12 +01:00
jaseg
07362c592f Make sure we asterisk-terminate all G0x commands.
While this is common in the wild, not terminating them violates the
spec. It also breaks JLCPCB pretty badly. It seems their human review
process uses a Gerber viewer that like most can handle this, and won't
notice anything out of the ordinary, but then their photoplotter chokes
on this and literally stops plotting the file, discarding anything that
is after that line. This error is then apparently ignored and the
resulting broken boards shipped to the customer.
2023-11-14 21:52:12 +01:00
jaseg
2f5f7719c6 Split CLI into pretty svg and layer export sub-commands 2023-11-14 21:52:12 +01:00
jaseg
cb1d3eb3fb pretty svg export: Mirror board bottom side 2023-11-14 21:52:12 +01:00
jaseg
4ee5c51f22 Add JLCPCB KiCad Gerber X2/aperture macro test files 2023-11-14 21:52:12 +01:00
jaseg
53788354e8 Add JLCPCB/FAB-3000 example gerbers 2023-11-14 21:52:12 +01:00
jaseg
10962ae2f4 Add P-CAD 2006 example gerbers 2023-11-14 21:52:12 +01:00
jaseg
a19d307a7d Bump version to v1.1.0 2023-10-27 14:47:42 +02:00
jaseg
165e101dda ci: Disable tests for now, since upstream kicad-cli is broken. 2023-10-27 14:40:50 +02:00
jaseg
187c44555c Work around pip now requiring a new random feature switch to work
...for no good reason except to annoy anyone using it in a container.
2023-10-27 12:55:38 +02:00
jaseg
36da1fd68b Fix failing test cases 2023-10-26 23:53:23 +02:00
jaseg
9624e46147 Move coil stuff to separate repo 2023-10-26 00:48:52 +02:00
jaseg
a35125b123 Fix all failing tests that don't involve kicad-cli 2023-10-26 00:36:24 +02:00
jaseg
31af2b260c WIP 2023-10-20 18:24:45 +02:00
jaseg
dd49698df9 Update coil generator for the board house's latest fit 2023-10-20 12:44:48 +02:00
jaseg
ef2b53325c Test board fixes 2023-10-20 11:44:10 +02:00
jaseg
5d3cd4694d Improve mouse bite spacing for JLC 2023-10-18 17:19:05 +02:00
jaseg
3631871a40 Optimize coil model mesh precision 2023-10-18 17:15:20 +02:00
jaseg
b710462419 WIP 2023-10-18 10:59:28 +02:00
jaseg
313aa7dd26 Make coil test board more amenable to our favorite fab 2023-10-16 14:34:06 +02:00
jaseg
1ce02a9d25 Update sims & gen 2023-10-13 18:22:00 +02:00
jaseg
0ae13de322 Fix trace orientation 2023-10-12 20:50:40 +02:00
jaseg
c212663bb2 Update gen script 2023-10-12 20:47:55 +02:00
jaseg
2a9c91b025 Add coil test board gen 2023-10-12 20:44:52 +02:00
jaseg
2d4c40c0f7 Get self capacitance simulation to work 2023-10-11 17:06:42 +02:00
jaseg
a11f144c67 Give notebook a sensible name 2023-10-10 17:52:31 +02:00
jaseg
e78e939a13 Run more sims 2023-10-10 17:52:01 +02:00
jaseg
4ea1b26293 Coupling sims work 2023-10-09 19:56:42 +02:00
jaseg
cc59a6567e magnetic sim agreees with calculations now 2023-10-09 14:46:27 +02:00
jaseg
a2d1429036 Try to fix mesh export 2023-10-06 18:49:08 +02:00
jaseg
f793f12edb Try to fix mesh fragmentation 2023-10-06 16:45:21 +02:00
jaseg
78f5bf965f Mesh WIP 2023-10-06 16:17:25 +02:00
jaseg
84f7e5d25b Add missing simulation yamls 2023-10-06 14:42:35 +02:00
jaseg
ba689e632e EM solver WIP 2023-10-04 16:02:46 +02:00
jaseg
4400dc361e mag mesh gen looks good 2023-09-29 18:30:02 +02:00
jaseg
fdaf3c7e93 Add sim files 2023-09-29 10:37:40 +02:00
jaseg
6e12adb07e mesh gen works 2023-09-27 15:50:40 +02:00
jaseg
cff22b9e08 WIP 2023-09-26 22:42:57 +02:00
jaseg
f711c1d91c cli: Add kicad schematic svg rendering 2023-09-26 16:44:40 +02:00
jaseg
61e591b5b8 WiP 2023-09-22 18:54:11 +02:00
jaseg
95da482033 WIP 2023-09-22 18:54:11 +02:00
jaseg
d2143bdf4d Trace connectivity WIP 2023-09-22 13:30:11 +02:00
jaseg
5f1350d4f4 coil gen: add kicad pcb export 2023-09-20 14:24:15 +02:00
jaseg
5ff40e0ad1 twisted coil gen: add trace length calculation 2023-09-19 19:24:41 +02:00
jaseg
7d21dafd6a Two layer coil gen works with arcs! 2023-09-19 18:58:18 +02:00
jaseg
301601e81d Multilayer coil WIP 2023-09-19 12:44:22 +02:00
jaseg
3e47e7c2da Add line wonkifier 2023-07-22 18:29:04 +02:00
jaseg
ba4cafa3a4 Add tmtheme support 2023-07-22 14:25:18 +02:00
jaseg
2f0a21abf9 Made junctions smaller 2023-07-22 13:20:48 +02:00
jaseg
eb5c01ddd0 Subsheet rendering works too 2023-07-22 13:19:26 +02:00
jaseg
6e7337cca5 Rendering looks pretty good 2023-07-22 12:40:34 +02:00
jaseg
eb20595e00 WIP 2023-07-21 20:55:35 +02:00
jaseg
e4dcbe177f symbol pin rendering works 2023-07-21 20:27:14 +02:00
jaseg
8cb91dabdb WIP 2023-07-21 19:15:21 +02:00
jaseg
91b99a0452 WIP 2023-07-21 17:56:24 +02:00
jaseg
09c9d26728 WIP 2023-07-21 14:46:35 +02:00
jaseg
21ca5f7f5c WIP 2023-07-21 14:38:09 +02:00
jaseg
a39af853c8 Schematics WIP 2023-07-21 13:27:02 +02:00
jaseg
b69e9fded4 Kicad schematic rendering WIP 2023-07-21 01:46:11 +02:00
jaseg
60674ab5b3 Fix line rendering 2023-07-20 16:56:20 +02:00
jaseg
bdbdf7f586 Schematic rendering WIP 2023-07-20 16:42:19 +02:00
jaseg
a1b8cbf861 Make kicad eat schematics written by gerbonara 2023-07-18 21:54:26 +02:00
jaseg
58142cb0c7 kicad: Add schematic file format support 2023-07-18 21:15:08 +02:00
jaseg
08c4091e57 kicad: Improve API and fix kicad-nightly compat 2023-07-17 23:23:19 +02:00
jaseg
860fa4c53b LayerStack: Fix issue SVG rendering lazy-loaded stacks 2023-07-17 23:23:00 +02:00
jaseg
b2729a46ac Improve auto layout API 2023-07-07 20:19:36 +02:00
jaseg
572486aa25 kicad: Fix layers attribute handling and improve rotation API 2023-07-06 22:42:39 +02:00
jaseg
0799cc55ad kicad: Fix Footprint.property_value 2023-07-05 17:33:29 +02:00
jaseg
9f2f1f97f9 Improve coil gen, and fix some kicad s-expr issues 2023-07-05 17:25:28 +02:00
jaseg
cb188ac593 kicad: various pcb re-serialization fixes 2023-07-05 13:42:37 +02:00
jaseg
a5087636ab kicad: Fix additional dimension flags 2023-07-05 13:31:51 +02:00
jaseg
fb1d392831 kicad: Fix dimension.locked attr 2023-07-05 13:30:27 +02:00
jaseg
8f7b2893dc Fix failing symbol tests 2023-07-04 16:20:11 +02:00
jaseg
e696c09eac kicad: Small file format fixes 2023-07-04 12:55:34 +02:00
jaseg
3bc92876b4 Fix arc bounding box calculation for full circles 2023-07-03 23:00:00 +02:00
jaseg
34fae0a7c2 Fix handling of circles on outline layer 2023-07-03 22:59:47 +02:00
jaseg
2eb88e8127 Fix variable expression rendering 2023-07-03 17:50:15 +02:00
jaseg
95728fb33c Fix GerberX2 attribtue handling 2023-07-03 17:49:52 +02:00
jaseg
0920af4149 Fix test when ran with pytest-xdist 2023-07-01 10:36:57 +02:00
jaseg
7a95a0dde7 Remove obsolete workaround for breakage in kicad-cli 2023-06-29 22:55:01 +02:00
jaseg
88642a1803 Fix some failing unit tests 2023-06-29 22:45:47 +02:00
jaseg
56d55fda5d kicad: Extend query API 2023-06-29 19:47:31 +02:00
jaseg
467e482bf4 Fix pcb stackup handling 2023-06-21 13:37:38 +02:00
jaseg
dd8507d202 Polish twisted coil generator 2023-06-20 14:40:55 +02:00
jaseg
d43308c5cc Add twisted coil gen 2023-06-20 14:07:03 +02:00
jaseg
c29802c2b9 Add missing file for initial PCB support 2023-06-14 17:29:43 +02:00
jaseg
a92177904e Coil gen done 2023-06-14 17:29:26 +02:00
jaseg
0148db6249 Beautiful spirals! 2023-06-14 12:05:33 +02:00
jaseg
5178eba26f WIP 2023-06-14 12:01:51 +02:00
jaseg
ddd0641649 basic coils working 2023-06-14 11:39:44 +02:00
jaseg
e349c2c08b WIP 2023-06-14 10:47:49 +02:00
jaseg
96fde32c0b Coils WIP 2023-06-13 19:26:11 +02:00
jaseg
86276490eb coil gen WIP 2023-06-13 18:53:03 +02:00
jaseg
070494a1c3 stroke WIP 2023-06-12 19:43:13 +02:00
jaseg
1d0ba4da70 coil gen WIP 2023-06-12 19:43:00 +02:00
jaseg
3561817903 Add basic KiCad PCB file format support 2023-06-12 18:39:33 +02:00
jaseg
03f2ec0a30 rework WIP 2023-05-08 23:22:55 +02:00
jaseg
732c58f70b Extend breakout generator 2023-05-07 12:59:10 +02:00
jaseg
4bac77d0b4 Add pad ring generator 2023-05-07 12:59:10 +02:00
jaseg
b1e189eed2 protoserve: Add some input validation 2023-05-05 23:10:57 +02:00
jaseg
af7b04f510 Remove broken aperture macro optimization 2023-04-30 11:50:41 +02:00
jaseg
b50587d6ad Fix dropped aperture macro names 2023-04-30 11:27:21 +02:00
jaseg
26c2460490 Fix remaining unit tests 2023-04-30 11:07:29 +02:00
jaseg
af3458b1e2 Fix failing kicad tests 2023-04-30 11:06:47 +02:00
jaseg
73a48f1dcb protoserve: Add ALio layout 2023-04-30 01:58:30 +02:00
jaseg
9ffc96cbe5 Finish macro-based alio layout 2023-04-30 01:44:58 +02:00
jaseg
fda2433154 Alio macros WIP 2023-04-30 01:26:21 +02:00
jaseg
f674f5d9af protoboard: add ALio layout by arief ibrahim adha 2023-04-30 00:40:05 +02:00
jaseg
fb61b4fa12 protoserve: Adjust margins and clearance defaults 2023-04-29 23:26:13 +02:00
jaseg
e4a459368c protoserve: Fix error with empty layouts 2023-04-29 23:17:11 +02:00
jaseg
176252b564 Protoserve fixes, add doc to protoboard.py 2023-04-29 23:11:29 +02:00
jaseg
fdb07ea26e Fix incorrect aperture macro outline primitive point count 2023-04-29 12:15:55 +02:00
jaseg
8d5403260b Fix aperture macro rotation issue and add missing data files 2023-04-29 11:28:38 +02:00
jaseg
778e819745 Freeze apertures and aperture macros, make gerbonara faster 2023-04-29 01:00:45 +02:00
jaseg
958b47ab47 Speed up protoboard generation 2023-04-26 23:37:38 +02:00
jaseg
38f766dc42 Add spiky layout to protoserve 2023-04-26 22:57:14 +02:00
jaseg
549a33d386 Finish spiky proto layout by electroniceel 2023-04-26 22:48:08 +02:00
jaseg
8409fbb908 Export flashes as svg <use> tags 2023-04-26 22:36:20 +02:00
jaseg
9f74fad6a2 Fix aperture macro svg output rotation 2023-04-26 21:40:37 +02:00
jaseg
e98ed31255 Fix all failing footprint tests 2023-04-26 09:52:24 +02:00
jaseg
1f841ad71b Fix last failing tests. Rerun pending. 2023-04-26 00:35:37 +02:00
jaseg
59fe7b3b83 WIP 2023-04-25 22:21:07 +02:00
jaseg
1dbe7f1f73 Fix more tests 2023-04-25 00:02:24 +02:00
jaseg
5a41d96fe3 Fixing more tests 2023-04-24 00:56:32 +02:00
jaseg
bda404c18b Fix a bunch of failing tests 2023-04-23 23:33:00 +02:00
jaseg
aaaf96e8d9 Simplify stroked arc bounding box calculation 2023-04-23 23:22:56 +02:00
jaseg
a93d118773 kicad unit tests WIP 2023-04-22 17:16:20 +02:00
jaseg
5ce88e4d1b Fix a bunch of bugs on the way to electroniceel's protoboard layout 2023-04-20 00:46:30 +02:00
jaseg
240e5569aa Fix serialization bug with aperture macro comments 2023-04-19 11:11:03 +02:00
jaseg
2c6c9a5cbc Basic KiCad footprint rendering works 2023-04-19 00:51:27 +02:00
jaseg
263033c9bd protoserve: Remove incorrect SMD pad shape entry 2023-04-16 13:42:14 +02:00
jaseg
0421e03717 Bump version to v1.0.2 2023-04-15 23:24:11 +02:00
jaseg
390579850b Fix dasher 2023-04-15 23:23:27 +02:00
jaseg
2eefb9cc7d ci: Clone kicad libraries for tests 2023-04-15 22:31:09 +02:00
jaseg
5c7bfb2744 Fix protoserve bugs in obround pads 2023-04-15 22:26:38 +02:00
jaseg
5ea1491704 Bump version to v1.0.1 2023-04-15 22:14:59 +02:00
jaseg
4bd1097fc1 Bump minimum Python version to 3.10 2023-04-15 22:12:45 +02:00
jaseg
3556dc081b Remove extraneous file 2023-04-15 17:12:27 +02:00
jaseg
fba0507a55 Small bugfix 2023-04-15 17:09:35 +02:00
jaseg
2400ff8e5f cad: Add KiCad symbol/footprint parser 2023-04-15 17:09:20 +02:00
jaseg
b43e4e2eec Bump version to v1.0.0 2023-04-11 00:52:32 +02:00
jaseg
138f6504e7 Fix failing tests 2023-04-11 00:52:32 +02:00
jaseg
b0bc7971bc Remove WIP unit test file 2023-04-11 00:03:20 +02:00
jaseg
8181651a75 protoserve: Fix bugs, make gerber link more visible 2023-04-10 23:57:15 +02:00
jaseg
888ae71889 protoserve: Gerber download works 2023-04-10 23:57:15 +02:00
jaseg
fba189c695 protogen web interface works 2023-04-10 23:57:15 +02:00
jaseg
e18dbb11f8 protoserve WIP 2023-04-10 23:57:15 +02:00
jaseg
b1b39cd65c protoboard webthing WIP 2023-04-10 23:57:15 +02:00
jaseg
6fe3def9d6 Make SVG export even smaller 2023-04-10 23:57:15 +02:00
jaseg
0dcd281406 Make generated pretty SVGs smaller 2023-04-10 23:57:15 +02:00
jaseg
ba92060431 Copper fill WIP 2023-04-10 23:57:15 +02:00
jaseg
24577464ee Improve protoboard row/column numbering 2023-04-10 23:57:15 +02:00
jaseg
60e893c82d cad: Add pad numbering for protoboards 2023-04-10 23:57:15 +02:00
jaseg
d9b3fafa80 Add RF protoboard 2023-04-10 23:57:15 +02:00
jaseg
666e385cb4 Add missing protoboard file, add powered proto layout 2023-04-10 23:57:15 +02:00
jaseg
a1efd9d09a Fix aperture macro multiplication syntax 2023-04-10 23:57:15 +02:00
jaseg
506f61ae84 Fix rectangle aperture rotation 2023-04-10 23:57:15 +02:00
jaseg
2f04847426 Add more protoboard layouts 2023-04-10 23:57:15 +02:00
jaseg
ae1f522862 Initial protoboard generation working 2023-04-10 23:57:15 +02:00
jaseg
44ca8349eb cad: Fix outline reconstruction and add text feature 2023-04-10 23:57:15 +02:00
jaseg
ce8d045178 cad: Finish initial board support 2023-04-10 23:57:15 +02:00
jaseg
07b2628dbb Various convenience improvements, and make board name guessing really smart 2023-04-10 23:57:15 +02:00
jaseg
387ff3de76 cad: Add trace corner rounding function 2023-04-10 23:57:15 +02:00
jaseg
a95aacac48 Add missing WIP changes 2023-04-10 23:57:15 +02:00
jaseg
d0894b2522 Add beginnings of CAD module 2023-04-10 23:57:15 +02:00
jaseg
dcb31f3131 Fix extraneous tool selection codes in merged Excellon files 2023-04-10 23:57:15 +02:00
jaseg
33a35f796d Fix failing tests 2023-04-10 23:57:15 +02:00
jaseg
0295440770 Improve layer stack handling 2023-04-10 23:57:15 +02:00
jaseg
800827b2c5 Add convex hull and point in polygon functions 2023-04-10 23:57:15 +02:00
jaseg
a85a7d426e Improve drill layer handling
Now, drill_pth and drill_npth contain those layers where they match, and
everything else is put in _drill_layers. The @property drill_layers now
returns everything.
2023-04-10 23:57:15 +02:00
jaseg
ec0ecdeb68 Dedup both Excellon and Gerber tools during write 2023-04-10 23:57:15 +02:00
jaseg
900de13d8c Fix crash in gerber to excellon conversion 2023-04-10 23:57:15 +02:00
Ricardo (XenGi) Band
b2ba39e1eb
oopsi 2023-03-23 15:59:29 +01:00
XenGi
1f1d81533a Update __init__.py 2023-03-23 14:56:21 +00:00
XenGi
1c9dcc1a9f Update Makefile 2023-03-23 12:13:35 +00:00
XenGi
ba5bf67235 Update file README.md 2023-03-23 12:07:51 +00:00
jaseg
f7aa6657e7 Bump version to v0.13.0
This fixes the broken packages on PyPI
2023-03-05 20:59:46 +01:00
jaseg
b5e6a48d54 Fix empty filename issue with some layer stacks 2023-03-05 20:48:12 +01:00
jaseg
a9931c469b Fix MANIFEST issue 2023-03-05 20:48:04 +01:00
jaseg
9d9b47842f CI: Fix some dumb packaging stuff 2023-02-25 21:37:14 +01:00
jaseg
cba1c9a8a2 Bump version to v0.12.0 2023-02-25 19:52:33 +01:00
jaseg
19bcd5ce96 Fix .gitlab-ci.yml for some python packaging bullshi 2023-02-25 19:46:56 +01:00
jaseg
1aaac3936f Still more doc 2023-02-25 19:43:54 +01:00
jaseg
8b40d15dab Moar doc 2023-02-25 17:31:16 +01:00
jaseg
d43eff8b49 Extend CLI tests 2023-02-23 23:52:29 +01:00
jaseg
70179a4178 Finish first batch of render tests 2023-02-21 23:43:59 +01:00
jaseg
866eafb4eb Add cli test infrastructure 2023-02-21 23:18:56 +01:00
jaseg
16f1247fda Fix copyright headers 2023-02-21 23:06:52 +01:00
jaseg
67dfad8418 layers: Fix single file handling 2023-02-21 23:06:39 +01:00
jaseg
6231f67139 Fix tests 2023-02-21 22:48:14 +01:00
jaseg
9a6bc691cb cli: Add merge command 2023-02-21 00:44:09 +01:00
jaseg
a374483998 cli: First draft of most of the CLI 2023-02-19 23:42:17 +01:00
jaseg
f64b03efc7 Add CLI 2023-02-17 00:03:04 +01:00
Nein Seg
fb52e10408 Merge branch 'fix/to_svg' into 'main'
fix: runtime errors on LayerStack#to_svg

See merge request gerbolyze/gerbonara!1
2022-07-30 11:15:08 +00:00
Alessandro Racheli
fec4cf0057 fix: runtime errors on LayerStack#to_svg
This commit removes some inexistent variables from the to_svg method
of the LayerStack class
2022-07-29 16:22:05 +02:00
jaseg
8f4cdd8810 Fix zipfile writing when lazily loading 2022-07-03 22:49:09 +02:00
jaseg
791eca7679 pretty svg: make render upright 2022-07-03 22:26:37 +02:00
jaseg
163c30663f Pretty SVG export: fix drill layer 2022-07-03 21:47:40 +02:00
jaseg
f558f66bc0 Pretty SVG WIP 2022-07-03 21:35:20 +02:00
jaseg
b75404efce fix repated aperture macro D code export 2022-06-24 16:52:39 +02:00
jaseg
7bdbe66dc7 Fix zip import/export and bounds for empty boards 2022-06-21 14:17:08 +02:00
jaseg
71e996e874 downgrade minimum python version to 3.8 2022-06-21 14:16:52 +02:00
jaseg
39d7d693ee ci: add ubuntu 20.04 / python 3.8 tests 2022-06-21 12:39:11 +02:00
jaseg
218f9d9b1f Make gerbonara python3.8 compatible. 2022-06-21 12:26:38 +02:00
jaseg
ee233317f1 Fix local tests 2022-06-21 12:07:13 +02:00
jaseg
6752dab125 local tests: add --parallel arg 2022-06-21 11:31:17 +02:00
jaseg
acf2747e86 fix local arch and ubuntu tests 2022-06-21 11:22:34 +02:00
jaseg
2f4e52d31e assume pytest-parallel already is in container image 2022-06-21 11:22:06 +02:00
jaseg
fa089d32ca ci: correct ubuntu job name 2022-06-21 11:21:50 +02:00
jaseg
a169ee43f8 ci: install gerbv on ubuntu during container build 2022-06-21 11:12:32 +02:00
jaseg
3aeea67f37 Add podman local test runner 2022-06-21 11:02:07 +02:00
jaseg
95d0b60490 repo: git'ignore tests image_cache 2022-06-21 11:01:26 +02:00
jaseg
6491f1cf44 ci: fix ubuntu tests gerbv installation 2022-06-21 11:00:37 +02:00
jaseg
432524caa6 tests: whittle down test cases a bit 2022-06-21 11:00:04 +02:00
jaseg
0f70b225cc tests: add missing allegro test files 2022-06-21 10:53:13 +02:00
jaseg
4bd438e9cc Add missing test data files 2022-06-21 10:52:03 +02:00
jaseg
8ddeb65b3f ci: add missing dependencies to ubuntu tests 2022-06-21 10:18:34 +02:00
jaseg
2766b447fa ci: fix pytest invocation on ubuntu 2022-06-21 10:12:57 +02:00
jaseg
cb0c84d36c ci: pip install pytest for ubuntu tests 2022-06-21 10:07:51 +02:00
jaseg
99eec1e092 Add ubuntu tests 2022-06-21 10:03:53 +02:00
jaseg
a1eb3afa75 Add git safe.directory work around to docs CI job 2022-06-21 10:01:22 +02:00
jaseg
6369b5ddb6 re-enable tests and docs in CI 2022-06-21 09:55:56 +02:00
jaseg
9ca9731110 Fix directory testcase 2022-06-21 09:52:46 +02:00
jaseg
bff588ff75 Work aronud lack of dataclasses.KW_ONLY in python < 3.10 2022-06-21 09:52:13 +02:00
jaseg
3c4b7b1de3 fix zip export 2022-06-20 14:38:48 +02:00
jaseg
7ded0d6d6f Fix gerber-to-excellon conversion 2022-06-19 00:51:06 +02:00
jaseg
f1ac559eb3 Make name generation for layer stack saving smarter 2022-06-19 00:50:52 +02:00
jaseg
748ab7ccf2 Fix rs274x to excellon conversion 2022-06-18 23:52:47 +02:00
jaseg
45d41af3aa Fix drill layer merging 2022-06-18 23:52:37 +02:00
jaseg
dfaf23b718 Add selectable inkscape SVG export 2022-06-18 23:52:22 +02:00
jaseg
4b38d0905e Add altium naming scheme, fix kicad scheme 2022-06-18 23:52:02 +02:00
jaseg
a88364b7a9 Fix up saving and zip writing logic 2022-06-18 23:51:43 +02:00
jaseg
fa7a526883 Add CachedLazyCamFile 2022-06-18 23:50:43 +02:00
jaseg
6833bf8657 pkg: update package metadata 2022-06-12 21:51:03 +02:00
jaseg
9e898ceefb ci: build and upload sdists 2022-06-12 21:37:31 +02:00
jaseg
0d967895af Add missing __init__.py
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-06-11 22:34:57 +02:00
jaseg
94289b7af4 doc: Fix sphinx build
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-06-10 23:00:44 +02:00
jaseg
230418705f ci: Work around more safe.directory issues
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-06-10 22:43:04 +02:00
jaseg
1f50a881ca ci: fix git safe.directory error 2022-06-10 22:42:54 +02:00
jaseg
89f3aeeac7 Gitlab CI test
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-06-10 22:23:28 +02:00
jaseg
b1be792c52 Fix tests
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-06-10 22:11:39 +02:00
jaseg
460ea625af Fix merging, bounding boxes and svg precision 2022-06-10 00:39:07 +02:00
jaseg
e422243a6e Fix layer stack SVG export 2022-05-21 15:29:18 +02:00
jaseg
c127c89fa3 Fix arc bounding box calculation 2022-05-21 15:29:07 +02:00
jaseg
45cd00387e Fix parsing of aperture macro comments 2022-05-21 15:28:41 +02:00
jaseg
a1de37d83f Add SVG export to more things 2022-04-24 20:08:14 +02:00
jaseg
b42b0e85fa Rectify latest cairo SVG export
It seems Cairo got updated and its SVG export is now broken in a new and
exciting way. Now it exports documents with dimensionless w/h. The issue
with that is that Cairo thinks that means point @ 72 pt / inch, but
everyone else including the actual svg spec interpret that as raw document
units or pixels, AFAICT completely breaking dpi scaling in common
viewers. Since we have to mess with Cairo's SVG files anyway, we just
fix this as well by re-writing the broken w/h into physical units
according to a hardcoded conversion factor that matches cairo's
hardcoded scale.
2022-04-24 20:05:29 +02:00
jaseg
766c4eb4b3 docs: auto-discover version from git
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-02-07 00:03:46 +01:00
jaseg
4d1f4c709d docs: Add upstream doc link
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-02-07 00:00:53 +01:00
jaseg
48a890e7f4 Add doc link to PyPI 2022-02-07 00:00:08 +01:00
jaseg
39e2eda95d Update readme with doc link 2022-02-06 23:57:25 +01:00
jaseg
14027c552d doc/ci: Fix pages build
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-02-06 23:54:28 +01:00
jaseg
02a5f92af1 Fix doc intro code blocks
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-02-06 23:49:51 +01:00
jaseg
a939ab016e docs: add quickstart guide
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-02-06 23:47:31 +01:00
jaseg
e03e39b421 CI: remove tests as our test suite is a bit too heavy for public gitlab runners
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-02-06 23:32:14 +01:00
jaseg
a91d760c08 CI: WIP
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-02-06 23:07:51 +01:00
jaseg
cac0ef4240 CI: WIP
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-02-06 22:33:19 +01:00
jaseg
fc0779c4f8 CI: WIP
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-02-06 21:17:57 +01:00
jaseg
e03d71ef15 CI: WIP
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-02-06 21:09:21 +01:00
jaseg
99b51e1ece CI: WIP
Some checks failed
pcb-tools / test (3.5) (push) Has been cancelled
pcb-tools / test (3.6) (push) Has been cancelled
pcb-tools / test (3.7) (push) Has been cancelled
pcb-tools / test (3.8) (push) Has been cancelled
pcb-tools / coverage (push) Has been cancelled
2022-02-06 21:06:23 +01:00
596 changed files with 402097 additions and 3010 deletions

View file

@ -1,8 +0,0 @@
[run]
branch = True
source = gerber
[report]
ignore_errors = True
omit =
gerber/tests/*

View file

@ -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
View file

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

View file

@ -4,46 +4,106 @@ variables:
stages:
- build
- test
- docs
- publish
build:debian_10:
build:archlinux:
stage: build
image: "registry.gitlab.com/gerbolyze/build-containers/debian:10"
image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
variables:
GIT_SUBMODULE_STRATEGY: none
script:
- 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:debian_10:
# 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/debian:10"
image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
script:
- pip3 install --user pytest
- ~/.local/bin/pytest -o 'testpaths=gerbonara/tests' -o 'norecursedirs=*'
- git config --global --add safe.directory "$CI_PROJECT_DIR"
- sphinx-build -E docs docs/_build
dependencies:
- build:debian_10
- build:archlinux
artifacts:
name: "gerbolyze-$CI_COMMIT_REF_NAME-gerbonara"
when: on_failure
name: "docs-gerbonara-$CI_COMMIT_REF_NAME"
paths:
- gerbonara_test_failures/*
- docs/_build
publish:gerbonara:
stage: publish
variables:
GIT_SUBMODULE_STRATEGY: none
image: "registry.gitlab.com/gerbolyze/build-containers/debian:10"
image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
cache: {}
script:
- pip install -U --user twine
- export TWINE_USERNAME TWINE_PASSWORD
- ~/.local/bin/twine upload dist/*
- pip3 install --user --break-system-packages twine rich
- twine upload dist/*
dependencies:
- build:debian_10
- build:archlinux
only:
- /^v.*$/
pages:
stage: publish
variables:
GIT_SUBMODULE_STRATEGY: none
image: "registry.gitlab.com/gerbolyze/build-containers/archlinux:latest"
script:
- git config --global --add safe.directory "$CI_PROJECT_DIR"
- sphinx-build -E docs public
dependencies:
- build:archlinux
artifacts:
paths:
- public
only:
- /^v.*$/

12
.pypirc
View file

@ -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
View 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

View file

@ -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}'

View file

@ -1,5 +1,5 @@
[![pipeline status](https://gitlab.com/gerbonara/gerbonara/badges/master/pipeline.svg)](https://gitlab.com/gerbonara/gerbonara/commits/master)
[![coverage report](https://gitlab.com/gerbonara/gerbonara/badges/master/coverage.svg)](https://gitlab.com/gerbonara/gerbonara/commits/master)
[![pipeline status](https://gitlab.com/gerbolyze/gerbonara/badges/master/pipeline.svg)](https://gitlab.com/gerbolyze/gerbonara/commits/master)
[![coverage report](https://gitlab.com/gerbolyze/gerbonara/badges/master/coverage.svg)](https://gitlab.com/gerbolyze/gerbonara/commits/master)
[![pypi](https://img.shields.io/pypi/v/gerbonara)](https://pypi.org/project/gerbonara/)
[![aur](https://img.shields.io/aur/version/python-gerbonara)](https://aur.archlinux.org/packages/python-gerbonara/)
@ -23,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
View 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)

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

64
docs/examples.rst Normal file
View 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')

View file

@ -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:

View file

@ -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.

View file

@ -10,7 +10,7 @@ from gerbonara.utils import MM
from gerbonara.utils import rotate_point
def highlight_outline(input_dir, output_dir):
stack = LayerStack.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

View file

@ -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))

View file

@ -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=' ')

View file

@ -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()

View file

@ -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}'

View file

@ -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)

View file

@ -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,
}

View file

@ -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

View file

@ -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}')

View file

@ -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)

View file

@ -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()

View file

@ -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))

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
git

7
podman/testdata/testscript.sh vendored Executable file
View 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
View 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
View 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
View 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

View file

@ -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',
)

View file

@ -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

View file

@ -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
View 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()

View 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)

View 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()

View 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)

View 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,
}

View file

@ -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)

View 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()

View file

View 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))
))
)

View 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))
)
)

View 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))
)
)

View file

@ -0,0 +1 @@

View 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>')

View 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')

View 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

View 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),
}

View 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')

View 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

View 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))))

View 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'

View 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))

View 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

View 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")

View 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()))

View 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()

File diff suppressed because it is too large Load diff

View 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()

File diff suppressed because it is too large Load diff

View 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
View 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()

View file

File diff suppressed because it is too large Load diff

View 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')

View file

@ -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)

View 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)

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

167
src/gerbonara/newstroke.py Normal file
View 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)

View file

@ -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

View 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.
@ -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
View 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
View file

@ -0,0 +1 @@
image_cache

0
tests/__init__.py Normal file
View file

172
tests/conftest.py Normal file
View 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)))

View file

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

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