From b2d8f5dd92e03bbb1b0ba5ac69febc77494d2c29 Mon Sep 17 00:00:00 2001 From: Timothy Yin Date: Fri, 21 Mar 2025 13:41:39 +0800 Subject: [PATCH] update plugins --- .DS_Store | Bin 10244 -> 10244 bytes 3rdparty/.DS_Store | Bin 8196 -> 8196 bytes 3rdparty/plugins/.DS_Store | Bin 8196 -> 10244 bytes .../com_github_30350n_pcb2blender/__init__.py | 24 + .../com_github_30350n_pcb2blender/export.py | 319 +++++++++++ .../images/blender_icon_32x32.png | Bin 0 -> 1432 bytes .../com_github_30350n_pcb2blender/ui.py | 108 ++++ .../__init__.py | 8 + .../cli.py | 36 ++ .../config.py | 26 + .../events.py | 14 + .../icon.png | Bin 0 -> 1391 bytes .../options.py | 8 + .../plugin.py | 151 ++++++ .../process.py | 510 ++++++++++++++++++ .../thread.py | 158 ++++++ .../transformations.csv | 52 ++ .../utils.py | 101 ++++ 3rdparty/resources/.DS_Store | Bin 10244 -> 10244 bytes .../com_github_30350n_pcb2blender/icon.png | Bin 0 -> 9155 bytes .../icon.png | Bin 0 -> 3753 bytes 21 files changed, 1515 insertions(+) create mode 100644 3rdparty/plugins/com_github_30350n_pcb2blender/__init__.py create mode 100644 3rdparty/plugins/com_github_30350n_pcb2blender/export.py create mode 100644 3rdparty/plugins/com_github_30350n_pcb2blender/images/blender_icon_32x32.png create mode 100644 3rdparty/plugins/com_github_30350n_pcb2blender/ui.py create mode 100644 3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/__init__.py create mode 100644 3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/cli.py create mode 100644 3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/config.py create mode 100644 3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/events.py create mode 100644 3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/icon.png create mode 100644 3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/options.py create mode 100644 3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/plugin.py create mode 100644 3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/process.py create mode 100644 3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/thread.py create mode 100644 3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/transformations.csv create mode 100644 3rdparty/plugins/com_github_bennymeg_JLC-Plugin-for-KiCad/utils.py create mode 100644 3rdparty/resources/com_github_30350n_pcb2blender/icon.png create mode 100644 3rdparty/resources/com_github_bennymeg_JLC-Plugin-for-KiCad/icon.png diff --git a/.DS_Store b/.DS_Store index d8411a0944d9a78876c30d11798f534fd3d8072c..247f13012a63d05952a807c65b35d69d5c1768cf 100644 GIT binary patch delta 573 zcmZn(XbG6$FQ~!5z`)4BAi$85ZWx@LpIflmP+~dzW_Au1j(U(B3xgg*IzuKy2}~X+ z#=z<_!vQASu>6ub3~O-!7E>o0kIpXhk;FJ1Bl{aFrI8K$TwL* zfSYmGWJA$H9;xbTLt_hL9R))(gUM?}Wf*%VpAwa4?44{VC^Gq{z+pnl%7he|JN}nV zo+YHj+|WLA@_r#zC@ntupAaw8$qAErg)MoK%8Lu2ej#A5o^beNLt#;^vf!e;ocz3W zpsRr)$-vG~z>vdG3J$eAhGOE)YZCUJ+$zk^gKXa9Q^N8DJhpkU$VZON>v?)-ak(Ruq((Y$KsE`LmE5bNiV`lRpTFOx6(QV`j)upPVQxJ~;r+ zR$)GVfB`H&M_6U@JQ4fJH$^=rr-}^@mtM2?k#p`m@|0?eB65sO&xY~*yY{RCl`o_PL3Dhf%BA@lqYPSA|l7gw3Ti0JyD6t sXGFPJwQgSmD!nbL$^m8_JLWjKPgHEOoER??!-vhfVy*0?sJ_k!0QXBYr~m)} diff --git a/3rdparty/plugins/.DS_Store b/3rdparty/plugins/.DS_Store index 8e2ddc72b513a978852118c442c942a1269a42d9..d88879647e8ed3b5930a50981ba134cceeada9d3 100644 GIT binary patch literal 10244 zcmeI1J#Q015Qg8yDIx_VN+cj06=@<8A__Va8$loil7%Qk{FMZJzLWET=uQ+sNr&K1 z&`?2%lpg?pfQ}jokmz`4cjN5ExXGO*6wO+@bNex~chBzaX~qD|@@{((hyYA8i#&Up z#XpktOCCw5e9JjXLw%r&0XqB*kc^Dh1|^^blz!K?Icbj$7S;uzoMN+0yrv&ubs{gTUS>{ORoZmkgYl}yyBNl)vp z^BmX1E?e2x+gNP0k~kH!m0#cEBDUhBUT^l2CT&c={`mFNoBjQp#q709wU=%N&-%N{ zTf;>%Nf48d(AdEyxinc@!UFSkmVMSzj%wl>bB~dcJ{dAT5|RUIe~MWP*#X&$w~k$b z`-_u9dgWLQ`GHWF&X3_I`)r+DpJMyS-m{$Z>~1_izNI`jy#M*x?zv;$SDsl>Yk6Lo z@R}RLt7-RDAJ53FW8c@iSVfKR>ixram9;K+>wxkecWX`F<~+#{S0^z?o^kfl@6FPm zB~O2H^W@gWL_~(!G3(Ocnb0Biu3evbJmhZP;u@u7KVaW&Jito029?<#t3~aFyx+b0 za&V3oy2p@hSvEtCl}q+KxMKIGd~XsmlCyH#l9lV!#*`oPszq{ZSYfZw9gwB8Y+;YJ zJG_dy=a=PN4#{FB`bM%P&k(X+x;$iE`YeZM@#JO+*8^u32uqU^Py$Lo2`B+2Z~_Tb z+%rWI|Gzc<|Nj&CQo5k_Ci^Z)+>>S$@2 delta 113 zcmZn(XmOBWU|?W$DortDU;r^WfEYvza8E20o2aMAD6lbLH$S7mW*z}+#>p3j%qQ;? z`Z@WtsO{z+(O_0VW}s3akl+Rqt{`0-3%@f@=2r>iV1$^(Fgc!Q>g0TJ`^_Q}Czt>s CWf(mG 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 0000000000000000000000000000000000000000..090b999770010e119ae6aaf4d0b5a1c9630afba2 GIT binary patch literal 1432 zcmV;J1!ww+P)VYY*vKU%Y8eg&F5bHn*tA;k=_s>#ud_7QS#k&EkR_$~Dj^=30P@&EtJg#bb zR(M)?c0H?Eyer(LCc{zTb77Y*7Zkp?^hk-{EKZd8$6`Gg@PVyeSV>fIq>9y~o)(su z<4mV=YWY6aOIl)?g?Wj+Vm%lHn8J&~43qd6VTdvDBMjhSP*&8)cNg>LSd&~;yha|K zRT3o0;u19{)ngLlqQ_*W$XosY+gBq#>?u`m@q+!uG(Y;FaR&<9rultrnuQane}=0} zC4cP*Mn6U0u4v9fXxoH~>xvfHgUc=Ge$v8vxG2?&+2V0{KZDi~blyVKa;azKeNG=C zxFKJ41N#TiFX!79L_t(o zh3%JbXjNq#$3N%ZyWP8apE)(_EM!ZYay8UOMKdq1k=V9aS)&+}AfnJ0krFj1dRlNA z;fq>9VQ5f+q96-XF=Qf^rp{7p{@GlH6Qn(ocM zDJkxnP600iyBzkTz!@dQ%E=bUXa*8cQoIGc=cpG2>P<^GO^blIh$n&VfN;2X123AE z?oCU8k|Gz_20Q@-)6n5bQnLf7F)e*D6(Jcm0(HPlz?;UE?1n?O7!|g2F~taM0Yz= zZJ^Gy^ytc&^bjg3mIBSd!m-X?f@SsOhBskk?8V)YzBKI;T{CaO-r8y z@J+J$^T207uG1e}{Vp@Y8yT@}zCO~%-lG?|_Qzk$n>&;0@DiRbTSl<_O$=Xv-j;?0 zEdYD~ti0tDP*V7TkAd~>m_JlSe%&r?8(#lpFZ+L(IOLTpLNvX)7Q=v^?awmQdC(bN zY+AY_>KuJ*noBTn}&qC=|h)=(Y3kB62N^8MN0=<2>nQNb)$ z-M@f>Ie7pKweMr7eV1}Dij`TU*CxJ&yOWO$nCBz2rIJr+gw(QWOF^fbb;J+kjfr((Ud*rvC+Appw`CmjMODPEZ3tH*g%-1MD{~ mJ(NkWbVOy}I_oz6NB9ROj1>muM@}aI0000 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 0000000000000000000000000000000000000000..7d5dd3c3cd057b41e004b1f73bf49b04a57be8dc GIT binary patch literal 1391 zcmV-#1(5oQP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1q4Y%K~zXfm6m&K zQ&k+tzqh@&&)eIU($0i35c| z4C;T17(xsvL7XUyLZtAhh&6Q|4-SB&EzyF}3($w4}mBc0f5<2-# zq?i;uZV!xBBcfCkEj=v=Ng)K8APi0eyd_?EialWGvdRV>*E$sYFjN#MaB39}xH!}o zPQpHKK0FpXB8doE{%OHbbO<_)4#low6c-djtx{)KQdu(|s?3ceGD}TbxHW22&@n7Y z_;BUc74UH$td7Nk!UdRWpQ*?8okWHwaR$-o=R-VTrE0C73{MI0$`%qe^niYKFH@LIkNpXPJeoo~Ya zDIA_QY7~djI7oONgrX)OkilUvll!o?%P6vMeY1$uopJBEy7gO#>p_{+VLF{gLP#N% z5UCPH_AI*aygAA=F) zx(MI5KIcjzL6S(T!5Vd#JlTu_mmMWuCsr<+L*9hR`oz0>hc2zy$cYh>;88FZ4m4@; z&O1uqG9Dj^@S zdBhx-g`_)tr5$l!ATtbE6mk2jqe%F>p(Dq2&Y=&$&hqZ4-?OfVj|qopQtf6|r}&Nz zQFFT=t~_hkh%%IAIrGnA9kH3tj%%;)Qce+DRESx1+cBV$e{*|0iyJ=MY!*{uH4#~9 zVYTICjp+e_%yGO$wm3nls3Ri;9z>R;lYHCtiox?ug1zD?t!OpvC&jjNad~+t=c3TC xcT=7W=^0wBUQGnr$fl>r&%0Q 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 6e98eb4ff3395de8caf7e85293689b70a3c432d5..c6ea5c650801b3fe26fc55ae7b8dbac9001f4907 100644 GIT binary patch delta 645 zcmZn(XbG6$C7U^hRb%4Qw`Yeq)d$qxj@&5aoh7>pTAfn**-JVOCPGD8xB5fJAv zqyj}!fMiilx?yl~er^E+7$_eGQeeH-MCi@UcX3I|$xj06;b35BXq$QLm?LTWQ*i1x zCc^%L3}m+(Fih?fwAx%JD9GrU1omJaLnT8lkd+SfycdHHgENCJLjch8r9hEPU;yYc zq%q_(6ai^(Am5oGks$>&IQUjhb`X~KCfhW$0BWCkY_grOEG6cl1Q^sjOS0X!IYsC+ Y*JgHwUo6(~z+?eR6l8=M(3BHQ03dCOWdHyG delta 55 zcmV-70LcG@P=rvBPXQdUP`eKS9J34%I{}l?5;wEH5)lED%N04ZpcQ8dv4GeDvj-sh N1d~e`NwXptx&jo56Dj}z 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 0000000000000000000000000000000000000000..015c3d2dc60f19cf01590f2eeb3e201f8bf6ac05 GIT binary patch literal 9155 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 T^lF#F00000NkvXXu0mjfc?l4T literal 0 HcmV?d00001