├── pcbmode ├── utils │ ├── __init__.py │ ├── board.py │ ├── messages.py │ ├── point.py │ ├── style.py │ ├── coord_file.py │ ├── excellon.py │ ├── place.py │ ├── component.py │ ├── shape.py │ ├── bom.py │ ├── footprint.py │ ├── extract.py │ └── utils.py ├── __init__.py ├── config.py ├── stackups │ ├── two-layer.json │ └── four-layer.json ├── pcbmode_config.json ├── styles │ └── default │ │ └── layout.json └── pcbmode.py ├── .github └── FUNDING.yml ├── images └── pcbmode-logo.png ├── docs ├── source │ ├── images │ │ ├── polar-cap.png │ │ ├── fish-battery.png │ │ └── hello-solder │ │ │ └── board.png │ ├── index.rst │ ├── introduction.rst │ ├── extraction.rst │ ├── workflow.rst │ ├── pours.rst │ ├── layer-control.rst │ ├── text.rst │ ├── routing.rst │ ├── setup.rst │ ├── shapes.rst │ ├── conf.py │ ├── hello-solder.rst │ └── components.rst └── Makefile ├── SUPPORTERS.md ├── CONTRIBUTE.md ├── .gitignore ├── setup.py ├── LICENSE └── README.md /pcbmode/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pcbmode/__init__.py: -------------------------------------------------------------------------------- 1 | # Intentionally blank 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: saardrimer 4 | -------------------------------------------------------------------------------- /images/pcbmode-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boldport/pcbmode/HEAD/images/pcbmode-logo.png -------------------------------------------------------------------------------- /docs/source/images/polar-cap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boldport/pcbmode/HEAD/docs/source/images/polar-cap.png -------------------------------------------------------------------------------- /docs/source/images/fish-battery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boldport/pcbmode/HEAD/docs/source/images/fish-battery.png -------------------------------------------------------------------------------- /docs/source/images/hello-solder/board.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boldport/pcbmode/HEAD/docs/source/images/hello-solder/board.png -------------------------------------------------------------------------------- /SUPPORTERS.md: -------------------------------------------------------------------------------- 1 | People who kindly support PCBmodE with cash 2 | -- 3 | 4 | (You too can support this project via GitHub Sponsors.) 5 | 6 | **Active** 7 | 8 | * Rufus Cable, @threebytesfull 9 | 10 | **Past** 11 | -------------------------------------------------------------------------------- /pcbmode/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is used as a global config file while PCBmodE is running. 4 | # DO NOT EDIT THIS FILE 5 | 6 | cfg = {} # PCBmodE configuration 7 | brd = {} # board data 8 | stl = {} # style data 9 | pth = {} # path database 10 | msg = {} # message database 11 | stk = {} # stackup data 12 | 13 | -------------------------------------------------------------------------------- /pcbmode/utils/board.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import pcbmode.config as config 4 | from . import messages as msg 5 | from .module import Module 6 | 7 | 8 | 9 | class Board(): 10 | """ 11 | """ 12 | 13 | def __init__(self): 14 | 15 | self._module_dict = config.brd 16 | self._module_routing = config.rte 17 | module = Module(self._module_dict, 18 | self._module_routing) 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | 2 | Welcome to PCBmodE's documentation! 3 | =================================== 4 | 5 | Contents: 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | introduction 11 | workflow 12 | setup 13 | structure 14 | components 15 | shapes 16 | pours 17 | text 18 | routing 19 | extraction 20 | layer-polarity 21 | layer-control 22 | hello-solder 23 | 24 | 25 | Indices and tables 26 | ================== 27 | 28 | * :ref:`genindex` 29 | * :ref:`modindex` 30 | * :ref:`search` 31 | 32 | -------------------------------------------------------------------------------- /CONTRIBUTE.md: -------------------------------------------------------------------------------- 1 | # Contributing to PCBmodE 2 | 3 | If you'd like to contribute to PCBmodE's codebase please agree that 4 | 5 | * you license all your future contributions to this project under [CC0 1.0 Universal (CC0 1.0) Public Domain Dedication](https://creativecommons.org/publicdomain/zero/1.0), and 6 | 7 | * that if your contribution was part of work you did for an employer or a client, or while employed, you have their permission to release it. 8 | 9 | If you agree add your full name and email address that you'd use for your contributions. Then issue a pull request with this modified file before issuing a contribution pull request. 10 | 11 | ## Those who agreed: 12 | full name, email address 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | setup( 3 | name = "pcbmode", 4 | packages = find_packages(), 5 | use_scm_version=True, 6 | setup_requires=['setuptools_scm'], 7 | 8 | install_requires = ['lxml', 'pyparsing'], 9 | 10 | package_data = { 11 | 'pcbmode': ['stackups/*.json', 12 | 'styles/*/*.json', 13 | 'fonts/*.svg', 14 | 'pcbmode_config.json'], 15 | }, 16 | 17 | # metadata for upload to PyPI 18 | author = "Saar Drimer", 19 | author_email = "saardrimer@gmail.com", 20 | description = "A printed circuit board design tool with a twist", 21 | license = "MIT", 22 | keywords = "pcb svg eda pcbmode", 23 | url = "https://github.com/boldport/pcbmode", 24 | 25 | entry_points={ 26 | 'console_scripts': ['pcbmode = pcbmode.pcbmode:main'] 27 | }, 28 | zip_safe = True 29 | ) 30 | 31 | -------------------------------------------------------------------------------- /pcbmode/utils/messages.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | from __future__ import print_function 4 | 5 | import sys 6 | 7 | def info(info, newline=True): 8 | """ 9 | """ 10 | if newline == True: 11 | print("-- %s" % info) 12 | else: 13 | sys.stdout.write("-- %s" % info) 14 | 15 | 16 | def note(note, newline=True): 17 | """ 18 | """ 19 | if newline == True: 20 | print("-- NOTE: %s" % note) 21 | else: 22 | sys.stdout.write("-- NOTE: %s" % note) 23 | 24 | 25 | 26 | def subInfo(info, newline=True): 27 | """ 28 | """ 29 | if newline == True: 30 | print(" * %s" % info) 31 | else: 32 | sys.stdout.write(" * %s" % info) 33 | 34 | 35 | 36 | def error(info, error_type=None): 37 | """ 38 | """ 39 | print('-----------------------------') 40 | print('Yikes, ERROR!') 41 | print('* %s' % info) 42 | print('Solder on!') 43 | print('-----------------------------') 44 | if error_type != None: 45 | raise error_type 46 | raise Exception 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Boldport 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pcbmode/stackups/two-layer.json: -------------------------------------------------------------------------------- 1 | { 2 | "stackup": [ 3 | { 4 | "name": "top", 5 | "type": "signal-layer-surface", 6 | "stack": [ 7 | { 8 | "type": "silkscreen", 9 | "name": "silkscreen" 10 | }, 11 | { 12 | "type": "soldermask", 13 | "name": "soldermask" 14 | }, 15 | { 16 | "type": "conductor", 17 | "name": "copper", 18 | "material": "copper" 19 | } 20 | ] 21 | }, 22 | { 23 | "name": "insulator", 24 | "type": "insulator", 25 | "stack": [ 26 | { 27 | "material": "FR4" 28 | } 29 | ] 30 | }, 31 | { 32 | "name": "bottom", 33 | "type": "signal-layer-surface", 34 | "stack": [ 35 | { 36 | "type": "conductor", 37 | "name": "copper", 38 | "material": "copper" 39 | }, 40 | { 41 | "type": "soldermask", 42 | "name": "soldermask" 43 | }, 44 | { 45 | "type": "silkscreen", 46 | "name": "silkscreen" 47 | } 48 | ] 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /docs/source/introduction.rst: -------------------------------------------------------------------------------- 1 | ############ 2 | Introduction 3 | ############ 4 | 5 | What is PCBmodE? 6 | ---------------- 7 | 8 | *PCBmodE* is a Python script that takes input JSON files and converts them into an Inkscape SVG that represents a printed circuit board. *PCBmodE* can then convert the SVG it generated into Gerber and Excellon files for manufacturing. 9 | 10 | 11 | How is PCBmodE different? 12 | ------------------------- 13 | 14 | *PCBmodE* was conceived as a circuit design tool that allows the designer to put any arbitrary shape on any layer of the board; it is natively vector-based. *PCBmodE* uses open and widely used formats (SVG, JSON) together with open source tools (Python, Inkscape) without proprietary elements (Gerber is an exception). It also provides a fresh take on circuit design and opens new uses for the circuit board manufacturing medium. 15 | 16 | *PCBmodE* uses stylesheets with CSS-like syntax. This seperates 'style' from 'content', similarly to the relationship of HTML and CSS. 17 | 18 | *PCBmodE* is free and open source (MIT license). 19 | 20 | 21 | What PCBmodE isn't 22 | ------------------ 23 | 24 | *PCBmodE* is not a complete circuit design tool. It does not (currently) have a notion of schematics, have design rule checks, or support more than two layers. 25 | 26 | *PCBmodE* is 'alpha' software and isn't as user friendly as we'd like it to be, yet. 27 | -------------------------------------------------------------------------------- /docs/source/extraction.rst: -------------------------------------------------------------------------------- 1 | ########## 2 | Extraction 3 | ########## 4 | 5 | One of the common steps of the *PCBmodE* workflow is extracting information from the SVG and storing it in primary JSON files. 6 | 7 | The following will be extracted from the SVG: 8 | 9 | * Routing shapes and location 10 | * Vias' location 11 | * Components' location and rotation 12 | * Documentation elements' location 13 | * Drill index location 14 | 15 | That's it. 16 | 17 | .. note:: It's quite likely that more information will be extracted in the future to make the design process require fewer steps. Architecturally, however, the use of a GUI is meant only to assist the textual design process, not replace it. 18 | 19 | Other information needs to be entered manually with a text editor. A great tool in this process is Inkscape's built-in XML editor (open with ``SHIFT+CTRL+X``) which allows you to see the path definition of shape (the ``d`` property) and copy it over to the JSON file. 20 | 21 | .. tip:: Since some shapes (pours, silkscreen, etc.) are not extracted, it's sometimes a bit of a guesswork to get the location just right. To do that in a single iteration, use the XML editor to change the transform of the shape (press ``CTRL+ENTER`` to apply) until the position is right. Then copy over the coordinates for that shape to the JSON file. **Note** that Inkscape inverts the y-axis coordinate, so when entering it into the JSON invert it back. 22 | 23 | 24 | -------------------------------------------------------------------------------- /docs/source/workflow.rst: -------------------------------------------------------------------------------- 1 | ######## 2 | Workflow 3 | ######## 4 | 5 | *PCBmodE* was originally conceived as a tool that enables the designer to precisely define and position design elements in a text file, and not through a GUI. For practical reasons, *PCBmodE* does not have a GUI of its own, and uses an unmodified Inkscape for visual representation and some editing that cannot practically be done textually. 6 | 7 | A typical *PCBmodE* design workflow is the following: 8 | 9 | 1) Edit JSON files with a text editor 10 | 2) "Compile" the board using *PCBmodE* 11 | 3) View the generated SVG in Inkscape 12 | 13 | Then, optionally 14 | 15 | 4) Make modifications in Inkscape 16 | 5) Extract changes using *PCBmodE* 17 | 18 | and then 19 | 20 | 6) Back to step 1 or step 2 21 | 22 | or 23 | 24 | 7) Generate production files using *PCBmodE* 25 | 26 | .. note:: It is possible to design a complete circuit in a text editor without using Inkscape at all! This would only require generating, or hand crafting, SVG paths for the routing. 27 | 28 | .. tip:: Inkscape does not reload the SVG when it is regenerated by *PCBmodE*. To reload quickly, press ``ALT+f`` and the ``v``. 29 | 30 | .. tip:: Until you get used to it, the extraction process may not do what you expect, so experiment first before designing something that will disappear when you reload the SVG. It might also be practical to design in a separate Inkscape window and then copy over the shapes to the design's SVG. 31 | 32 | -------------------------------------------------------------------------------- /pcbmode/stackups/four-layer.json: -------------------------------------------------------------------------------- 1 | { 2 | "stackup": [ 3 | { 4 | "name": "top", 5 | "type": "signal-layer-surface", 6 | "stack": [ 7 | { 8 | "type": "silkscreen", 9 | "name": "silkscreen" 10 | }, 11 | { 12 | "type": "soldermask", 13 | "name": "soldermask" 14 | }, 15 | { 16 | "type": "conductor", 17 | "name": "copper", 18 | "material": "copper" 19 | } 20 | ] 21 | }, 22 | { 23 | "name": "insulator", 24 | "type": "insulator", 25 | "stack": [ 26 | { 27 | "material": "FR4" 28 | } 29 | ] 30 | }, 31 | { 32 | "name": "internal-1", 33 | "type": "signal-layer-internal", 34 | "stack": [ 35 | { 36 | "material": "copper", 37 | "name": "copper", 38 | "type": "conductor" 39 | } 40 | ] 41 | }, 42 | { 43 | "name": "insulator", 44 | "type": "insulator", 45 | "stack": [ 46 | { 47 | "material": "FR4" 48 | } 49 | ] 50 | }, 51 | { 52 | "name": "internal-2", 53 | "type": "signal-layer-internal", 54 | "stack": [ 55 | { 56 | "material": "copper", 57 | "name": "copper", 58 | "type": "conductor" 59 | } 60 | ] 61 | }, 62 | { 63 | "name": "insulator", 64 | "type": "insulator", 65 | "stack": [ 66 | { 67 | "material": "FR4" 68 | } 69 | ] 70 | }, 71 | { 72 | "name": "bottom", 73 | "type": "signal-layer-surface", 74 | "stack": [ 75 | { 76 | "type": "conductor", 77 | "name": "copper", 78 | "material": "copper" 79 | }, 80 | { 81 | "type": "soldermask", 82 | "name": "soldermask" 83 | }, 84 | { 85 | "type": "silkscreen", 86 | "name": "silkscreen" 87 | } 88 | ] 89 | } 90 | ] 91 | } 92 | -------------------------------------------------------------------------------- /docs/source/pours.rst: -------------------------------------------------------------------------------- 1 | ############ 2 | Copper pours 3 | ############ 4 | 5 | A `copper pour `_ covers the surface area of a board with copper while maintaining a certain buffer from other copper features, such as routes and pads. A 'bridge' can connect between a copper feature and a pour. 6 | 7 | Defining pours 8 | -------------- 9 | 10 | Pours are defined in their own section in the board's JSON under ``shapes`` 11 | 12 | 13 | .. code-block:: json 14 | 15 | { 16 | "shapes": { 17 | "pours": 18 | [ 19 | { 20 | "layers": [ 21 | "bottom", 22 | "top" 23 | ], 24 | "type": "layer" 25 | } 26 | ] 27 | } 28 | } 29 | 30 | 31 | The above will place a pour over the entire top and bottom layer of the board. It's possible to pour a specific shape, and that's done just like any other shape definition. 32 | 33 | .. warning:: Since *PCBmodE* does not have a netlist, those bridges need to be added manually, and careful attention needs to be paid to prevent shorts -- there's no DRC! 34 | 35 | .. tip:: Even if you're pouring over a single layer, the ``layers`` definition only accepts a list, so you'd use ``["bottom"]``, not ``"bottom"``. 36 | 37 | 38 | Defining buffers 39 | ---------------- 40 | 41 | The global settings for the buffer size between the pour and a feature is defined in the board's JSON file, as follows: 42 | 43 | .. code-block:: json 44 | 45 | "distances": { 46 | "from-pour-to": { 47 | "drill": 0.4, 48 | "outline": 0.25, 49 | "pad": 0.4, 50 | "route": 0.25 51 | } 52 | } 53 | 54 | If this block, or any of its definitions, is missing, defaults will be used. 55 | 56 | These global settings can be overridden for every shape and route. For routes, it's done using the ``pcbmode:buffer-to-pour`` definition, as described in :doc:`routing`. For shapes it's done using the ``buffer-to-pour`` definition, as described in :doc:`shapes`. 57 | 58 | 59 | -------------------------------------------------------------------------------- /pcbmode/utils/point.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | from math import pi, sin, cos 4 | import decimal 5 | 6 | import pcbmode.config as config 7 | 8 | DEG2RAD = 2 * pi / 360 9 | 10 | 11 | class Point: 12 | 13 | def __init__(self, x=0, y=0): 14 | try: 15 | self.sig_dig = config.cfg['significant-digits'] 16 | except: 17 | self.sig_dig = 8 18 | self.x = round(float(x), self.sig_dig) 19 | self.y = round(float(y), self.sig_dig) 20 | 21 | def __add__(self, p): 22 | """ add point 'p' of type Point to current point""" 23 | return Point(self.x + p.x, self.y + p.y) 24 | 25 | def __sub__(self, p): 26 | """ subtract point 'p' of type Point to current point""" 27 | return Point(self.x - p.x, self.y - p.y) 28 | 29 | def __repr__(self, d=2): 30 | """ 31 | return a string representation; 'd' determines amount 32 | of significant digits to display 33 | """ 34 | return "[%.*f, %.*f]" % (d, self.x, d, self.y) 35 | 36 | def __eq__(self, p): 37 | """ equality attribute """ 38 | return (self.x == p.x) and (self.y == p.y) 39 | 40 | def __ne__(self, p): 41 | """ not equal attribute """ 42 | return not((self.x == p.x) and (self.y == p.y)) 43 | 44 | def assign(self, x=0, y=0): 45 | self.x = round(float(x), self.sig_dig) 46 | self.y = round(float(y), self.sig_dig) 47 | return 48 | 49 | def rotate(self, deg, p): 50 | """ rotate the point in degrees around another point """ 51 | rad = deg * DEG2RAD 52 | x = self.x 53 | y = self.y 54 | self.x = (x * cos(rad) + y * sin(rad)) 55 | self.y = (x * -sin(rad) + y * cos(rad)) 56 | return 57 | 58 | def round(self, d): 59 | """ round decimal to nearest 'd' decimal digits """ 60 | self.x = round(self.x, d) 61 | self.y = round(self.y, d) 62 | return 63 | 64 | def mult(self, scalar): 65 | """ multiply by scalar """ 66 | self.x *= float(scalar) 67 | self.y *= float(scalar) 68 | return 69 | -------------------------------------------------------------------------------- /docs/source/layer-control.rst: -------------------------------------------------------------------------------- 1 | ############ 2 | Layer control 3 | ############ 4 | 5 | When opening a *PCBmodE* SVG in Inkscape, the board's layers can be manipulated by opening the layer pane (``CTRL+SHIFT+L``). Each layer can then be set to be hidden/visible or editable/locked. The default for each layer is defined in ``utils/svg.py`` 6 | 7 | .. code-block:: python 8 | 9 | layer_control = { 10 | "copper": { 11 | "hidden": False, "locked": False, 12 | "pours": { "hidden": False, "locked": True }, 13 | "pads": { "hidden": False, "locked": False }, 14 | "routing": { "hidden": False, "locked": False } 15 | }, 16 | "soldermask": { "hidden": False, "locked": False }, 17 | "solderpaste": { "hidden": True, "locked": True }, 18 | "silkscreen": { "hidden": False, "locked": False }, 19 | "assembly": { "hidden": False, "locked": False }, 20 | "documentation": { "hidden": False, "locked": False }, 21 | "dimensions": { "hidden": False, "locked": True }, 22 | "origin": { "hidden": False, "locked": True }, 23 | "drills": { "hidden": False, "locked": False }, 24 | "outline": { "hidden": False, "locked": True } 25 | } 26 | 27 | but can be overridden in the board's configuration file. So, for example, if we wish to have the solderpaste layers visible when the SVG is generated, we'd add 28 | 29 | 30 | .. code-block:: json 31 | 32 | { 33 | "layer-control": 34 | { 35 | "solderpaste": { "hidden": false, "locked": true } 36 | } 37 | } 38 | 39 | Or if we'd like the outline to be editable (instead of the default 'locked') we'd add 40 | 41 | .. code-block:: json 42 | 43 | { 44 | "layer-control": 45 | { 46 | "solderpaste": { "hidden": false, "locked": true }, 47 | "outline": { "hidden": false, "locked": false } 48 | } 49 | } 50 | 51 | 52 | 53 | .. tip:: The reason that some layers are locked by default -- 'outline' is a good example -- is because they are not edited regularly, but span the entire board so very often take focus when selecting objects. Locking them puts them out of the way until an edit is required. 54 | 55 | -------------------------------------------------------------------------------- /pcbmode/utils/style.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import pcbmode.config as config 4 | from . import messages as msg 5 | 6 | 7 | 8 | # import pcbmode modules 9 | from . import utils 10 | 11 | 12 | 13 | 14 | class Style(): 15 | """ 16 | Manages the logic for determining the style of an object 17 | based on its shape definition, 'shape_dict'. In the layout 18 | file, default 'fill' or 'stroke' styles are defined for the 19 | various layers; these will be used if otherwise not specified 20 | in the shape definition. 21 | 22 | 'sub_item' is used to be more specific within the style definition. 23 | Originally it was added for 'refdef' within silkscreen and 24 | soldermask, but could be used with other types. 25 | """ 26 | def __init__(self, shape_dict, layer_name, sub_item=None): 27 | 28 | default_style = config.stl['layout']['defaults']['style'][layer_name] 29 | if sub_item == None: 30 | layer_style = config.stl['layout'][layer_name] 31 | else: 32 | layer_style = config.stl['layout'][layer_name][sub_item] 33 | 34 | # Unless specified, 'text' will default to 'fill' on all layers. 35 | # Other 'types' depend on the layers they are on. 36 | if shape_dict.get('style') == None: 37 | if shape_dict['type'] in ['text', 'refdef']: 38 | self._style = 'fill' 39 | else: 40 | self._style = default_style 41 | else: 42 | self._style = shape_dict['style'] 43 | 44 | try: 45 | self._style_dict = layer_style.get(self._style).copy() 46 | except: 47 | self._style_dict = None 48 | 49 | # Apply defaults if style dict wasn't found 50 | if self._style_dict == None: 51 | if self._style == 'fill': 52 | self._style_dict = {"stroke": "none"} 53 | elif self._style == 'stroke': 54 | self._style_dict = {"fill": "none"} 55 | else: 56 | msg.error("Encountered an unknown 'style' type, %s" % self._style) 57 | 58 | # If the style is 'stroke' we need to override the default 'stroke-width' 59 | # setting with a possible custom definition 60 | if self._style == 'stroke': 61 | self._style_dict['stroke-width'] = (shape_dict.get('stroke-width') or 62 | layer_style[self._style].get('stroke-width')) 63 | self._style_dict['fill'] = "none" 64 | 65 | 66 | def getStyleType(self): 67 | return self._style 68 | 69 | 70 | def getStyleString(self): 71 | return utils.dictToStyleText(self._style_dict) 72 | 73 | 74 | def getStrokeWidth(self): 75 | return self._style_dict['stroke-width'] 76 | -------------------------------------------------------------------------------- /docs/source/text.rst: -------------------------------------------------------------------------------- 1 | #### 2 | Text 3 | #### 4 | 5 | One of the unique features of *PCBmodE* is that any font -- as long as it is in SVG form -- can be used for any text on the board. 6 | 7 | Fonts 8 | ----- 9 | 10 | SVG fonts have an SVG path for every glyph, and other useful information about how to place the font so the glyphs align. *PCBmodE* uses that information to place text on the board's layers. 11 | 12 | The folder in which *PCBmodE* looks for a font is defined in the the configuration file ``pcbmode_config.json``. 13 | 14 | .. code-block:: json 15 | 16 | { 17 | "locations": 18 | { 19 | "boards": "boards/", 20 | "components": "components/", 21 | "fonts": "fonts/", 22 | "build": "build/", 23 | "styles": "styles/" 24 | } 25 | } 26 | 27 | When looking for a font file, *PCBmodE* will first look at the local project folder and then where ``pcbmode.py`` is. 28 | 29 | .. tip:: When you find a font that you'd like to use, search for an SVG version of it. Many fonts at http://www.fontsquirrel.com have an SVG version for download. 30 | 31 | 32 | Defining text 33 | ------------- 34 | 35 | A text definition looks like the following 36 | 37 | .. code-block:: json 38 | 39 | { 40 | "type": "text", 41 | "layers": ["bottom"], 42 | "font-family": "Overlock-Regular-OTF-webfont", 43 | "font-size": "1.5mm", 44 | "letter-spacing": "0mm", 45 | "line-height": "1.5mm", 46 | "location": [ 47 | -32.39372, 48 | -33.739699 49 | ], 50 | "rotate": 0, 51 | "style": "fill", 52 | "value": "Your text\nhere!" 53 | } 54 | 55 | type 56 | ``text``: place a text element 57 | layers (optional; default ``["top"]``) 58 | list: layers to place the shape on (even if placing on a single layer, the definition needs to be in a form of a list) 59 | font-family 60 | text: The name of the font file, without the ``.svg`` 61 | font-size 62 | float: font size in mm (the ``mm`` must be present) 63 | value 64 | text: the text to display; use ``\n`` for newline 65 | letter-spacing (optional; default ``0mm``) 66 | float: positive/negative value increases/decreases the spacing. ``0mm`` maintains the natural spacing defined by the font 67 | line-height (optional; defaults to ``font-size``) 68 | float: the distance between lines; a negative value is allowed 69 | location (optional; default ``[0, 0]``) 70 | list: ``x`` and ``y`` to place the *center* of the text object 71 | rotate (optional; default ``0``) 72 | float: rotation, clock-wise degrees 73 | style (optional; default depends on sheet) 74 | ``stroke`` or ``fill``: style of the shape 75 | stroke-width (optional; default depends on sheet; ignored unless ``style`` is ``stroke``) 76 | float: stroke width 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /docs/source/routing.rst: -------------------------------------------------------------------------------- 1 | ####### 2 | Routing 3 | ####### 4 | 5 | Routing, of course, is an essential part of a circuit board. *PCBmodE* does not have an auto-router, and routing is typically done in Inkscape, although theoretically, routing can be added manually in a text editor. All routing shapes reside in the routing SVG layer of each PCB layer. 6 | 7 | .. important:: Make sure that you place the routes and vias on the routing SVG layer of the desired PCB layer. To choose that layer either click on an element in the layer or open the layer pane by pressing ``CTRL+SHIFT+L``. 8 | 9 | .. important:: In order to place routes, make sure that Inkscape is set to 'optimise' paths by going to ``File->Inkscape Preferences->Transforms`` and choosing ``optimised`` under ``Store transformation``. 10 | 11 | 12 | Adding routes 13 | ------------- 14 | 15 | Choose the desired routing SVG layer. Using the Bezier tool (``SHIFT+F6``) to draw a shape. 16 | 17 | For a filled shape, make sure that it is a closed path and in the ``Fill and stroke`` pane (``SHIFT+CTRL+F``) click on the ``flat color`` button on the ``Fill`` tab, and the ``No paint`` (marked with an ``X``) on the ``Stroke point`` tab. 18 | 19 | For a stroke, in the ``Fill and stroke`` pane (``SHIFT+CTRL+F``) click on the ``No paint`` button on the ``Fill`` tab, and the ``Flat color`` on the ``Stroke point`` tab. Adjust the stroke thickness on the ``Stroke style`` tab. 20 | 21 | .. note:: Shapes can be either stroke or fill, not both. If you'd like a filled and stroked shape, you'll need to create two shapes. 22 | 23 | Finally, you *must* move the shape with the mouse or with the arrows. 24 | 25 | .. note:: When creating a new shape Inkscape adds a matrix transform, which is removed when the shape is moved because of the ``optimise`` settings as described above. This minor inconvenience is a compromise that greatly simplifies the extraction process. 26 | 27 | If the route is placed where there is a copper pour, it will automatically have a buffer around it that's defined in the board's configuration. Sometimes, it is desirable to reduce or increase this buffer, or eliminate it completely in order to create a bridge (for example when connecting a via to a pour). This is how it is done: 28 | 29 | 1) Choose the route 30 | 2) Open Inkscape's XML editor (``SHIFT+CTRL+X``) 31 | 3) On the bottom right, next to ``set`` remove what's there and type in ``pcbmode:buffer-to-pour`` 32 | 4) In the box below type in the buffer in millimeters (don't add 'mm') that you'd like, or ``0`` for none 33 | 5) Press ``set`` or ``CTRL+ENTER`` to save that property 34 | 35 | .. tip:: Once you've created one route, you can simply cut-and-paste it and edit it using the node tool without an additional settings. You can even cut-and-paste routes from a different design. 36 | 37 | 38 | Adding vias 39 | ----------- 40 | 41 | Vias are components just like any other. There are placed just like other components, but in the routing file ``_routing.json", not the main board's JSON. 42 | 43 | .. code-block:: json 44 | { 45 | "vias": { 46 | "362835dd0": { 47 | "footprint": "via", 48 | "layer": "top", 49 | "location": [ 50 | -8, 51 | -0.883744 52 | ] 53 | } 54 | } 55 | } 56 | 57 | You can assign a unique key to the via, but that will be over-written by a hash when extracted. 58 | 59 | .. note:: Since vias are components, anything could be a via, so if it makes sense to place a 2x2 0.1" header as a "via", that's possible. 60 | 61 | .. important:: Don't forget to extract the changes! 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![PCBmodE logo](/images/pcbmode-logo.png) 2 | 3 | # A circuit board design software with a twist 4 | 5 | PCBmodE is a circuit board design software written in Python. Its main advantage is allowing the design to create and use arbitrary shapes for any element of the board. Using stock [Inkscape](http://inkscape.org) as the GUI provides all the features of a drawing tool. This, in contrast to traditional PCB design tools that restrict visual freedom and don't have the full feature set of a vector editing software. 6 | 7 | ## Workflow 8 | 9 | PCBmodE folows a layout-driven design flow. There's no schematic functionality or DRC other than the designer's eyes. It's essentiallya script that runs from commandline generating files depending on the stage of the design. 10 | 11 | 1. Text editor: edit input [JSON](http://en.wikipedia.org/wiki/JSON) files 12 | 2. PCBmodE: convert JSON files to Inkscape SVG 13 | 3. Inkscape: edit SVG (component movement, routing, etc.) 14 | 4. PCBmodE: extract changes and apply them back to input JSON files 15 | 16 | Iterate the above until the design is ready, and then 17 | 18 | 1. PCBmodE: create [Gerber](http://en.wikipedia.org/wiki/Gerber_format) files from SVG 19 | 2. Send to fab 20 | 3. Get lovely boards back and impress people on Twitter 21 | 22 | ## Requirements 23 | 24 | * Python 2.7 25 | * [PyParsing](http://pyparsing.wikispaces.com/) 26 | * [lxml](http://lxml.de/) 27 | * [Inkscape](http://inkscape.org) 28 | 29 | PCBmodE is developed and tested under Linux, so it might or might not work under other OSs. 30 | 31 | ### Resources 32 | * [Documentation](http://pcbmode.readthedocs.org) (needs serious updating!) 33 | * Boldport Club Discord [#pcbmode](https://discordapp.com/channels/422844882315640832/422881024796786708) (you need to be a [Boldport Club](https://boldport.com/club) member to have access) 34 | 35 | ## Roadmap 36 | 37 | PCBmodE was written and is maintained by Saar Drimer of [Boldport](https://boldport.com). It has been used to design all of Boldport's [products](https://boldport.com/shop) since 2013. It is, therefore, very functional but sadly not that well documented and development happens in bursts. 38 | 39 | The next version of PCBmodE is v5, codename 'cinco'. For this release I'd like to get the following done: 40 | * Package PCBmodE so it's easy to install 41 | * Update and maintain the documentation 42 | * Migrate to Python3 (drop support for Python2) 43 | * Work with Inkscape 1.x (drop support for previous versions) 44 | * Improve performance and do better than the current simple caching method 45 | * Implement the latest Gerber X2 standard 46 | * Automatic 'gerber-lp' generation from SVG shapes 47 | * Support any font form Google Fonts 48 | * Fully support SVG paths (currently 'l' is buggy and 'a' isn't supported) 49 | 50 | New desired extensions: 51 | * Footprint creation wizard 52 | * New project wizard 53 | * Browser viewer of PCBmodE SVGs 54 | * Inkscape add-on to invoke PCBmodE functions from within Inkscape 55 | 56 | ## Contributing 57 | 58 | For contributing code, see the CONTRIBUTE.md included in this repository. 59 | 60 | If you'd like to contribute _towards_ development in the form of hard cold electronic money, see the end of [this](https://boldport.com/pcbmode) page. 61 | 62 | ## The name 63 | The 'mod' in PCBmodE has a double meaning. The first is short for 'modern' (in contrast to tired old EDA tools). The second is a play on the familiar 'modifications' or 'mods' done to imperfect PCBs. Call it 'PCB mode' or 'PCB mod E', whichever you prefer. 64 | 65 | ## License 66 | PCBmodE is licensed under the [MIT License](http://opensource.org/licenses/MIT). 67 | -------------------------------------------------------------------------------- /pcbmode/utils/coord_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | #import json 4 | import os 5 | import re 6 | import pcbmode.config as config 7 | 8 | from . import utils 9 | from . import messages as msg 10 | from .shape import Shape 11 | from .style import Style 12 | 13 | 14 | def makeCoordFile(arg=False): 15 | """ 16 | 17 | """ 18 | 19 | 20 | def _getOutline(): 21 | """ 22 | Process the module's outline shape. Modules don't have to have an outline 23 | defined, so in that case return None. 24 | """ 25 | shape = None 26 | 27 | outline_dict = config.brd.get('outline') 28 | if outline_dict != None: 29 | shape_dict = outline_dict.get('shape') 30 | if shape_dict != None: 31 | shape = Shape(shape_dict) 32 | style = Style(shape_dict, 'outline') 33 | shape.setStyle(style) 34 | 35 | return shape 36 | 37 | # A dict of the components 38 | comp_dict = config.brd['components'] 39 | 40 | # Get the board's dimensions 41 | # This gives the bounding box of the shape 42 | outline = _getOutline() 43 | board_width = outline.getWidth() 44 | board_height = outline.getHeight() 45 | 46 | board_name = config.cfg['name'] 47 | board_revision = config.brd['config'].get('rev') 48 | base_name = "%s_rev_%s" % (board_name, board_revision) 49 | 50 | coord_path = os.path.join(config.cfg['base-dir'], 51 | config.cfg['locations']['build'], 52 | 'production') 53 | 54 | coord_csv_c = os.path.join(coord_path, base_name + '_%s_%s.csv'% ('coord_file', 'CENTRE')) 55 | coord_csv_bl = os.path.join(coord_path, base_name + '_%s_%s.csv'% ('coord_file', 'BOTTOM_LEFT')) 56 | 57 | # To print as the file's header 58 | preamble = ("This file was auto-generated by PCBmodE.\n\nThe coordinates are relative to the %s of the board outline shape\n\nRotation is in degrees, clock-wise, and as seen from the\ntop or EACH layer. Confirm with ASSEMBLY DRAWING for correctness.\n\nAll dimension are in MILLIMETERS (mm).\n\n") 59 | 60 | # CSV header 61 | header = "%s,%s,%s,%s,%s" % ('Designator', 62 | 'Placement-layer', 63 | 'Coord-X(mm)', 64 | 'Coord-Y(mm)', 65 | 'Rotation(deg)') 66 | 67 | # Create coordinate file with centre as origin 68 | # as it is in the board's json 69 | with open(coord_csv_c, "wb") as f: 70 | f.write(preamble % 'CENTRE'+'\n') 71 | f.write(header+'\n') 72 | for refdef in comp_dict: 73 | ent = comp_dict[refdef] 74 | f.write("%s,%s,%s,%s,%s\n" % (refdef, 75 | ent['layer'], 76 | ent['location'][0], 77 | ent['location'][1], 78 | ent['rotate'])) 79 | 80 | 81 | 82 | # Create coordinate file with bottom left as origin 83 | with open(coord_csv_bl, "wb") as f: 84 | f.write(preamble % 'BOTTOM LEFT'+'\n') 85 | f.write(header+'\n') 86 | for refdef in comp_dict: 87 | ent = comp_dict[refdef] 88 | f.write("%s,%s,%s,%s,%s\n" % (refdef, 89 | ent['layer'], 90 | board_width/2 + ent['location'][0], 91 | board_height/2 + ent['location'][1], 92 | ent['rotate'])) 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /docs/source/setup.rst: -------------------------------------------------------------------------------- 1 | ##### 2 | Setup 3 | ##### 4 | 5 | *PCBmodE* is written and tested with Python 2.7 under Linux. It may or may not work on other operating systems or later versions of Python. With time 'official' support for Windows/MAC will be added. 6 | 7 | It comes in the form of a installable tool called `pcbmode` which is 8 | run from the command line. 9 | 10 | What you'll need 11 | ================ 12 | 13 | * Python 2.7 14 | * Inkscape 15 | * Text editor 16 | 17 | Installation from Source with Virtualenv 18 | ======================================== 19 | 20 | Virualenv is a Python tool that makes it easy to keep applications in 21 | their own isolated environments. As a bonus, root permissions are not 22 | required. This can come useful when running experimental versions of 23 | PCBmodE. 24 | 25 | These instructions describe how to build PCBmodE for use in a 26 | virtualenv. To be able to build python-lxml (one of PCBmodE's 27 | dependencies) you need to install some system-level development 28 | packages. On Debian based systems these are installed like this: 29 | 30 | .. code-block:: bash 31 | 32 | sudo apt-get install libxml2-dev libxslt1-dev python-dev 33 | 34 | Fetch the *PCBModE* source. Stable snapshots are available at 35 | `https://github.com/boldport/pcbmode/releases 36 | `_. The latest 37 | development sources are available via git: 38 | 39 | .. code-block:: bash 40 | 41 | git clone https://github.com/boldport/pcbmode.git 42 | 43 | After putting PCBmodE in a directory called `pcbmode`, run these 44 | commands to create a virtualenv in the directory `pcbmode-env/` next 45 | to it, and install PCBmodE in the virtualenv. 46 | 47 | .. code-block:: bash 48 | 49 | virtualenv pcbmode-env 50 | source pcbmode-env/bin/activate 51 | cd pcbmode 52 | python setup.py install 53 | 54 | After installation, PCBmodE will be available in your path as 55 | ``pcbmode``. But since it was installed in a virtualenv, the 56 | ``pcbmode`` command will only be available in your path after running 57 | ``pcbmode-env/bin/activate`` and will no longer be in your path after 58 | running ``deactivate``. You will need to activate the virtualenv each 59 | time you want to run `pcbmode` from a new terminal window. 60 | 61 | Nothing is installed globally, so to start from scratch you can just follow these steps: 62 | 63 | .. code-block:: bash 64 | 65 | deactivate # skip if pcbmode-env is not active 66 | rm -r pcbmode-env 67 | cd pcbmode 68 | git clean -dfX # erases any untracked files (build files etc). save your work! 69 | 70 | Running PCBmodE 71 | =============== 72 | 73 | .. tip:: To see all the options that *PCBmodE* supports, use ``pcbmode --help`` 74 | 75 | By default *PCBmodE* expects to find the board files under 76 | 77 | boards/ 78 | 79 | relative to the place where it is invoked. 80 | 81 | .. tip:: Paths where *PCBmodE* looks for thing can be changed in the config file ``pcbmode_config.json`` 82 | 83 | Here's one way to organise the build environment 84 | 85 | cool-pcbs/ 86 | PCBmodE/ 87 | boards/ 88 | hello-solder/ 89 | hello-solder.json 90 | hello-solder_routing.json 91 | components/ 92 | ... 93 | cordwood/ 94 | ... 95 | 96 | 97 | To make the ``hello-solder`` board, run *PCBmodE* within ``cool-pcbs`` 98 | 99 | pcbmode -b hello-solder -m 100 | 101 | Then open the SVG with Inkscape 102 | 103 | inkscape cool-pcbs/boards/hello-solder/build/hello-solder.svg 104 | 105 | If the SVG opens you're good to go! 106 | 107 | .. note:: *PCBmodE* processes a lot of shapes on the first time it is run, so it will take a noticeable time. This time will be dramatically reduced on subsequent invocations since *PCBmodE* caches the shapes in a datafile within the project's build directory. 108 | 109 | 110 | -------------------------------------------------------------------------------- /pcbmode/utils/excellon.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import re 5 | from lxml import etree as et 6 | 7 | import pcbmode.config as config 8 | from . import messages as msg 9 | 10 | # pcbmode modules 11 | from . import utils 12 | from .point import Point 13 | 14 | 15 | 16 | def makeExcellon(manufacturer='default'): 17 | """ 18 | """ 19 | 20 | ns = {'pcbmode':config.cfg['ns']['pcbmode'], 21 | 'svg':config.cfg['ns']['svg']} 22 | 23 | # Open the board's SVG 24 | svg_in = utils.openBoardSVG() 25 | drills_layer = svg_in.find("//svg:g[@pcbmode:sheet='drills']", 26 | namespaces=ns) 27 | 28 | excellon = Excellon(drills_layer) 29 | 30 | # Save to file 31 | base_dir = os.path.join(config.cfg['base-dir'], 32 | config.cfg['locations']['build'], 33 | 'production') 34 | base_name = "%s_rev_%s" % (config.brd['config']['name'], 35 | config.brd['config']['rev']) 36 | 37 | filename_info = config.cfg['manufacturers'][manufacturer]['filenames']['drills'] 38 | 39 | add = '_%s.%s' % ('drills', 40 | filename_info['plated'].get('ext') or 'txt') 41 | filename = os.path.join(base_dir, base_name + add) 42 | 43 | with open(filename, "wb") as f: 44 | for line in excellon.getExcellon(): 45 | f.write(line) 46 | 47 | 48 | 49 | 50 | 51 | class Excellon(): 52 | """ 53 | """ 54 | 55 | def __init__(self, svg): 56 | """ 57 | """ 58 | 59 | self._svg = svg 60 | 61 | self._ns = {'pcbmode':config.cfg['ns']['pcbmode'], 62 | 'svg':config.cfg['ns']['svg']} 63 | 64 | # Get all drill paths except for the ones used in the 65 | # drill-index 66 | drill_paths = self._svg.findall(".//svg:g[@pcbmode:type='component-shapes']//svg:path", 67 | namespaces=self._ns) 68 | 69 | drills_dict = {} 70 | for drill_path in drill_paths: 71 | diameter = drill_path.get('{'+config.cfg['ns']['pcbmode']+'}diameter') 72 | location = self._getLocation(drill_path) 73 | if diameter not in drills_dict: 74 | drills_dict[diameter] = {} 75 | drills_dict[diameter]['locations'] = [] 76 | drills_dict[diameter]['locations'].append(location) 77 | 78 | self._preamble = self._createPreamble() 79 | self._content = self._createContent(drills_dict) 80 | self._postamble = self._createPostamble() 81 | 82 | 83 | def getExcellon(self): 84 | return (self._preamble+ 85 | self._content+ 86 | self._postamble) 87 | 88 | 89 | 90 | def _createContent(self, drills): 91 | """ 92 | """ 93 | ex = [] 94 | for i, diameter in enumerate(drills): 95 | # This is probably not necessary, but I'm not 100% certain 96 | # that if the item order of a dict is gurenteed. If not 97 | # the result can be quite devastating where drill 98 | # diameters are wrong! 99 | # Drill index must be greater than 0 100 | drills[diameter]['index'] = i+1 101 | ex.append("T%dC%s\n" % (i+1, float(diameter))) 102 | 103 | ex.append('M95\n') # End of a part program header 104 | 105 | for diameter in drills: 106 | ex.append("T%s\n" % drills[diameter]['index']) 107 | for coord in drills[diameter]['locations']: 108 | ex.append(self._getPoint(coord)) 109 | 110 | return ex 111 | 112 | 113 | 114 | def _createPreamble(self): 115 | """ 116 | """ 117 | ex = [] 118 | ex.append('M48\n') # Beginning of a part program header 119 | ex.append('METRIC,TZ\n') # Metric, trailing zeros 120 | ex.append('G90\n') # Absolute mode 121 | ex.append('M71\n') # Metric measuring mode 122 | return ex 123 | 124 | 125 | 126 | def _createPostamble(self): 127 | """ 128 | """ 129 | ex = [] 130 | ex.append('M30\n') # End of Program, rewind 131 | return ex 132 | 133 | 134 | 135 | def _getLocation(self, path): 136 | """ 137 | Returns the location of a path, factoring in all the transforms of 138 | its ancestors, and its own transform 139 | """ 140 | 141 | location = Point() 142 | 143 | # We need to get the transforms of all ancestors that have 144 | # one in order to get the location correctly 145 | ancestors = path.xpath("ancestor::*[@transform]") 146 | for ancestor in ancestors: 147 | transform = ancestor.get('transform') 148 | transform_data = utils.parseTransform(transform) 149 | # Add them up 150 | location += transform_data['location'] 151 | 152 | # Add the transform of the path itself 153 | transform = path.get('transform') 154 | if transform != None: 155 | transform_data = utils.parseTransform(transform) 156 | location += transform_data['location'] 157 | 158 | return location 159 | 160 | 161 | 162 | 163 | def _getPoint(self, point): 164 | """ 165 | Converts a Point type into an Excellon coordinate 166 | """ 167 | return "X%.6fY%.6f\n" % (point.x, -point.y) 168 | 169 | 170 | 171 | -------------------------------------------------------------------------------- /pcbmode/pcbmode_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "locations": 3 | { 4 | "boards": "boards/", 5 | "components": "components/", 6 | "fonts": "fonts/", 7 | "build": "build/", 8 | "styles": "styles/", 9 | "stackups": "stackups/", 10 | "shapes": "shapes/" 11 | }, 12 | "distances": 13 | { 14 | "from-pour-to": 15 | { 16 | "outline": 0.5, 17 | "drill": 0.3, 18 | "pad": 0.2, 19 | "route": 0.25 20 | }, 21 | "soldermask": 22 | { 23 | "path-scale": 1.05, 24 | "rect-buffer": 0.05, 25 | "circle-buffer": 0.05 26 | }, 27 | "solderpaste": 28 | { 29 | "path-scale": 0.9, 30 | "rect-buffer": -0.1, 31 | "circle-buffer": -0.1 32 | } 33 | }, 34 | "refdef-index": 35 | { 36 | "common": 37 | { 38 | "AT": "Attenuator", 39 | "BR": "Bridge rectifier", 40 | "BT": "Battery", 41 | "C": "Capacitor", 42 | "CN": "Capacitor network", 43 | "D": "Diode", 44 | "DL": "Delay line", 45 | "DS": "Display", 46 | "F": "Fuse", 47 | "FB": "Ferrite bead", 48 | "FD": "Fiducial", 49 | "J": "Jack connector", 50 | "JP": "Link (Jumper)", 51 | "K": "Relay", 52 | "L": "Inductor", 53 | "LS": "Loudspeaker or buzzer", 54 | "M": "Motor", 55 | "MK": "Microphone", 56 | "MP": "Mechanical part", 57 | "P": "Plug connector", 58 | "PS": "Power supply", 59 | "Q": "Transistor", 60 | "R": "Resistor", 61 | "RN": "Resistor network", 62 | "RT": "Thermistor", 63 | "RV": "Varistor", 64 | "S": "Switch", 65 | "T": "Transformer", 66 | "TC": "Thermocouple", 67 | "TUN": "Tuner", 68 | "TP": "Test point", 69 | "U": "Integrated circuit", 70 | "V": "Vacuum tube", 71 | "VR": "Variable resistor", 72 | "X": "Transducer", 73 | "Y": "Crystal / oscillator", 74 | "Z": "Zener diode" 75 | }, 76 | "pcbmode": 77 | { 78 | "BJT": "BJT", 79 | "C": "Capacitor", 80 | "D": "Diode", 81 | "F": "Fuse", 82 | "FB": "Ferrite bead", 83 | "FD": "Fiducial", 84 | "FET": "FET", 85 | "IC": "Integrated circuit", 86 | "J": "Connector", 87 | "L": "Inductor", 88 | "LED": "LED", 89 | "MH": "Mounting hole", 90 | "MIC": "Microphone", 91 | "PB": "Pushbutton", 92 | "POT": "Potentiometer", 93 | "R": "Resistor", 94 | "REL": "Relay", 95 | "SKD": "Schottkey diode", 96 | "SW": "Switch", 97 | "TP": "Test point", 98 | "TVS": "TVS", 99 | "ZEN": "Zener diode" 100 | } 101 | }, 102 | "manufacturers": 103 | { 104 | "default": 105 | { 106 | "filenames": 107 | { 108 | "gerbers": 109 | { 110 | "top": 111 | { 112 | "conductor": {"ext": "ger"}, 113 | "soldermask": {"ext": "ger"}, 114 | "silkscreen": {"ext": "ger"}, 115 | "solderpaste": {"ext": "ger"} 116 | }, 117 | "internal": 118 | { 119 | "conductor": {"ext": "ger"} 120 | }, 121 | "bottom": 122 | { 123 | "conductor": {"ext": "ger"}, 124 | "soldermask": {"ext": "ger"}, 125 | "silkscreen": {"ext": "ger"}, 126 | "solderpaste": {"ext": "ger"} 127 | }, 128 | "other": 129 | { 130 | "documentation": {"ext": "ger"}, 131 | "outline": {"ext": "ger"} 132 | } 133 | }, 134 | "drills": 135 | { 136 | "plated": {"ext": "txt"}, 137 | "non-plated": {"ext": "txt"} 138 | }, 139 | "text": 140 | { 141 | "readme": {"ext": "txt"} 142 | } 143 | } 144 | }, 145 | "oshpark": 146 | { 147 | "filenames": 148 | { 149 | "gerbers": 150 | { 151 | "top": 152 | { 153 | "conductor": {"ext": "GTL"}, 154 | "soldermask": {"ext": "GTS"}, 155 | "silkscreen": {"ext": "GTO"}, 156 | "solderpaste": {"ext": "GTP"} 157 | }, 158 | "internal": 159 | { 160 | "conductor": {"ext": "ger"} 161 | }, 162 | "bottom": 163 | { 164 | "conductor": {"ext": "GBL"}, 165 | "soldermask": {"ext": "GBS"}, 166 | "silkscreen": {"ext": "GBO"}, 167 | "solderpaste": {"ext": "GBP"} 168 | }, 169 | "other": 170 | { 171 | "documentation": {"ext": "GBR"}, 172 | "outline": {"ext": "GKO"} 173 | } 174 | }, 175 | "drills": 176 | { 177 | "plated": {"ext": "XLN"}, 178 | "non-plated": {"end": "_NPTH", "ext": "drl"} 179 | }, 180 | "text": 181 | { 182 | "readme": {"ext": "txt"} 183 | } 184 | } 185 | }, 186 | "dirtypcb": 187 | { 188 | "filenames": 189 | { 190 | "gerbers": 191 | { 192 | "top": 193 | { 194 | "copper": {"ext": "gtl"}, 195 | "soldermask": {"ext": "gts"}, 196 | "silkscreen": {"ext": "gto"}, 197 | "solderpaste": {"ext": "gtp"} 198 | }, 199 | "internal": 200 | { 201 | "conductor": {"ext": "ger"} 202 | }, 203 | "bottom": 204 | { 205 | "copper": {"ext": "gbl"}, 206 | "soldermask": {"ext": "gbs"}, 207 | "silkscreen": {"ext": "gbo"}, 208 | "solderpaste": {"ext": "gbp"} 209 | }, 210 | "other": 211 | { 212 | "documentation": {"ext": "ger"}, 213 | "outline": {"ext": "gbr"} 214 | } 215 | }, 216 | "drills": 217 | { 218 | "plated": {"ext": "txt"}, 219 | "non-plated": {"ext": "txt"} 220 | }, 221 | "text": 222 | { 223 | "readme": {"ext": "txt"} 224 | } 225 | } 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /docs/source/shapes.rst: -------------------------------------------------------------------------------- 1 | ###### 2 | Shapes 3 | ###### 4 | 5 | Shapes are the basic building blocks of *PCBmodE*. Here's an example of a shape type ``path``: 6 | 7 | .. code-block:: json 8 | 9 | { 10 | "type": "path", 11 | "layers": ["bottom"], 12 | "location": [3.1, -5.667], 13 | "stroke-width": 1.2, 14 | "style": "stroke", 15 | "value": "m -48.3,0 0,-5.75 c 0,-1.104569 0.895431,-2 2,-2 0,0 11.530272,-0.555504 17.300001,-0.5644445 10.235557,-0.015861 20.4577816,0.925558 30.6933324,0.9062128 C 10.767237,-7.4253814 19.826085,-8.3105055 28.900004,-8.3144445 34.703053,-8.3169636 46.3,-7.75 46.3,-7.75 c 1.103988,0.035813 2,0.895431 2,2 l 0,5.75 0,5.75 c 0,1.104569 -0.895431,2 -2,2 0,0 -11.596947,0.5669636 -17.399996,0.5644445 C 19.826085,8.3105055 10.767237,7.4253814 1.6933334,7.4082317 -8.5422174,7.3888865 -18.764442,8.3303051 -28.999999,8.3144445 -34.769728,8.305504 -46.3,7.75 -46.3,7.75 c -1.103982,-0.036019 -2,-0.895431 -2,-2 l 0,-5.75" 16 | } 17 | 18 | This will place an SVG path as a ``stroke`` with width ``1.2 mm`` at location ``x=3.1`` and ``y=5.667``. The shape will be placed on the bottom layer of the PCB. 19 | 20 | Shape types 21 | =========== 22 | 23 | For each shape a ``type`` must be defined. Below are the available shapes. 24 | 25 | Rectangle 26 | --------- 27 | 28 | Below is an example of a filled rectangle with rounded corners except for the top left corner. 29 | 30 | .. code-block:: json 31 | 32 | { 33 | "type": "rect", 34 | "layers": ["top"], 35 | "width": 1.7, 36 | "height": 1.7, 37 | "location": [6, 7.2], 38 | "radii": {"tl": 0, 39 | "tr": 0.3, 40 | "bl": 0.3, 41 | "br": 0.3}, 42 | "rotate": 15, 43 | "style": "fill" 44 | } 45 | 46 | type 47 | ``rect``: place a rectangle 48 | layers (optional; default ``["top"]``) 49 | list: layers to place the shape on (even if placing on a single layer, the definition needs to be in a form of a list) 50 | width 51 | float: width of the rectangle 52 | height 53 | float: height of the rectangle 54 | location (optional; default ``[0,0]``) 55 | list: ``x`` and ``y`` coordinates for where to place the shape 56 | radii (optional) 57 | dict: radius of round corners 58 | ``tl``: top left radius, 59 | ``tr``: top right radius, 60 | ``bl``: bottom left radius, 61 | ``br``: bottom right radius, 62 | rotate (optional; default ``0``) 63 | float: rotation, clock-wise degrees 64 | style (optional; default depends on sheet) 65 | ``stroke`` or ``fill``: style of the shape 66 | stroke-width (optional; default depends on sheet; ignored unless ``style`` is ``stroke``) 67 | float: stroke width 68 | buffer-to-pour (optional; defaults to global setting) 69 | float: custom buffer from shape to copper pour; 0 for no buffer 70 | 71 | 72 | 73 | Circle 74 | ------ 75 | 76 | Below is an example of a circle outline of diameter 1.7 mm and stroke width of 0.23 mm 77 | 78 | .. code-block:: json 79 | 80 | { 81 | "type": "circle", 82 | "layers": ["bottom"], 83 | "location": [-3.2, -6], 84 | "diameter": 1.7, 85 | "style": "stroke" 86 | "stroke-width": 0.23 87 | } 88 | 89 | type 90 | ``circle``: place a circle 91 | layers (optional; default ``["top"]``) 92 | list: layers to place the shape on (even if placing on a single layer, the definition needs to be in a form of a list) 93 | location (optional; default ``[0,0]``) 94 | list: ``x`` and ``y`` coordinates for where to place the shape 95 | diameter 96 | float: diameter of circle 97 | style (optional; default depends on sheet) 98 | ``stroke`` or ``fill``: style of the shape 99 | stroke-width (optional; default depends on sheet; ignored unless ``style`` is ``stroke``) 100 | float: stroke width 101 | buffer-to-pour (optional; defaults to global setting) 102 | float: custom buffer from shape to copper pour; 0 for no buffer 103 | 104 | 105 | Path 106 | ---- 107 | 108 | Other than simple shapes above, and SVG path can be placed. 109 | 110 | .. code-block:: json 111 | 112 | { 113 | "type": "path", 114 | "layers": ["top","bottom"], 115 | "location": [3.1, 5.667], 116 | "stroke-width": 1.2, 117 | "style": "stroke", 118 | "rotate": 23, 119 | "scale": 1.2, 120 | "value": "m -48.3,0 0,-5.75 c 0,-1.104569 0.895431,-2 2,-2 0,0 11.530272,-0.555504 17.300001,-0.5644445 10.235557,-0.015861 20.4577816,0.925558 30.6933324,0.9062128 C 10.767237,-7.4253814 19.826085,-8.3105055 28.900004,-8.3144445 34.703053,-8.3169636 46.3,-7.75 46.3,-7.75 c 1.103988,0.035813 2,0.895431 2,2 l 0,5.75 0,5.75 c 0,1.104569 -0.895431,2 -2,2 0,0 -11.596947,0.5669636 -17.399996,0.5644445 C 19.826085,8.3105055 10.767237,7.4253814 1.6933334,7.4082317 -8.5422174,7.3888865 -18.764442,8.3303051 -28.999999,8.3144445 -34.769728,8.305504 -46.3,7.75 -46.3,7.75 c -1.103982,-0.036019 -2,-0.895431 -2,-2 l 0,-5.75" 121 | } 122 | 123 | type 124 | ``path``: place an SVG path 125 | value 126 | path: in SVG this is the ``d`` property of a ```` 127 | layers (optional; default ``["top"]``) 128 | list: layers to place the shape on (even if placing on a single layer, the definition needs to be in a form of a list) 129 | location (optional; default ``[0,0]``) 130 | list: ``x`` and ``y`` coordinates for where to place the shape 131 | diameter 132 | float: diameter of circle 133 | style (optional; default depends on sheet) 134 | ``stroke`` or ``fill``: style of the shape 135 | stroke-width (optional; default depends on sheet; ignored unless ``style`` is ``stroke``) 136 | float: stroke width 137 | rotate (optional; default ``0``) 138 | float: rotation, clock-wise degrees 139 | scale (optional; default ``1``) 140 | float: scale factor to apply to the path 141 | buffer-to-pour (optional; defaults to global setting) 142 | float: custom buffer from shape to copper pour; 0 for no buffer 143 | 144 | 145 | Text 146 | ---- 147 | 148 | Placing a text shape is covered in :doc:`text`. 149 | -------------------------------------------------------------------------------- /pcbmode/utils/place.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | from lxml import etree as et 4 | 5 | import pcbmode.config as config 6 | from . import messages as msg 7 | 8 | # pcbmode modules 9 | from . import utils 10 | from . import svg 11 | from .point import Point 12 | 13 | 14 | 15 | 16 | 17 | def placeShape(shape, svg_layer, invert=False, original=False): 18 | """ 19 | Places a shape or type 'Shape' onto SVG layer 'svg_layer'. 20 | 'invert' : placed path should be mirrored 21 | 'original': use the original path, not the transformed one 22 | """ 23 | 24 | sig_dig = config.cfg['significant-digits'] 25 | 26 | style_string = shape.getStyleString() 27 | style_type = shape.getStyleType() 28 | gerber_lp = shape.getGerberLP() 29 | location = shape.getLocation() 30 | 31 | if original == False: 32 | translate = 'translate(%s,%s)' % (round((((1,-1)[invert])*location.x), sig_dig), 33 | round(location.y*config.cfg['invert-y'], sig_dig)) 34 | transform = translate 35 | else: 36 | transform = None 37 | 38 | if invert == True: 39 | path = shape.getTransformedPath(True) 40 | else: 41 | if original == True: 42 | path = shape.getOriginalPath() 43 | else: 44 | path = shape.getTransformedPath() 45 | 46 | element = et.SubElement(svg_layer, 47 | 'path', 48 | d=path) 49 | # Set style string 50 | element.set('style', style_string) 51 | 52 | # Set style type in pcbmode namespace. This is later used to easliy 53 | # identify the type when the path is converted to Gerber format 54 | element.set('{'+config.cfg['ns']['pcbmode']+'}style', style_type) 55 | 56 | if transform != None: 57 | element.set('transform', transform) 58 | 59 | if gerber_lp != None: 60 | element.set('{'+config.cfg['ns']['pcbmode']+'}gerber-lp', gerber_lp) 61 | 62 | if shape.getType() == 'text': 63 | element.set('{'+config.cfg['ns']['pcbmode']+'}text', shape.getText()) 64 | 65 | return element 66 | 67 | 68 | 69 | 70 | 71 | 72 | def placeDrill(drill, 73 | layer, 74 | location, 75 | scale, 76 | soldermask_layers={}, 77 | mask_groups={}): 78 | """ 79 | Places the drilling point 80 | """ 81 | 82 | diameter = drill.get('diameter') 83 | offset = utils.to_Point(drill.get('offset') or [0, 0]) 84 | path = svg.drill_diameter_to_path(diameter) 85 | mask_path = svg.circle_diameter_to_path(diameter) 86 | 87 | sig_dig = config.cfg['significant-digits'] 88 | transform = 'translate(%s %s)' % (round((location.x + offset.x)*scale, sig_dig), 89 | round((-location.y - offset.y)*scale, sig_dig)) 90 | 91 | drill_element = et.SubElement(layer, 'path', 92 | transform=transform, 93 | d=path, 94 | id='pad_drill', 95 | diameter=str(diameter)) 96 | 97 | pour_buffer = 1.0 98 | try: 99 | pour_buffer = board_cfg['distances']['buffer_from_pour_to'].get('drill') or 1.0 100 | except: 101 | pass 102 | 103 | # add a mask buffer between pour and board outline 104 | if mask_groups != {}: 105 | for pcb_layer in surface_layers: 106 | mask_group = et.SubElement(mask_groups[pcb_layer], 'g', 107 | id="drill_masks") 108 | pour_mask = et.SubElement(mask_group, 'path', 109 | transform=transform, 110 | style=MASK_STYLE % str(pour_buffer*2), 111 | gerber_lp="c", 112 | d=mask_path) 113 | 114 | 115 | 116 | # place the size of the drill; id the drill element has a 117 | # "show_diameter": "no", then this can be suppressed 118 | # default to 'yes' 119 | show_diameter = drill.get('show_diameter') or 'yes' 120 | if show_diameter.lower() != 'no': 121 | text = "%s mm" % (str(diameter)) 122 | text_style = config.stl['layout']['drills'].get('text') or None 123 | if text_style is not None: 124 | text_style['font-size'] = str(diameter/10.0)+'px' 125 | text_style = utils.dict_to_style(text_style) 126 | t = et.SubElement(layer, 'text', 127 | x=str(location.x), 128 | # TODO: get rid of this hack 129 | y=str(-location.y-(diameter/4)), 130 | style=text_style) 131 | t.text = text 132 | 133 | # place soldermask unless specified otherwise 134 | # default is 'yes' 135 | add_soldermask = drill.get('add_soldermask') or 'yes' 136 | style = utils.dict_to_style(config.stl['layout']['soldermask'].get('fill')) 137 | possible_answers = ['yes', 'top', 'top only', 'bottom', 'bottom only', 'top and bottom'] 138 | if (add_soldermask.lower() in possible_answers) and (soldermask_layers != {}): 139 | # TODO: get this into a configuration parameter 140 | drill_soldermask_scale_factors = drill.get('soldermask_scale_factors') or {'top':1.2, 'bottom':1.2} 141 | path_top = svg.circle_diameter_to_path(diameter * drill_soldermask_scale_factors['top']) 142 | path_bottom = svg.circle_diameter_to_path(diameter * drill_soldermask_scale_factors['bottom']) 143 | 144 | if add_soldermask.lower() == 'yes' or add_soldermask.lower() == 'top and bottom': 145 | drill_element = et.SubElement(soldermask_layers['top'], 146 | 'path', 147 | transform=transform, 148 | style=style, 149 | d=path_top) 150 | drill_element = et.SubElement(soldermask_layers['bottom'], 151 | 'path', 152 | transform=transform, 153 | style=style, 154 | d=path_bottom) 155 | elif add_soldermask.lower() == 'top only' or add_soldermask.lower() == 'top': 156 | drill_element = et.SubElement(soldermask_layers['top'], 157 | 'path', 158 | transform=transform, 159 | style=style, 160 | d=path_top) 161 | elif add_soldermask.lower() == 'bottom only' or add_soldermask.lower() == 'bottom': 162 | drill_element = et.SubElement(soldermask_layers['bottom'], 163 | 'path', 164 | transform=transform, 165 | style=style, 166 | d=path_bottom) 167 | else: 168 | print("ERROR: unrecognised drills soldermask option") 169 | 170 | return 171 | 172 | 173 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PCBmodE.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PCBmodE.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/PCBmodE" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PCBmodE" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /pcbmode/utils/component.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | 5 | import pcbmode.config as config 6 | import copy 7 | 8 | # pcbmode modules 9 | from . import utils 10 | from . import messages as msg 11 | from .shape import Shape 12 | from .style import Style 13 | from .footprint import Footprint 14 | 15 | 16 | 17 | class Component(): 18 | """ 19 | """ 20 | 21 | def __init__(self, refdef, component): 22 | """ 23 | """ 24 | 25 | self._refdef = refdef 26 | self._layer = component.get('layer') or 'top' 27 | 28 | self._rotate = component.get('rotate') or 0 29 | if self._layer=='bottom': 30 | self._rotate *= -1 31 | 32 | self._rotate_point = utils.toPoint(component.get('rotate-point') or [0, 0]) 33 | self._scale = component.get('scale') or 1 34 | self._location = component.get('location') or [0, 0] 35 | 36 | # Get footprint definition and shapes 37 | try: 38 | self._footprint_name = component['footprint'] 39 | except: 40 | msg.error("Cannot find a 'footprint' name for refdef %s." % refdef) 41 | 42 | filename = self._footprint_name + '.json' 43 | 44 | paths = [os.path.join(config.cfg['base-dir'], 45 | config.cfg['locations']['shapes'], 46 | filename), 47 | os.path.join(config.cfg['base-dir'], 48 | config.cfg['locations']['components'], 49 | filename)] 50 | 51 | footprint_dict = None 52 | for path in paths: 53 | if os.path.isfile(path): 54 | footprint_dict = utils.dictFromJsonFile(path) 55 | break 56 | 57 | if footprint_dict == None: 58 | fname_list = "" 59 | for path in paths: 60 | fname_list += " %s" % path 61 | msg.error("Couldn't find shape file. Looked for it here:\n%s" % (fname_list)) 62 | 63 | footprint = Footprint(footprint_dict) 64 | footprint_shapes = footprint.getShapes() 65 | 66 | #------------------------------------------------ 67 | # Apply component-specific modifiers to footprint 68 | #------------------------------------------------ 69 | for sheet in ['conductor', 'soldermask', 'solderpaste', 'pours', 'silkscreen', 'assembly', 'drills']: 70 | for layer in config.stk['layer-names']: 71 | for shape in footprint_shapes[sheet].get(layer) or []: 72 | 73 | # In order to apply the rotation we need to adust the location 74 | shape.rotateLocation(self._rotate, self._rotate_point) 75 | 76 | shape.transformPath(scale=self._scale, 77 | rotate=self._rotate, 78 | rotate_point=self._rotate_point, 79 | mirror=shape.getMirrorPlacement(), 80 | add=True) 81 | 82 | #-------------------------------------------------------------- 83 | # Remove silkscreen and assembly shapes if instructed 84 | #-------------------------------------------------------------- 85 | # If the 'show' flag is 'false then remove these items from the 86 | # shapes dictionary 87 | #-------------------------------------------------------------- 88 | for sheet in ['silkscreen','assembly']: 89 | 90 | try: 91 | shapes_dict = component[sheet].get('shapes') or {} 92 | except: 93 | shapes_dict = {} 94 | 95 | # If the setting is to not show silkscreen shapes for the 96 | # component, delete the shapes from the shapes' dictionary 97 | if shapes_dict.get('show') == False: 98 | for pcb_layer in utils.getSurfaceLayers(): 99 | footprint_shapes[sheet][pcb_layer] = [] 100 | 101 | 102 | 103 | #---------------------------------------------------------- 104 | # Add silkscreen and assembly reference designator (refdef) 105 | #---------------------------------------------------------- 106 | for sheet in ['silkscreen','assembly']: 107 | 108 | try: 109 | refdef_dict = component[sheet].get('refdef') or {} 110 | except: 111 | refdef_dict = {} 112 | 113 | if refdef_dict.get('show') != False: 114 | layer = refdef_dict.get('layer') or 'top' 115 | 116 | # Rotate the refdef; if unspecified the rotation is the same as 117 | # the rotation of the component 118 | refdef_dict['rotate'] = refdef_dict.get('rotate') or 0 119 | 120 | # Sometimes you'd want to keep all refdefs at the same angle 121 | # and not rotated with the component 122 | if refdef_dict.get('rotate-with-component') != False: 123 | refdef_dict['rotate'] += self._rotate 124 | 125 | refdef_dict['rotate-point'] = utils.toPoint(refdef_dict.get('rotate-point')) or self._rotate_point 126 | 127 | refdef_dict['location'] = refdef_dict.get('location') or [0, 0] 128 | refdef_dict['type'] = 'text' 129 | refdef_dict['value'] = refdef_dict.get('value') or refdef 130 | refdef_dict['font-family'] = (refdef_dict.get('font-family') or 131 | config.stl['layout'][sheet]['refdef'].get('font-family') or 132 | config.stl['defaults']['font-family']) 133 | refdef_dict['font-size'] = (refdef_dict.get('font-size') or 134 | config.stl['layout'][sheet]['refdef'].get('font-size') or 135 | "2mm") 136 | refdef_shape = Shape(refdef_dict) 137 | 138 | refdef_shape.is_refdef = True 139 | refdef_shape.rotateLocation(self._rotate, self._rotate_point) 140 | style = Style(refdef_dict, sheet, 'refdef') 141 | refdef_shape.setStyle(style) 142 | 143 | # Add the refdef to the silkscreen/assembly list. It's 144 | # important that this is added at the very end since the 145 | # placement process assumes the refdef is last 146 | try: 147 | footprint_shapes[sheet][layer] 148 | except: 149 | footprint_shapes[sheet][layer] = [] 150 | 151 | footprint_shapes[sheet][layer].append(refdef_shape) 152 | 153 | 154 | #------------------------------------------------------ 155 | # Invert layers 156 | #------------------------------------------------------ 157 | # If the placement is on the bottom of the baord then we need 158 | # to invert the placement of all components. This affects the 159 | # surface laters but also internal layers 160 | 161 | if self._layer == 'bottom': 162 | layers = config.stk['layer-names'] 163 | 164 | for sheet in ['conductor', 'pours', 'soldermask', 'solderpaste', 'silkscreen', 'assembly']: 165 | sheet_dict = footprint_shapes[sheet] 166 | sheet_dict_new = {} 167 | for i, pcb_layer in enumerate(layers): 168 | try: 169 | sheet_dict_new[layers[len(layers)-i-1]] = copy.copy(sheet_dict[pcb_layer]) 170 | except: 171 | continue 172 | 173 | footprint_shapes[sheet] = copy.copy(sheet_dict_new) 174 | 175 | self._footprint_shapes = footprint_shapes 176 | 177 | 178 | 179 | 180 | 181 | def getShapes(self): 182 | """ 183 | """ 184 | return self._footprint_shapes 185 | 186 | 187 | def getLocation(self): 188 | """ 189 | """ 190 | return self._location 191 | 192 | 193 | def getRefdef(self): 194 | return self._refdef 195 | 196 | 197 | def getPlacementLayer(self): 198 | return self._layer 199 | 200 | 201 | def getFootprintName(self): 202 | return self._footprint_name 203 | 204 | 205 | def getRotation(self): 206 | return self._rotate 207 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # PCBmodE documentation build configuration file, created by 4 | # sphinx-quickstart on Sat Sep 13 21:22:12 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [] 32 | 33 | # Add any paths that contain templates here, relative to this directory. 34 | templates_path = ['_templates'] 35 | 36 | # The suffix of source filenames. 37 | source_suffix = '.rst' 38 | 39 | # The encoding of source files. 40 | #source_encoding = 'utf-8-sig' 41 | 42 | # The master toctree document. 43 | master_doc = 'index' 44 | 45 | # General information about the project. 46 | project = u'PCBmodE' 47 | copyright = u'2014, Saar Drimer' 48 | 49 | # The version info for the project you're documenting, acts as replacement for 50 | # |version| and |release|, also used in various other places throughout the 51 | # built documents. 52 | # 53 | # The short X.Y version. 54 | version = '3.0' 55 | # The full version, including alpha/beta/rc tags. 56 | release = '3.0' 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | #language = None 61 | 62 | # There are two options for replacing |today|: either, you set today to some 63 | # non-false value, then it is used: 64 | #today = '' 65 | # Else, today_fmt is used as the format for a strftime call. 66 | #today_fmt = '%B %d, %Y' 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | exclude_patterns = [] 71 | 72 | # The reST default role (used for this markup: `text`) to use for all 73 | # documents. 74 | #default_role = None 75 | 76 | # If true, '()' will be appended to :func: etc. cross-reference text. 77 | #add_function_parentheses = True 78 | 79 | # If true, the current module name will be prepended to all description 80 | # unit titles (such as .. function::). 81 | #add_module_names = True 82 | 83 | # If true, sectionauthor and moduleauthor directives will be shown in the 84 | # output. They are ignored by default. 85 | #show_authors = False 86 | 87 | # The name of the Pygments (syntax highlighting) style to use. 88 | pygments_style = 'sphinx' 89 | 90 | # A list of ignored prefixes for module index sorting. 91 | #modindex_common_prefix = [] 92 | 93 | # If true, keep warnings as "system message" paragraphs in the built documents. 94 | #keep_warnings = False 95 | 96 | 97 | # -- Options for HTML output ---------------------------------------------- 98 | 99 | # The theme to use for HTML and HTML Help pages. See the documentation for 100 | # a list of builtin themes. 101 | html_theme = 'default' 102 | 103 | # Theme options are theme-specific and customize the look and feel of a theme 104 | # further. For a list of options available for each theme, see the 105 | # documentation. 106 | #html_theme_options = {} 107 | 108 | # Add any paths that contain custom themes here, relative to this directory. 109 | #html_theme_path = [] 110 | 111 | # The name for this set of Sphinx documents. If None, it defaults to 112 | # " v documentation". 113 | #html_title = None 114 | 115 | # A shorter title for the navigation bar. Default is the same as html_title. 116 | #html_short_title = None 117 | 118 | # The name of an image file (relative to this directory) to place at the top 119 | # of the sidebar. 120 | #html_logo = None 121 | 122 | # The name of an image file (within the static path) to use as favicon of the 123 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 124 | # pixels large. 125 | #html_favicon = None 126 | 127 | # Add any paths that contain custom static files (such as style sheets) here, 128 | # relative to this directory. They are copied after the builtin static files, 129 | # so a file named "default.css" will overwrite the builtin "default.css". 130 | html_static_path = ['_static'] 131 | 132 | # Add any extra paths that contain custom files (such as robots.txt or 133 | # .htaccess) here, relative to this directory. These files are copied 134 | # directly to the root of the documentation. 135 | #html_extra_path = [] 136 | 137 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 138 | # using the given strftime format. 139 | #html_last_updated_fmt = '%b %d, %Y' 140 | 141 | # If true, SmartyPants will be used to convert quotes and dashes to 142 | # typographically correct entities. 143 | #html_use_smartypants = True 144 | 145 | # Custom sidebar templates, maps document names to template names. 146 | #html_sidebars = {} 147 | 148 | # Additional templates that should be rendered to pages, maps page names to 149 | # template names. 150 | #html_additional_pages = {} 151 | 152 | # If false, no module index is generated. 153 | #html_domain_indices = True 154 | 155 | # If false, no index is generated. 156 | #html_use_index = True 157 | 158 | # If true, the index is split into individual pages for each letter. 159 | #html_split_index = False 160 | 161 | # If true, links to the reST sources are added to the pages. 162 | #html_show_sourcelink = True 163 | 164 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 165 | #html_show_sphinx = True 166 | 167 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 168 | #html_show_copyright = True 169 | 170 | # If true, an OpenSearch description file will be output, and all pages will 171 | # contain a tag referring to it. The value of this option must be the 172 | # base URL from which the finished HTML is served. 173 | #html_use_opensearch = '' 174 | 175 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 176 | #html_file_suffix = None 177 | 178 | # Output file base name for HTML help builder. 179 | htmlhelp_basename = 'PCBmodEdoc' 180 | 181 | 182 | # -- Options for LaTeX output --------------------------------------------- 183 | 184 | latex_elements = { 185 | # The paper size ('letterpaper' or 'a4paper'). 186 | #'papersize': 'letterpaper', 187 | 188 | # The font size ('10pt', '11pt' or '12pt'). 189 | #'pointsize': '10pt', 190 | 191 | # Additional stuff for the LaTeX preamble. 192 | #'preamble': '', 193 | } 194 | 195 | # Grouping the document tree into LaTeX files. List of tuples 196 | # (source start file, target name, title, 197 | # author, documentclass [howto, manual, or own class]). 198 | latex_documents = [ 199 | ('index', 'PCBmodE.tex', u'PCBmodE Documentation', 200 | u'Saar Drimer', 'manual'), 201 | ] 202 | 203 | # The name of an image file (relative to this directory) to place at the top of 204 | # the title page. 205 | #latex_logo = None 206 | 207 | # For "manual" documents, if this is true, then toplevel headings are parts, 208 | # not chapters. 209 | #latex_use_parts = False 210 | 211 | # If true, show page references after internal links. 212 | #latex_show_pagerefs = False 213 | 214 | # If true, show URL addresses after external links. 215 | #latex_show_urls = False 216 | 217 | # Documents to append as an appendix to all manuals. 218 | #latex_appendices = [] 219 | 220 | # If false, no module index is generated. 221 | #latex_domain_indices = True 222 | 223 | 224 | # -- Options for manual page output --------------------------------------- 225 | 226 | # One entry per manual page. List of tuples 227 | # (source start file, name, description, authors, manual section). 228 | man_pages = [ 229 | ('index', 'pcbmode', u'PCBmodE Documentation', 230 | [u'Saar Drimer'], 1) 231 | ] 232 | 233 | # If true, show URL addresses after external links. 234 | #man_show_urls = False 235 | 236 | 237 | # -- Options for Texinfo output ------------------------------------------- 238 | 239 | # Grouping the document tree into Texinfo files. List of tuples 240 | # (source start file, target name, title, author, 241 | # dir menu entry, description, category) 242 | texinfo_documents = [ 243 | ('index', 'PCBmodE', u'PCBmodE Documentation', 244 | u'Saar Drimer', 'PCBmodE', 'One line description of project.', 245 | 'Miscellaneous'), 246 | ] 247 | 248 | # Documents to append as an appendix to all manuals. 249 | #texinfo_appendices = [] 250 | 251 | # If false, no module index is generated. 252 | #texinfo_domain_indices = True 253 | 254 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 255 | #texinfo_show_urls = 'footnote' 256 | 257 | # If true, do not generate a @detailmenu in the "Top" node's menu. 258 | #texinfo_no_detailmenu = False 259 | -------------------------------------------------------------------------------- /pcbmode/utils/shape.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import copy 5 | from lxml import etree as et 6 | 7 | from pkg_resources import resource_exists, resource_filename 8 | 9 | import pcbmode.config as config 10 | from . import messages as msg 11 | 12 | # import pcbmode modules 13 | from . import utils 14 | from . import svg 15 | from .point import Point 16 | from .svgpath import SvgPath 17 | 18 | 19 | 20 | 21 | class Shape(): 22 | """ 23 | """ 24 | 25 | def __init__(self, shape): 26 | 27 | gerber_lp = None 28 | mirror = False 29 | 30 | self._shape_dict = shape 31 | 32 | # Invert rotation so it's clock-wise. Inkscape is counter-clockwise and 33 | # it's unclear to ma what's the "right" direction. clockwise makse more 34 | # sense to me. This should be the only place to make the change. 35 | self._inv_rotate = -1 36 | 37 | try: 38 | self._type = shape.get('type') 39 | except: 40 | msg.error("Shapes must have a 'type' defined") 41 | 42 | # A 'layer' type is a copy of the outline. Here we copy the 43 | # outline shape and override the type 44 | if self._type in ['layer']: 45 | self._shape_dict = config.brd['outline'].get('shape').copy() 46 | self._type = self._shape_dict.get('type') 47 | 48 | self._place_mirrored = shape.get('mirror') or False 49 | 50 | self._rotate = shape.get('rotate') or 0 51 | self._rotate *= self._inv_rotate 52 | self._rotate_point = shape.get('rotate-point') or Point(0,0) 53 | self._scale = shape.get('scale') or 1 54 | self._pour_buffer = shape.get('buffer-to-pour') 55 | 56 | # A general purpose label field; intended for use for pad 57 | # labels 58 | self._label = None 59 | 60 | if self._type in ['rect', 'rectangle']: 61 | path = svg.width_and_height_to_path(self._shape_dict['width'], 62 | self._shape_dict['height'], 63 | self._shape_dict.get('radii')) 64 | elif self._type in ['circ', 'circle', 'round']: 65 | path = svg.circle_diameter_to_path(self._shape_dict['diameter']) 66 | elif self._type in ['drill']: 67 | self._diameter = self._shape_dict['diameter'] 68 | path = svg.drillPath(self._diameter) 69 | elif self._type in ['text', 'string']: 70 | try: 71 | self._text = self._shape_dict['value'] 72 | except KeyError: 73 | msg.error("Could not find the text to display. The text to be displayed should be defined in the 'value' field, for example, 'value': 'DEADBEEF\\nhar\\nhar'") 74 | 75 | # Get the font's name 76 | font = self._shape_dict.get('font-family') or config.stl['layout']['defaults']['font-family'] 77 | font_filename = "%s.svg" % font 78 | 79 | # Search for the font SVG in these paths 80 | paths = [os.path.join(config.cfg['base-dir'], 81 | config.cfg['locations']['fonts'], 82 | font_filename)] 83 | 84 | font_resource = ('pcbmode', '/'.join(['fonts',font_filename])) 85 | if resource_exists(*font_resource): 86 | paths.append(resource_filename(*font_resource)) 87 | 88 | filenames = '' 89 | font_data = None 90 | for path in paths: 91 | filename = path 92 | filenames += " %s \n" % filename 93 | if os.path.isfile(filename): 94 | font_data = et.ElementTree(file=filename) 95 | break 96 | 97 | if font_data == None: 98 | msg.error("Couldn't find style file %s. Looked for it here:\n%s" % (font_filename, filenames)) 99 | 100 | try: 101 | fs = self._shape_dict['font-size'] 102 | except: 103 | msg.error("A 'font-size' attribute must be specified for a 'text' type") 104 | 105 | ls = self._shape_dict.get('letter-spacing') or '0mm' 106 | lh = self._shape_dict.get('line-height') or fs 107 | 108 | font_size, letter_spacing, line_height = utils.getTextParams(fs, 109 | ls, 110 | lh) 111 | 112 | # With the units-per-em we can figure out the scale factor 113 | # to use for the desired font size 114 | units_per_em = float(font_data.find("//n:font-face", namespaces={'n': config.cfg['namespace']['svg']}).get('units-per-em')) or 1000 115 | self._scale = font_size/units_per_em 116 | 117 | # Get the path to use. This returns the path without 118 | # scaling, which will be applied later, in the same manner 119 | # as to the other shape types 120 | path, gerber_lp = utils.textToPath(font_data, 121 | self._text, 122 | letter_spacing, 123 | line_height, 124 | self._scale) 125 | 126 | # In the case where the text is an outline/stroke instead 127 | # of a fill we get rid of the gerber_lp 128 | if self._shape_dict.get('style') == 'stroke': 129 | gerber_lp = None 130 | 131 | self._rotate += 180 132 | 133 | elif self._type in ['path']: 134 | path = self._shape_dict.get('value') 135 | else: 136 | msg.error("'%s' is not a recongnised shape type" % self._type) 137 | 138 | 139 | self._path = SvgPath(path, gerber_lp) 140 | 141 | self._path.transform(scale=self._scale, 142 | rotate_angle=self._rotate, 143 | rotate_point=self._rotate_point, 144 | mirror=self._place_mirrored) 145 | 146 | self._gerber_lp = (shape.get('gerber-lp') or 147 | shape.get('gerber_lp') or 148 | gerber_lp or 149 | None) 150 | 151 | self._location = utils.toPoint(shape.get('location', [0, 0])) 152 | 153 | 154 | 155 | 156 | def transformPath(self, scale=1, rotate=0, rotate_point=Point(), mirror=False, add=False): 157 | if add == False: 158 | self._path.transform(scale, 159 | rotate*self._inv_rotate, 160 | rotate_point, 161 | mirror) 162 | else: 163 | self._path.transform(scale*self._scale, 164 | rotate*self._inv_rotate+self._rotate, 165 | rotate_point+self._rotate_point, 166 | mirror) 167 | 168 | 169 | 170 | def rotateLocation(self, angle, point=Point()): 171 | """ 172 | """ 173 | self._location.rotate(angle, point) 174 | 175 | 176 | def getRotation(self): 177 | return self._rotate 178 | 179 | 180 | 181 | def setRotation(self, rotate): 182 | self._rotate = rotate 183 | 184 | 185 | 186 | def getOriginalPath(self): 187 | """ 188 | Returns that original, unmodified path 189 | """ 190 | return self._path.getOriginal() 191 | 192 | 193 | 194 | def getTransformedPath(self, mirrored=False): 195 | if mirrored == True: 196 | return self._path.getTransformedMirrored() 197 | else: 198 | return self._path.getTransformed() 199 | 200 | 201 | 202 | def getWidth(self): 203 | return self._path.getWidth() 204 | 205 | 206 | 207 | def getHeight(self): 208 | return self._path.getHeight() 209 | 210 | 211 | 212 | def getGerberLP(self): 213 | return self._gerber_lp 214 | 215 | 216 | 217 | def setStyle(self, style): 218 | """ 219 | style: Style object 220 | """ 221 | self._style = style 222 | 223 | 224 | def getStyle(self): 225 | """ 226 | Return the shape's style Style object 227 | """ 228 | return self._style 229 | 230 | 231 | def getStyleString(self): 232 | style = self._style.getStyleString() 233 | return style 234 | 235 | 236 | def getStyleType(self): 237 | style = self._style.getStyleType() 238 | return style 239 | 240 | 241 | def getScale(self): 242 | return self._scale 243 | 244 | 245 | def getLocation(self): 246 | return self._location 247 | 248 | 249 | def setLocation(self, location): 250 | self._location = location 251 | 252 | 253 | def getParsedPath(self): 254 | return self._parsed 255 | 256 | 257 | def getPourBuffer(self): 258 | return self._pour_buffer 259 | 260 | 261 | def getType(self): 262 | return self._type 263 | 264 | 265 | def getText(self): 266 | return self._text 267 | 268 | 269 | def getDiameter(self): 270 | return self._diameter 271 | 272 | 273 | def setLabel(self, label): 274 | self._label = label 275 | 276 | 277 | def getLabel(self): 278 | return self._label 279 | 280 | 281 | def getMirrorPlacement(self): 282 | return self._place_mirrored 283 | -------------------------------------------------------------------------------- /pcbmode/utils/bom.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | #import json 4 | import os 5 | import re 6 | import pcbmode.config as config 7 | 8 | from . import utils 9 | from . import messages as msg 10 | 11 | 12 | 13 | def make_bom(quantity=None): 14 | """ 15 | 16 | """ 17 | 18 | def natural_key(string_): 19 | """See http://www.codinghorror.com/blog/archives/001018.html""" 20 | return [int(s) if s.isdigit() else s for s in re.split(r'(\d+)', string_)] 21 | 22 | 23 | 24 | dnp_text = 'Do not populate' 25 | uncateg_text = 'Uncategorised' 26 | 27 | components_dict = config.brd['components'] 28 | 29 | bom_dict = {} 30 | 31 | for refdef in components_dict: 32 | 33 | description = '' 34 | 35 | try: 36 | place = components_dict[refdef]['place'] 37 | except: 38 | place = True 39 | 40 | try: 41 | ignore = components_dict[refdef]['bom']['ignore'] 42 | except: 43 | ignore = False 44 | 45 | # If component isn't placed, ignore it 46 | if place == True and ignore == False: 47 | 48 | # Get footprint definition and shapes 49 | try: 50 | footprint_name = components_dict[refdef]['footprint'] 51 | except: 52 | msg.error("Cannot find a 'footprint' name for refdef %s." % refdef) 53 | 54 | # Open footprint file 55 | fname = os.path.join(config.cfg['base-dir'], 56 | config.cfg['locations']['components'], 57 | footprint_name + '.json') 58 | footprint_dict = utils.dictFromJsonFile(fname) 59 | 60 | info_dict = footprint_dict.get('info') or {} 61 | 62 | try: 63 | comp_bom_dict = components_dict[refdef]['bom'] 64 | except: 65 | comp_bom_dict = {} 66 | 67 | try: 68 | fp_bom_dict = footprint_dict['info'] 69 | except: 70 | fp_bom_dict = {} 71 | 72 | 73 | # Override component BoM info on top of footprint info 74 | for key in comp_bom_dict: 75 | fp_bom_dict[key] = comp_bom_dict[key] 76 | 77 | description = fp_bom_dict.get('description') or uncateg_text 78 | 79 | try: 80 | dnp = components_dict[refdef]['bom']['dnp'] 81 | except: 82 | dnp = False 83 | 84 | if dnp == True: 85 | description = dnp_text 86 | 87 | if description not in bom_dict: 88 | bom_dict[description] = fp_bom_dict 89 | bom_dict[description]['refdefs'] = [] 90 | bom_dict[description]['refdefs'].append(refdef) 91 | 92 | 93 | try: 94 | bom_content = config.brd['bom'] 95 | except: 96 | bom_content = [ 97 | { 98 | "field": "line-item", 99 | "text": "#" 100 | }, 101 | { 102 | "field": "quantity", 103 | "text": "Qty" 104 | }, 105 | { 106 | "field": "designators", 107 | "text": "Designators" 108 | }, 109 | { 110 | "field": "description", 111 | "text": "Description" 112 | }, 113 | { 114 | "field": "package", 115 | "text": "Package" 116 | }, 117 | { 118 | "field": "manufacturer", 119 | "text": "Manufacturer" 120 | }, 121 | { 122 | "field": "part-number", 123 | "text": "Part #" 124 | }, 125 | { 126 | "field": "suppliers", 127 | "text": "Suppliers", 128 | "suppliers": 129 | [ 130 | { 131 | "field": "farnell", 132 | "text": "Farnell #", 133 | "search-url": "http://uk.farnell.com/catalog/Search?st=" 134 | }, 135 | { 136 | "field": "mouser", 137 | "text": "Mouser #", 138 | "search-url": "http://uk.mouser.com/Search/Refine.aspx?Keyword=" 139 | }, 140 | { 141 | "field": "octopart", 142 | "text": "Octopart", 143 | "search-url": "https://octopart.com/search?q=" 144 | } 145 | ] 146 | }, 147 | { 148 | "field": "notes", 149 | "text": "Notes" 150 | } 151 | ] 152 | 153 | 154 | 155 | # Set up the BoM file name 156 | bom_path = os.path.join(config.cfg['base-dir'], 157 | config.cfg['locations']['build'], 158 | 'bom') 159 | # Create path if it doesn't exist already 160 | utils.create_dir(bom_path) 161 | 162 | board_name = config.cfg['name'] 163 | board_revision = config.brd['config'].get('rev') 164 | base_name = "%s_rev_%s" % (board_name, board_revision) 165 | 166 | bom_html = os.path.join(bom_path, base_name + '_%s.html'% 'bom') 167 | bom_csv = os.path.join(bom_path, base_name + '_%s.csv'% 'bom') 168 | 169 | 170 | html = [] 171 | csv = [] 172 | 173 | 174 | html.append('') 175 | html.append('') 183 | html.append('') 184 | 185 | header = [] 186 | for item in bom_content: 187 | if item['field'] == 'suppliers': 188 | for supplier in item['suppliers']: 189 | header.append("%s" % supplier['text']) 190 | else: 191 | header.append("%s" % item['text']) 192 | if item['field'] == 'quantity' and quantity != None: 193 | header.append("@%s" % quantity) 194 | 195 | html.append(' ') 196 | html.append(' ' % (len(header), board_name, board_revision)) 197 | html.append(' ') 198 | html.append(' ') 199 | for item in header: 200 | if item == 'Designators': 201 | html.append(' ' % item) 202 | else: 203 | html.append(' ' % item) 204 | html.append(' ') 205 | 206 | uncateg_content = [] 207 | dnp_content = [] 208 | index = 1 209 | 210 | for desc in sorted(bom_dict): 211 | content = [] 212 | for item in bom_content: 213 | if item['field'] == 'line-item': 214 | content.append("%s" % str(index)) 215 | elif item['field'] == 'suppliers': 216 | for supplier in item['suppliers']: 217 | 218 | try: 219 | number = bom_dict[desc][item['field']][supplier['field']] 220 | except: 221 | number = "" 222 | 223 | search_url = supplier.get('search-url') 224 | if search_url != None: 225 | content.append('%s' % (search_url, number, number)) 226 | else: 227 | content.append(number) 228 | 229 | elif item['field'] == 'quantity': 230 | units = len(bom_dict[desc]['refdefs']) 231 | content.append("%s" % (str(units))) 232 | if quantity != None: 233 | content.append("%s" % (str(units*int(quantity)))) 234 | elif item['field'] == 'designators': 235 | # Natural/human sort the list of designators 236 | sorted_list = sorted(bom_dict[desc]['refdefs'], key=natural_key) 237 | 238 | refdefs = '' 239 | for refdef in sorted_list[:-1]: 240 | refdefs += "%s " % refdef 241 | refdefs += "%s" % sorted_list[-1] 242 | content.append("%s " % refdefs) 243 | elif item['field'] == 'description': 244 | content.append("%s " % desc) 245 | elif item['field'] == 'part-number': 246 | try: 247 | number = bom_dict[desc][item['field']] 248 | except: 249 | number = "" 250 | content.append('%s' % number) 251 | else: 252 | try: 253 | content.append(bom_dict[desc][item['field']]) 254 | except: 255 | content.append("") 256 | 257 | if desc == uncateg_text: 258 | uncateg_content = content 259 | elif desc == dnp_text: 260 | dnp_content = content 261 | else: 262 | html.append(' ') 263 | for item in content: 264 | html.append(' ' % (('odd','even')[index%2==0], item)) 265 | html.append(' ') 266 | index += 1 267 | 268 | 269 | for content in (dnp_content, uncateg_content): 270 | html.append(' ') 271 | html.append(' ') 272 | html.append(' ') 273 | if len(content) > 0: 274 | content[0] = index 275 | for item in content: 276 | html.append(' ' % (('odd','even')[index%2==0], item)) 277 | html.append(' ') 278 | index += 1 279 | 280 | 281 | html.append('
Bill of materials -- %s rev %s
%s%s
%s
%s
') 282 | 283 | html.append('

