[PCB Variant] Implemented the 3D filtering

- The PCB filtering code was moved to make it, some errors could be
  introduced
This commit is contained in:
Salvador E. Tropea 2022-09-09 08:57:05 -03:00
parent b0b7c6c041
commit 4dc93da42b
9 changed files with 279 additions and 177 deletions

View File

@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- New outputs:
- PCB_Variant: saves a PCB with filters and variants applied.
- Support for Eurocircuits drill adjust to fix small OARs.
Option `eurocircuits_reduce_holes`. (#227)

View File

@ -2270,6 +2270,34 @@ Notes:
Internally we use 10 for low priority, 90 for high priority and 50 for most outputs.
- `run_by_default`: [boolean=true] When enabled this output will be created when no specific outputs are requested.
* PCB with variant generator
* Type: `pcb_variant`
* Description: Creates a copy of the PCB with all the filters and variants applied.
This copy isn't intended for development.
Is just a tweaked version of the original where you can look at the results.
* 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 `pcb_variant` output.
* Valid keys:
- **`output`**: [string='%f-%i%I%v.%x'] Filename for the output (%i=variant, %x=kicad_pcb). Affected by global options.
- `dnf_filter`: [string|list(string)='_none'] Name of the filter to mark components as not fitted.
A short-cut to use for simple cases where a variant is an overkill.
- `hide_excluded`: [boolean=false] Hide components in the Fab layer that are marked as excluded by a variant.
- `variant`: [string=''] Board variant to apply.
- `category`: [string|list(string)=''] The category for this output. If not specified an internally defined category is used.
Categories looks like file system paths, i.e. PCB/fabrication/gerber.
- `disable_run_by_default`: [string|boolean] Use it to disable the `run_by_default` status of other output.
Useful when this output extends another and you don't want to generate the original.
Use the boolean true value to disable the output you are extending.
- `extends`: [string=''] Copy the `options` section from the indicated output.
- `output_id`: [string=''] Text to use for the %I expansion content. To differentiate variations of this output.
- `priority`: [number=50] [0,100] Priority for this output. High priority outputs are created first.
Internally we use 10 for low priority, 90 for high priority and 50 for most outputs.
- `run_by_default`: [boolean=true] When enabled this output will be created when no specific outputs are requested.
* PcbDraw - Beautiful 2D PCB render
* Type: `pcbdraw`
* Description: Exports the PCB as a 2D model (SVG, PNG or JPG).
@ -2741,6 +2769,9 @@ Notes:
- `converted_output`: [string='%f-%i%I%v.%x'] Converted output file name (%i='report', %x=`convert_to`).
Note that the extension should match the `convert_to` value. Affected by global options.
- `eurocircuits_class_target`: [string='10F'] Which Eurocircuits class are we aiming at.
- `eurocircuits_reduce_holes`: [number=0.45] When computing the Eurocircuits category: Final holes sizes smaller or equal to this given
diameter can be reduced to accommodate the correct annular ring values.
Use 0 to disable it.
- `category`: [string|list(string)=''] The category for this output. If not specified an internally defined category is used.
Categories looks like file system paths, i.e. PCB/fabrication/gerber.
- `disable_run_by_default`: [string|boolean] Use it to disable the `run_by_default` status of other output.

View File

@ -1194,6 +1194,23 @@ outputs:
variant: ''
# [string=''] Color used for through-hole `colored_vias`
via_color: ''
# PCB 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.
- name: 'pcb_variant_example'
comment: 'Creates a copy of the PCB with all the filters and variants applied.'
type: 'pcb_variant'
dir: 'Example/pcb_variant_dir'
options:
# [string|list(string)='_none'] 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: '_none'
# [boolean=false] Hide components in the Fab layer that are marked as excluded by a variant
hide_excluded: false
# [string='%f-%i%I%v.%x'] Filename for the output (%i=variant, %x=kicad_pcb). Affected by global options
output: '%f-%i%I%v.%x'
# [string=''] Board variant to apply
variant: ''
# PcbDraw - Beautiful 2D PCB render:
# Uses configurable colors.
# Can also render the components if the 2D models are available
@ -1634,6 +1651,10 @@ outputs:
do_convert: false
# [string='10F'] Which Eurocircuits class are we aiming at
eurocircuits_class_target: '10F'
# [number=0.45] When computing the Eurocircuits category: Final holes sizes smaller or equal to this given
# diameter can be reduced to accommodate the correct annular ring values.
# Use 0 to disable it
eurocircuits_reduce_holes: 0.45
# [string='%f-%i%I%v.%x'] Output file name (%i='report', %x='txt'). Affected by global options
output: '%f-%i%I%v.%x'
# [string='full'] Name for one of the internal templates (full, full_svg, simple) or a custom template file.

View File

@ -243,6 +243,8 @@ SILK_COLORS = {'black': "0b1013", 'white': "d5dce4"}
KICAD5_SVG_SCALE = 116930/297002200
# Some browser name to pretend
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0'
# Text used to disable 3D models
DISABLE_3D_MODEL_TEXT = '_Disabled_by_KiBot'
class Rect(object):

View File

@ -3,13 +3,14 @@
# Copyright (c) 2020-2022 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
import os
from copy import deepcopy
from tempfile import NamedTemporaryFile, mkdtemp
from glob import glob
import os
import re
from tempfile import NamedTemporaryFile, mkdtemp
from .gs import GS
from .kiplot import load_sch, get_board_comps_data
from .misc import Rect, W_WRONGPASTE
from .misc import Rect, W_WRONGPASTE, DISABLE_3D_MODEL_TEXT
if not GS.kicad_version_n:
# When running the regression tests we need it
from kibot.__main__ import detect_kicad
@ -197,6 +198,8 @@ class VariantOptions(BaseOptions):
A short-cut to use for simple cases where a variant is an overkill """
super().__init__()
self._comps = None
self.undo_3d_models = {}
self.undo_3d_models_rep = {}
def config(self, parent):
super().config(parent)
@ -408,23 +411,199 @@ class VariantOptions(BaseOptions):
for gi in self.old_bfab:
gi.SetLayer(self.bfab)
def filter_pcb_components(self, board):
def replace_3D_models(self, models, new_model, c):
""" Changes the 3D model using a provided model.
Stores changes in self.undo_3d_models_rep """
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 undo_3d_models_rename(self, board):
""" Restores the file name for any renamed 3D module """
for m in GS.get_modules_board(board):
# 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)
# Reset the list of changes
self.undo_3d_models = {}
self.undo_3d_models_rep = {}
def remove_3D_models(self, board, comps_hash):
""" Removes 3D models for excluded or not fitted components.
Applies the global_field_3D_model model rename """
if not comps_hash:
return
# Remove the 3D models for not fitted components
rem_models = []
for m in GS.get_modules_board(board):
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_field_3D_model)
if new_model:
# We will change the 3D model
self.replace_3D_models(models, new_model, c)
self.rem_models = rem_models
def restore_3D_models(self, board, comps_hash):
""" Restore the removed 3D models.
Restores the renamed models. """
self.undo_3d_models_rename(board)
if not comps_hash:
return
# Undo the removing
for m in GS.get_modules_board(board):
ref = m.GetReference()
c = comps_hash.get(ref, None)
if c and c.included and not c.fitted:
models = m.Models()
restore = self.rem_models.pop(0)
for model in restore:
models.push_front(model)
def apply_list_of_3D_models(self, enable, slots, m, var):
# Disable the unused models adding bogus text to the end
slots = [int(v) for v in slots if v]
models = m.Models()
m_objs = []
# Extract the models, we get a copy
while not models.empty():
m_objs.insert(0, models.pop())
for i, m3d in enumerate(m_objs):
if self.extra_debug:
logger.debug('- {} {} {} {}'.format(var, i+1, i+1 in slots, m3d.m_Filename))
if i+1 not in slots:
if enable:
# Revert the added text
m3d.m_Filename = m3d.m_Filename[:-self.len_disable]
else:
# Not used, add text to make their name invalid
m3d.m_Filename += DISABLE_3D_MODEL_TEXT
# Push it back to the module
models.push_back(m3d)
def apply_3D_variant_aspect(self, board, enable=False):
""" Disable/Enable the 3D models that aren't for this variant.
This mechanism uses the MTEXT attributes. """
# The magic text is %variant:slot1,slot2...%
field_regex = re.compile(r'\%([^:]+):([\d,]*)\%') # Generic (by name)
field_regex_sp = re.compile(r'\$([^:]*):([\d,]*)\$') # Variant specific
self.extra_debug = extra_debug = GS.debug_level > 3
if extra_debug:
logger.debug("{} 3D models that aren't for this variant".format('Enable' if enable else 'Disable'))
self.len_disable = len(DISABLE_3D_MODEL_TEXT)
variant_name = self.variant.name
for m in GS.get_modules_board(board):
if extra_debug:
logger.debug("Processing module " + m.GetReference())
default = None
matched = False
# Look for text objects
for gi in m.GraphicalItems():
if gi.GetClass() == 'MTEXT':
# Check if the text matches the magic style
text = gi.GetText().strip()
match = field_regex.match(text)
if match:
# Check if this is for the current variant
var = match.group(1)
slots = match.group(2).split(',') if match.group(2) else []
# Do the match
if var == '_default_':
default = slots
if self.extra_debug:
logger.debug('- Found defaults: {}'.format(slots))
else:
matched = var == variant_name
if matched:
self.apply_list_of_3D_models(enable, slots, m, var)
break
else:
# Try with the variant specific pattern
match = field_regex_sp.match(text)
if match:
var = match.group(1)
slots = match.group(2).split(',') if match.group(2) else []
# Do the match
matched = self.variant.matches_variant(var)
if matched:
self.apply_list_of_3D_models(enable, slots, m, var)
break
if not matched and default is not None:
self.apply_list_of_3D_models(enable, slots, m, '_default_')
def filter_pcb_components(self, board, do_3D=False, do_2D=True):
if not self._comps:
return False
self.comps_hash = self.get_refs_hash()
self.cross_modules(board, self.comps_hash)
self.remove_paste_and_glue(board, self.comps_hash)
if hasattr(self, 'hide_excluded') and self.hide_excluded:
self.remove_fab(board, self.comps_hash)
if do_2D:
self.cross_modules(board, self.comps_hash)
self.remove_paste_and_glue(board, self.comps_hash)
if hasattr(self, 'hide_excluded') and self.hide_excluded:
self.remove_fab(board, self.comps_hash)
if do_3D:
# Disable the models that aren't for this variant
self.apply_3D_variant_aspect(board)
# Remove the 3D models for not fitted components (also rename)
self.remove_3D_models(board, self.comps_hash)
return True
def unfilter_pcb_components(self, board):
def unfilter_pcb_components(self, board, do_3D=False, do_2D=True):
if not self._comps:
return
self.uncross_modules(board, self.comps_hash)
self.restore_paste_and_glue(board, self.comps_hash)
if hasattr(self, 'hide_excluded') and self.hide_excluded:
self.restore_fab(board, self.comps_hash)
if do_2D:
self.uncross_modules(board, self.comps_hash)
self.restore_paste_and_glue(board, self.comps_hash)
if hasattr(self, 'hide_excluded') and self.hide_excluded:
self.restore_fab(board, self.comps_hash)
if do_3D:
# Undo the removing (also rename)
self.restore_3D_models(board, self.comps_hash)
# Re-enable the modules that aren't for this variant
self.apply_3D_variant_aspect(board, enable=True)
def set_title(self, title):
self.old_title = None

View File

@ -4,19 +4,16 @@
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
import os
import re
import requests
import tempfile
from .error import KiPlotConfigurationError
from .misc import W_MISS3D, W_FAILDL
from .gs import (GS)
from .misc import W_MISS3D, W_FAILDL, DISABLE_3D_MODEL_TEXT
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()
DISABLE_TEXT = '_Disabled_by_KiBot'
class Base3DOptions(VariantOptions):
@ -49,56 +46,11 @@ class Base3DOptions(VariantOptions):
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.get_modules():
# 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 """
Try to download the missing models
Stores changes in self.undo_3d_models_rep """
models_replaced = False
# Load KiCad configuration so we can expand the 3D models path
KiConf.init(GS.pcb_file)
@ -116,7 +68,7 @@ class Base3DOptions(VariantOptions):
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_TEXT):
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))
@ -180,77 +132,6 @@ class Base3DOptions(VariantOptions):
models.add(full_name)
return list(models)
def apply_list_of_models(self, enable, slots, m, var):
# Disable the unused models adding bogus text to the end
slots = [int(v) for v in slots if v]
models = m.Models()
m_objs = []
# Extract the models, we get a copy
while not models.empty():
m_objs.insert(0, models.pop())
for i, m3d in enumerate(m_objs):
if self.extra_debug:
logger.debug('- {} {} {} {}'.format(var, i+1, i+1 in slots, m3d.m_Filename))
if i+1 not in slots:
if enable:
# Revert the added text
m3d.m_Filename = m3d.m_Filename[:-self.len_disable]
else:
# Not used, add text to make their name invalid
m3d.m_Filename += DISABLE_TEXT
# Push it back to the module
models.push_back(m3d)
def apply_variant_aspect(self, enable=False):
""" Disable/Enable the 3D models that aren't for this variant.
This mechanism uses the MTEXT attributes. """
# The magic text is %variant:slot1,slot2...%
field_regex = re.compile(r'\%([^:]+):([\d,]*)\%') # Generic (by name)
field_regex_sp = re.compile(r'\$([^:]*):([\d,]*)\$') # Variant specific
self.extra_debug = extra_debug = GS.debug_level > 3
if extra_debug:
logger.debug("{} 3D models that aren't for this variant".format('Enable' if enable else 'Disable'))
self.len_disable = len(DISABLE_TEXT)
variant_name = self.variant.name
for m in GS.get_modules():
if extra_debug:
logger.debug("Processing module " + m.GetReference())
default = None
matched = False
# Look for text objects
for gi in m.GraphicalItems():
if gi.GetClass() == 'MTEXT':
# Check if the text matches the magic style
text = gi.GetText().strip()
match = field_regex.match(text)
if match:
# Check if this is for the current variant
var = match.group(1)
slots = match.group(2).split(',') if match.group(2) else []
# Do the match
if var == '_default_':
default = slots
if self.extra_debug:
logger.debug('- Found defaults: {}'.format(slots))
else:
matched = var == variant_name
if matched:
self.apply_list_of_models(enable, slots, m, var)
break
else:
# Try with the variant specific pattern
match = field_regex_sp.match(text)
if match:
var = match.group(1)
slots = match.group(2).split(',') if match.group(2) else []
# Do the match
matched = self.variant.matches_variant(var)
if matched:
self.apply_list_of_models(enable, slots, m, var)
break
if not matched and default is not None:
self.apply_list_of_models(enable, slots, m, '_default_')
def filter_components(self):
self.undo_3d_models_rep = {}
if not self._comps:
@ -259,47 +140,14 @@ class Base3DOptions(VariantOptions):
# Some missing components found and we downloaded them
# Save the fixed board
ret = self.save_tmp_board()
# Undo the changes
self.undo_3d_models_rename()
# Undo the changes done during download
self.undo_3d_models_rename(GS.board)
return ret
return GS.pcb_file
comps_hash = self.get_refs_hash()
# Disable the models that aren't for this variant
self.apply_variant_aspect()
# Remove the 3D models for not fitted components
rem_models = []
for m in GS.get_modules():
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_field_3D_model)
if new_model:
# We will change the 3D model
self.replace_models(models, new_model, c)
self.filter_pcb_components(GS.board, do_3D=True, do_2D=False)
self.download_models()
fname = self.save_tmp_board()
self.undo_3d_models_rename()
# Undo the removing
for m in GS.get_modules():
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)
# Re-enable the modules that aren't for this variant
self.apply_variant_aspect(enable=True)
self.unfilter_pcb_components(GS.board, do_3D=True, do_2D=False)
return fname
def get_targets(self, out_dir):

View File

@ -27,11 +27,11 @@ class PCB_Variant_Options(VariantOptions):
def run(self, output):
super().run(output)
self.filter_pcb_components(GS.board)
logger.error('Saving PCB to '+output)
self.filter_pcb_components(GS.board, do_3D=True)
logger.debug('Saving PCB to '+output)
GS.board.Save(output)
GS.copy_project(output)
self.unfilter_pcb_components(GS.board)
self.unfilter_pcb_components(GS.board, do_3D=True)
@output_class

View File

@ -69,6 +69,7 @@ Outer Annular Ring: ${oar_mm} mm (${oar_mils} mils)
- By design rules: ${oar_d_mm} mm (${oar_d_mils} mils)
Eurocircuits class: ${pattern_class}${drill_class}
- Using min drill ${drill_real_ec_mm} mm for an OAR of ${oar_ec_mm} mm
# General stats

View File

@ -0,0 +1,18 @@
# Example KiBot config file
kibot:
version: 1
variants:
- name: 'default'
comment: 'Default variant'
type: ibom
variants_blacklist: T2,T3
outputs:
- name: 'pcb_default'
comment: "PCB w/variant"
type: pcb_variant
options:
variant: default
scaling: 0
title: 'Hello %V'