diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6b11b4d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + + +0.3.0 + +- initial commit \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0029184 --- /dev/null +++ b/LICENSE @@ -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. + + , 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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..195e288 --- /dev/null +++ b/__init__.py @@ -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() diff --git a/auto_modules.py b/auto_modules.py new file mode 100644 index 0000000..b5c7dc1 --- /dev/null +++ b/auto_modules.py @@ -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') +""" + \ No newline at end of file diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..bcdb948 --- /dev/null +++ b/constants.py @@ -0,0 +1,6 @@ +from pathlib import Path + +PREFIX = 'BG_' +INIT_POS = 9.0 +MODULE_DIR = Path(__file__).parent +BGCOL = 'Background' \ No newline at end of file diff --git a/export_psd_layers.py b/export_psd_layers.py new file mode 100644 index 0000000..2a1ddd4 --- /dev/null +++ b/export_psd_layers.py @@ -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) diff --git a/file_utils.py b/file_utils.py new file mode 100644 index 0000000..a1b998c --- /dev/null +++ b/file_utils.py @@ -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) \ No newline at end of file diff --git a/fn.py b/fn.py new file mode 100644 index 0000000..41e7fad --- /dev/null +++ b/fn.py @@ -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 \ No newline at end of file diff --git a/operators/__init__.py b/operators/__init__.py new file mode 100644 index 0000000..9118b33 --- /dev/null +++ b/operators/__init__.py @@ -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() \ No newline at end of file diff --git a/operators/convert_planes.py b/operators/convert_planes.py new file mode 100644 index 0000000..dce56d3 --- /dev/null +++ b/operators/convert_planes.py @@ -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) diff --git a/operators/import_planes.py b/operators/import_planes.py new file mode 100644 index 0000000..ea061ab --- /dev/null +++ b/operators/import_planes.py @@ -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) \ No newline at end of file diff --git a/operators/manage_objects.py b/operators/manage_objects.py new file mode 100644 index 0000000..f7e29db --- /dev/null +++ b/operators/manage_objects.py @@ -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) diff --git a/operators/manage_planes.py b/operators/manage_planes.py new file mode 100644 index 0000000..676f681 --- /dev/null +++ b/operators/manage_planes.py @@ -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) diff --git a/preferences.py b/preferences.py new file mode 100644 index 0000000..a771b39 --- /dev/null +++ b/preferences.py @@ -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) \ No newline at end of file diff --git a/texture_plane.blend b/texture_plane.blend new file mode 100644 index 0000000..4a2d3bc Binary files /dev/null and b/texture_plane.blend differ diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..ab89266 --- /dev/null +++ b/ui/__init__.py @@ -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() \ No newline at end of file diff --git a/ui/panels.py b/ui/panels.py new file mode 100644 index 0000000..401f946 --- /dev/null +++ b/ui/panels.py @@ -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) diff --git a/ui/ui_list.py b/ui/ui_list.py new file mode 100644 index 0000000..72f11a2 --- /dev/null +++ b/ui/ui_list.py @@ -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