Skip to content

Commit

Permalink
Minor UX Updates on Layer Content Browser, Value Edits and Graph View…
Browse files Browse the repository at this point in the history
…ers (#39)

* Ability to open images (when supported by Qt) in layer content browser
* Layer content browser no longer fails when valid older `usda` content doesn't roundtrip to crate (due to new USD versions) 
* Other minor UX improvements in layer content browser (copy identifiers, tab management, line count, zoom in / out)
* Support for editing string and token values in the experimental value editor
* Fast edit of some value types through context menu in the prim browser in USDView
* Minor fix on Display Color Editor buttons
* Minor UX fix for bidirectional connection edges offsetting only when not using splines in graph viewers
* Enable Python-3.12 in CI
  • Loading branch information
chrizzFTD authored Sep 3, 2024
1 parent 756935c commit ee35827
Show file tree
Hide file tree
Showing 13 changed files with 332 additions and 53 deletions.
20 changes: 11 additions & 9 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,23 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.11"]
python-version: ["3.9", "3.12"]
include:
- python-version: "3.9"
install-arguments: ". PySide2 usd-core==22.5 PyOpenGL pygraphviz"
- python-version: "3.11"
- python-version: "3.12"
install-arguments: ".[full]"
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Set up Graphviz
uses: ts-graphviz/setup-graphviz@v1
uses: ts-graphviz/setup-graphviz@v2
- name: Install Required Libraries
# Needed for PySide6 CI only: https://stackoverflow.com/questions/75907862/pyside6-wsl2-importerror-libegl-so-1
if: "matrix.python-version == '3.11'"
if: "matrix.python-version == '3.12'"
run: |
sudo apt-get install -y libegl1
- name: Install
Expand All @@ -40,7 +40,9 @@ jobs:
python -m pip install ${{ matrix.install-arguments }}
- name: Test
run: |
pytest --cov=./ --cov-report xml
codecov
pytest --cov .
# https://github.com/marketplace/actions/codecov
- name: Codecov Report
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
4 changes: 2 additions & 2 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@
# built documents.
#
# The short X.Y version.
version = '0.15'
version = '0.17'
# The full version, including alpha/beta/rc tags.
release = '0.15.0'
release = '0.17.0'

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion docs/source/layer_content_browser.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ Array attributes and time samples have their contents reduced to a maximum of 6

.. image:: https://user-images.githubusercontent.com/8294116/231415967-c960d036-05e6-42d3-905f-d673f8cf2579.gif

The ``Layer Content Browser`` widget can be opened from the USDView ``Composition`` tab (as shown above), as well as from the layer tree of the :ref:`Layer Stack Composition` widget:
The browser can be opened from USDView's ``Composition`` tab (as shown above), as well as from the :ref:`Layer Stack Composition`'s layer tree:

.. image:: https://user-images.githubusercontent.com/8294116/156912110-a573d9a6-6aed-4b8a-b492-dbaad2613283.gif

Images from formats supported by Qt through ``QtGui.QImageReader.supportedImageFormats`` will also be displayed:

.. image:: images/layer_content_browser_image_tab_update.gif

.. _sdffilter: https://openusd.org/release/toolset.html#sdffilter
.. _usdtree: https://openusd.org/release/toolset.html#usdtree
27 changes: 27 additions & 0 deletions grill/usd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import functools
import contextlib

import numpy as np

from itertools import chain
from collections import abc

Expand Down Expand Up @@ -358,6 +360,31 @@ def _format_prim_hierarchy(prims, include_descendants=True, predicate=Usd.PrimDe
return "\n".join(printer.ftree(prim) for prim in prims_to_tree)


# add other mesh creation utilities here?
def _make_plane(mesh, width, depth):
# https://github.com/marcomusy/vedo/issues/86
# https://blender.stackexchange.com/questions/230534/fastest-way-to-skin-a-grid
x_ = np.linspace(-(width / 2), width / 2, width)
z_ = np.linspace(depth / 2, - depth / 2, depth)
X, Z = np.meshgrid(x_, z_)
x = X.ravel()
z = Z.ravel()
y = np.zeros_like(x)
points = np.stack((x, y, z), axis=1)
xmax = x_.size
zmax = z_.size
faceVertexIndices = np.array([
(i + j * xmax, i + j * xmax + 1, i + 1 + (j + 1) * xmax, i + (j + 1) * xmax)
for j in range(zmax - 1) for i in range(xmax - 1)
])

faceVertexCounts = np.full(len(faceVertexIndices), 4)
with Sdf.ChangeBlock():
mesh.GetPointsAttr().Set(points)
mesh.GetFaceVertexCountsAttr().Set(faceVertexCounts)
mesh.GetFaceVertexIndicesAttr().Set(faceVertexIndices)


class _GeomPrimvarInfo(enum.Enum): # TODO: find a better name
# One element for the entire Imageable prim; no interpolation.
CONSTANT = UsdGeom.Tokens.constant, {UsdGeom.Imageable: 1}
Expand Down
7 changes: 5 additions & 2 deletions grill/views/_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,14 @@ def __init__(self, primvar: UsdGeom.Primvar, *args, **kwargs):
super().__init__(*args, **kwargs)
self._primvar = primvar

def setColorStyle(qobj, color):
qobj.setStyleSheet(f"background-color: rgb{color.getRgb()[:3]}") # don't want alpha

def _pick_color(parent, launcher):
result = QtWidgets.QColorDialog.getColor(launcher._color, parent, options=QtWidgets.QColorDialog.ShowAlphaChannel)
if result.isValid():
launcher._color = result
launcher.setStyleSheet(f"background-color: rgb{result.getRgb()}")
setColorStyle(launcher, result)
self._update_value()

layout = QtWidgets.QVBoxLayout()
Expand Down Expand Up @@ -131,7 +134,7 @@ def _color_option_changed(*__):
launcher = QtWidgets.QPushButton()
_color_launchers[label].append(launcher)
launcher._color = color
launcher.setStyleSheet(f"background-color: rgb{color.getRgb()}")
setColorStyle(launcher, color)
launcher.clicked.connect(partial(_pick_color, self, launcher))
range_layout.addWidget(launcher)
range_frame = QtWidgets.QFrame()
Expand Down
3 changes: 2 additions & 1 deletion grill/views/_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,8 @@ def adjust(self):

if not is_target_plugged:
line = QtCore.QLineF(source_point, target_point)
if self._bidirectional_shift and source_point != target_point: # offset in case of bidirectional connections
if not self._spline_path and self._bidirectional_shift and source_point != target_point:
# offset in case of bidirectional connections when we are not using splines (as lines would overlap)
line = _parallel_line(line, distance=self._bidirectional_shift, head_offset=0)

# Check if there is an intersection on the target node to know where to draw the arrow
Expand Down
145 changes: 139 additions & 6 deletions grill/views/description.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
)}
)
_HIGHLIGHT_PATTERN = re.compile(
rf'(^(?P<comment>#.*$)|^( *(?P<specifier>def|over|class)( (?P<prim_type>\w+))? (?P<prim_name>\"\w+\")| +((?P<metadata>(?P<arc_selection>variants|payload)|{"|".join(_usd._metadata_keys())})|(?P<list_op>add|(ap|pre)pend|delete) (?P<arc>inherits|variantSets|references|payload|specializes|apiSchemas|rel (?P<rel_name>[\w:]+))|(?P<variantSet>variantSet) (?P<set_string>\"\w+\")|(?P<custom_meta>custom )?(?P<interpolation_meta>uniform )?(?P<prop_type>{"|".join(_usd._attr_value_type_names())}|dictionary|rel)(?P<prop_array>\[])? (?P<prop_name>[\w:.]+))( (\(|((?P<value_assignment>= )[\[(]?))|$))|(?P<string_value>\"[^\"]+\")|(?P<identifier>@[^@]+@)(?P<identifier_prim_path><[/\w]+>)?|(?P<relationship><[/\w:.]+>)|(?P<collapsed><< [^>]+ >>)|(?P<boolean>true|false)|(?P<number>-?[\d.]+))'
rf'(^(?P<comment>#.*$)|^( *(?P<specifier>def|over|class)( (?P<prim_type>\w+))? (?P<prim_name>\"\w+\")| +((?P<metadata>(?P<arc_selection>variants|payload|references)|{"|".join(_usd._metadata_keys())})|(?P<list_op>add|(ap|pre)pend|delete) (?P<arc>inherits|variantSets|references|payload|specializes|apiSchemas|rel (?P<rel_name>[\w:]+))|(?P<variantSet>variantSet) (?P<set_string>\"\w+\")|(?P<custom_meta>custom )?(?P<interpolation_meta>uniform )?(?P<prop_type>{"|".join(_usd._attr_value_type_names())}|dictionary|rel)(?P<prop_array>\[])? (?P<prop_name>[\w:.]+))( (\(|((?P<value_assignment>= )[\[(]?))|$))|(?P<string_value>\"[^\"]+\")|(?P<identifier>@[^@]+@)(?P<identifier_prim_path><[/\w]+>)?|(?P<relationship><[/\w:.]+>)|(?P<collapsed><< [^>]+ >>)|(?P<boolean>true|false)|(?P<number>-?[\d.]+))'
)

