NodeBox v2 looks interesting

For as long as the Field project has been going we've been keeping an eye on NodeBox. Not only is it a hybrid visual programming environment for making 2d vector & bitmapped images, but it uses Python as its programming language. Field's 2d drawing tools we're started before we heard of NodeBox, but our image filtering extensions we're directly provoked and inspired by that project. But NodeBox's 2d spline code has always been well developed, well tested and well documented — so much so that we'd been thinking about ways of hacking into it to let Field users use that API as well as "ours". We like our API's but we don't think they are the last word on 2d drawing by any means.

It was going to be pretty tedious, because while Python-based, NodeBox was very much "non-pure" Python — much of it was a wrapper around Quartz (Apple's native drawing code). Thankfully, we didn't bother figuring any of this out, because the latest beta version of NodeBox has taken a very interesting and unexpected twist — it's now a Java/Jython project (just like Field) with an all new, even more hybrid (and even more interesting) interface.

Let's assume that that NodeBox's spline code remains as well developed, tested and documented as it has been even as their team goes through this complete rewrite. This can only mean one thing — it really is time to get Field's hooks into NodeBox and start tracking their beta releases.

NodeBox2, meet Field

First, let's download the beta of NodeBox from here. We're going to use the "text_fx.ndbx" example:

You have two options — you can build NodeBox from source, or you can just download one of their binaries. Building from source gets you documentation inside Field's autocomplete, but otherwise it's the same functionality.

Building NodeBox 2 from source

To make the connection to Field very easy first build NodeBox with this command:

ant build dist-mac

This will give you a nodeBox.jar file in 'dist/lib'. Using the Plugin manager in Field "add jar/directory to classpath". While you are there, why not add 'src' to Field's source code search path as well (this will give you great auto-complete and comments on NodeBox's methods):

(that's what you get when you hit command-period after typing Path().).

Just using the binary

Most straightforward is to just download a NodeBox.app from their site. Then, place this file inside the extensions directory of Field (that's probably /Applications/field.app/Contents/extensions). Restart Field.

In both cases you'll now be able to write and execute this code:

from nodebox.graphics import *
print Path()

without any error.

Low-level integration

After a few minuites of poking around NodeBox's inside using autocomplete and inspection we realize that NodeBox makes heavy use of Java 2D. This means that this code works:

# this imports the nodebox libraries that we need
from nodebox.graphics import *

_self.lines.clear()

# this is pure nodebox
np = Path()
np.ellipse(10,10,10,10)

# and this takes a nodebox path and turns it into a Field FLine
line = FLine().appendShape(np.getGeneralPath())
_self.lines.add(line)

.appendShape(...) appends a Java 2D General Path to a Field FLine. The rest of the _self.lines code should be familiar from the FLine drawing tutorials. And see below for more ways of moving between FLine and Path)

Now you can originate geometry using NodeBox routines and then integrate them with the FLine based drawing code — pretty cool.

Higher-level integration

Well, that was easy (really, with auto-completion and 'print' it took all of 5 mins). But hacking into NodeBox at this level misses a lot of the interesting part of their libraries — in particular it misses the compositional nature of their "network". While we have mixed feelings about node-based editing interfaces we certainly like node-based abstraction.

Fortunately NodeBox's architecture and design means that we can just dig right into the good stuff. Let's "rewrite" the text_scatter.ndbx example in Field/NodeBox, box by box.

As we can see from the first screenshot above we are looking at a composition consisting of four boxes (look at the bottom right panel for the network). One just makes an ellipse, that ought to be easy — in fact we can just look at the code in the bottom right panel.

In NodeBox we have:

from nodebox.graphics import Path

def cook(self):
    p = Path()
    p.ellipse(self.x, self.y, self.width, self.height)
    p.fillColor = self.fill
    if self.strokeWidth > 0:
        p.strokeColor = self.stroke
        p.strokeWidth = self.strokeWidth
    else:
        p.strokeColor = None
    return p.asGeometry()

In Field we'll strip out the optional things and have something like:

from nodebox.graphics import *
def ellipse():
    p = Path()
    p.ellipse(0,0,10,10)
    p.fillColor = Color(0,0.1,0.2,0.15)
    p.strokeColor = Color(0,0.3,0.2,0.1)
    return p.asGeometry()

For the "text path" node, NodeBox gives:

from nodebox.graphics import Path, Text

def cook(self):
    t = Text(self.text, self.x, self.y, self.width, self.height)
    t.fontName = self.font
    t.fontSize = self.size
    # valueOf requires a correct value: LEFT, CENTER, RIGHT or JUSTIFY. Anything else will
    # make it crash. If users start doing crazy things and change the alignment, at least
    # make sure you catch the error.
    try:
        t.align = Text.Align.valueOf(self.align)
    except:
        pass
    p = t.path
    p.fillColor = self.fill
    if self.strokeWidth > 0:
        p.strokeColor = self.stroke
        p.strokeWidth = self.strokeWidth
    else:
        p.strokeColor = None
    return p.asGeometry()

Again, stripped of some of the options we'll add:

def text():
    t = Text("Field", 0, 0, 0,0)
    t.fontName = "Verdana"
    t.fontSize = 200
    t.align = Text.Align.LEFT
    return t.path.asGeometry()

Scatter is pretty interesting — it samples random points inside a shape. In NodeBox:

from nodebox.graphics import Path, Point
from random import seed, uniform

def cook(self):
    seed(self.seed)
    if self.shape is None: return None
    bx, by, bw, bh = list(self.shape.bounds)
    p = Path()
    for i in xrange(self.amount):
        tries = 100
        while tries > 0:
            pt = Point(bx + uniform(0, 1) * bw, by + uniform(0, 1) * bh)
            if self.shape.contains(pt):
                break
            tries -= 1
        if tries:
            p.moveto(pt.x, pt.y)
        else:
            pass # add warning: no points found on the path

    return p.asGeometry()

In Field:

def scatter(shape):
    p = Path()
    bx, by, bw, bh = list(shape.bounds)
    for i in xrange(300):
        tries = 100
        while tries > 0:
            pt = Point(bx + uniform(0, 1) * bw, by + uniform(0, 1) * bh)
            if shape.contains(pt):
                break
            tries -= 1
        if tries:
            p.moveto(pt.x, pt.y)
        else:
            pass 

    return p.asGeometry()

What we're mainly doing here is converting def xxx(self): self.blah ... — where 'self' is some magical NodeBox object/convention into a more straightforward and transparent function call def xxx(blah):.

Finally the node that takes the ellipse and the scattered text path and puts them together. In NodeBox:

from nodebox.node import StampExpression
from nodebox.graphics import Path, Geometry, Transform

def cook(self):
    if self.shape is None: return None
    if self.template is None: return self.shape.clone()

    if self.stamp:
        stamp = StampExpression(self.node, "expr")
        
    g = Geometry()
    for i, point in enumerate(self.template.points):
        if self.stamp:
            context.put('CNUM', i)
            stamp.evaluate(context)
            self.node.stampExpressions(context)
            
        t = Transform()
        t.translate(point.x, point.y)
        newShape = t.map(self.shape)
        g.extend(newShape)
    return g

And in Field — ignoring the Stamp expression context stuff (see below):

def stamp(shape, template):
    g = Geometry()
    for i, point in enumerate(template.points):
        t = Transform()
        t.translate(point.x, point.y)
        newShape = t.map(shape)
        g.extend(newShape)
    return g

Now we need one more thing in Field — we actually need to call these functions to make some geometry. In the Beta 10 or later we can just do this:

_self.lines.clear()
_self.lines <<= stamp(ellipse(), scatter(text()))

And we'll get something like:

Voila! The heart of NodeBox inside Field. You can see in the screen shot that we've been unable to resist putting in a couple of embedded sliders directly into our code. Now, whether you like prefer typing stamp(ellipse(), scatter(text())) or connecting boxes together is up to you...

SVG import

One of the things that attracted us to NodeBox is that, unlike Field, it actually has a useable SVG importer. Rather than improving ours, why not borrow theirs? Sure enough:

from nodebox.graphics import *
import svg

_self.lines.clear()

f = file("/Users/marc/Downloads//iconic/vector/spin.svg", 'r')
s = f.read()
f.close()
g = Geometry()
paths = svg.parse(s, True)
for path in paths:
    g.add(path)

_self.lines <<= g

... works wonderfully. Better yet, this code is, like the example above, ripped right out of the NodeBox "import" node source visible right inside the NodeBox UI itself.

Because it's short, let's look at that example line by line:

  • from nodebox.graphics import * — NodeBox defines the bulk of it's Java classes in the nodebox.graphics package. We just import them wholesale into Field with this statement.
  • import svg — it also defines a (Python-based) svg parser in the package 'svg'. We can see this in the NodeBox environment itself. (Some version of Jython throw a spurious exception at this point, you can ignore it).
  • _self.lines.clear() — this line should be very familiar to you if you've done any work with Field's 2d drawing system. It clears the list of lines that this box is associated with. If you miss this line out then every time you execute this box, you'll add more and more geometry to it.
  • f = file( ... ) — the next three lines are pure Python — they read the contents of the svg file into a string. I didn't even write these, this is the code that NodeBox uses in the 'importer' node.
  • g = Geometry()} — NodeBox uses Geometry instances to hold multiple Path instances.
  • paths = svg.parse(s, True)} — actually digs the paths out of the svg string.
  • _self.lines <<= g — tells Field to add to _self.lines the contents of the Geometry instance g and thus draw it.

