diff --git a/README.md b/README.md index 4272f4e..1718755 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [Installation/Usage](#installationusage) | [Docs](https://nxt-dev.github.io/) | [Contributing](CONTRIBUTING.md) | [Licensing](LICENSE) # Installation/Usage -**To Use NXT please use the [NXT Standalone](#nxt-standalone) or [DCC plugin zip.](#maya-plugin)** +**To Use NXT please use the [NXT Standalone](#nxt-standalone) or [DCC plugin zip.](#DCC-Plugins)** Only clone this repo if you're [contributing](CONTRIBUTING.md) to the NXT codebase.
@@ -24,32 +24,14 @@ Our releases are hosted on [PyPi](https://pypi.org/project/nxt-editor/). - Update: - `pip install -U nxt-editor` -### Blender addon: -- Install: - 1. Download Blender addon (nxt_blender.zip) [latest release](https://github.com/nxt-dev/nxt_editor/releases/latest) - 2. Extract and follow `README.md` inside [nxt_blender](nxt_editor/integration/blender/README.md) instructions (also included in the download) -- Launch: - 1. Load the `nxt_blender` Addon (Edit > Preferences > Add-ons) - 2. Navigate the newly created NXT menu and select Open Editor. -- Update: - - Automatically: NXT > Update NXT - - By Hand: `/path/to/python.exe -m pip install -U nxt-editor` - - Relaunch Blender after - +### DCC Plugins -### Maya plugin: +Each of our supported DCC's get a zip file on our [latest release](https://github.com/nxt-dev/nxt_editor/releases/latest) -- Install: - 1. Download the maya module(`nxt_maya.zip`) from the [latest release](https://github.com/nxt-dev/nxt_editor/releases/latest) - 2. Follow the [nxt_maya](nxt_editor/integration/maya/README.md) instructions (also included in the download) -- Launch: - 1. Load `nxt_maya` plugin in Maya - 2. Select the `nxt` menu from the menus at the top of Maya - 3. Click `Open Editor` -- Update: - 1. Download the `nxt_maya` zip from the [latest release](https://github.com/nxt-dev/nxt_editor/releases/latest) - 2. Extract the zip and replace the existing `nxt_maya` files with the newly extracted files. - 3. Re-launch Maya +Each one contains a `README.md` inside to explain how to install/update them. +- [nxt_maya](nxt_editor/integration/maya/README.md) +- [nxt_blender](nxt_editor/integration/blender/README.md) +- [nxt_unreal](nxt_editor/integration/unreal/README.md)
diff --git a/build/make_maya_plugin.nxt b/build/make_maya_plugin.nxt index f29d579..e9847b6 100644 --- a/build/make_maya_plugin.nxt +++ b/build/make_maya_plugin.nxt @@ -170,7 +170,11 @@ "os.makedirs('${mod_folder}/scripts/Qt')", "", "with open('${mod_folder}/scripts/Qt/__init__.py', 'w+') as fp:", - " fp.write(result.content)" + " if isinstance(result.content, str):", + " fp.write(result.content)", + " else:", + " fp.write(result.content.decode())", + "" ] } } diff --git a/build/make_unreal_plugin.nxt b/build/make_unreal_plugin.nxt new file mode 100644 index 0000000..6e02431 --- /dev/null +++ b/build/make_unreal_plugin.nxt @@ -0,0 +1,60 @@ +{ + "version": "1.17", + "alias": "make_unreal_plugin", + "color": "#879fda", + "mute": false, + "solo": false, + "meta_data": { + "positions": { + "/make_plugin": [ + -144.0, + -49.0 + ] + } + }, + "nodes": { + "/": { + "code": [ + "import os", + "import shutil" + ] + }, + "/make_plugin": { + "start_point": true, + "attrs": { + "icon_path": { + "type": "raw", + "value": "${file::../nxt_editor/resources/icons/nxt_128.png}" + }, + "result_dir": { + "type": "raw", + "value": "${path::nxt_unreal}" + }, + "unreal_integration_dir": { + "type": "raw", + "value": "${file::../nxt_editor/integration/unreal}" + }, + "uplugin_path": { + "type": "raw", + "value": "${file::${unreal_integration_dir}/nxt_unreal.uplugin}" + } + }, + "code": [ + "if os.path.exists('${result_dir}'):", + " shutil.rmtree('${result_dir}')", + "shutil.copytree('${unreal_integration_dir}', '${result_dir}')", + "resources_dir = '${result_dir}/Resources'", + "os.makedirs(resources_dir)", + "target_icon_path = os.path.join(resources_dir, 'Icon128.png')", + "shutil.copyfile('${icon_path}', target_icon_path)", + "leftover_init = os.path.join(self.result_dir, '__init__.py')", + "leftover_pycache = os.path.join(self.result_dir, '__pycache__')", + "os.remove(leftover_init)", + "try:", + " shutil.rmtree(leftover_pycache)", + "except:", + " pass" + ] + } + } +} \ No newline at end of file diff --git a/build/release.nxt b/build/release.nxt index e21a2dc..be60d4d 100644 --- a/build/release.nxt +++ b/build/release.nxt @@ -5,6 +5,7 @@ "mute": false, "solo": false, "references": [ + "make_unreal_plugin.nxt", "make_maya_plugin.nxt", "make_blender_plugin.nxt", "../../nxt/build/release.nxt" @@ -23,8 +24,8 @@ }, "positions": { "/CreateRelease": [ - 2460.0, - 60.0 + 2861.9022362347496, + 69.87218527196421 ], "/GitClone": [ -1000.0, @@ -59,8 +60,8 @@ 420.0 ], "/ReleaseLoop": [ - 2420.0, - 0.0 + 2824.2201296032613, + 7.554291903452558 ], "/build_maya_plugin": [ 1800.0, @@ -78,6 +79,10 @@ 1820.0, 0.0 ], + "/make_plugin": [ + 2465.5358761299212, + 1.3083409543444589 + ], "/versions": [ 140.0, 0.0 @@ -85,6 +90,10 @@ "/zip_blender_addon": [ 2140.0, 80.0 + ], + "/zip_blender_addon2": [ + 2460.556716567386, + 151.71085853406586 ] }, "collapse": { @@ -128,6 +137,7 @@ "DraftRelease", "UploadBlenderAddon", "UploadMayaPlugin", + "UploadUnrealPlugin", "OpenReleaseURL" ] }, @@ -157,8 +167,21 @@ } } }, + "/CreateRelease/UploadUnrealPlugin": { + "instance": "/GitUpload", + "attrs": { + "asset_path": { + "type": "raw", + "value": "${/make_plugin/zip_unreal_plugin.zip_path}" + }, + "content_type": { + "type": "raw", + "value": "application/zip" + } + } + }, "/ReleaseLoop": { - "execute_in": "/make_addon", + "execute_in": "/make_plugin", "attrs": { "release_types": { "type": "tuple", @@ -224,6 +247,34 @@ "shutil.rmtree('${mod_folder}')" ] }, + "/make_plugin": { + "start_point": false, + "execute_in": "/make_addon", + "child_order": [ + "zip_unreal_plugin" + ], + "attrs": { + "result_dir": { + "value": "${path::${release_dir}/nxt_unreal}" + } + } + }, + "/make_plugin/zip_unreal_plugin": { + "attrs": { + "zip_name": { + "type": "raw", + "value": "${result_dir}" + }, + "zip_path": { + "type": "raw", + "value": "${zip_name}.zip" + } + }, + "code": [ + "self.zip_path = shutil.make_archive('${zip_name}', 'zip', '${result_dir}')", + "shutil.rmtree('${result_dir}')" + ] + }, "/versions": { "attrs": { "EDITOR": { diff --git a/nxt_editor/__init__.py b/nxt_editor/__init__.py index c96437e..a9d778a 100644 --- a/nxt_editor/__init__.py +++ b/nxt_editor/__init__.py @@ -78,6 +78,7 @@ def make_resources(qrc_path=None, result_path=None): from nxt_editor import qresources except ImportError: make_resources() + from nxt_editor import qresources def _new_qapp(): diff --git a/nxt_editor/connection_graphics_item.py b/nxt_editor/connection_graphics_item.py index a68df04..526a621 100644 --- a/nxt_editor/connection_graphics_item.py +++ b/nxt_editor/connection_graphics_item.py @@ -10,6 +10,7 @@ from . import colors from nxt import nxt_path, nxt_node import nxt_editor +from nxt_editor.node_graphics_item import MIN_LOD logger = logging.getLogger(nxt_editor.LOGGER_NAME) @@ -98,9 +99,19 @@ def rebuild_line(self): self.update() def paint(self, painter, option, widget): - painter.setRenderHints(QtGui.QPainter.Antialiasing | - QtGui.QPainter.SmoothPixmapTransform) - pen = QtGui.QPen(self.color, self.thickness, self.pen_style) + lod = QtWidgets.QStyleOptionGraphicsItem.levelOfDetailFromTransform( + painter.worldTransform()) + if lod > MIN_LOD: + painter.setRenderHints(QtGui.QPainter.Antialiasing | + QtGui.QPainter.SmoothPixmapTransform) + thick_mult = 1 + pen_style = self.pen_style + else: + painter.setRenderHints(False) + thick_mult = 3 + pen_style = QtCore.Qt.PenStyle.SolidLine + pen = QtGui.QPen(self.color, self.thickness * thick_mult, + self.pen_style) # if self.tgt_path in self.model.selection: # pen.setColor(colors.SELECTED) # elif self.is_hovered: diff --git a/nxt_editor/dockwidgets/code_editor.py b/nxt_editor/dockwidgets/code_editor.py index 6c7476a..7ed7aef 100644 --- a/nxt_editor/dockwidgets/code_editor.py +++ b/nxt_editor/dockwidgets/code_editor.py @@ -319,19 +319,26 @@ def update_editor(self, node_list=()): # for faster updates. And avoid that early exit check at the top. get_code = self.stage_model.get_node_code_string code_string = get_code(self.node_path, - self.stage_model.data_state, - self.stage_model.comp_layer) + self.stage_model.data_state, + self.stage_model.comp_layer) cached_state = self.stage_model.data_state == DATA_STATE.CACHED + self.actual_display_state = DATA_STATE.RAW if code_string and cached_state: self.actual_display_state = DATA_STATE.CACHED elif not code_string and cached_state: self.actual_display_state = DATA_STATE.RAW code_string = get_code(self.node_path, DATA_STATE.RAW, - self.stage_model.comp_layer) + self.stage_model.comp_layer) + else: + self.actual_display_state = self.stage_model.data_state if self.editing_active: - self.overlay_widget.main_color = self.overlay_widget.base_color + self.overlay_widget.hide() + elif self.code_is_local: + self.overlay_widget.main_color = None + self.overlay_widget.show() else: self.overlay_widget.main_color = self.overlay_widget.ext_color + self.overlay_widget.show() self.overlay_widget.update() self.editor.verticalScrollBar().blockSignals(True) self.cached_code_lines = code_string.split('\n') @@ -397,11 +404,6 @@ def update_code_is_local(self): self.editor.current_line_highlight = not is_local self.overlay_widget.main_color = self.overlay_widget.base_color self.update_background() - if self.editing_active or is_local: - self.overlay_widget.hide() - else: - self.overlay_widget.main_color = self.overlay_widget.ext_color - self.overlay_widget.show() self.code_is_local = is_local def localize_code(self): @@ -1280,26 +1282,34 @@ def __init__(self, parent=None): self.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) self.data_state = '' + self.click_msg = 'Double Click To Edit' def paintEvent(self, event): painter = QtGui.QPainter() painter.begin(self) + painter.setFont(QtGui.QFont("Roboto", 14)) + font_metrics = QtGui.QFontMetrics(painter.font()) painter.setRenderHint(QtGui.QPainter.Antialiasing) # actual_display_state code_editor = self._parent.ce_widget model = code_editor.stage_model self.data_state = code_editor.actual_display_state painter.setPen(QtCore.Qt.white) - font_matrics = QtGui.QFontMetrics(painter.font()) - offset = font_matrics.boundingRect(self.data_state).width() - offset += painter.font().pointSize() + # Draw top right data state text + offset = font_metrics.boundingRect(self.data_state).width() + offset += painter.font().pointSize() * 1.5 painter.drawText(self.rect().right() - offset, painter.font().pointSize() * 1.5, self.data_state) + # Draw center message text + msg_offset = font_metrics.boundingRect(self.click_msg).width() + msg_offset += painter.font().pointSize() + painter.drawText(self.rect().center().x() - (msg_offset*.5), + self.rect().center().y(), self.click_msg) painter.setCompositionMode(QtGui.QPainter.CompositionMode_Darken) - path = QtGui.QPainterPath() path.addRoundedRect(QtCore.QRectF(self.rect()), 9, 9) - painter.fillPath(path, QtGui.QBrush(self.main_color)) + if self.main_color: + painter.fillPath(path, QtGui.QBrush(self.main_color)) painter.setCompositionMode(QtGui.QPainter.CompositionMode_Screen) display_is_raw = self.data_state == DATA_STATE.RAW mode_is_cache = model.data_state == DATA_STATE.CACHED diff --git a/nxt_editor/dockwidgets/widget_builder.py b/nxt_editor/dockwidgets/widget_builder.py index 7541916..79bde18 100644 --- a/nxt_editor/dockwidgets/widget_builder.py +++ b/nxt_editor/dockwidgets/widget_builder.py @@ -1,6 +1,10 @@ # Built-in import logging import ast +try: + from collections.abc import Iterable +except ImportError: + from collections import Iterable # External from Qt import QtWidgets @@ -18,6 +22,7 @@ from nxt_editor import user_dir from nxt_editor.dockwidgets.dock_widget_base import DockWidgetBase from nxt import DATA_STATE, nxt_path +from nxt import nxt_node logger = logging.getLogger(nxt_editor.LOGGER_NAME) @@ -39,6 +44,7 @@ def __init__(self, graph_model=None, title='Workflow Tools', parent=None, minimum_width=minimum_width, minimum_height=minimum_height) + self.updating = False self.setObjectName('Workflow Tools') self.default_title = title @@ -262,6 +268,8 @@ def get_window_title(self): return title or None def update_window(self, changed_paths=None): + if self.updating: + return if not self.isVisible(): return @@ -271,7 +279,9 @@ def update_window(self, changed_paths=None): return update = False if changed_paths else True - for path in (changed_paths or []): + if not isinstance(changed_paths, Iterable): + changed_paths = [] + for path in changed_paths: node_path, _ = nxt_path.path_attr_partition(path) is_widget = get_widget_type(node_path, self.stage_model) if is_widget: @@ -285,7 +295,7 @@ def update_window(self, changed_paths=None): break if not update: return - + self.updating = True # window title title = self.get_window_title() self.setWindowTitle(title or self.default_title) @@ -310,6 +320,7 @@ def update_window(self, changed_paths=None): widget=self.window_frame, items=None, parent=self) + self.updating = False def set_window_style(self): background_color = self.stage_model.get_node_attr_value( @@ -881,15 +892,22 @@ def execute_node_path(self, node_path, attr_name): # filter out widget nodes filtered_nodes = [] - widget_descendants = [] + dont_run = [] for path in descendants: widget_type = get_widget_type(path, self.stage_model) if widget_type: des = self.stage_model.comp_layer.descendants(path, ordered=True) - widget_descendants += des - if not widget_type and path not in widget_descendants: - filtered_nodes.append(path) + dont_run += des + if not widget_type and path not in dont_run: + enabled = self.stage_model.get_node_enabled(path) + anc_enabled = self.stage_model.get_node_ancestor_enabled(path) + if enabled and anc_enabled: + filtered_nodes.append(path) + else: + des = self.stage_model.comp_layer.descendants(path, + ordered=True) + dont_run += [path] + des node_paths = [exec_path] + filtered_nodes if user_dir.user_prefs.get(RECOMP_PREF, True): diff --git a/nxt_editor/integration/__init__.py b/nxt_editor/integration/__init__.py index 020fa48..5b768b2 100644 --- a/nxt_editor/integration/__init__.py +++ b/nxt_editor/integration/__init__.py @@ -1,5 +1,6 @@ import os -import shutil +import sys +import subprocess import importlib @@ -32,29 +33,41 @@ def _safe_import_package(package_name, global_name=None): def _install_and_import_package(self, module_name, package_name=None, global_name=None): + """Calls a subprocess to pip install the given package name and then + attempts to import the new package. + + :param module_name: Desired module to import after install + :param package_name: pip package name + :param global_name: Global name to access the module if different + than the module name. + :raises: subprocess.CalledProcessError + :return: bool + """ if package_name is None: package_name = module_name if global_name is None: global_name = module_name - args = ['install', package_name] - self.ensure_pip() - import pip - if hasattr(pip, 'main'): - pip.main(['install', package_name]) - else: - pip._internal.main(['install', package_name]) + environ_copy = dict(os.environ) + environ_copy["PYTHONNOUSERSITE"] = "1" + subprocess.run([sys.executable, "-m", "pip", "install", + package_name], check=True, env=environ_copy) + success = self._safe_import_package(package_name=package_name, global_name=global_name) return success - def _update_package(self, package_name): - args = ['install', '-U', package_name] - self.ensure_pip() - import pip - if hasattr(pip, 'main'): - pip.main(args) - else: - pip._internal.main(args) + @staticmethod + def _update_package(package_name): + """Calls a subprocess to pip update the given package name. + + :param package_name: pip package name + :raises: subprocess.CalledProcessError + :return: None + """ + environ_copy = dict(os.environ) + environ_copy["PYTHONNOUSERSITE"] = "1" + subprocess.run([sys.executable, "-m", "pip", "install", "-U", + package_name], check=True, env=environ_copy) print('Please restart your DCC or Python interpreter') def check_for_nxt_core(self, install=False): @@ -81,51 +94,39 @@ def check_for_nxt_editor(self, install=False): print('Failed to import and/or install nxt-editor') return success - @staticmethod - def ensure_pip(): - try: - import pip - except ImportError: - import ensurepip - ensurepip.bootstrap() - os.environ.pop('PIP_REQ_TRACKER', None) - def update(self): if self.check_for_nxt_core(): self._update_package('nxt-core') if self.check_for_nxt_editor(): self._update_package('nxt-editor') + @staticmethod + def _uninstall_package(package_name): + """Calls a subprocess to pip uninstall the given package name. Will + NOT prompt the user to confrim uninstall. + + :param package_name: pip package name + :raises: subprocess.CalledProcessError + :return: None + """ + environ_copy = dict(os.environ) + environ_copy["PYTHONNOUSERSITE"] = "1" + subprocess.run([sys.executable, "-m", "pip", "uninstall", + package_name, '-y'], check=True, env=environ_copy) + + def uninstall(self): + if self.check_for_nxt_core(): + self._uninstall_package('nxt-core') + if self.check_for_nxt_editor(): + self._uninstall_package('nxt-editor') + # print('Please restart your DCC or Python interpreter') -class Blender(NxtIntegration): - def __init__(self): - super(Blender, self).__init__(name='blender') - import bpy - b_major, b_minor, b_patch = bpy.app.version - if b_major != 2 or b_minor < 80: - raise RuntimeError('Blender version is not compatible with this ' - 'version of nxt.') - user_dir = os.path.expanduser('~/AppData/Roaming/Blender ' - 'Foundation/Blender/' - '{}.{}'.format(b_major, b_minor)) - self.user_dir = user_dir - nxt_modules = os.path.join(user_dir, 'scripts/addons/modules') - self.modules_dir = nxt_modules.replace(os.sep, '/') + def launch_nxt(self): + raise NotImplementedError('Your DCC needs it own nxt launch method.') - @classmethod - def setup(cls): - self = cls() - import bpy - bpy.ops.preferences.addon_disable(module='nxt_' + self.name) - addons_dir = os.path.join(self.user_dir, 'scripts/addons') - integration_filepath = self.get_integration_filepath() - shutil.copy(integration_filepath, addons_dir) - bpy.ops.preferences.addon_enable(module='nxt_' + self.name) + def quit_nxt(self): + raise NotImplementedError('Your DCC needs it own nxt quit method.') - @classmethod - def update(cls): - self = cls() - og_cwd = os.getcwd() - os.chdir(self.modules_dir) - super(Blender, self).update() - os.chdir(og_cwd) + def create_context(self): + raise NotImplementedError('Your DCC needs it own method of creating a ' + 'context.') diff --git a/nxt_editor/integration/blender/README.md b/nxt_editor/integration/blender/README.md index e7b2c04..d9215be 100644 --- a/nxt_editor/integration/blender/README.md +++ b/nxt_editor/integration/blender/README.md @@ -4,22 +4,54 @@ This is a Blender addon for nxt. Note that it will access the internet to instal ### By hand (if you're familiar with pip) 1. Locate the path to blenders Python interpreter - - In Blender, you can run `sys.exec_prefix` to find the folder containing the Python executable. + - In Blender, you can run `sys.exec_prefix` to find the folder containing the Python executable 2. Open Terminal or CMD (If you're using Windows) -3. Run: `/path/to/python.exe -m pip install nxt-editor` +3. Run: `/path/to/blender/python.exe -m pip install nxt-editor` 4. Start Blender 5. Open the addon manager (Edit > Preferences > Add-ons) -6. Click "Install" and select the `nxt_blender.py` file provided with this addon zip. -7. To launch NXT navigate the newly created `NXT` menu and select `Open Editor`. +6. Click "Install" and select the `nxt_blender.py` file provided with this addon zip +7. To launch NXT navigate the newly created `NXT` menu and select `Open Editor` -_Note: If you install NXT Blender this way the "Update" button may not work in the NXT menu._ ### Automated -1. Open Blender's script editor. -2. Drag and drop `blender_installer.py` into the script editor and click the play button or press `Alt+P` (default run script hotkey) -3. The installation may take a minute or two, during this time Blender will be unresponsive. - - Optionally open the System Console window before running the script, so you can see what's happening. +1. Open the addon manager (Edit > Preferences > Add-ons) +2. Click "Install" and select the `nxt_blender.py` file provided with this addon zip +3. Enable the `NXT Blender` and twirl down the addon preferences +3. Click `Install NXT dependencies` + - The installation may take a minute or two, during this time Blender will be unresponsive + - Optionally open the console window before running the script, so you can see what's happening +4. Restart Blender # Usage -- Ensure the `NXT Blender` addon is enabled. -- To launch NXT navigate the newly created `NXT` menu and select `Open Editor`. \ No newline at end of file +- Ensure the `NXT Blender` addon is enabled +- To launch NXT navigate the newly created `NXT` menu and select `Open Editor` + +# Updating +### By hand (if you're familiar with pip) +1. In terminal or cmd run: `/path/to/blender/python.exe -m pip install -U nxt-editor nxt` +2. Restart Blender + +### Automated +1. Open the addon manager (Edit > Preferences > Add-ons) +3. Locate the `NXT Blender` and twirl down the addon preferences +3. Click `Update NXT dependencies` +4. Restart Blender + +_or_ + +1. Navigate to the NXT menu +2. Click `Update NXT` +3. Restart Blender + +# Uninstall +### By hand (if you're familiar with pip) +1. Open the addon manager (Edit > Preferences > Add-ons) +2. Locate the `NXT Blender` and twirl down the addon preferences +3. Click 'Remove' +1. In terminal or cmd run: `/path/to/blender/python.exe -m pip uninstall nxt-editor nxt -y` + +### Automated +1. Open the addon manager (Edit > Preferences > Add-ons) +3. Locate the `NXT Blender` and twirl down the addon preferences +3. Click `Uninstall NXT dependencies` +3. When that finishes, click the 'Remove' button \ No newline at end of file diff --git a/nxt_editor/integration/blender/__init__.py b/nxt_editor/integration/blender/__init__.py new file mode 100644 index 0000000..09f83bb --- /dev/null +++ b/nxt_editor/integration/blender/__init__.py @@ -0,0 +1,96 @@ +# Builtin +import os +import shutil +import sys +import atexit + +# External +import bpy +from Qt import QtCore, QtWidgets + +# Internal +from nxt.constants import NXT_DCC_ENV_VAR +from nxt_editor.integration import NxtIntegration +import nxt_editor + +__NXT_INTEGRATION__ = None + + +class Blender(NxtIntegration): + def __init__(self): + super(Blender, self).__init__(name='blender') + b_major, b_minor, b_patch = bpy.app.version + if b_major != 2 or b_minor < 80: + raise RuntimeError('Blender version is not compatible with this ' + 'version of nxt.') + addons_dir = bpy.utils.user_resource('SCRIPTS', 'addons') + self.addons_dir = addons_dir.replace(os.sep, '/') + self.instance = None + self.nxt_qapp = QtWidgets.QApplication.instance() + + @classmethod + def setup(cls): + self = cls() + bpy.ops.preferences.addon_disable(module='nxt_' + self.name) + integration_filepath = self.get_integration_filepath() + shutil.copy(integration_filepath, self.addons_dir) + bpy.ops.preferences.addon_enable(module='nxt_' + self.name) + + @classmethod + def update(cls): + self = cls() + og_cwd = os.getcwd() + super(Blender, self).update() + os.chdir(og_cwd) + addon_file = os.path.join(os.path.dirname(__file__), 'nxt_blender.py') + shutil.copy(addon_file, self.addons_dir) + + @classmethod + def launch_nxt(cls): + self = cls() + os.environ[NXT_DCC_ENV_VAR] = 'blender' + global __NXT_INTEGRATION__ + if not __NXT_INTEGRATION__: + __NXT_INTEGRATION__ = self + else: + self = __NXT_INTEGRATION__ + if self.instance: + self.instance.show() + return + if not self.nxt_qapp: + self.nxt_qapp = nxt_editor._new_qapp() + nxt_win = nxt_editor.show_new_editor(start_rpc=False) + else: + nxt_win = nxt_editor.show_new_editor(start_rpc=False) + if 'win32' in sys.platform: + # gives nxt it's own entry on taskbar + nxt_win.setWindowFlags(QtCore.Qt.Window) + + def unregister_nxt(): + self.instance = None + if self.nxt_qapp: + self.nxt_qapp.quit() + self.nxt_qapp = None + + nxt_win.close_signal.connect(unregister_nxt) + nxt_win.show() + atexit.register(nxt_win.close) + self.instance = nxt_win + return self + + def quit_nxt(self): + if self.instance: + self.instance.close() + atexit.unregister(self.instance.close) + if self.nxt_qapp: + self.nxt_qapp.quit() + global __NXT_INTEGRATION__ + __NXT_INTEGRATION__ = None + + def create_context(self): + placeholder_txt = 'Blender {}.{}'.format(*bpy.app.version) + args = ['-noaudio', '--background', '--python'] + self.instance.create_remote_context(placeholder_txt, + interpreter_exe=bpy.app.binary_path, + exe_script_args=args) + diff --git a/nxt_editor/integration/blender/blender_installer.py b/nxt_editor/integration/blender/blender_installer.py deleted file mode 100644 index 878e601..0000000 --- a/nxt_editor/integration/blender/blender_installer.py +++ /dev/null @@ -1,37 +0,0 @@ -import os -import sys - -try: - import pip - _ = pip.main - _ = pip._internal.main - -except (ImportError, AttributeError): - import ensurepip - ensurepip.bootstrap() - os.environ.pop('PIP_REQ_TRACKER', None) - -try: - import nxt_editor -except ImportError: - import bpy - b_major, b_minor, b_patch = bpy.app.version - user_dir = os.path.expanduser('~/AppData/Roaming/Blender ' - 'Foundation/Blender/' - '{}.{}'.format(b_major, b_minor)) - t = os.path.join(user_dir, 'scripts/addons/modules') - t = t.replace(os.sep, '/') - args = ['install', 'D:/Projects/nxt_editor', '--target', t] - if hasattr(pip, 'main'): - pip.main(args) - else: - pip._internal.main(args) - # The next time blender starts it will see this path automatically - cwd = os.getcwd() - os.chdir(t) - sys.path.append(t) - os.chdir(cwd) - -# Install nxt_blender addon -from nxt_editor import integration -integration.Blender.setup() diff --git a/nxt_editor/integration/blender/nxt_blender.py b/nxt_editor/integration/blender/nxt_blender.py index 7e43d8d..f99857c 100644 --- a/nxt_editor/integration/blender/nxt_blender.py +++ b/nxt_editor/integration/blender/nxt_blender.py @@ -1,28 +1,41 @@ +""" +Loosely based on the example addon from this repo: +https://github.com/robertguetzkow/blender-python-examples +""" # Builtin import os import sys +import subprocess # External -from Qt import QtCore, QtWidgets import bpy -# Internal -from nxt.constants import NXT_DCC_ENV_VAR -from nxt_editor.constants import NXT_WEBSITE -import nxt_editor.main_window -import nxt_editor -os.environ[NXT_DCC_ENV_VAR] = 'blender' +try: + # External + from Qt import QtCore, QtWidgets + # Internal + from nxt_editor.constants import NXT_WEBSITE + from nxt_editor.integration import blender + nxt_installed = True +except ImportError: + nxt_installed = False + NXT_WEBSITE = 'https://nxt-dev.github.io/' + +nxt_package_name = 'nxt-editor' bl_info = { "name": "NXT Blender", "blender": (2, 80, 0), - "version": (0, 1, 0), + "version": (0, 2, 0), "location": "NXT > Open Editor", "wiki_url": "https://nxt-dev.github.io/", "tracker_url": "https://github.com/nxt-dev/nxt_editor/issues", "category": "nxt", - "warning": "This is an experimental version of nxt_blender. Save early, " - "save often." + "description": "NXT is a general purpose code compositor designed for " + "rigging, scene assembly, and automation. (This is an " + "experimental version of nxt_blender. Save " + "early, save often.)", + "warning": "This addon requires installation of dependencies." } @@ -38,45 +51,32 @@ class BLENDER_PLUGIN_VERSION(object): VERSION = VERSION_STR -__NXT_INSTANCE__ = None -__NXT_CREATED_QAPP__ = None - - -class OpenNxtEditor(bpy.types.Operator): - bl_label = "Open NXT Editor" - bl_idname = "nxt.nxt_editor" +class CreateBlenderContext(bpy.types.Operator): + bl_label = "Create Remote Blender NXT Context" + bl_idname = "nxt.create_blender_context" def execute(self, context): - global __NXT_INSTANCE__ - global __NXT_CREATED_QAPP__ - if __NXT_INSTANCE__: - __NXT_INSTANCE__.show() - return - if not __NXT_CREATED_QAPP__: - nxt_win = nxt_editor.launch_editor() + global nxt_installed + if nxt_installed: + b = blender.__NXT_INTEGRATION__ + if not b: + b = blender.Blender.launch_nxt() + b.create_context() else: - nxt_win = nxt_editor.show_new_editor() - if 'win32' in sys.platform: - # gives nxt it's own entry on taskbar - nxt_win.setWindowFlags(QtCore.Qt.Window) - - def unregister_nxt(): - global __NXT_INSTANCE__ - __NXT_INSTANCE__ = None - - nxt_win.close_signal.connect(unregister_nxt) - nxt_win.show() - __NXT_INSTANCE__ = nxt_win + show_dependency_warning() return {'FINISHED'} -class UpdateNxt(bpy.types.Operator): - bl_label = "Update NXT" - bl_idname = "nxt.nxt_update" +class OpenNxtEditor(bpy.types.Operator): + bl_label = "Open NXT Editor" + bl_idname = "nxt.nxt_editor" def execute(self, context): - import nxt_editor.integration - nxt_editor.integration.Blender.update() + global nxt_installed + if nxt_installed: + blender.Blender.launch_nxt() + else: + show_dependency_warning() return {'FINISHED'} @@ -97,8 +97,11 @@ def draw(self, context): layout = self.layout layout.operator("nxt.nxt_editor", text="Open Editor") layout.separator() - layout.operator("nxt.nxt_update", text="Update NXT (Requires Blender " - "Restart)") + layout.operator("nxt.nxt_update_dependencies", + text="Update NXT (Requires Blender Restart)") + layout.separator() + layout.operator('nxt.create_blender_context', text='Create Blender ' + 'Context') layout.separator() layout.operator("nxt.nxt_about", text="About") @@ -106,34 +109,147 @@ def menu_draw(self, context): self.layout.menu("TOPBAR_MT_nxt") -nxt_menu_operators = (TOPBAR_MT_nxt, OpenNxtEditor) +class NxtInstallDependencies(bpy.types.Operator): + bl_idname = 'nxt.nxt_install_dependencies' + bl_label = "Install NXT dependencies" + bl_description = ("Downloads and installs the required python packages " + "for NXT. Internet connection is required. " + "Blender may have to be started with elevated " + "permissions in order to install the package. " + "Alternatively you can pip install nxt-editor into your " + "Blender Python environment.") + bl_options = {"REGISTER", "INTERNAL"} + + @classmethod + def poll(cls, context): + global nxt_installed + return not nxt_installed + + def execute(self, context): + success = False + environ_copy = dict(os.environ) + environ_copy["PYTHONNOUSERSITE"] = "1" + pkg = 'nxt-editor' + try: + subprocess.run([sys.executable, "-m", "pip", "install", pkg], + check=True, env=environ_copy) + except subprocess.CalledProcessError as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + if not success: + self.report({"INFO"}, 'Please restart Blender to ' + 'finish installing NXT.') + return {"FINISHED"} + + +class NxtUpdateDependencies(bpy.types.Operator): + bl_idname = 'nxt.nxt_update_dependencies' + bl_label = "Update NXT dependencies" + bl_description = ("Downloads and updates the required python packages " + "for NXT. Internet connection is required. " + "Blender may have to be started with elevated " + "permissions in order to install the package. " + "Alternatively you can pip install -U nxt-editor into " + "your Blender Python environment.") + bl_options = {"REGISTER", "INTERNAL"} + + @classmethod + def poll(cls, context): + global nxt_installed + return nxt_installed + + def execute(self, context): + try: + blender.Blender._update_package('nxt-editor') + except subprocess.CalledProcessError as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + self.report({"INFO"}, 'Please restart Blender to ' + 'finish updating NXT.') + return {"FINISHED"} + + +class NxtUninstallDependencies(bpy.types.Operator): + bl_idname = 'nxt.nxt_uninstall_dependencies' + bl_label = "Uninstall NXT dependencies" + bl_description = ("Uninstalls the NXT Python packages. " + "Blender may have to be started with elevated " + "permissions in order to install the package. " + "Alternatively you can pip uninstall nxt-editor from " + "your Blender Python environment.") + bl_options = {"REGISTER", "INTERNAL"} + + @classmethod + def poll(cls, context): + global nxt_installed + return nxt_installed + + def execute(self, context): + try: + blender.Blender().uninstall() + except subprocess.CalledProcessError as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + self.report({"INFO"}, 'Please restart Blender to ' + 'finish uninstalling NXT dependencies.') + return {"FINISHED"} + + +class NxtDependenciesPreferences(bpy.types.AddonPreferences): + bl_idname = __name__ + + def draw(self, context): + layout = self.layout + layout.operator(NxtInstallDependencies.bl_idname, icon="PLUGIN") + layout.operator(NxtUpdateDependencies.bl_idname, icon="SCRIPT") + layout.operator(NxtUninstallDependencies.bl_idname, icon="PANEL_CLOSE") + + +def show_dependency_warning(): + + def draw(self, context): + layout = self.layout + lines = [ + f"Please install the missing dependencies for the NXT add-on.", + "1. Open the preferences (Edit > Preferences > Add-ons).", + f"2. Search for the \"{bl_info.get('name')}\" add-on.", + "3. Open the details section of the add-on.", + f"4. Click on the \"{NxtInstallDependencies.bl_label}\" button.", + "This will download and install the missing Python packages. " + "You man need to start Blender with elevated permissions", + f"Alternatively you can pip install \"{nxt_package_name}\" into " + f"your Blender Python environment." + ] + + for line in lines: + layout.label(text=line) + bpy.context.window_manager.popup_menu(draw, title='NXT Warning!', + icon="ERROR") + + +nxt_operators = (TOPBAR_MT_nxt, OpenNxtEditor, NxtUpdateDependencies, + NxtUninstallDependencies, NxtDependenciesPreferences, + NxtInstallDependencies, CreateBlenderContext) def register(): - global __NXT_CREATED_QAPP__ - existing = QtWidgets.QApplication.instance() - if existing: - __NXT_CREATED_QAPP__ = False - else: - __NXT_CREATED_QAPP__ = True - nxt_editor._new_qapp() - bpy.utils.register_class(TOPBAR_MT_nxt) - bpy.utils.register_class(OpenNxtEditor) + global nxt_installed + for cls in nxt_operators: + bpy.utils.register_class(cls) bpy.utils.register_class(AboutNxt) - bpy.utils.register_class(UpdateNxt) bpy.types.TOPBAR_MT_editor_menus.append(TOPBAR_MT_nxt.menu_draw) def unregister(): - global __NXT_CREATED_QAPP__ - if __NXT_CREATED_QAPP__: - QtWidgets.QApplication.instance().quit() - __NXT_CREATED_QAPP__ = False + try: + if blender.__NXT_INTEGRATION__: + blender.__NXT_INTEGRATION__.quit_nxt() + except NameError: + pass bpy.types.TOPBAR_MT_editor_menus.remove(TOPBAR_MT_nxt.menu_draw) - bpy.utils.unregister_class(TOPBAR_MT_nxt) - bpy.utils.unregister_class(OpenNxtEditor) + for cls in nxt_operators: + bpy.utils.unregister_class(cls) bpy.utils.unregister_class(AboutNxt) - bpy.utils.unregister_class(UpdateNxt) if __name__ == "__main__": diff --git a/nxt_editor/integration/unreal/Content/Python/init_unreal.py b/nxt_editor/integration/unreal/Content/Python/init_unreal.py new file mode 100644 index 0000000..95e915f --- /dev/null +++ b/nxt_editor/integration/unreal/Content/Python/init_unreal.py @@ -0,0 +1,94 @@ +import os +import sys +import unreal +import subprocess + + +def is_nxt_available(): + try: + from nxt_editor.integration.unreal import launch_nxt_in_ue + return True + except: + return False + +def get_python_exc_path(): + exc_name = 'python' + if sys.platform == 'win32': + exc_name = 'python.exe' + + real_prefix = os.path.realpath(sys.prefix) + return os.path.join(real_prefix, exc_name) + +def install_nxt_to_interpreter(): + subprocess.check_call([get_python_exc_path(), '-m', 'pip', + 'install', 'nxt-editor']) + unreal.log_warning("Please restart the editor for nxt menu options.") + +def update_installed_nxt(): + subprocess.check_call([get_python_exc_path(), '-m', 'pip', + 'install', '--upgrade', 'nxt-editor', 'nxt-core']) + +def uninstall_nxt_from_interpreter(): + subprocess.check_call([get_python_exc_path(), '-m', 'pip', + 'uninstall', '-y', 'nxt-editor', 'nxt-core']) + unreal.log_warning("Nxt menu will refresh next editor launch.") + +def make_open_editor_entry(): + entry = unreal.ToolMenuEntry(name='Open Editor', + type=unreal.MultiBlockType.MENU_ENTRY) + entry.set_label('Open Editor') + launch_command = "from nxt_editor.integration.unreal import launch_nxt_in_ue; launch_nxt_in_ue()" + entry.set_string_command(unreal.ToolMenuStringCommandType.PYTHON, 'Python', + string=launch_command) + return entry + +def make_install_entry(): + entry = unreal.ToolMenuEntry(name='Install Package', + type=unreal.MultiBlockType.MENU_ENTRY) + entry.set_label('Install nxt package to active python') + entry.set_string_command(unreal.ToolMenuStringCommandType.PYTHON, 'Python', + string='install_nxt_to_interpreter()') + return entry + +def make_update_entry(): + entry = unreal.ToolMenuEntry(name='Update nxt package', + type=unreal.MultiBlockType.MENU_ENTRY) + entry.set_label('Update nxt python package') + entry.set_string_command(unreal.ToolMenuStringCommandType.PYTHON, 'Python', + string='update_installed_nxt()') + return entry + +def make_uninstall_entry(): + entry = unreal.ToolMenuEntry(name='Uninstall Package', + type=unreal.MultiBlockType.MENU_ENTRY) + entry.set_label('Uninstall nxt package from active python') + entry.set_string_command(unreal.ToolMenuStringCommandType.PYTHON, 'Python', + string='uninstall_nxt_from_interpreter()') + return entry + + +def make_or_find_nxt_menu(): + menus = unreal.ToolMenus.get() + nxt_menu = menus.find_menu("LevelEditor.MainMenu.NxtMenu") + if nxt_menu: + return nxt_menu + main_menu = menus.find_menu("LevelEditor.MainMenu") + if not main_menu: + raise ValueError("Cannot find main menu") + nxt_menu = main_menu.add_sub_menu(main_menu.get_name(), "nxt-section", + "NxtMenu", "nxt", "The nxt graph editor") + return nxt_menu + +def refresh_nxt_menu(): + nxt_menu = make_or_find_nxt_menu() + if is_nxt_available(): + nxt_menu.add_menu_entry("nxt-section", make_open_editor_entry()) + nxt_menu.add_menu_entry("nxt-section", make_update_entry()) + nxt_menu.add_menu_entry("nxt-section", make_uninstall_entry()) + else: + nxt_menu.add_menu_entry("nxt-section", make_install_entry()) + menus = unreal.ToolMenus.get() + menus.refresh_all_widgets() + +if __name__ == '__main__': + refresh_nxt_menu() diff --git a/nxt_editor/integration/unreal/README.md b/nxt_editor/integration/unreal/README.md new file mode 100644 index 0000000..d5f9269 --- /dev/null +++ b/nxt_editor/integration/unreal/README.md @@ -0,0 +1,18 @@ +# Installation +**This is an experimental version of nxt_unreal. Save early, save often.** +This is an Unreal plugin to connect to the nxt python package. The nxt python package will be downloaded from the internet as part of this installation. + +1. Move this plugin either into your project or engine's plugin directory. +2. Ensure that the python editor scripting plugin is activated. +3. Activate the plugin in the engine plugin browser. Exit the engine. +4. Install the nxt python package + - __automated -__ From unreal editor top menu option "nxt", select "Install nxt package to active python" + - __by hand -__ Locate engine python(`sys.prefix`) in commmand prompt or terminal and run: `python -m pip install nxt-editor` (Note on windows this will be `python.exe`) +4. Restart Editor + +# Launch +From the top menu "nxt", select "Open Editor" to start the nxt editor. + +# Update +- __automated -__ From nxt menu select "Update nxt package" +- __by hand -__ From command line of engine python run `python -m pip install --upgrade nxt-editor` diff --git a/nxt_editor/integration/unreal/__init__.py b/nxt_editor/integration/unreal/__init__.py new file mode 100644 index 0000000..063c64b --- /dev/null +++ b/nxt_editor/integration/unreal/__init__.py @@ -0,0 +1,34 @@ +# Built-in +import os +import atexit + +# External +import unreal +from Qt import QtWidgets + +# Internal +from nxt.constants import NXT_DCC_ENV_VAR +import nxt_editor + + +global __NXT_WINDOW +__NXT_WINDOW = None + +def launch_nxt_in_ue(): + os.environ[NXT_DCC_ENV_VAR] = 'unreal' + existing = QtWidgets.QApplication.instance() + if existing: + unreal.log('Found existing QApp') + else: + unreal.log('Building new QApp for nxt') + existing = nxt_editor._new_qapp() + + global __NXT_WINDOW + if __NXT_WINDOW: + __NXT_WINDOW.show() + __NXT_WINDOW.raise_() + else: + __NXT_WINDOW = nxt_editor.show_new_editor() + + __NXT_WINDOW.close_signal.connect(existing.exit) + atexit.register(__NXT_WINDOW.close) \ No newline at end of file diff --git a/nxt_editor/integration/unreal/nxt_unreal.uplugin b/nxt_editor/integration/unreal/nxt_unreal.uplugin new file mode 100644 index 0000000..f933456 --- /dev/null +++ b/nxt_editor/integration/unreal/nxt_unreal.uplugin @@ -0,0 +1,17 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "0.1.0", + "FriendlyName": "Nxt Unreal", + "Description": "Unreal editor connections to the nxt python module.", + "Category": "Scripting", + "CreatedBy": "The nxt Authors", + "CreatedByURL": "https://nxt-dev.github.io", + "DocsURL": "https://nxt-dev.github.io/tutorials", + "MarketplaceURL": "", + "SupportURL": "https://github.com/nxt-dev/nxt_editor/discussions", + "CanContainContent": true, + "IsBetaVersion": true, + "IsExperimentalVersion": false, + "Installed": false +} \ No newline at end of file diff --git a/nxt_editor/main_window.py b/nxt_editor/main_window.py index 8427945..f802a16 100644 --- a/nxt_editor/main_window.py +++ b/nxt_editor/main_window.py @@ -327,6 +327,12 @@ def handle_rpc_tailing_signals(self, state): self.rpc_log_tail.new_text.disconnect(raw_write_func) self.rpc_log_tail.new_text.disconnect(rich_write_func) + def event(self, event): + if event.type() == QtCore.QEvent.WindowDeactivate: + self._held_keys = [] + self.zoom_keys_down = False + return super(MainWindow, self).event(event) + @staticmethod def set_waiting_cursor(state=True): if state: @@ -337,15 +343,15 @@ def set_waiting_cursor(state=True): @staticmethod def create_remote_context(place_holder_text='', interpreter_exe=sys.executable, - context_graph=None): + context_graph=None, exe_script_args=()): cur_context = nxt.remote.contexts.get_current_context_exe_name() pop_up = QtWidgets.QDialog() pop_up.setWindowTitle('Create context for "{}"'.format(cur_context)) v_layout = QtWidgets.QVBoxLayout() pop_up.setLayout(v_layout) label = QtWidgets.QPlainTextEdit() - info = ('Create context for "{}" your host ' - 'python interpreter\n' + info = ('Create remote context for your host ' + 'Python interpreter/DCC\n' 'Type your desired name in the box below ' 'and click create.'.format(cur_context)) label.setPlainText(info) @@ -358,6 +364,7 @@ def create_remote_context(place_holder_text='', v_layout.addLayout(h_layout) name = QtWidgets.QLineEdit() name.setPlaceholderText(str(place_holder_text)) + name.setText(str(place_holder_text)) create_button = QtWidgets.QPushButton('Create!') h_layout.addWidget(name) h_layout.addWidget(create_button) @@ -366,7 +373,8 @@ def do_create(): try: nxt.create_context(name.text(), interpreter_exe=interpreter_exe, - context_graph=context_graph) + context_graph=context_graph, + exe_script_args=exe_script_args) pop_up.close() except (IOError, NameError) as e: info = str(e) diff --git a/nxt_editor/node_graphics_item.py b/nxt_editor/node_graphics_item.py index e448210..58f160e 100644 --- a/nxt_editor/node_graphics_item.py +++ b/nxt_editor/node_graphics_item.py @@ -9,6 +9,7 @@ from Qt import QtWidgets from Qt import QtGui from Qt import QtCore +from PySide2 import __version_info__ as qt_version # Internal import nxt_editor @@ -17,12 +18,23 @@ from . import colors from nxt.stage import INTERNAL_ATTRS from .label_edit import NameEditDialog +from .user_dir import USER_PREF, user_prefs logger = logging.getLogger(nxt_editor.LOGGER_NAME) +MIN_LOD = user_prefs.get(USER_PREF.LOD, .4) -class NodeGraphicsItem(QtWidgets.QGraphicsItem): +_pyside_version = qt_version + + +if _pyside_version[1] < 11: + graphic_type = QtWidgets.QGraphicsItem +else: + graphic_type = QtWidgets.QGraphicsObject + + +class NodeGraphicsItem(graphic_type): """The graphics item used to represent nodes in the graph. Contains instances of NodeGraphicsPlug for each attribute on the associated node. Contains functionality for arranging children into stacks. @@ -44,11 +56,12 @@ def __init__(self, model, node_path, view): # item settings self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) - #self.setCacheMode(QtWidgets.QGraphicsItem.ItemCoordinateCache) + self.setFlags(QtWidgets.QGraphicsItem.ItemIsMovable | QtWidgets.QGraphicsItem.ItemIsFocusable | QtWidgets.QGraphicsItem.ItemIsSelectable | - QtWidgets.QGraphicsItem.ItemSendsScenePositionChanges) + QtWidgets.QGraphicsItem.ItemSendsScenePositionChanges | + QtWidgets.QGraphicsItem.ItemNegativeZStacksBehindParent) self.setAcceptHoverEvents(True) # draw settings @@ -85,6 +98,99 @@ def __init__(self, model, node_path, view): # draw node self.update_from_model() + # Setup groups + # In + self.in_anim_group = QtCore.QParallelAnimationGroup() + self.in_anim_group.finished.connect(self.finished_anim) + # Out + self.out_anim_group = QtCore.QParallelAnimationGroup() + self.out_anim_group.finished.connect(self.finished_anim) + + def _setup_anim_properties(self): + # Position anim property + self.pos_anim = QtCore.QPropertyAnimation(self, b"pos", self) + # Set graphics effect + effect = QtWidgets.QGraphicsOpacityEffect(self) + effect.setOpacity(1) + self.setGraphicsEffect(effect) + # Opacity anim property + self.opacity_anim = QtCore.QPropertyAnimation(effect, b"opacity", + effect) + # Lower power caching + self.setCacheMode(QtWidgets.QGraphicsItem.ItemCoordinateCache) + + def setup_in_anim(self): + self._setup_anim_properties() + self.in_anim_group.addAnimation(self.pos_anim) + self.in_anim_group.addAnimation(self.opacity_anim) + + def setup_out_anim(self): + self._setup_anim_properties() + self.out_anim_group.addAnimation(self.pos_anim) + self.out_anim_group.addAnimation(self.opacity_anim) + + def finished_anim(self): + self.setGraphicsEffect(None) + self.in_anim_group.clear() + self.out_anim_group.clear() + self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) + self.view.update() + self.view._animating.remove(self) + + def get_is_animating(self): + i = self.in_anim_group.State.Running == self.in_anim_group.state() + o = self.out_anim_group.State.Running == self.out_anim_group.state() + return i or o + + def anim_into_place(self, end_pos): + if self.get_is_animating(): + return + if end_pos == self.pos(): + return + self.view._animating.append(self) + if self.view.do_animations: + self.setup_in_anim() + else: + self.setPos(end_pos) + self.in_anim_group.finished.emit() + return + + self.opacity_anim.setStartValue(0) + self.opacity_anim.setEndValue(1) + self.opacity_anim.setDuration(80) + + curve = QtCore.QEasingCurve(QtCore.QEasingCurve.OutBack) + curve.setAmplitude(.8) + self.pos_anim.setEasingCurve(curve) + + self.pos_anim.setDuration(100) + self.pos_anim.setEndValue(end_pos) + self.in_anim_group.start() + + def anim_out(self): + if self.get_is_animating(): + return + self.view._animating.append(self) + if self.view.do_animations: + self.setup_out_anim() + else: + self.out_anim_group.finished.emit() + return + self.setCacheMode(QtWidgets.QGraphicsItem.ItemCoordinateCache) + self.opacity_anim.setStartValue(1) + self.opacity_anim.setEndValue(0) + self.opacity_anim.setDuration(80) + + self.pos_anim.setDuration(80) + self.pos_anim.setEasingCurve(QtCore.QEasingCurve.Linear) + x_move = self.stack_offset * -1 * .5 + if not self.parentItem() or not self.parentItem().parentItem(): + y_move = 0.0 + else: + y_move = (self.parentItem().boundingRect().height() * -1.0) * .5 + self.pos_anim.setEndValue(QtCore.QPointF(x_move, y_move)) + self.out_anim_group.start() + def update_color(self): layers = self.model.get_layers_with_opinion(self.node_path) n_colors = [] @@ -160,7 +266,7 @@ def itemChange(self, change, value): ml = QtWidgets.QApplication.mouseButtons() == QtCore.Qt.LeftButton shift = QtWidgets.QApplication.keyboardModifiers() == QtCore.Qt.ShiftModifier force_snap = self.view.alignment_actions.snap_action.isChecked() - if (ml & shift) or force_snap: + if (ml & shift) or force_snap and not self.get_is_animating(): value = self.closest_grid_point(value) return value @@ -176,16 +282,22 @@ def paint(self, painter, option, widget): """Override of QtWidgets.QGraphicsItem paint. Handles all visuals of the Node. Split up into 3 functions for organization. """ - painter.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.TextAntialiasing | QtGui.QPainter.SmoothPixmapTransform) - self.draw_title(painter) - self.draw_attributes(painter) - self.draw_border(painter) + lod = QtWidgets.QStyleOptionGraphicsItem.levelOfDetailFromTransform(painter.worldTransform()) + if lod > MIN_LOD: + painter.setRenderHints(QtGui.QPainter.Antialiasing | + QtGui.QPainter.TextAntialiasing | + QtGui.QPainter.SmoothPixmapTransform) + else: + painter.setRenderHints(False) + self.draw_title(painter, lod) + self.draw_attributes(painter, lod) + self.draw_border(painter, lod) def closest_grid_point(self, position): snapped_pos = self.model.snap_pos_to_grid((position.x(), position.y())) return QtCore.QPointF(*snapped_pos) - def draw_border(self, painter): + def draw_border(self, painter, lod=1.): """Draws border, called exclusively by paint. :param painter: painter from paint. @@ -213,7 +325,7 @@ def draw_border(self, painter): self.get_selection_rect().width() - 2, self.get_selection_rect().height() - 2)) - def draw_title(self, painter): + def draw_title(self, painter, lod=1.): """Draw title of the node. Called exclusively in paint. :param painter: painter from paint. @@ -307,76 +419,92 @@ def draw_title(self, painter): if self.exec_out_plug: self.scene().removeItem(self.exec_out_plug) self.exec_out_plug = None - - # draw attr dots - offset = -6 - for fill in self.attr_dots: - painter.setBrush(QtCore.Qt.white) - if fill: + if lod > MIN_LOD: + # draw attr dots + offset = -6 + for fill in self.attr_dots: painter.setBrush(QtCore.Qt.white) - else: - painter.setBrush(QtCore.Qt.NoBrush) - dots_color = QtGui.QColor(QtCore.Qt.white).darker(self.dim_factor) - painter.setPen(QtGui.QPen(dots_color, 0.5)) - dot_x = self.max_width - 15 - dot_y = (self.title_rect_height / 2) + offset - painter.drawEllipse(QtCore.QPointF(dot_x, dot_y), 2, 2) - offset += 6 + if fill: + painter.setBrush(QtCore.Qt.white) + else: + painter.setBrush(QtCore.Qt.NoBrush) + dots_color = QtGui.QColor(QtCore.Qt.white).darker(self.dim_factor) + painter.setPen(QtGui.QPen(dots_color, 0.5)) + dot_x = self.max_width - 15 + dot_y = (self.title_rect_height / 2) + offset + painter.drawEllipse(QtCore.QPointF(dot_x, dot_y), 2, 2) + offset += 6 # draw title - painter.setPen(QtGui.QColor(QtCore.Qt.white).darker(self.dim_factor)) - if not self.node_enabled: - painter.setPen(QtGui.QColor(QtCore.Qt.white).darker(150)) + painter.setFont(self.title_font) title_str = nxt_path.node_name_from_node_path(self.node_path) font_metrics = QtGui.QFontMetrics(self.title_font) width = self.max_width - 40 if self.error_list: width -= 20 - title = font_metrics.elidedText(title_str, QtCore.Qt.ElideRight, width) - painter.drawText(15, 0, self.max_width - 15, self.title_rect_height, - QtCore.Qt.AlignVCenter, title) - - # draw error - if self.error_list: - pos = QtCore.QPointF(self.max_width-45, self.title_rect_height/4) - error_item = ErrorItem(font=QtGui.QFont('Roboto', 16, 75), - pos=pos, text='!') - error_item.setParentItem(self) - error_item.setZValue(50) - self.error_item = error_item + if lod > MIN_LOD: + painter.setPen( + QtGui.QColor(QtCore.Qt.white).darker(self.dim_factor)) + if not self.node_enabled: + painter.setPen(QtGui.QColor(QtCore.Qt.white).darker(150)) + title = font_metrics.elidedText(title_str, + QtCore.Qt.ElideRight, width) + painter.drawText(15, 0, self.max_width - 15, self.title_rect_height, + QtCore.Qt.AlignVCenter, title) else: - if self.error_item: - self.scene().removeItem(self.error_item) - self.error_item.deleteLater() - self.error_item = None + painter.setBrush(QtGui.QColor(QtCore.Qt.white).darker(self.dim_factor)) + if not self.node_enabled: + painter.setBrush(QtGui.QColor(QtCore.Qt.white).darker(150)) + proxy_rect = font_metrics.boundingRect(title_str) + r_width = proxy_rect.width() * .8 + height = proxy_rect.height() + painter.drawRect(15, height * .8, + min(r_width, width), height * .2) + + if lod > MIN_LOD: + # draw error + if self.error_list: + pos = QtCore.QPointF(self.max_width-45, self.title_rect_height/4) + error_item = ErrorItem(font=QtGui.QFont('Roboto', 16, 75), + pos=pos, text='!') + error_item.setParentItem(self) + error_item.setZValue(50) + self.error_item = error_item + else: + if self.error_item: + self.scene().removeItem(self.error_item) + self.error_item.deleteLater() + self.error_item = None + # draw collapse state arrow for arrow in self.collapse_arrows: self.scene().removeItem(arrow) - self.collapse_arrows = [] - # TODO calculation needed arrows should be done outside drawing - # draw collapse state arrow - if self.collapse_state: - des_colors = self.model.get_descendant_colors(self.node_path) - filled = self.model.has_children(self.node_path) - if not filled: - des_colors = [QtCore.Qt.white] - elif not des_colors: - disp = self.model.comp_layer - des_colors = [self.model.get_node_color(self.node_path, disp)] - i = 0 - num = len(des_colors) - for c in des_colors: - arrow = CollapseArrow(self, filled=filled, color=c) - arrow_width = arrow.width * 1.1 - center_offset = (arrow_width * (num * .5) - arrow_width * .5) - cur_offset = (i * arrow_width) - pos = ((self.max_width * .5) + center_offset - cur_offset) - arrow.setPos(pos, self.title_rect_height) - self.collapse_arrows += [arrow] - i += 1 - - def draw_attributes(self, painter): + if lod > MIN_LOD: + self.collapse_arrows = [] + # TODO calculation needed arrows should be done outside drawing + + if self.collapse_state: + des_colors = self.model.get_descendant_colors(self.node_path) + filled = self.model.has_children(self.node_path) + if not filled: + des_colors = [QtCore.Qt.white] + elif not des_colors: + disp = self.model.comp_layer + des_colors = [self.model.get_node_color(self.node_path, disp)] + i = 0 + num = len(des_colors) + for c in des_colors: + arrow = CollapseArrow(self, filled=filled, color=c) + arrow_width = arrow.width * 1.1 + center_offset = (arrow_width * (num * .5) - arrow_width * .5) + cur_offset = (i * arrow_width) + pos = ((self.max_width * .5) + center_offset - cur_offset) + arrow.setPos(pos, self.boundingRect().height()) + self.collapse_arrows += [arrow] + i += 1 + + def draw_attributes(self, painter, lod=1.): """Draw attributes for this node. Called exclusively by paint. :param painter: painter from paint. @@ -395,44 +523,60 @@ def draw_attributes(self, painter): self._attr_plug_graphics.setdefault(attr_name, {}) attr_plug_graphics = self._attr_plug_graphics[attr_name] current_in_plug = attr_plug_graphics.get('in_plug') - in_pos = self.get_attr_in_pos(attr_name, scene=False) - if current_in_plug: - current_in_plug.setPos(in_pos) - current_in_plug.color = target_color - current_in_plug.update() - else: - in_plug = NodeGraphicsPlug(pos=in_pos, - radius=self.ATTR_PLUG_RADIUS, - color=target_color, - attr_name_represented=attr_name, - is_input=True) - attr_plug_graphics['in_plug'] = in_plug - in_plug.setParentItem(self) + if lod > MIN_LOD: + in_pos = self.get_attr_in_pos(attr_name, scene=False) + if current_in_plug: + current_in_plug.show() + current_in_plug.setPos(in_pos) + current_in_plug.color = target_color + current_in_plug.update() + else: + current_in_plug = NodeGraphicsPlug(pos=in_pos, + radius=self.ATTR_PLUG_RADIUS, + color=target_color, + attr_name_represented=attr_name, + is_input=True) + attr_plug_graphics['in_plug'] = current_in_plug + current_in_plug.setParentItem(self) + elif current_in_plug: + current_in_plug.hide() current_out_plug = attr_plug_graphics.get('out_plug') - out_pos = self.get_attr_out_pos(attr_name, scene=False) - if current_out_plug: - current_out_plug.setPos(out_pos) - current_out_plug.color = target_color - current_out_plug.update() - else: - out_plug = NodeGraphicsPlug(pos=out_pos, - radius=self.ATTR_PLUG_RADIUS, - color=target_color, - attr_name_represented=attr_name, - is_input=False) - attr_plug_graphics['out_plug'] = out_plug - out_plug.setParentItem(self) + if lod > MIN_LOD: + out_pos = self.get_attr_out_pos(attr_name, scene=False) + if current_out_plug: + current_out_plug.show() + current_out_plug.setPos(out_pos) + current_out_plug.color = target_color + current_out_plug.update() + else: + out_plug = NodeGraphicsPlug(pos=out_pos, + radius=self.ATTR_PLUG_RADIUS, + color=target_color, + attr_name_represented=attr_name, + is_input=False) + attr_plug_graphics['out_plug'] = out_plug + out_plug.setParentItem(self) + elif current_out_plug: + current_out_plug.hide() # draw attr_name - painter.setPen(attr_details['title_color']) - painter.setFont(attr_details['title_font']) rect = attr_details['bg_rect'] + painter.setFont(attr_details['title_font']) font_metrics = QtGui.QFontMetrics(self.attr_font) title = font_metrics.elidedText(attr_name, QtCore.Qt.ElideRight, self.max_width - 20) - painter.drawText(rect.x() + 10, rect.y() - 1, rect.width(), - rect.height(), QtCore.Qt.AlignVCenter, title) + if lod > MIN_LOD: + painter.setPen(attr_details['title_color']) + painter.drawText(rect.x() + 10, rect.y() - 1, rect.width(), + rect.height(), QtCore.Qt.AlignVCenter, title) + else: + proxy_rect = font_metrics.boundingRect(title) + height = proxy_rect.height() + width = proxy_rect.width() + painter.setBrush(attr_details['title_color'].darker(150)) + painter.drawRect(rect.x() + 10, rect.y() + height*.8, + width, height*.2) def calculate_attribute_draw_details(self): """Calculate position of all known attr names. Details stored in @@ -721,9 +865,8 @@ def arrange_descendants(self): children_paths = self.model.get_children(self.node_path, ordered=True, include_implied=True) prev_y = 0 - offset_z = len(children_paths) prev_child = None - index = 0 + index = 1 for child_path in children_paths: child = self.view.get_node_graphic(child_path) if not child: @@ -737,11 +880,9 @@ def arrange_descendants(self): else: y = self.get_selection_rect().height() y += prev_y - child.setPos(self.stack_offset, y) - child.setZValue(offset_z) - + new_pos = QtCore.QPointF(self.stack_offset, y) + child.anim_into_place(new_pos) prev_y = y - offset_z -= 1 child.arrange_descendants() prev_child = child index += 1 @@ -824,7 +965,14 @@ def boundingRect(self): def paint(self, painter, option, widget): """Override of QtWidgets.QGraphicsItem paint. Handles all visuals of the Plug.""" - painter.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.TextAntialiasing | QtGui.QPainter.SmoothPixmapTransform) + lod = QtWidgets.QStyleOptionGraphicsItem.levelOfDetailFromTransform( + painter.worldTransform()) + if lod > MIN_LOD: + painter.setRenderHints(QtGui.QPainter.Antialiasing | + QtGui.QPainter.TextAntialiasing | + QtGui.QPainter.SmoothPixmapTransform) + else: + painter.setRenderHints(False) if self.is_hovered: painter.setPen(QtGui.QPen(QtCore.Qt.white, self.hover_width, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin)) else: @@ -836,7 +984,7 @@ def paint(self, painter, option, widget): # create triangle polygon = QtGui.QPolygonF() step_angle = 120 - for i in range(4): + for i in [0, 1, 2, 3]: step = step_angle * i x = self.radius * 1.2 * math.cos(math.radians(step)) y = self.radius * 1.2 * math.sin(math.radians(step)) @@ -853,8 +1001,6 @@ def paint(self, painter, option, widget): painter.setBrush(QtCore.Qt.green) painter.drawPolygon(polygon) - painter.setPen(QtCore.Qt.black) - painter.setFont(QtGui.QFont('Roboto', 12)) elif self.is_break: painter.drawRect(self.radius * -1, self.radius * -1, self.radius * 2, self.radius * 2) else: @@ -931,6 +1077,7 @@ def __init__(self, parent=None, filled=False, color=None): is_str = isinstance(self.color, str) if is_str: self.color = QtGui.QColor(self.color) + self.setZValue(30) def boundingRect(self): """Override of QtWidgets.QGraphicsItem boundingRect. If this rectangle @@ -946,18 +1093,35 @@ def paint(self, painter, option, widget): if self.filled: brush = QtGui.QBrush(self.color) painter.setBrush(brush) + if self.color.lightness() < 100: + pen_color = QtGui.QColor(self.color.lighter(300)) + else: + pen_color = QtGui.QColor(self.color.darker(300)) + pen = QtGui.QPen(pen_color) + pen.setJoinStyle(QtCore.Qt.RoundJoin) painter.setPen(QtCore.Qt.NoPen) else: - painter.setBrush(QtCore.Qt.NoBrush) - painter.setPen(QtCore.Qt.white) + brush = QtGui.QBrush(QtCore.Qt.white, QtCore.Qt.Dense6Pattern) + painter.setBrush(brush) + pen = QtGui.QPen(QtCore.Qt.white) + pen.setStyle(QtCore.Qt.DotLine) + painter.setPen(pen) # draw triangle points = [ - QtCore.QPointF(0-(self.width*.5), 0), + QtCore.QPointF(0 - (self.width * .5), 0), QtCore.QPointF(self.width*.5, 0), QtCore.QPointF(0, self.height) ] painter.drawPolygon(points) + # Draw outline + if self.filled: + painter.setBrush(QtCore.Qt.NoBrush) + painter.setPen(pen) + painter.drawLine(0 - (self.width * .5), 0, + 0, self.height) + painter.drawLine(0, self.height, + self.width * .5, 0) def itemChange(self, change, value): """Override of QtWidgets.QGraphicsItem itemChange.""" diff --git a/nxt_editor/resources/icons/nxt_128.png b/nxt_editor/resources/icons/nxt_128.png new file mode 100644 index 0000000..16f8006 Binary files /dev/null and b/nxt_editor/resources/icons/nxt_128.png differ diff --git a/nxt_editor/stage_model.py b/nxt_editor/stage_model.py index f5d38f3..2716e7c 100644 --- a/nxt_editor/stage_model.py +++ b/nxt_editor/stage_model.py @@ -748,7 +748,7 @@ def get_descendant_colors(self, base_path): base_node = self.comp_layer.lookup(base_path) if base_node is None: return layer_colors - layers = [self.get_node_source_layer(base_path, self.comp_layer)] + layers = [] des = self.get_descendants(base_path, self.comp_layer, True) for d in des: node = self.comp_layer.lookup(d) @@ -2073,7 +2073,6 @@ def clear_breakpoints(self, layer=None): layer = layer or self.top_layer cmd = ClearBreakpoints(model=self, layer_path=layer.real_path) self.undo_stack.push(cmd) - self.nodes_changed.emit(cmd.prev_breaks) self.breaks_changed.emit([]) def _add_breakpoint(self, node_path, layer): @@ -2544,14 +2543,15 @@ def execute_snippet(self, code_string, node_path, rt_layer=None, logger.warning('No code to execute!') return self.about_to_execute.emit(True) - rt = rt_layer + rt_layer = rt_layer or self.current_rt_layer np = [node_path] - valid, bad_paths = self.validate_runtime_layer(rt_layer=rt, + valid, bad_paths = self.validate_runtime_layer(rt_layer=rt_layer, node_paths=np) if not rt_layer or not valid: new_rt = self.prompt_runtime_rebuild(must_rebuild=bool(bad_paths)) if new_rt: rt_layer = new_rt + self.current_rt_layer = new_rt elif not rt_layer: return if not rt_layer or not hasattr(rt_layer, '_console'): @@ -2866,7 +2866,7 @@ def can_build_run(self): return True def setup_build(self, node_paths, rt_layer=None): - # Reset timer vars + # Reset once_sec_timer vars self.build_start_time = time.time() self.build_paused_time = .0 self.last_step_time = .0 @@ -2927,7 +2927,7 @@ def step_build(self): pause_delta = self.build_paused_time - time.time() self.build_start_time -= pause_delta # Always reset the paused time as a build step is the same as paused - # in regard to the build timer + # in regard to the build once_sec_timer self.build_paused_time = .0 if not self.can_build_run(): logger.error("Cannot step execution. Build is not ready.") diff --git a/nxt_editor/stage_view.py b/nxt_editor/stage_view.py index 14c9fef..b27b473 100644 --- a/nxt_editor/stage_view.py +++ b/nxt_editor/stage_view.py @@ -12,10 +12,11 @@ # Interal import nxt_editor from nxt import nxt_node, tokens -from nxt_editor.node_graphics_item import NodeGraphicsItem, NodeGraphicsPlug +from nxt_editor.node_graphics_item import (NodeGraphicsItem, NodeGraphicsPlug, + _pyside_version) from nxt_editor.connection_graphics_item import AttrConnectionGraphic from nxt_editor.commands import * - +from .user_dir import USER_PREF, user_prefs logger = logging.getLogger(nxt_editor.LOGGER_NAME) @@ -38,6 +39,18 @@ class StageView(QtWidgets.QGraphicsView): def __init__(self, model, parent=None): super(StageView, self).__init__(parent=parent) self.main_window = parent + self._do_anim_pref = user_prefs.get(USER_PREF.ANIMATION, True) + if _pyside_version[1] < 11: + self._do_anim_pref = False + self.do_animations = self._do_anim_pref + self.once_sec_timer = QtCore.QTimer(self) + self.once_sec_timer.timeout.connect(self.calculate_fps) + self.frames = 0 + self.fps = 0 + self.once_sec_timer.setInterval(1000) + if user_prefs.get(USER_PREF.FPS, True): + self.once_sec_timer.start() + self._animating = [] # EXEC ACTIONS self.exec_actions = parent.execute_actions self.addActions(self.exec_actions.actions()) @@ -64,9 +77,11 @@ def __init__(self, model, parent=None): self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.horizontalScrollBar().setValue(0) self.verticalScrollBar().setValue(0) - + self.setOptimizationFlag(self.DontSavePainterState, enabled=True) + self.setOptimizationFlag(self.DontAdjustForAntialiasing, enabled=True) # scene - self.setScene(QtWidgets.QGraphicsScene()) + self._scene = QtWidgets.QGraphicsScene() + self.setScene(self._scene) # TODO Currently setting scene rect and never changing it. We hope for expanding graphs in the future. self.scene().setSceneRect(QtCore.QRect(-5000, -5000, 10000, 10000)) # rubber band @@ -132,23 +147,38 @@ def __init__(self, model, parent=None): # filepath HUD self.filepath_label = HUDItem(text=None) self.filepath_label.setFont(QtGui.QFont("Roboto Mono", 8)) - self.filepath_label.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) + self.filepath_label.setAlignment(QtCore.Qt.AlignLeft | + QtCore.Qt.AlignTop) self.update_filepath() self.hud_layout.addWidget(self.filepath_label, 0, 0) # resolved HUD - self.resolved_label = HUDItem(text='resolved', - fade_time=1000, - start_color=QtGui.QColor(255, 255, 255, 255), - end_color=QtGui.QColor(255, 255, 255, 100)) + self.resolved_label = HUDItem(text='resolved', fade_time=1000) self.resolved_label.setFont(QtGui.QFont("Roboto", 12, weight=75)) - self.resolved_label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignTop) + self.resolved_label.setAlignment(QtCore.Qt.AlignRight | + QtCore.Qt.AlignTop) self.hud_layout.addWidget(self.resolved_label, 0, 3) + self.fps_label = HUDItem(text='resolved', fade_time=0) + self.fps_label.setFont(QtGui.QFont("Roboto", 12, weight=75)) + self.fps_label.setAlignment(QtCore.Qt.AlignRight | + QtCore.Qt.AlignBottom) + if user_prefs.get(USER_PREF.FPS, True): + self.hud_layout.addWidget(self.fps_label, 1, 3) + self.SEL_ADD_MODIFIERS = QtCore.Qt.ShiftModifier | QtCore.Qt.ControlModifier self.SEL_TOGGLE_MODIFIERS = QtCore.Qt.KeyboardModifiers(QtCore.Qt.ShiftModifier) self.SEL_RMV_MODIFIERS = QtCore.Qt.KeyboardModifiers(QtCore.Qt.ControlModifier) + def calculate_fps(self): + self.fps = (self.frames + self.fps) * .5 + self.fps_label.setText(str(round(self.fps))) + self.frames = 0 + + def drawForeground(self, painter, rect): + super(StageView, self).drawForeground(painter, rect) + self.frames += 1 + def focusInEvent(self, event): super(StageView, self).focusInEvent(event) @@ -294,7 +324,10 @@ def draw_graph(self, dirty): else: node_paths = self.model.get_descendants(nxt_path.WORLD, include_implied=True) + og_do_anims = self.do_animations + self.do_animations = False self.handle_nodes_changed(node_paths) + self.do_animations = og_do_anims def draw_node(self, node_path): graphic = self.get_node_graphic(node_path) @@ -707,9 +740,6 @@ def mousePressEvent(self, event): # start panning action self.panning = True self._previous_mouse_pos = None - for node in self._node_graphics.values(): - coord_cache = QtWidgets.QGraphicsItem.ItemCoordinateCache - node.setCacheMode(coord_cache) event.accept() return @@ -918,9 +948,6 @@ def mouseReleaseEvent(self, event): if self.panning and event.button() == QtCore.Qt.MiddleButton: self._previous_mouse_pos = None self.panning = False - for node in self._node_graphics.values(): - pretty_mode = QtWidgets.QGraphicsItem.DeviceCoordinateCache - node.setCacheMode(pretty_mode) self._current_pan_distance = 0.0 event.accept() return @@ -944,39 +971,28 @@ def mouseDoubleClickEvent(self, event): item.collapse_node() def wheelEvent(self, event): - num_degrees = event.delta() / 8. - num_steps = num_degrees / 15. - self._num_scheduled_scalings += num_steps - if self._num_scheduled_scalings * num_steps < 0.: - self._num_scheduled_scalings = num_steps - anim = QtCore.QTimeLine(duration=150, parent=self) - anim.setUpdateInterval(20) - anim.valueChanged.connect(self.scaling_time) - anim.finished.connect(self.anim_finished) self._view_pos = event.pos() self._scene_pos = self.mapToScene(self._view_pos) - anim.start() - event.accept() - return - def scaling_time(self, f): - zoom_pref_key = user_dir.USER_PREF.ZOOM_MULT - pref_mult = user_dir.user_prefs.get(zoom_pref_key, 1.0) - mult = 150 / pref_mult - factor = 1.0 + self._num_scheduled_scalings / mult - if 0 < factor < 1: + try: + new_scale = event.delta() * .001 + 1.0 + except AttributeError: + new_scale = 1.1 + + if 0 < new_scale < 1: if self.scale_factor > self._scale_minimum: - self.scale(factor, factor) + self.scale(new_scale, new_scale) self._center_view() - else: self.scale(1, 1) - elif factor > 1: + elif new_scale > 1: if self.scale_factor < self._scale_maximum: - self.scale(factor, factor) + self.scale(new_scale, new_scale) self._center_view() else: self.scale(1, 1) + event.accept() + return def _center_view(self): view_center = self.mapToScene(self.viewport().rect().center()) @@ -1134,6 +1150,7 @@ def on_model_selection_changed(self, new_selection): def handle_nodes_changed(self, node_paths): updated_paths = [] roots_hit = set() + new_nodes = [] for path in node_paths: if path == nxt_path.WORLD: # never draw world node. @@ -1216,6 +1233,10 @@ def handle_node_move(self, node_path, pos): node_item.setPos(pos[0], pos[1]) def handle_collapse_changed(self, node_paths): + while self._animating: + QtWidgets.QApplication.processEvents() + og_do_anims = self.do_animations + self.do_animations = self._do_anim_pref comp_layer = self.model.comp_layer roots_hit = set() for path in node_paths: @@ -1231,7 +1252,8 @@ def handle_collapse_changed(self, node_paths): include_implied=True) for child_path in children: self.remove_node_graphic(child_path) - roots_hit.add(nxt_path.get_root_path(path)) + if children: + roots_hit.add(nxt_path.get_root_path(path)) else: descendants = self.model.get_descendants(path, include_implied=True) @@ -1241,6 +1263,7 @@ def handle_collapse_changed(self, node_paths): if not root_graphic: continue root_graphic.arrange_descendants() + self.do_animations = og_do_anims def remove_node_graphic(self, node_path): if node_path not in self._node_graphics: @@ -1249,13 +1272,12 @@ def remove_node_graphic(self, node_path): if not graphic: return self.remove_node_connection_graphics(node_path) - self.scene().removeItem(graphic) - # because node graphics are parented to one another, removing parent - # implicitly removes descendants. - for key in list(self._node_graphics.keys()): - if nxt_path.is_ancestor(key, node_path): - self._node_graphics.pop(key) - self.remove_node_connection_graphics(key) + + def handle_del(): + self.scene().removeItem(graphic) + + graphic.out_anim_group.finished.connect(handle_del) + graphic.anim_out() def get_node_graphic(self, name): return self._node_graphics.get(name, None) diff --git a/nxt_editor/user_dir.py b/nxt_editor/user_dir.py index 57fae5d..26cbd87 100644 --- a/nxt_editor/user_dir.py +++ b/nxt_editor/user_dir.py @@ -68,6 +68,9 @@ class USER_PREF(): TREE_INDENT = 'layer_tree_indent' FIND_REP_NODE_PATTERNS = 'find_replace_nodes_patterns' FIND_REP_ATTRS = 'find_replace_attrs' + FPS = 'fps' + LOD = 'lod' + ANIMATION = 'animation' class EDITOR_CACHE(): diff --git a/nxt_editor/version.json b/nxt_editor/version.json index 9dfb411..0845990 100644 --- a/nxt_editor/version.json +++ b/nxt_editor/version.json @@ -1,7 +1,7 @@ { "EDITOR": { "MAJOR": 3, - "MINOR": 6, - "PATCH": 2 + "MINOR": 7, + "PATCH": 0 } }