Creation Platform: Tutorial 01: Building an Asset Viewer

August 23, 2012, Posted by Helge Mathee

Hey folks,

this time I’d like to look at building a full-on Asset Viewer, like the Asset Browser sample, utilizing Creation Platform 1.0.28-beta. As said earlier, the AssetBrowser is a sample, not a final feature application, so since I consider the process of building a new application from scratch easier to follow for learning purposes, I won’t base the subject of this tutorial on the AssetBrowser, but rather build a new one.
Another thing to mention is the fact that Creation Platform is still evolving, so particular interfaces (such as the Parsers, for example) might still slightly change throughout the beta. For the Asset Viewer / Browser this represents only a small amount of changes, but it is still possible. This is also the reason this tutorial is linked to 1.0.28-beta in particular, and might not apply exactly the same way to future versions of Creation Platform.

Here’s a video covering the full process of implementing the Asset Viewer: https://vimeo.com/groups/fabric/videos/48067381

To start off, we’ll create the folder for our development. I’ll place all of the projects in a folder below my home folder like so:

cd ~
mkdir dev
cd dev
mkdir AssetViewer
cd AssetViewer

You might as well create a new repository following the instructions on GitHub.com resp. fork my existing repository. In the video accompanying this blog post I’ll cover this in more details.
To start off with the Application, the first thing to do is create an empty file to edit. You can then edit the file by opening your text editor like so (if you followed my installation instructions and set up an alias for your text editor):

touch AssetViewer.py
edit AssetViewer.py

As the next step we’ll want to create an Application class by inheriting from Creation Platform’s Application widget, without any functionality for now, like so:

from FabricEngine.CreationPlatform.PySide.Widgets import *

class AssetViewerApp(Application):

  def __init__(self, **options):
    
    super(AssetViewerApp, self).__init__(**options)
    
    
    self.constructionCompleted()
    
    
app = AssetViewerApp()
app.exec_()

Next up is the main menubar. We can just ask the Application class (the super class of our App) for its mainwindow, and then access the mainwindow’s menuBar() method. From then on it is just pure Qt code (PyQt docs: http://www.riverbankcomputing.com/static/Docs/PyQt4/html/qtgui.html) to setup methods etc. We’ll define a new method on the app, which will be used to switch folders, called browseDirectory, and then finally connect that to the signal of the menuBar action. Here’s the method (empty skeleton for now):

  def browseDirectory(self, directory = None):
    print directory

… and here’s the code to be added prior the self.constructionCompleted() call:

    # access mainwindow and menubar
    mainWindow = self.getMainWindow()
    menuBar = mainWindow.menuBar()
    
    # setup menu actions
    fileMenu = menuBar.addMenu("File")
    browseAction = fileMenu.addAction("Browse")
    fileMenu.addSeparator()
    quitAction = fileMenu.addAction("Quit")
    
    # connect menu action
    browseAction.triggered.connect(self.browseDirectory)
    quitAction.triggered.connect(self.close)

Now we are going to add code to be run whenever the browseDirectory method is invoked without a directory parameter provided. This will open up a user interface for choosing a folder on the harddrive(s). We’ll also add a private member to this class which is going to store the current directory it is browsing. It will be initiated at the end of the constructor:

    self.__directory = os.path.abspath('.')

Here are the changes for the browseDirectory method:

    # if this is called from the menubar
    if directory is None:
      qtDir = QtGui.QFileDialog.getExistingDirectory(
        self.getMainWindow(),
        "Choose directory",
        self.__directory)
      if qtDir is None:
        return
      self.__directory = str(qtDir)
    
    print self.__directory

To be able to display meta data related to the files we will be viewing, let’s create a new dockwidget to show that data. By implementing a new dockwidget inheriting the “DockWidget” class we can provide a floating resp. docket user interface element. We’ll also set the minimum size to ensure that the user cannot shrink the dockwidget too much. Add this to the App’s constructor method prior self.constructionCompleted()

    # define the metadata dockwidget class
    class MetaDataDockWidget(DockWidget):
      
      def __init__(self, options):
        
        super(MetaDataDockWidget, self).__init__(options)
        self.setWindowTitle('MetaData')
        self.setMinimumSize(QtCore.QSize(200, 100))
    
    
    # add all dockwidgets
    self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, MetaDataDockWidget({}))