Generated by PCBmodE, maintained by Boldport.') 284 | 285 | html.append('') 286 | 287 | with open(bom_html, "wb") as f: 288 | for line in html: 289 | f.write(line+'\n') 290 | 291 | 292 | #print bom_dict 293 | 294 | 295 | -------------------------------------------------------------------------------- /docs/source/hello-solder.rst: -------------------------------------------------------------------------------- 1 | ###################### 2 | Tutorial: hello-solder 3 | ###################### 4 | 5 | The 'hello-solder' is a fun design for learn how *PCBmodE* by example. 6 | 7 | .. image:: images/hello-solder/board.png 8 | 9 | Setup 10 | ===== 11 | 12 | Get the boards repository from `here `_ and follow the instructions :doc:`setup` to 'compile' the board. This command should do it 13 | 14 | ``pcbmode -b hello-solder -m`` 15 | 16 | 17 | .. info:: *PCBmodE* caches some of the heavy computations in a file in the ``build`` directory, so subsequent invocations will run much faster. 18 | 19 | Then open the SVG you produced with Inkscape 20 | 21 | ``inkscape path/to/project/boards/hello-solder/build/hello-solder.svg`` 22 | 23 | Once opened, open the layers pane by pressing ``CTRL+SHIFT+L`` and get familiar with the layers of the board by making some hidden and visible. 24 | 25 | 26 | Outline 27 | ======= 28 | 29 | Using the layer pane hide all layers except for ``outline``. This shape is defined by an SVG path. In SVG (actually XML) is looks like this 30 | 31 | .. code-block:: XML 32 | 33 | 39 | 40 | You can view this by clicking on the outline and pressing ``SHIFT+CTRL+X`` to invoke Inkscape's built-in XML editor. This shows you the group the outline belongs to, so collapse the list on the left and choose the single element in the group. This should show you something like the above. 41 | 42 | .. tip:: Can't select the outline? That's because the layer is locked. On the layer pane click the lock next to the ``outline`` layer. 43 | 44 | Now open ``hello-solder.json`` in the project directory with a text editor. The shape above was created using the following definition 45 | 46 | .. code-block:: json 47 | 48 | { 49 | "outline": { 50 | "shape": { 51 | "type": "path", 52 | "value": "m -16.698952,-6.4545028 c -2.780854,0.8264621 -4.806955,3.3959901 -4.806955,6.44592474 0,3.04953526 2.025571,5.63383146 4.805863,6.46294446 0.373502,0.1206099 0.541906,0.3377362 0.36641,0.7166985 -0.537601,0.9664023 -0.841925,2.0765939 -0.841925,3.2625791 0,3.718159 3.019899,6.738056 6.738055,6.738056 1.1862717,0 2.2968105,-0.30644 3.2633909,-0.844923 0.2779016,-0.144746 0.6338321,-0.09921 0.7184502,0.343724 0.8185077,2.79334 3.3927864,4.831546 6.45156129,4.831546 3.06962611,0 5.66024241,-2.052348 6.47040841,-4.860911 0.097465,-0.315553 0.453736,-0.434303 0.7700817,-0.273567 0.9522855,0.514048 2.0438307,0.804131 3.2017325,0.804131 3.718159,0 6.729236,-3.019897 6.729236,-6.738056 0,-1.1177297 -0.269937,-2.1676049 -0.750914,-3.0935477 -0.277868,-0.520065 0.07101,-0.817639 0.379848,-0.9166584 2.730845,-0.859225 4.710233,-3.4176958 4.710233,-6.43201596 0,-2.98855014 -1.945688,-5.51459174 -4.640357,-6.39242304 -0.362382,-0.1152866 -0.660925,-0.5371332 -0.411209,-1.0139163 0.45685,-0.9074068 0.712399,-1.9307068 0.712399,-3.0182436 0,-3.718158 -3.011077,-6.746875 -6.729236,-6.746875 -0.165351,0.02476 -0.410376,-0.219946 -0.219238,-0.595553 0.129165,-0.314741 0.201599,-0.658879 0.201599,-1.018404 0,-1.496699 -1.2196914,-2.707569 -2.7163892,-2.707569 -1.0789126,0 -2.0094311,0.629927 -2.4450348,1.542338 -0.119881,0.280927 -0.5068697,0.412753 -0.8079468,0.144495 -1.1862758,-1.048846 -2.7462918,-1.686833 -4.45521281,-1.686833 -3.12285319,0 -5.73997179,2.120433 -6.49986279,5.003566 -0.079222,0.219391 -0.1844607,0.406694 -0.6008463,0.210249 -0.9826557,-0.564791 -2.1176191,-0.892287 -3.3326933,-0.892287 -3.718156,0 -6.738055,3.028717 -6.738055,6.746875 0,1.0923431 0.258164,2.1203908 0.718982,3.0310127 0.257646,0.4766398 0.146527,0.778116 -0.242375,0.9476435 z" 53 | } 54 | } 55 | } 56 | 57 | Since this is the board's outline *PCBmodE* assumes that its placement is at the center (that is ``location: [0,0]``) and that the style is an ``outline``. 58 | 59 | Let's try something. In Inkscape, modify the path using the node tool (press ``F2``). Using the XML editor cut-and-paste the path into the board's JSON file, replacing the existing outline path. Now recompile the board using the same command as above. 60 | 61 | When it's done, back in Inkscape, press ``ALT+F`` and then ``V`` to reload. Click ``yes`` and see your shape used as an outline. Notice that the shape is centered -- it's always like that with *PCBmodE*, all coordinates are relative to the center of the board. Also, the dimensions for the new outline are calculated and added automatically. 62 | 63 | 64 | Components 65 | ========== 66 | 67 | Placing components is done by "instantiating" a component that is defined in another JSON file in the ``components`` directory within the project. Here's an example from ``hello-solder.json`` for reference designator ``R2`` 68 | 69 | .. code-block:: json 70 | 71 | { 72 | "R2": { 73 | "footprint": "0805", 74 | "layer": "top", 75 | "location": [ 76 | 5.3, 77 | 5.3 78 | ], 79 | "rotate": 45, 80 | "show": true 81 | } 82 | } 83 | 84 | ``R2`` is the unique name for this instantiation of footprint ``0805``. It can be any unique (for the design) name, but convention is to keep it short, one or two letters followed by a number. 85 | 86 | .. tip:: There are no hard rules about reference designator format and prefixes, so they vary depending on the context. Wikipedia has a `list `_ that you can follow in the absence of other guidelines. 87 | 88 | The footprint for ``0805`` is defined in the file 89 | 90 | components/0805.json 91 | 92 | Open it with a text editor. 93 | 94 | .. code-block:: json 95 | 96 | { 97 | "pins": 98 | { 99 | "1": 100 | { 101 | "layout": 102 | { 103 | "pad": "pad", 104 | "location": [-1.143, 0] 105 | } 106 | }, 107 | "2": 108 | { 109 | "layout": 110 | { 111 | "pad": "pad", 112 | "location": [1.143, 0], 113 | "rotate": 180 114 | } 115 | } 116 | } 117 | } 118 | 119 | We define two pins (we'll also call surface mount pads "pins") called ``1`` and ``2``. For each of these we instantiate ``pad`` as the shape and place it at the coordinate defined in ``location`` (remember, placement is always relative to the center). We rotate pin ``2`` by 180 degrees. 120 | 121 | .. tip:: Pin names can be any text, and a label can be added too. See :doc:`components` for more detail. 122 | 123 | The pad is defined in the same file, like so 124 | 125 | .. code-block:: json 126 | 127 | { 128 | "pads": 129 | { 130 | "pad": 131 | { 132 | "shapes": 133 | [ 134 | { 135 | "type": "rect", 136 | "layers": ["top"], 137 | "width": 1.542, 138 | "height": 1.143, 139 | "radii": {"tl": 0.25, "tr": 0, "bl": 0.25, "br": 0} 140 | } 141 | ] 142 | } 143 | } 144 | } 145 | 146 | Of course it's possible to define more than one pad, and it's even possible to have multiple shapes as part of a single pad in order to create complex shapes. See :doc:`shapes` for more on defining shapes. 147 | 148 | We would like to now add a silkscreen shape and assembly drawing. Here's how we do that 149 | 150 | .. code-block:: json 151 | 152 | { 153 | "layout": 154 | { 155 | "silkscreen": 156 | { 157 | "shapes": 158 | [ 159 | { 160 | "type": "rect", 161 | "width": 0.3, 162 | "height": 1, 163 | "location": [0, 0], 164 | "style": "fill" 165 | } 166 | ] 167 | }, 168 | "assembly": 169 | { 170 | "shapes": 171 | [ 172 | { 173 | "type": "rect", 174 | "width": 2.55, 175 | "height": 1.4 176 | } 177 | ] 178 | } 179 | } 180 | } 181 | 182 | Here's an exercise: instead a small silkscreen square, draw an outline rectangle with rounded corners around the component's pads. For a bonus, add a tiny silkscreen dot next to one of the pads. 183 | 184 | 185 | Shapes 186 | ====== 187 | 188 | 189 | Routing 190 | ======= 191 | 192 | 193 | Documentation and indexes 194 | ========================= 195 | 196 | 197 | Extraction 198 | ========== 199 | 200 | 201 | Production 202 | ========== 203 | 204 | .. LocalWords: PCBmodE Inkscape inkscape 205 | -------------------------------------------------------------------------------- /docs/source/components.rst: -------------------------------------------------------------------------------- 1 | ########## 2 | Components 3 | ########## 4 | 5 | Components are the building blocks of the board. In fact, they are used for placing any element on board, except for routes. A via is a 'component', and a copper pour is defined within a 'component' and then instantiated into the board's JSON file. 6 | 7 | Defining components 8 | =================== 9 | 10 | Components are defined in their own JSON file. The skeleton of this file is the following 11 | 12 | .. code-block:: json 13 | 14 | { 15 | "pins": 16 | { 17 | }, 18 | "layout": 19 | { 20 | "silkscreen": 21 | { 22 | }, 23 | "assembly": 24 | { 25 | } 26 | }, 27 | "pads": 28 | { 29 | } 30 | } 31 | 32 | ``pins`` is where the pins of a components are 'instantiated'. ``pads`` contain what pads or pins are in terms of their shapes and drills. Each 'pin' instantiates a 'pad' from ``pads''. ``layout`` contain silkscreen and assembly shapes. 33 | 34 | pins 35 | ---- 36 | 37 | Here's what a component with two pins looks like 38 | 39 | .. code-block:: json 40 | 41 | { 42 | "pins": 43 | { 44 | "1": 45 | { 46 | "layout": 47 | { 48 | "pad": "pad", 49 | "location": [-1.27, 0], 50 | "show-label": false 51 | } 52 | }, 53 | "2-TH": 54 | { 55 | "layout": 56 | { 57 | "pad": "pad", 58 | "location": [1.27, 0], 59 | "label": "PWR", 60 | "show-label": true 61 | } 62 | } 63 | } 64 | } 65 | 66 | Each pin has a unique key -- ``1`` and ``2-TH`` above -- that does not necessarily need to be a number. ``pad`` instantiates the type of landing pad to use, which is defined in the ``pads'' section. ``location`` is the position of the pin relative to the *centre of the component*. 67 | 68 | *PCBmodE* can discreetly place a label at the centre of the pin (this is viewable when zooming in on the pin). The label can be defined using ``label``, or if ``label`` is missing, the key will be used instead. To not place the label use ``"show-label": false``. 69 | 70 | 71 | pads 72 | ---- 73 | 74 | Pads define the shape of pins. Here's a definition for a simple throughole capacitor 75 | 76 | .. code-block:: json 77 | 78 | { 79 | "pins": { 80 | "1": { 81 | "layout": { 82 | "pad": "th-sq", 83 | "location": [-2, 0] 84 | } 85 | }, 86 | "2": { 87 | "layout": 88 | { 89 | "pad": "th", 90 | "location": [2, 0] 91 | } 92 | } 93 | }, 94 | "layout": { 95 | "silkscreen": { 96 | "shapes": [ 97 | { 98 | "type": "path", 99 | "value": "m -10.515586,19.373448 c -0.214789,0.0199 -0.437288,0.01645 -0.664669,-0.0017 m -0.514055,0.01247 c -0.202682,0.02292 -0.412185,0.02382 -0.626017,0.01069 m 1.56129,1.209208 c -0.557685,-0.851271 -0.665205,-1.634778 -0.04126,-2.443953 m -0.82831,2.449655 c -0.07502,-0.789306 -0.06454,-1.60669 1.98e-4,-2.441891", 100 | "location": [0, 0], 101 | "style": "stroke" 102 | } 103 | ] 104 | }, 105 | "assembly": { 106 | "shapes": [ 107 | { 108 | "type": "rect", 109 | "width": 2.55, 110 | "height": 1.4 111 | } 112 | ] 113 | } 114 | }, 115 | "pads": { 116 | "th": { 117 | "shapes": [ 118 | { 119 | "type": "circle", 120 | "layers": ["top", "bottom"], 121 | "outline": 0, 122 | "diameter": 1.9, 123 | "offset": [0, 0] 124 | } 125 | ], 126 | "drills": [ 127 | { 128 | "diameter": 1 129 | } 130 | ] 131 | }, 132 | "th-sq": { 133 | "shapes": [ 134 | { 135 | "type": "rect", 136 | "layers": ["top", "bottom"], 137 | "width": 1.9, 138 | "height": 1.9, 139 | "offset": [0, 0], 140 | "radii": { "tl": 0.3,"bl": 0.3,"tr": 0.3,"br": 0.3 } 141 | } 142 | ], 143 | "drills": [ 144 | { 145 | "diameter": 1 146 | } 147 | ] 148 | } 149 | } 150 | } 151 | 152 | This would result in this component 153 | 154 | .. image:: images/polar-cap.png 155 | 156 | Here's a more complex footprint for a battery holder on an ocean-themed board 157 | 158 | .. code-block:: json 159 | 160 | { 161 | "pins": { 162 | "POS-1": { 163 | "layout": 164 | { 165 | "pad": "pad", 166 | "location": [13.3, 0], 167 | "rotate": 95 168 | } 169 | }, 170 | "NEG": { 171 | "layout": { 172 | "pad": "pad", 173 | "location": [0, 0] 174 | } 175 | }, 176 | "POS-2": { 177 | "layout": { 178 | "pad": "pad", 179 | "location": [-13.3, 0], 180 | "rotate": -95 181 | } 182 | } 183 | }, 184 | "layout": { 185 | "assembly": { 186 | "shapes": [ 187 | { 188 | "type": "rect", 189 | "layers": ["top"], 190 | "width": 21.1, 191 | "height": 19.9, 192 | "offset": [0, 0] 193 | } 194 | ] 195 | } 196 | }, 197 | "pads": { 198 | "pad": { 199 | "shapes": [ 200 | { 201 | "type": "path", 202 | "style": "fill", 203 | "scale": 1, 204 | "layers": ["top"], 205 | "value": "M 30.090397,29.705755 28.37226,29.424698 c 0,0 2.879054,-2.288897 4.991896,-2.270979 2.611383,0.02215 2.971834,2.016939 2.971834,2.016939 l 2.261927,-1.675577 -0.816738,2.741522 0.747218,2.459909 -2.119767,-1.518159 c 0,0 -0.605255,1.760889 -3.359198,1.739078 C 31.737346,32.90704 28.38105,30.56764 28.38105,30.56764 z", 206 | "soldermask": [ 207 | { 208 | "type": "path", 209 | "style": "fill", 210 | "scale": 1, 211 | "rotate": 10, 212 | "layers": ["top"], 213 | "value": "M 30.090397,29.705755 28.37226,29.424698 c 0,0 2.879054,-2.288897 4.991896,-2.270979 2.611383,0.02215 2.971834,2.016939 2.971834,2.016939 l 2.261927,-1.675577 -0.816738,2.741522 0.747218,2.459909 -2.119767,-1.518159 c 0,0 -0.605255,1.760889 -3.359198,1.739078 C 31.737346,32.90704 28.38105,30.56764 28.38105,30.56764 z" 214 | }, 215 | { 216 | "type": "path", 217 | "style": "fill", 218 | "scale": 0.5, 219 | "rotate": 20, 220 | "location": [0, 4.7], 221 | "layers": ["top"], 222 | "value": "M 30.090397,29.705755 28.37226,29.424698 c 0,0 2.879054,-2.288897 4.991896,-2.270979 2.611383,0.02215 2.971834,2.016939 2.971834,2.016939 l 2.261927,-1.675577 -0.816738,2.741522 0.747218,2.459909 -2.119767,-1.518159 c 0,0 -0.605255,1.760889 -3.359198,1.739078 C 31.737346,32.90704 28.38105,30.56764 28.38105,30.56764 z" 223 | } 224 | ] 225 | }, 226 | { 227 | "type": "circle", 228 | "layers": ["bottom"], 229 | "outline": 0, 230 | "diameter": 2.3, 231 | "offset": [0, 0] 232 | } 233 | ], 234 | "drills": [ 235 | { 236 | "diameter": 1.2 237 | } 238 | ] 239 | } 240 | } 241 | } 242 | 243 | 244 | This will what it looks like 245 | 246 | .. image:: images/fish-battery.png 247 | 248 | Notice that you can define multiple shapes for the soldermask that are independent of the shape of the shape of the copper. 249 | 250 | To control how soldermask shapes are placed, you have the following options: 251 | 252 | * No ``soldermask`` definition will assume default placement. The buffers and multipliers are defined in the board's JSON file 253 | * ``"soldermask": []`` will not place a soldermask shape 254 | * ``"soldermask": [{...},{...},...]`` as above will place custom shapes 255 | 256 | Defining custom solderpaste shapes works in exactly the same way except that you'd use ``soldepaste`` instead of ``soldermask``. 257 | 258 | 259 | 260 | layout shapes 261 | ------------- 262 | 263 | 264 | 265 | 266 | 267 | 268 | Placing components and shapes 269 | ============================= 270 | 271 | Footprints for components and shapes are stored in their own directories within the project path (those can be changed in the configuration file). 272 | 273 | This is an example of instantiating a component within the board's JSON file 274 | 275 | .. code-block:: json 276 | 277 | { 278 | "components": 279 | { 280 | "J2": 281 | { 282 | "footprint": "my-part", 283 | "layer": "top", 284 | "location": [ 285 | 36.7, 286 | 0 287 | ], 288 | "rotate": -90, 289 | "show": true, 290 | "silkscreen": { 291 | "refdef": { 292 | "location": [ 293 | -7.2, 294 | 2.16 295 | ], 296 | "rotate": 0, 297 | "rotate-with-component": false, 298 | "show": true 299 | }, 300 | "shapes": { 301 | "show": true 302 | } 303 | } 304 | } 305 | } 306 | } 307 | 308 | The key of each component -- ``J2`` above -- record is the component's reference designator, or in *PCBmodE*-speak, 'refdef'. Note that as opposed to ``shape`` types, here ``layer`` can only accept one layer. 309 | 310 | ``silkscreen`` is optional, but allows control over the placement of the reference designator, and whether shapes are placed or not. 311 | 312 | 313 | .. note:: 314 | 315 | The sharp-minded amongst you will notice that 'refdef' is not exactly short form of 'reference designator'. I noticed that fact only in version 3.0 of *PCBmodE*, way too far to change it. So I embraced this folly and it will forever be. 316 | 317 | 318 | 319 | -------------------------------------------------------------------------------- /pcbmode/utils/footprint.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import re 5 | import json 6 | from lxml import etree as et 7 | 8 | import pcbmode.config as config 9 | from . import messages as msg 10 | 11 | 12 | # pcbmode modules 13 | from . import svg 14 | from . import utils 15 | from . import place 16 | import copy 17 | from .style import Style 18 | from .point import Point 19 | from .shape import Shape 20 | 21 | 22 | 23 | class Footprint(): 24 | """ 25 | """ 26 | 27 | def __init__(self, footprint): 28 | 29 | self._footprint = footprint 30 | 31 | 32 | 33 | self._shapes = {'conductor': {}, 34 | 'pours': {}, 35 | 'soldermask': {}, 36 | 'silkscreen': {}, 37 | 'assembly': {}, 38 | 'solderpaste': {}, 39 | 'drills': {}} 40 | 41 | self._processPins() 42 | self._processPours() 43 | self._processShapes() 44 | self._processAssemblyShapes() 45 | 46 | 47 | 48 | 49 | def getShapes(self): 50 | return self._shapes 51 | 52 | 53 | 54 | def _processPins(self): 55 | """ 56 | Converts pins into 'shapes' 57 | """ 58 | 59 | pins = self._footprint.get('pins') or {} 60 | 61 | for pin in pins: 62 | 63 | pin_location = pins[pin]['layout']['location'] or [0, 0] 64 | 65 | try: 66 | pad_name = pins[pin]['layout']['pad'] 67 | except: 68 | msg.error("Each defined 'pin' must have a 'pad' name that is defined in the 'pads' dection of the footprint.") 69 | 70 | try: 71 | pad_dict = self._footprint['pads'][pad_name] 72 | except: 73 | msg.error("There doesn't seem to be a pad definition for pad '%s'." % pad_name) 74 | 75 | # Get the pin's rotation, if any 76 | pin_rotate = pins[pin]['layout'].get('rotate') or 0 77 | 78 | shapes = pad_dict.get('shapes') or [] 79 | 80 | for shape_dict in shapes: 81 | 82 | shape_dict = shape_dict.copy() 83 | 84 | # Which layer(s) to place the shape on 85 | layers = utils.getExtendedLayerList(shape_dict.get('layers') or ['top']) 86 | 87 | # Add the pin's location to the pad's location 88 | shape_location = shape_dict.get('location') or [0, 0] 89 | shape_dict['location'] = [shape_location[0] + pin_location[0], 90 | shape_location[1] + pin_location[1]] 91 | 92 | # Add the pin's rotation to the pad's rotation 93 | shape_dict['rotate'] = (shape_dict.get('rotate') or 0) + pin_rotate 94 | 95 | # Determine if and which label to show 96 | show_name = pins[pin]['layout'].get('show-label') or True 97 | if show_name == True: 98 | pin_label = pins[pin]['layout'].get('label') or pin 99 | 100 | for layer in layers: 101 | 102 | shape = Shape(shape_dict) 103 | style = Style(shape_dict, 'conductor') 104 | shape.setStyle(style) 105 | try: 106 | self._shapes['conductor'][layer].append(shape) 107 | except: 108 | self._shapes['conductor'][layer] = [] 109 | self._shapes['conductor'][layer].append(shape) 110 | 111 | for stype in ['soldermask','solderpaste']: 112 | 113 | # Get a custom shape specification if it exists 114 | sdict_list = shape_dict.get(stype) 115 | 116 | # Not defined; default 117 | if sdict_list == None: 118 | # Use default settings for shape based on 119 | # the pad shape 120 | sdict = shape_dict.copy() 121 | 122 | # Which shape type is the pad? 123 | shape_type = shape.getType() 124 | 125 | # Apply modifier based on shape type 126 | if shape_type == 'path': 127 | sdict['scale'] = shape.getScale()*config.brd['distances'][stype]['path-scale'] 128 | elif shape_type in ['rect', 'rectangle']: 129 | sdict['width'] += config.brd['distances'][stype]['rect-buffer'] 130 | sdict['height'] += config.brd['distances'][stype]['rect-buffer'] 131 | elif shape_type in ['circ', 'circle']: 132 | sdict['diameter'] += config.brd['distances'][stype]['circle-buffer'] 133 | else: 134 | pass 135 | 136 | # Create shape based on new dictionary 137 | sshape = Shape(sdict) 138 | 139 | # Define style 140 | sstyle = Style(sdict, stype) 141 | 142 | # Apply style 143 | sshape.setStyle(sstyle) 144 | 145 | # Add shape to footprint's shape dictionary 146 | #self._shapes[stype][layer].append(sshape) 147 | try: 148 | self._shapes[stype][layer].append(sshape) 149 | except: 150 | self._shapes[stype][layer] = [] 151 | self._shapes[stype][layer].append(sshape) 152 | 153 | 154 | 155 | # Do not place shape 156 | elif (sdict_list == {}) or (sdict_list == []): 157 | pass 158 | 159 | # Custom shape definition 160 | else: 161 | 162 | # If dict (as before support of multiple 163 | # shapes) then append to a single element 164 | # list 165 | if type(sdict_list) is dict: 166 | sdict_list = [sdict_list] 167 | 168 | # Process list of shapes 169 | for sdict_ in sdict_list: 170 | sdict = sdict_.copy() 171 | shape_loc = utils.toPoint(sdict.get('location') or [0, 0]) 172 | 173 | # Apply rotation 174 | sdict['rotate'] = (sdict.get('rotate') or 0) + pin_rotate 175 | 176 | # Rotate location 177 | shape_loc.rotate(pin_rotate, Point()) 178 | 179 | sdict['location'] = [shape_loc.x + pin_location[0], 180 | shape_loc.y + pin_location[1]] 181 | 182 | # Create new shape 183 | sshape = Shape(sdict) 184 | 185 | # Create new style 186 | sstyle = Style(sdict, stype) 187 | 188 | # Apply style 189 | sshape.setStyle(sstyle) 190 | 191 | # Add shape to footprint's shape dictionary 192 | #self._shapes[stype][layer].append(sshape) 193 | try: 194 | self._shapes[stype][layer].append(sshape) 195 | except: 196 | self._shapes[stype][layer] = [] 197 | self._shapes[stype][layer].append(sshape) 198 | 199 | 200 | # Add pin label 201 | if (pin_label != None): 202 | shape.setLabel(pin_label) 203 | 204 | 205 | 206 | 207 | drills = pad_dict.get('drills') or [] 208 | for drill_dict in drills: 209 | drill_dict = drill_dict.copy() 210 | drill_dict['type'] = drill_dict.get('type') or 'drill' 211 | drill_location = drill_dict.get('location') or [0, 0] 212 | drill_dict['location'] = [drill_location[0] + pin_location[0], 213 | drill_location[1] + pin_location[1]] 214 | shape = Shape(drill_dict) 215 | style = Style(drill_dict, 'drills') 216 | shape.setStyle(style) 217 | try: 218 | self._shapes['drills']['top'].append(shape) 219 | except: 220 | self._shapes['drills']['top'] = [] 221 | self._shapes['drills']['top'].append(shape) 222 | 223 | 224 | 225 | 226 | 227 | def _processPours(self): 228 | """ 229 | """ 230 | 231 | try: 232 | shapes = self._footprint['layout']['pours']['shapes'] 233 | except: 234 | return 235 | 236 | for shape_dict in shapes: 237 | layers = utils.getExtendedLayerList(shape_dict.get('layers') or ['top']) 238 | for layer in layers: 239 | shape = Shape(shape_dict) 240 | style = Style(shape_dict, 'conductor', 'pours') 241 | shape.setStyle(style) 242 | 243 | try: 244 | self._shapes['pours'][layer].append(shape) 245 | except: 246 | self._shapes['pours'][layer] = [] 247 | self._shapes['pours'][layer].append(shape) 248 | 249 | 250 | 251 | 252 | 253 | def _processShapes(self): 254 | """ 255 | """ 256 | 257 | sheets = ['conductor', 'silkscreen', 'soldermask'] 258 | 259 | for sheet in sheets: 260 | 261 | try: 262 | shapes = self._footprint['layout'][sheet]['shapes'] 263 | except: 264 | shapes = [] 265 | 266 | for shape_dict in shapes: 267 | layers = utils.getExtendedLayerList(shape_dict.get('layers') or ['top']) 268 | for layer in layers: 269 | # Mirror the shape if it's text and on bottom later, 270 | # but let explicit shape setting override 271 | if layer == 'bottom': 272 | if shape_dict['type'] == 'text': 273 | shape_dict['mirror'] = shape_dict.get('mirror') or 'True' 274 | shape = Shape(shape_dict) 275 | style = Style(shape_dict, sheet) 276 | shape.setStyle(style) 277 | try: 278 | self._shapes[sheet][layer].append(shape) 279 | except: 280 | self._shapes[sheet][layer] = [] 281 | self._shapes[sheet][layer].append(shape) 282 | 283 | 284 | 285 | 286 | 287 | 288 | def _processAssemblyShapes(self): 289 | """ 290 | """ 291 | try: 292 | shapes = self._footprint['layout']['assembly']['shapes'] 293 | except: 294 | return 295 | 296 | for shape_dict in shapes: 297 | layers = utils.getExtendedLayerList(shape_dict.get('layer') or ['top']) 298 | for layer in layers: 299 | shape = Shape(shape_dict) 300 | style = Style(shape_dict, 'assembly') 301 | shape.setStyle(style) 302 | try: 303 | self._shapes['assembly'][layer].append(shape) 304 | except: 305 | self._shapes['assembly'][layer] = [] 306 | self._shapes['assembly'][layer].append(shape) 307 | 308 | 309 | 310 | -------------------------------------------------------------------------------- /pcbmode/styles/default/layout.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": 3 | { 4 | "font-family": "UbuntuMono-R-webfont", 5 | "style": 6 | { 7 | "outline": "stroke", 8 | "conductor": "fill", 9 | "silkscreen": "stroke", 10 | "documentation": "fill", 11 | "assembly": "stroke", 12 | "soldermask": "fill", 13 | "solderpaste": "fill", 14 | "drills": "fill", 15 | "placement": "stroke", 16 | "refdef": "fill", 17 | "dimensions": "stroke", 18 | "origin": "stroke" 19 | } 20 | }, 21 | "board": 22 | { 23 | "board_background": 24 | { 25 | "fill-opacity": 1, 26 | "fill": "#000000", 27 | "stroke": "none" 28 | } 29 | }, 30 | "dimensions": 31 | { 32 | "default": 33 | { 34 | "stroke": "#555555", 35 | "stroke-opacity": 1, 36 | "fill": "#555555" 37 | }, 38 | "stroke": 39 | { 40 | "stroke-width": 0.2 41 | }, 42 | "text": 43 | { 44 | "font-family": "UbuntuMono-R-webfont", 45 | "font-size": "1.5mm", 46 | "line-height": "1mm", 47 | "letter-spacing": "0mm" 48 | } 49 | }, 50 | "origin": 51 | { 52 | "default": 53 | { 54 | "stroke": "#555555", 55 | "stroke-width": 0.05, 56 | "stroke-opacity": 1, 57 | "fill": "none" 58 | } 59 | }, 60 | "outline": 61 | { 62 | "default": 63 | { 64 | "stroke": "#555555", 65 | "stroke-opacity": 1, 66 | "fill": "none" 67 | }, 68 | "stroke": 69 | { 70 | "stroke-width": 0.05 71 | }, 72 | "fill": 73 | { 74 | } 75 | }, 76 | "conductor": 77 | { 78 | "default": 79 | { 80 | "top": 81 | { 82 | "stroke": "#008500", 83 | "fill": "#008500", 84 | "stroke-opacity": 0.6, 85 | "fill-opacity": 0.6 86 | }, 87 | "internal": 88 | { 89 | "stroke": "#F2B705", 90 | "fill": "#F2B705", 91 | "stroke-opacity": 0.6, 92 | "fill-opacity": 0.6 93 | }, 94 | "bottom": 95 | { 96 | "stroke": "#A60000", 97 | "fill": "#A60000", 98 | "stroke-opacity": 0.6, 99 | "fill-opacity": 0.6 100 | } 101 | }, 102 | "pads": 103 | { 104 | "top": 105 | { 106 | "stroke": "none" 107 | }, 108 | "internal": 109 | { 110 | "stroke": "none" 111 | }, 112 | "bottom": 113 | { 114 | "stroke": "none" 115 | }, 116 | "labels": 117 | { 118 | "font-size": "0.1px", 119 | "font-style": "normal", 120 | "font-variant": "normal", 121 | "font-weight": "bold", 122 | "font-stretch": "normal", 123 | "text-align": "center", 124 | "line-height":" 100%", 125 | "letter-spacing": "0", 126 | "word-spacing": "0", 127 | "writing-mode": "lr-tb", 128 | "text-anchor": "middle", 129 | "fill": "#f3ff00", 130 | "fill-opacity": 1, 131 | "stroke": "none", 132 | "font-family": "Sans serif" 133 | } 134 | }, 135 | "routing": 136 | { 137 | "top": 138 | { 139 | "stroke": "#269926", 140 | "fill": "#269926", 141 | "fill-opacity": 0.6 142 | }, 143 | "internal": 144 | { 145 | "stroke": "#F2B705", 146 | "fill": "#F2B705", 147 | "fill-opacity": 0.6 148 | }, 149 | "bottom": 150 | { 151 | "stroke": "#BF3030", 152 | "fill": "#BF3030", 153 | "fill-opacity": 0.6 154 | } 155 | }, 156 | "pours": 157 | { 158 | "top": 159 | { 160 | "fill-opacity": 0.2, 161 | "stroke-opacity": 0.2, 162 | "stroke-linejoin": "round", 163 | "stroke-linecap": "round" 164 | }, 165 | "internal": 166 | { 167 | "fill-opacity": 0.2, 168 | "stroke-opacity": 0.2, 169 | "stroke-linejoin": "round", 170 | "stroke-linecap": "round" 171 | }, 172 | "bottom": 173 | { 174 | "fill-opacity": 0.2, 175 | "stroke-opacity": 0.2, 176 | "stroke-linejoin": "round", 177 | "stroke-linecap": "round" 178 | } 179 | }, 180 | "fill": 181 | { 182 | "fill-opacity": 0.6, 183 | "stroke": "none" 184 | }, 185 | "stroke": 186 | { 187 | "fill": "none", 188 | "stroke-width": 0.15, 189 | "stroke-linejoin": "round", 190 | "stroke-linecap": "round" 191 | } 192 | }, 193 | "silkscreen": 194 | { 195 | "default": 196 | { 197 | "top": 198 | { 199 | "stroke": "#9FEE00", 200 | "fill": "#9FEE00", 201 | "stroke-opacity": 0.6, 202 | "fill-opacity": 0.6 203 | }, 204 | "bottom": 205 | { 206 | "stroke": "#CD0074", 207 | "fill": "#CD0074", 208 | "stroke-opacity": 0.6, 209 | "fill-opacity": 0.6 210 | } 211 | }, 212 | "stroke": 213 | { 214 | "fill": "none", 215 | "stroke-width": 0.15, 216 | "stroke-linejoin": "round", 217 | "stroke-linecap": "round" 218 | }, 219 | "fill": 220 | { 221 | "fill-opacity": 0.6, 222 | "stroke": "none" 223 | }, 224 | "refdef": 225 | { 226 | "font-family": "UbuntuMono-R-webfont", 227 | "font-size": "2mm", 228 | "line-height": "2mm", 229 | "letter-spacing": "0mm" 230 | }, 231 | "text": 232 | { 233 | "font-family": "UbuntuMono-R-webfont", 234 | "font-size": "1mm", 235 | "line-height": "1mm", 236 | "letter-spacing": "0mm" 237 | } 238 | }, 239 | "solderpaste": 240 | { 241 | "default": 242 | { 243 | "top": 244 | { 245 | "stroke": "#A0A0A0", 246 | "fill": "#A0A0A0", 247 | "stroke-opacity": 0.6, 248 | "fill-opacity": 0.6 249 | }, 250 | "bottom": 251 | { 252 | "stroke": "#A0A0A0", 253 | "fill": "#A0A0A0", 254 | "stroke-opacity": 0.6, 255 | "fill-opacity": 0.6 256 | } 257 | }, 258 | "stroke": 259 | { 260 | "fill": "none", 261 | "stroke-width": 0.15, 262 | "stroke-linejoin": "round", 263 | "stroke-linecap": "round" 264 | }, 265 | "fill": 266 | { 267 | "fill-opacity": 0.6, 268 | "stroke": "none" 269 | } 270 | }, 271 | "documentation": 272 | { 273 | "default": 274 | { 275 | "fill": "#000455", 276 | "fill-opacity": 1, 277 | "stroke": "#000000", 278 | "stroke-width": 0, 279 | "stroke-opacity": 1, 280 | "stroke-dasharray": "none" 281 | }, 282 | "text": 283 | { 284 | "font-family": "Overlock-Regular-OTF-webfont", 285 | "font-size": "1.5mm", 286 | "letter-spacing": "0mm", 287 | "line-height": "1.5mm" 288 | } 289 | }, 290 | "assembly": 291 | { 292 | "default": 293 | { 294 | "top": 295 | { 296 | "stroke": "#009999", 297 | "fill": "#009999", 298 | "stroke-opacity": 0.6, 299 | "fill-opacity": 0.6 300 | }, 301 | "bottom": 302 | { 303 | "stroke": "#FF7400", 304 | "fill": "#FF7400", 305 | "stroke-opacity": 0.6, 306 | "fill-opacity": 0.6 307 | } 308 | }, 309 | "stroke": 310 | { 311 | "fill": "none", 312 | "stroke-width": 0.15, 313 | "stroke-linejoin": "round", 314 | "stroke-linecap": "round" 315 | }, 316 | "fill": 317 | { 318 | "fill-opacity": 0.6, 319 | "stroke": "none" 320 | }, 321 | "refdef": 322 | { 323 | "font-family": "UbuntuMono-R-webfont", 324 | "font-size": "2mm", 325 | "line-height": "2mm", 326 | "letter-spacing": "0mm" 327 | }, 328 | "text": 329 | { 330 | "font-family": "UbuntuMono-R-webfont", 331 | "font-size": "1mm", 332 | "line-height": "1mm", 333 | "letter-spacing": "0mm" 334 | } 335 | }, 336 | "soldermask": 337 | { 338 | "default": 339 | { 340 | "top": 341 | { 342 | "stroke": "#67E667", 343 | "fill": "#67E667", 344 | "stroke-opacity": 0.6, 345 | "fill-opacity": 0.6 346 | }, 347 | "bottom": 348 | { 349 | "stroke": "#FF7373", 350 | "fill": "#FF7373", 351 | "stroke-opacity": 0.6, 352 | "fill-opacity": 0.6 353 | } 354 | }, 355 | "fill": 356 | { 357 | "stroke": "none" 358 | }, 359 | "stroke": 360 | { 361 | "fill": "none", 362 | "stroke-width": 0.15, 363 | "stroke-linejoin": "round", 364 | "stroke-linecap": "round" 365 | } 366 | }, 367 | "drills": 368 | { 369 | "default": 370 | { 371 | "fill": "#444444", 372 | "fill-opacity": 0.6, 373 | "stroke": "none", 374 | "fill-rule": "evenodd" 375 | } 376 | }, 377 | "placement": 378 | { 379 | "default": 380 | { 381 | "top": { 382 | "stroke": "#444444", 383 | "fill": "none", 384 | "stroke-opacity": 0.5, 385 | "stroke-width": 0.03, 386 | "stroke-linejoin": "round", 387 | "stroke-linecap": "round" 388 | }, 389 | "internal": { 390 | "stroke": "#444444", 391 | "fill": "none", 392 | "stroke-opacity": 0.5, 393 | "stroke-width": 0.03, 394 | "stroke-linejoin": "round", 395 | "stroke-linecap": "round" 396 | }, 397 | "bottom": { 398 | "stroke": "#444444", 399 | "fill": "none", 400 | "stroke-opacity": 0.5, 401 | "stroke-width": 0.03, 402 | "stroke-linejoin": "round", 403 | "stroke-linecap": "round" 404 | } 405 | }, 406 | "text": 407 | { 408 | "font-size": "0.1px", 409 | "font-style": "normal", 410 | "font-variant": "normal", 411 | "font-weight": "bold", 412 | "font-stretch": "normal", 413 | "text-align": "center", 414 | "line-height":" 100%", 415 | "letter-spacing": "0", 416 | "word-spacing": "0", 417 | "writing-mode": "lr-tb", 418 | "text-anchor": "middle", 419 | "fill": "#222222", 420 | "fill-opacity": 0.8, 421 | "stroke": "none", 422 | "font-family": "Sans serif" 423 | } 424 | }, 425 | "drill-index": 426 | { 427 | "count-text": 428 | { 429 | "fill": "#111111", 430 | "fill-opacity": 0.8, 431 | "font-size": 1.0, 432 | "font-style": "normal", 433 | "font-variant": "normal", 434 | "font-weight": "bold", 435 | "font-stretch": "normal", 436 | "text-align": "center", 437 | "line-height": "100%", 438 | "letter-spacing": "0", 439 | "word-spacing": "0", 440 | "writing-mode": "lr-tb", 441 | "text-anchor": "middle", 442 | "stroke": "none", 443 | "font-family": "Courier 10 Pitch" 444 | }, 445 | "text": 446 | { 447 | "fill": "#111111", 448 | "fill-opacity": 0.8, 449 | "font-style": "normal", 450 | "font-size": 1.6, 451 | "font-variant": "normal", 452 | "font-weight": "bold", 453 | "font-stretch": "normal", 454 | "line-height": "100%", 455 | "letter-spacing": "0", 456 | "word-spacing": "0", 457 | "writing-mode": "lr-tb", 458 | "text-anchor": "start", 459 | "stroke": "none", 460 | "font-family": "Courier 10 Pitch" 461 | } 462 | }, 463 | "layer-index": 464 | { 465 | "text": 466 | { 467 | "font-family": "Overlock-Regular-OTF-webfont", 468 | "font-size": "1.2mm", 469 | "letter-spacing": "0mm", 470 | "line-height": "1.2mm" 471 | } 472 | }, 473 | "bom": 474 | { 475 | "css": [ 476 | "p {padding:0;line-height:50%;font-family:Ubuntu, Arial, sans-serif;font-size:12px;font-weight:normal;}", 477 | "a {text-decoration:none;color:#3399CC;font-weight:bold;}", 478 | ".tg {border-collapse:collapse;border-spacing:0;border-color:#3399CC;}", 479 | ".tg td{font-family:Arial, sans-serif;font-size:14px;padding:10px 10px;border-style:solid;border-width:0px;overflow:hidden;word-break:normal;border-color:#3399CC;color:#333;background-color:#e8edff;border-top-width:1px;border-bottom-width:1px;}", 480 | ".tg th{font-family:Ubuntu, Arial, sans-serif;font-size:14px;font-weight:normal;padding:10px 10px;border-style:solid;border-width:0px;overflow:hidden;word-break:normal;border-color:#3399CC;color:#333;background-color:#b9c9fe;border-top-width:1px;border-bottom-width:1px;white-space:nowrap;}", 481 | ".tg .tg-title{font-weight:bold;background-color:#67B8DE;font-size:18px;text-align:left}", 482 | ".tg .tg-header{font-weight:bold;background-color:#91C9E8;text-align:left}", 483 | ".tg .tg-header-des{font-weight:bold;background-color:#91C9E8;text-align:left;width:350px}", 484 | ".tg .tg-item-odd{background-color:#FEFEFE}", 485 | ".tg .tg-item-even{background-color:#EEEEEE}", 486 | ".tg .tg-skip{background-color:#FFFFFF;height:8px}" 487 | ] 488 | } 489 | } 490 | -------------------------------------------------------------------------------- /pcbmode/utils/extract.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import json 5 | 6 | import pcbmode.config as config 7 | from . import messages as msg 8 | 9 | # pcbmode modules 10 | from . import utils 11 | from .point import Point 12 | 13 | 14 | 15 | def extract(extract, extract_refdefs): 16 | """ 17 | """ 18 | 19 | svg_in = utils.openBoardSVG() 20 | 21 | 22 | if extract == True: 23 | msg.info("Extracting routing and vias") 24 | extractRouting(svg_in) 25 | 26 | msg.info("Extracting components info") 27 | extractComponents(svg_in) 28 | 29 | msg.info("Extracting documentation and indicies locations") 30 | extractDocs(svg_in) 31 | 32 | if extract_refdefs == True: 33 | msg.info("Extracting refdefs info") 34 | extractRefdefs(svg_in) 35 | 36 | 37 | return 38 | 39 | 40 | 41 | 42 | def extractComponents(svg_in): 43 | """ 44 | """ 45 | 46 | xpath_expr_place = '//svg:g[@pcbmode:pcb-layer="%s"]//svg:g[@pcbmode:sheet="placement"]//svg:g[@pcbmode:type="%s"]' 47 | 48 | for pcb_layer in config.stk['surface-layer-names']: 49 | 50 | # Find all 'component' markers 51 | markers = svg_in.findall(xpath_expr_place % (pcb_layer, 'component'), 52 | namespaces={'pcbmode':config.cfg['ns']['pcbmode'], 53 | 'svg':config.cfg['ns']['svg']}) 54 | # Find all 'shape' markers 55 | markers += svg_in.findall(xpath_expr_place % (pcb_layer, 'shape'), 56 | namespaces={'pcbmode':config.cfg['ns']['pcbmode'], 57 | 'svg':config.cfg['ns']['svg']}) 58 | 59 | for marker in markers: 60 | 61 | transform_data = utils.parseTransform(marker.get('transform')) 62 | refdef = marker.get('{'+config.cfg['ns']['pcbmode']+'}refdef') 63 | marker_type = marker.get('{'+config.cfg['ns']['pcbmode']+'}type') 64 | 65 | if marker_type == 'component': 66 | comp_dict = config.brd['components'][refdef] 67 | elif marker_type == 'shape': 68 | comp_dict = config.brd['shapes'][refdef] 69 | else: 70 | continue 71 | 72 | # Ignore location extraction when parsing 'rotate' 73 | if transform_data['type'] != 'rotate': 74 | new_location = transform_data['location'] 75 | old_location = utils.toPoint(comp_dict.get('location') or [0, 0]) 76 | 77 | # Invert 'y' coordinate 78 | new_location.y *= config.cfg['invert-y'] 79 | 80 | # Change component location if needed 81 | if new_location != old_location: 82 | x1 = utils.niceFloat(old_location.x) 83 | y1 = utils.niceFloat(old_location.y) 84 | x2 = utils.niceFloat(new_location.x) 85 | y2 = utils.niceFloat(new_location.y) 86 | msg.subInfo("%s has moved from [%s,%s] to [%s,%s]" % (refdef,x1,y2,x2,y2)) 87 | # Apply new location 88 | comp_dict['location'] = [x2,y2] 89 | 90 | # Change component rotation if needed 91 | if transform_data['type'] in ['rotate','matrix']: 92 | old_rotate = comp_dict.get('rotate') or 0 93 | new_rotate = transform_data['rotate'] 94 | comp_dict['rotate'] = utils.niceFloat((old_rotate+new_rotate) % 360) 95 | msg.subInfo("Component %s rotated from %s to %s" % (refdef, 96 | old_rotate, 97 | comp_dict['rotate'])) 98 | 99 | 100 | # Save board config to file (everything is saved, not only the 101 | # component data) 102 | filename = os.path.join(config.cfg['locations']['boards'], 103 | config.cfg['name'], 104 | config.cfg['name'] + '.json') 105 | try: 106 | with open(filename, 'wb') as f: 107 | f.write(json.dumps(config.brd, sort_keys=True, indent=2)) 108 | except: 109 | msg.error("Cannot save file %s" % filename) 110 | 111 | return 112 | 113 | 114 | 115 | 116 | 117 | 118 | def extractRefdefs(svg_in): 119 | """ 120 | """ 121 | 122 | xpath_refdefs = '//svg:g[@pcbmode:sheet="silkscreen"]//svg:g[@pcbmode:type="refdef"]' 123 | refdefs_elements = svg_in.findall(xpath_refdefs, 124 | namespaces={'pcbmode':config.cfg['ns']['pcbmode'], 125 | 'svg':config.cfg['ns']['svg']}) 126 | 127 | for refdef_element in refdefs_elements: 128 | 129 | # Get refdef group location 130 | group_trans_data = utils.parseTransform(refdef_element.get('transform')) 131 | group_loc = group_trans_data['location'] 132 | # Invert 'y' coordinate because Inkscape 133 | group_loc.y *= config.cfg['invert-y'] 134 | 135 | # Get reference designator 136 | refdef = refdef_element.get('{'+config.cfg['ns']['pcbmode']+'}refdef') 137 | 138 | # Get component dictionary 139 | refdef_dict = config.brd['components'].get(refdef) 140 | 141 | # Get component placement layer 142 | comp_loc = utils.toPoint(refdef_dict.get('location', [0,0])) 143 | 144 | if comp_loc != group_loc: 145 | 146 | # Get location of the refdef from the component dict 147 | try: 148 | loc_old = utils.toPoint(refdef_dict['silkscreen']['refdef']['location']) 149 | except: 150 | loc_old = Point() 151 | 152 | # Get component placement layer 153 | comp_layer = refdef_dict.get('layer', 'top') 154 | 155 | # Get component rotation 156 | comp_rotation = refdef_dict.get('rotate', 0) 157 | 158 | difference = group_loc-comp_loc 159 | difference.rotate(-comp_rotation, Point()) 160 | 161 | if comp_layer == 'bottom': 162 | difference.x *= -1 163 | 164 | loc_new = loc_old+difference 165 | 166 | try: 167 | tmp = refdef_dict['silkscreen'] 168 | except: 169 | refdef_dict['silkscreen'] = {} 170 | 171 | try: 172 | tmp = refdef_dict['silkscreen']['refdef'] 173 | except: 174 | refdef_dict['silkscreen']['refdef'] = {} 175 | 176 | x = utils.niceFloat(loc_new.x) 177 | y = utils.niceFloat(loc_new.y) 178 | refdef_dict['silkscreen']['refdef']['location'] = [x,y] 179 | 180 | 181 | # Save board config to file (everything is saved, not only the 182 | # component data) 183 | filename = os.path.join(config.cfg['locations']['boards'], 184 | config.cfg['name'], 185 | config.cfg['name'] + '.json') 186 | try: 187 | with open(filename, 'wb') as f: 188 | f.write(json.dumps(config.brd, sort_keys=True, indent=2)) 189 | except: 190 | msg.error("Cannot save file %s" % filename) 191 | 192 | return 193 | 194 | 195 | 196 | 197 | 198 | def extractRouting(svg_in): 199 | """ 200 | Extracts routing from the the 'routing' SVG layers of each PCB layer. 201 | Inkscape SVG layers for each PCB ('top', 'bottom', etc.) layer. 202 | """ 203 | 204 | # Open the routing file if it exists. The existing data is used 205 | # for stats displayed as PCBmodE is run. The file is then 206 | # overwritten. 207 | output_file = os.path.join(config.cfg['base-dir'], 208 | config.cfg['name'] + '_routing.json') 209 | try: 210 | routing_dict_old = utils.dictFromJsonFile(output_file, False) 211 | except: 212 | routing_dict_old = {'routes': {}, 'vias': {}} 213 | 214 | #--------------- 215 | # Extract routes 216 | #--------------- 217 | 218 | # Store extracted data here 219 | routing_dict = {} 220 | 221 | # The XPATH expression for extracting routes, but not vias 222 | xpath_expr = "//svg:g[@pcbmode:pcb-layer='%s']//svg:g[@pcbmode:sheet='routing']//svg:path[(@d) and not (@pcbmode:type='via')]" 223 | 224 | routes_dict = {} 225 | 226 | for pcb_layer in config.stk['layer-names']: 227 | routes = svg_in.xpath(xpath_expr % pcb_layer, 228 | namespaces={'pcbmode':config.cfg['ns']['pcbmode'], 229 | 'svg':config.cfg['ns']['svg']}) 230 | 231 | for route in routes: 232 | route_dict = {} 233 | route_id = route.get('{'+config.cfg['ns']['pcbmode']+'}id') 234 | path = route.get('d') 235 | 236 | style_text = route.get('style') or '' 237 | 238 | # This hash digest provides a unique identifier for 239 | # the route based on its path, location, and style 240 | digest = utils.digest(path+ 241 | #str(location.x)+ 242 | #str(location.y)+ 243 | style_text) 244 | 245 | try: 246 | routes_dict[pcb_layer][digest] = {} 247 | except: 248 | routes_dict[pcb_layer] = {} 249 | routes_dict[pcb_layer][digest] = {} 250 | routes_dict[pcb_layer][digest]['type'] = 'path' 251 | routes_dict[pcb_layer][digest]['value'] = path 252 | 253 | stroke_width = utils.getStyleAttrib(style_text, 'stroke-width') 254 | if stroke_width != None: 255 | # Sometimes Inkscape will add a 'px' suffix to the stroke-width 256 | #property pf a path; this removes it 257 | stroke_width = stroke_width.rstrip('px') 258 | routes_dict[pcb_layer][digest]['style'] = 'stroke' 259 | routes_dict[pcb_layer][digest]['stroke-width'] = round(float(stroke_width), 4) 260 | 261 | custom_buffer = route.get('{'+config.cfg['ns']['pcbmode']+'}buffer-to-pour') 262 | if custom_buffer != None: 263 | routes_dict[pcb_layer][digest]['buffer-to-pour'] = float(custom_buffer) 264 | 265 | gerber_lp = route.get('{'+config.cfg['ns']['pcbmode']+'}gerber-lp') 266 | if gerber_lp != None: 267 | routes_dict[pcb_layer][digest]['gerber-lp'] = gerber_lp 268 | 269 | 270 | 271 | routing_dict['routes'] = routes_dict 272 | 273 | # Create simple stats and display them 274 | total = 0 275 | total_old = 0 276 | new = 0 277 | existing = 0 278 | for pcb_layer in config.stk['layer-names']: 279 | try: 280 | total += len(routing_dict['routes'][pcb_layer]) 281 | except: 282 | pass 283 | try: 284 | new_dict = routing_dict['routes'][pcb_layer] 285 | except: 286 | new_dict = {} 287 | try: 288 | old_dict = routing_dict_old['routes'][pcb_layer] 289 | except: 290 | old_dict = {} 291 | for key in new_dict: 292 | if key not in old_dict: 293 | new += 1 294 | else: 295 | existing += 1 296 | 297 | for pcb_layer in config.stk['layer-names']: 298 | total_old += len(old_dict) 299 | 300 | message = "Extracted %s routes; %s new (or modified), %s existing" % (total, new, existing) 301 | if total_old > total: 302 | message += ", %s removed" % (total_old - total) 303 | msg.subInfo(message) 304 | 305 | 306 | #------------------------------- 307 | # Extract vias 308 | #------------------------------- 309 | 310 | xpath_expr_place = '//svg:g[@pcbmode:pcb-layer="%s"]//svg:g[@pcbmode:sheet="placement"]//svg:g[@pcbmode:type="via"]' 311 | 312 | vias_dict = {} 313 | 314 | for pcb_layer in config.stk['surface-layer-names']: 315 | 316 | # Find all markers 317 | markers = svg_in.findall(xpath_expr_place % pcb_layer, 318 | namespaces={'pcbmode':config.cfg['ns']['pcbmode'], 319 | 'svg':config.cfg['ns']['svg']}) 320 | 321 | for marker in markers: 322 | transform_data = utils.parseTransform(marker.get('transform')) 323 | location = transform_data['location'] 324 | # Invert 'y' coordinate 325 | location.y *= config.cfg['invert-y'] 326 | 327 | # Change component rotation if needed 328 | if transform_data['type'] == 'matrix': 329 | rotate = transform_data['rotate'] 330 | rotate = utils.niceFloat((rotate) % 360) 331 | else: 332 | rotate = 0 333 | 334 | digest = utils.digest("%s%s" % (location.x, location.y)) 335 | 336 | # Define a via, just like any other component, but disable 337 | # placement of refdef 338 | vias_dict[digest] = {} 339 | vias_dict[digest]['footprint'] = marker.get('{'+config.cfg['ns']['pcbmode']+'}footprint') 340 | vias_dict[digest]['location'] = [utils.niceFloat(location.x), 341 | utils.niceFloat(location.y)] 342 | vias_dict[digest]['layer'] = pcb_layer 343 | vias_dict[digest]['silkscreen'] = {'refdef':{'show':False}} 344 | vias_dict[digest]['assembly'] = {'refdef':{'show':False}} 345 | 346 | 347 | # Get the vis's ID 348 | try: 349 | via_id = marker.get('{'+config.cfg['ns']['pcbmode']+'}id') 350 | except: 351 | via_id = None 352 | 353 | # Apply existing rotation 354 | if via_id != None: 355 | try: 356 | old_via_rotate = routing_dict_old['vias'][via_id]['rotate'] 357 | except: 358 | old_via_rotate = 0 359 | 360 | vias_dict[digest]['rotate'] = old_via_rotate + rotate 361 | 362 | 363 | 364 | 365 | routing_dict['vias'] = vias_dict 366 | 367 | # Display stats 368 | if len(vias_dict) == 0: 369 | msg.subInfo("No vias found") 370 | elif len(vias_dict) == 1: 371 | msg.subInfo("Extracted 1 via") 372 | else: 373 | msg.subInfo("Extracted %s vias" % (len(vias_dict))) 374 | 375 | 376 | # Save extracted routing into routing file 377 | try: 378 | with open(output_file, 'wb') as f: 379 | f.write(json.dumps(routing_dict, sort_keys=True, indent=2)) 380 | except: 381 | msg.error("Cannot save file %s" % output_file) 382 | 383 | return 384 | 385 | 386 | 387 | 388 | 389 | 390 | def extractDocs(svg_in): 391 | """ 392 | Extracts the position of the documentation elements and updates 393 | the board's json 394 | """ 395 | 396 | # Get copper refdef shape groups from SVG data 397 | xpath_expr = '//svg:g[@pcbmode:sheet="documentation"]//svg:g[@pcbmode:type="module-shapes"]' 398 | docs = svg_in.findall(xpath_expr, 399 | namespaces={'pcbmode':config.cfg['ns']['pcbmode'], 400 | 'svg':config.cfg['ns']['svg']}) 401 | 402 | 403 | for doc in docs: 404 | doc_key = doc.get('{'+config.cfg['ns']['pcbmode']+'}doc-key') 405 | translate_data = utils.parseTransform(doc.get('transform')) 406 | location = translate_data['location'] 407 | location.y *= config.cfg['invert-y'] 408 | 409 | current_location = utils.toPoint(config.brd['documentation'][doc_key]['location']) 410 | if current_location != location: 411 | config.brd['documentation'][doc_key]['location'] = [location.x, location.y] 412 | msg.subInfo("Found new location ([%s, %s]) for '%s'" % (location.x, location.y, doc_key)) 413 | 414 | 415 | # Extract drill index location 416 | xpath_expr = '//svg:g[@pcbmode:sheet="drills"]//svg:g[@pcbmode:type="drill-index"]' 417 | drill_index = svg_in.find(xpath_expr, 418 | namespaces={'pcbmode':config.cfg['ns']['pcbmode'], 419 | 'svg':config.cfg['ns']['svg']}) 420 | transform_dict = utils.parseTransform(drill_index.get('transform')) 421 | location = transform_dict['location'] 422 | location.y *= config.cfg['invert-y'] 423 | 424 | # Modify the location in the board's config file. If a 425 | # 'drill-index' field doesn't exist, create it 426 | drill_index_dict = config.brd.get('drill-index') 427 | if drill_index_dict == None: 428 | config.brd['drill-index'] = {} 429 | config.brd['drill-index']['location'] = [location.x, location.y] 430 | 431 | 432 | # Save board config to file (everything is saved, not only the 433 | # component data) 434 | filename = os.path.join(config.cfg['locations']['boards'], 435 | config.cfg['name'], 436 | config.cfg['name'] + '.json') 437 | try: 438 | with open(filename, 'wb') as f: 439 | f.write(json.dumps(config.brd, sort_keys=True, indent=2)) 440 | except: 441 | msg.error("Cannot save file %s" % filename) 442 | -------------------------------------------------------------------------------- /pcbmode/pcbmode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import json 5 | import argparse 6 | 7 | try: 8 | from os import getcwdu as getcwd 9 | except: 10 | from os import getcwd as getcwd 11 | 12 | from pkg_resources import resource_filename, resource_exists 13 | 14 | # PCBmodE modules 15 | from . import config 16 | from .utils import utils 17 | from .utils import gerber 18 | from .utils import extract 19 | from .utils import excellon 20 | from .utils import messages as msg 21 | from .utils import bom 22 | from .utils import coord_file 23 | from .utils.board import Board 24 | 25 | 26 | def cmdArgSetup(pcbmode_version): 27 | """ 28 | Sets up the commandline arguments form and variables 29 | """ 30 | 31 | description = "PCBmodE is a script-based PCB design tool that generates SVG files from JSON inpus files. It can then convert the SVG into Gerbers. Viewing and (some) editing is done with Inkscape. " 32 | 33 | epilog = """ 34 | """ 35 | 36 | # commandline argument settings and parsing 37 | argp = argparse.ArgumentParser(description=description, 38 | add_help=True, epilog=epilog) 39 | 40 | argp.add_argument('-b', '--board-name', 41 | dest='boards', required=True, nargs=1, 42 | help='The name of the board. The location of the files should be specified in the configuration file, otherwise defaults are used') 43 | 44 | argp.add_argument('-f', '--filein', required=False, 45 | dest='filein', 46 | help='Input file name') 47 | 48 | argp.add_argument('-o', '--fileout', 49 | dest='fileout', 50 | help='Output file name') 51 | 52 | argp.add_argument('-c', '--config-file', default='pcbmode_config.json', 53 | dest='config_file', 54 | help='Configuration file name (default=pcbmode_config.json)') 55 | 56 | argp.add_argument('-m', '--make-board', 57 | action='store_true', dest='make', default=False, 58 | help="Create SVG for the board specified with the '-b'/'--board_name' switch. The output's location can be specified in the configuration file") 59 | 60 | argp.add_argument('-e', '--extract', 61 | action='store_true', dest='extract', default=False, 62 | help="Extract routing and component placement from board's SVG") 63 | 64 | argp.add_argument('--extract-refdefs', 65 | action='store_true', dest='extract_refdefs', default=False, 66 | help="Extract components' reference designator location and rotation from board's SVG") 67 | 68 | argp.add_argument('--fab', nargs='?', 69 | dest='fab', default=False, 70 | help='Generate manufacturing files (Gerbers, Excellon, etc.) An optional argument specifies the fab for custom filenames') 71 | 72 | argp.add_argument('-p', '--make-pngs', 73 | action='store_true', dest='pngs', default=False, 74 | help='Generate a PNG of the board (requires Inkscape)') 75 | 76 | argp.add_argument('--no-layer-index', 77 | action='store_true', dest='no_layer_index', default=False, 78 | help='Do not add a layer index to SVG') 79 | 80 | argp.add_argument('--no-drill-index', 81 | action='store_true', dest='no_drill_index', default=False, 82 | help='Do not add a drill index to SVG') 83 | 84 | argp.add_argument('--no-flashes', 85 | action='store_true', dest='no_flashes', default=False, 86 | help='Do not add pad flashes to Gerbers') 87 | 88 | argp.add_argument('--no-docs', 89 | action='store_true', dest='no_docs', default=False, 90 | help='Do not add documentation') 91 | 92 | argp.add_argument('--renumber-refdefs', nargs='?', 93 | dest='renumber', default=False, 94 | help="Renumber refdefs (valid options are 'top-to-bottom' (default), 'bottom-to-top', 'left-to-right', 'right-to-left'") 95 | 96 | argp.add_argument('--make-coord-file', nargs='?', 97 | dest='coord_file', default=False, 98 | help="Create a simple placement coordinate CSV file") 99 | 100 | argp.add_argument('--make-bom', nargs='?', 101 | dest='make_bom', default=False, 102 | help='Create a bill of materials') 103 | 104 | argp.add_argument('--sig-dig', nargs=1, 105 | dest='sig_dig', default=False, 106 | help="Number of significant digits to use when generating the board's SVG. Valid values are between 2 and 8.") 107 | 108 | 109 | return argp 110 | 111 | 112 | 113 | 114 | 115 | 116 | def makeConfig(name, version, cmdline_args): 117 | """ 118 | """ 119 | 120 | # Read in PCBmodE's configuration file. Look for it in the 121 | # calling directory, and then where the script is 122 | msg.info("Processing PCBmodE's configuration file") 123 | 124 | paths = [os.path.join(os.getcwd(), cmdline_args.config_file)] 125 | 126 | config_resource = (__name__, 'pcbmode_config.json') 127 | if resource_exists(*config_resource): 128 | paths.append(resource_filename(*config_resource)) 129 | 130 | filenames = '' 131 | for path in paths: 132 | filename = path 133 | filenames += " %s \n" % filename 134 | if os.path.isfile(filename): 135 | config.cfg = utils.dictFromJsonFile(filename) 136 | break 137 | 138 | if config.cfg == {}: 139 | msg.error("Couldn't open PCBmodE's configuration file %s. Looked for it here:\n%s" % (cmdline_args.config_file, filenames)) 140 | 141 | # add stuff 142 | config.cfg['name'] = name 143 | config.cfg['version'] = version 144 | config.cfg['base-dir'] = os.path.join(config.cfg['locations']['boards'], name) 145 | 146 | config.cfg['digest-digits'] = 10 147 | 148 | # Read in the board's configuration data 149 | msg.info("Processing board's configuration file") 150 | filename = os.path.join(config.cfg['locations']['boards'], 151 | config.cfg['name'], 152 | config.cfg['name'] + '.json') 153 | config.brd = utils.dictFromJsonFile(filename) 154 | 155 | tmp_dict = config.brd.get('config') 156 | if tmp_dict != None: 157 | config.brd['config']['units'] = tmp_dict.get('units', 'mm') or 'mm' 158 | config.brd['config']['style-layout'] = tmp_dict.get('style-layout', 'default') or 'default' 159 | else: 160 | config.brd['config'] = {} 161 | config.brd['config']['units'] = 'mm' 162 | config.brd['config']['style-layout'] = 'default' 163 | 164 | 165 | #================================= 166 | # Style 167 | #================================= 168 | 169 | # Get style file; search for it in the project directory and 170 | # where the script it 171 | layout_style = config.brd['config']['style-layout'] 172 | layout_style_filename = 'layout.json' 173 | paths = [os.path.join(config.cfg['base-dir'], 174 | config.cfg['locations']['styles'], 175 | layout_style, layout_style_filename)] # project dir 176 | 177 | style_resource = (__name__, '/'.join(['styles', layout_style, layout_style_filename])) 178 | if resource_exists(*style_resource): 179 | paths.append(resource_filename(*style_resource)) 180 | 181 | filenames = '' 182 | for path in paths: 183 | filename = path 184 | filenames += " %s \n" % filename 185 | if os.path.isfile(filename): 186 | config.stl['layout'] = utils.dictFromJsonFile(filename) 187 | break 188 | 189 | if not 'layout' in config.stl or config.stl['layout'] == {}: 190 | msg.error("Couldn't find style file %s. Looked for it here:\n%s" % (layout_style_filename, filenames)) 191 | 192 | #------------------------------------------------------------- 193 | # Stackup 194 | #------------------------------------------------------------- 195 | try: 196 | stackup_filename = config.brd['stackup']['name'] + '.json' 197 | except: 198 | stackup_filename = 'two-layer.json' 199 | 200 | paths = [os.path.join(config.cfg['base-dir'], config.cfg['locations']['stackups'], stackup_filename)] # project dir 201 | 202 | stackup_resource = (__name__, '/'.join(['stackups', stackup_filename])) 203 | if resource_exists(*stackup_resource): 204 | paths.append(resource_filename(*stackup_resource)) 205 | 206 | filenames = '' 207 | for path in paths: 208 | filename = path 209 | filenames += " %s \n" % filename 210 | if os.path.isfile(filename): 211 | config.stk = utils.dictFromJsonFile(filename) 212 | break 213 | 214 | if config.stk == {}: 215 | msg.error("Couldn't find stackup file %s. Looked for it here:\n%s" % (stackup_filename, filenames)) 216 | 217 | config.stk['layers-dict'], config.stk['layer-names'] = utils.getLayerList() 218 | config.stk['surface-layers'] = [config.stk['layers-dict'][0], config.stk['layers-dict'][-1]] 219 | config.stk['internal-layers'] = config.stk['layers-dict'][1:-1] 220 | config.stk['surface-layer-names'] = [config.stk['layer-names'][0], config.stk['layer-names'][-1]] 221 | config.stk['internal-layer-names'] = config.stk['layer-names'][1:-1] 222 | 223 | #--------------------------------------------------------------- 224 | # Path database 225 | #--------------------------------------------------------------- 226 | filename = os.path.join(config.cfg['locations']['boards'], 227 | config.cfg['name'], 228 | config.cfg['locations']['build'], 229 | 'paths_db.json') 230 | 231 | # Open database file. If it doesn't exist, leave the database in 232 | # ots initial state of {} 233 | if os.path.isfile(filename): 234 | config.pth = utils.dictFromJsonFile(filename) 235 | 236 | 237 | #---------------------------------------------------------------- 238 | # Routing 239 | #---------------------------------------------------------------- 240 | filename = os.path.join(config.cfg['base-dir'], 241 | config.brd['files'].get('routing-json') or config.cfg['name'] + '_routing.json') 242 | 243 | # Open database file. If it doesn't exist, leave the database in 244 | # ots initial state of {} 245 | if os.path.isfile(filename): 246 | config.rte = utils.dictFromJsonFile(filename) 247 | else: 248 | config.rte = {} 249 | 250 | 251 | # namespace URLs 252 | config.cfg['ns'] = { 253 | None : "http://www.w3.org/2000/svg", 254 | "dc" : "http://purl.org/dc/elements/1.1/", 255 | "cc" : "http://creativecommons.org/ns#", 256 | "rdf" : "http://www.w3.org/1999/02/22-rdf-syntax-ns#", 257 | "svg" : "http://www.w3.org/2000/svg", 258 | "sodipodi" : "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd", 259 | "inkscape" : "http://www.inkscape.org/namespaces/inkscape", 260 | # Namespace URI are strings; they don't need to be URLs. See: 261 | # http://en.wikipedia.org/wiki/XML_namespace 262 | "pcbmode" : "pcbmode" 263 | } 264 | 265 | config.cfg['namespace'] = config.cfg['ns'] 266 | 267 | # Get amount of significant digits to use for floats 268 | config.cfg['significant-digits'] = config.cfg.get('significant-digits', 8) 269 | 270 | if cmdline_args.sig_dig != False: 271 | sig_dig = int(cmdline_args.sig_dig[0]) 272 | if (2 <= sig_dig <= 8): 273 | config.cfg['significant-digits'] = sig_dig 274 | else: 275 | msg.info("Commandline significant digit specification not in range, setting to %d" % config.cfg['significant-digits']) 276 | 277 | # buffer from board outline to display block edge 278 | config.cfg['display-frame-buffer'] = config.cfg.get('display_frame_buffer', 1.0) 279 | 280 | # the style for masks used for copper pours 281 | config.cfg['mask-style'] = "fill:#000;stroke:#000;stroke-linejoin:round;stroke-width:%s;" 282 | 283 | 284 | #------------------------------------------------------------------ 285 | # Distances 286 | #------------------------------------------------------------------ 287 | # If any of the distance definitions are missing from the board's 288 | # configuration file, use PCBmodE's defaults 289 | #------------------------------------------------------------------ 290 | config_distances_dict = config.cfg['distances'] 291 | try: 292 | board_distances_dict = config.brd.get('distances') 293 | except: 294 | board_distances_dict = {} 295 | 296 | distance_keys = ['from-pour-to', 'soldermask', 'solderpaste'] 297 | 298 | for dk in distance_keys: 299 | config_dict = config_distances_dict[dk] 300 | try: 301 | board_dict = board_distances_dict[dk] 302 | except: 303 | board_distances_dict[dk] = {} 304 | board_dict = board_distances_dict[dk] 305 | 306 | for k in config_dict.keys(): 307 | board_dict[k] = (board_dict.get(k) or config_dict[k]) 308 | 309 | #----------------------------------------------------------------- 310 | # Commandline overrides 311 | #----------------------------------------------------------------- 312 | # These are stored in a temporary dictionary so that they are not 313 | # written to the config file when the board's configuration is 314 | # dumped, with extraction, for example 315 | #----------------------------------------------------------------- 316 | config.tmp = {} 317 | config.tmp['no-layer-index'] = (cmdline_args.no_layer_index or 318 | config.brd['config'].get('no-layer-index') or 319 | False) 320 | config.tmp['no-flashes'] = (cmdline_args.no_flashes or 321 | config.brd['config'].get('no-flashes') or 322 | False) 323 | config.tmp['no-docs'] = (cmdline_args.no_docs or 324 | config.brd['config'].get('no-docs') or 325 | False) 326 | config.tmp['no-drill-index'] = (cmdline_args.no_drill_index or 327 | config.brd['config'].get('no-drill-index') or 328 | False) 329 | 330 | 331 | # Define Gerber setting from board's config or defaults 332 | try: 333 | tmp = config.brd['gerber'] 334 | except: 335 | config.brd['gerber'] = {} 336 | gd = config.brd['gerber'] 337 | gd['decimals'] = config.brd['gerber'].get('decimals') or 6 338 | gd['digits'] = config.brd['gerber'].get('digits') or 6 339 | gd['steps-per-segment'] = config.brd['gerber'].get('steps-per-segment') or 100 340 | gd['min-segment-length'] = config.brd['gerber'].get('min-segment-length') or 0.05 341 | 342 | # Inkscape inverts the 'y' axis for some historical reasons. 343 | # This means that we need to invert it as well. This should 344 | # be the only place this inversion happens so it's easy to 345 | # control if things change. 346 | config.cfg['invert-y'] = -1 347 | 348 | 349 | #----------------------------------------------------------------- 350 | # Commandline overrides 351 | #----------------------------------------------------------------- 352 | # Controls the visibility of layers and whether they are locked by 353 | # default. This is the "master" control; settings in the board's 354 | # config file will override these settings 355 | #----------------------------------------------------------------- 356 | layer_control_default = { 357 | "conductor": { 358 | "place": True, "hide": False, "lock": False, 359 | "pours": { "place": True, "hide": False, "lock": True }, 360 | "pads": { "place": True, "hide": False, "lock": False }, 361 | "routing": { "place": True, "hide": False, "lock": False } 362 | }, 363 | "soldermask": { "place": True, "hide": False, "lock": False }, 364 | "solderpaste": { "place": True, "hide": True, "lock": True }, 365 | "silkscreen": { "place": True, "hide": False, "lock": False }, 366 | "assembly": { "place": True, "hide": False, "lock": False }, 367 | "documentation": { "place": True, "hide": False, "lock": False }, 368 | "dimensions": { "place": True, "hide": False, "lock": True }, 369 | "origin": { "place": True, "hide": False, "lock": True }, 370 | "drills": { "place": True, "hide": False, "lock": False }, 371 | "placement": { "place": True, "hide": False, "lock": False }, 372 | "outline": { "place": True, "hide": False, "lock": True } 373 | } 374 | 375 | # Get overrides 376 | layer_control_config = config.brd.get('layer-control') 377 | if layer_control_config != None: 378 | config.brd['layer-control'] = dict(layer_control_default.items() + 379 | layer_control_config.items()) 380 | else: 381 | config.brd['layer-control'] = layer_control_default 382 | 383 | 384 | return 385 | 386 | 387 | 388 | 389 | 390 | def main(): 391 | 392 | # Get PCBmodE version 393 | version = utils.get_git_revision() 394 | 395 | # Setup and parse commandline arguments 396 | argp = cmdArgSetup(version) 397 | cmdline_args = argp.parse_args() 398 | 399 | # Might support running multiple boards in the future, 400 | # for now get the first onw 401 | board_name = cmdline_args.boards[0] 402 | makeConfig(board_name, version, cmdline_args) 403 | 404 | # Check if build directory exists; if not, create 405 | build_dir = os.path.join(config.cfg['base-dir'], config.cfg['locations']['build']) 406 | utils.create_dir(build_dir) 407 | 408 | # Renumber refdefs and dump board config file 409 | if cmdline_args.renumber is not False: 410 | msg.info("Renumbering refdefs") 411 | if cmdline_args.renumber is None: 412 | order = 'top-to-bottom' 413 | else: 414 | order = cmdline_args.renumber.lower() 415 | 416 | utils.renumberRefdefs(order) 417 | 418 | # Extract information from SVG file 419 | elif cmdline_args.extract is True or cmdline_args.extract_refdefs is True: 420 | extract.extract(extract=cmdline_args.extract, 421 | extract_refdefs=cmdline_args.extract_refdefs) 422 | 423 | # Create a BoM 424 | elif cmdline_args.make_bom is not False: 425 | bom.make_bom(cmdline_args.make_bom) 426 | 427 | elif cmdline_args.coord_file is not False: 428 | coord_file.makeCoordFile(cmdline_args.coord_file) 429 | 430 | else: 431 | # Make the board 432 | if cmdline_args.make is True: 433 | msg.info("Creating board") 434 | board = Board() 435 | 436 | # Create production files (Gerbers, Excellon, etc.) 437 | if cmdline_args.fab is not False: 438 | if cmdline_args.fab is None: 439 | manufacturer = 'default' 440 | else: 441 | manufacturer = cmdline_args.fab.lower() 442 | 443 | msg.info("Creating Gerbers") 444 | gerber.gerberise(manufacturer) 445 | 446 | msg.info("Creating excellon drill file") 447 | excellon.makeExcellon(manufacturer) 448 | 449 | if cmdline_args.pngs is True: 450 | msg.info("Creating PNGs") 451 | utils.makePngs() 452 | 453 | 454 | filename = os.path.join(config.cfg['locations']['boards'], 455 | config.cfg['name'], 456 | config.cfg['locations']['build'], 457 | 'paths_db.json') 458 | 459 | try: 460 | f = open(filename, 'w') 461 | except IOError as e: 462 | print("I/O error({0}): {1}".format(e.errno, e.strerror)) 463 | 464 | json.dump(config.pth, f, sort_keys=True, indent=2) 465 | f.close() 466 | 467 | msg.info("Done!") 468 | 469 | 470 | 471 | if __name__ == "__main__": 472 | main() 473 | -------------------------------------------------------------------------------- /pcbmode/utils/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import json 4 | import os 5 | import re 6 | import subprocess as subp # for shell commands 7 | import math 8 | from operator import itemgetter # for sorting lists by dict value 9 | from lxml import etree as et 10 | 11 | try: 12 | # Python 3 13 | import html.parser as HTMLParser 14 | except: 15 | # Python 2 16 | import HTMLParser 17 | 18 | from pkg_resources import get_distribution 19 | 20 | import pcbmode.config as config 21 | 22 | # pcbmode modules 23 | from .point import Point 24 | from . import messages as msg 25 | import hashlib 26 | 27 | 28 | 29 | 30 | def dictToStyleText(style_dict): 31 | """ 32 | Convert a dictionary into an SVG/CSS style attribute 33 | """ 34 | 35 | style = '' 36 | for key in style_dict: 37 | style += "%s:%s;" % (key, style_dict[key]) 38 | 39 | return style 40 | 41 | 42 | 43 | 44 | 45 | def openBoardSVG(): 46 | """ 47 | Opens the built PCBmodE board SVG. 48 | Returns an ElementTree object 49 | """ 50 | 51 | filename = os.path.join(config.cfg['base-dir'], 52 | config.cfg['locations']['build'], 53 | config.cfg['name'] + '.svg') 54 | try: 55 | data = et.ElementTree(file=filename) 56 | except IOError as e: 57 | msg.error("Cannot open %s; has the board been made using the '-m' option yet?" % filename) 58 | 59 | return data 60 | 61 | 62 | 63 | 64 | 65 | 66 | def parseDimension(string): 67 | """ 68 | Parses a dimention recieved from the source files, separating the units, 69 | if specified, from the value 70 | """ 71 | if string != None: 72 | result = re.match('(-?\d*\.?\d+)\s?(\w+)?', string) 73 | value = float(result.group(1)) 74 | unit = result.group(2) 75 | else: 76 | value = None 77 | unit = None 78 | return value, unit 79 | 80 | 81 | 82 | 83 | def to_Point(coord=[0, 0]): 84 | """ 85 | Takes a coordinate in the form of [x,y] and 86 | returns a Point type 87 | """ 88 | return Point(coord[0], coord[1]) 89 | 90 | 91 | 92 | 93 | def toPoint(coord=[0, 0]): 94 | """ 95 | Takes a coordinate in the form of [x,y] and 96 | returns a Point type 97 | """ 98 | if coord == None: 99 | return None 100 | else: 101 | return Point(coord[0], coord[1]) 102 | 103 | 104 | 105 | 106 | 107 | def get_git_revision(): 108 | 109 | return get_distribution('pcbmode').version 110 | 111 | 112 | 113 | 114 | 115 | 116 | def makePngs(): 117 | """ 118 | Creates a PNG of the board using Inkscape 119 | """ 120 | 121 | # Directory for storing the Gerbers within the build path 122 | images_path = os.path.join(config.cfg['base-dir'], 123 | config.cfg['locations']['build'], 124 | 'images') 125 | # Create it if it doesn't exist 126 | create_dir(images_path) 127 | 128 | # create individual PNG files for layers 129 | png_dpi = 600 130 | msg.subInfo("Generating PNGs for each layer of the board") 131 | 132 | command = ['inkscape', 133 | '--without-gui', 134 | '--file=%s' % os.path.join(config.cfg['base-dir'], 135 | config.cfg['locations']['build'], 136 | config.cfg['name'] + '.svg'), 137 | '--export-png=%s' % os.path.join(images_path, config.cfg['name'] + '_rev_' + 138 | config.brd['config']['rev'] + 139 | '.png'), 140 | '--export-dpi=%s' % str(png_dpi), 141 | '--export-area-drawing', 142 | '--export-background=#FFFFFF'] 143 | 144 | try: 145 | subp.call(command) 146 | except OSError as e: 147 | msg.error("Cannot find, or run, Inkscape in commandline mode") 148 | 149 | return 150 | 151 | 152 | 153 | 154 | 155 | # get_json_data_from_file 156 | def dictFromJsonFile(filename, error=True): 157 | """ 158 | Open a json file and returns its content as a dict 159 | """ 160 | 161 | def checking_for_unique_keys(pairs): 162 | """ 163 | Check if there are duplicate keys defined; this is useful 164 | for any hand-edited file 165 | 166 | This SO answer was useful here: 167 | http://stackoverflow.com/questions/16172011/json-in-python-receive-check-duplicate-key-error 168 | """ 169 | result = dict() 170 | for key,value in pairs: 171 | if key in result: 172 | msg.error("duplicate key ('%s') specified in %s" % (key, filename), KeyError) 173 | result[key] = value 174 | return result 175 | 176 | try: 177 | with open(filename, 'r') as f: 178 | json_data = json.load(f, object_pairs_hook=checking_for_unique_keys) 179 | except (IOError, OSError): 180 | if error == True: 181 | msg.error("Couldn't open JSON file: %s" % filename, IOError) 182 | else: 183 | msg.info("Couldn't open JSON file: %s" % filename, IOError) 184 | 185 | return json_data 186 | 187 | 188 | 189 | 190 | def getLayerList(): 191 | """ 192 | """ 193 | layer_list = [] 194 | for record in config.stk['stackup']: 195 | if record['type'] == 'signal-layer-surface' or record['type'] == 'signal-layer-internal': 196 | layer_list.append(record) 197 | 198 | layer_names = [] 199 | for record in layer_list: 200 | layer_names.append(record['name']) 201 | 202 | return layer_list, layer_names 203 | 204 | 205 | 206 | def getSurfaceLayers(): 207 | """ 208 | Returns a list of surface layer names 209 | Only here until this function is purged from the 210 | codebase 211 | """ 212 | return config.stk['surface-layer-names'] 213 | 214 | 215 | 216 | 217 | def getInternalLayers(): 218 | """ 219 | Returns a list of internal layer names 220 | Only here until this function is purged from the 221 | codebase 222 | """ 223 | return config.stk['internal-layer-names'] 224 | 225 | 226 | 227 | 228 | def getExtendedLayerList(layers): 229 | """ 230 | For the list of layers we may get a list of all 231 | internal layers ('internal-1', 'internal-2, etc.) or 232 | simply 'internal', meaning that that shape is meant 233 | to go into all internal layers, which is the most 234 | common case. The following 'expands' the layer list 235 | """ 236 | if 'internal' in layers: 237 | layers.remove('internal') 238 | layers.extend(config.stk['internal-layer-names']) 239 | return layers 240 | 241 | 242 | 243 | 244 | def getExtendedSheetList(layer, sheet): 245 | """ 246 | We may want multiple sheets of the same type, such as two 247 | soldermask layers on the same physical layer. This function 248 | expands the list if such layers are defined in the stackup 249 | """ 250 | 251 | for layer_dict in config.stk['layers-dict']: 252 | if layer_dict['name'] == layer: 253 | break 254 | stack_sheets = layer_dict['stack'] 255 | 256 | sheet_names = [] 257 | for stack_sheet in stack_sheets: 258 | sheet_names.append(stack_sheet['name']) 259 | 260 | new_list = [] 261 | for sheet_name in sheet_names: 262 | if sheet_name.startswith(sheet): 263 | new_list.append(sheet_name) 264 | 265 | return new_list 266 | 267 | 268 | 269 | 270 | 271 | def create_dir(path): 272 | """ 273 | Checks if a directory exists, and creates one if not 274 | """ 275 | 276 | try: 277 | # try to create directory first; this prevents TOCTTOU-type race condition 278 | os.makedirs(path) 279 | except OSError: 280 | # if the dir exists, pass 281 | if os.path.isdir(path): 282 | pass 283 | else: 284 | print("ERROR: couldn't create build path %s" % path) 285 | raise 286 | 287 | return 288 | 289 | 290 | 291 | 292 | 293 | def add_dict_values(d1, d2): 294 | """ 295 | Add the values of two dicts 296 | Helpful code here: 297 | http://stackoverflow.com/questions/1031199/adding-dictionaries-in-python 298 | """ 299 | 300 | return dict((n, d1.get(n, 0)+d2.get(n, 0)) for n in set(d1)|set(d2) ) 301 | 302 | 303 | 304 | 305 | 306 | 307 | def process_meander_type(type_string, meander_type): 308 | """ 309 | Extract meander path type parameters and return them as a dict 310 | """ 311 | 312 | if (meander_type == 'meander-round'): 313 | look_for = ['radius', 'theta', 'bus-width', 'pitch'] 314 | elif (meander_type == 'meander-sawtooth'): 315 | look_for = ['base-length', 'amplitude', 'bus-width', 'pitch'] 316 | else: 317 | print("ERROR: unrecognised meander type") 318 | reaise 319 | 320 | meander = {} 321 | 322 | regex = '\s*%s\s*:\s*(?P[^;]*)' 323 | 324 | for param in look_for: 325 | tmp = re.search(regex % param, type_string) 326 | if tmp is not None: 327 | meander[param] = float(tmp.group('v')) 328 | 329 | # add optional fields as 'None' 330 | for param in look_for: 331 | if meander.get(param) is None: 332 | meander[param] = None 333 | 334 | return meander 335 | 336 | 337 | 338 | 339 | 340 | def checkForPoursInLayer(layer): 341 | """ 342 | Returns True or False if there are pours in the specified layer 343 | """ 344 | 345 | # In case there are no 'shapes' defined 346 | try: 347 | pours = config.brd['shapes'].get('pours') 348 | except: 349 | pours = {} 350 | 351 | if pours is not None: 352 | print pours 353 | for pour_dict in pours: 354 | layers = getExtendedLayerList(pour_dict.get('layers')) 355 | if layer in layers: 356 | return True 357 | 358 | #return False 359 | return True 360 | 361 | 362 | 363 | 364 | def interpret_svg_matrix(matrix_data): 365 | """ 366 | Takes an array for six SVG parameters and returns angle, scale 367 | and placement coordinate 368 | 369 | This SO answer was helpful here: 370 | http://stackoverflow.com/questions/15546273/svg-matrix-to-rotation-degrees 371 | """ 372 | 373 | # apply float() to all elements, just in case 374 | matrix_data = [ float(x) for x in matrix_data ] 375 | 376 | coord = Point(matrix_data[4], -matrix_data[5]) 377 | if matrix_data[0] == 0: 378 | angle = math.degrees(0) 379 | else: 380 | angle = math.atan(matrix_data[2] / matrix_data[0]) 381 | 382 | scale = Point(math.fabs(matrix_data[0] / math.cos(angle)), 383 | math.fabs(matrix_data[3] / math.cos(angle))) 384 | 385 | # convert angle to degrees 386 | angle = math.degrees(angle) 387 | 388 | # Inkscape rotates anti-clockwise, PCBmodE "thinks" clockwise. The following 389 | # adjusts these two views, although at some point we'd 390 | # need to have the same view, or make it configurable 391 | angle = -angle 392 | 393 | return coord, angle, scale 394 | 395 | 396 | 397 | 398 | 399 | 400 | def parse_refdef(refdef): 401 | """ 402 | Parses a reference designator and returns the refdef categoty, 403 | number, and extra characters 404 | """ 405 | 406 | regex = r'^(?P[a-zA-z\D]+?)(?P\d+)(?P[\-\s].*)?' 407 | parse = re.match(regex, refdef) 408 | 409 | # TODO: there has to be a more elegant way for doing this! 410 | if parse == None: 411 | return None, None, None 412 | else: 413 | t = parse.group('t') 414 | n = int(parse.group('n')) 415 | e = parse.group('e') 416 | return t, n, e 417 | 418 | 419 | 420 | 421 | 422 | 423 | #def renumber_refdefs(cfg, order): 424 | def renumberRefdefs(order): 425 | """ 426 | Renumber the refdefs in the specified order 427 | """ 428 | 429 | components = config.brd['components'] 430 | comp_dict = {} 431 | new_dict = {} 432 | 433 | for refdef in components: 434 | 435 | rd_type, rd_number, rd_extra = parse_refdef(refdef) 436 | location = to_Point(components[refdef].get('location') or [0, 0]) 437 | tmp = {} 438 | tmp['record'] = components[refdef] 439 | tmp['type'] = rd_type 440 | tmp['number'] = rd_number 441 | tmp['extra'] = rd_extra 442 | tmp['coord-x'] = location.x 443 | tmp['coord-y'] = location.y 444 | 445 | if comp_dict.get(rd_type) == None: 446 | comp_dict[rd_type] = [] 447 | comp_dict[rd_type].append(tmp) 448 | 449 | # Sort list according to 'order' 450 | for comp_type in comp_dict: 451 | if order == 'left-to-right': 452 | reverse = False 453 | itemget_param = 'coord_x' 454 | elif order == 'right-to-left': 455 | reverse = True 456 | itemget_param = 'coord_x' 457 | elif order == 'top-to-bottom': 458 | reverse = True 459 | itemget_param = 'coord-y' 460 | elif order == 'bottom-to-top': 461 | reverse = False 462 | itemget_param = 'coord-y' 463 | else: 464 | msg.error('Unrecognised renumbering order %s' % (order)) 465 | 466 | sorted_list = sorted(comp_dict[comp_type], 467 | key=itemgetter(itemget_param), 468 | reverse=reverse) 469 | 470 | 471 | for i, record in enumerate(sorted_list): 472 | new_refdef = "%s%s" % (record['type'], i+1) 473 | if record['extra'] is not None: 474 | new_refdef += "%s" % (record['extra']) 475 | new_dict[new_refdef] = record['record'] 476 | 477 | config.brd['components'] = new_dict 478 | 479 | # Save board config to file (everything is saved, not only the 480 | # component data) 481 | filename = os.path.join(config.cfg['locations']['boards'], 482 | config.cfg['name'], 483 | config.cfg['name'] + '.json') 484 | try: 485 | with open(filename, 'wb') as f: 486 | f.write(json.dumps(config.brd, sort_keys=True, indent=2)) 487 | except: 488 | msg.error("Cannot save file %s" % filename) 489 | 490 | 491 | return 492 | 493 | 494 | 495 | 496 | 497 | 498 | def getTextParams(font_size, letter_spacing, line_height): 499 | try: 500 | letter_spacing, letter_spacing_unit = parseDimension(letter_spacing) 501 | except: 502 | msg.error("There's a problem with parsing the 'letter-spacing' property with value '%s'. The format should be an integer or float followed by 'mm' (the only unit supported). For example, '0.3mm' or '-2 mm' should work." % letter_spacing) 503 | 504 | if letter_spacing_unit == None: 505 | letter_spacing_unit = 'mm' 506 | 507 | try: 508 | line_height, line_height_unit = parseDimension(line_height) 509 | except: 510 | msg.error("There's a problem parsing the 'line-height' property with value '%s'. The format should be an integer or float followed by 'mm' (the only unit supported). For example, '0.3mm' or '-2 mm' should work." % line_height) 511 | 512 | if line_height_unit == None: 513 | line_height_unit = 'mm' 514 | 515 | try: 516 | font_size, font_size_unit = parseDimension(font_size) 517 | except: 518 | throw("There's a problem parsing the 'font-size'. It's most likely missing. The format should be an integer or float followed by 'mm' (the only unit supported). For example, '0.3mm' or '2 mm' should work. Of course, it needs to be a positive figure.") 519 | 520 | if font_size_unit == None: 521 | font_size_unit = 'mm' 522 | 523 | return float(font_size), float(letter_spacing), float(line_height) 524 | 525 | 526 | 527 | 528 | def textToPath(font_data, text, letter_spacing, line_height, scale_factor): 529 | from .svgpath import SvgPath 530 | """ 531 | Convert a text string (unicode and newlines allowed) to a path. 532 | The 'scale_factor' is needed in order to scale rp 'letter_spacing' and 'line_height' 533 | to the original scale of the font. 534 | """ 535 | 536 | # This the horizontal advance that applied to all glyphs unless there's a specification for 537 | # for the glyph itself 538 | font_horiz_adv_x = float(font_data.find("//n:font", namespaces={'n': config.cfg['namespace']['svg']}).get('horiz-adv-x')) 539 | 540 | # This is the number if 'units' per 'em'. The default, in the absence of a definition is 1000 541 | # according to the SVG spec 542 | units_per_em = float(font_data.find("//n:font-face", namespaces={'n': config.cfg['namespace']['svg']}).get('units-per-em')) or 1000 543 | 544 | glyph_ascent = float(font_data.find("//n:font-face", namespaces={'n': config.cfg['namespace']['svg']}).get('ascent')) 545 | glyph_decent = float(font_data.find("//n:font-face", namespaces={'n': config.cfg['namespace']['svg']}).get('descent')) 546 | 547 | text_width = 0 548 | text_path = '' 549 | 550 | # split text into charcters and find unicade chars 551 | try: 552 | text = re.findall(r'(\&#x[0-9abcdef]*;|.|\n)', text) 553 | except: 554 | throw("There's a problem parsing the text '%s'. Unicode and \\n newline should be fine, by the way." % text) 555 | 556 | 557 | # instantiate HTML parser 558 | htmlpar = HTMLParser.HTMLParser() 559 | gerber_lp = '' 560 | text_height = 0 561 | 562 | if line_height == None: 563 | line_height = units_per_em 564 | 565 | for i, symbol in enumerate(text[:]): 566 | 567 | symbol = htmlpar.unescape(symbol) 568 | # get the glyph definition from the file 569 | if symbol == '\n': 570 | text_width = 0 571 | text_height += units_per_em + (line_height/scale_factor-units_per_em) 572 | else: 573 | glyph = font_data.find(u'//n:glyph[@unicode="%s"]' % symbol, namespaces={'n': config.cfg['namespace']['svg']}) 574 | if glyph == None: 575 | utils.throw("Damn, there's no glyph definition for '%s' in the '%s' font :(" % (symbol, font)) 576 | else: 577 | # Unless the glyph has its own width, use the global font width 578 | glyph_width = float(glyph.get('horiz-adv-x') or font_horiz_adv_x) 579 | if symbol != ' ': 580 | glyph_path = SvgPath(glyph.get('d')) 581 | first_point = glyph_path.getFirstPoint() 582 | offset_x = float(first_point[0]) 583 | offset_y = float(first_point[1]) 584 | path = glyph_path.getRelative() 585 | path = re.sub('^(m\s?[-\d\.]+\s?,\s?[-\d\.]+)', 'M %s,%s' % (str(text_width+offset_x), str(offset_y-text_height)), path) 586 | gerber_lp += (glyph.get('gerber-lp') or 587 | glyph.get('gerber_lp') or 588 | "%s" % 'd'*glyph_path.getNumberOfSegments()) 589 | text_path += "%s " % (path) 590 | 591 | text_width += glyph_width+letter_spacing/scale_factor 592 | 593 | 594 | # Mirror text 595 | text_path = SvgPath(text_path) 596 | text_path.transform() 597 | text_path = text_path.getTransformedMirrored() 598 | 599 | return text_path, gerber_lp 600 | 601 | 602 | 603 | 604 | 605 | def digest(string): 606 | digits = config.cfg['digest-digits'] 607 | return hashlib.md5(string.encode()).hexdigest()[:digits-1] 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | def getStyleAttrib(style, attrib): 616 | """ 617 | """ 618 | regex = r".*?%s:\s?(?P[^;]*)(?:;|$)" 619 | match = re.match(regex % attrib, style) 620 | if match == None: 621 | return None 622 | else: 623 | return match.group('s') 624 | 625 | 626 | 627 | 628 | def niceFloat(f): 629 | if f.is_integer(): 630 | return int(f) 631 | else: 632 | return round(f, 6) 633 | 634 | 635 | 636 | 637 | def parseTransform(transform): 638 | """ 639 | Returns a Point() for the input transform 640 | """ 641 | data = {} 642 | 643 | if transform == None: 644 | data['type'] = 'translate' 645 | data['location'] = Point() 646 | elif 'translate' in transform.lower(): 647 | regex = r".*?translate\s?\(\s?(?P[+-]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)(\s?[\s,]\s?)?(?P[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)?\s?\).*" 648 | # regex = r".*?translate\s?\(\s?(?P[+-]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)\s?[\s,]\s?(?P[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)\s?\).*" 649 | # regex = r".*?translate\s?\(\s?(?P-?[0-9]*\.?[0-9]+)\s?[\s,]\s?(?P-?[0-9]*\.?[0-9]+\s?)\s?\).*" 650 | coord = re.match(regex, transform) 651 | data['type'] = 'translate' 652 | x = coord.group('x') 653 | y = coord.group('y') 654 | if coord.group('y') != None: 655 | y = coord.group('y') 656 | else: 657 | y = 0 658 | data['location'] = Point(x,y) 659 | elif 'matrix' in transform.lower(): 660 | data['type'] = 'matrix' 661 | data['location'], data['rotate'], data['scale'] = parseSvgMatrix(transform) 662 | elif 'rotate' in transform.lower(): 663 | data['type'] = 'rotate' 664 | data['location'], data['rotate'] = parseSvgRotate(transform) 665 | else: 666 | msg.error("Found a path transform that cannot be handled, %s. SVG stansforms should be in the form of 'translate(num,num)' or 'matrix(num,num,num,num,num,num)" % transform) 667 | 668 | return data 669 | 670 | 671 | 672 | def parseSvgRotate(rotate): 673 | """ 674 | """ 675 | regex = r".*?rotate\((?P.*?)\).*" 676 | rotate = re.match(regex, rotate) 677 | rotate = rotate.group('m') 678 | rotate = rotate.split(',') 679 | 680 | # Apply float() to all elements 681 | rotate = [ float(x) for x in rotate ] 682 | 683 | location = Point(rotate[1], rotate[2]) 684 | 685 | angle = rotate[0] 686 | 687 | return location, angle 688 | 689 | 690 | 691 | 692 | def parseSvgMatrix(matrix): 693 | """ 694 | Takes an array for six SVG parameters and returns angle, scale 695 | and placement coordinate 696 | 697 | This SO answer was helpful here: 698 | http://stackoverflow.com/questions/15546273/svg-matrix-to-rotation-degrees 699 | """ 700 | regex = r".*?matrix\((?P.*?)\).*" 701 | matrix = re.match(regex, matrix) 702 | matrix = matrix.group('m') 703 | matrix = matrix.split(',') 704 | 705 | # Apply float() to all elements 706 | matrix = [ float(x) for x in matrix ] 707 | 708 | coord = Point(matrix[4], matrix[5]) 709 | if matrix[0] == 0: 710 | angle = math.degrees(0) 711 | else: 712 | angle = math.atan(matrix[2] / matrix[0]) 713 | 714 | #scale = Point(math.fabs(matrix[0] / math.cos(angle)), 715 | # math.fabs(matrix[3] / math.cos(angle))) 716 | scale_x = math.sqrt(matrix[0]*matrix[0] + matrix[1]*matrix[1]), 717 | scale_y = math.sqrt(matrix[2]*matrix[2] + matrix[3]*matrix[3]), 718 | 719 | scale = max(scale_x, scale_y)[0] 720 | 721 | # convert angle to degrees 722 | angle = math.degrees(angle) 723 | 724 | # Inkscape rotates anti-clockwise, PCBmodE "thinks" clockwise. The following 725 | # adjusts these two views, although at some point we'd 726 | # need to have the same view, or make it configurable 727 | angle = -angle 728 | 729 | return coord, angle, scale 730 | 731 | 732 | 733 | 734 | 735 | 736 | --------------------------------------------------------------------------------