Commit dd23fd88aeeb17410a97b9f50d5a50dec2018d0e
receivedTue, 27. Aug 2024, 19:21:19 (by user sx)
Tue, 27 Aug 2024 17:21:19 +0000 (19:21 +0200)
authorManfred Steiner <sx@htl-kaindorf.at>
Tue, 27 Aug 2024 17:21:15 +0000 (19:21 +0200)
committerManfred Steiner <sx@htl-kaindorf.at>
Tue, 27 Aug 2024 17:21:15 +0000 (19:21 +0200)
2 files changed:
kicad/dist/v2a/README.md
kicad/dist/v2a/plugins/com_github_bouni_kicad-jlcpcb-tool/fabrication.py [new file with mode: 0644]

index 1640df26457160d0814c839103e48c62f298170d..8a073eccf1bff5043ac46146d3a869082e8ed964 100644 (file)
@@ -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 (file)
index 0000000..141a156
--- /dev/null
@@ -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))