├── .github └── workflows │ ├── build_asset.yml │ └── build_asset_release.yml ├── .gitignore ├── LICENSE ├── README.md ├── SparkFunKiCadPanelizer ├── __init__.py ├── dialog │ ├── __init__.py │ ├── compat.py │ ├── dialog.py │ └── dialog_text_base.py ├── icon.png ├── panelizer │ ├── __init__.py │ └── panelizer.py ├── plugin.py ├── resource │ ├── _version.py │ └── info-15.png ├── test_dialog.py └── util.py ├── __init__.py ├── icons └── SparkFunPanelizer.png ├── img ├── install_from_file.png ├── panelizer.png ├── run_panelizer.png └── run_panelizer_2.png ├── pcm ├── build.py ├── metadata_template.json └── resources │ └── icon.png └── text_dialog.fbp /.github/workflows/build_asset.yml: -------------------------------------------------------------------------------- 1 | name: Build Asset 2 | on: 3 | workflow_dispatch: 4 | branches: 5 | 6 | jobs: 7 | build: 8 | 9 | name: Build 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@master 15 | 16 | - name: Setup Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: '3.11' 20 | 21 | - name: Update zip 22 | run: | 23 | cd pcm 24 | python build.py 25 | cd build 26 | echo "ZIP_NAME=$(ls SparkFunKiCadPanelizer*.zip)" >> $GITHUB_ENV 27 | echo "PCM_NAME=$(ls SparkFunKiCadPanelizer*.zip | rev | cut -c 5- | rev)" >> $GITHUB_ENV 28 | 29 | - name: Upload pcm build to action - avoid double-zip 30 | uses: actions/upload-artifact@v3 31 | with: 32 | name: ${{ env.PCM_NAME }} 33 | path: ./pcm/build/${{ env.PCM_NAME }} 34 | retention-days: 7 35 | -------------------------------------------------------------------------------- /.github/workflows/build_asset_release.yml: -------------------------------------------------------------------------------- 1 | name: Build Asset for Release 2 | on: 3 | release: 4 | types: [published] 5 | 6 | jobs: 7 | build: 8 | 9 | name: Build 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@master 15 | 16 | - name: Setup Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: '3.11' 20 | 21 | - name: Update zip 22 | run: | 23 | cd pcm 24 | python build.py 25 | cd build 26 | echo "ZIP_NAME=$(ls SparkFunKiCadPanelizer*.zip)" >> $GITHUB_ENV 27 | echo "PCM_NAME=$(ls SparkFunKiCadPanelizer*.zip | rev | cut -c 5- | rev)" >> $GITHUB_ENV 28 | 29 | - name: Publish release 30 | uses: softprops/action-gh-release@v1 31 | with: 32 | files: | 33 | ./pcm/build/${{ env.ZIP_NAME }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[co] 3 | panel_config.json 4 | panelizer.log 5 | pcm/build/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | **SparkFun code, firmware, and software is released under the MIT License(http://opensource.org/licenses/MIT).** 3 | 4 | The MIT License (MIT) 5 | 6 | Copyright (c) 2023 SparkFun Electronics 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SparkFun PCB Panelizer plugin for KiCad 7 / 8 / 9 2 | 3 | This plugin converts a single PCB into a panel of multiple PCBs, separated by v-score grooves. 4 | 5 | ![Panelizer](./img/panelizer.png) 6 | 7 | We've tried to keep this panelizer simple and easy-to-use, while also including all of the features of the original [SparkFun Panelizer for Eagle](https://github.com/sparkfun/SparkFun_Eagle_Settings/blob/main/ulp/SparkFun-Panelizer.ulp). If you need a more comprehensive panelizer which supports tabs, rounded corners, mouse-bites and a whole bunch of other features, please check out [Jan Mrázek (@yaqwsx)](https://github.com/yaqwsx)'s [KiKit](https://github.com/yaqwsx/KiKit). 8 | 9 | ## Limitations 10 | 11 | This is a simple panelizer. Simple to install, simple to use. Simple to write and maintain too. As such, it has limitations...: 12 | 13 | * Each copy of the board uses the same net names; if you run the DRC on the panel, it will find multiple unconnected net errors between the copies. 14 | * The part references are duplicated too; you will see multiple R1's, U1's, etc. in the BOM. (Some users actually prefer that!) ([KiKit](https://github.com/yaqwsx/KiKit) avoids this by creating unique copies with unique net names and part IDs.) 15 | * The edge cuts of the original board are copied across into the panel. The v-score grooves define and 'fill' the gap between boards. We've never had a problem having panels manufactured even though, strictly, the individual boards are 'floating' within the panel. (That's how the [SparkFun Panelizer for Eagle](https://github.com/sparkfun/SparkFun_Eagle_Settings/blob/main/ulp/SparkFun-Panelizer.ulp) does it too.) 16 | 17 | ## Installation and Usage 18 | 19 | Open the KiCad Plugin and Content Manager (PCM) from the main window and filter for `SparkFun Panelizer`. 20 | 21 | To install manually, open the [GitHub Repo Releases page](https://github.com/sparkfun/SparkFun_KiCad_Panelizer/releases) and download the `SparkFunKiCadPanelizer-pcm.zip` file attached to the latest release. Then use the PCM _**Install from File...**_ option and select the .zip file to install it. For best results, **Uninstall** the previous version first, **Apply Pending Changes**, and then **Install from File...**. 22 | 23 | ![Install manually](./img/install_from_file.png) 24 | 25 | The panelizer plugin runs inside the KiCad PCB Editor window. (Although you can run the panelizer in a Command Prompt too. See [below](#how-it-works) for details.) 26 | 27 | Click the four flame panelizer icon to open the panelizer GUI: 28 | 29 | ![Open panelizer](./img/run_panelizer.png) 30 | 31 | We have deliberately kept the GUI options as simple as possible. (More options are available in the Command Prompt). 32 | 33 | * Select your units: inches or mm. The panel size and gap settings are defined using these units. 34 | * Set your preferred panel size in X and Y. You can instruct the panelizer to make the panel smaller or larger than the defined size. We usually want the panel to be smaller than 5.5" x 7.5" for our Pick and Place machine. But larger-than is handy if you are creating a panel containing a single PCB. Set the panel size to (e.g.) 1" x 1", and select larger-than. 35 | * You can add vertical or horizontal gaps between columns or rows of boards. This is handy if you have overhanging components - like USB-C connectors. 36 | * If you are designing a M.2 card - like our MicroMod Processor Boards and Function Boards - select the exposed edge option. The panelizer will create a panel with one or two rows of PCBs depending on the panel size. For two-row, the top row is automatically rotated by 180 degrees to expose the PCB 'bottom' edge. 37 | * From v1.1.0, you can use the V-Score page to set the layer for the v-score lines and text. Previously this was hard-coded to `User.Comments`. From v1.1.0, you can select an alternate layer (e.g. `User.1`) if `User.Comments` already has text on it. Select the same layer when running the [CAMmer](https://github.com/sparkfun/SparkFun_KiCad_CAMmer). 38 | 39 | Click **Panelize** to panelize the board. 40 | 41 | ![Run panelizer](./img/run_panelizer_2.png) 42 | 43 | The panel is automatically saved to a sub-folder called `Production` and has "panelized" included in the file name. KiCad will save it as a separate project, with its own separate backups. Remember to re-open your original PCB afterwards. 44 | 45 | The panelizer settings are saved in a file called `panel_config.json` so they can be reused. 46 | 47 | SparkFun ordering instructions are saved in `ordering_instructions.txt`. These are generated from the logos and text found in the PCB drawing. 48 | 49 | `panelizer.log` contains useful diagnostic information. If the PCB fails to panelize, you will find the reason why in `panelizer.log`. 50 | 51 | ## License and Credits 52 | 53 | The code for this plugin is licensed under the MIT license. Please see `LICENSE` for more info. 54 | 55 | `panelizer.py` is based heavily on [Simon John (@sej7278)](https://github.com/sej7278/kicad-panelizer)'s version of [Willem Hillier (@willemcvu)'s kicad-panelizer](https://github.com/willemcvu/kicad-panelizer). 56 | 57 | The [wxFormBuilder](https://github.com/wxFormBuilder/wxFormBuilder/releases) `text_dialog.fbp` and associated code is based on [Greg Davill (@gregdavill)](https://github.com/gregdavill)'s [KiBuzzard](https://github.com/gregdavill/KiBuzzard). 58 | 59 | ## How It Works 60 | 61 | The plugin GUI itself is designed with [wxFormBuilder](https://github.com/wxFormBuilder/wxFormBuilder/releases) and stored in `text_dialog.fbp`. 62 | Copy and paste the wx Python code from wxFormBuilder into `./SparkFunKiCadPanelizer/dialog/dialog_text_base.py`. 63 | 64 | `.github/workflows/build_asset_release.yml` generates the .zip file containing the plugin Python code (`./plugins`), icon (`./resources`) and the Plugin and Content Manager (PCM) `metadata.json`. The workflow automatically attaches the zip file to each release as an asset. Edit `./SparkFunKiCadPanelizer/resource/_version.py` first and update the version number. `build.py` is called by the workflow, copies `metadata_template.json` into `metadata.json` and then updates it with the correct version and download URL. The version number is also added to the .zip filename. The PCM should automatically download and install new versions of the panelizer for you. 65 | 66 | You can run the panelizer stand-alone if you want to. Open a **KiCad 7.0 Command Prompt**. On Windows, you will find this in `Start Menu / All Apps / KiCad 7.0`. cd to the `SparkFun_KiCad_Panelizer\SparkFunKiCadPanelizer\panelizer` directory. `python panelizer.py` will show the help for the arguments. When you run the panelizer plugin in KiCad, it will panelize whichever PCB is currently open and save the panel into a copy with the suffix **_panelized.kicad_pcb**. When running the panelizer stand-alone, you need to provide the path `-p` to the PCB to be panelized. But again the panel is saved as a separate copy. 67 | 68 | - Your friends at SparkFun 69 | 70 | -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import subprocess 4 | import threading 5 | import time 6 | 7 | import wx 8 | import wx.aui 9 | from wx import FileConfig 10 | 11 | import os 12 | from .util import add_paths 13 | dir_path = os.path.dirname(os.path.realpath(__file__)) 14 | paths = dir_path 15 | #paths = [ 16 | # os.path.join(dir_path, 'deps'), 17 | #] 18 | 19 | def check_for_panelizer_button(): 20 | # From Miles McCoo's blog 21 | # https://kicad.mmccoo.com/2017/03/05/adding-your-own-command-buttons-to-the-pcbnew-gui/ 22 | def find_pcbnew_window(): 23 | windows = wx.GetTopLevelWindows() 24 | pcbneww = [w for w in windows if "pcbnew" in w.GetTitle().lower()] 25 | if len(pcbneww) != 1: 26 | return None 27 | return pcbneww[0] 28 | 29 | def callback(_): 30 | plugin.Run() 31 | 32 | while not wx.GetApp(): 33 | time.sleep(1) 34 | bm = wx.Bitmap(os.path.join(os.path.dirname(__file__),'icon.png'), wx.BITMAP_TYPE_PNG) 35 | button_wx_item_id = 0 36 | 37 | from pcbnew import ID_H_TOOLBAR 38 | while True: 39 | time.sleep(1) 40 | pcbnew_window = find_pcbnew_window() 41 | if not pcbnew_window: 42 | continue 43 | 44 | top_tb = pcbnew_window.FindWindowById(ID_H_TOOLBAR) 45 | if button_wx_item_id == 0 or not top_tb.FindTool(button_wx_item_id): 46 | top_tb.AddSeparator() 47 | button_wx_item_id = wx.NewId() 48 | top_tb.AddTool(button_wx_item_id, "SparkFunKiCadPanelizer", bm, 49 | "SparkFun KiCad Panelizer", wx.ITEM_NORMAL) 50 | top_tb.Bind(wx.EVT_TOOL, callback, id=button_wx_item_id) 51 | top_tb.Realize() 52 | 53 | 54 | try: 55 | with add_paths(paths): 56 | from .plugin import PanelizerPlugin 57 | plugin = PanelizerPlugin() 58 | plugin.register() 59 | except Exception as e: 60 | print(e) 61 | import logging 62 | root = logging.getLogger() 63 | root.debug(repr(e)) 64 | 65 | # Add a button the hacky way if plugin button is not supported 66 | # in pcbnew, unless this is linux. 67 | if not plugin.pcbnew_icon_support and not sys.platform.startswith('linux'): 68 | t = threading.Thread(target=check_for_panelizer_button) 69 | t.daemon = True 70 | t.start() 71 | 72 | -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/dialog/__init__.py: -------------------------------------------------------------------------------- 1 | from .dialog import Dialog 2 | -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/dialog/compat.py: -------------------------------------------------------------------------------- 1 | 2 | import wx 3 | 4 | class DialogShim(wx.Dialog): 5 | def __init__(self, parent, **kwargs): 6 | try: 7 | wx.StockGDI._initStockObjects() 8 | except: 9 | pass 10 | 11 | wx.Dialog.__init__(self, parent, **kwargs) 12 | 13 | def SetSizeHints(self, a, b, c=None): 14 | if c is not None: 15 | super(wx.Dialog, self).SetSizeHints(a,b,c) 16 | else: 17 | super(wx.Dialog, self).SetSizeHints(a,b) # Was super(wx.Dialog, self).SetSizeHintsSz(a,b) 18 | -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/dialog/dialog.py: -------------------------------------------------------------------------------- 1 | """Subclass of dialog_text_base, which is generated by wxFormBuilder.""" 2 | from logging import exception 3 | import os 4 | import wx 5 | import json 6 | import sys 7 | 8 | from . import dialog_text_base 9 | 10 | _APP_NAME = "SparkFun KiCad Panelizer" 11 | 12 | # sub folder for our resource files 13 | _RESOURCE_DIRECTORY = os.path.join("..", "resource") 14 | 15 | #https://stackoverflow.com/a/50914550 16 | def resource_path(relative_path): 17 | """ Get absolute path to resource, works for dev and for PyInstaller """ 18 | base_path = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) 19 | return os.path.join(base_path, _RESOURCE_DIRECTORY, relative_path) 20 | 21 | def get_version(rel_path: str) -> str: 22 | try: 23 | with open(resource_path(rel_path), encoding='utf-8') as fp: 24 | for line in fp.read().splitlines(): 25 | if line.startswith("__version__"): 26 | delim = '"' if '"' in line else "'" 27 | return line.split(delim)[1] 28 | raise RuntimeError("Unable to find version string.") 29 | except: 30 | raise RuntimeError("Unable to find _version.py.") 31 | 32 | _APP_VERSION = get_version("_version.py") 33 | 34 | def get_btn_bitmap(bitmap): 35 | path = resource_path(bitmap) 36 | png = wx.Bitmap(path, wx.BITMAP_TYPE_PNG) 37 | return wx.BitmapBundle(png) 38 | 39 | def ParseFloat(InputString, DefaultValue=0.0): 40 | value = DefaultValue 41 | if InputString != "": 42 | try: 43 | value = float(InputString) 44 | except ValueError: 45 | print("Value not valid") 46 | return value 47 | 48 | class Dialog(dialog_text_base.DialogPanelBase): 49 | def __init__(self, parent, config, layertable, ordering, panelizer, func): 50 | 51 | dialog_text_base.DialogPanelBase.__init__(self, None) 52 | 53 | self.panel = DialogPanel(self, config, layertable, ordering, panelizer, func) 54 | 55 | best_size = self.panel.BestSize 56 | # hack for some gtk themes that incorrectly calculate best size 57 | best_size.IncBy(dx=0, dy=30) 58 | self.SetClientSize(best_size) 59 | 60 | self.SetTitle(_APP_NAME + " - " + _APP_VERSION) 61 | 62 | # hack for new wxFormBuilder generating code incompatible with old wxPython 63 | # noinspection PyMethodOverriding 64 | def SetSizeHints(self, sz1, sz2): 65 | try: 66 | # wxPython 4 67 | super(Dialog, self).SetSizeHints(sz1, sz2) 68 | except TypeError: 69 | # wxPython 3 70 | self.SetSizeHintsSz(sz1, sz2) 71 | 72 | class DialogPanel(dialog_text_base.DialogPanel): 73 | # The names of the config items need to match the names in dialog_text_base minus the m_ 74 | # - except for vScoreLayer 75 | vscore_layer = 'vScoreLayer' 76 | default_vscore_layer = 'User.Comments' 77 | config_defaults = { 78 | 'dimensionsInchesBtn': 'true', 79 | 'dimensionsMmBtn': 'false', 80 | 'panelSizeSmallerBtn': 'true', 81 | 'panelSizeLargerBtn': 'false', 82 | 'panelSizeXCtrl': '5.5', 83 | 'panelSizeYCtrl': '7.5', 84 | 'gapsVerticalCtrl': '0.0', 85 | 'gapsHorizontalCtrl': '0.0', 86 | 'removeRightVerticalCheck': 'false', 87 | 'productionBordersCheck': 'false', 88 | 'productionFiducialsCheck': 'false', 89 | 'productionExposeCheck': 'false', 90 | vscore_layer: default_vscore_layer 91 | } 92 | 93 | def __init__(self, parent, config, layertable, ordering, panelizer, func): 94 | 95 | dialog_text_base.DialogPanel.__init__(self, parent) 96 | 97 | self.config_file = config 98 | 99 | self.layertable = layertable 100 | 101 | self.ordering_instructions = ordering 102 | 103 | self.panelizer = panelizer 104 | 105 | self.func = func 106 | 107 | self.error = None 108 | 109 | self.general = GeneralPanel(self.notebook) 110 | self.vscore = VScorePanel(self.notebook) 111 | self.notebook.AddPage(self.general, "General") 112 | self.notebook.AddPage(self.vscore, "V-Score") 113 | 114 | # Delete any existing rows in LayersGrid 115 | if self.vscore.LayersGrid.NumberRows: 116 | self.vscore.LayersGrid.DeleteRows(0, self.vscore.LayersGrid.NumberRows) 117 | # Append empty rows based on layertable 118 | self.vscore.LayersGrid.AppendRows(len(self.layertable)) 119 | # Initialize them 120 | row = 0 121 | for layer, names in self.layertable.items(): 122 | self.vscore.LayersGrid.SetCellValue(row, 0, "0") # JSON style 123 | self.vscore.LayersGrid.SetCellRenderer(row, 0, wx.grid.GridCellBoolRenderer()) 124 | layerName = names['standardName'] 125 | if names['actualName'] != names['standardName']: 126 | layerName += " (" + names['actualName'] + ")" 127 | self.vscore.LayersGrid.SetCellValue(row, 1, layerName) 128 | self.vscore.LayersGrid.SetReadOnly(row, 1) 129 | row += 1 130 | 131 | self.loadConfig() 132 | 133 | def loadConfig(self): 134 | # Load up last sessions config 135 | params = self.config_defaults 136 | try: 137 | with open(self.config_file, 'r') as cf: 138 | json_params = json.load(cf) 139 | params.update(json_params) 140 | except Exception as e: 141 | # Don't throw exception if we can't load previous config 142 | pass 143 | 144 | self.LoadSettings(params) 145 | 146 | def saveConfig(self): 147 | try: 148 | with open(self.config_file, 'w') as cf: 149 | json.dump(self.CurrentSettings(), cf, indent=2) 150 | except Exception as e: 151 | # Don't throw exception if we can't save config 152 | pass 153 | 154 | def LoadSettings(self, params): 155 | for key,value in params.items(): 156 | if key not in self.config_defaults.keys(): 157 | continue 158 | if value is None: 159 | continue 160 | 161 | if self.vscore_layer in key: 162 | defaultLayerFound = False 163 | for row in range(self.vscore.LayersGrid.GetNumberRows()): 164 | if value in self.vscore.LayersGrid.GetCellValue(row, 1): 165 | b = "1" 166 | defaultLayerFound = True 167 | else: 168 | b = "0" 169 | self.vscore.LayersGrid.SetCellValue(row, 0, b) 170 | if not defaultLayerFound: 171 | self.vscore.LayersGrid.SetCellValue(0, 0, "1") # Default to the first layer 172 | else: 173 | try: 174 | obj = getattr(self.general, "m_{}".format(key)) 175 | if hasattr(obj, "SetValue"): 176 | obj.SetValue(value) 177 | elif hasattr(obj, "SetStringSelection"): 178 | obj.SetStringSelection(value) 179 | else: 180 | raise Exception("Invalid item") 181 | except Exception as e: 182 | pass 183 | 184 | return params 185 | 186 | def CurrentSettings(self): 187 | params = {} 188 | 189 | for item in self.config_defaults.keys(): 190 | if self.vscore_layer in item: 191 | for row in range(self.vscore.LayersGrid.GetNumberRows()): 192 | if self.vscore.LayersGrid.GetCellValue(row, 0) == "1": 193 | layername = self.vscore.LayersGrid.GetCellValue(row, 1) 194 | if " (" in layername: 195 | layername = layername[:layername.find(" (")] # Trim the actual name - if present 196 | params.update({self.vscore_layer: layername}) 197 | else: 198 | obj = getattr(self.general, "m_{}".format(item)) 199 | if hasattr(obj, "GetValue"): 200 | params.update({item: obj.GetValue()}) 201 | elif hasattr(obj, "GetStringSelection"): 202 | params.update({item: obj.GetStringSelection()}) 203 | else: 204 | raise Exception("Invalid item") 205 | 206 | return params 207 | 208 | def OnPanelizeClick(self, e): 209 | self.saveConfig() 210 | self.func(self, self.panelizer) 211 | 212 | def OnCancelClick(self, e): 213 | self.GetParent().EndModal(wx.ID_CANCEL) 214 | 215 | class GeneralPanel(dialog_text_base.GeneralPanelBase): 216 | 217 | def __init__(self, parent): 218 | dialog_text_base.GeneralPanelBase.__init__(self, parent) 219 | 220 | self.m_buttonGapsVerticalHelp.SetLabelText("") 221 | # Icon by Icons8 https://icons8.com : https://icons8.com/icon/63308/info 222 | self.m_buttonGapsVerticalHelp.SetBitmap(get_btn_bitmap("info-15.png")) 223 | 224 | self.m_buttonGapsHorizontalHelp.SetLabelText("") 225 | # Icon by Icons8 https://icons8.com : https://icons8.com/icon/63308/info 226 | self.m_buttonGapsHorizontalHelp.SetBitmap(get_btn_bitmap("info-15.png")) 227 | 228 | self.m_buttonFiducialsHelp.SetLabelText("") 229 | # Icon by Icons8 https://icons8.com : https://icons8.com/icon/63308/info 230 | self.m_buttonFiducialsHelp.SetBitmap(get_btn_bitmap("info-15.png")) 231 | 232 | self.m_buttonEdgeHelp.SetLabelText("") 233 | # Icon by Icons8 https://icons8.com : https://icons8.com/icon/63308/info 234 | self.m_buttonEdgeHelp.SetBitmap(get_btn_bitmap("info-15.png")) 235 | 236 | def ClickGapsVerticalHelp( self, event ): 237 | wx.MessageBox("\ 238 | This sets the width of the vertical gaps\n\ 239 | within the panel. Vertical gaps run from\n\ 240 | the bottom rail to the top rail. The width\n\ 241 | is defined in X.\n\ 242 | \n\ 243 | The gap width should be at least 0.3\" to\n\ 244 | aid automated inspection.\ 245 | ", 'Info', wx.OK | wx.ICON_INFORMATION) 246 | 247 | def ClickGapsHorizontalHelp( self, event ): 248 | wx.MessageBox("\ 249 | This sets the width of the horizontal gaps\n\ 250 | within the panel. Horizontal gaps run from\n\ 251 | the left rail to the right rail. The width\n\ 252 | is defined in Y.\n\ 253 | \n\ 254 | The gap width should be at least 0.3\" to\n\ 255 | aid automated inspection.\ 256 | ", 'Info', wx.OK | wx.ICON_INFORMATION) 257 | 258 | def ClickFiducialsHelp( self, event ): 259 | wx.MessageBox("\ 260 | By default, the panel fiducials are placed in\n\ 261 | the top and bottom edges. Normally we\n\ 262 | recommend leaving them there.\n\ 263 | \n\ 264 | Selecting this option moves them to the left\n\ 265 | and right edges.\n\ 266 | \n\ 267 | This can be useful when using horizontal gaps\n\ 268 | and vertical v-scores. It avoids the panel\n\ 269 | having to be scrapped if an edge has been\n\ 270 | snapped off and the fiducials are missing.\ 271 | ", 'Info', wx.OK | wx.ICON_INFORMATION) 272 | 273 | def ClickEdgeHelp( self, event ): 274 | wx.MessageBox("\ 275 | Select this option if you are panelizing\n\ 276 | a MicroMod Processor or Function Board.\n\ 277 | \n\ 278 | The bottom and top edges will be exposed\n\ 279 | so the fingers and chamfered edge can be\n\ 280 | manufactured. The top row of PCBs is\n\ 281 | rotated automatically.\ 282 | ", 'Info', wx.OK | wx.ICON_INFORMATION) 283 | 284 | class VScorePanel(dialog_text_base.VScorePanelBase): 285 | 286 | def __init__(self, parent): 287 | dialog_text_base.VScorePanelBase.__init__(self, parent) 288 | 289 | self.Layout() 290 | self.LayersGrid.SetColSize(0, 50) 291 | self.LayersGrid.SetColSize(1, self.GetParent().GetClientSize().x - 80) 292 | 293 | def OnLayersGridCellClicked(self, event): 294 | self.LayersGrid.ClearSelection() 295 | #self.LayersGrid.SelectRow(event.Row) 296 | if event.Col == 0: 297 | for row in range(self.LayersGrid.GetNumberRows()): 298 | val = "1" if (row == event.Row) else "0" # JSON style 299 | self.LayersGrid.SetCellValue(row, 0, val) 300 | 301 | -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/dialog/dialog_text_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | ########################################################################### 4 | ## Python code generated with wxFormBuilder (version 3.10.1-0-g8feb16b3) 5 | ## http://www.wxformbuilder.org/ 6 | ## 7 | ## PLEASE DO *NOT* EDIT THIS FILE! 8 | ########################################################################### 9 | 10 | from .compat import DialogShim 11 | import wx 12 | import wx.xrc 13 | import wx.grid 14 | 15 | import gettext 16 | _ = gettext.gettext 17 | 18 | ########################################################################### 19 | ## Class DialogPanelBase 20 | ########################################################################### 21 | 22 | class DialogPanelBase ( DialogShim ): 23 | 24 | def __init__( self, parent ): 25 | DialogShim.__init__ ( self, parent, id = wx.ID_ANY, title = _(u"SparkFun KiCad Panelizer"), pos = wx.DefaultPosition, size = wx.Size( -1,-1 ), style = wx.DEFAULT_DIALOG_STYLE|wx.RESIZE_BORDER|wx.STAY_ON_TOP|wx.BORDER_DEFAULT ) 26 | 27 | self.SetSizeHints( wx.Size( -1,-1 ), wx.DefaultSize ) 28 | 29 | 30 | self.Centre( wx.BOTH ) 31 | 32 | # Connect Events 33 | self.Bind( wx.EVT_INIT_DIALOG, self.OnInitDlg ) 34 | 35 | def __del__( self ): 36 | pass 37 | 38 | 39 | # Virtual event handlers, override them in your derived class 40 | def OnInitDlg( self, event ): 41 | pass 42 | 43 | 44 | ########################################################################### 45 | ## Class DialogPanel 46 | ########################################################################### 47 | 48 | class DialogPanel ( wx.Panel ): 49 | 50 | def __init__( self, parent, id = wx.ID_ANY, pos = wx.DefaultPosition, size = wx.Size( -1,-1 ), style = wx.TAB_TRAVERSAL, name = wx.EmptyString ): 51 | wx.Panel.__init__ ( self, parent, id = id, pos = pos, size = size, style = style, name = name ) 52 | 53 | bSizer7 = wx.BoxSizer( wx.VERTICAL ) 54 | 55 | self.notebook = wx.Notebook( self, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.NB_TOP|wx.BORDER_DEFAULT ) 56 | self.notebook.SetMinSize( wx.Size( 350,450 ) ) 57 | 58 | 59 | bSizer7.Add( self.notebook, 1, wx.EXPAND |wx.ALL, 5 ) 60 | 61 | lowerSizer = wx.BoxSizer( wx.HORIZONTAL ) 62 | 63 | 64 | lowerSizer.Add( ( 0, 0), 1, wx.EXPAND, 5 ) 65 | 66 | self.m_buttonPanelize = wx.Button( self, wx.ID_ANY, _(u"Panelize"), wx.DefaultPosition, wx.DefaultSize, 0 ) 67 | 68 | self.m_buttonPanelize.SetDefault() 69 | lowerSizer.Add( self.m_buttonPanelize, 0, wx.ALL, 5 ) 70 | 71 | self.m_buttonCancel = wx.Button( self, wx.ID_ANY, _(u"Cancel"), wx.DefaultPosition, wx.DefaultSize, 0 ) 72 | lowerSizer.Add( self.m_buttonCancel, 0, wx.ALL, 5 ) 73 | 74 | 75 | bSizer7.Add( lowerSizer, 0, wx.EXPAND, 5 ) 76 | 77 | 78 | self.SetSizer( bSizer7 ) 79 | self.Layout() 80 | bSizer7.Fit( self ) 81 | 82 | # Connect Events 83 | self.m_buttonPanelize.Bind( wx.EVT_BUTTON, self.OnPanelizeClick ) 84 | self.m_buttonCancel.Bind( wx.EVT_BUTTON, self.OnCancelClick ) 85 | 86 | def __del__( self ): 87 | pass 88 | 89 | 90 | # Virtual event handlers, override them in your derived class 91 | def OnPanelizeClick( self, event ): 92 | pass 93 | 94 | def OnCancelClick( self, event ): 95 | pass 96 | 97 | 98 | ########################################################################### 99 | ## Class GeneralPanelBase 100 | ########################################################################### 101 | 102 | class GeneralPanelBase ( wx.Panel ): 103 | 104 | def __init__( self, parent, id = wx.ID_ANY, pos = wx.DefaultPosition, size = wx.Size( -1,-1 ), style = wx.TAB_TRAVERSAL, name = wx.EmptyString ): 105 | wx.Panel.__init__ ( self, parent, id = id, pos = pos, size = size, style = style, name = name ) 106 | 107 | bMainSizer = wx.BoxSizer( wx.VERTICAL ) 108 | 109 | sbSizer1 = wx.StaticBoxSizer( wx.StaticBox( self, wx.ID_ANY, _(u"Dimensions:") ), wx.VERTICAL ) 110 | 111 | self.m_dimensionsInchesBtn = wx.RadioButton( sbSizer1.GetStaticBox(), wx.ID_ANY, _(u"Inches"), wx.DefaultPosition, wx.DefaultSize, wx.RB_GROUP ) 112 | sbSizer1.Add( self.m_dimensionsInchesBtn, 0, wx.ALL, 5 ) 113 | 114 | self.m_dimensionsMmBtn = wx.RadioButton( sbSizer1.GetStaticBox(), wx.ID_ANY, _(u"Millimeters"), wx.DefaultPosition, wx.DefaultSize, 0 ) 115 | sbSizer1.Add( self.m_dimensionsMmBtn, 0, wx.ALL, 5 ) 116 | 117 | 118 | bMainSizer.Add( sbSizer1, 0, wx.ALL|wx.EXPAND, 5 ) 119 | 120 | sbSizer2 = wx.StaticBoxSizer( wx.StaticBox( self, wx.ID_ANY, _(u"Panel Size:") ), wx.VERTICAL ) 121 | 122 | self.m_panelSizeSmallerBtn = wx.RadioButton( sbSizer2.GetStaticBox(), wx.ID_ANY, _(u"Must be smaller than"), wx.DefaultPosition, wx.DefaultSize, wx.RB_GROUP ) 123 | sbSizer2.Add( self.m_panelSizeSmallerBtn, 0, wx.ALL, 5 ) 124 | 125 | self.m_panelSizeLargerBtn = wx.RadioButton( sbSizer2.GetStaticBox(), wx.ID_ANY, _(u"Must be larger than"), wx.DefaultPosition, wx.DefaultSize, 0 ) 126 | sbSizer2.Add( self.m_panelSizeLargerBtn, 0, wx.ALL, 5 ) 127 | 128 | fgSizerPanelSize = wx.FlexGridSizer( 0, 2, 4, 4 ) 129 | fgSizerPanelSize.SetFlexibleDirection( wx.BOTH ) 130 | fgSizerPanelSize.SetNonFlexibleGrowMode( wx.FLEX_GROWMODE_SPECIFIED ) 131 | 132 | self.m_panelSizeXLabel = wx.StaticText( sbSizer2.GetStaticBox(), wx.ID_ANY, _(u"X: "), wx.DefaultPosition, wx.DefaultSize, wx.ALIGN_LEFT ) 133 | self.m_panelSizeXLabel.Wrap( -1 ) 134 | 135 | fgSizerPanelSize.Add( self.m_panelSizeXLabel, 1, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5 ) 136 | 137 | self.m_panelSizeXCtrl = wx.TextCtrl( sbSizer2.GetStaticBox(), wx.ID_ANY, wx.EmptyString, wx.DefaultPosition, wx.DefaultSize, wx.TE_PROCESS_ENTER ) 138 | self.m_panelSizeXCtrl.SetMaxLength( 0 ) 139 | self.m_panelSizeXCtrl.SetMinSize( wx.Size( 64,-1 ) ) 140 | 141 | fgSizerPanelSize.Add( self.m_panelSizeXCtrl, 1, wx.ALIGN_CENTER_VERTICAL, 5 ) 142 | 143 | self.m_panelSizeYLabel = wx.StaticText( sbSizer2.GetStaticBox(), wx.ID_ANY, _(u"Y: "), wx.DefaultPosition, wx.DefaultSize, wx.ALIGN_LEFT ) 144 | self.m_panelSizeYLabel.Wrap( -1 ) 145 | 146 | fgSizerPanelSize.Add( self.m_panelSizeYLabel, 1, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5 ) 147 | 148 | self.m_panelSizeYCtrl = wx.TextCtrl( sbSizer2.GetStaticBox(), wx.ID_ANY, wx.EmptyString, wx.DefaultPosition, wx.DefaultSize, wx.TE_PROCESS_ENTER ) 149 | self.m_panelSizeYCtrl.SetMaxLength( 0 ) 150 | self.m_panelSizeYCtrl.SetMinSize( wx.Size( 64,-1 ) ) 151 | 152 | fgSizerPanelSize.Add( self.m_panelSizeYCtrl, 1, wx.ALIGN_CENTER_VERTICAL, 5 ) 153 | 154 | 155 | sbSizer2.Add( fgSizerPanelSize, 1, wx.ALL, 5 ) 156 | 157 | 158 | bMainSizer.Add( sbSizer2, 0, wx.EXPAND, 5 ) 159 | 160 | sbSizer3 = wx.StaticBoxSizer( wx.StaticBox( self, wx.ID_ANY, _(u"Gaps:") ), wx.VERTICAL ) 161 | 162 | fgSizerVerticalGaps = wx.FlexGridSizer( 0, 3, 0, 0 ) 163 | fgSizerVerticalGaps.SetFlexibleDirection( wx.BOTH ) 164 | fgSizerVerticalGaps.SetNonFlexibleGrowMode( wx.FLEX_GROWMODE_SPECIFIED ) 165 | 166 | self.m_gapsVerticalLabel = wx.StaticText( sbSizer3.GetStaticBox(), wx.ID_ANY, _(u"Vertical Gap (X):"), wx.DefaultPosition, wx.DefaultSize, wx.ALIGN_LEFT ) 167 | self.m_gapsVerticalLabel.Wrap( -1 ) 168 | 169 | self.m_gapsVerticalLabel.SetMinSize( wx.Size( 120,-1 ) ) 170 | 171 | fgSizerVerticalGaps.Add( self.m_gapsVerticalLabel, 1, wx.ALIGN_CENTER_VERTICAL, 5 ) 172 | 173 | self.m_gapsVerticalCtrl = wx.TextCtrl( sbSizer3.GetStaticBox(), wx.ID_ANY, wx.EmptyString, wx.DefaultPosition, wx.DefaultSize, wx.TE_PROCESS_ENTER ) 174 | self.m_gapsVerticalCtrl.SetMaxLength( 0 ) 175 | self.m_gapsVerticalCtrl.SetMinSize( wx.Size( 64,-1 ) ) 176 | 177 | fgSizerVerticalGaps.Add( self.m_gapsVerticalCtrl, 1, wx.ALIGN_CENTER_VERTICAL, 5 ) 178 | 179 | self.m_buttonGapsVerticalHelp = wx.Button( sbSizer3.GetStaticBox(), wx.ID_ANY, _(u"MyButton"), wx.DefaultPosition, wx.DefaultSize, 0 ) 180 | self.m_buttonGapsVerticalHelp.SetMinSize( wx.Size( 15,15 ) ) 181 | 182 | fgSizerVerticalGaps.Add( self.m_buttonGapsVerticalHelp, 0, wx.ALL, 5 ) 183 | 184 | 185 | sbSizer3.Add( fgSizerVerticalGaps, 1, wx.EXPAND, 5 ) 186 | 187 | fgSizerHorizontalGaps = wx.FlexGridSizer( 0, 3, 0, 0 ) 188 | fgSizerHorizontalGaps.SetFlexibleDirection( wx.BOTH ) 189 | fgSizerHorizontalGaps.SetNonFlexibleGrowMode( wx.FLEX_GROWMODE_SPECIFIED ) 190 | 191 | self.m_gapsHorizontalLabel = wx.StaticText( sbSizer3.GetStaticBox(), wx.ID_ANY, _(u"Horizontal Gap (Y):"), wx.DefaultPosition, wx.DefaultSize, wx.ALIGN_LEFT ) 192 | self.m_gapsHorizontalLabel.Wrap( -1 ) 193 | 194 | self.m_gapsHorizontalLabel.SetMinSize( wx.Size( 120,-1 ) ) 195 | 196 | fgSizerHorizontalGaps.Add( self.m_gapsHorizontalLabel, 1, wx.ALIGN_CENTER_VERTICAL, 5 ) 197 | 198 | self.m_gapsHorizontalCtrl = wx.TextCtrl( sbSizer3.GetStaticBox(), wx.ID_ANY, wx.EmptyString, wx.DefaultPosition, wx.DefaultSize, wx.TE_PROCESS_ENTER ) 199 | self.m_gapsHorizontalCtrl.SetMaxLength( 0 ) 200 | self.m_gapsHorizontalCtrl.SetMinSize( wx.Size( 64,-1 ) ) 201 | 202 | fgSizerHorizontalGaps.Add( self.m_gapsHorizontalCtrl, 1, wx.ALIGN_CENTER_VERTICAL, 5 ) 203 | 204 | self.m_buttonGapsHorizontalHelp = wx.Button( sbSizer3.GetStaticBox(), wx.ID_ANY, _(u"MyButton"), wx.DefaultPosition, wx.DefaultSize, 0 ) 205 | self.m_buttonGapsHorizontalHelp.SetMinSize( wx.Size( 15,15 ) ) 206 | 207 | fgSizerHorizontalGaps.Add( self.m_buttonGapsHorizontalHelp, 0, wx.ALL, 5 ) 208 | 209 | 210 | sbSizer3.Add( fgSizerHorizontalGaps, 1, wx.EXPAND, 5 ) 211 | 212 | self.m_removeRightVerticalCheck = wx.CheckBox( sbSizer3.GetStaticBox(), wx.ID_ANY, _(u"Remove right-most vertical gap and use v-score instead"), wx.DefaultPosition, wx.DefaultSize, 0 ) 213 | sbSizer3.Add( self.m_removeRightVerticalCheck, 0, wx.ALL, 5 ) 214 | 215 | 216 | bMainSizer.Add( sbSizer3, 0, wx.EXPAND, 5 ) 217 | 218 | sbSizer4 = wx.StaticBoxSizer( wx.StaticBox( self, wx.ID_ANY, _(u"Extra Production Bits:") ), wx.VERTICAL ) 219 | 220 | self.m_productionBordersCheck = wx.CheckBox( sbSizer4.GetStaticBox(), wx.ID_ANY, _(u"Add Panel Borders and Fiducials"), wx.DefaultPosition, wx.DefaultSize, 0 ) 221 | sbSizer4.Add( self.m_productionBordersCheck, 0, wx.ALL, 5 ) 222 | 223 | fgSizer3 = wx.FlexGridSizer( 0, 2, 0, 0 ) 224 | fgSizer3.SetFlexibleDirection( wx.BOTH ) 225 | fgSizer3.SetNonFlexibleGrowMode( wx.FLEX_GROWMODE_SPECIFIED ) 226 | 227 | self.m_productionFiducialsCheck = wx.CheckBox( sbSizer4.GetStaticBox(), wx.ID_ANY, _(u"Move Panel Fiducials to Left+Right Edges"), wx.DefaultPosition, wx.DefaultSize, 0 ) 228 | fgSizer3.Add( self.m_productionFiducialsCheck, 0, wx.ALL, 5 ) 229 | 230 | self.m_buttonFiducialsHelp = wx.Button( sbSizer4.GetStaticBox(), wx.ID_ANY, _(u"MyButton"), wx.DefaultPosition, wx.DefaultSize, 0 ) 231 | self.m_buttonFiducialsHelp.SetMinSize( wx.Size( 15,15 ) ) 232 | 233 | fgSizer3.Add( self.m_buttonFiducialsHelp, 0, wx.ALL, 5 ) 234 | 235 | 236 | sbSizer4.Add( fgSizer3, 1, wx.EXPAND, 5 ) 237 | 238 | fgSizer5 = wx.FlexGridSizer( 0, 2, 0, 0 ) 239 | fgSizer5.SetFlexibleDirection( wx.BOTH ) 240 | fgSizer5.SetNonFlexibleGrowMode( wx.FLEX_GROWMODE_SPECIFIED ) 241 | 242 | self.m_productionExposeCheck = wx.CheckBox( sbSizer4.GetStaticBox(), wx.ID_ANY, _(u"Expose Bottom/Card Edge"), wx.DefaultPosition, wx.DefaultSize, 0 ) 243 | fgSizer5.Add( self.m_productionExposeCheck, 0, wx.ALL, 5 ) 244 | 245 | self.m_buttonEdgeHelp = wx.Button( sbSizer4.GetStaticBox(), wx.ID_ANY, _(u"MyButton"), wx.DefaultPosition, wx.DefaultSize, 0 ) 246 | self.m_buttonEdgeHelp.SetMinSize( wx.Size( 15,15 ) ) 247 | 248 | fgSizer5.Add( self.m_buttonEdgeHelp, 0, wx.ALL, 5 ) 249 | 250 | 251 | sbSizer4.Add( fgSizer5, 1, wx.EXPAND, 5 ) 252 | 253 | 254 | bMainSizer.Add( sbSizer4, 0, wx.EXPAND, 5 ) 255 | 256 | 257 | self.SetSizer( bMainSizer ) 258 | self.Layout() 259 | bMainSizer.Fit( self ) 260 | 261 | # Connect Events 262 | self.m_buttonGapsVerticalHelp.Bind( wx.EVT_BUTTON, self.ClickGapsVerticalHelp ) 263 | self.m_buttonGapsHorizontalHelp.Bind( wx.EVT_BUTTON, self.ClickGapsHorizontalHelp ) 264 | self.m_buttonFiducialsHelp.Bind( wx.EVT_BUTTON, self.ClickFiducialsHelp ) 265 | self.m_buttonEdgeHelp.Bind( wx.EVT_BUTTON, self.ClickEdgeHelp ) 266 | 267 | def __del__( self ): 268 | pass 269 | 270 | 271 | # Virtual event handlers, override them in your derived class 272 | def ClickGapsVerticalHelp( self, event ): 273 | pass 274 | 275 | def ClickGapsHorizontalHelp( self, event ): 276 | pass 277 | 278 | def ClickFiducialsHelp( self, event ): 279 | pass 280 | 281 | def ClickEdgeHelp( self, event ): 282 | pass 283 | 284 | 285 | ########################################################################### 286 | ## Class VScorePanelBase 287 | ########################################################################### 288 | 289 | class VScorePanelBase ( wx.Panel ): 290 | 291 | def __init__( self, parent, id = wx.ID_ANY, pos = wx.DefaultPosition, size = wx.Size( -1,-1 ), style = wx.TAB_TRAVERSAL, name = wx.EmptyString ): 292 | wx.Panel.__init__ ( self, parent, id = id, pos = pos, size = size, style = style, name = name ) 293 | 294 | bSizer11 = wx.BoxSizer( wx.VERTICAL ) 295 | 296 | sbSizer5 = wx.StaticBoxSizer( wx.StaticBox( self, wx.ID_ANY, _(u"V-Score Layer:") ), wx.VERTICAL ) 297 | 298 | LayersGridSizer = wx.FlexGridSizer( 0, 2, 0, 0 ) 299 | LayersGridSizer.SetFlexibleDirection( wx.BOTH ) 300 | LayersGridSizer.SetNonFlexibleGrowMode( wx.FLEX_GROWMODE_SPECIFIED ) 301 | 302 | self.LayersGrid = wx.grid.Grid( sbSizer5.GetStaticBox(), wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, 0 ) 303 | 304 | # Grid 305 | self.LayersGrid.CreateGrid( 1, 2 ) 306 | self.LayersGrid.EnableEditing( True ) 307 | self.LayersGrid.EnableGridLines( True ) 308 | self.LayersGrid.EnableDragGridSize( False ) 309 | self.LayersGrid.SetMargins( 0, 0 ) 310 | 311 | # Columns 312 | self.LayersGrid.AutoSizeColumns() 313 | self.LayersGrid.EnableDragColMove( False ) 314 | self.LayersGrid.EnableDragColSize( True ) 315 | self.LayersGrid.SetColLabelValue( 0, _(u"Use") ) 316 | self.LayersGrid.SetColLabelValue( 1, _(u"Layer") ) 317 | self.LayersGrid.SetColLabelSize( 30 ) 318 | self.LayersGrid.SetColLabelAlignment( wx.ALIGN_LEFT, wx.ALIGN_CENTER ) 319 | 320 | # Rows 321 | self.LayersGrid.AutoSizeRows() 322 | self.LayersGrid.EnableDragRowSize( False ) 323 | self.LayersGrid.SetRowLabelSize( 1 ) 324 | self.LayersGrid.SetRowLabelAlignment( wx.ALIGN_CENTER, wx.ALIGN_CENTER ) 325 | 326 | # Label Appearance 327 | 328 | # Cell Defaults 329 | self.LayersGrid.SetDefaultCellAlignment( wx.ALIGN_LEFT, wx.ALIGN_TOP ) 330 | LayersGridSizer.Add( self.LayersGrid, 0, wx.ALL|wx.EXPAND, 5 ) 331 | 332 | 333 | sbSizer5.Add( LayersGridSizer, 1, wx.EXPAND, 5 ) 334 | 335 | 336 | bSizer11.Add( sbSizer5, 1, wx.EXPAND, 5 ) 337 | 338 | 339 | self.SetSizer( bSizer11 ) 340 | self.Layout() 341 | bSizer11.Fit( self ) 342 | 343 | # Connect Events 344 | self.LayersGrid.Bind( wx.grid.EVT_GRID_CELL_LEFT_CLICK, self.OnLayersGridCellClicked ) 345 | 346 | def __del__( self ): 347 | pass 348 | 349 | 350 | # Virtual event handlers, override them in your derived class 351 | def OnLayersGridCellClicked( self, event ): 352 | pass 353 | 354 | 355 | -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkfun/SparkFun_KiCad_Panelizer/ed898dcbf9b7be8acf3f59b43614c3909c5bdaac/SparkFunKiCadPanelizer/icon.png -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/panelizer/__init__.py: -------------------------------------------------------------------------------- 1 | from .panelizer import Panelizer -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/panelizer/panelizer.py: -------------------------------------------------------------------------------- 1 | """ 2 | SparkFun's version of: 3 | 4 | Simon John (@sej7278)'s version of: 5 | 6 | kicad-panelizer 7 | A simple script to create a v-scored panel of a KiCad board. 8 | Original author: Willem Hillier (@willemcvu) 9 | 10 | https://github.com/willemcvu/kicad-panelizer 11 | https://github.com/sej7278/kicad-panelizer 12 | """ 13 | 14 | import os 15 | import sys 16 | from argparse import ArgumentParser 17 | import pcbnew 18 | import logging 19 | from datetime import datetime 20 | import wx 21 | 22 | # sub folder for our resource files 23 | _RESOURCE_DIRECTORY = os.path.join("..", "resource") 24 | 25 | #https://stackoverflow.com/a/50914550 26 | def resource_path(relative_path): 27 | """ Get absolute path to resource, works for dev and for PyInstaller """ 28 | base_path = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) 29 | return os.path.join(base_path, _RESOURCE_DIRECTORY, relative_path) 30 | 31 | def get_version(rel_path: str) -> str: 32 | try: 33 | with open(resource_path(rel_path), encoding='utf-8') as fp: 34 | for line in fp.read().splitlines(): 35 | if line.startswith("__version__"): 36 | delim = '"' if '"' in line else "'" 37 | return line.split(delim)[1] 38 | raise RuntimeError("Unable to find version string.") 39 | except: 40 | raise RuntimeError("Unable to find _version.py.") 41 | 42 | _APP_VERSION = get_version("_version.py") 43 | 44 | class Panelizer(): 45 | def __init__(self): 46 | pass 47 | 48 | productionDir = "Production" 49 | 50 | def args_parse(self, args): 51 | # set up command-line arguments parser 52 | parser = ArgumentParser(description="A script to panelize KiCad 7 files.") 53 | parser.add_argument( 54 | "-v", "--version", action="version", version="%(prog)s " + _APP_VERSION 55 | ) 56 | parser.add_argument( 57 | "-p", "--path", help="Path to the *.kicad_pcb file to be panelized" 58 | ) 59 | parser.add_argument("--numx", type=int, help="Number of boards in X direction") 60 | parser.add_argument("--numy", type=int, help="Number of boards in Y direction") 61 | parser.add_argument("--gapx", type=float, default="0.0", help="Gap between boards in X direction (mm), default 0.0") 62 | parser.add_argument("--gapy", type=float, default="0.0", help="Gap between boards in Y direction (mm), default 0.0") 63 | parser.add_argument( 64 | "--norightgap", action="store_true", help="Remove the right-most X gap and insert an extra v-score" 65 | ) 66 | parser.add_argument("--panelx", type=float, help="Panel size in X direction (mm)") 67 | parser.add_argument("--panely", type=float, help="Panel size in Y direction (mm)") 68 | parser.add_argument( 69 | "--smaller", action="store_true", help="Default: the created panel will be smaller than panelx x panely" 70 | ) 71 | parser.add_argument( 72 | "--larger", action="store_true", help="The created panel will be larger than panelx x panely" 73 | ) 74 | parser.add_argument("--hrail", type=float, default="0.0", help="Horizontal edge rail width (mm)") 75 | parser.add_argument("--vrail", type=float, default="0.0", help="Vertical edge rail width (mm)") 76 | 77 | parser.add_argument("--hrailtext", help="Text to put on the horizontal edge rail") 78 | parser.add_argument("--vrailtext", help="Text to put on the vertical edge rail") 79 | parser.add_argument( 80 | "--htitle", action="store_true", help="Print title info on horizontal edge rail" 81 | ) 82 | parser.add_argument( 83 | "--vtitle", action="store_true", help="Print title info on vertical edge rail" 84 | ) 85 | parser.add_argument("--textsize", type=float, default="2.0", help="The rail text size (mm), default 2.0") 86 | parser.add_argument( 87 | "--vscorelayer", default="User.Comments", help="Layer to put v-score lines on" 88 | ) 89 | parser.add_argument( 90 | "--vscoretextlayer", default="User.Comments", help="Layer to put v-score text on" 91 | ) 92 | parser.add_argument( 93 | "--vscoretext", default="V-SCORE", help="Text used to indicate v-scores" 94 | ) 95 | parser.add_argument( 96 | "--vscorewidth", 97 | type=float, 98 | default="0.5", 99 | help="The width of the v-score lines (mm), default 0.5", 100 | ) 101 | parser.add_argument( 102 | "--vscoreextends", 103 | type=float, 104 | help="How far past the board to extend the v-score lines (mm), default -vscorewidth/2", 105 | ) 106 | parser.add_argument( 107 | "--fiducialslr", action="store_true", help="Add panel fiducials left and right" 108 | ) 109 | parser.add_argument( 110 | "--fiducialstb", action="store_true", help="Add panel fiducials top and bottom" 111 | ) 112 | parser.add_argument( 113 | "--fiducialpos", type=float, default="0.5", 114 | help="Position the fiducials at this fraction of the rail width, default 0.5" 115 | ) 116 | 117 | parser.add_argument( 118 | "--exposeedge", action="store_true", help="Expose PCB bottom edge - e.g. for M.2 cards" 119 | ) 120 | parser.add_argument( 121 | "--verbose", action='store_true', help="Verbose logging") 122 | return parser.parse_args(args) 123 | 124 | def startPanelizer(self, args, board=None, ordering=None, logger=None): 125 | """The main method 126 | 127 | Args: 128 | args - the command line args [1:] - parsed with args_parse 129 | board - the KiCad BOARD when running in a plugin 130 | 131 | Returns: 132 | sysExit - the value for sys.exit (if called from __main__) 133 | report - a helpful text report 134 | """ 135 | 136 | # v-scoring parameters 137 | V_SCORE_TEXT_SIZE = 2 # mm 138 | 139 | # Panel fiducial parameters 140 | FIDUCIAL_MASK = 3.0 # mm - Fiducial_1.5mm_Mask3mm 141 | FIDUCIAL_OFFSET_FROM_RAIL = 2.5 # mm 142 | FIDUCIAL_CLAMP_CLEARANCE = 3.0 # mm 143 | FIDUCIAL_FOOTPRINT_BIG = "Fiducial_1.5mm_Mask3mm" 144 | FIDUCIAL_FOOTPRINT_SMALL = "Fiducial_1mm_Mask3mm" 145 | 146 | # Text for empty edges 147 | EMPTY_EDGE_TEXT = "Panelized" 148 | 149 | # Minimum spacer for exposed edge panels 150 | MINIMUM_SPACER = 6.35 # mm 151 | 152 | INVALID_WIDTH = 999999999 153 | 154 | # 'Extra' ordering instructions 155 | # Any PCB_TEXT containing any of these keywords will be copied into the ordering instructions 156 | possibleExtras = ['clean', 'Clean', 'CLEAN', 'stackup', 'Stackup', 'STACKUP'] 157 | 158 | # Possible logos and their default mask and silkscreen colors: seen, mask, silk 159 | possibleLogos = { 160 | "SparkFun_Logo": [False, "Red", "White"], 161 | "SparkX_Logo": [False, "Black", "White"], 162 | "SparkPNT_Logo": [False, "Red", "White"], 163 | } 164 | 165 | sysExit = -1 # -1 indicates sysExit has not (yet) been set. The code below will set this to 0, 1, 2. 166 | report = "\nSTART: " + datetime.now().isoformat() + "\n" 167 | 168 | if logger is None: 169 | logger = logging.getLogger() 170 | logger.setLevel([ logging.WARNING, logging.DEBUG ][args.verbose]) 171 | 172 | logger.info('PANELIZER START: ' + datetime.now().isoformat()) 173 | 174 | # Read the args 175 | sourceBoardFile = args.path 176 | NUM_X = args.numx 177 | NUM_Y = args.numy 178 | PANEL_X = args.panelx 179 | PANEL_Y = args.panely 180 | GAP_X = args.gapx 181 | GAP_Y = args.gapy 182 | REMOVE_RIGHT = args.norightgap 183 | HORIZONTAL_EDGE_RAIL_WIDTH = args.hrail 184 | VERTICAL_EDGE_RAIL_WIDTH = args.vrail 185 | HORIZONTAL_EDGE_RAIL_TEXT = args.hrailtext 186 | VERTICAL_EDGE_RAIL_TEXT = args.vrailtext 187 | RAIL_TEXT_SIZE = args.textsize 188 | V_SCORE_LAYER = args.vscorelayer 189 | V_SCORE_TEXT_LAYER = args.vscoretextlayer 190 | V_SCORE_TEXT = args.vscoretext 191 | V_SCORE_WIDTH = args.vscorewidth 192 | V_SCORE_LINE_LENGTH_BEYOND_BOARD = args.vscoreextends 193 | if V_SCORE_LINE_LENGTH_BEYOND_BOARD is None: 194 | V_SCORE_LINE_LENGTH_BEYOND_BOARD = V_SCORE_WIDTH / 2 195 | TITLE_X = args.htitle 196 | TITLE_Y = args.vtitle 197 | FIDUCIALS_LR = args.fiducialslr 198 | FIDUCIALS_TB = args.fiducialstb 199 | FIDUCIAL_POS = args.fiducialpos 200 | EXPOSED_EDGE = args.exposeedge 201 | 202 | # Check if this is running in a plugin 203 | if board is None: 204 | if sourceBoardFile is None: 205 | report += "No path to kicad_pcb file. Quitting.\n" 206 | sysExit = 2 207 | return sysExit, report 208 | else: 209 | # Check that input board is a *.kicad_pcb file 210 | sourceFileExtension = os.path.splitext(sourceBoardFile)[1] 211 | if not sourceFileExtension == ".kicad_pcb": 212 | report += sourceBoardFile + " is not a *.kicad_pcb file. Quitting.\n" 213 | sysExit = 2 214 | return sysExit, report 215 | 216 | # Load source board from file 217 | board = pcbnew.LoadBoard(sourceBoardFile) 218 | # Output file name is format \Production\{inputFile}_panelized.kicad_pcb 219 | panelOutputPath = os.path.split(sourceBoardFile)[0] # Get the file path head 220 | panelOutputPath = os.path.join(panelOutputPath, self.productionDir) # Add the production dir 221 | if not os.path.exists(panelOutputPath): 222 | os.mkdir(panelOutputPath) 223 | panelOutputFile = os.path.split(sourceBoardFile)[1] # Get the file path tail 224 | panelOutputFile = os.path.join(panelOutputPath, os.path.splitext(panelOutputFile)[0] + "_panelized.kicad_pcb") 225 | else: # Running in a plugin 226 | panelOutputPath = os.path.split(board.GetFileName())[0] # Get the file path head 227 | panelOutputPath = os.path.join(panelOutputPath, self.productionDir) # Add the production dir 228 | if not os.path.exists(panelOutputPath): 229 | os.mkdir(panelOutputPath) 230 | panelOutputFile = os.path.split(board.GetFileName())[1] # Get the file path tail 231 | panelOutputFile = os.path.join(panelOutputPath, os.path.splitext(panelOutputFile)[0] + "_panelized.kicad_pcb") 232 | 233 | # Check if PCB needs to be saved first 234 | #if board.IsModified(): # This doesn't work. Need to find something that does... 235 | if wx.GetApp() is not None: 236 | resp = wx.MessageBox("Do you want to save the PCB first?", 237 | 'Save PCB?', wx.YES_NO | wx.ICON_INFORMATION) 238 | if resp == wx.YES: 239 | report += "Board saved by user.\n" 240 | board.Save(board.GetFileName()) 241 | else: 242 | board.Save(board.GetFileName()) 243 | 244 | if board is None: 245 | report += "Could not load board. Quitting.\n" 246 | sysExit = 2 247 | return sysExit, report 248 | 249 | # Check if about to overwrite a panel 250 | if os.path.isfile(panelOutputFile): 251 | if wx.GetApp() is not None: 252 | resp = wx.MessageBox("You are about to overwrite a panel file.\nAre you sure?", 253 | 'Warning', wx.OK | wx.CANCEL | wx.ICON_WARNING) 254 | if resp != wx.OK: 255 | report += "User does not want to overwrite the panel. Quitting.\n" 256 | sysExit = 1 257 | return sysExit, report 258 | 259 | # Check that rails are at least RAIL_TEXT_SIZE plus 1mm 260 | if (((HORIZONTAL_EDGE_RAIL_TEXT or TITLE_X) and (HORIZONTAL_EDGE_RAIL_WIDTH < (RAIL_TEXT_SIZE + 2))) or 261 | ((VERTICAL_EDGE_RAIL_TEXT or TITLE_Y) and (VERTICAL_EDGE_RAIL_WIDTH < (RAIL_TEXT_SIZE + 2)))): 262 | report += "Rails are not large enough for the selected text. Quitting.\n" 263 | sysExit = 2 264 | return sysExit, report 265 | 266 | # Only allow numbers or panels 267 | if (PANEL_X or PANEL_Y) and (NUM_X or NUM_Y): 268 | report += "Specify number of boards or size of panel, not both. Quitting.\n" 269 | sysExit = 2 270 | return sysExit, report 271 | 272 | # Expect panel size or number of boards 273 | if (not PANEL_X or not PANEL_Y) and (not NUM_X or not NUM_Y): 274 | report += "Specify number of boards or size of panel. Quitting.\n" 275 | sysExit = 2 276 | return sysExit, report 277 | 278 | # Check the fiducial position 279 | if (FIDUCIAL_POS < 0.0) or (FIDUCIAL_POS > 1.0): 280 | report += "Invalid fiducial position. Quitting.\n" 281 | sysExit = 2 282 | return sysExit, report 283 | 284 | # Check the fiducials 285 | if FIDUCIALS_LR: 286 | fiducialExtent = (abs(0.5 - FIDUCIAL_POS) * VERTICAL_EDGE_RAIL_WIDTH) + (FIDUCIAL_MASK / 2) 287 | clampClearance = VERTICAL_EDGE_RAIL_WIDTH - ((FIDUCIAL_POS * VERTICAL_EDGE_RAIL_WIDTH) + (FIDUCIAL_MASK / 2)) 288 | if fiducialExtent >= (VERTICAL_EDGE_RAIL_WIDTH / 2): 289 | report += "Cannot add L+R fiducials at the selected position - edge rails not wide enough.\n" 290 | FIDUCIALS_LR = False 291 | sysExit = 1 292 | elif clampClearance < FIDUCIAL_CLAMP_CLEARANCE: 293 | report += "Vertical edge rails do not provide adequate clamp clearance.\n" 294 | sysExit = 1 295 | if FIDUCIALS_TB: 296 | fiducialExtent = (abs(0.5 - FIDUCIAL_POS) * HORIZONTAL_EDGE_RAIL_WIDTH) + (FIDUCIAL_MASK / 2) 297 | clampClearance = HORIZONTAL_EDGE_RAIL_WIDTH - ((FIDUCIAL_POS * HORIZONTAL_EDGE_RAIL_WIDTH) + (FIDUCIAL_MASK / 2)) 298 | if fiducialExtent >= (HORIZONTAL_EDGE_RAIL_WIDTH / 2): 299 | report += "Cannot add T+B fiducials at the selected position - edge rails not wide enough.\n" 300 | FIDUCIALS_TB = False 301 | sysExit = 1 302 | elif clampClearance < FIDUCIAL_CLAMP_CLEARANCE: 303 | report += "Horizontal edge rails do not provide adequate clamp clearance.\n" 304 | sysExit = 1 305 | 306 | # Check smaller / larger 307 | if args.smaller and args.larger: 308 | report += "Both smaller- and larger-than were selected. Defaulting to smaller-than.\n" 309 | sysExit = 1 310 | if args.smaller: 311 | SMALLER_THAN = True 312 | elif args.larger: 313 | SMALLER_THAN = False 314 | else: # Both - or neither 315 | SMALLER_THAN = True 316 | 317 | # Check gaps 318 | if (GAP_X < 0.0) or (GAP_Y < 0.0): 319 | report += "Gap width can not be negative. Quitting.\n" 320 | sysExit = 2 321 | return sysExit, report 322 | if (GAP_X != 0.0) and (GAP_Y != 0.0): 323 | report += "Specify X or Y gaps, not both. Quitting.\n" 324 | sysExit = 2 325 | return sysExit, report 326 | if (GAP_X != 0.0) and (HORIZONTAL_EDGE_RAIL_WIDTH == 0.0): 327 | report += "Can not have X gaps without horizontal rails. Quitting.\n" 328 | sysExit = 2 329 | return sysExit, report 330 | if (GAP_Y != 0.0) and (VERTICAL_EDGE_RAIL_WIDTH == 0.0): 331 | report += "Can not have Y gaps without vertical rails. Quitting.\n" 332 | sysExit = 2 333 | return sysExit, report 334 | if ((GAP_X > 0.0) and (GAP_X < 7.62)) or ((GAP_Y > 0.0) and (GAP_Y < 7.62)): # Check non-zero gaps are 0.3" for AOI 335 | report += "Gaps should be at least 0.3\" for AOI.\n" 336 | sysExit = 1 337 | 338 | # Check exposed edge 339 | if EXPOSED_EDGE: 340 | if GAP_X > 0.0 or GAP_Y > 0.0: 341 | report += "Can not have gaps on exposed edge panels. Quitting.\n" 342 | sysExit = 2 343 | return sysExit, report 344 | if HORIZONTAL_EDGE_RAIL_TEXT or HORIZONTAL_EDGE_RAIL_WIDTH > 0.0 or TITLE_X: 345 | report += "Can not have horizontal rails or text on exposed edge panels. Quitting.\n" 346 | sysExit = 2 347 | return sysExit, report 348 | if NUM_Y: 349 | if NUM_Y > 2: 350 | report += "Can not have more than two rows on exposed edge panels. Quitting.\n" 351 | sysExit = 2 352 | return sysExit, report 353 | 354 | # All dimension parameters used by this script are mm unless otherwise noted 355 | # KiCad works in nm (integer) 356 | SCALE = 1000000 # Convert mm to nm 357 | 358 | # Set up layer table 359 | layertable = {} 360 | numlayers = pcbnew.PCB_LAYER_ID_COUNT 361 | edgeLayerNumber = None 362 | vScoreLayerNumber = None 363 | vScoreTextLayerNumber = None 364 | for i in range(numlayers): 365 | layertable[i] = {'standardName': board.GetStandardLayerName(i), 'actualName': board.GetLayerName(i)} 366 | if "Edge.Cuts" in board.GetStandardLayerName(i): 367 | edgeLayerNumber = i 368 | if V_SCORE_LAYER in board.GetStandardLayerName(i): 369 | vScoreLayerNumber = i 370 | if V_SCORE_TEXT_LAYER in board.GetStandardLayerName(i): 371 | vScoreTextLayerNumber = i 372 | 373 | if edgeLayerNumber is None: 374 | report += "Edge.Cuts not found in layertable. Quitting.\n" 375 | sysExit = 2 376 | return sysExit, report 377 | 378 | if vScoreLayerNumber is None: 379 | report += V_SCORE_LAYER + " not found in layertable. Quitting.\n" 380 | sysExit = 2 381 | return sysExit, report 382 | 383 | if vScoreTextLayerNumber is None: 384 | report += V_SCORE_TEXT_LAYER + " not found in layertable. Quitting.\n" 385 | sysExit = 2 386 | return sysExit, report 387 | 388 | # Fill the zones 389 | # This prevents badness with pads on "F.Cu, B.Cu and connected layers" and "Connected layers only" 390 | report += "Zones filled by Panelizer.\n" 391 | fillerTool = pcbnew.ZONE_FILLER(board) 392 | fillerTool.Fill(board.Zones()) 393 | 394 | # Get dimensions of board 395 | # Note: the bounding box width and height _include_ the Edge.Cuts line width. 396 | # We will subtract it. 397 | boardWidth = board.GetBoardEdgesBoundingBox().GetWidth() # nm 398 | boardHeight = board.GetBoardEdgesBoundingBox().GetHeight() # nm 399 | boardCenter = board.GetBoardEdgesBoundingBox().GetCenter() 400 | 401 | boardLeftEdge = boardCenter.x - boardWidth / 2 402 | boardRightEdge = boardCenter.x + boardWidth / 2 403 | boardTopEdge = boardCenter.y - boardHeight / 2 404 | boardBottomEdge = boardCenter.y + boardHeight / 2 405 | 406 | cutWidth = 0 407 | drawings = board.GetDrawings() 408 | for drawing in drawings: 409 | if hasattr(drawing, "IsOnLayer") and hasattr(drawing, "GetWidth"): 410 | if drawing.IsOnLayer(edgeLayerNumber): 411 | if drawing.GetWidth() > cutWidth: 412 | cutWidth = drawing.GetWidth() 413 | #report += "Subtracting Edge.Cuts line width of {}mm.\n".format(cutWidth / SCALE) 414 | boardWidth -= cutWidth 415 | boardHeight -= cutWidth 416 | 417 | # Print report 418 | report += ( 419 | "\nBoard: " 420 | + str(panelOutputFile) 421 | #+ "\nGenerated with: ./panelizer.py " 422 | #+ str(args) 423 | + "\n" 424 | ) 425 | report += "Board dimensions: " 426 | report += "{:.2f} x ".format(boardWidth / SCALE) 427 | report += "{:.2f} mm.\n".format(boardHeight / SCALE) 428 | 429 | boardWidth += GAP_X * SCALE # convert mm to nm 430 | boardWidth += V_SCORE_WIDTH * SCALE # Add v-score width 431 | boardHeight += GAP_Y * SCALE # convert mm to nm 432 | boardHeight += V_SCORE_WIDTH * SCALE # Add v-score width 433 | 434 | # How many whole boards can we fit on the panel? 435 | # For simplicity, don't include the edge rail widths in this calculation. 436 | # (Assume panelx and panely define the PnP working area) 437 | spacerHeight = 0 438 | if not EXPOSED_EDGE: 439 | if PANEL_X: 440 | NUM_X = int((PANEL_X * SCALE) / boardWidth) 441 | if not SMALLER_THAN: 442 | while (NUM_X * boardWidth) < (PANEL_X * SCALE): 443 | NUM_X += 1 444 | if PANEL_Y: 445 | NUM_Y = int((PANEL_Y * SCALE) / boardHeight) 446 | if not SMALLER_THAN: 447 | while (NUM_Y * boardHeight) < (PANEL_Y * SCALE): 448 | NUM_Y += 1 449 | else: 450 | # Exposed edge: calculate if the panel can accomodate one or two rows 451 | if PANEL_X: 452 | NUM_X = int((PANEL_X * SCALE) / boardWidth) 453 | if not SMALLER_THAN: 454 | while (NUM_X * boardWidth) < (PANEL_X * SCALE): 455 | NUM_X += 1 456 | if PANEL_Y: 457 | if not SMALLER_THAN: 458 | NUM_Y = 2 459 | elif (PANEL_Y * SCALE) < boardHeight: 460 | NUM_Y = 0 461 | elif (PANEL_Y * SCALE) > ((boardHeight * 2 ) + (MINIMUM_SPACER * SCALE)): 462 | NUM_Y = 2 463 | else: 464 | NUM_Y = 1 465 | 466 | if NUM_Y == 2: 467 | spacerHeight = int(MINIMUM_SPACER * SCALE) # If PANEL_Y is not defined, add the minimum spacer 468 | if PANEL_Y: 469 | spacerHeight = int((PANEL_Y * SCALE) - (boardHeight * 2)) 470 | if spacerHeight < (MINIMUM_SPACER * SCALE): 471 | if not SMALLER_THAN: 472 | spacerHeight = MINIMUM_SPACER * SCALE 473 | else: 474 | NUM_Y = 0 475 | 476 | # Check we can actually panelize the board 477 | if NUM_X == 0 or NUM_Y == 0: 478 | report += "Panel size is too small for board. Quitting.\n" 479 | sysExit = 2 480 | return sysExit, report 481 | 482 | if PANEL_X or PANEL_Y: 483 | report += "You can fit " + str(NUM_X) + " x " + str(NUM_Y) + " boards on the panel.\n" 484 | 485 | solderMask = None 486 | silkscreen = None 487 | copperLayers = "Layers: {}".format(board.GetCopperLayerCount()) # Should we trust the instructions or the tracks?! 488 | controlledImpedance = None 489 | finish = None 490 | thickness = None 491 | material = None 492 | copperWeight = None 493 | orderingExtras = None 494 | 495 | # Array of tracks 496 | # Note: Duplicate uses the same net name for the duplicated track. 497 | # This creates DRC unconnected net errors. (KiKit does this properly...) 498 | minTrackWidth = INVALID_WIDTH 499 | minViaDrill = INVALID_WIDTH 500 | tracks = board.GetTracks() 501 | newTracks = [] 502 | for sourceTrack in tracks: # iterate through each track to be copied 503 | width = sourceTrack.GetWidth() 504 | if width < minTrackWidth: 505 | minTrackWidth = width 506 | if isinstance(sourceTrack, pcbnew.PCB_VIA): 507 | drill = sourceTrack.GetDrill() 508 | if drill < minViaDrill: 509 | minViaDrill = drill 510 | for x in range(0, NUM_X): # iterate through x direction 511 | for y in range(0, NUM_Y): # iterate through y direction 512 | if (x != 0) or (y != 0): # do not duplicate source object to location 513 | newTrack = sourceTrack.Duplicate() 514 | xpos = int(x * boardWidth) 515 | ypos = int(-y * boardHeight) 516 | if EXPOSED_EDGE and (y == 1): 517 | centre = pcbnew.VECTOR2I(boardCenter.x, boardCenter.y) 518 | angle = pcbnew.EDA_ANGLE(1800, pcbnew.TENTHS_OF_A_DEGREE_T) 519 | newTrack.Rotate(centre, angle) 520 | ypos -= int(spacerHeight + V_SCORE_WIDTH * SCALE) 521 | newTrack.Move( 522 | pcbnew.VECTOR2I(xpos, ypos) 523 | ) # move to correct location 524 | newTracks.append(newTrack) # add to temporary list of tracks 525 | 526 | for track in newTracks: 527 | board.Add(track) 528 | 529 | # Array of modules 530 | modules = board.GetFootprints() 531 | newModules = [] 532 | prodIDs = [] 533 | for sourceModule in modules: 534 | for logo in possibleLogos.keys(): 535 | if logo in sourceModule.GetFPIDAsString(): 536 | possibleLogos[logo][0] = True # Set 'seen' to True 537 | pos = sourceModule.GetPosition() # Check if footprint is outside the bounding box 538 | if pos.x >= boardLeftEdge and pos.x <= boardRightEdge and \ 539 | pos.y >= boardTopEdge and pos.y <= boardBottomEdge: 540 | for x in range(0, NUM_X): # iterate through x direction 541 | for y in range(0, NUM_Y): # iterate through y direction 542 | if (x != 0) or (y != 0): # do not duplicate source object to location 543 | newModule = pcbnew.FOOTPRINT(sourceModule) 544 | ref = "" 545 | if hasattr(newModule, "Reference"): 546 | ref = newModule.Reference().GetText() 547 | if EXPOSED_EDGE and (y == 1): 548 | centre = pcbnew.VECTOR2I(boardCenter.x, boardCenter.y) 549 | angle = pcbnew.EDA_ANGLE(1800, pcbnew.TENTHS_OF_A_DEGREE_T) 550 | newModule.Rotate(centre, angle) 551 | xpos = int(x * boardWidth + newModule.GetPosition().x) 552 | ypos = int(-y * boardHeight + newModule.GetPosition().y) 553 | if EXPOSED_EDGE and (y == 1): 554 | ypos -= int(spacerHeight + V_SCORE_WIDTH * SCALE) 555 | newModule.SetPosition(pcbnew.VECTOR2I(xpos,ypos)) 556 | newModules.append(newModule) 557 | if hasattr(sourceModule, "HasProperty"): 558 | if sourceModule.HasProperty("PROD_ID"): 559 | prodIDs.append([sourceModule.GetPropertyNative("PROD_ID"), ref]) 560 | else: # Add source object to prodIDs 561 | if hasattr(sourceModule, "HasProperty"): 562 | if sourceModule.HasProperty("PROD_ID"): 563 | ref = "" 564 | if hasattr(sourceModule, "Reference"): 565 | ref = sourceModule.Reference().GetText() 566 | prodIDs.append([sourceModule.GetPropertyNative("PROD_ID"), ref]) 567 | else: # Move source modules which are outside the bounding box 568 | # If the module is below the bottom edge and likely to clip the rail, move it below the rail 569 | if pos.y > boardBottomEdge: 570 | if pos.y < (boardBottomEdge + (2 * int(HORIZONTAL_EDGE_RAIL_WIDTH * SCALE))): 571 | sourceModule.Move(pcbnew.VECTOR2I(0, int(HORIZONTAL_EDGE_RAIL_WIDTH * SCALE))) 572 | elif pos.y < boardTopEdge: # If the module is above the top edge, move it above the panel 573 | sourceModule.Move(pcbnew.VECTOR2I(0, int((-(NUM_Y - 1) * boardHeight) - (HORIZONTAL_EDGE_RAIL_WIDTH * SCALE)))) 574 | elif pos.x > boardRightEdge: # If the module is to the right, move it beyond the panel 575 | sourceModule.Move(pcbnew.VECTOR2I(int(((NUM_X - 1) * boardWidth) + (VERTICAL_EDGE_RAIL_WIDTH * SCALE)), 0)) 576 | else: # elif pos.x < boardLeftEdge: # If the module is to the left, move it outside the rail 577 | sourceModule.Move(pcbnew.VECTOR2I(int(-VERTICAL_EDGE_RAIL_WIDTH * SCALE), 0)) 578 | 579 | for module in newModules: 580 | board.Add(module) 581 | 582 | for logo in possibleLogos.keys(): 583 | if possibleLogos[logo][0] == True: # if seen 584 | solderMask = "Solder Mask: " + possibleLogos[logo][1] 585 | silkscreen = "Silkscreen: " + possibleLogos[logo][2] 586 | break 587 | 588 | # Array of zones 589 | modules = board.GetFootprints() 590 | newZones = [] 591 | for a in range(0, board.GetAreaCount()): 592 | for x in range(0, NUM_X): # iterate through x direction 593 | for y in range(0, NUM_Y): # iterate through y direction 594 | if (x != 0) or (y != 0): # do not duplicate source object to location 595 | sourceZone = board.GetArea(a) 596 | newZone = sourceZone.Duplicate() 597 | newZone.SetNet(sourceZone.GetNet()) 598 | xpos = int(x * boardWidth) 599 | ypos = int(-y * boardHeight) 600 | if EXPOSED_EDGE and (y == 1): 601 | centre = pcbnew.VECTOR2I(boardCenter.x, boardCenter.y) 602 | angle = pcbnew.EDA_ANGLE(1800, pcbnew.TENTHS_OF_A_DEGREE_T) 603 | newZone.Rotate(centre, angle) 604 | ypos -= int(spacerHeight + V_SCORE_WIDTH * SCALE) 605 | newZone.Move( 606 | pcbnew.VECTOR2I(xpos, ypos) 607 | ) 608 | newZones.append(newZone) 609 | 610 | for zone in newZones: 611 | board.Add(zone) 612 | 613 | # Array of drawing objects 614 | drawings = board.GetDrawings() 615 | newDrawings = [] 616 | for sourceDrawing in drawings: 617 | if isinstance(sourceDrawing, pcbnew.PCB_TEXT): 618 | txt = sourceDrawing.GetShownText(aAllowExtraText=True) # 8.0 Fix: PCB_TEXT.GetShownText() missing 1 required positional argument: 'aAllowExtraText' 619 | lines = txt.splitlines() 620 | for line in lines: 621 | if "mask" in line or "Mask" in line or "MASK" in line: 622 | solderMask = line # This will override the possibleLogos mask color 623 | if "silkscreen" in line or "Silkscreen" in line or "SILKSCREEN" in line: 624 | silkscreen = line # This will override the possibleLogos silk color 625 | if "layers" in line or "Layers" in line or "LAYERS" in line: 626 | if copperLayers is None: # Should we trust the instructions or the tracks?! 627 | copperLayers = line 628 | if "impedance" in line or "Impedance" in line or "IMPEDANCE" in line: 629 | controlledImpedance = line 630 | if "finish" in line or "Finish" in line or "FINISH" in line: 631 | finish = line 632 | if "thickness" in line or "Thickness" in line or "THICKNESS" in line: 633 | thickness = line 634 | if "material" in line or "Material" in line or "MATERIAL" in line: 635 | material = line 636 | if "weight" in line or "Weight" in line or "WEIGHT" in line or "oz" in line or "Oz" in line or "OZ" in line: 637 | if copperWeight is None: 638 | copperWeight = line 639 | else: 640 | copperWeight += "\n" + line 641 | for extra in possibleExtras: 642 | if extra in line: 643 | if orderingExtras is None: 644 | orderingExtras = "" 645 | orderingExtras += line + "\n" 646 | pos = sourceDrawing.GetPosition() # Check if drawing is outside the bounding box 647 | if pos.x >= boardLeftEdge and pos.x <= boardRightEdge and \ 648 | pos.y >= boardTopEdge and pos.y <= boardBottomEdge: 649 | for x in range(0, NUM_X): # iterate through x direction 650 | for y in range(0, NUM_Y): # iterate through y direction 651 | if (x != 0) or (y != 0): # do not duplicate source object to location 652 | newDrawing = sourceDrawing.Duplicate() 653 | xpos = int(x * boardWidth) 654 | ypos = int(-y * boardHeight) 655 | if EXPOSED_EDGE and (y == 1): 656 | centre = pcbnew.VECTOR2I(boardCenter.x, boardCenter.y) 657 | angle = pcbnew.EDA_ANGLE(1800, pcbnew.TENTHS_OF_A_DEGREE_T) 658 | newDrawing.Rotate(centre, angle) 659 | ypos -= int(spacerHeight + V_SCORE_WIDTH * SCALE) 660 | newDrawing.Move( 661 | pcbnew.VECTOR2I(xpos, ypos) 662 | ) 663 | newDrawings.append(newDrawing) 664 | else: # Move source drawings which are outside the bounding box 665 | #if txt is not None: # Copy all text outside the bounding box to the report 666 | # report += txt + "\n" 667 | if pos.y > boardBottomEdge: # If the drawing is below the bottom edge, move it below the rail 668 | if pos.y < (boardBottomEdge + (2 * int(HORIZONTAL_EDGE_RAIL_WIDTH * SCALE))): # But only if in the way 669 | sourceDrawing.Move(pcbnew.VECTOR2I(0, int(HORIZONTAL_EDGE_RAIL_WIDTH * SCALE))) 670 | elif pos.y < boardTopEdge: # If the drawing is above the top edge, move it above the panel 671 | sourceDrawing.Move(pcbnew.VECTOR2I(0, int((-(NUM_Y - 1) * boardHeight) - (HORIZONTAL_EDGE_RAIL_WIDTH * SCALE)))) 672 | elif pos.x > boardRightEdge: # If the drawing is to the right, move it beyond the panel 673 | sourceDrawing.Move(pcbnew.VECTOR2I(int(((NUM_X - 1) * boardWidth) + (VERTICAL_EDGE_RAIL_WIDTH * SCALE)), 0)) 674 | else: # elif pos.x < boardLeftEdge: # If the drawing is to the left, move it outside the rail 675 | sourceDrawing.Move(pcbnew.VECTOR2I(int(-VERTICAL_EDGE_RAIL_WIDTH * SCALE), 0)) 676 | 677 | for drawing in newDrawings: 678 | board.Add(drawing) 679 | 680 | # Get dimensions and center coordinate of entire array (without siderails to be added shortly) 681 | arrayWidth = board.GetBoardEdgesBoundingBox().GetWidth() 682 | arrayWidth -= cutWidth 683 | arrayHeight = board.GetBoardEdgesBoundingBox().GetHeight() 684 | arrayHeight -= cutWidth 685 | arrayCenter = board.GetBoardEdgesBoundingBox().GetCenter() 686 | 687 | spacerLeft = arrayCenter.x - arrayWidth / 2 688 | spacerRight = arrayCenter.x + arrayWidth / 2 689 | spacerTop = arrayCenter.y - spacerHeight / 2 690 | spacerBottom = arrayCenter.y + spacerHeight / 2 691 | 692 | # SFE: On SFE panels, we keep all the existing edge cuts and let JLCPCB figure out which are 693 | # true edges and which are v-scores. Also, if the edges are not straight lines, it gets 694 | # complicated quickly. (The original version of this panelizer only worked on rectangular 695 | # boards.) (Use KiKit if you want to avoid the redundant edge cuts.) 696 | # 697 | # Erase all existing edgeCuts objects (individual board outlines) 698 | #drawings = board.GetDrawings() 699 | #for drawing in drawings: 700 | # if drawing.IsOnLayer(edgeLayerNumber): 701 | # drawing.DeleteStructure() 702 | 703 | # Rail Edge.Cuts 704 | v_score_top_outer = int( 705 | arrayCenter.y 706 | - arrayHeight / 2 707 | - GAP_Y * SCALE 708 | - V_SCORE_WIDTH * SCALE 709 | - HORIZONTAL_EDGE_RAIL_WIDTH * SCALE 710 | ) 711 | v_score_top_inner = int(v_score_top_outer + HORIZONTAL_EDGE_RAIL_WIDTH * SCALE) 712 | v_score_bottom_outer = int( 713 | arrayCenter.y 714 | + arrayHeight / 2 715 | + GAP_Y * SCALE 716 | + V_SCORE_WIDTH * SCALE 717 | + HORIZONTAL_EDGE_RAIL_WIDTH * SCALE 718 | ) 719 | v_score_bottom_inner = int(v_score_bottom_outer - HORIZONTAL_EDGE_RAIL_WIDTH * SCALE) 720 | v_score_left_outer = int( 721 | arrayCenter.x 722 | - arrayWidth / 2 723 | - GAP_X * SCALE 724 | - V_SCORE_WIDTH * SCALE 725 | - VERTICAL_EDGE_RAIL_WIDTH * SCALE 726 | ) 727 | v_score_left_inner = v_score_left_outer + VERTICAL_EDGE_RAIL_WIDTH * SCALE 728 | v_score_right_outer = int( 729 | arrayCenter.x 730 | + arrayWidth / 2 731 | + ((GAP_X * SCALE) if (not REMOVE_RIGHT) else 0.0) 732 | + V_SCORE_WIDTH * SCALE 733 | + VERTICAL_EDGE_RAIL_WIDTH * SCALE 734 | ) 735 | v_score_right_inner = v_score_right_outer - VERTICAL_EDGE_RAIL_WIDTH * SCALE 736 | 737 | # Define rail edge cuts - if any 738 | edgeCuts = [] 739 | if EXPOSED_EDGE and (NUM_Y == 2): 740 | # Add the spacer 741 | edgeCuts.append([spacerLeft, spacerTop, 742 | spacerRight, spacerTop]) 743 | edgeCuts.append([spacerRight, spacerTop, 744 | spacerRight, spacerBottom]) 745 | edgeCuts.append([spacerRight, spacerBottom, 746 | spacerLeft, spacerBottom]) 747 | edgeCuts.append([spacerLeft, spacerBottom, 748 | spacerLeft, spacerTop]) 749 | if HORIZONTAL_EDGE_RAIL_WIDTH > 0.0 and VERTICAL_EDGE_RAIL_WIDTH > 0.0: 750 | # Both vertical and horizontal edges, continuous border 751 | edgeCuts.append([v_score_left_outer, v_score_top_outer, 752 | v_score_right_outer, v_score_top_outer]) 753 | edgeCuts.append([v_score_right_outer, v_score_top_outer, 754 | v_score_right_outer, v_score_bottom_outer]) 755 | edgeCuts.append([v_score_right_outer, v_score_bottom_outer, 756 | v_score_left_outer, v_score_bottom_outer]) 757 | edgeCuts.append([v_score_left_outer, v_score_bottom_outer, 758 | v_score_left_outer, v_score_top_outer]) 759 | edgeCuts.append([v_score_left_inner, v_score_top_inner, 760 | v_score_right_inner, v_score_top_inner]) 761 | edgeCuts.append([v_score_right_inner, v_score_top_inner, 762 | v_score_right_inner, v_score_bottom_inner]) 763 | edgeCuts.append([v_score_right_inner, v_score_bottom_inner, 764 | v_score_left_inner, v_score_bottom_inner]) 765 | edgeCuts.append([v_score_left_inner, v_score_bottom_inner, 766 | v_score_left_inner, v_score_top_inner]) 767 | #report += "Adding both horizontal and vertical rails.\n" 768 | elif HORIZONTAL_EDGE_RAIL_WIDTH > 0.0: 769 | # Horizontal edges only 770 | v_score_left_inner += V_SCORE_WIDTH * SCALE 771 | v_score_right_inner -= V_SCORE_WIDTH * SCALE 772 | v_score_left_inner += GAP_X * SCALE 773 | v_score_right_inner -= (GAP_X * SCALE) if (not REMOVE_RIGHT) else 0.0 774 | edgeCuts.append([v_score_left_inner, v_score_top_outer, 775 | v_score_right_inner, v_score_top_outer]) 776 | edgeCuts.append([v_score_right_inner, v_score_top_outer, 777 | v_score_right_inner, v_score_top_inner]) 778 | edgeCuts.append([v_score_right_inner, v_score_top_inner, 779 | v_score_left_inner, v_score_top_inner]) 780 | edgeCuts.append([v_score_left_inner, v_score_top_inner, 781 | v_score_left_inner, v_score_top_outer]) 782 | edgeCuts.append([v_score_left_inner, v_score_bottom_inner, 783 | v_score_right_inner, v_score_bottom_inner]) 784 | edgeCuts.append([v_score_right_inner, v_score_bottom_inner, 785 | v_score_right_inner, v_score_bottom_outer]) 786 | edgeCuts.append([v_score_right_inner, v_score_bottom_outer, 787 | v_score_left_inner, v_score_bottom_outer]) 788 | edgeCuts.append([v_score_left_inner, v_score_bottom_outer, 789 | v_score_left_inner, v_score_bottom_inner]) 790 | #report += "Adding horizontal rails only.\n" 791 | elif VERTICAL_EDGE_RAIL_WIDTH > 0.0: 792 | # VERTICAL edges only 793 | v_score_top_inner += V_SCORE_WIDTH * SCALE 794 | v_score_bottom_inner -= V_SCORE_WIDTH * SCALE 795 | v_score_top_inner += GAP_Y * SCALE 796 | v_score_bottom_inner -= GAP_Y * SCALE 797 | edgeCuts.append([v_score_left_outer, v_score_top_inner, 798 | v_score_left_inner, v_score_top_inner]) 799 | edgeCuts.append([v_score_left_inner, v_score_top_inner, 800 | v_score_left_inner, v_score_bottom_inner]) 801 | edgeCuts.append([v_score_left_inner, v_score_bottom_inner, 802 | v_score_left_outer, v_score_bottom_inner]) 803 | edgeCuts.append([v_score_left_outer, v_score_bottom_inner, 804 | v_score_left_outer, v_score_top_inner]) 805 | edgeCuts.append([v_score_right_inner, v_score_top_inner, 806 | v_score_right_outer, v_score_top_inner]) 807 | edgeCuts.append([v_score_right_outer, v_score_top_inner, 808 | v_score_right_outer, v_score_bottom_inner]) 809 | edgeCuts.append([v_score_right_outer, v_score_bottom_inner, 810 | v_score_right_inner, v_score_bottom_inner]) 811 | edgeCuts.append([v_score_right_inner, v_score_bottom_inner, 812 | v_score_right_inner, v_score_top_inner]) 813 | #report += "Adding vertical rails only.\n" 814 | else: 815 | pass # report += "Adding no rails.\n" 816 | 817 | # Add rail cuts - if any 818 | for cut in edgeCuts: 819 | edge = pcbnew.PCB_SHAPE(board) 820 | board.Add(edge) 821 | edge.SetStart(pcbnew.VECTOR2I(int(cut[0]), int(cut[1]))) 822 | edge.SetEnd(pcbnew.VECTOR2I(int(cut[2]), int(cut[3]))) 823 | edge.SetLayer(edgeLayerNumber) 824 | 825 | # Re-calculate board dimensions with new edge cuts 826 | panelWidth = board.GetBoardEdgesBoundingBox().GetWidth() 827 | panelWidth -= cutWidth 828 | panelHeight = board.GetBoardEdgesBoundingBox().GetHeight() 829 | panelHeight -= cutWidth 830 | panelCenter = board.GetBoardEdgesBoundingBox().GetCenter() 831 | 832 | # Absolute edges of v-scoring 833 | vscore_top = ( 834 | panelCenter.y 835 | - panelHeight / 2 836 | - V_SCORE_LINE_LENGTH_BEYOND_BOARD * SCALE 837 | ) 838 | vscore_bottom = ( 839 | panelCenter.y 840 | + panelHeight / 2 841 | + V_SCORE_LINE_LENGTH_BEYOND_BOARD * SCALE 842 | ) 843 | vscore_right = ( 844 | panelCenter.x 845 | + panelWidth / 2 846 | + V_SCORE_LINE_LENGTH_BEYOND_BOARD * SCALE 847 | ) 848 | vscore_left = ( 849 | panelCenter.x 850 | - panelWidth / 2 851 | - V_SCORE_LINE_LENGTH_BEYOND_BOARD * SCALE 852 | ) 853 | 854 | # Vertical v-scores 855 | # Add v-scores only if vertical gaps (running top to bottom, defined in X) 856 | # are <= V_SCORE_WIDTH. Unless REMOVE_RIGHT is true. 857 | v_score_x = [] 858 | 859 | if GAP_X <= V_SCORE_WIDTH: 860 | if VERTICAL_EDGE_RAIL_WIDTH > 0: 861 | RANGE_START = 0 862 | RANGE_END = NUM_X + 1 863 | else: 864 | RANGE_START = 1 865 | RANGE_END = NUM_X 866 | for x in range(RANGE_START, RANGE_END): 867 | v_score_x.append( 868 | arrayCenter.x 869 | - arrayWidth / 2 870 | - GAP_X * SCALE / 2 871 | - V_SCORE_WIDTH * SCALE / 2 872 | + boardWidth * x 873 | ) 874 | else: 875 | # Board has vertical gaps. Check REMOVE_RIGHT 876 | if REMOVE_RIGHT: 877 | v_score_x.append( 878 | arrayCenter.x 879 | + arrayWidth / 2 880 | + V_SCORE_WIDTH * SCALE / 2 881 | ) 882 | 883 | for x_loc in v_score_x: 884 | v_score_line = pcbnew.PCB_SHAPE(board) 885 | v_score_line.SetStart(pcbnew.VECTOR2I(int(x_loc), int(vscore_top))) 886 | v_score_line.SetEnd(pcbnew.VECTOR2I(int(x_loc), int(vscore_bottom))) 887 | v_score_line.SetLayer(vScoreLayerNumber) 888 | v_score_line.SetWidth(int(V_SCORE_WIDTH * SCALE)) 889 | board.Add(v_score_line) 890 | v_score_text = pcbnew.PCB_TEXT(board) 891 | v_score_text.SetText(V_SCORE_TEXT) 892 | v_score_text.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_LEFT) 893 | v_score_text.SetPosition( 894 | pcbnew.VECTOR2I(int(x_loc), int(vscore_top - V_SCORE_TEXT_SIZE * SCALE)) 895 | ) 896 | v_score_text.SetTextSize( 897 | pcbnew.VECTOR2I(SCALE * V_SCORE_TEXT_SIZE, SCALE * V_SCORE_TEXT_SIZE) 898 | ) 899 | v_score_text.SetLayer(vScoreTextLayerNumber) 900 | v_score_text.SetTextAngle(pcbnew.EDA_ANGLE(900, pcbnew.TENTHS_OF_A_DEGREE_T)) 901 | board.Add(v_score_text) 902 | 903 | # Horizontal v-scores 904 | # Add v-scores only if the horizontal gaps (running left to right, defined in Y) 905 | # are <= V_SCORE_WIDTH. Ignore for larger gaps. 906 | v_score_y = [] 907 | 908 | if EXPOSED_EDGE and (NUM_Y == 2): 909 | v_score_y.append( 910 | spacerBottom 911 | + V_SCORE_WIDTH * SCALE / 2 912 | ) 913 | v_score_y.append( 914 | spacerTop 915 | - V_SCORE_WIDTH * SCALE / 2 916 | ) 917 | elif GAP_Y <= V_SCORE_WIDTH: 918 | if HORIZONTAL_EDGE_RAIL_WIDTH > 0: 919 | RANGE_START = 0 920 | RANGE_END = NUM_Y + 1 921 | else: 922 | RANGE_START = 1 923 | RANGE_END = NUM_Y 924 | for y in range(RANGE_START, RANGE_END): 925 | v_score_y.append( 926 | arrayCenter.y 927 | - arrayHeight / 2 928 | - GAP_Y * SCALE / 2 929 | - V_SCORE_WIDTH * SCALE / 2 930 | + boardHeight * y 931 | ) 932 | 933 | for y_loc in v_score_y: 934 | v_score_line = pcbnew.PCB_SHAPE(board) 935 | v_score_line.SetStart(pcbnew.VECTOR2I(int(vscore_left), int(y_loc))) 936 | v_score_line.SetEnd(pcbnew.VECTOR2I(int(vscore_right), int(y_loc))) 937 | v_score_line.SetLayer(vScoreLayerNumber) 938 | v_score_line.SetWidth(int(V_SCORE_WIDTH * SCALE)) 939 | board.Add(v_score_line) 940 | v_score_text = pcbnew.PCB_TEXT(board) 941 | v_score_text.SetText(V_SCORE_TEXT) 942 | v_score_text.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_RIGHT) 943 | v_score_text.SetPosition( 944 | pcbnew.VECTOR2I(int(vscore_left - V_SCORE_TEXT_SIZE * SCALE), int(y_loc)) 945 | ) 946 | v_score_text.SetTextSize( 947 | pcbnew.VECTOR2I(SCALE * V_SCORE_TEXT_SIZE, SCALE * V_SCORE_TEXT_SIZE) 948 | ) 949 | v_score_text.SetLayer(vScoreTextLayerNumber) 950 | v_score_text.SetTextAngle(pcbnew.EDA_ANGLE(0, pcbnew.TENTHS_OF_A_DEGREE_T)) 951 | board.Add(v_score_text) 952 | 953 | # Add route out text 954 | route_outs = [] 955 | if GAP_X > 0.0: 956 | if VERTICAL_EDGE_RAIL_WIDTH > 0.0: 957 | RANGE_START = 0 958 | if not REMOVE_RIGHT: 959 | RANGE_END = NUM_X + 1 960 | else: 961 | RANGE_END = NUM_X 962 | else: 963 | RANGE_START = 1 964 | RANGE_END = NUM_X 965 | for x in range(RANGE_START, RANGE_END): 966 | route_outs.append([ 967 | int( 968 | arrayCenter.x 969 | - arrayWidth / 2 970 | - GAP_X * SCALE / 2 971 | - V_SCORE_WIDTH * SCALE / 2 972 | + boardWidth * x 973 | ), 974 | int(arrayCenter.y), 975 | 900 976 | ]) 977 | if GAP_Y > 0.0: 978 | if HORIZONTAL_EDGE_RAIL_WIDTH > 0.0: 979 | RANGE_START = 0 980 | RANGE_END = NUM_Y + 1 981 | else: 982 | RANGE_START = 1 983 | RANGE_END = NUM_Y 984 | for y in range(RANGE_START, RANGE_END): 985 | route_outs.append([ 986 | int(arrayCenter.x), 987 | int( 988 | arrayCenter.y 989 | - arrayHeight / 2 990 | - GAP_Y * SCALE / 2 991 | - V_SCORE_WIDTH * SCALE / 2 992 | + boardHeight * y 993 | ), 994 | 0 995 | ]) 996 | for pos in route_outs: 997 | route_text = pcbnew.PCB_TEXT(board) 998 | route_text.SetText("ROUTE OUT") 999 | route_text.SetTextSize(pcbnew.VECTOR2I(SCALE * V_SCORE_TEXT_SIZE, SCALE * V_SCORE_TEXT_SIZE)) 1000 | route_text.SetLayer(vScoreLayerNumber) 1001 | route_text.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_CENTER) 1002 | route_text.SetPosition(pcbnew.VECTOR2I(pos[0], pos[1])) 1003 | route_text.SetTextAngle(pcbnew.EDA_ANGLE(pos[2], pcbnew.TENTHS_OF_A_DEGREE_T)) 1004 | board.Add(route_text) 1005 | 1006 | # Add Do Not Remove text on exposed edge panel 1007 | if EXPOSED_EDGE and (NUM_Y == 2): 1008 | route_text = pcbnew.PCB_TEXT(board) 1009 | route_text.SetText("DO NOT REMOVE") 1010 | route_text.SetTextSize(pcbnew.VECTOR2I(SCALE * V_SCORE_TEXT_SIZE, SCALE * V_SCORE_TEXT_SIZE)) 1011 | route_text.SetLayer(vScoreTextLayerNumber) 1012 | route_text.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_CENTER) 1013 | route_text.SetPosition(pcbnew.VECTOR2I(arrayCenter.x, arrayCenter.y)) 1014 | board.Add(route_text) 1015 | 1016 | # Add fiducials 1017 | 1018 | # Find the KiCad Fiducial footprints 1019 | kicadVersion = pcbnew.GetBuildVersion().split('.')[0] 1020 | fiducialEnv = "KICAD{}_FOOTPRINT_DIR".format(kicadVersion) 1021 | fiducialPath = os.getenv(fiducialEnv ) # This works when running the plugin inside KiCad 1022 | if fiducialPath is not None: 1023 | fiducialPath = os.path.join(fiducialPath,"Fiducial.pretty") 1024 | else: 1025 | fiducialPath = os.getenv('PYTHONHOME') # This works when running the panelizer manually in a KiCad Command Prompt 1026 | if fiducialPath is not None: 1027 | fiducialPath = os.path.join(fiducialPath,"..","share","kicad","footprints","Fiducial.pretty") 1028 | 1029 | if fiducialPath is None: 1030 | report += "Could not find a path to Fiducial.pretty. Unable to add fiducials.\n" 1031 | sysExit = 1 1032 | else: 1033 | fiducials = [] 1034 | if FIDUCIALS_LR: 1035 | fiducials.append([ 1036 | int(panelCenter.x - (panelWidth / 2 - VERTICAL_EDGE_RAIL_WIDTH * (1.0 - FIDUCIAL_POS) * SCALE)), 1037 | int(panelCenter.y + (panelHeight / 2 - (SCALE * FIDUCIAL_OFFSET_FROM_RAIL + SCALE * HORIZONTAL_EDGE_RAIL_WIDTH))), 1038 | FIDUCIAL_FOOTPRINT_BIG 1039 | ]) 1040 | fiducials.append([ 1041 | int(panelCenter.x - (panelWidth / 2 - VERTICAL_EDGE_RAIL_WIDTH * (1.0 - FIDUCIAL_POS) * SCALE)), 1042 | int(panelCenter.y - (panelHeight / 2 - (SCALE * FIDUCIAL_OFFSET_FROM_RAIL + SCALE * HORIZONTAL_EDGE_RAIL_WIDTH))), 1043 | FIDUCIAL_FOOTPRINT_SMALL 1044 | ]) 1045 | fiducials.append([ 1046 | int(panelCenter.x + (panelWidth / 2 - VERTICAL_EDGE_RAIL_WIDTH * (1.0 - FIDUCIAL_POS) * SCALE)), 1047 | int(panelCenter.y - (panelHeight / 2 - (SCALE * FIDUCIAL_OFFSET_FROM_RAIL + SCALE * HORIZONTAL_EDGE_RAIL_WIDTH))), 1048 | FIDUCIAL_FOOTPRINT_SMALL 1049 | ]) 1050 | if FIDUCIALS_TB: 1051 | fiducials.append([ 1052 | int(panelCenter.x - (panelWidth / 2 - (SCALE * FIDUCIAL_OFFSET_FROM_RAIL + SCALE * VERTICAL_EDGE_RAIL_WIDTH))), 1053 | int(panelCenter.y + (panelHeight / 2 - HORIZONTAL_EDGE_RAIL_WIDTH * (1.0 - FIDUCIAL_POS) * SCALE)), 1054 | FIDUCIAL_FOOTPRINT_BIG 1055 | ]) 1056 | fiducials.append([ 1057 | int(panelCenter.x - (panelWidth / 2 - (SCALE * FIDUCIAL_OFFSET_FROM_RAIL + SCALE * VERTICAL_EDGE_RAIL_WIDTH))), 1058 | int(panelCenter.y - (panelHeight / 2 - HORIZONTAL_EDGE_RAIL_WIDTH * (1.0 - FIDUCIAL_POS) * SCALE)), 1059 | FIDUCIAL_FOOTPRINT_SMALL 1060 | ]) 1061 | fiducials.append([ 1062 | int(panelCenter.x + (panelWidth / 2 - (SCALE * FIDUCIAL_OFFSET_FROM_RAIL + SCALE * VERTICAL_EDGE_RAIL_WIDTH))), 1063 | int(panelCenter.y - (panelHeight / 2 - HORIZONTAL_EDGE_RAIL_WIDTH * (1.0 - FIDUCIAL_POS) * SCALE)), 1064 | FIDUCIAL_FOOTPRINT_SMALL 1065 | ]) 1066 | for pos in fiducials: 1067 | # Front / Top 1068 | fiducial = pcbnew.FootprintLoad(fiducialPath, pos[2]) 1069 | fiducial.SetReference("") # Clear the reference silk 1070 | fiducial.SetValue("") 1071 | board.Add(fiducial) 1072 | fiducial.SetPosition(pcbnew.VECTOR2I(pos[0], pos[1])) 1073 | 1074 | # Back / Bottom 1075 | fiducial = pcbnew.FootprintLoad(fiducialPath, pos[2]) 1076 | fiducial.SetReference("") # Clear the reference silk 1077 | fiducial.SetValue("") 1078 | board.Add(fiducial) 1079 | fiducial.SetPosition(pcbnew.VECTOR2I(pos[0], pos[1])) 1080 | fiducial.Flip(pcbnew.VECTOR2I(pos[0], pos[1]), False) 1081 | 1082 | # Add text to rail 1083 | if HORIZONTAL_EDGE_RAIL_TEXT: # Add text to bottom rail 1084 | hrail_text = pcbnew.PCB_TEXT(board) 1085 | hrail_text.SetText(HORIZONTAL_EDGE_RAIL_TEXT) 1086 | hrail_text.SetTextSize(pcbnew.VECTOR2I(int(SCALE * RAIL_TEXT_SIZE), int(SCALE * RAIL_TEXT_SIZE))) 1087 | hrail_text.SetLayer(pcbnew.F_SilkS) 1088 | hrail_text.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_LEFT) 1089 | hrail_text.SetPosition( 1090 | pcbnew.VECTOR2I( 1091 | int(panelCenter.x - (panelWidth / 2 - VERTICAL_EDGE_RAIL_WIDTH * SCALE - SCALE * FIDUCIAL_OFFSET_FROM_RAIL * 2)), 1092 | int(panelCenter.y + (panelHeight / 2 - HORIZONTAL_EDGE_RAIL_WIDTH / 2 * SCALE)) 1093 | ) 1094 | ) 1095 | board.Add(hrail_text) 1096 | 1097 | if VERTICAL_EDGE_RAIL_TEXT: # Add text to left rail 1098 | vrail_text = pcbnew.PCB_TEXT(board) 1099 | vrail_text.SetText(VERTICAL_EDGE_RAIL_TEXT) 1100 | vrail_text.SetTextSize(pcbnew.VECTOR2I(int(SCALE * RAIL_TEXT_SIZE), int(SCALE * RAIL_TEXT_SIZE))) 1101 | vrail_text.SetLayer(pcbnew.F_SilkS) 1102 | vrail_text.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_LEFT) 1103 | vrail_text.SetPosition( 1104 | pcbnew.VECTOR2I( 1105 | int(panelCenter.x - (panelWidth / 2 - VERTICAL_EDGE_RAIL_WIDTH / 2 * SCALE)), 1106 | int(panelCenter.y - (panelHeight / 2 - HORIZONTAL_EDGE_RAIL_WIDTH * SCALE - SCALE * FIDUCIAL_OFFSET_FROM_RAIL * 2)) 1107 | ) 1108 | ) 1109 | vrail_text.SetTextAngle(pcbnew.EDA_ANGLE(-900, pcbnew.TENTHS_OF_A_DEGREE_T)) # rotate if on vrail 1110 | board.Add(vrail_text) 1111 | 1112 | # Add title text to rail 1113 | TITLE_TEXT = "" 1114 | if board.GetTitleBlock().GetTitle(): 1115 | TITLE_TEXT += str(board.GetTitleBlock().GetTitle()) 1116 | 1117 | if board.GetTitleBlock().GetRevision(): 1118 | TITLE_TEXT += " Rev. " + str(board.GetTitleBlock().GetRevision()) 1119 | 1120 | if board.GetTitleBlock().GetDate(): 1121 | TITLE_TEXT += ", " + str(board.GetTitleBlock().GetDate()) 1122 | 1123 | if board.GetTitleBlock().GetCompany(): 1124 | TITLE_TEXT += " (c) " + str(board.GetTitleBlock().GetCompany()) 1125 | 1126 | if TITLE_TEXT == "": 1127 | TITLE_TEXT = os.path.split(panelOutputFile)[1] # Default to the panel filename 1128 | 1129 | if TITLE_X: # Add text to top rail 1130 | titleblock_text = pcbnew.PCB_TEXT(board) 1131 | titleblock_text.SetText(TITLE_TEXT) 1132 | titleblock_text.SetTextSize(pcbnew.VECTOR2I(int(SCALE * RAIL_TEXT_SIZE), int(SCALE * RAIL_TEXT_SIZE))) 1133 | titleblock_text.SetLayer(pcbnew.F_SilkS) 1134 | titleblock_text.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_LEFT) 1135 | titleblock_text.SetPosition( 1136 | pcbnew.VECTOR2I( 1137 | int(panelCenter.x - (panelWidth / 2 - VERTICAL_EDGE_RAIL_WIDTH * SCALE - SCALE * FIDUCIAL_OFFSET_FROM_RAIL * 2)), 1138 | int(panelCenter.y - (panelHeight / 2 - HORIZONTAL_EDGE_RAIL_WIDTH / 2 * SCALE)) 1139 | ) 1140 | ) 1141 | board.Add(titleblock_text) 1142 | 1143 | if TITLE_Y: # Add text to right rail 1144 | titleblock_text = pcbnew.PCB_TEXT(board) 1145 | titleblock_text.SetText(TITLE_TEXT) 1146 | titleblock_text.SetTextSize(pcbnew.VECTOR2I(int(SCALE * RAIL_TEXT_SIZE), int(SCALE * RAIL_TEXT_SIZE))) 1147 | titleblock_text.SetLayer(pcbnew.F_SilkS) 1148 | titleblock_text.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_LEFT) 1149 | titleblock_text.SetPosition( 1150 | pcbnew.VECTOR2I( 1151 | int(panelCenter.x + (panelWidth / 2 - VERTICAL_EDGE_RAIL_WIDTH / 2 * SCALE)), 1152 | int(panelCenter.y - (panelHeight / 2 - HORIZONTAL_EDGE_RAIL_WIDTH * SCALE - SCALE * FIDUCIAL_OFFSET_FROM_RAIL * 2)) 1153 | ) 1154 | ) 1155 | titleblock_text.SetTextAngle(pcbnew.EDA_ANGLE(-900, pcbnew.TENTHS_OF_A_DEGREE_T)) 1156 | board.Add(titleblock_text) 1157 | 1158 | # If rails are present but don't contain copper or silk, add something so JLCPCB picks up the correct panel size 1159 | # Bottom Edge 1160 | if (HORIZONTAL_EDGE_RAIL_WIDTH > 0.0) and (not FIDUCIALS_TB) and (not HORIZONTAL_EDGE_RAIL_TEXT): 1161 | hrail_text = pcbnew.PCB_TEXT(board) 1162 | hrail_text.SetText(EMPTY_EDGE_TEXT) 1163 | hrail_text.SetTextSize(pcbnew.VECTOR2I(int(SCALE * RAIL_TEXT_SIZE), int(SCALE * RAIL_TEXT_SIZE))) 1164 | hrail_text.SetLayer(pcbnew.F_SilkS) 1165 | hrail_text.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_LEFT) 1166 | hrail_text.SetPosition( 1167 | pcbnew.VECTOR2I( 1168 | int(panelCenter.x - (panelWidth / 2 - VERTICAL_EDGE_RAIL_WIDTH * SCALE - SCALE * FIDUCIAL_OFFSET_FROM_RAIL * 2)), 1169 | int(panelCenter.y + (panelHeight / 2 - HORIZONTAL_EDGE_RAIL_WIDTH / 2 * SCALE)) 1170 | ) 1171 | ) 1172 | board.Add(hrail_text) 1173 | # Top Edge 1174 | if (HORIZONTAL_EDGE_RAIL_WIDTH > 0.0) and (not FIDUCIALS_TB) and (not TITLE_X): 1175 | titleblock_text = pcbnew.PCB_TEXT(board) 1176 | titleblock_text.SetText(EMPTY_EDGE_TEXT) 1177 | titleblock_text.SetTextSize(pcbnew.VECTOR2I(int(SCALE * RAIL_TEXT_SIZE), int(SCALE * RAIL_TEXT_SIZE))) 1178 | titleblock_text.SetLayer(pcbnew.F_SilkS) 1179 | titleblock_text.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_LEFT) 1180 | titleblock_text.SetPosition( 1181 | pcbnew.VECTOR2I( 1182 | int(panelCenter.x - (panelWidth / 2 - VERTICAL_EDGE_RAIL_WIDTH * SCALE - SCALE * FIDUCIAL_OFFSET_FROM_RAIL * 2)), 1183 | int(panelCenter.y - (panelHeight / 2 - HORIZONTAL_EDGE_RAIL_WIDTH / 2 * SCALE)) 1184 | ) 1185 | ) 1186 | board.Add(titleblock_text) 1187 | # Left Edge 1188 | if (VERTICAL_EDGE_RAIL_WIDTH > 0.0) and (not FIDUCIALS_LR) and (not VERTICAL_EDGE_RAIL_TEXT): 1189 | vrail_text = pcbnew.PCB_TEXT(board) 1190 | vrail_text.SetText(EMPTY_EDGE_TEXT) 1191 | vrail_text.SetTextSize(pcbnew.VECTOR2I(int(SCALE * RAIL_TEXT_SIZE), int(SCALE * RAIL_TEXT_SIZE))) 1192 | vrail_text.SetLayer(pcbnew.F_SilkS) 1193 | vrail_text.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_LEFT) 1194 | vrail_text.SetPosition( 1195 | pcbnew.VECTOR2I( 1196 | int(panelCenter.x - (panelWidth / 2 - VERTICAL_EDGE_RAIL_WIDTH / 2 * SCALE)), 1197 | int(panelCenter.y - (panelHeight / 2 - HORIZONTAL_EDGE_RAIL_WIDTH * SCALE - SCALE * FIDUCIAL_OFFSET_FROM_RAIL * 2)) 1198 | ) 1199 | ) 1200 | vrail_text.SetTextAngle(pcbnew.EDA_ANGLE(-900, pcbnew.TENTHS_OF_A_DEGREE_T)) # rotate if on vrail 1201 | board.Add(vrail_text) 1202 | # Right Edge 1203 | if (VERTICAL_EDGE_RAIL_WIDTH > 0.0) and (not FIDUCIALS_LR) and (not TITLE_Y): 1204 | titleblock_text = pcbnew.PCB_TEXT(board) 1205 | titleblock_text.SetText(EMPTY_EDGE_TEXT) 1206 | titleblock_text.SetTextSize(pcbnew.VECTOR2I(int(SCALE * RAIL_TEXT_SIZE), int(SCALE * RAIL_TEXT_SIZE))) 1207 | titleblock_text.SetLayer(pcbnew.F_SilkS) 1208 | titleblock_text.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_LEFT) 1209 | titleblock_text.SetPosition( 1210 | pcbnew.VECTOR2I( 1211 | int(panelCenter.x + (panelWidth / 2 - VERTICAL_EDGE_RAIL_WIDTH / 2 * SCALE)), 1212 | int(panelCenter.y - (panelHeight / 2 - HORIZONTAL_EDGE_RAIL_WIDTH * SCALE - SCALE * FIDUCIAL_OFFSET_FROM_RAIL * 2)) 1213 | ) 1214 | ) 1215 | titleblock_text.SetTextAngle(pcbnew.EDA_ANGLE(-900, pcbnew.TENTHS_OF_A_DEGREE_T)) 1216 | board.Add(titleblock_text) 1217 | 1218 | # Finally, refill the zones 1219 | # This prevents the badness reported in #21 1220 | report += "Zones refilled by Panelizer.\n" 1221 | fillerTool = pcbnew.ZONE_FILLER(board) 1222 | fillerTool.Fill(board.Zones()) 1223 | 1224 | # Save output 1225 | board.SetFileName(panelOutputFile) 1226 | board.Save(panelOutputFile) 1227 | 1228 | # Warn if panel is under 70x70mm 1229 | if panelWidth / SCALE < 70 or panelHeight / SCALE < 70: 1230 | report += "Warning: panel is under 70x70mm. It may be too small to v-score.\n" 1231 | sysExit = 1 1232 | 1233 | # Add ordering instructions: 1234 | if ordering is None: 1235 | report += "\nOrdering Instructions:\n" 1236 | report += ( 1237 | "Panel dimensions: " 1238 | + "{:.2f} x ".format(panelWidth / SCALE) 1239 | + "{:.2f} mm.\n".format(panelHeight / SCALE) 1240 | ) 1241 | if controlledImpedance is not None: 1242 | report += controlledImpedance + "\n" 1243 | if material is not None: 1244 | report += material + "\n" 1245 | if solderMask is None: 1246 | solderMask = "Solder Mask: Red (Default)" 1247 | report += solderMask + "\n" 1248 | if silkscreen is None: 1249 | silkscreen = "Silkscreen: White (Default)" 1250 | report += silkscreen + "\n" 1251 | if copperLayers is None: 1252 | copperLayers = "Layers: 2 (Default)" 1253 | report += copperLayers + "\n" 1254 | if finish is None: 1255 | finish = "Finish: HASL Lead-free (Default)" 1256 | report += finish + "\n" 1257 | if thickness is None: 1258 | thickness = "Thickness: 1.6mm (Default)" 1259 | report += thickness + "\n" 1260 | if copperWeight is None: 1261 | copperWeight = "Copper weight: 1oz (Default)" 1262 | report += copperWeight + "\n" 1263 | if minTrackWidth < INVALID_WIDTH: 1264 | report += "Minimum track width: {:.2f}mm ({:.2f}mil)\n".format( 1265 | float(minTrackWidth) / SCALE, float(minTrackWidth) * 1000 / (SCALE * 25.4)) 1266 | if minViaDrill < INVALID_WIDTH: 1267 | report += "Minimum via drill: {:.2f}mm ({:.2f}mil)\n".format( 1268 | float(minViaDrill) / SCALE, float(minViaDrill) * 1000 / (SCALE * 25.4)) 1269 | if orderingExtras is not None: 1270 | report += orderingExtras 1271 | else: 1272 | try: 1273 | defaultsUsed = False 1274 | with open(ordering, 'w') as oi: 1275 | oi.write("Ordering Instructions:\n") 1276 | oi.write( 1277 | "Panel dimensions: " 1278 | + "{:.2f} x ".format(panelWidth / SCALE) 1279 | + "{:.2f} mm.\n".format(panelHeight / SCALE) 1280 | ) 1281 | if controlledImpedance is not None: 1282 | oi.write(controlledImpedance + "\n") 1283 | if material is not None: 1284 | oi.write(material + "\n") 1285 | if solderMask is None: 1286 | defaultsUsed = True 1287 | solderMask = "Solder Mask: Red (Default)" 1288 | oi.write(solderMask + "\n") 1289 | if silkscreen is None: 1290 | defaultsUsed = True 1291 | silkscreen = "Silkscreen: White (Default)" 1292 | oi.write(silkscreen + "\n") 1293 | if copperLayers is None: 1294 | defaultsUsed = True 1295 | copperLayers = "Layers: 2 (Default)" 1296 | oi.write(copperLayers + "\n") 1297 | if finish is None: 1298 | defaultsUsed = True 1299 | finish = "Finish: HASL Lead-free (Default)" 1300 | oi.write(finish + "\n") 1301 | if thickness is None: 1302 | defaultsUsed = True 1303 | thickness = "Thickness: 1.6mm (Default)" 1304 | oi.write(thickness + "\n") 1305 | if copperWeight is None: 1306 | defaultsUsed = True 1307 | copperWeight = "Copper weight: 1oz (Default)" 1308 | oi.write(copperWeight + "\n") 1309 | if minTrackWidth < INVALID_WIDTH: 1310 | oi.write("Minimum track width: {:.2f}mm ({:.2f}mil)\n".format( 1311 | float(minTrackWidth) / SCALE, float(minTrackWidth) * 1000 / (SCALE * 25.4))) 1312 | if minViaDrill < INVALID_WIDTH: 1313 | oi.write("Minimum via drill: {:.2f}mm ({:.2f}mil)\n".format( 1314 | float(minViaDrill) / SCALE, float(minViaDrill) * 1000 / (SCALE * 25.4))) 1315 | if orderingExtras is not None: 1316 | oi.write(orderingExtras) 1317 | if defaultsUsed: 1318 | report += "Warning: Ordering Instructions contains default values.\n" 1319 | sysExit = 1 1320 | except Exception as e: 1321 | # Don't throw exception if we can't save ordering instructions 1322 | pass 1323 | 1324 | if len(prodIDs) > 0: 1325 | emptyProdIDs = {} 1326 | for prodID in prodIDs: 1327 | if (prodID[0] == '') or (prodID[0] == ' '): 1328 | if prodID[1] not in emptyProdIDs: 1329 | emptyProdIDs[prodID[1]] = 1 1330 | else: 1331 | emptyProdIDs[prodID[1]] = emptyProdIDs[prodID[1]] + 1 1332 | if len(emptyProdIDs) > 0: 1333 | refs = "" 1334 | for ref, num in emptyProdIDs.items(): 1335 | if refs == "": 1336 | refs += ref 1337 | else: 1338 | refs += "," + ref 1339 | if wx.GetApp() is not None: 1340 | resp = wx.MessageBox("Empty (undefined) PROD_IDs found!\n" + refs, 1341 | 'Warning', wx.OK | wx.ICON_WARNING) 1342 | report += "Empty (undefined) PROD_IDs found: " + refs + "\n" 1343 | sysExit = 1 1344 | 1345 | if sysExit < 0: 1346 | sysExit = 0 1347 | 1348 | return sysExit, report 1349 | 1350 | def startPanelizerCommand(self, command, board=None, ordering=None, logger=None): 1351 | 1352 | parser = self.args_parse(command) 1353 | 1354 | sysExit, report = self.startPanelizer(parser, board, ordering, logger) 1355 | 1356 | return sysExit, report 1357 | 1358 | if __name__ == '__main__': 1359 | 1360 | panelizer = Panelizer() 1361 | 1362 | if len(sys.argv) < 2: 1363 | parser = panelizer.args_parse(['-h']) # Test args: e.g. ['-p','','--numx','3','--numy','3'] 1364 | else: 1365 | parser = panelizer.args_parse(sys.argv[1:]) #Parse the args 1366 | 1367 | sysExit, report = panelizer.startPanelizer(parser) 1368 | 1369 | print(report) 1370 | 1371 | sys.exit(sysExit) 1372 | -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import wx 4 | import wx.aui 5 | 6 | import pcbnew 7 | 8 | from .dialog import Dialog 9 | 10 | from .panelizer.panelizer import Panelizer 11 | 12 | class PanelizerPlugin(pcbnew.ActionPlugin, object): 13 | 14 | def __init__(self): 15 | super(PanelizerPlugin, self).__init__() 16 | 17 | self.logger = None 18 | self.config_file = None 19 | 20 | self.name = "Panelize PCB" 21 | self.category = "Modify PCB" 22 | self.pcbnew_icon_support = hasattr(self, "show_toolbar_button") 23 | self.show_toolbar_button = True 24 | icon_dir = os.path.dirname(__file__) 25 | self.icon_file_name = os.path.join(icon_dir, 'icon.png') 26 | self.description = "Panelize PCB" 27 | 28 | self._pcbnew_frame = None 29 | 30 | self.supportedVersions = ['7.','8.','9.'] 31 | 32 | self.kicad_build_version = pcbnew.GetBuildVersion() 33 | 34 | productionDir = "Production" 35 | 36 | def IsSupported(self): 37 | for v in self.supportedVersions: 38 | if self.kicad_build_version.startswith(v): 39 | return True 40 | return False 41 | 42 | def Run(self): 43 | if self._pcbnew_frame is None: 44 | try: 45 | self._pcbnew_frame = [x for x in wx.GetTopLevelWindows() if ('pcbnew' in x.GetTitle().lower() and not 'python' in x.GetTitle().lower()) or ('pcb editor' in x.GetTitle().lower())] 46 | if len(self._pcbnew_frame) == 1: 47 | self._pcbnew_frame = self._pcbnew_frame[0] 48 | else: 49 | self._pcbnew_frame = None 50 | except: 51 | pass 52 | 53 | # Construct the config_file path from the board name 54 | board = pcbnew.GetBoard() 55 | panelOutputPath = os.path.split(board.GetFileName())[0] # Get the file path head 56 | panelOutputPath = os.path.join(panelOutputPath, self.productionDir) # Add the production dir 57 | if not os.path.exists(panelOutputPath): 58 | os.mkdir(panelOutputPath) 59 | self.config_file = os.path.join(panelOutputPath, 'panel_config.json') 60 | self.ordering_instructions = os.path.join(panelOutputPath, 'ordering_instructions.txt') 61 | 62 | logFile = os.path.join(panelOutputPath, 'panelizer.log') 63 | try: 64 | os.remove(logFile) 65 | except FileNotFoundError: 66 | pass 67 | 68 | self.logger = logging.getLogger('panelizer_logger') 69 | f_handler = logging.FileHandler(logFile) 70 | f_handler.setLevel(logging.DEBUG) # Log everything 71 | f_format = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 72 | f_handler.setFormatter(f_format) 73 | self.logger.addHandler(f_handler) 74 | 75 | # Build layer table 76 | layertable = {} 77 | numlayers = pcbnew.PCB_LAYER_ID_COUNT 78 | for i in range(numlayers): 79 | layertable[i] = {'standardName': board.GetStandardLayerName(i), 'actualName': board.GetLayerName(i)} 80 | 81 | # Check the number of copper layers. Delete unwanted layers from the table. 82 | wantedCopper = [] 83 | wantedCopper.append("F.Cu") 84 | wantedCopper.append("B.Cu") 85 | if (board.GetCopperLayerCount() > 2): 86 | for i in range(1, board.GetCopperLayerCount() - 1): 87 | wantedCopper.append("In{}.Cu".format(i)) 88 | deleteLayers = [] 89 | for layer, names in layertable.items(): 90 | if names['standardName'][-3:] == ".Cu": 91 | if names['standardName'] not in wantedCopper: 92 | deleteLayers.append(layer) 93 | for layer in deleteLayers: 94 | layertable.pop(layer, None) 95 | 96 | def run_panelizer(dlg, p_panelizer): 97 | self.logger.log(logging.INFO, "Running Panelizer") 98 | 99 | if not self.IsSupported(): 100 | # Log a warning if this version of KiCad has not been tested 101 | self.logger.log(logging.WARNING, "Version check failed. \"{}\" may not be supported. Panelizing may fail".format(self.kicad_build_version)) 102 | 103 | command = [] 104 | 105 | convertDimensions = 1.0 106 | if dlg.CurrentSettings()["dimensionsInchesBtn"]: 107 | convertDimensions = 25.4 108 | 109 | panelx = float(dlg.CurrentSettings()["panelSizeXCtrl"]) * convertDimensions 110 | panely = float(dlg.CurrentSettings()["panelSizeYCtrl"]) * convertDimensions 111 | command.extend(['--panelx','{:.6f}'.format(panelx)]) 112 | command.extend(['--panely','{:.6f}'.format(panely)]) 113 | 114 | smallerThan = dlg.CurrentSettings()["panelSizeSmallerBtn"] 115 | if smallerThan: 116 | command.append('--smaller') 117 | else: 118 | command.append('--larger') 119 | 120 | vscorelayer = dlg.CurrentSettings()[dlg.vscore_layer] 121 | command.extend(['--vscorelayer', vscorelayer, '--vscoretextlayer', vscorelayer]) 122 | 123 | gapx = float(dlg.CurrentSettings()["gapsVerticalCtrl"]) * convertDimensions 124 | gapy = float(dlg.CurrentSettings()["gapsHorizontalCtrl"]) * convertDimensions 125 | command.extend(['--gapx','{:.6f}'.format(gapx)]) 126 | command.extend(['--gapy','{:.6f}'.format(gapy)]) 127 | 128 | removeRight = dlg.CurrentSettings()["removeRightVerticalCheck"] 129 | if removeRight: 130 | command.append('--norightgap') 131 | 132 | exposeedge = dlg.CurrentSettings()["productionExposeCheck"] 133 | if exposeedge: 134 | command.append('--exposeedge') 135 | 136 | fiducials = dlg.CurrentSettings()["productionBordersCheck"] 137 | leftright = dlg.CurrentSettings()["productionFiducialsCheck"] 138 | if not exposeedge: 139 | if fiducials: 140 | # Default the rail width to 1/4" and nudge by 1/4 of the rail width. 141 | # This provides the clearance needed for clamping and AOI Inspection of the fiducials. 142 | # This is nasty. The default should be in panelizer.py. But I can't think of a solution 143 | # which is good for everyone - including anyone running the panelizer from the command line. 144 | command.extend(['--hrail','6.35','--vrail','6.35']) 145 | command.extend(['--fiducialpos','0.25']) 146 | if leftright: 147 | command.append('--fiducialslr') 148 | else: 149 | command.append('--fiducialstb') 150 | else: 151 | if fiducials: 152 | # Same comment as above 153 | command.extend(['--vrail','6.35']) 154 | command.extend(['--fiducialpos','0.25']) 155 | command.append('--fiducialslr') 156 | 157 | self.logger.log(logging.INFO, command) 158 | 159 | board = pcbnew.GetBoard() 160 | 161 | if board is not None: 162 | sysExit, report = p_panelizer.startPanelizerCommand(command, board, self.ordering_instructions, self.logger) 163 | logWarn = logging.INFO 164 | if sysExit >= 1: 165 | logWarn = logging.WARNING 166 | if sysExit >= 2: 167 | logWarn = logging.ERROR 168 | self.logger.log(logWarn, report) 169 | if sysExit > 0: 170 | wx.MessageBox("Panelizer " + ("warning" if (sysExit == 1) else "error") + ".\nPlease check panelizer.log for details.", 171 | ("Warning" if (sysExit == 1) else "Error"), wx.OK | (wx.ICON_WARNING if (sysExit == 1) else wx.ICON_ERROR)) 172 | else: 173 | wx.MessageBox("Panelizer complete.\nPlease check panelizer.log for details.", 174 | "Info", wx.OK | wx.ICON_INFORMATION) 175 | else: 176 | self.logger.log(logging.ERROR, "Could not get the board") 177 | 178 | dlg.GetParent().EndModal(wx.ID_OK) 179 | 180 | dlg = Dialog(self._pcbnew_frame, self.config_file, layertable, self.ordering_instructions, Panelizer(), run_panelizer) 181 | 182 | try: 183 | result = dlg.ShowModal() 184 | if result == wx.ID_OK: 185 | self.logger.log(logging.INFO, "Panelizer complete") 186 | elif result == wx.ID_CANCEL: 187 | self.logger.log(logging.INFO, "Panelizer cancelled") 188 | else: 189 | self.logger.log(logging.INFO, "Panelizer finished - " + str(result)) 190 | 191 | finally: 192 | self.logger.removeHandler(f_handler) 193 | dlg.Destroy() 194 | 195 | 196 | -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/resource/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.4.0" -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/resource/info-15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkfun/SparkFun_KiCad_Panelizer/ed898dcbf9b7be8acf3f59b43614c3909c5bdaac/SparkFunKiCadPanelizer/resource/info-15.png -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/test_dialog.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from dialog.dialog import * 3 | 4 | import sys 5 | import subprocess 6 | 7 | from panelizer.panelizer import Panelizer 8 | 9 | class MyApp(wx.App): 10 | def OnInit(self): 11 | 12 | config_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'panel_config.json') 13 | ordering_instructions = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'ordering_instructions.txt') 14 | layertable = {0: {'standardName': 'F.Cu', 'actualName': 'F-Cu-Renamed'}, 15 | 1: {'standardName': 'User.Comments', 'actualName': 'User-Comments-Renamed'}, 16 | 2: {'standardName': 'User.1', 'actualName': 'User-1-Renamed'}} 17 | 18 | self.frame = frame = Dialog(None, config_file, layertable, ordering_instructions, Panelizer(), self.run) 19 | if frame.ShowModal() == wx.ID_OK: 20 | print("Graceful Exit") 21 | frame.Destroy() 22 | return True 23 | 24 | def run(self, dlg, p_panelizer): 25 | 26 | self.frame.EndModal(wx.ID_OK) 27 | 28 | 29 | app = MyApp() 30 | app.MainLoop() 31 | 32 | print("Done") -------------------------------------------------------------------------------- /SparkFunKiCadPanelizer/util.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | class add_paths(): 4 | def __init__(self, paths): 5 | self.paths = paths 6 | 7 | def __enter__(self): 8 | for path in self.paths: 9 | while path in sys.path: 10 | sys.path.remove(path) 11 | sys.path.insert(0, path) 12 | 13 | def __exit__(self, exc_type, exc_value, traceback): 14 | for path in self.paths: 15 | try: 16 | while path in sys.path: 17 | sys.path.remove(path) 18 | except ValueError: 19 | pass -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .SparkFunKiCadPanelizer import plugin -------------------------------------------------------------------------------- /icons/SparkFunPanelizer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkfun/SparkFun_KiCad_Panelizer/ed898dcbf9b7be8acf3f59b43614c3909c5bdaac/icons/SparkFunPanelizer.png -------------------------------------------------------------------------------- /img/install_from_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkfun/SparkFun_KiCad_Panelizer/ed898dcbf9b7be8acf3f59b43614c3909c5bdaac/img/install_from_file.png -------------------------------------------------------------------------------- /img/panelizer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkfun/SparkFun_KiCad_Panelizer/ed898dcbf9b7be8acf3f59b43614c3909c5bdaac/img/panelizer.png -------------------------------------------------------------------------------- /img/run_panelizer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkfun/SparkFun_KiCad_Panelizer/ed898dcbf9b7be8acf3f59b43614c3909c5bdaac/img/run_panelizer.png -------------------------------------------------------------------------------- /img/run_panelizer_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkfun/SparkFun_KiCad_Panelizer/ed898dcbf9b7be8acf3f59b43614c3909c5bdaac/img/run_panelizer_2.png -------------------------------------------------------------------------------- /pcm/build.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | from os import path 4 | import shutil 5 | import pathlib 6 | import json 7 | import sys 8 | 9 | src_path = path.join(path.dirname(__file__),'..','SparkFunKiCadPanelizer') 10 | version_path = path.join(src_path, 'resource', '_version.py') 11 | 12 | metadata_template = path.join(path.dirname(__file__),'metadata_template.json') 13 | resources_path = path.join(path.dirname(__file__),'resources') 14 | 15 | build_path = path.join('build') 16 | 17 | # Delete build and recreate 18 | try: 19 | shutil.rmtree(build_path) 20 | except FileNotFoundError: 21 | pass 22 | os.mkdir(build_path) 23 | os.mkdir(path.join(build_path,'plugin')) 24 | os.chdir(build_path) 25 | 26 | # Copy plugin 27 | shutil.copytree(src_path, path.join('plugin','plugins')) 28 | 29 | # Clean out any __pycache__ or .pyc files (https://stackoverflow.com/a/41386937) 30 | [p.unlink() for p in pathlib.Path('.').rglob('*.py[co]')] 31 | [p.rmdir() for p in pathlib.Path('.').rglob('__pycache__')] 32 | 33 | # Don't include test_dialog.py. It isn't needed. It's a developer thing. 34 | [p.unlink() for p in pathlib.Path('.').rglob('test_dialog.py')] 35 | 36 | # Copy icon 37 | shutil.copytree(resources_path, path.join('plugin','resources')) 38 | 39 | # Copy metadata 40 | metadata = path.join('plugin','metadata.json') 41 | shutil.copy(metadata_template, metadata) 42 | 43 | # Load up json script 44 | with open(metadata) as f: 45 | md = json.load(f) 46 | 47 | # Get version from resource/_version.py 48 | # https://stackoverflow.com/a/7071358 49 | import re 50 | verstrline = open(version_path, "rt").read() 51 | VSRE = r"^__version__ = ['\"]([^'\"]*)['\"]" 52 | mo = re.search(VSRE, verstrline, re.M) 53 | if mo: 54 | verstr = mo.group(1) 55 | else: 56 | verstr = "1.0.0" 57 | 58 | # Update download URL 59 | md['versions'][0].update({ 60 | 'version': verstr, 61 | 'download_url': 'https://github.com/sparkfun/SparkFun_KiCad_Panelizer/releases/download/v{0}/SparkFunKiCadPanelizer-{0}-pcm.zip'.format(verstr) 62 | }) 63 | 64 | # Update metadata.json 65 | with open(metadata, 'w') as of: 66 | json.dump(md, of, indent=2) 67 | 68 | # Zip all files 69 | zip_file = 'SparkFunKiCadPanelizer-{0}-pcm.zip'.format(md['versions'][0]['version']) 70 | shutil.make_archive(pathlib.Path(zip_file).stem, 'zip', 'plugin') 71 | 72 | # Rename the plugin directory so we can upload it as an artifact - and avoid the double-zip 73 | shutil.move('plugin', 'SparkFunKiCadPanelizer-{0}-pcm'.format(md['versions'][0]['version'])) 74 | -------------------------------------------------------------------------------- /pcm/metadata_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://go.kicad.org/pcm/schemas/v1", 3 | "name": "SparkFun KiCad Panelizer", 4 | "description": "SparkFun's simple PCB panelizer for KiCad 7 / 8", 5 | "description_full": "Converts a single PCB into a panel of multiple PCBs with v-scores in between", 6 | "identifier": "com.github.sparkfun.SparkFunKiCadPanelizer", 7 | "type": "plugin", 8 | "author": { 9 | "name": "SparkFun", 10 | "contact": { 11 | "github": "https://github.com/sparkfun", 12 | "web": "https://sparkfun.com", 13 | "twitter": "https://twitter.com/sparkfun" 14 | } 15 | }, 16 | "maintainer": { 17 | "name": "SparkFun", 18 | "contact": { 19 | "github": "https://github.com/sparkfun", 20 | "web": "https://sparkfun.com", 21 | "twitter": "https://twitter.com/sparkfun" 22 | } 23 | }, 24 | "license": "MIT", 25 | "resources": { 26 | "homepage": "https://github.com/sparkfun/SparkFun_KiCad_Panelizer" 27 | }, 28 | "versions": [ 29 | { 30 | "version": "1.0.0", 31 | "status": "stable", 32 | "kicad_version": "7.0" 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /pcm/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkfun/SparkFun_KiCad_Panelizer/ed898dcbf9b7be8acf3f59b43614c3909c5bdaac/pcm/resources/icon.png --------------------------------------------------------------------------------