First Commit

master
ChristopheSeux 2023-09-28 11:34:41 +02:00
parent d302e8d14b
commit e4ae2be1e8
18 changed files with 3579 additions and 0 deletions

6
CHANGELOG.md Normal file
View File

@ -0,0 +1,6 @@
# Changelog
0.3.0
- initial commit

339
LICENSE Normal file
View File

@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Background Plane Manager
Copyright (C) 2023 autour-de-minuit / blender
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

76
__init__.py Normal file
View File

@ -0,0 +1,76 @@
# SPDX-License-Identifier: GPL-2.0-or-later
bl_info = {
"name": "Background plane manager",
"description": "Manage the background image planes and grease pencil object relative to a camera",
"author": "Samuel Bernou",
"version": (0, 4, 2),
"blender": (3, 5, 0),
"location": "View3D",
"warning": "",
"doc_url": "https://gitlab.com/autour-de-minuit/blender/background_plane_manager",
"category": "Object"
}
import bpy
from pathlib import Path
from . import operators
from . import export_psd_layers
from . import ui
from . import preferences
from . import fn
from . file_utils import install_module
install_module('psd_tools', 'psd-tools')
"""
from . import auto_modules
## module auto-install
## module_name, package_name
DEPENDENCIES = {
('psd_tools', 'psd-tools'),
}
# modules_loc = bpy.utils.user_resource('SCRIPTS', path='modules')
modules_loc = Path(__file__).parents[1] / 'modules'
error_message = f'''--- Cannot import modules (see console).
Try enabling addon after restarting blender as admin
---
'''
error = auto_modules.pip_install_and_import(DEPENDENCIES)
# note: an internet connexion is needed to auto-install needed modules)
if error:
raise Exception(error_message) from error
has_psd_tools = True
try:
import psd_tools
except Exception:
has_psd_tools = False
"""
# TODO: Add an enum in prefs to choose default type to use
# Override this enum if there is an environement variable in project
modules = (
operators,
export_psd_layers,
preferences,
ui,
)
def register():
for m in modules:
m.register()
preferences.ui_in_sidebar_update(fn.get_addon_prefs(), bpy.context)
def unregister():
for m in reversed(modules):
m.unregister()

145
auto_modules.py Normal file
View File

@ -0,0 +1,145 @@
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
import os, sys
import subprocess
from pathlib import Path
# THIRD_PARTY = Path(__file__).resolve().parent / "libs"# os.path.join(os.path.dirname(os.path.abspath(__file__)), "libs")
# DEFAULT_CACHE_DIR = Path(__file__).resolve().parent / "cache"# os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache")
# SUBPROCESS_DIR = PYTHON_PATH.parent
PYTHON_PATH = Path(sys.executable)
BLENDER_SITE_PACKAGE = PYTHON_PATH.parents[1] / 'lib' / 'site-packages'
# or: BLENDER_SITE_PACKAGE = Path(bpy.utils.resource_path('LOCAL')) / 'python' / 'lib' / 'site-packages'
def module_can_be_imported(name):
try:
__import__(name)
return True
except ModuleNotFoundError:
return False
def install_pip():
# pip can not necessarily be imported into Blender after this
subprocess.run([str(PYTHON_PATH), "-m", "ensurepip"])
def install_package(name):
# import logging
# logging.debug(f"Using {PYTHON_PATH} for installation")
## !!! try with , '--user' ?
subprocess.run([str(PYTHON_PATH), "-m", "pip", "install", name])
def setup(dependencies):
'''
Get a set containing multiple tuple of (module, package) names pair (str)
install pip with ensurepip if needed, try to import, install, retry
'''
os.environ['PYTHONUSERBASE'] = str(BLENDER_SITE_PACKAGE)
if not module_can_be_imported("pip"):
install_pip()
for module_name, package_name in dependencies:
if not module_can_be_imported(module_name):
print(f'Installing module: {module_name} - package: {package_name}')
install_package(package_name)
module_can_be_imported(package_name)
### --- classic install (not used)
def pip_install(package_name):
'''Get a package name (str) and try to install and print in console'''
print(f'---Installing {package_name}---')
try:
output = subprocess.check_output([bpy.app.binary_path_python, '-m', 'pip', 'install', package_name])
print(output)
except subprocess.CalledProcessError as e:
print(e.output)
return e.output
def pip_install_and_import(dependencies):
'''
Get a set containing multiple tuple of (module, package) names pair (str)
try to import, if import fail, try to install then try to reimport
'''
# os.environ['PYTHONUSERBASE'] = str(BLENDER_SITE_PACKAGE)
for module_name, package_name in dependencies:
# '--user'# install for all version of blender (suposely in app data roaming or config files...)
# '--no-deps'# dont update dependancy (in this case, avoid installing downloading a duplication of numpy)
try:
__import__(module_name)
continue
except ImportError:
try:
## auto install dependancy (need to run as admin)
print(f'Installing module: {module_name} - package: {package_name}') #"--user" ?
#### Using target
## within built-in modules (need run as admin in most case)
# subprocess.check_call([str(PYTHON_PATH), "-m", "pip", "install", f'--target={BLENDER_SITE_PACKAGE}', package_name])
done = False
## within external modules (if script files are there)
external_scripts = bpy.context.preferences.filepaths.script_directory
print('external_scripts: ', external_scripts)
print('__file__: ', __file__)
in_external = str(Path(__file__)).startswith(str(Path(external_scripts)))
if external_scripts and len(external_scripts) > 2 and in_external:
external_scripts = Path(external_scripts)
if external_scripts.exists():
external_modules = external_scripts / 'modules'
print(f'using external scripts modules: {external_modules}')
external_modules.mkdir(exist_ok=True)# dont raise error if already exists
# cmd = [str(PYTHON_PATH), "-m", "pip", "install", f'--target={external_modules}', package_name]
cmd = [str(PYTHON_PATH), "-m", "pip", '--no-cache-dir', "install", f'--target={external_modules}', package_name, '--no-deps']
print('Run', ' '.join(cmd))
subprocess.check_call(cmd)
done=True
## within user local modules (if not in external scripts)
if not done:
user_module = Path(bpy.utils.user_resource('SCRIPTS', path='modules', create=True)) # create the folder if not exists
print(f'Using users modules: {user_module}')
# cmd = [str(PYTHON_PATH), "-m", "pip", "install", f'--target={user_module}', package_name]
cmd = [str(PYTHON_PATH), "-m", "pip", '--no-cache-dir', "install", f'--target={user_module}', package_name, '--no-deps']
print('Run', ' '.join(cmd))
subprocess.check_call(cmd)
except Exception as e:
print(f'{package_name} install error: {e}')
print('Maybe try restarting blender as Admin')
return e
try:
__import__(module_name)
except ImportError as e:
print(f'!!! module {module_name} still cannot be imported')
return e
"""
## addons Paths
# natives
built_in_addons = os.path.join(bpy.utils.resource_path('LOCAL') , path='scripts', 'addons')
# users
users_addons = bpy.utils.user_resource('SCRIPTS', path='addons')
#external
external_addons = None
external_script_dir = bpy.context.preferences.filepaths.script_directory
if external_script_dir and len(external_script_dir) > 2:
external_addons = os.path.join(external_script_dir, 'addons')
"""

6
constants.py Normal file
View File

@ -0,0 +1,6 @@
from pathlib import Path
PREFIX = 'BG_'
INIT_POS = 9.0
MODULE_DIR = Path(__file__).parent
BGCOL = 'Background'

278
export_psd_layers.py Normal file
View File

