# -*- 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) from fnmatch import fnmatch import os import re import requests from shutil import copy2 from .bom.units import comp_match from .EasyEDA.easyeda_3d import download_easyeda_3d_model from .fil_base import reset_filters from .misc import W_MISS3D, W_FAILDL, W_DOWN3D, DISABLE_3D_MODEL_TEXT, W_BADTOL, W_BADRES, W_RESVALISSUE, W_RES3DNAME from .gs import GS from .optionable import Optionable 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() # 3D models for resistors data # Tolerance bar: # 20% - 3 # 10% Silver 4 # 5% Gold 4 # 2% Red 5 # 1% Brown 5 # 0.5% Green 5 # 0.25% Blue 5 # 0.1% Violet 5 # 0.05% Orange 5 # 0.02% Yellow 5 # 0.01% Grey 5 # Special multipliers # Multiplier < 1 # 0.1 Gold # 0.01 Silver X = 0 Y = 1 Z = 2 COLORS = [(0.149, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.4), # 0 Black (0.149, 0.40, 0.26, 0.13, 0.40, 0.26, 0.13, 0.4), # 1 Brown (0.149, 0.85, 0.13, 0.13, 0.85, 0.13, 0.13, 0.4), # 2 Red (0.149, 0.94, 0.37, 0.14, 0.94, 0.37, 0.14, 0.4), # 3 Naraja (0.149, 0.98, 0.99, 0.06, 0.98, 0.99, 0.06, 0.4), # 4 Yellow (0.149, 0.20, 0.80, 0.20, 0.20, 0.80, 0.20, 0.4), # 5 Green (0.149, 0.03, 0.00, 0.77, 0.03, 0.00, 0.77, 0.4), # 6 Blue (0.149, 0.56, 0.00, 1.00, 0.56, 0.00, 1.00, 0.4), # 7 Violet (0.149, 0.62, 0.62, 0.62, 0.62, 0.62, 0.62, 0.4), # 8 Grey (0.149, 0.99, 0.99, 0.99, 0.99, 0.99, 0.99, 0.4), # 9 White (0.379, 0.86, 0.74, 0.50, 0.86, 0.74, 0.50, 1.0), # 5% Gold (10) (0.271, 0.82, 0.82, 0.78, 0.33, 0.26, 0.17, 0.7), # 10% Silver (11) (0.149, 0.883, 0.711, 0.492, 0.043, 0.121, 0.281, 0.4), # Body color ] TOL_COLORS = {5: 10, 10: 11, 20: 12, 2: 2, 1: 1, 0.5: 5, 0.25: 6, 0.1: 7, 0.05: 3, 0.02: 4, 0.01: 8} WIDTHS_4 = [5, 12, 10.5, 12, 10.5, 12, 21, 12, 5] WIDTHS_5 = [5, 10, 8.5, 10, 8.5, 10, 8.5, 10, 14.5, 10, 5] def do_expand_env(fname, used_extra, extra_debug, lib_nickname): # Is it using ALIAS:xxxxx? force_used_extra = False if ':' in fname: ind = fname.index(':') alias_name = fname[:ind] rest = fname[ind+1:] if alias_name in KiConf.aliases_3D: # Yes, replace the alias fname = os.path.join(KiConf.aliases_3D[alias_name], rest) # Make sure the name we created is what kicad2step gets force_used_extra = True if extra_debug: logger.debug("- Replaced alias {} -> {}".format(alias_name+':'+rest, fname)) full_name = KiConf.expand_env(fname, used_extra, ref_dir=GS.pcb_dir) if extra_debug: logger.debug("- Expanded {} -> {}".format(fname, full_name)) if os.path.isfile(full_name) or ':' not in fname or GS.global_disable_3d_alias_as_env: full_name_cwd = KiConf.expand_env(fname, used_extra, ref_dir=os.getcwd()) if os.path.isfile(full_name_cwd): full_name = full_name_cwd force_used_extra = True else: # We still missing the 3D model # Try relative to the footprint lib aliases = KiConf.get_fp_lib_aliases() lib_alias = aliases.get(lib_nickname) if lib_alias is not None: full_name_lib = os.path.join(lib_alias.uri, fname) if os.path.isfile(full_name_lib): logger.debug("- Using path relative to `{}` for `{}` ({})".format(lib_nickname, fname, full_name_lib)) full_name = full_name_lib # KiCad 5 and 6 will need help force_used_extra = not GS.ki7 if force_used_extra: used_extra[0] = True return full_name # Look for ALIAS:file ind = fname.index(':') alias_name = fname[:ind] if len(alias_name) == 1: # Is a drive letter, not an alias return full_name rest = fname[ind+1:] new_fname = '${'+alias_name+'}'+os.path.sep+rest new_full_name = KiConf.expand_env(new_fname, used_extra) if extra_debug: logger.debug("- Expanded {} -> {}".format(new_fname, new_full_name)) if os.path.isfile(new_full_name): used_extra[0] = True return new_full_name return full_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 and KICAD6_3DMODEL_DIR. They are downloaded to a temporal directory and discarded. If you want to cache the downloaded files specify a directory using the KIBOT_3D_MODELS environment variable """ 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 copy_options(self, ref): super().copy_options(ref) self.no_virtual = ref.no_virtual self.download = ref.download self.kicad_3d_url = ref.kicad_3d_url def download_model(self, url, fname, rel_dirs): """ Download the 3D model from the provided URL """ dest = os.path.join(self._tmp_dir, fname) os.makedirs(os.path.dirname(dest), exist_ok=True) # Is already there? if os.path.isfile(dest): logger.debug('Using cached model `{}`'.format(dest)) return dest logger.debug('Downloading `{}`'.format(url)) failed = False try: r = requests.get(url, allow_redirects=True) except Exception: failed = True if failed or r.status_code != 200: logger.warning(W_FAILDL+'Failed to download `{}`'.format(url)) return None with open(dest, 'wb') as f: f.write(r.content) return dest def wrl_name(self, name, force_wrl): """ Try to use the WRL version """ if not force_wrl: return name nm, ext = os.path.splitext(name) if ext.lower() == '.wrl': return name nm += '.wrl' if os.path.isfile(nm): logger.debug('- Forcing WRL '+nm) return nm return name def try_download_kicad(self, model, full_name, downloaded, rel_dirs, force_wrl): if not (model.startswith('${KISYS3DMOD}/') or model.startswith('${KICAD6_3DMODEL_DIR}/')): return None # This is a model from KiCad, try to download it fname = model[model.find('/')+1:] replace = None if full_name in downloaded: # Already downloaded return os.path.join(self._tmp_dir, fname) # Download the model url = self.kicad_3d_url+fname replace = self.download_model(url, fname, rel_dirs) if not replace: return None # Successfully downloaded downloaded.add(full_name) # 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, rel_dirs) elif force_wrl: # This should be a .step, so we download the wrl url = os.path.splitext(url)[0]+'.wrl' fname = os.path.splitext(fname)[0]+'.wrl' self.download_model(url, fname, rel_dirs) return replace def try_download_easyeda(self, model, full_name, downloaded, sch_comp, lcsc_field): if not lcsc_field or not sch_comp: return None lcsc_id = sch_comp.get_field_value(lcsc_field) if not lcsc_id: return None fname = os.path.basename(model) cache_name = os.path.join(self._tmp_dir, fname) if full_name in downloaded: # Already downloaded return cache_name if os.path.isfile(cache_name): downloaded.add(full_name) logger.debug('Using cached model `{}`'.format(cache_name)) return cache_name logger.debug('- Trying to download {} component as {}/{}'.format(lcsc_id, self._tmp_dir, fname)) replace = download_easyeda_3d_model(lcsc_id, self._tmp_dir, fname) if not replace: return None # Successfully downloaded downloaded.add(full_name) return replace def is_tht_resistor(self, name): # Works for R_Axial_DIN* KiCad 6.0.10 3D models name = os.path.splitext(os.path.basename(name))[0] return name.startswith('R_Axial_DIN') def colored_tht_resistor_name(self, name, bars): name = os.path.splitext(os.path.basename(name))[0] return os.path.join(self._tmp_dir, name+'_'+'_'.join(map(str, bars))+'.wrl') def add_tht_resistor_colors(self, file, colors): for bar, c in enumerate(colors): col = COLORS[c] file.write("Shape {\n") file.write("\t\tappearance Appearance {material DEF RES-BAR-%02d Material {\n" % (bar+1)) file.write("\t\tambientIntensity {}\n".format(col[0])) file.write("\t\tdiffuseColor {} {} {}\n".format(col[1], col[2], col[3])) file.write("\t\tspecularColor {} {} {}\n".format(col[4], col[5], col[6])) file.write("\t\temissiveColor 0.0 0.0 0.0\n") file.write("\t\ttransparency 0.0\n") file.write("\t\tshininess {}\n".format(col[7])) file.write("\t\t}\n") file.write("\t}\n") file.write("}\n") def write_tht_resistor_strip(self, points, file, axis, n, mat, index, only_coord=False): if not only_coord: file.write("Shape { geometry IndexedFaceSet\n") file.write(index) end = points[0][axis] start = points[2][axis] length = start-end length/15 n_start = start-self.starts[n]*length n_end = n_start-self.widths[n]*length new_points = [] for p in points: ax = [] for a, v in enumerate(p): if a == axis: ax.append("%.3f" % (n_start if v == start else n_end)) else: ax.append("%.3f" % v) new_points.append(' '.join(ax)) file.write("coord Coordinate { point ["+','.join(new_points)+"]\n") if only_coord: return file.write("}}\n") file.write("appearance Appearance{material USE "+mat+" }\n") file.write("}\n") def create_colored_tht_resistor(self, ori, name, bars, r_len): # ** Process the 3D model # Fill the starts ac = 0 self.starts = [] for c, w in enumerate(self.widths): self.starts.append(ac/100) self.widths[c] = w/100 ac += w # Create the model coo_re = re.compile(r"coord Coordinate \{ point \[((\S+ \S+ \S+,?)+)\](.*)") with open(ori, "rt") as f: prev_ln = None points = None axis = None with open(name, "wt") as d: colors_defined = False for ln in f: if not colors_defined and ln.startswith('Shape { geometry IndexedFaceSet'): self.add_tht_resistor_colors(d, bars) colors_defined = True m = coo_re.match(ln) if m: index = prev_ln points = list(map(lambda x: tuple(map(float, x.split(' '))), m.group(1).split(','))) x_len = (points[0][X]-points[2][X])*2.54*2 if abs(x_len-r_len) < 0.01: logger.debug(' - Found horizontal: {}'.format(round(x_len, 2))) self.write_tht_resistor_strip(points, d, X, 0, 'PIN-01', index, only_coord=True) # d.write(ln) axis = X else: y_len = (points[0][Z]-points[2][Z])*2.54*2 if abs(y_len-r_len) < 0.01: logger.debug(' - Found vertical: {}'.format(round(y_len, 2))) self.write_tht_resistor_strip(points, d, Z, 0, 'PIN-01', index, only_coord=True) axis = Z else: d.write(ln) points = None else: d.write(ln) if ln == "}\n" and points is not None: for st in range(1, len(self.widths)): bar = (st >> 1)+1 self.write_tht_resistor_strip(points, d, axis, st, 'RES-BAR-%02d' % bar if st % 2 else 'RES-THT-01', index) points = None prev_ln = ln # Copy the STEP model (no colors) step_ori = os.path.splitext(ori)[0]+'.step' if os.path.isfile(step_ori): step_name = os.path.splitext(name)[0]+'.step' copy2(step_ori, step_name) else: logger.warning(W_MISS3D+'Missing 3D model {}'.format(step_ori)) def do_colored_tht_resistor(self, name, c, changed): if not GS.global_colored_tht_resistors or not self.is_tht_resistor(name) or c is None: return name # Find the length of the resistor (is in the name of the 3D model) m = re.search(r"L([\d\.]+)mm", name) if not m: logger.warning(W_RES3DNAME+'3D model for resistor without length: {}'.format(name)) return name r_len = float(m.group(1)) # THT Resistor that we want to add colors # Check the tolerance tol = next(filter(lambda x: x, map(c.get_field_value, GS.global_field_tolerance)), None) if not tol: tol = GS.global_default_resistor_tolerance logger.warning(W_BADTOL+'Missing tolerance for {}, using {}%'.format(c.ref, tol)) else: tol = tol.strip() if tol[-1] == '%': tol = tol[:-1].strip() try: tol = float(tol) except ValueError: logger.warning(W_BADTOL+'Malformed tolerance for {}: `{}`'.format(c.ref, tol)) return name if tol not in TOL_COLORS: logger.warning(W_BADTOL+'Unknown tolerance for {}: `{}`'.format(c.ref, tol)) return name tol_color = TOL_COLORS[tol] # Check the value res = comp_match(c.value, c.ref_prefix, c.ref) if res is None: return name val = res[0]*res[1][0] if val < 0.01: logger.warning(W_BADRES+'Resistor {} out of range, minimum value is 10 mOhms'.format(c.ref)) return name val_str = "{0:.0f}".format(val*100) # Find how many bars we'll use if tol < 5: # Use 5 bars for 2 % tol or better self.widths = WIDTHS_5.copy() nbars = 5 else: self.widths = WIDTHS_4.copy() nbars = 4 bars = [0]*nbars # Bars with digits dig_bars = nbars-2 # Fill the multiplier mult = len(val_str)-nbars if mult < 0: val_str = val_str.rjust(dig_bars, '0') mult = min(9-mult, 11) bars[dig_bars] = mult # Max is all 99 with 9 as multiplier max_val = pow(10, dig_bars)-1 if val > max_val*1e9: logger.warning(W_BADRES+'Resistor {} out of range, maximum value is {} GOhms'.format(c.ref, max_val)) return name # Fill the digits for bar in range(dig_bars): bars[bar] = ord(val_str[bar])-ord('0') # Make sure we don't have digits that can't be represented rest = val_str[dig_bars:] if rest and not all(map(lambda x: x == '0', rest)): logger.warning(W_RESVALISSUE+'Digits not represented in {} {} ({} %)'.format(c.ref, c.value, tol)) bars[nbars-1] = tol_color # For 20% remove the last bar if tol_color == 12: bars = bars[:-1] self.widths[-3] = self.widths[-1]+self.widths[-2]+self.widths[-3] self.widths = self.widths[:-2] # Create the name in the cache cache_name = self.colored_tht_resistor_name(name, bars) if os.path.isfile(cache_name) and GS.global_cache_3d_resistors: status = 'cached' else: status = 'created' self.create_colored_tht_resistor(name, cache_name, bars, r_len) changed[0] = True # Show the result logger.debug('- {} {} {}% {} ({})'.format(c.ref, c.value, tol, bars, status)) return cache_name def replace_model(self, replace, m3d, force_wrl, is_copy_mode, rename_function, rename_data): """ Helper function to replace the 3D model in m3d using the `replace` file """ self.source_models.add(replace) old_name = m3d.m_Filename new_name = self.wrl_name(replace, force_wrl) if not is_copy_mode else rename_function(rename_data, replace) self.undo_3d_models[new_name] = old_name m3d.m_Filename = new_name self.models_replaced = True def download_models(self, rename_filter=None, rename_function=None, rename_data=None, force_wrl=False, all_comps=None): """ Check we have the 3D models. Inform missing models. Try to download the missing models Stores changes in self.undo_3d_models_rep """ self.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() # For the mode where we copy the 3D models self.source_models = set() is_copy_mode = rename_filter is not None rel_dirs = getattr(rename_data, 'rel_dirs', []) extra_debug = GS.debug_level > 3 if all_comps is None: all_comps = [] all_comps_hash = {c.ref: c for c in all_comps} # Find the LCSC field lcsc_field = self.solve_field_name('_field_lcsc_part', empty_when_none=True) # Find a place to store the downloaded models if self._tmp_dir is None: self._tmp_dir = os.environ.get('KIBOT_3D_MODELS') if self._tmp_dir is None: self._tmp_dir = os.path.join(os.path.expanduser('~'), '.cache', 'kibot', '3d') else: self._tmp_dir = os.path.abspath(self._tmp_dir) rel_dirs.append(self._tmp_dir) logger.debug('Using `{}` as dir for downloaded 3D models'.format(self._tmp_dir)) # Look for all the footprints for m in GS.get_modules(): ref = m.GetReference() lib_id = m.GetFPID() lib_nickname = str(lib_id.GetLibNickname()) sch_comp = all_comps_hash.get(ref, None) # 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: if m3d.m_Filename.endswith(DISABLE_3D_MODEL_TEXT): # Skip models we intentionally disabled using a bogus name if extra_debug: logger.debug("- Skipping {} (disabled)".format(m3d.m_Filename)) continue if is_copy_mode and not fnmatch(m3d.m_Filename, rename_filter): # Skip filtered footprints continue used_extra = [False] full_name = do_expand_env(m3d.m_Filename, used_extra, extra_debug, lib_nickname) if not os.path.isfile(full_name): logger.debugl(2, 'Missing 3D model file {} ({})'.format(full_name, m3d.m_Filename)) # Missing 3D model if self.download: replace = self.try_download_kicad(m3d.m_Filename, full_name, downloaded, rel_dirs, force_wrl) if replace is None: replace = self.try_download_easyeda(m3d.m_Filename, full_name, downloaded, sch_comp, lcsc_field) if replace: replace = self.do_colored_tht_resistor(replace, sch_comp, used_extra) self.replace_model(replace, m3d, force_wrl, is_copy_mode, rename_function, rename_data) if full_name not in downloaded: logger.warning(W_MISS3D+'Missing 3D model for {}: `{}`'.format(ref, full_name)) else: # File was found replace = self.do_colored_tht_resistor(full_name, sch_comp, used_extra) if used_extra[0] or is_copy_mode: # The file is there, but we got it expanding a user defined text # This is completely valid for KiCad, but kicad2step doesn't support it if not self.models_replaced and extra_debug: logger.debug('- Modifying models with text vars') self.replace_model(replace, m3d, force_wrl, is_copy_mode, rename_function, rename_data) # Push the models back for model in reversed(models_l): models.append(model) if downloaded: logger.warning(W_DOWN3D+' {} 3D models downloaded'.format(len(downloaded))) return self.models_replaced if not is_copy_mode else list(self.source_models) def list_models(self, even_missing=False): """ 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.get_modules(): # Look for all the 3D models for this footprint for m3d in m.Models(): full_name = KiConf.expand_env(m3d.m_Filename) if even_missing or os.path.isfile(full_name): models.add(full_name) return list(models) def filter_components(self, highlight=None, force_wrl=False): if not self._comps: # No filters, but we need to apply some stuff all_comps = None dnp_removed = False # Get a list of components in the schematic. Enables downloading LCSC parts. if GS.sch_file: GS.load_sch() all_comps = GS.sch.get_components() if (GS.global_kicad_dnp_applies_to_3D and any(map(lambda c: c.kicad_dnp is not None and c.kicad_dnp, all_comps))): # One or more components are DNP, remove them reset_filters(all_comps) all_comps_hash = {c.ref: c for c in all_comps} self.remove_3D_models(GS.board, all_comps_hash) dnp_removed = True # No variant/filter to apply if self.download_models(force_wrl=force_wrl, all_comps=all_comps) or dnp_removed: # Some missing components found and we downloaded them # Save the fixed board ret = self.save_tmp_board() # Undo the changes done during download self.undo_3d_models_rename(GS.board) if dnp_removed: self.restore_3D_models(GS.board, all_comps_hash) return ret return GS.pcb_file self.filter_pcb_components(do_3D=True, do_2D=True, highlight=highlight) self.download_models(force_wrl=force_wrl, all_comps=self._comps) fname = self.save_tmp_board() self.unfilter_pcb_components(do_3D=True, do_2D=True) return fname def get_targets(self, out_dir): return [self._parent.expand_filename(out_dir, self.output)] def remove_temporals(self): super().remove_temporals() self._tmp_dir = None class Base3DOptionsWithHL(Base3DOptions): """ 3D options including which components will be displayed and highlighted """ def __init__(self): with document: 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.highlight = Optionable """ [list(string)=[]] List of components to highlight """ self.highlight_padding = 1.5 """ [0,1000] How much the highlight extends around the component [mm] """ self.highlight_on_top = False """ Highlight over the component (not under) """ super().__init__() def config(self, parent): super().config(parent) self._filters_to_expand = False # List of components self._show_all_components = False self._show_components_raw = self.show_components if isinstance(self.show_components, str): if self.show_components == 'all': self._show_all_components = True self.show_components = [] elif isinstance(self.show_components, type): # Default is all self._show_all_components = True else: # a list self.show_components = self.solve_kf_filters(self.show_components) # Highlight if isinstance(self.highlight, type): self.highlight = None else: self.highlight = self.solve_kf_filters(self.highlight) def copy_options(self, ref): """ Copy its options from another similar object """ super().copy_options(ref) self.show_components = ref.show_components self.highlight = ref.highlight self.highlight_padding = ref.highlight_padding self.highlight_on_top = ref.highlight_on_top self._filters_to_expand = ref._filters_to_expand self._show_all_components = ref._show_all_components class Base3D(BaseOutput): def __init__(self): super().__init__() def get_dependencies(self): files = super().get_dependencies() files.extend(self.options.list_models()) return files