Writing Moonlight|3D plugins

Introduction

In this tutorial you will be given a short introduction to developing plugins for Moonlight|3D. It is assumed that you are familiar with programming in Java. During the course of this tutorial you will write all the components required for a "create disk" tool that will generate a simple planar, almost circular mesh.

Anatomy of a plugin

First, let's just start with an empty plugin. Create a new empty package for your plugin. Then create a class named Plugin which implements the ml.core.plugins.Plugin interface. An empty implementation will probably look like this:

package ml.tutorial.createdisk;

import java.util.ArrayList;
import ml.core.plugins.Plugin;

class Plugin implements Plugin {
    public String getName() {
        // return unique name of plugin
        return "ml.tutorial.createdisk.Plugin";
    }

    public ArrayList<String> getDependencies() {
        return null;
    }

    public void load() {
    }

    public void unload() {
    }
}

This is the absolute minimum a plugin must implement. The getName() function usually returns a the full name of the Plugin class as a string. You might choose to return class.getName() here, but this is uncommon. The getDependencies() function usually returns a list of dependencies this plugin needs. The strings in the dependency list must match the names given by the corresponding plugins. As this is a trivial plugin, we do not depend on anything, so returning null is sufficient. Note that getName() and getDependencies() are called before any plugin is actually loaded, so do attempt to do anything fancy in these functions as this will fail.

The load() and unload() functions are called when the plugin is loaded or unloaded. You are responsible to register the functionality the plugin provides with the main program and to remove the any traced of the plugin when the plugin is unloaded. Since this is an empty plugin, there is no initialisation to perform. Thus, these two functions stay empty.

Now you can compile this java class to bytecode and stick it into a jar file. In order to get the plugin loaded, you need to copy the jar file to the plugins/ directory of your moonlight installation and you need to write a plugin definition file called createdisk.pluginlist with the following contents:

<plugins>
  <classpath location="plugins/createdisk.jar"/>
  <plugin className="ml.tutorial.createdisk.Plugin"/>
</plugins>

When you start Moonlight|3D for the next time it will attempt to load the plugin you created. Verify that the plugin got loaded successfully by selecting Help/About in the Moonlight|3D menu and then the plugins tab. If all went well you should now be able to see an entry labled ml.ui.tutorial.createdisk.Plugin there.

Writing a skeleton operator graph node

Of course a empty plugin class isn't of much use to anyone. So we now will try to add a bit of new functionality to the plugin. We do this by adding a new operator graph node that does all the dirtywork of creating the mesh for the disk. Let's start out with the bare skeleton code to get directions on what to do. First, we need the operator graph node class itself:

package ml.tutorial.createdisk;

import ml.backend.og.Node;

class CreateDisk extends Node {
    public CreateDisk() {
    }

    @Override
    public void update() {
    }
}

An operator graph node is in itself quite a simple construct. It needs a constructor to set itself up. We will see what there is to set up in a minute. And then there is the update() method. This method is called on an operator graph node instance to call it to action. This method should implement the algorithm the operator graph node stands for.

But because Moonlight|3D cannot know how to create an instance of that object (we write a plugin, remember?) we need a factory class. Luckily, there is already a generic one named ml.core.helper.DefaultFactory which we can use. We need to register the factory in the operator graph manager so that the program can actually use it. So we alter the load() and unload() methods of our plugin class like this:

    void load() {
        ml.core.State.getInstance().getOGManager().registerNodeFactory(new DefaultFactory("CreateDisk",CreateDisk.class));
    }

    void unload() {
        ml.core.State.getInstance().getOGManager().unregisterNodeFactory("CreateDisk");
    }

The first parameter in the DefaultFactory? constructor is the name by which the objects this factory can create are referenced. The second parameter is a reference to the class object of our CreateDisk? node which is needed by the DefaultFactory? to implement new instances. So when the plugin is built and loaded the next time around it registers the brand-new factory in the operator graph manager so that nodes of the new type "CreateDisk?" can be created by the operator graph manager, which will actually be instances of the CreateDisk? class which we wrote above. But it will not create such a node until we write an action to do that job which appears in the UI, e.g. as a menu bar item.

Writing an action

So let's see how to write "actions" in Moonlight|3D. Tools provide actions the user can perform. They may be added to menu bars, for example. In its simplest form an action looks quite compact:

package ml.tutorial.createdisk;

import ml.core.State;
import ml.ui.core.Action;

import com.trolltech.qt.gui.QWidget;

class CreateDiskAction extends Action {
    public CreateDiskTool(QWidget parent) {
        super("CreateDiskAction","Create disk",parent);
        setDefaultCategory("Tutorial");
        triggered.connect(this,"run()");
    }

    @Override
    public void run() {
        ml.core.State().getInstance().logger().info("ml.tutorial.createdisk.CreateDiskTool#run called");
    }
}

And we extend the load() and unload() methods of our Plugin class to look like this:

    void load() {
        ml.core.State.getInstance().getOGManager().registerNodeFactory(new CreateDiskFactory());
        ml.core.State.getInstance().getUIManager().registerAction(new CreateDiskAction());
    }

    void unload() {
        ml.core.State.getInstance().getOGManager().unregisterNodeFactory("CreateDisk");
        ml.core.State.getInstance().getUIManager().unregisterAction("CreateDiskAction");
    }

So in the Action class there is a constructor setting up a couple of things and a run() method that is currently empty except for a log message. So what does happen here? To understand that, take a look at the additions to the Plugin class: now it "registers" the CreateDiskActin? class in a UI manager. This means that it is added to an internal list of actions. When you compile this code and run Moonlight|3D again you will be able to find this action anywhere in the menu bar. This is because we haven't yet defined where to put this menu bar item.

Moonlight|3D uses a quite elaborate mechanism to decide where to put entries in the menu bar. For that purpose the menu bar contains so-called extension points which are locations where new entries, called contributions, can be added to (for a more detailed description see MoonlightMenuBar). In order to make our "Create disk" action appear in the menu bar we need to define a contribution and add it to a proper extension point. We therefore extend the Plugin load() method like this:

TODO: write the menu contribution

    void load() {
        ml.core.State.getInstance().getOGManager().registerNodeFactory(new CreateDiskFactory());
        ml.core.State.getInstance().getUIManager().registerAction(new CreateDiskAction());

        MenuNode contribution=new MenuNode(MenuNode.Type.Contribution,"Tutorial","menubar");
        MenuNode tutorialMenu=new MenuNode(MenuNode.Type.Menu,"Tutorial","");
        tutorialMenu.add(new MenuNode(MenuNode.Type.Separator,"",""));
        tutorialMenu.add(new MenuNode(MenuNode.Type.Entry,"Create disk","CreateDiskAction"));
        contribution.add(tutorialMenu);
        state.getUIManager().getLayout("mainwindow").addMenuContribution(contribution);
    }

When you run Moonlight|3D now with this plugin active you'll see that there is a new menu bar entry "Tutorial" with an item called "Create disk". Also, a button with the same label is in the toolchest in the category "Tutorial". How did that happen?

The plugin creates a menu contribution named "Tutorial" for the extension point "menubar" which happens to be the toplevel extension point in the main menu bar. Then it adds a new drop down menu named "Tutoral" and adds a separator and an entry named "Create disk" to it, which is finallyl connected to the CreateDiskAction? we defined above. The whole contribution is registered in the layout named "mainwindow" which happens to be the main window of Moonlight|3D. At this stage the program searches for the extension point "menubar" and appends our contribution to it. All this happens during plugin initialisation before the user interface is actually built. The actual creation of the menu bar happens only after all plugins are loaded.

The run() method of the CreateDiskAction? gets called whenever the user clicks on either the button in the toolchest or on the entry in the menu bar. When you try this the only thing that will happen is that the log messages appears in the log viewer in the bottom of the Moonlight|3D window. Let's change that to something more meaningful: Change the code of the action's run() method to this:

    void run() {
        ml.core.State.getInstance().getOGManager().createNodeByName("CreateDisk");
    }

So now the run() method is requesting the operator graph manager to create a CreateDisk? node. Start up Moonlight|3D again with this version of the plugin and click on the "Create disk" menu entry and see nothing big happen. Switch to the operator graph editor view to see a small blue rectangle labled "CreateDisk?" there. That's the representation of our new operator graph node. But you see that there are no slots yet - the blue rectangle is empty except for the node name. Click on the node to select it. The property editor will be equally empty for now. To fix this we need to fill the operator graph node with some more code.

Filling in the operator graph node implementation

We start out by setting up the parameters of the create disk operator graph node as properties. Of course, we will create the output slot along the way as well. Change the start of the CreateDisk? class to look like this:

package ml.tutorial.createdisk;

import ml.core.helper.Property;
import ml.core.helper.Value;

class CreateDisk public Node {
    private Property name;
    private Property radius;
    private Property divisions;

