Monday, March 29, 2010

Shapes: Bezier Control Points & Data Models

This article explores a GUI that manipulates a shape with bezier shape segments.

The end result is the following applet. To use this applet:

  1. Click the "Create New Path" button.
  2. Click and drag in the large empty JComponent below the controls.
  3. Repeat the previous step until you like your shape.
  4. Double-click, or click "Create New Path" to leave path-creation mode.
  5. Interact with the square and circular handles in the workspace.
  6. Try fiddling with the scale and constraints.




You can download this jar (source included) here.

Data Models


The GeneralPath (or Path2D in Java 1.6) is the simplest way to represent an arbitrary shape in Java.

However for the applet above we need something a lot more specific. We need:

  • A mechanism to change curve data after it has been added.
  • A model that emphasizes a shape as a list of nodes, with optional control points before and after each node.

Also the book About Face mentions:

The closer the represented model comes to the user's mental model, the easier he will find the program to use and to understand.

In my case "the user" is actually the developer who wants to use the classes I write. I want to obscure from the developer that a PathIterator is really going to define a cubic segment #N with 3 points:

  1. The second control point for the (n-1)th point.
  2. The first control point for the nth point.
  3. The nth point.

To mimick the GUI presented in the applet I defined the CubicPath. With this class you can iterate through the data in a shape in a couple of ways:

for(int nodeIndex = 0; nodeIndex < myPath.getNodeCount(); nodeIndex++) {
myPath.getNode(nodeIndex, dest);
//do something
}

... or:
for(int pathIndex = 0; pathIndex < myPath.getPathCount(); pathIndex++) {
for(int nodeIndex = 0; nodeIndex < myPath.getNodeCount(); nodeIndex++) {
myPath.getNode(pathIndex, nodeIndex, dest);
//do something
}
}

Similarly there are methods for getNextControlForNode(nodeIndex) and getPrevControlForNode().

(In this case "next" means "defined after" and "prev" means "defined before".)

It is possible for control points to be null. In the absence of control points on either side of a segment: that segment will look like a line.

(Also, deep down the SimplifiedPathIterator is used so if possible a cubic segment will be converted to a line in the PathIterator. But this is exactly the kind of implementation detail I'm trying to bury, so pretend I didn't say anything.)

Scaling Factor


I applied the first draft of the CubicPath to a project at work, and I was surprised that one my boss's first critiques was: the handles are too sensitive. That is: in order to get certain interesting curves you have the drag the handles outside of the JComponent.

At work we've modeled cubic-based shapes before, and a long time ago (in a very different, complicated architecture) we did in fact scale the vector the control points form with the actual end point. I don't think we scaled it by very much, and I was impressed my boss immediately noticed this contrast in the two models.

So to address this problem I added the scalingFactor field in the CubicPath. (See setScaleFactor and getScaleFactor). I'd have to fumble around for the right words to mathematically/programmatically express exactly what it does: but if you play around with this feature in the applet above it should be pretty self explanatory.

It in no way affects the underlying shape; but it does affect the points returned by getNextControlForNode() and getPrevControlForNode(). This lets you adjust the "sensitivity" of the handles.

Continuous Curves


In order to maintain continuous curves: the two control points surrounding a node must be collinear. Their magnitudes can vary, but their angle needs to be the same.

To help in this area I added a couple extra methods (see the "Constraint" feature in the applet to try these out):

setNextControlForNodeFromPrev(int nodeIndex,boolean includeDistance);
setPrevControlForNodeFromNext(int nodeIndex,boolean includeDistance);

So if you just defined the previous control point for a node, you can call setNextControlForNodeFromPrev to make sure the next control point matches the previous (and vice versa).

These methods will always force the angles of the control points to match. The boolean includeDistance controls whether the magnitude is supposed to match, too.

Conclusion


Like all my blog articles: this may be revised in coming days/weeks after I first publish it. But it is my hope that this is a solid interface for designing java.awt.Shapes that will really simplify my life. My goal is to never have to delve into this business again. Hopefully if this meets your needs you can use it and save some time, too.

No comments:

Post a Comment