diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cefd1fd..662bad07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.6.2] - UNRELEASED +### Added +- 3D: colored 3D models for THT resistors + ### Fixed - Makefile: don't skip all preflights on each run, just the ones we generate as targets. (#405) diff --git a/kibot/globals.py b/kibot/globals.py index 8dd5a8cf..273f9c6d 100644 --- a/kibot/globals.py +++ b/kibot/globals.py @@ -264,6 +264,8 @@ class Globals(FiltersOptions): """ The KiCad v7 PCB flag *Do Not Populate* is applied to our fitted flag for 3D models, even when no filter/variant is specified. Disabling `kicad_dnp_applied` also disables this flag """ + self.colored_tht_resistors = True + """ Try to add color bands to the 3D models of KiCad THT resistors """ self.set_doc('filters', " [list(dict)] KiBot warnings to be ignored ") self._filter_what = 'KiBot warnings' self.filters = FilterOptionsKiBot diff --git a/kibot/gs.py b/kibot/gs.py index 7d03837e..8059d576 100644 --- a/kibot/gs.py +++ b/kibot/gs.py @@ -123,6 +123,7 @@ class GS(object): # The class that controls the global options class_for_global_opts = None global_castellated_pads = None + global_colored_tht_resistors = None global_copper_thickness = None global_cross_footprints_for_dnp = None global_cross_no_body = None diff --git a/kibot/misc.py b/kibot/misc.py index a02b994a..22c8fdba 100644 --- a/kibot/misc.py +++ b/kibot/misc.py @@ -267,6 +267,10 @@ W_BLINDVIAS = '(W118) ' W_LIBTVERSION = '(W119) ' W_LIBTUNK = '(W120) ' W_DRC7BUG = '(W121) ' +W_BADTOL = '(W122) ' +W_BADRES = '(W123) ' +W_RESVALISSUE = '(W124) ' +W_RES3DNAME = '(W125) ' # 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", diff --git a/kibot/out_base_3d.py b/kibot/out_base_3d.py index 76ae4511..1f071494 100644 --- a/kibot/out_base_3d.py +++ b/kibot/out_base_3d.py @@ -5,10 +5,13 @@ # 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 +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 @@ -17,6 +20,46 @@ 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): @@ -181,18 +224,219 @@ class Base3DOptions(VariantOptions): 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 + # TODO: Configurable + tol = c.get_field_value('tol') or c.get_field_value('tolerance') + if not tol: + tol = 20 + logger.warning(W_BADTOL+'Missing tolerance for {}, using 20%'.format(c.ref)) + 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)) + 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): + 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 """ - models_replaced = False + 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 - source_models = set() + 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 @@ -241,34 +485,24 @@ class Base3DOptions(VariantOptions): if replace is None: replace = self.try_download_easyeda(m3d.m_Filename, full_name, downloaded, sch_comp, lcsc_field) if replace: - 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 - models_replaced = True + 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 - source_models.add(full_name) - old_name = m3d.m_Filename - new_name = (self.wrl_name(full_name, force_wrl) if not is_copy_mode else - rename_function(rename_data, full_name)) - self.undo_3d_models[new_name] = old_name - m3d.m_Filename = new_name - if not models_replaced and extra_debug: + if not self.models_replaced and extra_debug: logger.debug('- Modifying models with text vars') - models_replaced = True + 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 models_replaced if not is_copy_mode else list(source_models) + 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 """ diff --git a/tests/test_plot/test_step.py b/tests/test_plot/test_step.py index 07694310..257faf65 100644 --- a/tests/test_plot/test_step.py +++ b/tests/test_plot/test_step.py @@ -155,6 +155,19 @@ def test_render_3d_variant_1(test_dir): ctx.clean_up(keep_project=True) +# @pytest.mark.slow +# @pytest.mark.pcbnew +# def test_render_3d_res_tht_1(test_dir): +# prj = 'resistor_tht' +# yaml = 'render_3d_tht_res_1' +# ctx = context.TestContext(test_dir, prj, yaml) +# ctx.run() # extra_debug=True +# # Check all outputs are there +# name = prj+'-3D_top.png' +# ctx.expect_out_file(name) +# ctx.clean_up(keep_project=True) + + @pytest.mark.slow @pytest.mark.pcbnew @pytest.mark.skipif(context.ki5(), reason="uses pcb2blender")