diff --git a/CHANGELOG.md b/CHANGELOG.md index b70f3f98f4..09882896f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## [2.18.0](https://github.com/pypeclub/openpype/tree/2.18.0) (2021-05-18) + +[Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.3...2.18.0) + +**Enhancements:** + +- Use SubsetLoader and multiple contexts for delete_old_versions [\#1484](ttps://github.com/pypeclub/OpenPype/pull/1484)) +- TVPaint: Increment workfile version on successfull publish. [\#1489](https://github.com/pypeclub/OpenPype/pull/1489) +- Maya: Use of multiple deadline servers [\#1483](https://github.com/pypeclub/OpenPype/pull/1483) + +**Fixed bugs:** + +- Use instance frame start instead of timeline. [\#1486](https://github.com/pypeclub/OpenPype/pull/1486) +- Maya: Redshift - set proper start frame on proxy [\#1480](https://github.com/pypeclub/OpenPype/pull/1480) +- Maya: wrong collection of playblasted frames [\#1517](https://github.com/pypeclub/OpenPype/pull/1517) +- Existing subsets hints in creator [\#1502](https://github.com/pypeclub/OpenPype/pull/1502) + +## [2.17.3](https://github.com/pypeclub/openpype/tree/2.17.3) (2021-05-06) + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.3...2.17.3) + +**Fixed bugs:** + +- Nuke: workfile version synced to db version always [\#1479](https://github.com/pypeclub/OpenPype/pull/1479) + +## [2.17.2](https://github.com/pypeclub/openpype/tree/2.17.2) (2021-05-04) + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.1...2.17.2) + +**Enhancements:** + +- Forward/Backward compatible apps and tools with OpenPype 3 [\#1463](https://github.com/pypeclub/OpenPype/pull/1463) ## [2.17.1](https://github.com/pypeclub/openpype/tree/2.17.1) (2021-04-30) @@ -7,28 +39,30 @@ **Enhancements:** +- Nuke: deadline submission with gpu [\#1414](https://github.com/pypeclub/OpenPype/pull/1414) - TVPaint frame range definition [\#1424](https://github.com/pypeclub/OpenPype/pull/1424) - PS - group all published instances [\#1415](https://github.com/pypeclub/OpenPype/pull/1415) -- Nuke: deadline submission with gpu [\#1414](https://github.com/pypeclub/OpenPype/pull/1414) - Add task name to context pop up. [\#1383](https://github.com/pypeclub/OpenPype/pull/1383) -- AE add duration validation [\#1363](https://github.com/pypeclub/OpenPype/pull/1363) -- Maya: Support for Redshift proxies [\#1360](https://github.com/pypeclub/OpenPype/pull/1360) +- Enhance review letterbox feature. [\#1371](https://github.com/pypeclub/OpenPype/pull/1371) **Fixed bugs:** -- Nuke: fixing undo for loaded mov and sequence [\#1433](https://github.com/pypeclub/OpenPype/pull/1433) -- AE - validation for duration was 1 frame shorter [\#1426](https://github.com/pypeclub/OpenPype/pull/1426) - Houdini menu filename [\#1417](https://github.com/pypeclub/OpenPype/pull/1417) -- Maya: Vray - problem getting all file nodes for look publishing [\#1399](https://github.com/pypeclub/OpenPype/pull/1399) +- AE - validation for duration was 1 frame shorter [\#1426](https://github.com/pypeclub/OpenPype/pull/1426) +**Merged pull requests:** + +- Maya: Vray - problem getting all file nodes for look publishing [\#1399](https://github.com/pypeclub/OpenPype/pull/1399) +- Maya: Support for Redshift proxies [\#1360](https://github.com/pypeclub/OpenPype/pull/1360) ## [2.17.0](https://github.com/pypeclub/openpype/tree/2.17.0) (2021-04-20) -[Full Changelog](https://github.com/pypeclub/openpype/compare/3.0.0-beta2...2.17.0) +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-beta.2...2.17.0) **Enhancements:** - Forward compatible ftrack group [\#1243](https://github.com/pypeclub/OpenPype/pull/1243) +- Settings in mongo as dict [\#1221](https://github.com/pypeclub/OpenPype/pull/1221) - Maya: Make tx option configurable with presets [\#1328](https://github.com/pypeclub/OpenPype/pull/1328) - TVPaint asset name validation [\#1302](https://github.com/pypeclub/OpenPype/pull/1302) - TV Paint: Set initial project settings. [\#1299](https://github.com/pypeclub/OpenPype/pull/1299) @@ -56,35 +90,6 @@ - Nuke: reverse search to make it more versatile [\#1178](https://github.com/pypeclub/OpenPype/pull/1178) -## [2.16.1](https://github.com/pypeclub/pype/tree/2.16.1) (2021-04-13) - -[Full Changelog](https://github.com/pypeclub/pype/compare/2.16.0...2.16.1) - -**Enhancements:** - -- Nuke: comp renders mix up [\#1301](https://github.com/pypeclub/pype/pull/1301) -- Validate project settings [\#1297](https://github.com/pypeclub/pype/pull/1297) -- After Effects: added SubsetManager [\#1234](https://github.com/pypeclub/pype/pull/1234) - -**Fixed bugs:** - -- Ftrack custom attributes in bulks [\#1312](https://github.com/pypeclub/pype/pull/1312) -- Ftrack optional pypclub role [\#1303](https://github.com/pypeclub/pype/pull/1303) -- AE remove orphaned instance from workfile - fix self.stub [\#1282](https://github.com/pypeclub/pype/pull/1282) -- Avalon schema names [\#1242](https://github.com/pypeclub/pype/pull/1242) -- Handle duplication of Task name [\#1226](https://github.com/pypeclub/pype/pull/1226) -- Modified path of plugin loads for Harmony and TVPaint [\#1217](https://github.com/pypeclub/pype/pull/1217) -- Regex checks in profiles filtering [\#1214](https://github.com/pypeclub/pype/pull/1214) -- Bulk mov strict task [\#1204](https://github.com/pypeclub/pype/pull/1204) -- Update custom ftrack session attributes [\#1202](https://github.com/pypeclub/pype/pull/1202) -- Nuke: write node colorspace ignore `default\(\)` label [\#1199](https://github.com/pypeclub/pype/pull/1199) -- Nuke: reverse search to make it more versatile [\#1178](https://github.com/pypeclub/pype/pull/1178) - -**Merged pull requests:** - -- Forward compatible ftrack group [\#1243](https://github.com/pypeclub/pype/pull/1243) -- Error message in pyblish UI [\#1206](https://github.com/pypeclub/pype/pull/1206) -- Nuke: deadline submission with search replaced env values from preset [\#1194](https://github.com/pypeclub/pype/pull/1194) ## [2.16.0](https://github.com/pypeclub/pype/tree/2.16.0) (2021-03-22) @@ -1145,4 +1150,7 @@ A large cleanup release. Most of the change are under the hood. \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* +\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* + + \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* diff --git a/openpype/cli.py b/openpype/cli.py index 9c49825721..df38c74a21 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -224,6 +224,11 @@ def launch(app, project, asset, task, PypeCommands().run_application(app, project, asset, task, tools, arguments) +@main.command(context_settings={"ignore_unknown_options": True}) +def projectmanager(): + PypeCommands().launch_project_manager() + + @main.command( context_settings=dict( ignore_unknown_options=True, diff --git a/openpype/hosts/aftereffects/__init__.py b/openpype/hosts/aftereffects/__init__.py index e69de29bb2..deae48d122 100644 --- a/openpype/hosts/aftereffects/__init__.py +++ b/openpype/hosts/aftereffects/__init__.py @@ -0,0 +1,9 @@ +def add_implementation_envs(env, _app): + """Modify environments to contain all required for implementation.""" + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True", + "WEBSOCKET_URL": "ws://localhost:8097/ws/" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/blender/__init__.py b/openpype/hosts/blender/__init__.py index e69de29bb2..747394aad0 100644 --- a/openpype/hosts/blender/__init__.py +++ b/openpype/hosts/blender/__init__.py @@ -0,0 +1,55 @@ +import os + + +def add_implementation_envs(env, _app): + """Modify environments to contain all required for implementation.""" + # Prepare path to implementation script + implementation_user_script_path = os.path.join( + os.environ["OPENPYPE_REPOS_ROOT"], + "repos", + "avalon-core", + "setup", + "blender" + ) + + # Add blender implementation script path to PYTHONPATH + python_path = env.get("PYTHONPATH") or "" + python_path_parts = [ + path + for path in python_path.split(os.pathsep) + if path + ] + python_path_parts.insert(0, implementation_user_script_path) + env["PYTHONPATH"] = os.pathsep.join(python_path_parts) + + # Modify Blender user scripts path + previous_user_scripts = set() + # Implementation path is added to set for easier paths check inside loops + # - will be removed at the end + previous_user_scripts.add(implementation_user_script_path) + + openpype_blender_user_scripts = ( + env.get("OPENPYPE_BLENDER_USER_SCRIPTS") or "" + ) + for path in openpype_blender_user_scripts.split(os.pathsep): + if path and os.path.exists(path): + previous_user_scripts.add(os.path.normpath(path)) + + blender_user_scripts = env.get("BLENDER_USER_SCRIPTS") or "" + for path in blender_user_scripts.split(os.pathsep): + if path and os.path.exists(path): + previous_user_scripts.add(os.path.normpath(path)) + + # Remove implementation path from user script paths as is set to + # `BLENDER_USER_SCRIPTS` + previous_user_scripts.remove(implementation_user_script_path) + env["BLENDER_USER_SCRIPTS"] = implementation_user_script_path + + # Set custom user scripts env + env["OPENPYPE_BLENDER_USER_SCRIPTS"] = os.pathsep.join( + previous_user_scripts + ) + + # Define Qt binding if not defined + if not env.get("QT_PREFERRED_BINDING"): + env["QT_PREFERRED_BINDING"] = "PySide2" diff --git a/openpype/hosts/blender/api/__init__.py b/openpype/hosts/blender/api/__init__.py index 66102a2ae1..ecf4fdf4da 100644 --- a/openpype/hosts/blender/api/__init__.py +++ b/openpype/hosts/blender/api/__init__.py @@ -4,6 +4,8 @@ import traceback import bpy +from .lib import append_user_scripts + from avalon import api as avalon from pyblish import api as pyblish @@ -29,7 +31,7 @@ def install(): pyblish.register_plugin_path(str(PUBLISH_PATH)) avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH)) avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH)) - + append_user_scripts() avalon.on("new", on_new) avalon.on("open", on_open) diff --git a/openpype/hosts/blender/api/lib.py b/openpype/hosts/blender/api/lib.py new file mode 100644 index 0000000000..fe5d3f93e9 --- /dev/null +++ b/openpype/hosts/blender/api/lib.py @@ -0,0 +1,127 @@ +import os +import traceback +import importlib + +import bpy +import addon_utils + + +def load_scripts(paths): + """Copy of `load_scripts` from Blender's implementation. + + It is possible that whis function will be changed in future and usage will + be based on Blender version. + """ + import bpy_types + + loaded_modules = set() + + previous_classes = [ + cls + for cls in bpy.types.bpy_struct.__subclasses__() + ] + + def register_module_call(mod): + register = getattr(mod, "register", None) + if register: + try: + register() + except: + traceback.print_exc() + else: + print("\nWarning! '%s' has no register function, " + "this is now a requirement for registerable scripts" % + mod.__file__) + + def unregister_module_call(mod): + unregister = getattr(mod, "unregister", None) + if unregister: + try: + unregister() + except: + traceback.print_exc() + + def test_reload(mod): + # reloading this causes internal errors + # because the classes from this module are stored internally + # possibly to refresh internal references too but for now, best not to. + if mod == bpy_types: + return mod + + try: + return importlib.reload(mod) + except: + traceback.print_exc() + + def test_register(mod): + if mod: + register_module_call(mod) + bpy.utils._global_loaded_modules.append(mod.__name__) + + from bpy_restrict_state import RestrictBlend + + with RestrictBlend(): + for base_path in paths: + for path_subdir in bpy.utils._script_module_dirs: + path = os.path.join(base_path, path_subdir) + if not os.path.isdir(path): + continue + + bpy.utils._sys_path_ensure_prepend(path) + + # Only add to 'sys.modules' unless this is 'startup'. + if path_subdir != "startup": + continue + for mod in bpy.utils.modules_from_path(path, loaded_modules): + test_register(mod) + + addons_paths = [] + for base_path in paths: + addons_path = os.path.join(base_path, "addons") + if not os.path.exists(addons_path): + continue + addons_paths.append(addons_path) + addons_module_path = os.path.join(addons_path, "modules") + if os.path.exists(addons_module_path): + bpy.utils._sys_path_ensure_prepend(addons_module_path) + + if addons_paths: + # Fake addons + origin_paths = addon_utils.paths + + def new_paths(): + paths = origin_paths() + addons_paths + return paths + + addon_utils.paths = new_paths + addon_utils.modules_refresh() + + # load template (if set) + if any(bpy.utils.app_template_paths()): + import bl_app_template_utils + bl_app_template_utils.reset(reload_scripts=False) + del bl_app_template_utils + + for cls in bpy.types.bpy_struct.__subclasses__(): + if cls in previous_classes: + continue + if not getattr(cls, "is_registered", False): + continue + for subcls in cls.__subclasses__(): + if not subcls.is_registered: + print( + "Warning, unregistered class: %s(%s)" % + (subcls.__name__, cls.__name__) + ) + + +def append_user_scripts(): + user_scripts = os.environ.get("OPENPYPE_BLENDER_USER_SCRIPTS") + if not user_scripts: + return + + try: + load_scripts(user_scripts.split(os.pathsep)) + except Exception: + print("Couldn't load user scripts \"{}\"".format(user_scripts)) + traceback.print_exc() diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index f72e364f50..5404cec587 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -20,21 +20,9 @@ class CreateLayout(openpype.hosts.blender.api.plugin.Creator): asset = self.data["asset"] subset = self.data["subset"] name = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - collection = bpy.data.collections.new(name=name) - bpy.context.scene.collection.children.link(collection) + collection = bpy.context.collection + collection.name = name self.data['task'] = api.Session.get('AVALON_TASK') lib.imprint(collection, self.data) - # Add the rig object and all the children meshes to - # a set and link them all at the end to avoid duplicates. - # Blender crashes if trying to link an object that is already linked. - # This links automatically the children meshes if they were not - # selected, and doesn't link them twice if they, insted, - # were manually selected by the user. - objects_to_link = set() - - if (self.options or {}).get("useSelection"): - for obj in lib.get_selection(): - collection.children.link(obj.users_collection[0]) - return collection diff --git a/openpype/hosts/blender/plugins/load/load_layout.py b/openpype/hosts/blender/plugins/load/load_layout.py index f1f2fdcddd..87ef9670a6 100644 --- a/openpype/hosts/blender/plugins/load/load_layout.py +++ b/openpype/hosts/blender/plugins/load/load_layout.py @@ -367,13 +367,13 @@ class UnrealLayoutLoader(plugin.AssetLoader): # Y axis mirrored obj.location = ( location.get('x'), - -location.get('y'), + location.get('y'), location.get('z') ) obj.rotation_euler = ( - rotation.get('x') + math.pi / 2, - -rotation.get('y'), - -rotation.get('z') + rotation.get('x'), + rotation.get('y'), + rotation.get('z') ) obj.scale = ( scale.get('x'), diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index d645bedfcc..35a241b98e 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -108,19 +108,21 @@ class BlendModelLoader(plugin.AssetLoader): self.__class__.__name__, ) - container_metadata = container.get( - blender.pipeline.AVALON_PROPERTY) + metadata = container.get(blender.pipeline.AVALON_PROPERTY) - container_metadata["libpath"] = libpath - container_metadata["lib_container"] = lib_container + metadata["libpath"] = libpath + metadata["lib_container"] = lib_container obj_container = self._process( libpath, lib_container, container_name, None) - container_metadata["obj_container"] = obj_container + metadata["obj_container"] = obj_container # Save the list of objects in the metadata container - container_metadata["objects"] = obj_container.all_objects + metadata["objects"] = obj_container.all_objects + + metadata["parent"] = str(context["representation"]["parent"]) + metadata["family"] = context["representation"]["context"]["family"] nodes = list(container.objects) nodes.append(container) diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index c5690a6ab8..9035458c12 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -155,18 +155,20 @@ class BlendRigLoader(plugin.AssetLoader): self.__class__.__name__, ) - container_metadata = container.get( - blender.pipeline.AVALON_PROPERTY) + metadata = container.get(blender.pipeline.AVALON_PROPERTY) - container_metadata["libpath"] = libpath - container_metadata["lib_container"] = lib_container + metadata["libpath"] = libpath + metadata["lib_container"] = lib_container obj_container = self._process( libpath, lib_container, collection_name, None, None) - container_metadata["obj_container"] = obj_container + metadata["obj_container"] = obj_container # Save the list of objects in the metadata container - container_metadata["objects"] = obj_container.all_objects + metadata["objects"] = obj_container.all_objects + + metadata["parent"] = str(context["representation"]["parent"]) + metadata["family"] = context["representation"]["context"]["family"] nodes = list(container.objects) nodes.append(container) diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py new file mode 100644 index 0000000000..c6c9bf67f5 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/extract_layout.py @@ -0,0 +1,92 @@ +import os +import json + +import bpy + +from avalon import blender, io +import openpype.api + + +class ExtractLayout(openpype.api.Extractor): + """Extract a layout.""" + + label = "Extract Layout" + hosts = ["blender"] + families = ["layout"] + optional = True + + def process(self, instance): + # Define extract output file path + stagingdir = self.staging_dir(instance) + + # Perform extraction + self.log.info("Performing extraction..") + + json_data = [] + + for collection in instance: + for asset in collection.children: + collection = bpy.data.collections[asset.name] + container = bpy.data.collections[asset.name + '_CON'] + metadata = container.get(blender.pipeline.AVALON_PROPERTY) + + parent = metadata["parent"] + family = metadata["family"] + + self.log.debug("Parent: {}".format(parent)) + blend = io.find_one( + { + "type": "representation", + "parent": io.ObjectId(parent), + "name": "blend" + }, + projection={"_id": True}) + blend_id = blend["_id"] + + json_element = {} + json_element["reference"] = str(blend_id) + json_element["family"] = family + json_element["instance_name"] = asset.name + json_element["asset_name"] = metadata["lib_container"] + json_element["file_path"] = metadata["libpath"] + + obj = collection.objects[0] + + json_element["transform"] = { + "translation": { + "x": obj.location.x, + "y": obj.location.y, + "z": obj.location.z + }, + "rotation": { + "x": obj.rotation_euler.x, + "y": obj.rotation_euler.y, + "z": obj.rotation_euler.z, + }, + "scale": { + "x": obj.scale.x, + "y": obj.scale.y, + "z": obj.scale.z + } + } + json_data.append(json_element) + + json_filename = "{}.json".format(instance.name) + json_path = os.path.join(stagingdir, json_filename) + + with open(json_path, "w+") as file: + json.dump(json_data, fp=file, indent=2) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'json', + 'ext': 'json', + 'files': json_filename, + "stagingDir": stagingdir, + } + instance.data["representations"].append(representation) + + self.log.info("Extracted instance '%s' to: %s", + instance.name, representation) diff --git a/openpype/hosts/harmony/__init__.py b/openpype/hosts/harmony/__init__.py index e69de29bb2..8560fbaf4b 100644 --- a/openpype/hosts/harmony/__init__.py +++ b/openpype/hosts/harmony/__init__.py @@ -0,0 +1,10 @@ +import os + + +def add_implementation_envs(env, _app): + """Modify environments to contain all required for implementation.""" + openharmony_path = os.path.join( + os.environ["OPENPYPE_REPOS_ROOT"], "pype", "vendor", "OpenHarmony" + ) + # TODO check if is already set? What to do if is already set? + env["LIB_OPENHARMONY_PATH"] = openharmony_path diff --git a/openpype/hosts/hiero/__init__.py b/openpype/hosts/hiero/__init__.py index e69de29bb2..1781f808e2 100644 --- a/openpype/hosts/hiero/__init__.py +++ b/openpype/hosts/hiero/__init__.py @@ -0,0 +1,40 @@ +import os +import platform + + +def add_implementation_envs(env, _app): + # Add requirements to HIERO_PLUGIN_PATH + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + new_hiero_paths = [ + os.path.join(pype_root, "openpype", "hosts", "hiero", "startup") + ] + old_hiero_path = env.get("HIERO_PLUGIN_PATH") or "" + for path in old_hiero_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_hiero_paths: + new_hiero_paths.append(norm_path) + + env["HIERO_PLUGIN_PATH"] = os.pathsep.join(new_hiero_paths) + + # Try to add QuickTime to PATH + quick_time_path = "C:/Program Files (x86)/QuickTime/QTSystem" + if platform.system() == "windows" and os.path.exists(quick_time_path): + path_value = env.get("PATH") or "" + path_paths = [ + path + for path in path_value.split(os.pathsep) + if path + ] + path_paths.append(quick_time_path) + env["PATH"] = os.pathsep.join(path_paths) + + # Set default values if are not already set via settings + defaults = { + "LOGLEVEL": "DEBUG" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/houdini/__init__.py b/openpype/hosts/houdini/__init__.py index e69de29bb2..8c12d13c81 100644 --- a/openpype/hosts/houdini/__init__.py +++ b/openpype/hosts/houdini/__init__.py @@ -0,0 +1,38 @@ +import os + + +def add_implementation_envs(env, _app): + # Add requirements to HOUDINI_PATH and HOUDINI_MENU_PATH + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + + startup_path = os.path.join( + pype_root, "openpype", "hosts", "houdini", "startup" + ) + new_houdini_path = [startup_path] + new_houdini_menu_path = [startup_path] + + old_houdini_path = env.get("HOUDINI_PATH") or "" + old_houdini_menu_path = env.get("HOUDINI_MENU_PATH") or "" + + for path in old_houdini_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_houdini_path: + new_houdini_path.append(norm_path) + + for path in old_houdini_menu_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_houdini_menu_path: + new_houdini_menu_path.append(norm_path) + + # Add ampersand for unknown reason (Maybe is needed in Houdini?) + new_houdini_path.append("&") + new_houdini_menu_path.append("&") + + env["HOUDINI_PATH"] = os.pathsep.join(new_houdini_path) + env["HOUDINI_MENU_PATH"] = os.pathsep.join(new_houdini_menu_path) diff --git a/openpype/hosts/maya/__init__.py b/openpype/hosts/maya/__init__.py index e69de29bb2..549f100007 100644 --- a/openpype/hosts/maya/__init__.py +++ b/openpype/hosts/maya/__init__.py @@ -0,0 +1,29 @@ +import os + + +def add_implementation_envs(env, _app): + # Add requirements to PYTHONPATH + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + new_python_paths = [ + os.path.join(pype_root, "openpype", "hosts", "maya", "startup"), + os.path.join(pype_root, "repos", "avalon-core", "setup", "maya"), + os.path.join(pype_root, "tools", "mayalookassigner") + ] + old_python_path = env.get("PYTHONPATH") or "" + for path in old_python_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_python_paths: + new_python_paths.append(norm_path) + + env["PYTHONPATH"] = os.pathsep.join(new_python_paths) + + # Set default values if are not already set via settings + defaults = { + "OPENPYPE_LOG_NO_COLORS": "Yes" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/nuke/__init__.py b/openpype/hosts/nuke/__init__.py index e69de29bb2..f1e81617e0 100644 --- a/openpype/hosts/nuke/__init__.py +++ b/openpype/hosts/nuke/__init__.py @@ -0,0 +1,43 @@ +import os +import platform + + +def add_implementation_envs(env, _app): + # Add requirements to NUKE_PATH + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + new_nuke_paths = [ + os.path.join(pype_root, "openpype", "hosts", "nuke", "startup"), + os.path.join( + pype_root, "repos", "avalon-core", "setup", "nuke", "nuke_path" + ) + ] + old_nuke_path = env.get("NUKE_PATH") or "" + for path in old_nuke_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_nuke_paths: + new_nuke_paths.append(norm_path) + + env["NUKE_PATH"] = os.pathsep.join(new_nuke_paths) + + # Try to add QuickTime to PATH + quick_time_path = "C:/Program Files (x86)/QuickTime/QTSystem" + if platform.system() == "windows" and os.path.exists(quick_time_path): + path_value = env.get("PATH") or "" + path_paths = [ + path + for path in path_value.split(os.pathsep) + if path + ] + path_paths.append(quick_time_path) + env["PATH"] = os.pathsep.join(path_paths) + + # Set default values if are not already set via settings + defaults = { + "LOGLEVEL": "DEBUG" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index ea6476485b..e6dde813a7 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -53,11 +53,10 @@ def get_created_node_imageio_setting(**kwarg): imageio_node = None for node in imageio_nodes: log.info(node) - if (node["nukeNodeClass"] != nodeclass) and ( - creator not in node["plugins"]): - continue - - imageio_node = node + if (nodeclass in node["nukeNodeClass"]) and ( + creator in node["plugins"]): + imageio_node = node + break log.info("ImageIO node: {}".format(imageio_node)) return imageio_node @@ -340,9 +339,9 @@ def create_write_node(name, data, input=None, prenodes=None, review=True): nuke.message(msg) # build file path to workfiles - fpath = str(anatomy_filled["work"]["folder"]).replace("\\", "/") + fdir = str(anatomy_filled["work"]["folder"]).replace("\\", "/") fpath = data["fpath_template"].format( - work=fpath, version=data["version"], subset=data["subset"], + work=fdir, version=data["version"], subset=data["subset"], frame=data["frame"], ext=representation ) diff --git a/openpype/hosts/photoshop/__init__.py b/openpype/hosts/photoshop/__init__.py index e69de29bb2..a91e0a65ff 100644 --- a/openpype/hosts/photoshop/__init__.py +++ b/openpype/hosts/photoshop/__init__.py @@ -0,0 +1,9 @@ +def add_implementation_envs(env, _app): + """Modify environments to contain all required for implementation.""" + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True", + "WEBSOCKET_URL": "ws://localhost:8099/ws/" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index 1df8502959..967a704ccf 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -9,6 +9,7 @@ class CreateImage(openpype.api.Creator): name = "imageDefault" label = "Image" family = "image" + defaults = ["Main"] def process(self): groups = [] @@ -16,7 +17,9 @@ class CreateImage(openpype.api.Creator): create_group = False stub = photoshop.stub() + useSelection = False if (self.options or {}).get("useSelection"): + useSelection = True multiple_instances = False selection = stub.get_selected_layers() self.log.info("selection {}".format(selection)) @@ -61,7 +64,9 @@ class CreateImage(openpype.api.Creator): # No selection creates an empty group. create_group = True else: - create_group = True + stub.select_layers(stub.get_layers()) + group = stub.group_selected_layers(self.name) + groups.append(group) if create_group: group = stub.create_group(self.name) @@ -77,13 +82,19 @@ class CreateImage(openpype.api.Creator): group.name = group.name.replace(stub.PUBLISH_ICON, ''). \ replace(stub.LOADED_ICON, '') + if useSelection: + subset_name = self.data["subset"] + group.name + else: + # use value provided by user from Creator + subset_name = self.data["subset"] + if group.long_name: for directory in group.long_name[::-1]: name = directory.replace(stub.PUBLISH_ICON, '').\ replace(stub.LOADED_ICON, '') long_names.append(name) - self.data.update({"subset": "image" + group.name}) + self.data.update({"subset": subset_name}) self.data.update({"uuid": str(group.id)}) self.data.update({"long_name": "_".join(long_names)}) stub.imprint(group, self.data) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index b56f128831..87574d1269 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -35,21 +35,16 @@ class ExtractImage(openpype.api.Extractor): if layer.visible and layer.id not in extract_ids: stub.set_visible(layer.id, False) - save_options = [] - if "png" in self.formats: - save_options.append('png') - if "jpg" in self.formats: - save_options.append('jpg') - file_basename = os.path.splitext( stub.get_active_document_name() )[0] - for extension in save_options: + for extension in self.formats: _filename = "{}.{}".format(file_basename, extension) files[extension] = _filename full_filename = os.path.join(staging_dir, _filename) stub.saveAs(full_filename, extension, True) + self.log.info(f"Extracted: {extension}") representations = [] for extension, filename in files.items(): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 3b6d8ef951..b52078fd5f 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -6,7 +6,12 @@ from avalon import photoshop class ExtractReview(openpype.api.Extractor): - """Produce a flattened image file from all instances.""" + """ + Produce a flattened image file from all 'image' instances. + + If no 'image' instance is created, it produces flattened image from + all visible layers. + """ label = "Extract Review" hosts = ["photoshop"] @@ -30,14 +35,15 @@ class ExtractReview(openpype.api.Extractor): ) output_image_path = os.path.join(staging_dir, output_image) with photoshop.maintained_visibility(): - # Hide all other layers. - extract_ids = set([ll.id for ll in stub. - get_layers_in_layers(layers)]) - self.log.info("extract_ids {}".format(extract_ids)) - for layer in stub.get_layers(): - # limit unnecessary calls to client - if layer.visible and layer.id not in extract_ids: - stub.set_visible(layer.id, False) + if layers: + # Hide all other layers. + extract_ids = set([ll.id for ll in stub. + get_layers_in_layers(layers)]) + self.log.debug("extract_ids {}".format(extract_ids)) + for layer in stub.get_layers(): + # limit unnecessary calls to client + if layer.visible and layer.id not in extract_ids: + stub.set_visible(layer.id, False) stub.saveAs(output_image_path, 'jpg', True) diff --git a/openpype/hosts/tvpaint/__init__.py b/openpype/hosts/tvpaint/__init__.py index e69de29bb2..0e793fcf9f 100644 --- a/openpype/hosts/tvpaint/__init__.py +++ b/openpype/hosts/tvpaint/__init__.py @@ -0,0 +1,8 @@ +def add_implementation_envs(env, _app): + """Modify environments to contain all required for implementation.""" + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index e69de29bb2..1280442916 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -0,0 +1,18 @@ +import os + + +def add_implementation_envs(env, _app): + """Modify environments to contain all required for implementation.""" + # Set AVALON_UNREAL_PLUGIN required for Unreal implementation + unreal_plugin_path = os.path.join( + os.environ["OPENPYPE_REPOS_ROOT"], "repos", "avalon-unreal-integration" + ) + env["AVALON_UNREAL_PLUGIN"] = unreal_plugin_path + + # Set default environments if are not set via settings + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/unreal/plugins/publish/extract_layout.py b/openpype/hosts/unreal/plugins/publish/extract_layout.py index 5924221f51..2d9f6eb3d1 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_layout.py +++ b/openpype/hosts/unreal/plugins/publish/extract_layout.py @@ -78,14 +78,14 @@ class ExtractLayout(openpype.api.Extractor): json_element["transform"] = { "translation": { - "x": transform.translation.x, + "x": -transform.translation.x, "y": transform.translation.y, "z": transform.translation.z }, "rotation": { - "x": math.radians(transform.rotation.euler().x), + "x": math.radians(transform.rotation.euler().x + 90.0), "y": math.radians(transform.rotation.euler().y), - "z": math.radians(transform.rotation.euler().z), + "z": math.radians(180.0 - transform.rotation.euler().z) }, "scale": { "x": transform.scale3d.x, diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 838c5aa7a1..c97545fdf4 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -58,6 +58,10 @@ from .python_module_tools import ( ) from .avalon_context import ( + CURRENT_DOC_SCHEMAS, + PROJECT_NAME_ALLOWED_SYMBOLS, + PROJECT_NAME_REGEX, + create_project, is_latest, any_outdated, get_asset, @@ -163,6 +167,10 @@ __all__ = [ "recursive_bases_from_class", "classes_from_module", + "CURRENT_DOC_SCHEMAS", + "PROJECT_NAME_ALLOWED_SYMBOLS", + "PROJECT_NAME_REGEX", + "create_project", "is_latest", "any_outdated", "get_asset", diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index c5c192f51b..a44c43102f 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -3,7 +3,6 @@ import re import copy import json import platform -import getpass import collections import inspect import subprocess @@ -362,7 +361,6 @@ class ApplicationManager: context = ApplicationLaunchContext( app, executable, **data ) - # TODO pass context through launch hooks return context.launch() @@ -626,7 +624,7 @@ class ApplicationLaunchContext: # Logger logger_name = "{}-{}".format(self.__class__.__name__, self.app_name) - self.log = PypeLogger().get_logger(logger_name) + self.log = PypeLogger.get_logger(logger_name) self.executable = executable @@ -1033,7 +1031,7 @@ def _merge_env(env, current_env): return result -def prepare_host_environments(data): +def prepare_host_environments(data, implementation_envs=True): """Modify launch environments based on launched app and context. Args: @@ -1089,7 +1087,24 @@ def prepare_host_environments(data): # Merge dictionaries env_values = _merge_env(tool_env, env_values) - final_env = _merge_env(acre.compute(env_values), data["env"]) + loaded_env = _merge_env(acre.compute(env_values), data["env"]) + + final_env = None + # Add host specific environments + if app.host_name and implementation_envs: + module = __import__("openpype.hosts", fromlist=[app.host_name]) + host_module = getattr(module, app.host_name, None) + add_implementation_envs = None + if host_module: + add_implementation_envs = getattr( + host_module, "add_implementation_envs", None + ) + if add_implementation_envs: + # Function may only modify passed dict without returning value + final_env = add_implementation_envs(loaded_env, app) + + if final_env is None: + final_env = loaded_env # Update env data["env"].update(final_env) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 2d8726352a..2a7c58c4ee 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -17,6 +17,99 @@ avalon = None log = logging.getLogger("AvalonContext") +CURRENT_DOC_SCHEMAS = { + "project": "openpype:project-3.0", + "asset": "openpype:asset-3.0", + "config": "openpype:config-2.0" +} +PROJECT_NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" +PROJECT_NAME_REGEX = re.compile( + "^[{}]+$".format(PROJECT_NAME_ALLOWED_SYMBOLS) +) + + +def create_project( + project_name, project_code, library_project=False, dbcon=None +): + """Create project using OpenPype settings. + + This project creation function is not validating project document on + creation. It is because project document is created blindly with only + minimum required information about project which is it's name, code, type + and schema. + + Entered project name must be unique and project must not exist yet. + + Args: + project_name(str): New project name. Should be unique. + project_code(str): Project's code should be unique too. + library_project(bool): Project is library project. + dbcon(AvalonMongoDB): Object of connection to MongoDB. + + Raises: + ValueError: When project name already exists in MongoDB. + + Returns: + dict: Created project document. + """ + + from openpype.settings import ProjectSettings, SaveWarningExc + from avalon.api import AvalonMongoDB + from avalon.schema import validate + + if dbcon is None: + dbcon = AvalonMongoDB() + + if not PROJECT_NAME_REGEX.match(project_name): + raise ValueError(( + "Project name \"{}\" contain invalid characters" + ).format(project_name)) + + database = dbcon.database + project_doc = database[project_name].find_one( + {"type": "project"}, + {"name": 1} + ) + if project_doc: + raise ValueError("Project with name \"{}\" already exists".format( + project_name + )) + + project_doc = { + "type": "project", + "name": project_name, + "data": { + "code": project_code, + "library_project": library_project + }, + "schema": CURRENT_DOC_SCHEMAS["project"] + } + # Insert document with basic data + database[project_name].insert_one(project_doc) + # Load ProjectSettings for the project and save it to store all attributes + # and Anatomy + try: + project_settings_entity = ProjectSettings(project_name) + project_settings_entity.save() + except SaveWarningExc as exc: + print(str(exc)) + except Exception: + database[project_name].delete_one({"type": "project"}) + raise + + project_doc = database[project_name].find_one({"type": "project"}) + + try: + # Validate created project document + validate(project_doc) + except Exception: + # Remove project if is not valid + database[project_name].delete_one({"type": "project"}) + raise + + return project_doc + + def with_avalon(func): @functools.wraps(func) def wrap_avalon(*args, **kwargs): diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 3bb01798e4..410e51e2a4 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -26,9 +26,7 @@ from openpype.modules.ftrack.lib import ( BaseEvent ) -from openpype.modules.ftrack.lib.avalon_sync import ( - EntitySchemas -) +from openpype.lib import CURRENT_DOC_SCHEMAS class SyncToAvalonEvent(BaseEvent): @@ -1128,7 +1126,7 @@ class SyncToAvalonEvent(BaseEvent): "_id": mongo_id, "name": name, "type": "asset", - "schema": EntitySchemas["asset"], + "schema": CURRENT_DOC_SCHEMAS["asset"], "parent": proj["_id"], "data": { "ftrackId": ftrack_ent["id"], diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index f58e858a5a..a3b926464e 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -34,7 +34,7 @@ log = Logger.get_logger(__name__) # Current schemas for avalon types -EntitySchemas = { +CURRENT_DOC_SCHEMAS = { "project": "openpype:project-3.0", "asset": "openpype:asset-3.0", "config": "openpype:config-2.0" @@ -1862,7 +1862,7 @@ class SyncEntitiesFactory: item["_id"] = new_id item["parent"] = self.avalon_project_id - item["schema"] = EntitySchemas["asset"] + item["schema"] = CURRENT_DOC_SCHEMAS["asset"] item["data"]["visualParent"] = avalon_parent new_id_str = str(new_id) @@ -2003,8 +2003,8 @@ class SyncEntitiesFactory: project_item["_id"] = new_id project_item["parent"] = None - project_item["schema"] = EntitySchemas["project"] - project_item["config"]["schema"] = EntitySchemas["config"] + project_item["schema"] = CURRENT_DOC_SCHEMAS["project"] + project_item["config"]["schema"] = CURRENT_DOC_SCHEMAS["config"] self.ftrack_avalon_mapper[self.ft_project_id] = new_id self.avalon_ftrack_mapper[new_id] = self.ft_project_id diff --git a/openpype/modules/sync_server/providers/local_drive.py b/openpype/modules/sync_server/providers/local_drive.py index 3b3e699d00..4b80ed44f2 100644 --- a/openpype/modules/sync_server/providers/local_drive.py +++ b/openpype/modules/sync_server/providers/local_drive.py @@ -170,7 +170,10 @@ class LocalDriveHandler(AbstractProvider): site=site, progress=status_val ) - target_file_size = os.path.getsize(target_path) + try: + target_file_size = os.path.getsize(target_path) + except FileNotFoundError: + pass time.sleep(0.5) def _normalize_site_name(self, site_name): diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index ed403b836d..15de4b12e9 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -501,6 +501,8 @@ class SyncServerModule(PypeModule, ITrayModule): items = self.get_configurable_items_for_site(project_name, site_name, scope) + # Local Settings need 'local' instead of real value + site_name = site_name.replace(get_local_site_id(), 'local') editable[site_name] = items return editable @@ -591,8 +593,6 @@ class SyncServerModule(PypeModule, ITrayModule): else: item["value"] = val - - editable.append(item) return editable @@ -877,7 +877,7 @@ class SyncServerModule(PypeModule, ITrayModule): } all_sites = {self.DEFAULT_SITE: studio_config} if sync_enabled: - all_sites['local'] = {'provider': 'local_drive'} + all_sites[get_local_site_id()] = {'provider': 'local_drive'} return all_sites def get_provider_for_site(self, project_name=None, site=None): diff --git a/openpype/plugins/publish/collect_anatomy_context_data.py b/openpype/plugins/publish/collect_anatomy_context_data.py index 5b955a0592..f121760e27 100644 --- a/openpype/plugins/publish/collect_anatomy_context_data.py +++ b/openpype/plugins/publish/collect_anatomy_context_data.py @@ -65,12 +65,19 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): "username": context.data["user"] } - app_manager = ApplicationManager() - app_name = os.environ.get("AVALON_APP_NAME") - if app_name: - app = app_manager.applications.get(app_name) - if app: - context_data["app"] = app.host_name + # Use AVALON_APP as first if available it is the same as host name + # - only if is not defined use AVALON_APP_NAME (e.g. on Farm) and + # set it back to AVALON_APP env variable + host_name = os.environ.get("AVALON_APP") + if not host_name: + app_manager = ApplicationManager() + app_name = os.environ.get("AVALON_APP_NAME") + if app_name: + app = app_manager.applications.get(app_name) + if app: + host_name = app.host_name + os.environ["AVALON_APP"] = host_name + context_data["app"] = host_name datetime_data = context.data.get("datetimeData") or {} context_data.update(datetime_data) diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 981cca82dc..326ca8349a 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -110,6 +110,12 @@ class PypeCommands: with open(output_json_path, "w") as file_stream: json.dump(env, file_stream, indent=4) + @staticmethod + def launch_project_manager(): + from openpype.tools import project_manager + + project_manager.main() + def texture_copy(self, project, asset, path): pass diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json new file mode 100644 index 0000000000..0db6e8248d --- /dev/null +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -0,0 +1,17 @@ +{ + "create": { + "CreateImage": { + "defaults": [ + "Main" + ] + } + }, + "publish": { + "ExtractImage": { + "formats": [ + "png", + "jpg" + ] + } + } +} \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/tvpaint.json b/openpype/settings/defaults/project_settings/tvpaint.json index 4a424b1c03..9d5b922b8e 100644 --- a/openpype/settings/defaults/project_settings/tvpaint.json +++ b/openpype/settings/defaults/project_settings/tvpaint.json @@ -16,5 +16,21 @@ "active": true } }, + "load": { + "LoadImage": { + "defaults": { + "stretch": true, + "timestretch": true, + "preload": true + } + }, + "ImportImage": { + "defaults": { + "stretch": true, + "timestretch": true, + "preload": true + } + } + }, "filters": {} } \ No newline at end of file diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 63d6da4633..020924db67 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -5,18 +5,11 @@ "icon": "{}/app_icons/maya.png", "host_name": "maya", "environment": { - "PYTHONPATH": [ - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/maya/startup", - "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/maya", - "{OPENPYPE_REPOS_ROOT}/repos/maya-look-assigner", - "{PYTHONPATH}" - ], "MAYA_DISABLE_CLIC_IPM": "Yes", "MAYA_DISABLE_CIP": "Yes", "MAYA_DISABLE_CER": "Yes", "PYMEL_SKIP_MEL_INIT": "Yes", - "LC_ALL": "C", - "OPENPYPE_LOG_NO_COLORS": "Yes" + "LC_ALL": "C" }, "variants": { "2022": { @@ -110,15 +103,7 @@ "icon": "{}/app_icons/nuke.png", "host_name": "nuke", "environment": { - "NUKE_PATH": [ - "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/nuke/nuke_path", - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/nuke/startup", - "{OPENPYPE_STUDIO_PLUGINS}/nuke" - ], - "PATH": { - "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" - }, - "LOGLEVEL": "DEBUG" + "NUKE_PATH": "{OPENPYPE_STUDIO_PLUGINS}/nuke" }, "variants": { "13-0": { @@ -224,15 +209,7 @@ "icon": "{}/app_icons/nuke.png", "host_name": "nuke", "environment": { - "NUKE_PATH": [ - "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/nuke/nuke_path", - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/nuke/startup", - "{OPENPYPE_STUDIO_PLUGINS}/nuke" - ], - "PATH": { - "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" - }, - "LOGLEVEL": "DEBUG" + "NUKE_PATH": "{OPENPYPE_STUDIO_PLUGINS}/nuke" }, "variants": { "13-0": { @@ -368,15 +345,8 @@ "icon": "{}/app_icons/nuke.png", "host_name": "hiero", "environment": { - "HIERO_PLUGIN_PATH": [ - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/hiero/startup" - ], - "PATH": { - "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" - }, "WORKFILES_STARTUP": "0", - "TAG_ASSETBUILD_STARTUP": "0", - "LOGLEVEL": "DEBUG" + "TAG_ASSETBUILD_STARTUP": "0" }, "variants": { "13-0": { @@ -510,15 +480,8 @@ "icon": "{}/app_icons/hiero.png", "host_name": "hiero", "environment": { - "HIERO_PLUGIN_PATH": [ - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/hiero/startup" - ], - "PATH": { - "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" - }, "WORKFILES_STARTUP": "0", - "TAG_ASSETBUILD_STARTUP": "0", - "LOGLEVEL": "DEBUG" + "TAG_ASSETBUILD_STARTUP": "0" }, "variants": { "13-0": { @@ -783,18 +746,7 @@ "label": "Houdini", "icon": "{}/app_icons/houdini.png", "host_name": "houdini", - "environment": { - "HOUDINI_PATH": { - "darwin": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup:&", - "linux": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup:&", - "windows": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup;&" - }, - "HOUDINI_MENU_PATH": { - "darwin": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup:&", - "linux": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup:&", - "windows": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup;&" - } - }, + "environment": {}, "variants": { "18-5": { "use_python_2": true, @@ -852,14 +804,7 @@ "label": "Blender", "icon": "{}/app_icons/blender.png", "host_name": "blender", - "environment": { - "BLENDER_USER_SCRIPTS": "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/blender", - "PYTHONPATH": [ - "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/blender", - "{PYTHONPATH}" - ], - "QT_PREFERRED_BINDING": "PySide2" - }, + "environment": {}, "variants": { "2-83": { "use_python_2": false, @@ -940,8 +885,7 @@ "icon": "{}/app_icons/harmony.png", "host_name": "harmony", "environment": { - "AVALON_HARMONY_WORKFILES_ON_LAUNCH": "1", - "LIB_OPENHARMONY_PATH": "{OPENPYPE_REPOS_ROOT}/pype/vendor/OpenHarmony" + "AVALON_HARMONY_WORKFILES_ON_LAUNCH": "1" }, "variants": { "20": { @@ -985,9 +929,7 @@ "label": "TVPaint", "icon": "{}/app_icons/tvpaint.png", "host_name": "tvpaint", - "environment": { - "OPENPYPE_LOG_NO_COLORS": "True" - }, + "environment": {}, "variants": { "animation_11-64bits": { "use_python_2": false, @@ -1034,8 +976,6 @@ "host_name": "photoshop", "environment": { "AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH": "1", - "OPENPYPE_LOG_NO_COLORS": "Yes", - "WEBSOCKET_URL": "ws://localhost:8099/ws/", "WORKFILES_SAVE_AS": "Yes" }, "variants": { @@ -1084,8 +1024,6 @@ "host_name": "aftereffects", "environment": { "AVALON_AFTEREFFECTS_WORKFILES_ON_LAUNCH": "1", - "OPENPYPE_LOG_NO_COLORS": "Yes", - "WEBSOCKET_URL": "ws://localhost:8097/ws/", "WORKFILES_SAVE_AS": "Yes" }, "variants": { @@ -1159,10 +1097,7 @@ "label": "Unreal Editor", "icon": "{}/app_icons/ue4.png'", "host_name": "unreal", - "environment": { - "AVALON_UNREAL_PLUGIN": "{OPENPYPE_REPOS_ROOT}/repos/avalon-unreal-integration", - "OPENPYPE_LOG_NO_COLORS": "True" - }, + "environment": {}, "variants": { "4-26": { "use_python_2": false, diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index 2c71b622ee..33881a6097 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -57,6 +57,7 @@ from .exceptions import ( SchemaError, DefaultsNotDefined, StudioDefaultsNotDefined, + BaseInvalidValueType, InvalidValueType, InvalidKeySymbols, SchemaMissingFileInfo, @@ -96,7 +97,7 @@ from .input_entities import ( PathInput, RawJsonEntity ) - +from .color_entity import ColorEntity from .enum_entity import ( BaseEnumEntity, EnumEntity, @@ -115,6 +116,7 @@ from .anatomy_entities import AnatomyEntity __all__ = ( "DefaultsNotDefined", "StudioDefaultsNotDefined", + "BaseInvalidValueType", "InvalidValueType", "InvalidKeySymbols", "SchemaMissingFileInfo", @@ -146,6 +148,8 @@ __all__ = ( "PathInput", "RawJsonEntity", + "ColorEntity", + "BaseEnumEntity", "EnumEntity", "AppsEnumEntity", diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 3e73fa8aa6..90efb73fbc 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -9,6 +9,7 @@ from .lib import ( ) from .exceptions import ( + BaseInvalidValueType, InvalidValueType, SchemeGroupHierarchyBug, EntitySchemaError @@ -377,7 +378,7 @@ class BaseItemEntity(BaseEntity): try: new_value = self.convert_to_valid_type(value) - except InvalidValueType: + except BaseInvalidValueType: new_value = NOT_SET if new_value is not NOT_SET: @@ -846,6 +847,13 @@ class ItemEntity(BaseItemEntity): ) raise EntitySchemaError(self, reason) + if self.is_file and self.file_item is not None: + reason = ( + "Entity has set `is_file` to true but" + " it's parent is already marked as file item." + ) + raise EntitySchemaError(self, reason) + super(ItemEntity, self).schema_validations() def create_schema_object(self, *args, **kwargs): diff --git a/openpype/settings/entities/color_entity.py b/openpype/settings/entities/color_entity.py new file mode 100644 index 0000000000..7a1b1d9848 --- /dev/null +++ b/openpype/settings/entities/color_entity.py @@ -0,0 +1,54 @@ +from .lib import STRING_TYPE +from .input_entities import InputEntity +from .exceptions import ( + BaseInvalidValueType, + InvalidValueType +) + + +class ColorEntity(InputEntity): + schema_types = ["color"] + + def _item_initalization(self): + self.valid_value_types = (list, ) + self.value_on_not_set = [0, 0, 0, 255] + + def convert_to_valid_type(self, value): + """Conversion to valid type. + + Complexity of entity requires to override BaseEntity implementation. + """ + # Convertion to valid value type `list` + if isinstance(value, (set, tuple)): + value = list(value) + + # Skip other validations if is not `list` + if not isinstance(value, list): + raise InvalidValueType( + self.valid_value_types, type(value), self.path + ) + + # Allow list of len 3 (last aplha is set to max) + if len(value) == 3: + value.append(255) + + if len(value) != 4: + reason = "Color entity expect 4 items in list got {}".format( + len(value) + ) + raise BaseInvalidValueType(reason, self.path) + + new_value = [] + for item in value: + if not isinstance(item, int): + if isinstance(item, (STRING_TYPE, float)): + item = int(item) + + is_valid = isinstance(item, int) and -1 < item < 256 + if not is_valid: + reason = ( + "Color entity expect 4 integers in range 0-255 got {}" + ).format(value) + raise BaseInvalidValueType(reason, self.path) + new_value.append(item) + return new_value diff --git a/openpype/settings/entities/exceptions.py b/openpype/settings/entities/exceptions.py index 3649e63ab7..f352c94f20 100644 --- a/openpype/settings/entities/exceptions.py +++ b/openpype/settings/entities/exceptions.py @@ -15,20 +15,22 @@ class StudioDefaultsNotDefined(Exception): super(StudioDefaultsNotDefined, self).__init__(msg) -class InvalidValueType(Exception): - msg_template = "{}" +class BaseInvalidValueType(Exception): + def __init__(self, reason, path): + msg = "Path \"{}\". {}".format(path, reason) + self.msg = msg + super(BaseInvalidValueType, self).__init__(msg) + +class InvalidValueType(BaseInvalidValueType): def __init__(self, valid_types, invalid_type, path): - msg = "Path \"{}\". ".format(path) - joined_types = ", ".join( [str(valid_type) for valid_type in valid_types] ) - msg += "Got invalid type \"{}\". Expected: {}".format( + msg = "Got invalid type \"{}\". Expected: {}".format( invalid_type, joined_types ) - self.msg = msg - super(InvalidValueType, self).__init__(msg) + super(InvalidValueType, self).__init__(msg, path) class RequiredKeyModified(KeyError): diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index 18312a8364..6c31b61f59 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -420,6 +420,18 @@ } ``` +### color +- preimplemented entity to store and load color values +- entity store and expect list of 4 integers in range 0-255 + - integers represents rgba [Red, Green, Blue, Alpha] + +``` +{ + "type": "color", + "key": "bg_color", + "label": "Background Color" +} +``` ## Noninteractive widgets - have nothing to do with data diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index b4666b302a..e77f13d351 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -82,6 +82,10 @@ "type": "schema", "name": "schema_project_aftereffects" }, + { + "type": "schema", + "name": "schema_project_photoshop" + }, { "type": "schema", "name": "schema_project_harmony" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 1346fb3dad..d47a6917da 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -10,7 +10,6 @@ "collapsible": true, "key": "publish", "label": "Publish plugins", - "is_file": true, "children": [ { "type": "dict", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json index b1bb207578..aae2bb2539 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -603,7 +603,6 @@ "collapsible": true, "key": "publish", "label": "Publish plugins", - "is_file": true, "children": [ { "type": "dict", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json new file mode 100644 index 0000000000..3a20b4e79c --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json @@ -0,0 +1,57 @@ +{ + "type": "dict", + "collapsible": true, + "key": "photoshop", + "label": "Photoshop", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Creator plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CreateImage", + "label": "Create Image", + "children": [ + { + "type": "list", + "key": "defaults", + "label": "Default Subsets", + "object_type": "text" + } + ] + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "publish", + "label": "Publish plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "ExtractImage", + "label": "Extract Image", + "children": [ + { + "type": "label", + "label": "Currently only jpg and png are supported" + }, + { + "type": "list", + "key": "formats", + "label": "Extract Formats", + "object_type": "text" + } + ] + } + ] + } + ] +} diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_standalonepublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_standalonepublisher.json index 47eea3441c..28755ad268 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_standalonepublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_standalonepublisher.json @@ -11,7 +11,6 @@ "key": "create", "label": "Creator plugins", "collapsible_key": true, - "is_file": true, "object_type": { "type": "dict", "children": [ @@ -56,7 +55,6 @@ "collapsible": true, "key": "publish", "label": "Publish plugins", - "is_file": true, "children": [ { "type": "dict", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json index 9428ce2db0..cb2cc9c9d1 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json @@ -4,7 +4,6 @@ "label": "Site Sync (beta testing)", "collapsible": true, "checkbox_key": "enabled", - "is_file": true, "children": [ { "type": "boolean", @@ -44,7 +43,6 @@ "key": "sites", "label": "Sites", "collapsible_key": false, - "is_file": true, "object_type": { "type": "dict", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json index ab404f03ff..00080f8247 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json @@ -10,7 +10,6 @@ "collapsible": true, "key": "publish", "label": "Publish plugins", - "is_file": true, "children": [ { "type": "schema_template", @@ -47,6 +46,72 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "load", + "label": "Loader plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "LoadImage", + "label": "Load Image", + "children": [ + { + "key": "defaults", + "type": "dict", + "children": [ + { + "type": "boolean", + "key": "stretch", + "label": "Stretch" + }, + { + "type": "boolean", + "key": "timestretch", + "label": "TimeStretch" + }, + { + "type": "boolean", + "key": "preload", + "label": "Preload" + } + ] + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ImportImage", + "label": "Import Image", + "children": [ + { + "key": "defaults", + "type": "dict", + "children": [ + { + "type": "boolean", + "key": "stretch", + "label": "Stretch" + }, + { + "type": "boolean", + "key": "timestretch", + "label": "TimeStretch" + }, + { + "type": "boolean", + "key": "preload", + "label": "Preload" + } + ] + } + ] + } + ] + }, { "type": "schema", "name": "schema_publish_gui_filter" diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index 0efe3b8fea..426da4b71e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -228,14 +228,9 @@ ] }, { - "type": "schema_template", - "name": "template_rgba_color", - "template_data": [ - { - "label": "Fill Color", - "name": "fill_color" - } - ] + "type": "color", + "label": "Fill Color", + "key": "fill_color" }, { "key": "line_thickness", @@ -245,14 +240,9 @@ "maximum": 1000 }, { - "type": "schema_template", - "name": "template_rgba_color", - "template_data": [ - { - "label": "Line Color", - "name": "line_color" - } - ] + "type": "color", + "label": "Line Color", + "key": "line_color" } ] } diff --git a/openpype/settings/entities/schemas/system_schema/example_schema.json b/openpype/settings/entities/schemas/system_schema/example_schema.json index 48a21cc0c6..a4ed56df32 100644 --- a/openpype/settings/entities/schemas/system_schema/example_schema.json +++ b/openpype/settings/entities/schemas/system_schema/example_schema.json @@ -4,6 +4,11 @@ "type": "dict", "is_file": true, "children": [ + { + "key": "color", + "label": "Color input", + "type": "color" + }, { "type": "dict", "key": "schema_template_exaples", diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index d1b498bb86..b643293c87 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -97,7 +97,6 @@ "key": "sites", "label": "Sites", "collapsible_key": false, - "is_file": true, "object_type": { "type": "dict", @@ -156,8 +155,7 @@ }, "is_group": true, "key": "templates_mapping", - "label": "Templates mapping", - "is_file": true + "label": "Templates mapping" } ] }, diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py index 81aa841eb7..1fa3a3868a 100644 --- a/openpype/tools/mayalookassigner/app.py +++ b/openpype/tools/mayalookassigner/app.py @@ -192,7 +192,7 @@ class App(QtWidgets.QWidget): for i, (asset, item) in enumerate(asset_nodes.items()): # Label prefix - prefix = "({}/{})".format(i+1, len(asset_nodes)) + prefix = "({}/{})".format(i + 1, len(asset_nodes)) # Assign the first matching look relevant for this asset # (since assigning multiple to the same nodes makes no sense) @@ -212,18 +212,19 @@ class App(QtWidgets.QWidget): self.echo("{} Assigning {} to {}\t".format(prefix, subset_name, asset)) + nodes = item["nodes"] - self.echo("Getting vray proxy nodes ...") - vray_proxies = set(cmds.ls(type="VRayProxy")) - nodes = set(item["nodes"]).difference(vray_proxies) + if cmds.pluginInfo('vrayformaya', query=True, loaded=True): + self.echo("Getting vray proxy nodes ...") + vray_proxies = set(cmds.ls(type="VRayProxy")) + nodes = list(set(item["nodes"]).difference(vray_proxies)) + if vray_proxies: + for vp in vray_proxies: + vrayproxy_assign_look(vp, subset_name) - # Assign look + # Assign look if nodes: - assign_look_by_version([nodes], version_id=version["_id"]) - - if vray_proxies: - for vp in vray_proxies: - vrayproxy_assign_look(vp, subset_name) + assign_look_by_version(nodes, version_id=version["_id"]) end = time.time() diff --git a/openpype/tools/project_manager/__init__.py b/openpype/tools/project_manager/__init__.py new file mode 100644 index 0000000000..62fa8af8aa --- /dev/null +++ b/openpype/tools/project_manager/__init__.py @@ -0,0 +1,10 @@ +from .project_manager import ( + ProjectManagerWindow, + main +) + + +__all__ = ( + "ProjectManagerWindow", + "main" +) diff --git a/openpype/tools/project_manager/__main__.py b/openpype/tools/project_manager/__main__.py new file mode 100644 index 0000000000..2e57af5f11 --- /dev/null +++ b/openpype/tools/project_manager/__main__.py @@ -0,0 +1,5 @@ +from project_manager import main + + +if __name__ == "__main__": + main() diff --git a/openpype/tools/project_manager/project_manager/__init__.py b/openpype/tools/project_manager/project_manager/__init__.py new file mode 100644 index 0000000000..49ade4a989 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/__init__.py @@ -0,0 +1,50 @@ +__all__ = ( + "IDENTIFIER_ROLE", + + "HierarchyView", + + "ProjectModel", + "CreateProjectDialog", + + "HierarchyModel", + "HierarchySelectionModel", + "BaseItem", + "RootItem", + "ProjectItem", + "AssetItem", + "TaskItem", + + "ProjectManagerWindow", + "main" +) + + +from .constants import ( + IDENTIFIER_ROLE +) +from .widgets import CreateProjectDialog +from .view import HierarchyView +from .model import ( + ProjectModel, + + HierarchyModel, + HierarchySelectionModel, + BaseItem, + RootItem, + ProjectItem, + AssetItem, + TaskItem +) +from .window import ProjectManagerWindow + + +def main(): + import sys + from Qt import QtWidgets + + app = QtWidgets.QApplication([]) + + window = ProjectManagerWindow() + window.show() + + sys.exit(app.exec_()) diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py new file mode 100644 index 0000000000..6fb4b991ed --- /dev/null +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -0,0 +1,13 @@ +import re +from Qt import QtCore + + +IDENTIFIER_ROLE = QtCore.Qt.UserRole + 1 +DUPLICATED_ROLE = QtCore.Qt.UserRole + 2 +HIERARCHY_CHANGE_ABLE_ROLE = QtCore.Qt.UserRole + 3 +REMOVED_ROLE = QtCore.Qt.UserRole + 4 +ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 +EDITOR_OPENED_ROLE = QtCore.Qt.UserRole + 6 + +NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" +NAME_REGEX = re.compile("^[" + NAME_ALLOWED_SYMBOLS + "]*$") diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py new file mode 100644 index 0000000000..51edff028f --- /dev/null +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -0,0 +1,159 @@ +from Qt import QtWidgets, QtCore + +from .widgets import ( + NameTextEdit, + FilterComboBox +) +from .multiselection_combobox import MultiSelectionComboBox + + +class ResizeEditorDelegate(QtWidgets.QStyledItemDelegate): + @staticmethod + def _q_smart_min_size(editor): + min_size_hint = editor.minimumSizeHint() + size_policy = editor.sizePolicy() + width = 0 + height = 0 + if size_policy.horizontalPolicy() != QtWidgets.QSizePolicy.Ignored: + if ( + size_policy.horizontalPolicy() + & QtWidgets.QSizePolicy.ShrinkFlag + ): + width = min_size_hint.width() + else: + width = max( + editor.sizeHint().width(), + min_size_hint.width() + ) + + if size_policy.verticalPolicy() != QtWidgets.QSizePolicy.Ignored: + if size_policy.verticalPolicy() & QtWidgets.QSizePolicy.ShrinkFlag: + height = min_size_hint.height() + else: + height = max( + editor.sizeHint().height(), + min_size_hint.height() + ) + + output = QtCore.QSize(width, height).boundedTo(editor.maximumSize()) + min_size = editor.minimumSize() + if min_size.width() > 0: + output.setWidth(min_size.width()) + if min_size.height() > 0: + output.setHeight(min_size.height()) + + return output.expandedTo(QtCore.QSize(0, 0)) + + def updateEditorGeometry(self, editor, option, index): + self.initStyleOption(option, index) + + option.showDecorationSelected = editor.style().styleHint( + QtWidgets.QStyle.SH_ItemView_ShowDecorationSelected, None, editor + ) + + widget = option.widget + + style = widget.style() if widget else QtWidgets.QApplication.style() + geo = style.subElementRect( + QtWidgets.QStyle.SE_ItemViewItemText, option, widget + ) + delta = self._q_smart_min_size(editor).width() - geo.width() + if delta > 0: + if editor.layoutDirection() == QtCore.Qt.RightToLeft: + geo.adjust(-delta, 0, 0, 0) + else: + geo.adjust(0, 0, delta, 0) + editor.setGeometry(geo) + + +class NumberDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, minimum, maximum, decimals, *args, **kwargs): + super(NumberDelegate, self).__init__(*args, **kwargs) + self.minimum = minimum + self.maximum = maximum + self.decimals = decimals + + def createEditor(self, parent, option, index): + if self.decimals > 0: + editor = QtWidgets.QDoubleSpinBox(parent) + else: + editor = QtWidgets.QSpinBox(parent) + + editor.setObjectName("NumberEditor") + editor.setMinimum(self.minimum) + editor.setMaximum(self.maximum) + editor.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + + value = index.data(QtCore.Qt.EditRole) + if value is not None: + try: + if isinstance(value, str): + value = float(value) + editor.setValue(value) + + except Exception: + print("Couldn't set invalid value \"{}\"".format(str(value))) + + return editor + + +class NameDelegate(QtWidgets.QStyledItemDelegate): + def createEditor(self, parent, option, index): + editor = NameTextEdit(parent) + editor.setObjectName("NameEditor") + value = index.data(QtCore.Qt.EditRole) + if value is not None: + editor.setText(str(value)) + return editor + + +class TypeDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, project_doc_cache, *args, **kwargs): + self._project_doc_cache = project_doc_cache + super(TypeDelegate, self).__init__(*args, **kwargs) + + def createEditor(self, parent, option, index): + editor = FilterComboBox(parent) + editor.setObjectName("TypeEditor") + editor.style().polish(editor) + if not self._project_doc_cache.project_doc: + return editor + + task_type_defs = self._project_doc_cache.project_doc["config"]["tasks"] + editor.addItems(list(task_type_defs.keys())) + + return editor + + def setEditorData(self, editor, index): + value = index.data(QtCore.Qt.EditRole) + index = editor.findText(value) + if index >= 0: + editor.setCurrentIndex(index) + + def setModelData(self, editor, model, index): + editor.value_cleanup() + super(TypeDelegate, self).setModelData(editor, model, index) + + +class ToolsDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, tools_cache, *args, **kwargs): + self._tools_cache = tools_cache + super(ToolsDelegate, self).__init__(*args, **kwargs) + + def createEditor(self, parent, option, index): + editor = MultiSelectionComboBox(parent) + editor.setObjectName("ToolEditor") + if not self._tools_cache.tools_data: + return editor + + for key, label in self._tools_cache.tools_data: + editor.addItem(label, key) + + return editor + + def setEditorData(self, editor, index): + value = index.data(QtCore.Qt.EditRole) + editor.set_value(value) + + def setModelData(self, editor, model, index): + model.setData(index, editor.value(), QtCore.Qt.EditRole) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py new file mode 100644 index 0000000000..5000729adf --- /dev/null +++ b/openpype/tools/project_manager/project_manager/model.py @@ -0,0 +1,2040 @@ +import collections +import copy +import json +from queue import Queue +from uuid import uuid4 + +from .constants import ( + IDENTIFIER_ROLE, + ITEM_TYPE_ROLE, + DUPLICATED_ROLE, + HIERARCHY_CHANGE_ABLE_ROLE, + REMOVED_ROLE, + EDITOR_OPENED_ROLE +) +from .style import ResourceCache + +from openpype.lib import CURRENT_DOC_SCHEMAS +from pymongo import UpdateOne, DeleteOne +from avalon.vendor import qtawesome +from Qt import QtCore, QtGui + + +class ProjectModel(QtGui.QStandardItemModel): + project_changed = QtCore.Signal() + + def __init__(self, dbcon, *args, **kwargs): + self.dbcon = dbcon + + self._project_names = set() + + super(ProjectModel, self).__init__(*args, **kwargs) + + def refresh(self): + self.dbcon.Session["AVALON_PROJECT"] = None + + project_items = [] + + none_project = QtGui.QStandardItem("< Select Project >") + none_project.setData(None) + project_items.append(none_project) + + database = self.dbcon.database + project_names = set() + for project_name in database.collection_names(): + # Each collection will have exactly one project document + project_doc = database[project_name].find_one( + {"type": "project"}, + {"name": 1} + ) + if not project_doc: + continue + + project_name = project_doc.get("name") + if project_name: + project_names.add(project_name) + project_items.append(QtGui.QStandardItem(project_name)) + + self.clear() + + self._project_names = project_names + + self.invisibleRootItem().appendRows(project_items) + + +class HierarchySelectionModel(QtCore.QItemSelectionModel): + def __init__(self, multiselection_columns, *args, **kwargs): + super(HierarchySelectionModel, self).__init__(*args, **kwargs) + self.multiselection_columns = multiselection_columns + + def setCurrentIndex(self, index, command): + if index.column() in self.multiselection_columns: + if ( + command & QtCore.QItemSelectionModel.Clear + and command & QtCore.QItemSelectionModel.Select + ): + command = QtCore.QItemSelectionModel.NoUpdate + super(HierarchySelectionModel, self).setCurrentIndex(index, command) + + +class HierarchyModel(QtCore.QAbstractItemModel): + _columns_def = [ + ("name", "Name"), + ("type", "Type"), + ("fps", "FPS"), + ("frameStart", "Frame start"), + ("frameEnd", "Frame end"), + ("handleStart", "Handle start"), + ("handleEnd", "Handle end"), + ("resolutionWidth", "Width"), + ("resolutionHeight", "Height"), + ("clipIn", "Clip in"), + ("clipOut", "Clip out"), + ("pixelAspect", "Pixel aspect"), + ("tools_env", "Tools") + ] + multiselection_columns = { + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } + columns = [ + item[0] + for item in _columns_def + ] + columns_len = len(columns) + column_labels = { + idx: item[1] + for idx, item in enumerate(_columns_def) + } + + index_moved = QtCore.Signal(QtCore.QModelIndex) + project_changed = QtCore.Signal() + + def __init__(self, dbcon, parent=None): + super(HierarchyModel, self).__init__(parent) + + self.multiselection_column_indexes = { + self.columns.index(key) + for key in self.multiselection_columns + } + + # TODO Reset them on project change + self._current_project = None + self._root_item = None + self._items_by_id = {} + self._asset_items_by_name = collections.defaultdict(set) + self.dbcon = dbcon + + self._reset_root_item() + + @property + def items_by_id(self): + return self._items_by_id + + def _reset_root_item(self): + self._root_item = RootItem(self) + + def refresh_project(self): + self.set_project(self._current_project, True) + + @property + def project_item(self): + output = None + for row in range(self._root_item.rowCount()): + item = self._root_item.child(row) + if isinstance(item, ProjectItem): + output = item + break + return output + + def set_project(self, project_name, force=False): + if self._current_project == project_name and not force: + return + + self.clear() + + self._current_project = project_name + if not project_name: + return + + project_doc = self.dbcon.database[project_name].find_one( + {"type": "project"}, + ProjectItem.query_projection + ) + if not project_doc: + return + + project_item = ProjectItem(project_doc) + self.add_item(project_item) + + asset_docs = self.dbcon.database[project_name].find( + {"type": "asset"}, + AssetItem.query_projection + ) + asset_docs_by_id = { + asset_doc["_id"]: asset_doc + for asset_doc in asset_docs + } + + # Prepare booleans if asset item can be modified (name or hierarchy) + # - the same must be applied to all it's parents + asset_ids = list(asset_docs_by_id.keys()) + result = [] + if asset_ids: + result = self.dbcon.database[project_name].aggregate([ + { + "$match": { + "type": "subset", + "parent": {"$in": asset_ids} + } + }, + { + "$group": { + "_id": "$parent", + "count": {"$sum": 1} + } + } + ]) + + asset_modifiable = { + asset_id: True + for asset_id in asset_docs_by_id.keys() + } + for item in result: + asset_id = item["_id"] + count = item["count"] + asset_modifiable[asset_id] = count < 1 + + asset_docs_by_parent_id = collections.defaultdict(list) + for asset_doc in asset_docs_by_id.values(): + parent_id = asset_doc["data"].get("visualParent") + asset_docs_by_parent_id[parent_id].append(asset_doc) + + appending_queue = Queue() + appending_queue.put((None, project_item)) + + asset_items_by_id = {} + non_modifiable_items = set() + while not appending_queue.empty(): + parent_id, parent_item = appending_queue.get() + asset_docs = asset_docs_by_parent_id.get(parent_id) or [] + + new_items = [] + for asset_doc in sorted(asset_docs, key=lambda item: item["name"]): + # Create new Item + new_item = AssetItem(asset_doc) + # Store item to be added under parent in bulk + new_items.append(new_item) + + # Store item by id for task processing + asset_id = asset_doc["_id"] + if not asset_modifiable[asset_id]: + non_modifiable_items.add(new_item.id) + + asset_items_by_id[asset_id] = new_item + # Add item to appending queue + appending_queue.put((asset_id, new_item)) + + if new_items: + self.add_items(new_items, parent_item) + + # Handle Asset's that are not modifiable + # - pass the information to all it's parents + non_modifiable_queue = Queue() + for item_id in non_modifiable_items: + non_modifiable_queue.put(item_id) + + while not non_modifiable_queue.empty(): + item_id = non_modifiable_queue.get() + item = self._items_by_id[item_id] + item.setData(False, HIERARCHY_CHANGE_ABLE_ROLE) + + parent = item.parent() + if ( + isinstance(parent, AssetItem) + and parent.id not in non_modifiable_items + ): + non_modifiable_items.add(parent.id) + non_modifiable_queue.put(parent.id) + + # Add task items + for asset_id, asset_item in asset_items_by_id.items(): + asset_doc = asset_docs_by_id[asset_id] + asset_tasks = asset_doc["data"]["tasks"] + if not asset_tasks: + continue + + task_items = [] + for task_name in sorted(asset_tasks.keys()): + _task_data = copy.deepcopy(asset_tasks[task_name]) + _task_data["name"] = task_name + task_item = TaskItem(_task_data) + task_items.append(task_item) + + self.add_items(task_items, asset_item) + + self.project_changed.emit() + + def rowCount(self, parent=None): + if parent is None or not parent.isValid(): + parent_item = self._root_item + else: + parent_item = parent.internalPointer() + return parent_item.rowCount() + + def columnCount(self, *args, **kwargs): + return self.columns_len + + def data(self, index, role): + if not index.isValid(): + return None + + column = index.column() + key = self.columns[column] + + item = index.internalPointer() + return item.data(role, key) + + def setData(self, index, value, role=QtCore.Qt.EditRole): + if not index.isValid(): + return False + + item = index.internalPointer() + column = index.column() + key = self.columns[column] + if ( + key == "name" + and role in (QtCore.Qt.EditRole, QtCore.Qt.DisplayRole) + ): + self._rename_asset(item, value) + + result = item.setData(value, role, key) + if result: + self.dataChanged.emit(index, index, [role]) + + return result + + def headerData(self, section, orientation, role): + if role == QtCore.Qt.DisplayRole: + if section < self.columnCount(): + return self.column_labels[section] + + return super(HierarchyModel, self).headerData( + section, orientation, role + ) + + def flags(self, index): + item = index.internalPointer() + if item is None: + return QtCore.Qt.NoItemFlags + column = index.column() + key = self.columns[column] + return item.flags(key) + + def parent(self, index=None): + if not index.isValid(): + return QtCore.QModelIndex() + + item = index.internalPointer() + parent_item = item.parent() + + # If it has no parents we return invalid + if not parent_item or parent_item is self._root_item: + return QtCore.QModelIndex() + + return self.createIndex(parent_item.row(), 0, parent_item) + + def index(self, row, column, parent=None): + """Return index for row/column under parent""" + parent_item = None + if parent is not None and parent.isValid(): + parent_item = parent.internalPointer() + + return self.index_from_item(row, column, parent_item) + + def index_for_item(self, item, column=0): + return self.index_from_item( + item.row(), column, item.parent() + ) + + def index_from_item(self, row, column, parent=None): + if parent is None: + parent = self._root_item + + child_item = parent.child(row) + if child_item: + return self.createIndex(row, column, child_item) + + return QtCore.QModelIndex() + + def add_new_asset(self, source_index): + item_id = source_index.data(IDENTIFIER_ROLE) + item = self.items_by_id[item_id] + + if isinstance(item, (RootItem, ProjectItem)): + name = "ep" + new_row = None + else: + name = None + new_row = item.rowCount() + + asset_data = {} + if name: + asset_data["name"] = name + + new_child = AssetItem(asset_data) + + result = self.add_item(new_child, item, new_row) + if result is not None: + # WARNING Expecting result is index for column 0 ("name") + new_name = result.data(QtCore.Qt.EditRole) + self._validate_asset_duplicity(new_name) + + return result + + def add_new_task(self, parent_index): + item_id = parent_index.data(IDENTIFIER_ROLE) + item = self.items_by_id[item_id] + + if isinstance(item, TaskItem): + parent = item.parent() + else: + parent = item + + if not isinstance(parent, AssetItem): + return None + + new_child = TaskItem() + return self.add_item(new_child, parent) + + def add_items(self, items, parent=None, start_row=None): + if parent is None: + parent = self._root_item + + if parent.data(REMOVED_ROLE): + return [] + + if start_row is None: + start_row = parent.rowCount() + + end_row = start_row + len(items) - 1 + + parent_index = self.index_from_item(parent.row(), 0, parent.parent()) + + self.beginInsertRows(parent_index, start_row, end_row) + + for idx, item in enumerate(items): + row = start_row + idx + if item.parent() is not parent: + item.set_parent(parent) + + parent.add_child(item, row) + + if isinstance(item, AssetItem): + name = item.data(QtCore.Qt.EditRole, "name") + self._asset_items_by_name[name].add(item.id) + + if item.id not in self._items_by_id: + self._items_by_id[item.id] = item + + self.endInsertRows() + + indexes = [] + for row in range(start_row, end_row + 1): + indexes.append( + self.index_from_item(row, 0, parent) + ) + return indexes + + def add_item(self, item, parent=None, row=None): + result = self.add_items([item], parent, row) + if result: + return result[0] + return None + + def remove_delete_flag(self, item_ids, with_children=True): + items_by_id = {} + for item_id in item_ids: + if item_id in items_by_id: + continue + + item = self.items_by_id[item_id] + if isinstance(item, (AssetItem, TaskItem)): + items_by_id[item_id] = item + + for item in tuple(items_by_id.values()): + parent = item.parent() + while True: + if not isinstance(parent, (AssetItem, TaskItem)): + break + + if parent.id not in items_by_id: + items_by_id[parent.id] = parent + + parent = parent.parent() + + if not with_children: + continue + + def _children_recursion(_item): + if not isinstance(_item, AssetItem): + return + + for row in range(_item.rowCount()): + _child_item = _item.child(row) + if _child_item.id in items_by_id: + continue + + items_by_id[_child_item.id] = _child_item + _children_recursion(_child_item) + + _children_recursion(item) + + for item in items_by_id.values(): + if item.data(REMOVED_ROLE): + item.setData(False, REMOVED_ROLE) + if isinstance(item, AssetItem): + name = item.data(QtCore.Qt.EditRole, "name") + self._asset_items_by_name[name].add(item.id) + self._validate_asset_duplicity(name) + + def delete_index(self, index): + return self.delete_indexes([index]) + + def delete_indexes(self, indexes): + items_by_id = {} + processed_ids = set() + for index in indexes: + if not index.isValid(): + continue + + item_id = index.data(IDENTIFIER_ROLE) + # There may be indexes for multiple columns + if item_id not in processed_ids: + processed_ids.add(item_id) + + item = self._items_by_id[item_id] + if isinstance(item, (TaskItem, AssetItem)): + items_by_id[item_id] = item + + if not items_by_id: + return + + for item in items_by_id.values(): + self._remove_item(item) + + def _remove_item(self, item): + is_removed = item.data(REMOVED_ROLE) + if is_removed: + return + + parent = item.parent() + + all_descendants = collections.defaultdict(dict) + all_descendants[parent.id][item.id] = item + + def _fill_children(_all_descendants, cur_item, parent_item=None): + if parent_item is not None: + _all_descendants[parent_item.id][cur_item.id] = cur_item + + if isinstance(cur_item, TaskItem): + was_removed = cur_item.data(REMOVED_ROLE) + task_removed = True + if not was_removed and parent_item is not None: + task_removed = parent_item.data(REMOVED_ROLE) + if not was_removed: + cur_item.setData(task_removed, REMOVED_ROLE) + return task_removed + + remove_item = True + task_children = [] + for row in range(cur_item.rowCount()): + child_item = cur_item.child(row) + if isinstance(child_item, TaskItem): + task_children.append(child_item) + continue + + if not _fill_children(_all_descendants, child_item, cur_item): + remove_item = False + + if remove_item: + cur_item.setData(True, REMOVED_ROLE) + if isinstance(cur_item, AssetItem): + self._rename_asset(cur_item, None) + + for task_item in task_children: + _fill_children(_all_descendants, task_item, cur_item) + return remove_item + + _fill_children(all_descendants, item) + + modified_children = [] + while all_descendants: + for parent_id in tuple(all_descendants.keys()): + children = all_descendants[parent_id] + if not children: + all_descendants.pop(parent_id) + continue + + parent_children = {} + all_without_children = True + for child_id in tuple(children.keys()): + if child_id in all_descendants: + all_without_children = False + break + parent_children[child_id] = children[child_id] + + if not all_without_children: + continue + + parent_item = self._items_by_id[parent_id] + row_ranges = [] + start_row = end_row = None + chilren_by_row = {} + for row in range(parent_item.rowCount()): + child_item = parent_item.child(row) + child_id = child_item.id + if child_id not in children: + continue + + chilren_by_row[row] = child_item + children.pop(child_item.id) + + remove_item = child_item.data(REMOVED_ROLE) + if not remove_item or not child_item.is_new: + modified_children.append(child_item) + if end_row is not None: + row_ranges.append((start_row, end_row)) + start_row = end_row = None + continue + + end_row = row + if start_row is None: + start_row = row + + if end_row is not None: + row_ranges.append((start_row, end_row)) + + parent_index = None + for start, end in row_ranges: + if parent_index is None: + parent_index = self.index_for_item(parent_item) + + self.beginRemoveRows(parent_index, start, end) + + for idx in range(start, end + 1): + child_item = chilren_by_row[idx] + # Force name validation + if isinstance(child_item, AssetItem): + self._rename_asset(child_item, None) + child_item.set_parent(None) + self._items_by_id.pop(child_item.id) + + self.endRemoveRows() + + for item in modified_children: + s_index = self.index_for_item(item) + e_index = self.index_for_item(item, column=self.columns_len - 1) + self.dataChanged.emit(s_index, e_index, [QtCore.Qt.BackgroundRole]) + + def _rename_asset(self, asset_item, new_name): + if not isinstance(asset_item, AssetItem): + return + + prev_name = asset_item.data(QtCore.Qt.EditRole, "name") + if prev_name == new_name: + return + + if asset_item.id in self._asset_items_by_name[prev_name]: + self._asset_items_by_name[prev_name].remove(asset_item.id) + + self._validate_asset_duplicity(prev_name) + + if new_name is None: + return + self._asset_items_by_name[new_name].add(asset_item.id) + + self._validate_asset_duplicity(new_name) + + def _validate_asset_duplicity(self, name): + if name not in self._asset_items_by_name: + return + + item_ids = self._asset_items_by_name[name] + if not item_ids: + self._asset_items_by_name.pop(name) + + elif len(item_ids) == 1: + for item_id in item_ids: + item = self._items_by_id[item_id] + index = self.index_for_item(item) + self.setData(index, False, DUPLICATED_ROLE) + + else: + for item_id in item_ids: + item = self._items_by_id[item_id] + index = self.index_for_item(item) + self.setData(index, True, DUPLICATED_ROLE) + + def _move_horizontal_single(self, index, direction): + if not index.isValid(): + return + + item_id = index.data(IDENTIFIER_ROLE) + if item_id is None: + return + + item = self._items_by_id[item_id] + if isinstance(item, (RootItem, ProjectItem)): + return + + if item.data(REMOVED_ROLE): + return + + if ( + isinstance(item, AssetItem) + and not item.data(HIERARCHY_CHANGE_ABLE_ROLE) + ): + return + + if abs(direction) != 1: + return + + # Move under parent of parent + src_row = item.row() + src_parent = item.parent() + src_parent_index = self.index_from_item( + src_parent.row(), 0, src_parent.parent() + ) + + dst_row = None + dst_parent = None + + if direction == -1: + if isinstance(src_parent, (RootItem, ProjectItem)): + return + dst_parent = src_parent.parent() + dst_row = src_parent.row() + 1 + + # Move under parent before or after if before is None + elif direction == 1: + src_row_count = src_parent.rowCount() + if src_row_count == 1: + return + + item_row = item.row() + dst_parent = None + for row in reversed(range(item_row)): + _item = src_parent.child(row) + if not isinstance(_item, AssetItem): + continue + + if _item.data(REMOVED_ROLE): + continue + + dst_parent = _item + break + + _next_row = item_row + 1 + if dst_parent is None and _next_row < src_row_count: + for row in range(_next_row, src_row_count): + _item = src_parent.child(row) + if not isinstance(_item, AssetItem): + continue + + if _item.data(REMOVED_ROLE): + continue + + dst_parent = _item + break + + if dst_parent is None: + return + + dst_row = dst_parent.rowCount() + + if src_parent is dst_parent: + return + + if ( + isinstance(item, TaskItem) + and not isinstance(dst_parent, AssetItem) + ): + return + + dst_parent_index = self.index_from_item( + dst_parent.row(), 0, dst_parent.parent() + ) + + self.beginMoveRows( + src_parent_index, + src_row, + src_row, + dst_parent_index, + dst_row + ) + src_parent.remove_child(item) + dst_parent.add_child(item) + item.set_parent(dst_parent) + dst_parent.move_to(item, dst_row) + + self.endMoveRows() + + new_index = self.index(dst_row, index.column(), dst_parent_index) + self.index_moved.emit(new_index) + + def move_horizontal(self, indexes, direction): + if not indexes: + return + + if isinstance(indexes, QtCore.QModelIndex): + indexes = [indexes] + + if len(indexes) == 1: + self._move_horizontal_single(indexes[0], direction) + return + + items_by_id = {} + for index in indexes: + item_id = index.data(IDENTIFIER_ROLE) + item = self._items_by_id[item_id] + if isinstance(item, (RootItem, ProjectItem)): + continue + + if ( + direction == -1 + and isinstance(item.parent(), (RootItem, ProjectItem)) + ): + continue + + items_by_id[item_id] = item + + if not items_by_id: + return + + parents_by_id = {} + items_ids_by_parent_id = collections.defaultdict(set) + skip_ids = set(items_by_id.keys()) + for item_id, item in tuple(items_by_id.items()): + item_parent = item.parent() + + parent_ids = set() + skip_item = False + parent = item_parent + while parent is not None: + if parent.id in skip_ids: + skip_item = True + skip_ids |= parent_ids + break + parent_ids.add(parent.id) + parent = parent.parent() + + if skip_item: + items_by_id.pop(item_id) + else: + parents_by_id[item_parent.id] = item_parent + items_ids_by_parent_id[item_parent.id].add(item_id) + + if direction == 1: + for parent_id, parent in parents_by_id.items(): + items_ids = items_ids_by_parent_id[parent_id] + if len(items_ids) == parent.rowCount(): + for item_id in items_ids: + items_by_id.pop(item_id) + + items = tuple(items_by_id.values()) + if direction == -1: + items = reversed(items) + + for item in items: + index = self.index_for_item(item) + self._move_horizontal_single(index, direction) + + def _move_vertical_single(self, index, direction): + if not index.isValid(): + return + + item_id = index.data(IDENTIFIER_ROLE) + item = self._items_by_id[item_id] + if isinstance(item, (RootItem, ProjectItem)): + return + + if item.data(REMOVED_ROLE): + return + + if ( + isinstance(item, AssetItem) + and not item.data(HIERARCHY_CHANGE_ABLE_ROLE) + ): + return + + if abs(direction) != 1: + return + + src_parent = item.parent() + if not isinstance(src_parent, AssetItem): + return + + src_parent_index = self.index_from_item( + src_parent.row(), 0, src_parent.parent() + ) + source_row = item.row() + + parent_items = [] + parent = src_parent + while True: + parent = parent.parent() + parent_items.insert(0, parent) + if isinstance(parent, ProjectItem): + break + + dst_parent = None + # Down + if direction == 1: + current_idxs = [] + current_max_idxs = [] + for parent_item in parent_items: + current_max_idxs.append(parent_item.rowCount()) + if not isinstance(parent_item, ProjectItem): + current_idxs.append(parent_item.row()) + current_idxs.append(src_parent.row()) + indexes_len = len(current_idxs) + + while True: + def _update_parents(idx, top=True): + if idx < 0: + return False + + if current_max_idxs[idx] == current_idxs[idx]: + if not _update_parents(idx - 1, False): + return False + + parent = parent_items[idx] + row_count = 0 + if parent is not None: + row_count = parent.rowCount() + current_max_idxs[idx] = row_count + current_idxs[idx] = 0 + return True + + if top: + return True + + current_idxs[idx] += 1 + parent_item = parent_items[idx] + new_item = parent_item.child(current_idxs[idx]) + parent_items[idx + 1] = new_item + + return True + + updated = _update_parents(indexes_len - 1) + if not updated: + return + + start = current_idxs[-1] + end = current_max_idxs[-1] + current_idxs[-1] = current_max_idxs[-1] + parent = parent_items[-1] + for row in range(start, end): + child_item = parent.child(row) + if ( + child_item is src_parent + or child_item.data(REMOVED_ROLE) + or not isinstance(child_item, AssetItem) + ): + continue + + dst_parent = child_item + destination_row = 0 + break + + if dst_parent is not None: + break + + # Up + elif direction == -1: + current_idxs = [] + for parent_item in parent_items: + if not isinstance(parent_item, ProjectItem): + current_idxs.append(parent_item.row()) + current_idxs.append(src_parent.row()) + + max_idxs = [0 for _ in current_idxs] + indexes_len = len(current_idxs) + + while True: + if current_idxs == max_idxs: + return + + def _update_parents(_current_idx, top=True): + if _current_idx < 0: + return False + + if current_idxs[_current_idx] == 0: + if not _update_parents(_current_idx - 1, False): + return False + + parent = parent_items[_current_idx] + row_count = 0 + if parent is not None: + row_count = parent.rowCount() + current_idxs[_current_idx] = row_count + return True + if top: + return True + + current_idxs[_current_idx] -= 1 + parent_item = parent_items[_current_idx] + new_item = parent_item.child(current_idxs[_current_idx]) + parent_items[_current_idx + 1] = new_item + + return True + + updated = _update_parents(indexes_len - 1) + if not updated: + return + + parent_item = parent_items[-1] + row_count = current_idxs[-1] + current_idxs[-1] = 0 + for row in reversed(range(row_count)): + child_item = parent_item.child(row) + if ( + child_item is src_parent + or child_item.data(REMOVED_ROLE) + or not isinstance(child_item, AssetItem) + ): + continue + + dst_parent = child_item + destination_row = dst_parent.rowCount() + break + + if dst_parent is not None: + break + + if dst_parent is None: + return + + dst_parent_index = self.index_from_item( + dst_parent.row(), 0, dst_parent.parent() + ) + + self.beginMoveRows( + src_parent_index, + source_row, + source_row, + dst_parent_index, + destination_row + ) + + if src_parent is dst_parent: + dst_parent.move_to(item, destination_row) + + else: + src_parent.remove_child(item) + dst_parent.add_child(item) + item.set_parent(dst_parent) + dst_parent.move_to(item, destination_row) + + self.endMoveRows() + + new_index = self.index( + destination_row, index.column(), dst_parent_index + ) + self.index_moved.emit(new_index) + + def move_vertical(self, indexes, direction): + if not indexes: + return + + if isinstance(indexes, QtCore.QModelIndex): + indexes = [indexes] + + if len(indexes) == 1: + self._move_vertical_single(indexes[0], direction) + return + + items_by_id = {} + for index in indexes: + item_id = index.data(IDENTIFIER_ROLE) + items_by_id[item_id] = self._items_by_id[item_id] + + skip_ids = set(items_by_id.keys()) + for item_id, item in tuple(items_by_id.items()): + parent = item.parent() + parent_ids = set() + skip_item = False + while parent is not None: + if parent.id in skip_ids: + skip_item = True + skip_ids |= parent_ids + break + parent_ids.add(parent.id) + parent = parent.parent() + + if skip_item: + items_by_id.pop(item_id) + + items = tuple(items_by_id.values()) + if direction == 1: + items = reversed(items) + + for item in items: + index = self.index_for_item(item) + self._move_vertical_single(index, direction) + + def child_removed(self, child): + self._items_by_id.pop(child.id, None) + + def column_name(self, column): + """Return column key by index""" + if column < len(self.columns): + return self.columns[column] + return None + + def clear(self): + self.beginResetModel() + self._reset_root_item() + self.endResetModel() + + def save(self): + all_valid = True + for item in self._items_by_id.values(): + if not item.is_valid: + all_valid = False + break + + if not all_valid: + return + + project_item = None + for _project_item in self._root_item.children(): + project_item = _project_item + + if not project_item: + return + + project_name = project_item.name + project_col = self.dbcon.database[project_name] + + to_process = Queue() + to_process.put(project_item) + + bulk_writes = [] + while not to_process.empty(): + parent = to_process.get() + insert_list = [] + for item in parent.children(): + if not isinstance(item, AssetItem): + continue + + to_process.put(item) + + if item.is_new: + insert_list.append(item) + + elif item.data(REMOVED_ROLE): + if item.data(HIERARCHY_CHANGE_ABLE_ROLE): + bulk_writes.append(DeleteOne( + {"_id": item.asset_id} + )) + else: + bulk_writes.append(UpdateOne( + {"_id": item.asset_id}, + {"$set": {"type": "archived_asset"}} + )) + + else: + update_data = item.update_data() + if update_data: + bulk_writes.append(UpdateOne( + {"_id": item.asset_id}, + update_data + )) + + if insert_list: + new_docs = [] + for item in insert_list: + new_docs.append(item.to_doc()) + + result = project_col.insert_many(new_docs) + for idx, mongo_id in enumerate(result.inserted_ids): + insert_list[idx].mongo_id = mongo_id + + if bulk_writes: + project_col.bulk_write(bulk_writes) + + self.refresh_project() + + def copy_mime_data(self, indexes): + items = [] + processed_ids = set() + for index in indexes: + if not index.isValid(): + continue + item_id = index.data(IDENTIFIER_ROLE) + if item_id in processed_ids: + continue + processed_ids.add(item_id) + item = self._items_by_id[item_id] + items.append(item) + + parent_item = None + for item in items: + if not isinstance(item, TaskItem): + raise ValueError("Can copy only tasks") + + if parent_item is None: + parent_item = item.parent() + elif item.parent() is not parent_item: + raise ValueError("Can copy only tasks from same parent") + + data = [] + for task_item in items: + data.append(task_item.to_json_data()) + + encoded_data = QtCore.QByteArray() + stream = QtCore.QDataStream(encoded_data, QtCore.QIODevice.WriteOnly) + stream.writeQString(json.dumps(data)) + mimedata = QtCore.QMimeData() + mimedata.setData("application/copy_task", encoded_data) + return mimedata + + def paste_mime_data(self, index, mime_data): + if not index.isValid(): + return + + item_id = index.data(IDENTIFIER_ROLE) + item = self._items_by_id[item_id] + if not isinstance(item, (AssetItem, TaskItem)): + return + + raw_data = mime_data.data("application/copy_task") + encoded_data = QtCore.QByteArray.fromRawData(raw_data) + stream = QtCore.QDataStream(encoded_data, QtCore.QIODevice.ReadOnly) + text = stream.readQString() + try: + data = json.loads(text) + except Exception: + data = [] + + if not data: + return + + if isinstance(item, TaskItem): + parent = item.parent() + else: + parent = item + + for task_item_data in data: + task_data = {} + for name, data in task_item_data.items(): + task_data = data + task_data["name"] = name + + task_item = TaskItem(task_data, True) + self.add_item(task_item, parent) + + +class BaseItem: + columns = [] + # Use `set` for faster result + editable_columns = set() + + _name_icons = None + _is_duplicated = False + item_type = "base" + + _None = object() + + def __init__(self, data=None): + self._id = uuid4() + self._children = list() + self._parent = None + + self._data = { + key: None + for key in self.columns + } + self._global_data = {} + self._source_data = data + if data: + for key, value in data.items(): + if key in self.columns: + self._data[key] = value + + def name_icon(self): + return None + + @property + def is_valid(self): + return not self._is_duplicated + + def model(self): + return self._parent.model() + + def move_to(self, item, row): + idx = self._children.index(item) + if idx == row: + return + + self._children.pop(idx) + self._children.insert(row, item) + + def _get_global_data(self, role): + if role == ITEM_TYPE_ROLE: + return self.item_type + + if role == IDENTIFIER_ROLE: + return self._id + + if role == DUPLICATED_ROLE: + return self._is_duplicated + + if role == REMOVED_ROLE: + return False + + return self._global_data.get(role, self._None) + + def _set_global_data(self, value, role): + self._global_data[role] = value + return True + + def data(self, role, key=None): + value = self._get_global_data(role) + if value is not self._None: + return value + + if key not in self.columns: + return None + + if role == QtCore.Qt.ForegroundRole: + if key == "name" and not self.is_valid: + return ResourceCache.colors["warning"] + return None + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + value = self._data[key] + if value is None: + value = self.parent().data(role, key) + return value + + if role == QtCore.Qt.DecorationRole and key == "name": + return self.name_icon() + return None + + def setData(self, value, role, key=None): + if role == DUPLICATED_ROLE: + if value == self._is_duplicated: + return False + + self._is_duplicated = value + return True + + if role == QtCore.Qt.EditRole: + if key in self.editable_columns: + self._data[key] = value + # must return true if successful + return True + + return self._set_global_data(value, role) + + @property + def id(self): + return self._id + + @property + def is_new(self): + return False + + def rowCount(self): + return len(self._children) + + def child(self, row): + if -1 < row < self.rowCount(): + return self._children[row] + return None + + def children(self): + return self._children + + def child_row(self, child): + if child not in self._children: + return -1 + return self._children.index(child) + + def parent(self): + return self._parent + + def set_parent(self, parent): + if parent is self._parent: + return + + if self._parent: + self._parent.remove_child(self) + self._parent = parent + + def row(self): + if self._parent is not None: + return self._parent.child_row(self) + return -1 + + def add_child(self, item, row=None): + if item in self._children: + return + + row_count = self.rowCount() + if row is None or row == row_count: + self._children.append(item) + return + + if row > row_count or row < 0: + raise ValueError( + "Invalid row number {} expected range 0 - {}".format( + row, row_count + ) + ) + + self._children.insert(row, item) + + def remove_child(self, item): + if item in self._children: + self._children.remove(item) + + def flags(self, key): + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + if key in self.editable_columns: + flags |= QtCore.Qt.ItemIsEditable + return flags + + +class RootItem(BaseItem): + item_type = "root" + + def __init__(self, model): + super(RootItem, self).__init__() + self._model = model + + def model(self): + return self._model + + def flags(self, *args, **kwargs): + return QtCore.Qt.NoItemFlags + + +class ProjectItem(BaseItem): + item_type = "project" + + columns = { + "name", + "type", + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env", + } + query_projection = { + "_id": 1, + "name": 1, + "type": 1, + + "data.frameStart": 1, + "data.frameEnd": 1, + "data.fps": 1, + "data.resolutionWidth": 1, + "data.resolutionHeight": 1, + "data.handleStart": 1, + "data.handleEnd": 1, + "data.clipIn": 1, + "data.clipOut": 1, + "data.pixelAspect": 1, + "data.tools_env": 1 + } + + def __init__(self, project_doc): + self._mongo_id = project_doc["_id"] + + data = self.data_from_doc(project_doc) + super(ProjectItem, self).__init__(data) + + @property + def project_id(self): + return self._mongo_id + + @property + def asset_id(self): + return None + + @property + def name(self): + return self._data["name"] + + def child_parents(self): + return [] + + @classmethod + def data_from_doc(cls, project_doc): + data = { + "name": project_doc["name"], + "type": project_doc["type"] + } + doc_data = project_doc.get("data") or {} + for key in cls.columns: + if key in data: + continue + + data[key] = doc_data.get(key) + + return data + + def flags(self, *args, **kwargs): + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + +class AssetItem(BaseItem): + item_type = "asset" + + columns = { + "name", + "type", + "fps", + "frameStart", + "frameEnd", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } + editable_columns = { + "name", + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } + query_projection = { + "_id": 1, + "data.tasks": 1, + "data.visualParent": 1, + "schema": 1, + + "name": 1, + "type": 1, + "data.frameStart": 1, + "data.frameEnd": 1, + "data.fps": 1, + "data.resolutionWidth": 1, + "data.resolutionHeight": 1, + "data.handleStart": 1, + "data.handleEnd": 1, + "data.clipIn": 1, + "data.clipOut": 1, + "data.pixelAspect": 1, + "data.tools_env": 1 + } + + def __init__(self, asset_doc): + if not asset_doc: + asset_doc = {} + self.mongo_id = asset_doc.get("_id") + self._project_id = None + self._edited_columns = { + column_name: False + for column_name in self.editable_columns + } + + # Item data + self._hierarchy_changes_enabled = True + self._removed = False + + # Task children duplication variables + self._task_items_by_name = collections.defaultdict(list) + self._task_name_by_item_id = {} + self._duplicated_task_names = set() + + # Copy of original document + self._origin_asset_doc = copy.deepcopy(asset_doc) + + data = self.data_from_doc(asset_doc) + + self._origin_data = copy.deepcopy(data) + + super(AssetItem, self).__init__(data) + + @property + def project_id(self): + if self._project_id is None: + self._project_id = self.parent().project_id + return self._project_id + + @property + def asset_id(self): + return self.mongo_id + + @property + def is_new(self): + return self.asset_id is None + + @property + def is_valid(self): + if self._is_duplicated or not self._data["name"]: + return False + return True + + @property + def name(self): + return self._data["name"] + + def child_parents(self): + parents = self.parent().child_parents() + parents.append(self.name) + return parents + + def to_doc(self): + tasks = {} + for item in self.children(): + if isinstance(item, TaskItem): + tasks.update(item.to_doc_data()) + + doc_data = { + "parents": self.parent().child_parents(), + "visualParent": self.parent().asset_id, + "tasks": tasks + } + schema_name = ( + self._origin_asset_doc.get("schema") + or CURRENT_DOC_SCHEMAS["asset"] + ) + + doc = { + "name": self.data(QtCore.Qt.EditRole, "name"), + "type": self.data(QtCore.Qt.EditRole, "type"), + "schema": schema_name, + "data": doc_data, + "parent": self.project_id + } + if self.mongo_id: + doc["_id"] = self.mongo_id + + for key in self._data.keys(): + if key in doc: + continue + # Use `data` method to get inherited values + doc_data[key] = self.data(QtCore.Qt.EditRole, key) + + return doc + + def update_data(self): + if not self.mongo_id: + return {} + + document = self.to_doc() + + changes = {} + + for key, value in document.items(): + if key in ("data", "_id"): + continue + + if ( + key in self._origin_asset_doc + and self._origin_asset_doc[key] == value + ): + continue + + changes[key] = value + + if "data" not in self._origin_asset_doc: + changes["data"] = document["data"] + else: + origin_data = self._origin_asset_doc["data"] + + for key, value in document["data"].items(): + if key in origin_data and origin_data[key] == value: + continue + _key = "data.{}".format(key) + changes[_key] = value + + if changes: + return {"$set": changes} + return {} + + @classmethod + def data_from_doc(cls, asset_doc): + data = { + "name": None, + "type": "asset" + } + if asset_doc: + for key in data.keys(): + if key in asset_doc: + data[key] = asset_doc[key] + + doc_data = asset_doc.get("data") or {} + for key in cls.columns: + if key in data: + continue + + data[key] = doc_data.get(key) + + return data + + def name_icon(self): + if self.__class__._name_icons is None: + self.__class__._name_icons = ResourceCache.get_icons()["asset"] + + if self._removed: + icon_type = "removed" + elif not self.is_valid: + icon_type = "invalid" + elif self.is_new: + icon_type = "new" + else: + icon_type = "default" + return self.__class__._name_icons[icon_type] + + def _get_global_data(self, role): + if role == HIERARCHY_CHANGE_ABLE_ROLE: + return self._hierarchy_changes_enabled + + if role == REMOVED_ROLE: + return self._removed + + if role == QtCore.Qt.ToolTipRole: + name = self.data(QtCore.Qt.EditRole, "name") + if not name: + return "Name is not set" + + elif self._is_duplicated: + return "Duplicated asset name \"{}\"".format(name) + return None + + return super(AssetItem, self)._get_global_data(role) + + def data(self, role, key=None): + if role == EDITOR_OPENED_ROLE: + if key not in self._edited_columns: + return False + return self._edited_columns[key] + + if role == QtCore.Qt.DisplayRole and self._edited_columns.get(key): + return "" + + return super(AssetItem, self).data(role, key) + + def setData(self, value, role, key=None): + if role == EDITOR_OPENED_ROLE: + if key not in self._edited_columns: + return False + self._edited_columns[key] = value + return True + + if role == REMOVED_ROLE: + self._removed = value + return True + + if role == HIERARCHY_CHANGE_ABLE_ROLE: + if self._hierarchy_changes_enabled == value: + return False + self._hierarchy_changes_enabled = value + return True + + if ( + role == QtCore.Qt.EditRole + and key == "name" + and not self._hierarchy_changes_enabled + ): + return False + return super(AssetItem, self).setData(value, role, key) + + def flags(self, key): + if key == "name": + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + if self._hierarchy_changes_enabled: + flags |= QtCore.Qt.ItemIsEditable + return flags + return super(AssetItem, self).flags(key) + + def _add_task(self, item): + name = item.data(QtCore.Qt.EditRole, "name").lower() + item_id = item.data(IDENTIFIER_ROLE) + + self._task_name_by_item_id[item_id] = name + self._task_items_by_name[name].append(item) + if len(self._task_items_by_name[name]) > 1: + self._duplicated_task_names.add(name) + for _item in self._task_items_by_name[name]: + _item.setData(True, DUPLICATED_ROLE) + elif item.data(DUPLICATED_ROLE): + item.setData(False, DUPLICATED_ROLE) + + def _remove_task(self, item): + # This method is probably obsolete with changed logic and added + # `on_task_remove_state_change` method. + item_id = item.data(IDENTIFIER_ROLE) + if item_id not in self._task_name_by_item_id: + return + + name = self._task_name_by_item_id.pop(item_id) + self._task_items_by_name[name].remove(item) + if not self._task_items_by_name[name]: + self._task_items_by_name.pop(name) + + elif len(self._task_items_by_name[name]) == 1: + self._duplicated_task_names.remove(name) + for _item in self._task_items_by_name[name]: + _item.setData(False, DUPLICATED_ROLE) + + def _rename_task(self, item): + if item.data(REMOVED_ROLE): + return + + new_name = item.data(QtCore.Qt.EditRole, "name").lower() + item_id = item.data(IDENTIFIER_ROLE) + prev_name = self._task_name_by_item_id[item_id] + if new_name == prev_name: + return + + # Remove from previous name mapping + self._task_items_by_name[prev_name].remove(item) + if not self._task_items_by_name[prev_name]: + self._task_items_by_name.pop(prev_name) + + elif len(self._task_items_by_name[prev_name]) == 1: + self._duplicated_task_names.remove(prev_name) + for _item in self._task_items_by_name[prev_name]: + _item.setData(False, DUPLICATED_ROLE) + + # Add to new name mapping + self._task_items_by_name[new_name].append(item) + if len(self._task_items_by_name[new_name]) > 1: + self._duplicated_task_names.add(new_name) + for _item in self._task_items_by_name[new_name]: + _item.setData(True, DUPLICATED_ROLE) + else: + item.setData(False, DUPLICATED_ROLE) + + self._task_name_by_item_id[item_id] = new_name + + def on_task_name_change(self, task_item): + self._rename_task(task_item) + + def on_task_remove_state_change(self, task_item): + is_removed = task_item.data(REMOVED_ROLE) + item_id = task_item.data(IDENTIFIER_ROLE) + if is_removed: + name = self._task_name_by_item_id.pop(item_id) + self._task_items_by_name[name].remove(task_item) + + else: + name = task_item.data(QtCore.Qt.EditRole, "name").lower() + self._task_name_by_item_id[item_id] = name + self._task_items_by_name[name].append(task_item) + + # Remove from previous name mapping + if not self._task_items_by_name[name]: + self._task_items_by_name.pop(name) + + elif len(self._task_items_by_name[name]) == 1: + if name in self._duplicated_task_names: + self._duplicated_task_names.remove(name) + task_item.setData(False, DUPLICATED_ROLE) + + else: + self._duplicated_task_names.add(name) + for _item in self._task_items_by_name[name]: + _item.setData(True, DUPLICATED_ROLE) + + def add_child(self, item, row=None): + if item in self._children: + return + + super(AssetItem, self).add_child(item, row) + + if isinstance(item, TaskItem): + self._add_task(item) + + def remove_child(self, item): + if item not in self._children: + return + + if isinstance(item, TaskItem): + self._remove_task(item) + + super(AssetItem, self).remove_child(item) + + +class TaskItem(BaseItem): + item_type = "task" + + columns = { + "name", + "type" + } + editable_columns = { + "name", + "type" + } + + def __init__(self, data=None, is_new=None): + self._removed = False + if is_new is None: + is_new = data is None + self._is_new = is_new + if data is None: + data = {} + + self._edited_columns = { + column_name: False + for column_name in self.editable_columns + } + self._origin_data = copy.deepcopy(data) + super(TaskItem, self).__init__(data) + + @property + def is_new(self): + return self._is_new + + @property + def is_valid(self): + if self._is_duplicated or not self._data["type"]: + return False + if not self.data(QtCore.Qt.EditRole, "name"): + return False + return True + + def name_icon(self): + if self.__class__._name_icons is None: + self.__class__._name_icons = ResourceCache.get_icons()["task"] + + if self._removed: + icon_type = "removed" + elif not self.is_valid: + icon_type = "invalid" + elif self.is_new: + icon_type = "new" + else: + icon_type = "default" + return self.__class__._name_icons[icon_type] + + def add_child(self, item, row=None): + raise AssertionError("BUG: Can't add children to Task") + + def _get_global_data(self, role): + if role == REMOVED_ROLE: + return self._removed + + if role == QtCore.Qt.ToolTipRole: + if not self._data["type"]: + return "Type is not set" + + name = self.data(QtCore.Qt.EditRole, "name") + if not name: + return "Name is not set" + + elif self._is_duplicated: + return "Duplicated task name \"{}".format(name) + return None + + return super(TaskItem, self)._get_global_data(role) + + def to_doc_data(self): + if self._removed: + return {} + data = copy.deepcopy(self._data) + data.pop("name") + name = self.data(QtCore.Qt.EditRole, "name") + return { + name: data + } + + def data(self, role, key=None): + if role == EDITOR_OPENED_ROLE: + if key not in self._edited_columns: + return False + return self._edited_columns[key] + + if role == QtCore.Qt.DisplayRole and self._edited_columns.get(key): + return "" + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + if key == "type": + return self._data["type"] + + if key == "name": + if not self._data["type"]: + if role == QtCore.Qt.DisplayRole: + return "< Select Type >" + if role == QtCore.Qt.EditRole: + return "" + else: + return self._data[key] or self._data["type"] + + return super(TaskItem, self).data(role, key) + + def setData(self, value, role, key=None): + if role == EDITOR_OPENED_ROLE: + if key not in self._edited_columns: + return False + self._edited_columns[key] = value + return True + + if role == REMOVED_ROLE: + if value == self._removed: + return False + self._removed = value + self.parent().on_task_remove_state_change(self) + return True + + if ( + role == QtCore.Qt.EditRole + and key == "name" + and not value + ): + value = None + + result = super(TaskItem, self).setData(value, role, key) + + if role == QtCore.Qt.EditRole: + if ( + key == "name" + or (key == "type" and not self._data["name"]) + ): + self.parent().on_task_name_change(self) + + return result + + def to_json_data(self): + """Convert json data without parent reference. + + Method used for mime data on copy/paste + """ + return self.to_doc_data() diff --git a/openpype/tools/project_manager/project_manager/multiselection_combobox.py b/openpype/tools/project_manager/project_manager/multiselection_combobox.py new file mode 100644 index 0000000000..b26976d3c6 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/multiselection_combobox.py @@ -0,0 +1,215 @@ +from Qt import QtCore, QtGui, QtWidgets + + +class ComboItemDelegate(QtWidgets.QStyledItemDelegate): + """ + Helper styled delegate (mostly based on existing private Qt's + delegate used by the QtWidgets.QComboBox). Used to style the popup like a + list view (e.g windows style). + """ + + def paint(self, painter, option, index): + option = QtWidgets.QStyleOptionViewItem(option) + option.showDecorationSelected = True + + # option.state &= ( + # ~QtWidgets.QStyle.State_HasFocus + # & ~QtWidgets.QStyle.State_MouseOver + # ) + super(ComboItemDelegate, self).paint(painter, option, index) + + +class MultiSelectionComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal() + ignored_keys = { + QtCore.Qt.Key_Up, + QtCore.Qt.Key_Down, + QtCore.Qt.Key_PageDown, + QtCore.Qt.Key_PageUp, + QtCore.Qt.Key_Home, + QtCore.Qt.Key_End + } + + def __init__(self, parent=None, **kwargs): + super(MultiSelectionComboBox, self).__init__(parent=parent, **kwargs) + self.setObjectName("MultiSelectionComboBox") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + self._popup_is_shown = False + self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True) + self._initial_mouse_pos = None + self._delegate = ComboItemDelegate(self) + self.setItemDelegate(self._delegate) + + def mousePressEvent(self, event): + """Reimplemented.""" + self._popup_is_shown = False + super(MultiSelectionComboBox, self).mousePressEvent(event) + if self._popup_is_shown: + self._initial_mouse_pos = self.mapToGlobal(event.pos()) + self._block_mouse_release_timer.start( + QtWidgets.QApplication.doubleClickInterval() + ) + + def showPopup(self): + """Reimplemented.""" + super(MultiSelectionComboBox, self).showPopup() + view = self.view() + view.installEventFilter(self) + view.viewport().installEventFilter(self) + self._popup_is_shown = True + + def hidePopup(self): + """Reimplemented.""" + self.view().removeEventFilter(self) + self.view().viewport().removeEventFilter(self) + self._popup_is_shown = False + self._initial_mouse_pos = None + super(MultiSelectionComboBox, self).hidePopup() + self.view().clearFocus() + + def _event_popup_shown(self, obj, event): + if not self._popup_is_shown: + return + + current_index = self.view().currentIndex() + model = self.model() + + if event.type() == QtCore.QEvent.MouseMove: + if ( + self.view().isVisible() + and self._initial_mouse_pos is not None + and self._block_mouse_release_timer.isActive() + ): + diff = obj.mapToGlobal(event.pos()) - self._initial_mouse_pos + if diff.manhattanLength() > 9: + self._block_mouse_release_timer.stop() + return + + index_flags = current_index.flags() + state = current_index.data(QtCore.Qt.CheckStateRole) + new_state = None + + if event.type() == QtCore.QEvent.MouseButtonRelease: + if ( + self._block_mouse_release_timer.isActive() + or not current_index.isValid() + or not self.view().isVisible() + or not self.view().rect().contains(event.pos()) + or not index_flags & QtCore.Qt.ItemIsSelectable + or not index_flags & QtCore.Qt.ItemIsEnabled + or not index_flags & QtCore.Qt.ItemIsUserCheckable + ): + return + + if state == QtCore.Qt.Unchecked: + new_state = QtCore.Qt.Checked + else: + new_state = QtCore.Qt.Unchecked + + elif event.type() == QtCore.QEvent.KeyPress: + # TODO: handle QtCore.Qt.Key_Enter, Key_Return? + if event.key() == QtCore.Qt.Key_Space: + # toogle the current items check state + if ( + index_flags & QtCore.Qt.ItemIsUserCheckable + and index_flags & QtCore.Qt.ItemIsTristate + ): + new_state = QtCore.Qt.CheckState((int(state) + 1) % 3) + + elif index_flags & QtCore.Qt.ItemIsUserCheckable: + if state != QtCore.Qt.Checked: + new_state = QtCore.Qt.Checked + else: + new_state = QtCore.Qt.Unchecked + + if new_state is not None: + model.setData(current_index, new_state, QtCore.Qt.CheckStateRole) + self.view().update(current_index) + self.value_changed.emit() + return True + + def eventFilter(self, obj, event): + """Reimplemented.""" + result = self._event_popup_shown(obj, event) + if result is not None: + return result + + return super(MultiSelectionComboBox, self).eventFilter(obj, event) + + def addItem(self, *args, **kwargs): + idx = self.count() + super(MultiSelectionComboBox, self).addItem(*args, **kwargs) + self.model().item(idx).setCheckable(True) + + def paintEvent(self, event): + """Reimplemented.""" + painter = QtWidgets.QStylePainter(self) + option = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(option) + painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) + + # draw the icon and text + items = self.checked_items_text() + if not items: + return + + text_rect = self.style().subControlRect( + QtWidgets.QStyle.CC_ComboBox, + option, + QtWidgets.QStyle.SC_ComboBoxEditField + ) + text = ", ".join(items) + new_text = self.fontMetrics().elidedText( + text, QtCore.Qt.ElideRight, text_rect.width() + ) + painter.drawText( + text_rect, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, + new_text + ) + + def setItemCheckState(self, index, state): + self.setItemData(index, state, QtCore.Qt.CheckStateRole) + + def set_value(self, values): + for idx in range(self.count()): + value = self.itemData(idx, role=QtCore.Qt.UserRole) + if value in values: + check_state = QtCore.Qt.Checked + else: + check_state = QtCore.Qt.Unchecked + self.setItemData(idx, check_state, QtCore.Qt.CheckStateRole) + + def value(self): + items = list() + for idx in range(self.count()): + state = self.itemData(idx, role=QtCore.Qt.CheckStateRole) + if state == QtCore.Qt.Checked: + items.append( + self.itemData(idx, role=QtCore.Qt.UserRole) + ) + return items + + def checked_items_text(self): + items = list() + for idx in range(self.count()): + state = self.itemData(idx, role=QtCore.Qt.CheckStateRole) + if state == QtCore.Qt.Checked: + items.append(self.itemText(idx)) + return items + + def wheelEvent(self, event): + event.ignore() + + def keyPressEvent(self, event): + if ( + event.key() == QtCore.Qt.Key_Down + and event.modifiers() & QtCore.Qt.AltModifier + ): + return self.showPopup() + + if event.key() in self.ignored_keys: + return event.ignore() + + return super(MultiSelectionComboBox, self).keyPressEvent(event) diff --git a/openpype/tools/project_manager/project_manager/style/__init__.py b/openpype/tools/project_manager/project_manager/style/__init__.py new file mode 100644 index 0000000000..b686967ddd --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/__init__.py @@ -0,0 +1,98 @@ +import os +from openpype import resources +from avalon.vendor import qtawesome + + +class ResourceCache: + colors = { + "standard": "#333333", + "new": "#2d9a4c", + "warning": "#c83232" + } + icons = None + + @classmethod + def get_icon(cls, *keys): + output = cls.get_icons() + for key in keys: + output = output[key] + return output + + @classmethod + def get_icons(cls): + if cls.icons is None: + cls.icons = { + "asset": { + "default": qtawesome.icon( + "fa.folder", + color=cls.colors["standard"] + ), + "new": qtawesome.icon( + "fa.folder", + color=cls.colors["new"] + ), + "invalid": qtawesome.icon( + "fa.exclamation-triangle", + color=cls.colors["warning"] + ), + "removed": qtawesome.icon( + "fa.trash", + color=cls.colors["warning"] + ) + }, + "task": { + "default": qtawesome.icon( + "fa.check-circle-o", + color=cls.colors["standard"] + ), + "new": qtawesome.icon( + "fa.check-circle", + color=cls.colors["new"] + ), + "invalid": qtawesome.icon( + "fa.exclamation-circle", + color=cls.colors["warning"] + ), + "removed": qtawesome.icon( + "fa.trash", + color=cls.colors["warning"] + ) + }, + "refresh": qtawesome.icon( + "fa.refresh", + color=cls.colors["standard"] + ) + } + return cls.icons + + @classmethod + def get_color(cls, color_name): + return cls.colors[color_name] + + @classmethod + def style_fill_data(cls): + output = {} + for color_name, color_value in cls.colors.items(): + key = "color:{}".format(color_name) + output[key] = color_value + return output + + +def load_stylesheet(): + from . import qrc_resources + + qrc_resources.qInitResources() + + current_dir = os.path.dirname(os.path.abspath(__file__)) + style_path = os.path.join(current_dir, "style.css") + with open(style_path, "r") as style_file: + stylesheet = style_file.read() + + for key, value in ResourceCache.style_fill_data().items(): + replacement_key = "{" + key + "}" + stylesheet = stylesheet.replace(replacement_key, value) + return stylesheet + + +def app_icon_path(): + return resources.pype_icon_filepath() diff --git a/openpype/tools/project_manager/project_manager/style/images/combobox_arrow.png b/openpype/tools/project_manager/project_manager/style/images/combobox_arrow.png new file mode 100644 index 0000000000..5805d9842b Binary files /dev/null and b/openpype/tools/project_manager/project_manager/style/images/combobox_arrow.png differ diff --git a/openpype/tools/project_manager/project_manager/style/images/combobox_arrow_disabled.png b/openpype/tools/project_manager/project_manager/style/images/combobox_arrow_disabled.png new file mode 100644 index 0000000000..e271f7f90b Binary files /dev/null and b/openpype/tools/project_manager/project_manager/style/images/combobox_arrow_disabled.png differ diff --git a/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py b/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py new file mode 100644 index 0000000000..836934019d --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- + +# Resource object code +# +# Created by: The Resource Compiler for PyQt5 (Qt v5.15.2) +# +# WARNING! All changes made in this file will be lost! + +from PyQt5 import QtCore + + +qt_resource_data = b"\ +\x00\x00\x00\xa5\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\x9c\x53\x34\xfc\x5d\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x0b\x02\x04\x6d\ +\x98\x1b\x69\x00\x00\x00\x29\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18\x32\x32\x30\x20\x0b\x32\x1a\ +\x32\x30\x30\x42\x98\x10\x41\x46\x43\x14\x13\x50\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2\x2f\x48\xdf\x4a\x00\x00\x00\x00\x49\x45\x4e\x44\ +\xae\x42\x60\x82\ +\x00\x00\x00\xa6\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x3b\xdc\ +\x3b\x0c\x9b\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\x73\x3e\x20\x0b\xa4\x08\x30\x32\x30\x20\x0b\xa6\ +\x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ +" + + +qt_resource_name = b"\ +\x00\x08\ +\x06\xc5\x8e\xa5\ +\x00\x6f\ +\x00\x70\x00\x65\x00\x6e\x00\x70\x00\x79\x00\x70\x00\x65\ +\x00\x06\ +\x07\x03\x7d\xc3\ +\x00\x69\ +\x00\x6d\x00\x61\x00\x67\x00\x65\x00\x73\ +\x00\x12\ +\x01\x2e\x03\x27\ +\x00\x63\ +\x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\ +\x00\x67\ +\x00\x1b\ +\x03\x5a\x32\x27\ +\x00\x63\ +\x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\ +\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +" + + +qt_resource_struct_v1 = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x03\ +\x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x00\x52\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa9\ +" + + +qt_resource_struct_v2 = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x01\x76\x41\x9d\xa2\x35\ +\x00\x00\x00\x52\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa9\ +\x00\x00\x01\x76\x41\x9d\xa2\x35\ +" + + +qt_version = [int(v) for v in QtCore.qVersion().split('.')] +if qt_version < [5, 8, 0]: + rcc_version = 1 + qt_resource_struct = qt_resource_struct_v1 +else: + rcc_version = 2 + qt_resource_struct = qt_resource_struct_v2 + + +def qInitResources(): + QtCore.qRegisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) + + +def qCleanupResources(): + QtCore.qUnregisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) diff --git a/openpype/tools/project_manager/project_manager/style/pyside2_resources.py b/openpype/tools/project_manager/project_manager/style/pyside2_resources.py new file mode 100644 index 0000000000..b73d5e334a --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/pyside2_resources.py @@ -0,0 +1,84 @@ +# Resource object code (Python 3) +# Created by: object code +# Created by: The Resource Compiler for Qt version 5.15.2 +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore + + +qt_resource_data = b"\ +\x00\x00\x00\xa5\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\x9cS4\xfc]\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x0b\x02\x04m\ +\x98\x1bi\x00\x00\x00)IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18220 \x0b2\x1a\ +200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ +\xaeB`\x82\ +\x00\x00\x00\xa6\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ +;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ +\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +" + + +qt_resource_name = b"\ +\x00\x08\ +\x06\xc5\x8e\xa5\ +\x00o\ +\x00p\x00e\x00n\x00p\x00y\x00p\x00e\ +\x00\x06\ +\x07\x03}\xc3\ +\x00i\ +\x00m\x00a\x00g\x00e\x00s\ +\x00\x12\ +\x01.\x03'\ +\x00c\ +\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\ +\x00g\ +\x00\x1b\ +\x03Z2'\ +\x00c\ +\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\ +\x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ +" + + +qt_resource_struct = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00(\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x01vA\x9d\xa25\ +\x00\x00\x00R\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa9\ +\x00\x00\x01vA\x9d\xa25\ +" + + +def qInitResources(): + QtCore.qRegisterResourceData( + 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + ) + + +def qCleanupResources(): + QtCore.qUnregisterResourceData( + 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + ) diff --git a/openpype/tools/project_manager/project_manager/style/qrc_resources.py b/openpype/tools/project_manager/project_manager/style/qrc_resources.py new file mode 100644 index 0000000000..a9e219c9ad --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/qrc_resources.py @@ -0,0 +1,32 @@ +import Qt + + +initialized = False +resources = None +if Qt.__binding__ == "PySide2": + from . import pyside2_resources as resources +elif Qt.__binding__ == "PyQt5": + from . import pyqt5_resources as resources + + +def qInitResources(): + global resources + global initialized + if resources is not None and not initialized: + initialized = True + resources.qInitResources() + + +def qCleanupResources(): + global resources + global initialized + if resources is not None: + initialized = False + resources.qCleanupResources() + + +__all__ = ( + "resources", + "qInitResources", + "qCleanupResources" +) diff --git a/openpype/tools/project_manager/project_manager/style/resources.qrc b/openpype/tools/project_manager/project_manager/style/resources.qrc new file mode 100644 index 0000000000..9281c69479 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/resources.qrc @@ -0,0 +1,6 @@ + + + images/combobox_arrow.png + images/combobox_arrow_disabled.png + + diff --git a/openpype/tools/project_manager/project_manager/style/style.css b/openpype/tools/project_manager/project_manager/style/style.css new file mode 100644 index 0000000000..31196b7cc6 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/style.css @@ -0,0 +1,21 @@ +QTreeView::item { + padding-top: 3px; + padding-bottom: 3px; + padding-right: 3px; +} + + +QTreeView::item:selected, QTreeView::item:selected:!active { + background: rgba(0, 122, 204, 127); + color: black; +} + +#RefreshBtn { + padding: 2px; +} + +#TypeEditor, #ToolEditor, #NameEditor, #NumberEditor { + background: transparent; + border: 1px solid #005c99; + border-radius: 0.3em; +} diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py new file mode 100644 index 0000000000..70af11e68d --- /dev/null +++ b/openpype/tools/project_manager/project_manager/view.py @@ -0,0 +1,643 @@ +import collections +from queue import Queue + +from Qt import QtWidgets, QtCore, QtGui + +from .delegates import ( + NumberDelegate, + NameDelegate, + TypeDelegate, + ToolsDelegate +) + +from openpype.lib import ApplicationManager +from .constants import ( + REMOVED_ROLE, + IDENTIFIER_ROLE, + ITEM_TYPE_ROLE, + HIERARCHY_CHANGE_ABLE_ROLE, + EDITOR_OPENED_ROLE +) + + +class NameDef: + pass + + +class NumberDef: + def __init__(self, minimum=None, maximum=None, decimals=None): + self.minimum = 0 if minimum is None else minimum + self.maximum = 999999 if maximum is None else maximum + self.decimals = 0 if decimals is None else decimals + + +class TypeDef: + pass + + +class ToolsDef: + pass + + +class ProjectDocCache: + def __init__(self, dbcon): + self.dbcon = dbcon + self.project_doc = None + + def set_project(self, project_name): + self.project_doc = None + + if not project_name: + return + + self.project_doc = self.dbcon.database[project_name].find_one( + {"type": "project"} + ) + + +class ToolsCache: + def __init__(self): + self.tools_data = [] + + def refresh(self): + app_manager = ApplicationManager() + tools_data = [] + for tool_name, tool in app_manager.tools.items(): + tools_data.append( + (tool_name, tool.label) + ) + self.tools_data = tools_data + + +class HierarchyView(QtWidgets.QTreeView): + """A tree view that deselects on clicking on an empty area in the view""" + column_delegate_defs = { + "name": NameDef(), + "type": TypeDef(), + "frameStart": NumberDef(1), + "frameEnd": NumberDef(1), + "fps": NumberDef(1, decimals=2), + "resolutionWidth": NumberDef(0), + "resolutionHeight": NumberDef(0), + "handleStart": NumberDef(0), + "handleEnd": NumberDef(0), + "clipIn": NumberDef(1), + "clipOut": NumberDef(1), + "pixelAspect": NumberDef(0, decimals=2), + "tools_env": ToolsDef() + } + + columns_sizes = { + "default": { + "stretch": QtWidgets.QHeaderView.ResizeToContents + }, + "name": { + "stretch": QtWidgets.QHeaderView.Stretch + }, + "type": { + "stretch": QtWidgets.QHeaderView.Interactive, + "width": 100 + }, + "tools_env": { + "stretch": QtWidgets.QHeaderView.Interactive, + "width": 140 + }, + "pixelAspect": { + "stretch": QtWidgets.QHeaderView.Interactive, + "width": 80 + } + } + persistent_columns = { + "type", + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } + + def __init__(self, dbcon, source_model, parent): + super(HierarchyView, self).__init__(parent) + # Direct access to model + self._source_model = source_model + self._editors_mapping = {} + self._persisten_editors = set() + # Access to parent because of `show_message` method + self._parent = parent + + project_doc_cache = ProjectDocCache(dbcon) + tools_cache = ToolsCache() + + main_delegate = QtWidgets.QStyledItemDelegate() + self.setItemDelegate(main_delegate) + self.setAlternatingRowColors(True) + self.setSelectionMode(HierarchyView.ExtendedSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + column_delegates = {} + column_key_to_index = {} + for key, item_type in self.column_delegate_defs.items(): + if isinstance(item_type, NameDef): + delegate = NameDelegate() + + elif isinstance(item_type, NumberDef): + delegate = NumberDelegate( + item_type.minimum, + item_type.maximum, + item_type.decimals + ) + + elif isinstance(item_type, TypeDef): + delegate = TypeDelegate(project_doc_cache) + + elif isinstance(item_type, ToolsDef): + delegate = ToolsDelegate(tools_cache) + + column = self._source_model.columns.index(key) + self.setItemDelegateForColumn(column, delegate) + column_delegates[key] = delegate + column_key_to_index[key] = column + + source_model.index_moved.connect(self._on_rows_moved) + self.customContextMenuRequested.connect(self._on_context_menu) + self._source_model.project_changed.connect(self._on_project_reset) + + self._project_doc_cache = project_doc_cache + self._tools_cache = tools_cache + + self._delegate = main_delegate + self._column_delegates = column_delegates + self._column_key_to_index = column_key_to_index + + def header_init(self): + header = self.header() + header.setStretchLastSection(False) + + default_behavior = self.columns_sizes["default"] + widths_by_idx = {} + for idx in range(header.count()): + key = self._source_model.columns[idx] + behavior = self.columns_sizes.get(key, default_behavior) + logical_index = header.logicalIndex(idx) + stretch = behavior["stretch"] + header.setSectionResizeMode(logical_index, stretch) + width = behavior.get("width") + if width is not None: + widths_by_idx[idx] = width + + for idx, width in widths_by_idx.items(): + self.setColumnWidth(idx, width) + + def set_project(self, project_name): + # Trigger helpers first + self._project_doc_cache.set_project(project_name) + self._tools_cache.refresh() + + # Trigger update of model after all data for delegates are filled + self._source_model.set_project(project_name) + + def _on_project_reset(self): + self.header_init() + + self.collapseAll() + + project_item = self._source_model.project_item + if project_item: + index = self._source_model.index_for_item(project_item) + self.expand(index) + + def _on_rows_moved(self, index): + parent_index = index.parent() + if not self.isExpanded(parent_index): + self.expand(parent_index) + + def commitData(self, editor): + super(HierarchyView, self).commitData(editor) + current_index = self.currentIndex() + column = current_index.column() + row = current_index.row() + skipped_index = None + # Change column from "type" to "name" + if column == 1: + new_index = self._source_model.index( + current_index.row(), + 0, + current_index.parent() + ) + self.setCurrentIndex(new_index) + elif column > 0: + indexes = [] + for index in self.selectedIndexes(): + if index.column() == column: + if index.row() == row: + skipped_index = index + else: + indexes.append(index) + + if skipped_index is not None: + value = current_index.data(QtCore.Qt.EditRole) + for index in indexes: + index.model().setData(index, value, QtCore.Qt.EditRole) + + # Update children data + self.updateEditorData() + + def _deselect_editor(self, editor): + if editor: + if isinstance( + editor, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox) + ): + line_edit = editor.findChild(QtWidgets.QLineEdit) + line_edit.deselect() + + elif isinstance(editor, QtWidgets.QLineEdit): + editor.deselect() + + def edit(self, index, *args, **kwargs): + result = super(HierarchyView, self).edit(index, *args, **kwargs) + if result: + # Mark index to not return text for DisplayRole + editor = self.indexWidget(index) + if ( + editor not in self._persisten_editors + and editor not in self._editors_mapping + ): + self._editors_mapping[editor] = index + self._source_model.setData(index, True, EDITOR_OPENED_ROLE) + # Deselect content of editor + # QUESTION not sure if we want do this all the time + self._deselect_editor(editor) + return result + + def closeEditor(self, editor, hint): + if ( + editor not in self._persisten_editors + and editor in self._editors_mapping + ): + index = self._editors_mapping.pop(editor) + self._source_model.setData(index, False, EDITOR_OPENED_ROLE) + super(HierarchyView, self).closeEditor(editor, hint) + + def openPersistentEditor(self, index): + self._source_model.setData(index, True, EDITOR_OPENED_ROLE) + super(HierarchyView, self).openPersistentEditor(index) + editor = self.indexWidget(index) + self._persisten_editors.add(editor) + self._deselect_editor(editor) + + def closePersistentEditor(self, index): + self._source_model.setData(index, False, EDITOR_OPENED_ROLE) + editor = self.indexWidget(index) + self._persisten_editors.remove(editor) + super(HierarchyView, self).closePersistentEditor(index) + + def rowsInserted(self, parent_index, start, end): + super(HierarchyView, self).rowsInserted(parent_index, start, end) + + for row in range(start, end + 1): + for key, column in self._column_key_to_index.items(): + if key not in self.persistent_columns: + continue + col_index = self._source_model.index(row, column, parent_index) + if bool( + self._source_model.flags(col_index) + & QtCore.Qt.ItemIsEditable + ): + self.openPersistentEditor(col_index) + + # Expand parent on insert + if not self.isExpanded(parent_index): + self.expand(parent_index) + + def mousePressEvent(self, event): + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + + super(HierarchyView, self).mousePressEvent(event) + + def keyPressEvent(self, event): + call_super = False + if event.key() == QtCore.Qt.Key_Delete: + self._delete_items() + + elif event.matches(QtGui.QKeySequence.Copy): + self._copy_items() + + elif event.matches(QtGui.QKeySequence.Paste): + self._paste_items() + + elif event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + mdfs = event.modifiers() + if mdfs == (QtCore.Qt.ShiftModifier | QtCore.Qt.ControlModifier): + self._on_ctrl_shift_enter_pressed() + elif mdfs == QtCore.Qt.ShiftModifier: + self._on_shift_enter_pressed() + else: + if self.state() == HierarchyView.NoState: + self._on_enter_pressed() + + elif event.modifiers() == QtCore.Qt.ControlModifier: + if event.key() == QtCore.Qt.Key_Left: + self._on_left_ctrl_pressed() + elif event.key() == QtCore.Qt.Key_Right: + self._on_right_ctrl_pressed() + elif event.key() == QtCore.Qt.Key_Up: + self._on_up_ctrl_pressed() + elif event.key() == QtCore.Qt.Key_Down: + self._on_down_ctrl_pressed() + else: + call_super = True + + if call_super: + super(HierarchyView, self).keyPressEvent(event) + else: + event.accept() + + def _copy_items(self, indexes=None): + try: + if indexes is None: + indexes = self.selectedIndexes() + mime_data = self._source_model.copy_mime_data(indexes) + + QtWidgets.QApplication.clipboard().setMimeData(mime_data) + self._show_message("Tasks copied") + except ValueError as exc: + self._show_message(str(exc)) + + def _paste_items(self): + index = self.currentIndex() + mime_data = QtWidgets.QApplication.clipboard().mimeData() + self._source_model.paste_mime_data(index, mime_data) + + def _delete_items(self, indexes=None): + if indexes is None: + indexes = self.selectedIndexes() + self._source_model.delete_indexes(indexes) + + def _on_ctrl_shift_enter_pressed(self): + self._add_task_and_edit() + + def add_asset(self, parent_index=None): + if parent_index is None: + parent_index = self.currentIndex() + + if not parent_index.isValid(): + return + + # Stop editing + self.setState(HierarchyView.NoState) + QtWidgets.QApplication.processEvents() + + return self._source_model.add_new_asset(parent_index) + + def add_task(self, parent_index=None): + if parent_index is None: + parent_index = self.currentIndex() + + if not parent_index.isValid(): + return + + return self._source_model.add_new_task(parent_index) + + def _add_asset_and_edit(self, parent_index=None): + new_index = self.add_asset(parent_index) + if new_index is None: + return + + # Change current index + self.selectionModel().setCurrentIndex( + new_index, + QtCore.QItemSelectionModel.Clear + | QtCore.QItemSelectionModel.Select + ) + # Start editing + self.edit(new_index) + + def _add_task_and_edit(self): + new_index = self.add_task() + if new_index is None: + return + + # Stop editing + self.setState(HierarchyView.NoState) + QtWidgets.QApplication.processEvents() + + # TODO change hardcoded column index to coded + task_type_index = self._source_model.index( + new_index.row(), 1, new_index.parent() + ) + # Change current index + self.selectionModel().setCurrentIndex( + task_type_index, + QtCore.QItemSelectionModel.Clear + | QtCore.QItemSelectionModel.Select + ) + # Start editing + self.edit(task_type_index) + + def _on_shift_enter_pressed(self): + parent_index = self.currentIndex() + if not parent_index.isValid(): + return + + if parent_index.data(ITEM_TYPE_ROLE) == "asset": + parent_index = parent_index.parent() + self._add_asset_and_edit(parent_index) + + def _on_up_ctrl_pressed(self): + indexes = self.selectedIndexes() + self._source_model.move_vertical(indexes, -1) + + def _on_down_ctrl_pressed(self): + indexes = self.selectedIndexes() + self._source_model.move_vertical(indexes, 1) + + def _on_left_ctrl_pressed(self): + indexes = self.selectedIndexes() + self._source_model.move_horizontal(indexes, -1) + + def _on_right_ctrl_pressed(self): + indexes = self.selectedIndexes() + self._source_model.move_horizontal(indexes, 1) + + def _on_enter_pressed(self): + index = self.currentIndex() + if ( + index.isValid() + and index.flags() & QtCore.Qt.ItemIsEditable + ): + self.edit(index) + + def _remove_delete_flag(self, item_ids): + """Remove deletion flag on items marked for deletion.""" + self._source_model.remove_delete_flag(item_ids) + + def _expand_items(self, indexes): + """Expand multiple items with all it's children. + + Args: + indexes (list): List of QModelIndex that should be expanded. + """ + process_queue = Queue() + for index in indexes: + if index.column() == 0: + process_queue.put(index) + + item_ids = set() + # Use deque as expanding not visible items as first is faster + indexes_deque = collections.deque() + while not process_queue.empty(): + index = process_queue.get() + item_id = index.data(IDENTIFIER_ROLE) + if item_id in item_ids: + continue + item_ids.add(item_id) + + indexes_deque.append(index) + + for row in range(self._source_model.rowCount(index)): + process_queue.put(self._source_model.index( + row, 0, index + )) + + while indexes_deque: + self.expand(indexes_deque.pop()) + + def _collapse_items(self, indexes): + """Collapse multiple items with all it's children. + + Args: + indexes (list): List of QModelIndex that should be collapsed. + """ + item_ids = set() + process_queue = Queue() + for index in indexes: + if index.column() == 0: + process_queue.put(index) + + while not process_queue.empty(): + index = process_queue.get() + item_id = index.data(IDENTIFIER_ROLE) + if item_id in item_ids: + continue + item_ids.add(item_id) + + self.collapse(index) + + for row in range(self._source_model.rowCount(index)): + process_queue.put(self._source_model.index( + row, 0, index + )) + + def _show_message(self, message): + """Show message to user.""" + self._parent.show_message(message) + + def _on_context_menu(self, point): + """Context menu on right click. + + Currently is menu shown only on "name" column. + """ + index = self.indexAt(point) + column = index.column() + if column != 0: + return + + actions = [] + + context_menu = QtWidgets.QMenu(self) + + indexes = self.selectedIndexes() + + items_by_id = {} + for index in indexes: + if index.column() != column: + continue + + item_id = index.data(IDENTIFIER_ROLE) + items_by_id[item_id] = self._source_model.items_by_id[item_id] + + item_ids = tuple(items_by_id.keys()) + if len(item_ids) == 1: + item = items_by_id[item_ids[0]] + item_type = item.data(ITEM_TYPE_ROLE) + if item_type in ("asset", "project"): + add_asset_action = QtWidgets.QAction("Add Asset", context_menu) + add_asset_action.triggered.connect( + self._add_asset_and_edit + ) + actions.append(add_asset_action) + + if item_type in ("asset", "task"): + add_task_action = QtWidgets.QAction("Add Task", context_menu) + add_task_action.triggered.connect( + self._add_task_and_edit + ) + actions.append(add_task_action) + + # Remove delete tag on items + removed_item_ids = [] + show_delete_items = False + for item_id, item in items_by_id.items(): + if item.data(REMOVED_ROLE): + removed_item_ids.append(item_id) + elif ( + not show_delete_items + and item.data(ITEM_TYPE_ROLE) != "project" + and item.data(HIERARCHY_CHANGE_ABLE_ROLE) + ): + show_delete_items = True + + if show_delete_items: + action = QtWidgets.QAction("Delete items", context_menu) + action.triggered.connect( + lambda: self._delete_items() + ) + actions.append(action) + + if removed_item_ids: + action = QtWidgets.QAction("Keep items", context_menu) + action.triggered.connect( + lambda: self._remove_delete_flag(removed_item_ids) + ) + actions.append(action) + + # Collapse/Expand action + show_collapse_expand_action = False + for item_id in item_ids: + item = items_by_id[item_id] + item_type = item.data(ITEM_TYPE_ROLE) + if item_type != "task": + show_collapse_expand_action = True + break + + if show_collapse_expand_action: + expand_action = QtWidgets.QAction("Expand all", context_menu) + collapse_action = QtWidgets.QAction("Collapse all", context_menu) + expand_action.triggered.connect( + lambda: self._expand_items(indexes) + ) + collapse_action.triggered.connect( + lambda: self._collapse_items(indexes) + ) + actions.append(expand_action) + actions.append(collapse_action) + + if not actions: + return + + for action in actions: + context_menu.addAction(action) + + global_point = self.viewport().mapToGlobal(point) + context_menu.exec_(global_point) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py new file mode 100644 index 0000000000..9c57febcf6 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -0,0 +1,281 @@ +import re + +from .constants import ( + NAME_ALLOWED_SYMBOLS, + NAME_REGEX +) +from openpype.lib import ( + create_project, + PROJECT_NAME_ALLOWED_SYMBOLS, + PROJECT_NAME_REGEX +) +from avalon.api import AvalonMongoDB + +from Qt import QtWidgets, QtCore + + +class NameTextEdit(QtWidgets.QLineEdit): + def __init__(self, *args, **kwargs): + super(NameTextEdit, self).__init__(*args, **kwargs) + + self.textChanged.connect(self._on_text_change) + + def _on_text_change(self, text): + if NAME_REGEX.match(text): + return + + idx = self.cursorPosition() + before_text = text[0:idx] + after_text = text[idx:len(text)] + sub_regex = "[^{}]+".format(NAME_ALLOWED_SYMBOLS) + new_before_text = re.sub(sub_regex, "", before_text) + new_after_text = re.sub(sub_regex, "", after_text) + idx -= (len(before_text) - len(new_before_text)) + + self.setText(new_before_text + new_after_text) + self.setCursorPosition(idx) + + +class FilterComboBox(QtWidgets.QComboBox): + def __init__(self, parent=None): + super(FilterComboBox, self).__init__(parent) + + self._last_value = None + + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setEditable(True) + + filter_proxy_model = QtCore.QSortFilterProxyModel(self) + filter_proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + filter_proxy_model.setSourceModel(self.model()) + + completer = QtWidgets.QCompleter(filter_proxy_model, self) + completer.setCompletionMode( + QtWidgets.QCompleter.UnfilteredPopupCompletion + ) + self.setCompleter(completer) + + self.lineEdit().textEdited.connect( + filter_proxy_model.setFilterFixedString + ) + completer.activated.connect(self.on_completer_activated) + + self._completer = completer + self._filter_proxy_model = filter_proxy_model + + def focusInEvent(self, event): + super(FilterComboBox, self).focusInEvent(event) + self._last_value = self.lineEdit().text() + self.lineEdit().selectAll() + + def value_cleanup(self): + text = self.lineEdit().text() + idx = self.findText(text) + if idx < 0: + count = self._completer.completionModel().rowCount() + if count > 0: + index = self._completer.completionModel().index(0, 0) + text = index.data(QtCore.Qt.DisplayRole) + idx = self.findText(text) + elif self._last_value is not None: + idx = self.findText(self._last_value) + + if idx < 0: + idx = 0 + self.setCurrentIndex(idx) + + def on_completer_activated(self, text): + if text: + index = self.findText(text) + self.setCurrentIndex(index) + + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + self.value_cleanup() + + super(FilterComboBox, self).keyPressEvent(event) + + def setModel(self, model): + super(FilterComboBox, self).setModel(model) + self._filter_proxy_model.setSourceModel(model) + self._completer.setModel(self._filter_proxy_model) + + def setModelColumn(self, column): + self._completer.setCompletionColumn(column) + self._filter_proxy_model.setFilterKeyColumn(column) + super(FilterComboBox, self).setModelColumn(column) + + +class CreateProjectDialog(QtWidgets.QDialog): + def __init__(self, parent=None, dbcon=None): + super(CreateProjectDialog, self).__init__(parent) + + self.setWindowTitle("Create Project") + + self.allowed_regex = "[^{}]+".format(PROJECT_NAME_ALLOWED_SYMBOLS) + + if dbcon is None: + dbcon = AvalonMongoDB() + + self.dbcon = dbcon + self._ignore_code_change = False + self._project_name_is_valid = False + self._project_code_is_valid = False + self._project_code_value = None + + project_names, project_codes = self._get_existing_projects() + + inputs_widget = QtWidgets.QWidget(self) + project_name_input = QtWidgets.QLineEdit(inputs_widget) + project_code_input = QtWidgets.QLineEdit(inputs_widget) + library_project_input = QtWidgets.QCheckBox(inputs_widget) + + inputs_layout = QtWidgets.QFormLayout(inputs_widget) + inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.addRow("Project name:", project_name_input) + inputs_layout.addRow("Project code:", project_code_input) + inputs_layout.addRow("Library project:", library_project_input) + + project_name_label = QtWidgets.QLabel(self) + project_code_label = QtWidgets.QLabel(self) + + btns_widget = QtWidgets.QWidget(self) + ok_btn = QtWidgets.QPushButton("Ok", btns_widget) + ok_btn.setEnabled(False) + cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget) + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn) + btns_layout.addWidget(cancel_btn) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(inputs_widget, 0) + main_layout.addWidget(project_name_label, 1) + main_layout.addWidget(project_code_label, 1) + main_layout.addStretch(1) + main_layout.addWidget(btns_widget, 0) + + project_name_input.textChanged.connect(self._on_project_name_change) + project_code_input.textChanged.connect(self._on_project_code_change) + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + self.invalid_project_names = project_names + self.invalid_project_codes = project_codes + + self.project_name_label = project_name_label + self.project_code_label = project_code_label + + self.project_name_input = project_name_input + self.project_code_input = project_code_input + self.library_project_input = library_project_input + + self.ok_btn = ok_btn + + @property + def project_name(self): + return self.project_name_input.text() + + def _on_project_name_change(self, value): + if self._project_code_value is None: + self._ignore_code_change = True + self.project_code_input.setText(value.lower()) + self._ignore_code_change = False + + self._update_valid_project_name(value) + + def _on_project_code_change(self, value): + if not value: + value = None + + self._update_valid_project_code(value) + + if not self._ignore_code_change: + self._project_code_value = value + + def _update_valid_project_name(self, value): + message = "" + is_valid = True + if not value: + message = "Project name is empty" + is_valid = False + + elif value in self.invalid_project_names: + message = "Project name \"{}\" already exist".format(value) + is_valid = False + + elif not PROJECT_NAME_REGEX.match(value): + message = ( + "Project name \"{}\" contain not supported symbols" + ).format(value) + is_valid = False + + self._project_name_is_valid = is_valid + self.project_name_label.setText(message) + self.project_name_label.setVisible(bool(message)) + self._enable_button() + + def _update_valid_project_code(self, value): + message = "" + is_valid = True + if not value: + message = "Project code is empty" + is_valid = False + + elif value in self.invalid_project_names: + message = "Project code \"{}\" already exist".format(value) + is_valid = False + + elif not PROJECT_NAME_REGEX.match(value): + message = ( + "Project code \"{}\" contain not supported symbols" + ).format(value) + is_valid = False + + self._project_code_is_valid = is_valid + self.project_code_label.setText(message) + self._enable_button() + + def _enable_button(self): + self.ok_btn.setEnabled( + self._project_name_is_valid and self._project_code_is_valid + ) + + def _on_cancel_clicked(self): + self.done(0) + + def _on_ok_clicked(self): + if not self._project_name_is_valid or not self._project_code_is_valid: + return + + project_name = self.project_name_input.text() + project_code = self.project_code_input.text() + library_project = self.library_project_input.isChecked() + create_project(project_name, project_code, library_project, self.dbcon) + + self.done(1) + + def _get_existing_projects(self): + project_names = set() + project_codes = set() + for project_name in self.dbcon.database.collection_names(): + # Each collection will have exactly one project document + project_doc = self.dbcon.database[project_name].find_one( + {"type": "project"}, + {"name": 1, "data.code": 1} + ) + if not project_doc: + continue + + project_name = project_doc.get("name") + if not project_name: + continue + + project_names.add(project_name) + project_code = project_doc.get("data", {}).get("code") + if not project_code: + project_code = project_name + + project_codes.add(project_code) + return project_names, project_codes diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py new file mode 100644 index 0000000000..a800214517 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/window.py @@ -0,0 +1,176 @@ +from Qt import QtWidgets, QtCore, QtGui + +from . import ( + ProjectModel, + + HierarchyModel, + HierarchySelectionModel, + HierarchyView, + + CreateProjectDialog +) +from .style import load_stylesheet, ResourceCache + +from openpype import resources +from avalon.api import AvalonMongoDB + + +class ProjectManagerWindow(QtWidgets.QWidget): + def __init__(self, parent=None): + super(ProjectManagerWindow, self).__init__(parent) + + self.setWindowTitle("OpenPype Project Manager") + self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) + + # Top part of window + top_part_widget = QtWidgets.QWidget(self) + + # Project selection + project_widget = QtWidgets.QWidget(top_part_widget) + + dbcon = AvalonMongoDB() + + project_model = ProjectModel(dbcon) + project_combobox = QtWidgets.QComboBox(project_widget) + project_combobox.setModel(project_model) + project_combobox.setRootModelIndex(QtCore.QModelIndex()) + + refresh_projects_btn = QtWidgets.QPushButton(project_widget) + refresh_projects_btn.setIcon(ResourceCache.get_icon("refresh")) + refresh_projects_btn.setToolTip("Refresh projects") + refresh_projects_btn.setObjectName("RefreshBtn") + + create_project_btn = QtWidgets.QPushButton( + "Create project...", project_widget + ) + + project_layout = QtWidgets.QHBoxLayout(project_widget) + project_layout.setContentsMargins(0, 0, 0, 0) + project_layout.addWidget(project_combobox, 0) + project_layout.addWidget(refresh_projects_btn, 0) + project_layout.addWidget(create_project_btn, 0) + project_layout.addStretch(1) + + # Helper buttons + helper_btns_widget = QtWidgets.QWidget(top_part_widget) + + helper_label = QtWidgets.QLabel("Add:", helper_btns_widget) + add_asset_btn = QtWidgets.QPushButton( + ResourceCache.get_icon("asset", "default"), + "Asset", + helper_btns_widget + ) + add_task_btn = QtWidgets.QPushButton( + ResourceCache.get_icon("task", "default"), + "Task", + helper_btns_widget + ) + + helper_btns_layout = QtWidgets.QHBoxLayout(helper_btns_widget) + helper_btns_layout.setContentsMargins(0, 0, 0, 0) + helper_btns_layout.addWidget(helper_label) + helper_btns_layout.addWidget(add_asset_btn) + helper_btns_layout.addWidget(add_task_btn) + helper_btns_layout.addStretch(1) + + # Add widgets to top widget layout + top_part_layout = QtWidgets.QVBoxLayout(top_part_widget) + top_part_layout.setContentsMargins(0, 0, 0, 0) + top_part_layout.addWidget(project_widget) + top_part_layout.addWidget(helper_btns_widget) + + hierarchy_model = HierarchyModel(dbcon) + + hierarchy_view = HierarchyView(dbcon, hierarchy_model, self) + hierarchy_view.setModel(hierarchy_model) + + _selection_model = HierarchySelectionModel( + hierarchy_model.multiselection_column_indexes + ) + _selection_model.setModel(hierarchy_view.model()) + hierarchy_view.setSelectionModel(_selection_model) + + buttons_widget = QtWidgets.QWidget(self) + + message_label = QtWidgets.QLabel(buttons_widget) + save_btn = QtWidgets.QPushButton("Save", buttons_widget) + + buttons_layout = QtWidgets.QHBoxLayout(buttons_widget) + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addWidget(message_label) + buttons_layout.addStretch(1) + buttons_layout.addWidget(save_btn) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(top_part_widget) + main_layout.addWidget(hierarchy_view) + main_layout.addWidget(buttons_widget) + + refresh_projects_btn.clicked.connect(self._on_project_refresh) + create_project_btn.clicked.connect(self._on_project_create) + project_combobox.currentIndexChanged.connect(self._on_project_change) + save_btn.clicked.connect(self._on_save_click) + add_asset_btn.clicked.connect(self._on_add_asset) + add_task_btn.clicked.connect(self._on_add_task) + + self.project_model = project_model + self.project_combobox = project_combobox + + self.hierarchy_view = hierarchy_view + self.hierarchy_model = hierarchy_model + + self.message_label = message_label + + self.resize(1200, 600) + self.setStyleSheet(load_stylesheet()) + + self.refresh_projects() + + def _set_project(self, project_name=None): + self.hierarchy_view.set_project(project_name) + + def refresh_projects(self, project_name=None): + if project_name is None: + if self.project_combobox.count() > 0: + project_name = self.project_combobox.currentText() + + self.project_model.refresh() + + if self.project_combobox.count() == 0: + return self._set_project() + + if project_name: + row = self.project_combobox.findText(project_name) + if row >= 0: + self.project_combobox.setCurrentIndex(row) + + self._set_project(self.project_combobox.currentText()) + + def _on_project_change(self): + self._set_project(self.project_combobox.currentText()) + + def _on_project_refresh(self): + self.refresh_projects() + + def _on_save_click(self): + self.hierarchy_model.save() + + def _on_add_asset(self): + self.hierarchy_view.add_asset() + + def _on_add_task(self): + self.hierarchy_view.add_task() + + def show_message(self, message): + # TODO add nicer message pop + self.message_label.setText(message) + + def _on_project_create(self): + dialog = CreateProjectDialog(self) + dialog.exec_() + if dialog.result() != 1: + return + + project_name = dialog.project_name + self.show_message("Created project \"{}\"".format(project_name)) + self.refresh_projects(project_name) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 4762aa4b6b..01d4babd0f 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -21,6 +21,7 @@ from openpype.settings.entities import ( TextEntity, PathInput, RawJsonEntity, + ColorEntity, DefaultsNotDefined, StudioDefaultsNotDefined, @@ -44,7 +45,7 @@ from .item_widgets import ( PathWidget, PathInputWidget ) - +from .color_widget import ColorWidget from avalon.vendor import qtawesome @@ -113,6 +114,9 @@ class SettingsCategoryWidget(QtWidgets.QWidget): elif isinstance(entity, RawJsonEntity): return RawJsonWidget(*args) + elif isinstance(entity, ColorEntity): + return ColorWidget(*args) + elif isinstance(entity, BaseEnumEntity): return EnumeratorWidget(*args) diff --git a/openpype/tools/settings/settings/color_widget.py b/openpype/tools/settings/settings/color_widget.py new file mode 100644 index 0000000000..fa0cd2c989 --- /dev/null +++ b/openpype/tools/settings/settings/color_widget.py @@ -0,0 +1,171 @@ +from Qt import QtWidgets, QtCore, QtGui + +from .item_widgets import InputWidget + +from openpype.widgets.color_widgets import ( + ColorPickerWidget, + draw_checkerboard_tile +) + + +class ColorWidget(InputWidget): + def _add_inputs_to_layout(self): + self.input_field = ColorViewer(self.content_widget) + + self.setFocusProxy(self.input_field) + + self.content_layout.addWidget(self.input_field, 1) + + self.input_field.clicked.connect(self._on_click) + + self._dialog = None + + def _on_click(self): + if self._dialog: + self._dialog.open() + return + + dialog = ColorDialog(self.input_field.color(), self) + self._dialog = dialog + + dialog.open() + dialog.finished.connect(self._on_dialog_finish) + + def _on_dialog_finish(self, *_args): + if not self._dialog: + return + + color = self._dialog.result() + if color is not None: + self.input_field.set_color(color) + self._on_value_change() + + self._dialog.deleteLater() + self._dialog = None + + def _on_entity_change(self): + if self.entity.value != self.input_value(): + self.set_entity_value() + + def set_entity_value(self): + self.input_field.set_color(*self.entity.value) + + def input_value(self): + color = self.input_field.color() + return [color.red(), color.green(), color.blue(), color.alpha()] + + def _on_value_change(self): + if self.ignore_input_changes: + return + + self.entity.set(self.input_value()) + + +class ColorViewer(QtWidgets.QWidget): + clicked = QtCore.Signal() + + def __init__(self, parent=None): + super(ColorViewer, self).__init__(parent) + + self.setMinimumSize(10, 10) + + self.actual_pen = QtGui.QPen() + self.actual_color = QtGui.QColor() + self._checkerboard = None + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.clicked.emit() + super(ColorViewer, self).mouseReleaseEvent(event) + + def checkerboard(self): + if not self._checkerboard: + self._checkerboard = draw_checkerboard_tile(self.height() / 4) + return self._checkerboard + + def color(self): + return self.actual_color + + def set_color(self, *args): + # Create copy of entered color + self.actual_color = QtGui.QColor(*args) + # Repaint + self.update() + + def set_alpha(self, alpha): + # Change alpha of current color + self.actual_color.setAlpha(alpha) + # Repaint + self.update() + + def paintEvent(self, event): + rect = event.rect() + + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + radius = rect.height() / 2 + rounded_rect = QtGui.QPainterPath() + rounded_rect.addRoundedRect(QtCore.QRectF(rect), radius, radius) + painter.setClipPath(rounded_rect) + + pen = QtGui.QPen(QtGui.QColor(255, 255, 255, 67)) + pen.setWidth(1) + painter.setPen(pen) + painter.drawTiledPixmap(rect, self.checkerboard()) + painter.fillRect(rect, self.actual_color) + painter.drawPath(rounded_rect) + + painter.end() + + +class ColorDialog(QtWidgets.QDialog): + def __init__(self, color=None, parent=None): + super(ColorDialog, self).__init__(parent) + + self.setWindowTitle("Color picker dialog") + + picker_widget = ColorPickerWidget(color, self) + + footer_widget = QtWidgets.QWidget(self) + + ok_btn = QtWidgets.QPushButton("Ok", footer_widget) + cancel_btn = QtWidgets.QPushButton("Cancel", footer_widget) + + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + footer_layout.setContentsMargins(0, 0, 0, 0) + footer_layout.addStretch(1) + footer_layout.addWidget(ok_btn) + footer_layout.addWidget(cancel_btn) + + layout = QtWidgets.QVBoxLayout(self) + + layout.addWidget(picker_widget, 1) + layout.addWidget(footer_widget, 0) + + ok_btn.clicked.connect(self.on_ok_clicked) + cancel_btn.clicked.connect(self.on_cancel_clicked) + + self.picker_widget = picker_widget + self.ok_btn = ok_btn + self.cancel_btn = cancel_btn + + self._result = None + + def showEvent(self, event): + super(ColorDialog, self).showEvent(event) + + btns_width = max(self.ok_btn.width(), self.cancel_btn.width()) + self.ok_btn.setFixedWidth(btns_width) + self.cancel_btn.setFixedWidth(btns_width) + + def on_ok_clicked(self): + self._result = self.picker_widget.color() + self.close() + + def on_cancel_clicked(self): + self._result = None + self.close() + + def result(self): + return self._result diff --git a/openpype/version.py b/openpype/version.py index 27186ad2bb..a88ae329d0 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.0.0-rc4" +__version__ = "3.0.0-rc.5" diff --git a/openpype/widgets/color_widgets/__init__.py b/openpype/widgets/color_widgets/__init__.py new file mode 100644 index 0000000000..324b23543d --- /dev/null +++ b/openpype/widgets/color_widgets/__init__.py @@ -0,0 +1,14 @@ +from .color_picker_widget import ( + ColorPickerWidget +) + +from .color_view import ( + draw_checkerboard_tile +) + + +__all__ = ( + "ColorPickerWidget", + + "draw_checkerboard_tile" +) diff --git a/openpype/widgets/color_widgets/color_inputs.py b/openpype/widgets/color_widgets/color_inputs.py new file mode 100644 index 0000000000..eda8c618f1 --- /dev/null +++ b/openpype/widgets/color_widgets/color_inputs.py @@ -0,0 +1,639 @@ +import re +from Qt import QtWidgets, QtCore, QtGui + +from .color_view import draw_checkerboard_tile + + +slide_style = """ +QSlider::groove:horizontal { + background: qlineargradient( + x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #000, stop: 1 #fff + ); + height: 8px; + border-radius: 4px; +} + +QSlider::handle:horizontal { + background: qlineargradient( + x1:0, y1:0, x2:1, y2:1, stop:0 #ddd, stop:1 #bbb + ); + border: 1px solid #777; + width: 8px; + margin-top: -1px; + margin-bottom: -1px; + border-radius: 4px; +} + +QSlider::handle:horizontal:hover { + background: qlineargradient( + x1:0, y1:0, x2:1, y2:1, stop:0 #eee, stop:1 #ddd + ); + border: 1px solid #444;ff + border-radius: 4px; +}""" + + +class AlphaSlider(QtWidgets.QSlider): + def __init__(self, *args, **kwargs): + super(AlphaSlider, self).__init__(*args, **kwargs) + self._mouse_clicked = False + self.setSingleStep(1) + self.setMinimum(0) + self.setMaximum(255) + self.setValue(255) + + self._checkerboard = None + + def checkerboard(self): + if self._checkerboard is None: + self._checkerboard = draw_checkerboard_tile( + 3, QtGui.QColor(173, 173, 173), QtGui.QColor(27, 27, 27) + ) + return self._checkerboard + + def mousePressEvent(self, event): + self._mouse_clicked = True + if event.button() == QtCore.Qt.LeftButton: + self._set_value_to_pos(event.pos().x()) + return event.accept() + return super(AlphaSlider, self).mousePressEvent(event) + + def _set_value_to_pos(self, pos_x): + value = ( + self.maximum() - self.minimum() + ) * pos_x / self.width() + self.minimum() + self.setValue(value) + + def mouseMoveEvent(self, event): + if self._mouse_clicked: + self._set_value_to_pos(event.pos().x()) + super(AlphaSlider, self).mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + self._mouse_clicked = True + super(AlphaSlider, self).mouseReleaseEvent(event) + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + + painter.fillRect(event.rect(), QtCore.Qt.transparent) + + painter.setRenderHint(QtGui.QPainter.SmoothPixmapTransform) + rect = self.style().subControlRect( + QtWidgets.QStyle.CC_Slider, + opt, + QtWidgets.QStyle.SC_SliderGroove, + self + ) + final_height = 9 + offset_top = 0 + if rect.height() > final_height: + offset_top = int((rect.height() - final_height) / 2) + rect = QtCore.QRect( + rect.x(), + offset_top, + rect.width(), + final_height + ) + + pix_rect = QtCore.QRect(event.rect()) + pix_rect.setX(rect.x()) + pix_rect.setWidth(rect.width() - (2 * rect.x())) + pix = QtGui.QPixmap(pix_rect.width(), pix_rect.height()) + pix_painter = QtGui.QPainter(pix) + pix_painter.drawTiledPixmap(pix_rect, self.checkerboard()) + gradient = QtGui.QLinearGradient(rect.topLeft(), rect.bottomRight()) + gradient.setColorAt(0, QtCore.Qt.transparent) + gradient.setColorAt(1, QtCore.Qt.white) + pix_painter.fillRect(pix_rect, gradient) + pix_painter.end() + + brush = QtGui.QBrush(pix) + painter.save() + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(brush) + ratio = rect.height() / 2 + painter.drawRoundedRect(rect, ratio, ratio) + painter.restore() + + _handle_rect = self.style().subControlRect( + QtWidgets.QStyle.CC_Slider, + opt, + QtWidgets.QStyle.SC_SliderHandle, + self + ) + + handle_rect = QtCore.QRect(rect) + if offset_top > 1: + height = handle_rect.height() + handle_rect.setY(handle_rect.y() - 1) + handle_rect.setHeight(height + 2) + handle_rect.setX(_handle_rect.x()) + handle_rect.setWidth(handle_rect.height()) + + painter.save() + + gradient = QtGui.QRadialGradient() + radius = handle_rect.height() / 2 + center_x = handle_rect.width() / 2 + handle_rect.x() + center_y = handle_rect.height() + gradient.setCenter(center_x, center_y) + gradient.setCenterRadius(radius) + gradient.setFocalPoint(center_x, center_y) + + gradient.setColorAt(0.9, QtGui.QColor(127, 127, 127)) + gradient.setColorAt(1, QtCore.Qt.transparent) + + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(gradient) + painter.drawEllipse(handle_rect) + + painter.restore() + + +class AlphaInputs(QtWidgets.QWidget): + alpha_changed = QtCore.Signal(int) + + def __init__(self, parent=None): + super(AlphaInputs, self).__init__(parent) + + self._block_changes = False + self.alpha_value = None + + percent_input = QtWidgets.QDoubleSpinBox(self) + percent_input.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + percent_input.setMinimum(0) + percent_input.setMaximum(100) + percent_input.setDecimals(2) + + int_input = QtWidgets.QSpinBox(self) + int_input.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + int_input.setMinimum(0) + int_input.setMaximum(255) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(int_input) + layout.addWidget(QtWidgets.QLabel("0-255")) + layout.addWidget(percent_input) + layout.addWidget(QtWidgets.QLabel("%")) + + percent_input.valueChanged.connect(self._on_percent_change) + int_input.valueChanged.connect(self._on_int_change) + + self.percent_input = percent_input + self.int_input = int_input + + self.set_alpha(255) + + def set_alpha(self, alpha): + if alpha == self.alpha_value: + return + self.alpha_value = alpha + + self.update_alpha() + + def _on_percent_change(self): + if self._block_changes: + return + self.alpha_value = int(self.percent_input.value() * 255 / 100) + self.alpha_changed.emit(self.alpha_value) + self.update_alpha() + + def _on_int_change(self): + if self._block_changes: + return + + self.alpha_value = self.int_input.value() + self.alpha_changed.emit(self.alpha_value) + self.update_alpha() + + def update_alpha(self): + self._block_changes = True + if self.int_input.value() != self.alpha_value: + self.int_input.setValue(self.alpha_value) + + percent = round(100 * self.alpha_value / 255, 2) + if self.percent_input.value() != percent: + self.percent_input.setValue(percent) + + self._block_changes = False + + +class RGBInputs(QtWidgets.QWidget): + value_changed = QtCore.Signal() + + def __init__(self, color, parent=None): + super(RGBInputs, self).__init__(parent) + + self._block_changes = False + + self.color = color + + input_red = QtWidgets.QSpinBox(self) + input_green = QtWidgets.QSpinBox(self) + input_blue = QtWidgets.QSpinBox(self) + + input_red.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + input_green.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + input_blue.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + + input_red.setMinimum(0) + input_green.setMinimum(0) + input_blue.setMinimum(0) + + input_red.setMaximum(255) + input_green.setMaximum(255) + input_blue.setMaximum(255) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(input_red, 1) + layout.addWidget(input_green, 1) + layout.addWidget(input_blue, 1) + + input_red.valueChanged.connect(self._on_red_change) + input_green.valueChanged.connect(self._on_green_change) + input_blue.valueChanged.connect(self._on_blue_change) + + self.input_red = input_red + self.input_green = input_green + self.input_blue = input_blue + + def _on_red_change(self, value): + if self._block_changes: + return + self.color.setRed(value) + self._on_change() + + def _on_green_change(self, value): + if self._block_changes: + return + self.color.setGreen(value) + self._on_change() + + def _on_blue_change(self, value): + if self._block_changes: + return + self.color.setBlue(value) + self._on_change() + + def _on_change(self): + self.value_changed.emit() + + def color_changed(self): + if ( + self.input_red.value() == self.color.red() + and self.input_green.value() == self.color.green() + and self.input_blue.value() == self.color.blue() + ): + return + + self._block_changes = True + + self.input_red.setValue(self.color.red()) + self.input_green.setValue(self.color.green()) + self.input_blue.setValue(self.color.blue()) + + self._block_changes = False + + +class CMYKInputs(QtWidgets.QWidget): + value_changed = QtCore.Signal() + + def __init__(self, color, parent=None): + super(CMYKInputs, self).__init__(parent) + + self.color = color + + self._block_changes = False + + input_cyan = QtWidgets.QSpinBox(self) + input_magenta = QtWidgets.QSpinBox(self) + input_yellow = QtWidgets.QSpinBox(self) + input_black = QtWidgets.QSpinBox(self) + + input_cyan.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + input_magenta.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + input_yellow.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + input_black.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + + input_cyan.setMinimum(0) + input_magenta.setMinimum(0) + input_yellow.setMinimum(0) + input_black.setMinimum(0) + + input_cyan.setMaximum(255) + input_magenta.setMaximum(255) + input_yellow.setMaximum(255) + input_black.setMaximum(255) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(input_cyan, 1) + layout.addWidget(input_magenta, 1) + layout.addWidget(input_yellow, 1) + layout.addWidget(input_black, 1) + + input_cyan.valueChanged.connect(self._on_change) + input_magenta.valueChanged.connect(self._on_change) + input_yellow.valueChanged.connect(self._on_change) + input_black.valueChanged.connect(self._on_change) + + self.input_cyan = input_cyan + self.input_magenta = input_magenta + self.input_yellow = input_yellow + self.input_black = input_black + + def _on_change(self): + if self._block_changes: + return + self.color.setCmyk( + self.input_cyan.value(), + self.input_magenta.value(), + self.input_yellow.value(), + self.input_black.value() + ) + self.value_changed.emit() + + def color_changed(self): + if self._block_changes: + return + _cur_color = QtGui.QColor() + _cur_color.setCmyk( + self.input_cyan.value(), + self.input_magenta.value(), + self.input_yellow.value(), + self.input_black.value() + ) + if ( + _cur_color.red() == self.color.red() + and _cur_color.green() == self.color.green() + and _cur_color.blue() == self.color.blue() + ): + return + + c, m, y, k, _ = self.color.getCmyk() + self._block_changes = True + + self.input_cyan.setValue(c) + self.input_magenta.setValue(m) + self.input_yellow.setValue(y) + self.input_black.setValue(k) + + self._block_changes = False + + +class HEXInputs(QtWidgets.QWidget): + hex_regex = re.compile("^#(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$") + value_changed = QtCore.Signal() + + def __init__(self, color, parent=None): + super(HEXInputs, self).__init__(parent) + self.color = color + + input_field = QtWidgets.QLineEdit(self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(input_field, 1) + + input_field.textChanged.connect(self._on_change) + + self.input_field = input_field + + def _on_change(self): + if self._block_changes: + return + input_value = self.input_field.text() + # TODO what if does not match? + if self.hex_regex.match(input_value): + self.color.setNamedColor(input_value) + self.value_changed.emit() + + def color_changed(self): + input_value = self.input_field.text() + if self.hex_regex.match(input_value): + _cur_color = QtGui.QColor() + _cur_color.setNamedColor(input_value) + if ( + _cur_color.red() == self.color.red() + and _cur_color.green() == self.color.green() + and _cur_color.blue() == self.color.blue() + ): + return + self._block_changes = True + + self.input_field.setText(self.color.name()) + + self._block_changes = False + + +class HSVInputs(QtWidgets.QWidget): + value_changed = QtCore.Signal() + + def __init__(self, color, parent=None): + super(HSVInputs, self).__init__(parent) + + self._block_changes = False + + self.color = color + + input_hue = QtWidgets.QSpinBox(self) + input_sat = QtWidgets.QSpinBox(self) + input_val = QtWidgets.QSpinBox(self) + + input_hue.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + input_sat.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + input_val.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + + input_hue.setMinimum(0) + input_sat.setMinimum(0) + input_val.setMinimum(0) + + input_hue.setMaximum(359) + input_sat.setMaximum(255) + input_val.setMaximum(255) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(input_hue, 1) + layout.addWidget(input_sat, 1) + layout.addWidget(input_val, 1) + + input_hue.valueChanged.connect(self._on_change) + input_sat.valueChanged.connect(self._on_change) + input_val.valueChanged.connect(self._on_change) + + self.input_hue = input_hue + self.input_sat = input_sat + self.input_val = input_val + + def _on_change(self): + if self._block_changes: + return + self.color.setHsv( + self.input_hue.value(), + self.input_sat.value(), + self.input_val.value() + ) + self.value_changed.emit() + + def color_changed(self): + _cur_color = QtGui.QColor() + _cur_color.setHsv( + self.input_hue.value(), + self.input_sat.value(), + self.input_val.value() + ) + if ( + _cur_color.red() == self.color.red() + and _cur_color.green() == self.color.green() + and _cur_color.blue() == self.color.blue() + ): + return + + self._block_changes = True + h, s, v, _ = self.color.getHsv() + + self.input_hue.setValue(h) + self.input_sat.setValue(s) + self.input_val.setValue(v) + + self._block_changes = False + + +class HSLInputs(QtWidgets.QWidget): + value_changed = QtCore.Signal() + + def __init__(self, color, parent=None): + super(HSLInputs, self).__init__(parent) + + self._block_changes = False + + self.color = color + + input_hue = QtWidgets.QSpinBox(self) + input_sat = QtWidgets.QSpinBox(self) + input_light = QtWidgets.QSpinBox(self) + + input_hue.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + input_sat.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + input_light.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + + input_hue.setMinimum(0) + input_sat.setMinimum(0) + input_light.setMinimum(0) + + input_hue.setMaximum(359) + input_sat.setMaximum(255) + input_light.setMaximum(255) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(input_hue, 1) + layout.addWidget(input_sat, 1) + layout.addWidget(input_light, 1) + + input_hue.valueChanged.connect(self._on_change) + input_sat.valueChanged.connect(self._on_change) + input_light.valueChanged.connect(self._on_change) + + self.input_hue = input_hue + self.input_sat = input_sat + self.input_light = input_light + + def _on_change(self): + if self._block_changes: + return + self.color.setHsl( + self.input_hue.value(), + self.input_sat.value(), + self.input_light.value() + ) + self.value_changed.emit() + + def color_changed(self): + _cur_color = QtGui.QColor() + _cur_color.setHsl( + self.input_hue.value(), + self.input_sat.value(), + self.input_light.value() + ) + if ( + _cur_color.red() == self.color.red() + and _cur_color.green() == self.color.green() + and _cur_color.blue() == self.color.blue() + ): + return + + self._block_changes = True + h, s, l, _ = self.color.getHsl() + + self.input_hue.setValue(h) + self.input_sat.setValue(s) + self.input_light.setValue(l) + + self._block_changes = False + + +class ColorInputsWidget(QtWidgets.QWidget): + color_changed = QtCore.Signal(QtGui.QColor) + + def __init__(self, parent=None, **kwargs): + super(ColorInputsWidget, self).__init__(parent) + + color = QtGui.QColor() + + input_fields = [] + + if kwargs.get("use_hex", True): + input_fields.append(HEXInputs(color, self)) + + if kwargs.get("use_rgb", True): + input_fields.append(RGBInputs(color, self)) + + if kwargs.get("use_hsl", True): + input_fields.append(HSLInputs(color, self)) + + if kwargs.get("use_hsv", True): + input_fields.append(HSVInputs(color, self)) + + if kwargs.get("use_cmyk", True): + input_fields.append(CMYKInputs(color, self)) + + inputs_widget = QtWidgets.QWidget(self) + inputs_layout = QtWidgets.QVBoxLayout(inputs_widget) + + for input_field in input_fields: + inputs_layout.addWidget(input_field) + input_field.value_changed.connect(self._on_value_change) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(inputs_widget, 0) + spacer = QtWidgets.QWidget(self) + layout.addWidget(spacer, 1) + + self.input_fields = input_fields + + self.color = color + + def set_color(self, color): + if ( + color.red() == self.color.red() + and color.green() == self.color.green() + and color.blue() == self.color.blue() + ): + return + self.color.setRed(color.red()) + self.color.setGreen(color.green()) + self.color.setBlue(color.blue()) + self._on_value_change() + + def _on_value_change(self): + for input_field in self.input_fields: + input_field.color_changed() + self.color_changed.emit(self.color) diff --git a/openpype/widgets/color_widgets/color_picker_widget.py b/openpype/widgets/color_widgets/color_picker_widget.py new file mode 100644 index 0000000000..81ec1f87aa --- /dev/null +++ b/openpype/widgets/color_widgets/color_picker_widget.py @@ -0,0 +1,176 @@ +import os +from Qt import QtWidgets, QtCore, QtGui + +from .color_triangle import QtColorTriangle +from .color_view import ColorViewer +from .color_screen_pick import PickScreenColorWidget +from .color_inputs import ( + AlphaSlider, + AlphaInputs, + HEXInputs, + RGBInputs, + HSLInputs, + HSVInputs +) + + +class ColorPickerWidget(QtWidgets.QWidget): + color_changed = QtCore.Signal(QtGui.QColor) + + def __init__(self, color=None, parent=None): + super(ColorPickerWidget, self).__init__(parent) + + # Color triangle + color_triangle = QtColorTriangle(self) + + alpha_slider_proxy = QtWidgets.QWidget(self) + alpha_slider = AlphaSlider(QtCore.Qt.Horizontal, alpha_slider_proxy) + + alpha_slider_layout = QtWidgets.QHBoxLayout(alpha_slider_proxy) + alpha_slider_layout.setContentsMargins(5, 5, 5, 5) + alpha_slider_layout.addWidget(alpha_slider, 1) + + # Eye picked widget + pick_widget = PickScreenColorWidget() + pick_widget.setMaximumHeight(50) + + # Color pick button + btn_pick_color = QtWidgets.QPushButton(self) + icon_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "eyedropper.png" + ) + btn_pick_color.setIcon(QtGui.QIcon(icon_path)) + btn_pick_color.setToolTip("Pick a color") + + # Color preview + color_view = ColorViewer(self) + color_view.setMaximumHeight(50) + + alpha_inputs = AlphaInputs(self) + + color_inputs_color = QtGui.QColor() + col_inputs_by_label = [ + ("HEX", HEXInputs(color_inputs_color, self)), + ("RGB", RGBInputs(color_inputs_color, self)), + ("HSL", HSLInputs(color_inputs_color, self)), + ("HSV", HSVInputs(color_inputs_color, self)) + ] + + layout = QtWidgets.QGridLayout(self) + empty_col = 1 + label_col = empty_col + 1 + input_col = label_col + 1 + empty_widget = QtWidgets.QWidget(self) + empty_widget.setFixedWidth(10) + layout.addWidget(empty_widget, 0, empty_col) + + row = 0 + layout.addWidget(btn_pick_color, row, label_col) + layout.addWidget(color_view, row, input_col) + row += 1 + + color_input_fields = [] + for label, input_field in col_inputs_by_label: + layout.addWidget(QtWidgets.QLabel(label, self), row, label_col) + layout.addWidget(input_field, row, input_col) + input_field.value_changed.connect( + self._on_color_input_value_change + ) + color_input_fields.append(input_field) + row += 1 + + layout.addWidget(color_triangle, 0, 0, row + 1, 1) + layout.setRowStretch(row, 1) + row += 1 + + layout.addWidget(alpha_slider_proxy, row, 0) + + layout.addWidget(QtWidgets.QLabel("Alpha", self), row, label_col) + layout.addWidget(alpha_inputs, row, input_col) + row += 1 + layout.setRowStretch(row, 1) + + color_view.set_color(color_triangle.cur_color) + + color_triangle.color_changed.connect(self.triangle_color_changed) + alpha_slider.valueChanged.connect(self._on_alpha_slider_change) + pick_widget.color_selected.connect(self.on_color_change) + alpha_inputs.alpha_changed.connect(self._on_alpha_inputs_changed) + btn_pick_color.released.connect(self.pick_color) + + self.color_input_fields = color_input_fields + self.color_inputs_color = color_inputs_color + + self.pick_widget = pick_widget + + self.color_triangle = color_triangle + self.alpha_slider = alpha_slider + + self.color_view = color_view + self.alpha_inputs = alpha_inputs + self.btn_pick_color = btn_pick_color + + self._minimum_size_set = False + + if color: + self.set_color(color) + self.alpha_changed(color.alpha()) + + def showEvent(self, event): + super(ColorPickerWidget, self).showEvent(event) + if self._minimum_size_set: + return + + triangle_size = max(int(self.width() / 5 * 3), 180) + self.color_triangle.setMinimumWidth(triangle_size) + self.color_triangle.setMinimumHeight(triangle_size) + self._minimum_size_set = True + + def color(self): + return self.color_view.color() + + def set_color(self, color): + self.alpha_inputs.set_alpha(color.alpha()) + self.on_color_change(color) + + def pick_color(self): + self.pick_widget.pick_color() + + def triangle_color_changed(self, color): + self.color_view.set_color(color) + if self.color_inputs_color != color: + self.color_inputs_color.setRgb( + color.red(), color.green(), color.blue() + ) + for color_input in self.color_input_fields: + color_input.color_changed() + + def on_color_change(self, color): + self.color_view.set_color(color) + self.color_triangle.set_color(color) + if self.color_inputs_color != color: + self.color_inputs_color.setRgb( + color.red(), color.green(), color.blue() + ) + for color_input in self.color_input_fields: + color_input.color_changed() + + def _on_color_input_value_change(self): + for input_field in self.color_input_fields: + input_field.color_changed() + self.on_color_change(QtGui.QColor(self.color_inputs_color)) + + def alpha_changed(self, value): + self.color_view.set_alpha(value) + if self.alpha_slider.value() != value: + self.alpha_slider.setValue(value) + + if self.alpha_inputs.alpha_value != value: + self.alpha_inputs.set_alpha(value) + + def _on_alpha_inputs_changed(self, value): + self.alpha_changed(value) + + def _on_alpha_slider_change(self, value): + self.alpha_changed(value) diff --git a/openpype/widgets/color_widgets/color_screen_pick.py b/openpype/widgets/color_widgets/color_screen_pick.py new file mode 100644 index 0000000000..87f50745eb --- /dev/null +++ b/openpype/widgets/color_widgets/color_screen_pick.py @@ -0,0 +1,248 @@ +import Qt +from Qt import QtWidgets, QtCore, QtGui + + +class PickScreenColorWidget(QtWidgets.QWidget): + color_selected = QtCore.Signal(QtGui.QColor) + + def __init__(self, parent=None): + super(PickScreenColorWidget, self).__init__(parent) + self.labels = [] + self.magnification = 2 + + self._min_magnification = 1 + self._max_magnification = 10 + + def add_magnification_delta(self, delta): + _delta = abs(delta / 1000) + if delta > 0: + self.magnification += _delta + else: + self.magnification -= _delta + + if self.magnification > self._max_magnification: + self.magnification = self._max_magnification + elif self.magnification < self._min_magnification: + self.magnification = self._min_magnification + + def pick_color(self): + if self.labels: + if self.labels[0].isVisible(): + return + self.labels = [] + + for screen in QtWidgets.QApplication.screens(): + label = PickLabel(self) + label.pick_color(screen) + label.color_selected.connect(self.on_color_select) + label.close_session.connect(self.end_pick_session) + self.labels.append(label) + + def end_pick_session(self): + for label in self.labels: + label.close() + self.labels = [] + + def on_color_select(self, color): + self.color_selected.emit(color) + self.end_pick_session() + + +class PickLabel(QtWidgets.QLabel): + color_selected = QtCore.Signal(QtGui.QColor) + close_session = QtCore.Signal() + + def __init__(self, pick_widget): + super(PickLabel, self).__init__() + self.setMouseTracking(True) + + self.pick_widget = pick_widget + + self.radius_pen = QtGui.QPen(QtGui.QColor(27, 27, 27), 2) + self.text_pen = QtGui.QPen(QtGui.QColor(127, 127, 127), 4) + self.text_bg = QtGui.QBrush(QtGui.QColor(27, 27, 27)) + self._mouse_over = False + + self.radius = 100 + self.radius_ratio = 11 + + @property + def magnification(self): + return self.pick_widget.magnification + + def pick_color(self, screen_obj): + self.show() + self.windowHandle().setScreen(screen_obj) + geo = screen_obj.geometry() + args = ( + QtWidgets.QApplication.desktop().winId(), + geo.x(), geo.y(), geo.width(), geo.height() + ) + if Qt.__binding__ in ("PyQt4", "PySide"): + pix = QtGui.QPixmap.grabWindow(*args) + else: + pix = screen_obj.grabWindow(*args) + + if pix.width() > pix.height(): + size = pix.height() + else: + size = pix.width() + + self.radius = int(size / self.radius_ratio) + + self.setPixmap(pix) + self.showFullScreen() + + def wheelEvent(self, event): + y_delta = event.angleDelta().y() + self.pick_widget.add_magnification_delta(y_delta) + self.update() + + def enterEvent(self, event): + self._mouse_over = True + super().enterEvent(event) + + def leaveEvent(self, event): + self._mouse_over = False + super().leaveEvent(event) + self.update() + + def mouseMoveEvent(self, event): + self.update() + + def paintEvent(self, event): + super().paintEvent(event) + if not self._mouse_over: + return + + mouse_pos_to_widet = self.mapFromGlobal(QtGui.QCursor.pos()) + + magnified_half_size = self.radius / self.magnification + magnified_size = magnified_half_size * 2 + + zoom_x_1 = mouse_pos_to_widet.x() - magnified_half_size + zoom_x_2 = mouse_pos_to_widet.x() + magnified_half_size + zoom_y_1 = mouse_pos_to_widet.y() - magnified_half_size + zoom_y_2 = mouse_pos_to_widet.y() + magnified_half_size + pix_width = magnified_size + pix_height = magnified_size + draw_pos_x = 0 + draw_pos_y = 0 + if zoom_x_1 < 0: + draw_pos_x = abs(zoom_x_1) + pix_width -= draw_pos_x + zoom_x_1 = 1 + elif zoom_x_2 > self.pixmap().width(): + pix_width -= zoom_x_2 - self.pixmap().width() + + if zoom_y_1 < 0: + draw_pos_y = abs(zoom_y_1) + pix_height -= draw_pos_y + zoom_y_1 = 1 + elif zoom_y_2 > self.pixmap().height(): + pix_height -= zoom_y_2 - self.pixmap().height() + + new_pix = QtGui.QPixmap(magnified_size, magnified_size) + new_pix.fill(QtCore.Qt.transparent) + new_pix_painter = QtGui.QPainter(new_pix) + new_pix_painter.drawPixmap( + QtCore.QRect(draw_pos_x, draw_pos_y, pix_width, pix_height), + self.pixmap().copy(zoom_x_1, zoom_y_1, pix_width, pix_height) + ) + new_pix_painter.end() + + painter = QtGui.QPainter(self) + + ellipse_rect = QtCore.QRect( + mouse_pos_to_widet.x() - self.radius, + mouse_pos_to_widet.y() - self.radius, + self.radius * 2, + self.radius * 2 + ) + ellipse_rect_f = QtCore.QRectF(ellipse_rect) + path = QtGui.QPainterPath() + path.addEllipse(ellipse_rect_f) + painter.setClipPath(path) + + new_pix_rect = QtCore.QRect( + mouse_pos_to_widet.x() - self.radius + 1, + mouse_pos_to_widet.y() - self.radius + 1, + new_pix.width() * self.magnification, + new_pix.height() * self.magnification + ) + + painter.drawPixmap(new_pix_rect, new_pix) + + painter.setClipping(False) + + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + painter.setPen(self.radius_pen) + painter.drawEllipse(ellipse_rect_f) + + image = self.pixmap().toImage() + if image.valid(mouse_pos_to_widet): + color = QtGui.QColor(image.pixel(mouse_pos_to_widet)) + else: + color = QtGui.QColor() + + color_text = "Red: {} - Green: {} - Blue: {}".format( + color.red(), color.green(), color.blue() + ) + font = painter.font() + font.setPointSize(self.radius / 10) + painter.setFont(font) + + text_rect_height = int(painter.fontMetrics().height() + 10) + text_rect = QtCore.QRect( + ellipse_rect.x(), + ellipse_rect.bottom(), + ellipse_rect.width(), + text_rect_height + ) + if text_rect.bottom() > self.pixmap().height(): + text_rect.moveBottomLeft(ellipse_rect.topLeft()) + + rect_radius = text_rect_height / 2 + path = QtGui.QPainterPath() + path.addRoundedRect( + QtCore.QRectF(text_rect), + rect_radius, + rect_radius + ) + painter.fillPath(path, self.text_bg) + + painter.setPen(self.text_pen) + painter.drawText( + text_rect, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignCenter, + color_text + ) + + color_rect_x = ellipse_rect.x() - text_rect_height + if color_rect_x < 0: + color_rect_x += (text_rect_height + ellipse_rect.width()) + + color_rect = QtCore.QRect( + color_rect_x, + ellipse_rect.y(), + text_rect_height, + ellipse_rect.height() + ) + path = QtGui.QPainterPath() + path.addRoundedRect( + QtCore.QRectF(color_rect), + rect_radius, + rect_radius + ) + painter.fillPath(path, color) + painter.drawRoundedRect(color_rect, rect_radius, rect_radius) + painter.end() + + def mouseReleaseEvent(self, event): + color = QtGui.QColor(self.pixmap().toImage().pixel(event.pos())) + self.color_selected.emit(color) + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key_Escape: + self.close_session.emit() diff --git a/openpype/widgets/color_widgets/color_triangle.py b/openpype/widgets/color_widgets/color_triangle.py new file mode 100644 index 0000000000..d4db175d84 --- /dev/null +++ b/openpype/widgets/color_widgets/color_triangle.py @@ -0,0 +1,1431 @@ +from enum import Enum +from math import floor, sqrt, sin, cos, acos, pi as PI +from Qt import QtWidgets, QtCore, QtGui + +TWOPI = PI * 2 + + +class TriangleState(Enum): + IdleState = object() + SelectingHueState = object() + SelectingSatValueState = object() + + +class DoubleColor: + def __init__(self, r, g=None, b=None): + if g is None: + g = r.g + b = r.b + r = r.r + self.r = float(r) + self.g = float(g) + self.b = float(b) + + +class Vertex: + def __init__(self, color, point): + # Convert GlobalColor to QColor as globals don't have red, green, blue + if isinstance(color, QtCore.Qt.GlobalColor): + color = QtGui.QColor(color) + + # Convert QColor to DoubleColor + if isinstance(color, QtGui.QColor): + color = DoubleColor(color.red(), color.green(), color.blue()) + + self.color = color + self.point = point + + +class QtColorTriangle(QtWidgets.QWidget): + """The QtColorTriangle class provides a triangular color selection widget. + + This widget uses the HSV color model, and is therefore useful for + selecting colors by eye. + + The triangle in the center of the widget is used for selecting + saturation and value, and the surrounding circle is used for + selecting hue. + + Use set_color() and color() to set and get the current color. + """ + color_changed = QtCore.Signal(QtGui.QColor) + + # Thick of color wheel ratio where 1 is fully filled circle + inner_radius_ratio = 5.0 + # Ratio where hue selector on wheel is relative to `inner_radius_ratio` + # - middle of the wheel is twice `inner_radius_ratio` + selector_radius_ratio = inner_radius_ratio * 2 + # Size ratio of selectors on wheel and in triangle + ellipse_size_ratio = 10.0 + # Ration of selectors thickness + ellipse_thick_ratio = 50.0 + # Hue offset on color wheel (0 - 359) + # - red on top if set to "0" + hue_offset = 90 + + def __init__(self, parent=None): + super(QtColorTriangle, self).__init__(parent) + self.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Minimum + ) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + self.angle_a = float() + self.angle_b = float() + self.angle_c = float() + + self.bg_image = QtGui.QImage( + self.sizeHint(), QtGui.QImage.Format_RGB32 + ) + self.cur_color = QtGui.QColor() + self.point_a = QtCore.QPointF() + self.point_b = QtCore.QPointF() + self.point_c = QtCore.QPointF() + self.point_d = QtCore.QPointF() + + self.cur_hue = int() + + self.pen_width = int() + self.ellipse_size = int() + self.outer_radius = int() + self.selector_pos = QtCore.QPointF() + + self.sel_mode = TriangleState.IdleState + + self._triangle_outline_pen = QtGui.QPen( + QtGui.QColor(40, 40, 40, 128), + 2 + ) + # Prepare hue numbers for color circle + _hue_circle_range = [] + for idx in range(11): + # Some Qt versions may require: + # hue = int(idx * 360.0) + percent_idx = idx * 0.1 + hue = int(360.0 - (percent_idx * 360.0)) + _hue_circle_range.append((percent_idx, hue)) + self._hue_circle_range = tuple(_hue_circle_range) + + color = QtGui.QColor() + color.setHsv(0, 255, 255) + self.set_color(color) + + def set_color(self, col): + if ( + col.red() == self.cur_color.red() + and col.green() == self.cur_color.green() + and col.blue() == self.cur_color.blue() + ): + return + + self.cur_color = col + + hue, *_ = self.cur_color.getHsv() + + # Never use an invalid hue to display colors + if hue != -1: + self.cur_hue = hue + + angle_with_offset = (360 - self.cur_hue - self.hue_offset) % 360 + self.angle_a = (angle_with_offset * TWOPI) / 360.0 + self.angle_a += PI / 2.0 + if self.angle_a > TWOPI: + self.angle_a -= TWOPI + + self.angle_b = self.angle_a + TWOPI / 3 + self.angle_c = self.angle_b + TWOPI / 3 + + if self.angle_b > TWOPI: + self.angle_b -= TWOPI + if self.angle_c > TWOPI: + self.angle_c -= TWOPI + + cx = float(self.contentsRect().center().x()) + cy = float(self.contentsRect().center().y()) + inner_radius = ( + self.outer_radius + - (self.outer_radius / self.inner_radius_ratio) + ) + selector_radius = ( + self.outer_radius + - (self.outer_radius / self.selector_radius_ratio) + ) + self.point_a = QtCore.QPointF( + cx + (cos(self.angle_a) * inner_radius), + cy - (sin(self.angle_a) * inner_radius) + ) + self.point_b = QtCore.QPointF( + cx + (cos(self.angle_b) * inner_radius), + cy - (sin(self.angle_b) * inner_radius) + ) + self.point_c = QtCore.QPointF( + cx + (cos(self.angle_c) * inner_radius), + cy - (sin(self.angle_c) * inner_radius) + ) + self.point_d = QtCore.QPointF( + cx + (cos(self.angle_a) * selector_radius), + cy - (sin(self.angle_a) * selector_radius) + ) + + self.selector_pos = self._point_from_color(self.cur_color) + self.update() + + self.color_changed.emit(self.cur_color) + + def heightForWidth(self, width): + return width + + def polish(self): + size_w = self.contentsRect().width() + size_h = self.contentsRect().height() + if size_w < size_h: + size = size_w + else: + size = size_h + + self.outer_radius = (size - 1) / 2 + + self.pen_width = int( + floor(self.outer_radius / self.ellipse_thick_ratio) + ) + self.ellipse_size = int( + floor(self.outer_radius / self.ellipse_size_ratio) + ) + + cx = float(self.contentsRect().center().x()) + cy = float(self.contentsRect().center().y()) + + inner_radius = ( + self.outer_radius + - (self.outer_radius / self.inner_radius_ratio) + ) + selector_radius = ( + self.outer_radius + - (self.outer_radius / self.selector_radius_ratio) + ) + self.point_a = QtCore.QPointF( + cx + (cos(self.angle_a) * inner_radius), + cy - (sin(self.angle_a) * inner_radius) + ) + self.point_b = QtCore.QPointF( + cx + (cos(self.angle_b) * inner_radius), + cy - (sin(self.angle_b) * inner_radius) + ) + self.point_c = QtCore.QPointF( + cx + (cos(self.angle_c) * inner_radius), + cy - (sin(self.angle_c) * inner_radius) + ) + self.point_d = QtCore.QPointF( + cx + (cos(self.angle_a) * selector_radius), + cy - (sin(self.angle_a) * selector_radius) + ) + + self.selector_pos = self._point_from_color(self.cur_color) + + self.update() + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + if event.rect().intersects(self.contentsRect()): + event_region = event.region() + if hasattr(event_region, "intersect"): + clip_region = event_region.intersect(self.contentsRect()) + else: + clip_region = event_region.intersected( + self.contentsRect() + ) + painter.setClipRegion(clip_region) + + self.paint_bg() + + # Blit the static generated background with the hue gradient onto + # the double buffer. + buf = QtGui.QImage(self.bg_image.copy()) + + # Draw the trigon + # Find the color with only the hue, and max value and saturation + hue_color = QtGui.QColor() + hue_color.setHsv(self.cur_hue, 255, 255) + + # Draw the triangle + self.drawTrigon( + buf, self.point_a, self.point_b, self.point_c, hue_color + ) + + # Slow step: convert the image to a pixmap + pix = QtGui.QPixmap.fromImage(buf) + pix_painter = QtGui.QPainter(pix) + pix_painter.setRenderHint(QtGui.QPainter.Antialiasing) + + # Draw an outline of the triangle + pix_painter.setPen(self._triangle_outline_pen) + pix_painter.drawLine(self.point_a, self.point_b) + pix_painter.drawLine(self.point_b, self.point_c) + pix_painter.drawLine(self.point_c, self.point_a) + + # Draw the color wheel selector + pix_painter.setPen(QtGui.QPen(QtCore.Qt.white, self.pen_width)) + pix_painter.drawEllipse( + int(self.point_d.x() - self.ellipse_size / 2.0), + int(self.point_d.y() - self.ellipse_size / 2.0), + self.ellipse_size, self.ellipse_size + ) + + # Draw the triangle selector + pix_painter.setBrush(self.cur_color) + pix_painter.drawEllipse( + QtCore.QRectF( + self.selector_pos.x() - self.ellipse_size / 2.0, + self.selector_pos.y() - self.ellipse_size / 2.0, + self.ellipse_size + 0.5, + self.ellipse_size + 0.5 + ) + ) + + pix_painter.end() + # Blit + painter.drawPixmap(self.contentsRect().topLeft(), pix) + painter.end() + + def mouseMoveEvent(self, event): + if (event.buttons() & QtCore.Qt.LeftButton) == 0: + return + + depos = QtCore.QPointF( + event.pos().x(), + event.pos().y() + ) + new_color = False + + if self.sel_mode is TriangleState.SelectingHueState: + self.angle_a = self._angle_at(depos, self.contentsRect()) + self.angle_b = self.angle_a + (TWOPI / 3.0) + self.angle_c = self.angle_b + (TWOPI / 3.0) + if self.angle_b > TWOPI: + self.angle_b -= TWOPI + if self.angle_c > TWOPI: + self.angle_c -= TWOPI + + am = self.angle_a - (PI / 2) + if am < 0: + am += TWOPI + self.cur_hue = ( + 360 - int((am * 360.0) / TWOPI) - self.hue_offset + ) % 360 + hue, sat, val, _ = self.cur_color.getHsv() + + if self.cur_hue != hue: + new_color = True + self.cur_color.setHsv(self.cur_hue, sat, val) + + cx = float(self.contentsRect().center().x()) + cy = float(self.contentsRect().center().y()) + inner_radius = ( + self.outer_radius + - (self.outer_radius / self.inner_radius_ratio) + ) + selector_radius = ( + self.outer_radius + - (self.outer_radius / self.selector_radius_ratio) + ) + self.point_a = QtCore.QPointF( + cx + (cos(self.angle_a) * inner_radius), + cy - (sin(self.angle_a) * inner_radius) + ) + self.point_b = QtCore.QPointF( + cx + (cos(self.angle_b) * inner_radius), + cy - (sin(self.angle_b) * inner_radius) + ) + self.point_c = QtCore.QPointF( + cx + (cos(self.angle_c) * inner_radius), + cy - (sin(self.angle_c) * inner_radius) + ) + self.point_d = QtCore.QPointF( + cx + (cos(self.angle_a) * selector_radius), + cy - (sin(self.angle_a) * selector_radius) + ) + + self.selector_pos = self._point_from_color(self.cur_color) + else: + aa = Vertex(QtCore.Qt.transparent, self.point_a) + bb = Vertex(QtCore.Qt.transparent, self.point_b) + cc = Vertex(QtCore.Qt.transparent, self.point_c) + + self.selector_pos = self._move_point_to_triangle( + depos.x(), depos.y(), aa, bb, cc + ) + col = self._color_from_point(self.selector_pos) + if col != self.cur_color: + # Ensure that hue does not change when selecting + # saturation and value. + _, sat, val, _ = col.getHsv() + self.cur_color.setHsv(self.cur_hue, sat, val) + new_color = True + + if new_color: + self.color_changed.emit(self.cur_color) + + self.update() + + def mousePressEvent(self, event): + # Only respond to the left mouse button. + if event.button() != QtCore.Qt.LeftButton: + return + + depos = QtCore.QPointF( + event.pos().x(), + event.pos().y() + ) + rad = self._radius_at(depos, self.contentsRect()) + new_color = False + + # As in mouseMoveEvent, either find the a, b, c angles or the + # radian position of the selector, then order an update. + inner_radius = ( + self.outer_radius - (self.outer_radius / self.inner_radius_ratio) + ) + if rad > inner_radius: + self.sel_mode = TriangleState.SelectingHueState + + self.angle_a = self._angle_at(depos, self.contentsRect()) + self.angle_b = self.angle_a + TWOPI / 3.0 + self.angle_c = self.angle_b + TWOPI / 3.0 + if self.angle_b > TWOPI: + self.angle_b -= TWOPI + if self.angle_c > TWOPI: + self.angle_c -= TWOPI + + am = self.angle_a - PI / 2 + if am < 0: + am += TWOPI + + self.cur_hue = ( + 360 - int((am * 360.0) / TWOPI) - self.hue_offset + ) % 360 + hue, sat, val, _ = self.cur_color.getHsv() + + if hue != self.cur_hue: + new_color = True + self.cur_color.setHsv(self.cur_hue, sat, val) + + cx = float(self.contentsRect().center().x()) + cy = float(self.contentsRect().center().y()) + + self.point_a = QtCore.QPointF( + cx + (cos(self.angle_a) * inner_radius), + cy - (sin(self.angle_a) * inner_radius) + ) + self.point_b = QtCore.QPointF( + cx + (cos(self.angle_b) * inner_radius), + cy - (sin(self.angle_b) * inner_radius) + ) + self.point_c = QtCore.QPointF( + cx + (cos(self.angle_c) * inner_radius), + cy - (sin(self.angle_c) * inner_radius) + ) + + selector_radius = ( + self.outer_radius + - (self.outer_radius / self.selector_radius_ratio) + ) + self.point_d = QtCore.QPointF( + cx + (cos(self.angle_a) * selector_radius), + cy - (sin(self.angle_a) * selector_radius) + ) + + self.selector_pos = self._point_from_color(self.cur_color) + self.color_changed.emit(self.cur_color) + else: + self.sel_mode = TriangleState.SelectingSatValueState + + aa = Vertex(QtCore.Qt.transparent, self.point_a) + bb = Vertex(QtCore.Qt.transparent, self.point_b) + cc = Vertex(QtCore.Qt.transparent, self.point_c) + + self.selector_pos = self._move_point_to_triangle( + depos.x(), depos.y(), aa, bb, cc + ) + col = self._color_from_point(self.selector_pos) + if col != self.cur_color: + self.cur_color = col + new_color = True + + if new_color: + self.color_changed.emit(self.cur_color) + + self.update() + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.sel_mode = TriangleState.IdleState + + def keyPressEvent(self, event): + key = event.key() + if key == QtCore.Qt.Key_Left: + self.cur_hue -= 1 + if self.cur_hue < 0: + self.cur_hue += 360 + _, sat, val, _ = self.cur_color.getHsv() + + tmp = QtGui.QColor() + tmp.setHsv(self.cur_hue, sat, val) + self.set_color(tmp) + + elif key == QtCore.Qt.Key_Right: + self.cur_hue += 1 + if (self.cur_hue > 359): + self.cur_hue -= 360 + _, sat, val, _ = self.cur_color.getHsv() + tmp = QtGui.QColor() + tmp.setHsv(self.cur_hue, sat, val) + self.set_color(tmp) + + elif key == QtCore.Qt.Key_Up: + _, sat, val, _ = self.cur_color.getHsv() + if event.modifiers() & QtCore.Qt.ShiftModifier: + if sat > 5: + sat -= 5 + else: + sat = 0 + else: + if val > 5: + val -= 5 + else: + val = 0 + + tmp = QtGui.QColor() + tmp.setHsv(self.cur_hue, sat, val) + self.set_color(tmp) + + elif key == QtCore.Qt.Key_Down: + _, sat, val, _ = self.cur_color.getHsv() + if event.modifiers() & QtCore.Qt.ShiftModifier: + if sat < 250: + sat += 5 + else: + sat = 255 + else: + if val < 250: + val += 5 + else: + val = 255 + + tmp = QtGui.QColor() + tmp.setHsv(self.cur_hue, sat, val) + self.set_color(tmp) + + def resizeEvent(self, _event): + size_w = self.contentsRect().width() + size_h = self.contentsRect().height() + if size_w < size_h: + size = size_w + else: + size = size_h + + self.outer_radius = (size - 1) / 2 + + self.pen_width = int( + floor(self.outer_radius / self.ellipse_thick_ratio) + ) + self.ellipse_size = int( + floor(self.outer_radius / self.ellipse_size_ratio) + ) + + cx = float(self.contentsRect().center().x()) + cy = float(self.contentsRect().center().y()) + inner_radius = ( + self.outer_radius + - (self.outer_radius / self.inner_radius_ratio) + ) + selector_radius = ( + self.outer_radius + - (self.outer_radius / self.selector_radius_ratio) + ) + self.point_a = QtCore.QPointF( + cx + (cos(self.angle_a) * inner_radius), + cy - (sin(self.angle_a) * inner_radius) + ) + self.point_b = QtCore.QPointF( + cx + (cos(self.angle_b) * inner_radius), + cy - (sin(self.angle_b) * inner_radius) + ) + self.point_c = QtCore.QPointF( + cx + (cos(self.angle_c) * inner_radius), + cy - (sin(self.angle_c) * inner_radius) + ) + self.point_d = QtCore.QPointF( + cx + (cos(self.angle_a) * selector_radius), + cy - (sin(self.angle_a) * selector_radius) + ) + + # Find the current position of the selector + self.selector_pos = self._point_from_color(self.cur_color) + + self.update() + + def drawTrigon(self, buf, pa, pb, pc, color): + # Create three Vertex objects. A Vertex contains a double-point + # coordinate and a color. + # pa is the tip of the arrow + # pb is the black corner + # pc is the white corner + p1 = Vertex(color, pa) + p2 = Vertex(QtCore.Qt.black, pb) + p3 = Vertex(QtCore.Qt.white, pc) + + # Sort. Make p1 above p2, which is above p3 (using y coordinate). + # Bubble sorting is fastest here. + if p1.point.y() > p2.point.y(): + p1, p2 = p2, p1 + if p1.point.y() > p3.point.y(): + p1, p3 = p3, p1 + if p2.point.y() > p3.point.y(): + p2, p3 = p3, p2 + + # All the three y deltas are >= 0 + p1p2ydist = float(p2.point.y() - p1.point.y()) + p1p3ydist = float(p3.point.y() - p1.point.y()) + p2p3ydist = float(p3.point.y() - p2.point.y()) + p1p2xdist = float(p2.point.x() - p1.point.x()) + p1p3xdist = float(p3.point.x() - p1.point.x()) + p2p3xdist = float(p3.point.x() - p2.point.x()) + + # The first x delta decides wether we have a lefty or a righty + # trigon. + lefty = p1p2xdist < 0 + + # Left and right colors and X values. The key in this map is the + # y values. Our goal is to fill these structures with all the + # information needed to do a single pass top-to-bottom, + # left-to-right drawing of the trigon. + leftColors = {} + rightColors = {} + leftX = {} + rightX = {} + + # Scan longy - find all left and right colors and X-values for + # the tallest edge (p1-p3). + # Initialize with known values + x = p1.point.x() + source = p1.color + dest = p3.color + r = source.r + g = source.g + b = source.b + y1 = int(floor(p1.point.y())) + y2 = int(floor(p3.point.y())) + + # Find slopes (notice that if the y dists are 0, we don't care + # about the slopes) + xdelta = 0.0 + rdelta = 0.0 + gdelta = 0.0 + bdelta = 0.0 + if p1p3ydist != 0.0: + xdelta = p1p3xdist / p1p3ydist + rdelta = (dest.r - r) / p1p3ydist + gdelta = (dest.g - g) / p1p3ydist + bdelta = (dest.b - b) / p1p3ydist + + # Calculate gradients using linear approximation + for y in range(y1, y2): + if lefty: + rightColors[y] = DoubleColor(r, g, b) + rightX[y] = x + else: + leftColors[y] = DoubleColor(r, g, b) + leftX[y] = x + + r += rdelta + g += gdelta + b += bdelta + x += xdelta + + # Scan top shorty - find all left and right colors and x-values + # for the topmost of the two not-tallest short edges. + x = p1.point.x() + source = p1.color + dest = p2.color + r = source.r + g = source.g + b = source.b + y1 = int(floor(p1.point.y())) + y2 = int(floor(p2.point.y())) + + # Find slopes (notice that if the y dists are 0, we don't care + # about the slopes) + xdelta = 0.0 + rdelta = 0.0 + gdelta = 0.0 + bdelta = 0.0 + if p1p2ydist != 0.0: + xdelta = p1p2xdist / p1p2ydist + rdelta = (dest.r - r) / p1p2ydist + gdelta = (dest.g - g) / p1p2ydist + bdelta = (dest.b - b) / p1p2ydist + + # Calculate gradients using linear approximation + for y in range(y1, y2): + if lefty: + leftColors[y] = DoubleColor(r, g, b) + leftX[y] = x + else: + rightColors[y] = DoubleColor(r, g, b) + rightX[y] = x + + r += rdelta + g += gdelta + b += bdelta + x += xdelta + + # Scan bottom shorty - find all left and right colors and + # x-values for the bottommost of the two not-tallest short edges. + x = p2.point.x() + source = p2.color + dest = p3.color + r = source.r + g = source.g + b = source.b + y1 = int(floor(p2.point.y())) + y2 = int(floor(p3.point.y())) + + # Find slopes (notice that if the y dists are 0, we don't care + # about the slopes) + xdelta = 0.0 + rdelta = 0.0 + gdelta = 0.0 + bdelta = 0.0 + if p2p3ydist != 0.0: + xdelta = p2p3xdist / p2p3ydist + rdelta = (dest.r - r) / p2p3ydist + gdelta = (dest.g - g) / p2p3ydist + bdelta = (dest.b - b) / p2p3ydist + + # Calculate gradients using linear approximation + for y in range(y1, y2): + if lefty: + leftColors[y] = DoubleColor(r, g, b) + leftX[y] = x + else: + rightColors[y] = DoubleColor(r, g, b) + rightX[y] = x + + r += rdelta + g += gdelta + b += bdelta + x += xdelta + + # Inner loop. For each y in the left map of x-values, draw one + # line from left to right. + p3yfloor = int(floor(p3.point.y())) + p1yfloor = int(floor(p1.point.y())) + for y in range(p1yfloor, p3yfloor): + lx = leftX[y] + rx = rightX[y] + + lxi = int(floor(lx)) + rxi = int(floor(rx)) + rc = rightColors[y] + lc = leftColors[y] + + # if the xdist is 0, don't draw anything. + xdist = rx - lx + if xdist != 0.0: + r = lc.r + g = lc.g + b = lc.b + rdelta = (rc.r - r) / xdist + gdelta = (rc.g - g) / xdist + bdelta = (rc.b - b) / xdist + + # Inner loop 2. Draws the line from left to right. + for x in range(lxi, rxi + 1): + buf.setPixel(x, y, QtGui.qRgb(int(r), int(g), int(b))) + r += rdelta + g += gdelta + b += bdelta + + def _radius_at(self, pos, rect): + mousexdist = pos.x() - float(rect.center().x()) + mouseydist = pos.y() - float(rect.center().y()) + return sqrt(mousexdist ** 2 + mouseydist ** 2) + + def _angle_at(self, pos, rect): + mousexdist = pos.x() - float(rect.center().x()) + mouseydist = pos.y() - float(rect.center().y()) + mouserad = sqrt(mousexdist ** 2 + mouseydist ** 2) + if mouserad == 0.0: + return 0.0 + + angle = acos(mousexdist / mouserad) + if mouseydist >= 0: + angle = TWOPI - angle + + return angle + + def _point_from_color(self, col): + # Simplifications for the corner cases. + if col == QtCore.Qt.black: + return self.point_b + elif col == QtCore.Qt.white: + return self.point_c + + # Find the x and y slopes + ab_deltax = self.point_b.x() - self.point_a.x() + ab_deltay = self.point_b.y() - self.point_a.y() + bc_deltax = self.point_c.x() - self.point_b.x() + bc_deltay = self.point_c.y() - self.point_b.y() + ac_deltax = self.point_c.x() - self.point_a.x() + ac_deltay = self.point_c.y() - self.point_a.y() + + # Extract the h,s,v values of col. + _, sat, val, _ = col.getHsv() + + # Find the line that passes through the triangle where the value + # is equal to our color's value. + p1 = self.point_a.x() + (ab_deltax * float(255 - val)) / 255.0 + q1 = self.point_a.y() + (ab_deltay * float(255 - val)) / 255.0 + p2 = self.point_b.x() + (bc_deltax * float(val)) / 255.0 + q2 = self.point_b.y() + (bc_deltay * float(val)) / 255.0 + + # Find the line that passes through the triangle where the + # saturation is equal to our color's value. + p3 = self.point_a.x() + (ac_deltax * float(255 - sat)) / 255.0 + q3 = self.point_a.y() + (ac_deltay * float(255 - sat)) / 255.0 + p4 = self.point_b.x() + q4 = self.point_b.y() + + # Find the intersection between these lines. + if p1 != p2: + a = (q2 - q1) / (p2 - p1) + c = (q4 - q3) / (p4 - p3) + b = q1 - a * p1 + d = q3 - c * p3 + + x = (d - b) / (a - c) + y = a * x + b + else: + x = p1 + p4_p3 = p4 - p3 + if p4_p3 == 0: + y = 0 + else: + y = q3 + (x - p3) * (q4 - q3) / p4_p3 + + return QtCore.QPointF(x, y) + + def _color_from_point(self, p): + # Find the outer radius of the hue gradient. + size_w = self.contentsRect().width() + size_h = self.contentsRect().height() + if size_w < size_h: + size = size_w + else: + size = size_h + outer_radius = (size - 1) / 2 + + # Find the center coordinates + cx = float(self.contentsRect().center().x()) + cy = float(self.contentsRect().center().y()) + + # Find the a, b and c from their angles, the center of the rect + # and the radius of the hue gradient donut. + inner_radius = outer_radius - (outer_radius / self.inner_radius_ratio) + pa = QtCore.QPointF( + cx + (cos(self.angle_a) * inner_radius), + cy - (sin(self.angle_a) * inner_radius) + ) + pb = QtCore.QPointF( + cx + (cos(self.angle_b) * inner_radius), + cy - (sin(self.angle_b) * inner_radius) + ) + pc = QtCore.QPointF( + cx + (cos(self.angle_c) * inner_radius), + cy - (sin(self.angle_c) * inner_radius) + ) + + # Find the hue value from the angle of the 'a' point. + angle = self.angle_a - PI / 2.0 + if angle < 0: + angle += TWOPI + hue = ( + 360 + - int(floor((360.0 * angle) / TWOPI)) + - self.hue_offset + ) % 360 + + # Create the color of the 'a' corner point. We know that b is + # black and c is white. + color = QtGui.QColor() + color.setHsv(hue, 255, 255) + + # See also drawTrigon(), which basically does exactly the same to + # determine all colors in the trigon. + p1 = Vertex(color, pa) + p2 = Vertex(QtCore.Qt.black, pb) + p3 = Vertex(QtCore.Qt.white, pc) + + # Make sure p1 is above p2, which is above p3. + if p1.point.y() > p2.point.y(): + p1, p2 = p2, p1 + if p1.point.y() > p3.point.y(): + p1, p3 = p3, p1 + if p2.point.y() > p3.point.y(): + p2, p3 = p3, p2 + + # Find the slopes of all edges in the trigon. All the three y + # deltas here are positive because of the above sorting. + p1p2ydist = p2.point.y() - p1.point.y() + p1p3ydist = p3.point.y() - p1.point.y() + p2p3ydist = p3.point.y() - p2.point.y() + p1p2xdist = p2.point.x() - p1.point.x() + p1p3xdist = p3.point.x() - p1.point.x() + p2p3xdist = p3.point.x() - p2.point.x() + + # The first x delta decides wether we have a lefty or a righty + # trigon. A lefty trigon has its tallest edge on the right hand + # side of the trigon. The righty trigon has it on its left side. + # This property determines wether the left or the right set of x + # coordinates will be continuous. + lefty = p1p2xdist < 0 + + # Find whether the selector's y is in the first or second shorty, + # counting from the top and downwards. This is used to find the + # color at the selector point. + firstshorty = (p.y() >= p1.point.y() and p.y() < p2.point.y()) + + # From the y value of the selector's position, find the left and + # right x values. + if lefty: + if firstshorty: + if (floor(p1p2ydist) != 0.0): + leftx = ( + p1.point.x() + + ((p1p2xdist * (p.y() - p1.point.y())) / p1p2ydist) + ) + else: + leftx = min(p1.point.x(), p2.point.x()) + + else: + if (floor(p2p3ydist) != 0.0): + leftx = ( + p2.point.x() + + (p2p3xdist * (p.y() - p2.point.y())) / p2p3ydist + ) + else: + leftx = min(p2.point.x(), p3.point.x()) + + rightx = ( + p1.point.x() + + ((p1p3xdist * (p.y() - p1.point.y())) / p1p3ydist) + ) + else: + leftx = ( + p1.point.x() + + ((p1p3xdist * (p.y() - p1.point.y())) / p1p3ydist) + ) + if firstshorty: + if floor(p1p2ydist) != 0.0: + rightx = ( + p1.point.x() + + ((p1p2xdist * (p.y() - p1.point.y())) / p1p2ydist) + ) + else: + rightx = max(p1.point.x(), p2.point.x()) + + else: + if floor(p2p3ydist) != 0.0: + rightx = ( + p2.point.x() + + ((p2p3xdist * (p.y() - p2.point.y())) / p2p3ydist) + ) + else: + rightx = max(p2.point.x(), p3.point.x()) + + # Find the r,g,b values of the points on the trigon's edges that + # are to the left and right of the selector. + if firstshorty: + if floor(p1p2ydist) != 0.0: + p_p1_ratio = (p.y() - p1.point.y()) / p1p2ydist + p2_p_ratio = (p2.point.y() - p.y()) / p1p2ydist + rshort = (p2.color.r * p_p1_ratio) + (p1.color.r * p2_p_ratio) + gshort = (p2.color.g * p_p1_ratio) + (p1.color.g * p2_p_ratio) + bshort = (p2.color.b * p_p1_ratio) + (p1.color.b * p2_p_ratio) + elif lefty: + if p1.point.x() <= p2.point.x(): + rshort = p1.color.r + gshort = p1.color.g + bshort = p1.color.b + else: + rshort = p2.color.r + gshort = p2.color.g + bshort = p2.color.b + + else: + if p1.point.x() > p2.point.x(): + rshort = p1.color.r + gshort = p1.color.g + bshort = p1.color.b + else: + rshort = p2.color.r + gshort = p2.color.g + bshort = p2.color.b + + else: + if floor(p2p3ydist) != 0.0: + p_p2_ratio = (p.y() - p2.point.y()) / p2p3ydist + p3_p_ratio = (p3.point.y() - p.y()) / p2p3ydist + rshort = (p3.color.r * p_p2_ratio) + (p2.color.r * p3_p_ratio) + gshort = (p3.color.g * p_p2_ratio) + (p2.color.g * p3_p_ratio) + bshort = (p3.color.b * p_p2_ratio) + (p2.color.b * p3_p_ratio) + elif lefty: + if p2.point.x() <= p3.point.x(): + rshort = p2.color.r + gshort = p2.color.g + bshort = p2.color.b + else: + rshort = p3.color.r + gshort = p3.color.g + bshort = p3.color.b + + else: + if p2.point.x() > p3.point.x(): + rshort = p2.color.r + gshort = p2.color.g + bshort = p2.color.b + else: + rshort = p3.color.r + gshort = p3.color.g + bshort = p3.color.b + + # p1p3ydist is never 0 + p_p1_ratio = (p.y() - p1.point.y()) / p1p3ydist + p3_p_ratio = (p3.point.y() - p.y()) / p1p3ydist + rlong = (p3.color.r * p_p1_ratio) + (p1.color.r * p3_p_ratio) + glong = (p3.color.g * p_p1_ratio) + (p1.color.g * p3_p_ratio) + blong = (p3.color.b * p_p1_ratio) + (p1.color.b * p3_p_ratio) + + # rshort,gshort,bshort is the color on one of the shortys. + # rlong,glong,blong is the color on the longy. So depending on + # wether we have a lefty trigon or not, we can determine which + # colors are on the left and right edge. + if lefty: + rl = rshort + gl = gshort + bl = bshort + rr = rlong + gr = glong + br = blong + else: + rl = rlong + gl = glong + bl = blong + rr = rshort + gr = gshort + br = bshort + + # Find the distance from the left x to the right x (xdist). Then + # find the distances from the selector to each of these (saxdist + # and saxdist2). These distances are used to find the color at + # the selector. + xdist = rightx - leftx + saxdist = p.x() - leftx + saxdist2 = xdist - saxdist + + # Now determine the r,g,b values of the selector using a linear + # approximation. + if xdist != 0.0: + r = (saxdist2 * rl / xdist) + (saxdist * rr / xdist) + g = (saxdist2 * gl / xdist) + (saxdist * gr / xdist) + b = (saxdist2 * bl / xdist) + (saxdist * br / xdist) + else: + # In theory, the left and right color will be equal here. But + # because of the loss of precision, we get an error on both + # colors. The best approximation we can get is from adding + # the two errors, which in theory will eliminate the error + # but in practise will only minimize it. + r = (rl + rr) / 2 + g = (gl + gr) / 2 + b = (bl + br) / 2 + + # Now floor the color components and fit them into proper + # boundaries. This again is to compensate for the error caused by + # loss of precision. + ri = int(floor(r)) + gi = int(floor(g)) + bi = int(floor(b)) + if ri < 0: + ri = 0 + elif ri > 255: + ri = 255 + + if gi < 0: + gi = 0 + elif gi > 255: + gi = 255 + + if bi < 0: + bi = 0 + elif bi > 255: + bi = 255 + + # Voila, we have the color at the point of the selector. + return QtGui.QColor(ri, gi, bi) + + def paint_bg(self): + bg_image = QtGui.QPixmap(self.contentsRect().size()) + bg_image.fill(QtCore.Qt.transparent) + self.bg_image = bg_image + + painter = QtGui.QPainter(self.bg_image) + + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + hue_gradient = QtGui.QConicalGradient( + bg_image.rect().center(), 90 - self.hue_offset + ) + sat_val_gradient = QtGui.QConicalGradient( + bg_image.rect().center(), 90 - self.hue_offset + ) + + hue_color = QtGui.QColor() + sat_val_color = QtGui.QColor() + _, sat, val, _ = self.cur_color.getHsv() + + for idx, hue in self._hue_circle_range: + hue_color.setHsv(hue, 255, 255) + sat_val_color.setHsv(hue, sat, val) + + hue_gradient.setColorAt(idx, hue_color) + sat_val_gradient.setColorAt(idx, sat_val_color) + + inner_radius = self.outer_radius - ( + self.outer_radius / self.inner_radius_ratio + ) + half_radius = self.outer_radius - ( + (self.outer_radius - inner_radius) / 2 + ) + + hue_inner_radius_rect = QtCore.QRectF( + bg_image.rect().center().x() - inner_radius, + bg_image.rect().center().y() - inner_radius, + inner_radius * 2 + 1, + inner_radius * 2 + 1 + ) + hue_outer_radius_rect = QtCore.QRectF( + bg_image.rect().center().x() - half_radius - 1, + bg_image.rect().center().y() - half_radius - 1, + half_radius * 2 + 3, + half_radius * 2 + 3 + ) + sat_val_inner_radius_rect = QtCore.QRectF( + bg_image.rect().center().x() - half_radius, + bg_image.rect().center().y() - half_radius, + half_radius * 2 + 1, + half_radius * 2 + 1 + ) + sat_val_outer_radius_rect = QtCore.QRectF( + bg_image.rect().center().x() - self.outer_radius, + bg_image.rect().center().y() - self.outer_radius, + self.outer_radius * 2 + 1, + self.outer_radius * 2 + 1 + ) + hue_path = QtGui.QPainterPath() + hue_path.addEllipse(hue_inner_radius_rect) + hue_path.addEllipse(hue_outer_radius_rect) + + sat_val_path = QtGui.QPainterPath() + sat_val_path.addEllipse(sat_val_inner_radius_rect) + sat_val_path.addEllipse(sat_val_outer_radius_rect) + + painter.save() + painter.setClipPath(hue_path) + painter.fillRect(self.bg_image.rect(), hue_gradient) + painter.restore() + + painter.save() + painter.setClipPath(sat_val_path) + painter.fillRect(self.bg_image.rect(), sat_val_gradient) + painter.restore() + + painter.end() + + @staticmethod + def vlen(x, y): + return sqrt((x ** 2) + (y ** 2)) + + @staticmethod + def vprod(x1, y1, x2, y2): + return x1 * x2 + y1 * y2 + + @staticmethod + def _angle_between_angles(p, a1, a2): + if a1 > a2: + a2 += TWOPI + if p < PI: + p += TWOPI + + return p >= a1 and p < a2 + + @staticmethod + def _point_above_point(x, y, px, py, ax, ay, bx, by): + floored_ax = floor(ax) + floored_bx = floor(bx) + floored_ay = floor(ay) + floored_by = floor(by) + + if floored_ax == floored_bx: + # line is vertical + if floored_ay < floored_by: + return x < ax + elif floored_ay > floored_by: + return x > ax + return not (x == ax and y == ay) + + if floored_ax > floored_bx: + if floored_ay < floored_by: + # line is draw upright-to-downleft + return (floor(x) < floor(px) or floor(y) < floor(py)) + elif floored_ay > floored_by: + # line is draw downright-to-upleft + return (floor(x) > floor(px) or floor(y) < floor(py)) + # line is flat horizontal + return y < ay + + if floored_ay < floored_by: + # line is draw upleft-to-downright + return (floor(x) < floor(px) or floor(y) > floor(py)) + elif floored_ay > floored_by: + # line is draw downleft-to-upright + return (floor(x) > floor(px) or floor(y) > floor(py)) + # line is flat horizontal + return y > ay + + @staticmethod + def _point_in_line(x, y, ax, ay, bx, by): + if ax > bx: + if ay < by: + # line is draw upright-to-downleft + + # if (x,y) is in on or above the upper right point, + # return -1. + if y <= ay and x >= ax: + return -1 + + # if (x,y) is in on or below the lower left point, + # return 1. + if y >= by and x <= bx: + return 1 + else: + # line is draw downright-to-upleft + + # If the line is flat, only use the x coordinate. + if floor(ay) == floor(by): + # if (x is to the right of the rightmost point, + # return -1. otherwise if x is to the left of the + # leftmost point, return 1. + if x >= ax: + return -1 + elif x <= bx: + return 1 + else: + # if (x,y) is on or below the lower right point, + # return -1. + if y >= ay and x >= ax: + return -1 + + # if (x,y) is on or above the upper left point, return 1. + if y <= by and x <= bx: + return 1 + else: + if ay < by: + # line is draw upleft-to-downright + + # If (x,y) is on or above the upper left point, return -1. + if y <= ay and x <= ax: + return -1 + + # If (x,y) is on or below the lower right point, return 1. + if y >= by and x >= bx: + return 1 + else: + # line is draw downleft-to-upright + + # If the line is flat, only use the x coordinate. + if floor(ay) == floor(by): + if x <= ax: + return -1 + elif x >= bx: + return 1 + else: + # If (x,y) is on or below the lower left point, return -1. + if y >= ay and x <= ax: + return -1 + + # If (x,y) is on or above the upper right point, return 1. + if y <= by and x >= bx: + return 1 + + # No tests proved that (x,y) was outside [(ax,ay),(bx,by)], so we + # assume it's inside the line's bounds. + return 0 + + def _move_point_to_triangle(self, x, y, a, b, c): + # Let v1A be the vector from (x,y) to a. + # Let v2A be the vector from a to b. + # Find the angle alphaA between v1A and v2A. + v1xA = x - a.point.x() + v1yA = y - a.point.y() + v2xA = b.point.x() - a.point.x() + v2yA = b.point.y() - a.point.y() + vpA = self.vprod(v1xA, v1yA, v2xA, v2yA) + cosA = vpA / (self.vlen(v1xA, v1yA) * self.vlen(v2xA, v2yA)) + alphaA = acos(cosA) + + # Let v1B be the vector from x to b. + # Let v2B be the vector from b to c. + v1xB = x - b.point.x() + v1yB = y - b.point.y() + v2xB = c.point.x() - b.point.x() + v2yB = c.point.y() - b.point.y() + vpB = self.vprod(v1xB, v1yB, v2xB, v2yB) + cosB = vpB / (self.vlen(v1xB, v1yB) * self.vlen(v2xB, v2yB)) + alphaB = acos(cosB) + + # Let v1C be the vector from x to c. + # Let v2C be the vector from c back to a. + v1xC = x - c.point.x() + v1yC = y - c.point.y() + v2xC = a.point.x() - c.point.x() + v2yC = a.point.y() - c.point.y() + vpC = self.vprod(v1xC, v1yC, v2xC, v2yC) + cosC = vpC / (self.vlen(v1xC, v1yC) * self.vlen(v2xC, v2yC)) + alphaC = acos(cosC) + + # Find the radian angles between the (1,0) vector and the points + # A, B, C and (x,y). Use this information to determine which of + # the edges we should project (x,y) onto. + angleA = self._angle_at(a.point, self.contentsRect()) + angleB = self._angle_at(b.point, self.contentsRect()) + angleC = self._angle_at(c.point, self.contentsRect()) + angleP = self._angle_at(QtCore.QPointF(x, y), self.contentsRect()) + + # If (x,y) is in the a-b area, project onto the a-b vector. + if self._angle_between_angles(angleP, angleA, angleB): + # Find the distance from (x,y) to a. Then use the slope of + # the a-b vector with this distance and the angle between a-b + # and a-(x,y) to determine the point of intersection of the + # perpendicular projection from (x,y) onto a-b. + pdist = sqrt( + ((x - a.point.x()) ** 2) + ((y - a.point.y()) ** 2) + ) + + # the length of all edges is always > 0 + p0x = ( + a.point.x() + + ((b.point.x() - a.point.x()) / self.vlen(v2xB, v2yB)) + * cos(alphaA) * pdist + ) + p0y = ( + a.point.y() + + ((b.point.y() - a.point.y()) / self.vlen(v2xB, v2yB)) + * cos(alphaA) * pdist + ) + + # If (x,y) is above the a-b line, which basically means it's + # outside the triangle, then return its projection onto a-b. + if self._point_above_point( + x, y, + p0x, p0y, + a.point.x(), a.point.y(), + b.point.x(), b.point.y() + ): + # If the projection is "outside" a, return a. If it is + # outside b, return b. Otherwise return the projection. + n = self._point_in_line( + p0x, p0y, + a.point.x(), a.point.y(), + b.point.x(), b.point.y() + ) + if n < 0: + return a.point + elif n > 0: + return b.point + + return QtCore.QPointF(p0x, p0y) + + elif self._angle_between_angles(angleP, angleB, angleC): + # If (x,y) is in the b-c area, project onto the b-c vector. + pdist = sqrt( + ((x - b.point.x()) ** 2) + ((y - b.point.y()) ** 2) + ) + + # the length of all edges is always > 0 + p0x = ( + b.point.x() + + ((c.point.x() - b.point.x()) / self.vlen(v2xC, v2yC)) + * cos(alphaB) * pdist + ) + p0y = ( + b.point.y() + + ((c.point.y() - b.point.y()) / self.vlen(v2xC, v2yC)) + * cos(alphaB) + * pdist + ) + + if self._point_above_point( + x, y, + p0x, p0y, + b.point.x(), b.point.y(), + c.point.x(), c.point.y() + ): + n = self._point_in_line( + p0x, p0y, + b.point.x(), b.point.y(), + c.point.x(), c.point.y() + ) + if n < 0: + return b.point + elif n > 0: + return c.point + return QtCore.QPointF(p0x, p0y) + + elif self._angle_between_angles(angleP, angleC, angleA): + # If (x,y) is in the c-a area, project onto the c-a vector. + pdist = sqrt( + ((x - c.point.x()) ** 2) + ((y - c.point.y()) ** 2) + ) + + # the length of all edges is always > 0 + p0x = ( + c.point.x() + + ((a.point.x() - c.point.x()) / self.vlen(v2xA, v2yA)) + * cos(alphaC) + * pdist + ) + p0y = ( + c.point.y() + + ((a.point.y() - c.point.y()) / self.vlen(v2xA, v2yA)) + * cos(alphaC) * pdist + ) + + if self._point_above_point( + x, y, + p0x, p0y, + c.point.x(), c.point.y(), + a.point.x(), a.point.y() + ): + n = self._point_in_line( + p0x, p0y, + c.point.x(), c.point.y(), + a.point.x(), a.point.y() + ) + if n < 0: + return c.point + elif n > 0: + return a.point + return QtCore.QPointF(p0x, p0y) + + # (x,y) is inside the triangle (inside a-b, b-c and a-c). + return QtCore.QPointF(x, y) diff --git a/openpype/widgets/color_widgets/color_view.py b/openpype/widgets/color_widgets/color_view.py new file mode 100644 index 0000000000..8644281a1d --- /dev/null +++ b/openpype/widgets/color_widgets/color_view.py @@ -0,0 +1,83 @@ +from Qt import QtWidgets, QtCore, QtGui + + +def draw_checkerboard_tile(piece_size=None, color_1=None, color_2=None): + if piece_size is None: + piece_size = 7 + + if color_1 is None: + color_1 = QtGui.QColor(188, 188, 188) + + if color_2 is None: + color_2 = QtGui.QColor(90, 90, 90) + + pix = QtGui.QPixmap(piece_size * 2, piece_size * 2) + pix_painter = QtGui.QPainter(pix) + + rect = QtCore.QRect( + 0, 0, piece_size, piece_size + ) + pix_painter.fillRect(rect, color_1) + rect.moveTo(piece_size, piece_size) + pix_painter.fillRect(rect, color_1) + rect.moveTo(piece_size, 0) + pix_painter.fillRect(rect, color_2) + rect.moveTo(0, piece_size) + pix_painter.fillRect(rect, color_2) + pix_painter.end() + + return pix + + +class ColorViewer(QtWidgets.QWidget): + def __init__(self, parent=None): + super(ColorViewer, self).__init__(parent) + + self.setMinimumSize(10, 10) + + self.alpha = 255 + self.actual_pen = QtGui.QPen() + self.actual_color = QtGui.QColor() + self._checkerboard = None + + def checkerboard(self): + if not self._checkerboard: + self._checkerboard = draw_checkerboard_tile(4) + return self._checkerboard + + def color(self): + return self.actual_color + + def set_color(self, color): + if color == self.actual_color: + return + + # Create copy of entered color + self.actual_color = QtGui.QColor(color) + # Set alpha by current alpha value + self.actual_color.setAlpha(self.alpha) + # Repaint + self.update() + + def set_alpha(self, alpha): + if alpha == self.alpha: + return + # Change alpha of current color + self.actual_color.setAlpha(alpha) + # Store the value + self.alpha = alpha + # Repaint + self.update() + + def paintEvent(self, event): + clip_rect = event.rect() + rect = clip_rect.adjusted(0, 0, -1, -1) + + painter = QtGui.QPainter(self) + painter.setClipRect(clip_rect) + painter.drawTiledPixmap(rect, self.checkerboard()) + painter.setBrush(self.actual_color) + pen = QtGui.QPen(QtGui.QColor(255, 255, 255, 67)) + painter.setPen(pen) + painter.drawRect(rect) + painter.end() diff --git a/openpype/widgets/color_widgets/eyedropper.png b/openpype/widgets/color_widgets/eyedropper.png new file mode 100644 index 0000000000..baf6209e0b Binary files /dev/null and b/openpype/widgets/color_widgets/eyedropper.png differ diff --git a/pyproject.toml b/pyproject.toml index f7b5dd1426..f7eeafd04f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.0.0-rc4" +version = "3.0.0-rc.5" description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" diff --git a/setup.py b/setup.py index c096befa34..5fb0b33f2a 100644 --- a/setup.py +++ b/setup.py @@ -98,7 +98,7 @@ executables = [ setup( name="OpenPype", version=__version__, - description="Ultimate pipeline", + description="OpenPype", cmdclass={"build_sphinx": BuildDoc}, options={ "build_exe": build_exe_options, diff --git a/tools/build.ps1 b/tools/build.ps1 index 5c392c355c..d9fef0f471 100644 --- a/tools/build.ps1 +++ b/tools/build.ps1 @@ -12,8 +12,22 @@ PS> .\build.ps1 +.EXAMPLE + +To build without automatical submodule update: +PS> .\build.ps1 --no-submodule-update + +.LINK +https://openpype.io/docs + #> +$arguments=$ARGS +$disable_submodule_update="" +if($arguments -eq "--no-submodule-update") { + $disable_submodule_update=$true +} + function Start-Progress { param([ScriptBlock]$code) $scroll = "/-\|/-\|" @@ -76,17 +90,17 @@ $art = @" . . .. . .. _oOOP3OPP3Op_. . - .PPpo~· ·· ~2p. ·· ···· · · - ·Ppo · .pPO3Op.· · O:· · · · - .3Pp · oP3'· 'P33· · 4 ·· · · · ·· · · · - ·~OP 3PO· .Op3 : · ·· _____ _____ _____ - ·P3O · oP3oP3O3P' · · · · / /·/ /·/ / - O3:· O3p~ · ·:· · ·/____/·/____/ /____/ - 'P · 3p3· oP3~· ·.P:· · · ·· · · ·· · · · - · ': · Po' ·Opo'· .3O· . o[ by Pype Club ]]]==- - - · · - · '_ .. · . _OP3·· · ·https://openpype.io·· · - ~P3·OPPPO3OP~ · ·· · - · ' '· · ·· · · · ·· · + .PPpo~. .. ~2p. .. .... . . + .Ppo . .pPO3Op.. . O:. . . . + .3Pp . oP3'. 'P33. . 4 .. . . . .. . . . + .~OP 3PO. .Op3 : . .. _____ _____ _____ + .P3O . oP3oP3O3P' . . . . / /./ /./ / + O3:. O3p~ . .:. . ./____/./____/ /____/ + 'P . 3p3. oP3~. ..P:. . . .. . . .. . . . + . ': . Po' .Opo'. .3O. . o[ by Pype Club ]]]==- - - . . + . '_ .. . . _OP3.. . .https://openpype.io.. . + ~P3.OPPPO3OP~ . .. . + . ' '. . .. . . . .. . "@ @@ -134,10 +148,14 @@ catch { Write-Host $_.Exception.Message Exit-WithCode 1 } - -Write-Host ">>> " -NoNewLine -ForegroundColor green -Write-Host "Making sure submodules are up-to-date ..." -git submodule update --init --recursive +if (-not $disable_submodule_update) { + Write-Host ">>> " -NoNewLine -ForegroundColor green + Write-Host "Making sure submodules are up-to-date ..." + git submodule update --init --recursive +} else { + Write-Host "*** " -NoNewLine -ForegroundColor yellow + Write-Host "Not updating submodules ..." +} Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "OpenPype [ " -NoNewline -ForegroundColor white @@ -164,7 +182,7 @@ Write-Host "OK" -ForegroundColor green Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Building OpenPype ..." -$startTime = (Get-Date).Millisecond +$startTime = [int][double]::Parse((Get-Date -UFormat %s)) $out = & poetry run python setup.py build 2>&1 if ($LASTEXITCODE -ne 0) @@ -183,7 +201,7 @@ Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "restoring current directory" Set-Location -Path $current_dir -$endTime = (Get-Date).Millisecond +$endTime = [int][double]::Parse((Get-Date -UFormat %s)) Write-Host "*** " -NoNewline -ForegroundColor Cyan Write-Host "All done in $($endTime - $startTime) secs. You will find OpenPype and build log in " -NoNewLine Write-Host "'.\build'" -NoNewline -ForegroundColor Green diff --git a/tools/build.sh b/tools/build.sh index 953d51bd81..ccd97ea4c1 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -57,6 +57,26 @@ BIPurple='\033[1;95m' # Purple BICyan='\033[1;96m' # Cyan BIWhite='\033[1;97m' # White +args=$@ +disable_submodule_update = 0 +while :; do + case $1 in + --no-submodule-update) + disable_submodule_update=1 + ;; + --) + shift + break + ;; + *) + break + esac + + shift +done + + + ############################################################################## # Detect required version of python @@ -172,9 +192,12 @@ main () { . "$openpype_root/tools/create_env.sh" || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } fi +if [ "$disable_submodule_update" == 1 ]; then echo -e "${BIGreen}>>>${RST} Making sure submodules are up-to-date ..." git submodule update --init --recursive - + else + echo -e "${BIYellow}***${RST} Not updating submodules ..." + fi echo -e "${BIGreen}>>>${RST} Building ..." if [[ "$OSTYPE" == "linux-gnu"* ]]; then poetry run python "$openpype_root/setup.py" build > "$openpype_root/build/build.log" || { echo -e "${BIRed}!!!${RST} Build failed, see the build log."; return; } diff --git a/tools/build_dependencies.py b/tools/build_dependencies.py index fb52e2b5fd..de3b6da021 100644 --- a/tools/build_dependencies.py +++ b/tools/build_dependencies.py @@ -26,10 +26,12 @@ import platform from pathlib import Path import shutil import blessed +import enlighten import time term = blessed.Terminal() +manager = enlighten.get_manager() def _print(msg: str, type: int = 0) -> None: @@ -52,6 +54,24 @@ def _print(msg: str, type: int = 0) -> None: print("{}{}".format(header, msg)) +def count_folders(path: Path) -> int: + """Recursively count items inside given Path. + + Args: + path (Path): Path to count. + + Returns: + int: number of items. + + """ + cnt = 0 + for child in path.iterdir(): + if child.is_dir(): + cnt += 1 + cnt += count_folders(child) + return cnt + + _print("Starting dependency cleanup ...") start_time = time.time_ns() @@ -96,30 +116,55 @@ deps_dir = build_dir / "dependencies" # copy all files _print("Copying dependencies ...") -shutil.copytree(site_pkg.as_posix(), deps_dir.as_posix()) +total_files = count_folders(site_pkg) +progress_bar = enlighten.Counter( + total=total_files, desc="Processing Dependencies", + units="%", color="green") + + +def _progress(_base, _names): + progress_bar.update() + return [] + + +shutil.copytree(site_pkg.as_posix(), + deps_dir.as_posix(), + ignore=_progress) +progress_bar.close() # iterate over frozen libs and create list to delete libs_dir = build_dir / "lib" to_delete = [] -_print("Finding duplicates ...") +# _print("Finding duplicates ...") deps_items = list(deps_dir.iterdir()) +item_count = len(list(libs_dir.iterdir())) +find_progress_bar = enlighten.Counter( + total=item_count, desc="Finding duplicates", units="%", color="yellow") + for d in libs_dir.iterdir(): if (deps_dir / d.name) in deps_items: to_delete.append(d) - _print(f"found {d}", 3) + # _print(f"found {d}", 3) + find_progress_bar.update() +find_progress_bar.close() # add openpype and igniter in libs too to_delete.append(libs_dir / "openpype") to_delete.append(libs_dir / "igniter") # delete duplicates -_print(f"Deleting {len(to_delete)} duplicates ...") +# _print(f"Deleting {len(to_delete)} duplicates ...") +delete_progress_bar = enlighten.Counter( + total=len(to_delete), desc="Deleting duplicates", units="%", color="red") for d in to_delete: if d.is_dir(): shutil.rmtree(d) else: d.unlink() + delete_progress_bar.update() + +delete_progress_bar.close() end_time = time.time_ns() total_time = (end_time - start_time) / 1000000000 diff --git a/tools/build_win_installer.ps1 b/tools/build_win_installer.ps1 index 4a4d011258..05ec0f9823 100644 --- a/tools/build_win_installer.ps1 +++ b/tools/build_win_installer.ps1 @@ -1,16 +1,14 @@ <# .SYNOPSIS - Helper script to build OpenPype. + Helper script to build OpenPype Installer. .DESCRIPTION - This script will detect Python installation, and build OpenPype to `build` - directory using existing virtual environment created by Poetry (or - by running `/tools/create_venv.ps1`). It will then shuffle dependencies in - build folder to optimize for different Python versions (2/3) in Python host. + This script will use already built OpenPype (in `build` directory) and + create Windows installer from it using Inno Setup (https://jrsoftware.org/) .EXAMPLE -PS> .\build.ps1 +PS> .\build_win_installer.ps1 #> @@ -76,11 +74,19 @@ function Install-Poetry() { $art = @" -▒█▀▀▀█ █▀▀█ █▀▀ █▀▀▄ ▒█▀▀█ █░░█ █▀▀█ █▀▀ ▀█▀ ▀█▀ ▀█▀ -▒█░░▒█ █░░█ █▀▀ █░░█ ▒█▄▄█ █▄▄█ █░░█ █▀▀ ▒█░ ▒█░ ▒█░ -▒█▄▄▄█ █▀▀▀ ▀▀▀ ▀░░▀ ▒█░░░ ▄▄▄█ █▀▀▀ ▀▀▀ ▄█▄ ▄█▄ ▄█▄ - .---= [ by Pype Club ] =---. - https://openpype.io + . . .. . .. + _oOOP3OPP3Op_. . + .PPpo~. .. ~2p. .. .... . . + .Ppo . .pPO3Op.. . O:. . . . + .3Pp . oP3'. 'P33. . 4 .. . . . .. . . . + .~OP 3PO. .Op3 : . .. _____ _____ _____ + .P3O . oP3oP3O3P' . . . . / /./ /./ / + O3:. O3p~ . .:. . ./____/./____/ /____/ + 'P . 3p3. oP3~. ..P:. . . .. . . .. . . . + . ': . Po' .Opo'. .3O. . o[ by Pype Club ]]]==- - - . . + . '_ .. . . _OP3.. . .https://openpype.io.. . + ~P3.OPPPO3OP~ . .. . + . ' '. . .. . . . .. . "@ diff --git a/tools/create_env.ps1 b/tools/create_env.ps1 index 5600ae71c7..7ada92c1e8 100644 --- a/tools/create_env.ps1 +++ b/tools/create_env.ps1 @@ -11,6 +11,11 @@ PS> .\create_env.ps1 +.EXAMPLE + +Print verbose information from Poetry: +PS> .\create_env.ps1 --verbose + #> $arguments=$ARGS @@ -98,17 +103,17 @@ $art = @" . . .. . .. _oOOP3OPP3Op_. . - .PPpo~· ·· ~2p. ·· ···· · · - ·Ppo · .pPO3Op.· · O:· · · · - .3Pp · oP3'· 'P33· · 4 ·· · · · ·· · · · - ·~OP 3PO· .Op3 : · ·· _____ _____ _____ - ·P3O · oP3oP3O3P' · · · · / /·/ /·/ / - O3:· O3p~ · ·:· · ·/____/·/____/ /____/ - 'P · 3p3· oP3~· ·.P:· · · ·· · · ·· · · · - · ': · Po' ·Opo'· .3O· . o[ by Pype Club ]]]==- - - · · - · '_ .. · . _OP3·· · ·https://openpype.io·· · - ~P3·OPPPO3OP~ · ·· · - · ' '· · ·· · · · ·· · + .PPpo~. .. ~2p. .. .... . . + .Ppo . .pPO3Op.. . O:. . . . + .3Pp . oP3'. 'P33. . 4 .. . . . .. . . . + .~OP 3PO. .Op3 : . .. _____ _____ _____ + .P3O . oP3oP3O3P' . . . . / /./ /./ / + O3:. O3p~ . .:. . ./____/./____/ /____/ + 'P . 3p3. oP3~. ..P:. . . .. . . .. . . . + . ': . Po' .Opo'. .3O. . o[ by Pype Club ]]]==- - - . . + . '_ .. . . _OP3.. . .https://openpype.io.. . + ~P3.OPPPO3OP~ . .. . + . ' '. . .. . . . .. . "@ diff --git a/tools/create_zip.ps1 b/tools/create_zip.ps1 index 2fef4d216b..a34af89159 100644 --- a/tools/create_zip.ps1 +++ b/tools/create_zip.ps1 @@ -4,14 +4,19 @@ .DESCRIPTION This script will detect Python installation and run OpenPype to create - zip. It needs mongodb running. I will create zip from current source code - version and copy it top `%LOCALAPPDATA%/pypeclub/pype` if `--path` or `-p` + zip. It will create zip from current source code + version and copy it top `%LOCALAPPDATA%/pypeclub/openpype` if `--path` or `-p` argument is not used. .EXAMPLE PS> .\create_zip.ps1 +.EXAMPLE + +To put generated zip to C:\OpenPype directory: +PS> .\create_zip.ps1 --path C:\OpenPype + #> function Exit-WithCode($exitcode) { @@ -52,17 +57,17 @@ $art = @" . . .. . .. _oOOP3OPP3Op_. . - .PPpo~· ·· ~2p. ·· ···· · · - ·Ppo · .pPO3Op.· · O:· · · · - .3Pp · oP3'· 'P33· · 4 ·· · · · ·· · · · - ·~OP 3PO· .Op3 : · ·· _____ _____ _____ - ·P3O · oP3oP3O3P' · · · · / /·/ /·/ / - O3:· O3p~ · ·:· · ·/____/·/____/ /____/ - 'P · 3p3· oP3~· ·.P:· · · ·· · · ·· · · · - · ': · Po' ·Opo'· .3O· . o[ by Pype Club ]]]==- - - · · - · '_ .. · . _OP3·· · ·https://openpype.io·· · - ~P3·OPPPO3OP~ · ·· · - · ' '· · ·· · · · ·· · + .PPpo~. .. ~2p. .. .... . . + .Ppo . .pPO3Op.. . O:. . . . + .3Pp . oP3'. 'P33. . 4 .. . . . .. . . . + .~OP 3PO. .Op3 : . .. _____ _____ _____ + .P3O . oP3oP3O3P' . . . . / /./ /./ / + O3:. O3p~ . .:. . ./____/./____/ /____/ + 'P . 3p3. oP3~. ..P:. . . .. . . .. . . . + . ': . Po' .Opo'. .3O. . o[ by Pype Club ]]]==- - - . . + . '_ .. . . _OP3.. . .https://openpype.io.. . + ~P3.OPPPO3OP~ . .. . + . ' '. . .. . . . .. . "@ diff --git a/tools/make_docs.ps1 b/tools/make_docs.ps1 index 01edaf9c58..2f9350eff0 100644 --- a/tools/make_docs.ps1 +++ b/tools/make_docs.ps1 @@ -32,17 +32,17 @@ $art = @" . . .. . .. _oOOP3OPP3Op_. . - .PPpo~· ·· ~2p. ·· ···· · · - ·Ppo · .pPO3Op.· · O:· · · · - .3Pp · oP3'· 'P33· · 4 ·· · · · ·· · · · - ·~OP 3PO· .Op3 : · ·· _____ _____ _____ - ·P3O · oP3oP3O3P' · · · · / /·/ /·/ / - O3:· O3p~ · ·:· · ·/____/·/____/ /____/ - 'P · 3p3· oP3~· ·.P:· · · ·· · · ·· · · · - · ': · Po' ·Opo'· .3O· . o[ by Pype Club ]]]==- - - · · - · '_ .. · . _OP3·· · ·https://openpype.io·· · - ~P3·OPPPO3OP~ · ·· · - · ' '· · ·· · · · ·· · + .PPpo~. .. ~2p. .. .... . . + .Ppo . .pPO3Op.. . O:. . . . + .3Pp . oP3'. 'P33. . 4 .. . . . .. . . . + .~OP 3PO. .Op3 : . .. _____ _____ _____ + .P3O . oP3oP3O3P' . . . . / /./ /./ / + O3:. O3p~ . .:. . ./____/./____/ /____/ + 'P . 3p3. oP3~. ..P:. . . .. . . .. . . . + . ': . Po' .Opo'. .3O. . o[ by Pype Club ]]]==- - - . . + . '_ .. . . _OP3.. . .https://openpype.io.. . + ~P3.OPPPO3OP~ . .. . + . ' '. . .. . . . .. . "@ diff --git a/tools/run_documentation.ps1 b/tools/run_documentation.ps1 index 1be3709642..a3e3a9b8dd 100644 --- a/tools/run_documentation.ps1 +++ b/tools/run_documentation.ps1 @@ -1,23 +1,38 @@ <# .SYNOPSIS - Helper script to run mongodb. + Helper script to run Docusaurus for easy editing of OpenPype documentation. .DESCRIPTION - This script will detect mongodb, add it to the PATH and launch it on specified port and db location. + This script is using `yarn` package manager to run Docusaurus. If you don't + have `yarn`, install Node.js (https://nodejs.org/) and then run: + + npm install -g yarn + + It take some time to run this script. If all is successful you should see + new browser window with OpenPype documentation. All changes is markdown files + under .\website should be immediately seen in browser. .EXAMPLE -PS> .\run_mongo.ps1 +PS> .\run_documentation.ps1 #> $art = @" -▒█▀▀▀█ █▀▀█ █▀▀ █▀▀▄ ▒█▀▀█ █░░█ █▀▀█ █▀▀ ▀█▀ ▀█▀ ▀█▀ -▒█░░▒█ █░░█ █▀▀ █░░█ ▒█▄▄█ █▄▄█ █░░█ █▀▀ ▒█░ ▒█░ ▒█░ -▒█▄▄▄█ █▀▀▀ ▀▀▀ ▀░░▀ ▒█░░░ ▄▄▄█ █▀▀▀ ▀▀▀ ▄█▄ ▄█▄ ▄█▄ - .---= [ by Pype Club ] =---. - https://openpype.io + . . .. . .. + _oOOP3OPP3Op_. . + .PPpo~. .. ~2p. .. .... . . + .Ppo . .pPO3Op.. . O:. . . . + .3Pp . oP3'. 'P33. . 4 .. . . . .. . . . + .~OP 3PO. .Op3 : . .. _____ _____ _____ + .P3O . oP3oP3O3P' . . . . / /./ /./ / + O3:. O3p~ . .:. . ./____/./____/ /____/ + 'P . 3p3. oP3~. ..P:. . . .. . . .. . . . + . ': . Po' .Opo'. .3O. . o[ by Pype Club ]]]==- - - . . + . '_ .. . . _OP3.. . .https://openpype.io.. . + ~P3.OPPPO3OP~ . .. . + . ' '. . .. . . . .. . "@ @@ -26,7 +41,6 @@ Write-Host $art -ForegroundColor DarkGreen $script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent $openpype_root = (Get-Item $script_dir).parent.FullName -cd $openpype_root/website - -yarn run start +Set-Location $openpype_root/website +& yarn run start diff --git a/tools/run_mongo.ps1 b/tools/run_mongo.ps1 index 05fc497d34..6719e520fe 100644 --- a/tools/run_mongo.ps1 +++ b/tools/run_mongo.ps1 @@ -15,17 +15,17 @@ $art = @" . . .. . .. _oOOP3OPP3Op_. . - .PPpo~· ·· ~2p. ·· ···· · · - ·Ppo · .pPO3Op.· · O:· · · · - .3Pp · oP3'· 'P33· · 4 ·· · · · ·· · · · - ·~OP 3PO· .Op3 : · ·· _____ _____ _____ - ·P3O · oP3oP3O3P' · · · · / /·/ /·/ / - O3:· O3p~ · ·:· · ·/____/·/____/ /____/ - 'P · 3p3· oP3~· ·.P:· · · ·· · · ·· · · · - · ': · Po' ·Opo'· .3O· . o[ by Pype Club ]]]==- - - · · - · '_ .. · . _OP3·· · ·https://openpype.io·· · - ~P3·OPPPO3OP~ · ·· · - · ' '· · ·· · · · ·· · + .PPpo~. .. ~2p. .. .... . . + .Ppo . .pPO3Op.. . O:. . . . + .3Pp . oP3'. 'P33. . 4 .. . . . .. . . . + .~OP 3PO. .Op3 : . .. _____ _____ _____ + .P3O . oP3oP3O3P' . . . . / /./ /./ / + O3:. O3p~ . .:. . ./____/./____/ /____/ + 'P . 3p3. oP3~. ..P:. . . .. . . .. . . . + . ': . Po' .Opo'. .3O. . o[ by Pype Club ]]]==- - - . . + . '_ .. . . _OP3.. . .https://openpype.io.. . + ~P3.OPPPO3OP~ . .. . + . ' '. . .. . . . .. . "@ diff --git a/tools/run_project_manager.ps1 b/tools/run_project_manager.ps1 new file mode 100644 index 0000000000..67c2d2eb5e --- /dev/null +++ b/tools/run_project_manager.ps1 @@ -0,0 +1,18 @@ +<# +.SYNOPSIS + Helper script to run Project Manager. + +.DESCRIPTION + + +.EXAMPLE + +PS> .\run_project_manager.ps1 + +#> +$current_dir = Get-Location +$script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent +$openpype_root = (Get-Item $script_dir).parent.FullName +Set-Location -Path $openpype_root +& poetry run python "$($openpype_root)\start.py" projectmanager +Set-Location -Path $current_dir diff --git a/tools/run_tests.ps1 b/tools/run_tests.ps1 index 7b9a5c841d..30e1f29e59 100644 --- a/tools/run_tests.ps1 +++ b/tools/run_tests.ps1 @@ -34,17 +34,17 @@ $art = @" . . .. . .. _oOOP3OPP3Op_. . - .PPpo~· ·· ~2p. ·· ···· · · - ·Ppo · .pPO3Op.· · O:· · · · - .3Pp · oP3'· 'P33· · 4 ·· · · · ·· · · · - ·~OP 3PO· .Op3 : · ·· _____ _____ _____ - ·P3O · oP3oP3O3P' · · · · / /·/ /·/ / - O3:· O3p~ · ·:· · ·/____/·/____/ /____/ - 'P · 3p3· oP3~· ·.P:· · · ·· · · ·· · · · - · ': · Po' ·Opo'· .3O· . o[ by Pype Club ]]]==- - - · · - · '_ .. · . _OP3·· · ·https://openpype.io·· · - ~P3·OPPPO3OP~ · ·· · - · ' '· · ·· · · · ·· · + .PPpo~. .. ~2p. .. .... . . + .Ppo . .pPO3Op.. . O:. . . . + .3Pp . oP3'. 'P33. . 4 .. . . . .. . . . + .~OP 3PO. .Op3 : . .. _____ _____ _____ + .P3O . oP3oP3O3P' . . . . / /./ /./ / + O3:. O3p~ . .:. . ./____/./____/ /____/ + 'P . 3p3. oP3~. ..P:. . . .. . . .. . . . + . ': . Po' .Opo'. .3O. . o[ by Pype Club ]]]==- - - . . + . '_ .. . . _OP3.. . .https://openpype.io.. . + ~P3.OPPPO3OP~ . .. . + . ' '. . .. . . . .. . "@ diff --git a/website/docs/artist_install.md b/website/docs/artist_install.md new file mode 100644 index 0000000000..6fb5abe9e2 --- /dev/null +++ b/website/docs/artist_install.md @@ -0,0 +1,82 @@ +--- +id: artist_install +title: Installation +sidebar_label: Installation +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + +## Installation + +OpenPype comes in packages for Windows (10 or Server), Mac OS X (Mojave or higher), and Linux distribution (Centos, Ubuntu), and you can install them on your machine the same way as you are used to. + +:::important +To install OpenPype you will need administrator permissions. +::: + +:::note pick your platform + + + + +For installation on Windows, download and run the executable file `OpenPype-3.0.0.exe`. +During the installation process, you can change the destination location path of the application, + +![Windows installation](assets/install_01.png) + +and create an icon on the desktop. + +![Windows create icon](assets/install_02.png) + + + + + + +For installation on your Linux distribution, download and unzip `OpenPype-3.0.0.zip`. A new folder `OpenPype-3.0.0` will be created. +Inside this folder find and run `openpype_gui`, + +![Linux launch](assets/install_03.png) + + + + + + +For installation on Mac OS X, download and run dmg image file `OpenPype-3.0.0.dmg`. + +Drag the OpenPype icon into the Application folder. + +![Mac installation](assets/install_04.png) + +After the installation, you can find OpenPype among the other Applications. + + + +::: + +## Run OpenPype + +To run OpenPype click on the icon or find executable file (e.g. `C:\Program Files (x86)\OpenPype\openpype_gui.exe`) in the application location. +On the very first run of OpenPype the user will be asked for OpenPype Mongo URL. +This piece of information will be provided by the administrator or project manager who set up the studio. + +![Mongo example](assets/install_05.png) + +Once the Mongo URL address is entered, press `Start`, and OpenPype will be initiated. +OpenPype will also remember the connection for the next launch, so it is a one-time process. + +:::note +If the launch was successful, the artist should see a turquoise OpenPype logo in their +tray menu. Keep in mind that on Windows this icon might be hidden by default, in which case, the artist can simply drag the icon down to the tray. + +![Systray](assets/artist_systray.png) +::: \ No newline at end of file diff --git a/website/docs/assets/artist_systray.png b/website/docs/assets/artist_systray.png new file mode 100644 index 0000000000..6a0dd23375 Binary files /dev/null and b/website/docs/assets/artist_systray.png differ diff --git a/website/docs/assets/install_01.png b/website/docs/assets/install_01.png new file mode 100644 index 0000000000..c6b55826a3 Binary files /dev/null and b/website/docs/assets/install_01.png differ diff --git a/website/docs/assets/install_02.png b/website/docs/assets/install_02.png new file mode 100644 index 0000000000..1e97fd8139 Binary files /dev/null and b/website/docs/assets/install_02.png differ diff --git a/website/docs/assets/install_03.png b/website/docs/assets/install_03.png new file mode 100644 index 0000000000..1ddd0eea8b Binary files /dev/null and b/website/docs/assets/install_03.png differ diff --git a/website/docs/assets/install_04.png b/website/docs/assets/install_04.png new file mode 100644 index 0000000000..6c03c5abd4 Binary files /dev/null and b/website/docs/assets/install_04.png differ diff --git a/website/docs/assets/install_05.png b/website/docs/assets/install_05.png new file mode 100644 index 0000000000..a7119a49fa Binary files /dev/null and b/website/docs/assets/install_05.png differ diff --git a/website/docs/changelog.md b/website/docs/changelog.md index bec4a02173..57048b9398 100644 --- a/website/docs/changelog.md +++ b/website/docs/changelog.md @@ -4,6 +4,101 @@ title: Changelog sidebar_label: Changelog --- +## [2.18.0](https://github.com/pypeclub/openpype/tree/2.18.0) +_**release date:** (2021-05-18)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.3...2.18.0) + +**Enhancements:** + +- Use SubsetLoader and multiple contexts for delete_old_versions [\#1484](ttps://github.com/pypeclub/OpenPype/pull/1484)) +- TVPaint: Increment workfile version on successfull publish. [\#1489](https://github.com/pypeclub/OpenPype/pull/1489) +- Maya: Use of multiple deadline servers [\#1483](https://github.com/pypeclub/OpenPype/pull/1483) + +**Fixed bugs:** + +- Use instance frame start instead of timeline. [\#1486](https://github.com/pypeclub/OpenPype/pull/1486) +- Maya: Redshift - set proper start frame on proxy [\#1480](https://github.com/pypeclub/OpenPype/pull/1480) +- Maya: wrong collection of playblasted frames [\#1517](https://github.com/pypeclub/OpenPype/pull/1517) +- Existing subsets hints in creator [\#1502](https://github.com/pypeclub/OpenPype/pull/1502) + + +### [2.17.3](https://github.com/pypeclub/openpype/tree/2.17.3) +_**release date:** (2021-05-06)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.3...2.17.3) + +**Fixed bugs:** + +- Nuke: workfile version synced to db version always [\#1479](https://github.com/pypeclub/OpenPype/pull/1479) + +### [2.17.2](https://github.com/pypeclub/openpype/tree/2.17.2) +_**release date:** (2021-05-04)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.1...2.17.2) + +**Enhancements:** + +- Forward/Backward compatible apps and tools with OpenPype 3 [\#1463](https://github.com/pypeclub/OpenPype/pull/1463) + +### [2.17.1](https://github.com/pypeclub/openpype/tree/2.17.1) +_**release date:** (2021-04-30)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.0...2.17.1) + +**Enhancements:** + +- Faster settings UI loading [\#1442](https://github.com/pypeclub/OpenPype/pull/1442) +- Nuke: deadline submission with gpu [\#1414](https://github.com/pypeclub/OpenPype/pull/1414) +- TVPaint frame range definition [\#1424](https://github.com/pypeclub/OpenPype/pull/1424) +- PS - group all published instances [\#1415](https://github.com/pypeclub/OpenPype/pull/1415) +- Add task name to context pop up. [\#1383](https://github.com/pypeclub/OpenPype/pull/1383) +- Enhance review letterbox feature. [\#1371](https://github.com/pypeclub/OpenPype/pull/1371) +- AE add duration validation [\#1363](https://github.com/pypeclub/OpenPype/pull/1363) + +**Fixed bugs:** + +- Houdini menu filename [\#1417](https://github.com/pypeclub/OpenPype/pull/1417) +- Nuke: fixing undo for loaded mov and sequence [\#1433](https://github.com/pypeclub/OpenPype/pull/1433) +- AE - validation for duration was 1 frame shorter [\#1426](https://github.com/pypeclub/OpenPype/pull/1426) + +**Merged pull requests:** + +- Maya: Vray - problem getting all file nodes for look publishing [\#1399](https://github.com/pypeclub/OpenPype/pull/1399) +- Maya: Support for Redshift proxies [\#1360](https://github.com/pypeclub/OpenPype/pull/1360) + +## [2.17.0](https://github.com/pypeclub/openpype/tree/2.17.0) +_**release date:** (2021-04-20)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-beta.2...2.17.0) + +**Enhancements:** + +- Forward compatible ftrack group [\#1243](https://github.com/pypeclub/OpenPype/pull/1243) +- Maya: Make tx option configurable with presets [\#1328](https://github.com/pypeclub/OpenPype/pull/1328) +- TVPaint asset name validation [\#1302](https://github.com/pypeclub/OpenPype/pull/1302) +- TV Paint: Set initial project settings. [\#1299](https://github.com/pypeclub/OpenPype/pull/1299) +- TV Paint: Validate mark in and out. [\#1298](https://github.com/pypeclub/OpenPype/pull/1298) +- Validate project settings [\#1297](https://github.com/pypeclub/OpenPype/pull/1297) +- After Effects: added SubsetManager [\#1234](https://github.com/pypeclub/OpenPype/pull/1234) +- Show error message in pyblish UI [\#1206](https://github.com/pypeclub/OpenPype/pull/1206) + +**Fixed bugs:** + +- Hiero: fixing source frame from correct object [\#1362](https://github.com/pypeclub/OpenPype/pull/1362) +- Nuke: fix colourspace, prerenders and nuke panes opening [\#1308](https://github.com/pypeclub/OpenPype/pull/1308) +- AE remove orphaned instance from workfile - fix self.stub [\#1282](https://github.com/pypeclub/OpenPype/pull/1282) +- Nuke: deadline submission with search replaced env values from preset [\#1194](https://github.com/pypeclub/OpenPype/pull/1194) +- Ftrack custom attributes in bulks [\#1312](https://github.com/pypeclub/OpenPype/pull/1312) +- Ftrack optional pypclub role [\#1303](https://github.com/pypeclub/OpenPype/pull/1303) +- After Effects: remove orphaned instances [\#1275](https://github.com/pypeclub/OpenPype/pull/1275) +- Avalon schema names [\#1242](https://github.com/pypeclub/OpenPype/pull/1242) +- Handle duplication of Task name [\#1226](https://github.com/pypeclub/OpenPype/pull/1226) +- Modified path of plugin loads for Harmony and TVPaint [\#1217](https://github.com/pypeclub/OpenPype/pull/1217) +- Regex checks in profiles filtering [\#1214](https://github.com/pypeclub/OpenPype/pull/1214) +- Update custom ftrack session attributes [\#1202](https://github.com/pypeclub/OpenPype/pull/1202) +- Nuke: write node colorspace ignore `default\(\)` label [\#1199](https://github.com/pypeclub/OpenPype/pull/1199) + ## [2.16.0](https://github.com/pypeclub/pype/tree/2.16.0) _**release date:** 2021-03-22_ diff --git a/website/sidebars.js b/website/sidebars.js index c9edf5e3b7..7a5379c36b 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -9,6 +9,7 @@ module.exports = { "artist_concepts", "artist_publish", "artist_tools", + "artist_install" ], }, { diff --git a/website/src/pages/features.js b/website/src/pages/features.js index 35e156a230..d5c036eb89 100644 --- a/website/src/pages/features.js +++ b/website/src/pages/features.js @@ -37,15 +37,21 @@ const key_features = [ docs: "/docs/artist_tools#publisher", }, { - label: "Inventory", + label: "Scene manager", description: "Universal GUI for managing versions of assets loaded into your working scene.", docs: "docs/artist_tools#inventory", - }, - { + }, + { + label: "Project manager", + docs: "", + description: + "Tools for creating shots, assets and task within your project if you don't use third party project management", + }, + { label: "Library Loader", description: - "A loader GUI that allows yo to load content from dedicated cross project asset library", + "A loader GUI that allows yo to load content from dedicated cross project asset library", docs: "docs/artist_tools#library-loader", image: "", }, @@ -56,12 +62,6 @@ const key_features = [ "A standalone GUI for publishing data into pipeline without going though DCC app.", image: "", }, - { - label: "Pype Tray", - link: "", - description: - "Cross platform wrapper app, which is the central point of pype. All other tools are ran from here.", - }, { label: "App Launcher", link: "", @@ -69,14 +69,26 @@ const key_features = [ "Standalone GUI for launching application in the chosen context directly from tray", }, { - label: "Timer Manager", + label: "Configuration GUI", link: "", + description: + "All settings and configuration are done via openPype Settings tool. No need to dig around .json and .yaml", + }, + { + label: "Site Sync", + docs: "docs/module_site_sync", + description: + "Built in file synchronization between your central storage (cloud or physical) and all your freelancers", + }, + { + label: "Timers Manager", + link: "docs/admin_settings_system#timers-manager", description: "Service for monitoring the user activity to start, stop and synchronise time tracking.", }, { label: "Farm rendering", - link: "", + docs: "docs/module_deadline", description: "Integrations with Deadline and Muster render managers. Render, publish and generate reviews on the farm.", }, @@ -93,10 +105,10 @@ const key_features = [ "System for simple scene building. Loads pre-defined publishes to scene with single click, speeding up scene preparation.", }, { - label: "Configuration GUI", - link: "", + label: "Reviewables", + docs: "docs/project_settings/settings_project_global#extract-review", description: - "All settings and configuration are done via openPype Settings tool. No need to dig around .json and .yaml", + "Generate automated reviewable quicktimes and sequences in any format, with metadata burnins.", }, ]; @@ -109,6 +121,10 @@ const ftrack = [ docs: "docs/manager_ftrack#project-management", label: "Project Setup", description: "Quickly sets up project with customisable pre-defined structure and attributes." + }, { + docs: "docs/module_ftrack#update-status-on-task-action", + label: "Automate statuses", + description: "Quickly sets up project with customisable pre-defined structure and attributes." }, { docs: "docs/admin_ftrack#event-server", label: "Event Server", @@ -118,7 +134,7 @@ const ftrack = [ label: "Review publishing", description: "All reviewables from all DCC aps, including farm renders are pushed to ftrack online review." }, { - docs: "", + docs: "docs/admin_settings_system#timers-manager", label: "Auto Time Tracker", description: "Automatically starts and stops ftrack time tracker, base on artist activity." } @@ -185,8 +201,8 @@ const maya_features = [ description:"Makes all your playblasts consistent, with burnins and correct viewport settings" }, { - label: "Model > Render", - description:"We cover full project data flow from model through animation, till final render.", + label: "Renderlayers and AOVs", + description:"Full support of rendersetup layers and AOVs in all major renderers.", docs: "docs/artist_hosts_maya#working-with-pype-in-maya" }, { @@ -211,6 +227,7 @@ const maya_families = [ {label:"VDB Cache"}, {label:"Assembly"}, {label:"Camera"}, + {label:"CameraRig"}, {label:"RenderSetup"}, {label:"Render"}, {label:"Plate"}, @@ -231,7 +248,7 @@ const nuke_features = [ docs: "docs/artist_hosts_nuke#set-colorspace" }, { label: "Script Building", - description:"Automatically build first workfiles from published plates or renders", + description:"Automatically build initial workfiles from published plates or renders", docs: "docs/artist_hosts_nuke#build-first-work-file" }, { @@ -254,10 +271,8 @@ const nuke_families = [ {label: "Render"}, {label: "Plate"}, {label: "Review"}, - {label: "Group"}, {label: "Workfile"}, {label: "LUT"}, - {label: "Cache"}, {label: "Gizmo"}, {label: "Prerender"}, ] @@ -294,6 +309,26 @@ const deadline_families = [ ] const hiero_features = [ + { + label: "Project setup", + description:"Automatic colour, timeline and fps setup of you hiero project." + }, + { + label: "Create shots", + description:"Populate project with shots based on your conformed edit." + }, + { + label: "Publish plates", + description:"Publish multiple tracks with plates to you shots from a single timeline." + }, + { + label: "Retimes", + description:"Publish retime information for individual plates." + }, + { + label: "LUTS and fx", + description:"Publish soft effects from your timeline to be used on shots." + }, ] const hiero_families = [ @@ -330,7 +365,6 @@ const houdini_families = [ {label:"Point Cache"}, {label:"VDB Cache"}, {label:"Camera"}, - {label:"Review"}, {label:"Workfile"}, ] @@ -355,11 +389,29 @@ const harmony_families = [ {label: "Workfile"} ] +const tvpaint_families = [ + {label: "Render"}, + {label: "Review"}, + {label: "Image"}, + {label: "Audio"}, + {label: "Workfile"} +] + const photoshop_families = [ {label: "Render"}, {label: "Plate"}, {label: "Image"}, {label: "LayeredImage"}, + {label: "Background"}, + {label: "Workfile"} +] + +const aftereffects_families = [ + {label: "Render"}, + {label: "Plate"}, + {label: "Image"}, + {label: "Audio"}, + {label: "Background"}, {label: "Workfile"} ] @@ -512,9 +564,15 @@ function Home() {
-

