Skip to content

Instantly share code, notes, and snippets.

@BigRoy
Last active October 26, 2024 16:32
Show Gist options
  • Save BigRoy/5ac50208969fdc69a722d66874faf8a2 to your computer and use it in GitHub Desktop.
Save BigRoy/5ac50208969fdc69a722d66874faf8a2 to your computer and use it in GitHub Desktop.
Example of how to embed a simple USD viewport in Qt application
"""
MIT License
Copyright (c) 2019 Roy Nieterau
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
import sys
from PySide import QtGui, QtCore
from pxr import Usd, UsdUtils, Sdf
from pxr.Usdviewq.stageView import StageView
app = QtGui.QApplication([])
class Widget(QtGui.QWidget):
def __init__(self, stage=None):
super(Widget, self).__init__()
self.model = StageView.DefaultDataModel()
self.view = StageView(dataModel=self.model,
printTiming=True)
layout = QtGui.QVBoxLayout(self)
layout.addWidget(self.view)
layout.setContentsMargins(0, 0, 0, 0)
if stage:
self.setStage(stage)
def setStage(self, stage):
self.model.stage = stage
def closeEvent(self, event):
# Ensure to close the renderer to avoid GlfPostPendingGLErrors
self.view.closeRenderer()
# Load kitchen set
path = r"path/to/Kitchen_set/Kitchen_set.usd"
with Usd.StageCacheContext(UsdUtils.StageCache.Get()):
stage = Usd.Stage.Open(path)
window = Widget(stage)
window.setWindowTitle("USD Viewer")
window.resize(QtCore.QSize(750, 750))
window.show()
# Make camera fit the loaded geometry
window.view.updateView(resetCam=True, forceComputeBBox=True)
app.exec_()
@BigRoy
Copy link
Author

BigRoy commented Nov 4, 2019

Here's a preview of what you'll get:
afbeelding

Some details:

  • You can navigate the viewport with Alt + left/middle/right mouse drag just like in the usdview application.
  • You can run this against PySide2 as well, however you'll need to compile USD with PySide2 and you'll need to update the example's import statements.
  • The self.view.closeRenderer() call is required to avoid the following error on UI close:
Runtime Error: in GlfPostPendingGLErrors at line 85 of USD/pxr/imaging/lib/glf/diagnostic.cpp -- GL error: invalid operation, reported from __cdecl pxrInternal_v0_19__pxrReserved__::HdxColorCorrectionTask::~HdxColorCorrectionTask(void)

@BigRoy
Copy link
Author

BigRoy commented Nov 4, 2019

Force a current frame for time dependent loaded USD stages to ensure geometry gets displayed

When loading an Alembic file or any format that only has time-dependent input data make sure to set the time to read from in USD data model, otherwise it might fail to display the geometry as described in this USD issue.

The easiest fix is for example to set the timecode to "EarliestTime":

earliest = Usd.TimeCode.EarliestTime()
window.model.currentFrame = Usd.TimeCode(earliest)

As a reference, this is where usdview is applying the currentFrame

@BigRoy
Copy link
Author

BigRoy commented Nov 6, 2019

Playing through time of the USD stage in the StageView embedded in your custom Qt interface

Playing through time might not seem obvious on how to do, because it involves setting up your own QTimer to continuously trigger the viewport updates and actually advance the time. Just setting the model.playing = True does not end up actually playing the scene. It's only telling the StageView.DefaultDataModel that it should be representing the data in a way that's optimized for playback.

Similarly, during playback you'll need to trigger the update of the StageView manually, preferably with view.updateForPlayback() as opposed to view.updateView() since that is also more optimized for playback. Plus it'll then also consider the playback settings, like not showing bounding boxes or selections during playback by default, dependent on ViewSettings

Since you advance the frames manually you'll also have to make sure you run at the right FPS and not faster. Here's what USD view implements to play the frames in a speed that matches the FPS.


Example Qt USD view widget with timeline

qt_usd_custom_viewer_timeline_bjorn

Here's an updated example of the above Gist snippet that also implements a very simple Timeline widget to start playing your loaded Alembics or USD files using the Hydra viewer right in your own Qt interface. Note that this example does not implement the "correct FPS speed" as described in the link above.

"""
MIT License

Copyright (c) 2019 Roy Nieterau

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""

import sys
from PySide import QtGui, QtCore
from pxr import Usd
from pxr.Usdviewq.stageView import StageView


class QJumpSlider(QtGui.QSlider):
    """QSlider that jumps to exactly where you click on it.
    
    This can also be done using QProxyStyle however that is unavailable
    in PySide, PyQt4 and early releases of PySide2 (e.g. Maya 2019) as
    such we implement it in a slightly less clean way.
    See: https://stackoverflow.com/a/26281608/1838864
    
    """
   
    def __init__(self, parent = None):
        super(QJumpSlider, self).__init__(parent)
     
    def mousePressEvent(self, event):
        #Jump to click position
        self.setValue(QtGui.QStyle.sliderValueFromPosition(self.minimum(), 
                                                           self.maximum(), 
                                                           event.x(), 
                                                           self.width()))
     
    def mouseMoveEvent(self, event):
        #Jump to pointer position while moving
        self.setValue(QtGui.QStyle.sliderValueFromPosition(self.minimum(), 
                                                           self.maximum(), 
                                                           event.x(), 
                                                           self.width()))

 
