Creating visualizations: Van der Waals visual model

This tutorial shows you how to create a new visual model that shows the van der Waals representation of a group of atoms.

Note: SAMSON already has a Van der Waals visual model which is installed by default.

The source code for an Element created in this tutorial can be found at https://github.com/1A-OneAngstrom/SAMSON-Developer-Tutorials.

# Generating an Element

We will start by creating a new SAMSON Element called VanDerWaals thanks to the SAMSON Element generator (please, refer to the SAMSON Element generator tutorial for a reminder on how to use it):

The SAMSON Element generator generates a visual model class (derived from the SBMVisualModel class) and a property widget class for the visual model.

Now, try building the SAMSON Element.

# Setting up the visual model

A visual model is applied to a group of nodes passed to the constructor of the visual model. Since we want to apply a van der Waals visual model to a group of atoms, we add a member to the visual model to remember the list of atoms that we were passed.

Open the SEVanDerWaalsVisualModel.hpp file and include the SBAtom.hpp header:

#include "SBAtom.hpp"

And add the following member inside the declaration of the SEVanDerWaalsVisualModel class in the SEVanDerWaalsVisualModel.hpp file:

class SEVanDerWaalsVisualModel : public SBMVisualModel {
// ...
private:
SBPointerIndexer<SBAtom> atomIndexer;
};

A pointer indexer is similar to a node indexer, thus assigns indices to pointed objects, but automatically removes references from the indexer when a pointed object is deleted (in the C++ sense) so that it can never point to deleted memory.

Then, in the SEVanDerWaalsVisualModel.cpp file, modify the second constructor as follows:

SEVanDerWaalsVisualModel::SEVanDerWaalsVisualModel(const SBNodeIndexer& nodeIndexer) {
SBNodeIndexer temporaryIndexer;
SB_FOR(SBNode* node, nodeIndexer)
node->getNodes(temporaryIndexer, SBNode::IsType(SBNode::Atom));
SB_FOR(SBNode* node, temporaryIndexer)
atomIndexer.addReferenceTarget(node);
temporaryIndexer.clear();
SAMSON::setStatusMessage(QString("Van der Waals visual model: added for ") + QString::number(atomIndexer.size()), 0);
}

First, we collect atoms among the nodes passed to the constructor of the visual model (possibly among their descendants), then we add the atoms to the atom indexer atomIndexer.

# Disabling the property window

The property window of a visual model is mainly used to display the possible parameters of the visual model to the user such that they can be modified.

Note: This is also can be done via the introspection mechanism by exposing visual model attributes. In this case they could be modified via the Inspector.

If enabled, the property window appears when the visual model was created or can be invoced from the context menu of the visual model node in the data graph. For simplicity, in this tutorial we will not be using the property window. To disable the property window, please open the SEVanDerWaalsDescriptor.cpp file that describes the SAMSON Element and comment the following string:

//SB_ELEMENT_CLASS(SEVanDerWaalsVisualModelProperties);

You can also change the SAMSON Element category to Visualization:

SB_ELEMENT_CATEGORY(SBClass::Category::Visualization);

# Displaying spheres

To display something in the viewport, we overload the display function of the visual model. For convenience, SAMSON provides many functions to ease rendering objects (instead of having to use OpenGL functions).

In this tutorial, we are going to use the displaySpheres function, to draw one sphere per atom, with a radius equal to the van der Waals radius of the atom.

Let's now code inside the display function of the visual model.

First, we allocate arrays needed to transfer data to SAMSON for rendering spheres and initialize them to zeros:

void SEVanDerWaalsVisualModel::display() {
unsigned int numberOfAtoms = atomIndexer.size();
// allocate arrays and initialize them to zeros
float* positionData = new float[3 * numberOfAtoms]();
float* radiusData = new float[numberOfAtoms]();
float* colorData = new float[4 * numberOfAtoms]();
unsigned int* flagData = new unsigned int[numberOfAtoms]();

Observe the different array sizes above: we need 3 float per atom for the position, 1 float for the radius, 4 float for the color (red, green, blue, and opacity), and 1 unsigned int for flags used to alter the rendering colors (e.g. the selection flag and the highlighting flag).

We also need to know whether a color scheme is applied to the visual model, so we retrieve the pointer to the material applied to the visual model:

// retreive the pointer to the material applied to the visual model
SBNodeMaterial* material = getMaterial();

We can now fill the arrays by traversing the list of atoms:

// fill in the arrays
for (unsigned int i = 0; i < numberOfAtoms; i++) {
SBPointer<SBAtom> currentAtom = atomIndexer[i];
// check if the atom is not null
if (!currentAtom.isValid()) continue;
// check if the atom is not erased
if (currentAtom->isErased()) continue;
// fill the position array
SBPosition3 position = currentAtom->getPosition();
positionData[3 * i + 0] = (float)position.v[0].getValue();
positionData[3 * i + 1] = (float)position.v[1].getValue();
positionData[3 * i + 2] = (float)position.v[2].getValue();
// fill the radius array
radiusData[i] = (float)currentAtom->getVanDerWaalsRadius().getValue();
// fill the color array based on the material applied to the visual model, if any
if (material) {
// if a material is applied to the visual model use its color scheme
material->getColorScheme()->getColor(colorData + 4 * i, currentAtom());
}
else if (currentAtom->getMaterial()) {
// else if a material is applied to the current atom use its color scheme
currentAtom->getMaterial()->getColorScheme()->getColor(colorData + 4 * i, currentAtom());
}
else {
// else set the default color
colorData[4 * i + 0] = 1.0f;
colorData[4 * i + 1] = 1.0f;
colorData[4 * i + 2] = 1.0f;
colorData[4 * i + 3] = 1.0f;
}
// fill the flag array based on the combination of the flags for the visual model and the current atom
flagData[i] = currentAtom->getInheritedFlags() | getInheritedFlags();
}

Here, first we check whether the atom has been deleted or not. Thus, we will display spheres only for atoms, present in the data graph.

Then, we fill the position array and the radius array based on atom's position and the van der Waals radius, respectively. Both physical quantities have length units (SBQuantity::length), so we extract their numerical value with the getValue function, and cast the resulting double as a float, which is expected by the renderer.

Then, we fill the color array based on the material applied to the visual model, if any. Precisely, if a material is applied to the visual model, we charge it of filling the color array, based on the current atom we’re examining. Else, we check whether the atom itself has a color scheme in order to use it. Else, we set the color to white (red, green, blue and opacity are set to their maximum value: 1.0f).

Finally, we fill the flag array based on an “or” combination of the flags of the visual model and the flags of the current atom, to help the user have visual feedback when nodes are highlighted or selected.

We can now ask SAMSON to display these spheres:

// display spheres for atoms
numberOfAtoms,
positionData,
radiusData,
colorData,
flagData);

And we complete the display function by cleaning up memory:

delete[] positionData;
delete[] radiusData;
delete[] colorData;
delete[] flagData;

Now, if you build and launch SAMSON, and apply this visual model ( Ctrl/ Cmd⌘ + Shift + V) to a molecule without any material applied, it will show white spheres. Let's make it more appealing with default color set based on the CPK color scheme. For that, add the <SBElementTable.hpp header file in the SEVanDerWaalsVisualModel.cpp file:

#include "SBElementTable.hpp"

and change the setting of the default color to the following line:

// else set the default color based on CPK color
memcpy(&colorData[4 * i],
SBElementTable::getElement(currentAtom->getElementType()).getColorCPK(),
4 * sizeof(float));

Here we get the CPK color for a current atom element type.

Now, if you build and launch SAMSON, and apply this visual model to a molecule without any material applied:

Adding the Van der Waals visual model

it will show spheres with CPK color:

Van der Waals visual model with CPK colors and no shadows casted from the visual model

