Writing a Plugin to Introduce a New Shape

Preliminaries

This document explains how a new shape can be realized using the plugin mechanism of Gravisto. For the following example(s) it is necessary to have Graffiti_Core and Graffiti_Editor in your classpath.

Getting Started

To ensure that the node shape developed here is selectable on a nodes attributes (.graphics.shape) in inspector you will have to change code in the constructor of org.graffiti.plugins.editcomponents.defaults.NodeShapeEditComponent.java, which is part of the org.graffiti.plugins.editcomponents.defaults plugin in the Graffiti_Plugins package. This is necessary because currently there is no mechanism in Gravisto available which allows writing a plugin for a plugin. This may change in future. The constructor of NodeShapeEditComponent should look like the following after the necessary changes:

public NodeShapeEditComponent(Displayable disp)
{
    super(disp);
    this.comboText = new String[] { "Rectangle", "Circle", "Ellipse",
        "Test Shape"};
    this.comboValue = new String[]
    {
      "org.graffiti.plugins.views.defaults.RectangleNodeShape",
      "org.graffiti.plugins.views.defaults.CircleNodeShape",
      "org.graffiti.plugins.views.defaults.EllipseNodeShape",
      "de.chris.plugins.shapes.test.TestNodeShape"
    };
    this.comboBox = new JComboBox(this.comboText);
}
      

Like for every plugin it is assumed that you provide a valid plugin description file first. The next step is creating a new plugin adapter, e.g., ShapeTestPlugin. For a shape plugin which is member of the package de.chris.plugins.shapes.test it looks similar to the following:

package de.chris.plugins.shapes.test;

import org.graffiti.plugin.EditorPluginAdapter;
import org.graffiti.plugin.view.GraffitiShape;

public class ShapeTestPlugin
    extends EditorPluginAdapter
{
    public ShapeTestPlugin()
    {
        this.shapes = new GraffitiShape[1];
        shapes[0] = new TestNodeShape();
    }
}
      

One shape plugin can contain more than one shape. Therefore the inherited member array shapes can be filled with one or more shape instances. For this every shape instance must implement the interface org.graffiti.plugin.view.NodeShape.

Writing a Shape

The following class TestNodeShape is an implementation of a shape.

package de.chris.plugins.shapes.test;

import java.awt.Rectangle;
import java.awt.geom.AffineTransform;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Line2D;
import java.awt.geom.PathIterator;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.awt.geom.RoundRectangle2D;

import org.graffiti.graphics.DimensionAttribute;
import org.graffiti.graphics.NodeGraphicAttribute;

import org.graffiti.plugin.view.NodeShape;

import org.graffiti.util.GraphicHelper;

