511 lines
23 KiB
Python
511 lines
23 KiB
Python
# 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)
|