Autodesk Maya

+

Autodesk Maya

versions 2017 and higher

+

+ OpenPype includes very robust Maya implementation that can handle full CG workflow from model, + through animation till final renders. Scene settings, Your artists won't need to touch file browser at all and OpenPype will + take care of all the file management. Most of maya workflows are supported including gpucaches, referencing, nested references and render proxies. +

+ {maya_features && maya_features.length && (
{maya_features.map((props, idx) => ( @@ -537,7 +595,7 @@ function Home() {
-

Foundry Nuke | NukeX

+

Foundry Nuke | NukeX

versions 11.0 and higher

@@ -563,10 +621,18 @@ function Home() {
-

Foundry Hiero | Nuke Studio

+

Foundry Hiero | Nuke Studio

versions 11.0 and higher

+ {hiero_features && hiero_features.length && ( +
+ {hiero_features.map((props, idx) => ( + + ))} +
+ )} +

Supported Families

{hiero_families && hiero_families.length && ( @@ -579,6 +645,78 @@ function Home() {
+
+
+

After Effects

+ +

versions 2020 and higher

+ +

Supported Families

+ + {aftereffects_families && aftereffects_families.length && ( +
+ {aftereffects_families.map((props, idx) => ( + + ))} +
+ )} +
+
+ +
+
+

Photoshop

+ +

versions 2020 and higher

+ +

Supported Families

+ + {photoshop_families && photoshop_families.length && ( +
+ {photoshop_families.map((props, idx) => ( + + ))} +
+ )} +
+
+ +
+
+

Harmony

+ +

versions 17 and higher

+ +

Supported Families

+ + {harmony_families && harmony_families.length && ( +
+ {harmony_families.map((props, idx) => ( + + ))} +
+ )} +
+
+ +
+
+

TV Paint

+ +

versions 11

+ +

Supported Families

+ + {tvpaint_families && tvpaint_families.length && ( +
+ {tvpaint_families.map((props, idx) => ( + + ))} +
+ )} +
+
+

Houdini

@@ -600,9 +738,9 @@ function Home() {
-

Blender

+

Blender

-

versions 2.80 and higher

+

versions 2.83 and higher

Supported Families

@@ -636,44 +774,6 @@ function Home() {
-
-
-

Harmony

- -

versions 17 and higher

- -

Supported Families

- - {harmony_families && harmony_families.length && ( -
- {harmony_families.map((props, idx) => ( - - ))} -
- )} -
-
- - -
-
-

Photoshop

- -

versions 2020 and higher

- -

Supported Families

- - {photoshop_families && photoshop_families.length && ( -
- {photoshop_families.map((props, idx) => ( - - ))} -
- )} -
-
- - ); }