2

I'm new to Javafx and I'm experimenting with animations. Following this, I've created a curve with two anchor points. Moving the anchor points changes the shape of the curve. Next, I followed this to create an animation where a square follows the curve from one end point to the other. Combining those two works fine, except when I move one of the anchor points! My square keeps following the original trajectory. Any suggestions on how to fix this? I don't want to restart the animation; the square should just continue moving along its path without visible interruption.

Here's a complete working example:

import javafx.animation.PathTransition;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.scene.Cursor;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import javafx.scene.shape.*;
import javafx.stage.Stage;
import javafx.util.Duration;


public class CurveAnimation extends Application {


    public static void main(String[] args) throws Exception { launch(args); }

    @Override
    public void start(final Stage stage) throws Exception {

        //Create a curve
        CubicCurve curve = new CubicCurve();
        curve.setStartX(100);
        curve.setStartY(100);
        curve.setControlX1(150);
        curve.setControlY1(50);
        curve.setControlX2(250);
        curve.setControlY2(150);
        curve.setEndX(300);
        curve.setEndY(100);
        curve.setStroke(Color.FORESTGREEN);
        curve.setStrokeWidth(4);
        curve.setFill(Color.CORNSILK.deriveColor(0, 1.2, 1, 0.6));

        //Create anchor points at each end of the curve
        Anchor start    = new Anchor(Color.PALEGREEN, curve.startXProperty(),    curve.startYProperty());
        Anchor end      = new Anchor(Color.TOMATO,    curve.endXProperty(),      curve.endYProperty());

        //Create object that follows the curve
        Rectangle rectPath = new Rectangle (0, 0, 40, 40);
        rectPath.setArcHeight(25);
        rectPath.setArcWidth(25);
        rectPath.setFill(Color.ORANGE);

        //Create the animation
        PathTransition pathTransition = new PathTransition();
        pathTransition.setDuration(Duration.millis(2000));
        pathTransition.setPath(curve);
        pathTransition.setNode(rectPath);
        pathTransition.setOrientation(PathTransition.OrientationType.ORTHOGONAL_TO_TANGENT);
        pathTransition.setCycleCount(Timeline.INDEFINITE);
        pathTransition.setAutoReverse(true);
        pathTransition.play();

        Group root = new Group();
        root.getChildren().addAll(curve, start, end, rectPath);

        stage.setScene(new Scene( root, 400, 400, Color.ALICEBLUE));
        stage.show();
    }


    /**
     * Create draggable anchor points
     */
    class Anchor extends Circle {
        Anchor(Color color, DoubleProperty x, DoubleProperty y) {
            super(x.get(), y.get(), 10);
            setFill(color.deriveColor(1, 1, 1, 0.5));
            setStroke(color);
            setStrokeWidth(2);
            setStrokeType(StrokeType.OUTSIDE);

            x.bind(centerXProperty());
            y.bind(centerYProperty());
            enableDrag();
        }

        // make a node movable by dragging it around with the mouse.
        private void enableDrag() {
            final Delta dragDelta = new Delta();

            setOnMousePressed(mouseEvent -> {
                // record a delta distance for the drag and drop operation.
                dragDelta.x = getCenterX() - mouseEvent.getX();
                dragDelta.y = getCenterY() - mouseEvent.getY();
                getScene().setCursor(Cursor.MOVE);
            });

            setOnMouseReleased(mouseEvent -> getScene().setCursor(Cursor.HAND));

            setOnMouseDragged(mouseEvent -> {
                double newX = mouseEvent.getX() + dragDelta.x;
                if (newX > 0 && newX < getScene().getWidth()) {
                    setCenterX(newX);
                }
                double newY = mouseEvent.getY() + dragDelta.y;
                if (newY > 0 && newY < getScene().getHeight()) {
                    setCenterY(newY);
                }
            });

            setOnMouseEntered(mouseEvent -> {
                if (!mouseEvent.isPrimaryButtonDown()) {
                    getScene().setCursor(Cursor.HAND);
                }
            });

            setOnMouseExited(mouseEvent -> {
                if (!mouseEvent.isPrimaryButtonDown()) {
                    getScene().setCursor(Cursor.DEFAULT);
                }
            });
        }

