Graph Model

The graph model is the central component of the graph widget. It is the application’s dynamic data structure, independent of the user interface. It directly manages the data and closely follows the model-view pattern. It defines the interface to be able to interoperate with the components of the model-view architecture.

GraphModel

GraphModel is the base class for graph model which provides the standard API. It is not supposed to be instantiated directly. Instead, the user subclasses it to create a new model.

The model manages two kinds of data elements. Nodes and Ports are the atomic data elements of the model.

There is no specific Python type for the elements of the model. Since Python has dynamic types, the model can return any object as a node or a port. When the widget needs to get a property of the node, it provides the given node back to the model, e.g. model[port].name in the delegate to query a port’s name.

Here is a simple model, defining the nodes and ports which are the key elements for a graph, as well as the properties for the element: connections, name and type:

![Code Result](Graph Model_0.png)

# define the graph root
graph_root = GraphNode("MyGraph")
# define two nodes with different types under the graph root
nodeA = EventNode("Event", graph_root)
graph_root.add_child(nodeA)
nodeB = AnimationNode("Animation", graph_root)
graph_root.add_child(nodeB)

class MyModel(GraphModel):
    """
    A simple model derived from GraphModel
    """
    @property
    def nodes(self, item=None):
        # when the node is None, we return the graph level node
        if item is None:
            return graph_root

        # when the input item root, we return all the nodes exist on the current graph
        # the return type is a Node list, so the graph nodes are Node type in MyModel
        if item == graph_root:
            return [child for child in graph_root.children()]
        return []

    @property
    def ports(self, item=None):
        # if input is a Node type we return the ports
        if isinstance(item, Node):
            return item.in_ports + item.out_ports
        # if input is a Port type we return the subports of the input port
        elif isinstance(item, Port):
            return item.children
        return []

    @property
    def name(self, item=None):
        # name of the item, item here could either be Node or Port
        return item.name

    @property
    def type(self, item):
        """type of the item, the item here could be Node or Port"""
        return item.type

    @property
    def inputs(self, item):
        # Node type doesn't have connection
        if isinstance(item, Node):
            return None

        # return all the input port's inputs
        if item.is_input:
            return item.inputs

        return None

    @property
    def outputs(self, item):
        # Node type doesn't have connection
        if isinstance(item, Node):
            return None

        # return all the output port's outputs
        if not item.is_input:
            return item.outputs

        return None

    @inputs.setter
    def inputs(self, value, item):
        # disconnection
        if len(value) == 0:
            if isinstance(item, Port):
                if len(item.inputs) > 0:
                    item.inputs.clear()
                elif len(item.outputs)>0:
                    item.outputs.clear()
        else:
            # when the item is not a Port, but a CompoundNode, it means that we are connecting a port to the Output node of
            # a compoundNode. In this case, we need to create a new output port for the node
            source = value[0]
            if isinstance(item, CompoundNode):
                ports = [port.name for port in item.out_ports]
                new_port_name = self.get_next_free_name(item, ports, source.name)
                new_port = Port(new_port_name, source.type, item)
                item.out_ports.append(new_port)
                # We are connecting to the new Port
                new_port.outputs.append(source)
                new_port.node = item
            # if the source is a CompoundNode, then we are connection a port to the Input node of a CompoundNode.
            # we need to create a new input port
            elif isinstance(source, CompoundNode):
                ports = [port.name for port in source.in_ports]
                new_port_name = self.get_next_free_name(source, ports, item.name)
                new_port = Port(new_port_name, item.type, source)
                source.in_ports.append(new_port)
                # add connection
                item.inputs.append(new_port)
                new_port.node = source
            else:
                # do the connection
                if item.is_input:
                    item.inputs.append(source)
                else:
                    item.outputs.append(source)
        self._item_changed(None)


# Accessing nodes and properties example
model = MyModel()

# query the graph
# The graph/node/port is accessed through evaluation of model[key]. It will
# return the proxy object that redirects its properties back to
# model. So the following line will call MyModel.nodes(None).
graph = model[None].nodes
nodes = model[graph].nodes

for node in nodes:
    node_name = model[node].name
    node_type = model[node].type
    print(f"The model has node {node_name} with type {node_type}")

    ports = model[node].ports
    for port in ports:
        # this will call MyModel.name(port)
        port_name = model[port].name
        port_type = model[port].type
        print(f"The node {node_name} has port {port_name} with type {port_type}")

        # prepare data for connection
        if port_name == "output:time":
            source_port = port
        elif port_name == "input:time":
            target_port = port

        subports = model[port].ports
        if subports:
            for subport in subports:
                subport_name = model[subport].name
                subport_type = model[subport].type
                print(f"The port {port_name} has subport {subport_name} with type {subport_type}")

# do the connection
if source_port and target_port:
    model[target_port].inputs = [source_port]

print(f"There is a connection between {target_port.path} and {source_port.path}")

Here is the result by running the above script in the script editor:

The model has node Event with type Event
The node Event has port input:Default Input with type str
The node Event has port input:Group Input with type group
The port input:Group Input has subport input:child1 with type int
The port input:Group Input has subport input:child2 with type int
The node Event has port output:time with type float
The model has node Animation with type Animation
The node Animation has port input:asset with type asset
The node Animation has port input:time with type float
The node Animation has port output:num_frames with type int
The node Animation has port output:num_skeletons with type int
The node Animation has port output:is_live with type bool

There is a connection between MyGraph:Animation:time and MyGraph:Event:time

Here is a visual representation of the graph in the above example.

Define node and port