    public CreateDisk() {
        createSlot("output",Slot.Type.Output);
        name=new Property(this);
        name.setName("name");
        name.setType(Value.Type.String);
        try {
            name.setValue("Disk");
        } catch(ml.core.exceptions.Exception e) {
            // this exception cannot happen here - type safety is guaranteed
            e.printStackTrace();
        }
        radius=new Property(this);
        radius.setName("radius");
        radius.setType(Value.Type.Float);
        try {
            radius.setValue(1.0);
        } catch(ml.core.exceptions.Exception e) {
            // this exception cannot happen here - type safety is guaranteed
            e.printStackTrace();
        }
        divisions=new Property(this);
        divisions.setName("divisions");
        divisions.setType(Value.Type.Integer);
        try {
            divisions.setValue(1.0);
        } catch(ml.core.exceptions.Exception e) {
            // this exception cannot happen here - type safety is guaranteed
            e.printStackTrace();
        }
    }

This code accomplishes two things: First it calls createSlot() to create an output slot and then it sets up three properties: the name of the disk object, the radius of the disk object and the number of divisions we want the disk mesh to have. Since we're creating a polygon mesh here we need to approxmate the ideal disk shape somehow and the divisions property controls the quality of the approximation. As you can see, setting up a property actually consists of 4 steps: creating the Property object, setting the property name to something unique and meaningful, setting the type of the property properly and setting a default value. Note the way we don't handle exceptions at all around the calls to setValue(). setValue() only throws an exception when there is a type mismatch. This is the only way this function can fail. But since we set the type of each property immediately above the call to setValue() we know that the properties are of the right type and therefore know that no exception will ever be thrown here.

So what does this gain us? Running Moonlight|3D again with this improved operator graph node will give us a different display of the operator graph node in the operator graph editor: There is a slot labled output now inside the CreateDisk? rectangle. And when you select the node the property editor will populate with the properties of this node: name, radius and divisions with the default values for each one of them. Try to alter them and you will see that Moonlight|3D will correctly set these values and remember them. You can also save this scene to a file and relaod it. Moonlight|3D will save the state of the CreateDisk? node and restore it properly when loading the scene. And all that without you having to write a single line of code for this.

So now we need to move on to the real thing: creating the actual disk mesh. Because this is the last addition we will make to the CreateDisk? class the full final source code of this class is given below. The new additions are all to the update() method:

package ml.tutorial.createdisk;

import ml.backend.og.Node;
import ml.backend.og.Slot;
import ml.backend.sg.Root;
import ml.backend.sg.plugins.mesh.Mesh;
import ml.core.helper.Property;
import ml.core.helper.Value;
import ml.math.Vector3D;

class CreateDisk public Node {
    private Property name;
    private Property radius;
    private Property divisions;

    public CreateDisk() {
        createSlot("output",Slot.Type.Output);
        name=new Property(this);
        name.setName("name");
        name.setType(Value.Type.String);
        try {
            name.setValue("Disk");
        } catch(ml.core.exceptions.Exception e) {
            // this exception cannot happen here - type safety is guaranteed
            e.printStackTrace();
        }
        radius=new Property(this);
        radius.setName("radius");
        radius.setType(Value.Type.Float);
        try {
            radius.setValue(1.0);
        } catch(ml.core.exceptions.Exception e) {
            // this exception cannot happen here - type safety is guaranteed
            e.printStackTrace();
        }
        divisions=new Property(this);
        divisions.setName("divisions");
        divisions.setType(Value.Type.Integer);
        try {
            divisions.setValue(1.0);
        } catch(ml.core.exceptions.Exception e) {
            // this exception cannot happen here - type safety is guaranteed
            e.printStackTrace();
        }
    }

    @Override
    public update()  {
        Root root=new Root();
        Mesh mesh=new Mesh();

        Vector3D vertexPosition=new Vector3D();

        // create vertices:
        vertexPosition.clear();
        mesh.addVertex(vertexPosition);
        for(int i=0;i<divisions.getIntegerValue();i++) {
            vertexPosition.X[0]=radius.getFloatValue()*Math.cos(i*2*Math.Pi/divisions.getIntegerValue());
            vertexPosition.X[1]=radius.getFloatValue()*Math.sin(i*2*Math.Pi/divisions.getIntegerValue());
            mesh.addVertex(vertexPosition);
        }

        // create edges:
        // create faces:

        root.addChild(mesh);

        getSlot("output").setGraph(root);
    }
}

Wow, there's quite a lot going on here. It surely looks overwhelming at first. So let's try to digest this one step by step.

TODO: finish code and description

Conclusion

TODO