The meta data will be represented as a text field, so we’ll add a QPlainTextEdit to the layout of the dockwidget. We’ll also expose access to the text edit’s content through a getter and setter for the meta data. Then we’ll test that at the end of our app’s constructor. Add this to the MetaDataDockWidget’s constructor:

        # create new empty widget and set it as central widget
        widget = QtGui.QWidget(self)
        self.setWidget(widget)
        
        # create layout
        widget.setLayout(QtGui.QGridLayout(widget))
        self.__textEdit = QtGui.QPlainTextEdit(widget)
        self.__textEdit.setReadOnly(True)
        widget.layout().addWidget(self.__textEdit, 0, 0)

Then add these methods to the MetaDataDockWidget class:

      def setMetaData(self, data):
        self.__textEdit.setPlainText(str(data))
        
      def metaData(self):
        return  self.__textEdit.plainText()

And finally restructure the addDockWidget call in the App’s constructor like this:

    self.__metaDataDock = MetaDataDockWidget({})
    self.__metaDataDock.setMetaData('Test test test')
    self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, self.__metaDataDock)

Before we look into creating any content in Fabric Engine we’ll add some code to do the actual file browsing. Based on the provided directory, we can use python’s glob module to look up all of the filenames matching a certain pattern. Update the browseDirectory method to look like this:

  def browseDirectory(self, directory = None):
    
    # if this is called from the menubar
    if directory is None:
      qtDir = QtGui.QFileDialog.getExistingDirectory(
        self.getMainWindow(),
        "Choose directory",
        self.__directory)
      if qtDir is None:
        return
      self.__directory = str(qtDir)
    else:
      self.__directory = directory

    # gather all files    
    allFiles = []
    allFiles.extend(glob.glob(os.path.join(self.__directory, "*.obj")))
    allFiles.extend(glob.glob(os.path.join(self.__directory, "*.las")))
    allFiles.extend(glob.glob(os.path.join(self.__directory, "*.laz")))
    
    for allFile in allFiles:
      print allFile

To be able to utilize Creation Platform for processing, we’ll need to create a scene. We’ll manage the scene creation in the browseDirectory method. Whenever there are still scenes open, we’ll close them. Then we can open a new scene with the right extensions loaded to be able to parse our target data.


    # if we already have a scene, close it
    if len(self._scenes) > 0:
      self._scenes[0].close()
      self._scenes.remove(self._scenes[0])
      
    # create a new scene
    self._scenes.append(Scene(self, exts = {'FabricOBJ':'', 'FabricLIDAR': ''}, guarded = True))

To start with the thumbnails, we’ll introduce a new private member (self.__thumbNails) which is going to store all widgets representing the viewports. We will have to remove them from the layout if we create a new scene resp. if we browse a different folder, and recreate them when browsing files. So when destroying the scene:

      for thumbnail in self.__thumbnails:
        self.getMainWindow().getCentralWidget().layout().removeWidget(thumbnail)
        thumbnail.close()
      self.__thumbnails = []

Here’s the class for the ThumbnailViewport, essentially just wrapping the Viewport class with some customizations:


    # thumbnail widget
    class ThumbnailViewport(Viewport):
      
      def __init__(self, scene, **options):
        super(ThumbnailViewport, self).__init__(scene, **options)
      
        self.setBackgroundColor(Color(1.0, 0.0, 0.0, 1.0))
        self.setMaximumSize(QtCore.QSize(200, 200))