@ -0,0 +1,278 @@
import bpy
from bpy_extras.io_utils import ImportHelper
import sys
import os
import json
import psd_tools
from pathlib import Path
from time import time
from . import fn
def print_progress(progress, min=0, max=100, barlen=50, prefix='', suffix='', line_width=80):
total_len = max - min
progress_float = (progress - min) / total_len
bar_progress = int(progress_float * barlen) * '='
bar_empty = (barlen - len(bar_progress)) * ' '
percentage = ''.join((str(int(progress_float * 100)), '%'))
progress_string = ''.join((prefix, '[', bar_progress, bar_empty, ']', ' ', percentage, suffix))[:line_width]
print_string = ''.join((progress_string, ' ' * (line_width - len(progress_string))))
print(print_string, end='\r')
def export_psd(psd_file, output=None, scale=0.5):
'''
export_psd(string psd_file) -> list layers, list bboxes, tuple image_size, string png_dir
Reads psd_file and exports all top level layers to png's.
Returns a list of all the layer objects, the image size and
the png export directory.
string psd_file - the filepath of the psd file
'''
# def get_layers(layer, all_layers=[]):
# if not layer.is_group():
# return
# for sub_layer in reversed(layer): # reversed() since psd_tools 1.8
# all_layers.append(sub_layer)
# get_layers(sub_layer, all_layers=all_layers)
# return all_layers
def get_dimensions(layer, bbox):
if bbox is not None:
# print('with bbox')
# pp(bbox)
x = layer.bbox[0] + bbox[0]
y = layer.bbox[1] + bbox[1]
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
else:
# print('layer bbox')
# pp(layer.bbox[:])
x = layer.bbox[0]
y = layer.bbox[1]
width = layer.bbox[2] - x
height = layer.bbox[3] - y
# print('x', x)
# print('y', y)
# print('width', width)
# print('height', height)
return x, y, width, height
def export_layers_as_png(layers, output, crop=False, scale=0.5):
output = Path(output)
box = layers.viewbox
all_layers = []
for i, layer in enumerate(layers):
# if (layer.is_group() or (not self.hidden_layers and not layer.is_visible())):
# continue
layer.visible = True
if layer.is_group() and 'GUIDE' in layer.name:
for l in layer:
#print(l.name, l.visible)
l.visible = True
name = layer.name
norm_name = fn.norm_str(name, padding=2) # Gadget normstr
print('name: ', name)
png_output = (output/norm_name).with_suffix('.png')
print('Layer Output', png_output)
prefix = ' - exporting: '
suffix = ' - {}'.format(layer.name)
print_progress(i+1, max=(len(layers)), barlen=40, prefix=prefix, suffix=suffix, line_width=120)
# if self.clean_name:
# name = bpy.path.clean_name(layer.name).rstrip('_')
# else:
# name = layer.name.replace('\x00', '')
# name = name.rstrip('_')
# if self.layer_index_name:
# name = name + '_' + str(i)
# composite return a PIL object
if crop:
# get pre-crop size
# layer_image = layer.topil()
layer_image = layer.composite(viewport=box)
bbox = layer_image.getbbox()
## TODO layer bbox might be completely off (when it's a group)
image = layer.composite()# This crop the image
else:
image = layer.composite(viewport=box, force=True)# This crop to canvas size before getting bbox ??
bbox = None
if not image:
continue
## optimisation reduce size
#if scale != 1 :
image = image.resize((image.width // int(1/scale), image.height // int(1/scale)))
try:
image.save(str(png_output))
except Exception as e:
print(e)
# if crop:
# layer_box_tuple = get_dimensions(layer, bbox)
# else:
# layer_box_tuple = None
lbbox = get_dimensions(layer, bbox)
all_layers.append({'name': name, 'layer_bbox': lbbox, 'bbox': bbox, 'index': i, 'path': f'./{png_output.name}'})
## No crop for now
# try:
# layer_image = layer.topil()
# except ValueError:
# print("Could not process layer " + layer.name)
# bboxes.append(None)
# continue
# if layer_image is None:
# bboxes.append(None)
# continue
# ## AUTOCROP
# if self.crop_layers:
# bbox = layer_image.getbbox()
# bboxes.append(bbox)
#
# layer_image = layer_image.crop(bbox)
# else:
# bboxes.append(None)
# layer_image.save(png_file)
return all_layers
print(f'Exporting: {psd_file}')
psd_file = Path(psd_file)
if not output:
# export relative to psd location
output = psd_file.parent / 'render' # f'{psd_file.stem}_pngs'
output.mkdir(exist_ok = True)
psd = psd_tools.PSDImage.open(psd_file)
## get all layer separately
# layers = get_layers(psd)
# bboxes = export_layers_as_png(layers, png_dir)
## export the main image (PSD, MAIN, COMPOSITE ?)
image = psd.composite()
org_image_size = [image.width, image.height]
image = image.resize((image.width // 2, image.height // 2))
image.save(str((output / 'main.png')))
## export top level layer passing directly psd
all_layers = export_layers_as_png(psd, output, crop=False, scale=scale)
bb = psd.bbox
image_size = (bb[2] - bb[0], bb[3] - bb[1])
# return ([l.name for l in psd], bboxes, image_size, output)
return (all_layers, image_size, org_image_size, output)
def export_psd_bg(psd_fp, scale=0.5):
'''Export layers of the psd, create and return a json file
psd_fp :: filepath to the psd file
scale :: output resolution of the layers (ex: 0.5 means divided by 2)
'''
t0 = time()
psd_fp = Path(psd_fp)
## FIXME: Choose how to handle this nomenclature
output = psd_fp.parent / 'render'
if scale >= 1:
output = psd_fp.parent / 'render_hd'
# print('Folder Output', output)
## note: No output passed create 'render' folder aside psd automatically
layers, image_size, org_image_size, png_dir = export_psd(psd_fp, output, scale=scale)
json_fp = png_dir/'setup.json'
setup_dic = {
'psd': f'../{psd_fp.name}' ,'layers': layers, 'image_size': image_size,
'org_image_size' : org_image_size,
}
## Dump data to json file
json_file = png_dir / 'setup.json'
# json.dump(str(json_file), setup_dic, ensure_ascii=False)
json_file.write_text(json.dumps(setup_dic, indent=4, ensure_ascii=False), encoding='utf8')
print(f'Exported in : {time() - t0:.2f}s')
return json_fp
class BPM_OT_export_psd_layers(bpy.types.Operator, ImportHelper):
bl_idname = "bpm.export_psd_layers"
bl_label = "Export Psd Layers"
bl_description = "Export psd layers from a psd file in a render folder"
bl_options = {"REGISTER", "INTERNAL"}
# path_to_pal : bpy.props.StringProperty(name="paht to palette", description="path to the palette", default="")
@classmethod
def poll(cls, context):
return context.object and context.object.type == 'GPENCIL'
filename_ext = '.psd'
filter_glob: bpy.props.StringProperty(default='*.psd', options={'HIDDEN'} )
filepath : bpy.props.StringProperty(
name="File Path",
description="File path used for import",
maxlen= 1024)
resolution_scale: bpy.props.FloatProperty(
name='Resolution Scale',
default=1.0,
description='Export resolution ratio. Ex: 0.5 export at half resolution\
\nusefull for non-def lightweight backgrounds in Blender',
options={'HIDDEN'})
def execute(self, context):
json_fp = export_psd_bg(self.filepath, scale=self.resolution_scale)
if not json_fp:
self.report({'ERROR'}, f'Problem when exporting image for: {self.filepath}')
return {'CANCELLED'}
self.report({'INFO'}, f'BG exported, Json at: {json_fp}')
return {"FINISHED"}
classes=(
BPM_OT_export_psd_layers,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

134
file_utils.py Normal file
View File

@ -0,0 +1,134 @@
import importlib
import re
import subprocess
import platform
import sys
import unicodedata
from pathlib import Path
def install_module(module_name, package_name=None):
'''Install a python module with pip or return it if already installed'''
try:
module = importlib.import_module(module_name)
except ModuleNotFoundError:
print(f'Installing Module {module_name} ....')
subprocess.call([sys.executable, '-m', 'ensurepip'])
subprocess.call([sys.executable, '-m', 'pip', 'install', package_name or module_name])
module = importlib.import_module(module_name)
return module
def import_module_from_path(path):
from importlib import util
try:
path = Path(path)
spec = util.spec_from_file_location(path.stem, str(path))
mod = util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
except Exception as e:
print(f'Cannot import file {path}')
print(e)
def norm_str(string, separator='_', format=str.lower, padding=0):
string = str(string)
string = string.replace('_', ' ')
string = string.replace('-', ' ')
string = re.sub('[ ]+', ' ', string)
string = re.sub('[ ]+\/[ ]+', '/', string)
string = string.strip()
if format:
string = format(string)
# Padd rightest number
string = re.sub(r'(\d+)(?!.*\d)', lambda x : x.group(1).zfill(padding), string)
string = string.replace(' ', separator)
string = unicodedata.normalize('NFKD', string).encode('ASCII', 'ignore').decode("utf-8")
return string
def norm_name(string, separator='_', format=str.lower, padding=0):
string = str(string)
string = string.split('/')[-1] #used to remove first slash -> albert / albert_casque -> albert_casque
string = string.replace('_', ' ')
string = string.replace('-', ' ')
string = re.sub('[ ]+', ' ', string)
string = re.sub('[ ]+\/[ ]+', '/', string)
string = string.strip()
if format:
string = format(string)
# Padd rightest number
string = re.sub(r'(\d+)(?!.*\d)', lambda x : x.group(1).zfill(padding), string)
string = string.replace(' ', separator)
string = unicodedata.normalize('NFKD', string).encode('ASCII', 'ignore').decode("utf-8")
return string
def read_file(path):
'''Read a file with an extension in (json, yaml, yml, txt)'''
exts = ('.json', '.yaml', '.yml', '.txt')
if not path:
print('Try to read empty file')
path = Path(path)
if not path.exists():
print('File not exist', path)
return
if path.suffix not in exts:
print(f'Cannot read file {path}, extension must be in {exts}')
return
txt = path.read_text()
data = None
if path.suffix.lower() in ('.yaml', '.yml'):
yaml = install_module('yaml')
try:
data = yaml.safe_load(txt)
except Exception:
print(f'Could not load yaml file {path}')
return
elif path.suffix.lower() == '.json':
try:
data = json.loads(txt)
except Exception:
print(f'Could not load json file {path}')
return
else:
data = txt
return data
def open_file(filepath, env=None, select=False):
if platform.system() == 'Darwin': # macOS
cmd = ['open']
if select:
cmd += ['-R']
elif platform.system() == 'Windows': # Windows
cmd = ['explorer']
if select:
cmd += ['/select,']
else: # linux variants
cmd = ['xdg-open']
if select:
cmd = ['nemo']
cmd += [str(filepath)]
subprocess.Popen(cmd, env=env)

809
fn.py Normal file
View File

@ -0,0 +1,809 @@
# SPDX-License-Identifier: GPL-3.0-or-later
## Standalone based on SPA code
## Code from create_gp_texture_ref.py (Copyright (C) 2023, The SPA Studios. All rights reserved.)
## adapted to create as a single 'GP as image' and fit in camera with driver
import bpy
import os
import re
import time
from pathlib import Path
from mathutils import Vector, geometry
from pprint import pprint as pp
from .constants import PREFIX, BGCOL
def get_addon_prefs():
return bpy.context.preferences.addons[__package__].preferences
def norm_str(string, separator='_', format=str.lower, padding=0):
import unicodedata
string = str(string)
string = string.replace('_', ' ')
string = string.replace('-', ' ')
string = re.sub('[ ]+', ' ', string)
string = re.sub('[ ]+\/[ ]+', '/', string)
string = string.strip()
if format:
string = format(string)
# Padd rightest number
string = re.sub(r'(\d+)(?!.*\d)', lambda x : x.group(1).zfill(padding), string)
string = string.replace(' ', separator)
string = unicodedata.normalize('NFKD', string).encode('ASCII', 'ignore').decode("utf-8")
return string
def clean_image_name(name):
'''Strip blender numbering then strip extension'''
name = re.sub(r'\.\d{3}$', '', name.strip())
return os.path.splitext(name)[0].strip()
def get_image_infos_from_object(ob):
'''Get image filepath of a background whatever the type
return image_data, transparency'''
transparency = None
if ob.type == 'EMPTY':
image_data = ob.data
transparency = ob.color[3]
elif ob.type == 'MESH':
texture_plane_ng = bpy.data.node_groups.get('texture_plane')
if not texture_plane_ng:
print(f'{ob.name} : No texture plane nodegroup found !')
return
mat = ob.active_material
node = next((n for n in mat.node_tree.nodes
if n.type=='GROUP' and n.node_tree is texture_plane_ng), None)
if not node:
print(f'{ob.name} : No texture plane nodegroup in material {mat.name}')
return
## Get input texture
tex_node = next(n for n in mat.node_tree.nodes
if n.type=='TEX_IMAGE')
if not tex_node:
print(f'{ob.name} : No image texture node in material {mat.name}')
return
image_data = tex_node.image
transparency = node.inputs['Transparency'].default_value
elif ob.type == 'GPENCIL':
image_data = ob.data.materials[0].grease_pencil.fill_image
transparency = ob.data.layers[0].opacity
if not image_data:
return
return (image_data, transparency)
def get_image(ob):
'''Get image data from image object'''
infos = get_image_infos_from_object(ob)
if infos is None:
return
img, _opacity = infos
return img
def get_opacity(ob):
'''Get opacity from image object'''
infos = get_image_infos_from_object(ob)
if infos is None:
return
_img, opacity = infos
return opacity
def set_opacity(ob, opacity):
'''Set opacity of BG image object whatever the type'''
if ob.type == 'EMPTY':
ob.color[3] = opacity
elif ob.type == 'MESH':
texture_plane_ng = bpy.data.node_groups.get('texture_plane')
if texture_plane_ng:
node = next(n for n in ob.active_material.node_tree.nodes
if n.type == 'GROUP' and n.node_tree is texture_plane_ng)
node.inputs['Transparency'].default_value = opacity
elif ob.type == 'GPENCIL':
ob.data.layers[0].opacity = opacity
def create_plane_holder(img, name=None, parent=None, link_in_col=None):
'''
img :: blender image or path to an image
prefix :: name holder with image name with given prefix
parent :: object to parent to (only if passed)
link_in_col :: collection to link after creation
Return created object mesh without face
None if creation failed
'''
## get image if a path is passed in
if not isinstance(img, bpy.types.Image):
try:
# load from path (str)
img = bpy.data.images.load(str(img), check_existing=True)
except:
return
# name = img.name.split('.')[0]
name = name or clean_image_name(img.name)
obj_name = name # PREFIX + name # <- always set prefix ?
x,y = img.size[:]
# create and place object plane
# find height from width (as 1)
if y < x:
h = y/x
w = 1
else:
h = 1
w = x/y
# size/2 to get center image (half width | height)
h = h/2
w = w/2
verts = [
(-w, h, 0),#2
(w, h, 0),#3
(w, -h, 0),#4
(-w, -h, 0),#1
]
mesh = bpy.data.meshes.new(obj_name)
mesh.from_pydata(verts, [(3,2), (2,1), (1,0), (0,3)], [])
holder = bpy.data.objects.new(obj_name, mesh)
holder.lock_location = (True, True, False)
holder.hide_render = True
if link_in_col is not None:
link_in_col.objects.link(holder)
# bpy.context.scene.collection.objects.link(holder)
if parent:
holder.parent = parent
return holder
## Remove func is no intermediate collection created
def get_col(name, parent=None, create=True):
parent = parent or bpy.context.scene.collection
col = bpy.data.collections.get(name)
if not col and create:
col = bpy.data.collections.new(name)
if col not in parent.children[:]:
parent.children.link(col)
return col
def create_cam(name, type='ORTHO', size=6, focale=50):
'''Create an orthographic camera with given scale
size :: othographic scale of the camera
Return camera object
'''
cam_data = bpy.data.cameras.new(name)
cam_data.type = type
cam_data.clip_start = 0.02
cam_data.clip_end = 2000
cam_data.ortho_scale = size
cam_data.lens = focale
cam = bpy.data.objects.new(name, cam_data)
cam.rotation_euler.x = 1.5707963705062866
cam.location = (0, -8, 0)
bpy.context.scene.collection.objects.link(cam)
return cam
def link_nodegroup(filepath, group_name, link=True, keep_existing=False):
ng = bpy.data.node_groups.get(group_name)
if ng:
if keep_existing:
return ng
## risk deleting nodegroup from other object !
# else:
# bpy.data.node_groups.remove(ng)
lib_path = bpy.path.relpath(str(filepath))
with bpy.data.libraries.load(lib_path, link=link) as (data_from, data_to):
data_to.node_groups = [n for n in data_from.node_groups if n.startswith(group_name)]
group = bpy.data.node_groups.get(group_name)
if group:
return group
def create_plane_driver(plane, cam, distance=None):
# Multiple planes spacing
plane.lock_location = (True, True, False)
plane.lock_rotation = (True,)*3
plane.lock_scale = (True,)*3
plane['scale'] = 1.0
plane['distance'] = distance if distance is not None else 9.0
# DRIVERS
## LOC X AND Y (shift) ##
for axis in range(2):
driver = plane.driver_add('scale', axis)
# Driver type
driver.driver.type = 'SCRIPTED'
# Variable DISTANCE
var = driver.driver.variables.new()
var.name = "distance"
#-# use a propertie to drive distance to camera
var.type = 'SINGLE_PROP'
var.targets[0].id = plane
var.targets[0].data_path = '["distance"]'
#-# Can use directly object location (but give refresh problem)
# var.type = 'TRANSFORMS'
# var.targets[0].id = plane
# var.targets[0].transform_type = 'LOC_Z'
# var.targets[0].transform_space = 'LOCAL_SPACE'
# Variable FOV
var = driver.driver.variables.new()
var.name = "FOV"
var.type = 'SINGLE_PROP'
var.targets[0].id_type = "OBJECT"
var.targets[0].id = cam
var.targets[0].data_path = 'data.angle'
# Variable scale
var = driver.driver.variables.new()
var.name = "scale"
var.type = 'SINGLE_PROP'
var.targets[0].id = plane
var.targets[0].data_path = '["scale"]'
# Expression
# -distance*tan(FOV/2)
driver.driver.expression = \
"tan(FOV/2) * distance * scale * 2"
# "tan(FOV/2) * -distance * scale * 2" # inverse dist if based on locZ
# Driver for location
dist_drv = plane.driver_add('location', 2)
dist_drv.driver.type = 'SCRIPTED' #'AVERAGE' #need to be inverted
var = dist_drv.driver.variables.new()
var.name = "distance"
var.type = 'SINGLE_PROP'
var.targets[0].id = plane
var.targets[0].data_path = '["distance"]'
dist_drv.driver.expression = '-distance'
return driver, dist_drv
def set_collection(ob, collection, unlink=True) :
''' link an object in a collection and create it if necessary
if unlink object is removed from other collections
return collection (get/created)
'''
scn = bpy.context.scene
col = None
visible = False
linked = False
# check if collection exist or create it
for c in bpy.data.collections :
if c.name == collection : col = c
if not col :
col = bpy.data.collections.new(name=collection)
# link the collection to the scene's collection if necessary
# for c in scn.collection.children :
# if c.name == col.name : visible = True
# if not visible : scn.collection.children.link(col)
# check if the object is already in the collection and link it if necessary
for o in col.objects :
if o == ob : linked = True
if not linked : col.objects.link(ob)
# remove object from scene's collection
for o in scn.collection.objects :
if o == ob : scn.collection.objects.unlink(ob)
# if unlink flag we remove the object from other collections
if unlink :
for c in ob.users_collection :
if c.name != collection : c.objects.unlink(ob)
return col
def placeholder_name(name='', context=None) -> str:
# def increment(match):
# return str(int(match.group(1))+1).zfill(len(match.group(1)))
context = context or bpy.context
name = name.strip()
if not name:
# Create a default name (fing last 3 number, before blender increment if exists, default increment)
numbers = [int(match.group(1)) for o in context.scene.objects\
if (match := re.search(r'^drawing.*_(\d+)(?:\.\d{3})?$', o.name, flags=re.I))\
# and o.type == 'GPENCIL'
]
if numbers:
numbers.sort()
name = f'drawing_{numbers[-1] + 1:03d}'
else:
name = 'drawing_001'
# elif context.scene.objects.get(name):
# name = re.sub(r'(\d+)(?!.*\d)', increment, name) # find rightmost number
return name
def reset_gp_uv(ob):
uvs = [(0.5, 0.5), (-0.5, 0.5), (-0.5, -0.5), (0.5, -0.5)]
try:
for p, uv in zip(ob.data.layers[0].frames[0].strokes[0].points, uvs):
p.uv_fill = uv
except:
print('Could not set Gp points UV')
## Get empty coords
def get_ref_object_space_coord(o):
size = o.empty_display_size
x,y = o.empty_image_offset
img = o.data
res_x, res_y = img.size
scaling = 1 / max(res_x, res_y)
# 3----2
# | |
# 0----1
corners = [
Vector((0,0)),
Vector((res_x, 0)),
Vector((0, res_y)),
Vector((res_x, res_y)),
]
obj_space_corners = []
for co in corners:
nco_x = ((co.x + (x * res_x)) * size) * scaling
nco_y = ((co.y + (y * res_y)) * size) * scaling
obj_space_corners.append(Vector((nco_x, nco_y, 0)))
return obj_space_corners
## Generate GP object
def create_gpencil_reference(
gpd: bpy.types.GreasePencil,
gpf: bpy.types.GPencilFrame,
image: bpy.types.Image,
) -> bpy.types.GPencilStroke:
"""
Add a rectangular stroke textured with `image` to the given grease pencil fame.
:param gpd: The grease pencil data.
:param gpf: The grease pencil frame.
:param image: The image to use as texture.
:return: The created grease pencil stroke.
"""
name = clean_image_name(image.name) + '_texgp'
# Create new material
mat = bpy.data.materials.new(f".ref/{name}")
bpy.data.materials.create_gpencil_data(mat)
gpd.materials.append(mat)
idx = gpd.materials.find(mat.name)
# Setup material settings
mat.grease_pencil.show_stroke = False
mat.grease_pencil.show_fill = True
mat.grease_pencil.fill_image = image
mat.grease_pencil.fill_style = "TEXTURE"
mat.grease_pencil.mix_factor = 0.0
mat.grease_pencil.texture_offset = (0.0, 0.0)
mat.grease_pencil.texture_angle = 0.0
mat.grease_pencil.texture_scale = (1.0, 1.0)
mat.grease_pencil.texture_clamp = True
# Create the stroke
gps_new = gpf.strokes.new()
gps_new.points.add(4, pressure=0, strength=0)
## This will make sure that the uv's always remain the same
# gps_new.use_automatic_uvs = False # <<- /!\ exists only in SPA core changes
gps_new.use_cyclic = True
gps_new.material_index = idx
x,y = image.size[:]
# create and place object plane
# find height from width (as 1)
if y < x:
h = y/x
w = 1
else:
h = 1
w = x/y
# size/2 to get center image (half width | height)
h = h/2
w = w/2
coords = [
(w, h, 0),
(-w, h, 0),
(-w, -h, 0),
(w, -h, 0),
]
uvs = [(0.5, 0.5), (-0.5, 0.5), (-0.5, -0.5), (0.5, -0.5)]
for i, (co, uv) in enumerate(zip(coords, uvs)):
gps_new.points[i].co = co
gps_new.points[i].uv_fill = uv
return gps_new
def import_image_as_gp_reference(
context: bpy.types.Context,
image,
pack_image: bool = False,
):
"""
Import image from `image` as a textured rectangle in the given
grease pencil object.
:param context: The active context.
:param image: The image filepath or image datablock
:param pack_image: Whether to pack the image into the Blender file.
"""
if not image:
return
scene = context.scene
if not isinstance(image, bpy.types.Image):
image = bpy.data.images.load(str(image), check_existing=True)
## Create grease pencil object
gpd = bpy.data.grease_pencils.new(image.name)
ob = bpy.data.objects.new(image.name, gpd)
gpl = gpd.layers.new(image.name)
gpf = gpl.frames.new(0) # TDOO: test negative
if pack_image:
image.pack()
gps: bpy.types.GPencilStroke = create_gpencil_reference(
gpd,
gpf,
image,
)
gps.select = True # Not needed
cam = scene.camera # should be bg_cam
# Create the driver and parent
ob.use_grease_pencil_lights = False
ob['is_background'] = True
print(f'Created {ob.name} GP background object')
return ob
def gp_transfer_mode(ob, context=None):
context= context or bpy.context
if ob.type != 'GPENCIL' or context.object is ob:
return
prev_mode = context.mode
possible_gp_mods = ('OBJECT',
'EDIT_GPENCIL', 'SCULPT_GPENCIL', 'PAINT_GPENCIL',
'WEIGHT_GPENCIL', 'VERTEX_GPENCIL')
if prev_mode not in possible_gp_mods:
prev_mode = None
mode_swap = False
## TODO optional: Option to stop mode sync ?
## Set in same mode as previous object
if context.scene.tool_settings.lock_object_mode:
if context.mode != 'OBJECT':
mode_swap = True
bpy.ops.object.mode_set(mode='OBJECT')
# set active
context.view_layer.objects.active = ob
## keep same mode accross objects
if mode_swap and prev_mode is not None:
bpy.ops.object.mode_set(mode=prev_mode)
else:
## keep same mode accross objects
context.view_layer.objects.active = ob
if context.mode != prev_mode is not None:
bpy.ops.object.mode_set(mode=prev_mode)
for o in [o for o in context.scene.objects if o.type == 'GPENCIL']:
o.select_set(o == ob) # select only active (when not in object mode)
## generate tex plane
# def create_image_plane(coords, name):
# '''Create an a mesh plane with a defaut UVmap from passed coordinate
# object and mesh get passed name
# return plane object
# '''
# fac = [(0, 1, 3, 2)]
# me = bpy.data.meshes.new(name)
# me.from_pydata(coords, [], fac)
# plane = bpy.data.objects.new(name, me)
# # col = bpy.context.collection
# # col.objects.link(plane)
# me.uv_layers.new(name='UVMap')
# return plane
def create_material(name, img, node_group, keep_existing=False):
if keep_existing:
## Reuse if exist and seem clean
m = bpy.data.materials.get(name)
if m:
if name in m.name and m.use_nodes:
valid_image = next((n for n in m.node_tree.nodes if n.type == 'TEX_IMAGE' and n.image == img), None)
valid_nodegroup = next((n for n in m.node_tree.nodes if n.type == 'GROUP' and n.node_tree), None)
if valid_image and valid_nodegroup:
# Replace node_tree if different
print('node_group: ', node_group)
print('valid_nodegroup.node_tree: ', valid_nodegroup.node_tree)
if node_group != valid_nodegroup.node_tree:
valid_nodegroup = node_group
return m
# create mat
mat = bpy.data.materials.new(name)
mat.blend_method = 'BLEND' # 'CLIP' (clip is able to respect space)
mat.use_nodes = True
node_tree = mat.node_tree
nodes = node_tree.nodes
links = node_tree.links
# clear default BSDG
principled = nodes.get('Principled BSDF')
if principled: nodes.remove(principled)
mat_output = nodes.get('Material Output')
group = nodes.new("ShaderNodeGroup")
group.node_tree = node_group
group.location = (0, 276)
img_tex = nodes.new('ShaderNodeTexImage')
img_tex.image = img
img_tex.interpolation = 'Linear' # 'Closest','Cubic','Smart'
img_tex.extension = 'CLIP' # or EXTEND
img_tex.location = (-400, 218)
# links.new(img_tex.outputs['Color'], group.inputs['Color'])
# links.new(img_tex.outputs['Alpha'], group.inputs['Alpha'])
links.new(img_tex.outputs[0], group.inputs[0])
links.new(img_tex.outputs[1], group.inputs[1])
links.new(group.outputs[0], mat_output.inputs[0])
return mat
def create_image_plane(img, node_group, parent=None):
'''
img :: blender image or path to an image
parent :: object to parent to (only if passed)
Return created object or None if creation failed
'''
## get image if a path is passed in
if not isinstance(img, bpy.types.Image):
try:
img = bpy.data.images.load(str(img), check_existing=True)
except:
return
name = img.name.split('.')[0]
obj_name = f'{name}_texplane'
x,y = img.size[:]
# create and place object plane
# find height from width (as 1)
if y < x:
h = y/x
w = 1
else:
h = 1
w = x/y
# size/2 to get center image (half width | height)
h = h/2
w = w/2
verts = [
(-w, h, 0),#2
(w, h, 0),#3
(w, -h, 0),#4
(-w, -h, 0),#1
]
faces = [(3, 2, 1, 0)]
mesh = bpy.data.meshes.new(obj_name)
mesh.from_pydata(verts, [], faces)
plane = bpy.data.objects.new(obj_name, mesh)
# setup material (link nodegroup)
plane.data.uv_layers.new()
# Create and assign material
mat = create_material(name, img, node_group)
plane.data.materials.append(mat)
bpy.context.scene.collection.objects.link(plane)
# full lock (or just kill selectability...)
plane.lock_location = [True]*3
plane.hide_select = True
if parent:
plane.parent = parent
return plane
def create_empty_image(image):
if not isinstance(image, bpy.types.Image):
try:
image = bpy.data.images.load(str(image), check_existing=True)
except:
return
# ob = bpy.data.objects.new(f'{image_name}_texempty', None)
ob = bpy.data.objects.new(image.name, None)
# ob.hide_viewport = (cam_type == 'ORTHO') # hide if in persp mode
# load image on the empty
ob.empty_display_type = 'IMAGE'
ob.data = image
# ob.empty_display_size = bg_cam.data.ortho_scale # set display size
ob.use_empty_image_alpha = True
ob.color[3] = 0.5 # transparency
return ob
## Scan background objects and reload
def scan_backgrounds(context=None, scene=None, all_bg=False):
'''return a list (sorted by negative locZ)
of all holder object (parent of texempty and texplane)
'''
context = context or bpy.context
scene = scene or context.scene
oblist = []
if all_bg:
# oblist = [o for o in scene.objects if o.get('is_background_holder')]
oblist = [o for o in scene.objects if o.name.startswith(PREFIX) and o.type == 'MESH']
else:
col = bpy.data.collections.get(BGCOL)
if not col:
return oblist
# oblist = [o for o in col.all_objects if o.get('is_background_holder')]
oblist = [o for o in col.all_objects if o.name.startswith(PREFIX) and o.type == 'MESH']
oblist.sort(key=lambda x : x.location.z, reverse=True)
return oblist
def add_loc_z_driver(plane):
# Create properties
# plane['distance'] = abs(plane.location.z)
plane['distance'] = -plane.location.z
# Driver for location
dist_drv = plane.driver_add('location', 2)
dist_drv.driver.type = 'SCRIPTED' #'AVERAGE' #need to be inverted
var = dist_drv.driver.variables.new()
var.name = "distance"
var.type = 'SINGLE_PROP'
var.targets[0].id = plane
var.targets[0].data_path = '["distance"]'
dist_drv.driver.expression = '-distance'
def reload_bg_list(scene=None):
'''List holder plane object and add them to bg collection'''
start = time.time()
scn = scene or bpy.context.scene
uilist = scn.bg_props.planes
# current_index = scn.bg_props.index
oblist = scan_backgrounds()
# print('reload_bg_list > oblist:')
# pp(oblist)
warning = []
if not oblist:
return f'No BG found, need to be in collection "{BGCOL}" !'
## Clean
for ob in oblist:
bg_viewlayer = bpy.context.view_layer.layer_collection.children.get(BGCOL)
if not bg_viewlayer:
return "No 'Background' collection in scene master collection !"
ob_viewcol = bg_viewlayer.children.get(ob.name)
if not ob_viewcol:
warning.append(f'{ob.name} not found in "Background" collection')
continue
# if vl_col is hided or object is hided
# visible = any(o for o in ob.children if o.visible_get()) # ob.visible_get()
# for o in ob.children: o.hide_set(False)
ob_viewcol.hide_viewport = False
ob_viewcol.collection.hide_viewport = False
# ob_viewcol.exclude = not visible
## Driver check (don't erase if exists, else will delete persp mode drivers...)
if not ob.get('distance'):
if ob.animation_data:
for dr in ob.animation_data.drivers:
if dr.driver.variables.get('distance'):
# dist = ob.get('distance')
ob.animation_data.drivers.remove(dr)
add_loc_z_driver(ob)
uilist.clear()
## Populate the list
for ob in oblist: # populate list
item = uilist.add()
item.plane = ob
item.type = "bg"
scn.bg_props.index = len(uilist) - 1 # id (len of list in the ad loop)
if ob.children and ob.children[0].type == 'GPENCIL':
reset_gp_uv(ob.children[0]) # Force reset UV
## Add Grease pencil objects
gp_list = [o for o in bpy.context.scene.objects if o.type == 'GPENCIL' \
and not o.get('is_background') and o not in oblist]
for ob in gp_list:
item = uilist.add()
item.plane = ob
item.type = "obj"
scn.bg_props.index = len(uilist) - 1 # id (len of list in the ad loop)
print(f"{len(uilist)} loaded in {time.time()-start:.2f}")
return warning
def coord_distance_from_cam_straight(coord=None, context=None, camera=None):
context = context or bpy.context
coord = coord or context.scene.cursor.location
camera = camera or context.scene.camera
if not camera:
return
view_mat = camera.matrix_world
view_point = view_mat @ Vector((0, 0, -1000))
co = geometry.intersect_line_plane(view_mat.translation, view_point, coord, view_point)
if co is None:
return None
return (co - view_mat.translation).length
def coord_distance_from_cam(coord, context=None, camera=None):
context = context or bpy.context
coord = coord or context.scene.cursor.location
camera = camera or context.scene.camera
if not camera:
return
return (coord - camera.matrix_world.to_translation()).length

25
operators/__init__.py Normal file
View File

@ -0,0 +1,25 @@
# SPDX-License-Identifier: GPL-2.0-or-later
from . import (manage_objects, manage_planes, import_planes, convert_planes)
modules = (
manage_objects,
manage_planes,
import_planes,
convert_planes
)
# if 'bpy' in locals():
# import importlib
# for mod in modules:
# importlib.reload(mod)
import bpy
def register():
for mod in modules:
mod.register()
def unregister():
for mod in reversed(modules):
mod.unregister()

165
operators/convert_planes.py Normal file
View File

@ -0,0 +1,165 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import re
# import bpy_extras
# from bpy_extras.io_utils import ImportHelper
from pathlib import Path
from bpy.types import Operator
from .. import fn
from .. constants import BGCOL, MODULE_DIR
## Plane conversion between multiple types: GP plane / image plane / empty references
def convert_background_image(ob, target = 'GPENCIL'):
'''Target in ['GPENCIL', 'EMPTY', 'MESH']'''
if target == ob.type:
print('WARNING', f'{ob.name} Already of type {target}')
return
scn = bpy.context.scene
cam = scn.objects.get('bg_cam')
if not cam:
cam = ob.parent
if not cam:
cam = scn.camera
if not cam:
print('No "bg_cam", no parent, source object is not parented !')
return
# Get info from Source
img_infos = fn.get_image_infos_from_object(ob)
if not img_infos:
print(f'No could not retrieve image source from {ob}')
return
image_data, opacity = img_infos
### Create new plane type
## RENAME to avoid conflict (always keep same name for list scan ?)
## rename source temporarily
## write type in name ?
## name only holder (if there is a holder)
ob.name = f'{ob.name}_temp'
## TODO: ensure image filepath is ok ?
suffix = ''
if target == 'GPENCIL':
## Create GP plane from Texture source
new = fn.import_image_as_gp_reference(
bpy.context,
image_data,
pack_image=False,
)
suffix = '_texgp'
if target == 'MESH':
## HARDCODED text blend path:
# blend = '/s/blender/blender-2.9_scripts/addons/bg_plane_manager/texture_plane.blend'
blend = str(MODULE_DIR / 'texture_plane.blend')
node_group = fn.link_nodegroup(blend, 'texture_plane', link=False)
new = fn.create_image_plane(image_data, node_group)
suffix = '_texplane'
if target == 'EMPTY':
new = fn.create_empty_image(image_data)
suffix = '_texempty'
# Strip after '.' or get original name ?
print('source name: ', new.name)
new.name = fn.clean_image_name(image_data.name) + suffix
print('final name:', new.name)
new['is_background'] = True
## Without holder
## Transfer attributes
# fn.create_plane_driver(new, cam)
# new.parent = cam
# new['distance'] = ob['distance']
# new['scale'] = ob['scale']
# new.lock_location = ob.lock_location # lock only X,Y ?
# new.hide_select = ob.hide_select
# new.rotation_euler = ob.rotation_euler
## With holder
new.parent = ob.parent
## Loc/scale overrided by driver
# new.location = ob.location
# new.scale = ob.scale
## Transfer current transparency
# Destination
fn.set_opacity(new, opacity)
## Set in collection
if not len(ob.users_collection):
fn.set_collection(new, BGCOL)
else:
fn.set_collection(new, ob.users_collection[0].name)
## Remove old object
bpy.data.objects.remove(ob)
return new
class BPM_OT_convert_planes(Operator):
bl_idname = "bpm.convert_planes"
bl_label = "Convert Planes"
bl_description = "Convert as plane all images inside a folder"
bl_options = {"REGISTER"} # , "UNDO"
target_type : bpy.props.EnumProperty(
name="Convert To", description="", default='GPENCIL', options={'ANIMATABLE'},
items=(
('GPENCIL', 'Gpencil Object', 'Convert bg planes to gpencil objects', 0),
('EMPTY', 'Empty Reference', 'Convert bg planes to empty objects', 1),
('MESH', 'Texture Plane', 'Convert bg planes to mesh objects', 2),
))
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=250)
# return self.execute(context)
def execute(self, context):
print('BPM_OT_convert_planes')
holder_list = fn.scan_backgrounds(all_bg=True)
if not holder_list:
self.report({'ERROR'}, 'No Background found to convert, Structure must have a parent holder')
return {'CANCELLED'}
for holder in holder_list:
if not holder.children:
print(f'{holder.name} has not child')
continue
ob = holder.children[0] # take first child FIXME: what if multiple childrens ?
new_bg = convert_background_image(ob, target=self.target_type)
# if isinstance(new_bg, str):
# self.report({'ERROR'}, new_bg)
return {"FINISHED"}
classes=(
BPM_OT_convert_planes,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

263
operators/import_planes.py Normal file
View File

@ -0,0 +1,263 @@
# SPDX-License-Identifier: GPL-3.0-or-later
## Standalone based on SPA code
## Code from create_gp_texture_ref.py (Copyright (C) 2023, The SPA Studios. All rights reserved.)
## adapted to create as a single 'GP as image' and fit in camera with driver
import bpy
from bpy_extras.io_utils import ImportHelper
from bpy.props import (StringProperty,
CollectionProperty,
BoolProperty,
EnumProperty)
import json
import bpy_extras
from mathutils import Vector
import os
from pathlib import Path
from .. import fn
from .. constants import *
from .. export_psd_layers import export_psd_bg
def get_json_infos(json_path) -> tuple((list, tuple)):
'''return a tuple with (image paths list, [x, y] original psd resolution'''
import json
setup_json_path = Path(json_path)
setup_json = json.loads(setup_json_path.read_text())
# psd = Path(setup_json['psd']) # path to psd
# image_size = setup_json['image_size'] # image size (reduced)
# png_dir = Path(setup_json['png_dir']) # path to png directory
layers = setup_json['layers'] # dic : name, lbbox, bbox, index, path
org_image_size = setup_json['org_image_size'] # original PSD dimensions
img_list = [Path(l['path']) for l in layers] # (png_dir /l['name']).with_suffix('.png')
return img_list, org_image_size
def setup_bg_camera(image_size, context=None, scn=None, cam_type=None):
'''Create background camera from scene active camera according to background image resolution
image_size ::
cam_type :: wether the BG camera should be set to perspective or orthographic
'''
context = context or bpy.context
scn=scn or context.scene
movie_resolution = scn.render.resolution_x, scn.render.resolution_y
anim_cam = scn.camera
anim_cam_scale = 6 # anim_cam.data.ortho_scale
anim_cam_focale = 50 # anim_cam.data.lens
cam_type = cam_type or anim_cam.data.type
## scale up plane scale and bg_cam ortho_scale according to film res
bg_factor = (anim_cam_scale * image_size[0]) / movie_resolution[0]
#print(f'({anim_cam_scale} * {image_size[0]}) / {movie_resolution[0]} = {bg_factor}')
## scale down the focal according to film res
bg_focale = (anim_cam_focale * movie_resolution[0]) / image_size[0]
#print(f'({anim_cam_focale} * {movie_resolution[0]}) / {image_size[0]} = {bg_focale}')
bg_cam = scn.objects.get('bg_cam')
if not bg_cam:
bg_cam = fn.create_cam(name='bg_cam', type=cam_type, size=bg_factor, focale=bg_focale)
bg_cam['resolution'] = image_size
bg_cam.matrix_world = anim_cam.matrix_world
bg_cam.hide_select = True
bg_cam.lock_location = bg_cam.lock_rotation = bg_cam.lock_scale = [True]*3
fn.set_collection(bg_cam, 'Camera')
fn.set_collection(anim_cam, 'Camera')
return bg_cam
class BPM_OT_import_bg_images(bpy.types.Operator, ImportHelper):
bl_idname = "bpm.import_bg_images"
bl_label = "Import Background images"
bl_description = "Import either a Json, a PSD, multiple files"
bl_options = {"REGISTER"} # , "INTERNAL"
# filename_ext = '.json'
filter_glob: StringProperty(
default=';'.join([f'*{i}' for i in bpy.path.extensions_image]) + ';*.json',
options={'HIDDEN'} )
## Active selection
filepath : StringProperty(
name="File Path",
description="File path used for import",
maxlen= 1024) # the active file
## Handle multi-selection
files: CollectionProperty(
name="File Path",
type=bpy.types.OperatorFileListElement,
) # The filelist collection
## Choice to place before or after ?
import_type : EnumProperty(
name="Import As", description="Type of import to ", default='GPENCIL', options={'ANIMATABLE'},
items=(
('GPENCIL', 'Gpencil Object', 'Import bg planes as gpencil objects', 0),
('EMPTY', 'Empty Reference', 'Import bg planes as empty objects', 1),
('MESH', 'Texture Plane', 'Import bg planes as mesh objects', 2),
))
mode : EnumProperty(
name="Mode", description="", default='REPLACE', options={'ANIMATABLE'},
items=(
('REPLACE', 'Replace Existing', 'Replace the image if already exists', 0),
('SKIP', 'Skip Existing', 'Skip the import if the image alreaady exists in planes', 1),
# ('PURGE', 'Purge', 'When object exists, fully delete it before linking, even associated collection and holder', 2),
))
def execute(self, context):
org_image_size = None
scn = context.scene
active_file = Path(self.filepath)
if len(self.files) == 1 and active_file.suffix.lower() in ('.json', '.psd'):
json_path = None
print('active_file.suffix.lower(): ', active_file.suffix.lower())
if active_file.suffix.lower() == '.psd':
print('Is a PSD')
## Export layers and create json setup file
# Export passes in a 'render' or 'render_hd' folder aside psd
json_path = export_psd_bg(str(active_file))
elif active_file.suffix.lower() == '.json':
print('Is a json')
# Use json data to batch import
json_path = active_file
if not json_path:
self.report({'ERROR'}, 'No json path to load, you can try loading from psd or selecting image file directly')
return {'CANCELLED'}
file_list, org_image_size = get_json_infos(json_path)
folder = Path(json_path).parent # render folder
else:
folder = active_file.parent
# Filter out json (we may want ot import the PSD as imagelisted in bpy.path.extensions_image)
file_list = [folder / f.name for f in self.files if Path(f.name).suffix in bpy.path.extensions_image]
if not file_list:
self.report({'ERROR'}, 'Image file list is empty')
return {'CANCELLED'}
## simple_list
# existing_backgrounds = [o for o in context.scene.objects if o.get('is_background')]
# existing_holders = [o for o in context.scene.objects if o.name.startswith(PREFIX) and o.type == 'MESH' and o.children]
## Has dict
# existing_backgrounds = {o : img_info for o in context.scene.objects if o.get('is_background') and (img_info := o.get('is_background'))}
# FIXME: Use existing background custom prop? : o.get('is_background')
## list of Tuples : [(plane_object, img_data, transparency_value), ...]
existing_backgrounds = [(o, *img_info) for o in context.scene.objects \
if o.get('is_background') and (img_info := fn.get_image_infos_from_object(o)) and o.get('is_background') and o.parent]
existing_backgrounds.sort(key=lambda x: x[0].parent.location.z, reverse=True) # sort by parent (holder) location Z
far_plane = INIT_POS
if existing_backgrounds:
far_plane = existing_backgrounds[-1][0].parent.location.z
## Ensure bg_cam setup (option to use active camera ?)
bg_cam = scn.objects.get('bg_cam')
if not bg_cam:
if not org_image_size:
## Get image size from first file
img = bpy.data.images.load(file_list[0], check_existing=True)
org_image_size = (img.size[0], img.size[1])
bg_cam = setup_bg_camera(
org_image_size, context=context, scn=scn, cam_type=scn.camera.data.type)
## Ensure Background collection
backgrounds = fn.get_col(BGCOL) # Create if needed
for fp in file_list:
print(f'Importing {fp.name}')
current_holder = None
file_stem = Path(fp).stem
# current_bg = next((o for o if o.parent and fn.get_image_infos_from_object(o)), None)
current_bg = next((o for o in existing_backgrounds if o[1].filepath == fp), None)
if current_bg:
current_holder = current_bg.parent
# TODO store opacity or delete existing objects only after loop
if current_bg and self.mode == 'SKIP':
print(f' - SKIP: existing {current_bg.name}')
continue
if current_bg and self.mode == 'REPLACE':
print(f' - DEL: existing {current_bg.name}')
bpy.data.objects.remove(current_bg)
# Import image
if self.import_type == 'GPENCIL':
bg_img = fn.import_image_as_gp_reference(
context=context,
image=fp,
pack_image=False,
)
elif self.import_type == 'MESH':
bg_img = fn.create_image_plane(fp)
elif self.import_type == 'EMPTY':
bg_img = fn.create_empty_image(fp)
bg_img.name = file_stem + '_texgp'
bg_img.hide_select = True
if current_holder:
bg_img.parent = current_holder
fn.set_collection(bg_img, current_holder.users_collection[0].name)
continue
# img, opacity = fn.get_image_infos_from_object(bg_img)
print('bg_img: ', bg_img)
img = fn.get_image(bg_img)
# bg_name = fn.clean_image_name(img.name)
file_stem = file_stem
# Get create collection from image clean name
bg_col = fn.get_col(file_stem, parent=backgrounds)
## Set in collection
bg_col.objects.link(bg_img)
print('img: ', img.name, bg_img.name, bg_img.users_collection)
## create the holder, parent to camera, set driver and set collection. Could also pass 'fp'
holder = fn.create_plane_holder(img, name=PREFIX + file_stem, parent=bg_cam, link_in_col=bg_col)
print('holder: ', holder.name)
fn.create_plane_driver(holder, bg_cam, distance=far_plane)
bg_img.parent = holder
far_plane += 2
fn.reload_bg_list(scene=scn)
self.report({'INFO'}, f'Settings loaded from: {os.path.basename(self.filepath)}')
return {"FINISHED"}
classes=(
BPM_OT_import_bg_images,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

397
operators/manage_objects.py Normal file
View File

@ -0,0 +1,397 @@
import re
from math import pi
import bpy
import mathutils
from bpy.types import Operator
from mathutils import Matrix, Vector
from .. import fn
def set_resolution_from_cam_prop(cam=None):
if not cam:
cam = bpy.context.scene.camera
if not cam:
return ('ERROR', 'No active camera')
res = cam.get('resolution')
if not res:
return ('ERROR', 'Cam has no resolution attribute')
rd = bpy.context.scene.render
if rd.resolution_x == res[0] and rd.resolution_y == res[1]:
return ('INFO', f'Resolution already at {res[0]}x{res[1]}')
else:
rd.resolution_x, rd.resolution_y = res[0], res[1]
return ('INFO', f'Resolution to {res[0]}x{res[1]}')
class BPM_OT_swap_cams(Operator):
bl_idname = "bpm.swap_cams"
bl_label = "Swap Cameras"
bl_description = "Toggle between anim and bg cam"
bl_options = {"REGISTER"}
@classmethod
def poll(cls, context):
return True
def execute(self, context):
anim_cam = bpy.context.scene.objects.get('anim_cam')
bg_cam = bpy.context.scene.objects.get('bg_cam')
if not anim_cam or not bg_cam:
self.report({'ERROR'}, 'anim_cam or bg_cam is missing')
return {"CANCELLED"}
cam = context.scene.camera
if not cam:
context.scene.camera = anim_cam
set_resolution_from_cam_prop()
return {"FINISHED"}
in_draw = False
if cam.parent and cam.name in ('draw_cam', 'action_cam'):
if cam.name == 'draw_cam':
draw_cam = cam
in_draw = True
cam = cam.parent
## swap
# context.scene.camera = bg_cam if cam is anim_cam else anim_cam
if cam is anim_cam:
main = context.scene.camera = bg_cam
anim_cam.hide_viewport = True
bg_cam.hide_viewport = False
else:
main = context.scene.camera = anim_cam
anim_cam.hide_viewport = False
bg_cam.hide_viewport = True
if in_draw:
draw_cam.parent = main
draw_cam.data = main.data
# back in draw_cam
context.scene.camera = draw_cam
bg_cam.hide_viewport = anim_cam.hide_viewport = True
# set res
ret = set_resolution_from_cam_prop(main)
if ret:
self.report({ret[0]}, ret[1])
return {"FINISHED"}
class BPM_OT_send_gp_to_plane(Operator):
bl_idname = "bpm.send_gp_to_plane"
bl_label = "Send To Plane"
bl_description = "Send the selected GPs to current active layer, adjusting scale to keep size in camera"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.object and context.object.type != 'CAMERA'
def execute(self, context):
offset = 0.005 # 0.001
ob = context.object
cam = context.scene.camera
settings = context.scene.bg_props
plane = settings.planes[settings.index].plane
plane_mat = plane.matrix_world
plane_co = plane_mat.translation
plane_no = Vector((0,0,1))
plane_no.rotate(plane_mat)
cam_co = cam.matrix_world.translation
ob_co = ob.matrix_world.translation
if cam.data.type == 'ORTHO':
forward = Vector((0,0,-1))
backward = Vector((0,0,1))
forward.rotate(cam.matrix_world)
backward.rotate(cam.matrix_world)
new_co = mathutils.geometry.intersect_line_plane(ob_co, ob_co+forward, plane_co, plane_no)
if not new_co:
new_co = mathutils.geometry.intersect_line_plane(ob_co, ob_co+backward, plane_co, plane_no)
if not new_co:
self.report({'ERROR'}, 'Could not hit background surface by tracing looking in cam direction from obj\nCheck if BG plane is parallel to camera view')
return {"CANCELLED"}
new_vec = new_co - ob_co
new_vec += backward * offset
ob.matrix_world.translation += new_vec
self.report({'INFO'}, f'Moved {ob.name} to {plane.name} ({new_vec.length:.3f}m)')
return {"FINISHED"}
# PERSP mode
init_dist = (ob_co - cam_co).length
new_co = mathutils.geometry.intersect_line_plane(cam_co, ob_co, plane_co, plane_no)
print('new_co: ', new_co)
if not new_co:
self.report({'ERROR'}, 'Grease pencil object might be behind camera\nCould not hit background surface by tracing from cam to BG')
return {"CANCELLED"}
new_vec = new_co - cam_co
new_vec -= new_vec.normalized() * offset # substract offset from cam to ob vector
new_dist = new_vec.length # check distance after offset applied for right scaling
dist_percentage = new_dist / init_dist
ob.matrix_world.translation = cam_co + new_vec # replace from cam to ob
ob.scale = ob.matrix_world.to_scale() * dist_percentage # adjust scale
self.report({'INFO'}, f'Moved {ob.name} to {plane.name} ({new_dist:.3f}m)')
return {"FINISHED"}
class BPM_OT_parent_to_bg(Operator):
bl_idname = "bpm.parent_to_bg"
bl_label = "Parent To Selected Background"
bl_description = "Parent selected active object to active Background in list"
bl_options = {"REGISTER"}
@classmethod
def poll(cls, context):
return context.object# and context.object.type != 'CAMERA'
def execute(self, context):
settings = context.scene.bg_props
plane = settings.planes[settings.index].plane
plane_list = [i.plane for i in settings.planes]
# plane_mat = plane.matrix_world
o = bpy.context.object
if o in plane_list:
self.report({'ERROR'}, 'Selected object must not be a plane')
return {"CANCELLED"}
mat = o.matrix_world.copy()
if o.parent:
parent = o.parent
o.parent = None
self.report({'INFO'}, f'Object "{o.name}" unparented from {parent.name}')
else:
o.parent = plane
self.report({'INFO'}, f'Object "{o.name}" parented to {plane.name}')
o.matrix_world = mat
return {"FINISHED"}
# TODO make the align to plane orientation (change object rotation without affecting points)
# need to make a loop with frame_set on each frame (and change only relevant layers...)
class BPM_OT_align_to_plane(Operator):
bl_idname = "bpm.align_to_plane"
bl_label = "Align to GP plane"
bl_description = "Align the current GP object to plane\n(change object orientation while keeping points in place)"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return True
def execute(self, context):
settings = context.scene.bg_props
plane = settings.planes[settings.index].plane
plane_mat = plane.matrix_world
o = bpy.context.object
old_mat = o.matrix_world.copy()
# reset or align to needed plane
# decompose old matrix
loc, rot, scale = old_mat.decompose()
matloc = Matrix.Translation(loc)
#matrot = Matrix()
matrot = rot.to_matrix().to_4x4()
# recreate a neutral mat scale
matscale_x = Matrix.Scale(scale[0], 4,(1,0,0))
matscale_y = Matrix.Scale(scale[1], 4,(0,1,0))
matscale_z = Matrix.Scale(scale[2], 4,(0,0,1))
matscale = matscale_x @ matscale_y @ matscale_z
#C.object.rotation_euler = (0,0,0)
# mat_90 = Matrix.Rotation(-pi/2, 4, 'X')
print("old_mat", old_mat)#Dbg
#new_mat = o.matrix_world.copy()
new_mat = matloc @ matscale
context.object.matrix_world = new_mat
for l in o.data.layers:
for f in l.frames:
for s in f.strokes:
for p in s.points:
p.co = matrot @ p.co
return {"FINISHED"}
def place_object_from_facing_cam(ob=None, cam=None, distance=8):
ob = ob or bpy.context.object
cam = cam or bpy.context.scene.camera
if not cam:
return
scale = ob.matrix_world.to_scale()
mat_scale_x = Matrix.Scale(scale[0], 4,(1,0,0))
mat_scale_y = Matrix.Scale(scale[1], 4,(0,1,0))
mat_scale_z = Matrix.Scale(scale[2], 4,(0,0,1))
mat_scale = mat_scale_x @ mat_scale_y @ mat_scale_z
mat = cam.matrix_world.copy()
cam_mat_inv = mat.inverted()
mat_90 = Matrix.Rotation(-pi/2, 4, 'X')
# Offset in object local Y
mat.translation -= Vector((0, 0, distance)) @ cam_mat_inv
mat = mat @ mat_90 @ mat_scale
ob.matrix_world = mat
class BPM_OT_create_and_place_in_camera(Operator):
bl_idname = "bpm.create_and_place_in_camera"
bl_label = "Create and Place Gpencil In Camera"
bl_description = "Create GP object\
\nCentered and Rotated so X-Z front axis is facing cam\
\nCtrl + Click to place selected object intead of creating"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
@classmethod
def poll(cls, context):
if not context.scene.camera:
cls.poll_message_set("Need a scene camera, object is created facing cam")
return False
return True
create : bpy.props.BoolProperty(name='Create', default=False, options={'SKIP_SAVE'})
name : bpy.props.StringProperty(name='Name', default='', options={'SKIP_SAVE'})
distance : bpy.props.FloatProperty(name='Distance', default=8, subtype='DISTANCE')
use_light : bpy.props.BoolProperty(name='Use Light', default=False, options={'SKIP_SAVE'})
edit_line_opacity : bpy.props.FloatProperty(name='Edit Line Opacity',
description="Edit line opacity for newly created objects\
\nAdvanced users generally like it at 0 (show only selected line in edit mode)\
\nBlender default is 0.5",
default=0.0, min=0.0, max=1.0)
def invoke(self, context, event):
if event.ctrl:
self.create = False
## Set placeholder name (Comment to let an empty string)
self.name = fn.placeholder_name(self.name, context)
prefs = fn.get_addon_prefs()
self.use_light = prefs.use_light
self.edit_line_opacity = prefs.edit_line_opacity
if not bpy.context.scene.objects.get('bg_cam'):
self.report({'ERROR'}, 'No bg_cam')
return {"CANCELLED"}
## match current plane distance
settings = context.scene.bg_props
if settings.planes and settings.planes[settings.index].plane:
plane = settings.planes[settings.index].plane
self.distance = fn.coord_distance_from_cam_straight(plane.matrix_world.to_translation()) - 0.005
else:
self.distance = fn.coord_distance_from_cam_straight(context.scene.cursor.location)
self.distance = max([1.0, self.distance]) # minimum one meter away from cam
if self.create:
return context.window_manager.invoke_props_dialog(self, width=250)
else:
if not context.object:
self.report({'ERROR'}, 'No active object')
return {"CANCELLED"}
return self.execute(context)
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.prop(self, 'name', icon='OUTLINER_OB_GREASEPENCIL')
layout.prop(self, 'distance', icon='DRIVER_DISTANCE')
layout.separator()
layout.prop(self, 'use_light')
layout.prop(self, 'edit_line_opacity')
def execute(self, context):
if self.create:
ob_name = fn.placeholder_name(self.name, context)
## Create Object
prefs = fn.get_addon_prefs()
gp_data = bpy.data.grease_pencils.new(ob_name)
ob = bpy.data.objects.new(ob_name, gp_data)
ob.use_grease_pencil_lights = prefs.use_light
gp_data.edit_line_color[3] = prefs.edit_line_opacity
l = gp_data.layers.new('GP_Layer')
l.frames.new(context.scene.frame_current)
fn.set_collection(ob, 'GP') # Gpencils
# Add to bg_plane collection
new_item = context.scene.bg_props.planes.add()
new_item.plane = ob
new_item.type = 'obj'
# Set active on last
context.scene.bg_props.index = len(context.scene.bg_props.planes) - 1
fn.gp_transfer_mode(ob)
loaded_palette = False
if hasattr(bpy.types, "GPTB_OT_load_default_palette"):
res = bpy.ops.gp.load_default_palette()
if res == {"FINISHED"}:
loaded_palette = True
if not loaded_palette:
# Append at least line material
mat = bpy.data.materials.get('line')
if not mat:
## Create basic GP mat
mat = bpy.data.materials.new(name='line')
bpy.data.materials.create_gpencil_data(mat)
gp_data.materials.append(mat)
else:
ob = context.object
## Place in centered and front facing camera at given distance
cam = context.scene.camera
place_object_from_facing_cam(ob, cam, self.distance)
return {"FINISHED"}
classes=(
## Scene
BPM_OT_swap_cams,
## GP related
BPM_OT_send_gp_to_plane,
BPM_OT_parent_to_bg,
BPM_OT_create_and_place_in_camera,
# BPM_OT_align_to_plane # << TODO
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

319
operators/manage_planes.py Normal file
View File

@ -0,0 +1,319 @@
from pathlib import Path
import bpy
from bpy.types import Operator
from mathutils import Vector
from os.path import abspath
from .. import fn
from .. constants import BGCOL
## Open image folder
## Open change material alpha mode when using texture
## Selection ops (select toggle / all)
## Set distance modal
## Move plane by index
## Rebuild collection (Scan Background collection)
class BPM_OT_open_bg_folder(Operator):
bl_idname = "bpm.open_bg_folder"
bl_label = "Open bg folder"
bl_description = "Open folder of active element"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if context.scene.bg_props.planes and context.scene.bg_props.planes[context.scene.bg_props.index].type == 'bg':
return True
else:
cls.poll_message_set("Only available when a background image planes is selected")
return False
def execute(self, context):
item = context.scene.bg_props.planes[context.scene.bg_props.index]
ob = item.plane
# name = f'{item.plane.name}_texempty'
tex_obj = next((o for o in ob.children), None)
if not tex_obj:
self.report({'ERROR'}, f'Could not found child for holder: {ob.name}')
return {"CANCELLED"}
img = fn.get_image(tex_obj)
fp = Path(abspath(bpy.path.abspath(img.filepath)))
if not fp.exists():
self.report({'ERROR'}, f'Not found: {fp}')
return {"CANCELLED"}
bpy.ops.wm.path_open(filepath=str(fp.parent))
return {"FINISHED"}
class BPM_OT_change_material_alpha_mode(Operator):
bl_idname = "bpm.change_material_alpha_mode"
bl_label = "Swap Material Aplha"
bl_description = "Swap alpha : blend <-> Clip"
bl_options = {"REGISTER"} # , "UNDO"
@classmethod
def poll(cls, context):
return True
def execute(self, context):
print('----')
# maybe add a shift to act on selection only
on_selection = False
ct = 0
mode = None
for col in bpy.context.scene.collection.children:
if col.name != BGCOL:
continue
for bgcol in col.children:
for o in bgcol.all_objects:
if o.type == 'MESH' and len(o.data.materials):
if on_selection and o.parent and not o.parent.select_get():
continue
if mode is None:
mode = 'BLEND' if o.data.materials[0].blend_method == 'CLIP' else 'CLIP'
print(o.name, '>>', mode)
ct += 1
o.data.materials[0].blend_method = mode
self.report({'INFO'}, f'{ct} swapped to alpha {mode}')
return {"FINISHED"}
class BPM_OT_select_swap(Operator):
bl_idname = "bpm.select_swap_active_bg"
bl_label = "Select / Deselect"
bl_description = "Select/Deselect Active Background"
bl_options = {"REGISTER"} # , "UNDO"
@classmethod
def poll(cls, context):
return True
def execute(self, context):
settings = context.scene.bg_props
plane = settings.planes[settings.index].plane
if not plane:
self.report({'ERROR'}, 'could not found current plane\ntry a refresh')
return{'CANCELLED'}
plane.select_set(not plane.select_get())
return {"FINISHED"}
class BPM_OT_select_all(Operator):
bl_idname = "bpm.select_all"
bl_label = "Select All Background"
bl_description = "Select all Background"
bl_options = {"REGISTER"}
@classmethod
def poll(cls, context):
return True
def execute(self, context):
bg_col = bpy.data.collections.get('Background')
if bg_col:
bg_col.hide_select = False
settings = context.scene.bg_props
for p in settings.planes:
if p and p.type == 'bg':
if p.plane.visible_get():
p.plane.select_set(True)
return {"FINISHED"}
# class BPM_OT_frame_backgrounds(Operator):
# bl_idname = "bpm.frame_backgrounds"
# bl_label = "Frame Backgrounds"
# bl_description = "Get out of camera and frame backgrounds from a quarter point or view"
# bl_options = {"REGISTER"}
# @classmethod
# def poll(cls, context):
# return True
# def execute(self, context):
# if context.space_data.region_3d.view_perspective != 'CAMERA':
# context.space_data.region_3d.view_perspective = 'CAMERA'
# return {"FINISHED"}
# ## go out of camera, frame all bg in view at wauter angle
# settings = context.scene.bg_props
# visible_planes = [p.plane for p in settings.planes if p and p.type == 'bg' and p.plane.visible_get()]
# # TODO get out of camera and view frame planes
# context.space_data.region_3d.view_perspective = 'PERSPECTIVE'
# return {"FINISHED"}
class BPM_OT_set_distance(Operator):
bl_idname = "bpm.set_distance"
bl_label = "Distance"
bl_description = "Set distance of the active plane object\
\nShift move all further plane\
\nCtrl move all closer planes\
\nAlt move All"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return True
def invoke(self, context, event):
self.init_mouse_x = event.mouse_x
context.window_manager.modal_handler_add(self)
self.active = context.scene.bg_props.planes[context.scene.bg_props.index].plane
if context.scene.bg_props.move_hided:
self.plane_list = [i.plane for i in context.scene.bg_props.planes if i.type == 'bg']
else:
# o.visible_get() << if not get_view_layer_col(o.users_collection[0]).exclude
self.plane_list = [i.plane for i in context.scene.bg_props.planes if i.type == 'bg' and i.plane.visible_get()]
if not self.plane_list:
self.report({'ERROR'}, f'No plane found in list with current filter')
return {"CANCELLED"}
self.plane_list.sort(key=lambda o: o.get('distance'), reverse=True)
self.active_index = self.plane_list.index(self.active)
self.offset = 0.0
self.further = self.plane_list[:self.active_index+1] # need +1 to include active
self.closer = self.plane_list[self.active_index:]
self.init_dist = [o.get('distance') for o in self.plane_list]
# context.area.header_text_set(f'{self.mode}')
return {'RUNNING_MODAL'}
def modal(self, context, event):
context.area.header_text_set(f'Shift : move active and all further planes |\
Ctrl : move active and all closer planes | Shift + Ctrl :move all| Offset : {self.offset:.3f}m')
if event.type in {'LEFTMOUSE'} and event.value == 'PRESS':
# VALID
context.area.header_text_set(None)
return {"FINISHED"}
if event.type in {'ESC', 'RIGHTMOUSE'} and event.value == 'PRESS':
# CANCEL
context.area.header_text_set(None)
for plane, init_dist in zip(self.plane_list, self.init_dist):
plane['distance'] = init_dist
plane.location = plane.location
return {"CANCELLED"}
if event.type in {'MOUSEMOVE'}:
self.mouse = Vector((event.mouse_x, event.mouse_y))
self.offset = (event.mouse_x - self.init_mouse_x) * 0.1
for plane, init_dist in zip(self.plane_list, self.init_dist):
# reset to init distance dist
plane['distance'] = init_dist
if event.ctrl and event.shift or event.alt:
# all
plane['distance'] = init_dist + self.offset
elif event.shift:
if plane in self.further:
plane['distance'] = init_dist + self.offset
elif event.ctrl:
if plane in self.closer:
plane['distance'] = init_dist + self.offset
else:
if plane == self.active:
plane['distance'] = init_dist + self.offset
## needed esle no update...
plane.location = plane.location
return {"RUNNING_MODAL"}
class BPM_OT_move_plane(Operator):
bl_idname = "bpm.move_plane"
bl_label = "Move Plane"
bl_description = "Move active plane up or down"
bl_options = {"REGISTER", "UNDO"}
direction : bpy.props.StringProperty()
@classmethod
def poll(cls, context):
props = context.scene.bg_props
return props \
and props.planes \
and props.index >= 0 \
and props.index < len(props.planes) \
and props.planes[props.index].type == 'bg'
def execute(self, context):
props = context.scene.bg_props
planes = props.planes
plane_ob = planes[props.index].plane
plane_objects = sorted([p.plane for p in planes if p.type == 'bg'], key=lambda x :x['distance'])
index = plane_objects.index(plane_ob)
if self.direction == 'UP':
if index == 0:
return {"FINISHED"}
other_plane = plane_objects[index - 1]
elif self.direction == 'DOWN':
# If index == len(planes)-1: # Invalid when there are GP as well
if index == len(planes) - 1 or planes[index + 1].type != 'bg':
# End of list or avoid going below a GP object item.
return {"FINISHED"}
other_plane = plane_objects[index + 1]
other_plane['distance'], plane_ob['distance'] = plane_ob['distance'], other_plane['distance']
plane_ob.location = plane_ob.location
other_plane.location = other_plane.location
return {"FINISHED"}
class BPM_OT_reload(Operator):
bl_idname = "bpm.reload_list"
bl_label = "Refresh Bg List"
bl_description = "Refresh the background list (scan for objects using a distance prop)"
bl_options = {"REGISTER"} # , "UNDO"
@classmethod
def poll(cls, context):
return True
def execute(self, context):
error = fn.reload_bg_list()
if error:
if isinstance(error, list):
self.report({'WARNING'}, 'Wrong name for some object, see console:' + '\n'.join(error))
else:
self.report({'ERROR'}, error)
return{'CANCELLED'}
return {"FINISHED"}
classes=(
# BPM_OT_change_background_type,
BPM_OT_select_swap,
BPM_OT_change_material_alpha_mode,
BPM_OT_select_all,
BPM_OT_reload,
BPM_OT_open_bg_folder,
BPM_OT_set_distance,
BPM_OT_move_plane
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

66
preferences.py Normal file
View File

@ -0,0 +1,66 @@
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from pathlib import Path
from shutil import which
from bpy.props import (FloatProperty,
BoolProperty,
EnumProperty,
StringProperty,
IntProperty,
PointerProperty)
def ui_in_sidebar_update(self, _):
from .ui.panels import BPM_PT_bg_manager_panel
if hasattr(bpy.types, BPM_PT_bg_manager_panel.bl_idname):
try:
bpy.utils.unregister_class(BPM_PT_bg_manager_panel)
except:
pass
BPM_PT_bg_manager_panel.bl_category = self.category.strip()
bpy.utils.register_class(BPM_PT_bg_manager_panel)
class BPM_prefs(bpy.types.AddonPreferences):
bl_idname = __name__.split('.')[0] # or __package__
category : StringProperty(
name="Category",
description="Choose a name for the sidebar category tab",
default="View",
update=ui_in_sidebar_update)
## Object settings
edit_line_opacity : FloatProperty(
name='Default Edit Line Opacity',
description="Edit line opacity for newly created objects\
\nAdvanced users generally like it at 0 (show only selected line in edit mode)\
\nBlender default is 0.5",
default=0.0, min=0.0, max=1.0)
use_light : BoolProperty(
name='Use light',
description="Use light or not for newly created objects",
default=False)
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.prop(self, 'category')
layout.label(text='Object Settings:', icon='GREASEPENCIL')
layout.label(text='Properties of newly created Grease pencil object:')
layout.prop(self, 'edit_line_opacity')
layout.prop(self, 'use_light')
### --- REGISTER ---
def register():
bpy.utils.register_class(BPM_prefs)
def unregister():
bpy.utils.unregister_class(BPM_prefs)

BIN
texture_plane.blend Normal file

Binary file not shown.

23
ui/__init__.py Normal file
View File

@ -0,0 +1,23 @@
# SPDX-License-Identifier: GPL-2.0-or-later
from . import (ui_list, panels)
modules = (
ui_list,
panels
)
# if 'bpy' in locals():
# import importlib
# for mod in modules:
# importlib.reload(mod)
import bpy
def register():
for mod in modules:
mod.register()
def unregister():
for mod in reversed(modules):
mod.unregister()

172
ui/panels.py Normal file
View File

@ -0,0 +1,172 @@
import bpy
from bpy.types import Panel
from .. constants import BGCOL, PREFIX
class BPM_PT_bg_manager_panel(Panel):
bl_idname = 'BPM_PT_bg_manager_panel'
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "View"
bl_label = "BG Plane Manager"
def draw(self, context):
layout = self.layout
scn = bpy.context.scene
# layout.label(text='Manual tests:')
# layout.operator('bpm.import_bg_images')
# layout.operator('bpm.convert_planes')
## Camera swapping
row = layout.row(align=True)
cam = context.scene.camera
# cam_name = cam.name if cam else 'None'
# if cam and cam_name == 'draw_cam':
# cam_name = f'{cam.parent.name} > {cam_name}'
# row.operator("bpm.swap_cams", text=cam_name, icon='OUTLINER_OB_CAMERA')
if cam:
anim_cam_name = 'anim_cam'
bg_cam_name = 'bg_cam'
in_anim = in_bg = False
if cam.name == 'anim_cam':
in_anim = True
elif cam.name == 'bg_cam':
in_bg = True
elif cam.name == 'draw_cam' and cam.parent:
if cam.parent.name == 'anim_cam':
in_anim = True
anim_cam_name = anim_cam_name + '> draw_cam'
elif cam.parent.name == 'bg_cam':
in_bg = True
bg_cam_name = bg_cam_name + '> draw_cam'
# in_bg_cam = context.scene.camera.name != 'bg_cam'
row.operator("bpm.swap_cams", text=anim_cam_name, icon='OUTLINER_OB_CAMERA', depress=in_anim)
row.operator("bpm.swap_cams", text=bg_cam_name, icon='OUTLINER_OB_CAMERA', depress=in_bg)
planes = [i.plane for i in scn.bg_props.planes]
if not planes:
## Show import/reload if nothing is in list
row = layout.row()
row.operator("bpm.import_bg_images", icon="IMPORT", text="Import Planes")
row.operator("bpm.reload_list", icon="FILE_REFRESH", text="")
return
## Show settings related to active plane
active_item = scn.bg_props.planes[scn.bg_props.index]
ob = active_item.plane
if not ob:
layout.operator("bpm.reload_list", icon="FILE_REFRESH", text="Need refresh")
return
bg_main_col = context.view_layer.layer_collection.children.get(BGCOL)
if not bg_main_col:
layout.label(text="No Background collection")
layout.label(text="Import plane automatically create hierarchy")
return
if active_item.type == 'bg':
layercol = bg_main_col.children.get(ob.name[len(PREFIX):])
if not layercol:
layout.label(text=f"No {ob.name} collection")
# return
col= layout.column()
## Backgrounds and Objects UI List
col.separator()
row = col.row()
list_row = row.row()
list_row.scale_y = scn.bg_props.ui_list_scale # 1.6
minimum_rows = 8
list_row.template_list("BPM_UL_bg_list", "", scn.bg_props, "planes", scn.bg_props, "index", rows=minimum_rows)
#https://docs.blender.org/api/blender2.8/bpy.types.UILayout.html#bpy.types.UILayout.template_list
col = row.column(align=True)
col.operator('bpm.create_and_place_in_camera', icon='OUTLINER_OB_GREASEPENCIL', text='').create = True
col.separator()
col.menu("BPM_MT_more_option", icon='DOWNARROW_HLT', text='')
col.separator()
col.operator('bpm.move_plane', icon='TRIA_UP', text='').direction = 'UP'
col.operator('bpm.move_plane', icon='TRIA_DOWN', text='').direction = 'DOWN'
col.separator()
# select toggle
# icon = 'RESTRICT_SELECT_ON' if ob.select_get() else 'RESTRICT_SELECT_OFF'
# col.operator("bpm.select_swap_active_bg", icon=icon, text='')
# lock
bg_col = bpy.data.collections.get(BGCOL)
if bg_col:
# lock_icon = 'LOCKED' if bg_col.hide_select else 'UNLOCKED'
col.prop(bg_col, 'hide_select', icon='LOCKED', text='', invert_checkbox=True) # # emboss=False
# col.prop(bg_col, 'hide_select', text='') # origin select icon
# select all
col.operator('bpm.select_all', icon='RESTRICT_SELECT_OFF', text='')
col = layout.column()
col.use_property_split = True
if active_item.type == 'bg':
row = col.row()
tex_ob = next((o for o in ob.children), None)
if not tex_ob:
layout.label(text='Missing Image Child', icon='ERROR')
return
row.prop(scn.bg_props, 'opacity', slider=True)
# row.operator("bpm.change_background_type", icon="NODE_TEXTURE", text=text_bg_type)
if tex_ob.type == 'MESH' and len(tex_ob.data.materials):
text_mode = 'Clip' if tex_ob.data.materials[0].blend_method == 'CLIP' else 'Blend'
row.operator("bpm.change_material_alpha_mode", icon="MATERIAL", text=text_mode)
# located under list
row = layout.row()
row.label(text='Move:')
if active_item.type == 'bg':
row.operator("bpm.set_distance", text=f"Distance {ob['distance']:.1f}m")
row.prop(scn.bg_props, 'move_hided', text='Hided')
elif active_item.type == 'obj':
cam = context.scene.objects.get('bg_cam') or context.scene.camera
if hasattr(bpy.types, 'OBJECT_OT_depth_proportional_move'):
distance = (ob.matrix_world.to_translation() - cam.matrix_world.to_translation()).length
row.operator("object.depth_proportional_move", text=f"Distance {distance:.1f}m") # icon='TRANSFORM_ORIGINS'
else:
row.label(text=f"Distance {(ob.matrix_world.to_translation() - cam.matrix_world.to_translation()).length:.1f}m")
if active_item.type == 'bg':
row = layout.row()
# row.label(text='Actions:')
## Send object to a plane
row = layout.row()
row.operator("bpm.send_gp_to_plane")
## parent to object
active = context.object
if active and active.parent and active not in planes:
layout.label(text=f'{active.parent.name} > {active.name}')
text='Clear Parent '
else:
text='Parent To Plane'
row.operator("bpm.parent_to_bg", text=text)
def register():
bpy.utils.register_class(BPM_PT_bg_manager_panel)
def unregister():
if hasattr(bpy.types, BPM_PT_bg_manager_panel.bl_idname):
bpy.utils.unregister_class(BPM_PT_bg_manager_panel)

356
ui/ui_list.py Normal file
View File

@ -0,0 +1,356 @@
import bpy
from pathlib import Path
from .. import fn
from .. constants import PREFIX
from bpy.types import UIList, PropertyGroup, Menu
from bpy.props import (PointerProperty,
IntProperty,
BoolProperty,
StringProperty,
EnumProperty,
FloatProperty
)
class BPM_UL_bg_list(UIList):
# order_by_distance : BoolProperty(default=True)
show_items : EnumProperty(
name="Show Items", description="Filter items to show, GP objects, Background or all",
default='all', options={'HIDDEN'},
items=(
('all', 'All', 'Show Background and Gp object', '', 0),
('obj', 'Gp Objects', 'Show only Gp object', '', 1),
('bg', 'Backgrounds', 'Show only backgrounds', '', 2),
))
#(key, label, descr, id[, icon])
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
# draw_item must handle the three layout types... Usually 'DEFAULT' and 'COMPACT' can share the same code.
# layout.alignment = 'CENTER'
## TODO: Find a better solution to get the collection
## Get Collection from plane name -> problem: prefix "BG_" and suffix
# if not item.plane.get('is_background_holder'):
if not item.plane.name.startswith(PREFIX): # (can check if has a parent using PREFIX)
gp_ob = item.plane
layout.prop(gp_ob, 'hide_viewport', text='', emboss=False, icon='HIDE_OFF')
icon_col = layout.row()
icon_col.label(text='', icon='OUTLINER_OB_GREASEPENCIL') # BLANK1
icon_col.ui_units_x = context.scene.bg_props.ui_list_scale # 1.6
row = layout.row()
row.label(text=gp_ob.name)
if gp_ob.data.users > 1:
row.template_ID(item, "data")
else:
row.label(text='', icon='BLANK1')
row.label(text='', icon='BLANK1')# <- add selection
return
layercol = context.view_layer.layer_collection.children['Background'].children.get(fn.clean_image_name(item.plane.name[len(PREFIX):]))
if not layercol:
layout.label(text=f'{item.plane.name} (problem with name)', icon='ERROR')
return
# icon = 'HIDE_ON' if layercol.exclude else 'HIDE_OFF'
layout.prop(layercol, 'exclude', text='', emboss=False, icon='HIDE_OFF')
if not item.plane.children:
layout.label(text=f'{item.plane.name} (No children)', icon='ERROR')
## Image preview
image = fn.get_image(item.plane.children[0])
# layout.label(icon_value=image.preview_ensure().icon_id)
# layout.template_icon(icon_value=image.preview_ensure().icon_id)
icon_col = layout.row()
icon_col.template_icon(icon_value=image.preview_ensure().icon_id, scale=1.0)
icon_col.ui_units_x = context.scene.bg_props.ui_list_scale # 1.6
## Name
row = layout.row()
row.enabled = not layercol.exclude
# row.label(text=item.plane.name) # <- Object has BG_ prefix, trim or use collection name
row.label(text=layercol.name)
if context.scene.bg_props.show_distance:# and not layercol.exclude:
row = layout.row()
# row.enabled = not layercol.exclude
row.prop(item.plane, '["distance"]', text='')
# layout.prop(item.plane, 'location', index=2, text='') # , emboss=False
ob = context.object
if ob and ob.parent == item.plane:
layout.label(text='', icon='DECORATE_LINKED') # select from prop group
else:
layout.label(text='', icon='BLANK1') # select from prop group
if not layercol.exclude:
icon = 'LAYER_ACTIVE' if item.plane.select_get() else 'LAYER_USED'
layout.prop(item, 'select', icon=icon, text='', emboss=False) # select from prop group
else:
layout.label(text='', icon='BLANK1') # select from prop group
## note
# You should always start your row layout by a label (icon + text), or a non-embossed text field,
# this will also make the row easily selectable in the list! The later also enables ctrl-click rename.
# We use icon_value of label, as our given icon is an integer value, not an enum ID.
# Note "data" names should never be translated!
def draw_filter(self, context, layout):
row = layout.row()
subrow = row.row(align=True)
subrow.prop(self, "filter_name", text="") # Only show items matching this name (use * as wildcard)
# invert result
# icon = 'ZOOM_OUT' if self.use_filter_invert else 'ZOOM_IN'
# subrow.prop(self, "use_filter_invert", text="", icon=icon)
# sort by name : ALPHA SORTING NOT WORKING, MUST CHANGE IN filter_items
# subrow.prop(self, "use_filter_sort_alpha", text="", icon='SORTALPHA') # buit-in sort
subrow.prop(self, "show_items", text="") # type enum filter # icon='DOWNARROW_HLT'
# reverse order
icon = 'SORT_DESC' if self.use_filter_sort_reverse else 'SORT_ASC'
subrow.prop(self, "use_filter_sort_reverse", text="", icon=icon) # built-in reverse
def filter_items(self, context, data, propname):
helpers = bpy.types.UI_UL_list
items = getattr(data, propname)
filtered = []
# ordered = [items[:].index(i) for i in sorted(items[:], key=lambda o: o.plane.location.z)]
items = [(items[:].index(i), i) for i in items[:]]
# needed ?
filtered = [self.bitflag_filter_item] * len(items)
## Filter out out items thata
for i, item in items:
## GP/BG Type filter
if self.show_items != 'all':
if self.show_items != item.type:
filtered[i] &= ~self.bitflag_filter_item
## Search filter
if item.plane and self.filter_name.lower() not in item.plane.name.lower():
filtered[i] &= ~self.bitflag_filter_item
# if self.order_by_distance:
cam = context.scene.objects.get('bg_cam') or context.scene.camera
if not cam:
return filtered, ordered
## Real Distance from bg_cam or active camera
ordered = helpers.sort_items_helper(items, lambda o: (o[1].plane.matrix_world.to_translation() - cam.matrix_world.to_translation()).length)
## By distance attribute (only Backgrounds have distance attr)
# ordered = helpers.sort_items_helper(items, lambda o: o[1].plane.get('distance'))
return filtered, ordered
'''
class BPM_UL_bg_list(UIList):
# order_by_distance : BoolProperty(default=True)
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
# draw_item must handle the three layout types... Usually 'DEFAULT' and 'COMPACT' can share the same code.
# layout.alignment = 'CENTER'
## TODO: Find a better solution to get the collection
## Get Collection from plane name -> problem: prefix "BG_" and suffix
layercol = context.view_layer.layer_collection.children['Background'].children.get(fn.clean_image_name(item.plane.name[len(PREFIX):]))
if not layercol:
layout.label(text=f'{item.plane.name} (problem with name)', icon='ERROR')
return
# icon = 'HIDE_ON' if layercol.exclude else 'HIDE_OFF'
layout.prop(layercol, 'exclude', text='', emboss=False, icon='HIDE_OFF')
if not item.plane.children:
layout.label(text=f'{item.plane.name} (No children)', icon='ERROR')
## Image preview
image = fn.get_image(item.plane.children[0])
# layout.label(icon_value=image.preview_ensure().icon_id)
# layout.template_icon(icon_value=image.preview_ensure().icon_id)
icon_col = layout.row()
icon_col.template_icon(icon_value=image.preview_ensure().icon_id, scale=1.0)
icon_col.ui_units_x = context.scene.bg_props.ui_list_scale # 1.6
## Name
row = layout.row()
row.enabled = not layercol.exclude
# row.label(text=item.plane.name) # <- Object has BG_ prefix, trim or use collection name
row.label(text=layercol.name)
ob = context.object
if ob and ob.parent == item.plane:
layout.label(text='', icon='DECORATE_LINKED') # select from prop group
else:
layout.label(text='', icon='BLANK1') # select from prop group
if not layercol.exclude:
icon = 'LAYER_ACTIVE' if item.plane.select_get() else 'LAYER_USED'
layout.prop(item, 'select', icon=icon, text='', emboss=False) # select from prop group
else:
layout.label(text='', icon='BLANK1') # select from prop group
if context.scene.bg_props.show_distance:# and not layercol.exclude:
row = layout.row()
# row.enabled = not layercol.exclude
row.prop(item.plane, '["distance"]', text='')
# layout.prop(item.plane, 'location', index=2, text='') # , emboss=False
## note
# You should always start your row layout by a label (icon + text), or a non-embossed text field,
# this will also make the row easily selectable in the list! The later also enables ctrl-click rename.
# We use icon_value of label, as our given icon is an integer value, not an enum ID.
# Note "data" names should never be translated!
def filter_items(self, context, data, propname):
helpers = bpy.types.UI_UL_list
items = getattr(data, propname)
filtered = []
# ordered = [items[:].index(i) for i in sorted(items[:], key=lambda o: o.plane.location.z)]
items = [(items[:].index(i), i) for i in items[:]]
# needed ?
filtered = [self.bitflag_filter_item] * len(items)
for i, item in items:
if item.plane and self.filter_name.lower() not in item.plane.name.lower():
filtered[i] &= ~self.bitflag_filter_item
#else:
# if self.order_by_distance:
ordered = helpers.sort_items_helper(items, lambda o: o[1].plane.get('distance'))
return filtered, ordered
# def draw_filter(self, context, layout):
# """UI code for the filtering/sorting/search area."""
# col = layout.column(align=True)
# row = col.row(align=True)
# # row.prop(self, 'order_by_distance', text='', icon='DRIVER_DISTANCE')
# # row.prop(self, 'use_filter_invert', text='', icon='ARROW_LEFTRIGHT')
'''
### --- updates
def get_plane_targets(context):
selection = [i.plane for i in context.scene.bg_props.planes if i.plane.select_get()]
if selection:
return selection
## active (even not selected)
return [context.scene.bg_props.planes[context.scene.bg_props.index].plane]
def update_opacity(self, context):
pool = get_plane_targets(context)
for ob in pool:
if not ob or not ob.children:
continue
fn.set_opacity(ob.children[0], opacity=context.scene.bg_props.opacity)
def update_on_index_change(self, context):
# print('index change:', context.scene.bg_props.index)
props = context.scene.bg_props
planes_list = props.planes
item = planes_list[props.index]
if not item.plane:
## remove slot !
print(f'bg_props.planes: No plane/object at index {props.index}, removing item')
planes_list.remove(props.index)
return
if item.type == 'bg':
plane = item.plane
if not plane.children:
return
opacity = fn.get_opacity(plane.children[0])
if not opacity:
return
props['opacity'] = opacity
elif item.type == 'obj':
fn.gp_transfer_mode(item.plane, context)
def update_select(self, context):
# print('index change:', context.scene.bg_props.index)
plane = self.plane
plane.select_set(not plane.select_get())
context.scene.bg_props['index'] = context.scene.bg_props.planes[:].index(self)
class BPM_more_option(Menu):
bl_idname = "BPM_MT_more_option"
bl_label = "Options"
def draw(self, context):
layout = self.layout
layout.operator("bpm.reload_list", text="Refresh", icon="FILE_REFRESH")
layout.operator("bpm.open_bg_folder", icon="FILE_FOLDER")
layout.operator("bpm.import_bg_images", text="Import Planes", icon="IMPORT")
layout.operator("bpm.convert_planes", text="Convert Planes", icon="OUTLINER_OB_IMAGE")
layout.separator()
layout.prop(context.scene.bg_props, 'show_distance', icon='DRIVER_DISTANCE')
layout.prop(context.scene.bg_props, 'ui_list_scale')
# Create sniptool property group
class BPM_bg_list_prop(PropertyGroup):
plane : PointerProperty(type=bpy.types.Object)
select: BoolProperty(update=update_select) # use and update to set the plane selection
type: StringProperty(default='bg')
# is_bg: BoolProperty(default=True)
class BPM_bg_settings(PropertyGroup):
index : IntProperty(update=update_on_index_change)
planes : bpy.props.CollectionProperty(type=BPM_bg_list_prop)
show_distance : BoolProperty(name='Show Distance', default=False)
opacity : FloatProperty(name='Opacity', default=1.0, min=0.0, max=1.0, update=update_opacity)
move_hided : BoolProperty(name='Move hided', default=True)
ui_list_scale : FloatProperty(name='UI Item Y Scale', default=1.6, min=0.6, soft_min=1.0, max=2.0)
# distance : FloatProperty(name='Distance', default=0.0, update=update_distance)
### --- REGISTER ---
classes=(
# prop and UIlist
BPM_bg_list_prop,
BPM_bg_settings,
BPM_UL_bg_list,
BPM_more_option,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.bg_props = bpy.props.PointerProperty(type=BPM_bg_settings)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
del bpy.types.Scene.bg_props