From dd23fd88aeeb17410a97b9f50d5a50dec2018d0e Mon Sep 17 00:00:00 2001 From: Manfred Steiner Date: Tue, 27 Aug 2024 19:21:15 +0200 Subject: [PATCH] JLCPCB plugin bugfix --- kicad/dist/v2a/README.md | 5 +- .../fabrication.py | 368 ++++++++++++++++++ 2 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 kicad/dist/v2a/plugins/com_github_bouni_kicad-jlcpcb-tool/fabrication.py diff --git a/kicad/dist/v2a/README.md b/kicad/dist/v2a/README.md index 1640df2..8a073ec 100644 --- a/kicad/dist/v2a/README.md +++ b/kicad/dist/v2a/README.md @@ -5,7 +5,10 @@ Letze Aktualisierung: SX / ? ## Überblick * Produktionsfreigabe (Branch/Commit): xx.x.x (v2a/?) -* KiCad v8.0.4(rc) / Plugin JLCPCB Tools v2024.06.01 (bugs fixed) +* KiCad v8.0.4(rc) +* KiCad Plugin JLCPCB Tools v2024.06.01 (bugs fixed) + [https://github.com/ms270169/kicad-jlcpcb-tools/commit/4ee8e2d](https://github.com/ms270169/kicad-jlcpcb-tools/commit/4ee8e2de78bbc91fa503ecbf587e590d3963f5c8) ) + Modified file: [fabrication.py](plugins/com_github_bouni_kicad-jlcpcb-tool/fabrication.py) * Schaltung (Farbe): [nano-x-base_v2a_schematic.pdf](nano-x-base_v2a_schematic.pdf) * Schaltung (Schwarz/Weiß): [nano-x-base_v2a_schematic-bw.pdf](nano-x-base_v2a_schematic-bw.pdf) * Bild vorne: [nano-x-base_v2a_image-front.png](nano-x-base_v2a_image-front.png) diff --git a/kicad/dist/v2a/plugins/com_github_bouni_kicad-jlcpcb-tool/fabrication.py b/kicad/dist/v2a/plugins/com_github_bouni_kicad-jlcpcb-tool/fabrication.py new file mode 100644 index 0000000..141a156 --- /dev/null +++ b/kicad/dist/v2a/plugins/com_github_bouni_kicad-jlcpcb-tool/fabrication.py @@ -0,0 +1,368 @@ +"""Handles the generation of the Gerber files, the BOM and the POS file.""" + +import inspect +import csv +import logging +import os +from pathlib import Path +import re +from zipfile import ZIP_DEFLATED, ZipFile + +from pcbnew import ( # pylint: disable=import-error + EXCELLON_WRITER, + PCB_PLOT_PARAMS, + PLOT_CONTROLLER, + PLOT_FORMAT_GERBER, + ZONE_FILLER, + B_Cu, + B_Mask, + B_Paste, + B_SilkS, + Cmts_User, + Edge_Cuts, + F_Cu, + F_Mask, + F_Paste, + F_SilkS, + Refresh, + ToMM, +) + +# Compatibility hack for V6 / V7 / V7.99 +try: + from pcbnew import DRILL_MARKS_NO_DRILL_SHAPE # pylint: disable=import-error + + NO_DRILL_SHAPE = DRILL_MARKS_NO_DRILL_SHAPE +except ImportError: + NO_DRILL_SHAPE = PCB_PLOT_PARAMS.NO_DRILL_SHAPE + + + +class Fabrication: + """Contains all functionality to generate the JLCPCB production files.""" + + def __init__(self, parent, board): + self.parent = parent + self.logger = logging.getLogger(__name__) + self.board = board + self.corrections = [] + self.path, self.filename = os.path.split(self.board.GetFileName()) + self.create_folders() + + def create_folders(self): + """Create output folders if they not already exist.""" + self.outputdir = os.path.join(self.path, "jlcpcb", "production_files") + Path(self.outputdir).mkdir(parents=True, exist_ok=True) + self.gerberdir = os.path.join(self.path, "jlcpcb", "gerber") + Path(self.gerberdir).mkdir(parents=True, exist_ok=True) + + def fill_zones(self): + """Refill copper zones following user prompt.""" + if self.parent.settings.get("gerber", {}).get("fill_zones", True): + filler = ZONE_FILLER(self.board) + zones = self.board.Zones() + filler.Fill(zones) + Refresh() + + def fix_rotation(self, footprint): + """Fix the rotation of footprints in order to be correct for JLCPCB.""" + original = footprint.GetOrientation() + # `.AsDegrees()` added in KiCAD 6.99 + try: + rotation = original.AsDegrees() + except AttributeError: + # we need to divide by 10 to get 180 out of 1800 for example. + # This might be a bug in 5.99 / 6.0 RC + rotation = original / 10 + if footprint.GetLayer() != 0: + # bottom angles need to be mirrored on Y-axis + rotation = (180 - rotation) % 360 + # First check if the value aka part name matches + for regex, correction in self.corrections: + if re.search(regex, str(footprint.GetValue())): + return self.rotate(footprint, rotation, correction) + # Then if the package matches + for regex, correction in self.corrections: + if re.search(regex, str(footprint.GetFPID().GetLibItemName())): + return self.rotate(footprint, rotation, correction) + # If no correction matches, return the original rotation + return rotation + + def rotate(self, footprint, rotation, correction): + """Calculate the actual correction.""" + rotation = (rotation + int(correction)) % 360 + self.logger.info( + "Fixed rotation of %s (%s / %s) on Top Layer by %d degrees", + footprint.GetReference(), + footprint.GetValue(), + footprint.GetFPID().GetLibItemName(), + correction, + ) + return rotation + + def get_position(self, footprint): + """Calculate position based on center of bounding box.""" + try: + usexy = footprint.GetFieldByName("JLCP-USE-XY") + if usexy is not None and usexy.GetText().lower() == "true": + print("Footprint %s: field 'JLCP-USE-XY' == '%s' -> skip location by pad" %( footprint.GetReference(), usexy.GetText())) + raise Exception() + pads = footprint.Pads() + bbox = pads[0].GetBoundingBox() + for pad in pads: + bbox.Merge(pad.GetBoundingBox()) + #self.logger.info(" => %s", bbox.GetCenter()) + return bbox.GetCenter() + except: + self.logger.info("WARNING footprint %s: original position used", footprint.GetReference()) + return footprint.GetPosition() + + def generate_geber(self, layer_count=None): + """Generate Gerber files.""" + # inspired by https://github.com/KiCad/kicad-source-mirror/blob/master/demos/python_scripts_examples/gen_gerber_and_drill_files_board.py + pctl = PLOT_CONTROLLER(self.board) + popt = pctl.GetPlotOptions() + + # https://github.com/KiCad/kicad-source-mirror/blob/master/pcbnew/pcb_plot_params.h + popt.SetOutputDirectory(self.gerberdir) + + # Plot format to Gerber + # https://github.com/KiCad/kicad-source-mirror/blob/master/include/plotter.h#L67-L78 + popt.SetFormat(1) + + # General Options + popt.SetPlotValue( + self.parent.settings.get("gerber", {}).get("plot_values", True) + ) + popt.SetPlotReference( + self.parent.settings.get("gerber", {}).get("plot_references", True) + ) + popt.SetPlotInvisibleText(False) + + popt.SetSketchPadsOnFabLayers(False) + + # Gerber Options + popt.SetUseGerberProtelExtensions(False) + + popt.SetCreateGerberJobFile(False) + + popt.SetSubtractMaskFromSilk(True) + + popt.SetPlotViaOnMaskLayer(False) # Set this to True if you need untented vias + + popt.SetUseAuxOrigin(True) + + # Tented vias or not, selcted by user in settings + popt.SetPlotViaOnMaskLayer( + not self.parent.settings.get("gerber", {}).get("tented_vias", True) + ) + + popt.SetUseGerberX2format(True) + + popt.SetIncludeGerberNetlistInfo(True) + + popt.SetDisableGerberMacros(False) + + popt.SetDrillMarksType(NO_DRILL_SHAPE) + + popt.SetPlotFrameRef(False) + + # delete all existing files in the output directory first + for f in os.listdir(self.gerberdir): + os.remove(os.path.join(self.gerberdir, f)) + + # if no layer_count is given, get the layer count from the board + if not layer_count: + layer_count = self.board.GetCopperLayerCount() + + plot_plan_top = [ + ("CuTop", F_Cu, "Top layer"), + ("SilkTop", F_SilkS, "Silk top"), + ("MaskTop", F_Mask, "Mask top"), + ("PasteTop", F_Paste, "Paste top"), + ] + plot_plan_bottom = [ + ("CuBottom", B_Cu, "Bottom layer"), + ("SilkBottom", B_SilkS, "Silk top"), + ("MaskBottom", B_Mask, "Mask bottom"), + ("PasteBottom", B_Paste, "Paste bottom"), + ("EdgeCuts", Edge_Cuts, "Edges"), + ("VScore", Cmts_User, "V score cut"), + ] + + plot_plan = [] + + # Single sided PCB + if layer_count == 1: + plot_plan = plot_plan_top + plot_plan_bottom[-2:] + # Double sided PCB + elif layer_count == 2: + plot_plan = plot_plan_top + plot_plan_bottom + # Everything with inner layers + else: + plot_plan = ( + plot_plan_top + + [ + (f"CuIn{layer}", layer, f"Inner layer {layer}") + for layer in range(1, layer_count - 1) + ] + + plot_plan_bottom + ) + + for layer_info in plot_plan: + if layer_info[1] <= B_Cu: + popt.SetSkipPlotNPTH_Pads(True) + else: + popt.SetSkipPlotNPTH_Pads(False) + pctl.SetLayer(layer_info[1]) + pctl.OpenPlotfile(layer_info[0], PLOT_FORMAT_GERBER, layer_info[2]) + if pctl.PlotLayer() is False: + self.logger.error("Error plotting %s", layer_info[2]) + self.logger.info("Successfully plotted %s", layer_info[2]) + pctl.ClosePlot() + + def generate_excellon(self): + """Generate Excellon files.""" + drlwriter = EXCELLON_WRITER(self.board) + mirror = False + minimalHeader = False + offset = self.board.GetDesignSettings().GetAuxOrigin() + mergeNPTH = False + drlwriter.SetOptions(mirror, minimalHeader, offset, mergeNPTH) + drlwriter.SetFormat(False) + genDrl = True + genMap = True + drlwriter.CreateDrillandMapFilesSet(self.gerberdir, genDrl, genMap) + self.logger.info("Finished generating Excellon files") + + def zip_gerber_excellon(self): + """Zip Gerber and Excellon files, ready for upload to JLCPCB.""" + zipname = f"GERBER-{Path(self.filename).stem}.zip" + with ZipFile( + os.path.join(self.outputdir, zipname), + "w", + compression=ZIP_DEFLATED, + compresslevel=9, + ) as zipfile: + for folderName, _, filenames in os.walk(self.gerberdir): + for filename in filenames: + if not filename.endswith(("gbr", "drl", "pdf")): + continue + filePath = os.path.join(folderName, filename) + zipfile.write(filePath, os.path.basename(filePath)) + self.logger.info("Finished generating ZIP file %s", os.path.join(self.outputdir, zipname)) + + def generate_cpl(self): + """Generate placement file (CPL).""" + self.logger.info("===> generate_cpl()") + cplname = f"CPL-{Path(self.filename).stem}.csv" + self.corrections = self.parent.library.get_all_correction_data() + aux_orgin = self.board.GetDesignSettings().GetAuxOrigin() + add_without_lcsc = self.parent.settings.get("gerber", {}).get( + "lcsc_bom_cpl", True + ) + with open( + os.path.join(self.outputdir, cplname), "w", newline="", encoding="utf-8" + ) as csvfile: + writer = csv.writer(csvfile, delimiter=",") + writer.writerow( + ["Designator", "Val", "Package", "Mid X", "Mid Y", "Rotation", "Layer"] + ) + footprints = sorted(self.board.Footprints(), key = lambda x: x.GetReference()) + for fp in footprints: + try: + part = self.parent.store.get_part(fp.GetReference()) + self.logger.info(" -> Part %s -> %s", fp.GetReference(), part) + if not part: # No matching part in the database, continue + continue + if part[6] == 1: # Exclude from POS + continue + if not add_without_lcsc and not part[3]: + continue + position = self.get_position(fp) - aux_orgin + writer.writerow( + [ + part[0], + part[1], + part[2], + ToMM(position.x), + ToMM(position.y) * -1, + self.fix_rotation(fp), + "top" if fp.GetLayer() == 0 else "bottom", + ] + ) + except Exception as error: + self.logger.info("ERROR -> %s %s", type(error).__name__, error) + self.logger.info(" error file info: %s", error.__traceback__.tb_frame) + self.logger.info(" error line#: %s", error.__traceback__.tb_lineno) + self.logger.info(" position: %s", fp.GetPosition()) + for i in inspect.getmembers(fp): + # Ignores anything starting with underscore + # (that is, private and protected attributes) + if not i[0].startswith('_'): + # Ignores methods + if not inspect.ismethod(i[1]): + self.logger.info(" property ==>>> %s", i) + else: + self.logger.info(" method ==>>> %s", i) + except: + self.logger.info("ERROR ?") + + self.logger.info("Finished generating CPL file %s", os.path.join(self.outputdir, cplname)) + + + def generate_bom(self): + """Generate BOM file.""" + bomname = f"BOM-{Path(self.filename).stem}.csv" + add_without_lcsc = self.parent.settings.get("gerber", {}).get( + "lcsc_bom_cpl", True + ) + with open( + os.path.join(self.outputdir, bomname), "w", newline="", encoding="utf-8" + ) as csvfile: + for fp in self.board.Footprints(): + self.logger.info(" -> Footprint Part %s", fp.GetReference()) + for part in self.parent.store.read_bom_parts(): + self.logger.info(" -> BOM part[1] %s", part[1]) + writer = csv.writer(csvfile, delimiter=",") + writer.writerow(["Comment", "Designator", "Footprint", "LCSC"]) + for part in self.parent.store.read_bom_parts(): + components = part[1].split(",") + nextComponents = components.copy() + for component in components: + for fp in self.board.Footprints(): + if fp.GetReference() == component: + if fp.IsDNP(): + nextComponents.remove(component) + part[1] = ','.join(nextComponents) + self.logger.info("Component %s has 'Do not placed' enabled: removing from BOM", component) + + #if component == 'J1': + # for i in inspect.getmembers(fp): + # # Ignores anything starting with underscore + # # (that is, private and protected attributes) + # if not i[0].startswith('_'): + # # Ignores methods + # if not inspect.ismethod(i[1]): + # self.logger.info(" property ==>>> %s", i) + # else: + # self.logger.info(" method ==>>> %s", i) + #for f in fp.GetFields(): + # self.logger.info("Component %s field: %s", component, f.GetName()) + # for a in fp.GetAttributes(): + # self.logger.info("Component %s attribute: %s", component, a.GetName()) + + # for m in inspect.getmembers(f): + # # Ignores anything starting with underscore + # # (that is, private and protected attributes) + # if not m[0].startswith('_'): + # # Ignores methods + # if not inspect.ismethod(m[1]): + # self.logger.info(" property ==>>> %s", m) + # else: + # self.logger.info(" method ==>>> %s", m) + + if not add_without_lcsc and not part[3]: + continue + writer.writerow(part) + self.logger.info("Finished generating BOM file %s", os.path.join(self.outputdir, bomname)) -- 2.39.5