update plugins

This commit is contained in:
Timothy Yin 2025-03-21 13:41:39 +08:00
parent cd1fd4bdfa
commit b2d8f5dd92
21 changed files with 1515 additions and 0 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
3rdparty/.DS_Store vendored

Binary file not shown.

Binary file not shown.

View File

@ -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()

View File

@ -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"<svg([^>]*)width=\"[^\"]*\"[^>]*height=\"[^\"]*\"[^>]*viewBox=\"[^\"]*\"[^>]*>"
)
svg_header_sub = "<svg\\g<1>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*\)")

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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_<boardname> at its top left corner and one with "\
"PCB3D_BR_<boardname> 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_<boardA>_ONTO_<boardB>_<zoffset>\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

View File

@ -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))

View File

@ -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)

View File

@ -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
]

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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"

View File

@ -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()

View File

@ -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)

View File

@ -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))

View File

@ -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
1 Regex To Match Rotation Delta X Delta Y
2 ^Bosch_LGA- 90 0 0
3 ^CP_EIA- 180 0 0
4 ^CP_Elec_ 180 0 0
5 ^C_Elec_ 180 0 0
6 ^DFN- 270 0 0
7 ^DFN- 270 0 0
8 ^D_SOT-23 180 0 0
9 ^HTSSOP- 270 0 0
10 ^HTSSOP- 270 0 0
11 ^HTSSOP- 270 0 0
12 ^JST_GH_SM 180 0 0
13 ^JST_PH_S 180 0 0
14 ^LQFP- 270 0 0
15 ^MSOP- 270 0 0
16 ^PowerPAK_SO-8_Single 270 0 0
17 ^QFN- 90 0 0
18 ^R_Array_Concave_ 90 0 0
19 ^R_Array_Convex_ 90 0 0
20 ^SC-74-6 180 0 0
21 ^SOIC- 270 0 0
22 ^SOIC-16_ 270 0 0
23 ^SOIC-8_ 270 0 0
24 ^SOIC127P798X216-8N -90 0 0
25 ^SOP-(?!18_) 270 0 0
26 ^SOP-(?!18_) 270 0 0
27 ^SOP-18_ 0 0 0
28 ^SOP-18_ 0 0 0
29 ^SOP-4_ 0 0 0
30 ^SOP-4_ 0 0 0
31 ^SOT-143 180 0 0
32 ^SOT-223 180 0 0
33 ^SOT-23 180 0 0
34 ^SOT-353 180 0 0
35 ^SOT-363 180 0 0
36 ^SOT-89 180 0 0
37 ^SSOP- 270 0 0
38 ^SW_SPST_B3 90 0 0
39 ^TDSON-8-1 270 0 0
40 ^TO-277 90 0 0
41 ^TQFP- 270 0 0
42 ^TSOT-23 180 0 0
43 ^TSSOP- 270 0 0
44 ^UDFN-10 270 0 0
45 ^USON-10 270 0 0
46 ^VSON-8_ 270 0 0
47 ^VSSOP-10_- 270 0 0
48 ^VSSOP-10_- 270 0 0
49 ^VSSOP-8_ 180 0 0
50 ^VSSOP-8_ 270 0 0
51 ^VSSOP-8_3.0x3.0mm_P0.65mm 270 0 0
52 ^qfn- 90 0 0

View File

@ -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()

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB