Added 3D view render

Related to #99
This commit is contained in:
Salvador E. Tropea 2021-11-17 17:40:54 -03:00
parent b4c1531e10
commit 27b26feb88
10 changed files with 477 additions and 226 deletions

View File

@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Default global `dir` option.
- Pattern to expand the variant name: %V
- PCB PDF Print: mechanism to change the block title. (#102)
- 3D view render
### Changed
- Internal BoM: now components with different Tolerance, Voltage, Current

View File

@ -58,6 +58,7 @@ For example, it's common that you might want for each board rev:
* Fab docs for the assembler, including the BoM (Bill of Materials), costs spreadsheet and board view
* Pick and place files
* PCB 3D model in STEP format
* PCB 3D render in PNG format
You want to do this in a one-touch way, and make sure everything you need to
do so it securely saved in version control, not on the back of an old
@ -1396,6 +1397,42 @@ Next time you need this list just use an alias, like this:
- `width_adjust`: [number=0] This width factor is intended to compensate PS printers/plotters that do not strictly obey line width settings.
Only used to plot pads and tracks.
* 3D render of the PCB
* Type: `render_3d`
* Description: Exports the image generated by KiCad's 3D viewer.
* Valid keys:
- `comment`: [string=''] A comment for documentation purposes.
- `dir`: [string='./'] Output directory for the generated files. If it starts with `+` the rest is concatenated to the default dir.
- `name`: [string=''] Used to identify this particular output definition.
- `options`: [dict] Options for the `render_3d` output.
* Valid keys:
- `background1`: [string='#66667F'] First color for the background gradient.
- `background2`: [string='#CCCCE5'] Second color for the background gradient.
- `board`: [string='#332B16'] Color for the board without copper or solder mask.
- `copper`: [string='#B29C00'] Color for the copper.
- `dnf_filter`: [string|list(string)=''] Name of the filter to mark components as not fitted.
A short-cut to use for simple cases where a variant is an overkill.
- `download`: [boolean=true] Downloads missing 3D models from KiCad git. Only applies to models in KISYS3DMOD.
- `kicad_3d_url`: [string='https://gitlab.com/kicad/libraries/kicad-packages3D/-/raw/master/'] Base URL for the KiCad 3D models.
- `move_x`: [number=0] Steps to move in the X axis, positive is to the right.
Just like pressing the right arrow in the 3D viewer.
- `move_y`: [number=0] Steps to move in the Y axis, positive is up.
Just like pressing the up arrow in the 3D viewer.
- `no_smd`: [boolean=false] Used to exclude 3D models for surface mount components.
- `no_tht`: [boolean=false] Used to exclude 3D models for through hole components.
- `no_virtual`: [boolean=false] Used to exclude 3D models for components with 'virtual' attribute.
- `output`: [string='%f-%i%v.%x'] Name for the generated image file (%i='3D_$VIEW' %x='png'). Affected by global options.
- `ray_tracing`: [boolean=false] Enable the ray tracing. Much better result, but slow, and you'll need to adjust `wait_rt`.
- `silk`: [string='#E5E5E5'] Color for the silk screen.
- `solder_mask`: [string='#143324'] Color for the solder mask.
- `solder_paste`: [string='#808080'] Color for the solder paste.
- `variant`: [string=''] Board variant to apply.
- `view`: [string='top'] [top,bottom,front,rear,right,left,z,Z,y,Y,x,X] Point of view.
- `wait_ray_tracing`: [number=5] How many seconds we must wait before capturing the ray tracing render.
Lamentably KiCad can save an unfinished image. Enlarge it if your image looks partially rendered.
- `zoom`: [number=0] Zoom steps. Use positive to enlarge, get closer, and negative to reduce.
Same result as using the mouse wheel in the 3D viewer.
* Schematic with variant generator
* Type: `sch_variant`
* Description: Creates a copy of the schematic with all the filters and variants applied.

View File

@ -58,6 +58,7 @@ For example, it's common that you might want for each board rev:
* Fab docs for the assembler, including the BoM (Bill of Materials), costs spreadsheet and board view
* Pick and place files
* PCB 3D model in STEP format
* PCB 3D render in PNG format
You want to do this in a one-touch way, and make sure everything you need to
do so it securely saved in version control, not on the back of an old

View File

@ -1021,6 +1021,60 @@ outputs:
width_adjust: 0
layers: all
# 3D render of the PCB:
- name: 'render_3d_example'
comment: 'Exports the image generated by KiCad's 3D viewer.'
type: 'render_3d'
dir: 'Example/render_3d_dir'
options:
# [string='#66667F'] First color for the background gradient
background1: '#66667F'
# [string='#CCCCE5'] Second color for the background gradient
background2: '#CCCCE5'
# [string='#332B16'] Color for the board without copper or solder mask
board: '#332B16'
# [string='#B29C00'] Color for the copper
copper: '#B29C00'
# [string|list(string)=''] Name of the filter to mark components as not fitted.
# A short-cut to use for simple cases where a variant is an overkill
dnf_filter: ''
# [boolean=true] Downloads missing 3D models from KiCad git. Only applies to models in KISYS3DMOD
download: true
# [string='https://gitlab.com/kicad/libraries/kicad-packages3D/-/raw/master/'] Base URL for the KiCad 3D models
kicad_3d_url: 'https://gitlab.com/kicad/libraries/kicad-packages3D/-/raw/master/'
# [number=0] Steps to move in the X axis, positive is to the right.
# Just like pressing the right arrow in the 3D viewer
move_x: 0
# [number=0] Steps to move in the Y axis, positive is up.
# Just like pressing the up arrow in the 3D viewer
move_y: 0
# [boolean=false] Used to exclude 3D models for surface mount components
no_smd: false
# [boolean=false] Used to exclude 3D models for through hole components
no_tht: false
# [boolean=false] Used to exclude 3D models for components with 'virtual' attribute
no_virtual: false
# [string='%f-%i%v.%x'] Name for the generated image file (%i='3D_$VIEW' %x='png'). Affected by global options
output: '%f-%i%v.%x'
# [boolean=false] Enable the ray tracing. Much better result, but slow, and you'll need to adjust `wait_rt`
ray_tracing: false
# [string='#E5E5E5'] Color for the silk screen
silk: '#E5E5E5'
# [string='#143324'] Color for the solder mask
solder_mask: '#143324'
# [string='#808080'] Color for the solder paste
solder_paste: '#808080'
# [string=''] Board variant to apply
variant: ''
# [string='top'] [top,bottom,front,rear,right,left,z,Z,y,Y,x,X] Point of view
view: 'top'
# [number=5] How many seconds we must wait before capturing the ray tracing render.
# Lamentably KiCad can save an unfinished image. Enlarge it if your image looks partially rendered
wait_ray_tracing: 5
# [number=0] Zoom steps. Use positive to enlarge, get closer, and negative to reduce.
# Same result as using the mouse wheel in the 3D viewer
zoom: 0
# Schematic with variant generator:
# This copy isn't intended for development.
# Is just a tweaked version of the original where you can look at the results.

View File

@ -34,6 +34,7 @@ PCBDRAW_ERR = 20
SVG_SCH_PRINT = 21
CORRUPTED_SCH = 22
WRONG_INSTALL = 23
RENDER_3D_ERR = 24
error_level_to_name = ['NONE',
'INTERNAL_ERROR',
'WRONG_ARGUMENTS',
@ -58,13 +59,16 @@ error_level_to_name = ['NONE',
'SVG_SCH_PRINT',
'CORRUPTED_SCH',
'WRONG_INSTALL',
'RENDER_3D_ERR',
]
CMD_EESCHEMA_DO = 'eeschema_do'
URL_EESCHEMA_DO = 'https://github.com/INTI-CMNB/kicad-automation-scripts'
URL_EESCHEMA_DO = 'https://github.com/INTI-CMNB/KiAuto'
CMD_PCBNEW_RUN_DRC = 'pcbnew_do'
URL_PCBNEW_RUN_DRC = URL_EESCHEMA_DO
CMD_PCBNEW_PRINT_LAYERS = 'pcbnew_do'
CMD_PCBNEW_PRINT_LAYERS = CMD_PCBNEW_RUN_DRC
URL_PCBNEW_PRINT_LAYERS = URL_EESCHEMA_DO
CMD_PCBNEW_3D = CMD_PCBNEW_RUN_DRC
URL_PCBNEW_3D = URL_EESCHEMA_DO
CMD_KIBOM = 'KiBOM_CLI.py'
URL_KIBOM = 'https://github.com/INTI-CMNB/KiBoM'
CMD_IBOM = 'generate_interactive_bom.py'

View File

@ -26,6 +26,7 @@ class Optionable(object):
_str_values_re = compile(r"string=.*\] \[([^\]]+)\]")
_num_range_re = compile(r"number=.*\] \[(-?\d+),(-?\d+)\]")
_default = None
_color_re = re.compile(r"#[A-Fa-f0-9]{6}$")
def __init__(self):
self._unkown_is_error = False
@ -267,6 +268,15 @@ class Optionable(object):
def get_default(cls):
return cls._default
def validate_color(self, name):
color = getattr(self, name)
if not self._color_re.match(color):
raise KiPlotConfigurationError('Invalid color for `{}` use `#rrggbb` with hex digits'.format(name))
def validate_colors(self, names):
for color in names:
self.validate_color(color)
class BaseOptions(Optionable):
""" A class to validate and hold output options.

230
kibot/out_base_3d.py Normal file
View File

@ -0,0 +1,230 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020-2021 Salvador E. Tropea
# Copyright (c) 2020-2021 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
import os
import requests
import tempfile
from tempfile import NamedTemporaryFile
from .error import KiPlotConfigurationError
from .misc import W_MISS3D, W_FAILDL
from .gs import (GS)
from .out_base import VariantOptions, BaseOutput
from .kicad.config import KiConf
from .macros import macros, document # noqa: F401
from . import log
logger = log.get_logger(__name__)
class Base3DOptions(VariantOptions):
def __init__(self):
with document:
self.no_virtual = False
""" Used to exclude 3D models for components with 'virtual' attribute """
self.download = True
""" Downloads missing 3D models from KiCad git. Only applies to models in KISYS3DMOD """
self.kicad_3d_url = 'https://gitlab.com/kicad/libraries/kicad-packages3D/-/raw/master/'
""" Base URL for the KiCad 3D models """
# Temporal dir used to store the downloaded files
self._tmp_dir = None
super().__init__()
self._expand_id = '3D'
def download_model(self, url, fname):
""" Download the 3D model from the provided URL """
logger.debug('Downloading `{}`'.format(url))
r = requests.get(url, allow_redirects=True)
if r.status_code != 200:
logger.warning(W_FAILDL+'Failed to download `{}`'.format(url))
return None
if self._tmp_dir is None:
self._tmp_dir = tempfile.mkdtemp()
logger.debug('Using `{}` as temporal dir for downloaded files'.format(self._tmp_dir))
dest = os.path.join(self._tmp_dir, fname)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, 'wb') as f:
f.write(r.content)
return dest
def undo_3d_models_rename(self):
""" Restores the file name for any renamed 3D module """
for m in GS.board.GetModules():
# Get the model references
models = m.Models()
models_l = []
while not models.empty():
models_l.append(models.pop())
# Fix any changed path
replaced = self.undo_3d_models_rep.get(m.GetReference())
for i, m3d in enumerate(models_l):
if m3d.m_Filename in self.undo_3d_models:
m3d.m_Filename = self.undo_3d_models[m3d.m_Filename]
if replaced:
m3d.m_Filename = replaced[i]
# Push the models back
for model in models_l:
models.push_front(model)
def replace_models(self, models, new_model, c):
""" Changes the 3D model using a provided model """
logger.debug('Changing 3D models for '+c.ref)
# Get the model references
models_l = []
while not models.empty():
models_l.append(models.pop())
# Check if we have more than one model
c_models = len(models_l)
if c_models > 1:
new_model = new_model.split(',')
c_replace = len(new_model)
if c_models != c_replace:
raise KiPlotConfigurationError('Found {} models in component {}, but {} replacements provided'.
format(c_models, c, c_replace))
else:
new_model = [new_model]
# Change the models
replaced = []
for i, m3d in enumerate(models_l):
replaced.append(m3d.m_Filename)
m3d.m_Filename = new_model[i]
self.undo_3d_models_rep[c.ref] = replaced
# Push the models back
for model in models_l:
models.push_front(model)
def download_models(self):
""" Check we have the 3D models.
Inform missing models.
Try to download the missing models """
models_replaced = False
# Load KiCad configuration so we can expand the 3D models path
KiConf.init(GS.pcb_file)
# List of models we already downloaded
downloaded = set()
self.undo_3d_models = {}
# Look for all the footprints
for m in GS.board.GetModules():
ref = m.GetReference()
# Extract the models (the iterator returns copies)
models = m.Models()
models_l = []
while not models.empty():
models_l.append(models.pop())
# Look for all the 3D models for this footprint
for m3d in models_l:
full_name = KiConf.expand_env(m3d.m_Filename)
if not os.path.isfile(full_name):
# Missing 3D model
if full_name not in downloaded:
logger.warning(W_MISS3D+'Missing 3D model for {}: `{}`'.format(ref, full_name))
if self.download and m3d.m_Filename.startswith('${KISYS3DMOD}/'):
# This is a model from KiCad, try to download it
fname = m3d.m_Filename[14:]
replace = None
if full_name in downloaded:
# Already downloaded
replace = os.path.join(self._tmp_dir, fname)
else:
# Download the model
url = self.kicad_3d_url+fname
replace = self.download_model(url, fname)
if replace:
# Successfully downloaded
downloaded.add(full_name)
self.undo_3d_models[replace] = m3d.m_Filename
# If this is a .wrl also download the .step
if url.endswith('.wrl'):
url = url[:-4]+'.step'
fname = fname[:-4]+'.step'
self.download_model(url, fname)
if replace:
m3d.m_Filename = replace
models_replaced = True
# Push the models back
for model in models_l:
models.push_front(model)
return models_replaced
def list_models(self):
""" Get the list of 3D models """
# Load KiCad configuration so we can expand the 3D models path
KiConf.init(GS.pcb_file)
models = set()
# Look for all the footprints
for m in GS.board.GetModules():
# Look for all the 3D models for this footprint
for m3d in m.Models():
full_name = KiConf.expand_env(m3d.m_Filename)
if os.path.isfile(full_name):
models.add(full_name)
return list(models)
def save_board(self, dir):
""" Save the PCB to a temporal file """
with NamedTemporaryFile(mode='w', suffix='.kicad_pcb', delete=False, dir=dir) as f:
fname = f.name
logger.debug('Storing modified PCB to `{}`'.format(fname))
GS.board.Save(fname)
return fname
def filter_components(self, dir):
self.undo_3d_models_rep = {}
if not self._comps:
# No variant/filter to apply
if self.download_models():
# Some missing components found and we downloaded them
# Save the fixed board
ret = self.save_board(dir)
# Undo the changes
self.undo_3d_models_rename()
return ret
return GS.pcb_file
comps_hash = self.get_refs_hash()
# Remove the 3D models for not fitted components
rem_models = []
for m in GS.board.GetModules():
ref = m.GetReference()
c = comps_hash.get(ref, None)
if c:
# The filter/variant knows about this component
models = m.Models()
if c.included and not c.fitted:
# Not fitted, remove the 3D model
rem_m_models = []
while not models.empty():
rem_m_models.append(models.pop())
rem_models.append(rem_m_models)
else:
# Fitted
new_model = c.get_field_value(GS.global_3D_model_field)
if new_model:
# We will change the 3D model
self.replace_models(models, new_model, c)
self.download_models()
fname = self.save_board(dir)
self.undo_3d_models_rename()
# Undo the removing
for m in GS.board.GetModules():
ref = m.GetReference()
c = comps_hash.get(ref, None)
if c and c.included and not c.fitted:
models = m.Models()
restore = rem_models.pop(0)
for model in restore:
models.push_front(model)
return fname
def get_targets(self, out_dir):
return [self._parent.expand_filename(out_dir, self.output)]
class Base3D(BaseOutput):
def __init__(self):
super().__init__()
def get_dependencies(self):
files = super().get_dependencies()
files.extend(self.options.list_models())
return files

View File

@ -4,14 +4,12 @@
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
import os
import re
from tempfile import (NamedTemporaryFile)
# Here we import the whole module to make monkeypatch work
import subprocess
import shutil
from .misc import PCBDRAW, PCBDRAW_ERR, URL_PCBDRAW, W_AMBLIST, W_UNRETOOL, W_USESVG2, W_USEIMAGICK
from .kiplot import check_script
from .error import KiPlotConfigurationError
from .gs import (GS)
from .optionable import Optionable
from .out_base import VariantOptions
@ -24,8 +22,6 @@ CONVERT = 'convert'
class PcbDrawStyle(Optionable):
_color_re = re.compile(r"#[A-Fa-f0-9]{6}$")
def __init__(self):
super().__init__()
with document:
@ -50,21 +46,9 @@ class PcbDrawStyle(Optionable):
self.highlight_padding = 1.5
""" [0,1000] how much the highlight extends around the component [mm] """
def validate_color(self, name):
color = getattr(self, name)
if not self._color_re.match(color):
raise KiPlotConfigurationError('Invalid color for `{}` use `#rrggbb` with hex digits'.format(name))
def config(self, parent):
super().config(parent)
self.validate_color('board')
self.validate_color('copper')
self.validate_color('board')
self.validate_color('silk')
self.validate_color('pads')
self.validate_color('outline')
self.validate_color('clad')
self.validate_color('vcut')
self.validate_colors(['board', 'copper', 'board', 'silk', 'pads', 'outline', 'clad', 'vcut'])
# Not implemented but required
self.highlight_offset = 0

133
kibot/out_render_3d.py Normal file
View File

@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2021 Salvador E. Tropea
# Copyright (c) 2021 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
import os
from shutil import rmtree
from .misc import CMD_PCBNEW_3D, URL_PCBNEW_3D, RENDER_3D_ERR
from .gs import (GS)
from .kiplot import check_script, exec_with_retry, add_extra_options
from .out_base_3d import Base3DOptions, Base3D
from .macros import macros, document, output_class # noqa: F401
from . import log
logger = log.get_logger(__name__)
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 = "#B29C00"
""" Color for the copper """
self.silk = "#E5E5E5"
""" Color for the silk screen """
self.solder_mask = "#143324"
""" 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.ray_tracing = False
""" Enable the ray tracing. Much better result, but slow, and you'll need to adjust `wait_rt` """
self.wait_ray_tracing = 5
""" How many seconds we must wait before capturing the ray tracing render.
Lamentably KiCad can save an unfinished image. Enlarge it if your image looks partially rendered """
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 """
super().__init__()
self._expand_ext = 'png'
def config(self, parent):
super().config(parent)
self.validate_colors(self._colors.keys())
view = self._views.get(self.view, None)
if view is not None:
self.view = view
self._expand_id += '_'+self._rviews.get(self.view)
def run(self, output):
super().run(output)
check_script(CMD_PCBNEW_3D, URL_PCBNEW_3D, '1.5.11')
# Base command with overwrite
cmd = [CMD_PCBNEW_3D, '3d_view', '--output_name', output]
# 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)])
if self.move_x:
cmd.extend(['--move_x', str(self.move_x)])
if self.move_y:
cmd.extend(['--move_y', str(self.move_y)])
if self.zoom:
cmd.extend(['--zoom', str(self.zoom)])
if self.wait_ray_tracing != 5:
cmd.extend(['--wait_rt', str(self.wait_ray_tracing)])
if self.ray_tracing:
cmd.append('--ray_tracing')
if self.view != 'z':
cmd.extend(['--view', self.view])
# The board
board_name = self.filter_components(GS.pcb_dir)
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
if board_name != GS.pcb_file:
os.remove(board_name)
# Remove the downloaded 3D models
if self._tmp_dir:
rmtree(self._tmp_dir)
if ret:
logger.error(CMD_PCBNEW_3D+' returned %d', ret)
exit(RENDER_3D_ERR)
if video_remove:
video_name = os.path.join(GS.out_dir, 'pcbnew_3d_view_screencast.ogv')
if os.path.isfile(video_name):
os.remove(video_name)
@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 """

View File

@ -5,23 +5,19 @@
# Project: KiBot (formerly KiPlot)
import re
import os
import requests
import tempfile
from subprocess import (check_output, STDOUT, CalledProcessError)
from tempfile import NamedTemporaryFile
from shutil import rmtree
from .error import KiPlotConfigurationError
from .misc import KICAD2STEP, KICAD2STEP_ERR, W_MISS3D, W_FAILDL
from .misc import KICAD2STEP, KICAD2STEP_ERR
from .gs import (GS)
from .out_base import VariantOptions
from .kicad.config import KiConf
from .out_base_3d import Base3DOptions, Base3D
from .macros import macros, document, output_class # noqa: F401
from . import log
logger = log.get_logger(__name__)
class STEPOptions(VariantOptions):
class STEPOptions(Base3DOptions):
def __init__(self):
with document:
self.metric_units = True
@ -30,20 +26,13 @@ class STEPOptions(VariantOptions):
""" Determines the coordinates origin. Using grid the coordinates are the same as you have in the design sheet.
The drill option uses the auxiliary reference defined by the user.
You can define any other origin using the format 'X,Y', i.e. '3.2,-10' """
self.no_virtual = False
""" Used to exclude 3D models for components with 'virtual' attribute """
self.min_distance = -1
""" The minimum distance between points to treat them as separate ones (-1 is KiCad default: 0.01 mm) """
self.output = GS.def_global_output
""" Name for the generated STEP file (%i='3D' %x='step') """
self.download = True
""" Downloads missing 3D models from KiCad git. Only applies to models in KISYS3DMOD """
self.kicad_3d_url = 'https://gitlab.com/kicad/libraries/kicad-packages3D/-/raw/master/'
""" Base URL for the KiCad 3D models """
# Temporal dir used to store the downloaded files
self._tmp_dir = None
super().__init__()
self._expand_id = '3D'
self._expand_ext = 'step'
@property
@ -56,193 +45,6 @@ class STEPOptions(VariantOptions):
raise KiPlotConfigurationError('Origin must be `grid` or `drill` or `X,Y`')
self._origin = val
def download_model(self, url, fname):
""" Download the 3D model from the provided URL """
logger.debug('Downloading `{}`'.format(url))
r = requests.get(url, allow_redirects=True)
if r.status_code != 200:
logger.warning(W_FAILDL+'Failed to download `{}`'.format(url))
return None
if self._tmp_dir is None:
self._tmp_dir = tempfile.mkdtemp()
logger.debug('Using `{}` as temporal dir for downloaded files'.format(self._tmp_dir))
dest = os.path.join(self._tmp_dir, fname)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, 'wb') as f:
f.write(r.content)
return dest
def undo_3d_models_rename(self):
""" Restores the file name for any renamed 3D module """
for m in GS.board.GetModules():
# Get the model references
models = m.Models()
models_l = []
while not models.empty():
models_l.append(models.pop())
# Fix any changed path
replaced = self.undo_3d_models_rep.get(m.GetReference())
for i, m3d in enumerate(models_l):
if m3d.m_Filename in self.undo_3d_models:
m3d.m_Filename = self.undo_3d_models[m3d.m_Filename]
if replaced:
m3d.m_Filename = replaced[i]
# Push the models back
for model in models_l:
models.push_front(model)
def replace_models(self, models, new_model, c):
""" Changes the 3D model using a provided model """
logger.debug('Changing 3D models for '+c.ref)
# Get the model references
models_l = []
while not models.empty():
models_l.append(models.pop())
# Check if we have more than one model
c_models = len(models_l)
if c_models > 1:
new_model = new_model.split(',')
c_replace = len(new_model)
if c_models != c_replace:
raise KiPlotConfigurationError('Found {} models in component {}, but {} replacements provided'.
format(c_models, c, c_replace))
else:
new_model = [new_model]
# Change the models
replaced = []
for i, m3d in enumerate(models_l):
replaced.append(m3d.m_Filename)
m3d.m_Filename = new_model[i]
self.undo_3d_models_rep[c.ref] = replaced
# Push the models back
for model in models_l:
models.push_front(model)
def download_models(self):
""" Check we have the 3D models.
Inform missing models.
Try to download the missing models """
models_replaced = False
# Load KiCad configuration so we can expand the 3D models path
KiConf.init(GS.pcb_file)
# List of models we already downloaded
downloaded = set()
self.undo_3d_models = {}
# Look for all the footprints
for m in GS.board.GetModules():
ref = m.GetReference()
# Extract the models (the iterator returns copies)
models = m.Models()
models_l = []
while not models.empty():
models_l.append(models.pop())
# Look for all the 3D models for this footprint
for m3d in models_l:
full_name = KiConf.expand_env(m3d.m_Filename)
if not os.path.isfile(full_name):
# Missing 3D model
if full_name not in downloaded:
logger.warning(W_MISS3D+'Missing 3D model for {}: `{}`'.format(ref, full_name))
if self.download and m3d.m_Filename.startswith('${KISYS3DMOD}/'):
# This is a model from KiCad, try to download it
fname = m3d.m_Filename[14:]
replace = None
if full_name in downloaded:
# Already downloaded
replace = os.path.join(self._tmp_dir, fname)
else:
# Download the model
url = self.kicad_3d_url+fname
replace = self.download_model(url, fname)
if replace:
# Successfully downloaded
downloaded.add(full_name)
self.undo_3d_models[replace] = m3d.m_Filename
# If this is a .wrl also download the .step
if url.endswith('.wrl'):
url = url[:-4]+'.step'
fname = fname[:-4]+'.step'
self.download_model(url, fname)
if replace:
m3d.m_Filename = replace
models_replaced = True
# Push the models back
for model in models_l:
models.push_front(model)
return models_replaced
def list_models(self):
""" Get the list of 3D models """
# Load KiCad configuration so we can expand the 3D models path
KiConf.init(GS.pcb_file)
models = set()
# Look for all the footprints
for m in GS.board.GetModules():
# Look for all the 3D models for this footprint
for m3d in m.Models():
full_name = KiConf.expand_env(m3d.m_Filename)
if os.path.isfile(full_name):
models.add(full_name)
return list(models)
def save_board(self, dir):
""" Save the PCB to a temporal file """
with NamedTemporaryFile(mode='w', suffix='.kicad_pcb', delete=False, dir=dir) as f:
fname = f.name
logger.debug('Storing modified PCB to `{}`'.format(fname))
GS.board.Save(fname)
return fname
def filter_components(self, dir):
self.undo_3d_models_rep = {}
if not self._comps:
# No variant/filter to apply
if self.download_models():
# Some missing components found and we downloaded them
# Save the fixed board
ret = self.save_board(dir)
# Undo the changes
self.undo_3d_models_rename()
return ret
return GS.pcb_file
comps_hash = self.get_refs_hash()
# Remove the 3D models for not fitted components
rem_models = []
for m in GS.board.GetModules():
ref = m.GetReference()
c = comps_hash.get(ref, None)
if c:
# The filter/variant knows about this component
models = m.Models()
if c.included and not c.fitted:
# Not fitted, remove the 3D model
rem_m_models = []
while not models.empty():
rem_m_models.append(models.pop())
rem_models.append(rem_m_models)
else:
# Fitted
new_model = c.get_field_value(GS.global_3D_model_field)
if new_model:
# We will change the 3D model
self.replace_models(models, new_model, c)
self.download_models()
fname = self.save_board(dir)
self.undo_3d_models_rename()
# Undo the removing
for m in GS.board.GetModules():
ref = m.GetReference()
c = comps_hash.get(ref, None)
if c and c.included and not c.fitted:
models = m.Models()
restore = rem_models.pop(0)
for model in restore:
models.push_front(model)
return fname
def get_targets(self, out_dir):
return [self._parent.expand_filename(out_dir, self.output)]
def run(self, output):
super().run(output)
# Make units explicit
@ -286,7 +88,7 @@ class STEPOptions(VariantOptions):
@output_class
class STEP(BaseOutput): # noqa: F821
class STEP(Base3D): # noqa: F821
""" STEP (ISO 10303-21 Clear Text Encoding of the Exchange Structure)
Exports the PCB as a 3D model.
This is the most common 3D format for exchange purposes.
@ -296,8 +98,3 @@ class STEP(BaseOutput): # noqa: F821
with document:
self.options = STEPOptions
""" [dict] Options for the `step` output """
def get_dependencies(self):
files = super().get_dependencies()
files.extend(self.options.list_models())
return files