Added internal plot of the worksheet to pcb_print

- Also renamed some options to make it simpler (sorry)
This commit is contained in:
Salvador E. Tropea 2022-04-16 14:48:35 -03:00
parent 9c2f8ccd58
commit 4b341a7a92
12 changed files with 830 additions and 67 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

71
kibot/kicad/pcb.py Normal file
View File

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

View File

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

511
kibot/kicad/worksheet.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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