The ARIEL Codebase for Developers - The-Franklin-Institute/ARIEL_Builder GitHub Wiki
ARIEL Codebase
This document outlines the basic structure of the ARIEL Builder software.
Overview
File Structure
How It All Works
Advanced Topics
Known Issues with Graphics
Recommendations for A New Version
Overview
ARIEL Builder is written for Mac OS X in Python 2.6. It makes use of openFrameworks version 0062. Using a tool called SWIG, openFrameworks' C++ tools were wrapped into Python. In addition, a C++ build of OpenGL was wrapped up with it (as distinguished from something like PyOpenGL, which is a significantly slower Python implementation of OpenGL).
ARIEL Builder's graphics and event loop run within openFrameworks. OpenFrameworks itself runs on OpenGL; this means that all oF tools (with some exceptions) are accessible and OpenGL calls can be mixed in.
As you will soon discover, running a Python application entirely on a SWIG-wrapped C++ library poses some problems. The reason for this decision was to leverage both the speed of openFrameworks– for which there is no Python equivalent– and the dynamic nature of Python.
File Structure
The latest build of the software is contained within ARIEL 2.0/App
. The ARIEL 2.0/Examples
folder contains an incomplete selection of sample sketches as well as a few advanced prototypes from the ARIEL research project. All of the python scripts live in ARIEL 2.0/App
.
There are four files that comprise the openFrameworks/OpenGL library:
_openframeworks.so
- the compiled binary file containing the actual library
openframeworks.py
- the Python wrapper file generated by SWIG
libfmodex.dylib
- compiled library for FMod, a set of audio tools used by openFrameworks
libGLee.so
- an OpenGL extension-loading library required by openFrameworks
When using the ARToolkit tools (packaged with this build of openFrameworks), LogitechPro4000.dat
is required.
The main application files are builder.py
, node.py
, player.py
, and App/python/lib/python2.6/site-packages/psl/gui.py
. More on their functions in the how it all works section.
Color palettes, short descriptions for nodes, and several utilities are broken out into different files within the App
folder.
The python
folder contains a 32-bit (arch i386) build of Python 2.6 (required to use this build of openFrameworks) along with several libraries (NumPy v1.2.1, Serial v2.5-rc2, and a custom library called PSL). This accounts for the ~275mb size of the software package.
The User Nodes
folder contains user-created ARIEL nodes. Upon launching ARIEL Builder, a script looks for valid nodes in this folder and imports them into the Builder environment.
How It All Works
Environment
Event Handling & Object Management
Nodes
Save & Undo
Loading Sketch Data
Player
Compiler
Environment
The main event loop can be found in builder.py
. This script must be launched using the Python build that comes with the software package; arielbuilder.command
is a shell script created for this purpose. Note that ARIEL 2.0/ARIEL Builder.app
is simply an AppleScript that launches arielbuilder.command
.
When builder.py
is launched, an instance of the ArielBuilder
class is created and passed into ofRunApp()
. All of the environment GUI initialization happens within the function ArielBuilder.setup
.
There's a very important file hidden deep within the structure of the Python build: App/python/lib/python2.6/site-packages/psl/gui.py
. This file contains the boilerplate GUI code necessary to create (almost) all of the interactive objects within the Builder environment. This file is imported into builder.py
; it contains a global variable called manager
that represents a PSGObjectManager
, the controller that maintains references to all of the GUI objects in the environment.
The environment's GUI is broken up into three major parts: the NodePallet
, the gui.MenuBar
, and the gui.PropertyDrawer
. The NodePallet contains the menu by which nodes are created; within it, multiple NodeCategory
objects store NodePalletIcon
objects that represent each possible Node. When clicked, a NodePalletIcon
will generate a Node
(more on those later).
Event Handling & Object Management
All GUI-related classes inherit the gui.PSGObject
base class. There's a neat little operation within PSGObject.__init__(parent=None)
that looks like this:
if not self.parent:
global manager
manager.addObject(self)
else:
self.parent.children.append(self)
If a PSGObject
is instantiated without being given a parent, it will automatically add itself to the PSGObjectManager
. The only objects with parents are those that belong in a hierarchy: menu bar -> menu item -> menu entries, etc. This ensures that all GUI elements are kept in the same place. If/when creating new GUI elements, make sure they inherit the PSGObject
class. There are several higher-level classes to choose from, depending on the desired functionality: Button
, Label
, and Window
.
The openFrameworks base app contains several event functions that we can expose: keyPressed
, mouseMoved
, mouseDragged
, mousePressed
, mouseReleased
, windowResized
, and the main update
and draw
loops. Most of the mouse events are forwarded to manager
, the PSGObjectManager
that contains all of the GUI elements. Key events are forwarded to gui.focus
, an instance of gui.FocusManager
that determines where to forward key events depending on what is being interacted with.
Nodes
This document describes the Node class as it relates to the ARIEL Builder environment. For an in-depth look at how nodes work and how to create new ones, see the Creating New Nodes section of the wiki.
Given the complexity of the interaction with Node
objects, there is a large amount of boilerplate GUI in place to expose meaningful interaction events. gui.Button
in App/python/lib/python2.6/site-packages/psl/gui.py
lays the groundwork. The bulk of it, however, is done within App/node.py
.
Before getting into the specifics, let's look at the broad strokes. A Node object is comprised of a body, some inputs, and some outputs. Dragging an output (represented as a little downward-facing triangle) exposes a cord. Plugging an output into an input creates a connection (assuming the data types are compatible- more on that later). Once a connection is in place, it can be undone by grabbing the output and pulling it away from the input.
The Node
class inherits the gui.Button
class, giving it a body that is draggable. Node
contains inputs
and outputs
arrays which store NodeInput
and NodeOutput
objects, respectively. A global ConnectionManager
object called connector
stores the connection information, however the NodeOutput
class is responsible for drawing the cord that connects an output to an input.
The NodeInput
and NodeOutput
classes are abstract and are not used at the concrete node level; instead there are different input and output types such as Number, Matrix, Image, Polygon, GlyphData, VectorList, etc. that inherit NodeInput
and NodeOutput
.
A Node
can have editable parameters e.g. variables, filepaths, drop-down menu selections, etc. that can be edited in the ARIEL Builder environment. The functions Node.setParameterDict()
and Node.getParameterDict()
are used to turn any parameters into a dictionary of key/value pairs upon request. When saving a sketch to a file, for example, Node.getParameterDict()
returns a dict that stores all of the current parameter values. This is covered in detail in the Creating New Nodes section.
Interaction
A Node
object itself only responds to being dragged or double-clicked within its square body. Node.handleMouse
checks for mouse events on the inputs and outputs first. If a NodeOutput
catches a mouse click, it responds by telling its Arrow
object to draw a cord to the mouse location. If an output is being dragged and the mouse is released over an input, the output calls connector.add(self, input)
. Connector stores pairs that describe connections between nodes as ConnectionManager.connections[index] = (NodeOutput, NodeInput)
.
Double-clicking a node reveals the Property Drawer, where that particular node's properties– if it has any– can be edited. The Node.propertyWindow
doesn't have anything in it by default. It's up to the concrete classes that extend Node
to populate their propertyWindow
with relevant menu items.
Concrete Node Classes
Concrete classes extend from the Node
class, e.g. class Camera(Node)
. There are a host of functions and objects to use depending on the features of the node.
For a detailed look at how to create a new node, see the Creating New Nodes page.
Save & Undo
File saving uses the Python cPickle module. The ArielBuilder.exportGraph
function in builder.py
handles the process. It begins by taking the gui.manager.objects
list, filtering it to contain only Node
objects, and extracting the relevant data about each node by calling its Node.getExportData
function.
def getExportData(self):
inputs = map(lambda o: (o.label,id(o)), self.inputs)
outputs = map(lambda o: (o.label,id(o)), self.outputs)
prams = self.getParameterDict()
layout = (self.x, self.y, self.w, self.h)
return `self.__class__`,inputs, outputs, prams, layout
Therefore the data being saved is a list containing the classname, inputs (and their unique class id
), outputs (and their class id
), parameters returned by Node.getParameterDict
, and the layout information of each Node
in the scene.
After the nodes have been saved, node.connector.getExportData()
is called, which returns a list of all of the connections in the form (input_id, output_id)
.
Calling cPickle.dumps(data)
on the final array returns a heavily-compressed and unintelligible string that can be unraveled later to recreate a scene.
To implement Undo functionality, gui.undo
represents a gui.UndoManager
which is given a cPickle string every time an undo-able action is performed. When undo is called the last recorded state is 'unwrapped' using cPickle.load(data)
.
This is not an ideal solution, as it does not currently take into account Property Window editing.
Loading Sketch data
Just as the state of the environment can be distilled into an array, the 'unwrapping' process can turn that array back into a set of nodes and connections. This process is contained within builder.py/ArielBuilder.loadGraph()
. Using the classname stored for each node in the cPickled array, the eval()
function is used to instantiate the node again. eval
can evaluate a piece of python code, so the classname is fed in as a string along with "()"
, where it is then populated with all of the other data:
try:
newNode = eval(classname + "()")
# ...
newNode.x, newNode.y, newNode.w, newNode.h = layout
newNode.setParameterDict(params)
During this process, a small operation saves each input and output to a dict called index
, along with a reference to the node that the input or output belongs to:
for i in inputs+outputs:
index[i[1]] = (newNode,i[0])
This rather cryptic line of code says:
for every input and output in this particular node:
the location "input/output id" in the dict "index" = (reference to its node, "label of the input/output")
Once all of the nodes have been created the connector data is analyzed. This is somewhat complicated because even though we stored the unique input and output id()
s, those ids will be different now because we've actually created new instances of all of the nodes, inputs, and outputs. This is where the dict index
comes in handy:
for conn in connectorData:
f,t = conn
fromNode, fromConn = index[f]
toNode, toConn = index[t]
fromConn = fromNode.getOutputByName(fromConn)
toConn = toNode.getInputByName(toConn)
fromConn.connect(toConn)
which is to say:
for each connection pair in connectorData:
f,t = connection pair
fromNode, fromConn = the node reference and label stored at the location "f" (an output id) of the dict "index"
toNode, toConn = the node reference and label stored at the location "t" (an input id) of the dict "index"
fromConn = the NEW output id of the output in question, which we get from the node using getOutputByName("label name")
toConn = the NEW input id of the input in question, which we get from node.getInputByName("label name")
connect the output to the input
Player
The file App/player.py
contains all of the code necessary to run an ARIEL sketch. You may notice that the class names in player.py
are identical to those in node.py
; this is part of the compiler mechanism that translates a sketch into a working piece of code.
For every node.Node
class, there is a player.Node
class. Whereas node.Node
represents the user interface for the environment that allows you to do the actual programming, player.Node
is a representation of the functionality of the Node.
When player.py
is run, another openFrameworks app is built and instantiated. The ArielApp
at the bottom of player.py
is the framework for the app: it contains the setup
, update
, and draw
functions, as well as the event callbacks. By the time the script reaches ArielApp.setup()
, however, most of the work has been done by the compiler (documented below).
Each player.Node
contains compute()
, update()
, and draw()
functions. compute()
is where the work is done; update()
tells the node's outputs to push their new values to whatever inputs they are connected to; and draw()
is exposed for nodes that draw to the screen.
When an app is exported, the order of the nodes is preserved. This order is determined by when each node was created; the first node created in the environment will be the first in the list (unless Order Edit Mode has been used). The update and draw loops in player.ArielApp
reflect this order:
def update(self):
for n in self.nodes:
n.compute()
n.update()
def draw(self):
...
for n in self.nodes:
n.draw()
There can be some weirdness if nodes further down the chain get updated before their 'parents' do; unfortunately, this is often the case. It often presents itself as 'lag', where images or numbers take longer than normal to respond to interaction. When doing time-sensitive calculations with numbers that involve many nodes, this problem can seriously affect performance. Order Edit Mode was designed to deal with this issue.
To see more on how nodes are structured, look at the Creating New Nodes page.
Compiler
The compiler's job is to take an ARIEL sketch that has been cPickled (using the builder.py/ArielBuilder.loadGraph()
function discussed earlier) and turn it into a functional application.
player.py
must be run from the command line with a filename as an argument, i.e. python/bin/python player.py scene.ariel
. When hitting the 'Run' button from ARIEL Builder, builder.exportAndRunGraph()
saves the current sketch as scene.ariel
and tells Terminal to run player.py
with the argument scene.ariel
:
def runGraph(self,fname):
args = ""
if self.fullscreen.currentChoice == 1:
args += " fullscreen"
if os.system(sys.executable+" player.py "+fname+args):
gui.doPopupMessage("Scene exited abnormally.","Check the Terminal window for additional error information.")
Calling this terminal command within builder.py
results in a blocking operation, i.e. running the sketch will prevent anything from taking place in the builder environment until the sketch is either closed or fails. This saves a ton of processing power for use running the sketch but has the unfortunate side effect of blocking the user from interacting with the environment– a source of confusion for some.
Upon running player.py
with a filename argument, the file is de-pickled into two arrays: nodes
and connections
. Skip past the concrete class declarations to the line import node
, where the compiler gets started:
import node
for obj in dir(node):
c = getattr(node, obj)
try:
if issubclass(c,node.NodeInput):
exec "arielplugin."+c.__name__+ " = NodeInput"
if issubclass(c,node.NodeOutput):
exec "arielplugin."+c.__name__+ " = NodeOutput"
except:
pass
for f in os.listdir("User Nodes"):
if f[-3:] != ".py":
continue
sys.path.append("User Nodes")
module = f[:-3]
exec "import "+module
exec "mod = "+module
if not eval("hasattr("+module+", 'arielplugin')"):
continue
for o in dir(mod):
c = getattr(mod,o)
if hasattr(c,"ARIELCLASS"):
exec o+" = c"
else:
pass
This section goes through each NodeInput
and NodeOutput
subclass, adds it to a module called arielplugin
from arielplugin.py
, and stores it so that User Nodes can translate their special input and output types to the player.NodeInput
and player.NodeOutput
types. This piece of code is only useful when User Nodes are implemented in an ARIEL sketch.
On to the nodes themselves:
for n in nodes:
classname = string.splitfields(string.split(n[0])[1],".")[1]
newNode = eval(classname + "()")
...
For each node in our list, get the classname (without the node.
prefix) and use eval to instantiate a node with it. This is the magic: when eval(classname + "()")
is run, the class being instantiated is a player.Node
as opposed to a node.Node
class, making a translation between player.py
and node.py
. This is why the classnames must be the same across player.py
and node.py
.
...
inputs = n[1]
outputs = n[2]
params = n[3]
for i in inputs:
label, number = i
c = newNode.getInput(label)
c.id = number
for o in outputs:
label, number = o
c = newNode.getOutput(label)
c.id = number
for key in params.keys():
cmd = "newNode."+key+ " = " + `params[key]`
exec(cmd)
activeNodes.append(newNode)
Since the nodes have already been instantiated, the NodeInput
and NodeOutput
objects within Node.__init__()
have already been created. Getting a reference to them is a matter of calling newNode.getInput(label)
, which allows this operation to give them an id
. This id
will be used to create the connections (much like in node.py/ArielBuilder.loadGraph()
).
The final section turns node.Node
parameters into player.Node
class variables. For each parameter found in the params
dict the key is turned into a variable using cmd = "newNode." + key + " = " + `params[key]`
. This means that if a node.Node
parameter for "number" is saved (using Node.setParameterDict()
) as d["number"]
, it will show up as a variable in the player version of the node, e.g. player.Node.number
.
Finally, each node is appended to activeNodes
, which is the node list that will be given to the ArielApp
class.
A simple operation turns the connection information from the original file into actual connections between nodes:
for c in connections:
f = None
t = None
for a in activeNodes:
f = a.getOutputByID(c[0])
if f:
break
for a in activeNodes:
t = a.getInputByID(c[1])
if t:
break
f.connection = t
Here the id
from each connection is used to get a reference to the newly created NodeInput
and NodeOutput
. The output stores a reference to the input, creating the connection necessary to pass information along.
Now the activeNodes list contains all of the Node
instances with their connections mapped. These become self.nodes
in ArielApp.setup()
, at which point several for
loops check for special cases that might require additional linking.
These special cases include the 'variable' system in Builder: the nodes 'get var' and 'set var' allow you to store a number without using cords. In order to implement this, the nodes store special references to each other that must be established during setup. ArielApp.routeSettersToGetters
takes care of this.
At the time of this writing, the only other objects that require special setup in ArielApp
are player.MouseInput
and player.GUIButton
, which are passed mouse events within ArielApp
's mouse event handlers, and player.keyEvent
, which must be passed the key events from ArielApp.keyPressed
.
Advanced Topics
Order Edit Mode
Order Edit Mode, available in the Edit menu of ARIEL Builder, was designed to deal with the order issue that arose from the way ARIEL Builder stores its list of nodes. When nodes are created, they are added to the list of objects maintained by gui.PSGObjectManager
. The first node created is the first node in the list, etc. However, users rarely create nodes in the top-down order that they will be arranged in once they are connected to each other. Since player.py
runs the sketch using this order, it can cause lag and, in serious cases, faulty data and calculations.
When a user enters Order Edit Mode, their sketch 'freezes' and an orange circle appears next to each node with a number inside it. The number corresponds to the node's spot in the list starting with zero. If the user clicks a circle, types in a new number, and hits return, the node will now occupy that spot in the list.
If a node was in position #19 and the user types in '10' and hits return, that node will become #10 and the nodes that were formerly 10-18 will shift to become 11-19. Users are recommended to start from 0 to avoid unintentionally shifting groups of nodes around.
This mode is powered by the builder.TagController
class. Each time the 'Order Edit Mode' menu item is selected, the function builder.ArielBuiler.showOrdering()
is executed, which creates a new TagController in the variable ArielBuilder.tagController
.
Mouse and key events are forwarded to the tag controller in this rather chunky manner:
def mouseMoved(self, *args):
if self.tagController == None:
apply(gui.manager.handleMouse,args)
apply(gui.manager.palletScrollBar.mouseMoved,args)
else:
apply(self.tagController.mouseMoved, args)
The switch in placement is performed using TagController.requestSwap(old_pos, new_pos)
.
Containers
The container system is an attempt to provide a method for users to create new nodes out of collections of existing nodes.
A Container is a node that points to an ARIEL sketch file. When the file is loaded into the node, it is searched for special nodes called ContainerInlet
, ContainerOutlet
, and ContainerTitle
. These determine how many inputs and outputs the Container has.
To create a Container, the user must create a separate ARIEL sketch that contains the desired functionality. ContainerInlet
and ContainerOutlet
nodes represent the inputs and outputs.
The container system works using the same process as Loading Sketch Data. To create the Container node in the environment, node.Container.parseFile()
unwraps a cPickled file (given as a parameter) and calls node.Container.parseExistingData()
to look for inlets, outlets, and a title.
At this point the container object is still only a representation of the file; the actual reading and 'unraveling' of the file doesn't happen until the user runs the sketch.
Within builder.ArielBuilder.exportAndRunGraph()
there is a statement that checks whether or not there are any Containers in the sketch. If there are, it runs ArielBuilder.stitchContainers()
, which in turn runs ArielBuilder.nodesFromContainer(container_data)
for each container found. This function closely resembles ArielBuilder.loadGraph()
in the way that it creates new nodes, however additional operations are necessary to stitch the nodes together.
The stitching process works by taking the outputs that were connected to the inputs of the Container object and attaching them instead to the nodes that the ContainerInlet
objects were connected to. Likewise, any Container outputs are removed and the actual node outputs are used that connect to the ContainerOutlet
objects within the container sketch.
There is a known issue with using multiple outlets; the stitching process can sometimes fail to build the connections correctly if multiple ContainerOutlet
nodes are used in the container sketch.
This process does not currently jive well with the Order Edit Mode system.
Recommendations for a New Version
...