diff --git a/.DS_Store b/.DS_Store index d8411a0..247f130 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/3rdparty/.DS_Store b/3rdparty/.DS_Store index af3b751..c02f363 100644 Binary files a/3rdparty/.DS_Store and b/3rdparty/.DS_Store differ diff --git a/3rdparty/plugins/.DS_Store b/3rdparty/plugins/.DS_Store index 8e2ddc7..d888796 100644 Binary files a/3rdparty/plugins/.DS_Store and b/3rdparty/plugins/.DS_Store differ diff --git a/3rdparty/plugins/com_github_30350n_pcb2blender/__init__.py b/3rdparty/plugins/com_github_30350n_pcb2blender/__init__.py new file mode 100644 index 0000000..3c3cc84 --- /dev/null +++ b/3rdparty/plugins/com_github_30350n_pcb2blender/__init__.py @@ -0,0 +1,24 @@ +from pathlib import Path + +import pcbnew, wx + +from .export import export_pcb3d, get_boarddefs +from .ui import SettingsDialog + +class Pcb2BlenderExporter(pcbnew.ActionPlugin): + def defaults(self): + self.name = "Export to Blender (.pcb3d)" + self.category = "Export" + self.show_toolbar_button = True + self.icon_file_name = ( + Path(__file__).parent / "images" / "blender_icon_32x32.png").as_posix() + self.description = "Export 3D Model to Blender." + + def Run(self): + board = pcbnew.GetBoard() + boarddefs, ignored = get_boarddefs(board) + with SettingsDialog(None, boarddefs, ignored) as dialog: + if dialog.ShowModal() == wx.OK: + export_pcb3d(dialog.file_picker.GetPath(), boarddefs) + +Pcb2BlenderExporter().register() diff --git a/3rdparty/plugins/com_github_30350n_pcb2blender/export.py b/3rdparty/plugins/com_github_30350n_pcb2blender/export.py new file mode 100644 index 0000000..c1fddd7 --- /dev/null +++ b/3rdparty/plugins/com_github_30350n_pcb2blender/export.py @@ -0,0 +1,319 @@ +import re, shutil, struct, tempfile +from dataclasses import dataclass, field +from enum import IntEnum +from pathlib import Path +from typing import List, Tuple +from zipfile import ZIP_DEFLATED, ZipFile + +import pcbnew +from pcbnew import DRILL_MARKS_NO_DRILL_SHAPE, PLOT_CONTROLLER, PLOT_FORMAT_SVG, ToMM + +PCB = "pcb.wrl" +COMPONENTS = "components" +LAYERS = "layers" +LAYERS_BOUNDS = "bounds" +LAYERS_STACKUP = "stackup" +BOARDS = "boards" +BOUNDS = "bounds" +STACKED = "stacked_" +PADS = "pads" + +INCLUDED_LAYERS = ( + "F_Cu", "B_Cu", "F_Paste", "B_Paste", "F_SilkS", "B_SilkS", "F_Mask", "B_Mask" +) + +SVG_MARGIN = 1.0 # mm + +@dataclass +class StackedBoard: + name: str + offset: Tuple[float, float, float] + +@dataclass +class BoardDef: + name: str + bounds: Tuple[float, float, float, float] + stacked_boards: List[StackedBoard] = field(default_factory=list) + +class KiCadColor(IntEnum): + CUSTOM = 0 + GREEN = 1 + RED = 2 + BLUE = 3 + PURPLE = 4 + BLACK = 5 + WHITE = 6 + YELLOW = 7 + +class SurfaceFinish(IntEnum): + HASL = 0 + ENIG = 1 + NONE = 2 + +SURFACE_FINISH_MAP = { + "ENIG": SurfaceFinish.ENIG, + "ENEPIG": SurfaceFinish.ENIG, + "Hard gold": SurfaceFinish.ENIG, + "HT_OSP": SurfaceFinish.NONE, + "OSP": SurfaceFinish.NONE, +} + +@dataclass +class Stackup: + thickness_mm: float = 1.6 + mask_color: KiCadColor = KiCadColor.GREEN + mask_color_custom: Tuple[int, int, int] = (0, 0, 0) + silks_color: KiCadColor = KiCadColor.WHITE + silks_color_custom: Tuple[int, int, int] = (0, 0, 0) + surface_finish: SurfaceFinish = SurfaceFinish.HASL + + def pack(self) -> bytes: + return struct.pack("!fbBBBbBBBb", + self.thickness_mm, + self.mask_color, *self.mask_color_custom, + self.silks_color, *self.silks_color_custom, + self.surface_finish, + ) + +def export_pcb3d(filepath, boarddefs): + init_tempdir() + + wrl_path = get_temppath(PCB) + components_path = get_temppath(COMPONENTS) + pcbnew.ExportVRML(wrl_path, 0.001, True, True, components_path, 0.0, 0.0) + + layers_path = get_temppath(LAYERS) + board = pcbnew.GetBoard() + box = board.ComputeBoundingBox(aBoardEdgesOnly=True) + bounds = ( + ToMM(box.GetLeft()) - SVG_MARGIN, ToMM(box.GetTop()) - SVG_MARGIN, + ToMM(box.GetRight() - box.GetLeft()) + SVG_MARGIN * 2, + ToMM(box.GetBottom() - box.GetTop()) + SVG_MARGIN * 2, + ) + export_layers(board, bounds, layers_path) + + with ZipFile(filepath, mode="w", compression=ZIP_DEFLATED) as file: + # always ensure the COMPONENTS, LAYERS and BOARDS directories are created + file.writestr(f"{COMPONENTS}/", "") + file.writestr(f"{LAYERS}/", "") + file.writestr(f"{BOARDS}/", "") + + file.write(wrl_path, PCB) + for path in components_path.glob("**/*.wrl"): + file.write(path, f"{COMPONENTS}/{path.name}") + + for path in layers_path.glob("**/*.svg"): + file.write(path, f"{LAYERS}/{path.name}") + file.writestr(f"{LAYERS}/{LAYERS_BOUNDS}", struct.pack("!ffff", *bounds)) + file.writestr(f"{LAYERS}/{LAYERS_STACKUP}", get_stackup(board).pack()) + + for boarddef in boarddefs.values(): + subdir = f"{BOARDS}/{boarddef.name}" + file.writestr(f"{subdir}/{BOUNDS}", struct.pack("!ffff", *boarddef.bounds)) + + for stacked in boarddef.stacked_boards: + file.writestr( + f"{subdir}/{STACKED}{stacked.name}", + struct.pack("!fff", *stacked.offset) + ) + + for i, footprint in enumerate(board.Footprints()): + has_model = len(footprint.Models()) > 0 + is_tht_or_smd = bool( + footprint.GetAttributes() & (pcbnew.FP_THROUGH_HOLE | pcbnew.FP_SMD)) + value = footprint.GetValue() + reference = footprint.GetReference() + for j, pad in enumerate(footprint.Pads()): + name = sanitized(f"{value}_{reference}_{i}_{j}") + is_flipped = pad.IsFlipped() + has_paste = pad.IsOnLayer(pcbnew.B_Paste if is_flipped else pcbnew.F_Paste) + data = struct.pack( + "!ff????BBffffBff", + *map(ToMM, pad.GetPosition()), + is_flipped, + has_model, + is_tht_or_smd, + has_paste, + pad.GetAttribute(), + pad.GetShape(), + *map(ToMM, pad.GetSize()), + pad.GetOrientation().AsRadians(), + pad.GetRoundRectRadiusRatio(), + pad.GetDrillShape(), + *map(ToMM, pad.GetDrillSize()), + ) + file.writestr(f"{PADS}/{name}", data) + +def get_boarddefs(board): + boarddefs = {} + ignored = [] + + tls = {} + brs = {} + stacks = {} + for drawing in board.GetDrawings(): + if drawing.Type() == pcbnew.PCB_TEXT_T: + text_obj = drawing.Cast() + text = text_obj.GetText() + + if not text.startswith("PCB3D_"): + continue + + pos = tuple(map(ToMM, text_obj.GetPosition())) + if text.startswith("PCB3D_TL_"): + tls.setdefault(text, pos) + elif text.startswith("PCB3D_BR_"): + brs.setdefault(text, pos) + elif text.startswith("PCB3D_STACK_"): + stacks.setdefault(text, pos) + else: + ignored.append(text) + + for tl_str in tls.copy(): + name = tl_str[9:] + br_str = "PCB3D_BR_" + name + if br_str in brs: + tl_pos = tls.pop(tl_str) + br_pos = brs.pop(br_str) + + boarddef = BoardDef( + sanitized(name), + (tl_pos[0], tl_pos[1], br_pos[0] - tl_pos[0], br_pos[1] - tl_pos[1]) + ) + boarddefs[boarddef.name] = boarddef + + for stack_str in stacks.copy(): + try: + other, onto, target, z_offset = stack_str[12:].split("_") + z_offset = float(z_offset) + except ValueError: + continue + + if onto != "ONTO": + continue + + other_name = sanitized(other) + target_name = sanitized(target) + + if other_name not in set(boarddefs) | {"FPNL"} or target_name not in boarddefs: + continue + + stack_pos = stacks.pop(stack_str) + target_pos = boarddefs[target_name].bounds[:2] + stacked = StackedBoard( + other_name, + (stack_pos[0] - target_pos[0], stack_pos[1] - target_pos[1], z_offset) + ) + boarddefs[target_name].stacked_boards.append(stacked) + + ignored += list(tls.keys()) + list(brs.keys()) + list(stacks.keys()) + + return boarddefs, ignored + +def get_stackup(board): + stackup = Stackup() + + tmp_path = get_temppath("pcb2blender_tmp.kicad_pcb") + board.Save(str(tmp_path)) + content = tmp_path.read_text(encoding="utf-8") + + if not (match := stackup_regex.search(content)): + return stackup + stackup_content = match.group(0) + + if matches := stackup_thickness_regex.finditer(stackup_content): + stackup.thickness_mm = sum(float(match.group(1)) for match in matches) + + if match := stackup_mask_regex.search(stackup_content): + stackup.mask_color, stackup.mask_color_custom = parse_kicad_color(match.group(1)) + + if match := stackup_silks_regex.search(stackup_content): + stackup.silks_color, stackup.silks_color_custom = parse_kicad_color(match.group(1)) + + if match := stackup_copper_finish_regex.search(stackup_content): + stackup.surface_finish = SURFACE_FINISH_MAP.get(match.group(1), SurfaceFinish.HASL) + + return stackup + +def parse_kicad_color(string): + if string[0] == "#": + return KiCadColor.CUSTOM, hex2rgb(string[1:7]) + else: + return KiCadColor[string.upper()], (0, 0, 0) + +def export_layers(board, bounds, output_directory: Path): + plot_controller = PLOT_CONTROLLER(board) + plot_options = plot_controller.GetPlotOptions() + plot_options.SetOutputDirectory(output_directory) + + plot_options.SetPlotFrameRef(False) + plot_options.SetAutoScale(False) + plot_options.SetScale(1) + plot_options.SetMirror(False) + plot_options.SetUseGerberAttributes(True) + plot_options.SetDrillMarksType(DRILL_MARKS_NO_DRILL_SHAPE) + + for layer in INCLUDED_LAYERS: + plot_controller.SetLayer(getattr(pcbnew, layer)) + plot_controller.OpenPlotfile(layer, PLOT_FORMAT_SVG, "") + plot_controller.PlotLayer() + filepath = Path(plot_controller.GetPlotFileName()) + plot_controller.ClosePlot() + filepath = filepath.replace(filepath.parent / f"{layer}.svg") + + content = filepath.read_text(encoding="utf-8") + width = f"{bounds[2]:.6f}mm" + height = f"{bounds[3]:.6f}mm" + viewBox = " ".join(f"{value:.6f}" for value in bounds) + content = svg_header_regex.sub(svg_header_sub.format(width, height, viewBox), content) + filepath.write_text(content, encoding="utf-8") + +def sanitized(name): + return re.sub(r"[\W]+", "_", name) + +def get_tempdir(): + return Path(tempfile.gettempdir()) / "pcb2blender_tmp" + +def get_temppath(filename): + return get_tempdir() / filename + +def init_tempdir(): + tempdir = get_tempdir() + if tempdir.exists(): + try: + shutil.rmtree(tempdir) + except OSError: + try: + # try to delete all files first + for file in tempdir.glob("**/*"): + if file.is_file(): + file.unlink() + shutil.rmtree(tempdir) + except OSError: + # if this still doesn't work, fuck it + return + tempdir.mkdir() + +def hex2rgb(hex_string): + return ( + int(hex_string[0:2], 16), + int(hex_string[2:4], 16), + int(hex_string[4:6], 16), + ) + +svg_header_regex = re.compile( + r"]*)width=\"[^\"]*\"[^>]*height=\"[^\"]*\"[^>]*viewBox=\"[^\"]*\"[^>]*>" +) +svg_header_sub = "width=\"{}\" height=\"{}\" viewBox=\"{}\">" + +stackup_regex = re.compile( + r"\(stackup\s*(?:\s*\([^\(\)]*(?:\([^\)]*\)\s*)*\)\s*)*\)", re.MULTILINE +) +stackup_thickness_regex = re.compile(r"\(thickness\s+([^) ]*)[^)]*\)") +stackup_mask_regex = re.compile( + r"\(layer\s+\"[FB].Mask\".*?\(color\s+\"([^\)]*)\"\s*\)", re.DOTALL +) +stackup_silks_regex = re.compile( + r"\(layer\s+\"[FB].SilkS\".*?\(color\s+\"([^\)]*)\"\s*\)", re.DOTALL +) +stackup_copper_finish_regex = re.compile(r"\(copper_finish\s+\"([^\"]*)\"\s*\)") diff --git a/3rdparty/plugins/com_github_30350n_pcb2blender/images/blender_icon_32x32.png b/3rdparty/plugins/com_github_30350n_pcb2blender/images/blender_icon_32x32.png new file mode 100644 index 0000000..090b999 Binary files /dev/null and b/3rdparty/plugins/com_github_30350n_pcb2blender/images/blender_icon_32x32.png differ diff --git a/3rdparty/plugins/com_github_30350n_pcb2blender/ui.py b/3rdparty/plugins/com_github_30350n_pcb2blender/ui.py new file mode 100644 index 0000000..d9e0c31 --- /dev/null +++ b/3rdparty/plugins/com_github_30350n_pcb2blender/ui.py @@ -0,0 +1,108 @@ +from pathlib import Path + +import wx + +class SettingsDialog(wx.Dialog): + def __init__(self, parent, boarddefs, ignored): + wx.Dialog.__init__(self, parent, title="Export to Blender") + + panel = self.init_panel(boarddefs, ignored) + sizer = wx.BoxSizer() + sizer.Add(panel) + self.SetSizerAndFit(sizer) + self.SetMinSize((0, 1000)) + + self.Center() + self.Show() + + def on_export(self, event): + path = Path(self.file_picker.GetPath()) + if path.parent.exists(): + self.EndModal(wx.OK) + else: + wx.MessageBox( + f"Invalid path \"{path.parent}\"!", caption="Error", + style=wx.CENTER | wx.ICON_ERROR | wx.OK + ) + + def init_panel(self, boarddefs, ignored): + panel = wx.Panel(self) + + rows = wx.BoxSizer(orient=wx.VERTICAL) + settings = wx.StaticBoxSizer(wx.StaticBox(panel, label="Settings"), orient=wx.VERTICAL) + column = wx.BoxSizer() + + text_export_as = wx.StaticText(panel, label="Export as") + column.Add(text_export_as, flag=wx.ALL | wx.ALIGN_CENTER, border=5) + + self.file_picker = wx.FilePickerCtrl( + panel, message="Export as", + wildcard="PCB 3D Model (.pcb3d)|*.pcb3d", + style=wx.FLP_SAVE | wx.FLP_USE_TEXTCTRL | wx.FLP_OVERWRITE_PROMPT, + size=(300, 25) + ) + column.Add(self.file_picker, proportion=1, flag=wx.ALL | wx.ALIGN_CENTER, border=5) + + settings.Add(column, flag=wx.EXPAND | wx.ALL, border=5) + rows.Add(settings, flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT, border=5) + + info = wx.StaticBoxSizer(wx.StaticBox(panel, label="Info"), orient=wx.VERTICAL) + + n_boards = max(1, len(boarddefs)) + plural = "" if n_boards == 1 else "s" + text_detected = wx.StaticText(panel, label=f"Detected {n_boards} Board{plural}.") + info.Add(text_detected, flag=wx.ALL, border=5) + + for name, boarddef in sorted(boarddefs.items()): + label = f"PCB {name}"\ + f" ({boarddef.bounds[2]:.2f}x{boarddef.bounds[3]:.2f}mm)" + if boarddef.stacked_boards: + label += " with " + for stacked in boarddef.stacked_boards: + label += "front panel" if stacked.name == "FPNL" else stacked.name + stack_str = ", ".join(f"{f:.2f}" for f in stacked.offset) + label += f" stacked at ({stack_str}), " + label = label[:-2] + "." + + info.Add(wx.StaticText(panel, label=label), flag=wx.ALL, border=5) + + rows.Add(info, flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT, border=5) + + if ignored: + warning = wx.StaticBoxSizer( + wx.StaticBox(panel, label="Warning (failed to parse some identifiers)"), + orient=wx.VERTICAL + ) + + for name in ignored: + warning.Add(wx.StaticText(panel, label=" " + name), flag=wx.ALL, border=5) + + rows.Add(warning, flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT, border=5) + + hint = wx.StaticBoxSizer(wx.StaticBox(panel, label="Hint"), orient=wx.VERTICAL) + boarddef_hint = ""\ + "To define a board, specify its bounds by placing a Text Item with the text "\ + "PCB3D_TL_ at its top left corner and one with "\ + "PCB3D_BR_ at its bottom right corner.\n\n"\ + "To stack a board A to another board B, add a Text Item with the text "\ + "PCB3D_STACK__ONTO__\n"\ + "at the location (relative to the top left corner of board B), "\ + "where you want the top left corner of A to be.\n"\ + "(zoffset is given in mm, 10.0 is a good default for 2.54mm headers and sockets)" + boarddef_hint_text = wx.StaticText(panel, label=boarddef_hint) + boarddef_hint_text.Wrap(400) + hint.Add(boarddef_hint_text, flag=wx.ALL, border=5) + rows.Add(hint, flag=wx.TOP | wx.LEFT | wx.RIGHT, border=5) + + buttons = wx.BoxSizer() + button_cancel = wx.Button(panel, id=wx.ID_CANCEL, label="Cancel", size=(85, 26)) + buttons.Add(button_cancel, flag=wx.ALL | wx.ALIGN_CENTER, border=5) + button_export = wx.Button(panel, id=wx.ID_OK, label="Export", size=(85, 26)) + button_export.Bind(wx.EVT_BUTTON, self.on_export) + buttons.Add(button_export, flag=wx.ALL | wx.ALIGN_CENTER, border=5) + + rows.Add(buttons, flag=wx.ALL | wx.ALIGN_RIGHT, border=5) + + panel.SetSizer(rows) + + return panel diff --git a/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/__init__.py b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/__init__.py new file mode 100644 index 0000000..7c6bdb8 --- /dev/null +++ b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/__init__.py @@ -0,0 +1,8 @@ +try: + from .plugin import Plugin + plugin = Plugin() + plugin.register() +except Exception as e: + import logging + logger = logging.getLogger() + logger.debug(repr(e)) diff --git a/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/cli.py b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/cli.py new file mode 100644 index 0000000..a7e1c0c --- /dev/null +++ b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/cli.py @@ -0,0 +1,36 @@ +import argparse as ap + +from .thread import ProcessThread +from .options import * + + +if __name__ == '__main__': + parser = ap.ArgumentParser(prog="Fabrication Toolkit", + description="Generates JLCPCB production files from a KiCAD board file") + + parser.add_argument("--path", "-p", type=str, help="Path to KiCAD board file", required=True) + parser.add_argument("--additionalLayers", "-aL", type=str, help="Additional layers(comma-separated)") + parser.add_argument("--user1VCut", "-u1", action="store_true", help="Set User.1 as V-Cut layer") + parser.add_argument("--user2AltVCut", "-u2", action="store_true", help="Use User.2 for alternative Edge-Cut layer") + parser.add_argument("--autoTranslate", "-t", action="store_true", help="Apply automatic position/rotation translations") + parser.add_argument("--autoFill", "-f", action="store_true", help="Apply automatic fill for all zones") + 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") + args = parser.parse_args() + + options = dict() + options[AUTO_TRANSLATE_OPT] = args.autoTranslate + options[AUTO_FILL_OPT] = args.autoFill + options[EXCLUDE_DNP_OPT] = args.excludeDNP + options[EXTEND_EDGE_CUT_OPT] = args.user1VCut + options[ALTERNATIVE_EDGE_CUT_OPT] = args.user2AltVCut + options[ALL_ACTIVE_LAYERS_OPT] = args.allActiveLayers + options[EXTRA_LAYERS] = args.additionalLayers + + openBrowser = args.openBrowser + + + path = args.path + + ProcessThread(wx=None, cli=path, options=options, openBrowser=openBrowser) \ No newline at end of file diff --git a/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/config.py b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/config.py new file mode 100644 index 0000000..4106ad6 --- /dev/null +++ b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/config.py @@ -0,0 +1,26 @@ +import pcbnew # type: ignore + +baseUrl = 'https://www.jlcpcb.com' + +netlistFileName = 'netlist.ipc' +designatorsFileName = 'designators.csv' +placementFileName = 'positions.csv' +bomFileName = 'bom.csv' +gerberArchiveName = 'gerbers.zip' +outputFolder = 'production' +bomRowLimit = 200 + +optionsFileName = 'fabrication-toolkit-options.json' + +standardLayers = [ pcbnew.F_Cu, pcbnew.B_Cu, + pcbnew.In1_Cu, pcbnew.In2_Cu, pcbnew.In3_Cu, pcbnew.In4_Cu, pcbnew.In5_Cu, + pcbnew.In6_Cu, pcbnew.In7_Cu, pcbnew.In8_Cu, pcbnew.In9_Cu, pcbnew.In10_Cu, + pcbnew.In11_Cu, pcbnew.In12_Cu, pcbnew.In13_Cu, pcbnew.In14_Cu, pcbnew.In15_Cu, + pcbnew.In16_Cu, pcbnew.In17_Cu, pcbnew.In18_Cu, pcbnew.In19_Cu, pcbnew.In20_Cu, + pcbnew.In21_Cu, pcbnew.In22_Cu, pcbnew.In23_Cu, pcbnew.In24_Cu, pcbnew.In25_Cu, + pcbnew.In26_Cu, pcbnew.In27_Cu, pcbnew.In28_Cu, pcbnew.In29_Cu, pcbnew.In30_Cu, + pcbnew.F_SilkS, pcbnew.B_SilkS, + pcbnew.F_Mask, pcbnew.B_Mask, + pcbnew.F_Paste, pcbnew.B_Paste, + pcbnew.Edge_Cuts + ] diff --git a/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/events.py b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/events.py new file mode 100644 index 0000000..ae271c9 --- /dev/null +++ b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/events.py @@ -0,0 +1,14 @@ +import wx + +STATUS_EVENT_ID = wx.NewId() + + +class StatusEvent(wx.PyEvent): + def __init__(self, data): + wx.PyEvent.__init__(self) + self.SetEventType(STATUS_EVENT_ID) + self.data = data + + @staticmethod + def invoke(window, function): + window.Connect(-1, -1, STATUS_EVENT_ID, function) diff --git a/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/icon.png b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/icon.png new file mode 100644 index 0000000..7d5dd3c Binary files /dev/null and b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/icon.png differ diff --git a/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/options.py b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/options.py new file mode 100644 index 0000000..900a01d --- /dev/null +++ b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/options.py @@ -0,0 +1,8 @@ +AUTO_TRANSLATE_OPT = "AUTO TRANSLATE" +AUTO_FILL_OPT = "AUTO FILL" +EXCLUDE_DNP_OPT = "EXCLUDE DNP" +OUTPUT_NAME_OPT = "OUTPUT NAME" +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 diff --git a/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/plugin.py b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/plugin.py new file mode 100644 index 0000000..ff5563d --- /dev/null +++ b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/plugin.py @@ -0,0 +1,151 @@ +import os +import wx +import pcbnew # type: ignore + +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 .utils import load_user_options, save_user_options, get_layer_names + + +# WX GUI form that show the plugin progress +class KiCadToJLCForm(wx.Frame): + def __init__(self): + wx.Dialog.__init__( + self, + None, + id=wx.ID_ANY, + title=u"Fabrication Toolkit", + 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) + self.SetBackgroundColour(wx.LIGHT_GREY) + + self.SetSizeHints(wx.Size(600, 100), wx.DefaultSize) + + userOptions = load_user_options({ + EXTRA_LAYERS: "", + ALL_ACTIVE_LAYERS_OPT: False, + EXTEND_EDGE_CUT_OPT: False, + ALTERNATIVE_EDGE_CUT_OPT: False, + AUTO_TRANSLATE_OPT: True, + AUTO_FILL_OPT: True, + EXCLUDE_DNP_OPT: False + }) + + self.mOptionsLabel = wx.StaticText(self, label='Options:') + # self.mOptionsSeparator = wx.StaticLine(self) + + layers = get_layer_names(pcbnew.GetBoard()) + self.mAdditionalLayersControl = wx.TextCtrl(self, size=wx.Size(600, 50)) + self.mAdditionalLayersControl.Hint = "Additional layers (comma-separated)" + self.mAdditionalLayersControl.AutoComplete(layers) + self.mAdditionalLayersControl.Enable() + self.mAdditionalLayersControl.SetValue(userOptions[EXTRA_LAYERS]) + self.mAllActiveLayersCheckbox = wx.CheckBox(self, label='Plot all active layers') + self.mAllActiveLayersCheckbox.SetValue(userOptions[ALL_ACTIVE_LAYERS_OPT]) + self.mExtendEdgeCutsCheckbox = wx.CheckBox(self, label='Set User.1 as V-Cut layer') + self.mExtendEdgeCutsCheckbox.SetValue(userOptions[EXTEND_EDGE_CUT_OPT]) + self.mAlternativeEdgeCutsCheckbox = wx.CheckBox(self, label='Use User.2 for alternative Edge-Cut layer') + self.mAlternativeEdgeCutsCheckbox.SetValue(userOptions[ALTERNATIVE_EDGE_CUT_OPT]) + self.mAutomaticTranslationCheckbox = wx.CheckBox(self, label='Apply automatic translations') + self.mAutomaticTranslationCheckbox.SetValue(userOptions[AUTO_TRANSLATE_OPT]) + self.mAutomaticFillCheckbox = wx.CheckBox(self, label='Apply automatic fill for all zones') + 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.mGaugeStatus = wx.Gauge( + self, wx.ID_ANY, 100, wx.DefaultPosition, wx.Size(600, 20), wx.GA_HORIZONTAL) + self.mGaugeStatus.SetValue(0) + self.mGaugeStatus.Hide() + + self.mGenerateButton = wx.Button(self, label='Generate', size=wx.Size(600, 60)) + self.mGenerateButton.Bind(wx.EVT_BUTTON, self.onGenerateButtonClick) + + boxSizer = wx.BoxSizer(wx.VERTICAL) + + boxSizer.Add(self.mOptionsLabel, 0, wx.ALL, 5) + # boxSizer.Add(self.mOptionsSeparator, 0, wx.ALL, 5) + boxSizer.Add(self.mAdditionalLayersControl, 0, wx.ALL, 5) + boxSizer.Add(self.mAllActiveLayersCheckbox, 0, wx.ALL, 5) + boxSizer.Add(self.mExtendEdgeCutsCheckbox, 0, wx.ALL, 5) + boxSizer.Add(self.mAlternativeEdgeCutsCheckbox, 0, wx.ALL, 5) + 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.mGaugeStatus, 0, wx.ALL, 5) + boxSizer.Add(self.mGenerateButton, 0, wx.ALL, 5) + + self.SetSizer(boxSizer) + self.Layout() + boxSizer.Fit(self) + + self.Centre(wx.BOTH) + + # Bind the ESC key event to a handler + self.Bind(wx.EVT_CHAR_HOOK, self.onKey) + + # Close the dialog when pressing the ESC key + def onKey(self, event): + if event.GetKeyCode() == wx.WXK_ESCAPE: + self.Close(True) + else: + event.Skip() + + + def onGenerateButtonClick(self, event): + options = dict() + options[EXTRA_LAYERS] = self.mAdditionalLayersControl.GetValue() + options[ALL_ACTIVE_LAYERS_OPT] = self.mAllActiveLayersCheckbox.GetValue() + options[EXTEND_EDGE_CUT_OPT] = self.mExtendEdgeCutsCheckbox.GetValue() + options[ALTERNATIVE_EDGE_CUT_OPT] = self.mAlternativeEdgeCutsCheckbox.GetValue() + options[AUTO_TRANSLATE_OPT] = self.mAutomaticTranslationCheckbox.GetValue() + options[AUTO_FILL_OPT] = self.mAutomaticFillCheckbox.GetValue() + options[EXCLUDE_DNP_OPT] = self.mExcludeDnpCheckbox.GetValue() + + save_user_options(options) + + self.mOptionsLabel.Hide() + self.mAdditionalLayersControl.Hide() + self.mAllActiveLayersCheckbox.Hide() + self.mExtendEdgeCutsCheckbox.Hide() + self.mAlternativeEdgeCutsCheckbox.Hide() + self.mAutomaticTranslationCheckbox.Hide() + self.mAutomaticFillCheckbox.Hide() + self.mExcludeDnpCheckbox.Hide() + self.mGenerateButton.Hide() + self.mGaugeStatus.Show() + + self.Fit() + self.SetTitle('Fabrication Toolkit (Processing...)') + + StatusEvent.invoke(self, self.updateDisplay) + ProcessThread(self, options) + + def updateDisplay(self, status): + if status.data == -1: + self.SetTitle('Fabrication Toolkit (Done!)') + pcbnew.Refresh() + self.Destroy() + else: + self.mGaugeStatus.SetValue(int(status.data)) + + +# Plugin definition +class Plugin(pcbnew.ActionPlugin): + def __init__(self): + self.name = "Fabrication Toolkit" + self.category = "Manufacturing" + self.description = "Toolkit for automating PCB fabrication process with KiCad and JLC PCB" + self.pcbnew_icon_support = hasattr(self, "show_toolbar_button") + self.show_toolbar_button = True + self.icon_file_name = os.path.join(os.path.dirname(__file__), 'icon.png') + self.dark_icon_file_name = os.path.join(os.path.dirname(__file__), 'icon.png') + + def Run(self): + KiCadToJLCForm().Show() diff --git a/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/process.py b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/process.py new file mode 100644 index 0000000..3ffbeae --- /dev/null +++ b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/process.py @@ -0,0 +1,510 @@ +# For better annotation. +from __future__ import annotations + +# System base libraries +import os +import re +import csv +import math +import shutil +from collections import defaultdict +from typing import Tuple + +# Interaction with KiCad. +import pcbnew # type: ignore +from .utils import footprint_has_field, footprint_get_field, get_plot_plan + +# Application definitions. +from .config import * + +class ProcessManager: + def __init__(self, board = None): + # if no board is already loaded by cli mode getBoard from kicad environment + if board is None: + self.board = pcbnew.GetBoard() + else: + self.board = board + self.bom = [] + self.components = [] + self.__rotation_db = self.__read_rotation_db() + + @staticmethod + def normalize_filename(filename): + return re.sub(r'[^\w\s\.\-]', '', filename) + + def update_zone_fills(self): + '''Verify all zones have up-to-date fills.''' + filler = pcbnew.ZONE_FILLER(self.board) + zones = self.board.Zones() + + # Fill returns true/false if a refill was made + # We cant use aCheck = True as that would require a rollback on the commit object if + # user decided to not perform the zone fill and the commit object is not exposed to python API + filler.Fill(zones, False) + + # Finally rebuild the connectivity db + self.board.BuildConnectivity() + + def generate_gerber(self, temp_dir, extra_layers, extend_edge_cuts, alternative_edge_cuts, all_active_layers): + '''Generate the Gerber files.''' + settings = self.board.GetDesignSettings() + settings.m_SolderMaskMargin = 50000 + settings.m_SolderMaskToCopperClearance = 5000 + settings.m_SolderMaskMinWidth = 0 + + plot_controller = pcbnew.PLOT_CONTROLLER(self.board) + + plot_options = plot_controller.GetPlotOptions() + plot_options.SetOutputDirectory(temp_dir) + plot_options.SetPlotFrameRef(False) + plot_options.SetSketchPadLineWidth(pcbnew.FromMM(0.1)) + plot_options.SetAutoScale(False) + plot_options.SetScale(1) + plot_options.SetMirror(False) + plot_options.SetUseGerberAttributes(True) + plot_options.SetUseGerberProtelExtensions(True) + plot_options.SetUseAuxOrigin(True) + plot_options.SetSubtractMaskFromSilk(True) + plot_options.SetUseGerberX2format(False) + plot_options.SetDrillMarksType(0) # NO_DRILL_SHAPE + + if hasattr(plot_options, "SetExcludeEdgeLayer"): + plot_options.SetExcludeEdgeLayer(True) + + if extra_layers is not None: + extra_layers = [element.strip() for element in extra_layers.strip().split(',') if element.strip()] + else: + extra_layers = [] + + for layer_info in get_plot_plan(self.board): + if (self.board.IsLayerEnabled(layer_info[1]) and (all_active_layers or layer_info[1] in standardLayers)) or layer_info[0] in extra_layers: + plot_controller.SetLayer(layer_info[1]) + plot_controller.OpenPlotfile(layer_info[2], pcbnew.PLOT_FORMAT_GERBER, layer_info[2]) + + if layer_info[1] == pcbnew.Edge_Cuts and hasattr(plot_controller, 'PlotLayers') and (extend_edge_cuts or alternative_edge_cuts): + seq = pcbnew.LSEQ() + # uses User_2 layer for alternative Edge_Cuts layer + if alternative_edge_cuts: + seq.push_back(pcbnew.User_2) + else: + seq.push_back(layer_info[1]) + # includes User_1 layer with Edge_Cuts layer to allow V Cuts to be defined as User_1 layer + # available for KiCad 7.0.1+ + if extend_edge_cuts: + seq.push_back(layer_info[1]) + seq.push_back(pcbnew.User_1) + plot_controller.PlotLayers(seq) + else: + plot_controller.PlotLayer() + + plot_controller.ClosePlot() + + def generate_drills(self, temp_dir): + '''Generate the drill file.''' + drill_writer = pcbnew.EXCELLON_WRITER(self.board) + + drill_writer.SetOptions( + False, + False, + self.board.GetDesignSettings().GetAuxOrigin(), + False) + drill_writer.SetFormat(True) + drill_writer.SetMapFileFormat(pcbnew.PLOT_FORMAT_GERBER) + drill_writer.CreateDrillandMapFilesSet(temp_dir, True, True) + + def generate_netlist(self, temp_dir): + '''Generate the connection netlist.''' + netlist_writer = pcbnew.IPC356D_WRITER(self.board) + netlist_writer.Write(os.path.join(temp_dir, netlistFileName)) + + def _get_footprint_position(self, footprint): + """Calculate position based on center of pads / bounding box.""" + origin_type = self._get_origin_from_footprint(footprint) + + if origin_type == 'Anchor': + position = footprint.GetPosition() + else: # if type_origin == 'Center' or anything else + pads = footprint.Pads() + if len(pads) > 0: + # get bounding box based on pads only to ignore non-copper layers, e.g. silkscreen + bbox = pads[0].GetBoundingBox() # start with small bounding box + for pad in pads: + bbox.Merge(pad.GetBoundingBox()) # expand bounding box + 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): + '''Generate the data tables.''' + if hasattr(self.board, 'GetModules'): + footprints = list(self.board.GetModules()) + else: + footprints = list(self.board.GetFootprints()) + + # sort footprint after designator + footprints.sort(key=lambda x: x.GetReference().upper()) + + # unique designator dictionary + footprint_designators = defaultdict(int) + for i, footprint in enumerate(footprints): + # count unique designators + footprint_designators[footprint.GetReference().upper()] += 1 + bom_designators = footprint_designators.copy() + + if len(footprint_designators.items()) > 0: + with open((os.path.join(temp_dir, designatorsFileName)), 'w', encoding='utf-8-sig') as f: + for key, value in footprint_designators.items(): + f.write('%s:%s\n' % (key, value)) + + for i, footprint in enumerate(footprints): + try: + footprint_name = str(footprint.GetFPID().GetFootprintName()) + except AttributeError: + footprint_name = str(footprint.GetFPID().GetLibItemName()) + + layer = self._get_layer_override_from_footprint(footprint) + + # mount_type = { + # 0: 'smt', + # 1: 'tht', + # 2: 'unspecified' + # }.get(footprint.GetAttributes()) + + is_dnp = (footprint_has_field(footprint, 'dnp') + or (footprint.GetValue().upper() == 'DNP') + or getattr(footprint, 'IsDNP', bool)()) + skip_dnp = exclude_dnp and is_dnp + + if not (footprint.GetAttributes() & pcbnew.FP_EXCLUDE_FROM_POS_FILES) and not is_dnp: + # append unique ID if duplicate footprint designator + unique_id = "" + if footprint_designators[footprint.GetReference().upper()] > 1: + unique_id = str(footprint_designators[footprint.GetReference().upper()]) + footprint_designators[footprint.GetReference().upper()] -= 1 + + designator = "{}{}{}".format(footprint.GetReference().upper(), "" if unique_id == "" else "_", unique_id) + position = self._get_footprint_position(footprint) + mid_x = (position[0] - self.board.GetDesignSettings().GetAuxOrigin()[0]) / 1000000.0 + mid_y = (position[1] - self.board.GetDesignSettings().GetAuxOrigin()[1]) * -1.0 / 1000000.0 + rotation = footprint.GetOrientation().AsDegrees() if hasattr(footprint.GetOrientation(), 'AsDegrees') else footprint.GetOrientation() / 10.0 + rotation_offset_db = self._get_rotation_from_db(footprint_name) # internal database offset + rotation_offset_manual = self._get_rotation_offset_from_footprint(footprint) # explicated offset by the designer + + # position offset needs to take rotation into account + pos_offset = self._get_position_offset_from_footprint(footprint) + if auto_translate: + pos_offset_db = self._get_position_offset_from_db(footprint_name) + pos_offset = (pos_offset[0] + pos_offset_db[0], pos_offset[1] + pos_offset_db[1]) + + rsin = math.sin(rotation / 180 * math.pi) + rcos = math.cos(rotation / 180 * math.pi) + + if layer == 'bottom': + pos_offset = ( pos_offset[0] * rcos + pos_offset[1] * rsin, pos_offset[0] * rsin - pos_offset[1] * rcos ) + else: + pos_offset = ( pos_offset[0] * rcos - pos_offset[1] * rsin, pos_offset[0] * rsin + pos_offset[1] * rcos ) + mid_x, mid_y = tuple(map(sum,zip((mid_x, mid_y), pos_offset))) + + # JLC expect 'Rotation' to be 'as viewed from above component', so bottom needs inverting, and ends up 180 degrees out as well + if layer == 'bottom': + rotation = (180.0 - rotation) + + if auto_translate: + rotation += rotation_offset_db + + rotation = (rotation + rotation_offset_manual) % 360.0 + + self.components.append({ + 'Designator': designator, + 'Mid X': mid_x, + 'Mid Y': mid_y, + 'Rotation': rotation, + 'Layer': layer, + }) + + if not (footprint.GetAttributes() & pcbnew.FP_EXCLUDE_FROM_BOM) and not skip_dnp: + # append unique ID if we are dealing with duplicate bom designator + unique_id = "" + if bom_designators[footprint.GetReference().upper()] > 1: + unique_id = str(bom_designators[footprint.GetReference().upper()]) + bom_designators[footprint.GetReference().upper()] -= 1 + + # merge similar parts into single entry + insert = True + 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) + under_limit = component['Quantity'] < bomRowLimit + + if same_footprint and same_value and same_lcsc and under_limit: + component['Designator'] += ", " + "{}{}{}".format(footprint.GetReference().upper(), "" if unique_id == "" else "_", unique_id) + component['Quantity'] += 1 + insert = False + break + + # add component to BOM + if insert: + self.bom.append({ + '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), + }) + + def generate_positions(self, temp_dir): + '''Generate the position file.''' + if len(self.components) > 0: + with open((os.path.join(temp_dir, placementFileName)), 'w', newline='', encoding='utf-8-sig') as outfile: + csv_writer = csv.writer(outfile) + # writing headers of CSV file + csv_writer.writerow(self.components[0].keys()) + + for component in self.components: + # writing data of CSV file + if ('**' not in component['Designator']): + csv_writer.writerow(component.values()) + + def generate_bom(self, temp_dir): + '''Generate the bom file.''' + if len(self.bom) > 0: + with open((os.path.join(temp_dir, bomFileName)), 'w', newline='', encoding='utf-8-sig') as outfile: + csv_writer = csv.writer(outfile) + # writing headers of CSV file + csv_writer.writerow(self.bom[0].keys()) + + # Output all of the component information + for component in self.bom: + # writing data of CSV file + if ('**' not in component['Designator']): + csv_writer.writerow(component.values()) + + def generate_archive(self, temp_dir, temp_file): + '''Generate the archive file.''' + temp_file = shutil.make_archive(temp_file, 'zip', temp_dir) + temp_file = shutil.move(temp_file, temp_dir) + + # remove non essential files + for item in os.listdir(temp_dir): + if not item.endswith(".zip") and not item.endswith(".csv") and not item.endswith(".ipc"): + 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]: + '''Read the rotations.cf config file so we know what rotations + to apply later. + ''' + db = {} + + with open(filename, newline='') as csvfile: + csvDialect = csv.Sniffer().sniff(csvfile.read(1024)) + csvfile.seek(0) + csvData = csv.DictReader(csvfile, fieldnames=["footprint", "rotation", "x", "y"], + restkey="extra", restval="0", dialect=csvDialect) + + rowNum = 0 + for row in csvData: + rowNum = rowNum + 1 + # First row is header row, skip. + if rowNum == 1: + skipFirst = False + continue + + # If there was too many fields, throw an exception. + if len(row) > 4: + raise RuntimeError("{}: Too many fields found in row {}: {}".format(filename, rowNum, row)) + + # See if the values we expect to be floating point numbers + # can be converted to floating point, if not throw an exception. + if row['rotation'] == "": + rotation = 0.0 + else: + try: + rotation = float(row['rotation']) + except ValueError: + raise RuntimeError("{}: Non-numeric rotation value found in row {}".format(filename, rowNum)) + + if row['x'] == "": + delta_x = 0.0 + else: + try: + delta_x = float(row['x']) + except ValueError: + raise RuntimeError("{}: Non-numeric translation value found in row {}".format(filename, rowNum)) + + if row['y'] == "": + delta_y = 0.0 + else: + try: + delta_y = float(row['y']) + except ValueError: + raise RuntimeError("{}: Non-numeric translation value found in row {}".format(filename, rowNum)) + + # Add the entry to the database in the format we expect. + db[rowNum] = {} + db[rowNum]['name'] = row['footprint'] + db[rowNum]['rotation'] = rotation + db[rowNum]['x'] = delta_x + db[rowNum]['y'] = delta_y + + return db + + def _get_rotation_from_db(self, footprint: str) -> float: + '''Get the rotation to be added from the database file.''' + # Look for regular expression math of the footprint name and not its root library. + + for entry in self.__rotation_db.items(): + # If the expression in the DB contains a :, search for it literally. + if (re.search(':', entry[1]['name'])): + if (re.search(entry[1]['name'], footprint)): + return float(entry[1]['rotation']) + # There is no : in the expression, so only search the right side of the : + else: + footprint_segments = footprint.split(':') + # Only one means there was no :, just check the short. + if (len(footprint_segments) == 1): + check = footprint_segments[0] + # More means there was a :, check the right side. + else: + check = footprint_segments[1] + if (re.search(entry[1]['name'], check)): + return float(entry[1]['rotation']) + + # Not found, no rotation. + return 0.0 + + def _get_position_offset_from_db(self, footprint: str) -> Tuple[float, float]: + '''Get the rotation to be added from the database file.''' + # Look for regular expression math of the footprint name and not its root library. + + for entry in self.__rotation_db.items(): + # If the expression in the DB contains a :, search for it literally. + if (re.search(':', entry[1]['name'])): + if (re.search(entry[1]['name'], footprint)): + return ( float(entry[1]['x']), float(entry[1]['y']) ) + # There is no : in the expression, so only search the right side of the : + else: + footprint_segments = footprint.split(':') + # Only one means there was no :, just check the short. + if (len(footprint_segments) == 1): + check = footprint_segments[0] + # More means there was a :, check the right side. + else: + check = footprint_segments[1] + if (re.search(entry[1]['name'], check)): + return ( float(entry[1]['x']), float(entry[1]['y']) ) + + # Not found, no delta. + return (0.0, 0.0) + + def _get_mpn_from_footprint(self, footprint) -> str: + ''''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'] + + if footprint_has_field(footprint, 'dnp'): + return 'DNP' + + for key in keys + fallback_keys: + if footprint_has_field(footprint, key): + return footprint_get_field(footprint, key) + + def _get_layer_override_from_footprint(self, footprint) -> str: + '''Get the layer override from standard symbol fields.''' + keys = ['FT Layer Override'] + fallback_keys = ['Layer Override', 'LayerOverride'] + + layer = { + pcbnew.F_Cu: 'top', + pcbnew.B_Cu: 'bottom', + }.get(footprint.GetLayer()) + + for key in keys + fallback_keys: + if footprint_has_field(footprint, key): + temp_layer = footprint_get_field(footprint, key) + if len(temp_layer) > 0: + if (temp_layer[0] == 'b' or temp_layer[0] == 'B'): + layer = "bottom" + break + elif (temp_layer[0] == 't' or temp_layer[0] == 'T'): + layer = "top" + break + + return layer + + def _get_rotation_offset_from_footprint(self, footprint) -> float: + '''Get the rotation offset from standard symbol fields.''' + keys = ['FT Rotation Offset'] + fallback_keys = ['Rotation Offset', 'RotOffset'] + + offset = "" + + for key in keys + fallback_keys: + if footprint_has_field(footprint, key): + offset = footprint_get_field(footprint, key) + break + + if offset is None or offset == "": + return 0.0 + else: + try: + return float(offset) + except ValueError: + raise RuntimeError("Rotation offset of {} is not a valid number".format(footprint.GetReference())) + + def _get_position_offset_from_footprint(self, footprint) -> Tuple[float, float]: + '''Get the position offset from standard symbol fields.''' + keys = ['FT Position Offset'] + fallback_keys = ['Position Offset', 'PosOffset'] + + offset = "" + + for key in keys + fallback_keys: + if footprint_has_field(footprint, key): + offset = footprint_get_field(footprint, key) + break + + if offset == "": + return (0.0, 0.0) + else: + try: + offset = offset.split(",") + 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'] + fallback_keys = ['Origin'] + + attributes = footprint.GetAttributes() + + # determine origin type by package type + if attributes & pcbnew.FP_SMD: + origin_type = 'Anchor' + else: + origin_type = 'Center' + + for key in keys + fallback_keys: + if footprint_has_field(footprint, key): + origin_type_override = str(footprint_get_field(footprint, key)).strip().capitalize() + + if origin_type_override in ['Anchor', 'Center']: + origin_type = origin_type_override + break + + return origin_type + + def _normalize_footprint_name(self, footprint) -> str: + # replace footprint names of resistors, capacitors, inductors, diodes, LEDs, fuses etc, with the footprint size only + pattern = re.compile(r'^(\w*_SMD:)?\w{1,4}_(\d+)_\d+Metric.*$') + + return pattern.sub(r'\2', footprint) diff --git a/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/thread.py b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/thread.py new file mode 100644 index 0000000..159a42b --- /dev/null +++ b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/thread.py @@ -0,0 +1,158 @@ +import os +import wx +import pcbnew # type: ignore +import shutil +import tempfile +import webbrowser +import datetime +import logging +from threading import Thread +from .events import StatusEvent +from .process import ProcessManager +from .config import * +from .options import * +from .utils import print_cli_progress_bar + + +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 + 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) + except Exception as e: + logging.error("Fabrication Toolkit - Error" + str(e)) + return + else: + self.board = None + + self.process_manager = ProcessManager(self.board) + self.wx = wx + self.cli = cli + self.options = options + self.openBrowser = openBrowser + self.start() + + def run(self): + # initializing + self.progress(0) + + temp_dir = tempfile.mkdtemp() + temp_dir_gerber = temp_dir + "_g" + os.makedirs(temp_dir_gerber) + + _, temp_file = tempfile.mkstemp() + project_directory = os.path.dirname(self.process_manager.board.GetFileName()) + + try: + # Verify all zones are up-to-date + self.progress(10) + if (self.options[AUTO_FILL_OPT]): + self.process_manager.update_zone_fills() + + # generate gerber + self.progress(20) + self.process_manager.generate_gerber(temp_dir_gerber, self.options[EXTRA_LAYERS], self.options[EXTEND_EDGE_CUT_OPT], + self.options[ALTERNATIVE_EDGE_CUT_OPT], self.options[ALL_ACTIVE_LAYERS_OPT]) + + # generate drill file + self.progress(30) + self.process_manager.generate_drills(temp_dir_gerber) + + # generate netlist + self.progress(40) + self.process_manager.generate_netlist(temp_dir) + + # generate data tables + self.progress(50) + self.process_manager.generate_tables(temp_dir, self.options[AUTO_TRANSLATE_OPT], self.options[EXCLUDE_DNP_OPT]) + + # generate pick and place file + self.progress(60) + self.process_manager.generate_positions(temp_dir) + + # generate BOM file + self.progress(70) + self.process_manager.generate_bom(temp_dir) + + # generate production archive + self.progress(85) + temp_file = self.process_manager.generate_archive(temp_dir_gerber, temp_file) + shutil.move(temp_file, temp_dir) + shutil.rmtree(temp_dir_gerber) + temp_file = os.path.join(temp_dir, os.path.basename(temp_file)) + except Exception as e: + if self.wx is None: + logging.error("Fabrication Toolkit - Error" + str(e)) + else: + wx.MessageBox(str(e), "Fabrication Toolkit - Error", wx.OK | wx.ICON_ERROR) + self.progress(-1) + return + + # progress bar done animation + read_so_far = 0 + total_size = os.path.getsize(temp_file) + with open(temp_file, 'rb') as file: + while True: + data = file.read(10) + if not data: + break + read_so_far += len(data) + percent = read_so_far * 1e2 / total_size + self.progress(85 + percent / 8) + + # generate gerber name + title_block = self.process_manager.board.GetTitleBlock() + title = title_block.GetTitle() + revision = title_block.GetRevision() + company = title_block.GetCompany() + file_date = title_block.GetDate() + + if (hasattr(self.process_manager.board, "GetProject") and hasattr(pcbnew, "ExpandTextVars")): + project = self.process_manager.board.GetProject() + title = pcbnew.ExpandTextVars(title, project) + revision = pcbnew.ExpandTextVars(revision, project) + company = pcbnew.ExpandTextVars(company, project) + file_date = pcbnew.ExpandTextVars(file_date, project) + + # make output dir + filename = os.path.splitext(os.path.basename(self.process_manager.board.GetFileName()))[0] + 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)) + + timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H-%M-%S') + backup_name = ProcessManager.normalize_filename("_".join(("{} {} {}".format(title or filename, revision or '', timestamp).strip()).split())) + shutil.make_archive(os.path.join(output_path, 'backups', backup_name), 'zip', temp_dir) + + + # copy to & open output dir + try: + shutil.copytree(temp_dir, output_path, dirs_exist_ok=True) + if self.openBrowser: + webbrowser.open("file://%s" % (output_path)) + shutil.rmtree(temp_dir) + except Exception as e: + if self.openBrowser: + webbrowser.open("file://%s" % (temp_dir)) + + if self.wx is None: + self.progress(100) + else: + self.progress(-1) + + def progress(self, percent): + if self.wx is None: + print_cli_progress_bar(percent, prefix = 'Progress:', suffix = 'Complete', length = 50) + else: + wx.PostEvent(self.wx, StatusEvent(percent)) diff --git a/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/transformations.csv b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/transformations.csv new file mode 100644 index 0000000..970190e --- /dev/null +++ b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/transformations.csv @@ -0,0 +1,52 @@ +"Regex To Match","Rotation","Delta X","Delta Y" +"^Bosch_LGA-",90,0,0 +"^CP_EIA-",180,0,0 +"^CP_Elec_",180,0,0 +"^C_Elec_",180,0,0 +"^DFN-",270,0,0 +"^DFN-",270,0,0 +"^D_SOT-23",180,0,0 +"^HTSSOP-",270,0,0 +"^HTSSOP-",270,0,0 +"^HTSSOP-",270,0,0 +"^JST_GH_SM",180,0,0 +"^JST_PH_S",180,0,0 +"^LQFP-",270,0,0 +"^MSOP-",270,0,0 +"^PowerPAK_SO-8_Single",270,0,0 +"^QFN-",90,0,0 +"^R_Array_Concave_",90,0,0 +"^R_Array_Convex_",90,0,0 +"^SC-74-6",180,0,0 +"^SOIC-",270,0,0 +"^SOIC-16_",270,0,0 +"^SOIC-8_",270,0,0 +"^SOIC127P798X216-8N",-90,0,0 +"^SOP-(?!18_)",270,0,0 +"^SOP-(?!18_)",270,0,0 +"^SOP-18_",0,0,0 +"^SOP-18_",0,0,0 +"^SOP-4_",0,0,0 +"^SOP-4_",0,0,0 +"^SOT-143",180,0,0 +"^SOT-223",180,0,0 +"^SOT-23",180,0,0 +"^SOT-353",180,0,0 +"^SOT-363",180,0,0 +"^SOT-89",180,0,0 +"^SSOP-",270,0,0 +"^SW_SPST_B3",90,0,0 +"^TDSON-8-1",270,0,0 +"^TO-277",90,0,0 +"^TQFP-",270,0,0 +"^TSOT-23",180,0,0 +"^TSSOP-",270,0,0 +"^UDFN-10",270,0,0 +"^USON-10",270,0,0 +"^VSON-8_",270,0,0 +"^VSSOP-10_-",270,0,0 +"^VSSOP-10_-",270,0,0 +"^VSSOP-8_",180,0,0 +"^VSSOP-8_",270,0,0 +"^VSSOP-8_3.0x3.0mm_P0.65mm",270,0,0 +"^qfn-",90,0,0 \ No newline at end of file diff --git a/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/utils.py b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/utils.py new file mode 100644 index 0000000..6a69162 --- /dev/null +++ b/3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/utils.py @@ -0,0 +1,101 @@ +import pcbnew # type: ignore +import os +import json +from .config import optionsFileName +import wx + +def get_version(): + return float('.'.join(pcbnew.GetBuildVersion().split(".")[0:2])) # e.g GetBuildVersion(): e.g. '7.99.0-3969-gc5ac2337e4' + +def is_v9(version = get_version()): + return version >= 8.99 and version < 9.99 + +def is_v8(version = get_version()): + return version >= 7.99 and version < 8.99 + +def is_v7(version = get_version()): + return version >= 6.99 and version < 7.99 + +def is_v6(version = get_version()): + return version >= 5.99 and version < 6.99 + +def footprint_has_field(footprint, field_name): + version = get_version() + + if is_v8(version) or is_v9(version): + return footprint.HasFieldByName(field_name) + else: + return footprint.HasProperty(field_name) + +def footprint_get_field(footprint, field_name): + version = get_version() + + if is_v8(version) or is_v9(version): + return footprint.GetFieldByName(field_name).GetText() + else: + return footprint.GetProperty(field_name) + +def get_user_options_file_path(): + boardFilePath = pcbnew.GetBoard().GetFileName() + return os.path.join(os.path.dirname(boardFilePath), optionsFileName) + +def load_user_options(default_options): + try: + with open(get_user_options_file_path(), 'r') as f: + user_options = json.load(f) + except: + user_options = default_options + + # merge the user options with the default options + options = default_options.copy() + options.update(user_options) + return options + +def save_user_options(options): + try: + with open(get_user_options_file_path(), 'w') as f: + json.dump(options, f) + except: + wx.MessageBox("Error saving user options", "Error", wx.OK | wx.ICON_ERROR) + +def get_plot_plan(board, active_only=True): + """Returns `(KiCad standard name, layer id, custom user name)` of all (active) layers of the given board.""" + layers = [] + i = pcbnew.PCBNEW_LAYER_ID_START - 1 + while i < pcbnew.PCBNEW_LAYER_ID_START + pcbnew.PCB_LAYER_ID_COUNT - 1: + i += 1 + if active_only and not board.IsLayerEnabled(i): + continue + + layer_std_name = pcbnew.BOARD.GetStandardLayerName(i) + layer_name = pcbnew.BOARD.GetLayerName(board, i) + + layers.append((layer_std_name, i, layer_name)) + return layers + +def get_layer_names(board, active_only=True): + """Returns a list of (active) layer names of the current board""" + plotPlan = get_plot_plan(board, active_only) + return [layer_info[0] for layer_info in plotPlan] + +def print_cli_progress_bar(percent, prefix = '', suffix = '', decimals = 1, length = 100, fill = '█', printEnd = "\r"): + """ + Call in a loop to create terminal progress bar string + @params: + percent - Required : current percentage (Int) + prefix - Optional : prefix string (Str) + suffix - Optional : suffix string (Str) + decimals - Optional : positive number of decimals in percent complete (Int) + length - Optional : character length of bar (Int) + fill - Optional : bar fill character (Str) + printEnd - Optional : end character (e.g. "\r", "\r\n") (Str) + """ + if percent == -1: + percent = 0 + filledLength = int(length * (percent / 100 )) + bar = fill * filledLength + '-' * (length - filledLength) + percent2dec = "%.2f" % percent + print(f'\r{prefix} |{bar}| {percent2dec}% {suffix}', end = printEnd) + # Print New Line on Complete + if percent == 100: + print() \ No newline at end of file diff --git a/3rdparty/resources/.DS_Store b/3rdparty/resources/.DS_Store index 6e98eb4..c6ea5c6 100644 Binary files a/3rdparty/resources/.DS_Store and b/3rdparty/resources/.DS_Store differ diff --git a/3rdparty/resources/com_github_30350n_pcb2blender/icon.png b/3rdparty/resources/com_github_30350n_pcb2blender/icon.png new file mode 100644 index 0000000..015c3d2 Binary files /dev/null and b/3rdparty/resources/com_github_30350n_pcb2blender/icon.png differ diff --git a/3rdparty/resources/com_github_bennymeg_JLC-Plugin-for-KiCad/icon.png b/3rdparty/resources/com_github_bennymeg_JLC-Plugin-for-KiCad/icon.png new file mode 100644 index 0000000..0ce6f12 Binary files /dev/null and b/3rdparty/resources/com_github_bennymeg_JLC-Plugin-for-KiCad/icon.png differ