Creation Platform: Tutorial 01: Building an Asset Viewer
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.
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
Twitter
Facebook
LinkedIn
Vimeo
Google +