public class TestNodeShape
    implements NodeShape
{
    protected final double DEFAULT_ARC_WH = 18;

    protected final double DEFAULT_HEIGHT = 30;

    protected final double DEFAULT_WIDTH = 30;

    protected NodeGraphicAttribute nodeAttr;

    protected RoundRectangle2D roundRect2D;

    protected RoundRectangle2D thickRoundRect2D;

    public TestNodeShape()
    {
        roundRect2D = new RoundRectangle2D.Double(0, 0, DEFAULT_WIDTH,
                DEFAULT_HEIGHT, DEFAULT_ARC_WH, DEFAULT_ARC_WH);
        thickRoundRect2D = new RoundRectangle2D.Double();
        thickRoundRect2D.setRoundRect(roundRect2D);
    }

    public Rectangle getBounds()
    {
        return thickRoundRect2D.getBounds();
    }

    public Rectangle2D getBounds2D()
    {
        return thickRoundRect2D.getBounds2D();
    }

    public Point2D getIntersection(Line2D line)
    {
        Rectangle2D rect = getRealBounds2D();
        double x = rect.getX();
        double y = rect.getY();
        double w = rect.getWidth();
        double h = rect.getHeight();
        double radius = DEFAULT_ARC_WH / 2d;

        Point2D lowerLeft = new Point2D.Double(x, (y + h) - radius);
        Point2D upperLeft = new Point2D.Double(x, y + radius);
        Point2D leftUpper = new Point2D.Double(x + radius, y);
        Point2D rightUpper = new Point2D.Double((x + w) - radius, y);
        Point2D upperRight = new Point2D.Double(x + w, y + radius);
        Point2D lowerRight = new Point2D.Double(x + w, (y + h) - radius);
        Point2D rightLower = new Point2D.Double((x + w) - radius, y + w);
        Point2D leftLower = new Point2D.Double(x + radius, y + w);

        // turn the round rectangle into 4 lines
        Line2D left = new Line2D.Double(lowerLeft, upperLeft);
        Line2D upper = new Line2D.Double(leftUpper, rightUpper);
        Line2D right = new Line2D.Double(upperRight, lowerRight);
        Line2D lower = new Line2D.Double(rightLower, leftLower);

        // testing with which line intersects with line
        // and then computing the intersection point
        if (left.intersectsLine(line))
        {
            return GraphicHelper.getIntersection(left, line);
        }
        else if (upper.intersectsLine(line))
        {
            return GraphicHelper.getIntersection(upper, line);
        }
        else if (right.intersectsLine(line))
        {
            return GraphicHelper.getIntersection(right, line);
        }
        else if (lower.intersectsLine(line))
        {
            return GraphicHelper.getIntersection(lower, line);
        }

        // intersection with upper left circle
        Ellipse2D upperLeftCircle2D = new Ellipse2D.Double(x, y,
                DEFAULT_ARC_WH, DEFAULT_ARC_WH);
        Point2D intUpperLeftCircle2D = intersectWithCircle(upperLeftCircle2D,
                line, "upper left");

        if (intUpperLeftCircle2D != null)
        {
            return intUpperLeftCircle2D;
        }

        // intersection with upper right circle
        Ellipse2D upperRightCircle2D = new Ellipse2D.Double((x + w) -
                DEFAULT_ARC_WH, y, DEFAULT_ARC_WH, DEFAULT_ARC_WH);
        Point2D intUpperRightCircle2D = intersectWithCircle(upperRightCircle2D,
                line, "upper right");

        if (intUpperRightCircle2D != null)
        {
            return intUpperRightCircle2D;
        }

        // intersection with lower left circle
        Ellipse2D lowerLeftCircle2D = new Ellipse2D.Double(x,
                (y + h) - DEFAULT_ARC_WH, DEFAULT_ARC_WH, DEFAULT_ARC_WH);
        Point2D intLowerLeftCircle2D = intersectWithCircle(lowerLeftCircle2D,
                line, "lower left");

        if (intLowerLeftCircle2D != null)
        {
            return intLowerLeftCircle2D;
        }

        // intersection with lower right circle
        Ellipse2D lowerRightCircle2D = new Ellipse2D.Double((x + w) -
                DEFAULT_ARC_WH, (y + h) - DEFAULT_ARC_WH, DEFAULT_ARC_WH,
                DEFAULT_ARC_WH);
        Point2D intLowerRightCircle2D = intersectWithCircle(lowerRightCircle2D,
                line, "lower right");

        if (intLowerRightCircle2D != null)
        {
            return intLowerRightCircle2D;
        }

        return null;
    }

    public PathIterator getPathIterator(AffineTransform at, double flatness)
    {
        return roundRect2D.getPathIterator(at, flatness);
    }

    public PathIterator getPathIterator(AffineTransform at)
    {
        return roundRect2D.getPathIterator(at);
    }

    public Rectangle2D getRealBounds2D()
    {
        Point2D coord = nodeAttr.getCoordinate().getCoordinate();

        Rectangle2D rect = getBounds2D();
        double w = rect.getWidth();
        double h = rect.getHeight();

        double realX = coord.getX() - (w / 2d);
        double realY = coord.getY() - (h / 2d);

        return new Rectangle2D.Double(realX, realY, w, h);
    }

    public void buildShape(NodeGraphicAttribute nodeAttr)
    {
        this.nodeAttr = nodeAttr;

        DimensionAttribute dim = nodeAttr.getDimension();
        double w = dim.getWidth();
        double h = dim.getHeight();

        double ft = Math.floor(nodeAttr.getFrameThickness());
        double offset = ft / 2d;
        roundRect2D.setFrame(offset, offset, w, h);

        double corrWidth = w + ft;
        double corrHeight = h + ft;

        if (Math.floor(offset) == offset)
        {
            corrWidth = w + ft + 1;
            corrHeight = h + ft + 1;
        }

        thickRoundRect2D.setFrame(0, 0, corrWidth, corrHeight);
    }

    public boolean contains(double x, double y, double w, double h)
    {
        return thickRoundRect2D.contains(x, y, w, h);
    }

    public boolean contains(double x, double y)
    {
        return thickRoundRect2D.contains(x, y);
    }

    public boolean contains(Point2D p)
    {
        return contains(p.getX(), p.getY());
    }

    public boolean contains(Rectangle2D r)
    {
        return contains(r.getX(), r.getY(), r.getWidth(), r.getHeight());
    }

    public boolean intersects(double x, double y, double w, double h)
    {
        return thickRoundRect2D.intersects(x, y, w, h);
    }

    public boolean intersects(Rectangle2D rect)
    {
        return thickRoundRect2D.intersects(rect);
    }

    private Point2D calculatePointOnLine(double u, Point2D s, Point2D t)
    {
        double diffX = t.getX() - s.getX();
        double diffY = t.getY() - s.getY();

        return new Point2D.Double(s.getX() + (u * diffX), s.getY() +
            (u * diffY));
    }

    private Point2D intersectWithCircle(Ellipse2D circle, Line2D intLine,
        String pos)
    {
        if (circle.getWidth() != circle.getHeight())
        {
            throw new IllegalArgumentException(
                "First parameter must be a circle, i.e. height and width " +
                "must be equal. Were: width=" + circle.getWidth() +
                "  height=" + circle.getHeight());
        }

        double cx = circle.getCenterX();
        double cy = circle.getCenterY();
        double radius = circle.getWidth() / 2d;
        double sx = intLine.getX1();
        double sy = intLine.getY1();
        double tx = intLine.getX2();
        double ty = intLine.getY2();

        double a = ((tx - sx) * (tx - sx)) + ((ty - sy) * (ty - sy));
        double b = 2d * (((tx - sx) * (sx - cx)) + ((ty - sy) * (sy - cy)));
        double c = ((cx * cx) + (cy * cy) + (sx * sx) + (sy * sy)) -
            (2d * ((cx * sx) + (cy * sy))) - (radius * radius);
        double discr = (b * b) - (4d * a * c);

        if (discr < 0d)
        {
            // line does not intersect
            return null;
        }
        else if (discr <= Double.MIN_VALUE) // epsilon test
        {
            // line is tangent
            double u = (-b) / (2 * a);

            Point2D res = calculatePointOnLine(u, intLine.getP1(),
                    intLine.getP2());

            if (((pos == "upper left") && (res.getX() <= cx) &&
                 (res.getY() <= cy)) ||
                ((pos == "upper right") && (res.getX() >= cx) &&
                 (res.getY() <= cy)) ||
                ((pos == "lower right") && (res.getX() >= cx) &&
                 (res.getY() >= cy)) ||
                ((pos == "lower left") && (res.getX() <= cx) &&
                 (res.getY() >= cy)))
            {
                return res;
            }
        }
        else
        {
            double discrsqr = Math.sqrt(discr);
            double u1 = (-b + discrsqr) / (2d * a); // first result
            double u2 = (-b - discrsqr) / (2d * a); // second result

            // there should be only one intersection point ...
            if ((0d <= u1) && (u1 <= 1d))
            {
                Point2D res = calculatePointOnLine(u1, intLine.getP1(),
                        intLine.getP2());

                if (((pos == "upper left") && (res.getX() <= cx) &&
                     (res.getY() <= cy)) ||
                    ((pos == "upper right") && (res.getX() >= cx) &&
                     (res.getY() <= cy)) ||
                    ((pos == "lower right") && (res.getX() >= cx) &&
                     (res.getY() >= cy)) ||
                    ((pos == "lower left") && (res.getX() <= cx) &&
                     (res.getY() >= cy)))
                {
                    return res;
                }
            }

            if ((0d <= u2) && (u2 <= 1d))
            {
                Point2D res = calculatePointOnLine(u2, intLine.getP1(),
                        intLine.getP2());

                if (((pos == "upper left") && (res.getX() <= cx) &&
                     (res.getY() <= cy)) ||
                    ((pos == "upper right") && (res.getX() >= cx) &&
                     (res.getY() <= cy)) ||
                    ((pos == "lower right") && (res.getX() >= cx) &&
                     (res.getY() >= cy)) ||
                    ((pos == "lower left") && (res.getX() <= cx) &&
                     (res.getY() >= cy)))
                {
                    return res;
                }
            }
        }

        return null;
    }
}
      