Further Field/NodeBox integration (beta 12)

NodeBox Path objects and Field FLine objects are solving very similar problems, holding almost identical kinds of data (geometry and style information). Obviously, we prefer Field's but going between them is quite straightforward. But popular demand, some helper extensions to FLine have just been added to make this pretty effortless. We can take the above example and turn this into:

from nodebox.graphics import *
import svg
f = file("/Users/marc/Downloads/iconic/vector/spin.svg", 'r')
s = f.read()
f.close()
g = Geometry()
paths = svg.parse(s, True)
_self.lines.clear()
for path in paths:
    line = FLine()
    line.appendShape(path.getGeneralPath())

    line.filterPositions2(lambda x,y : Vector2(x,y).noise(3))
    line.fillColor = Color4(0,0,0,0.2)
    line.strokeColor = Color4(1,0,0,0.2)

    line.filterPositions2(lambda x,y : Vector2(Math.sin(x*0.04)*200+300, Math.sin(y*0.04)*200+300))

    line.derived=1
    _self.lines.add(line)

That imports our svg file and noises it up, modifies the fill and stroke color and then does some strange non-linear coordinate system transformation to it before sticking it on the screen. The key new call is the FLine(path) constructor — which makes a FLine from a NodeBox Path. FLine().appendShape(path.getGeneralPath()) also does exactly what you'd expect — produces a FLine with the contents of some path instance.

Missing

What's missing? Well, probably all kinds of things. We haven't touched their context / embedded expression framework — partly because we don't understand it, partly because we're not sure we really need it. and partly because we are a little skeptical about how it scales (you should let the first of those statements carry the most weight). We'll continue to poke around the insides of NodeBox2 as it develops.

Also, being able to directly import and instantiate NodeBox nodes in Field seems like it would be fun and easy.

In any case we're very pleased to have NodeBox join the Java/Jython party.