From 534dcd26cae5ccd02292b18c97c0ee67e0f21856 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Nov 2023 16:16:36 +0100 Subject: [PATCH 1/8] Fix indentation --- usd_qtpy/layer_editor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usd_qtpy/layer_editor.py b/usd_qtpy/layer_editor.py index 5272b75..a09acfa 100644 --- a/usd_qtpy/layer_editor.py +++ b/usd_qtpy/layer_editor.py @@ -197,7 +197,7 @@ def canDropMimeData(self, data, action, row, column, parent) -> bool: column, parent) - # endregion + # endregion # region Custom methods def layer_count(self): From 499e6a76e24619402b86ae7035c79eafdeba5335 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Nov 2023 16:17:14 +0100 Subject: [PATCH 2/8] Fix missing ``# endregion` --- usd_qtpy/layer_editor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/usd_qtpy/layer_editor.py b/usd_qtpy/layer_editor.py index a09acfa..ddbc859 100644 --- a/usd_qtpy/layer_editor.py +++ b/usd_qtpy/layer_editor.py @@ -311,6 +311,7 @@ def on_layers_changed(self, notice, sender): # rebuilding the layer list then at all actually. But we need the # signal otherwise we can't detect layers added/removed to begin with. schedule(self.refresh, 50, channel="layerschanged") + # endregion class LayerWidget(QtWidgets.QWidget): From bc2325ac09433a907fc1bcfb9feb9f70984cb1ad Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Nov 2023 16:41:22 +0100 Subject: [PATCH 3/8] Implement remove layer from context menu --- usd_qtpy/layer_editor.py | 76 +++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/usd_qtpy/layer_editor.py b/usd_qtpy/layer_editor.py index ddbc859..89ce281 100644 --- a/usd_qtpy/layer_editor.py +++ b/usd_qtpy/layer_editor.py @@ -1,8 +1,7 @@ - - +import os import contextlib -import os.path import logging +from functools import partial from qtpy import QtWidgets, QtCore, QtGui @@ -15,6 +14,38 @@ log = logging.getLogger(__name__) +def remove_sublayer( + identifier, parent +): + """Remove a matching identifier as sublayer from parent layer + + The `identifier` may be the full path `layer.identifier` but can also + be the relative anchored sublayer path (the actual value in the usd file). + Hence, the sublayer paths *may* be relative paths even though a layer's + identifier passed in may be the full path. + + Arguments: + identifier (str): The layer identifier to remove; this may be the + anchored relative identifier in + parent (Sdf.Layer): The parent Sdf.Layer or layer + identifier to remove the child identifier for. + + Returns: + Optional[int]: Returns an integer for the removed sublayer index + if a removal occurred, otherwise returns None + + """ + absolute_identifier = parent.ComputeAbsolutePath(identifier) + for i, path in enumerate(parent.subLayerPaths): + if ( + path == identifier + # Allow anchored relative paths to match the full identifier + or parent.ComputeAbsolutePath(path) == absolute_identifier + ): + del parent.subLayerPaths[i] + return i + + def set_tips(widget, tip): widget.setStatusTip(tip) widget.setToolTip(tip) @@ -142,23 +173,14 @@ def dropMimeData(self, data, action, row, column, parent): with Sdf.ChangeBlock(): for source_identifier, source_parent_identifier in sources: - removed_index = -1 + removed_index = None source_parent_layer = None if source_parent_identifier: - self.log.debug("Removing old: %s -> %s", - source_parent_identifier, source_identifier) source_parent_layer = Sdf.Find(source_parent_identifier) - - # The sublayer paths *may* be relative paths even though a - # layer's identifier may be the full path. As such, we just - # compare whether all resolved layers are actually the - # same layer identifier or not - for i, path in enumerate(source_parent_layer.subLayerPaths): - path = source_parent_layer.ComputeAbsolutePath(path) - if path == source_identifier: - removed_index = i - del source_parent_layer.subLayerPaths[i] - break + removed_index = remove_sublayer( + source_identifier, + parent=source_parent_layer + ) new_parent_layer = parent.data(self.LayerRole) if row < 0 and column < 0: @@ -173,7 +195,7 @@ def dropMimeData(self, data, action, row, column, parent): if ( source_parent_layer and source_parent_layer.identifier == new_parent_layer.identifier # noqa - and removed_index != -1 and row >= removed_index + and removed_index is not None and row >= removed_index ): row -= 1 @@ -487,9 +509,9 @@ def __init__(self, stage, include_session_layer=False, parent=None): def on_view_context_menu(self, point): """Generate a right mouse click context menu for the layer view""" - point_index = self.view.indexAt(point) + index = self.view.indexAt(point) stage = self.model._stage - layer = point_index.data(self.model.LayerRole) + layer = index.data(self.model.LayerRole) if not layer: layer = stage.GetRootLayer() @@ -520,6 +542,8 @@ def on_view_context_menu(self, point): "Removes the layer from the layer stack. " "Does not remove files from disk" ) + action.triggered.connect(partial(self.on_remove_layer, + index)) action = menu.addAction("Show as text") action.setToolTip( @@ -571,6 +595,18 @@ def on_set_edit_target(self, layer): widget.edit_target.blockSignals(True) widget.edit_target.setChecked(layer == widget.layer) widget.edit_target.blockSignals(False) + + def on_remove_layer(self, index): + parent_index = self.model.parent(index) + + layer = index.data(LayerStackModel.LayerRole) + parent_layer = parent_index.data(LayerStackModel.LayerRole) + if not layer or not parent_layer: + return + + removed_index = remove_sublayer(layer.identifier, parent=parent_layer) + if removed_index is not None: + log.debug(f"Removed layer: {layer.identifier}") def hideEvent(self, event: QtGui.QCloseEvent) -> None: # TODO: This should be on a better event when we know the window From 307e8a1445b69060f77625d756fe2a26cb68a680 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Nov 2023 16:42:15 +0100 Subject: [PATCH 4/8] Cosmetics: remove old debug prints or refactor to logging, --- usd_qtpy/layer_editor.py | 10 +++++----- usd_qtpy/prim_spec_editor.py | 2 +- usd_qtpy/viewer.py | 2 -- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/usd_qtpy/layer_editor.py b/usd_qtpy/layer_editor.py index 89ce281..1ad64e6 100644 --- a/usd_qtpy/layer_editor.py +++ b/usd_qtpy/layer_editor.py @@ -467,8 +467,8 @@ def on_save_layer(self): # TODO: Perform an actual save # TODO: Prompt for filepath if layer is anonymous? # TODO: Allow making filepath relative to parent layer? - print(f"Saving: {layer}") - print(layer.ExportToString()) + log.debug(f"Saving: {layer}") + log.debug(layer.ExportToString()) layer.Save() # TODO: Do not update using this but base it off of signals from # Sdf.Notice.LayerDidSaveLayerToFile @@ -550,7 +550,7 @@ def on_view_context_menu(self, point): "Shows the layer as USD ASCII" ) - def show_layer(): + def show_layer_as_text(): text_edit = QtWidgets.QTextEdit(parent=self) text_edit.setPlainText(layer.ExportToString()) text_edit.setWindowTitle(layer.identifier) @@ -558,7 +558,7 @@ def show_layer(): text_edit.resize(700, 500) text_edit.show() - action.triggered.connect(show_layer) + action.triggered.connect(show_layer_as_text) menu.exec_(self.view.mapToGlobal(point)) @@ -571,7 +571,7 @@ def refresh_widgets(self): for row in iter_model_rows(self.model, column=0): layer = row.data(self.model.LayerRole) if layer is None: - print(f"Layer is None for {row}") + log.warning(f"Layer is None for %s", row) continue widget = LayerWidget(layer=layer, stage=self.model._stage, diff --git a/usd_qtpy/prim_spec_editor.py b/usd_qtpy/prim_spec_editor.py index 5715a11..d76ed0f 100644 --- a/usd_qtpy/prim_spec_editor.py +++ b/usd_qtpy/prim_spec_editor.py @@ -368,7 +368,7 @@ def on_delete(self): with Sdf.ChangeBlock(): for spec in specs: - print(f"Removing spec: {spec.path}") + log.debug(f"Removing spec: %s", spec.path) remove_spec(spec) if not self._listeners: diff --git a/usd_qtpy/viewer.py b/usd_qtpy/viewer.py index 99cd341..599b1fd 100644 --- a/usd_qtpy/viewer.py +++ b/usd_qtpy/viewer.py @@ -472,8 +472,6 @@ def keyPressEvent(self, event): # Implement some shortcuts for the widget # todo: move this code - print(event) - key = event.key() # TODO: Add CTRL + R for "quick render or playblast" if key == QtCore.Qt.Key_Space: From adf207e39955d6283604c102eaa3dd374103faca Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Nov 2023 17:53:31 +0100 Subject: [PATCH 5/8] Implement adding/removal of sublayers via the context menus --- usd_qtpy/layer_editor.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/usd_qtpy/layer_editor.py b/usd_qtpy/layer_editor.py index 1ad64e6..6a3d726 100644 --- a/usd_qtpy/layer_editor.py +++ b/usd_qtpy/layer_editor.py @@ -61,6 +61,11 @@ def __init__(self, layer: Sdf.Layer): class LayerStackModel(AbstractTreeModelMixin, QtCore.QAbstractItemModel): """Basic tree model that exposes a Stage's layer stack.""" + # TODO: Because the item key is based on the layer.identifier it currently + # does not support a single layer identifier to appear more than once + # across the full layer stack; to make them correctly unique we should + # make the key {parent.identifier}->{layer.identifier} a parent can + # contain the sublayer identifier only once # TODO: Tweak this more - currently loosely based on Luma Pictures # Layer Model https://github.com/LumaPictures/usd-qt/tree/master/treemodel headerLabels = ('Name', 'Path') @@ -521,6 +526,7 @@ def on_view_context_menu(self, point): action.setToolTip( "Add a new sublayer under the selected parent layer." ) + action.triggered.connect(partial(self.on_add_layer, index)) if layer: action = menu.addAction("Reload") @@ -542,8 +548,7 @@ def on_view_context_menu(self, point): "Removes the layer from the layer stack. " "Does not remove files from disk" ) - action.triggered.connect(partial(self.on_remove_layer, - index)) + action.triggered.connect(partial(self.on_remove_layer, index)) action = menu.addAction("Show as text") action.setToolTip( @@ -608,6 +613,26 @@ def on_remove_layer(self, index): if removed_index is not None: log.debug(f"Removed layer: {layer.identifier}") + def on_add_layer(self, index): + layer = index.data(LayerStackModel.LayerRole) + if not layer: + return + + filenames, _selected_filter = QtWidgets.QFileDialog.getOpenFileNames( + parent=self, + caption="Sublayer USD file", + filter="USD (*.usd *.usda *.usdc);" + ) + if not filenames: + return + + # TODO: Anchor path relative to the layer? + # TODO: Should we first confirm none of the layers is already a child + # or just let it error once it hits one matching path? + for filename in filenames: + log.debug("Adding sublayer: %s", filename) + layer.subLayerPaths.append(filename) + def hideEvent(self, event: QtGui.QCloseEvent) -> None: # TODO: This should be on a better event when we know the window # will be gone and unused after. The `closeEvent` doesn't seem From fa3028fcb664a43642571c93158d62d71ffd8714 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Nov 2023 17:59:33 +0100 Subject: [PATCH 6/8] Make `key` unique to layer identifier under the parent layer to allow the same identifier to appear more than once in the layer stack like USD supports --- usd_qtpy/layer_editor.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/usd_qtpy/layer_editor.py b/usd_qtpy/layer_editor.py index 6a3d726..7b4acc6 100644 --- a/usd_qtpy/layer_editor.py +++ b/usd_qtpy/layer_editor.py @@ -52,20 +52,22 @@ def set_tips(widget, tip): class LayerItem(TreeItem): - __slots__ = ('layer',) + __slots__ = ('layer', 'parent_layer') - def __init__(self, layer: Sdf.Layer): - super(LayerItem, self).__init__(key=layer.identifier) + def __init__(self, layer: Sdf.Layer, parent_layer: Sdf.Layer = None): + if parent_layer: + separator = "<--sublayer-->" # + key = separator.join([parent_layer.identifier, layer.identifier]) + else: + key = layer.identifier + + super(LayerItem, self).__init__(key=key) self.layer = layer + self.parent_layer = parent_layer class LayerStackModel(AbstractTreeModelMixin, QtCore.QAbstractItemModel): """Basic tree model that exposes a Stage's layer stack.""" - # TODO: Because the item key is based on the layer.identifier it currently - # does not support a single layer identifier to appear more than once - # across the full layer stack; to make them correctly unique we should - # make the key {parent.identifier}->{layer.identifier} a parent can - # contain the sublayer identifier only once # TODO: Tweak this more - currently loosely based on Luma Pictures # Layer Model https://github.com/LumaPictures/usd-qt/tree/master/treemodel headerLabels = ('Name', 'Path') @@ -310,7 +312,8 @@ def refresh(self): return def add_layer(layer: Sdf.Layer, parent=None): - layer_item = LayerItem(layer) + parent_layer = parent.layer if parent else None + layer_item = LayerItem(layer, parent_layer=parent_layer) item_tree.add_items(layer_item, parent=parent) for sublayer_path in layer.subLayerPaths: From 9265720caf03d45aa3f69b6250fffdc85cf5b823 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Nov 2023 17:59:54 +0100 Subject: [PATCH 7/8] Remove trailing empty comment --- usd_qtpy/layer_editor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usd_qtpy/layer_editor.py b/usd_qtpy/layer_editor.py index 7b4acc6..4471726 100644 --- a/usd_qtpy/layer_editor.py +++ b/usd_qtpy/layer_editor.py @@ -56,7 +56,7 @@ class LayerItem(TreeItem): def __init__(self, layer: Sdf.Layer, parent_layer: Sdf.Layer = None): if parent_layer: - separator = "<--sublayer-->" # + separator = "<--sublayer-->" key = separator.join([parent_layer.identifier, layer.identifier]) else: key = layer.identifier From 72164f9d24e015bb6772e51f246a096d62365904 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 23 Nov 2023 22:42:36 +0100 Subject: [PATCH 8/8] Allow dropping files directly from OS file explorer into the Layer list --- usd_qtpy/layer_editor.py | 52 ++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/usd_qtpy/layer_editor.py b/usd_qtpy/layer_editor.py index 4471726..4197c6a 100644 --- a/usd_qtpy/layer_editor.py +++ b/usd_qtpy/layer_editor.py @@ -125,7 +125,7 @@ def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlags: ) def supportedDropActions(self): - return QtCore.Qt.MoveAction + return QtCore.Qt.MoveAction | QtCore.Qt.CopyAction def mimeData(self, indexes): mimedata = QtCore.QMimeData() @@ -154,15 +154,54 @@ def mimeData(self, indexes): def dropMimeData(self, data, action, row, column, parent): if action == QtCore.Qt.IgnoreAction: return True - if not data.hasFormat("text/plain"): - return False if column > 0: return False + new_parent_layer = parent.data(self.LayerRole) + if not new_parent_layer: + raise RuntimeError( + "Can't drop on index that does not refer to a layer" + ) + + # If urls are in the data we consider only those. These are usually + # URLs from file drops from e.g. OS file explorer or alike. + if data.hasUrls(): + for url in reversed(data.urls()): + path = url.toLocalFile() + if not path: + continue + + if not os.path.isfile(path): + # Ignore dropped folders + continue + + # We first try to find or open the layer so see if it's a valid + # file format that way + try: + Sdf.Layer.FindOrOpen(path) + except Tf.ErrorException as exc: + log.error("Unable to drop unsupported file: %s", + path, + exc_info=exc) + continue + + if row == -1: + # Dropped on parent + new_parent_layer.subLayerPaths.append(path) + else: + # Dropped in-between other layers + new_parent_layer.subLayerPaths.insert(row, path) + return True + + if not data.hasFormat("text/plain"): + return False + + # Consider plain text data second + # TODO: This is likely better represented as a custom byte stream + # and as internal mimetype data to the model value = data.text() # Parse the text data separator = "<----" - sources = [] for line in value.split("\n"): if separator not in line: @@ -189,7 +228,6 @@ def dropMimeData(self, data, action, row, column, parent): parent=source_parent_layer ) - new_parent_layer = parent.data(self.LayerRole) if row < 0 and column < 0: # Dropped on parent, add dropped layer as child new_parent_layer.subLayerPaths.append(source_identifier) @@ -211,7 +249,7 @@ def dropMimeData(self, data, action, row, column, parent): return True def mimeTypes(self): - return ["text/plain"] + return ["text/plain", "text/uri-list"] def canDropMimeData(self, data, action, row, column, parent) -> bool: @@ -492,7 +530,7 @@ def __init__(self, stage, include_session_layer=False, parent=None): view = QtWidgets.QTreeView() view.setModel(model) - view.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove) + view.setDragDropMode(QtWidgets.QAbstractItemView.DragDrop) view.setDragDropOverwriteMode(False) view.setColumnHidden(1, True) view.setHeaderHidden(True)