diff --git a/README.md b/README.md index 3763425..546d8da 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -JLC PCB Plug-in for KiCad - +
| **JLC PCB Plug-in for KiCad** | @@ -37,7 +37,7 @@ Click on the Fabrication Toolkit +![Option Dialog](assets/options.png) ☑ __Additional layers__: Comma-separated list of additional layers to include in the gerber archive.
☑ __Set User.1 as V-Cut layer__: Merge User.1 layer with the Edge-Cut layer in production.
@@ -45,9 +45,16 @@ Options can be set in the dialog that appears when the plugin is invoked. They a ☑ __Apply automatic translations__: Apply known translation fixes for common components.
☑ __Apply automatic fill for all zones__: Refill all zones before generation production files.
☑ __Exclude DNP components from BOM__: Exclude components the had been set a DNP from th BOM.
+☑ __MPN Column in bom.csv__: Select bom.csv column to use for part numbers.
### ① Include Component Part Number in Production Files -Add an 'LCSC Part #'* field with the LCSC component part number to the symbol's fields property. +Use one of the fields from "Primary Fields" or "Fallback Fields" shown below +for Manufacturer Part Numbers. + +JLCPCB uses the Comment and Footprint columns in the bom.csv to match parts. +Older version of Fabrication Toolkit generated a 'LCSC Part #' column instead. +Use the column name that suits your process to contain the Manufacturer Part +Number or LCSC/JLCPCB component number. @@ -55,13 +62,13 @@ Add an 'LCSC Part #'* field with the LCSC component part number to the symbol's | 'LCSC Part #' | 'LCSC Part' | 'JLCPCB Part #' | 'JLCPCB Part' | | --- | --- | --- | --- | -_The fields will be query in the order denoted above._ +_The fields will be queried in the order denoted above._ #### Fallback Fields*: | 'LCSC' | 'JLC' | 'MPN' | 'Mpn' | 'mpn' | | --- | --- | --- | --- | --- | -_The fields will be query in the order denoted above._ +_The fields will be queried in the order denoted above._ --- @@ -102,7 +109,7 @@ _The fields will be queried in the order denoted above._ --- ### ④ Offset Component Position -The position of components in KiCad Footprints does not always match the orientation in the JLC library because KiCad and JLCPCB used different variation of the same standard. To the exception cases: add an 'FT Position Offset'* field with an comma separated x,y position offset to correct it. +The position of components in KiCad Footprints does not always match the orientation in the JLC library because KiCad and JLCPCB used different variation of the same standard. To the exception cases: add an 'FT Position Offset'* field with an comma separated x,y position offset to correct it. Use following table to quickly find out to which coordinate enter the correction based on JLC arrows clicks - depending on footprint rotation in KiCad PCB Editor status bar: |KiCad footprint deg | x | y| @@ -134,7 +141,7 @@ _The fields will be queried in the order denoted above._ _The fields will be queried in the order denoted above._ ### ⑤ Override Component Origin -The Fabrication Toolkit reports the position of each component based on an automatically selected point of reference. This default behavior can be overridden by adding an 'FT Origin'* field to the component. +The Fabrication Toolkit reports the position of each component based on an automatically selected point of reference. This default behavior can be overridden by adding an 'FT Origin'* field to the component. #### Primary Fields*: | 'FT Origin' | diff --git a/assets/options.png b/assets/options.png index 0fa55eb..7eb078e 100644 Binary files a/assets/options.png and b/assets/options.png differ diff --git a/plugins/cli.py b/plugins/cli.py index 3b3dd17..d052818 100644 --- a/plugins/cli.py +++ b/plugins/cli.py @@ -2,7 +2,7 @@ from .thread import ProcessThread from .options import * - + if __name__ == '__main__': parser = ap.ArgumentParser(prog="Fabrication Toolkit", @@ -17,6 +17,7 @@ parser.add_argument("--excludeDNP", "-e", action="store_true", help="Exclude DNP components from BOM") parser.add_argument("--allActiveLayers", "-aaL",action="store_true", help="Export all active layers instead of only commonly used ones") parser.add_argument("--openBrowser", "-b", action="store_true", help="Open webbrowser with directory file overview after generation") + parser.add_argument("--pnColumn", "-c", type=str, help="bom.csv column name for Manufacturer Part Numbers") args = parser.parse_args() options = dict() @@ -27,6 +28,7 @@ options[ALTERNATIVE_EDGE_CUT_OPT] = args.user2AltVCut options[ALL_ACTIVE_LAYERS_OPT] = args.allActiveLayers options[EXTRA_LAYERS] = args.additionalLayers + options[PN_COLUMN] = args.pnColumn openBrowser = args.openBrowser diff --git a/plugins/options.py b/plugins/options.py index 27ae033..9b78b2e 100644 --- a/plugins/options.py +++ b/plugins/options.py @@ -5,4 +5,5 @@ EXTEND_EDGE_CUT_OPT = "EXTEND_EDGE_CUT" ALTERNATIVE_EDGE_CUT_OPT = "ALTERNATIVE_EDGE_CUT" ALL_ACTIVE_LAYERS_OPT = "ALL_ACTIVE_LAYERS" -EXTRA_LAYERS = "EXTRA_LAYERS" \ No newline at end of file +EXTRA_LAYERS = "EXTRA_LAYERS" +PN_COLUMN = "PN_COLUMN" \ No newline at end of file diff --git a/plugins/plugin.py b/plugins/plugin.py index d94d94f..4330304 100644 --- a/plugins/plugin.py +++ b/plugins/plugin.py @@ -4,7 +4,9 @@ from .thread import ProcessThread from .events import StatusEvent -from .options import AUTO_FILL_OPT, AUTO_TRANSLATE_OPT, EXCLUDE_DNP_OPT, EXTEND_EDGE_CUT_OPT, ALTERNATIVE_EDGE_CUT_OPT, EXTRA_LAYERS, ALL_ACTIVE_LAYERS_OPT +from .options import AUTO_FILL_OPT, AUTO_TRANSLATE_OPT, EXCLUDE_DNP_OPT, \ + EXTEND_EDGE_CUT_OPT, ALTERNATIVE_EDGE_CUT_OPT, EXTRA_LAYERS, \ + ALL_ACTIVE_LAYERS_OPT, PN_COLUMN from .utils import load_user_options, save_user_options, get_layer_names @@ -19,7 +21,7 @@ def __init__(self): pos=wx.DefaultPosition, size=wx.DefaultSize, style=wx.DEFAULT_DIALOG_STYLE) - + # self.app = wx.PySimpleApp() icon = wx.Icon(os.path.join(os.path.dirname(__file__), 'icon.png')) self.SetIcon(icon) @@ -34,7 +36,8 @@ def __init__(self): ALTERNATIVE_EDGE_CUT_OPT: False, AUTO_TRANSLATE_OPT: True, AUTO_FILL_OPT: True, - EXCLUDE_DNP_OPT: False + EXCLUDE_DNP_OPT: False, + PN_COLUMN: "LCSC Part #" }) self.mOptionsLabel = wx.StaticText(self, label='Options:') @@ -58,6 +61,10 @@ def __init__(self): self.mAutomaticFillCheckbox.SetValue(userOptions[AUTO_FILL_OPT]) self.mExcludeDnpCheckbox = wx.CheckBox(self, label='Exclude DNP components from BOM') self.mExcludeDnpCheckbox.SetValue(userOptions[EXCLUDE_DNP_OPT]) + self.mPNColStaticText = wx.StaticText(self, label="MPN column in bom.csv:") + self.mPNColumnListbox = wx.ListBox(self, style=wx.LB_SINGLE, choices=["LCSC Part #", "Comment"]) + index = self.mPNColumnListbox.FindString(userOptions[PN_COLUMN]) + self.mPNColumnListbox.SetSelection(index) self.mGaugeStatus = wx.Gauge( self, wx.ID_ANY, 100, wx.DefaultPosition, wx.Size(600, 20), wx.GA_HORIZONTAL) @@ -78,6 +85,8 @@ def __init__(self): boxSizer.Add(self.mAutomaticTranslationCheckbox, 0, wx.ALL, 5) boxSizer.Add(self.mAutomaticFillCheckbox, 0, wx.ALL, 5) boxSizer.Add(self.mExcludeDnpCheckbox, 0, wx.ALL, 5) + boxSizer.Add(self.mPNColStaticText, 0, wx.ALL | wx.ALIGN_LEFT, 5) + boxSizer.Add(self.mPNColumnListbox, 0, wx.ALL | wx.ALIGN_LEFT | wx.EXPAND, 5) boxSizer.Add(self.mGaugeStatus, 0, wx.ALL, 5) boxSizer.Add(self.mGenerateButton, 0, wx.ALL, 5) @@ -107,7 +116,8 @@ def onGenerateButtonClick(self, event): options[AUTO_TRANSLATE_OPT] = self.mAutomaticTranslationCheckbox.GetValue() options[AUTO_FILL_OPT] = self.mAutomaticFillCheckbox.GetValue() options[EXCLUDE_DNP_OPT] = self.mExcludeDnpCheckbox.GetValue() - + index = self.mPNColumnListbox.GetSelection() + options[PN_COLUMN] = self.mPNColumnListbox.GetString(index) save_user_options(options) self.mOptionsLabel.Hide() @@ -118,6 +128,8 @@ def onGenerateButtonClick(self, event): self.mAutomaticTranslationCheckbox.Hide() self.mAutomaticFillCheckbox.Hide() self.mExcludeDnpCheckbox.Hide() + self.mPNColStaticText.Hide(); + self.mPNColumnListbox.Hide() self.mGenerateButton.Hide() self.mGaugeStatus.Show() diff --git a/plugins/process.py b/plugins/process.py index 91dfb7d..25546d5 100644 --- a/plugins/process.py +++ b/plugins/process.py @@ -67,7 +67,7 @@ def generate_gerber(self, temp_dir, extra_layers, extend_edge_cuts, alternative_ plot_options.SetSubtractMaskFromSilk(True) plot_options.SetUseGerberX2format(False) plot_options.SetDrillMarksType(0) # NO_DRILL_SHAPE - + if hasattr(plot_options, "SetExcludeEdgeLayer"): plot_options.SetExcludeEdgeLayer(True) @@ -117,7 +117,7 @@ def generate_netlist(self, temp_dir): netlist_writer = pcbnew.IPC356D_WRITER(self.board) netlist_writer.Write(os.path.join(temp_dir, netlistFileName)) - def _get_footprint_position(self, footprint): + def _get_footprint_position(self, footprint): """Calculate position based on center of pads / bounding box.""" origin_type = self._get_origin_from_footprint(footprint) @@ -133,10 +133,10 @@ def _get_footprint_position(self, footprint): position = bbox.GetCenter() else: position = footprint.GetPosition() # if we have no pads we fallback to anchor - + return position - def generate_tables(self, temp_dir, auto_translate, exclude_dnp): + def generate_tables(self, temp_dir, auto_translate, exclude_dnp, pnColumn): '''Generate the data tables.''' if hasattr(self.board, 'GetModules'): footprints = list(self.board.GetModules()) @@ -158,6 +158,8 @@ def generate_tables(self, temp_dir, auto_translate, exclude_dnp): for key, value in footprint_designators.items(): f.write('%s:%s\n' % (key, value)) + part_column_name = pnColumn + for i, footprint in enumerate(footprints): try: footprint_name = str(footprint.GetFPID().GetFootprintName()) @@ -172,8 +174,8 @@ def generate_tables(self, temp_dir, auto_translate, exclude_dnp): # 2: 'unspecified' # }.get(footprint.GetAttributes()) - is_dnp = (footprint_has_field(footprint, 'dnp') - or (footprint.GetValue().upper() == 'DNP') + is_dnp = (footprint_has_field(footprint, 'dnp') + or (footprint.GetValue().upper() == 'DNP') or getattr(footprint, 'IsDNP', bool)()) skip_dnp = exclude_dnp and is_dnp @@ -236,7 +238,7 @@ def generate_tables(self, temp_dir, auto_translate, exclude_dnp): for component in self.bom: same_footprint = component['Footprint'] == self._normalize_footprint_name(footprint_name) same_value = component['Value'].upper() == footprint.GetValue().upper() - same_lcsc = component['LCSC Part #'] == self._get_mpn_from_footprint(footprint) + same_lcsc = component[part_column_name] == self._get_mpn_from_footprint(footprint) under_limit = component['Quantity'] < bomRowLimit if same_footprint and same_value and same_lcsc and under_limit: @@ -247,14 +249,15 @@ def generate_tables(self, temp_dir, auto_translate, exclude_dnp): # add component to BOM if insert: - self.bom.append({ + fields = { 'Designator': "{}{}{}".format(footprint.GetReference().upper(), "" if unique_id == "" else "_", unique_id), 'Footprint': self._normalize_footprint_name(footprint_name), 'Quantity': 1, 'Value': footprint.GetValue(), # 'Mount': mount_type, - 'LCSC Part #': self._get_mpn_from_footprint(footprint), - }) + part_column_name: self._get_mpn_from_footprint(footprint), + } + self.bom.append(fields) def generate_positions(self, temp_dir): '''Generate the position file.''' @@ -294,7 +297,7 @@ def generate_archive(self, temp_dir, temp_file): os.remove(os.path.join(temp_dir, item)) return temp_file - + """ Private """ def __read_rotation_db(self, filename: str = os.path.join(os.path.dirname(__file__), 'transformations.csv')) -> dict[str, float]: @@ -352,7 +355,7 @@ def __read_rotation_db(self, filename: str = os.path.join(os.path.dirname(__file db[rowNum]['name'] = row['footprint'] db[rowNum]['rotation'] = rotation db[rowNum]['x'] = delta_x - db[rowNum]['y'] = delta_y + db[rowNum]['y'] = delta_y return db @@ -405,7 +408,7 @@ def _get_position_offset_from_db(self, footprint: str) -> Tuple[float, float]: return (0.0, 0.0) def _get_mpn_from_footprint(self, footprint) -> str: - ''''Get the MPN/LCSC stock code from standard symbol fields.''' + '''Get the MPN/LCSC stock code from standard symbol fields.''' keys = ['LCSC Part #', 'LCSC Part', 'JLCPCB Part #', 'JLCPCB Part'] fallback_keys = ['LCSC', 'JLC', 'MPN', 'Mpn', 'mpn'] @@ -479,7 +482,7 @@ def _get_position_offset_from_footprint(self, footprint) -> Tuple[float, float]: return (float(offset[0]), float(offset[1])) except Exception as e: raise RuntimeError("Position offset of {} is not a valid pair of numbers".format(footprint.GetReference())) - + def _get_origin_from_footprint(self, footprint) -> float: '''Get the origin from standard symbol fields.''' keys = ['FT Origin'] @@ -490,7 +493,7 @@ def _get_origin_from_footprint(self, footprint) -> float: # determine origin type by package type if attributes & pcbnew.FP_SMD: origin_type = 'Anchor' - else: + else: origin_type = 'Center' for key in keys + fallback_keys: diff --git a/plugins/thread.py b/plugins/thread.py index 14769d1..1b3d6e4 100644 --- a/plugins/thread.py +++ b/plugins/thread.py @@ -18,11 +18,11 @@ class ProcessThread(Thread): def __init__(self, wx, options, cli = None, openBrowser = True): Thread.__init__(self) - # prevent use of cli and grapgical mode at the same time + # prevent use of cli and graphical mode at the same time if (wx is None and cli is None) or (wx is not None and cli is not None): logging.error("Specify either graphical or cli use!") return - + if cli is not None: try: self.board = pcbnew.LoadBoard(cli) @@ -31,7 +31,7 @@ def __init__(self, wx, options, cli = None, openBrowser = True): return else: self.board = None - + self.process_manager = ProcessManager(self.board) self.wx = wx self.cli = cli @@ -71,7 +71,9 @@ def run(self): # generate data tables self.progress(50) - self.process_manager.generate_tables(temp_dir, self.options[AUTO_TRANSLATE_OPT], self.options[EXCLUDE_DNP_OPT]) + self.process_manager.generate_tables( + temp_dir, self.options[AUTO_TRANSLATE_OPT], self.options[EXCLUDE_DNP_OPT], + self.options[PN_COLUMN]) # generate pick and place file self.progress(60) @@ -126,7 +128,7 @@ def run(self): output_path = os.path.join(project_directory, outputFolder) if not os.path.exists(output_path): os.makedirs(output_path) - + # rename gerber archive gerberArchiveName = ProcessManager.normalize_filename("_".join(("{} {}".format(title or filename, revision or '').strip() + '.zip').split())) os.rename(temp_file, os.path.join(temp_dir, gerberArchiveName)) @@ -146,7 +148,7 @@ def run(self): if self.openBrowser: webbrowser.open("file://%s" % (temp_dir)) - if self.wx is None: + if self.wx is None: self.progress(100) else: self.progress(-1)