You can see that the visual model does not cast any shadows, and the only the shadows you see are casted by the molecule itself. Learn how to make you visual model to cast shadows in the next section.

# Casting shadows

SAMSON uses a shadow map algorithm in order to render shadows. This algorithm performs a hidden rendering pass from the point of view of the light and uses this render to determine whether a point is shadowed during the main rendering pass. In order to cast shadows, we thus need to render spheres again, but in the displayForShadow function. For faster results, we could avoid sending colors to SAMSON, since the shadow pass only cares to render geometry and determine the depth of objects. For simplicity, however, we can just call the display function from the displayForShadow function:

void SEVanDerWaalsVisualModel::displayForShadow() {
display();
}

The visual model then casts shadows:

Van der Waals visual model with shadows

# Enabling selection

In SAMSON, picking objects in the viewport is also implemented with rendering functions. However, instead assigning colors to each pixel of the viewport, we assign the index of the node being rendered at this location. Visual models can thus become selectable if they implement the displayForSelection function:

void SEVanDerWaalsVisualModel::displayForSelection() {
unsigned int numberOfAtoms = atomIndexer.size();
// allocate arrays and initialize them to zeros
float* positionData = new float[3 * numberOfAtoms]();
float* radiusData = new float[numberOfAtoms]();
unsigned int* nodeIndexData = new unsigned int[numberOfAtoms]();
// fill in the arrays
for (unsigned int i = 0; i < numberOfAtoms; i++) {
SBPointer<SBAtom> currentAtom = atomIndexer[i];
// check if the atom is not null
if (!currentAtom.isValid()) continue;
// check if the atom is not erased
if (currentAtom->isErased()) continue;
// fill the position array
SBPosition3 position = currentAtom->getPosition();
positionData[3 * i + 0] = (float)position.v[0].getValue();
positionData[3 * i + 1] = (float)position.v[1].getValue();
positionData[3 * i + 2] = (float)position.v[2].getValue();
// fill the radius array
radiusData[i] = (float)currentAtom->getVanDerWaalsRadius().getValue();
// fill the node index array
nodeIndexData[i] = currentAtom->getNodeIndex();
}
// display spheres for atoms
numberOfAtoms,
positionData,
radiusData,
nodeIndexData);
// clean up the memory
delete[] positionData;
delete[] radiusData;
delete[] nodeIndexData;
}

This function is shorter than the display function since we do not have to deal with colors. The main difference is that we fill the index array with the unique node index that SAMSON has assigned to the atom. As a result, when the user attempts to pick a sphere in the visual model, the atom is selected:

Van der Waals visual model with selection

# Modifying the properties

Let's now make it possible for the user to change the size of the spheres.

For that we introduce a dimensionless variable radiusFactor in the SEVanDerWaalsVisualModel class (file SEVanDerWaalsVisualModel.hpp) together with according getter and setter functions:

class SEVanDerWaalsVisualModel : public SBMVisualModel {
public:
// ...
/// \name Getter/setter functions
//@{
const float& getRadiusFactor() const;
void setRadiusFactor(const float& r);
//@}
private:
SBPointerIndexer<SBAtom> atomIndexer;
float radiusFactor; ///< The radius factor
};

And implementation of these functions in the SEVanDerWaalsVisualModel.cpp file:

const float& SEVanDerWaalsVisualModel::getRadiusFactor() const {
return radiusFactor;
}
void SEVanDerWaalsVisualModel::setRadiusFactor(const float& r) {
if (r < 0.0f) return;
radiusFactor = r;
// request re-rendering of the viewport
}

In the second constructor of the SEVanDerWaalsVisualModel class (file SEVanDerWaalsVisualModel.cpp) we set the default value for the radius factor to 1.0:

SEVanDerWaalsVisualModel::SEVanDerWaalsVisualModel(const SBNodeIndexer& nodeIndexer) {
radiusFactor = 1.0f; // set a default radius factor
// ...
}

And in both display and displayForSelection functions of the visual model modify the computation of the sphere radius by multiplying by the radius factor:

// fill the radius array
radiusData[i] = radiusFactor * (float)currentAtom->getVanDerWaalsRadius().getValue();

There are two main ways to provide a possibility for the user to modify properties of the visual model:

You can use either one of them or both.

## Inspector

Now, we expose the radius factor as an read and write attribute thanks to the getter and setter functions and the introspection mechanism. Note that the getter and setter function names should differ only in the get and set parts. Open the SEVanDerWaalsVisualModelDescriptor.hpp file and add the following attribute exposure:

SB_CLASS_BEGIN(SEVanDerWaalsVisualModel);
// ...
SB_ATTRIBUTE_READ_WRITE(const float&, SEVanDerWaalsVisualModel, RadiusFactor, "Radius factor", "Geometry");
SB_CLASS_END(SEVanDerWaalsVisualModel);

Here, we specify the type of the attribute (const float&), the class, the root name of the getter and setter functions, the description of the attribute, and an attribute's group (a group box in the Inspector window where this attribute will be placed).

Now, the user can modify the radius factor in the Inspector when the visual model is selected:

Changing the visual model properties in the Inspector

## Property window

First, if you disabled the property window of the visual model, enable it back by uncommenting the following string in the Element's descriptor file (SEVanDerWaalsDescriptor.cpp):

SB_ELEMENT_CLASS(SEVanDerWaalsVisualModelProperties);

Open the SEVanDerWaalsVisualModelProperties file, modify the default label, and add a QDoubleSpinBox. Rename this spin box to doubleSpinBoxRadiusFactor and set its parameters as on an image below (the defaul value to 1.0 and the minimum to 0.0):

Property window: adding a spin box

Add a slot onRadiusFactorChanged(double):

Property window: adding a slot

And connect the valueChanged(double) signal of the spin box to this slot:

Property window: connecting a signal to a slot

Now, add this slot in the SEVanDerWaalsVisualModelProperties class declaration (SEVanDerWaalsVisualModelProperties.hpp file):

class SEVanDerWaalsVisualModelProperties : public SBGDataGraphNodeProperties {
Q_OBJECT
public:
// ...
public slots:
/// \name Properties
//@{
void onRadiusFactorChanged(double radiusFactor);
//@}
private:
// ...
};

And its implementation in the SEVanDerWaalsVisualModelProperties.cpp file:

void SEVanDerWaalsVisualModelProperties::onRadiusFactorChanged(double radiusFactor) {
if (!visualModel.isValid()) return;
// set the radius factor for the visual model
visualModel->setRadiusFactor(radiusFactor);
// request re-rendering of the viewport
}

Here, we set the radius factor of the visual model from the property window and request to force the update of SAMSON's viewport which will render the viewport thus rendering the visual model with new parameters.

Now, the user can modify the radius factor in the property window and it will automaticaly update the visual model representation:

Changing the visual model properties with the property window

# Serializing the visual model

If you want for your visual model to be copyable and savable it is necessary to implement its serialization. Serialization is used in SAMSON to:

• copy nodes in the data graph,
• save documents in SAMSON format files (.sam, .samx) and further load it from them.

Please, see the Serialization section for more information.

By default, the newly created visual model is not serialized.

Open the SEVanDerWaalsVisualModel.cpp file and modify the isSerializable function to return true.

bool SEVanDerWaalsVisualModel::isSerializable() const {
return true;
}

This indicates to SAMSON that this class is serializable.

Now, we need to re-implement both serialize and unserialize functions.

When SAMSON serializes a group of nodes, it proceeds in two passes:

• First, all nodes that are to be serialized are added to a nodeIndexer. This way, each node that's about to be serialized has an index. It's those indices that need to be used to indicate which nodes are referenced by other nodes if the referenced nodes are serialized as well, else we need to use addresses of the referenced nodes.
• Then, the serialize function of each node is called. This function receives the nodeIndexer.

During unserialization, SAMSON also proceeds in two steps:

• First, it creates all required nodes and indexes them in a nodeIndexer, in the same order as during serialization.
• Then, the unserialize function of each node is called. This function receives the nodeIndexer.

The serialize function allows for serialization of your visual model.

void SEVanDerWaalsVisualModel::serialize(SBCSerializer* serializer, const SBNodeIndexer& nodeIndexer, const SBVersionNumber& sdkVersionNumber, const SBVersionNumber& classVersionNumber) const {
// Serialization of the parent class
SBMVisualModel::serialize(serializer, nodeIndexer, sdkVersionNumber, classVersionNumber);
// Write the radius factor
serializer->writeFloatElement("radiusFactor", radiusFactor);
// Write the number of atoms to which this visual model is applied
serializer->writeUnsignedIntElement("numberOfAtoms", atomIndexer.size());
unsigned int atomIndex = 0; // the index of the atom in the indexer
// Write indices of the atoms to which this visual model is applied
SB_FOR(SBPointer<SBAtom> atom, atomIndexer) {
if (nodeIndexer.getIndex(atom(), atomIndex)) {
// the atom is indexed
serializer->writeBoolElement("atomIsIndexed", true);
serializer->writeUnsignedIntElement("atomIndex", atomIndex);
}
else {
// the atom is not indexed, the user must be copying just the visual model
// so we serialize the atom address itself
serializer->writeBoolElement("atomIsIndexed", false);
serializer->writeUnsignedLongLongElement("atomIndex", (unsigned long long)atom());
}
}
}

First we invoke the serialization function of the parent class. The parent functions do many things (all the way up to SBNode itself, i.e. SBVisualModel::serialize calls SBModel::serialize which calls SBNode::serialize) and are needed for correct saving and loading of node properties. This allows to save the node's name, visibility flag, a material, etc. Then we save information on our visual model itself: the radius factor, number of nodes to which the visual model is applied (nodes referenced by the visual model), and the indices of these nodes in the nodeIndexer or their addresses. For the last part, we check whether the atom itself was serialized or not. Basically, saving of the document leads to the serialization of every node that can be serialized, while copying leads to the serialization only of the copied nodes. If the atom was serialized we save its index in the nodeIndexer, else we save the atom's address.

The unserialize function allows for unserialization of your visual model.

void SEVanDerWaalsVisualModel::unserialize(SBCSerializer* serializer, const SBNodeIndexer& nodeIndexer, const SBVersionNumber& sdkVersionNumber, const SBVersionNumber& classVersionNumber) {
// Unserialization of the parent class
SBMVisualModel::unserialize(serializer, nodeIndexer, sdkVersionNumber, classVersionNumber);
// Read the radius factor
radiusFactor = serializer->readFloatElement();
// Read the number of atoms to which this visual model is applied
unsigned int numberOfAtoms = serializer->readUnsignedIntElement();
bool atomIsIndexed;
unsigned int atomIndex = 0; // the index of the atom in the indexer
// Read indices of the atoms to which this visual model is applied and
// add these node into the atom indexer of the visual model
for (unsigned int i = 0; i < numberOfAtoms; ++i) {
atomIsIndexed = serializer->readBoolElement();
if (atomIsIndexed) {
// the atom was serialized too
atomIndex = serializer->readUnsignedIntElement();
atomIndexer.addReferenceTarget(nodeIndexer[atomIndex]);
}
else {
// the atom was not serialized, it must still exist in memory
atomIndexer.addReferenceTarget((SBAtom*)serializer->readUnsignedLongLongElement());
}
}
}

First, we invoke the unserialization function of the parent class. This will create the node in the data graph. Then we read information on our visual model itself: the radius factor, number of nodes to which the visual model is applied (nodes referenced by the visual model), and the indices of these nodes in the nodeIndexer or their addresses. We check whether the atom itself was serialized or not. If the atom was serialized we read its index in the nodeIndexer, else we read the atom's address.

This code allows us to both copy the visual model in the data graph and save it in SAMSON format files or load it from them.