This example is a rectangle with rounded corners for illustrating nodes. Therefore it wraps a RoundRectangle2D shape of java awt stored in the member variable roundRect2D. The member thickRoundRect2D includes a frame. The constructor creates this two shapes in memory with default sizes. The both methods getBounds and getBounds2D return a bounding rectangle, the first one with integer values and the latter one in double precision. The next method getIntersection calculates the intersection point between a line and the shape. If none exists, the method returns null. To compute this point it uses the helper method getRealBounds2D which returns a bounding rectangle with coordinates transformed relatively to the view instead relatively to the node component. The rest of getIntersection is a rather technical computation of coordinates (the rounded rectangle is modeled by a rectangle and four circles in the corners). It uses the helper method intersectWithCircle to check if a line crosses a circle at the pos position of the four corners of the rectangle. intersectWithCircle uses itself a helper method calculatePointOnLine which computes a point on the uth length of a line from s to t. The two methods getPathIterator are the heart of all java.awt.Shape implementations. It returns a PathIterator that describes the shape in terms of the line and curve segments that comprise it. The flatness argument of the first method tells it how good the approximation must be. The method buildShape sets all necessary properties using the values contained within the CollectionAttribute nodeAttr like size and frame. The four contains methods test whether the given coordinates, rectangles, rectangular areas, or points are inside the boundary of the shape. The two intersect methods test whether the interior of the shape intersects the interior of the rectangular areas.

Using Shapes

In order to use the newly written shape load this plugin via the plugin manager. Afterwards select "Test Shape" in the .graphics.shape combo box of a nodes attributes in the inspector. After pressing the "Apply" button you see the result.