[Copy Files][Added] Mode to export the whole project

SCH, PCB, symbols, footprints, 3D models and project files

Closes #491
This commit is contained in:
Salvador E. Tropea 2023-11-24 09:09:17 -03:00
parent 46c1ff5dd1
commit e686d9b6bb
7 changed files with 273 additions and 72 deletions

View File

@ -64,6 +64,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Basic support for regular list items (#480)
- Position:
- Experimental support for gerber position files (#500)
- Copy Files:
- Mode to export the whole project (SCH, PCB, symbols, footprints, 3D models
and project files) (#491)
- Help for the error levels
- Warnings:
- Explain about wrong dir/output separation (#493)
@ -98,6 +101,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
versions (#496)
- Problems when using NET_NAME(n) for a value (#511)
- JLCPCB rotations for bottom components
- Copy Files:
- Warnings when using both, the STEP and WRL model, of the same component
- Fail to detect 3D models subdirs when running alone
## [1.6.3] - 2023-06-26

View File

@ -697,24 +697,29 @@ outputs:
files:
# [string=''] Destination directory inside the output dir, empty means the same of the file
# relative to the source directory.
# Note that when you specify a name here files are copied to this destination
# without creating subdirs. The `project` mode is an exception.
# For the `3d_models` type you can use DIR+ to create subdirs under DIR
- dest: ''
# [string='.*'] A regular expression that source files must match
# [string='.*'] A regular expression that source files must match.
# Not used for the `project` mode
filter: '.*'
# [boolean=false] Only usable for the `3d_models` mode.
# Save a PCB copy modified to use the copied 3D models
# Save a PCB copy modified to use the copied 3D models.
# You don't need to specify it for `project` mode
save_pcb: false
# [string='*'] File names to add, wildcards allowed. Use ** for recursive match.
# By default this pattern is applied to the current working dir.
# See the `from_outdir` option
# [string='*'] For the `files` and `out_files` mode this is th file names to add,
# wildcards allowed. Use ** for recursive match.
# For the `output` mode this is the name of the output.
# For the `3d_models` is a pattern to match the name of the 3D models extracted from the PCB.
# Not used for the `project` mode
source: '*'
# [string='files'] [files,out_files,output,3d_models] How to interpret `source`.
# `files`: is a pattern for files relative to the working directory.
# `out_files`: is a pattern for files relative to output dir specified
# with `-d` command line option.
# `output`: is the name of an `output`.
# `3d_models`: is a pattern to match the name of the 3D models extracted
# from the PCB.
# [string='files'] [files,out_files,output,3d_models,project] From where do we get the files to be copied.
# `files`: files relative to the current working directory.
# `out_files`: files relative to output dir specified with `-d` command line option.
# `output`: files generated by the output specified by `source`.
# `3d_models`: 3D models used in the project.
# `project`: schematic, PCB, footprints, symbols, 3D models and project files (KiCad 6+)
source_type: 'files'
# [boolean=true] Store the file pointed by symlinks, not the symlink
follow_links: true

View File

@ -33,22 +33,27 @@ Parameters:
- Valid keys:
- **source** :index:`: <pair: output - copy_files - options - files; source>` [string='*'] File names to add, wildcards allowed. Use ** for recursive match.
By default this pattern is applied to the current working dir.
See the `from_outdir` option.
- **source_type** :index:`: <pair: output - copy_files - options - files; source_type>` [string='files'] [files,out_files,output,3d_models] How to interpret `source`.
`files`: is a pattern for files relative to the working directory.
`out_files`: is a pattern for files relative to output dir specified
with `-d` command line option.
`output`: is the name of an `output`.
`3d_models`: is a pattern to match the name of the 3D models extracted
from the PCB..
- **source** :index:`: <pair: output - copy_files - options - files; source>` [string='*'] For the `files` and `out_files` mode this is th file names to add,
wildcards allowed. Use ** for recursive match.
For the `output` mode this is the name of the output.
For the `3d_models` is a pattern to match the name of the 3D models extracted from the PCB.
Not used for the `project` mode.
- **source_type** :index:`: <pair: output - copy_files - options - files; source_type>` [string='files'] [files,out_files,output,3d_models,project] From where do we get the files to be copied.
`files`: files relative to the current working directory.
`out_files`: files relative to output dir specified with `-d` command line option.
`output`: files generated by the output specified by `source`.
`3d_models`: 3D models used in the project.
`project`: schematic, PCB, footprints, symbols, 3D models and project files (KiCad 6+).
- ``dest`` :index:`: <pair: output - copy_files - options - files; dest>` [string=''] Destination directory inside the output dir, empty means the same of the file
relative to the source directory.
Note that when you specify a name here files are copied to this destination
without creating subdirs. The `project` mode is an exception.
For the `3d_models` type you can use DIR+ to create subdirs under DIR.
- ``filter`` :index:`: <pair: output - copy_files - options - files; filter>` [string='.*'] A regular expression that source files must match.
Not used for the `project` mode.
- ``save_pcb`` :index:`: <pair: output - copy_files - options - files; save_pcb>` [boolean=false] Only usable for the `3d_models` mode.
Save a PCB copy modified to use the copied 3D models.
You don't need to specify it for `project` mode.
- **no_virtual** :index:`: <pair: output - copy_files - options; no_virtual>` [boolean=false] Used to exclude 3D models for components with 'virtual' attribute.
- ``dnf_filter`` :index:`: <pair: output - copy_files - options; dnf_filter>` [string|list(string)='_none'] Name of the filter to mark components as not fitted.

View File

@ -465,13 +465,14 @@ class GS(object):
GS.exit_with_error('No SCH file found (*.sch), use -e to specify one.', EXIT_BAD_ARGS)
@staticmethod
def copy_project(new_pcb_name):
def copy_project(new_pcb_name, dry=False):
pro_name = GS.pro_file
if pro_name is None or not os.path.isfile(pro_name):
return None
pro_copy = new_pcb_name.replace('.kicad_pcb', GS.pro_ext)
logger.debug('Copying project `{}` to `{}`'.format(pro_name, pro_copy))
copy2(pro_name, pro_copy)
if not dry:
logger.debug('Copying project `{}` to `{}`'.format(pro_name, pro_copy))
copy2(pro_name, pro_copy)
return pro_copy
@staticmethod
@ -757,3 +758,16 @@ class GS(object):
if GS.ki6:
return shape.ShowShape()
return shape.ShowShape(shape.GetShape())
@staticmethod
def create_fp_lib(lib_name):
""" Create a new footprints lib. You must provide a path.
Doesn't fail if the lib is there.
.pretty extension is forced. """
if not lib_name.endswith('.pretty'):
lib_name += '.pretty'
# FootprintLibCreate(PATH) doesn't check if the lib is there and aborts (no Python exception) if the directory exists
# So you must check it first and hence the abstraction is lost.
# This is why we just use os.makedirs
os.makedirs(lib_name, exist_ok=True)
return lib_name

View File

@ -29,7 +29,7 @@ from ..gs import GS
from .. import log
from ..misc import (W_NOCONFIG, W_NOKIENV, W_NOLIBS, W_NODEFSYMLIB, MISSING_WKS, W_MAXDEPTH, W_3DRESVER, W_LIBTVERSION,
W_LIBTUNK)
from .sexpdata import load, SExpData
from .sexpdata import load, SExpData, Symbol, dumps, Sep
from .sexp_helpers import _check_is_symbol_list, _check_integer, _check_relaxed
# Check python version to determine which version of ConfirParser to import
@ -124,6 +124,7 @@ class LibAlias(object):
self.uri = None
self.options = None
self.descr = None
self.type = None
@staticmethod
def parse(items, env, extra_env):
@ -142,6 +143,7 @@ class LibAlias(object):
s.descr = _check_relaxed(i, 1, i_type)
else:
logger.warning(W_LIBTUNK+'Unknown lib table attribute `{}`'.format(i))
s.legacy = s.type is not None and s.type != 'Legacy'
return s
def __str__(self):
@ -545,6 +547,29 @@ class KiConf(object):
KiConf.fp_aliases = KiConf.load_all_lib_aliases(FP_LIB_TABLE, KiConf.footprint_dir, '*.pretty')
return KiConf.fp_aliases
def save_fp_lib_aliases(fname, aliases, is_fp=True):
logger.debug(f'Writing lib table `{fname}`')
table = [Symbol('fp_lib_table' if is_fp else 'sym_lib_table'), Sep()]
for name in sorted(aliases.keys(), key=str.casefold):
alias = aliases[name]
cnt = [[Symbol('name'), alias.name],
[Symbol('type'), alias.type],
[Symbol('uri'), alias.uri]]
if alias.options is not None:
cnt.append([Symbol('options'), alias.options])
if alias.descr is not None:
cnt.append([Symbol('descr'), alias.descr])
table.append([Symbol('lib')] + cnt)
table.append(Sep())
with open(fname, 'wt') as f:
f.write(dumps(table))
f.write('\n')
def fp_nick_to_path(nick):
fp_aliases = KiConf.get_fp_lib_aliases()
alias = fp_aliases.get(str(nick)) # UTF8 -> str
return alias
def load_3d_aliases():
if not KiConf.config_dir:
return

View File

@ -1896,6 +1896,26 @@ class SchematicV6(Schematic):
data.extend([s.write(cross), Sep()])
return [Sep(), Sep(), _symbol('lib_symbols', data), Sep()]
def write_lib(self, path, name, comps, do_back_up=False):
""" Creates a lib path/name containing the specified comps """
lib = [Symbol('kicad_symbol_lib')]
# TODO: How do I know which is the valid version? Doesn't match the schematic version!
lib.append(_symbol('version', [20220914]))
lib.append(_symbol('generator', [Symbol("KiBot")]))
lib.append(Sep())
for s in comps:
lib.extend([self.lib_symbol_names[name+':'+s].write(False), Sep()])
# Keep a back-up of existing files
fname = os.path.join(path, name+'.kicad_sym')
if do_back_up and os.path.isfile(fname):
bkp = fname+'-bak'
os.replace(fname, bkp)
dirname = os.path.dirname(fname)
os.makedirs(dirname, exist_ok=True)
with open(fname, 'wt') as f:
f.write(dumps(lib))
f.write('\n')
def save(self, fname=None, dest_dir=None, base_sheet=None, saved=None, cross=False, exp_hierarchy=False):
# Switch to the current version
global version

View File

@ -3,6 +3,7 @@
# Copyright (c) 2022-2023 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
from copy import copy
import fnmatch
import glob
import os
@ -12,8 +13,8 @@ from sys import exit
from .error import KiPlotConfigurationError
from .gs import GS
from .kiplot import config_output, get_output_dir, run_output
from .kicad.config import KiConf
from .misc import WRONG_ARGUMENTS, INTERNAL_ERROR, W_COPYOVER
from .kicad.config import KiConf, LibAlias, FP_LIB_TABLE, SYM_LIB_TABLE
from .misc import WRONG_ARGUMENTS, INTERNAL_ERROR, W_COPYOVER, W_MISSLIB, W_MISSCMP
from .optionable import Optionable
from .out_base_3d import Base3DOptions
from .registrable import RegOutput
@ -35,43 +36,46 @@ class FilesList(Optionable):
super().__init__()
with document:
self.source = '*'
""" *File names to add, wildcards allowed. Use ** for recursive match.
By default this pattern is applied to the current working dir.
See the `from_outdir` option """
""" *For the `files` and `out_files` mode this is th file names to add,
wildcards allowed. Use ** for recursive match.
For the `output` mode this is the name of the output.
For the `3d_models` is a pattern to match the name of the 3D models extracted from the PCB.
Not used for the `project` mode """
self.source_type = 'files'
""" *[files,out_files,output,3d_models] How to interpret `source`.
`files`: is a pattern for files relative to the working directory.
`out_files`: is a pattern for files relative to output dir specified
with `-d` command line option.
`output`: is the name of an `output`.
`3d_models`: is a pattern to match the name of the 3D models extracted
from the PCB. """
""" *[files,out_files,output,3d_models,project] From where do we get the files to be copied.
`files`: files relative to the current working directory.
`out_files`: files relative to output dir specified with `-d` command line option.
`output`: files generated by the output specified by `source`.
`3d_models`: 3D models used in the project.
`project`: schematic, PCB, footprints, symbols, 3D models and project files (KiCad 6+) """
self.filter = '.*'
""" A regular expression that source files must match """
""" A regular expression that source files must match.
Not used for the `project` mode """
self.dest = ''
""" Destination directory inside the output dir, empty means the same of the file
relative to the source directory.
Note that when you specify a name here files are copied to this destination
without creating subdirs. The `project` mode is an exception.
For the `3d_models` type you can use DIR+ to create subdirs under DIR """
self.save_pcb = False
""" Only usable for the `3d_models` mode.
Save a PCB copy modified to use the copied 3D models """
Save a PCB copy modified to use the copied 3D models.
You don't need to specify it for `project` mode """
self._append_mode = False
def apply_rename(self, fname):
is_abs = os.path.isabs(fname)
append_mode = self.dest and self.dest[-1] == '+'
if self.dest and not append_mode:
if self.dest and not self._append_mode:
# A destination specified by the user
dest = os.path.basename(fname)
elif is_abs:
else:
for d in self.rel_dirs:
if d is not None and fname.startswith(d):
dest = os.path.relpath(fname, d)
break
else:
dest = os.path.basename(fname)
else:
dest = os.path.relpath(fname, os.getcwd())
return '${KIPRJMOD}/'+os.path.join(self.output_dir, dest)
res = '${KIPRJMOD}/'+os.path.join(self.output_dir, dest)
return res
class Copy_FilesOptions(Base3DOptions):
@ -118,24 +122,124 @@ class Copy_FilesOptions(Base3DOptions):
exit(INTERNAL_ERROR)
return files_list
def get_3d_models(self, f):
def copy_footprints(self, dest, dry):
out_lib_base = os.path.join(self.output_dir, dest, 'footprints')
out_lib_base_prj = os.path.join('${KIPRJMOD}', 'footprints')
aliases = {}
extra_files = []
added = set()
for m in GS.get_modules():
id = m.GetFPID()
lib_nick = str(id.GetLibNickname())
src_alias = KiConf.fp_nick_to_path(lib_nick)
if src_alias is None:
logger.warning(f'{W_MISSLIB}Missing footprint library `{lib_nick}`')
continue
src_lib = src_alias.uri
out_lib = os.path.join(out_lib_base, lib_nick)
out_lib = GS.create_fp_lib(out_lib)
if lib_nick not in aliases:
new_alias = copy(src_alias)
new_alias.uri = os.path.join(out_lib_base_prj, lib_nick+'.pretty')
aliases[lib_nick] = new_alias
name = str(id.GetLibItemName())
mod_fname = name+'.kicad_mod'
footprint_src = os.path.join(src_lib, mod_fname)
if not os.path.isfile(footprint_src):
logger.warning(f'{W_MISSCMP}Missing footprint `{name}` ({lib_nick}:{name})')
elif footprint_src not in added:
footprint_dst = os.path.join(out_lib, mod_fname)
extra_files.append((footprint_src, footprint_dst))
added.add(footprint_src)
table_fname = os.path.join(self.output_dir, dest, FP_LIB_TABLE)
extra_files.append(table_fname)
if not dry:
KiConf.save_fp_lib_aliases(table_fname, aliases)
return extra_files
def copy_symbols(self, dest, dry):
extra_files = []
if not GS.sch:
return extra_files
out_lib_base = os.path.join(self.output_dir, dest, 'symbols')
out_lib_base_prj = os.path.join('${KIPRJMOD}', 'symbols')
aliases = {}
# Split the collected components into separated libs
libs = {}
for obj in GS.sch.lib_symbol_names.values():
lib = obj.lib if obj.lib else 'locally_edited'
libs.setdefault(lib, []).append(obj.name)
table_fname = os.path.join(self.output_dir, dest, SYM_LIB_TABLE)
extra_files.append(table_fname)
if dry:
for lib in libs.keys():
extra_files.append(os.path.join(out_lib_base, lib+'.kicad_sym'))
else:
# Create the libs
for lib, comps in libs.items():
GS.sch.write_lib(out_lib_base, lib, comps)
new_alias = LibAlias()
new_alias.name = lib
new_alias.legacy = False
new_alias.type = 'KiCad'
new_alias.options = new_alias.descr = ''
new_alias.uri = os.path.join(out_lib_base_prj, lib+'.kicad_sym')
aliases[lib] = new_alias
extra_files.append(os.path.join(out_lib_base, lib+'.kicad_sym'))
# Create the sym-lib-table
KiConf.save_fp_lib_aliases(table_fname, aliases, is_fp=False)
return extra_files
def add_sch_files(self, files, dest_dir):
for f in GS.sch.get_files():
files.append(os.path.join(dest_dir, os.path.relpath(f, GS.sch_dir)))
def get_3d_models(self, f, mode_project, dry):
""" Look for the 3D models and make a list, optionally download them """
extra_files = []
GS.check_pcb()
GS.load_board()
dest_dir = f.dest
if dest_dir and dest_dir[-1] == '+':
dest_dir = dest_dir[:-1]
f.output_dir = dest_dir
if mode_project:
# From the PCB point this is just the 3D models dir
f.output_dir = '3d_models'
f._append_mode = True
else:
dest_dir = f.dest
f._append_mode = False
if dest_dir and dest_dir[-1] == '+':
dest_dir = dest_dir[:-1]
f._append_mode = True
f.output_dir = dest_dir
# Apply any variant
self.filter_pcb_components(do_3D=True, do_2D=True)
# Download missing models and rename all collect 3D models (renamed)
# Download missing models and rename all collected 3D models (renamed)
f.rel_dirs = self.rel_dirs
files_list = self.download_models(rename_filter=f.source, rename_function=FilesList.apply_rename, rename_data=f)
if f.save_pcb:
fname = os.path.join(self.output_dir, os.path.basename(GS.pcb_file))
logger.debug('Saving the PCB to '+fname)
GS.board.Save(fname)
GS.copy_project(fname)
if f.save_pcb or mode_project:
dest_dir = self.output_dir
if mode_project:
dest_dir = os.path.join(dest_dir, f.dest)
os.makedirs(dest_dir, exist_ok=True)
fname = os.path.join(dest_dir, os.path.basename(GS.pcb_file))
if not dry:
logger.debug('Saving the PCB to '+fname)
GS.board.Save(fname)
if mode_project:
logger.debug('Saving the schematic to '+dest_dir)
GS.sch.save_variant(dest_dir)
self.add_sch_files(extra_files, dest_dir)
elif mode_project:
self.add_sch_files(extra_files, dest_dir)
prj_name = GS.copy_project(fname, dry)
# Extra files that we are generating
extra_files.append(fname)
if prj_name:
extra_files.append(prj_name)
if mode_project:
extra_files += self.copy_footprints(f.dest, dry)
extra_files += self.copy_symbols(f.dest, dry)
if not self._comps:
# We must undo the download/rename
self.undo_3d_models_rename(GS.board)
@ -152,60 +256,79 @@ class Copy_FilesOptions(Base3DOptions):
fn = fn[:-5]+'.wrl'
if os.path.isfile(fn) and fn not in files_list:
new_list.append(fn)
return files_list+fnmatch.filter(new_list, f.source)
if mode_project:
# From the output point this needs to add the destination dir
f.output_dir = os.path.join(f.dest, f.output_dir)
return files_list+fnmatch.filter(new_list, f.source), extra_files
def get_files(self, no_out_run=False):
files = []
# The source file can be relative to the current directory or to the output directory
src_dir_cwd = os.getcwd()
src_dir_outdir = self.expand_filename_sch(GS.out_dir)
# Initialize the config class so we can know where are the 3D models at system level
KiConf.init(GS.pcb_file)
# List of base paths
self.rel_dirs = []
if KiConf.models_3d_dir:
self.rel_dirs.append(os.path.normpath(os.path.join(GS.pcb_dir, KiConf.models_3d_dir)))
if KiConf.party_3rd_dir:
self.rel_dirs.append(os.path.normpath(os.path.join(GS.pcb_dir, KiConf.party_3rd_dir)))
self.rel_dirs.append(GS.pcb_dir)
# Process each file specification expanding it to real files
for f in self.files:
from_outdir = False
if f.source_type == 'out_files' or f.source_type == 'output':
from_outdir = True
src_dir = src_dir_outdir if from_outdir else src_dir_cwd
src_dir = src_dir_outdir if f.source_type == 'out_files' or f.source_type == 'output' else src_dir_cwd
mode_project = f.source_type == 'project'
if mode_project and not GS.ki6:
raise KiPlotConfigurationError('The `project` mode needs KiCad 6 or newer')
mode_3d = f.source_type == '3d_models'
mode_3d_append = mode_3d and f.dest and f.dest[-1] == '+'
# Get the list of candidates
files_list = None
extra_files = []
if f.source_type == 'output':
# The files are generated by a KiBot output
files_list = self.get_from_output(f, no_out_run)
elif mode_3d:
files_list = self.get_3d_models(f)
elif mode_3d or mode_project:
# The files are 3D models
files_list, extra_files = self.get_3d_models(f, mode_project, no_out_run)
else: # files and out_files
# Regular files
source = f.expand_filename_both(f.source, make_safe=False)
files_list = glob.iglob(os.path.join(src_dir, source), recursive=True)
if GS.debug_level > 1:
files_list = list(files_list)
logger.debug('- Pattern {} list of files: {}'.format(source, files_list))
# Filter and adapt them
for fname in filter(re.compile(f.filter).match, files_list):
fil = re.compile(f.filter)
for fname in filter(fil.match, files_list):
fname_real = os.path.realpath(fname) if self.follow_links else os.path.abspath(fname)
# Compute the destination directory
dest = fname
is_abs = os.path.isabs(fname)
if f.dest and not mode_3d_append:
if f.dest and not f._append_mode:
# A destination specified by the user
# All files goes to the same destination directory
dest = os.path.join(f.dest, os.path.basename(fname))
elif mode_3d and is_abs:
elif (mode_3d or mode_project) and is_abs:
for d in self.rel_dirs:
if d is not None and fname.startswith(d):
dest = os.path.relpath(dest, d)
break
else:
dest = os.path.basename(fname)
if mode_3d_append:
dest = os.path.join(f.dest[:-1], dest)
if f._append_mode:
dest = os.path.join(f.output_dir, dest)
else:
dest = os.path.relpath(dest, src_dir)
files.append((fname_real, dest))
# Process the special extra files
for f in extra_files:
if isinstance(f, str):
if fil.match(f):
files.append((None, f))
else:
if fil.match(f[0]):
files.append(f)
return files
def get_targets(self, out_dir):
@ -215,7 +338,7 @@ class Copy_FilesOptions(Base3DOptions):
def get_dependencies(self):
files = self.get_files(no_out_run=True)
return sorted([v for v, _ in files])
return sorted([v for v, _ in files if v is not None])
def run(self, output):
super().run(output)
@ -227,6 +350,9 @@ class Copy_FilesOptions(Base3DOptions):
output += os.path.sep
copied = {}
for (src, dst) in files:
if src is None:
# Files we generate, we don't need to copy them
continue
dest = os.path.join(output, dst)
dest_dir = os.path.dirname(dest)
if not os.path.isdir(dest_dir):