class TimelineWidget(QtGui.QWidget):
    """Timeline widget
    
    The timeline plays throught time using QTimer and
    will try to match the FPS based on time spent between
    each frame.
    
    """
    # todo: Allow auto stop on __del__ or cleanup to kill timers

    frameChanged = QtCore.Signal(int, bool)
    playbackStopped = QtCore.Signal()
    playbackStarted = QtCore.Signal()

    def __init__(self, parent=None):
        super(TimelineWidget, self).__init__(parent=parent)
        
        # Don't take up more space in height than needed
        self.setSizePolicy(QtGui.QSizePolicy.Preferred,
                           QtGui.QSizePolicy.Fixed)
    
        self.slider = QJumpSlider(QtCore.Qt.Horizontal)
        self.slider.setStyleSheet("""
        QSlider::groove:horizontal {
    border: 1px solid #999999;
    background-color: #BBBBBB;
    margin: 0px 0;
}

QSlider::handle:horizontal {
    background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #b4b4b4, stop:1 #8f8f8f);
    border: 1px solid #5c5c5c;
    width: 15px;
    border-radius: 3px;
}
        """)
        
        # A bit of a random min/max
        # todo: replace this with sys.minint or alike
        RANGE = 1e6
        
        self.start = QtGui.QSpinBox()
        self.start.setButtonSymbols(QtGui.QAbstractSpinBox.NoButtons)
        self.start.setMinimum(-RANGE)
        self.start.setMaximum(RANGE)
        self.start.setKeyboardTracking(False)
        self.end = QtGui.QSpinBox()
        self.end.setButtonSymbols(QtGui.QAbstractSpinBox.NoButtons)
        self.end.setMinimum(-RANGE)
        self.end.setMaximum(RANGE)
        self.end.setKeyboardTracking(False)
        self.frame = QtGui.QSpinBox()
        self.frame.setButtonSymbols(QtGui.QAbstractSpinBox.NoButtons)
        self.frame.setMinimum(-RANGE)
        self.frame.setMaximum(RANGE)
        self.frame.setKeyboardTracking(False)
        self.playButton = QtGui.QPushButton("Play")
        
        layout = QtGui.QHBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(2)
        layout.addWidget(self.start)
        layout.addWidget(self.slider)
        layout.addWidget(self.end)
        layout.addWidget(self.frame)
        layout.addWidget(self.playButton)
        
        # Timeout interval in ms. We set it to 0 so it runs as fast as
        # possible. In advanceFrameForPlayback we use the sleep() call
        # to slow down rendering to self.framesPerSecond fps.
        self._timer = QtCore.QTimer(self)
        self._timer.setInterval(0)
        self._timer.timeout.connect(self._advanceFrameForPlayback)
        
        self.playButton.clicked.connect(self.toggle_play)
        self.slider.valueChanged.connect(self.frame.setValue)
        self.frame.valueChanged.connect(self._frameChanged)
        self.start.valueChanged.connect(self.slider.setMinimum)
        self.end.valueChanged.connect(self.slider.setMaximum)
    
    def setStartFrame(self, start):
        self.start.setValue(start)
    
    def setEndFrame(self, end):
        self.end.setValue(end)
       
    @property
    def playing(self):
        return self._timer.isActive()
        
    @playing.setter
    def playing(self, state):
        
        if self.playing == state:
            # Do nothing
            return
            
        # Change play/stop button based on new state
        self.playButton.setText("Stop" if state else "Play")
        
        if state:
            self._timer.start()
            self.playbackStarted.emit()
            
            # Set focus to the slider as it helps
            # key shortcuts to be registered on
            # the widgets we actually want it.
            self.slider.setFocus()
            
        else:
            self._timer.stop()
            self.playbackStopped.emit()
            
    def toggle_play(self):
        # Toggle play state
        self.playing = not self.playing
        
    def _advanceFrameForPlayback(self):
    
        # This should actually make sure that the playback speed
        # matches the FPS of the scene. Currently it will advance
        # as fast as possible. As such a very light scene will run
        # super fast. See `_advanceFrameForPlayback` in USD view
        # on how they manage the playback speed. That code is in:
        # pxr/usdImaging/lib/usdviewq/appController.py
    
        frame = self.frame.value()
        frame += 1
        # Loop around
        if frame >= self.slider.maximum():
            frame = self.slider.minimum()
        
        self.slider.setValue(frame)
        
    def _frameChanged(self, frame):
        """Trigger a frame change callback together with whether it's currently playing."""
    
        if self.slider.value() != frame:
            # Whenever a manual frame was entered
            # in the frame lineedit then the slider
            # would not have updated along.
            self.slider.blockSignals(True)
            self.slider.setValue(True)
            self.slider.blockSignals(False)
    
        self.frameChanged.emit(frame, self.playing)
    