_OUTLINE_SDF_PATTERN = re.compile( # this is a very minimal draft to have colors on the outline sdffilter mode.
Expand Down Expand Up @@ -91,7 +91,14 @@ def _format_layer_contents(layer, output_type="pseudoLayer", paths=tuple(), outp
with tempfile.TemporaryDirectory() as target_dir:
name = Path(layer.realPath).stem if layer.realPath else "".join(c if c.isalnum() else "_" for c in layer.identifier)
path = Path(target_dir) / f"{name}.usd"
layer.Export(str(path))
try:
layer.Export(str(path))
except Tf.ErrorException:
# Prefer crate export for performance, although it could fail to export non-standard layers.
# When that fails, try export with original file format.
# E.g. content that fails to export: https://github.com/PixarAnimationStudios/OpenUSD/blob/59992d2178afcebd89273759f2bddfe730e59aa8/pxr/usd/sdf/testenv/testSdfParsing.testenv/baseline/127_varyingRelationship.sdf#L9
path = path.with_suffix(f".{layer.fileExtension}")
layer.Export(str(path))
path_args = ("-p", "|".join(re.escape(str(p)) for p in paths)) if paths else tuple()
if output_type == "usdtree":
args = [_which("usdtree"), "-a", "-m", str(path)]
Expand Down Expand Up @@ -575,17 +582,83 @@ def __init__(self, layer, *args, resolver_context=Ar.GetResolver().CreateDefault
super().__init__(*args, **kwargs)
self._resolver_context = resolver_context
self._browsers_by_layer = dict() # {Sdf.Layer: _PseudoUSDTabBrowser}
self._tab_layer_by_idx = list() # {tab_idx: Sdf.Layer}
self._tab_layer_by_idx = list() # [tab_idx: Sdf.Layer]
self._addLayerTab(layer, paths)
self._resolved_layers = {layer}
self.setTabsClosable(True)
self.tabCloseRequested.connect(lambda idx: self.removeTab(idx))

def tabRemoved(self, index: int) -> None:
item = self._tab_layer_by_idx[index]
if isinstance(item, (Sdf.Layer, weakref.ref)):
item = item.__repr__.__self__
self._resolved_layers.discard(item)
del self._browsers_by_layer[self._tab_layer_by_idx.pop(index)]

def mousePressEvent(self, event):
if event.button() == QtCore.Qt.RightButton and (tab_index := self.tabBar().tabAt(event.pos())) != -1:
self._menu_for_tab(tab_index).exec_(event.globalPos())

super().mousePressEvent(event)

def _menu_for_tab(self, tab_index):
widget = self.widget(tab_index)
clipboard = QtWidgets.QApplication.instance().clipboard()
menu = QtWidgets.QMenu(self)
menu.addAction("Copy Identifier", partial(clipboard.setText, widget._identifier))
menu.addAction("Copy Resolved Path", partial(clipboard.setText, widget._resolved_path))
menu.addSeparator()
if tab_index < (max_tab_idx := len(self._tab_layer_by_idx)) - 1:
menu.addAction("Close Tabs to the Right", partial(self._close_many, range(tab_index + 1, max_tab_idx + 1)))
if tab_index > 0:
menu.addAction("Close Tabs to the Left", partial(self._close_many, range(tab_index)))
return menu

def _close_many(self, indices: range):
for index in reversed(indices):
self.tabCloseRequested.emit(index)

@_core.wait()
def _addLayerTab(self, layer, paths=tuple()):
def _addImageTab(self, path, *, identifier):
try:
focus_widget = self._browsers_by_layer[path]
except KeyError:
pixmap = QtGui.QPixmap(path)
if pixmap.isNull():
QtWidgets.QMessageBox.warning(self, "Error Opening Contents", f"Could not load {path}")
return
image_label = QtWidgets.QLabel(parent=self)
image_label.setAlignment(QtCore.Qt.AlignCenter)
image_label.resize(pixmap.size())
image_label.setPixmap(pixmap)

focus_widget = QtWidgets.QFrame(parent=self)
focus_layout = QtWidgets.QHBoxLayout()
focus_layout.setContentsMargins(0, 0, 0, 0)

image_item = QtWidgets.QGraphicsPixmapItem(pixmap)
viewport = _graph._GraphicsViewport(parent=self)
scene = QtWidgets.QGraphicsScene()
scene.addItem(image_item)
viewport.setScene(scene)

focus_layout.addWidget(viewport)
focus_widget.setLayout(focus_layout)

tab_idx = self.addTab(focus_widget, Path(path).name)

focus_widget._resolved_path = path
focus_widget._identifier = identifier
self.setTabToolTip(tab_idx, path)

self._tab_layer_by_idx.append(path)
assert len(self._tab_layer_by_idx) == (tab_idx + 1)
self._browsers_by_layer[path] = focus_widget

self.setCurrentWidget(focus_widget)

@_core.wait()
def _addLayerTab(self, layer, paths=tuple(), *, identifier=None):
layer_ref = weakref.ref(layer)
try:
focus_widget = self._browsers_by_layer[layer_ref]
Expand All @@ -607,6 +680,17 @@ def _addLayerTab(self, layer, paths=tuple()):
outline_model = QtGui.QStandardItemModel()
outline_tree = _Tree(outline_model, outliner_columns, _core._ColumnOptions.SEARCH)
outline_tree.setSelectionMode(outline_tree.SelectionMode.ExtendedSelection)
outline_tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)

def show_outline_tree_context_menu(*args):
if selected_indexes:= outline_tree.selectedIndexes():
content = "\n".join(str(index.data(QtCore.Qt.UserRole)) for index in selected_indexes if index.isValid())
menu = QtWidgets.QMenu(outline_tree)
menu.addAction("Copy Paths", partial(QtWidgets.QApplication.instance().clipboard().setText, content))
menu.exec_(QtGui.QCursor.pos())

outline_tree.customContextMenuRequested.connect(show_outline_tree_context_menu)

outline_model.setHorizontalHeaderLabels([""] * len(outliner_columns))
root_item = outline_model.invisibleRootItem()

Expand Down Expand Up @@ -667,6 +751,9 @@ def update_contents(*_, **__):
with QtCore.QSignalBlocker(browser):
_ensure_highligther(highlighters.get(format_choice, _Highlighter))
error, text = _format_layer_contents(layer_, format_combo.currentText(), paths, output_args)
line_count = len(text.split("\n"))
line_counter.setText("\n".join(chain(map(str, range(1, line_count)), ["\n"] * 5)))
line_counter.setFixedWidth(12 + (len(str(line_count)) * 8))
browser.setText(error if error else text)

populate(sorted(content_paths)) # Sdf.Layer.Traverse collects paths from deepest -> highest. Sort from high -> deep
Expand Down Expand Up @@ -749,9 +836,38 @@ def _find(text):
browser_line_filter.textChanged.connect(_find)
browser_line_filter.returnPressed.connect(lambda: _find(browser_line_filter.text()))

browser_layout.addWidget(browser)
line_counter = QtWidgets.QTextBrowser()
line_counter.setLineWrapMode(QtWidgets.QTextBrowser.NoWrap)
line_counter.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
line_counter.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)

line_counter_doc = line_counter.document()
line_text_option = line_counter_doc.defaultTextOption()
line_text_option.setAlignment(QtCore.Qt.AlignRight)
line_counter_doc.setDefaultTextOption(line_text_option)

line_count = len(text.split("\n"))
line_counter.setFixedWidth(12 + (len(str(line_count)) * 8))
line_counter.setText("\n".join(chain(map(str, range(1, line_count)), ["\n"]*5)))
line_counter.setEnabled(False)

line_scrollbar = line_counter.verticalScrollBar()
browser.verticalScrollBar().valueChanged.connect(line_scrollbar.setValue)
browser._line_counter = line_counter

browser_combined_layout = QtWidgets.QHBoxLayout()
browser_combined_layout.addWidget(line_counter)
browser_combined_layout.setSpacing(0)
browser_combined_layout.setContentsMargins(0,0,0,0)
browser_combined_layout.addWidget(browser)

browser_layout.addLayout(browser_combined_layout)

tab_idx = self.addTab(focus_widget, _layer_label(layer))
focus_widget._resolved_path = str(layer.resolvedPath)
focus_widget._identifier = identifier or layer.identifier
self.setTabToolTip(tab_idx, str(layer.resolvedPath))

self._tab_layer_by_idx.append(layer_ref)
assert len(self._tab_layer_by_idx) == (tab_idx+1)
self._browsers_by_layer[layer_ref] = focus_widget
Expand All @@ -764,17 +880,24 @@ def _on_identifier_requested(self, anchor: Sdf.Layer, identifier: str):
if not (layer := Sdf.Layer.FindOrOpen(identifier)):
layer = Sdf.Layer.FindOrOpenRelativeToLayer(anchor, identifier)
except Tf.ErrorException as exc:
resolved_path = str(Ar.GetResolver().Resolve(identifier)) or anchor.ComputeAbsolutePath(identifier)
if resolved_path and Path(resolved_path).suffix[1:] in _image_formats_to_browse():
self._addImageTab(resolved_path, identifier=identifier)
return
title = "Error Opening File"
text = str(exc.args[0])
else:
if layer:
self._addLayerTab(layer)
self._addLayerTab(layer, identifier=identifier)
self._resolved_layers.add(layer)
return
title = "Layer Not Found"
text = f"Could not find layer with {identifier=} under resolver context {self._resolver_context} with {anchor=}"
QtWidgets.QMessageBox.warning(self, title, text)

@cache
def _image_formats_to_browse():
return frozenset(str(fmt, 'utf-8') for fmt in QtGui.QImageReader.supportedImageFormats())

class _PseudoUSDTabBrowser(QtWidgets.QTextBrowser):
# See: https://doc.qt.io/qt-5/qtextbrowser.html#navigation
Expand All @@ -785,6 +908,7 @@ class _PseudoUSDTabBrowser(QtWidgets.QTextBrowser):
# https://stackoverflow.com/questions/66931106/make-all-matches-links-by-pattern
# https://fossies.org/dox/CuteMarkEd-0.11.3/markdownhighlighter_8cpp_source.html
identifier_requested = QtCore.Signal(str)
_line_counter = None

def mousePressEvent(self, event):
cursor = self.cursorForPosition(event.pos())
Expand Down Expand Up @@ -812,6 +936,15 @@ def mouseReleaseEvent(self, event):
else:
super().mouseReleaseEvent(event)

def wheelEvent(self, event):
if event.modifiers() == QtCore.Qt.ControlModifier:
method = operator.methodcaller("zoomIn" if event.angleDelta().y() > 0 else "zoomOut")
method(self)
if self._line_counter:
method(self._line_counter)
else:
super().wheelEvent(event)


class LayerStackComposition(QtWidgets.QDialog):
# TODO: display total amount of layer stacks, and sites
Expand Down
1 change: 0 additions & 1 deletion grill/views/maya.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ def selection_changed(*_, **__):
break
else:
widget.setPrim(None)
# widget.clear()

om.MEventMessage.addEventCallback("UFESelectionChanged", selection_changed)
selection_changed()
Expand Down
Loading

0 comments on commit ee35827

Please sign in to comment.