[3D] colored 3D models for THT resistors

This commit is contained in:
Salvador E. Tropea 2023-03-20 14:26:09 -03:00
parent e6ee30a004
commit 05105cdc9a
6 changed files with 276 additions and 19 deletions

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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 """

View File

@ -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")