class Widget(QtGui.QWidget):
    def __init__(self, stage=None):
        super(Widget, self).__init__()
        
        self.model = StageView.DefaultDataModel()
        self.model.viewSettings.showHUD = False
        
        self.view = StageView(dataModel=self.model,
                              printTiming=True)
                              
        self.timeline = TimelineWidget()
        
        layout = QtGui.QVBoxLayout(self)
        layout.addWidget(self.view)
        layout.addWidget(self.timeline)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(2)
        
        self.timeline.frameChanged.connect(self.on_frame_changed)
        self.timeline.playbackStarted.connect(self.on_playback_started)
        self.timeline.playbackStopped.connect(self.on_playback_stopped)
        
        if stage:
            self.setStage(stage)
        
    def setStage(self, stage):
        self.model.stage = stage
        
        # Set the model to the earliest time so that for animated meshes 
        # like Alembicit will be able to display the geometry
        # see: https://github.com/PixarAnimationStudios/USD/issues/1022
        earliest = Usd.TimeCode.EarliestTime()
        self.model.currentFrame = Usd.TimeCode(earliest)
        
        # Show the timeline and set it to frame range of the animation
        # if the loaded stage has an authored time code.
        if stage.HasAuthoredTimeCodeRange():
            self.timeline.setVisible(True)
            self.timeline.setStartFrame(stage.GetStartTimeCode())
            self.timeline.setEndFrame(stage.GetEndTimeCode())
        else:
            self.timeline.setVisible(False)
        
    def closeEvent(self, event):
        
        # Stop timeline so it stops its QTimer
        self.timeline.playing = False
        
        # Ensure to close the renderer to avoid GlfPostPendingGLErrors
        self.view.closeRenderer()
        
    def on_frame_changed(self, value, playback):
        self.model.currentFrame = Usd.TimeCode(value)
        if playback:
            self.view.updateForPlayback()
        else:
            self.view.updateView()
               
    def on_playback_stopped(self):
        self.model.playing = False
        self.view.updateView()
        
    def on_playback_started(self):
        self.model.playing = True
        self.view.updateForPlayback()
    
    def keyPressEvent(self, event):
        # Implement some shortcuts for the widget
        # todo: move this code
        key = event.key()
        if key == QtCore.Qt.Key_Space:
            self.timeline.toggle_play()
        elif key == QtCore.Qt.Key_F:
            # Reframe the objects
            self.view.updateView(resetCam=True,
                                 forceComputeBBox=True)

# Load kitchen set
path = r"path/to/Kitchen_set/Kitchen_set.usd"
stage = Usd.Stage.Open(path,
                       Usd.Stage.LoadAll)

app = QtGui.QApplication([])

window = Widget(stage)
window.setWindowTitle("USD Viewer")
window.show()

# Make camera fit the loaded geometry
window.view.updateView(resetCam=True, forceComputeBBox=True)
window.resize(QtCore.QSize(750, 750))

app.exec_()

Note: if you actually run this with the Kitchen_set.usd the timeline will default to invisible as the set has no animation range whatsoever. Loading up a .usd with timesamples or an .abc file directly would show the timeline.

@BigRoy
Copy link
Author

BigRoy commented May 30, 2023

Original snippet of this gist updated to PySide2 by Thorsten Kaufmann (originally posted on Academy Software Foundation slack channel)

import sys
from PySide2 import QtGui, QtCore, QtWidgets
from pxr import Usd, UsdUtils, Sdf
from pxr.Usdviewq.stageView import StageView

app = QtWidgets.QApplication([])

class Widget(QtWidgets.QWidget):
    def __init__(self, stage=None):
        super(Widget, self).__init__()
        self.model = StageView.DefaultDataModel()
        
        self.view = StageView(dataModel=self.model)
        
        layout = QtWidgets.QVBoxLayout(self)
        layout.addWidget(self.view)
        layout.setContentsMargins(0, 0, 0, 0)
        
        if stage:
            self.setStage(stage)
        
    def setStage(self, stage):
        self.model.stage = stage
                              
    def closeEvent(self, event):
        
        # Ensure to close the renderer to avoid GlfPostPendingGLErrors
        self.view.closeRenderer()
        

path = r"<path_to_usd>"
with Usd.StageCacheContext(UsdUtils.StageCache.Get()):
    stage = Usd.Stage.Open(path)

window = Widget(stage)
window.setWindowTitle("USD Viewer")
window.resize(QtCore.QSize(750, 750))
window.show()

# Make camera fit the loaded geometry
window.view.updateView(resetCam=True, forceComputeBBox=True)

app.exec_()

@natestrong
Copy link

Hello @BigRoy, Thank you so much for providing this example code. It helped me get started making a PySide6 USD Hydra project.

You can find it as a test project here:
https://github.com/natestrong/hydra-usd-pyside-viewer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment