KiBot/kibot/out_render_3d.py

340 lines
14 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2021-2022 Salvador E. Tropea
# Copyright (c) 2021-2022 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
# KiCad 6 bug: https://gitlab.com/kicad/code/kicad/-/issues/9890
"""
Dependencies:
- from: KiAuto
role: mandatory
version: 2.0.4
- from: ImageMagick
role: Automatically crop images
"""
import os
import shlex
from shutil import rmtree
import subprocess
from .misc import (RENDER_3D_ERR, PCB_MAT_COLORS, PCB_FINISH_COLORS, SOLDER_COLORS, SILK_COLORS,
KICAD_VERSION_6_0_2, MISSING_TOOL)
from .gs import GS
from .kiplot import exec_with_retry, add_extra_options, load_sch, get_board_comps_data
from .optionable import Optionable
from .out_base_3d import Base3DOptions, Base3D
from .macros import macros, document, output_class # noqa: F401
from . import log
logger = log.get_logger()
def _run_command(cmd):
logger.debug('- Executing: '+shlex.join(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(RENDER_3D_ERR)
if cmd_output.strip():
logger.debug('- Output from command:\n'+cmd_output.decode())
class Render3DOptions(Base3DOptions):
_colors = {'background1': 'bg_color_1',
'background2': 'bg_color_2',
'copper': 'copper_color',
'board': 'board_color',
'silk': 'silk_color',
'solder_mask': 'sm_color',
'solder_paste': 'sp_color'}
_views = {'top': 'z', 'bottom': 'Z', 'front': 'y', 'rear': 'Y', 'right': 'x', 'left': 'X'}
_rviews = {v: k for k, v in _views.items()}
def __init__(self):
with document:
self.output = GS.def_global_output
""" *Name for the generated image file (%i='3D_$VIEW' %x='png') """
self.no_tht = False
""" Used to exclude 3D models for through hole components """
self.no_smd = False
""" Used to exclude 3D models for surface mount components """
self.background1 = "#66667F"
""" First color for the background gradient """
self.background2 = "#CCCCE5"
""" Second color for the background gradient """
self.board = "#332B16"
""" Color for the board without copper or solder mask """
self.copper = "#8b898c"
""" Color for the copper """
self.silk = "#d5dce4"
""" Color for the silk screen """
self.solder_mask = "#208b47"
""" Color for the solder mask """
self.solder_paste = "#808080"
""" Color for the solder paste """
self.move_x = 0
""" *Steps to move in the X axis, positive is to the right.
Just like pressing the right arrow in the 3D viewer """
self.move_y = 0
""" *Steps to move in the Y axis, positive is up.
Just like pressing the up arrow in the 3D viewer """
self.rotate_x = 0
""" *Steps to rotate around the X axis, positive is clockwise.
Each step is currently 10 degrees. Only for KiCad 6 """
self.rotate_y = 0
""" *Steps to rotate around the Y axis, positive is clockwise.
Each step is currently 10 degrees. Only for KiCad 6 """
self.rotate_z = 0
""" *Steps to rotate around the Z axis, positive is clockwise.
Each step is currently 10 degrees. Only for KiCad 6 """
self.ray_tracing = False
""" *Enable the ray tracing. Much better result, but slow, and you'll need to adjust `wait_rt` """
self.wait_render = -600
""" How many seconds we must wait before capturing the render (ray tracing or normal).
Lamentably KiCad can save an unfinished image. Enlarge it if your image looks partially rendered.
Use negative values to enable the auto-detect using CPU load.
In this case the value is interpreted as a time-out. """
self.wait_ray_tracing = None
""" {wait_render} """
self.view = 'top'
""" *[top,bottom,front,rear,right,left,z,Z,y,Y,x,X] Point of view """
self.zoom = 0
""" *Zoom steps. Use positive to enlarge, get closer, and negative to reduce.
Same result as using the mouse wheel in the 3D viewer """
self.width = 1280
""" Image width (aprox.) """
self.height = 720
""" Image height (aprox.) """
self.orthographic = False
""" Enable the orthographic projection mode (top view looks flat) """
self.show_silkscreen = True
""" Show the silkscreen layers (KiCad 6) """
self.show_soldermask = True
""" Show the solder mask layers (KiCad 6) """
self.show_solderpaste = True
""" Show the solder paste layers (KiCad 6) """
self.show_zones = True
""" Show filled areas in zones (KiCad 6) """
self.clip_silk_on_via_annulus = True
""" Clip silkscreen at via annuli (KiCad 6) """
self.subtract_mask_from_silk = True
""" Clip silkscreen at solder mask edges (KiCad 6) """
self.show_components = Optionable
""" *[list(string)|string=all] [none,all] List of components to draw, can be also a string for `none` or `all`.
Unlike the `pcbdraw` output, the default is `all` """
self.auto_crop = False
""" When enabled the image will be post-processed to make the background transparent and then remove the
empty space around the image. In this mode the `background1` and `background2` colors are ignored """
self.auto_crop_color = "#ff70b7"
""" Color used for the chroma key. Adjust it if some regions of the board becomes transparent """
self.auto_crop_fuzz = 15
""" [0,100] Chroma key tolerance (percent). Bigger values will remove more pixels """
super().__init__()
self._expand_ext = 'png'
def config(self, parent):
# Apply global defaults
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.board = "#"+color
break
# Pre parse the view option
bottom = False
if 'view' in self._tree:
v = self._tree['view']
bottom = isinstance(v, str) and v == 'bottom'
# Solder mask
if 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.solder_mask) = SOLDER_COLORS[name.lower()]
# Add the default opacity (80%)
self.solder_mask += "D4"
# Silk screen
if 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.copper = "#"+color
break
super().config(parent)
self.validate_colors(list(self._colors.keys())+['auto_crop_color'])
view = self._views.get(self.view, None)
if view is not None:
self.view = view
# List of components
self._show_all_components = False
if isinstance(self.show_components, str):
if self.show_components == 'all':
self._show_all_components = True
self.show_components = set()
elif isinstance(self.show_components, type):
# Default is all
self._show_all_components = True
else: # a list
self.show_components = set(self.show_components)
self._expand_id += '_'+self._rviews.get(self.view)
def add_step(self, cmd, steps, ops):
if steps:
cmd.extend([ops, str(steps)])
def add_options(self, cmd):
# Add user options
if not self.no_virtual:
cmd.append('--virtual')
if self.no_tht:
cmd.append('--no_tht')
if self.no_smd:
cmd.append('--no_smd')
for color, option in self._colors.items():
cmd.extend(['--'+option, getattr(self, color)])
self.add_step(cmd, self.move_x, '--move_x')
self.add_step(cmd, self.move_y, '--move_y')
self.add_step(cmd, self.rotate_x, '--rotate_x')
self.add_step(cmd, self.rotate_y, '--rotate_y')
self.add_step(cmd, self.rotate_z, '--rotate_z')
if self.zoom:
cmd.extend(['--zoom', str(self.zoom)])
if self.wait_render != 5:
if self.wait_render < 0:
self.wait_render = -self.wait_render
cmd.append('--detect_rt')
cmd.extend(['--wait_rt', str(self.wait_render), '--use_rt_wait'])
if self.ray_tracing:
cmd.append('--ray_tracing')
if self.orthographic:
cmd.append('--orthographic')
if self.view != 'z':
cmd.extend(['--view', self.view])
if not self.show_silkscreen:
cmd.append('--hide_silkscreen')
if not self.show_soldermask:
cmd.append('--hide_soldermask')
if not self.show_solderpaste:
cmd.append('--hide_solderpaste')
if not self.show_zones:
cmd.append('--hide_zones')
if not self.clip_silk_on_via_annulus:
cmd.append('--dont_clip_silk_on_via_annulus')
if not self.subtract_mask_from_silk:
cmd.append('--dont_substrack_mask_from_silk')
def apply_show_components(self):
if self._show_all_components:
# Don't change anything
return
# The user specified a list of components, we must remove the rest
if not self._comps:
# No variant or filter applied
# Load the components
load_sch()
self._comps = GS.sch.get_components()
get_board_comps_data(self._comps)
# If the component isn't listed by the user make it DNF
for c in self._comps:
if c.ref not in self.show_components:
c.fitted = False
def run(self, output):
super().run(output)
if GS.ki6 and GS.kicad_version_n < KICAD_VERSION_6_0_2:
logger.error("3D Viewer not supported for KiCad 6.0.0/1\n"
"Please upgrade KiCad to 6.0.2 or newer")
exit(MISSING_TOOL)
command = self.ensure_tool('KiAuto')
if self.auto_crop:
self.background1 = self.background2 = self.auto_crop_color
convert_command = self.ensure_tool('ImageMagick')
# Base command with overwrite
cmd = [command, '--rec_w', str(self.width+2), '--rec_h', str(self.height+85),
'3d_view', '--output_name', output]
self.add_options(cmd)
# The board
self.apply_show_components()
board_name = self.filter_components()
cmd.extend([board_name, os.path.dirname(output)])
cmd, video_remove = add_extra_options(cmd)
# Execute it
ret = exec_with_retry(cmd)
# Remove the temporal PCB
self.remove_tmp_board(board_name)
# Remove the downloaded 3D models
if self._tmp_dir:
rmtree(self._tmp_dir)
if ret:
logger.error(command+' returned %d', ret)
exit(RENDER_3D_ERR)
if video_remove:
video_name = os.path.join(self.expand_filename_pcb(GS.out_dir), 'pcbnew_3d_view_screencast.ogv')
if os.path.isfile(video_name):
os.remove(video_name)
if self.auto_crop:
_run_command([convert_command, output, '-fuzz', str(self.auto_crop_fuzz)+'%', '-transparent',
self.color_str_to_rgb(self.auto_crop_color), output])
# Don't ask me why I need 2 passes
_run_command([convert_command, output, '-trim', '+repage', output])
_run_command([convert_command, output, '-trim', '+repage', output])
@output_class
class Render_3D(Base3D): # noqa: F821
""" 3D render of the PCB
Exports the image generated by KiCad's 3D viewer. """
def __init__(self):
super().__init__()
with document:
self.options = Render3DOptions
""" *[dict] Options for the `render_3d` output """
self._category = 'PCB/3D'
@staticmethod
def get_conf_examples(name, layers, templates):
outs = []
has_top = False
has_bottom = False
for la in layers:
if la.is_top() or la.layer.startswith('F.'):
has_top = True
elif la.is_bottom() or la.layer.startswith('B.'):
has_bottom = True
if has_top:
gb = {}
gb['name'] = 'basic_{}_top'.format(name)
gb['comment'] = '3D view from top'
gb['type'] = name
gb['dir'] = '3D'
gb['options'] = {'ray_tracing': True, 'orthographic': True}
outs.append(gb)
if GS.ki6:
gb = {}
gb['name'] = 'basic_{}_30deg'.format(name)
gb['comment'] = '3D view from 30 degrees'
gb['type'] = name
gb['dir'] = '3D'
gb['output_id'] = '30deg'
gb['options'] = {'ray_tracing': True, 'rotate_x': 3, 'rotate_z': -2}
outs.append(gb)
if has_bottom:
gb = {}
gb['name'] = 'basic_{}_bottom'.format(name)
gb['comment'] = '3D view from bottom'
gb['type'] = name
gb['dir'] = '3D'
gb['options'] = {'ray_tracing': True, 'orthographic': True, 'view': 'bottom'}
outs.append(gb)
return outs