KiBot/kibot/out_pcbdraw.py

581 lines
24 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2020-2023 Salvador E. Tropea
# Copyright (c) 2020-2023 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
"""
Dependencies:
- from: RSVG
role: Create PNG, JPG and BMP images
- from: ImageMagick
role: Create JPG and BMP images
- from: LXML
role: mandatory
- name: numpy
python_module: true
debian: python3-numpy
arch: python-numpy
downloader: python
role: Automatically adjust SVG margin
"""
import os
import subprocess
from tempfile import NamedTemporaryFile
# Here we import the whole module to make monkeypatch work
from .error import KiPlotConfigurationError
from .kiplot import load_sch, get_board_comps_data
from .misc import (PCBDRAW_ERR, PCB_MAT_COLORS, PCB_FINISH_COLORS, SOLDER_COLORS, SILK_COLORS, W_PCBDRAW)
from .gs import GS
from .layer import Layer
from .optionable import Optionable
from .out_base import VariantOptions, PcbMargin
from .macros import macros, document, output_class # noqa: F401
from . import log
logger = log.get_logger()
def mm2ki(val: float) -> int:
return int(val * 1000000)
def pcbdraw_warnings(tag, msg):
logger.warning('{}({}) {}'.format(W_PCBDRAW, tag, msg))
def _get_tmp_name(ext):
with NamedTemporaryFile(mode='w', suffix=ext, delete=False) as f:
f.close()
return f.name
def _run_command(cmd):
logger.debug('Executing: '+GS.pasteable_cmd(cmd))
try:
cmd_output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
logger.error('Failed to run %s, error %d', cmd[0], e.returncode)
if e.output:
logger.debug('Output from command: '+e.output.decode())
exit(PCBDRAW_ERR)
out = cmd_output.decode()
if out.strip():
logger.debug('Output from command:\n'+out)
class PcbDrawStyle(Optionable):
def __init__(self):
super().__init__()
with document:
self.copper = "#285e3a"
""" *Color for the copper zones (covered by solder mask) """
self.board = "#208b47"
""" *Color for the board without copper (covered by solder mask) """
self.silk = "#d5dce4"
""" *Color for the silk screen """
self.pads = "#8b898c"
""" *Color for the exposed pads (metal finish) """
self.outline = "#000000"
""" *Color for the outline """
self.clad = "#cabb3e"
""" *Color for the PCB core (not covered by solder mask) """
self.vcut = "#bf2600"
""" Color for the V-CUTS """
self.highlight_on_top = False
""" Highlight over the component (not under) """
self.highlight_style = "stroke:none;fill:#ff0000;opacity:0.5;"
""" SVG code for the highlight style """
self.highlight_padding = 1.5
""" [0,1000] How much the highlight extends around the component [mm] """
def config(self, parent):
# Apply global defaults
# PCB Material
if GS.global_pcb_material is not None:
material = GS.global_pcb_material.lower()
for mat, color in PCB_MAT_COLORS.items():
if mat in material:
self.clad = "#"+color
break
# Solder mask
if parent.bottom:
name = GS.global_solder_mask_color_bottom or GS.global_solder_mask_color
else:
name = GS.global_solder_mask_color_top or GS.global_solder_mask_color
if name and name.lower() in SOLDER_COLORS:
(self.copper, self.board) = SOLDER_COLORS[name.lower()]
# Silk screen
if parent.bottom:
name = GS.global_silk_screen_color_bottom or GS.global_silk_screen_color
else:
name = GS.global_silk_screen_color_top or GS.global_silk_screen_color
if name and name.lower() in SILK_COLORS:
self.silk = "#"+SILK_COLORS[name.lower()]
# PCB Finish
if GS.global_pcb_finish is not None:
name = GS.global_pcb_finish.lower()
for nm, color in PCB_FINISH_COLORS.items():
if nm in name:
self.pads = "#"+color
break
super().config(parent)
self.validate_colors(['board', 'copper', 'board', 'silk', 'pads', 'outline', 'clad', 'vcut'])
# Not implemented but required
self.highlight_offset = 0
def to_dict(self):
return {k.replace('_', '-'): v for k, v in self.get_attrs_gen()}
class PcbDrawRemap(Optionable):
""" This class accepts a free form dict.
No validation is done. """
def __init__(self):
super().__init__()
def config(self, parent):
pass
class PcbDrawResistorRemap(Optionable):
""" Reference -> New value """
def __init__(self):
super().__init__()
with document:
self.ref = ''
""" *Reference for the resistor to change """
self.reference = None
""" {ref} """
self.val = ''
""" *Value to use for `ref` """
self.value = None
""" {val} """
def config(self, parent):
super().config(parent)
if not self.ref or not self.val:
raise KiPlotConfigurationError("The resistors remapping must specify a `ref` and a `val`")
class PcbDrawRemapComponents(Optionable):
""" Reference -> Library + Footprint """
def __init__(self):
super().__init__()
with document:
self.ref = ''
""" *Reference for the component to change """
self.reference = None
""" {ref} """
self.lib = ''
""" *Library to use """
self.library = None
""" {lib} """
self.comp = ''
""" *Component to use (from `lib`) """
self.component = None
""" {comp} """
def config(self, parent):
super().config(parent)
if not self.ref or not self.lib or not self.comp:
raise KiPlotConfigurationError("The component remapping must specify a `ref`, a `lib` and a `comp`")
class PcbDrawOptions(VariantOptions):
def __init__(self):
with document:
self.style = PcbDrawStyle
""" *[string|dict] PCB style (colors). An internal name, the name of a JSON file or the style options """
self.libs = Optionable
""" [list(string)=[]] List of libraries """
self.placeholder = False
""" Show placeholder for missing components """
self.remap = PcbDrawRemap
""" [dict|None] (DEPRECATED) Replacements for PCB references using specified components (lib:component).
Use `remap_components` instead """
self.remap_components = PcbDrawRemapComponents
""" [list(dict)] Replacements for PCB references using specified components.
Replaces `remap` with type check """
self.no_drillholes = False
""" Do not make holes transparent """
self.bottom = False
""" *Render the bottom side of the board (default is top side) """
self.mirror = False
""" *Mirror the board """
self.highlight = Optionable
""" [list(string)=[]] List of components to highlight. Filter expansion is also allowed here,
see `show_components` """
self.show_components = Optionable
""" *[list(string)|string=none] [none,all] List of components to draw, can be also a string for none or all.
The default is none.
There two ways of using this option, please consult the `add_to_variant` option.
You can use `_kf(FILTER)` as an element in the list to get all the components that pass the filter.
You can even use `_kf(FILTER1;FILTER2)` to concatenate filters """
self.add_to_variant = True
""" The `show_components` list is added to the list of components indicated by the variant (fitted and not
excluded).
This is the old behavior, but isn't intuitive because the `show_components` meaning changes when a variant
is used. In this mode you should avoid using `show_components` and variants.
To get a more coherent behavior disable this option, and `none` will always be `none`.
Also `all` will be what the variant says """
self.vcuts = False
""" Render V-CUTS on the `vcuts_layer` layer """
self.vcuts_layer = 'Cmts.User'
""" Layer to render the V-CUTS, only used when `vcuts` is enabled.
Note that any other content from this layer will be included """
self.warnings = 'visible'
""" [visible,all,none] Using visible only the warnings about components in the visible side are generated """
self.dpi = 300
""" [10,1200] Dots per inch (resolution) of the generated image """
self.format = 'svg'
""" *[svg,png,jpg,bmp] Output format. Only used if no `output` is specified """
self.output = GS.def_global_output
""" *Name for the generated file """
self.margin = PcbMargin
""" [number|dict] Margin around the generated image [mm].
Using a number the margin is the same in the four directions """
self.outline_width = 0.15
""" [0,10] Width of the trace to draw the PCB border [mm].
Note this also affects the drill holes """
self.show_solderpaste = True
""" Show the solder paste layers """
self.resistor_remap = PcbDrawResistorRemap
""" [list(dict)] List of resistors to be remapped. You can change the value of the resistors here """
self.resistor_flip = Optionable
""" [string|list(string)=''] List of resistors to flip its bands """
self.size_detection = 'kicad_edge'
""" [kicad_edge,kicad_all,svg_paths] Method used to detect the size of the resulting image.
The `kicad_edge` method uses the size of the board as reported by KiCad,
components that extend beyond the PCB limit will be cropped. You can manually
adjust the margins to make them visible.
The `kicad_all` method uses the whole size reported by KiCad. Usually includes extra space.
The `svg_paths` uses all visible drawings in the image. To use this method you
must install the `numpy` Python module (may not be available in docker images) """
self.svg_precision = 4
""" [3,6] Scale factor used to represent 1 mm in the SVG (KiCad 6).
The value is how much zeros has the multiplier (1 mm = 10 power `svg_precision` units).
Note that for an A4 paper Firefox 91 and Chrome 105 can't handle more than 5 """
super().__init__()
def config(self, parent):
self._filters_to_expand = False
# Pre-parse the bottom option
if 'bottom' in self._tree:
bot = self._tree['bottom']
if isinstance(bot, bool):
self.bottom = bot
super().config(parent)
# Libs
if isinstance(self.libs, type):
self.libs = ['KiCAD-base']
# else:
# self.libs = ','.join(self.libs)
# V-CUTS layer
self._vcuts_layer = Layer.solve(self.vcuts_layer)[0]._id if self.vcuts else 41
# Highlight
if isinstance(self.highlight, type):
self.highlight = None
else:
self.highlight = self.solve_kf_filters(self.highlight)
# Margin
self.margin = PcbMargin.solve(self.margin)
# Filter
if isinstance(self.show_components, type):
# Default option is 'none'
self.show_components = None
elif isinstance(self.show_components, str):
if self.show_components == 'none':
self.show_components = None
else: # self.show_components == 'all'
# Empty list: means we don't filter
self.show_components = []
else: # A list
self.show_components = self.solve_kf_filters(self.show_components)
# Resistors remap/flip
if isinstance(self.resistor_remap, type):
self.resistor_remap = []
self.resistor_flip = Optionable.force_list(self.resistor_flip)
# Remap
self._remap = {}
if isinstance(self.remap, PcbDrawRemap):
for ref, v in self.remap._tree.items():
if not isinstance(v, str):
raise KiPlotConfigurationError("Wrong PcbDraw remap, must be `ref: lib:component` ({}: {})".format(ref, v))
lib_comp = v.split(':')
if len(lib_comp) == 2:
self._remap[ref] = tuple(lib_comp)
else:
raise KiPlotConfigurationError("Wrong PcbDraw remap, must be `ref: lib:component` ({}: {})".format(ref, v))
if isinstance(self.remap_components, list):
for mapping in self.remap_components:
self._remap[mapping.ref] = (mapping.lib, mapping.comp)
# Style
if isinstance(self.style, type):
# Apply the global defaults
style = PcbDrawStyle()
style.config(self)
self.style = style.to_dict()
elif isinstance(self.style, PcbDrawStyle):
self.style = self.style.to_dict()
self._expand_id = 'bottom' if self.bottom else 'top'
self._expand_ext = self.format
def setup_renderer(self, components, active_components, bottom, name):
super().setup_renderer(components, active_components)
self.add_to_variant = False
self.bottom = bottom
self.output = name
if not self.show_components:
self.show_components = None
return self.expand_filename_both(name, is_sch=False)
def save_renderer_options(self):
""" Save the current renderer settings """
super().save_renderer_options()
self.old_bottom = self.bottom
self.old_add_to_variant = self.add_to_variant
self.old_output = self.output
def restore_renderer_options(self):
""" Restore the renderer settings """
super().restore_renderer_options()
self.bottom = self.old_bottom
self.add_to_variant = self.old_add_to_variant
self.output = self.old_output
def expand_filtered_components(self, components):
""" Expands references to filters in show_components """
if not components or not self._filters_to_expand:
return components
new_list = []
if self._comps:
all_comps = self._comps
else:
load_sch()
all_comps = GS.sch.get_components()
get_board_comps_data(all_comps)
# Scan the list to show
for c in components:
if isinstance(c, str):
# A reference, just add it
new_list.append(c)
continue
# A filter, add its results
ext_list = []
for ac in all_comps:
if c.filter(ac):
ext_list.append(ac.ref)
new_list += ext_list
return new_list
def get_targets(self, out_dir):
return [self._parent.expand_filename(out_dir, self.output)]
def build_plot_components(self):
from .PcbDraw.plot import PlotComponents, ResistorValue
remapping = self._remap
def remapping_fun(ref, lib, name):
if ref in remapping:
remapped_lib, remapped_name = remapping[ref]
if name.endswith('.back'):
return remapped_lib, remapped_name + '.back'
else:
return remapped_lib, remapped_name
return lib, name
resistor_values = {}
for mapping in self.resistor_remap:
resistor_values[mapping.ref] = ResistorValue(value=mapping.val)
for ref in self.resistor_flip:
field = resistor_values.get(ref, ResistorValue())
field.flip_bands = True
resistor_values[ref] = field
plot_components = PlotComponents(remapping=remapping_fun,
resistor_values=resistor_values,
no_warn_back=self.warnings == 'visible')
filter_set = None
show_components = self.expand_filtered_components(self.show_components)
if self._comps:
# A variant is applied, filter the DNF components
all_comps = set(self.get_fitted_refs())
if self.add_to_variant:
# Old behavior: components from the variant + show_components
all_comps.update(show_components)
filter_set = all_comps
else:
# Something more coherent
if show_components:
# The user supplied a list of components
# Use only the valid ones, but only if fitted
filter_set = set(show_components).intersection(all_comps)
else:
# Empty list means all, but here is all fitted
filter_set = all_comps
else:
# No variant applied
if show_components:
# The user supplied a list of components
# Note: if the list is empty this means we don't filter
filter_set = set(show_components)
if filter_set is not None:
logger.debug('List of filtered components: '+str(filter_set))
plot_components.filter = lambda ref: ref in filter_set
else:
logger.debug('Using all components')
if self.highlight is not None:
highlight_set = set(self.expand_filtered_components(self.highlight))
plot_components.highlight = lambda ref: ref in highlight_set
return plot_components
def create_image(self, name, board):
self.ensure_tool('LXML')
from .PcbDraw.plot import PcbPlotter, PlotPaste, PlotPlaceholders, PlotSubstrate, PlotVCuts
# Select a name and format that PcbDraw can handle
svg_save_output_name = save_output_name = name
self.rsvg_command = None
self.convert_command = None
# Check we have the tools needed for the output format
if self.format != 'svg':
# We need RSVG for anything other than SVG
self.rsvg_command = self.ensure_tool('RSVG')
svg_save_output_name = _get_tmp_name('.svg')
# We need ImageMagick for anything other than SVG and PNG
if self.format != 'png':
self.convert_command = self.ensure_tool('ImageMagick')
save_output_name = _get_tmp_name('.png')
try:
plotter = PcbPlotter(board)
# Read libs from KiBot resources
plotter.setup_arbitrary_data_path(GS.get_resource_path('pcbdraw'))
# Libs indicated by PCBDRAW_LIB_PATH
plotter.setup_env_data_path()
# Libs from the user HOME and the system (for pcbdraw)
plotter.setup_global_data_path()
logger.debugl(3, 'PcbDraw data path: {}'.format(plotter.data_path))
plotter.yield_warning = pcbdraw_warnings
plotter.libs = self.libs
plotter.render_back = self.bottom
plotter.mirror = self.mirror
plotter.margin = self.margin
plotter.svg_precision = self.svg_precision
if self.style:
if isinstance(self.style, str):
plotter.resolve_style(self.style)
else:
plotter.style = self.style
plotter.plot_plan = [PlotSubstrate(drill_holes=not self.no_drillholes, outline_width=mm2ki(self.outline_width))]
if self.show_solderpaste:
plotter.plot_plan.append(PlotPaste())
if self.vcuts:
plotter.plot_plan.append(PlotVCuts(layer=self._vcuts_layer))
# Adjust the show_components option if needed
if self.add_to_variant:
# Enable the old behavior
if self._comps and self.show_components is None:
# A variant automatically adds their components
# So `none` becomes `all`
self.show_components = []
if self.show_components is not None:
plotter.plot_plan.append(self.build_plot_components())
if self.placeholder:
plotter.plot_plan.append(PlotPlaceholders())
plotter.compute_bbox = self.size_detection == 'svg_paths'
# Make sure we can use svgpathtools
if plotter.compute_bbox:
self.ensure_tool('numpy')
plotter.kicad_bb_only_edge = self.size_detection == 'kicad_edge'
image = plotter.plot()
# Most errors are reported as RuntimeError
# When the PCB can't be loaded we get IOError
# When the SVG contains errors we get SyntaxError
except (RuntimeError, SyntaxError, IOError) as e:
GS.exit_with_error('PcbDraw error: '+str(e), PCBDRAW_ERR)
# Save the result
logger.debug('Saving output to '+svg_save_output_name)
image.write(svg_save_output_name)
# Do we need to convert to PNG?
if self.rsvg_command:
logger.debug('Converting {} -> {}'.format(svg_save_output_name, save_output_name))
cmd = [self.rsvg_command, '--dpi-x', str(self.dpi), '--dpi-y', str(self.dpi),
'--output', save_output_name, '--format', 'png', svg_save_output_name]
_run_command(cmd)
os.remove(svg_save_output_name)
# Do we need to convert the saved file? (JPG/BMP)
if self.convert_command is not None:
logger.debug('Converting {} -> {}'.format(save_output_name, name))
cmd = [self.convert_command, save_output_name]
if self.format == 'jpg':
cmd += ['-quality', '85%']
cmd.append(name)
_run_command(cmd)
os.remove(save_output_name)
def run(self, name):
super().run(name)
# Apply any variant
self.filter_pcb_components(do_3D=True)
# Create the image
self.create_image(name, GS.board)
# Undo the variant
self.unfilter_pcb_components(do_3D=True)
@output_class
class PcbDraw(BaseOutput): # noqa: F821
""" PcbDraw - Beautiful 2D PCB render
Exports the PCB as a 2D model (SVG, PNG or JPG).
Uses configurable colors.
Can also render the components if the 2D models are available.
Note that this output is fast for simple PCBs, but becomes useless for huge ones.
You can easily create very complex PCBs using the `panelize` output.
In this case you can use other outputs, like `render_3d`, which are slow for small
PCBs but can handle big ones """
def __init__(self):
super().__init__()
with document:
self.options = PcbDrawOptions
""" *[dict] Options for the `pcbdraw` output """
self._category = 'PCB/docs'
def get_dependencies(self):
files = super().get_dependencies()
if isinstance(self.options.style, str) and os.path.isfile(self.options.style):
files.append(self.options.style)
return files
def get_renderer_options(self):
""" Where are the options for this output when used as a 'renderer' """
return self.options
@staticmethod
def get_conf_examples(name, layers):
outs = []
for la in layers:
is_top = la.is_top()
is_bottom = la.is_bottom()
if not is_top and not is_bottom:
continue
id = 'top' if is_top else 'bottom'
for style in ['jlcpcb-green-enig', 'set-blue-enig', 'set-red-hasl']:
style_2 = style.replace('-', '_')
for fmt in ['svg', 'png', 'jpg']:
gb = {}
gb['name'] = 'basic_{}_{}_{}_{}'.format(name, fmt, style_2, id)
gb['comment'] = 'PCB 2D render in {} format, using {} style'.format(fmt.upper(), style)
gb['type'] = name
gb['dir'] = os.path.join('PCB', '2D_render', style_2)
ops = {'style': style, 'format': fmt}
if is_bottom:
ops['bottom'] = True
gb['options'] = ops
outs.append(gb)
return outs