        // records relative x and y co-ordinates.
        private class Delta { double x, y; }
    }
}
Community
  • 1
  • 1
Joris Kinable
  • 2,232
  • 18
  • 29

1 Answers1

4

The PathTransition apparently just copies the values from the path when you call setPath, and doesn't observe them if they change.

To do what you want, you will need to use a Transition and implement the interpolation yourself. The interpolation must take a value double t and set the translateX and translateY properties of the node so that its center is on the curve with parameter t. If you want the ORTHOGONAL_TO_TANGENT orientation, you will also need to set the rotate property of the node to the angle of the tangent of the cubic curve to the positive horizontal. By computing these in the interpolate method, you can simply refer to the current control points of the curve.

To do the computation, you need to know a bit of geometry. The point on a linear Bezier curve with control points (i.e. start and end) P0 and P1 at parameter t is given by

B(t; P0, P1) = (1-t)*P0 + t*P1

You can compute higher order Bezier curves recursively by

B(t; P0, P1, ..., Pn) = (1-t)*B(P0, P1, ..., P(n-1); t) + t*B(P1, P2, ..., Pn;t)

and just differentiate both of those to get the tangent for the linear curve (which is, from geometrical consideration, obviously just P1-P0) and for the recursive relationship:

B'(t; P0, P1) = -P0 + P1

and

B'(t; P0, P1, ..., Pn) = -B(t; P0, ..., P(n-1)) + (1-t)B'(t; P0, ..., P(n-1))
                       +  B(t; P1, ..., Pn) + tB'(t; P1, ..., Pn)

Here is this implemented in code:

import javafx.animation.Animation;
import javafx.animation.PathTransition;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Point2D;
import javafx.scene.Cursor;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.CubicCurve;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.StrokeType;
import javafx.stage.Stage;
import javafx.util.Duration;


public class CurveAnimation extends Application {


    public static void main(String[] args) throws Exception { launch(args); }

    @Override
    public void start(final Stage stage) throws Exception {

        //Create a curve
        CubicCurve curve = new CubicCurve();
        curve.setStartX(100);
        curve.setStartY(100);
        curve.setControlX1(150);
        curve.setControlY1(50);
        curve.setControlX2(250);
        curve.setControlY2(150);
        curve.setEndX(300);
        curve.setEndY(100);
        curve.setStroke(Color.FORESTGREEN);
        curve.setStrokeWidth(4);
        curve.setFill(Color.CORNSILK.deriveColor(0, 1.2, 1, 0.6));

        //Create anchor points at each end of the curve
        Anchor start    = new Anchor(Color.PALEGREEN, curve.startXProperty(),    curve.startYProperty());
        Anchor end      = new Anchor(Color.TOMATO,    curve.endXProperty(),      curve.endYProperty());



        //Create object that follows the curve
        Rectangle rectPath = new Rectangle (0, 0, 40, 40);
        rectPath.setArcHeight(25);
        rectPath.setArcWidth(25);
        rectPath.setFill(Color.ORANGE);


        Transition transition = new Transition() {

            {
                setCycleDuration(Duration.millis(2000));
            }

            @Override
            protected void interpolate(double frac) {
                Point2D start = new Point2D(curve.getStartX(), curve.getStartY());
                Point2D control1 = new Point2D(curve.getControlX1(), curve.getControlY1());
                Point2D control2 = new Point2D(curve.getControlX2(), curve.getControlY2());
                Point2D end = new Point2D(curve.getEndX(), curve.getEndY());

                Point2D center = bezier(frac, start, control1, control2, end);

                double width = rectPath.getBoundsInLocal().getWidth() ;
                double height = rectPath.getBoundsInLocal().getHeight() ;

                rectPath.setTranslateX(center.getX() - width /2);
                rectPath.setTranslateY(center.getY() - height / 2);

                Point2D tangent = bezierDeriv(frac, start, control1, control2, end);
                double angle = Math.toDegrees(Math.atan2(tangent.getY(), tangent.getX()));
                rectPath.setRotate(angle);
            }

        };

        transition.setCycleCount(Animation.INDEFINITE);
        transition.setAutoReverse(true);
        transition.play();


        Group root = new Group();
        root.getChildren().addAll(curve, start, end, rectPath);

        stage.setScene(new Scene( root, 400, 400, Color.ALICEBLUE));
        stage.show();
    }