Additionally, we’ll want to loop over all files and construct a viewport for each one.

    # loop over all files
    count = 0
    for fileName in allFiles:
      fileNameParts = os.path.split(fileName)
      print fileNameParts[1]
      
      viewport = ThumbnailViewport(
        scene,
        parentWidget = self.getMainWindow(),
        name = fileNameParts[1]
      )
      self.__thumbnails.append(viewport)
      
      self.getMainWindow().getCentralWidget().layout().addWidget(viewport, 0, count)

      # for debugging      
      count = count + 1
      if count == 3:
        break

    # if this has been triggered through the menu, update the layout
    if directory is None:
      self.__updateLayout()

We may also restructure the layout setup. Instead of doing the layout during the browse, we can do that later. This allows us to automate the layout also on resize.

  def __updateLayout(self):
    centralWidget = self.getMainWindow().getCentralWidget()
    
    col = 0
    row = 0
    for thumbnail in self.__thumbnails:
      centralWidget.layout().addWidget(thumbnail, row, col, QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
      col = col + 1
      
      width = col * (thumbnail.maximumSize().width() + 10)
      if width >= centralWidget.size().width():
        col = 0
        row = row + 1

We’ll overload the showMainUI method, to ensure that the updateLayout method is called once the UI is shown. At that stage the size of the window can be determined, and we can align the thumbnail accordingly.

  def showMainUI(self):
    super(AssetViewerApp, self).showMainUI()
    self.__updateLayout()

To know how long it is going to take to build all viewport, it might make sense to show a progress indicator. So before we loop over all files:

    # setup progressbar
    progBar = QtGui.QProgressBar()
    progBar.setWindowModality(QtCore.Qt.ApplicationModal)
    progBar.setMinimum(0)
    progBar.setMaximum(len(allFiles))
    progBar.show()

Then during the load we set the value:

      progBar.setValue(count)

After we finish the loop, we need to hide it.

    progBar.hide()

Now when looping over the files, for each obj file we create an obj parser. Additional we will also want to create a shader group, a material and a light.

      # setup the shader
      group = ShaderGroup(scene)
      viewport.addShaderGroupNode(group)
      light = PointLight(scene)
      material = Material(scene,
        shaderGroup=group,
        xmlFile='Standard/Phong',
        light=light,
        diffuseColor=Color(0.0,1.0,0.0)
      )
      
      # get the file extension
      extension = fileName.rpartition('.')[2]
      if extension.lower() == "obj":
        
        parser = OBJParser(scene, url = fileName)
        for triangles in parser.getAllTrianglesNodes():
          instance = Instance(scene,
            transform = Transform(scene),
            geometry = triangles,
            material = material
          )

To ensure the camera can see the content, use the bounding box feature of the triangles node.

          # get the bounding box
          bbox = triangles.getBoundingBox()
          target = bbox['min'].add(bbox['max']).multiplyScalar(0.5)
          position = target.subtract(bbox['max']).multiplyScalar(3.0).add(target)
          
          # setup the camera
          camera.setTarget(target)
          camera.setNearDistance(target.subtract(position).length() * 0.01)
          camera.setFarDistance(target.subtract(position).length() * 5.0)

Before we continue to move onto Lidar, let’s restructure the code to initiate a couple of variables for each file …

      camTarget = Vec3(0.0, 0.0, 0.0)
      camPosition = Vec3(100.0, 100.0, 100.0)
      camNearDistance = 0.1
      camFarDistance = 10.0
      light = None

… and move the material and light code into the OBJ case since LIDAR won’t require a light or a phong shader …

        light = PointLight(scene)
        material = Material(scene,
          shaderGroup=group,
          xmlFile='Standard/Phong',
          light=light,
          diffuseColor=Color(0.0,1.0,0.0)
        )

… after the file specific stuff is processed, let’s setup the camera based on the parameters determined.

      # setup the camera + camera manipulation
      camera = TargetCamera(scene,
        target = camTarget,
        position = camPosition,
        nearDistance = camNearDistance,
        farDistance = camFarDistance
      )
      viewport.setCameraNode(camera)
      manipulator = CameraManipulator(scene, autoRegister=False)
      viewport.getManipulatorHostNode().addManipulatorNode(manipulator)
      
      # if we did create a light, connect it to the camera
      if not light is None:
        light.setTransformNode(camera.getTransformNode())

To add support for LIDAR data, simply add a file case now:

      elif extension.lower() == "las" or extension.lower() == "laz":

        material = Material(scene,
          shaderGroup=group,
          xmlFile='Standard/Flat',
          color=Color(0.0,1.0,0.0)
        )

        # create the LIDAR parser and the instances
        parser = LidarParser(scene, url = fileName)
        points = parser.getPointsNode()
        instance = Instance(scene,
          transform = Transform(scene),
          geometry = points,
          material = material
        )
          
        # get the bounding box
        bbox = points.getBoundingBox()
        camTarget = bbox['min'].add(bbox['max']).multiplyScalar(0.5)
        camPosition = camTarget.subtract(bbox['max']).multiplyScalar(3.0).add(camTarget)
        camNearDistance = camTarget.subtract(camPosition).length() * 0.01
        camFarDistance = camTarget.subtract(camPosition).length() * 5.0

To be able to load up more data then we can actually show on the screen, we need a QScrollArea. We can put that between our MainWindow and the content:

    # setup the scroll area
    scrollArea = QtGui.QScrollArea(self.getMainWindow().getCentralWidget())
    scrollArea.setWidgetResizable(True)
    scrollArea.setEnabled(True)    
    self.getMainWindow().getCentralWidget().layout().addWidget(scrollArea)
    self.__contentWidget = QtGui.QWidget(scrollArea)
    scrollArea.setWidget(self.__contentWidget)
    self.__contentWidget.setLayout(QtGui.QGridLayout())

We will then also have to change the __updateLayout method to take that into consideration:

  def __updateLayout(self):
    col = 0
    row = 0
    for thumbnail in self.__thumbnails:
      self.__contentWidget.layout().addWidget(thumbnail, row, col, QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
      
      width = (col+1) * (thumbnail.maximumSize().width() + 50)
      if width >= self.__contentWidget.size().width():
        col = 0
        row = row + 1
      else:
        col = col + 1

For being able to maximize the viewports, add two new private members to the ThumbnailViewport class:

      __prevWidget = None
      __prevMaximumSize = None

Then overload the mouseDoubleClickedEvent on the viewport class to catch it:

      def mouseDoubleClickEvent (self, event):
        
        application = self.getScene().getApplication()
        centralWidget = application.getMainWindow().getCentralWidget()
        prevWidget = centralWidget.layout().itemAt(0).widget()
        
        if self.__prevWidget is None:
          self.__prevWidget = prevWidget
          self.__prevMaximumSize = self.maximumSize()
          self.setMinimumSize(QtCore.QSize(1, 1))
          self.setMaximumSize(QtCore.QSize(10000, 10000))
          centralWidget.layout().removeWidget(prevWidget)
          centralWidget.layout().addWidget(self)
        else:
          self.setMinimumSize(self.__prevMaximumSize)
          self.setMaximumSize(self.__prevMaximumSize)
          centralWidget.layout().removeWidget(self)
          centralWidget.layout().addWidget(self.__prevWidget)
          self.__prevWidget = None
          application.updateLayout()

To be able to access the updateLayout method I have renamed it from __updateLayout to updateLayout to make it accessible from within the ThumbnailViewport’s methods.

That’s it. We now have a fully working AssetViewer. You might want to overload the mouseClickedEvent as well on the viewport, to populate the metaData Dockwidget from that, or add labels below each viewport showing the filename. You may want to add more top menus to access data from asset management systems and so on. Of course there’s a lot more work to do, but I hope this is a good starting point for you guys to build your own asset viewers. Let me know what you think!

Oh btw, here’s the github link for the full source code:
https://github.com/helgemathee/creationPlatformTutorials/blob/master/AssetViewer/AssetViewer.py

Comments