summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--Makefile1
-rw-r--r--README.md16
-rw-r--r--examples/cairo_example.pngbin110829 -> 104401 bytes
-rw-r--r--examples/cairo_example.py20
-rw-r--r--examples/gerbers/bottom_copper.GBL1811
-rw-r--r--examples/gerbers/bottom_mask.GBS66
-rw-r--r--examples/gerbers/shld.drd354
-rw-r--r--examples/pcb_bottom.pngbin0 -> 39174 bytes
-rw-r--r--examples/pcb_example.py39
-rw-r--r--examples/pcb_top.pngbin0 -> 99269 bytes
-rw-r--r--gerber/__init__.py3
-rw-r--r--gerber/am_statements.py224
-rw-r--r--gerber/cam.py23
-rw-r--r--gerber/common.py12
-rwxr-xr-xgerber/excellon.py351
-rw-r--r--gerber/excellon_report/excellon_drr.py25
-rw-r--r--gerber/excellon_settings.py105
-rw-r--r--gerber/excellon_statements.py236
-rw-r--r--gerber/excellon_tool.py186
-rw-r--r--gerber/exceptions.py36
-rw-r--r--gerber/gerber_statements.py107
-rw-r--r--gerber/ipc356.py89
-rw-r--r--gerber/layers.py246
-rw-r--r--gerber/ncparam/allegro.py25
-rw-r--r--gerber/pcb.py94
-rw-r--r--gerber/primitives.py437
-rw-r--r--gerber/render/cairo_backend.py356
-rw-r--r--gerber/render/excellon_backend.py189
-rw-r--r--gerber/render/render.py53
-rw-r--r--gerber/render/rs274x_backend.py470
-rw-r--r--gerber/render/theme.py70
-rw-r--r--gerber/rs274x.py226
-rw-r--r--gerber/tests/golden/example_two_square_boxes.pngbin0 -> 18247 bytes
-rw-r--r--gerber/tests/resources/example_two_square_boxes.gbr19
-rw-r--r--gerber/tests/test_am_statements.py19
-rw-r--r--gerber/tests/test_cairo_backend.py58
-rw-r--r--gerber/tests/test_cam.py11
-rw-r--r--gerber/tests/test_common.py5
-rw-r--r--gerber/tests/test_excellon.py47
-rw-r--r--gerber/tests/test_layers.py33
-rw-r--r--gerber/tests/test_primitives.py67
-rw-r--r--gerber/utils.py39
-rw-r--r--test-requirements.txt1
44 files changed, 5413 insertions, 762 deletions
diff --git a/.gitignore b/.gitignore
index 01ba410..a3ffb1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,10 +37,14 @@ nosetests.xml
.idea/workspace.xml
.idea/misc.xml
.idea
+.settings
# Komodo Files
*.komodoproject
# OS Files
.DS_Store
-Thumbs.db \ No newline at end of file
+Thumbs.db
+
+# Virtual environment
+venv
diff --git a/Makefile b/Makefile
index 8d3fd33..87f0124 100644
--- a/Makefile
+++ b/Makefile
@@ -31,4 +31,5 @@ doc-clean:
.PHONY: examples
examples:
PYTHONPATH=. $(PYTHON) examples/cairo_example.py
+ PYTHONPATH=. $(PYTHON) examples/pcb_example.py
diff --git a/README.md b/README.md
index 098b704..27764be 100644
--- a/README.md
+++ b/README.md
@@ -27,9 +27,25 @@ Rendering Examples:
-------------------
###Top Composite rendering
![Composite Top Image](examples/cairo_example.png)
+
Source code for this example can be found [here](examples/cairo_example.py).
Documentation:
--------------
[PCB Tools Documentation](http://pcb-tools.readthedocs.org/en/latest/)
+
+
+Development and Testing:
+------------------------
+
+Dependencies for developing and testing pcb-tools are listed in test-requirements.txt. Use of a virtual environment is strongly recommended.
+
+ $ virtualenv venv
+ $ source venv/bin/activate
+ (venv)$ pip install -r test-requirements.txt
+ (venv)$ pip install -e .
+
+We use nose to run pcb-tools's suite of unittests and doctests.
+
+ (venv)$ nosetests
diff --git a/examples/cairo_example.png b/examples/cairo_example.png
index 485586e..f33cc69 100644
--- a/examples/cairo_example.png
+++ b/examples/cairo_example.png
Binary files differ
diff --git a/examples/cairo_example.py b/examples/cairo_example.py
index a312e89..14a7037 100644
--- a/examples/cairo_example.py
+++ b/examples/cairo_example.py
@@ -25,7 +25,7 @@ a .png file.
import os
from gerber import read
-from gerber.render import GerberCairoContext
+from gerber.render import GerberCairoContext, theme
GERBER_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), 'gerbers'))
@@ -36,29 +36,33 @@ mask = read(os.path.join(GERBER_FOLDER, 'soldermask.GTS'))
silk = read(os.path.join(GERBER_FOLDER, 'silkscreen.GTO'))
drill = read(os.path.join(GERBER_FOLDER, 'ncdrill.DRD'))
-
# Create a new drawing context
ctx = GerberCairoContext()
+# Set opacity and color for copper layer
+ctx.alpha = 1.0
+ctx.color = theme.COLORS['hasl copper']
+
# Draw the copper layer
copper.render(ctx)
# Set opacity and color for soldermask layer
-ctx.alpha = 0.6
-ctx.color = (0.2, 0.2, 0.75)
+ctx.alpha = 0.75
+ctx.color = theme.COLORS['green soldermask']
# Draw the soldermask layer
-mask.render(ctx)
+mask.render(ctx, invert=True)
# Set opacity and color for silkscreen layer
-ctx.alpha = 0.85
-ctx.color = (1, 1, 1)
+ctx.alpha = 1.0
+ctx.color = theme.COLORS['white']
# Draw the silkscreen layer
silk.render(ctx)
# Set opacity for drill layer
-ctx.alpha = 1.
+ctx.alpha = 1.0
+ctx.color = theme.COLORS['black']
drill.render(ctx)
# Write output to png file
diff --git a/examples/gerbers/bottom_copper.GBL b/examples/gerbers/bottom_copper.GBL
new file mode 100644
index 0000000..0d98da3
--- /dev/null
+++ b/examples/gerbers/bottom_copper.GBL
@@ -0,0 +1,1811 @@
+G75*
+%MOIN*%
+%OFA0B0*%
+%FSLAX24Y24*%
+%IPPOS*%
+%LPD*%
+%AMOC8*
+5,1,8,0,0,1.08239X$1,22.5*
+%
+%ADD10C,0.0000*%
+%ADD11C,0.0110*%
+%ADD12C,0.0004*%
+%ADD13C,0.0554*%
+%ADD14C,0.0600*%
+%ADD15C,0.0160*%
+%ADD16C,0.0396*%
+%ADD17C,0.0240*%
+D10*
+X000300Y003064D02*
+X000300Y018064D01*
+X022800Y018064D01*
+X022800Y003064D01*
+X000300Y003064D01*
+X001720Y005114D02*
+X001722Y005164D01*
+X001728Y005214D01*
+X001738Y005263D01*
+X001752Y005311D01*
+X001769Y005358D01*
+X001790Y005403D01*
+X001815Y005447D01*
+X001843Y005488D01*
+X001875Y005527D01*
+X001909Y005564D01*
+X001946Y005598D01*
+X001986Y005628D01*
+X002028Y005655D01*
+X002072Y005679D01*
+X002118Y005700D01*
+X002165Y005716D01*
+X002213Y005729D01*
+X002263Y005738D01*
+X002312Y005743D01*
+X002363Y005744D01*
+X002413Y005741D01*
+X002462Y005734D01*
+X002511Y005723D01*
+X002559Y005708D01*
+X002605Y005690D01*
+X002650Y005668D01*
+X002693Y005642D01*
+X002734Y005613D01*
+X002773Y005581D01*
+X002809Y005546D01*
+X002841Y005508D01*
+X002871Y005468D01*
+X002898Y005425D01*
+X002921Y005381D01*
+X002940Y005335D01*
+X002956Y005287D01*
+X002968Y005238D01*
+X002976Y005189D01*
+X002980Y005139D01*
+X002980Y005089D01*
+X002976Y005039D01*
+X002968Y004990D01*
+X002956Y004941D01*
+X002940Y004893D01*
+X002921Y004847D01*
+X002898Y004803D01*
+X002871Y004760D01*
+X002841Y004720D01*
+X002809Y004682D01*
+X002773Y004647D01*
+X002734Y004615D01*
+X002693Y004586D01*
+X002650Y004560D01*
+X002605Y004538D01*
+X002559Y004520D01*
+X002511Y004505D01*
+X002462Y004494D01*
+X002413Y004487D01*
+X002363Y004484D01*
+X002312Y004485D01*
+X002263Y004490D01*
+X002213Y004499D01*
+X002165Y004512D01*
+X002118Y004528D01*
+X002072Y004549D01*
+X002028Y004573D01*
+X001986Y004600D01*
+X001946Y004630D01*
+X001909Y004664D01*
+X001875Y004701D01*
+X001843Y004740D01*
+X001815Y004781D01*
+X001790Y004825D01*
+X001769Y004870D01*
+X001752Y004917D01*
+X001738Y004965D01*
+X001728Y005014D01*
+X001722Y005064D01*
+X001720Y005114D01*
+X001670Y016064D02*
+X001672Y016114D01*
+X001678Y016164D01*
+X001688Y016213D01*
+X001702Y016261D01*
+X001719Y016308D01*
+X001740Y016353D01*
+X001765Y016397D01*
+X001793Y016438D01*
+X001825Y016477D01*
+X001859Y016514D01*
+X001896Y016548D01*
+X001936Y016578D01*
+X001978Y016605D01*
+X002022Y016629D01*
+X002068Y016650D01*
+X002115Y016666D01*
+X002163Y016679D01*
+X002213Y016688D01*
+X002262Y016693D01*
+X002313Y016694D01*
+X002363Y016691D01*
+X002412Y016684D01*
+X002461Y016673D01*
+X002509Y016658D01*
+X002555Y016640D01*
+X002600Y016618D01*
+X002643Y016592D01*
+X002684Y016563D01*
+X002723Y016531D01*
+X002759Y016496D01*
+X002791Y016458D01*
+X002821Y016418D01*
+X002848Y016375D01*
+X002871Y016331D01*
+X002890Y016285D01*
+X002906Y016237D01*
+X002918Y016188D01*
+X002926Y016139D01*
+X002930Y016089D01*
+X002930Y016039D01*
+X002926Y015989D01*
+X002918Y015940D01*
+X002906Y015891D01*
+X002890Y015843D01*
+X002871Y015797D01*
+X002848Y015753D01*
+X002821Y015710D01*
+X002791Y015670D01*
+X002759Y015632D01*
+X002723Y015597D01*
+X002684Y015565D01*
+X002643Y015536D01*
+X002600Y015510D01*
+X002555Y015488D01*
+X002509Y015470D01*
+X002461Y015455D01*
+X002412Y015444D01*
+X002363Y015437D01*
+X002313Y015434D01*
+X002262Y015435D01*
+X002213Y015440D01*
+X002163Y015449D01*
+X002115Y015462D01*
+X002068Y015478D01*
+X002022Y015499D01*
+X001978Y015523D01*
+X001936Y015550D01*
+X001896Y015580D01*
+X001859Y015614D01*
+X001825Y015651D01*
+X001793Y015690D01*
+X001765Y015731D01*
+X001740Y015775D01*
+X001719Y015820D01*
+X001702Y015867D01*
+X001688Y015915D01*
+X001678Y015964D01*
+X001672Y016014D01*
+X001670Y016064D01*
+X020060Y012714D02*
+X020062Y012764D01*
+X020068Y012814D01*
+X020078Y012863D01*
+X020091Y012912D01*
+X020109Y012959D01*
+X020130Y013005D01*
+X020154Y013048D01*
+X020182Y013090D01*
+X020213Y013130D01*
+X020247Y013167D01*
+X020284Y013201D01*
+X020324Y013232D01*
+X020366Y013260D01*
+X020409Y013284D01*
+X020455Y013305D01*
+X020502Y013323D01*
+X020551Y013336D01*
+X020600Y013346D01*
+X020650Y013352D01*
+X020700Y013354D01*
+X020750Y013352D01*
+X020800Y013346D01*
+X020849Y013336D01*
+X020898Y013323D01*
+X020945Y013305D01*
+X020991Y013284D01*
+X021034Y013260D01*
+X021076Y013232D01*
+X021116Y013201D01*
+X021153Y013167D01*
+X021187Y013130D01*
+X021218Y013090D01*
+X021246Y013048D01*
+X021270Y013005D01*
+X021291Y012959D01*
+X021309Y012912D01*
+X021322Y012863D01*
+X021332Y012814D01*
+X021338Y012764D01*
+X021340Y012714D01*
+X021338Y012664D01*
+X021332Y012614D01*
+X021322Y012565D01*
+X021309Y012516D01*
+X021291Y012469D01*
+X021270Y012423D01*
+X021246Y012380D01*
+X021218Y012338D01*
+X021187Y012298D01*
+X021153Y012261D01*
+X021116Y012227D01*
+X021076Y012196D01*
+X021034Y012168D01*
+X020991Y012144D01*
+X020945Y012123D01*
+X020898Y012105D01*
+X020849Y012092D01*
+X020800Y012082D01*
+X020750Y012076D01*
+X020700Y012074D01*
+X020650Y012076D01*
+X020600Y012082D01*
+X020551Y012092D01*
+X020502Y012105D01*
+X020455Y012123D01*
+X020409Y012144D01*
+X020366Y012168D01*
+X020324Y012196D01*
+X020284Y012227D01*
+X020247Y012261D01*
+X020213Y012298D01*
+X020182Y012338D01*
+X020154Y012380D01*
+X020130Y012423D01*
+X020109Y012469D01*
+X020091Y012516D01*
+X020078Y012565D01*
+X020068Y012614D01*
+X020062Y012664D01*
+X020060Y012714D01*
+X020170Y016064D02*
+X020172Y016114D01*
+X020178Y016164D01*
+X020188Y016213D01*
+X020202Y016261D01*
+X020219Y016308D01*
+X020240Y016353D01*
+X020265Y016397D01*
+X020293Y016438D01*
+X020325Y016477D01*
+X020359Y016514D01*
+X020396Y016548D01*
+X020436Y016578D01*
+X020478Y016605D01*
+X020522Y016629D01*
+X020568Y016650D01*
+X020615Y016666D01*
+X020663Y016679D01*
+X020713Y016688D01*
+X020762Y016693D01*
+X020813Y016694D01*
+X020863Y016691D01*
+X020912Y016684D01*
+X020961Y016673D01*
+X021009Y016658D01*
+X021055Y016640D01*
+X021100Y016618D01*
+X021143Y016592D01*
+X021184Y016563D01*
+X021223Y016531D01*
+X021259Y016496D01*
+X021291Y016458D01*
+X021321Y016418D01*
+X021348Y016375D01*
+X021371Y016331D01*
+X021390Y016285D01*
+X021406Y016237D01*
+X021418Y016188D01*
+X021426Y016139D01*
+X021430Y016089D01*
+X021430Y016039D01*
+X021426Y015989D01*
+X021418Y015940D01*
+X021406Y015891D01*
+X021390Y015843D01*
+X021371Y015797D01*
+X021348Y015753D01*
+X021321Y015710D01*
+X021291Y015670D01*
+X021259Y015632D01*
+X021223Y015597D01*
+X021184Y015565D01*
+X021143Y015536D01*
+X021100Y015510D01*
+X021055Y015488D01*
+X021009Y015470D01*
+X020961Y015455D01*
+X020912Y015444D01*
+X020863Y015437D01*
+X020813Y015434D01*
+X020762Y015435D01*
+X020713Y015440D01*
+X020663Y015449D01*
+X020615Y015462D01*
+X020568Y015478D01*
+X020522Y015499D01*
+X020478Y015523D01*
+X020436Y015550D01*
+X020396Y015580D01*
+X020359Y015614D01*
+X020325Y015651D01*
+X020293Y015690D01*
+X020265Y015731D01*
+X020240Y015775D01*
+X020219Y015820D01*
+X020202Y015867D01*
+X020188Y015915D01*
+X020178Y015964D01*
+X020172Y016014D01*
+X020170Y016064D01*
+X020060Y008714D02*
+X020062Y008764D01*
+X020068Y008814D01*
+X020078Y008863D01*
+X020091Y008912D01*
+X020109Y008959D01*
+X020130Y009005D01*
+X020154Y009048D01*
+X020182Y009090D01*
+X020213Y009130D01*
+X020247Y009167D01*
+X020284Y009201D01*
+X020324Y009232D01*
+X020366Y009260D01*
+X020409Y009284D01*
+X020455Y009305D01*
+X020502Y009323D01*
+X020551Y009336D01*
+X020600Y009346D01*
+X020650Y009352D01*
+X020700Y009354D01*
+X020750Y009352D01*
+X020800Y009346D01*
+X020849Y009336D01*
+X020898Y009323D01*
+X020945Y009305D01*
+X020991Y009284D01*
+X021034Y009260D01*
+X021076Y009232D01*
+X021116Y009201D01*
+X021153Y009167D01*
+X021187Y009130D01*
+X021218Y009090D01*
+X021246Y009048D01*
+X021270Y009005D01*
+X021291Y008959D01*
+X021309Y008912D01*
+X021322Y008863D01*
+X021332Y008814D01*
+X021338Y008764D01*
+X021340Y008714D01*
+X021338Y008664D01*
+X021332Y008614D01*
+X021322Y008565D01*
+X021309Y008516D01*
+X021291Y008469D01*
+X021270Y008423D01*
+X021246Y008380D01*
+X021218Y008338D01*
+X021187Y008298D01*
+X021153Y008261D01*
+X021116Y008227D01*
+X021076Y008196D01*
+X021034Y008168D01*
+X020991Y008144D01*
+X020945Y008123D01*
+X020898Y008105D01*
+X020849Y008092D01*
+X020800Y008082D01*
+X020750Y008076D01*
+X020700Y008074D01*
+X020650Y008076D01*
+X020600Y008082D01*
+X020551Y008092D01*
+X020502Y008105D01*
+X020455Y008123D01*
+X020409Y008144D01*
+X020366Y008168D01*
+X020324Y008196D01*
+X020284Y008227D01*
+X020247Y008261D01*
+X020213Y008298D01*
+X020182Y008338D01*
+X020154Y008380D01*
+X020130Y008423D01*
+X020109Y008469D01*
+X020091Y008516D01*
+X020078Y008565D01*
+X020068Y008614D01*
+X020062Y008664D01*
+X020060Y008714D01*
+X020170Y005064D02*
+X020172Y005114D01*
+X020178Y005164D01*
+X020188Y005213D01*
+X020202Y005261D01*
+X020219Y005308D01*
+X020240Y005353D01*
+X020265Y005397D01*
+X020293Y005438D01*
+X020325Y005477D01*
+X020359Y005514D01*
+X020396Y005548D01*
+X020436Y005578D01*
+X020478Y005605D01*
+X020522Y005629D01*
+X020568Y005650D01*
+X020615Y005666D01*
+X020663Y005679D01*
+X020713Y005688D01*
+X020762Y005693D01*
+X020813Y005694D01*
+X020863Y005691D01*
+X020912Y005684D01*
+X020961Y005673D01*
+X021009Y005658D01*
+X021055Y005640D01*
+X021100Y005618D01*
+X021143Y005592D01*
+X021184Y005563D01*
+X021223Y005531D01*
+X021259Y005496D01*
+X021291Y005458D01*
+X021321Y005418D01*
+X021348Y005375D01*
+X021371Y005331D01*
+X021390Y005285D01*
+X021406Y005237D01*
+X021418Y005188D01*
+X021426Y005139D01*
+X021430Y005089D01*
+X021430Y005039D01*
+X021426Y004989D01*
+X021418Y004940D01*
+X021406Y004891D01*
+X021390Y004843D01*
+X021371Y004797D01*
+X021348Y004753D01*
+X021321Y004710D01*
+X021291Y004670D01*
+X021259Y004632D01*
+X021223Y004597D01*
+X021184Y004565D01*
+X021143Y004536D01*
+X021100Y004510D01*
+X021055Y004488D01*
+X021009Y004470D01*
+X020961Y004455D01*
+X020912Y004444D01*
+X020863Y004437D01*
+X020813Y004434D01*
+X020762Y004435D01*
+X020713Y004440D01*
+X020663Y004449D01*
+X020615Y004462D01*
+X020568Y004478D01*
+X020522Y004499D01*
+X020478Y004523D01*
+X020436Y004550D01*
+X020396Y004580D01*
+X020359Y004614D01*
+X020325Y004651D01*
+X020293Y004690D01*
+X020265Y004731D01*
+X020240Y004775D01*
+X020219Y004820D01*
+X020202Y004867D01*
+X020188Y004915D01*
+X020178Y004964D01*
+X020172Y005014D01*
+X020170Y005064D01*
+D11*
+X019495Y004010D02*
+X019298Y003813D01*
+X019101Y004010D01*
+X019101Y003419D01*
+X018850Y003419D02*
+X018654Y003419D01*
+X018752Y003419D02*
+X018752Y004010D01*
+X018850Y004010D02*
+X018654Y004010D01*
+X018421Y004010D02*
+X018125Y004010D01*
+X018027Y003911D01*
+X018027Y003518D01*
+X018125Y003419D01*
+X018421Y003419D01*
+X018421Y004010D01*
+X017776Y004010D02*
+X017579Y004010D01*
+X017678Y004010D02*
+X017678Y003419D01*
+X017776Y003419D02*
+X017579Y003419D01*
+X016702Y003715D02*
+X016308Y003715D01*
+X015413Y004010D02*
+X015413Y003419D01*
+X015118Y003419D01*
+X015019Y003518D01*
+X015019Y003911D01*
+X015118Y004010D01*
+X015413Y004010D01*
+X014768Y004010D02*
+X014768Y003419D01*
+X014375Y003419D02*
+X014375Y004010D01*
+X014571Y003813D01*
+X014768Y004010D01*
+X014124Y004010D02*
+X013730Y003419D01*
+X014124Y003419D02*
+X013730Y004010D01*
+X012835Y004010D02*
+X012835Y003419D01*
+X012539Y003419D01*
+X012441Y003518D01*
+X012441Y003616D01*
+X012539Y003715D01*
+X012835Y003715D01*
+X012835Y004010D02*
+X012539Y004010D01*
+X012441Y003911D01*
+X012441Y003813D01*
+X012539Y003715D01*
+X012190Y003813D02*
+X012190Y003419D01*
+X012190Y003616D02*
+X011993Y003813D01*
+X011895Y003813D01*
+X011653Y003813D02*
+X011555Y003813D01*
+X011555Y003419D01*
+X011653Y003419D02*
+X011456Y003419D01*
+X011223Y003518D02*
+X011223Y003715D01*
+X011125Y003813D01*
+X010830Y003813D01*
+X010830Y004010D02*
+X010830Y003419D01*
+X011125Y003419D01*
+X011223Y003518D01*
+X011555Y004010D02*
+X011555Y004108D01*
+X010579Y003715D02*
+X010579Y003518D01*
+X010480Y003419D01*
+X010185Y003419D01*
+X010185Y003321D02*
+X010185Y003813D01*
+X010480Y003813D01*
+X010579Y003715D01*
+X010185Y003321D02*
+X010283Y003222D01*
+X010382Y003222D01*
+X009934Y003518D02*
+X009934Y003715D01*
+X009836Y003813D01*
+X009639Y003813D01*
+X009541Y003715D01*
+X009541Y003616D01*
+X009934Y003616D01*
+X009934Y003518D02*
+X009836Y003419D01*
+X009639Y003419D01*
+X019495Y003419D02*
+X019495Y004010D01*
+D12*
+X022869Y007639D02*
+X022869Y013789D01*
+D13*
+X018200Y011964D03*
+X017200Y011464D03*
+X017200Y010464D03*
+X018200Y009964D03*
+X018200Y010964D03*
+X017200Y009464D03*
+D14*
+X017350Y016514D02*
+X017350Y017114D01*
+X018350Y017114D02*
+X018350Y016514D01*
+X007350Y016664D02*
+X007350Y017264D01*
+X006350Y017264D02*
+X006350Y016664D01*
+X005350Y016664D02*
+X005350Y017264D01*
+X001800Y012564D02*
+X001200Y012564D01*
+X001200Y011564D02*
+X001800Y011564D01*
+X001800Y010564D02*
+X001200Y010564D01*
+X001200Y009564D02*
+X001800Y009564D01*
+X001800Y008564D02*
+X001200Y008564D01*
+D15*
+X001031Y008136D02*
+X000780Y008136D01*
+X000780Y007978D02*
+X019853Y007978D01*
+X019804Y008027D02*
+X020012Y007818D01*
+X020268Y007671D01*
+X020553Y007594D01*
+X020847Y007594D01*
+X021132Y007671D01*
+X021388Y007818D01*
+X021596Y008027D01*
+X021744Y008282D01*
+X021820Y008567D01*
+X021820Y008862D01*
+X021744Y009147D01*
+X021596Y009402D01*
+X021388Y009611D01*
+X021132Y009758D01*
+X020847Y009834D01*
+X020553Y009834D01*
+X020268Y009758D01*
+X020012Y009611D01*
+X019804Y009402D01*
+X019656Y009147D01*
+X019580Y008862D01*
+X019580Y008567D01*
+X019656Y008282D01*
+X019804Y008027D01*
+X019740Y008136D02*
+X001969Y008136D01*
+X001891Y008104D02*
+X002061Y008174D01*
+X002190Y008304D01*
+X002260Y008473D01*
+X002260Y008656D01*
+X002190Y008825D01*
+X002061Y008954D01*
+X001891Y009024D01*
+X001108Y009024D01*
+X000939Y008954D01*
+X000810Y008825D01*
+X000780Y008752D01*
+X000780Y009376D01*
+X000810Y009304D01*
+X000939Y009174D01*
+X001108Y009104D01*
+X001891Y009104D01*
+X002061Y009174D01*
+X002190Y009304D01*
+X002260Y009473D01*
+X002260Y009656D01*
+X002190Y009825D01*
+X002061Y009954D01*
+X001891Y010024D01*
+X001108Y010024D01*
+X000939Y009954D01*
+X000810Y009825D01*
+X000780Y009752D01*
+X000780Y010376D01*
+X000810Y010304D01*
+X000939Y010174D01*
+X001108Y010104D01*
+X001891Y010104D01*
+X002061Y010174D01*
+X002190Y010304D01*
+X002260Y010473D01*
+X002260Y010656D01*
+X002190Y010825D01*
+X002061Y010954D01*
+X001891Y011024D01*
+X001108Y011024D01*
+X000939Y010954D01*
+X000810Y010825D01*
+X000780Y010752D01*
+X000780Y011376D01*
+X000810Y011304D01*
+X000939Y011174D01*
+X001108Y011104D01*
+X001891Y011104D01*
+X002061Y011174D01*
+X002190Y011304D01*
+X002260Y011473D01*
+X002260Y011656D01*
+X002190Y011825D01*
+X002061Y011954D01*
+X001891Y012024D01*
+X001108Y012024D01*
+X000939Y011954D01*
+X000810Y011825D01*
+X000780Y011752D01*
+X000780Y012376D01*
+X000810Y012304D01*
+X000939Y012174D01*
+X001108Y012104D01*
+X001891Y012104D01*
+X002061Y012174D01*
+X002190Y012304D01*
+X002260Y012473D01*
+X002260Y012656D01*
+X002190Y012825D01*
+X002061Y012954D01*
+X001891Y013024D01*
+X001108Y013024D01*
+X000939Y012954D01*
+X000810Y012825D01*
+X000780Y012752D01*
+X000780Y015356D01*
+X000786Y015335D01*
+X001068Y014922D01*
+X001068Y014922D01*
+X001068Y014922D01*
+X001460Y014609D01*
+X001926Y014426D01*
+X002426Y014389D01*
+X002914Y014500D01*
+X003347Y014751D01*
+X003347Y014751D01*
+X003688Y015118D01*
+X003905Y015569D01*
+X003980Y016064D01*
+X003905Y016560D01*
+X003688Y017011D01*
+X003347Y017378D01*
+X002990Y017584D01*
+X005019Y017584D01*
+X004960Y017525D01*
+X004890Y017356D01*
+X004890Y016573D01*
+X004960Y016404D01*
+X005089Y016274D01*
+X005258Y016204D01*
+X005441Y016204D01*
+X005611Y016274D01*
+X005740Y016404D01*
+X005810Y016573D01*
+X005810Y017356D01*
+X005740Y017525D01*
+X005681Y017584D01*
+X006019Y017584D01*
+X005960Y017525D01*
+X005890Y017356D01*
+X005890Y016573D01*
+X005960Y016404D01*
+X006089Y016274D01*
+X006258Y016204D01*
+X006441Y016204D01*
+X006611Y016274D01*
+X006740Y016404D01*
+X006810Y016573D01*
+X006810Y017356D01*
+X006740Y017525D01*
+X006681Y017584D01*
+X006991Y017584D01*
+X006984Y017577D01*
+X006939Y017516D01*
+X006905Y017449D01*
+X006882Y017377D01*
+X006870Y017302D01*
+X006870Y016984D01*
+X007330Y016984D01*
+X007330Y016944D01*
+X007370Y016944D01*
+X007370Y016184D01*
+X007388Y016184D01*
+X007462Y016196D01*
+X007534Y016219D01*
+X007602Y016254D01*
+X007663Y016298D01*
+X007716Y016352D01*
+X007761Y016413D01*
+X007795Y016480D01*
+X007818Y016552D01*
+X007830Y016627D01*
+X007830Y016944D01*
+X007370Y016944D01*
+X007370Y016984D01*
+X007830Y016984D01*
+X007830Y017302D01*
+X007818Y017377D01*
+X007795Y017449D01*
+X007761Y017516D01*
+X007716Y017577D01*
+X007709Y017584D01*
+X018249Y017584D01*
+X018238Y017583D01*
+X018166Y017559D01*
+X018098Y017525D01*
+X018037Y017480D01*
+X017984Y017427D01*
+X017939Y017366D01*
+X017905Y017299D01*
+X017882Y017227D01*
+X017870Y017152D01*
+X017870Y016834D01*
+X018330Y016834D01*
+X018330Y016794D01*
+X018370Y016794D01*
+X018370Y016034D01*
+X018388Y016034D01*
+X018462Y016046D01*
+X018534Y016069D01*
+X018602Y016104D01*
+X018663Y016148D01*
+X018716Y016202D01*
+X018761Y016263D01*
+X018795Y016330D01*
+X018818Y016402D01*
+X018830Y016477D01*
+X018830Y016794D01*
+X018370Y016794D01*
+X018370Y016834D01*
+X018830Y016834D01*
+X018830Y017152D01*
+X018818Y017227D01*
+X018795Y017299D01*
+X018761Y017366D01*
+X018716Y017427D01*
+X018663Y017480D01*
+X018602Y017525D01*
+X018534Y017559D01*
+X018462Y017583D01*
+X018451Y017584D01*
+X020126Y017584D01*
+X019960Y017519D01*
+X019568Y017207D01*
+X019286Y016793D01*
+X019139Y016315D01*
+X019139Y015814D01*
+X019286Y015335D01*
+X019568Y014922D01*
+X019568Y014922D01*
+X019568Y014922D01*
+X019960Y014609D01*
+X020426Y014426D01*
+X020926Y014389D01*
+X021414Y014500D01*
+X021847Y014751D01*
+X021847Y014751D01*
+X022188Y015118D01*
+X022320Y015392D01*
+X022320Y005737D01*
+X022188Y006011D01*
+X021847Y006378D01*
+X021414Y006628D01*
+X021414Y006628D01*
+X020926Y006740D01*
+X020926Y006740D01*
+X020426Y006702D01*
+X019960Y006519D01*
+X019568Y006207D01*
+X019286Y005793D01*
+X019139Y005315D01*
+X019139Y004814D01*
+X019231Y004514D01*
+X009450Y004514D01*
+X009450Y003928D01*
+X009326Y003804D01*
+X009326Y003544D01*
+X002937Y003544D01*
+X002964Y003550D01*
+X003397Y003801D01*
+X003397Y003801D01*
+X003738Y004168D01*
+X003955Y004619D01*
+X004030Y005114D01*
+X003955Y005610D01*
+X003738Y006061D01*
+X003397Y006428D01*
+X002964Y006678D01*
+X002964Y006678D01*
+X002476Y006790D01*
+X002476Y006790D01*
+X001976Y006752D01*
+X001510Y006569D01*
+X001118Y006257D01*
+X000836Y005843D01*
+X000780Y005660D01*
+X000780Y008376D01*
+X000810Y008304D01*
+X000939Y008174D01*
+X001108Y008104D01*
+X001891Y008104D01*
+X002181Y008295D02*
+X019653Y008295D01*
+X019610Y008453D02*
+X013735Y008453D01*
+X013753Y008461D02*
+X013854Y008561D01*
+X013908Y008693D01*
+X013908Y008836D01*
+X013854Y008967D01*
+X013753Y009068D01*
+X013621Y009122D01*
+X013588Y009122D01*
+X011930Y010780D01*
+X011930Y012938D01*
+X011954Y012961D01*
+X012008Y013093D01*
+X012008Y013236D01*
+X011954Y013367D01*
+X019783Y013367D01*
+X019804Y013402D02*
+X019656Y013147D01*
+X019580Y012862D01*
+X019580Y012567D01*
+X019656Y012282D01*
+X019804Y012027D01*
+X020012Y011818D01*
+X020268Y011671D01*
+X020553Y011594D01*
+X020847Y011594D01*
+X021132Y011671D01*
+X021388Y011818D01*
+X021596Y012027D01*
+X021744Y012282D01*
+X021820Y012567D01*
+X021820Y012862D01*
+X021744Y013147D01*
+X021596Y013402D01*
+X021388Y013611D01*
+X021132Y013758D01*
+X020847Y013834D01*
+X020553Y013834D01*
+X020268Y013758D01*
+X020012Y013611D01*
+X019804Y013402D01*
+X019927Y013525D02*
+X000780Y013525D01*
+X000780Y013367D02*
+X011346Y013367D01*
+X011292Y013236D01*
+X011292Y013093D01*
+X011346Y012961D01*
+X011370Y012938D01*
+X011370Y010609D01*
+X011413Y010506D01*
+X013192Y008726D01*
+X013192Y008693D01*
+X013246Y008561D01*
+X013347Y008461D01*
+X013479Y008406D01*
+X013621Y008406D01*
+X013753Y008461D01*
+X013874Y008612D02*
+X019580Y008612D01*
+X019580Y008770D02*
+X013908Y008770D01*
+X013869Y008929D02*
+X019598Y008929D01*
+X019640Y009087D02*
+X017432Y009087D01*
+X017448Y009094D02*
+X017571Y009217D01*
+X017637Y009377D01*
+X017637Y009551D01*
+X017571Y009712D01*
+X017558Y009724D01*
+X017826Y009724D01*
+X017829Y009717D01*
+X017952Y009594D01*
+X018113Y009527D01*
+X018287Y009527D01*
+X018448Y009594D01*
+X018571Y009717D01*
+X018637Y009877D01*
+X018637Y010051D01*
+X018571Y010212D01*
+X018448Y010335D01*
+X018287Y010401D01*
+X018113Y010401D01*
+X017952Y010335D01*
+X017829Y010212D01*
+X017826Y010204D01*
+X017576Y010204D01*
+X017591Y010225D01*
+X017624Y010289D01*
+X017646Y010357D01*
+X017657Y010428D01*
+X017657Y010456D01*
+X017209Y010456D01*
+X017209Y010473D01*
+X017657Y010473D01*
+X017657Y010500D01*
+X017646Y010571D01*
+X017624Y010640D01*
+X017591Y010704D01*
+X017549Y010762D01*
+X017498Y010813D01*
+X017440Y010855D01*
+X017375Y010888D01*
+X017307Y010910D01*
+X017236Y010921D01*
+X017209Y010921D01*
+X017209Y010473D01*
+X017191Y010473D01*
+X017191Y010456D01*
+X016743Y010456D01*
+X016743Y010428D01*
+X016754Y010357D01*
+X016776Y010289D01*
+X016809Y010225D01*
+X016824Y010204D01*
+X016066Y010204D01*
+X016053Y010218D01*
+X015921Y010272D01*
+X015779Y010272D01*
+X015647Y010218D01*
+X015546Y010117D01*
+X015492Y009986D01*
+X015492Y009843D01*
+X015546Y009711D01*
+X015647Y009611D01*
+X015779Y009556D01*
+X015921Y009556D01*
+X016053Y009611D01*
+X016154Y009711D01*
+X016159Y009724D01*
+X016842Y009724D01*
+X016829Y009712D01*
+X016763Y009551D01*
+X016763Y009377D01*
+X016829Y009217D01*
+X016952Y009094D01*
+X017113Y009027D01*
+X017287Y009027D01*
+X017448Y009094D01*
+X017583Y009246D02*
+X019714Y009246D01*
+X019806Y009404D02*
+X017637Y009404D01*
+X017632Y009563D02*
+X018027Y009563D01*
+X017827Y009721D02*
+X017561Y009721D01*
+X017645Y010355D02*
+X018002Y010355D01*
+X018113Y010527D02*
+X018287Y010527D01*
+X018448Y010594D01*
+X018571Y010717D01*
+X018637Y010877D01*
+X018637Y011051D01*
+X018571Y011212D01*
+X018448Y011335D01*
+X018287Y011401D01*
+X018113Y011401D01*
+X017952Y011335D01*
+X017829Y011212D01*
+X017763Y011051D01*
+X017763Y010877D01*
+X017829Y010717D01*
+X017952Y010594D01*
+X018113Y010527D01*
+X017874Y010672D02*
+X017607Y010672D01*
+X017655Y010514D02*
+X022320Y010514D01*
+X022320Y010672D02*
+X018526Y010672D01*
+X018618Y010831D02*
+X022320Y010831D01*
+X022320Y010989D02*
+X018637Y010989D01*
+X018597Y011148D02*
+X022320Y011148D01*
+X022320Y011306D02*
+X018476Y011306D01*
+X018448Y011594D02*
+X018287Y011527D01*
+X018113Y011527D01*
+X017952Y011594D01*
+X017829Y011717D01*
+X017763Y011877D01*
+X017763Y012051D01*
+X017829Y012212D01*
+X017952Y012335D01*
+X018113Y012401D01*
+X018287Y012401D01*
+X018448Y012335D01*
+X018571Y012212D01*
+X018637Y012051D01*
+X018637Y011877D01*
+X018571Y011717D01*
+X018448Y011594D01*
+X018477Y011623D02*
+X020444Y011623D01*
+X020075Y011782D02*
+X018598Y011782D01*
+X018637Y011940D02*
+X019890Y011940D01*
+X019762Y012099D02*
+X018617Y012099D01*
+X018525Y012257D02*
+X019671Y012257D01*
+X019620Y012416D02*
+X011930Y012416D01*
+X011930Y012574D02*
+X019580Y012574D01*
+X019580Y012733D02*
+X011930Y012733D01*
+X011930Y012891D02*
+X019588Y012891D01*
+X019630Y013050D02*
+X011990Y013050D01*
+X012008Y013208D02*
+X019692Y013208D01*
+X020139Y013684D02*
+X000780Y013684D01*
+X000780Y013842D02*
+X022320Y013842D01*
+X022320Y013684D02*
+X021261Y013684D01*
+X021473Y013525D02*
+X022320Y013525D01*
+X022320Y013367D02*
+X021617Y013367D01*
+X021708Y013208D02*
+X022320Y013208D01*
+X022320Y013050D02*
+X021770Y013050D01*
+X021812Y012891D02*
+X022320Y012891D01*
+X022320Y012733D02*
+X021820Y012733D01*
+X021820Y012574D02*
+X022320Y012574D01*
+X022320Y012416D02*
+X021780Y012416D01*
+X021729Y012257D02*
+X022320Y012257D01*
+X022320Y012099D02*
+X021638Y012099D01*
+X021510Y011940D02*
+X022320Y011940D01*
+X022320Y011782D02*
+X021325Y011782D01*
+X020956Y011623D02*
+X022320Y011623D01*
+X022320Y011465D02*
+X017637Y011465D01*
+X017637Y011551D02*
+X017637Y011377D01*
+X017571Y011217D01*
+X017448Y011094D01*
+X017287Y011027D01*
+X017113Y011027D01*
+X016952Y011094D01*
+X016829Y011217D01*
+X016763Y011377D01*
+X016763Y011551D01*
+X016829Y011712D01*
+X016952Y011835D01*
+X017113Y011901D01*
+X017287Y011901D01*
+X017448Y011835D01*
+X017571Y011712D01*
+X017637Y011551D01*
+X017607Y011623D02*
+X017923Y011623D01*
+X017802Y011782D02*
+X017501Y011782D01*
+X017763Y011940D02*
+X011930Y011940D01*
+X011930Y011782D02*
+X016899Y011782D01*
+X016793Y011623D02*
+X011930Y011623D01*
+X011930Y011465D02*
+X016763Y011465D01*
+X016792Y011306D02*
+X011930Y011306D01*
+X011930Y011148D02*
+X016898Y011148D01*
+X017025Y010888D02*
+X016960Y010855D01*
+X016902Y010813D01*
+X016851Y010762D01*
+X016809Y010704D01*
+X016776Y010640D01*
+X016754Y010571D01*
+X016743Y010500D01*
+X016743Y010473D01*
+X017191Y010473D01*
+X017191Y010921D01*
+X017164Y010921D01*
+X017093Y010910D01*
+X017025Y010888D01*
+X016927Y010831D02*
+X011930Y010831D01*
+X011930Y010989D02*
+X017763Y010989D01*
+X017782Y010831D02*
+X017473Y010831D01*
+X017502Y011148D02*
+X017803Y011148D01*
+X017924Y011306D02*
+X017608Y011306D01*
+X017209Y010831D02*
+X017191Y010831D01*
+X017191Y010672D02*
+X017209Y010672D01*
+X017209Y010514D02*
+X017191Y010514D01*
+X016793Y010672D02*
+X012038Y010672D01*
+X012196Y010514D02*
+X016745Y010514D01*
+X016755Y010355D02*
+X012355Y010355D01*
+X012513Y010197D02*
+X015626Y010197D01*
+X015514Y010038D02*
+X012672Y010038D01*
+X012830Y009880D02*
+X015492Y009880D01*
+X015542Y009721D02*
+X012989Y009721D01*
+X013147Y009563D02*
+X015763Y009563D01*
+X015937Y009563D02*
+X016768Y009563D01*
+X016763Y009404D02*
+X013306Y009404D01*
+X013464Y009246D02*
+X016817Y009246D01*
+X016968Y009087D02*
+X013706Y009087D01*
+X013148Y008770D02*
+X002213Y008770D01*
+X002260Y008612D02*
+X013226Y008612D01*
+X013365Y008453D02*
+X002252Y008453D01*
+X002086Y008929D02*
+X012990Y008929D01*
+X012831Y009087D02*
+X000780Y009087D01*
+X000780Y008929D02*
+X000914Y008929D01*
+X000787Y008770D02*
+X000780Y008770D01*
+X000780Y008295D02*
+X000819Y008295D01*
+X000780Y007819D02*
+X020011Y007819D01*
+X020304Y007661D02*
+X000780Y007661D01*
+X000780Y007502D02*
+X022320Y007502D01*
+X022320Y007344D02*
+X000780Y007344D01*
+X000780Y007185D02*
+X022320Y007185D01*
+X022320Y007027D02*
+X000780Y007027D01*
+X000780Y006868D02*
+X022320Y006868D01*
+X022320Y006710D02*
+X021056Y006710D01*
+X021547Y006551D02*
+X022320Y006551D01*
+X022320Y006393D02*
+X021821Y006393D01*
+X021847Y006378D02*
+X021847Y006378D01*
+X021981Y006234D02*
+X022320Y006234D01*
+X022320Y006076D02*
+X022128Y006076D01*
+X022188Y006011D02*
+X022188Y006011D01*
+X022233Y005917D02*
+X022320Y005917D01*
+X022309Y005759D02*
+X022320Y005759D01*
+X020528Y006710D02*
+X002825Y006710D01*
+X003184Y006551D02*
+X020042Y006551D01*
+X019960Y006519D02*
+X019960Y006519D01*
+X019801Y006393D02*
+X003430Y006393D01*
+X003397Y006428D02*
+X003397Y006428D01*
+X003577Y006234D02*
+X019603Y006234D01*
+X019568Y006207D02*
+X019568Y006207D01*
+X019479Y006076D02*
+X003724Y006076D01*
+X003738Y006061D02*
+X003738Y006061D01*
+X003807Y005917D02*
+X019371Y005917D01*
+X019286Y005793D02*
+X019286Y005793D01*
+X019276Y005759D02*
+X003883Y005759D01*
+X003955Y005610D02*
+X003955Y005610D01*
+X003957Y005600D02*
+X019227Y005600D01*
+X019178Y005442D02*
+X003981Y005442D01*
+X004005Y005283D02*
+X019139Y005283D01*
+X019139Y005125D02*
+X004028Y005125D01*
+X004008Y004966D02*
+X019139Y004966D01*
+X019141Y004808D02*
+X003984Y004808D01*
+X003960Y004649D02*
+X019190Y004649D01*
+X020426Y006702D02*
+X020426Y006702D01*
+X021096Y007661D02*
+X022320Y007661D01*
+X022320Y007819D02*
+X021389Y007819D01*
+X021547Y007978D02*
+X022320Y007978D01*
+X022320Y008136D02*
+X021660Y008136D01*
+X021747Y008295D02*
+X022320Y008295D01*
+X022320Y008453D02*
+X021790Y008453D01*
+X021820Y008612D02*
+X022320Y008612D01*
+X022320Y008770D02*
+X021820Y008770D01*
+X021802Y008929D02*
+X022320Y008929D01*
+X022320Y009087D02*
+X021760Y009087D01*
+X021686Y009246D02*
+X022320Y009246D01*
+X022320Y009404D02*
+X021594Y009404D01*
+X021435Y009563D02*
+X022320Y009563D01*
+X022320Y009721D02*
+X021196Y009721D01*
+X020204Y009721D02*
+X018573Y009721D01*
+X018637Y009880D02*
+X022320Y009880D01*
+X022320Y010038D02*
+X018637Y010038D01*
+X018577Y010197D02*
+X022320Y010197D01*
+X022320Y010355D02*
+X018398Y010355D01*
+X018200Y009964D02*
+X015900Y009964D01*
+X015850Y009914D01*
+X016158Y009721D02*
+X016839Y009721D01*
+X018373Y009563D02*
+X019965Y009563D01*
+X017783Y012099D02*
+X011930Y012099D01*
+X011930Y012257D02*
+X017875Y012257D01*
+X020426Y014426D02*
+X020426Y014426D01*
+X020299Y014476D02*
+X002808Y014476D01*
+X002914Y014500D02*
+X002914Y014500D01*
+X003147Y014635D02*
+X019928Y014635D01*
+X019960Y014609D02*
+X019960Y014609D01*
+X019729Y014793D02*
+X003387Y014793D01*
+X003534Y014952D02*
+X019548Y014952D01*
+X019440Y015110D02*
+X003681Y015110D01*
+X003688Y015118D02*
+X003688Y015118D01*
+X003761Y015269D02*
+X019332Y015269D01*
+X019286Y015335D02*
+X019286Y015335D01*
+X019258Y015427D02*
+X003837Y015427D01*
+X003905Y015569D02*
+X003905Y015569D01*
+X003908Y015586D02*
+X019209Y015586D01*
+X019160Y015744D02*
+X003932Y015744D01*
+X003956Y015903D02*
+X019139Y015903D01*
+X019139Y016061D02*
+X018509Y016061D01*
+X018370Y016061D02*
+X018330Y016061D01*
+X018330Y016034D02*
+X018330Y016794D01*
+X017870Y016794D01*
+X017870Y016477D01*
+X017882Y016402D01*
+X017905Y016330D01*
+X017939Y016263D01*
+X017984Y016202D01*
+X018037Y016148D01*
+X018098Y016104D01*
+X018166Y016069D01*
+X018238Y016046D01*
+X018312Y016034D01*
+X018330Y016034D01*
+X018191Y016061D02*
+X017458Y016061D01*
+X017441Y016054D02*
+X017611Y016124D01*
+X017740Y016254D01*
+X017810Y016423D01*
+X017810Y017206D01*
+X017740Y017375D01*
+X017611Y017504D01*
+X017441Y017574D01*
+X017258Y017574D01*
+X017089Y017504D01*
+X016960Y017375D01*
+X016890Y017206D01*
+X016890Y016423D01*
+X016960Y016254D01*
+X017089Y016124D01*
+X017258Y016054D01*
+X017441Y016054D01*
+X017242Y016061D02*
+X003980Y016061D01*
+X003980Y016064D02*
+X003980Y016064D01*
+X003957Y016220D02*
+X005221Y016220D01*
+X005479Y016220D02*
+X006221Y016220D01*
+X006479Y016220D02*
+X007165Y016220D01*
+X007166Y016219D02*
+X007238Y016196D01*
+X007312Y016184D01*
+X007330Y016184D01*
+X007330Y016944D01*
+X006870Y016944D01*
+X006870Y016627D01*
+X006882Y016552D01*
+X006905Y016480D01*
+X006939Y016413D01*
+X006984Y016352D01*
+X007037Y016298D01*
+X007098Y016254D01*
+X007166Y016219D01*
+X007330Y016220D02*
+X007370Y016220D01*
+X007370Y016378D02*
+X007330Y016378D01*
+X007330Y016537D02*
+X007370Y016537D01*
+X007370Y016695D02*
+X007330Y016695D01*
+X007330Y016854D02*
+X007370Y016854D01*
+X007830Y016854D02*
+X016890Y016854D01*
+X016890Y017012D02*
+X007830Y017012D01*
+X007830Y017171D02*
+X016890Y017171D01*
+X016941Y017329D02*
+X007826Y017329D01*
+X007775Y017488D02*
+X017073Y017488D01*
+X017627Y017488D02*
+X018047Y017488D01*
+X017921Y017329D02*
+X017759Y017329D01*
+X017810Y017171D02*
+X017873Y017171D01*
+X017870Y017012D02*
+X017810Y017012D01*
+X017810Y016854D02*
+X017870Y016854D01*
+X017870Y016695D02*
+X017810Y016695D01*
+X017810Y016537D02*
+X017870Y016537D01*
+X017889Y016378D02*
+X017792Y016378D01*
+X017706Y016220D02*
+X017971Y016220D01*
+X018330Y016220D02*
+X018370Y016220D01*
+X018370Y016378D02*
+X018330Y016378D01*
+X018330Y016537D02*
+X018370Y016537D01*
+X018370Y016695D02*
+X018330Y016695D01*
+X018830Y016695D02*
+X019256Y016695D01*
+X019286Y016793D02*
+X019286Y016793D01*
+X019328Y016854D02*
+X018830Y016854D01*
+X018830Y017012D02*
+X019436Y017012D01*
+X019544Y017171D02*
+X018827Y017171D01*
+X018779Y017329D02*
+X019722Y017329D01*
+X019568Y017207D02*
+X019568Y017207D01*
+X019921Y017488D02*
+X018653Y017488D01*
+X018830Y016537D02*
+X019207Y016537D01*
+X019158Y016378D02*
+X018811Y016378D01*
+X018729Y016220D02*
+X019139Y016220D01*
+X019960Y017519D02*
+X019960Y017519D01*
+X022261Y015269D02*
+X022320Y015269D01*
+X022320Y015110D02*
+X022181Y015110D01*
+X022188Y015118D02*
+X022188Y015118D01*
+X022320Y014952D02*
+X022034Y014952D01*
+X021887Y014793D02*
+X022320Y014793D01*
+X022320Y014635D02*
+X021647Y014635D01*
+X021414Y014500D02*
+X021414Y014500D01*
+X021308Y014476D02*
+X022320Y014476D01*
+X022320Y014318D02*
+X000780Y014318D01*
+X000780Y014476D02*
+X001799Y014476D01*
+X001926Y014426D02*
+X001926Y014426D01*
+X001460Y014609D02*
+X001460Y014609D01*
+X001428Y014635D02*
+X000780Y014635D01*
+X000780Y014793D02*
+X001229Y014793D01*
+X001048Y014952D02*
+X000780Y014952D01*
+X000780Y015110D02*
+X000940Y015110D01*
+X000832Y015269D02*
+X000780Y015269D01*
+X000786Y015335D02*
+X000786Y015335D01*
+X000780Y014159D02*
+X022320Y014159D01*
+X022320Y014001D02*
+X000780Y014001D01*
+X000780Y013208D02*
+X011292Y013208D01*
+X011310Y013050D02*
+X000780Y013050D01*
+X000780Y012891D02*
+X000876Y012891D01*
+X000856Y012257D02*
+X000780Y012257D01*
+X000780Y012099D02*
+X011370Y012099D01*
+X011370Y012257D02*
+X002144Y012257D01*
+X002236Y012416D02*
+X011370Y012416D01*
+X011370Y012574D02*
+X002260Y012574D01*
+X002228Y012733D02*
+X011370Y012733D01*
+X011370Y012891D02*
+X002124Y012891D01*
+X002075Y011940D02*
+X011370Y011940D01*
+X011370Y011782D02*
+X002208Y011782D01*
+X002260Y011623D02*
+X011370Y011623D01*
+X011370Y011465D02*
+X002257Y011465D01*
+X002191Y011306D02*
+X011370Y011306D01*
+X011370Y011148D02*
+X001997Y011148D01*
+X001976Y010989D02*
+X011370Y010989D01*
+X011370Y010831D02*
+X002184Y010831D01*
+X002253Y010672D02*
+X011370Y010672D01*
+X011409Y010514D02*
+X002260Y010514D01*
+X002211Y010355D02*
+X011563Y010355D01*
+X011722Y010197D02*
+X002083Y010197D01*
+X002135Y009880D02*
+X012039Y009880D01*
+X012197Y009721D02*
+X002233Y009721D01*
+X002260Y009563D02*
+X012356Y009563D01*
+X012514Y009404D02*
+X002232Y009404D01*
+X002132Y009246D02*
+X012673Y009246D01*
+X011880Y010038D02*
+X000780Y010038D01*
+X000780Y009880D02*
+X000865Y009880D01*
+X000917Y010197D02*
+X000780Y010197D01*
+X000780Y010355D02*
+X000789Y010355D01*
+X000780Y010831D02*
+X000816Y010831D01*
+X000780Y010989D02*
+X001024Y010989D01*
+X001003Y011148D02*
+X000780Y011148D01*
+X000780Y011306D02*
+X000809Y011306D01*
+X000780Y011782D02*
+X000792Y011782D01*
+X000780Y011940D02*
+X000925Y011940D01*
+X002426Y014389D02*
+X002426Y014389D01*
+X003933Y016378D02*
+X004985Y016378D01*
+X004905Y016537D02*
+X003909Y016537D01*
+X003840Y016695D02*
+X004890Y016695D01*
+X004890Y016854D02*
+X003764Y016854D01*
+X003688Y017011D02*
+X003688Y017011D01*
+X003687Y017012D02*
+X004890Y017012D01*
+X004890Y017171D02*
+X003539Y017171D01*
+X003392Y017329D02*
+X004890Y017329D01*
+X004945Y017488D02*
+X003157Y017488D01*
+X003347Y017378D02*
+X003347Y017378D01*
+X005715Y016378D02*
+X005985Y016378D01*
+X005905Y016537D02*
+X005795Y016537D01*
+X005810Y016695D02*
+X005890Y016695D01*
+X005890Y016854D02*
+X005810Y016854D01*
+X005810Y017012D02*
+X005890Y017012D01*
+X005890Y017171D02*
+X005810Y017171D01*
+X005810Y017329D02*
+X005890Y017329D01*
+X005945Y017488D02*
+X005755Y017488D01*
+X006755Y017488D02*
+X006925Y017488D01*
+X006874Y017329D02*
+X006810Y017329D01*
+X006810Y017171D02*
+X006870Y017171D01*
+X006870Y017012D02*
+X006810Y017012D01*
+X006810Y016854D02*
+X006870Y016854D01*
+X006870Y016695D02*
+X006810Y016695D01*
+X006795Y016537D02*
+X006887Y016537D01*
+X006964Y016378D02*
+X006715Y016378D01*
+X007535Y016220D02*
+X016994Y016220D01*
+X016908Y016378D02*
+X007736Y016378D01*
+X007813Y016537D02*
+X016890Y016537D01*
+X016890Y016695D02*
+X007830Y016695D01*
+X011346Y013367D02*
+X011447Y013468D01*
+X011579Y013522D01*
+X011721Y013522D01*
+X011853Y013468D01*
+X011954Y013367D01*
+X020926Y014389D02*
+X020926Y014389D01*
+X009450Y004491D02*
+X003894Y004491D01*
+X003955Y004619D02*
+X003955Y004619D01*
+X003817Y004332D02*
+X009450Y004332D01*
+X009450Y004174D02*
+X003741Y004174D01*
+X003738Y004168D02*
+X003738Y004168D01*
+X003596Y004015D02*
+X009450Y004015D01*
+X009379Y003857D02*
+X003449Y003857D01*
+X003220Y003698D02*
+X009326Y003698D01*
+X002964Y003550D02*
+X002964Y003550D01*
+X000810Y005759D02*
+X000780Y005759D01*
+X000836Y005843D02*
+X000836Y005843D01*
+X000887Y005917D02*
+X000780Y005917D01*
+X000780Y006076D02*
+X000995Y006076D01*
+X001103Y006234D02*
+X000780Y006234D01*
+X000780Y006393D02*
+X001289Y006393D01*
+X001118Y006257D02*
+X001118Y006257D01*
+X000780Y006551D02*
+X001488Y006551D01*
+X001510Y006569D02*
+X001510Y006569D01*
+X001868Y006710D02*
+X000780Y006710D01*
+X001976Y006752D02*
+X001976Y006752D01*
+X000868Y009246D02*
+X000780Y009246D01*
+D16*
+X004150Y011564D03*
+X006500Y013714D03*
+X010000Y015114D03*
+X011650Y013164D03*
+X013300Y011464D03*
+X013350Y010114D03*
+X013550Y008764D03*
+X013500Y006864D03*
+X012100Y005314D03*
+X009250Y004064D03*
+X015200Y004514D03*
+X015650Y006264D03*
+X015850Y009914D03*
+X014250Y014964D03*
+D17*
+X011650Y013164D02*
+X011650Y010664D01*
+X013550Y008764D01*
+M02*
diff --git a/examples/gerbers/bottom_mask.GBS b/examples/gerbers/bottom_mask.GBS
new file mode 100644
index 0000000..b06654f
--- /dev/null
+++ b/examples/gerbers/bottom_mask.GBS
@@ -0,0 +1,66 @@
+G75*
+%MOIN*%
+%OFA0B0*%
+%FSLAX24Y24*%
+%IPPOS*%
+%LPD*%
+%AMOC8*
+5,1,8,0,0,1.08239X$1,22.5*
+%
+%ADD10C,0.0634*%
+%ADD11C,0.1360*%
+%ADD12C,0.0680*%
+%ADD13C,0.1340*%
+%ADD14C,0.0476*%
+D10*
+X017200Y009464D03*
+X018200Y009964D03*
+X018200Y010964D03*
+X017200Y010464D03*
+X017200Y011464D03*
+X018200Y011964D03*
+D11*
+X020700Y012714D03*
+X020700Y008714D03*
+D12*
+X018350Y016514D02*
+X018350Y017114D01*
+X017350Y017114D02*
+X017350Y016514D01*
+X007350Y016664D02*
+X007350Y017264D01*
+X006350Y017264D02*
+X006350Y016664D01*
+X005350Y016664D02*
+X005350Y017264D01*
+X001800Y012564D02*
+X001200Y012564D01*
+X001200Y011564D02*
+X001800Y011564D01*
+X001800Y010564D02*
+X001200Y010564D01*
+X001200Y009564D02*
+X001800Y009564D01*
+X001800Y008564D02*
+X001200Y008564D01*
+D13*
+X002350Y005114D03*
+X002300Y016064D03*
+X020800Y016064D03*
+X020800Y005064D03*
+D14*
+X015650Y006264D03*
+X013500Y006864D03*
+X012100Y005314D03*
+X009250Y004064D03*
+X015200Y004514D03*
+X013550Y008764D03*
+X013350Y010114D03*
+X013300Y011464D03*
+X011650Y013164D03*
+X010000Y015114D03*
+X006500Y013714D03*
+X004150Y011564D03*
+X014250Y014964D03*
+X015850Y009914D03*
+M02*
diff --git a/examples/gerbers/shld.drd b/examples/gerbers/shld.drd
deleted file mode 100644
index a919b5b..0000000
--- a/examples/gerbers/shld.drd
+++ /dev/null
@@ -1,354 +0,0 @@
-%
-M48
-M72
-T01C0.03200
-T02C0.03543
-T03C0.04000
-%
-T01
-X11212Y16343
-X80212Y16343
-X21212Y16343
-X99212Y22143
-X99212Y12143
-X40212Y16343
-T02
-X10812Y191043
-X70812Y111043
-X130812Y111043
-X80812Y141043
-X110812Y71043
-X160812Y51043
-X20812Y171043
-X30812Y91043
-X50812Y111043
-X50812Y121043
-X20812Y161043
-X90812Y111043
-X70812Y61043
-X40812Y171043
-X50812Y81043
-X160812Y61043
-X40812Y191043
-X30812Y31043
-X90812Y131043
-X10812Y31043
-X150812Y111043
-X170812Y51043
-X110812Y151043
-X10812Y51043
-X150812Y51043
-X140812Y121043
-X170812Y61043
-X30812Y61043
-X70812Y91043
-X70812Y101043
-X160812Y161043
-X40812Y81043
-X220812Y151043
-X180812Y71043
-X30812Y151043
-X50812Y161043
-X150812Y131043
-X40812Y61043
-X130812Y91043
-X90812Y61043
-X80812Y101043
-X30812Y191043
-X130812Y151043
-X60812Y31043
-X50812Y91043
-X40812Y111043
-X220812Y141043
-X30812Y81043
-X140812Y81043
-X60812Y61043
-X210812Y131043
-X160812Y71043
-X90812Y41043
-X120812Y151043
-X10812Y161043
-X80812Y151043
-X50812Y71043
-X160812Y151043
-X110812Y111043
-X30812Y121043
-X10812Y41043
-X20812Y41043
-X40812Y51043
-X10812Y151043
-X200812Y101043
-X70812Y41043
-X120812Y51043
-X40812Y41043
-X80812Y91043
-X170812Y161043
-X100812Y71043
-X40812Y31043
-X30812Y141043
-X180812Y131043
-X10812Y61043
-X120812Y141043
-X200812Y151043
-X90812Y121043
-X50812Y31043
-X170812Y121043
-X170812Y111043
-X60812Y121043
-X40812Y101043
-X120812Y121043
-X100812Y161043
-X10812Y81043
-X130812Y131043
-X60812Y81043
-X200812Y111043
-X140812Y51043
-X150812Y71043
-X160812Y111043
-X120812Y111043
-X130812Y101043
-X20812Y51043
-X20812Y201043
-X90812Y71043
-X190812Y61043
-X170812Y81043
-X70812Y71043
-X50812Y101043
-X150812Y81043
-X60812Y131043
-X190812Y121043
-X170812Y131043
-X130812Y121043
-X20812Y91043
-X70812Y151043
-X70812Y141043
-X180812Y111043
-X10812Y181043
-X40812Y131043
-X80812Y121043
-X120812Y61043
-X160812Y101043
-X90812Y31043
-X10812Y91043
-X80812Y71043
-X100812Y121043
-X100812Y51043
-X160812Y121043
-X40812Y71043
-X50812Y51043
-X180812Y81043
-X90812Y51043
-X60812Y71043
-X40812Y161043
-X190812Y141043
-X20812Y31043
-X100812Y151043
-X200812Y141043
-X180812Y151043
-X60812Y51043
-X120812Y131043
-X150812Y141043
-X180812Y51043
-X150812Y101043
-X170812Y101043
-X150812Y151043
-X30812Y111043
-X90812Y151043
-X80812Y131043
-X170812Y151043
-X80812Y51043
-X10812Y201043
-X60812Y151043
-X140812Y111043
-X100812Y91043
-X90812Y161043
-X130812Y81043
-X190812Y111043
-X140812Y101043
-X20812Y71043
-X150812Y121043
-X90812Y141043
-X60812Y111043
-X110812Y121043
-X30812Y71043
-X30812Y51043
-X210812Y141043
-X50812Y61043
-X140812Y131043
-X30812Y201043
-X190812Y101043
-X70812Y81043
-X20812Y121043
-X20812Y191043
-X80812Y161043
-X80812Y81043
-X20812Y151043
-X40812Y121043
-X80812Y31043
-X80812Y111043
-X190812Y151043
-X30812Y181043
-X60812Y91043
-X110812Y61043
-X180812Y61043
-X10812Y141043
-X50812Y131043
-X130812Y51043
-X50812Y151043
-X110812Y51043
-X70812Y131043
-X60812Y41043
-X200812Y161043
-X80812Y61043
-X140812Y161043
-X190812Y81043
-X20812Y141043
-X70812Y161043
-X140812Y151043
-X20812Y61043
-X20812Y81043
-X100812Y131043
-X200812Y131043
-X140812Y141043
-X40812Y151043
-X40812Y91043
-X60812Y101043
-X160812Y81043
-X130812Y71043
-X30812Y41043
-X10812Y71043
-X180812Y141043
-X170812Y141043
-X180812Y91043
-X180812Y101043
-X150812Y61043
-X120812Y161043
-X90812Y101043
-X200812Y121043
-X190812Y91043
-X160812Y141043
-X130812Y161043
-X20812Y101043
-X90812Y81043
-X190812Y161043
-X30812Y171043
-X40812Y181043
-X70812Y51043
-X110812Y101043
-X60812Y141043
-X120812Y101043
-X30812Y161043
-X100812Y141043
-X220812Y131043
-X50812Y141043
-X30812Y101043
-X60812Y161043
-X150812Y161043
-X20812Y131043
-X150812Y91043
-X100812Y61043
-X10812Y131043
-X30812Y131043
-X100812Y41043
-X140812Y61043
-X210812Y151043
-X70812Y121043
-X100812Y101043
-X180812Y121043
-X40812Y201043
-X190812Y71043
-X10812Y171043
-X110812Y141043
-X130812Y61043
-X110812Y81043
-X80812Y41043
-X50812Y41043
-X110812Y131043
-X190812Y131043
-X130812Y141043
-X140812Y91043
-X20812Y111043
-X140812Y71043
-X170812Y91043
-X120812Y91043
-X190812Y51043
-X120812Y81043
-X160812Y91043
-X100812Y81043
-X120812Y71043
-X10812Y121043
-X170812Y71043
-X110812Y91043
-X100812Y111043
-X110812Y161043
-X70812Y31043
-X90812Y91043
-X40812Y141043
-X20812Y181043
-X210812Y161043
-X180812Y161043
-X160812Y131043
-T03
-X86712Y189043
-X213012Y23043
-X126732Y201114
-X96712Y189043
-X86732Y201114
-X56732Y201114
-X142812Y23443
-X106712Y189043
-X112754Y11450
-X182720Y200950
-X106732Y201114
-X207259Y55639
-X207259Y81239
-X203131Y11150
-X76732Y201114
-X192720Y200950
-X66712Y189043
-X96732Y201114
-X193131Y11150
-X66732Y201114
-X203012Y23043
-X122754Y11450
-X76712Y189043
-X173131Y11150
-X192712Y188843
-X116712Y189043
-X116732Y201114
-X213131Y11150
-X162720Y200950
-X225059Y55639
-X183131Y11150
-X126712Y189043
-X183012Y23043
-X212712Y188843
-X163131Y11150
-X213563Y110846
-X122812Y23443
-X132812Y23443
-X182712Y188843
-X212720Y200950
-X202720Y200950
-X193012Y23043
-X213563Y120846
-X172720Y200950
-X225059Y81239
-X223563Y120846
-X56712Y189043
-X172712Y188843
-X213563Y100846
-X142720Y200950
-X163012Y23043
-X142754Y11450
-X223563Y110846
-X132754Y11450
-X142712Y188843
-X162712Y188843
-X152712Y188843
-X223563Y100846
-X202712Y188843
-X112812Y23443
-X173012Y23043
-X152720Y200950
-M30
diff --git a/examples/pcb_bottom.png b/examples/pcb_bottom.png
new file mode 100644
index 0000000..1e8c369
--- /dev/null
+++ b/examples/pcb_bottom.png
Binary files differ
diff --git a/examples/pcb_example.py b/examples/pcb_example.py
new file mode 100644
index 0000000..5341da0
--- /dev/null
+++ b/examples/pcb_example.py
@@ -0,0 +1,39 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright 2015 Hamilton Kibbe <ham@hamiltonkib.be>
+
+# 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.
+
+"""
+This example demonstrates the use of pcb-tools with cairo to render composite
+images using the PCB interface
+"""
+
+import os
+from gerber import PCB
+from gerber.render import GerberCairoContext, theme
+
+GERBER_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), 'gerbers'))
+
+
+# Create a new drawing context
+ctx = GerberCairoContext()
+
+# Create a new PCB
+pcb = PCB.from_directory(GERBER_FOLDER)
+
+pcb.theme = theme.THEMES['OSH Park']
+ctx.render_layers(pcb.top_layers, os.path.join(os.path.dirname(__file__), 'pcb_top.png'))
+ctx.render_layers(pcb.bottom_layers, os.path.join(os.path.dirname(__file__), 'pcb_bottom.png'))
+
diff --git a/examples/pcb_top.png b/examples/pcb_top.png
new file mode 100644
index 0000000..bf1d687
--- /dev/null
+++ b/examples/pcb_top.png
Binary files differ
diff --git a/gerber/__init__.py b/gerber/__init__.py
index b5a9014..5cfdad7 100644
--- a/gerber/__init__.py
+++ b/gerber/__init__.py
@@ -23,4 +23,5 @@ gerber-tools provides utilities for working with Gerber (RS-274X) and Excellon
files in python.
"""
-from .common import read, loads \ No newline at end of file
+from .common import read, loads
+from .pcb import PCB
diff --git a/gerber/am_statements.py b/gerber/am_statements.py
index 38f4d71..ed9f71e 100644
--- a/gerber/am_statements.py
+++ b/gerber/am_statements.py
@@ -16,7 +16,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from .utils import validate_coordinates, inch, metric
+import math
+from .utils import validate_coordinates, inch, metric, rotate_point
+from .primitives import Circle, Line, Outline, Polygon, Rectangle
+from math import asin
# TODO: Add support for aperture macro variables
@@ -67,6 +70,18 @@ class AMPrimitive(object):
def to_metric(self):
raise NotImplementedError('Subclass must implement `to-metric`')
+
+ def to_primitive(self, units):
+ """
+ Convert to a primitive, as defines the primitives module (for drawing)
+ """
+ raise NotImplementedError('Subclass must implement `to-primitive`')
+
+ @property
+ def _level_polarity(self):
+ if self.exposure == 'off':
+ return 'clear'
+ return 'dark'
def __eq__(self, other):
return self.__dict__ == other.__dict__
@@ -120,6 +135,12 @@ class AMCommentPrimitive(AMPrimitive):
def to_gerber(self, settings=None):
return '0 %s *' % self.comment
+ def to_primitive(self, units):
+ """
+ Returns None - has not primitive representation
+ """
+ return None
+
def __str__(self):
return '<Aperture Macro Comment: %s>' % self.comment
@@ -164,6 +185,10 @@ class AMCirclePrimitive(AMPrimitive):
diameter = float(modifiers[2])
position = (float(modifiers[3]), float(modifiers[4]))
return cls(code, exposure, diameter, position)
+
+ @classmethod
+ def from_primitive(cls, primitive):
+ return cls(1, 'on', primitive.diameter, primitive.position)
def __init__(self, code, exposure, diameter, position):
validate_coordinates(position)
@@ -189,6 +214,9 @@ class AMCirclePrimitive(AMPrimitive):
y = self.position[1])
return '{code},{exposure},{diameter},{x},{y}*'.format(**data)
+ def to_primitive(self, units):
+ return Circle((self.position), self.diameter, units=units, level_polarity=self._level_polarity)
+
class AMVectorLinePrimitive(AMPrimitive):
""" Aperture Macro Vector Line primitive. Code 2 or 20.
@@ -229,6 +257,11 @@ class AMVectorLinePrimitive(AMPrimitive):
------
ValueError, TypeError
"""
+
+ @classmethod
+ def from_primitive(cls, primitive):
+ return cls(2, 'on', primitive.aperture.width, primitive.start, primitive.end, 0)
+
@classmethod
def from_gerber(cls, primitive):
modifiers = primitive.strip(' *').split(',')
@@ -273,6 +306,22 @@ class AMVectorLinePrimitive(AMPrimitive):
endy = self.end[1],
rotation = self.rotation)
return fmtstr.format(**data)
+
+ def to_primitive(self, units):
+
+ line = Line(self.start, self.end, Rectangle(None, self.width, self.width))
+ vertices = line.vertices
+
+ aperture = Circle((0, 0), 0)
+
+ lines = []
+ prev_point = rotate_point(vertices[-1], self.rotation, (0, 0))
+ for point in vertices:
+ cur_point = rotate_point(point, self.rotation, (0, 0))
+
+ lines.append(Line(prev_point, cur_point, aperture))
+
+ return Outline(lines, units=units, level_polarity=self._level_polarity)
class AMOutlinePrimitive(AMPrimitive):
@@ -313,6 +362,19 @@ class AMOutlinePrimitive(AMPrimitive):
------
ValueError, TypeError
"""
+
+ @classmethod
+ def from_primitive(cls, primitive):
+
+ start_point = (round(primitive.primitives[0].start[0], 6), round(primitive.primitives[0].start[1], 6))
+ points = []
+ for prim in primitive.primitives:
+ points.append((round(prim.end[0], 6), round(prim.end[1], 6)))
+
+ rotation = 0.0
+
+ return cls(4, 'on', start_point, points, rotation)
+
@classmethod
def from_gerber(cls, primitive):
modifiers = primitive.strip(' *').split(",")
@@ -355,11 +417,28 @@ class AMOutlinePrimitive(AMPrimitive):
code=self.code,
exposure="1" if self.exposure == "on" else "0",
n_points=len(self.points),
- start_point="%.4g,%.4g" % self.start_point,
- points=",".join(["%.4g,%.4g" % point for point in self.points]),
+ start_point="%.6g,%.6g" % self.start_point,
+ points=",\n".join(["%.6g,%.6g" % point for point in self.points]),
rotation=str(self.rotation)
)
- return "{code},{exposure},{n_points},{start_point},{points},{rotation}*".format(**data)
+ # TODO I removed a closing asterix - not sure if this works for items with multiple statements
+ return "{code},{exposure},{n_points},{start_point},{points},\n{rotation}*".format(**data)
+
+ def to_primitive(self, units):
+
+ lines = []
+ prev_point = rotate_point(self.start_point, self.rotation)
+ for point in self.points:
+ cur_point = rotate_point(point, self.rotation)
+
+ lines.append(Line(prev_point, cur_point, Circle((0,0), 0)))
+
+ prev_point = cur_point
+
+ if lines[0].start != lines[-1].end:
+ raise ValueError('Outline must be closed')
+
+ return Outline(lines, units=units, level_polarity=self._level_polarity)
class AMPolygonPrimitive(AMPrimitive):
@@ -401,6 +480,11 @@ class AMPolygonPrimitive(AMPrimitive):
------
ValueError, TypeError
"""
+
+ @classmethod
+ def from_primitive(cls, primitive):
+ return cls(5, 'on', primitive.sides, primitive.position, primitive.diameter, primitive.rotation)
+
@classmethod
def from_gerber(cls, primitive):
modifiers = primitive.strip(' *').split(",")
@@ -450,6 +534,9 @@ class AMPolygonPrimitive(AMPrimitive):
)
fmt = "{code},{exposure},{vertices},{position},{diameter},{rotation}*"
return fmt.format(**data)
+
+ def to_primitive(self, units):
+ return Polygon(self.position, self.vertices, self.diameter / 2.0, 0, rotation=math.radians(self.rotation), units=units, level_polarity=self._level_polarity)
class AMMoirePrimitive(AMPrimitive):
@@ -562,6 +649,9 @@ class AMMoirePrimitive(AMPrimitive):
fmt = "{code},{position},{diameter},{ring_thickness},{gap},{max_rings},{crosshair_thickness},{crosshair_length},{rotation}*"
return fmt.format(**data)
+ def to_primitive(self, units):
+ raise NotImplementedError()
+
class AMThermalPrimitive(AMPrimitive):
""" Aperture Macro Thermal primitive. Code 7.
@@ -610,9 +700,10 @@ class AMThermalPrimitive(AMPrimitive):
outer_diameter = float(modifiers[3])
inner_diameter= float(modifiers[4])
gap = float(modifiers[5])
- return cls(code, position, outer_diameter, inner_diameter, gap)
+ rotation = float(modifiers[6])
+ return cls(code, position, outer_diameter, inner_diameter, gap, rotation)
- def __init__(self, code, position, outer_diameter, inner_diameter, gap):
+ def __init__(self, code, position, outer_diameter, inner_diameter, gap, rotation):
if code != 7:
raise ValueError('ThermalPrimitive code is 7')
super(AMThermalPrimitive, self).__init__(code, 'on')
@@ -621,6 +712,7 @@ class AMThermalPrimitive(AMPrimitive):
self.outer_diameter = outer_diameter
self.inner_diameter = inner_diameter
self.gap = gap
+ self.rotation = rotation
def to_inch(self):
self.position = tuple([inch(x) for x in self.position])
@@ -642,9 +734,89 @@ class AMThermalPrimitive(AMPrimitive):
outer_diameter = self.outer_diameter,
inner_diameter = self.inner_diameter,
gap = self.gap,
+ rotation = self.rotation
)
- fmt = "{code},{position},{outer_diameter},{inner_diameter},{gap}*"
+ fmt = "{code},{position},{outer_diameter},{inner_diameter},{gap},{rotation}*"
return fmt.format(**data)
+
+ def _approximate_arc_cw(self, start_angle, end_angle, radius, center):
+ """
+ Get an arc as a series of points
+
+ Parameters
+ ----------
+ start_angle : The start angle in radians
+ end_angle : The end angle in radians
+ radius`: Radius of the arc
+ center : The center point of the arc (x, y) tuple
+
+ Returns
+ -------
+ array of point tuples
+ """
+
+ # The total sweep
+ sweep_angle = end_angle - start_angle
+ num_steps = 10
+
+ angle_step = sweep_angle / num_steps
+
+ radius = radius
+ center = center
+
+ points = []
+
+ for i in range(num_steps + 1):
+ current_angle = start_angle + (angle_step * i)
+
+ nextx = (center[0] + math.cos(current_angle) * radius)
+ nexty = (center[1] + math.sin(current_angle) * radius)
+
+ points.append((nextx, nexty))
+
+ return points
+
+ def to_primitive(self, units):
+
+ # We start with calculating the top right section, then duplicate it
+
+ inner_radius = self.inner_diameter / 2.0
+ outer_radius = self.outer_diameter / 2.0
+
+ # Calculate the start angle relative to the horizontal axis
+ inner_offset_angle = asin(self.gap / 2.0 / inner_radius)
+ outer_offset_angle = asin(self.gap / 2.0 / outer_radius)
+
+ rotation_rad = math.radians(self.rotation)
+ inner_start_angle = inner_offset_angle + rotation_rad
+ inner_end_angle = math.pi / 2 - inner_offset_angle + rotation_rad
+
+ outer_start_angle = outer_offset_angle + rotation_rad
+ outer_end_angle = math.pi / 2 - outer_offset_angle + rotation_rad
+
+ outlines = []
+ aperture = Circle((0, 0), 0)
+
+ points = (self._approximate_arc_cw(inner_start_angle, inner_end_angle, inner_radius, self.position)
+ + list(reversed(self._approximate_arc_cw(outer_start_angle, outer_end_angle, outer_radius, self.position))))
+ # Add in the last point since outlines should be closed
+ points.append(points[0])
+
+ # There are four outlines at rotated sections
+ for rotation in [0, 90.0, 180.0, 270.0]:
+
+ lines = []
+ prev_point = rotate_point(points[0], rotation, self.position)
+ for point in points[1:]:
+ cur_point = rotate_point(point, rotation, self.position)
+
+ lines.append(Line(prev_point, cur_point, aperture))
+
+ prev_point = cur_point
+
+ outlines.append(Outline(lines, units=units, level_polarity=self._level_polarity))
+
+ return outlines
class AMCenterLinePrimitive(AMPrimitive):
@@ -685,6 +857,14 @@ class AMCenterLinePrimitive(AMPrimitive):
------
ValueError, TypeError
"""
+
+ @classmethod
+ def from_primitive(cls, primitive):
+ width = primitive.width
+ height = primitive.height
+ center = primitive.position
+ rotation = math.degrees(primitive.rotation)
+ return cls(21, 'on', width, height, center, rotation)
@classmethod
def from_gerber(cls, primitive):
@@ -729,6 +909,30 @@ class AMCenterLinePrimitive(AMPrimitive):
fmt = "{code},{exposure},{width},{height},{center},{rotation}*"
return fmt.format(**data)
+ def to_primitive(self, units):
+
+ x = self.center[0]
+ y = self.center[1]
+ half_width = self.width / 2.0
+ half_height = self.height / 2.0
+
+ points = []
+ points.append((x - half_width, y + half_height))
+ points.append((x - half_width, y - half_height))
+ points.append((x + half_width, y - half_height))
+ points.append((x + half_width, y + half_height))
+
+ aperture = Circle((0, 0), 0)
+
+ lines = []
+ prev_point = rotate_point(points[3], self.rotation, self.center)
+ for point in points:
+ cur_point = rotate_point(point, self.rotation, self.center)
+
+ lines.append(Line(prev_point, cur_point, aperture))
+
+ return Outline(lines, units=units, level_polarity=self._level_polarity)
+
class AMLowerLeftLinePrimitive(AMPrimitive):
""" Aperture Macro Lower Left Line primitive. Code 22.
@@ -811,6 +1015,9 @@ class AMLowerLeftLinePrimitive(AMPrimitive):
fmt = "{code},{exposure},{width},{height},{lower_left},{rotation}*"
return fmt.format(**data)
+ def to_primitive(self, units):
+ raise NotImplementedError()
+
class AMUnsupportPrimitive(AMPrimitive):
@classmethod
@@ -829,3 +1036,6 @@ class AMUnsupportPrimitive(AMPrimitive):
def to_gerber(self, settings=None):
return self.primitive
+
+ def to_primitive(self, units):
+ return None \ No newline at end of file
diff --git a/gerber/cam.py b/gerber/cam.py
index c567055..28918cb 100644
--- a/gerber/cam.py
+++ b/gerber/cam.py
@@ -72,9 +72,10 @@ class FileSettings(object):
elif zero_suppression is not None:
if zero_suppression not in ['leading', 'trailing']:
- raise ValueError('Zero suppression must be either leading or \
- trailling')
- self.zero_suppression = zero_suppression
+ # This is a common problem in Eagle files, so just suppress it
+ self.zero_suppression = 'leading'
+ else:
+ self.zero_suppression = zero_suppression
elif zeros is not None:
if zeros not in ['leading', 'trailing']:
@@ -165,6 +166,10 @@ class FileSettings(object):
self.zero_suppression == other.zero_suppression and
self.format == other.format and
self.angle_units == other.angle_units)
+
+ def __str__(self):
+ return ('<Settings: %s %s %s %s %s>' %
+ (self.units, self.notation, self.zero_suppression, self.format, self.angle_units))
class CamFile(object):
@@ -243,7 +248,7 @@ class CamFile(object):
"""
pass
- def render(self, ctx, filename=None):
+ def render(self, ctx, invert=False, filename=None):
""" Generate image of layer.
Parameters
@@ -254,12 +259,18 @@ class CamFile(object):
filename : string <optional>
If provided, save the rendered image to `filename`
"""
+
ctx.set_bounds(self.bounds)
ctx._paint_background()
- if ctx.invert:
- ctx._paint_inverted_layer()
+ if invert:
+ ctx.invert = True
+ ctx._clear_mask()
for p in self.primitives:
ctx.render(p)
+ if invert:
+ ctx.invert = False
+ ctx._render_mask()
+
if filename is not None:
ctx.dump(filename)
diff --git a/gerber/common.py b/gerber/common.py
index 1659e3b..04b6423 100644
--- a/gerber/common.py
+++ b/gerber/common.py
@@ -17,9 +17,12 @@
from . import rs274x
from . import excellon
+from . import ipc356
+from .exceptions import ParseError
from .utils import detect_file_format
+
def read(filename):
""" Read a gerber or excellon file and return a representative object.
@@ -35,14 +38,17 @@ def read(filename):
ExcellonFile. Returns None if file is not an Excellon or Gerber file.
"""
with open(filename, 'rU') as f:
- data = f.read()
+ data = f.read()
fmt = detect_file_format(data)
if fmt == 'rs274x':
return rs274x.read(filename)
elif fmt == 'excellon':
return excellon.read(filename)
+ elif fmt == 'ipc_d_356':
+ return ipc356.read(filename)
else:
- raise TypeError('Unable to detect file format')
+ raise ParseError('Unable to detect file format')
+
def loads(data):
""" Read gerber or excellon file contents from a string and return a
@@ -59,7 +65,7 @@ def loads(data):
CncFile object representing the file, either GerberFile or
ExcellonFile. Returns None if file is not an Excellon or Gerber file.
"""
-
+
fmt = detect_file_format(data)
if fmt == 'rs274x':
return rs274x.loads(data)
diff --git a/gerber/excellon.py b/gerber/excellon.py
index 708f50b..a0bad4f 100755
--- a/gerber/excellon.py
+++ b/gerber/excellon.py
@@ -26,15 +26,18 @@ This module provides Excellon file classes and parsing utilities
import math
import operator
+from .cam import CamFile, FileSettings
+from .excellon_statements import *
+from .excellon_tool import ExcellonToolDefinitionParser
+from .primitives import Drill, Slot
+from .utils import inch, metric
+
+
try:
from cStringIO import StringIO
except(ImportError):
from io import StringIO
-from .excellon_statements import *
-from .cam import CamFile, FileSettings
-from .primitives import Drill
-from .utils import inch, metric
def read(filename):
@@ -55,13 +58,16 @@ def read(filename):
data = f.read()
settings = FileSettings(**detect_excellon_format(data))
return ExcellonParser(settings).parse(filename)
-
-def loads(data):
+
+def loads(data, settings = None, tools = None):
""" Read data from string and return an ExcellonFile
Parameters
----------
data : string
string containing Excellon file contents
+
+ tools: dict (optional)
+ externally defined tools
Returns
-------
@@ -70,24 +76,85 @@ def loads(data):
"""
# File object should use settings from source file by default.
- settings = FileSettings(**detect_excellon_format(data))
- return ExcellonParser(settings).parse_raw(data)
+ if not settings:
+ settings = FileSettings(**detect_excellon_format(data))
+ return ExcellonParser(settings, tools).parse_raw(data)
class DrillHit(object):
+ """Drill feature that is a single drill hole.
+
+ Attributes
+ ----------
+ tool : ExcellonTool
+ Tool to drill the hole. Defines the size of the hole that is generated.
+ position : tuple(float, float)
+ Center position of the drill.
+
+ """
def __init__(self, tool, position):
self.tool = tool
self.position = position
def to_inch(self):
- if self.tool.units == 'metric':
- self.tool.to_inch()
- self.position = tuple(map(inch, self.position))
+ self.position = tuple(map(inch, self.position))
def to_metric(self):
- if self.tool.units == 'inch':
- self.tool.to_metric()
- self.position = tuple(map(metric, self.position))
+ self.position = tuple(map(metric, self.position))
+
+ @property
+ def bounding_box(self):
+ position = self.position
+ radius = self.tool.diameter / 2.
+
+ min_x = position[0] - radius
+ max_x = position[0] + radius
+ min_y = position[1] - radius
+ max_y = position[1] + radius
+ return ((min_x, max_x), (min_y, max_y))
+
+ def offset(self, x_offset, y_offset):
+ self.position = tuple(map(operator.add, self.position, (x_offset, y_offset)))
+
+ def __str__(self):
+ return 'Hit (%f, %f) {%s}' % (self.position[0], self.position[1], self.tool)
+
+class DrillSlot(object):
+ """
+ A slot is created between two points. The way the slot is created depends on the statement used to create it
+ """
+
+ TYPE_ROUT = 1
+ TYPE_G85 = 2
+
+ def __init__(self, tool, start, end, slot_type):
+ self.tool = tool
+ self.start = start
+ self.end = end
+ self.slot_type = slot_type
+
+ def to_inch(self):
+ self.start = tuple(map(inch, self.start))
+ self.end = tuple(map(inch, self.end))
+
+ def to_metric(self):
+ self.start = tuple(map(metric, self.start))
+ self.end = tuple(map(metric, self.end))
+
+ @property
+ def bounding_box(self):
+ start = self.start
+ end = self.end
+ radius = self.tool.diameter / 2.
+ min_x = min(start[0], end[0]) - radius
+ max_x = max(start[0], end[0]) + radius
+ min_y = min(start[1], end[1]) - radius
+ max_y = max(start[1], end[1]) + radius
+ return ((min_x, max_x), (min_y, max_y))
+
+ def offset(self, x_offset, y_offset):
+ self.start = tuple(map(operator.add, self.start, (x_offset, y_offset)))
+ self.end = tuple(map(operator.add, self.end, (x_offset, y_offset)))
class ExcellonFile(CamFile):
@@ -126,7 +193,17 @@ class ExcellonFile(CamFile):
@property
def primitives(self):
- return [Drill(hit.position, hit.tool.diameter,units=self.settings.units) for hit in self.hits]
+
+ primitives = []
+ for hit in self.hits:
+ if isinstance(hit, DrillHit):
+ primitives.append(Drill(hit.position, hit.tool.diameter, hit, units=self.settings.units))
+ elif isinstance(hit, DrillSlot):
+ primitives.append(Slot(hit.start, hit.end, hit.tool.diameter, hit, units=self.settings.units))
+ else:
+ raise ValueError('Unknown hit type')
+
+ return primitives
@property
@@ -134,12 +211,11 @@ class ExcellonFile(CamFile):
xmin = ymin = 100000000000
xmax = ymax = -100000000000
for hit in self.hits:
- radius = hit.tool.diameter / 2.
- x, y = hit.position
- xmin = min(x - radius, xmin)
- xmax = max(x + radius, xmax)
- ymin = min(y - radius, ymin)
- ymax = max(y + radius, ymax)
+ bbox = hit.bounding_box
+ xmin = min(bbox[0][0], xmin)
+ xmax = max(bbox[0][1], xmax)
+ ymin = min(bbox[1][0], ymin)
+ ymax = max(bbox[1][1], ymax)
return ((xmin, xmax), (ymin, ymax))
def report(self, filename=None):
@@ -170,21 +246,12 @@ class ExcellonFile(CamFile):
def write(self, filename=None):
filename = filename if filename is not None else self.filename
with open(filename, 'w') as f:
-
- # Copy the header verbatim
- for statement in self.statements:
- if not isinstance(statement, ToolSelectionStmt):
- f.write(statement.to_excellon(self.settings) + '\n')
- else:
- break
-
- # Write out coordinates for drill hits by tool
- for tool in iter(self.tools.values()):
- f.write(ToolSelectionStmt(tool.number).to_excellon(self.settings) + '\n')
- for hit in self.hits:
- if hit.tool.number == tool.number:
- f.write(CoordinateStmt(*hit.position).to_excellon(self.settings) + '\n')
- f.write(EndOfProgramStmt().to_excellon() + '\n')
+ self.writes(f)
+
+ def writes(self, f):
+ # Copy the header verbatim
+ for statement in self.statements:
+ f.write(statement.to_excellon(self.settings) + '\n')
def to_inch(self):
"""
@@ -199,7 +266,7 @@ class ExcellonFile(CamFile):
for primitive in self.primitives:
primitive.to_inch()
for hit in self.hits:
- hit.position = tuple(map(inch, hit,position))
+ hit.to_inch()
def to_metric(self):
@@ -214,7 +281,7 @@ class ExcellonFile(CamFile):
for primitive in self.primitives:
primitive.to_metric()
for hit in self.hits:
- hit.position = tuple(map(metric, hit.position))
+ hit.to_metric()
def offset(self, x_offset=0, y_offset=0):
for statement in self.statements:
@@ -222,7 +289,7 @@ class ExcellonFile(CamFile):
for primitive in self.primitives:
primitive.offset(x_offset, y_offset)
for hit in self. hits:
- hit.position = tuple(map(operator.add, hit.position, (x_offset, y_offset)))
+ hit.offset(x_offset, y_offset)
def path_length(self, tool_number=None):
""" Return the path length for a given tool
@@ -282,7 +349,7 @@ class ExcellonParser(object):
settings : FileSettings or dict-like
Excellon file settings to use when interpreting the excellon file.
"""
- def __init__(self, settings=None):
+ def __init__(self, settings=None, ext_tools=None):
self.notation = 'absolute'
self.units = 'inch'
self.zeros = 'leading'
@@ -290,9 +357,14 @@ class ExcellonParser(object):
self.state = 'INIT'
self.statements = []
self.tools = {}
+ self.ext_tools = ext_tools or {}
+ self.comment_tools = {}
self.hits = []
self.active_tool = None
self.pos = [0., 0.]
+ self.drill_down = False
+ # Default for lated is None, which means we don't know
+ self.plated = ExcellonTool.PLATED_UNKNOWN
if settings is not None:
self.units = settings.units
self.zeros = settings.zeros
@@ -332,13 +404,13 @@ class ExcellonParser(object):
def parse_raw(self, data, filename=None):
for line in StringIO(data):
- self._parse(line.strip())
+ self._parse_line(line.strip())
for stmt in self.statements:
stmt.units = self.units
return ExcellonFile(self.statements, self.tools, self.hits,
self._settings(), filename)
- def _parse(self, line):
+ def _parse_line(self, line):
# skip empty lines
if not line.strip():
return
@@ -352,6 +424,24 @@ class ExcellonParser(object):
detected_format = tuple([int(x) for x in comment_stmt.comment.split('=')[1].split(":")])
if detected_format:
self.format = detected_format
+
+ if "TYPE=PLATED" in comment_stmt.comment:
+ self.plated = ExcellonTool.PLATED_YES
+
+ if "TYPE=NON_PLATED" in comment_stmt.comment:
+ self.plated = ExcellonTool.PLATED_NO
+
+ if "HEADER:" in comment_stmt.comment:
+ self.state = "HEADER"
+
+ if " Holesize " in comment_stmt.comment:
+ self.state = "HEADER"
+
+ # Parse this as a hole definition
+ tools = ExcellonToolDefinitionParser(self._settings()).parse_raw(comment_stmt.comment)
+ if len(tools) == 1:
+ tool = tools[tools.keys()[0]]
+ self._add_comment_tool(tool)
elif line[:3] == 'M48':
self.statements.append(HeaderBeginStmt())
@@ -363,6 +453,16 @@ class ExcellonParser(object):
self.state = 'DRILL'
elif self.state == 'INIT':
self.state = 'HEADER'
+
+ elif line[:3] == 'M00' and self.state == 'DRILL':
+ if self.active_tool:
+ cur_tool_number = self.active_tool.number
+ next_tool = self._get_tool(cur_tool_number + 1)
+
+ self.statements.append(NextToolSelectionStmt(self.active_tool, next_tool))
+ self.active_tool = next_tool
+ else:
+ raise Exception('Invalid state exception')
elif line[:3] == 'M95':
self.statements.append(HeaderEndStmt())
@@ -371,12 +471,15 @@ class ExcellonParser(object):
elif line[:3] == 'M15':
self.statements.append(ZAxisRoutPositionStmt())
+ self.drill_down = True
elif line[:3] == 'M16':
self.statements.append(RetractWithClampingStmt())
+ self.drill_down = False
elif line[:3] == 'M17':
self.statements.append(RetractWithoutClampingStmt())
+ self.drill_down = False
elif line[:3] == 'M30':
stmt = EndOfProgramStmt.from_excellon(line, self._settings())
@@ -409,6 +512,9 @@ class ExcellonParser(object):
stmt = CoordinateStmt.from_excellon(line[3:], self._settings())
stmt.mode = self.state
+
+ # The start position is where we were before the rout command
+ start = (self.pos[0], self.pos[1])
x = stmt.x
y = stmt.y
@@ -423,15 +529,28 @@ class ExcellonParser(object):
self.pos[0] += x
if y is not None:
self.pos[1] += y
-
+
+ # Our ending position
+ end = (self.pos[0], self.pos[1])
+
+ if self.drill_down:
+ if not self.active_tool:
+ self.active_tool = self._get_tool(1)
+
+ self.hits.append(DrillSlot(self.active_tool, start, end, DrillSlot.TYPE_ROUT))
+ self.active_tool._hit()
+
elif line[:3] == 'G05':
self.statements.append(DrillModeStmt())
+ self.drill_down = False
self.state = 'DRILL'
elif 'INCH' in line or 'METRIC' in line:
stmt = UnitStmt.from_excellon(line)
self.units = stmt.units
self.zeros = stmt.zeros
+ if stmt.format:
+ self.format = stmt.format
self.statements.append(stmt)
elif line[:3] == 'M71' or line [:3] == 'M72':
@@ -451,6 +570,7 @@ class ExcellonParser(object):
elif line[:4] == 'FMAT':
stmt = FormatStmt.from_excellon(line)
self.statements.append(stmt)
+ self.format = stmt.format_tuple
elif line[:3] == 'G40':
self.statements.append(CutterCompensationOffStmt())
@@ -470,18 +590,24 @@ class ExcellonParser(object):
self.statements.append(infeed_rate_stmt)
elif line[0] == 'T' and self.state == 'HEADER':
- tool = ExcellonTool.from_excellon(line, self._settings())
- self.tools[tool.number] = tool
- self.statements.append(tool)
+ if not ',OFF' in line and not ',ON' in line:
+ tool = ExcellonTool.from_excellon(line, self._settings(), None, self.plated)
+ self._merge_properties(tool)
+ self.tools[tool.number] = tool
+ self.statements.append(tool)
+ else:
+ self.statements.append(UnknownStmt.from_excellon(line))
elif line[0] == 'T' and self.state != 'HEADER':
stmt = ToolSelectionStmt.from_excellon(line)
self.statements.append(stmt)
-
+
# T0 is used as END marker, just ignore
if stmt.tool != 0:
- # FIXME: for weird files with no tools defined, original calc from gerbv
- if stmt.tool not in self.tools:
+ tool = self._get_tool(stmt.tool)
+
+ if not tool:
+ # FIXME: for weird files with no tools defined, original calc from gerbv
if self._settings().units == "inch":
diameter = (16 + 8 * stmt.tool) / 1000.0;
else:
@@ -496,7 +622,7 @@ class ExcellonParser(object):
self.statements.insert(i, tool)
break
- self.active_tool = self.tools[stmt.tool]
+ self.active_tool = tool
elif line[0] == 'R' and self.state != 'HEADER':
stmt = RepeatHoleStmt.from_excellon(line, self._settings())
@@ -508,30 +634,117 @@ class ExcellonParser(object):
self.active_tool._hit()
elif line[0] in ['X', 'Y']:
- stmt = CoordinateStmt.from_excellon(line, self._settings())
- x = stmt.x
- y = stmt.y
- self.statements.append(stmt)
- if self.notation == 'absolute':
- if x is not None:
- self.pos[0] = x
- if y is not None:
- self.pos[1] = y
+ if 'G85' in line:
+ stmt = SlotStmt.from_excellon(line, self._settings())
+
+ # I don't know if this is actually correct, but it makes sense that this is where the tool would end
+ x = stmt.x_end
+ y = stmt.y_end
+
+ self.statements.append(stmt)
+
+ if self.notation == 'absolute':
+ if x is not None:
+ self.pos[0] = x
+ if y is not None:
+ self.pos[1] = y
+ else:
+ if x is not None:
+ self.pos[0] += x
+ if y is not None:
+ self.pos[1] += y
+
+ if self.state == 'DRILL' or self.state == 'HEADER':
+ if not self.active_tool:
+ self.active_tool = self._get_tool(1)
+
+ self.hits.append(DrillSlot(self.active_tool, (stmt.x_start, stmt.y_start), (stmt.x_end, stmt.y_end), DrillSlot.TYPE_G85))
+ self.active_tool._hit()
else:
- if x is not None:
- self.pos[0] += x
- if y is not None:
- self.pos[1] += y
- if self.state == 'DRILL':
- self.hits.append(DrillHit(self.active_tool, tuple(self.pos)))
- self.active_tool._hit()
+ stmt = CoordinateStmt.from_excellon(line, self._settings())
+
+ # We need this in case we are in rout mode
+ start = (self.pos[0], self.pos[1])
+
+ x = stmt.x
+ y = stmt.y
+ self.statements.append(stmt)
+ if self.notation == 'absolute':
+ if x is not None:
+ self.pos[0] = x
+ if y is not None:
+ self.pos[1] = y
+ else:
+ if x is not None:
+ self.pos[0] += x
+ if y is not None:
+ self.pos[1] += y
+
+ if self.state == 'LINEAR' and self.drill_down:
+ if not self.active_tool:
+ self.active_tool = self._get_tool(1)
+
+ self.hits.append(DrillSlot(self.active_tool, start, tuple(self.pos), DrillSlot.TYPE_ROUT))
+
+ elif self.state == 'DRILL' or self.state == 'HEADER':
+ # Yes, drills in the header doesn't follow the specification, but it there are many
+ # files like this
+ if not self.active_tool:
+ self.active_tool = self._get_tool(1)
+
+ self.hits.append(DrillHit(self.active_tool, tuple(self.pos)))
+ self.active_tool._hit()
+
else:
self.statements.append(UnknownStmt.from_excellon(line))
def _settings(self):
return FileSettings(units=self.units, format=self.format,
zeros=self.zeros, notation=self.notation)
-
+
+ def _add_comment_tool(self, tool):
+ """
+ Add a tool that was defined in the comments to this file.
+
+ If we have already found this tool, then we will merge this comment tool definition into
+ the information for the tool
+ """
+
+ existing = self.tools.get(tool.number)
+ if existing and existing.plated == None:
+ existing.plated = tool.plated
+
+ self.comment_tools[tool.number] = tool
+
+ def _merge_properties(self, tool):
+ """
+ When we have externally defined tools, merge the properties of that tool into this one
+
+ For now, this is only plated
+ """
+
+ if tool.plated == ExcellonTool.PLATED_UNKNOWN:
+ ext_tool = self.ext_tools.get(tool.number)
+
+ if ext_tool:
+ tool.plated = ext_tool.plated
+
+ def _get_tool(self, toolid):
+
+ tool = self.tools.get(toolid)
+ if not tool:
+ tool = self.comment_tools.get(toolid)
+ if tool:
+ tool.settings = self._settings()
+ self.tools[toolid] = tool
+
+ if not tool:
+ tool = self.ext_tools.get(toolid)
+ if tool:
+ tool.settings = self._settings()
+ self.tools[toolid] = tool
+
+ return tool
def detect_excellon_format(data=None, filename=None):
""" Detect excellon file decimal format and zero-suppression settings.
@@ -634,7 +847,11 @@ def _layer_size_score(size, hole_count, hole_area):
Lower is better.
"""
board_area = size[0] * size[1]
+ if board_area == 0:
+ return 0
+
hole_percentage = hole_area / board_area
hole_score = (hole_percentage - 0.25) ** 2
size_score = (board_area - 8) **2
return hole_score * size_score
+ \ No newline at end of file
diff --git a/gerber/excellon_report/excellon_drr.py b/gerber/excellon_report/excellon_drr.py
new file mode 100644
index 0000000..ab9e857
--- /dev/null
+++ b/gerber/excellon_report/excellon_drr.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# 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 DRR File module
+====================
+**Excellon file classes**
+
+Extra parsers for allegro misc files that can be useful when the Excellon file doesn't contain parameter information
+"""
+
diff --git a/gerber/excellon_settings.py b/gerber/excellon_settings.py
new file mode 100644
index 0000000..4dbe0ca
--- /dev/null
+++ b/gerber/excellon_settings.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+from argparse import PARSER
+
+# Copyright 2015 Garret Fick <garret@ficksworkshop.com>
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Excellon Settings Definition File module
+====================
+**Excellon file classes**
+
+This module provides Excellon file classes and parsing utilities
+"""
+
+import re
+try:
+ from cStringIO import StringIO
+except(ImportError):
+ from io import StringIO
+
+from .cam import FileSettings
+
+def loads(data):
+ """ Read settings file information and return an FileSettings
+ Parameters
+ ----------
+ data : string
+ string containing Excellon settings file contents
+
+ Returns
+ -------
+ file settings: FileSettings
+
+ """
+
+ return ExcellonSettingsParser().parse_raw(data)
+
+def map_coordinates(value):
+ if value == 'ABSOLUTE':
+ return 'absolute'
+ return 'relative'
+
+def map_units(value):
+ if value == 'ENGLISH':
+ return 'inch'
+ return 'metric'
+
+def map_boolean(value):
+ return value == 'YES'
+
+SETTINGS_KEYS = {
+ 'INTEGER-PLACES': (int, 'format-int'),
+ 'DECIMAL-PLACES': (int, 'format-dec'),
+ 'COORDINATES': (map_coordinates, 'notation'),
+ 'OUTPUT-UNITS': (map_units, 'units'),
+ }
+
+class ExcellonSettingsParser(object):
+ """Excellon Settings PARSER
+
+ Parameters
+ ----------
+ None
+ """
+
+ def __init__(self):
+ self.values = {}
+ self.settings = None
+
+ def parse_raw(self, data):
+ for line in StringIO(data):
+ self._parse(line.strip())
+
+ # Create the FileSettings object
+ self.settings = FileSettings(
+ notation=self.values['notation'],
+ units=self.values['units'],
+ format=(self.values['format-int'], self.values['format-dec'])
+ )
+
+ return self.settings
+
+ def _parse(self, line):
+
+ line_items = line.split()
+ if len(line_items) == 2:
+
+ item_type_info = SETTINGS_KEYS.get(line_items[0])
+ if item_type_info:
+ # Convert the value to the expected type
+ item_value = item_type_info[0](line_items[1])
+
+ self.values[item_type_info[1]] = item_value \ No newline at end of file
diff --git a/gerber/excellon_statements.py b/gerber/excellon_statements.py
index 2be7a05..7153c82 100644
--- a/gerber/excellon_statements.py
+++ b/gerber/excellon_statements.py
@@ -36,7 +36,8 @@ __all__ = ['ExcellonTool', 'ToolSelectionStmt', 'CoordinateStmt',
'ExcellonStatement', 'ZAxisRoutPositionStmt',
'RetractWithClampingStmt', 'RetractWithoutClampingStmt',
'CutterCompensationOffStmt', 'CutterCompensationLeftStmt',
- 'CutterCompensationRightStmt', 'ZAxisInfeedRateStmt']
+ 'CutterCompensationRightStmt', 'ZAxisInfeedRateStmt',
+ 'NextToolSelectionStmt', 'SlotStmt']
class ExcellonStatement(object):
@@ -110,9 +111,29 @@ class ExcellonTool(ExcellonStatement):
hit_count : integer
Number of tool hits in excellon file.
"""
+
+ PLATED_UNKNOWN = None
+ PLATED_YES = 'plated'
+ PLATED_NO = 'nonplated'
+ PLATED_OPTIONAL = 'optional'
+
+ @classmethod
+ def from_tool(cls, tool):
+ args = {}
+
+ args['depth_offset'] = tool.depth_offset
+ args['diameter'] = tool.diameter
+ args['feed_rate'] = tool.feed_rate
+ args['max_hit_count'] = tool.max_hit_count
+ args['number'] = tool.number
+ args['plated'] = tool.plated
+ args['retract_rate'] = tool.retract_rate
+ args['rpm'] = tool.rpm
+
+ return cls(None, **args)
@classmethod
- def from_excellon(cls, line, settings, id=None):
+ def from_excellon(cls, line, settings, id=None, plated=None):
""" Create a Tool from an excellon file tool definition line.
Parameters
@@ -149,6 +170,10 @@ class ExcellonTool(ExcellonStatement):
args['number'] = int(val)
elif cmd == 'Z':
args['depth_offset'] = parse_gerber_value(val, nformat, zero_suppression)
+
+ if plated != ExcellonTool.PLATED_UNKNOWN:
+ # Sometimees we can can parse the
+ args['plated'] = plated
return cls(settings, **args)
@classmethod
@@ -181,11 +206,15 @@ class ExcellonTool(ExcellonStatement):
self.diameter = kwargs.get('diameter')
self.max_hit_count = kwargs.get('max_hit_count')
self.depth_offset = kwargs.get('depth_offset')
+ self.plated = kwargs.get('plated')
+
self.hit_count = 0
def to_excellon(self, settings=None):
- fmt = self.settings.format
- zs = self.settings.zero_suppression
+ if self.settings and not settings:
+ settings = self.settings
+ fmt = settings.format
+ zs = settings.zero_suppression
stmt = 'T%02d' % self.number
if self.retract_rate is not None:
stmt += 'B%s' % write_gerber_value(self.retract_rate, fmt, zs)
@@ -219,6 +248,23 @@ class ExcellonTool(ExcellonStatement):
def _hit(self):
self.hit_count += 1
+
+ def equivalent(self, other):
+ """
+ Is the other tool equal to this, ignoring the tool number, and other file specified properties
+ """
+
+ if type(self) != type(other):
+ return False
+
+ return (self.diameter == other.diameter
+ and self.feed_rate == other.feed_rate
+ and self.retract_rate == other.retract_rate
+ and self.rpm == other.rpm
+ and self.depth_offset == other.depth_offset
+ and self.max_hit_count == other.max_hit_count
+ and self.plated == other.plated
+ and self.settings.units == other.settings.units)
def __repr__(self):
unit = 'in.' if self.settings.units == 'inch' else 'mm'
@@ -267,7 +313,28 @@ class ToolSelectionStmt(ExcellonStatement):
if self.compensation_index is not None:
stmt += '%02d' % self.compensation_index
return stmt
-
+
+class NextToolSelectionStmt(ExcellonStatement):
+
+ # TODO the statement exists outside of the context of the file,
+ # so it is imposible to know that it is really the next tool
+
+ def __init__(self, cur_tool, next_tool, **kwargs):
+ """
+ Select the next tool in the wheel.
+ Parameters
+ ----------
+ cur_tool : the tool that is currently selected
+ next_tool : the that that is now selected
+ """
+ super(NextToolSelectionStmt, self).__init__(**kwargs)
+
+ self.cur_tool = cur_tool
+ self.next_tool = next_tool
+
+ def to_excellon(self, settings=None):
+ stmt = 'M00'
+ return stmt
class ZAxisInfeedRateStmt(ExcellonStatement):
@@ -300,6 +367,14 @@ class ZAxisInfeedRateStmt(ExcellonStatement):
class CoordinateStmt(ExcellonStatement):
@classmethod
+ def from_point(cls, point, mode=None):
+
+ stmt = cls(point[0], point[1])
+ if mode:
+ stmt.mode = mode
+ return stmt
+
+ @classmethod
def from_excellon(cls, line, settings, **kwargs):
x_coord = None
y_coord = None
@@ -574,19 +649,35 @@ class EndOfProgramStmt(ExcellonStatement):
self.y += y_offset
class UnitStmt(ExcellonStatement):
+
+ @classmethod
+ def from_settings(cls, settings):
+ """Create the unit statement from the FileSettings"""
+
+ return cls(settings.units, settings.zeros)
@classmethod
def from_excellon(cls, line, **kwargs):
units = 'inch' if 'INCH' in line else 'metric'
zeros = 'leading' if 'LZ' in line else 'trailing'
- return cls(units, zeros, **kwargs)
+ if '0000.00' in line:
+ format = (4, 2)
+ elif '000.000' in line:
+ format = (3, 3)
+ elif '00.0000' in line:
+ format = (2, 4)
+ else:
+ format = None
+ return cls(units, zeros, format, **kwargs)
- def __init__(self, units='inch', zeros='leading', **kwargs):
+ def __init__(self, units='inch', zeros='leading', format=None, **kwargs):
super(UnitStmt, self).__init__(**kwargs)
self.units = units.lower()
self.zeros = zeros
+ self.format = format
def to_excellon(self, settings=None):
+ # TODO This won't export the invalid format statement if it exists
stmt = '%s,%s' % ('INCH' if self.units == 'inch' else 'METRIC',
'LZ' if self.zeros == 'leading'
else 'TZ')
@@ -648,6 +739,10 @@ class FormatStmt(ExcellonStatement):
def to_excellon(self, settings=None):
return 'FMAT,%d' % self.format
+
+ @property
+ def format_tuple(self):
+ return (self.format, 6 - self.format)
class LinkToolStmt(ExcellonStatement):
@@ -742,6 +837,133 @@ class UnknownStmt(ExcellonStatement):
return "<Unknown Statement: %s>" % self.stmt
+class SlotStmt(ExcellonStatement):
+ """
+ G85 statement. Defines a slot created by multiple drills between two specified points.
+
+ Format is two coordinates, split by G85in the middle, for example, XnY0nG85XnYn
+ """
+
+ @classmethod
+ def from_points(cls, start, end):
+
+ return cls(start[0], start[1], end[0], end[1])
+
+ @classmethod
+ def from_excellon(cls, line, settings, **kwargs):
+ # Split the line based on the G85 separator
+ sub_coords = line.split('G85')
+ (x_start_coord, y_start_coord) = SlotStmt.parse_sub_coords(sub_coords[0], settings)
+ (x_end_coord, y_end_coord) = SlotStmt.parse_sub_coords(sub_coords[1], settings)
+
+ # Some files seem to specify only one of the coordinates
+ if x_end_coord == None:
+ x_end_coord = x_start_coord
+ if y_end_coord == None:
+ y_end_coord = y_start_coord
+
+ c = cls(x_start_coord, y_start_coord, x_end_coord, y_end_coord, **kwargs)
+ c.units = settings.units
+ return c
+
+ @staticmethod
+ def parse_sub_coords(line, settings):
+
+ x_coord = None
+ y_coord = None
+
+ if line[0] == 'X':
+ splitline = line.strip('X').split('Y')
+ x_coord = parse_gerber_value(splitline[0], settings.format,
+ settings.zero_suppression)
+ if len(splitline) == 2:
+ y_coord = parse_gerber_value(splitline[1], settings.format,
+ settings.zero_suppression)
+ else:
+ y_coord = parse_gerber_value(line.strip(' Y'), settings.format,
+ settings.zero_suppression)
+
+ return (x_coord, y_coord)
+
+
+ def __init__(self, x_start=None, y_start=None, x_end=None, y_end=None, **kwargs):
+ super(SlotStmt, self).__init__(**kwargs)
+ self.x_start = x_start
+ self.y_start = y_start
+ self.x_end = x_end
+ self.y_end = y_end
+ self.mode = None
+
+ def to_excellon(self, settings):
+ stmt = ''
+
+ if self.x_start is not None:
+ stmt += 'X%s' % write_gerber_value(self.x_start, settings.format,
+ settings.zero_suppression)
+ if self.y_start is not None:
+ stmt += 'Y%s' % write_gerber_value(self.y_start, settings.format,
+ settings.zero_suppression)
+
+ stmt += 'G85'
+
+ if self.x_end is not None:
+ stmt += 'X%s' % write_gerber_value(self.x_end, settings.format,
+ settings.zero_suppression)
+ if self.y_end is not None:
+ stmt += 'Y%s' % write_gerber_value(self.y_end, settings.format,
+ settings.zero_suppression)
+
+ return stmt
+
+ def to_inch(self):
+ if self.units == 'metric':
+ self.units = 'inch'
+ if self.x_start is not None:
+ self.x_start = inch(self.x_start)
+ if self.y_start is not None:
+ self.y_start = inch(self.y_start)
+ if self.x_end is not None:
+ self.x_end = inch(self.x_end)
+ if self.y_end is not None:
+ self.y_end = inch(self.y_end)
+
+ def to_metric(self):
+ if self.units == 'inch':
+ self.units = 'metric'
+ if self.x_start is not None:
+ self.x_start = metric(self.x_start)
+ if self.y_start is not None:
+ self.y_start = metric(self.y_start)
+ if self.x_end is not None:
+ self.x_end = metric(self.x_end)
+ if self.y_end is not None:
+ self.y_end = metric(self.y_end)
+
+ def offset(self, x_offset=0, y_offset=0):
+ if self.x_start is not None:
+ self.x_start += x_offset
+ if self.y_start is not None:
+ self.y_start += y_offset
+ if self.x_end is not None:
+ self.x_end += x_offset
+ if self.y_end is not None:
+ self.y_end += y_offset
+
+ def __str__(self):
+ start_str = ''
+ if self.x_start is not None:
+ start_str += 'X: %g ' % self.x_start
+ if self.y_start is not None:
+ start_str += 'Y: %g ' % self.y_start
+
+ end_str = ''
+ if self.x_end is not None:
+ end_str += 'X: %g ' % self.x_end
+ if self.y_end is not None:
+ end_str += 'Y: %g ' % self.y_end
+
+ return '<Slot Statement: %s to %s>' % (start_str, end_str)
+
def pairwise(iterator):
""" Iterate over list taking two elements at a time.
diff --git a/gerber/excellon_tool.py b/gerber/excellon_tool.py
new file mode 100644
index 0000000..bd76e54
--- /dev/null
+++ b/gerber/excellon_tool.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# 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 Tool 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 .excellon_statements import ExcellonTool
+
+def loads(data, settings=None):
+ """ Read tool file information and return a map of tools
+ Parameters
+ ----------
+ data : string
+ string containing Excellon Tool Definition file contents
+
+ Returns
+ -------
+ dict tool name: ExcellonTool
+
+ """
+ return ExcellonToolDefinitionParser(settings).parse_raw(data)
+
+class ExcellonToolDefinitionParser(object):
+ """ Excellon File Parser
+
+ Parameters
+ ----------
+ None
+ """
+
+ allegro_tool = re.compile(r'(?P<size>[0-9/.]+)\s+(?P<plated>P|N)\s+T(?P<toolid>[0-9]{2})\s+(?P<xtol>[0-9/.]+)\s+(?P<ytol>[0-9/.]+)')
+ allegro_comment_mils = re.compile('Holesize (?P<toolid>[0-9]{1,2})\. = (?P<size>[0-9/.]+) Tolerance = \+(?P<xtol>[0-9/.]+)/-(?P<ytol>[0-9/.]+) (?P<plated>(PLATED)|(NON_PLATED)|(OPTIONAL)) MILS Quantity = [0-9]+')
+ allegro2_comment_mils = re.compile('T(?P<toolid>[0-9]{1,2}) Holesize (?P<toolid2>[0-9]{1,2})\. = (?P<size>[0-9/.]+) Tolerance = \+(?P<xtol>[0-9/.]+)/-(?P<ytol>[0-9/.]+) (?P<plated>(PLATED)|(NON_PLATED)|(OPTIONAL)) MILS Quantity = [0-9]+')
+ allegro_comment_mm = re.compile('Holesize (?P<toolid>[0-9]{1,2})\. = (?P<size>[0-9/.]+) Tolerance = \+(?P<xtol>[0-9/.]+)/-(?P<ytol>[0-9/.]+) (?P<plated>(PLATED)|(NON_PLATED)|(OPTIONAL)) MM Quantity = [0-9]+')
+ allegro2_comment_mm = re.compile('T(?P<toolid>[0-9]{1,2}) Holesize (?P<toolid2>[0-9]{1,2})\. = (?P<size>[0-9/.]+) Tolerance = \+(?P<xtol>[0-9/.]+)/-(?P<ytol>[0-9/.]+) (?P<plated>(PLATED)|(NON_PLATED)|(OPTIONAL)) MM Quantity = [0-9]+')
+
+ matchers = [
+ (allegro_tool, 'mils'),
+ (allegro_comment_mils, 'mils'),
+ (allegro2_comment_mils, 'mils'),
+ (allegro_comment_mm, 'mm'),
+ (allegro2_comment_mm, 'mm'),
+ ]
+
+ def __init__(self, settings=None):
+ self.tools = {}
+ self.settings = settings
+
+ def parse_raw(self, data):
+ for line in StringIO(data):
+ self._parse(line.strip())
+
+ return self.tools
+
+ def _parse(self, line):
+
+ for matcher in ExcellonToolDefinitionParser.matchers:
+ m = matcher[0].match(line)
+ if m:
+ unit = matcher[1]
+
+ size = float(m.group('size'))
+ platedstr = m.group('plated')
+ toolid = int(m.group('toolid'))
+ xtol = float(m.group('xtol'))
+ ytol = float(m.group('ytol'))
+
+ size = self._convert_length(size, unit)
+ xtol = self._convert_length(xtol, unit)
+ ytol = self._convert_length(ytol, unit)
+
+ if platedstr == 'PLATED':
+ plated = ExcellonTool.PLATED_YES
+ elif platedstr == 'NON_PLATED':
+ plated = ExcellonTool.PLATED_NO
+ elif platedstr == 'OPTIONAL':
+ plated = ExcellonTool.PLATED_OPTIONAL
+ else:
+ plated = ExcellonTool.PLATED_UNKNOWN
+
+ tool = ExcellonTool(None, number=toolid, diameter=size, plated=plated)
+
+ self.tools[tool.number] = tool
+
+ break
+
+ def _convert_length(self, value, unit):
+
+ # Convert the value to mm
+ if unit == 'mils':
+ value /= 39.3700787402
+
+ # Now convert to the settings unit
+ if self.settings.units == 'inch':
+ return value / 25.4
+ else:
+ # Already in mm
+ return value
+
+def loads_rep(data, settings=None):
+ """ Read tool report information generated by PADS and return a map of tools
+ Parameters
+ ----------
+ data : string
+ string containing Excellon Report file contents
+
+ Returns
+ -------
+ dict tool name: ExcellonTool
+
+ """
+ return ExcellonReportParser(settings).parse_raw(data)
+
+class ExcellonReportParser(object):
+
+ # We sometimes get files with different encoding, so we can't actually
+ # match the text - the best we can do it detect the table header
+ header = re.compile(r'====\s+====\s+====\s+====\s+=====\s+===')
+
+ def __init__(self, settings=None):
+ self.tools = {}
+ self.settings = settings
+
+ self.found_header = False
+
+ def parse_raw(self, data):
+ for line in StringIO(data):
+ self._parse(line.strip())
+
+ return self.tools
+
+ def _parse(self, line):
+
+ # skip empty lines and "comments"
+ if not line.strip():
+ return
+
+ if not self.found_header:
+ # Try to find the heaader, since we need that to be sure we understand the contents correctly.
+ if ExcellonReportParser.header.match(line):
+ self.found_header = True
+
+ elif line[0] != '=':
+ # Already found the header, so we know to to map the contents
+ parts = line.split()
+ if len(parts) == 6:
+ toolid = int(parts[0])
+ size = float(parts[1])
+ if parts[2] == 'x':
+ plated = ExcellonTool.PLATED_YES
+ elif parts[2] == '-':
+ plated = ExcellonTool.PLATED_NO
+ else:
+ plated = ExcellonTool.PLATED_UNKNOWN
+ feedrate = int(parts[3])
+ speed = int(parts[4])
+ qty = int(parts[5])
+
+ tool = ExcellonTool(None, number=toolid, diameter=size, plated=plated, feed_rate=feedrate, rpm=speed)
+
+ self.tools[tool.number] = tool \ No newline at end of file
diff --git a/gerber/exceptions.py b/gerber/exceptions.py
new file mode 100644
index 0000000..65ae905
--- /dev/null
+++ b/gerber/exceptions.py
@@ -0,0 +1,36 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright 2015 Hamilton Kibbe <ham@hamiltonkib.be>
+
+# 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.
+
+
+class ParseError(Exception):
+ pass
+
+
+class GerberParseError(ParseError):
+ pass
+
+
+class ExcellonParseError(ParseError):
+ pass
+
+
+class ExcellonFileError(IOError):
+ pass
+
+
+class GerberFileError(IOError):
+ pass
diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py
index 9931acf..52e7ac3 100644
--- a/gerber/gerber_statements.py
+++ b/gerber/gerber_statements.py
@@ -26,6 +26,7 @@ from .utils import (parse_gerber_value, write_gerber_value, decimal_string,
from .am_statements import *
from .am_read import read_macro
from .am_eval import eval_macro
+from .primitives import AMGroup
class Statement(object):
@@ -92,6 +93,11 @@ class ParamStmt(Statement):
class FSParamStmt(ParamStmt):
""" FS - Gerber Format Specification Statement
"""
+
+ @classmethod
+ def from_settings(cls, settings):
+
+ return cls('FS', settings.zero_suppression, settings.notation, settings.format)
@classmethod
def from_dict(cls, stmt_dict):
@@ -167,6 +173,10 @@ class FSParamStmt(ParamStmt):
class MOParamStmt(ParamStmt):
""" MO - Gerber Mode (measurement units) Statement.
"""
+
+ @classmethod
+ def from_units(cls, units):
+ return cls(None, units)
@classmethod
def from_dict(cls, stmt_dict):
@@ -225,6 +235,11 @@ class LPParamStmt(ParamStmt):
param = stmt_dict['param']
lp = 'clear' if stmt_dict.get('lp') == 'C' else 'dark'
return cls(param, lp)
+
+ @classmethod
+ def from_region(cls, region):
+ #todo what is the first param?
+ return cls(None, region.level_polarity)
def __init__(self, param, lp):
""" Initialize LPParamStmt class
@@ -257,7 +272,31 @@ class LPParamStmt(ParamStmt):
class ADParamStmt(ParamStmt):
""" AD - Gerber Aperture Definition Statement
"""
-
+
+ @classmethod
+ def rect(cls, dcode, width, height):
+ '''Create a rectangular aperture definition statement'''
+ return cls('AD', dcode, 'R', ([width, height],))
+
+ @classmethod
+ def circle(cls, dcode, diameter):
+ '''Create a circular aperture definition statement'''
+ return cls('AD', dcode, 'C', ([diameter],))
+
+ @classmethod
+ def obround(cls, dcode, width, height):
+ '''Create an obround aperture definition statement'''
+ return cls('AD', dcode, 'O', ([width, height],))
+
+ @classmethod
+ def polygon(cls, dcode, diameter, num_vertices, rotation, hole_diameter):
+ '''Create a polygon aperture definition statement'''
+ return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation, hole_diameter],))
+
+ @classmethod
+ def macro(cls, dcode, name):
+ return cls('AD', dcode, name, '')
+
@classmethod
def from_dict(cls, stmt_dict):
param = stmt_dict.get('param')
@@ -292,7 +331,9 @@ class ADParamStmt(ParamStmt):
ParamStmt.__init__(self, param)
self.d = d
self.shape = shape
- if modifiers:
+ if isinstance(modifiers, tuple):
+ self.modifiers = modifiers
+ elif modifiers:
self.modifiers = [tuple([float(x) for x in m.split("X") if len(x)]) for m in modifiers.split(",") if len(m)]
else:
self.modifiers = [tuple()]
@@ -388,6 +429,8 @@ class AMParamStmt(ParamStmt):
self.primitives.append(AMThermalPrimitive.from_gerber(primitive))
else:
self.primitives.append(AMUnsupportPrimitive.from_gerber(primitive))
+
+ return AMGroup(self.primitives, stmt=self, units=self.units)
def to_inch(self):
if self.units == 'metric':
@@ -814,6 +857,14 @@ class CoordStmt(Statement):
""" Coordinate Data Block
"""
+ OP_DRAW = 'D01'
+ OP_MOVE = 'D02'
+ OP_FLASH = 'D03'
+
+ FUNC_LINEAR = 'G01'
+ FUNC_ARC_CW = 'G02'
+ FUNC_ARC_CCW = 'G03'
+
@classmethod
def from_dict(cls, stmt_dict, settings):
function = stmt_dict['function']
@@ -832,6 +883,32 @@ class CoordStmt(Statement):
if j is not None:
j = parse_gerber_value(stmt_dict.get('j'), settings.format, settings.zero_suppression)
return cls(function, x, y, i, j, op, settings)
+
+ @classmethod
+ def move(cls, func, point):
+ if point:
+ return cls(func, point[0], point[1], None, None, CoordStmt.OP_MOVE, None)
+ # No point specified, so just write the function. This is normally for ending a region (D02*)
+ return cls(func, None, None, None, None, CoordStmt.OP_MOVE, None)
+
+ @classmethod
+ def line(cls, func, point):
+ return cls(func, point[0], point[1], None, None, CoordStmt.OP_DRAW, None)
+
+ @classmethod
+ def mode(cls, func):
+ return cls(func, None, None, None, None, None, None)
+
+ @classmethod
+ def arc(cls, func, point, center):
+ return cls(func, point[0], point[1], center[0], center[1], CoordStmt.OP_DRAW, None)
+
+ @classmethod
+ def flash(cls, point):
+ if point:
+ return cls(None, point[0], point[1], None, None, CoordStmt.OP_FLASH, None)
+ else:
+ return cls(None, None, None, None, None, CoordStmt.OP_FLASH, None)
def __init__(self, function, x, y, i, j, op, settings):
""" Initialize CoordStmt class
@@ -951,6 +1028,16 @@ class CoordStmt(Statement):
coord_str += 'Op: %s' % op
return '<Coordinate Statement: %s>' % coord_str
+
+ @property
+ def only_function(self):
+ """
+ Returns if the statement only set the function.
+ """
+
+ # TODO I would like to refactor this so that the function is handled separately and then
+ # TODO this isn't required
+ return self.function != None and self.op == None and self.x == None and self.y == None and self.i == None and self.j == None
class ApertureStmt(Statement):
@@ -1000,6 +1087,14 @@ class EofStmt(Statement):
class QuadrantModeStmt(Statement):
+
+ @classmethod
+ def single(cls):
+ return cls('single-quadrant')
+
+ @classmethod
+ def multi(cls):
+ return cls('multi-quadrant')
@classmethod
def from_gerber(cls, line):
@@ -1028,6 +1123,14 @@ class RegionModeStmt(Statement):
if 'G36' not in line and 'G37' not in line:
raise ValueError('%s is not a valid region mode statement' % line)
return (cls('on') if line[:3] == 'G36' else cls('off'))
+
+ @classmethod
+ def on(cls):
+ return cls('on')
+
+ @classmethod
+ def off(cls):
+ return cls('off')
def __init__(self, mode):
super(RegionModeStmt, self).__init__('RegionMode')
diff --git a/gerber/ipc356.py b/gerber/ipc356.py
index b8a7ba3..7dadd22 100644
--- a/gerber/ipc356.py
+++ b/gerber/ipc356.py
@@ -27,8 +27,11 @@ _NNAME = re.compile(r'^NNAME\d+$')
# Board Edge Coordinates
_COORD = re.compile(r'X?(?P<x>[\d\s]*)?Y?(?P<y>[\d\s]*)?')
-_SM_FIELD = {'0': 'none', '1': 'primary side', '2': 'secondary side', '3': 'both'}
-
+_SM_FIELD = {
+ '0': 'none',
+ '1': 'primary side',
+ '2': 'secondary side',
+ '3': 'both'}
def read(filename):
@@ -51,17 +54,17 @@ def read(filename):
class IPC_D_356(CamFile):
@classmethod
- def from_file(self, filename):
- p = IPC_D_356_Parser()
- return p.parse(filename)
-
+ def from_file(cls, filename):
+ parser = IPC_D_356_Parser()
+ return parser.parse(filename)
- def __init__(self, statements, settings, primitives=None):
+ def __init__(self, statements, settings, primitives=None, filename=None):
self.statements = statements
self.units = settings.units
self.angle_units = settings.angle_units
self.primitives = [TestRecord((rec.x_coord, rec.y_coord), rec.net_name,
rec.access) for rec in self.test_records]
+ self.filename = filename
@property
def settings(self):
@@ -95,8 +98,6 @@ class IPC_D_356(CamFile):
adjacent_nets.add(record.net)
nets.append(IPC356_Net(net, adjacent_nets))
return nets
-
-
@property
def components(self):
@@ -109,14 +110,12 @@ class IPC_D_356(CamFile):
@property
def outlines(self):
- return [stmt for stmt in self.statements
+ return [stmt for stmt in self.statements
if isinstance(stmt, IPC356_Outline)]
-
-
@property
def adjacency_records(self):
- return [record for record in self.statements
+ return [record for record in self.statements
if isinstance(record, IPC356_Adjacency)]
def render(self, ctx, layer='both', filename=None):
@@ -133,6 +132,7 @@ class IPC_D_356(CamFile):
class IPC_D_356_Parser(object):
# TODO: Allow multi-line statements (e.g. Altium board edge)
+
def __init__(self):
self.units = 'inch'
self.angle_units = 'degrees'
@@ -158,8 +158,7 @@ class IPC_D_356_Parser(object):
oldline = line
self._parse_line(oldline)
- return IPC_D_356(self.statements, self.settings)
-
+ return IPC_D_356(self.statements, self.settings, filename=filename)
def _parse_line(self, line):
if not len(line):
@@ -201,18 +200,23 @@ class IPC_D_356_Parser(object):
elif line[0:3] == '378':
# Conductor
- self.statements.append(IPC356_Conductor.from_line(line, self.settings))
-
+ self.statements.append(
+ IPC356_Conductor.from_line(
+ line, self.settings))
+
elif line[0:3] == '379':
# Net Adjacency
self.statements.append(IPC356_Adjacency.from_line(line))
-
+
elif line[0:3] == '389':
# Outline
- self.statements.append(IPC356_Outline.from_line(line, self.settings))
+ self.statements.append(
+ IPC356_Outline.from_line(
+ line, self.settings))
class IPC356_Comment(object):
+
@classmethod
def from_line(cls, line):
if line[0] != 'C':
@@ -228,6 +232,7 @@ class IPC356_Comment(object):
class IPC356_Parameter(object):
+
@classmethod
def from_line(cls, line):
if line[0] != 'P':
@@ -246,13 +251,14 @@ class IPC356_Parameter(object):
class IPC356_TestRecord(object):
+
@classmethod
def from_line(cls, line, settings):
offset = 0
units = settings.units
angle = settings.angle_units
- feature_types = {'1':'through-hole', '2': 'smt',
- '3':'tooling-feature', '4':'tooling-hole'}
+ feature_types = {'1': 'through-hole', '2': 'smt',
+ '3': 'tooling-feature', '4': 'tooling-hole'}
access = ['both', 'top', 'layer2', 'layer3', 'layer4', 'layer5',
'layer6', 'layer7', 'bottom']
record = {}
@@ -290,21 +296,21 @@ class IPC356_TestRecord(object):
if len(line) >= (43 + offset):
end = len(line) - 1 if len(line) < (50 + offset) else (49 + offset)
coord = int(line[42 + offset:end].strip())
- record['x_coord'] = (coord * 0.0001 if units == 'inch'
+ record['x_coord'] = (coord * 0.0001 if units == 'inch'
else coord * 0.001)
if len(line) >= (51 + offset):
end = len(line) - 1 if len(line) < (58 + offset) else (57 + offset)
coord = int(line[50 + offset:end].strip())
- record['y_coord'] = (coord * 0.0001 if units == 'inch'
- else coord * 0.001)
+ record['y_coord'] = (coord * 0.0001 if units == 'inch'
+ else coord * 0.001)
if len(line) >= (59 + offset):
end = len(line) - 1 if len(line) < (63 + offset) else (62 + offset)
dim = line[58 + offset:end].strip()
if dim != '':
record['rect_x'] = (int(dim) * 0.0001 if units == 'inch'
- else int(dim) * 0.001)
+ else int(dim) * 0.001)
if len(line) >= (64 + offset):
end = len(line) - 1 if len(line) < (68 + offset) else (67 + offset)
@@ -321,7 +327,7 @@ class IPC356_TestRecord(object):
else math.degrees(rot))
if len(line) >= (74 + offset):
- end = 74 + offset
+ end = 74 + offset
sm_info = line[73 + offset:end].strip()
record['soldermask_info'] = _SM_FIELD.get(sm_info)
@@ -337,7 +343,8 @@ class IPC356_TestRecord(object):
def __repr__(self):
return '<IPC-D-356 %s Test Record: %s>' % (self.net_name,
- self.feature_type)
+ self.feature_type)
+
class IPC356_Outline(object):
@@ -365,24 +372,27 @@ class IPC356_Outline(object):
class IPC356_Conductor(object):
+
@classmethod
def from_line(cls, line, settings):
if line[0:3] != '378':
raise ValueError('Not a valid IPC-D-356 Conductor statement')
-
+
scale = 0.0001 if settings.units == 'inch' else 0.001
net_name = line[3:17].strip()
layer = int(line[19:21])
-
+
# Parse out aperture definiting
raw_aperture = line[22:].split()[0]
aperture_dict = _COORD.match(raw_aperture).groupdict()
x = 0
y = 0
- x = int(aperture_dict['x']) * scale if aperture_dict['x'] is not '' else None
- y = int(aperture_dict['y']) * scale if aperture_dict['y'] is not '' else None
+ x = int(aperture_dict['x']) * \
+ scale if aperture_dict['x'] is not '' else None
+ y = int(aperture_dict['y']) * \
+ scale if aperture_dict['y'] is not '' else None
aperture = (x, y)
-
+
# Parse out conductor shapes
shapes = []
coord_list = ' '.join(line[22:].split()[1:])
@@ -399,7 +409,7 @@ class IPC356_Conductor(object):
shape.append((x * scale, y * scale))
shapes.append(tuple(shape))
return cls(net_name, layer, aperture, tuple(shapes))
-
+
def __init__(self, net_name, layer, aperture, shapes):
self.net_name = net_name
self.layer = layer
@@ -417,18 +427,19 @@ class IPC356_Adjacency(object):
if line[0:3] != '379':
raise ValueError('Not a valid IPC-D-356 Conductor statement')
nets = line[3:].strip().split()
-
+
return cls(nets[0], nets[1:])
def __init__(self, net, adjacent_nets):
self.net = net
self.adjacent_nets = adjacent_nets
-
+
def __repr__(self):
return '<IPC-D-356 %s Adjacency Record>' % self.net
class IPC356_EndOfFile(object):
+
def __init__(self):
pass
@@ -437,12 +448,14 @@ class IPC356_EndOfFile(object):
def __repr__(self):
return '<IPC-D-356 EOF>'
-
+
+
class IPC356_Net(object):
+
def __init__(self, name, adjacent_nets):
self.name = name
- self.adjacent_nets = set(adjacent_nets) if adjacent_nets is not None else set()
-
+ self.adjacent_nets = set(
+ adjacent_nets) if adjacent_nets is not None else set()
def __repr__(self):
return '<IPC-D-356 Net %s>' % self.name
diff --git a/gerber/layers.py b/gerber/layers.py
index b10cf16..2b73893 100644
--- a/gerber/layers.py
+++ b/gerber/layers.py
@@ -15,40 +15,212 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-top_copper_ext = ['gtl', 'cmp', 'top', ]
-top_copper_name = ['art01', 'top', 'GTL', 'layer1', 'soldcom', 'comp', ]
-
-bottom_copper_ext = ['gbl', 'sld', 'bot', 'sol', ]
-bottom_coppper_name = ['art02', 'bottom', 'bot', 'GBL', 'layer2', 'soldsold', ]
-
-internal_layer_ext = ['in', 'gt1', 'gt2', 'gt3', 'gt4', 'gt5', 'gt6', 'g1',
- 'g2', 'g3', 'g4', 'g5', 'g6', ]
-internal_layer_name = ['art', 'internal']
-
-power_plane_name = ['pgp', 'pwr', ]
-ground_plane_name = ['gp1', 'gp2', 'gp3', 'gp4', 'gt5', 'gp6', 'gnd',
- 'ground', ]
-
-top_silk_ext = ['gto', 'sst', 'plc', 'ts', 'skt', ]
-top_silk_name = ['sst01', 'topsilk', 'silk', 'slk', 'sst', ]
-
-bottom_silk_ext = ['gbo', 'ssb', 'pls', 'bs', 'skb', ]
-bottom_silk_name = ['sst', 'bsilk', 'ssb', 'botsilk', ]
-
-top_mask_ext = ['gts', 'stc', 'tmk', 'smt', 'tr', ]
-top_mask_name = ['sm01', 'cmask', 'tmask', 'mask1', 'maskcom', 'topmask',
- 'mst', ]
-
-bottom_mask_ext = ['gbs', 'sts', 'bmk', 'smb', 'br', ]
-bottom_mask_name = ['sm', 'bmask', 'mask2', 'masksold', 'botmask', 'msb', ]
-
-top_paste_ext = ['gtp', 'tm']
-top_paste_name = ['sp01', 'toppaste', 'pst']
-
-bottom_paste_ext = ['gbp', 'bm']
-bottom_paste_name = ['sp02', 'botpaste', 'psb']
-
-board_outline_ext = ['gko']
-board_outline_name = ['BDR', 'border', 'out', ]
-
-
+import os
+import re
+from collections import namedtuple
+
+from .excellon import ExcellonFile
+from .ipc356 import IPC_D_356
+
+
+Hint = namedtuple('Hint', 'layer ext name')
+
+hints = [
+ Hint(layer='top',
+ ext=['gtl', 'cmp', 'top', ],
+ name=['art01', 'top', 'GTL', 'layer1', 'soldcom', 'comp', ]
+ ),
+ Hint(layer='bottom',
+ ext=['gbl', 'sld', 'bot', 'sol', 'bottom', ],
+ name=['art02', 'bottom', 'bot', 'GBL', 'layer2', 'soldsold', ]
+ ),
+ Hint(layer='internal',
+ ext=['in', 'gt1', 'gt2', 'gt3', 'gt4', 'gt5', 'gt6', 'g1',
+ 'g2', 'g3', 'g4', 'g5', 'g6', ],
+ name=['art', 'internal', 'pgp', 'pwr', 'gp1', 'gp2', 'gp3', 'gp4',
+ 'gt5', 'gp6', 'gnd', 'ground', ]
+ ),
+ Hint(layer='topsilk',
+ ext=['gto', 'sst', 'plc', 'ts', 'skt', 'topsilk', ],
+ name=['sst01', 'topsilk', 'silk', 'slk', 'sst', ]
+ ),
+ Hint(layer='bottomsilk',
+ ext=['gbo', 'ssb', 'pls', 'bs', 'skb', 'bottomsilk', ],
+ name=['bsilk', 'ssb', 'botsilk', ]
+ ),
+ Hint(layer='topmask',
+ ext=['gts', 'stc', 'tmk', 'smt', 'tr', 'topmask', ],
+ name=['sm01', 'cmask', 'tmask', 'mask1', 'maskcom', 'topmask',
+ 'mst', ]
+ ),
+ Hint(layer='bottommask',
+ ext=['gbs', 'sts', 'bmk', 'smb', 'br', 'bottommask', ],
+ name=['sm', 'bmask', 'mask2', 'masksold', 'botmask', 'msb', ]
+ ),
+ Hint(layer='toppaste',
+ ext=['gtp', 'tm', 'toppaste', ],
+ name=['sp01', 'toppaste', 'pst']
+ ),
+ Hint(layer='bottompaste',
+ ext=['gbp', 'bm', 'bottompaste', ],
+ name=['sp02', 'botpaste', 'psb']
+ ),
+ Hint(layer='outline',
+ ext=['gko', 'outline', ],
+ name=['BDR', 'border', 'out', ]
+ ),
+ Hint(layer='ipc_netlist',
+ ext=['ipc'],
+ name=[],
+ ),
+]
+
+
+def guess_layer_class(filename):
+ try:
+ directory, name = os.path.split(filename)
+ name, ext = os.path.splitext(name.lower())
+ for hint in hints:
+ patterns = [r'^(\w*[.-])*{}([.-]\w*)?$'.format(x) for x in hint.name]
+ if ext[1:] in hint.ext or any(re.findall(p, name, re.IGNORECASE) for p in patterns):
+ return hint.layer
+ except:
+ pass
+ return 'unknown'
+
+
+def sort_layers(layers):
+ layer_order = ['outline', 'toppaste', 'topsilk', 'topmask', 'top',
+ 'internal', 'bottom', 'bottommask', 'bottomsilk',
+ 'bottompaste', 'drill', ]
+ output = []
+ drill_layers = [layer for layer in layers if layer.layer_class == 'drill']
+ internal_layers = list(sorted([layer for layer in layers if layer.layer_class == 'internal']))
+
+ for layer_class in layer_order:
+ if layer_class == 'internal':
+ output += internal_layers
+ elif layer_class == 'drill':
+ output += drill_layers
+ else:
+ for layer in layers:
+ if layer.layer_class == layer_class:
+ output.append(layer)
+ return output
+
+
+class PCBLayer(object):
+ """ Base class for PCB Layers
+
+ Parameters
+ ----------
+ source : CAMFile
+ CAMFile representing the layer
+
+
+ Attributes
+ ----------
+ filename : string
+ Source Filename
+
+ """
+ @classmethod
+ def from_gerber(cls, camfile):
+ filename = camfile.filename
+ layer_class = guess_layer_class(filename)
+ if isinstance(camfile, ExcellonFile) or (layer_class == 'drill'):
+ return DrillLayer.from_gerber(camfile)
+ elif layer_class == 'internal':
+ return InternalLayer.from_gerber(camfile)
+ if isinstance(camfile, IPC_D_356):
+ layer_class = 'ipc_netlist'
+ return cls(filename, layer_class, camfile)
+
+ def __init__(self, filename=None, layer_class=None, cam_source=None, **kwargs):
+ super(PCBLayer, self).__init__(**kwargs)
+ self.filename = filename
+ self.layer_class = layer_class
+ self.cam_source = cam_source
+ self.surface = None
+ self.primitives = cam_source.primitives if cam_source is not None else []
+
+ @property
+ def bounds(self):
+ if self.cam_source is not None:
+ return self.cam_source.bounds
+ else:
+ return None
+
+
+class DrillLayer(PCBLayer):
+ @classmethod
+ def from_gerber(cls, camfile):
+ return cls(camfile.filename, camfile)
+
+ def __init__(self, filename=None, cam_source=None, layers=None, **kwargs):
+ super(DrillLayer, self).__init__(filename, 'drill', cam_source, **kwargs)
+ self.layers = layers if layers is not None else ['top', 'bottom']
+
+
+class InternalLayer(PCBLayer):
+ @classmethod
+ def from_gerber(cls, camfile):
+ filename = camfile.filename
+ try:
+ order = int(re.search(r'\d+', filename).group())
+ except:
+ order = 0
+ return cls(filename, camfile, order)
+
+ def __init__(self, filename=None, cam_source=None, order=0, **kwargs):
+ super(InternalLayer, self).__init__(filename, 'internal', cam_source, **kwargs)
+ self.order = order
+
+ def __eq__(self, other):
+ if not hasattr(other, 'order'):
+ raise TypeError()
+ return (self.order == other.order)
+
+ def __ne__(self, other):
+ if not hasattr(other, 'order'):
+ raise TypeError()
+ return (self.order != other.order)
+
+ def __gt__(self, other):
+ if not hasattr(other, 'order'):
+ raise TypeError()
+ return (self.order > other.order)
+
+ def __lt__(self, other):
+ if not hasattr(other, 'order'):
+ raise TypeError()
+ return (self.order < other.order)
+
+ def __ge__(self, other):
+ if not hasattr(other, 'order'):
+ raise TypeError()
+ return (self.order >= other.order)
+
+ def __le__(self, other):
+ if not hasattr(other, 'order'):
+ raise TypeError()
+ return (self.order <= other.order)
+
+
+class LayerSet(object):
+ def __init__(self, name, layers, **kwargs):
+ super(LayerSet, self).__init__(**kwargs)
+ self.name = name
+ self.layers = list(layers)
+
+ def __len__(self):
+ return len(self.layers)
+
+ def __getitem__(self, item):
+ return self.layers[item]
+
+ def to_render(self):
+ return self.layers
+
+ def apply_theme(self, theme):
+ pass
diff --git a/gerber/ncparam/allegro.py b/gerber/ncparam/allegro.py
new file mode 100644
index 0000000..a67bcf1
--- /dev/null
+++ b/gerber/ncparam/allegro.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# 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.
+
+"""
+Allegro File module
+====================
+**Excellon file classes**
+
+Extra parsers for allegro misc files that can be useful when the Excellon file doesn't contain parameter information
+"""
+
diff --git a/gerber/pcb.py b/gerber/pcb.py
new file mode 100644
index 0000000..0518dd4
--- /dev/null
+++ b/gerber/pcb.py
@@ -0,0 +1,94 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# copyright 2015 Hamilton Kibbe <ham@hamiltonkib.be>
+#
+# 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
+from .exceptions import ParseError
+from .layers import PCBLayer, LayerSet, sort_layers
+from .common import read as gerber_read
+from .utils import listdir
+
+
+class PCB(object):
+
+ @classmethod
+ def from_directory(cls, directory, board_name=None, verbose=False):
+ layers = []
+ names = set()
+ # Validate
+ directory = os.path.abspath(directory)
+ if not os.path.isdir(directory):
+ raise TypeError('{} is not a directory.'.format(directory))
+ # Load gerber files
+ for filename in listdir(directory, True, True):
+ try:
+ camfile = gerber_read(os.path.join(directory, filename))
+ layer = PCBLayer.from_gerber(camfile)
+ layers.append(layer)
+ names.add(os.path.splitext(filename)[0])
+ if verbose:
+ print('Added {} layer <{}>'.format(layer.layer_class, filename))
+ except ParseError:
+ if verbose:
+ print('Skipping file {}'.format(filename))
+ # Try to guess board name
+ if board_name is None:
+ if len(names) == 1:
+ board_name = names.pop()
+ else:
+ board_name = os.path.basename(directory)
+ # Return PCB
+ return cls(layers, board_name)
+
+ def __init__(self, layers, name=None):
+ self.layers = sort_layers(layers)
+ self.name = name
+
+ def __len__(self):
+ return len(self.layers)
+
+ @property
+ def top_layers(self):
+ board_layers = [l for l in reversed(self.layers) if l.layer_class in ('topsilk', 'topmask', 'top')]
+ drill_layers = [l for l in self.drill_layers if 'top' in l.layers]
+ return board_layers + drill_layers
+
+ @property
+ def bottom_layers(self):
+ board_layers = [l for l in self.layers if l.layer_class in ('bottomsilk', 'bottommask', 'bottom')]
+ drill_layers = [l for l in self.drill_layers if 'bottom' in l.layers]
+ return board_layers + drill_layers
+
+ @property
+ def drill_layers(self):
+ return [l for l in self.layers if l.layer_class == 'drill']
+
+ @property
+ def layer_count(self):
+ """ Number of *COPPER* layers
+ """
+ return len([l for l in self.layers if l.layer_class in ('top', 'bottom', 'internal')])
+
+ @property
+ def board_bounds(self):
+ for layer in self.layers:
+ if layer.layer_class == 'outline':
+ return layer.bounds
+ for layer in self.layers:
+ if layer.layer_class == 'top':
+ return layer.bounds
+
diff --git a/gerber/primitives.py b/gerber/primitives.py
index 0ac12af..90b6fb9 100644
--- a/gerber/primitives.py
+++ b/gerber/primitives.py
@@ -17,7 +17,7 @@
import math
from operator import add, sub
-from .utils import validate_coordinates, inch, metric
+from .utils import validate_coordinates, inch, metric, rotate_point, nearly_equal
class Primitive(object):
@@ -43,7 +43,15 @@ class Primitive(object):
self._to_convert = list()
self.id = id
self.statement_id = statement_id
+
+ @property
+ def flashed(self):
+ '''Is this a flashed primitive'''
+
+ raise NotImplementedError('Is flashed must be '
+ 'implemented in subclass')
+ @property
def bounding_box(self):
""" Calculate bounding box
@@ -53,6 +61,17 @@ class Primitive(object):
"""
raise NotImplementedError('Bounding box calculation must be '
'implemented in subclass')
+
+ @property
+ def bounding_box_no_aperture(self):
+ """ Calculate bouxing box without considering the aperture
+
+ for most objects, this is the same as the bounding_box, but is different for
+ Lines and Arcs (which are not flashed)
+
+ Return ((min x, max x), (min y, max y))
+ """
+ return self.bounding_box
def to_inch(self):
if self.units == 'metric':
@@ -96,10 +115,13 @@ class Primitive(object):
setattr(self, attr, metric(value))
def offset(self, x_offset=0, y_offset=0):
- pass
+ raise NotImplementedError('The offset member must be implemented')
def __eq__(self, other):
return self.__dict__ == other.__dict__
+
+ def to_statement(self):
+ pass
class Line(Primitive):
@@ -111,6 +133,10 @@ class Line(Primitive):
self.end = end
self.aperture = aperture
self._to_convert = ['start', 'end', 'aperture']
+
+ @property
+ def flashed(self):
+ return False
@property
def angle(self):
@@ -131,6 +157,15 @@ class Line(Primitive):
min_y = min(self.start[1], self.end[1]) - height_2
max_y = max(self.start[1], self.end[1]) + height_2
return ((min_x, max_x), (min_y, max_y))
+
+ @property
+ def bounding_box_no_aperture(self):
+ '''Gets the bounding box without the aperture'''
+ min_x = min(self.start[0], self.end[0])
+ max_x = max(self.start[0], self.end[0])
+ min_y = min(self.start[1], self.end[1])
+ max_y = max(self.start[1], self.end[1])
+ return ((min_x, max_x), (min_y, max_y))
@property
def vertices(self):
@@ -183,20 +218,34 @@ class Line(Primitive):
def offset(self, x_offset=0, y_offset=0):
self.start = tuple(map(add, self.start, (x_offset, y_offset)))
self.end = tuple(map(add, self.end, (x_offset, y_offset)))
+
+ def equivalent(self, other, offset):
+
+ if not isinstance(other, Line):
+ return False
+
+ equiv_start = tuple(map(add, other.start, offset))
+ equiv_end = tuple(map(add, other.end, offset))
+ return nearly_equal(self.start, equiv_start) and nearly_equal(self.end, equiv_end)
class Arc(Primitive):
"""
"""
- def __init__(self, start, end, center, direction, aperture, **kwargs):
+ def __init__(self, start, end, center, direction, aperture, quadrant_mode, **kwargs):
super(Arc, self).__init__(**kwargs)
self.start = start
self.end = end
self.center = center
self.direction = direction
self.aperture = aperture
+ self.quadrant_mode = quadrant_mode
self._to_convert = ['start', 'end', 'center', 'aperture']
+ @property
+ def flashed(self):
+ return False
+
@property
def radius(self):
dy, dx = map(sub, self.start, self.center)
@@ -256,10 +305,60 @@ class Arc(Primitive):
if theta1 <= math.pi * 1.5 and (theta0 >= math.pi * 1.5 or theta0 < theta1):
points.append((self.center[0], self.center[1] - self.radius ))
x, y = zip(*points)
- min_x = min(x) - self.aperture.radius
- max_x = max(x) + self.aperture.radius
- min_y = min(y) - self.aperture.radius
- max_y = max(y) + self.aperture.radius
+
+ if isinstance(self.aperture, Circle):
+ radius = self.aperture.radius
+ else:
+ # TODO this is actually not valid, but files contain it
+ width = self.aperture.width
+ height = self.aperture.height
+ radius = max(width, height)
+
+ min_x = min(x) - radius
+ max_x = max(x) + radius
+ min_y = min(y) - radius
+ max_y = max(y) + radius
+ return ((min_x, max_x), (min_y, max_y))
+
+ @property
+ def bounding_box_no_aperture(self):
+ '''Gets the bounding box without considering the aperture'''
+ two_pi = 2 * math.pi
+ theta0 = (self.start_angle + two_pi) % two_pi
+ theta1 = (self.end_angle + two_pi) % two_pi
+ points = [self.start, self.end]
+ if self.direction == 'counterclockwise':
+ # Passes through 0 degrees
+ if theta0 > theta1:
+ points.append((self.center[0] + self.radius, self.center[1]))
+ # Passes through 90 degrees
+ if theta0 <= math.pi / 2. and (theta1 >= math.pi / 2. or theta1 < theta0):
+ points.append((self.center[0], self.center[1] + self.radius))
+ # Passes through 180 degrees
+ if theta0 <= math.pi and (theta1 >= math.pi or theta1 < theta0):
+ points.append((self.center[0] - self.radius, self.center[1]))
+ # Passes through 270 degrees
+ if theta0 <= math.pi * 1.5 and (theta1 >= math.pi * 1.5 or theta1 < theta0):
+ points.append((self.center[0], self.center[1] - self.radius ))
+ else:
+ # Passes through 0 degrees
+ if theta1 > theta0:
+ points.append((self.center[0] + self.radius, self.center[1]))
+ # Passes through 90 degrees
+ if theta1 <= math.pi / 2. and (theta0 >= math.pi / 2. or theta0 < theta1):
+ points.append((self.center[0], self.center[1] + self.radius))
+ # Passes through 180 degrees
+ if theta1 <= math.pi and (theta0 >= math.pi or theta0 < theta1):
+ points.append((self.center[0] - self.radius, self.center[1]))
+ # Passes through 270 degrees
+ if theta1 <= math.pi * 1.5 and (theta0 >= math.pi * 1.5 or theta0 < theta1):
+ points.append((self.center[0], self.center[1] - self.radius ))
+ x, y = zip(*points)
+
+ min_x = min(x)
+ max_x = max(x)
+ min_y = min(y)
+ max_y = max(y)
return ((min_x, max_x), (min_y, max_y))
def offset(self, x_offset=0, y_offset=0):
@@ -278,6 +377,10 @@ class Circle(Primitive):
self.diameter = diameter
self._to_convert = ['position', 'diameter']
+ @property
+ def flashed(self):
+ return True
+
@property
def radius(self):
return self.diameter / 2.
@@ -292,6 +395,19 @@ class Circle(Primitive):
def offset(self, x_offset=0, y_offset=0):
self.position = tuple(map(add, self.position, (x_offset, y_offset)))
+
+ def equivalent(self, other, offset):
+ '''Is this the same as the other circle, ignoring the offiset?'''
+
+ if not isinstance(other, Circle):
+ return False
+
+ if self.diameter != other.diameter:
+ return False
+
+ equiv_position = tuple(map(add, other.position, offset))
+
+ return nearly_equal(self.position, equiv_position)
class Ellipse(Primitive):
@@ -305,6 +421,9 @@ class Ellipse(Primitive):
self.height = height
self._to_convert = ['position', 'width', 'height']
+ @property
+ def flashed(self):
+ return True
@property
def bounding_box(self):
@@ -332,6 +451,10 @@ class Ellipse(Primitive):
class Rectangle(Primitive):
"""
+ When rotated, the rotation is about the center point.
+
+ Only aperture macro generated Rectangle objects can be rotated. If you aren't in a AMGroup,
+ then you don't need to worry about rotation
"""
def __init__(self, position, width, height, **kwargs):
super(Rectangle, self).__init__(**kwargs)
@@ -341,7 +464,10 @@ class Rectangle(Primitive):
self.height = height
self._to_convert = ['position', 'width', 'height']
-
+ @property
+ def flashed(self):
+ return True
+
@property
def lower_left(self):
return (self.position[0] - (self._abs_width / 2.),
@@ -372,6 +498,19 @@ class Rectangle(Primitive):
return (math.cos(math.radians(self.rotation)) * self.height +
math.sin(math.radians(self.rotation)) * self.width)
+ def equivalent(self, other, offset):
+ '''Is this the same as the other rect, ignoring the offiset?'''
+
+ if not isinstance(other, Rectangle):
+ return False
+
+ if self.width != other.width or self.height != other.height or self.rotation != other.rotation:
+ return False
+
+ equiv_position = tuple(map(add, other.position, offset))
+
+ return nearly_equal(self.position, equiv_position)
+
class Diamond(Primitive):
"""
@@ -383,6 +522,10 @@ class Diamond(Primitive):
self.width = width
self.height = height
self._to_convert = ['position', 'width', 'height']
+
+ @property
+ def flashed(self):
+ return True
@property
def lower_left(self):
@@ -427,6 +570,10 @@ class ChamferRectangle(Primitive):
self.chamfer = chamfer
self.corners = corners
self._to_convert = ['position', 'width', 'height', 'chamfer']
+
+ @property
+ def flashed(self):
+ return True
@property
def lower_left(self):
@@ -470,6 +617,10 @@ class RoundRectangle(Primitive):
self.radius = radius
self.corners = corners
self._to_convert = ['position', 'width', 'height', 'radius']
+
+ @property
+ def flashed(self):
+ return True
@property
def lower_left(self):
@@ -511,6 +662,10 @@ class Obround(Primitive):
self.width = width
self.height = height
self._to_convert = ['position', 'width', 'height']
+
+ @property
+ def flashed(self):
+ return True
@property
def lower_left(self):
@@ -566,14 +721,24 @@ class Obround(Primitive):
class Polygon(Primitive):
"""
+ Polygon flash defined by a set number of sides.
"""
- def __init__(self, position, sides, radius, **kwargs):
+ def __init__(self, position, sides, radius, hole_radius, **kwargs):
super(Polygon, self).__init__(**kwargs)
validate_coordinates(position)
self.position = position
self.sides = sides
self.radius = radius
+ self.hole_radius = hole_radius
self._to_convert = ['position', 'radius']
+
+ @property
+ def flashed(self):
+ return True
+
+ @property
+ def diameter(self):
+ return self.radius * 2
@property
def bounding_box(self):
@@ -585,7 +750,188 @@ class Polygon(Primitive):
def offset(self, x_offset=0, y_offset=0):
self.position = tuple(map(add, self.position, (x_offset, y_offset)))
+
+ @property
+ def vertices(self):
+
+ offset = self.rotation
+ da = 360.0 / self.sides
+
+ points = []
+ for i in xrange(self.sides):
+ points.append(rotate_point((self.position[0] + self.radius, self.position[1]), offset + da * i, self.position))
+
+ return points
+
+ def equivalent(self, other, offset):
+ """
+ Is this the outline the same as the other, ignoring the position offset?
+ """
+
+ # Quick check if it even makes sense to compare them
+ if type(self) != type(other) or self.sides != other.sides or self.radius != other.radius:
+ return False
+ equiv_pos = tuple(map(add, other.position, offset))
+
+ return nearly_equal(self.position, equiv_pos)
+
+
+class AMGroup(Primitive):
+ """
+ """
+ def __init__(self, amprimitives, stmt = None, **kwargs):
+ """
+
+ stmt : The original statment that generated this, since it is really hard to re-generate from primitives
+ """
+ super(AMGroup, self).__init__(**kwargs)
+
+ self.primitives = []
+ for amprim in amprimitives:
+ prim = amprim.to_primitive(self.units)
+ if isinstance(prim, list):
+ for p in prim:
+ self.primitives.append(p)
+ elif prim:
+ self.primitives.append(prim)
+ self._position = None
+ self._to_convert = ['_position', 'primitives']
+ self.stmt = stmt
+
+ def to_inch(self):
+ if self.units == 'metric':
+ super(AMGroup, self).to_inch()
+
+ # If we also have a stmt, convert that too
+ if self.stmt:
+ self.stmt.to_inch()
+
+
+ def to_metric(self):
+ if self.units == 'inch':
+ super(AMGroup, self).to_metric()
+
+ # If we also have a stmt, convert that too
+ if self.stmt:
+ self.stmt.to_metric()
+
+ @property
+ def flashed(self):
+ return True
+
+ @property
+ def bounding_box(self):
+ xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
+ minx, maxx = zip(*xlims)
+ miny, maxy = zip(*ylims)
+ min_x = min(minx)
+ max_x = max(maxx)
+ min_y = min(miny)
+ max_y = max(maxy)
+ return ((min_x, max_x), (min_y, max_y))
+
+ @property
+ def position(self):
+ return self._position
+
+ def offset(self, x_offset=0, y_offset=0):
+ self._position = tuple(map(add, self._position, (x_offset, y_offset)))
+
+ for primitive in self.primitives:
+ primitive.offset(x_offset, y_offset)
+
+ @position.setter
+ def position(self, new_pos):
+ '''
+ Sets the position of the AMGroup.
+ This offset all of the objects by the specified distance.
+ '''
+
+ if self._position:
+ dx = new_pos[0] - self._position[0]
+ dy = new_pos[1] - self._position[1]
+ else:
+ dx = new_pos[0]
+ dy = new_pos[1]
+
+ for primitive in self.primitives:
+ primitive.offset(dx, dy)
+
+ self._position = new_pos
+
+ def equivalent(self, other, offset):
+ '''
+ Is this the macro group the same as the other, ignoring the position offset?
+ '''
+
+ if len(self.primitives) != len(other.primitives):
+ return False
+
+ # We know they have the same number of primitives, so now check them all
+ for i in range(0, len(self.primitives)):
+ if not self.primitives[i].equivalent(other.primitives[i], offset):
+ return False
+
+ # If we didn't find any differences, then they are the same
+ return True
+
+class Outline(Primitive):
+ """
+ Outlines only exist as the rendering for a apeture macro outline.
+ They don't exist outside of AMGroup objects
+ """
+ def __init__(self, primitives, **kwargs):
+ super(Outline, self).__init__(**kwargs)
+ self.primitives = primitives
+ self._to_convert = ['primitives']
+
+ if self.primitives[0].start != self.primitives[-1].end:
+ raise ValueError('Outline must be closed')
+
+ @property
+ def flashed(self):
+ return True
+
+ @property
+ def bounding_box(self):
+ xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
+ minx, maxx = zip(*xlims)
+ miny, maxy = zip(*ylims)
+ min_x = min(minx)
+ max_x = max(maxx)
+ min_y = min(miny)
+ max_y = max(maxy)
+ return ((min_x, max_x), (min_y, max_y))
+
+ def offset(self, x_offset=0, y_offset=0):
+ for p in self.primitives:
+ p.offset(x_offset, y_offset)
+
+ @property
+ def width(self):
+ bounding_box = self.bounding_box()
+ return bounding_box[0][1] - bounding_box[0][0]
+
+ @property
+ def width(self):
+ bounding_box = self.bounding_box()
+ return bounding_box[1][1] - bounding_box[1][0]
+
+ def equivalent(self, other, offset):
+ '''
+ Is this the outline the same as the other, ignoring the position offset?
+ '''
+
+ # Quick check if it even makes sense to compare them
+ if type(self) != type(other) or len(self.primitives) != len(other.primitives):
+ return False
+
+ for i in range(0, len(self.primitives)):
+ if not self.primitives[i].equivalent(other.primitives[i], offset):
+ return False
+
+ return True
class Region(Primitive):
"""
@@ -594,10 +940,14 @@ class Region(Primitive):
super(Region, self).__init__(**kwargs)
self.primitives = primitives
self._to_convert = ['primitives']
+
+ @property
+ def flashed(self):
+ return False
@property
def bounding_box(self):
- xlims, ylims = zip(*[p.bounding_box for p in self.primitives])
+ xlims, ylims = zip(*[p.bounding_box_no_aperture for p in self.primitives])
minx, maxx = zip(*xlims)
miny, maxy = zip(*ylims)
min_x = min(minx)
@@ -620,6 +970,10 @@ class RoundButterfly(Primitive):
self.position = position
self.diameter = diameter
self._to_convert = ['position', 'diameter']
+
+ @property
+ def flashed(self):
+ return True
@property
def radius(self):
@@ -646,7 +1000,10 @@ class SquareButterfly(Primitive):
self.position = position
self.side = side
self._to_convert = ['position', 'side']
-
+
+ @property
+ def flashed(self):
+ return True
@property
def bounding_box(self):
@@ -682,6 +1039,10 @@ class Donut(Primitive):
self.width = 0.5 * math.sqrt(3.) * outer_diameter
self.height = outer_diameter
self._to_convert = ['position', 'width', 'height', 'inner_diameter', 'outer_diameter']
+
+ @property
+ def flashed(self):
+ return True
@property
def lower_left(self):
@@ -717,7 +1078,11 @@ class SquareRoundDonut(Primitive):
self.inner_diameter = inner_diameter
self.outer_diameter = outer_diameter
self._to_convert = ['position', 'inner_diameter', 'outer_diameter']
-
+
+ @property
+ def flashed(self):
+ return True
+
@property
def lower_left(self):
return tuple([c - self.outer_diameter / 2. for c in self.position])
@@ -741,12 +1106,17 @@ class SquareRoundDonut(Primitive):
class Drill(Primitive):
""" A drill hole
"""
- def __init__(self, position, diameter, **kwargs):
+ def __init__(self, position, diameter, hit, **kwargs):
super(Drill, self).__init__('dark', **kwargs)
validate_coordinates(position)
self.position = position
self.diameter = diameter
- self._to_convert = ['position', 'diameter']
+ self.hit = hit
+ self._to_convert = ['position', 'diameter', 'hit']
+
+ @property
+ def flashed(self):
+ return False
@property
def radius(self):
@@ -762,6 +1132,45 @@ class Drill(Primitive):
def offset(self, x_offset=0, y_offset=0):
self.position = tuple(map(add, self.position, (x_offset, y_offset)))
+
+ def __str__(self):
+ return '<Drill %f (%f, %f) [%s]>' % (self.diameter, self.position[0], self.position[1], self.hit)
+
+
+class Slot(Primitive):
+ """ A drilled slot
+ """
+ def __init__(self, start, end, diameter, hit, **kwargs):
+ super(Slot, self).__init__('dark', **kwargs)
+ validate_coordinates(start)
+ validate_coordinates(end)
+ self.start = start
+ self.end = end
+ self.diameter = diameter
+ self.hit = hit
+ self._to_convert = ['start', 'end', 'diameter', 'hit']
+
+ @property
+ def flashed(self):
+ return False
+
+ @property
+ def radius(self):
+ return self.diameter / 2.
+
+ @property
+ def bounding_box(self):
+ radius = self.radius
+ min_x = min(self.start[0], self.end[0]) - radius
+ max_x = max(self.start[0], self.end[0]) + radius
+ min_y = min(self.start[1], self.end[1]) - radius
+ max_y = max(self.start[1], self.end[1]) + radius
+ return ((min_x, max_x), (min_y, max_y))
+
+ def offset(self, x_offset=0, y_offset=0):
+ self.start = tuple(map(add, self.start, (x_offset, y_offset)))
+ self.end = tuple(map(add, self.end, (x_offset, y_offset)))
+
class TestRecord(Primitive):
""" Netlist Test record
diff --git a/gerber/render/cairo_backend.py b/gerber/render/cairo_backend.py
index 345f331..0cb230b 100644
--- a/gerber/render/cairo_backend.py
+++ b/gerber/render/cairo_backend.py
@@ -13,18 +13,27 @@
# 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.
-
-from .render import GerberContext
-
-import cairocffi as cairo
-
-from operator import mul
+# limitations under the License.
+
+try:
+ import cairo
+except ImportError:
+ import cairocffi as cairo
+
+from operator import mul, div
import math
import tempfile
+from .render import GerberContext, RenderSettings
+from .theme import THEMES
from ..primitives import *
+try:
+ from cStringIO import StringIO
+except(ImportError):
+ from io import StringIO
+
+
class GerberCairoContext(GerberContext):
def __init__(self, scale=300):
GerberContext.__init__(self)
@@ -32,42 +41,116 @@ class GerberCairoContext(GerberContext):
self.surface = None
self.ctx = None
self.bg = False
-
- def set_bounds(self, bounds):
+ self.mask = None
+ self.mask_ctx = None
+ self.origin_in_inch = None
+ self.size_in_inch = None
+ self._xform_matrix = None
+
+ @property
+ def origin_in_pixels(self):
+ return tuple(map(mul, self.origin_in_inch, self.scale)) if self.origin_in_inch is not None else (0.0, 0.0)
+
+ @property
+ def size_in_pixels(self):
+ return tuple(map(mul, self.size_in_inch, self.scale)) if self.size_in_inch is not None else (0.0, 0.0)
+
+ def set_bounds(self, bounds, new_surface=False):
origin_in_inch = (bounds[0][0], bounds[1][0])
size_in_inch = (abs(bounds[0][1] - bounds[0][0]), abs(bounds[1][1] - bounds[1][0]))
- size_in_pixels = map(mul, size_in_inch, self.scale)
-
- if self.surface is None:
+ size_in_pixels = tuple(map(mul, size_in_inch, self.scale))
+ self.origin_in_inch = origin_in_inch if self.origin_in_inch is None else self.origin_in_inch
+ self.size_in_inch = size_in_inch if self.size_in_inch is None else self.size_in_inch
+ if (self.surface is None) or new_surface:
self.surface_buffer = tempfile.NamedTemporaryFile()
self.surface = cairo.SVGSurface(self.surface_buffer, size_in_pixels[0], size_in_pixels[1])
self.ctx = cairo.Context(self.surface)
self.ctx.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
self.ctx.scale(1, -1)
self.ctx.translate(-(origin_in_inch[0] * self.scale[0]), (-origin_in_inch[1]*self.scale[0]) - size_in_pixels[1])
- # self.ctx.translate(-(origin_in_inch[0] * self.scale[0]), -origin_in_inch[1]*self.scale[1])
+ self.mask = cairo.SVGSurface(None, size_in_pixels[0], size_in_pixels[1])
+ self.mask_ctx = cairo.Context(self.mask)
+ self.mask_ctx.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
+ self.mask_ctx.scale(1, -1)
+ self.mask_ctx.translate(-(origin_in_inch[0] * self.scale[0]), (-origin_in_inch[1]*self.scale[0]) - size_in_pixels[1])
+ self._xform_matrix = cairo.Matrix(xx=1.0, yy=-1.0, x0=-self.origin_in_pixels[0], y0=self.size_in_pixels[1] + self.origin_in_pixels[1])
+
+ def render_layers(self, layers, filename, theme=THEMES['default']):
+ """ Render a set of layers
+ """
+ self.set_bounds(layers[0].bounds, True)
+ self._paint_background(True)
+ for layer in layers:
+ self._render_layer(layer, theme)
+ self.dump(filename)
+
+ def dump(self, filename):
+ """ Save image as `filename`
+ """
+ if filename and filename.lower().endswith(".svg"):
+ self.surface.finish()
+ self.surface_buffer.flush()
+ with open(filename, "w") as f:
+ self.surface_buffer.seek(0)
+ f.write(self.surface_buffer.read())
+ f.flush()
+ else:
+ return self.surface.write_to_png(filename)
+
+ def dump_str(self):
+ """ Return a string containing the rendered image.
+ """
+ fobj = StringIO()
+ self.surface.write_to_png(fobj)
+ return fobj.getvalue()
+
+ def dump_svg_str(self):
+ """ Return a string containg the rendered SVG.
+ """
+ self.surface.finish()
+ self.surface_buffer.flush()
+ return self.surface_buffer.read()
+
+ def _render_layer(self, layer, theme=THEMES['default']):
+ settings = theme.get(layer.layer_class, RenderSettings())
+ self.color = settings.color
+ self.alpha = settings.alpha
+ self.invert = settings.invert
+ if settings.mirror:
+ raise Warning('mirrored layers aren\'t supported yet...')
+ if self.invert:
+ self._clear_mask()
+ for prim in layer.primitives:
+ self.render(prim)
+ if self.invert:
+ self._render_mask()
def _render_line(self, line, color):
start = map(mul, line.start, self.scale)
end = map(mul, line.end, self.scale)
+ if not self.invert:
+ ctx = self.ctx
+ ctx.set_source_rgba(color[0], color[1], color[2], alpha=self.alpha)
+ ctx.set_operator(cairo.OPERATOR_OVER if line.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
+ else:
+ ctx = self.mask_ctx
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
+ ctx.set_operator(cairo.OPERATOR_CLEAR)
if isinstance(line.aperture, Circle):
width = line.aperture.diameter
- self.ctx.set_source_rgba(*color, alpha=self.alpha)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (line.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.set_line_width(width * self.scale[0])
- self.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
- self.ctx.move_to(*start)
- self.ctx.line_to(*end)
- self.ctx.stroke()
+ ctx.set_line_width(width * self.scale[0])
+ ctx.set_line_cap(cairo.LINE_CAP_ROUND)
+
+ ctx.move_to(*start)
+ ctx.line_to(*end)
+ ctx.stroke()
elif isinstance(line.aperture, Rectangle):
points = [tuple(map(mul, x, self.scale)) for x in line.vertices]
- self.ctx.set_source_rgba(*color, alpha=self.alpha)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (line.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.set_line_width(0)
- self.ctx.move_to(*points[0])
+ ctx.set_line_width(0)
+ ctx.move_to(*points[0])
for point in points[1:]:
- self.ctx.line_to(*point)
- self.ctx.fill()
+ ctx.line_to(*point)
+ ctx.fill()
def _render_arc(self, arc, color):
center = map(mul, arc.center, self.scale)
@@ -76,27 +159,49 @@ class GerberCairoContext(GerberContext):
radius = self.scale[0] * arc.radius
angle1 = arc.start_angle
angle2 = arc.end_angle
- width = arc.aperture.diameter if arc.aperture.diameter != 0 else 0.001
- self.ctx.set_source_rgba(*color, alpha=self.alpha)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (arc.level_polarity == "dark" and not self.invert)else cairo.OPERATOR_CLEAR)
- self.ctx.set_line_width(width * self.scale[0])
- self.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
- self.ctx.move_to(*start) # You actually have to do this...
+ if angle1 == angle2 and arc.quadrant_mode != 'single-quadrant':
+ # Make the angles slightly different otherwise Cario will draw nothing
+ angle2 -= 0.000000001
+ if isinstance(arc.aperture, Circle):
+ width = arc.aperture.diameter if arc.aperture.diameter != 0 else 0.001
+ else:
+ width = max(arc.aperture.width, arc.aperture.height, 0.001)
+
+ if not self.invert:
+ ctx = self.ctx
+ ctx.set_source_rgba(color[0], color[1], color[2], alpha=self.alpha)
+ ctx.set_operator(cairo.OPERATOR_OVER if arc.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
+ else:
+ ctx = self.mask_ctx
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
+ ctx.set_operator(cairo.OPERATOR_CLEAR)
+
+ ctx.set_line_width(width * self.scale[0])
+ ctx.set_line_cap(cairo.LINE_CAP_ROUND)
+ ctx.move_to(*start) # You actually have to do this...
if arc.direction == 'counterclockwise':
- self.ctx.arc(*center, radius=radius, angle1=angle1, angle2=angle2)
+ ctx.arc(center[0], center[1], radius, angle1, angle2)
else:
- self.ctx.arc_negative(*center, radius=radius, angle1=angle1, angle2=angle2)
- self.ctx.move_to(*end) # ...lame
+ ctx.arc_negative(center[0], center[1], radius, angle1, angle2)
+ ctx.move_to(*end) # ...lame
+ ctx.stroke()
def _render_region(self, region, color):
- self.ctx.set_source_rgba(*color, alpha=self.alpha)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (region.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.set_line_width(0)
- self.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
- self.ctx.move_to(*tuple(map(mul, region.primitives[0].start, self.scale)))
+ if not self.invert:
+ ctx = self.ctx
+ ctx.set_source_rgba(color[0], color[1], color[2], alpha=self.alpha)
+ ctx.set_operator(cairo.OPERATOR_OVER if region.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
+ else:
+ ctx = self.mask_ctx
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
+ ctx.set_operator(cairo.OPERATOR_CLEAR)
+
+ ctx.set_line_width(0)
+ ctx.set_line_cap(cairo.LINE_CAP_ROUND)
+ ctx.move_to(*tuple(map(mul, region.primitives[0].start, self.scale)))
for p in region.primitives:
if isinstance(p, Line):
- self.ctx.line_to(*tuple(map(mul, p.end, self.scale)))
+ ctx.line_to(*tuple(map(mul, p.end, self.scale)))
else:
center = map(mul, p.center, self.scale)
start = map(mul, p.start, self.scale)
@@ -105,76 +210,151 @@ class GerberCairoContext(GerberContext):
angle1 = p.start_angle
angle2 = p.end_angle
if p.direction == 'counterclockwise':
- self.ctx.arc(*center, radius=radius, angle1=angle1, angle2=angle2)
+ ctx.arc(center[0], center[1], radius, angle1, angle2)
else:
- self.ctx.arc_negative(*center, radius=radius, angle1=angle1, angle2=angle2)
- self.ctx.fill()
+ ctx.arc_negative(center[0], center[1], radius, angle1, angle2)
+ ctx.fill()
def _render_circle(self, circle, color):
center = tuple(map(mul, circle.position, self.scale))
- self.ctx.set_source_rgba(*color, alpha=self.alpha)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (circle.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.set_line_width(0)
- self.ctx.arc(*center, radius=circle.radius * self.scale[0], angle1=0, angle2=2 * math.pi)
- self.ctx.fill()
+ if not self.invert:
+ ctx = self.ctx
+ ctx.set_source_rgba(*color, alpha=self.alpha)
+ ctx.set_operator(cairo.OPERATOR_OVER if circle.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
+ else:
+ ctx = self.mask_ctx
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
+ ctx.set_operator(cairo.OPERATOR_CLEAR)
+ ctx.set_line_width(0)
+ ctx.arc(center[0], center[1], radius=circle.radius * self.scale[0], angle1=0, angle2=2 * math.pi)
+ ctx.fill()
def _render_rectangle(self, rectangle, color):
ll = map(mul, rectangle.lower_left, self.scale)
width, height = tuple(map(mul, (rectangle.width, rectangle.height), map(abs, self.scale)))
- self.ctx.set_source_rgba(*color, alpha=self.alpha)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (rectangle.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.set_line_width(0)
- self.ctx.rectangle(*ll,width=width, height=height)
- self.ctx.fill()
+
+ if not self.invert:
+ ctx = self.ctx
+ ctx.set_source_rgba(color[0], color[1], color[2], alpha=self.alpha)
+ ctx.set_operator(cairo.OPERATOR_OVER if rectangle.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
+ else:
+ ctx = self.mask_ctx
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
+ ctx.set_operator(cairo.OPERATOR_CLEAR)
+
+ if rectangle.rotation != 0:
+ ctx.save()
+
+ center = map(mul, rectangle.position, self.scale)
+ matrix = cairo.Matrix()
+ matrix.translate(center[0], center[1])
+ # For drawing, we already handles the translation
+ ll[0] = ll[0] - center[0]
+ ll[1] = ll[1] - center[1]
+ matrix.rotate(rectangle.rotation)
+ ctx.transform(matrix)
+
+ ctx.set_line_width(0)
+ ctx.rectangle(ll[0], ll[1], width, height)
+ ctx.fill()
+
+ if rectangle.rotation != 0:
+ ctx.restore()
def _render_obround(self, obround, color):
self._render_circle(obround.subshapes['circle1'], color)
self._render_circle(obround.subshapes['circle2'], color)
self._render_rectangle(obround.subshapes['rectangle'], color)
+
+ def _render_polygon(self, polygon, color):
+ if polygon.hole_radius > 0:
+ self.ctx.push_group()
+
+ vertices = polygon.vertices
+
+ self.ctx.set_source_rgba(color[0], color[1], color[2], self.alpha)
+ self.ctx.set_operator(cairo.OPERATOR_OVER if (polygon.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
+ self.ctx.set_line_width(0)
+ self.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
+
+ # Start from before the end so it is easy to iterate and make sure it is closed
+ self.ctx.move_to(*map(mul, vertices[-1], self.scale))
+ for v in vertices:
+ self.ctx.line_to(*map(mul, v, self.scale))
+
+ self.ctx.fill()
+
+ if polygon.hole_radius > 0:
+ # Render the center clear
+ center = tuple(map(mul, polygon.position, self.scale))
+ self.ctx.set_source_rgba(color[0], color[1], color[2], self.alpha)
+ self.ctx.set_operator(cairo.OPERATOR_CLEAR)
+ self.ctx.set_line_width(0)
+ self.ctx.arc(center[0], center[1], polygon.hole_radius * self.scale[0], 0, 2 * math.pi)
+ self.ctx.fill()
+
+ self.ctx.pop_group_to_source()
+ self.ctx.paint_with_alpha(1)
def _render_drill(self, circle, color):
self._render_circle(circle, color)
+
+ def _render_slot(self, slot, color):
+ start = map(mul, slot.start, self.scale)
+ end = map(mul, slot.end, self.scale)
+
+ width = slot.diameter
+
+ if not self.invert:
+ ctx = self.ctx
+ ctx.set_source_rgba(color[0], color[1], color[2], alpha=self.alpha)
+ ctx.set_operator(cairo.OPERATOR_OVER if slot.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
+ else:
+ ctx = self.mask_ctx
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
+ ctx.set_operator(cairo.OPERATOR_CLEAR)
+
+ ctx.set_line_width(width * self.scale[0])
+ ctx.set_line_cap(cairo.LINE_CAP_ROUND)
+ ctx.move_to(*start)
+ ctx.line_to(*end)
+ ctx.stroke()
+
+ def _render_amgroup(self, amgroup, color):
+ self.ctx.push_group()
+ for primitive in amgroup.primitives:
+ self.render(primitive)
+ self.ctx.pop_group_to_source()
+ self.ctx.paint_with_alpha(1)
def _render_test_record(self, primitive, color):
- self.ctx.select_font_face('monospace', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
- self.ctx.set_font_size(200)
- self._render_circle(Circle(primitive.position, 0.01), color)
- self.ctx.set_source_rgb(*color)
- self.ctx.set_operator(cairo.OPERATOR_OVER if (primitive.level_polarity == "dark" and not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.move_to(*[self.scale[0] * (coord + 0.01) for coord in primitive.position])
+ position = tuple(map(add, primitive.position, self.origin_in_inch))
+ self.ctx.set_operator(cairo.OPERATOR_OVER)
+ self.ctx.select_font_face('monospace', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
+ self.ctx.set_font_size(13)
+ self._render_circle(Circle(position, 0.015), color)
+ self.ctx.set_source_rgba(*color, alpha=self.alpha)
+ self.ctx.set_operator(cairo.OPERATOR_OVER if primitive.level_polarity == "dark" else cairo.OPERATOR_CLEAR)
+ self.ctx.move_to(*[self.scale[0] * (coord + 0.015) for coord in position])
self.ctx.scale(1, -1)
self.ctx.show_text(primitive.net_name)
self.ctx.scale(1, -1)
+
+ def _clear_mask(self):
+ self.mask_ctx.set_operator(cairo.OPERATOR_OVER)
+ self.mask_ctx.set_source_rgba(self.background_color[0], self.background_color[1], self.background_color[2], alpha=self.alpha)
+ self.mask_ctx.paint()
- def _paint_inverted_layer(self):
- self.ctx.set_source_rgba(*self.background_color)
+ def _render_mask(self):
self.ctx.set_operator(cairo.OPERATOR_OVER)
+ ptn = cairo.SurfacePattern(self.mask)
+ ptn.set_matrix(self._xform_matrix)
+ self.ctx.set_source(ptn)
self.ctx.paint()
- self.ctx.set_operator(cairo.OPERATOR_CLEAR)
- def _paint_background(self):
- if not self.bg:
- self.bg = True
- self.ctx.set_source_rgba(*self.background_color)
+ def _paint_background(self, force=False):
+ if (not self.bg) or force:
+ self.bg = True
+ self.ctx.set_source_rgba(self.background_color[0], self.background_color[1], self.background_color[2], alpha=1.0)
self.ctx.paint()
-
- def dump(self, filename):
- is_svg = filename.lower().endswith(".svg")
-
- if is_svg:
- self.surface.finish()
- self.surface_buffer.flush()
-
- with open(filename, "w") as f:
- self.surface_buffer.seek(0)
- f.write(self.surface_buffer.read())
- f.flush()
-
- else:
- self.surface.write_to_png(filename)
-
- def dump_svg_str(self):
- self.surface.finish()
- self.surface_buffer.flush()
- return self.surface_buffer.read()
- \ No newline at end of file
+
diff --git a/gerber/render/excellon_backend.py b/gerber/render/excellon_backend.py
new file mode 100644
index 0000000..da5b22b
--- /dev/null
+++ b/gerber/render/excellon_backend.py
@@ -0,0 +1,189 @@
+
+from .render import GerberContext
+from ..excellon import DrillSlot
+from ..excellon_statements import *
+
+class ExcellonContext(GerberContext):
+
+ MODE_DRILL = 1
+ MODE_SLOT =2
+
+ def __init__(self, settings):
+ GerberContext.__init__(self)
+
+ # Statements that we write
+ self.comments = []
+ self.header = []
+ self.tool_def = []
+ self.body_start = [RewindStopStmt()]
+ self.body = []
+ self.start = [HeaderBeginStmt()]
+
+ # Current tool and position
+ self.handled_tools = set()
+ self.cur_tool = None
+ self.drill_mode = ExcellonContext.MODE_DRILL
+ self.drill_down = False
+ self._pos = (None, None)
+
+ self.settings = settings
+
+ self._start_header()
+ self._start_comments()
+
+ def _start_header(self):
+ """Create the header from the settings"""
+
+ self.header.append(UnitStmt.from_settings(self.settings))
+
+ if self.settings.notation == 'incremental':
+ raise NotImplementedError('Incremental mode is not implemented')
+ else:
+ self.body.append(AbsoluteModeStmt())
+
+ def _start_comments(self):
+
+ # Write the digits used - this isn't valid Excellon statement, so we write as a comment
+ self.comments.append(CommentStmt('FILE_FORMAT=%d:%d' % (self.settings.format[0], self.settings.format[1])))
+
+ def _get_end(self):
+ """How we end depends on our mode"""
+
+ end = []
+
+ if self.drill_down:
+ end.append(RetractWithClampingStmt())
+ end.append(RetractWithoutClampingStmt())
+
+ end.append(EndOfProgramStmt())
+
+ return end
+
+ @property
+ def statements(self):
+ return self.start + self.comments + self.header + self.body_start + self.body + self._get_end()
+
+ def set_bounds(self, bounds):
+ pass
+
+ def _paint_background(self):
+ pass
+
+ def _render_line(self, line, color):
+ raise ValueError('Invalid Excellon object')
+ def _render_arc(self, arc, color):
+ raise ValueError('Invalid Excellon object')
+
+ def _render_region(self, region, color):
+ raise ValueError('Invalid Excellon object')
+
+ def _render_level_polarity(self, region):
+ raise ValueError('Invalid Excellon object')
+
+ def _render_circle(self, circle, color):
+ raise ValueError('Invalid Excellon object')
+
+ def _render_rectangle(self, rectangle, color):
+ raise ValueError('Invalid Excellon object')
+
+ def _render_obround(self, obround, color):
+ raise ValueError('Invalid Excellon object')
+
+ def _render_polygon(self, polygon, color):
+ raise ValueError('Invalid Excellon object')
+
+ def _simplify_point(self, point):
+ return (point[0] if point[0] != self._pos[0] else None, point[1] if point[1] != self._pos[1] else None)
+
+ def _render_drill(self, drill, color):
+
+ if self.drill_mode != ExcellonContext.MODE_DRILL:
+ self._start_drill_mode()
+
+ tool = drill.hit.tool
+ if not tool in self.handled_tools:
+ self.handled_tools.add(tool)
+ self.header.append(ExcellonTool.from_tool(tool))
+
+ if tool != self.cur_tool:
+ self.body.append(ToolSelectionStmt(tool.number))
+ self.cur_tool = tool
+
+ point = self._simplify_point(drill.position)
+ self._pos = drill.position
+ self.body.append(CoordinateStmt.from_point(point))
+
+ def _start_drill_mode(self):
+ """
+ If we are not in drill mode, then end the ROUT so we can do basic drilling
+ """
+
+ if self.drill_mode == ExcellonContext.MODE_SLOT:
+
+ # Make sure we are retracted before changing modes
+ last_cmd = self.body[-1]
+ if self.drill_down:
+ self.body.append(RetractWithClampingStmt())
+ self.body.append(RetractWithoutClampingStmt())
+ self.drill_down = False
+
+ # Switch to drill mode
+ self.body.append(DrillModeStmt())
+ self.drill_mode = ExcellonContext.MODE_DRILL
+
+ else:
+ raise ValueError('Should be in slot mode')
+
+ def _render_slot(self, slot, color):
+
+ # Set the tool first, before we might go into drill mode
+ tool = slot.hit.tool
+ if not tool in self.handled_tools:
+ self.handled_tools.add(tool)
+ self.header.append(ExcellonTool.from_tool(tool))
+
+ if tool != self.cur_tool:
+ self.body.append(ToolSelectionStmt(tool.number))
+ self.cur_tool = tool
+
+ # Two types of drilling - normal drill and slots
+ if slot.hit.slot_type == DrillSlot.TYPE_ROUT:
+
+ # For ROUT, setting the mode is part of the actual command.
+
+ # Are we in the right position?
+ if slot.start != self._pos:
+ if self.drill_down:
+ # We need to move into the right position, so retract
+ self.body.append(RetractWithClampingStmt())
+ self.drill_down = False
+
+ # Move to the right spot
+ point = self._simplify_point(slot.start)
+ self._pos = slot.start
+ self.body.append(CoordinateStmt.from_point(point, mode="ROUT"))
+
+ # Now we are in the right spot, so drill down
+ if not self.drill_down:
+ self.body.append(ZAxisRoutPositionStmt())
+ self.drill_down = True
+
+ # Do a linear move from our current position to the end position
+ point = self._simplify_point(slot.end)
+ self._pos = slot.end
+ self.body.append(CoordinateStmt.from_point(point, mode="LINEAR"))
+
+ self.drill_mode = ExcellonContext.MODE_SLOT
+
+ else:
+ # This is a G85 slot, so do this in normally drilling mode
+ if self.drill_mode != ExcellonContext.MODE_DRILL:
+ self._start_drill_mode()
+
+ # Slots don't use simplified points
+ self._pos = slot.end
+ self.body.append(SlotStmt.from_points(slot.start, slot.end))
+
+ def _render_inverted_layer(self):
+ pass
+ \ No newline at end of file
diff --git a/gerber/render/render.py b/gerber/render/render.py
index 8f49796..f521c44 100644
--- a/gerber/render/render.py
+++ b/gerber/render/render.py
@@ -23,12 +23,13 @@ Rendering
Render Gerber and Excellon files to a variety of formats. The render module
currently supports SVG rendering using the `svgwrite` library.
"""
+
+
+from ..primitives import *
from ..gerber_statements import (CommentStmt, UnknownStmt, EofStmt, ParamStmt,
CoordStmt, ApertureStmt, RegionModeStmt,
- QuadrantModeStmt,
-)
+ QuadrantModeStmt,)
-from ..primitives import *
class GerberContext(object):
""" Gerber rendering context base class
@@ -59,7 +60,6 @@ class GerberContext(object):
def __init__(self, units='inch'):
self._units = units
self._color = (0.7215, 0.451, 0.200)
- self._drill_color = (0.25, 0.25, 0.25)
self._background_color = (0.0, 0.0, 0.0)
self._alpha = 1.0
self._invert = False
@@ -132,8 +132,13 @@ class GerberContext(object):
self._invert = invert
def render(self, primitive):
+ if not primitive:
+ return
color = (self.color if primitive.level_polarity == 'dark'
else self.background_color)
+
+ self._pre_render_primitive(primitive)
+
if isinstance(primitive, Line):
self._render_line(primitive, color)
elif isinstance(primitive, Arc):
@@ -149,11 +154,31 @@ class GerberContext(object):
elif isinstance(primitive, Polygon):
self._render_polygon(primitive, color)
elif isinstance(primitive, Drill):
- self._render_drill(primitive, self.drill_color)
+ self._render_drill(primitive, self.color)
+ elif isinstance(primitive, Slot):
+ self._render_slot(primitive, self.color)
+ elif isinstance(primitive, AMGroup):
+ self._render_amgroup(primitive, color)
+ elif isinstance(primitive, Outline):
+ self._render_region(primitive, color)
elif isinstance(primitive, TestRecord):
self._render_test_record(primitive, color)
- else:
- return
+
+ self._post_render_primitive(primitive)
+
+ def _pre_render_primitive(self, primitive):
+ """
+ Called before rendering a primitive. Use the callback to perform some action before rendering
+ a primitive, for example adding a comment.
+ """
+ return
+
+ def _post_render_primitive(self, primitive):
+ """
+ Called after rendering a primitive. Use the callback to perform some action after rendering
+ a primitive
+ """
+ return
def _render_line(self, primitive, color):
pass
@@ -178,7 +203,21 @@ class GerberContext(object):
def _render_drill(self, primitive, color):
pass
+
+ def _render_slot(self, primitive, color):
+ pass
+
+ def _render_amgroup(self, primitive, color):
+ pass
def _render_test_record(self, primitive, color):
pass
+
+class RenderSettings(object):
+ def __init__(self, color=(0.0, 0.0, 0.0), alpha=1.0, invert=False, mirror=False):
+ self.color = color
+ self.alpha = alpha
+ self.invert = invert
+ self.mirror = mirror
+
diff --git a/gerber/render/rs274x_backend.py b/gerber/render/rs274x_backend.py
new file mode 100644
index 0000000..972edcb
--- /dev/null
+++ b/gerber/render/rs274x_backend.py
@@ -0,0 +1,470 @@
+
+from .render import GerberContext
+from ..am_statements import *
+from ..gerber_statements import *
+from ..primitives import AMGroup, Arc, Circle, Line, Obround, Outline, Polygon, Rectangle
+from copy import deepcopy
+
+class AMGroupContext(object):
+ '''A special renderer to generate aperature macros from an AMGroup'''
+
+ def __init__(self):
+ self.statements = []
+
+ def render(self, amgroup, name):
+
+ if amgroup.stmt:
+ # We know the statement it was generated from, so use that to create the AMParamStmt
+ # It will give a much better result
+
+ stmt = deepcopy(amgroup.stmt)
+ stmt.name = name
+
+ return stmt
+
+ else:
+ # Clone ourselves, then offset by the psotion so that
+ # our render doesn't have to consider offset. Just makes things simpler
+ nooffset_group = deepcopy(amgroup)
+ nooffset_group.position = (0, 0)
+
+ # Now draw the shapes
+ for primitive in nooffset_group.primitives:
+ if isinstance(primitive, Outline):
+ self._render_outline(primitive)
+ elif isinstance(primitive, Circle):
+ self._render_circle(primitive)
+ elif isinstance(primitive, Rectangle):
+ self._render_rectangle(primitive)
+ elif isinstance(primitive, Line):
+ self._render_line(primitive)
+ elif isinstance(primitive, Polygon):
+ self._render_polygon(primitive)
+ else:
+ raise ValueError('amgroup')
+
+ statement = AMParamStmt('AM', name, self._statements_to_string())
+ return statement
+
+ def _statements_to_string(self):
+ macro = ''
+
+ for statement in self.statements:
+ macro += statement.to_gerber()
+
+ return macro
+
+ def _render_circle(self, circle):
+ self.statements.append(AMCirclePrimitive.from_primitive(circle))
+
+ def _render_rectangle(self, rectangle):
+ self.statements.append(AMCenterLinePrimitive.from_primitive(rectangle))
+
+ def _render_line(self, line):
+ self.statements.append(AMVectorLinePrimitive.from_primitive(line))
+
+ def _render_outline(self, outline):
+ self.statements.append(AMOutlinePrimitive.from_primitive(outline))
+
+ def _render_polygon(self, polygon):
+ self.statements.append(AMPolygonPrimitive.from_primitive(polygon))
+
+ def _render_thermal(self, thermal):
+ pass
+
+
+class Rs274xContext(GerberContext):
+
+ def __init__(self, settings):
+ GerberContext.__init__(self)
+ self.comments = []
+ self.header = []
+ self.body = []
+ self.end = [EofStmt()]
+
+ # Current values so we know if we have to execute
+ # moves, levey changes before anything else
+ self._level_polarity = None
+ self._pos = (None, None)
+ self._func = None
+ self._quadrant_mode = None
+ self._dcode = None
+
+ # Primarily for testing and comarison to files, should we write
+ # flashes as a single statement or a move plus flash? Set to true
+ # to do in a single statement. Normally this can be false
+ self.condensed_flash = True
+
+ # When closing a region, force a D02 staement to close a region.
+ # This is normally not necessary because regions are closed with a G37
+ # staement, but this will add an extra statement for doubly close
+ # the region
+ self.explicit_region_move_end = False
+
+ self._next_dcode = 10
+ self._rects = {}
+ self._circles = {}
+ self._obrounds = {}
+ self._polygons = {}
+ self._macros = {}
+
+ self._i_none = 0
+ self._j_none = 0
+
+ self.settings = settings
+
+ self._start_header(settings)
+
+ def _start_header(self, settings):
+ self.header.append(FSParamStmt.from_settings(settings))
+ self.header.append(MOParamStmt.from_units(settings.units))
+
+ def _simplify_point(self, point):
+ return (point[0] if point[0] != self._pos[0] else None, point[1] if point[1] != self._pos[1] else None)
+
+ def _simplify_offset(self, point, offset):
+
+ if point[0] != offset[0]:
+ xoffset = point[0] - offset[0]
+ else:
+ xoffset = self._i_none
+
+ if point[1] != offset[1]:
+ yoffset = point[1] - offset[1]
+ else:
+ yoffset = self._j_none
+
+ return (xoffset, yoffset)
+
+ @property
+ def statements(self):
+ return self.comments + self.header + self.body + self.end
+
+ def set_bounds(self, bounds):
+ pass
+
+ def _paint_background(self):
+ pass
+
+ def _select_aperture(self, aperture):
+
+ # Select the right aperture if not already selected
+ if aperture:
+ if isinstance(aperture, Circle):
+ aper = self._get_circle(aperture.diameter)
+ elif isinstance(aperture, Rectangle):
+ aper = self._get_rectangle(aperture.width, aperture.height)
+ elif isinstance(aperture, Obround):
+ aper = self._get_obround(aperture.width, aperture.height)
+ elif isinstance(aperture, AMGroup):
+ aper = self._get_amacro(aperture)
+ else:
+ raise NotImplementedError('Line with invalid aperture type')
+
+ if aper.d != self._dcode:
+ self.body.append(ApertureStmt(aper.d))
+ self._dcode = aper.d
+
+ def _pre_render_primitive(self, primitive):
+
+ if hasattr(primitive, 'comment'):
+ self.body.append(CommentStmt(primitive.comment))
+
+ def _render_line(self, line, color):
+
+ self._select_aperture(line.aperture)
+
+ self._render_level_polarity(line)
+
+ # Get the right function
+ if self._func != CoordStmt.FUNC_LINEAR:
+ func = CoordStmt.FUNC_LINEAR
+ else:
+ func = None
+ self._func = CoordStmt.FUNC_LINEAR
+
+ if self._pos != line.start:
+ self.body.append(CoordStmt.move(func, self._simplify_point(line.start)))
+ self._pos = line.start
+ # We already set the function, so the next command doesn't require that
+ func = None
+
+ point = self._simplify_point(line.end)
+
+ # In some files, we see a lot of duplicated ponts, so omit those
+ if point[0] != None or point[1] != None:
+ self.body.append(CoordStmt.line(func, self._simplify_point(line.end)))
+ self._pos = line.end
+ elif func:
+ self.body.append(CoordStmt.mode(func))
+
+ def _render_arc(self, arc, color):
+
+ # Optionally set the quadrant mode if it has changed:
+ if arc.quadrant_mode != self._quadrant_mode:
+
+ if arc.quadrant_mode != 'multi-quadrant':
+ self.body.append(QuadrantModeStmt.single())
+ else:
+ self.body.append(QuadrantModeStmt.multi())
+
+ self._quadrant_mode = arc.quadrant_mode
+
+ # Select the right aperture if not already selected
+ self._select_aperture(arc.aperture)
+
+ self._render_level_polarity(arc)
+
+ # Find the right movement mode. Always set to be sure it is really right
+ dir = arc.direction
+ if dir == 'clockwise':
+ func = CoordStmt.FUNC_ARC_CW
+ self._func = CoordStmt.FUNC_ARC_CW
+ elif dir == 'counterclockwise':
+ func = CoordStmt.FUNC_ARC_CCW
+ self._func = CoordStmt.FUNC_ARC_CCW
+ else:
+ raise ValueError('Invalid circular interpolation mode')
+
+ if self._pos != arc.start:
+ # TODO I'm not sure if this is right
+ self.body.append(CoordStmt.move(CoordStmt.FUNC_LINEAR, self._simplify_point(arc.start)))
+ self._pos = arc.start
+
+ center = self._simplify_offset(arc.center, arc.start)
+ end = self._simplify_point(arc.end)
+ self.body.append(CoordStmt.arc(func, end, center))
+ self._pos = arc.end
+
+ def _render_region(self, region, color):
+
+ self._render_level_polarity(region)
+
+ self.body.append(RegionModeStmt.on())
+
+ for p in region.primitives:
+
+ if isinstance(p, Line):
+ self._render_line(p, color)
+ else:
+ self._render_arc(p, color)
+
+ if self.explicit_region_move_end:
+ self.body.append(CoordStmt.move(None, None))
+
+ self.body.append(RegionModeStmt.off())
+
+ def _render_level_polarity(self, region):
+ if region.level_polarity != self._level_polarity:
+ self._level_polarity = region.level_polarity
+ self.body.append(LPParamStmt.from_region(region))
+
+ def _render_flash(self, primitive, aperture):
+
+ self._render_level_polarity(primitive)
+
+ if aperture.d != self._dcode:
+ self.body.append(ApertureStmt(aperture.d))
+ self._dcode = aperture.d
+
+ if self.condensed_flash:
+ self.body.append(CoordStmt.flash(self._simplify_point(primitive.position)))
+ else:
+ self.body.append(CoordStmt.move(None, self._simplify_point(primitive.position)))
+ self.body.append(CoordStmt.flash(None))
+
+ self._pos = primitive.position
+
+ def _get_circle(self, diameter, dcode = None):
+ '''Define a circlar aperture'''
+
+ aper = self._circles.get(diameter, None)
+
+ if not aper:
+ if not dcode:
+ dcode = self._next_dcode
+ self._next_dcode += 1
+ else:
+ self._next_dcode = max(dcode + 1, self._next_dcode)
+
+ aper = ADParamStmt.circle(dcode, diameter)
+ self._circles[diameter] = aper
+ self.header.append(aper)
+
+ return aper
+
+ def _render_circle(self, circle, color):
+
+ aper = self._get_circle(circle.diameter)
+ self._render_flash(circle, aper)
+
+ def _get_rectangle(self, width, height, dcode = None):
+ '''Get a rectanglar aperture. If it isn't defined, create it'''
+
+ key = (width, height)
+ aper = self._rects.get(key, None)
+
+ if not aper:
+ if not dcode:
+ dcode = self._next_dcode
+ self._next_dcode += 1
+ else:
+ self._next_dcode = max(dcode + 1, self._next_dcode)
+
+ aper = ADParamStmt.rect(dcode, width, height)
+ self._rects[(width, height)] = aper
+ self.header.append(aper)
+
+ return aper
+
+ def _render_rectangle(self, rectangle, color):
+
+ aper = self._get_rectangle(rectangle.width, rectangle.height)
+ self._render_flash(rectangle, aper)
+
+ def _get_obround(self, width, height, dcode = None):
+
+ key = (width, height)
+ aper = self._obrounds.get(key, None)
+
+ if not aper:
+ if not dcode:
+ dcode = self._next_dcode
+ self._next_dcode += 1
+ else:
+ self._next_dcode = max(dcode + 1, self._next_dcode)
+
+ aper = ADParamStmt.obround(dcode, width, height)
+ self._obrounds[key] = aper
+ self.header.append(aper)
+
+ return aper
+
+ def _render_obround(self, obround, color):
+
+ aper = self._get_obround(obround.width, obround.height)
+ self._render_flash(obround, aper)
+
+ def _render_polygon(self, polygon, color):
+
+ aper = self._get_polygon(polygon.radius, polygon.sides, polygon.rotation, polygon.hole_radius)
+ self._render_flash(polygon, aper)
+
+ def _get_polygon(self, radius, num_vertices, rotation, hole_radius, dcode = None):
+
+ key = (radius, num_vertices, rotation, hole_radius)
+ aper = self._polygons.get(key, None)
+
+ if not aper:
+ if not dcode:
+ dcode = self._next_dcode
+ self._next_dcode += 1
+ else:
+ self._next_dcode = max(dcode + 1, self._next_dcode)
+
+ aper = ADParamStmt.polygon(dcode, radius * 2, num_vertices, rotation, hole_radius * 2)
+ self._polygons[key] = aper
+ self.header.append(aper)
+
+ return aper
+
+ def _render_drill(self, drill, color):
+ raise ValueError('Drills are not valid in RS274X files')
+
+ def _hash_amacro(self, amgroup):
+ '''Calculate a very quick hash code for deciding if we should even check AM groups for comparision'''
+
+ # We always start with an X because this forms part of the name
+ # Basically, in some cases, the name might start with a C, R, etc. That can appear
+ # to conflict with normal aperture definitions. Technically, it shouldn't because normal
+ # aperture definitions should have a comma, but in some cases the commit is omitted
+ hash = 'X'
+ for primitive in amgroup.primitives:
+
+ hash += primitive.__class__.__name__[0]
+
+ bbox = primitive.bounding_box
+ hash += str((bbox[0][1] - bbox[0][0]) * 100000)[0:2]
+ hash += str((bbox[1][1] - bbox[1][0]) * 100000)[0:2]
+
+ if hasattr(primitive, 'primitives'):
+ hash += str(len(primitive.primitives))
+
+ if isinstance(primitive, Rectangle):
+ hash += str(primitive.width * 1000000)[0:2]
+ hash += str(primitive.height * 1000000)[0:2]
+ elif isinstance(primitive, Circle):
+ hash += str(primitive.diameter * 1000000)[0:2]
+
+ if len(hash) > 20:
+ # The hash might actually get quite complex, so stop before
+ # it gets too long
+ break
+
+ return hash
+
+ def _get_amacro(self, amgroup, dcode = None):
+ # Macros are a little special since we don't have a good way to compare them quickly
+ # but in most cases, this should work
+
+ hash = self._hash_amacro(amgroup)
+ macro = None
+ macroinfo = self._macros.get(hash, None)
+
+ if macroinfo:
+
+ # We have a definition, but check that the groups actually are the same
+ for macro in macroinfo:
+
+ # Macros should have positions, right? But if the macro is selected for non-flashes
+ # then it won't have a position. This is of course a bad gerber, but they do exist
+ if amgroup.position:
+ position = amgroup.position
+ else:
+ position = (0, 0)
+
+ offset = (position[0] - macro[1].position[0], position[1] - macro[1].position[1])
+ if amgroup.equivalent(macro[1], offset):
+ break
+ macro = None
+
+ # Did we find one in the group0
+ if not macro:
+ # This is a new macro, so define it
+ if not dcode:
+ dcode = self._next_dcode
+ self._next_dcode += 1
+ else:
+ self._next_dcode = max(dcode + 1, self._next_dcode)
+
+ # Create the statements
+ # TODO
+ amrenderer = AMGroupContext()
+ statement = amrenderer.render(amgroup, hash)
+
+ self.header.append(statement)
+
+ aperdef = ADParamStmt.macro(dcode, hash)
+ self.header.append(aperdef)
+
+ # Store the dcode and the original so we can check if it really is the same
+ # If it didn't have a postition, set it to 0, 0
+ if amgroup.position == None:
+ amgroup.position = (0, 0)
+ macro = (aperdef, amgroup)
+
+ if macroinfo:
+ macroinfo.append(macro)
+ else:
+ self._macros[hash] = [macro]
+
+ return macro[0]
+
+ def _render_amgroup(self, amgroup, color):
+
+ aper = self._get_amacro(amgroup)
+ self._render_flash(amgroup, aper)
+
+ def _render_inverted_layer(self):
+ pass
+ \ No newline at end of file
diff --git a/gerber/render/theme.py b/gerber/render/theme.py
new file mode 100644
index 0000000..e538df8
--- /dev/null
+++ b/gerber/render/theme.py
@@ -0,0 +1,70 @@
+#! /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.
+
+
+from .render import RenderSettings
+
+COLORS = {
+ 'black': (0.0, 0.0, 0.0),
+ 'white': (1.0, 1.0, 1.0),
+ 'red': (1.0, 0.0, 0.0),
+ 'green': (0.0, 1.0, 0.0),
+ 'blue' : (0.0, 0.0, 1.0),
+ 'fr-4': (0.290, 0.345, 0.0),
+ 'green soldermask': (0.0, 0.612, 0.396),
+ 'blue soldermask': (0.059, 0.478, 0.651),
+ 'red soldermask': (0.968, 0.169, 0.165),
+ 'black soldermask': (0.298, 0.275, 0.282),
+ 'purple soldermask': (0.2, 0.0, 0.334),
+ 'enig copper': (0.686, 0.525, 0.510),
+ 'hasl copper': (0.871, 0.851, 0.839)
+}
+
+
+class Theme(object):
+ def __init__(self, name=None, **kwargs):
+ self.name = 'Default' if name is None else name
+ self.background = kwargs.get('background', RenderSettings(COLORS['black'], alpha=0.0))
+ self.topsilk = kwargs.get('topsilk', RenderSettings(COLORS['white']))
+ self.bottomsilk = kwargs.get('bottomsilk', RenderSettings(COLORS['white']))
+ self.topmask = kwargs.get('topmask', RenderSettings(COLORS['green soldermask'], alpha=0.8, invert=True))
+ self.bottommask = kwargs.get('bottommask', RenderSettings(COLORS['green soldermask'], alpha=0.8, invert=True))
+ self.top = kwargs.get('top', RenderSettings(COLORS['hasl copper']))
+ self.bottom = kwargs.get('top', RenderSettings(COLORS['hasl copper']))
+ self.drill = kwargs.get('drill', RenderSettings(COLORS['black']))
+ self.ipc_netlist = kwargs.get('ipc_netlist', RenderSettings(COLORS['red']))
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+ def get(self, key, noneval=None):
+ val = getattr(self, key)
+ return val if val is not None else noneval
+
+
+THEMES = {
+ 'default': Theme(),
+ 'OSH Park': Theme(name='OSH Park',
+ top=RenderSettings(COLORS['enig copper']),
+ bottom=RenderSettings(COLORS['enig copper']),
+ topmask=RenderSettings(COLORS['purple soldermask'], alpha=0.8, invert=True),
+ bottommask=RenderSettings(COLORS['purple soldermask'], alpha=0.8, invert=True)),
+ 'Blue': Theme(name='Blue',
+ topmask=RenderSettings(COLORS['blue soldermask'], alpha=0.8, invert=True),
+ bottommask=RenderSettings(COLORS['blue soldermask'], alpha=0.8, invert=True)),
+}
+
diff --git a/gerber/rs274x.py b/gerber/rs274x.py
index 9fd63da..260fbf8 100644
--- a/gerber/rs274x.py
+++ b/gerber/rs274x.py
@@ -21,15 +21,17 @@
import copy
import json
import re
+import sys
try:
from cStringIO import StringIO
except(ImportError):
from io import StringIO
-
+
from .gerber_statements import *
from .primitives import *
from .cam import CamFile, FileSettings
+from .utils import sq_distance
def read(filename):
@@ -49,8 +51,21 @@ def read(filename):
def loads(data):
+ """ Generate a GerberFile object from rs274x data in memory
+
+ Parameters
+ ----------
+ data : string
+ string containing gerber file contents
+
+ Returns
+ -------
+ file : :class:`gerber.rs274x.GerberFile`
+ A GerberFile created from the specified file.
+ """
return GerberParser().parse_raw(data)
+
class GerberFile(CamFile):
""" A class representing a single gerber file
@@ -80,8 +95,10 @@ class GerberFile(CamFile):
`bounds` is stored as ((min x, max x), (min y, max y))
"""
- def __init__(self, statements, settings, primitives, filename=None):
+ def __init__(self, statements, settings, primitives, apertures, filename=None):
super(GerberFile, self).__init__(statements, settings, primitives, filename)
+
+ self.apertures = apertures
@property
def comments(self):
@@ -97,15 +114,27 @@ class GerberFile(CamFile):
def bounds(self):
min_x = min_y = 1000000
max_x = max_y = -1000000
-
for stmt in [stmt for stmt in self.statements if isinstance(stmt, CoordStmt)]:
if stmt.x is not None:
min_x = min(stmt.x, min_x)
max_x = max(stmt.x, max_x)
-
if stmt.y is not None:
min_y = min(stmt.y, min_y)
max_y = max(stmt.y, max_y)
+ return ((min_x, max_x), (min_y, max_y))
+
+ @property
+ def bounding_box(self):
+ min_x = min_y = 1000000
+ max_x = max_y = -1000000
+
+ for prim in self.primitives:
+ bounds = prim.bounding_box
+ min_x = min(bounds[0][0], min_x)
+ max_x = max(bounds[0][1], max_x)
+
+ min_y = min(bounds[1][0], min_y)
+ max_y = max(bounds[1][1], max_y)
return ((min_x, max_x), (min_y, max_y))
@@ -148,14 +177,14 @@ class GerberParser(object):
STRING = r"[a-zA-Z0-9_+\-/!?<>”’(){}.\|&@# :]+"
NAME = r"[a-zA-Z_$\.][a-zA-Z_$\.0-9+\-]+"
- FS = r"(?P<param>FS)(?P<zero>(L|T|D))?(?P<notation>(A|I))X(?P<x>[0-7][0-7])Y(?P<y>[0-7][0-7])"
+ FS = r"(?P<param>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 = r"(?P<param>MO)(?P<mo>(MM|IN))"
LP = r"(?P<param>LP)(?P<lp>(D|C))"
- AD_CIRCLE = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>C)[,]?(?P<modifiers>[^,%]*)?"
+ AD_CIRCLE = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>C)[,]?(?P<modifiers>[^,%]*)"
AD_RECT = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>R)[,](?P<modifiers>[^,%]*)"
AD_OBROUND = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>O)[,](?P<modifiers>[^,%]*)"
AD_POLY = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>P)[,](?P<modifiers>[^,%]*)"
- AD_MACRO = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>{name})[,]?(?P<modifiers>[^,%]*)?".format(name=NAME)
+ AD_MACRO = r"(?P<param>AD)D(?P<d>\d+)(?P<shape>{name})[,]?(?P<modifiers>[^,%]*)".format(name=NAME)
AM = r"(?P<param>AM)(?P<name>{name})\*(?P<macro>[^%]*)".format(name=NAME)
# begin deprecated
@@ -215,11 +244,10 @@ class GerberParser(object):
def parse(self, filename):
with open(filename, "rU") as fp:
data = fp.read()
- return self.parse_raw(data, filename=None)
+ return self.parse_raw(data, filename)
def parse_raw(self, data, filename=None):
- lines = [line for line in StringIO(data)]
- for stmt in self._parse(lines):
+ for stmt in self._parse(self._split_commands(data)):
self.evaluate(stmt)
self.statements.append(stmt)
@@ -227,7 +255,38 @@ class GerberParser(object):
for stmt in self.statements:
stmt.units = self.settings.units
- return GerberFile(self.statements, self.settings, self.primitives, filename)
+ return GerberFile(self.statements, self.settings, self.primitives, self.apertures.values(), filename)
+
+ def _split_commands(self, data):
+ """
+ Split the data into commands. Commands end with * (and also newline to help with some badly formatted files)
+ """
+
+ length = len(data)
+ start = 0
+ in_header = True
+
+ for cur in range(0, length):
+
+ val = data[cur]
+
+ if val == '%' and start == cur:
+ in_header = True
+ continue
+
+ if val == '\r' or val == '\n':
+ if start != cur:
+ yield data[start:cur]
+ start = cur + 1
+
+ elif not in_header and val == '*':
+ yield data[start:cur + 1]
+ start = cur + 1
+
+ elif in_header and val == '%':
+ yield data[start:cur + 1]
+ start = cur + 1
+ in_header = False
def dump_json(self):
stmts = {"statements": [stmt.__dict__ for stmt in self.statements]}
@@ -242,7 +301,7 @@ class GerberParser(object):
def _parse(self, data):
oldline = ''
- for i, line in enumerate(data):
+ for line in data:
line = oldline + line.strip()
# skip empty lines
@@ -254,27 +313,16 @@ class GerberParser(object):
oldline = line
continue
-
did_something = True # make sure we do at least one loop
while did_something and len(line) > 0:
did_something = False
-
- # Region Mode
- (mode, r) = _match_one(self.REGION_MODE_STMT, line)
- if mode:
- yield RegionModeStmt.from_gerber(line)
- line = r
+
+ # consume empty data blocks
+ if line[0] == '*':
+ line = line[1:]
did_something = True
continue
-
- # Quadrant Mode
- (mode, r) = _match_one(self.QUAD_MODE_STMT, line)
- if mode:
- yield QuadrantModeStmt.from_gerber(line)
- line = r
- did_something = True
- continue
-
+
# coord
(coord, r) = _match_one(self.COORD_STMT, line)
if coord:
@@ -282,20 +330,11 @@ class GerberParser(object):
line = r
did_something = True
continue
-
+
# aperture selection
(aperture, r) = _match_one(self.APERTURE_STMT, line)
if aperture:
yield ApertureStmt(**aperture)
-
- did_something = True
- line = r
- continue
-
- # comment
- (comment, r) = _match_one(self.COMMENT_STMT, line)
- if comment:
- yield CommentStmt(comment["comment"])
did_something = True
line = r
continue
@@ -319,7 +358,9 @@ class GerberParser(object):
elif param["param"] == "AD":
yield ADParamStmt.from_dict(param)
elif param["param"] == "AM":
- yield AMParamStmt.from_dict(param)
+ stmt = AMParamStmt.from_dict(param)
+ stmt.units = self.settings.units
+ yield stmt
elif param["param"] == "OF":
yield OFParamStmt.from_dict(param)
elif param["param"] == "IN":
@@ -350,6 +391,30 @@ class GerberParser(object):
line = r
continue
+ # Region Mode
+ (mode, r) = _match_one(self.REGION_MODE_STMT, line)
+ if mode:
+ yield RegionModeStmt.from_gerber(line)
+ line = r
+ did_something = True
+ continue
+
+ # Quadrant Mode
+ (mode, r) = _match_one(self.QUAD_MODE_STMT, line)
+ if mode:
+ yield QuadrantModeStmt.from_gerber(line)
+ line = r
+ did_something = True
+ continue
+
+ # comment
+ (comment, r) = _match_one(self.COMMENT_STMT, line)
+ if comment:
+ yield CommentStmt(comment["comment"])
+ did_something = True
+ line = r
+ continue
+
# deprecated codes
(deprecated_unit, r) = _match_one(self.DEPRECATED_UNIT, line)
if deprecated_unit:
@@ -417,18 +482,28 @@ class GerberParser(object):
aperture = None
if shape == 'C':
diameter = modifiers[0][0]
- aperture = Circle(position=None, diameter=diameter)
+ aperture = Circle(position=None, diameter=diameter, units=self.settings.units)
elif shape == 'R':
width = modifiers[0][0]
height = modifiers[0][1]
- aperture = Rectangle(position=None, width=width, height=height)
+ aperture = Rectangle(position=None, width=width, height=height, units=self.settings.units)
elif shape == 'O':
width = modifiers[0][0]
height = modifiers[0][1]
- aperture = Obround(position=None, width=width, height=height)
+ aperture = Obround(position=None, width=width, height=height, units=self.settings.units)
elif shape == 'P':
- # FIXME: not supported yet?
- pass
+ outer_diameter = modifiers[0][0]
+ number_vertices = int(modifiers[0][1])
+ if len(modifiers[0]) > 2:
+ rotation = modifiers[0][2]
+ else:
+ rotation = 0
+
+ if len(modifiers[0]) > 3:
+ hole_diameter = modifiers[0][3]
+ else:
+ hole_diameter = 0
+ aperture = Polygon(position=None, sides=number_vertices, radius=outer_diameter/2.0, hole_radius=hole_diameter/2.0, rotation=rotation)
else:
aperture = self.macros[shape].build(modifiers)
@@ -437,7 +512,9 @@ class GerberParser(object):
def _evaluate_mode(self, stmt):
if stmt.type == 'RegionMode':
if self.region_mode == 'on' and stmt.mode == 'off':
- self.primitives.append(Region(self.current_region, level_polarity=self.level_polarity))
+ # Sometimes we have regions that have no points. Skip those
+ if self.current_region:
+ self.primitives.append(Region(self.current_region, level_polarity=self.level_polarity, units=self.settings.units))
self.current_region = None
self.region_mode = stmt.mode
elif stmt.type == 'QuadrantMode':
@@ -468,6 +545,12 @@ class GerberParser(object):
elif stmt.function in ('G02', 'G2', 'G03', 'G3'):
self.interpolation = 'arc'
self.direction = ('clockwise' if stmt.function in ('G02', 'G2') else 'counterclockwise')
+
+ if stmt.only_function:
+ # Sometimes we get a coordinate statement
+ # that only sets the function. If so, don't
+ # try futher otherwise that might draw/flash something
+ return
if stmt.op:
self.op = stmt.op
@@ -475,7 +558,7 @@ class GerberParser(object):
# no implicit op allowed, force here if coord block doesn't have it
stmt.op = self.op
- if self.op == "D01":
+ if self.op == "D01" or self.op == "D1":
start = (self.x, self.y)
end = (x, y)
@@ -486,6 +569,7 @@ class GerberParser(object):
# from gerber spec revision J3, Section 4.5, page 55:
# The segments are not graphics objects in themselves; segments are part of region which is the graphics object. The segments have no thickness.
# The current aperture is associated with the region. This has no graphical effect, but allows all its attributes to be applied to the region.
+
if self.current_region is None:
self.current_region = [Line(start, end, self.apertures.get(self.aperture, Circle((0,0), 0)), level_polarity=self.level_polarity, units=self.settings.units),]
else:
@@ -493,19 +577,24 @@ class GerberParser(object):
else:
i = 0 if stmt.i is None else stmt.i
j = 0 if stmt.j is None else stmt.j
- center = (start[0] + i, start[1] + j)
+ center = self._find_center(start, end, (i, j))
if self.region_mode == 'off':
- self.primitives.append(Arc(start, end, center, self.direction, self.apertures[self.aperture], level_polarity=self.level_polarity, units=self.settings.units))
+ self.primitives.append(Arc(start, end, center, self.direction, self.apertures[self.aperture], quadrant_mode=self.quadrant_mode, level_polarity=self.level_polarity, units=self.settings.units))
else:
if self.current_region is None:
- self.current_region = [Arc(start, end, center, self.direction, self.apertures[self.aperture], level_polarity=self.level_polarity, units=self.settings.units),]
+ self.current_region = [Arc(start, end, center, self.direction, self.apertures.get(self.aperture, Circle((0,0), 0)), quadrant_mode=self.quadrant_mode, level_polarity=self.level_polarity, units=self.settings.units),]
else:
- self.current_region.append(Arc(start, end, center, self.direction, self.apertures[self.aperture], level_polarity=self.level_polarity, units=self.settings.units))
-
- elif self.op == "D02":
- pass
+ self.current_region.append(Arc(start, end, center, self.direction, self.apertures.get(self.aperture, Circle((0,0), 0)), quadrant_mode=self.quadrant_mode, level_polarity=self.level_polarity, units=self.settings.units))
+
+ elif self.op == "D02" or self.op == "D2":
+
+ if self.region_mode == "on":
+ # D02 in the middle of a region finishes that region and starts a new one
+ if self.current_region and len(self.current_region) > 1:
+ self.primitives.append(Region(self.current_region, level_polarity=self.level_polarity, units=self.settings.units))
+ self.current_region = None
- elif self.op == "D03":
+ elif self.op == "D03" or self.op == "D3":
primitive = copy.deepcopy(self.apertures[self.aperture])
# XXX: temporary fix because there are no primitives for Macros and Polygon
if primitive is not None:
@@ -519,6 +608,35 @@ class GerberParser(object):
self.primitives.append(primitive)
self.x, self.y = x, y
+
+ def _find_center(self, start, end, offsets):
+ """
+ In single quadrant mode, the offsets are always positive, which means there are 4 possible centers.
+ The correct center is the only one that results in an arc with sweep angle of less than or equal to 90 degrees
+ """
+
+ if self.quadrant_mode == 'single-quadrant':
+
+ # The Gerber spec says single quadrant only has one possible center, and you can detect
+ # based on the angle. But for real files, this seems to work better - there is usually
+ # only one option that makes sense for the center (since the distance should be the same
+ # from start and end). Find the center that makes the most sense
+ sqdist_diff_min = sys.maxint
+ center = None
+ for factors in [(1, 1), (1, -1), (-1, 1), (-1, -1)]:
+
+ test_center = (start[0] + offsets[0] * factors[0], start[1] + offsets[1] * factors[1])
+
+ sqdist_start = sq_distance(start, test_center)
+ sqdist_end = sq_distance(end, test_center)
+
+ if abs(sqdist_start - sqdist_end) < sqdist_diff_min:
+ center = test_center
+ sqdist_diff_min = abs(sqdist_start - sqdist_end)
+
+ return center
+ else:
+ return (start[0] + offsets[0], start[1] + offsets[1])
def _evaluate_aperture(self, stmt):
self.aperture = stmt.d
diff --git a/gerber/tests/golden/example_two_square_boxes.png b/gerber/tests/golden/example_two_square_boxes.png
new file mode 100644
index 0000000..4732995
--- /dev/null
+++ b/gerber/tests/golden/example_two_square_boxes.png
Binary files differ
diff --git a/gerber/tests/resources/example_two_square_boxes.gbr b/gerber/tests/resources/example_two_square_boxes.gbr
new file mode 100644
index 0000000..54a8ac1
--- /dev/null
+++ b/gerber/tests/resources/example_two_square_boxes.gbr
@@ -0,0 +1,19 @@
+G04 Ucamco ex. 1: Two square boxes*
+%FSLAX25Y25*%
+%MOMM*%
+%TF.Part,Other*%
+%LPD*%
+%ADD10C,0.010*%
+D10*
+X0Y0D02*
+G01*
+X500000Y0D01*
+Y500000D01*
+X0D01*
+Y0D01*
+X600000D02*
+X1100000D01*
+Y500000D01*
+X600000D01*
+Y0D01*
+M02* \ No newline at end of file
diff --git a/gerber/tests/test_am_statements.py b/gerber/tests/test_am_statements.py
index 0cee13d..39324e5 100644
--- a/gerber/tests/test_am_statements.py
+++ b/gerber/tests/test_am_statements.py
@@ -146,7 +146,9 @@ def test_AMOutlinePrimitive_factory():
def test_AMOUtlinePrimitive_dump():
o = AMOutlinePrimitive(4, 'on', (0, 0), [(3, 3), (3, 0), (0, 0)], 0)
- assert_equal(o.to_gerber(), '4,1,3,0,0,3,3,3,0,0,0,0*')
+ # New lines don't matter for Gerber, but we insert them to make it easier to remove
+ # For test purposes we can ignore them
+ assert_equal(o.to_gerber().replace('\n', ''), '4,1,3,0,0,3,3,3,0,0,0,0*')
def test_AMOutlinePrimitive_conversion():
o = AMOutlinePrimitive(4, 'on', (0, 0), [(25.4, 25.4), (25.4, 0), (0, 0)], 0)
@@ -229,30 +231,31 @@ def test_AMMoirePrimitive_conversion():
assert_equal(m.crosshair_length, 25.4)
def test_AMThermalPrimitive_validation():
- assert_raises(ValueError, AMThermalPrimitive, 8, (0.0, 0.0), 7, 5, 0.2)
- assert_raises(TypeError, AMThermalPrimitive, 7, (0.0, '0'), 7, 5, 0.2)
+ assert_raises(ValueError, AMThermalPrimitive, 8, (0.0, 0.0), 7, 5, 0.2, 0.0)
+ assert_raises(TypeError, AMThermalPrimitive, 7, (0.0, '0'), 7, 5, 0.2, 0.0)
def test_AMThermalPrimitive_factory():
- t = AMThermalPrimitive.from_gerber('7,0,0,7,6,0.2*')
+ t = AMThermalPrimitive.from_gerber('7,0,0,7,6,0.2,45*')
assert_equal(t.code, 7)
assert_equal(t.position, (0, 0))
assert_equal(t.outer_diameter, 7)
assert_equal(t.inner_diameter, 6)
assert_equal(t.gap, 0.2)
+ assert_equal(t.rotation, 45)
def test_AMThermalPrimitive_dump():
- t = AMThermalPrimitive.from_gerber('7,0,0,7,6,0.2*')
- assert_equal(t.to_gerber(), '7,0,0,7.0,6.0,0.2*')
+ t = AMThermalPrimitive.from_gerber('7,0,0,7,6,0.2,30*')
+ assert_equal(t.to_gerber(), '7,0,0,7.0,6.0,0.2,30.0*')
def test_AMThermalPrimitive_conversion():
- t = AMThermalPrimitive(7, (25.4, 25.4), 25.4, 25.4, 25.4)
+ t = AMThermalPrimitive(7, (25.4, 25.4), 25.4, 25.4, 25.4, 0.0)
t.to_inch()
assert_equal(t.position, (1., 1.))
assert_equal(t.outer_diameter, 1.)
assert_equal(t.inner_diameter, 1.)
assert_equal(t.gap, 1.)
- t = AMThermalPrimitive(7, (1, 1), 1, 1, 1)
+ t = AMThermalPrimitive(7, (1, 1), 1, 1, 1, 0)
t.to_metric()
assert_equal(t.position, (25.4, 25.4))
assert_equal(t.outer_diameter, 25.4)
diff --git a/gerber/tests/test_cairo_backend.py b/gerber/tests/test_cairo_backend.py
new file mode 100644
index 0000000..e298439
--- /dev/null
+++ b/gerber/tests/test_cairo_backend.py
@@ -0,0 +1,58 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Author: Garret Fick <garret@ficksworkshop.com>
+import io
+import os
+
+from ..render.cairo_backend import GerberCairoContext
+from ..rs274x import read, GerberFile
+from .tests import *
+
+
+TWO_BOXES_FILE = os.path.join(os.path.dirname(__file__),
+ 'resources/example_two_square_boxes.gbr')
+TWO_BOXES_EXPECTED = os.path.join(os.path.dirname(__file__),
+ 'golden/example_two_square_boxes.png')
+
+def test_render_polygon():
+
+ _test_render(TWO_BOXES_FILE, TWO_BOXES_EXPECTED)
+
+def _test_render(gerber_path, png_expected_path, create_output_path = None):
+ """Render the gerber file and compare to the expected PNG output.
+
+ Parameters
+ ----------
+ gerber_path : string
+ Path to Gerber file to open
+ png_expected_path : string
+ Path to the PNG file to compare to
+ create_output : string|None
+ If not None, write the generated PNG to the specified path.
+ This is primarily to help with
+ """
+
+ gerber = read(gerber_path)
+
+ # Create PNG image to the memory stream
+ ctx = GerberCairoContext()
+ gerber.render(ctx)
+
+ actual_bytes = ctx.dump(None)
+
+ # If we want to write the file bytes, do it now. This happens
+ if create_output_path:
+ with open(create_output_path, 'wb') as out_file:
+ out_file.write(actual_bytes)
+ # Creating the output is dangerous - it could overwrite the expected result.
+ # So if we are creating the output, we make the test fail on purpose so you
+ # won't forget to disable this
+ assert_false(True, 'Test created the output %s. This needs to be disabled to make sure the test behaves correctly' % (create_output_path,))
+
+ # Read the expected PNG file
+
+ with open(png_expected_path, 'rb') as expected_file:
+ expected_bytes = expected_file.read()
+
+ assert_equal(expected_bytes, actual_bytes)
diff --git a/gerber/tests/test_cam.py b/gerber/tests/test_cam.py
index 00a8285..3ae0a24 100644
--- a/gerber/tests/test_cam.py
+++ b/gerber/tests/test_cam.py
@@ -113,10 +113,19 @@ def test_zeros():
def test_filesettings_validation():
""" Test FileSettings constructor argument validation
"""
+
+ # absolute-ish is not a valid notation
assert_raises(ValueError, FileSettings, 'absolute-ish', 'inch', None, (2, 5), None)
+
+ # degrees kelvin isn't a valid unit for a CAM file
assert_raises(ValueError, FileSettings, 'absolute', 'degrees kelvin', None, (2, 5), None)
+
assert_raises(ValueError, FileSettings, 'absolute', 'inch', 'leading', (2, 5), 'leading')
- assert_raises(ValueError, FileSettings, 'absolute', 'inch', 'following', (2, 5), None)
+
+ # Technnically this should be an error, but Eangle files often do this incorrectly so we
+ # allow it
+ # assert_raises(ValueError, FileSettings, 'absolute', 'inch', 'following', (2, 5), None)
+
assert_raises(ValueError, FileSettings, 'absolute', 'inch', None, (2, 5), 'following')
assert_raises(ValueError, FileSettings, 'absolute', 'inch', None, (2, 5, 6), None)
diff --git a/gerber/tests/test_common.py b/gerber/tests/test_common.py
index 7c66c0f..5991e5e 100644
--- a/gerber/tests/test_common.py
+++ b/gerber/tests/test_common.py
@@ -2,6 +2,7 @@
# -*- coding: utf-8 -*-
# Author: Hamilton Kibbe <ham@hamiltonkib.be>
+from ..exceptions import ParseError
from ..common import read, loads
from ..excellon import ExcellonFile
from ..rs274x import GerberFile
@@ -31,12 +32,12 @@ def test_load_from_string():
top_copper = loads(f.read())
assert_true(isinstance(ncdrill, ExcellonFile))
assert_true(isinstance(top_copper, GerberFile))
-
+
def test_file_type_validation():
""" Test file format validation
"""
- assert_raises(TypeError, read, 'LICENSE')
+ assert_raises(ParseError, read, 'LICENSE')
diff --git a/gerber/tests/test_excellon.py b/gerber/tests/test_excellon.py
index 705adc3..cd94b0f 100644
--- a/gerber/tests/test_excellon.py
+++ b/gerber/tests/test_excellon.py
@@ -78,8 +78,9 @@ def test_conversion():
for m_tool, i_tool in zip(iter(ncdrill.tools.values()), iter(ncdrill_inch.tools.values())):
assert_equal(i_tool, m_tool)
- for m, i in zip(ncdrill.primitives,inch_primitives):
- assert_equal(m, i)
+ for m, i in zip(ncdrill.primitives, inch_primitives):
+ assert_equal(m.position, i.position, '%s not equal to %s' % (m, i))
+ assert_equal(m.diameter, i.diameter, '%s not equal to %s' % (m, i))
def test_parser_hole_count():
@@ -98,60 +99,60 @@ def test_parser_hole_sizes():
def test_parse_whitespace():
p = ExcellonParser(FileSettings())
- assert_equal(p._parse(' '), None)
+ assert_equal(p._parse_line(' '), None)
def test_parse_comment():
p = ExcellonParser(FileSettings())
- p._parse(';A comment')
+ p._parse_line(';A comment')
assert_equal(p.statements[0].comment, 'A comment')
def test_parse_format_comment():
p = ExcellonParser(FileSettings())
- p._parse('; FILE_FORMAT=9:9 ')
+ p._parse_line('; FILE_FORMAT=9:9 ')
assert_equal(p.format, (9, 9))
def test_parse_header():
p = ExcellonParser(FileSettings())
- p._parse('M48 ')
+ p._parse_line('M48 ')
assert_equal(p.state, 'HEADER')
- p._parse('M95 ')
+ p._parse_line('M95 ')
assert_equal(p.state, 'DRILL')
def test_parse_rout():
p = ExcellonParser(FileSettings())
- p._parse('G00 ')
+ p._parse_line('G00 ')
assert_equal(p.state, 'ROUT')
- p._parse('G05 ')
+ p._parse_line('G05 ')
assert_equal(p.state, 'DRILL')
def test_parse_version():
p = ExcellonParser(FileSettings())
- p._parse('VER,1 ')
+ p._parse_line('VER,1 ')
assert_equal(p.statements[0].version, 1)
- p._parse('VER,2 ')
+ p._parse_line('VER,2 ')
assert_equal(p.statements[1].version, 2)
def test_parse_format():
p = ExcellonParser(FileSettings())
- p._parse('FMAT,1 ')
+ p._parse_line('FMAT,1 ')
assert_equal(p.statements[0].format, 1)
- p._parse('FMAT,2 ')
+ p._parse_line('FMAT,2 ')
assert_equal(p.statements[1].format, 2)
def test_parse_units():
settings = FileSettings(units='inch', zeros='trailing')
p = ExcellonParser(settings)
- p._parse(';METRIC,LZ')
+ p._parse_line(';METRIC,LZ')
assert_equal(p.units, 'inch')
assert_equal(p.zeros, 'trailing')
- p._parse('METRIC,LZ')
+ p._parse_line('METRIC,LZ')
assert_equal(p.units, 'metric')
assert_equal(p.zeros, 'leading')
@@ -160,9 +161,9 @@ def test_parse_incremental_mode():
settings = FileSettings(units='inch', zeros='trailing')
p = ExcellonParser(settings)
assert_equal(p.notation, 'absolute')
- p._parse('ICI,ON ')
+ p._parse_line('ICI,ON ')
assert_equal(p.notation, 'incremental')
- p._parse('ICI,OFF ')
+ p._parse_line('ICI,OFF ')
assert_equal(p.notation, 'absolute')
@@ -170,29 +171,29 @@ def test_parse_absolute_mode():
settings = FileSettings(units='inch', zeros='trailing')
p = ExcellonParser(settings)
assert_equal(p.notation, 'absolute')
- p._parse('ICI,ON ')
+ p._parse_line('ICI,ON ')
assert_equal(p.notation, 'incremental')
- p._parse('G90 ')
+ p._parse_line('G90 ')
assert_equal(p.notation, 'absolute')
def test_parse_repeat_hole():
p = ExcellonParser(FileSettings())
p.active_tool = ExcellonTool(FileSettings(), number=8)
- p._parse('R03X1.5Y1.5')
+ p._parse_line('R03X1.5Y1.5')
assert_equal(p.statements[0].count, 3)
def test_parse_incremental_position():
p = ExcellonParser(FileSettings(notation='incremental'))
- p._parse('X01Y01')
- p._parse('X01Y01')
+ p._parse_line('X01Y01')
+ p._parse_line('X01Y01')
assert_equal(p.pos, [2.,2.])
def test_parse_unknown():
p = ExcellonParser(FileSettings())
- p._parse('Not A Valid Statement')
+ p._parse_line('Not A Valid Statement')
assert_equal(p.statements[0].stmt, 'Not A Valid Statement')
diff --git a/gerber/tests/test_layers.py b/gerber/tests/test_layers.py
new file mode 100644
index 0000000..c77084d
--- /dev/null
+++ b/gerber/tests/test_layers.py
@@ -0,0 +1,33 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Author: Hamilton Kibbe <ham@hamiltonkib.be>
+
+from .tests import *
+from ..layers import guess_layer_class, hints
+
+
+def test_guess_layer_class():
+ """ Test layer type inferred correctly from filename
+ """
+
+ # Add any specific test cases here (filename, layer_class)
+ test_vectors = [(None, 'unknown'), ('NCDRILL.TXT', 'unknown'),
+ ('example_board.gtl', 'top'),
+ ('exampmle_board.sst', 'topsilk'),
+ ('ipc-d-356.ipc', 'ipc_netlist'),]
+
+ for hint in hints:
+ for ext in hint.ext:
+ assert_equal(hint.layer, guess_layer_class('board.{}'.format(ext)))
+ for name in hint.name:
+ assert_equal(hint.layer, guess_layer_class('{}.pho'.format(name)))
+
+ for filename, layer_class in test_vectors:
+ assert_equal(layer_class, guess_layer_class(filename))
+
+
+def test_sort_layers():
+ """ Test layer ordering
+ """
+ pass
diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py
index f8a32da..a88497c 100644
--- a/gerber/tests/test_primitives.py
+++ b/gerber/tests/test_primitives.py
@@ -9,10 +9,18 @@ from operator import add
def test_primitive_smoketest():
p = Primitive()
- assert_raises(NotImplementedError, p.bounding_box)
+ try:
+ p.bounding_box
+ assert_false(True, 'should have thrown the exception')
+ except NotImplementedError:
+ pass
p.to_metric()
p.to_inch()
- p.offset(1, 1)
+ try:
+ p.offset(1, 1)
+ assert_false(True, 'should have thrown the exception')
+ except NotImplementedError:
+ pass
def test_line_angle():
""" Test Line primitive angle calculation
@@ -150,7 +158,7 @@ def test_arc_radius():
((0, 1), (1, 0), (0, 0), 1),]
for start, end, center, radius in cases:
- a = Arc(start, end, center, 'clockwise', 0)
+ a = Arc(start, end, center, 'clockwise', 0, 'single-quadrant')
assert_equal(a.radius, radius)
def test_arc_sweep_angle():
@@ -163,7 +171,7 @@ def test_arc_sweep_angle():
for start, end, center, direction, sweep in cases:
c = Circle((0,0), 1)
- a = Arc(start, end, center, direction, c)
+ a = Arc(start, end, center, direction, c, 'single-quadrant')
assert_equal(a.sweep_angle, sweep)
def test_arc_bounds():
@@ -175,12 +183,12 @@ def test_arc_bounds():
]
for start, end, center, direction, bounds in cases:
c = Circle((0,0), 1)
- a = Arc(start, end, center, direction, c)
+ a = Arc(start, end, center, direction, c, 'single-quadrant')
assert_equal(a.bounding_box, bounds)
def test_arc_conversion():
c = Circle((0, 0), 25.4, units='metric')
- a = Arc((2.54, 25.4), (254.0, 2540.0), (25400.0, 254000.0),'clockwise', c, units='metric')
+ a = Arc((2.54, 25.4), (254.0, 2540.0), (25400.0, 254000.0),'clockwise', c, 'single-quadrant', units='metric')
#No effect
a.to_metric()
@@ -203,7 +211,7 @@ def test_arc_conversion():
assert_equal(a.aperture.diameter, 1.0)
c = Circle((0, 0), 1.0, units='inch')
- a = Arc((0.1, 1.0), (10.0, 100.0), (1000.0, 10000.0),'clockwise', c, units='inch')
+ a = Arc((0.1, 1.0), (10.0, 100.0), (1000.0, 10000.0),'clockwise', c, 'single-quadrant', units='inch')
a.to_metric()
assert_equal(a.start, (2.54, 25.4))
assert_equal(a.end, (254.0, 2540.0))
@@ -212,7 +220,7 @@ def test_arc_conversion():
def test_arc_offset():
c = Circle((0, 0), 1)
- a = Arc((0, 0), (1, 1), (2, 2), 'clockwise', c)
+ a = Arc((0, 0), (1, 1), (2, 2), 'clockwise', c, 'single-quadrant')
a.offset(1, 0)
assert_equal(a.start,(1., 0.))
assert_equal(a.end, (2., 1.))
@@ -703,29 +711,30 @@ def test_obround_offset():
def test_polygon_ctor():
""" Test polygon creation
"""
- test_cases = (((0,0), 3, 5),
- ((0, 0), 5, 6),
- ((1,1), 7, 7))
- for pos, sides, radius in test_cases:
- p = Polygon(pos, sides, radius)
+ test_cases = (((0,0), 3, 5, 0),
+ ((0, 0), 5, 6, 0),
+ ((1,1), 7, 7, 45))
+ for pos, sides, radius, hole_radius in test_cases:
+ p = Polygon(pos, sides, radius, hole_radius)
assert_equal(p.position, pos)
assert_equal(p.sides, sides)
assert_equal(p.radius, radius)
+ assert_equal(p.hole_radius, hole_radius)
def test_polygon_bounds():
""" Test polygon bounding box calculation
"""
- p = Polygon((2,2), 3, 2)
+ p = Polygon((2,2), 3, 2, 0)
xbounds, ybounds = p.bounding_box
assert_array_almost_equal(xbounds, (0, 4))
assert_array_almost_equal(ybounds, (0, 4))
- p = Polygon((2,2),3, 4)
+ p = Polygon((2,2), 3, 4, 0)
xbounds, ybounds = p.bounding_box
assert_array_almost_equal(xbounds, (-2, 6))
assert_array_almost_equal(ybounds, (-2, 6))
def test_polygon_conversion():
- p = Polygon((2.54, 25.4), 3, 254.0, units='metric')
+ p = Polygon((2.54, 25.4), 3, 254.0, 0, units='metric')
#No effect
p.to_metric()
@@ -741,7 +750,7 @@ def test_polygon_conversion():
assert_equal(p.position, (0.1, 1.0))
assert_equal(p.radius, 10.0)
- p = Polygon((0.1, 1.0), 3, 10.0, units='inch')
+ p = Polygon((0.1, 1.0), 3, 10.0, 0, units='inch')
#No effect
p.to_inch()
@@ -758,7 +767,7 @@ def test_polygon_conversion():
assert_equal(p.radius, 254.0)
def test_polygon_offset():
- p = Polygon((0, 0), 5, 10)
+ p = Polygon((0, 0), 5, 10, 0)
p.offset(1, 0)
assert_equal(p.position,(1., 0.))
p.offset(0, 1)
@@ -997,7 +1006,7 @@ def test_drill_ctor():
"""
test_cases = (((0, 0), 2), ((1, 1), 3), ((2, 2), 5))
for position, diameter in test_cases:
- d = Drill(position, diameter)
+ d = Drill(position, diameter, None)
assert_equal(d.position, position)
assert_equal(d.diameter, diameter)
assert_equal(d.radius, diameter/2.)
@@ -1005,21 +1014,21 @@ def test_drill_ctor():
def test_drill_ctor_validation():
""" Test drill argument validation
"""
- assert_raises(TypeError, Drill, 3, 5)
- assert_raises(TypeError, Drill, (3,4,5), 5)
+ assert_raises(TypeError, Drill, 3, 5, None)
+ assert_raises(TypeError, Drill, (3,4,5), 5, None)
def test_drill_bounds():
- d = Drill((0, 0), 2)
+ d = Drill((0, 0), 2, None)
xbounds, ybounds = d.bounding_box
assert_array_almost_equal(xbounds, (-1, 1))
assert_array_almost_equal(ybounds, (-1, 1))
- d = Drill((1, 2), 2)
+ d = Drill((1, 2), 2, None)
xbounds, ybounds = d.bounding_box
assert_array_almost_equal(xbounds, (0, 2))
assert_array_almost_equal(ybounds, (1, 3))
def test_drill_conversion():
- d = Drill((2.54, 25.4), 254., units='metric')
+ d = Drill((2.54, 25.4), 254., None, units='metric')
#No effect
d.to_metric()
@@ -1036,7 +1045,7 @@ def test_drill_conversion():
assert_equal(d.diameter, 10.0)
- d = Drill((0.1, 1.0), 10., units='inch')
+ d = Drill((0.1, 1.0), 10., None, units='inch')
#No effect
d.to_inch()
@@ -1053,15 +1062,15 @@ def test_drill_conversion():
assert_equal(d.diameter, 254.0)
def test_drill_offset():
- d = Drill((0, 0), 1.)
+ d = Drill((0, 0), 1., None)
d.offset(1, 0)
assert_equal(d.position,(1., 0.))
d.offset(0, 1)
assert_equal(d.position,(1., 1.))
def test_drill_equality():
- d = Drill((2.54, 25.4), 254.)
- d1 = Drill((2.54, 25.4), 254.)
+ d = Drill((2.54, 25.4), 254., None)
+ d1 = Drill((2.54, 25.4), 254., None)
assert_equal(d, d1)
- d1 = Drill((2.54, 25.4), 254.2)
+ d1 = Drill((2.54, 25.4), 254.2, None)
assert_not_equal(d, d1)
diff --git a/gerber/utils.py b/gerber/utils.py
index 1c0af52..b968dc8 100644
--- a/gerber/utils.py
+++ b/gerber/utils.py
@@ -26,6 +26,7 @@ files.
# Author: Hamilton Kibbe <ham@hamiltonkib.be>
# License:
+import os
from math import radians, sin, cos
from operator import sub
@@ -219,7 +220,10 @@ def detect_file_format(data):
if 'M48' in line:
return 'excellon'
elif '%FS' in line:
- return'rs274x'
+ return 'rs274x'
+ elif ((len(line.split()) >= 2) and
+ (line.split()[0] == 'P') and (line.split()[1] == 'JOB')):
+ return 'ipc_d_356'
return 'unknown'
@@ -284,7 +288,32 @@ def rotate_point(point, angle, center=(0.0, 0.0)):
`point` rotated about `center` by `angle` degrees.
"""
angle = radians(angle)
- xdelta, ydelta = tuple(map(sub, point, center))
- x = center[0] + (cos(angle) * xdelta) - (sin(angle) * ydelta)
- y = center[1] + (sin(angle) * xdelta) - (cos(angle) * ydelta)
- return (x, y)
+
+ cos_angle = cos(angle)
+ sin_angle = sin(angle)
+
+ return (
+ cos_angle * (point[0] - center[0]) - sin_angle * (point[1] - center[1]) + center[0],
+ sin_angle * (point[0] - center[0]) + cos_angle * (point[1] - center[1]) + center[1])
+
+def nearly_equal(point1, point2, ndigits = 6):
+ '''Are the points nearly equal'''
+
+ return round(point1[0] - point2[0], ndigits) == 0 and round(point1[1] - point2[1], ndigits) == 0
+
+
+def sq_distance(point1, point2):
+
+ diff1 = point1[0] - point2[0]
+ diff2 = point1[1] - point2[1]
+ return diff1 * diff1 + diff2 * diff2
+
+
+def listdir(directory, ignore_hidden=True, ignore_os=True):
+ os_files = ('.DS_Store', 'Thumbs.db', 'ethumbs.db')
+ files = os.listdir(directory)
+ if ignore_hidden:
+ files = [f for f in files if not f.startswith('.')]
+ if ignore_os:
+ files = [f for f in files if not f in os_files]
+ return files
diff --git a/test-requirements.txt b/test-requirements.txt
index c88a55a..826da33 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -1,3 +1,4 @@
# Test requirements
+cairocffi==0.6
coverage==3.7.1
nose==1.3.4