The above example is using Node and Port type from omni.kit.graph.editor.example.nodes. You can of course define your own type of node and port. For example, you can use Usd.Prim as the node type and Usd.Attribute as the port type (e.g. the node and port in omni.kit.window.material_graph) or you can use Sdf.Path for both node and port types.

The return type from the API of def nodes defines the node type and def ports defines the port type. You can map your own class of nodes and ports to graph’s node and port type. A graph can have any number of nodes and a node can have any number of ports. The port can have any number of subports, and further on, subports can have subsubports and so on.

For the above example, def nodes will return MyGraph GraphNode when input item is None, and when the input item is MyGraph, it will return a list containing Event and Animation Nodes. def ports will return a list containing Default Input and Group Input when input item is an Event node and return a list containing child1 and child2 when input item is Group Input.

Define connections

The property of inputs and outputs define the connections for graphs. At the moment most of our graphs only support connections between one input and one output. The one side connection (two inputs or two outputs) is also available with more details in the future docs. Let’s take the common case of one input and one output connection as an example: connect Event node’s output time port (source port) with Animation’s input time port (destination port).

When we execute the connection using code (model[target_port].inputs = [source_port]) or edit the graph to click and drag the source port to connect to the target port, inputs.setter is called with the input item as the Animation’s input time port and value as a list containing Event node’s output time port in this case. The same happens when we right click to disconnect the connection. During the disconnection, the input value will be empty list []

During connection, the first thing we do is to check whether it is a disconnection or connection. Then we execute necessary data changes to cache this connection/disconnection.

How does the delegate know that we should have a connection at a port? The answer is the return value from inputs and outputs. The inputs property returns the input source port for the item port and outputs returns the target port for the item port. Take the above connection as an example. Looking at the MyGraph:Animation:time port as the input item, inputs returns a list containing the Event node’s output time port. And there are no outputs for Animation’s time port, so outputs will return None for Animation’s time port. That’s enough to describe a connection between two ports. One might want to define outputs for MyGraph:Event:time as [MyGraph:Animation:time] and None for inputs. Keep your graph upstream or downstream through the whole graph to be consistent.

So if we are using upstream connections, we will see most of the time outputs will return None. There are exceptions for the subgraph.

Subgraph and IsolationGraphModel

To create a subgraph node, just drag and drop a subgraph node into the graph (named Scene Graph in the example). When you double click the subgraph node, you enter the subgraph graph editor, you will see that the navigation bar on the top left has updated to your current graph location. More details about navigation in the Graph Core session.

When you want to expose some attributes from the subgraph and connect to nodes outside of the subgraph, firstly, you need to expose the attributes to the subgraph node. You can create Input nodes to expose inputs and Output nodes to expose outputs. The creation of Input and Output nodes is dealt with by IsolationGraphModel. You can create Inputs/Outputs using add_input_or_output.

Once Input/Output nodes are created, you can connect the port you want to expose to the EmptyPort of the Input/Output node. During that, inputs.setter is called as a normal connection. The only difference is when you connect to EmptyPort, the port doesn’t exist on the subgraph node, so we need to create the port first then do the connection. That’s the code path of isinstance(item, CompoundNode) and isinstance(source, CompoundNode) in inputs.setter.

Once the attributes are exposed (Default input and child1 from Event as inputs, is_live from Animation as output), you can go back to the MyGraph level by clicking MyGraph on the navigation bar. You will see that three ports are created on the Compound node, and then finally, we can connect the exposed port to other nodes, e.g. Debug.is_debug in the above image.

The connection between Animation’s is_live and Compound’s is_live is reflected as the outputs return value for Animation’s is_live, since we have no idea about Compound’s is_live attribute until that’s created by the connection.

GraphModel with batch position

We created a GraphModelBatchPositionHelper class to manage batch position processing. For example, moving a backdrop node, multi-selecting a few nodes and moving them together, or moving the current node’s upstream nodes all together, where we need to deal with a collection of positions at the same time.

That’s the reason our SimpleGraphModel from the example extension is inherited both from the GraphModel and GraphModelBatchPositionHelper. GraphModel defines the basic graph data structure and GraphModelBatchPositionHelper allows us to deal with batch position processing much more easily.

Backdrop

A Backdrop is used to group nodes together to make the graph more organized. Different from subgraphs, it lives on the same level of the nodes placed in the given backdrop. A Backdrop just gives a visual UI group to the nodes. When we move the backdrop, the nodes which are placed in the given backdrop will move together. We leverage GraphModelBatchPositionHelper to do that.

BackdropGetter is defined in omni.kit.widget.graph as a helper to get the nodes that are placed in the given backdrop. BackdropGetter(self, lambda item: self[item].type == "Backdrop") defines the drive item to be the one has the type of “Backdrop” and returns all the nodes which are placed in the drive item.

We add the function to GraphModelBatchPositionHelper using self.add_get_moving_items_fn(BackdropGetter(...)) for batch_position_begin_edit later to determine which items to move. And batch_position_end_edit is used to end the position editing.

Multi-selection move

This is similar to Backdrop, but the group scheme is using multi-selection instead of Backdrop. omni.kit.widget.graph provides a SelectionGetter to get all the selected nodes of the given model, corresponding to BackdropGetter.

Moving Upstream Nodes Together

We provide an example in the omni.kit.graph.editor.example such that when you press D and move a node, all the upstreams of the current nodes will move together. This could be useful for organizing your graph faster. It is similar to backdrops and multi-selection, but we need to create a customized DescendantGetter ourselves by deriving it from AbstractBatchPositionGetter. Again it just returns back all the upstream nodes for the drive node. It acts the same as Backdrop or multi-selection by calling batch_position_begin_edit and batch_position_end_edit.