From 5298dc2c724e1c7eb87b01c5607083e961edd752 Mon Sep 17 00:00:00 2001 From: John Beard Date: Fri, 1 Jun 2018 23:56:22 +0100 Subject: [PATCH] Getting there --- docs/samples/generic_plot.kiplot.yaml | 31 +++- kiplot/__main__.py | 27 +++- kiplot/config_reader.py | 212 +++++++++++++++++++++++++- kiplot/kiplot.py | 148 +++++++++++++++++- kiplot/plot_config.py | 108 +++++++++++++ 5 files changed, 518 insertions(+), 8 deletions(-) create mode 100644 kiplot/plot_config.py diff --git a/docs/samples/generic_plot.kiplot.yaml b/docs/samples/generic_plot.kiplot.yaml index 8b320ebd..0284464a 100644 --- a/docs/samples/generic_plot.kiplot.yaml +++ b/docs/samples/generic_plot.kiplot.yaml @@ -2,10 +2,35 @@ kiplot: version: 1 +options: + basedir: /tmp/kiplot_out + outputs: - - 'gerbers': + - name: 'gerbers' comment: "Gerbers for the board house" + type: gerber + dir: gerberdir + options: + subtract_mask_from_silk: true + use_protel_extensions: false + line_width: 0.15 + exclude_edge_layer: false + exclude_pads_from_silkscreen: false + use_aux_axis_as_origin: false layers: - - F.Cu - - F.SilkS \ No newline at end of file + - layer: F.Cu + suffix: F_Cu + - layer: F.SilkS + suffix: F_SilkS + # - layer: Inner.1 + # suffix: Inner_1 + + - name: excellon_drill + comment: "Excellon drill files" + type: excellon + dir: gerberdir + options: + metric_units: true + pth_and_npth_single_file: true + use_aux_axis_as_origin: false \ No newline at end of file diff --git a/kiplot/__main__.py b/kiplot/__main__.py index 2cd40dda..8574f051 100644 --- a/kiplot/__main__.py +++ b/kiplot/__main__.py @@ -2,22 +2,45 @@ import argparse import logging +import os +import sys from . import kiplot +from . import config_reader def main(): - parser = argparse.ArgumentParser(description='Command-line Plotting for KiCad') + parser = argparse.ArgumentParser( + description='Command-line Plotting for KiCad') parser.add_argument('-v', '--verbose', action='store_true', help='show debugging information') + parser.add_argument('-b', '--board-file', required=True, + help='The PCB .kicad-pcb board file') + parser.add_argument('-c', '--plot-config', required=True, + help='The plotting config file to use') args = parser.parse_args() log_level = logging.DEBUG if args.verbose else logging.INFO logging.basicConfig(level=log_level) + if not os.path.isfile(args.board_file): + logging.error("Board file not found: {}".format(args.board_file)) + + if not os.path.isfile(args.plot_config): + logging.error("Plot config file not found: {}" + .format(args.plot_config)) + + cr = config_reader.CfgYamlReader() + + with open(args.plot_config) as cf_file: + cfg = cr.read(cf_file) + + plotter = kiplot.Plotter(cfg) + + plotter.plot(args.board_file) + if __name__ == "__main__": main() - \ No newline at end of file diff --git a/kiplot/config_reader.py b/kiplot/config_reader.py index 13086a86..6c59010a 100644 --- a/kiplot/config_reader.py +++ b/kiplot/config_reader.py @@ -2,10 +2,220 @@ Class to read KiPlot config files """ +import logging import yaml +import os +import re + +import pcbnew + +from . import plot_config as PC class CfgReader(object): def __init__(self): - pass \ No newline at end of file + pass + + +class CfgYamlReader(CfgReader): + + class YamlError(Exception): + pass + + def __init__(self): + super(CfgYamlReader, self).__init__() + + def _check_version(self, data): + + try: + version = data['kiplot']['version'] + except KeyError: + raise self.YamlError("YAML config needs kiplot.version.") + return None + + if version != 1: + raise self.YamlError("Unknown KiPlot config version: {}" + .format(version)) + return None + + return version + + def _get_required(self, data, key): + + try: + val = data[key] + except KeyError: + raise self.YamlError("Value is needed for {}".format(key)) + + return val + + def _parse_out_opts(self, otype, options): + + po = PC.OutputOptions(otype) + + # options that apply to the specific output type + to = po.type_options + + # common options - layer outputs + if otype in ['gerber']: + to.line_width = self._get_required(options, 'line_width') + + to.exclude_edge_layer = self._get_required( + options, 'exclude_edge_layer') + to.exclude_pads_from_silkscreen = self._get_required( + options, 'exclude_pads_from_silkscreen') + to.use_aux_axis_as_origin = self._get_required( + options, 'use_aux_axis_as_origin') + + # common options - drill outputs + if otype in ['excellon']: + to.use_aux_axis_as_origin = self._get_required( + options, 'use_aux_axis_as_origin') + + # set type-specific options + if otype == 'gerber': + to.subtract_mask_from_silk = self._get_required( + options, 'subtract_mask_from_silk') + to.use_protel_extensions = self._get_required( + options, 'use_protel_extensions') + + if otype == 'excellon': + to.metric_units = self._get_required( + options, 'metric_units') + to.pth_and_npth_single_file = self._get_required( + options, 'pth_and_npth_single_file') + + return po + + def _get_layer_from_str(self, s): + """ + Get the pcbnew layer from a string in the config + """ + + D = { + 'F.Cu': pcbnew.F_Cu, + 'B.Cu': pcbnew.B_Cu, + 'F.Adhes': pcbnew.F_Adhes, + 'B.Adhes': pcbnew.B_Adhes, + 'F.Paste': pcbnew.F_Paste, + 'B.Paste': pcbnew.B_Paste, + 'F.SilkS': pcbnew.F_SilkS, + 'B.SilkS': pcbnew.B_SilkS, + 'F.Mask': pcbnew.F_Mask, + 'B.Mask': pcbnew.B_Mask, + 'Dwgs.User': pcbnew.Dwgs_User, + 'Cmts.User': pcbnew.Cmts_User, + 'Eco1.User': pcbnew.Eco1_User, + 'Eco2.User': pcbnew.Eco2_User, + 'Edge.Cuts': pcbnew.Edge_Cuts, + 'Margin': pcbnew.Margin, + 'F.CrtYd': pcbnew.F_CrtYd, + 'B.CrtYd': pcbnew.B_CrtYd, + 'F.Fab': pcbnew.F_Fab, + 'B.Fab': pcbnew.B_Fab, + } + + layer = None + + if s in D: + layer = PC.LayerInfo(D[s], False) + elif s.startswith("Inner"): + m = re.match(r"^Inner\.([0-9]+)$", s) + + if not m: + raise self.YamlError("Malformed inner layer name: {}" + .format(s)) + + layer = PC.LayerInfo(int(m.group(1)), True) + else: + raise self.YamlError("Unknown layer name: {}".format(s)) + + return layer + + def _parse_layer(self, l_obj): + + l_str = self._get_required(l_obj, 'layer') + layer_id = self._get_layer_from_str(l_str) + layer = PC.LayerConfig(layer_id) + + layer.desc = l_obj['description'] if 'description' in l_obj else None + layer.suffix = l_obj['suffix'] if 'suffix' in l_obj else "" + + return layer + + def _parse_output(self, o_obj): + + try: + name = o_obj['name'] + except KeyError: + raise self.YamlError("Output needs a name") + + try: + desc = o_obj['description'] + except KeyError: + desc = None + + try: + otype = o_obj['type'] + except KeyError: + raise self.YamlError("Output needs a type") + + if otype not in ['gerber', 'excellon']: + raise self.YamlError("Unknown output type: {}".format(otype)) + + try: + options = o_obj['options'] + except KeyError: + raise self.YamlError("Output need to have options specified") + + outdir = self._get_required(o_obj, 'dir') + + output_opts = self._parse_out_opts(otype, options) + + o_cfg = PC.PlotOutput(name, desc, otype, output_opts) + o_cfg.outdir = outdir + + try: + layers = o_obj['layers'] + except KeyError: + layers = [] + + for l in layers: + o_cfg.layers.append(self._parse_layer(l)) + + return o_cfg + + def read(self, fstream): + """ + Read a file object into a config object + + :param fstream: file stream of a config YAML file + """ + + try: + data = yaml.load(fstream) + except yaml.YAMLError as e: + raise self.YamlError("Error loading YAML") + return None + + self._check_version(data) + + try: + outdir = data['options']['basedir'] + except KeyError: + outdir = "" + + # relative to CWD (absolute path overrides) + outdir = os.path.join(os.getcwd(), outdir) + + cfg = PC.PlotConfig() + + cfg.outdir = outdir + + for o in data['outputs']: + + op_cfg = self._parse_output(o) + cfg.add_output(op_cfg) + + return cfg diff --git a/kiplot/kiplot.py b/kiplot/kiplot.py index 9fbe3c7a..5e400878 100644 --- a/kiplot/kiplot.py +++ b/kiplot/kiplot.py @@ -2,8 +2,152 @@ Main Kiplot code """ +import logging +import os -class KiPlot(object): +from . import plot_config as PCfg + +try: + import pcbnew +except ImportError: + logging.error("Failed to import pcbnew Python module." + " Do you need to add it to PYTHONPATH?") + raise + + +class Plotter(object): + """ + Main Plotter class - this is what will perform the plotting + """ + + def __init__(self, cfg): + self.cfg = cfg + + def plot(self, brd_file): + logging.debug("Starting plot of board {}".format(brd_file)) + + board = pcbnew.LoadBoard(brd_file) + + logging.debug("Board loaded") + + for op in self.cfg.outputs: + + logging.debug("Processing output: {}".format(op.name)) + + # fresh plot controller + pc = pcbnew.PLOT_CONTROLLER(board) + + if self._output_is_layer(op): + self._do_layer_plot(board, pc, op) + elif self._output_is_drill(op): + self._do_drill_plot(board, pc, op) + else: + raise ValueError("Don't know how to plot type {}" + .format(op.options.type)) + + pc.ClosePlot() + + def _output_is_layer(self, output): + + return output.options.type in [PCfg.OutputOptions.GERBER] + + def _output_is_drill(self, output): + + return output.options.type in [PCfg.OutputOptions.EXCELLON] + + def _get_plot_format(self, output): + """ + Gets the Pcbnew plot format for a given KiPlot output type + """ + + if output.options.type == PCfg.OutputOptions.GERBER: + return pcbnew.PLOT_FORMAT_GERBER + + raise ValueError("Don't know how to translate plot type: {}" + .format(output.options.type)) + + def _do_layer_plot(self, board, plot_ctrl, output): + + self._configure_plot_ctrl(plot_ctrl, output) + + layer_cnt = board.GetCopperLayerCount() + + # plot every layer in the output + for l in output.layers: + + layer = l.layer + suffix = l.suffix + desc = l.desc + + # for inner layers, we can now check if the layer exists + if layer.is_inner: + + if layer.layer < 1 or layer.layer >= layer_cnt - 1: + raise ValueError( + "Inner layer {} is not valid for this board" + .format(layer.layer)) + + # Set current layer + plot_ctrl.SetLayer(layer.layer) + + # Plot single layer to file + plot_format = self._get_plot_format(output) + plot_ctrl.OpenPlotfile(suffix, plot_format, desc) + logging.debug("Plotting layer {} to {}".format( + layer.layer, plot_ctrl.GetPlotFileName())) + plot_ctrl.PlotLayer() + + def _do_drill_plot(self, board, plot_ctrl, output): - def __init__(self): pass + + def _configure_gerber_opts(self, po, output): + + # true if gerber + po.SetUseGerberAttributes(True) + + assert(output.options.type == PCfg.OutputOptions.GERBER) + gerb_opts = output.options.type_options + + po.SetSubtractMaskFromSilk(gerb_opts.subtract_mask_from_silk) + po.SetUseGerberProtelExtensions(gerb_opts.use_protel_extensions) + + def _configure_plot_ctrl(self, plot_ctrl, output): + + logging.debug("Configuring plot controller for output") + + po = plot_ctrl.GetPlotOptions() + + opts = output.options.type_options + + # Set some important plot options: + po.SetPlotFrameRef(False) + # Line width for items without one defined + po.SetLineWidth(opts.line_width) + + po.SetAutoScale(False) # do not change it + po.SetScale(1) # do not change it + po.SetMirror(False) + + po.SetExcludeEdgeLayer(opts.exclude_edge_layer) + po.SetPlotPadsOnSilkLayer(not opts.exclude_pads_from_silkscreen) + po.SetUseAuxOrigin(opts.use_aux_axis_as_origin) + + po.SetUseGerberAttributes(False) + + if output.options.type == PCfg.OutputOptions.GERBER: + self._configure_gerber_opts(po, output) + + # Disable plot pad holes + po.SetDrillMarksType(pcbnew.PCB_PLOT_PARAMS.NO_DRILL_SHAPE) + # Skip plot pad NPTH when possible: when drill size and shape == pad size + # and shape + # usually sel to True for copper layers + po.SetSkipPlotNPTH_Pads(False) + + # outdir is a combination of the config and output + outdir = os.path.join(self.cfg.outdir, output.outdir) + + logging.debug("Output destination: {}".format(outdir)) + + po.SetOutputDirectory(outdir) diff --git a/kiplot/plot_config.py b/kiplot/plot_config.py new file mode 100644 index 00000000..6ab2baed --- /dev/null +++ b/kiplot/plot_config.py @@ -0,0 +1,108 @@ + +import pcbnew + + +class LayerOptions(object): + """ + Common options that all layer outputs have + """ + def __init__(self): + self._line_width = None + self.exclude_edge_layer = False + self.exclude_pads_from_silkscreen = False + + @property + def line_width(self): + return self._line_width + + @line_width.setter + def line_width(self, value): + """ + Set the line width, in mm + """ + self._line_width = pcbnew.FromMM(value) + + +class GerberOptions(LayerOptions): + + def __init__(self): + + super(GerberOptions, self).__init__() + + self.subtract_mask_from_silk = False + self.use_protel_extensions = False + + +class DrillOptions(object): + + def __init__(self): + pass + + +class ExcellonOptions(DrillOptions): + + def __init__(self): + + super(ExcellonOptions, self).__init__() + + self.metric_units = True + + +class OutputOptions(object): + + GERBER = 'gerber' + EXCELLON = 'excellon' + + def __init__(self, otype): + self.type = otype + + if otype == self.GERBER: + self.type_options = GerberOptions() + elif otype == self.EXCELLON: + self.type_options = ExcellonOptions() + else: + self.type_options = None + + +class LayerInfo(object): + + def __init__(self, layer, is_inner): + + self.layer = layer + self.is_inner = is_inner + + +class LayerConfig(object): + + def __init__(self, layer): + + # the Pcbnew layer + self.layer = layer + self.suffix = "" + self.desc = "desc" + + +class PlotOutput(object): + + def __init__(self, name, description, otype, options): + self.name = name + self.description = description + self.outdir = None + self.options = options + + self.layers = [] + + +class PlotConfig(object): + + def __init__(self): + + self._outputs = [] + self.outdir = None + + def add_output(self, new_op): + self._outputs.append(new_op) + + @property + def outputs(self): + return self._outputs