diff --git a/Makefile b/Makefile index 8a3ab13a..bc917532 100644 --- a/Makefile +++ b/Makefile @@ -71,7 +71,7 @@ test_docker_local_1: # Also change the owner of the files to the current user (we run as root like in GitHub) #docker run --rm -it -v $(CWD):$(CWD) --workdir="$(CWD)" setsoft/kicad_auto_test:latest '/bin/bash' docker run --rm -v $(CWD):$(CWD) --workdir="$(CWD)" setsoft/kicad_auto_test:latest \ - /bin/bash -c "flake8 . --count --statistics ; python3-coverage run -a src/kibot --help-outputs > /dev/null; pytest-3 --log-cli-level debug -k 'test_print_sch_variant_ni_1' --test_dir output ; $(PY_COV) html; chown -R $(USER_ID):$(GROUP_ID) output/ tests/board_samples/ tests/.config/kiplot/plugins/__pycache__/ tests/test_plot/fake_pcbnew/__pycache__/ tests/.config/kibot/plugins/__pycache__/ .coverage htmlcov/" + /bin/bash -c "flake8 . --count --statistics ; python3-coverage run -a src/kibot --help-outputs > /dev/null; pytest-3 --log-cli-level debug -k 'test_report_simple_2' --test_dir output ; $(PY_COV) html; chown -R $(USER_ID):$(GROUP_ID) output/ tests/board_samples/ tests/.config/kiplot/plugins/__pycache__/ tests/test_plot/fake_pcbnew/__pycache__/ tests/.config/kibot/plugins/__pycache__/ .coverage htmlcov/" #$(PY_COV) report #x-www-browser htmlcov/index.html rm .coverage diff --git a/README.md b/README.md index d655bdec..e11fb5f8 100644 --- a/README.md +++ b/README.md @@ -1535,6 +1535,7 @@ Next time you need this list just use an alias, like this: - `name`: [string=''] Used to identify this particular output definition. - `options`: [dict] Options for the `pcb_print` output. * Valid keys: + - `blind_via_color`: [string=''] Color used for blind/buried `colored_vias`. - `color_theme`: [string='_builtin_classic'] Selects the color theme. Only applies to KiCad 6. To use the KiCad 6 default colors select `_builtin_default`. Usually user colors are stored as `user`, but you can give it another name. @@ -1543,12 +1544,19 @@ Next time you need this list just use an alias, like this: - `dnf_filter`: [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. - `drill_marks`: [string='full'] What to use to indicate the drill places, can be none, small or full (for real scale). - - `enable_ki6_frame_fix`: [boolean=false] KiCad 6 doesn't support custom title-block/frames from Python. - This option uses KiCad GUI to print the frame, is slow, but works. - Always enabled for KiCad 5, which crashes if we try to plot the frame. - `format`: [string='PDF'] [PDF,SVG,PNG,EPS,PS] Format for the output file/s. Note that for PS you need `ghostscript` which isn't part of the default docker images. + - `frame_plot_mechanism`: [string='internal'] [gui,internal,plot] Plotting the frame from Python is problematic. + This option selects a workaround strategy. + gui: uses KiCad GUI to do it. Is slow but you get the correct frame. + But it can't keep track of page numbers. + internal: KiBot loads the `.kicad_wks` and does the drawing work. + Best option, but some details are different from what the GUI generates. + plot: uses KiCad Python API. Only available for KiCad 6. + You get the default frame and some substitutions doesn't work. - `hide_excluded`: [boolean=false] Hide components in the Fab layer that are marked as excluded by a variant. + - `keep_temporal_files`: [boolean=false] Store the temporal page and layer files in the output dir and don't delete them. + - `micro_via_color`: [string=''] Color used for micro `colored_vias`. - `output`: [string='%f-%i%I%v.%x'] Filename for the output (%i=assembly, %x=pdf)/(%i=assembly_page_NN, %x=svg). Affected by global options. - *output_name*: Alias for output. - `pad_color`: [string=''] Color used for `colored_pads`. @@ -1583,7 +1591,7 @@ Next time you need this list just use an alias, like this: - `title`: [string=''] Text used to replace the sheet title. %VALUE expansions are allowed. If it starts with `+` the text is concatenated. - `variant`: [string=''] Board variant to apply. - - `via_color`: [string=''] Color used for `colored_vias`. + - `via_color`: [string=''] Color used for through-hole `colored_vias`. - `output_id`: [string=''] Text to use for the %I expansion content. To differentiate variations of this output. - `run_by_default`: [boolean=true] When enabled this output will be created when no specific outputs are requested. diff --git a/docs/samples/generic_plot.kibot.yaml b/docs/samples/generic_plot.kibot.yaml index d367e3b5..44cd80ec 100644 --- a/docs/samples/generic_plot.kibot.yaml +++ b/docs/samples/generic_plot.kibot.yaml @@ -960,6 +960,8 @@ outputs: type: 'pcb_print' dir: 'Example/pcb_print_dir' options: + # [string=''] Color used for blind/buried `colored_vias` + blind_via_color: '' # [string='_builtin_classic'] Selects the color theme. Only applies to KiCad 6. # To use the KiCad 6 default colors select `_builtin_default`. # Usually user colors are stored as `user`, but you can give it another name @@ -973,15 +975,24 @@ outputs: dnf_filter: '_none' # [string='full'] What to use to indicate the drill places, can be none, small or full (for real scale) drill_marks: 'full' - # [boolean=false] KiCad 6 doesn't support custom title-block/frames from Python. - # This option uses KiCad GUI to print the frame, is slow, but works. - # Always enabled for KiCad 5, which crashes if we try to plot the frame - enable_ki6_frame_fix: false # [string='PDF'] [PDF,SVG,PNG,EPS,PS] Format for the output file/s. # Note that for PS you need `ghostscript` which isn't part of the default docker images format: 'PDF' + # [string='internal'] [gui,internal,plot] Plotting the frame from Python is problematic. + # This option selects a workaround strategy. + # gui: uses KiCad GUI to do it. Is slow but you get the correct frame. + # But it can't keep track of page numbers. + # internal: KiBot loads the `.kicad_wks` and does the drawing work. + # Best option, but some details are different from what the GUI generates. + # plot: uses KiCad Python API. Only available for KiCad 6. + # You get the default frame and some substitutions doesn't work + frame_plot_mechanism: 'internal' # [boolean=false] Hide components in the Fab layer that are marked as excluded by a variant hide_excluded: false + # [boolean=false] Store the temporal page and layer files in the output dir and don't delete them + keep_temporal_files: false + # [string=''] Color used for micro `colored_vias` + micro_via_color: '' # [string='%f-%i%I%v.%x'] Filename for the output (%i=assembly, %x=pdf)/(%i=assembly_page_NN, %x=svg). Affected by global options output: '%f-%i%I%v.%x' # `output_name` is an alias for `output` @@ -1042,7 +1053,7 @@ outputs: title: '' # [string=''] Board variant to apply variant: '' - # [string=''] Color used for `colored_vias` + # [string=''] Color used for through-hole `colored_vias` via_color: '' # PcbDraw - Beautiful 2D PCB render: # Uses configurable colors. diff --git a/kibot/gs.py b/kibot/gs.py index 96755295..ac8193ed 100644 --- a/kibot/gs.py +++ b/kibot/gs.py @@ -268,7 +268,7 @@ class GS(object): return GS.kicad_version_n < KICAD_VERSION_5_99 @staticmethod - def expand_text_variables(text): + def expand_text_variables(text, extra_vars=None): vars = GS.load_pro_variables() new_text = '' last = 0 @@ -276,6 +276,8 @@ class GS(object): for match in GS.vars_regex.finditer(text): vname = match.group(1) value = vars.get(vname, None) + if value is None and extra_vars is not None: + value = extra_vars.get(vname, None) if value is None: value = '${'+vname+'}' logger.warning(W_UNKVAR+"Unknown text variable `{}`".format(vname)) diff --git a/kibot/kicad/config.py b/kibot/kicad/config.py index ec34eb92..40119607 100644 --- a/kibot/kicad/config.py +++ b/kibot/kicad/config.py @@ -395,34 +395,56 @@ class KiConf(object): logger.debug('Copying {} -> {}'.format(fname, dest)) copy2(fname, dest) data[key]['page_layout_descr_file'] = dest + return dest else: logger.error('Missing page layout file: '+fname) exit(MISSING_WKS) + return None - def fix_page_layout_k6(project): + def fix_page_layout_k6(project, dry): # Get the current definitions dest_dir = os.path.dirname(project) with open(project, 'rt') as f: pro_text = f.read() data = json.loads(pro_text) - KiConf.fix_page_layout_k6_key('pcbnew', data, dest_dir) - KiConf.fix_page_layout_k6_key('schematic', data, dest_dir) - with open(project, 'wt') as f: - f.write(json.dumps(data, sort_keys=True, indent=2)) + layouts = [None, None] + if not dry: + layouts[1] = KiConf.fix_page_layout_k6_key('pcbnew', data, dest_dir) + layouts[0] = KiConf.fix_page_layout_k6_key('schematic', data, dest_dir) + with open(project, 'wt') as f: + f.write(json.dumps(data, sort_keys=True, indent=2)) + else: + aux = data.get('schematic', None) + if aux: + layouts[0] = KiConf.expand_env(aux.get('page_layout_descr_file', None)) + aux = data.get('pcbnew', None) + if aux: + layouts[1] = KiConf.expand_env(aux.get('page_layout_descr_file', None)) + return layouts - def fix_page_layout_k5(project): + def fix_page_layout_k5(project, dry): order = 1 dest_dir = os.path.dirname(project) with open(project, 'rt') as f: lns = f.readlines() + is_pcb_new = False + layouts = [None, None] for c, line in enumerate(lns): + if line.startswith('[pcbnew]'): + is_pcb_new = True + if line.startswith('[schematic'): + is_pcb_new = False if line.startswith('PageLayoutDescrFile='): fname = line[20:].strip() if fname: fname = KiConf.expand_env(fname) if os.path.isfile(fname): dest = os.path.join(dest_dir, str(order)+'.kicad_wks') - copy2(fname, dest) + if not dry: + copy2(fname, dest) + layouts[is_pcb_new] = dest + else: + layouts[is_pcb_new] = fname order = order+1 else: logger.error('Missing page layout file: '+fname) @@ -430,17 +452,18 @@ class KiConf(object): else: dest = '' lns[c] = 'PageLayoutDescrFile='+dest+'\n' - with open(project, 'wt') as f: - lns = f.writelines(lns) + if not dry: + with open(project, 'wt') as f: + lns = f.writelines(lns) + return layouts - def fix_page_layout(project): + def fix_page_layout(project, dry=False): if not project: - return + return None, None KiConf.init(GS.pcb_file) if GS.ki5(): - KiConf.fix_page_layout_k5(project) - else: - KiConf.fix_page_layout_k6(project) + return KiConf.fix_page_layout_k5(project, dry) + return KiConf.fix_page_layout_k6(project, dry) def expand_env(name, used_extra=None): if used_extra is None: diff --git a/kibot/kicad/pcb.py b/kibot/kicad/pcb.py new file mode 100644 index 00000000..3d16a41f --- /dev/null +++ b/kibot/kicad/pcb.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022 Salvador E. Tropea +# Copyright (c) 2022 Instituto Nacional de TecnologĂ­a Industrial +# License: GPL-3.0 +# Project: KiBot (formerly KiPlot) +""" +KiCad v5/6 PCB format. +Currently used only for the paper size +""" +from .sexpdata import load, SExpData +from .v6_sch import _check_str, _check_symbol, _check_is_symbol_list, _check_float +PAGE_SIZE = {'A0': (841, 1189), + 'A1': (594, 841), + 'A2': (420, 594), + 'A3': (297, 420), + 'A4': (210, 297), + 'A5': (148, 210), + 'A': (215.9, 279.4), + 'B': (279.4, 431.8), + 'C': (431.8, 558.8), + 'D': (558.8, 863.6), + 'E': (863.6, 1117.6), + 'USLetter': (215.9, 279.4), + 'USLegal': (215.9, 355.6), + 'USLedger': (279.4, 431.8)} + + +class PCBError(Exception): + pass + + +class PCB(object): + def __init__(self): + super().__init__() + self.paper = 'A4' + self.paper_portrait = False + self.paper_w = self.paper_h = 0 + + @staticmethod + def load(file): + with open(file, 'rt') as fh: + error = None + try: + pcb = load(fh)[0] + except SExpData as e: + error = str(e) + if error: + raise PCBError(error) + if not isinstance(pcb, list) or pcb[0].value() != 'kicad_pcb': + raise PCBError('No kicad_pcb signature') + o = PCB() + for e in pcb[1:]: + e_type = _check_is_symbol_list(e) + if e_type == 'paper' or e_type == 'page': + o.paper = _check_str(e, 1, e_type) if e_type == 'paper' else _check_symbol(e, 1, e_type) + if o.paper == 'User': + o.paper_w = _check_float(e, 2, e_type) + o.paper_h = _check_float(e, 3, e_type) + else: + if o.paper not in PAGE_SIZE: + raise PCBError('Unknown paper size selected {}'.format(o.paper)) + size = PAGE_SIZE[o.paper] + if len(e) > 2 and _check_symbol(e, 2, e_type) == 'portrait': + o.paper_portrait = True + o.paper_w = size[0] + o.paper_h = size[1] + else: + o.paper_w = size[1] + o.paper_h = size[0] + break + return o diff --git a/kibot/kicad/v6_sch.py b/kibot/kicad/v6_sch.py index d98f64d6..4d7eced3 100644 --- a/kibot/kicad/v6_sch.py +++ b/kibot/kicad/v6_sch.py @@ -85,6 +85,17 @@ def _check_str(items, pos, name): return value +def _check_relaxed(items, pos, name): + value = _check_len(items, pos, name) + if isinstance(value, str): + return value + if isinstance(value, Symbol): + return value.value() + if isinstance(value, (float, int)): + return str(value) + raise SchError('{} is not a string, Symbol or number `{}`'.format(name, value)) + + def _check_symbol_value(items, pos, name, sym): value = _check_len(items, pos, name) if not isinstance(value, list) or not isinstance(value[0], Symbol) or value[0].value() != sym: diff --git a/kibot/kicad/worksheet.py b/kibot/kicad/worksheet.py new file mode 100644 index 00000000..0c179570 --- /dev/null +++ b/kibot/kicad/worksheet.py @@ -0,0 +1,511 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022 Salvador E. Tropea +# Copyright (c) 2022 Instituto Nacional de TecnologĂ­a Industrial +# License: GPL-3.0 +# Project: KiBot (formerly KiPlot) +# KiCad bugs: +# - Text bold doesn't work +# - Shape Line and Rect swapped +""" +KiCad v5/6 Worksheet format. +A basic implementation of the .kicad_wks file format. +Documentation: https://dev-docs.kicad.org/en/file-formats/sexpr-worksheet/ +""" +import io +from struct import unpack +from pcbnew import (wxPoint, wxSize, FromMM, GR_TEXT_HJUSTIFY_LEFT, GR_TEXT_HJUSTIFY_RIGHT, GR_TEXT_HJUSTIFY_CENTER, + GR_TEXT_VJUSTIFY_TOP, GR_TEXT_VJUSTIFY_CENTER, GR_TEXT_VJUSTIFY_BOTTOM, FILL_T_FILLED_SHAPE, + SHAPE_T_POLY, wxPointMM) +import pcbnew +from .sexpdata import load, SExpData +from .v6_sch import (_check_is_symbol_list, _check_float, _check_integer, _check_symbol_value, _check_str, _check_symbol, + _check_relaxed, _get_points, _check_symbol_str) +from ..svgutils.transform import ImageElement, GroupElement +from ..misc import W_WKSVERSION +from ..gs import GS +from .. import log + +logger = log.get_logger() +setup = None +SUP_VERSION = 20210606 + +# TODO +# - Mover los draw() a cada clase +KI5_2_KI6 = {'K': 'KICAD_VERSION', 'S': '#', 'N': '##', 'C0': 'COMMENT1', 'C1': 'COMMENT2', 'C2': 'COMMENT3', + 'C3': 'COMMENT4', 'C4': 'COMMENT5', 'C5': 'COMMENT6', 'C6': 'COMMENT7', 'C7': 'COMMENT8', + 'C8': 'COMMENT9', 'Y': 'COMPANY', 'F': 'FILENAME', 'D': 'ISSUE_DATE', 'Z': 'PAPER', 'R': 'REVISION', + 'P': 'SHEETNAME', 'T': 'TITLE'} + + +class WksError(Exception): + pass + + +def text_from_ki5(text): + for k, v in KI5_2_KI6.items(): + text = text.replace('%'+k, '${'+v+'}') + text = text.replace('%%', '%') + return text + + +def _check_mm(items, pos, name): + return FromMM(_check_float(items, pos, name)) + + +def _get_point(items, pos, sname, name): + value = _check_symbol_value(items, pos, name, sname) + ref = 'rbcorner' + if len(value) > 3: + ref = _check_symbol(value, 3, sname+' reference') + return wxPoint(_check_mm(value, 1, sname+' x'), _check_mm(value, 2, sname+' y')), ref + + +def _get_size(items, pos, sname, name): + value = _check_symbol_value(items, pos, name, sname) + return wxSize(_check_mm(value, 1, sname+' x'), _check_mm(value, 2, sname+' y')) + + +class WksSetup(object): + def __init__(self): + super().__init__() + self.text_w = self.text_h = FromMM(1.5) + self.line_width = self.text_line_width = FromMM(0.15) + self.left_margin = self.right_margin = self.top_margin = self.bottom_margin = FromMM(10) + + @staticmethod + def parse(items): + s = WksSetup() + for i in items[1:]: + i_type = _check_is_symbol_list(i) + if i_type == 'textsize': + s.text_w = _check_mm(i, 1, 'textsize width') + s.text_h = _check_mm(i, 2, 'textsize height') + elif i_type == 'linewidth': + s.line_width = _check_mm(i, 1, i_type) + elif i_type == 'textlinewidth': + s.text_line_width = _check_mm(i, 1, i_type) + elif i_type in ['left_margin', 'right_margin', 'top_margin', 'bottom_margin']: + setattr(s, i_type, _check_mm(i, 1, i_type)) + else: + raise WksError('Unknown setup attribute `{}`'.format(i)) + return s + + +class WksDrawing(object): + c_name = 'base' + + def __init__(self): + super().__init__() + self.repeat = 1 + self.incrx = self.incry = 0 + self.comment = '' + self.name = '' + self.option = '' + + def parse_fixed_args(self, items): + """ Default parser for fixed arguments. + Used when no fixed args are used. """ + return 1 + + def parse_specific_args(self, i_type, i, items, offset): + """ Default parser for arguments specific for the class. """ + raise WksError('Unknown {} attribute `{}`'.format(self.c_name, i)) + + @classmethod + def parse(cls, items): + s = cls() + offset = s.parse_fixed_args(items) + for c, i in enumerate(items[offset:]): + i_type = _check_is_symbol_list(i) + if i_type == 'repeat': + s.repeat = _check_integer(i, 1, i_type) + elif i_type in ['incrx', 'incry']: + setattr(s, i_type, _check_mm(i, 1, i_type)) + elif i_type == 'comment': + s.comment = _check_str(i, 1, i_type) + elif i_type == 'name': + s.nm = _check_relaxed(i, 1, i_type) + elif i_type == 'option': + # Not documented 2022/04/15 + s.option = _check_symbol(i, 1, i_type) + else: + s.parse_specific_args(i_type, i, items, c+offset) + return s + + +class WksLine(WksDrawing): + c_name = 'line' + + def __init__(self): + super().__init__() + self.start = wxPoint(0, 0) + self.start_ref = 'rbcorner' + self.end = wxPoint(0, 0) + self.end_ref = 'rbcorner' + self.line_width = setup.line_width + + def parse_specific_args(self, i_type, i, items, offset): + if i_type == 'linewidth': + self.line_width = _check_mm(i, 1, i_type) + elif i_type == 'start': + self.start, self.start_ref = _get_point(items, offset, i_type, self.c_name) + elif i_type == 'end': + self.end, self.end_ref = _get_point(items, offset, i_type, self.c_name) + else: + super().parse_specific_args(i_type, i, items, offset) + + +class WksRect(WksLine): + c_name = 'rect' + + def __init__(self): + super().__init__() + + +class WksFont(object): + name = 'font' + + def __init__(self): + super().__init__() + self.bold = False + self.italic = False + self.size = wxSize(setup.text_w, setup.text_h) + self.line_width = setup.text_line_width + + @staticmethod + def parse(items): + s = WksFont() + for c, i in enumerate(items[1:]): + i_type = _check_is_symbol_list(i, allow_orphan_symbol=('bold', 'italic')) + if i_type == 'bold': + s.bold = True + elif i_type == 'italic': + s.italic = True + elif i_type == 'size': + s.size = _get_size(items, c+1, i_type, WksFont.name) + elif i_type == 'linewidth': + s.line_width = _check_mm(i, 1, i_type) + else: + raise WksError('Unknown font attribute `{}`'.format(i)) + return s + + +class WksText(WksDrawing): + c_name = 'tbtext' + V_JUSTIFY = {'top': GR_TEXT_VJUSTIFY_TOP, 'bottom': GR_TEXT_VJUSTIFY_BOTTOM} + H_JUSTIFY = {'center': GR_TEXT_HJUSTIFY_CENTER, 'right': GR_TEXT_HJUSTIFY_RIGHT, 'left': GR_TEXT_HJUSTIFY_LEFT} + + def __init__(self): + super().__init__() + self.pos = wxPoint(0, 0) + self.pos_ref = 'rbcorner' + self.font = WksFont() + self.h_justify = GR_TEXT_HJUSTIFY_LEFT + self.v_justify = GR_TEXT_VJUSTIFY_CENTER + self.text = '' + self.rotate = 0 + self.max_len = 0 + self.max_height = 0 + self.incr_label = 1 + + def parse_fixed_args(self, items): + self.text = _check_relaxed(items, 1, self.c_name+' text') + return 2 + + def parse_specific_args(self, i_type, i, items, offset): + if i_type == 'rotate': + self.rotate = _check_float(i, 1, i_type) + elif i_type == 'pos': + self.pos, self.pos_ref = _get_point(items, offset, i_type, self.c_name) + elif i_type == 'justify': + # Not documented 2022/04/15 + for index in range(len(i)-1): + val = _check_symbol(i, index+1, i_type) + if val in WksText.V_JUSTIFY: + self.v_justify = WksText.V_JUSTIFY[val] + elif val in WksText.H_JUSTIFY: + self.h_justify = WksText.H_JUSTIFY[val] + else: + raise WksError('Unknown justify value `{}`'.format(val)) + elif i_type == 'font': + self.font = WksFont.parse(i) + elif i_type == 'maxlen': + # Not documented 2022/04/15 + self.max_len = _check_mm(i, 1, i_type) + elif i_type == 'maxheight': + # Not documented 2022/04/15 + self.max_height = _check_mm(i, 1, i_type) + elif i_type == 'incrlabel': + # Not documented 2022/04/15 + self.incr_label = _check_integer(i, 1, i_type) + else: + super().parse_specific_args(i_type, i, items, offset) + + +class WksPolygon(WksDrawing): + c_name = 'polygon' + + def __init__(self): + super().__init__() + self.pos = wxPoint(0, 0) + self.pos_ref = 'rbcorner' + self.rotate = 0 + self.line_width = setup.line_width + self.pts = [] + + def parse_specific_args(self, i_type, i, items, offset): + if i_type == 'rotate': + self.rotate = _check_float(i, 1, i_type) + elif i_type == 'pos': + self.pos, self.pos_ref = _get_point(items, offset, i_type, self.c_name) + elif i_type == 'linewidth': + self.line_width = _check_mm(i, 1, i_type) + elif i_type == 'pts': + self.pts.append([wxPointMM(p.x, p.y) for p in _get_points(i)]) + else: + super().parse_specific_args(i_type, i, items, offset) + + +class WksBitmap(WksDrawing): + c_name = 'bitmap' + + def __init__(self): + super().__init__() + self.pos = wxPoint(0, 0) + self.pos_ref = 'rbcorner' + self.scale = 1.0 + self.data = b'' + + def parse_specific_args(self, i_type, i, items, offset): + if i_type == 'pos': + self.pos, self.pos_ref = _get_point(items, offset, i_type, self.c_name) + elif i_type == 'scale': + self.scale = _check_float(i, 1, i_type) + elif i_type == 'pngdata': + for c in range(len(i)-1): + v = _check_symbol_str(i, c+1, self.c_name+' pngdata', 'data') + self.data += bytes([int(c, 16) for c in v.split(' ') if c]) + else: + super().parse_specific_args(i_type, i, items, offset) + + +class Worksheet(object): + def __init__(self, setup, elements, version, generator, has_images): + super().__init__() + self.setup = setup + self.elements = elements + self.version = version + self.generator = generator + self.has_images = has_images + + @staticmethod + def load(file): + with open(file, 'rt') as fh: + error = None + try: + wks = load(fh)[0] + except SExpData as e: + error = str(e) + if error: + raise WksError(error) + if not isinstance(wks, list) or (wks[0].value() != 'page_layout' and wks[0].value() != 'kicad_wks'): + raise WksError('No kicad_wks signature') + elements = [] + global setup + setup = WksSetup() + version = 0 + generator = '' + has_images = False + for e in wks[1:]: + e_type = _check_is_symbol_list(e) + if e_type == 'setup': + setup = WksSetup.parse(e) + elif e_type == 'rect': + elements.append(WksRect.parse(e)) + elif e_type == 'line': + elements.append(WksLine.parse(e)) + elif e_type == 'tbtext': + obj = WksText.parse(e) + if not version: + obj.text = text_from_ki5(obj.text) + elements.append(obj) + elif e_type == 'polygon': + elements.append(WksPolygon.parse(e)) + elif e_type == 'bitmap': + elements.append(WksBitmap.parse(e)) + has_images = True + elif e_type == 'version': + version = _check_integer(e, 1, e_type) + if version > SUP_VERSION: + logger.warning(W_WKSVERSION+"Unsupported worksheet version, loading could fail") + elif e_type == 'generator': + generator = _check_symbol(e, 1, e_type) + else: + raise WksError('Unknown worksheet attribute `{}`'.format(e_type)) + return Worksheet(setup, elements, version, generator, has_images) + + def set_page(self, pw, ph): + pw = FromMM(pw) + ph = FromMM(ph) + self.pw = pw + self.ph = ph + self.lm = self.setup.left_margin + self.tm = self.setup.top_margin + self.rm = pw-self.setup.right_margin + self.bm = ph-self.setup.bottom_margin + + def solve_ref(self, pt, inc_x, inc_y, ref): + pt = wxPoint(pt.x, pt.y) # Make a copy + if ref[0] == 'l': + pt.x += self.lm + elif ref[0] == 'r': + pt.x = self.rm-pt.x + inc_x = -inc_x + if ref[1] == 't': + pt.y += self.tm + elif ref[1] == 'b': + pt.y = self.bm-pt.y + inc_y = -inc_y + return pt, wxPoint(inc_x, inc_y) + + def check_page(self, e): + return e.option and ((e.option == 'page1only' and self.page != 1) or (e.option == 'notonpage1' and self.page == 1)) + + def draw_start_end(self, e, shape): + st, sti = self.solve_ref(e.start, e.incrx, e.incry, e.start_ref) + en, eni = self.solve_ref(e.end, e.incrx, e.incry, e.end_ref) + for _ in range(e.repeat): + s = pcbnew.PCB_SHAPE() + s.SetShape(shape) + s.SetStart(st) + s.SetEnd(en) + s.SetWidth(e.line_width) + s.SetLayer(self.layer) + self.board.Add(s) + self.pcb_items.append(s) + st += sti + en += eni + if st.x > self.rm or st.y > self.bm: + break + + def draw_line(self, e): + self.draw_start_end(e, 0) + + def draw_rect(self, e): + self.draw_start_end(e, 1) + + def draw_text(self, e): + pos, posi = self.solve_ref(e.pos, e.incrx, e.incry, e.pos_ref) + text = GS.expand_text_variables(e.text, self.tb_vars) + for _ in range(e.repeat): + s = pcbnew.PCB_TEXT(None) + s.SetText(text) + s.SetPosition(pos) + s.SetTextSize(e.font.size) + if e.font.bold: + s.SetBold(True) + s.SetTextThickness(round(e.font.line_width*2)) + else: + s.SetTextThickness(e.font.line_width) + s.SetHorizJustify(e.h_justify) + s.SetVertJustify(e.v_justify) + s.SetLayer(self.layer) + if e.font.italic: + s.SetItalic(True) + if e.rotate: + s.SetTextAngle(e.rotate*10) + # Adjust the text size to the maximum allowed + if e.max_len > 0: + w = s.GetBoundingBox().GetWidth() + if w > e.max_len: + s.SetTextWidth(round(e.font.size.x*e.max_len/w)) + if e.max_height > 0: + h = s.GetBoundingBox().GetHeight() + if h > e.max_height: + s.SetTextHeight(round(e.font.size.y*e.max_height/h)) + # Add it to the board and to the list of things to remove + self.board.Add(s) + self.pcb_items.append(s) + # Increment the position + pos += posi + if pos.x > self.rm or pos.y > self.bm: + break + # Increment the text + # This is what KiCad does ... not very cleaver + if text: + text_end = text[-1] + if text_end.isdigit(): + # Only increment the last digit "9" -> "10", "10" -> "11", "19" -> "110"?!! + text_end = str(int(text_end)+e.incr_label) + else: + text_end = chr((ord(text_end)+e.incr_label) % 256) + text = text[:-1]+text_end + else: + text = '?' + + def draw_polygon(self, e): + pos, posi = self.solve_ref(e.pos, e.incrx, e.incry, e.pos_ref) + for _ in range(e.repeat): + for pts in e.pts: + s = pcbnew.PCB_SHAPE() + s.SetShape(SHAPE_T_POLY) + s.SetFillMode(FILL_T_FILLED_SHAPE) + s.SetPolyPoints([pos+p for p in pts]) + s.SetWidth(e.line_width) + s.SetLayer(self.layer) + if e.rotate: + s.Rotate(pos, e.rotate*10) + self.board.Add(s) + self.pcb_items.append(s) + pos += posi + + def draw(self, board, layer, page, page_w, page_h, tb_vars): + self.pcb_items = [] + self.set_page(page_w, page_h) + self.layer = layer + self.board = board + self.page = page + self.tb_vars = tb_vars + self.images = [] + for e in self.elements: + if self.check_page(e): + continue + if e.c_name == 'line': + self.draw_line(e) + elif e.c_name == 'rect': + self.draw_rect(e) + elif e.c_name == 'tbtext': + self.draw_text(e) + elif e.c_name == 'polygon': + self.draw_polygon(e) + elif e.c_name == 'bitmap': + # Can we draw it using KiCad? I don't see how + # Make a list to be added to the SVG output + self.images.append(e) + + def add_images_to_svg(self, svg): + for e in self.images: + s = e.data + w, h = unpack('>LL', s[16:24]) + # For KiCad 300 dpi is 1:1 scale + dpi = 300/e.scale + # Convert pixels to mm and then to KiCad units + w = FromMM(w/dpi*25.4) + h = FromMM(h/dpi*25.4) + # KiCad informs the position for the center of the image + pos, posi = self.solve_ref(e.pos, e.incrx, e.incry, e.pos_ref) + for _ in range(e.repeat): + img = ImageElement(io.BytesIO(s), w, h) + img.moveto(pos.x-round(w/2), pos.y-round(h/2)) + # Put the image in a group + g = GroupElement([img]) + # Add the group to the SVG + svg.append(g) + # Increment the position + pos += posi + if pos.x > self.rm or pos.y > self.bm: + break + + def undraw(self, board): + for e in self.pcb_items: + board.Remove(e) diff --git a/kibot/kicad_layouts/default.kicad_wks b/kibot/kicad_layouts/default.kicad_wks new file mode 100644 index 00000000..8984edec --- /dev/null +++ b/kibot/kicad_layouts/default.kicad_wks @@ -0,0 +1,35 @@ +( page_layout + ( setup (textsize 1.5 1.5) (linewidth 0.15) (textlinewidth 0.15) + (left_margin 10) (right_margin 10) (top_margin 10) (bottom_margin 10) ) + ( rect (comment "rect around the title block") (linewidth 0.15) (start 110 34) (end 2 2) ) + ( rect (start 0 0 ltcorner) (end 0 0 rbcorner) (repeat 2) (incrx 2) (incry 2) ) + ( line (start 50 2 ltcorner) (end 50 0 ltcorner) (repeat 30) (incrx 50) ) + ( tbtext "1" (pos 25 1 ltcorner) (font (size 1.3 1.3))(repeat 100) (incrx 50) ) + ( line (start 50 2 lbcorner) (end 50 0 lbcorner) (repeat 30) (incrx 50) ) + ( tbtext "1" (pos 25 1 lbcorner) (font (size 1.3 1.3)) (repeat 100) (incrx 50) ) + ( line (start 0 50 ltcorner) (end 2 50 ltcorner) (repeat 30) (incry 50) ) + ( tbtext "A" (pos 1 25 ltcorner) (font (size 1.3 1.3)) + (justify center)(repeat 100) (incry 50) ) + ( line (start 0 50 rtcorner) (end 2 50 rtcorner) (repeat 30) (incry 50) ) + ( tbtext "A" (pos 1 25 rtcorner) (font (size 1.3 1.3)) + (justify center) (repeat 100) (incry 50) ) + ( tbtext "Date: %D" (pos 87 6.9) ) + ( line (start 110 5.5) (end 2 5.5) ) + ( tbtext "%K" (pos 109 4.1) (comment "Kicad version" ) ) + ( line (start 110 8.5) (end 2 8.5) ) + ( tbtext "Rev: %R" (pos 24 6.9)(font bold)(justify left) ) + ( tbtext "Size: %Z" (comment "Paper format name")(pos 109 6.9) ) + ( tbtext "Id: %S/%N" (comment "Sheet id")(pos 24 4.1) ) + ( line (start 110 12.5) (end 2 12.5) ) + ( tbtext "Title: %T" (pos 109 10.7)(font bold italic (size 2 2)) ) + ( tbtext "File: %F" (pos 109 14.3) ) + ( line (start 110 18.5) (end 2 18.5) ) + ( tbtext "Sheet: %P" (pos 109 17) ) + ( tbtext "%Y" (comment "Company name") (pos 109 20)(font bold) ) + ( tbtext "%C0" (comment "Comment 0") (pos 109 23) ) + ( tbtext "%C1" (comment "Comment 1") (pos 109 26) ) + ( tbtext "%C2" (comment "Comment 2") (pos 109 29) ) + ( tbtext "%C3" (comment "Comment 3") (pos 109 32) ) + ( line (start 90 8.5) (end 90 5.5) ) + ( line (start 26 8.5) (end 26 2) ) +) diff --git a/kibot/misc.py b/kibot/misc.py index e9884cfa..95b69a07 100644 --- a/kibot/misc.py +++ b/kibot/misc.py @@ -232,6 +232,7 @@ W_UNKVAR = '(W082) ' W_WRONGEXT = '(W083) ' W_COLORTHEME = '(W084) ' W_WRONGCOLOR = '(W085) ' +W_WKSVERSION = '(W086) ' # Somehow arbitrary, the colors are real, but can be different PCB_MAT_COLORS = {'fr1': "937042", 'fr2': "949d70", 'fr3': "adacb4", 'fr4': "332B16", 'fr5': "6cc290"} PCB_FINISH_COLORS = {'hal': "8b898c", 'hasl': "8b898c", 'imag': "8b898c", 'enig': "cfb96e", 'enepig': "cfb96e", diff --git a/kibot/out_pcb_print.py b/kibot/out_pcb_print.py index bc1f55f7..5e09d79e 100644 --- a/kibot/out_pcb_print.py +++ b/kibot/out_pcb_print.py @@ -18,10 +18,14 @@ from .optionable import Optionable from .out_base import VariantOptions from .kicad.color_theme import load_color_theme from .kicad.patch_svg import patch_svg_file +from .kicad.worksheet import Worksheet +from .kicad.config import KiConf +from .kicad.pcb import PCB from .misc import CMD_PCBNEW_PRINT_LAYERS, URL_PCBNEW_PRINT_LAYERS, PDF_PCB_PRINT, MISSING_TOOL from .kiplot import check_script, exec_with_retry, add_extra_options from .macros import macros, document, output_class # noqa: F401 from .layer import Layer, get_priority +from .__main__ import __version__ from . import PyPDF2 from . import log @@ -32,10 +36,8 @@ VIATYPE_THROUGH = 3 VIATYPE_BLIND_BURIED = 2 VIATYPE_MICROVIA = 1 - # - Use PyPDF2 for pdfunite # - Analyze KiCad 6 long delay -# - Manually draw the frame def _run_command(cmd): @@ -107,32 +109,6 @@ def to_inches(w): return val -def merge_svg(input_folder, input_files, output_folder, output_file, colored_holes, holes_color, monochrome): - """ Merge all pages into one """ - first = True - for (file, color) in input_files: - file = os.path.join(input_folder, file) - new_layer = fromstring(load_svg(file, color, colored_holes, holes_color, monochrome)) - width = get_width(new_layer) - if first: - svg_out = new_layer - # This is the width declared at the beginning of the file - base_width = width - phys_width = to_inches(new_layer.width) - first = False - else: - root = new_layer.getroot() - # Adjust the coordinates of this section to the main width - scale = base_width/width - if scale != 1.0: - logger.debug(' - Scaling {} by {}'.format(file, scale)) - for e in root: - e.scale(scale) - svg_out.append([root]) - svg_out.save(os.path.join(output_folder, output_file)) - return phys_width - - def create_pdf_from_pages(input_folder, input_files, output_fn): output = PyPDF2.PdfFileWriter() # Collect all pages @@ -295,10 +271,15 @@ class PCB_PrintOptions(VariantOptions): Usually user colors are stored as `user`, but you can give it another name """ self.plot_sheet_reference = True """ Include the title-block """ - self.enable_ki6_frame_fix = False - """ KiCad 6 doesn't support custom title-block/frames from Python. - This option uses KiCad GUI to print the frame, is slow, but works. - Always enabled for KiCad 5, which crashes if we try to plot the frame """ + self.frame_plot_mechanism = 'internal' + """ [gui,internal,plot] Plotting the frame from Python is problematic. + This option selects a workaround strategy. + gui: uses KiCad GUI to do it. Is slow but you get the correct frame. + But it can't keep track of page numbers. + internal: KiBot loads the `.kicad_wks` and does the drawing work. + Best option, but some details are different from what the GUI generates. + plot: uses KiCad Python API. Only available for KiCad 6. + You get the default frame and some substitutions doesn't work """ self.pages = PagesOptions """ [list(dict)] List of pages to include in the output document. Each page contains one or more layers of the PCB """ @@ -360,6 +341,8 @@ class PCB_PrintOptions(VariantOptions): self.validate_color(member) else: setattr(self, member, getattr(self._color_theme, color)) + if self.frame_plot_mechanism == 'plot' and GS.ki5(): + raise KiPlotConfigurationError("You can't use `plot` for `frame_plot_mechanism` with KiCad 5. It will crash.") def filter_components(self): if not self._comps: @@ -408,7 +391,7 @@ class PCB_PrintOptions(VariantOptions): for g in self.moved_items: g.SetLayer(self.cleared_layer) - def plot_frame_ki6(self, pc, po, p): + def plot_frame_api(self, pc, po, p): """ KiCad 6 can plot the frame because it loads the worksheet format. But not the one from the project, just a default """ self.clear_layer('Edge.Cuts') @@ -420,7 +403,49 @@ class PCB_PrintOptions(VariantOptions): pc.PlotLayer() self.restore_layer() - def plot_frame_ki5(self, dir_name, layer='Edge.Cuts'): + def fill_kicad_vars(self, page, pages, p): + vars = {} + vars['KICAD_VERSION'] = 'KiCad E.D.A. '+GS.kicad_version+' + KiBot v'+__version__ + vars['#'] = str(page) + vars['##'] = str(pages) + GS.load_pcb_title_block() + for num in range(9): + vars['COMMENT'+str(num+1)] = GS.pcb_com[num] + vars['COMPANY'] = GS.pcb_comp + vars['ISSUE_DATE'] = GS.pcb_date + vars['REVISION'] = GS.pcb_rev + # The set_title member already took care of modifying the board value + tb = GS.board.GetTitleBlock() + vars['TITLE'] = tb.GetTitle() + vars['FILENAME'] = GS.pcb_basename+'.kicad_pcb' + vars['SHEETNAME'] = p.sheet + layer = '' + for la in p.layers: + if len(layer): + layer += '+' + layer = layer+la.layer + vars['LAYER'] = layer + vars['PAPER'] = self.paper + return vars + + def plot_frame_internal(self, pc, po, p, page, pages): + """ Here we plot the frame manually """ + self.clear_layer('Edge.Cuts') + po.SetPlotFrameRef(False) + po.SetScale(1.0) + po.SetNegative(False) + pc.SetLayer(self.cleared_layer) + ws = Worksheet.load(self.layout) + tb_vars = self.fill_kicad_vars(page, pages, p) + ws.draw(GS.board, self.cleared_layer, page, self.paper_w, self.paper_h, tb_vars) + pc.OpenPlotfile('frame', PLOT_FORMAT_SVG, p.sheet) + pc.PlotLayer() + ws.undraw(GS.board) + self.restore_layer() + # We need to plot the images in a separated pass + self.last_worksheet = ws + + def plot_frame_gui(self, dir_name, layer='Edge.Cuts'): """ KiCad 5 crashes if we try to print the frame. So we print a frame using pcbnew_do export. We use SVG output to then generate a vectorized PDF. """ @@ -576,6 +601,43 @@ class PCB_PrintOptions(VariantOptions): # Add it to the list filelist.append((GS.pcb_basename+"-"+suffix+".svg", via_c)) + def add_frame_images(self, svg): + if not self.frame_plot_mechanism == 'internal' or not self.last_worksheet.has_images: + return + self.last_worksheet.add_images_to_svg(svg) + + def merge_svg(self, input_folder, input_files, output_folder, output_file, p): + """ Merge all pages into one """ + first = True + for (file, color) in input_files: + file = os.path.join(input_folder, file) + new_layer = fromstring(load_svg(file, color, p.colored_holes, p.holes_color, p.monochrome)) + width = get_width(new_layer) + if first: + svg_out = new_layer + # This is the width declared at the beginning of the file + base_width = width + phys_width = to_inches(new_layer.width) + first = False + self.add_frame_images(svg_out) + else: + root = new_layer.getroot() + # Adjust the coordinates of this section to the main width + scale = base_width/width + if scale != 1.0: + logger.debug(' - Scaling {} by {}'.format(file, scale)) + for e in root: + e.scale(scale) + svg_out.append([root]) + svg_out.save(os.path.join(output_folder, output_file)) + return phys_width + + def find_paper_size(self): + pcb = PCB.load(GS.pcb_file) + self.paper_w = pcb.paper_w + self.paper_h = pcb.paper_h + self.paper = pcb.paper + def generate_output(self, output): if self.format != 'SVG' and which(SVG2PDF) is None: logger.error('`{}` not installed. Install `librsvg2-bin` or equivalent'.format(SVG2PDF)) @@ -590,6 +652,13 @@ class PCB_PrintOptions(VariantOptions): else: temp_dir_base = mkdtemp(prefix='tmp-kibot-pcb_print-') logger.debug('- Temporal dir: {}'.format(temp_dir_base)) + self.find_paper_size() + # Find the layout file + layout = KiConf.fix_page_layout(GS.pro_file, dry=True)[1] + if not layout or not os.path.isfile(layout): + layout = os.path.abspath(os.path.join(os.path.dirname(__file__), 'kicad_layouts', 'default.kicad_wks')) + logger.debug('- Using layout: '+layout) + self.layout = layout # Plot options pc = PLOT_CONTROLLER(GS.board) po = pc.GetPlotOptions() @@ -639,13 +708,12 @@ class PCB_PrintOptions(VariantOptions): # 2) Plot the frame using an empty layer and 1.0 scale if self.plot_sheet_reference: logger.debug('- Plotting the frame') - if GS.ki6(): - if self.enable_ki6_frame_fix: - self.plot_frame_ki5(temp_dir) - else: - self.plot_frame_ki6(pc, po, p) - else: - self.plot_frame_ki5(temp_dir) + if self.frame_plot_mechanism == 'gui': + self.plot_frame_gui(temp_dir) + elif self.frame_plot_mechanism == 'plot': + self.plot_frame_api(pc, po, p) + else: # internal + self.plot_frame_internal(pc, po, p, len(pages)+1, len(self.pages)) color = p.sheet_reference_color if p.sheet_reference_color else self._color_theme.pcb_frame filelist.append((GS.pcb_basename+"-frame.svg", color)) pc.ClosePlot() @@ -656,7 +724,7 @@ class PCB_PrintOptions(VariantOptions): else: assembly_file = GS.pcb_basename+".svg" logger.debug('- Merging layers to {}'.format(assembly_file)) - merge_svg(temp_dir, filelist, temp_dir, assembly_file, p.colored_holes, p.holes_color, p.monochrome) + self.merge_svg(temp_dir, filelist, temp_dir, assembly_file, p) if self.format in ['PNG', 'EPS']: id = self._expand_id+('_page_'+page_str) out_file = self.expand_filename(output_dir, self.output, id, self._expand_ext) diff --git a/tests/yaml_samples/pcb_print_2.kibot.yaml b/tests/yaml_samples/pcb_print_2.kibot.yaml new file mode 100644 index 00000000..dcfca9fb --- /dev/null +++ b/tests/yaml_samples/pcb_print_2.kibot.yaml @@ -0,0 +1,22 @@ +# Example KiBot config file +kibot: + version: 1 + +outputs: + - name: 'print_front' + comment: "Experiment" + type: pcb_print + dir: Layers + options: + # title: 'Fake title for front copper and silk' + # color_theme: _builtin_default + # drill_marks: small + title: Chau + plot_sheet_reference: true + format: 'PDF' + keep_temporal_files: true + # enable_ki6_frame_fix: true + pages: + - layers: + - layer: Edge.Cuts + color: "#004040"