Sunday, January 6, 2013

Accumulating JavaFX Transforms With Xtend

I was a bit vague in my last post on JavaFX's transformation API, so I decided to write a separate post on this topic.

In JavaFX, each node in the scenegraph can be translated, rotated, scaled or sheared relative to its parent. In mathematical terms, each node maintains a transformation describing its own local coordinate system. This transformation can be defined with a matrix represented as a Transform object in JavaFX.

The JavaFX API offers some convenience methods to manipulate this transformation. E.g., you can scale a node in x-direction and translate it by 42 units in y-direction by calling
Unfortunately, these convenience methods do not accumulate as one would expect. Instead of multiplying the matrices for subsequent transformations, the only set specific entries in the matrix. As a result, translating an object before rotating yields the same result as applying these transformations in reverse order. This is mathematically wrong and does also not match the users expectations.

In my diagram editor application, I want to scroll (translate), zoom (scale) and rotate the canvas using mouse gestures. From the user's point of view it's imperative that each transformation builds on the previous state. The convenience methods don't match this usecase.

The correct solution to this problem is to multiply the transformation matrices. Unfortunately, JavaFX lacks any kind of calculation API for Transform and its subclasses.

Once again, this is where Xtend comes to our aid. An extension method in Xtend can be used to define functions for existing (closed) types, which syntactically look like being methods of the type on the caller's side. Affine is a subtype of Transform that allows its matrix entries to change. So I wrote extension methods to translate, rotate, scale and shear an existing Affine by multiplying the respective transformation matrix, e.g.
class TransformExtensions {
  def static scale(Affine it, double x, double y) {
    // left multiply a scale matrix to it
    // highly optimized as there are many zeros in the scale matrix
    mxx = x * mxx 
    xy = x * mxy 
    mxz = x * mxz
    tx = x * tx // take existing translation into account 

    myx = y * myx
    myy = y * myy
    myz = y * myz
    ty = y * ty 
Importing these as extensions
import static extension ...TransformExtensions.*
now allows to accumulate the transformations, e.g.
val diagram = scene.root
val diagramTransform = new Affine
diagram.transforms += diagramTransform

val EventHandler scrollHandler = [
  diagramTransform.translate(deltaX, deltaY)
scene.onScrollStarted = scrollHandler 
scene.onScroll = scrollHandler
scene.onScrollFinished = scrollHandler
val EventHandler rotateHandler = [
  diagramTransform.rotate(angle, sceneX, sceneY)
scene.onRotationStarted = rotateHandler
scene.onRotate = rotateHandler
scene.onRotationFinished = rotateHandler
The resulting behavior looks like the following screenshot. Note that the mouse position is the pivot for rotations and zoom. 


The same mechanism can be used to properly place labels along connections:

PS: I went for mutable transformation matrices, but the same mechnism will hold for immutable matrices. The extension methods then return a new Affine instead of modifying the receiver.


Richard said...

Hi Jan!

Actually, from an API design perspective, it is always wrong for the ordering in which properties are set to have significance. Because translateX, translateY, rotate, etc are properties, they *must not have order dependence*.

However, there is a way built into Node to do what you want. You just need to add transforms to the "transforms" list on each Node. Just use "node.getTransforms().add(...)" and put whatever transform you want into the list. The order in which the transforms are found in the list is the order in which they are multiplied together.


Unknown said...

@Richard: From the API perspective you are right. In my case the parameters are not properties. This is why I call the methods translate() instead of setTranslate().

Unfortunately, I find the documentation quite misleading. It would also be nice to document the apparently fixed order in which the transforms are performed.

In the example I am already using node.getTransforms(), but I keep it a singleton list. Aggregating the transforms in the node.getTransforms() doesn't seem to be an option in my case, as there is will be at least one new transform for each performed gesture, thus easily summing up to a huge number of them the longer the app is in use.