    private Point2D bezier(double t, Point2D... points) {
        if (points.length == 2) {
            return points[0].multiply(1-t).add(points[1].multiply(t));
        }
        Point2D[] leftArray = new Point2D[points.length - 1];
        System.arraycopy(points, 0, leftArray, 0, points.length - 1);
        Point2D[] rightArray = new Point2D[points.length - 1];
        System.arraycopy(points, 1, rightArray, 0, points.length - 1);
        return bezier(t, leftArray).multiply(1-t).add(bezier(t, rightArray).multiply(t));
    }

    private Point2D bezierDeriv(double t, Point2D... points) {
        if (points.length == 2) {
            return points[1].subtract(points[0]);
        }
        Point2D[] leftArray = new Point2D[points.length - 1];
        System.arraycopy(points, 0, leftArray, 0, points.length - 1);
        Point2D[] rightArray = new Point2D[points.length - 1];
        System.arraycopy(points, 1, rightArray, 0, points.length - 1);
        return bezier(t, leftArray).multiply(-1).add(bezierDeriv(t, leftArray).multiply(1-t))
                .add(bezier(t, rightArray)).add(bezierDeriv(t, rightArray).multiply(t));
    }



    /**
     * Create draggable anchor points
     */
    class Anchor extends Circle {
        Anchor(Color color, DoubleProperty x, DoubleProperty y) {
            super(x.get(), y.get(), 10);
            setFill(color.deriveColor(1, 1, 1, 0.5));
            setStroke(color);
            setStrokeWidth(2);
            setStrokeType(StrokeType.OUTSIDE);

            x.bind(centerXProperty());
            y.bind(centerYProperty());
            enableDrag();
        }

        // make a node movable by dragging it around with the mouse.
        private void enableDrag() {
            final Delta dragDelta = new Delta();

            setOnMousePressed(mouseEvent -> {
                // record a delta distance for the drag and drop operation.
                dragDelta.x = getCenterX() - mouseEvent.getX();
                dragDelta.y = getCenterY() - mouseEvent.getY();
                getScene().setCursor(Cursor.MOVE);
            });

            setOnMouseReleased(mouseEvent -> getScene().setCursor(Cursor.HAND));

            setOnMouseDragged(mouseEvent -> {
                double newX = mouseEvent.getX() + dragDelta.x;
                if (newX > 0 && newX < getScene().getWidth()) {
                    setCenterX(newX);
                }
                double newY = mouseEvent.getY() + dragDelta.y;
                if (newY > 0 && newY < getScene().getHeight()) {
                    setCenterY(newY);
                }
            });

            setOnMouseEntered(mouseEvent -> {
                if (!mouseEvent.isPrimaryButtonDown()) {
                    getScene().setCursor(Cursor.HAND);
                }
            });

            setOnMouseExited(mouseEvent -> {
                if (!mouseEvent.isPrimaryButtonDown()) {
                    getScene().setCursor(Cursor.DEFAULT);
                }
            });
        }

        // records relative x and y co-ordinates.
        private class Delta { double x, y; }
    }
}

I don't know if it's the code, the math, or just the animation, but this is somehow deeply satisfying...

James_D
  • 201,275
  • 16
  • 291
  • 322
  • That is absolutely beautiful! Thanks a lot, this is incredibly helpful. I agree that it's quite fascinating... :) I used it to create a trajectory (Path) over a map and simulate a vehicle driving over it; I can now translate/scale the map while the vehicle remains on its trajectory. One minor issue: If you change "transition.setCycleCount(Animation.INDEFINITE);" to a finite number, the node stops. If you would drag the anchor where the node stopped, the node no longer 'sticks' to the anchor but remains at the position it was when the animation ended. – Joris Kinable Sep 04 '15 at 03:43