397 lines
16 KiB
Python
397 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright (c) 2020-2021 Salvador E. Tropea
|
|
# Copyright (c) 2020-2021 Instituto Nacional de Tecnología Industrial
|
|
# License: GPL-3.0
|
|
# Project: KiBot (formerly KiPlot)
|
|
import os
|
|
from copy import deepcopy
|
|
from .gs import GS
|
|
from .kiplot import load_sch, get_board_comps_data
|
|
from .misc import Rect, KICAD_VERSION_5_99, W_WRONGPASTE
|
|
if GS.kicad_version_n >= KICAD_VERSION_5_99: # pragma: no cover (Ki6)
|
|
# New name, no alias ...
|
|
from pcbnew import FP_SHAPE, wxPoint, LSET
|
|
else:
|
|
from pcbnew import EDGE_MODULE, wxPoint, LSET
|
|
from .registrable import RegOutput
|
|
from .optionable import Optionable, BaseOptions
|
|
from .fil_base import BaseFilter, apply_fitted_filter, reset_filters
|
|
from .macros import macros, document # noqa: F401
|
|
from .error import KiPlotConfigurationError
|
|
from . import log
|
|
|
|
logger = log.get_logger(__name__)
|
|
|
|
|
|
class BaseOutput(RegOutput):
|
|
def __init__(self):
|
|
super().__init__()
|
|
with document:
|
|
self.name = ''
|
|
""" Used to identify this particular output definition """
|
|
self.type = ''
|
|
""" Type of output """
|
|
self.dir = './'
|
|
""" Output directory for the generated files. If it starts with `+` the rest is concatenated to the default dir """
|
|
self.comment = ''
|
|
""" A comment for documentation purposes """
|
|
self.extends = ''
|
|
""" Copy the options from the indicated output """
|
|
self.run_by_default = True
|
|
""" When enabled this output will be created when no specific outputs are requested """
|
|
self.disable_run_by_default = ''
|
|
""" Use it to disable the `run_by_default` status of other output.
|
|
Useful when this output extends another and you don't want to generate the original. """
|
|
if GS.global_dir:
|
|
self.dir = GS.global_dir
|
|
self._sch_related = False
|
|
self._both_related = False
|
|
self._unkown_is_error = True
|
|
self._done = False
|
|
|
|
@staticmethod
|
|
def attr2longopt(attr):
|
|
return '--'+attr.replace('_', '-')
|
|
|
|
def is_sch(self):
|
|
""" True for outputs that works on the schematic """
|
|
return self._sch_related or self._both_related
|
|
|
|
def is_pcb(self):
|
|
""" True for outputs that works on the PCB """
|
|
return (not self._sch_related) or self._both_related
|
|
|
|
def get_targets(self, out_dir):
|
|
""" Returns a list of targets generated by this output """
|
|
if not (hasattr(self, "options") and hasattr(self.options, "get_targets")):
|
|
logger.error("Output {} doesn't implement get_targets(), plese report it".format(self))
|
|
return []
|
|
return self.options.get_targets(out_dir)
|
|
|
|
def get_dependencies(self):
|
|
""" Returns a list of files needed to create this output """
|
|
if self._sch_related:
|
|
if GS.sch:
|
|
return GS.sch.get_files()
|
|
return [GS.sch_file]
|
|
return [GS.pcb_file]
|
|
|
|
def config(self, parent):
|
|
if self._tree and not self._configured and isinstance(self.extends, str) and self.extends:
|
|
logger.debug("Extending `{}` from `{}`".format(self.name, self.extends))
|
|
# Copy the data from the base output
|
|
out = RegOutput.get_output(self.extends)
|
|
if out is None:
|
|
raise KiPlotConfigurationError('Unknown output `{}` in `extends`'.format(self.extends))
|
|
if out._tree:
|
|
options = out._tree.get('options', None)
|
|
if options:
|
|
old_options = self._tree.get('options', {})
|
|
# logger.error("Old options: "+str(old_options))
|
|
options = deepcopy(options)
|
|
options.update(old_options)
|
|
self._tree['options'] = options
|
|
# logger.error("New options: "+str(options))
|
|
super().config(parent)
|
|
to_dis = self.disable_run_by_default
|
|
if to_dis:
|
|
out = RegOutput.get_output(to_dis)
|
|
if out is None:
|
|
raise KiPlotConfigurationError('Unknown output `{}` in `disable_run_by_default`'.format(to_dis))
|
|
if self.dir[0] == '+':
|
|
self.dir = (GS.global_dir if GS.global_dir is not None else './') + self.dir[1:]
|
|
if getattr(self, 'options', None) and isinstance(self.options, type):
|
|
# No options, get the defaults
|
|
self.options = self.options()
|
|
# Configure them using an empty tree
|
|
self.options.config(self)
|
|
|
|
def expand_dirname(self, out_dir):
|
|
return self.options.expand_filename_both(out_dir, is_sch=self._sch_related)
|
|
|
|
def expand_filename(self, out_dir, name):
|
|
name = self.options.expand_filename_both(name, is_sch=self._sch_related)
|
|
return os.path.abspath(os.path.join(out_dir, name))
|
|
|
|
def run(self, output_dir):
|
|
self.options.run(self.expand_filename(output_dir, self.options.output))
|
|
|
|
|
|
class BoMRegex(Optionable):
|
|
""" Implements the pair column/regex """
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._unkown_is_error = True
|
|
with document:
|
|
self.column = ''
|
|
""" Name of the column to apply the regular expression """
|
|
self.regex = ''
|
|
""" Regular expression to match """
|
|
self.field = None
|
|
""" {column} """
|
|
self.regexp = None
|
|
""" {regex} """
|
|
self.skip_if_no_field = False
|
|
""" Skip this test if the field doesn't exist """
|
|
self.match_if_field = False
|
|
""" Match if the field exists, no regex applied. Not affected by `invert` """
|
|
self.match_if_no_field = False
|
|
""" Match if the field doesn't exists, no regex applied. Not affected by `invert` """
|
|
self.invert = False
|
|
""" Invert the regex match result """
|
|
|
|
|
|
class VariantOptions(BaseOptions):
|
|
""" BaseOptions plus generic support for variants. """
|
|
def __init__(self):
|
|
with document:
|
|
self.variant = ''
|
|
""" Board variant to apply """
|
|
self.dnf_filter = Optionable
|
|
""" [string|list(string)='_none'] Name of the filter to mark components as not fitted.
|
|
A short-cut to use for simple cases where a variant is an overkill """
|
|
super().__init__()
|
|
self._comps = None
|
|
|
|
def config(self, parent):
|
|
super().config(parent)
|
|
self.variant = RegOutput.check_variant(self.variant)
|
|
self.dnf_filter = BaseFilter.solve_filter(self.dnf_filter, 'dnf_filter')
|
|
|
|
def get_refs_hash(self):
|
|
if not self._comps:
|
|
return None
|
|
return {c.ref: c for c in self._comps}
|
|
|
|
def get_fitted_refs(self):
|
|
""" List of fitted and included components """
|
|
if not self._comps:
|
|
return []
|
|
return [c.ref for c in self._comps if c.fitted and c.included]
|
|
|
|
def get_not_fitted_refs(self):
|
|
""" List of 'not fitted' components, also includes 'not included' """
|
|
if not self._comps:
|
|
return []
|
|
return [c.ref for c in self._comps if not c.fitted or not c.included]
|
|
|
|
@staticmethod
|
|
def create_module_element(m):
|
|
if GS.kicad_version_n >= KICAD_VERSION_5_99:
|
|
return FP_SHAPE(m) # pragma: no cover (Ki6)
|
|
return EDGE_MODULE(m)
|
|
|
|
@staticmethod
|
|
def cross_module(m, rect, layer):
|
|
""" Draw a cross over a module.
|
|
The rect is a Rect object with the size.
|
|
The layer is which layer id will be used. """
|
|
seg1 = VariantOptions.create_module_element(m)
|
|
seg1.SetWidth(120000)
|
|
seg1.SetStart(wxPoint(rect.x1, rect.y1))
|
|
seg1.SetEnd(wxPoint(rect.x2, rect.y2))
|
|
seg1.SetLayer(layer)
|
|
seg1.SetLocalCoord() # Update the local coordinates
|
|
m.Add(seg1)
|
|
seg2 = VariantOptions.create_module_element(m)
|
|
seg2.SetWidth(120000)
|
|
seg2.SetStart(wxPoint(rect.x1, rect.y2))
|
|
seg2.SetEnd(wxPoint(rect.x2, rect.y1))
|
|
seg2.SetLayer(layer)
|
|
seg2.SetLocalCoord() # Update the local coordinates
|
|
m.Add(seg2)
|
|
return [seg1, seg2]
|
|
|
|
def cross_modules(self, board, comps_hash):
|
|
""" Draw a cross in all 'not fitted' modules using *.Fab layer """
|
|
if comps_hash is None:
|
|
return
|
|
# Cross the affected components
|
|
ffab = board.GetLayerID('F.Fab')
|
|
bfab = board.GetLayerID('B.Fab')
|
|
extra_ffab_lines = []
|
|
extra_bfab_lines = []
|
|
for m in board.GetModules():
|
|
ref = m.GetReference()
|
|
# Rectangle containing the drawings, no text
|
|
frect = Rect()
|
|
brect = Rect()
|
|
c = comps_hash.get(ref, None)
|
|
if c and c.included and not c.fitted:
|
|
# Meassure the component BBox (only graphics)
|
|
for gi in m.GraphicalItems():
|
|
if gi.GetClass() == 'MGRAPHIC':
|
|
l_gi = gi.GetLayer()
|
|
if l_gi == ffab:
|
|
frect.Union(gi.GetBoundingBox().getWxRect())
|
|
if l_gi == bfab:
|
|
brect.Union(gi.GetBoundingBox().getWxRect())
|
|
# Cross the graphics in *.Fab
|
|
if frect.x1 is not None:
|
|
extra_ffab_lines.append(self.cross_module(m, frect, ffab))
|
|
else:
|
|
extra_ffab_lines.append(None)
|
|
if brect.x1 is not None:
|
|
extra_bfab_lines.append(self.cross_module(m, brect, bfab))
|
|
else:
|
|
extra_bfab_lines.append(None)
|
|
# Remmember the data used to undo it
|
|
self.extra_ffab_lines = extra_ffab_lines
|
|
self.extra_bfab_lines = extra_bfab_lines
|
|
|
|
def uncross_modules(self, board, comps_hash):
|
|
""" Undo the crosses in *.Fab layer """
|
|
if comps_hash is None:
|
|
return
|
|
# Undo the drawings
|
|
for m in board.GetModules():
|
|
ref = m.GetReference()
|
|
c = comps_hash.get(ref, None)
|
|
if c and c.included and not c.fitted:
|
|
restore = self.extra_ffab_lines.pop(0)
|
|
if restore:
|
|
for line in restore:
|
|
m.Remove(line)
|
|
restore = self.extra_bfab_lines.pop(0)
|
|
if restore:
|
|
for line in restore:
|
|
m.Remove(line)
|
|
|
|
def remove_paste_and_glue(self, board, comps_hash):
|
|
""" Remove from solder paste layers the filtered components. """
|
|
if comps_hash is None:
|
|
return
|
|
exclude = LSET()
|
|
fpaste = board.GetLayerID('F.Paste')
|
|
bpaste = board.GetLayerID('B.Paste')
|
|
exclude.addLayer(fpaste)
|
|
exclude.addLayer(bpaste)
|
|
old_layers = []
|
|
fadhes = board.GetLayerID('F.Adhes')
|
|
badhes = board.GetLayerID('B.Adhes')
|
|
old_fadhes = []
|
|
old_badhes = []
|
|
rescue = board.GetLayerID('Rescue')
|
|
fmask = board.GetLayerID('F.Mask')
|
|
bmask = board.GetLayerID('B.Mask')
|
|
for m in board.GetModules():
|
|
ref = m.GetReference()
|
|
c = comps_hash.get(ref, None)
|
|
if c and c.included and not c.fitted:
|
|
# Remove all pads from *.Paste
|
|
old_c_layers = []
|
|
for p in m.Pads():
|
|
pad_layers = p.GetLayerSet()
|
|
is_front = fpaste in pad_layers.Seq()
|
|
old_c_layers.append(pad_layers.FmtHex())
|
|
pad_layers.removeLayerSet(exclude)
|
|
if len(pad_layers.Seq()) == 0:
|
|
# No layers at all. Ridiculous, but happends.
|
|
# At least add an F.Mask
|
|
pad_layers.addLayer(fmask if is_front else bmask)
|
|
logger.warning(W_WRONGPASTE+'Pad with solder paste, but no copper or solder mask aperture in '+ref)
|
|
p.SetLayerSet(pad_layers)
|
|
old_layers.append(old_c_layers)
|
|
# Remove any graphical item in the *.Adhes layers
|
|
for gi in m.GraphicalItems():
|
|
l_gi = gi.GetLayer()
|
|
if l_gi == fadhes:
|
|
gi.SetLayer(rescue)
|
|
old_fadhes.append(gi)
|
|
if l_gi == badhes:
|
|
gi.SetLayer(rescue)
|
|
old_badhes.append(gi)
|
|
# Store the data to undo the above actions
|
|
self.old_layers = old_layers
|
|
self.old_fadhes = old_fadhes
|
|
self.old_badhes = old_badhes
|
|
self.fadhes = fadhes
|
|
self.badhes = badhes
|
|
return exclude
|
|
|
|
def restore_paste_and_glue(self, board, comps_hash):
|
|
if comps_hash is None:
|
|
return
|
|
for m in board.GetModules():
|
|
ref = m.GetReference()
|
|
c = comps_hash.get(ref, None)
|
|
if c and c.included and not c.fitted:
|
|
restore = self.old_layers.pop(0)
|
|
for p in m.Pads():
|
|
pad_layers = p.GetLayerSet()
|
|
res = restore.pop(0)
|
|
pad_layers.ParseHex(res, len(res))
|
|
p.SetLayerSet(pad_layers)
|
|
for gi in self.old_fadhes:
|
|
gi.SetLayer(self.fadhes)
|
|
for gi in self.old_badhes:
|
|
gi.SetLayer(self.badhes)
|
|
|
|
def remove_fab(self, board, comps_hash):
|
|
""" Remove from Fab the excluded components. """
|
|
if comps_hash is None:
|
|
return
|
|
ffab = board.GetLayerID('F.Fab')
|
|
bfab = board.GetLayerID('B.Fab')
|
|
old_ffab = []
|
|
old_bfab = []
|
|
rescue = board.GetLayerID('Rescue')
|
|
for m in board.GetModules():
|
|
ref = m.GetReference()
|
|
c = comps_hash.get(ref, None)
|
|
if not c.included:
|
|
# Remove any graphical item in the *.Fab layers
|
|
for gi in m.GraphicalItems():
|
|
l_gi = gi.GetLayer()
|
|
if l_gi == ffab:
|
|
gi.SetLayer(rescue)
|
|
old_ffab.append(gi)
|
|
if l_gi == bfab:
|
|
gi.SetLayer(rescue)
|
|
old_bfab.append(gi)
|
|
# Store the data to undo the above actions
|
|
self.old_ffab = old_ffab
|
|
self.old_bfab = old_bfab
|
|
self.ffab = ffab
|
|
self.bfab = bfab
|
|
|
|
def restore_fab(self, board, comps_hash):
|
|
if comps_hash is None:
|
|
return
|
|
for gi in self.old_ffab:
|
|
gi.SetLayer(self.ffab)
|
|
for gi in self.old_bfab:
|
|
gi.SetLayer(self.bfab)
|
|
|
|
def set_title(self, title):
|
|
self.old_title = None
|
|
if title:
|
|
tb = GS.board.GetTitleBlock()
|
|
self.old_title = tb.GetTitle()
|
|
text = self.expand_filename_pcb(title)
|
|
if text[0] == '+':
|
|
text = self.old_title+text[1:]
|
|
tb.SetTitle(text)
|
|
|
|
def restore_title(self):
|
|
self.old_title = None
|
|
if self.old_title is not None:
|
|
GS.board.GetTitleBlock().SetTitle(self.old_title)
|
|
|
|
def run(self, output_dir):
|
|
""" Makes the list of components available """
|
|
if not self.dnf_filter and not self.variant:
|
|
return
|
|
load_sch()
|
|
# Get the components list from the schematic
|
|
comps = GS.sch.get_components()
|
|
get_board_comps_data(comps)
|
|
# Apply the filter
|
|
reset_filters(comps)
|
|
apply_fitted_filter(comps, self.dnf_filter)
|
|
# Apply the variant
|
|
if self.variant:
|
|
# Apply the variant
|
|
comps = self.variant.filter(comps)
|
|
self._comps = comps
|