I am trying to get my node to travel along the path of a circle, and at the same time have THAT circle travel along the path of a rectangle. Is it possible?
This is what I have so far:
void move(GamePane aThis)
{
double speed = 10;
Rectangle rectangle = new Rectangle(100, 200, 100, 500);
Circle circle = new Circle(50);
circle.setFill(Color.WHITE);
circle.setStroke(Color.BLACK);
circle.setStrokeWidth(3);
PathTransition pt = new PathTransition();
pt.setDuration(Duration.millis(1000));
pt.setPath(circle);
pt.setNode(this);
pt.setOrientation(PathTransition.OrientationType.ORTHOGONAL_TO_TANGENT);
pt.setCycleCount(Timeline.INDEFINITE);
pt.setAutoReverse(false);
pt.play();
PathTransition pt2 = new PathTransition();
pt2.setDuration(Duration.millis(1000));
pt2.setPath(rectangle);
pt2.setNode(circle);
pt2.setOrientation
(PathTransition.OrientationType.ORTHOGONAL_TO_TANGENT);
pt2.setCycleCount(Timeline.INDEFINITE);
pt2.setAutoReverse(false);
pt2.play();
}
Theoretically it should be possible to nest one transition over the other.
But there is a problem: transitions are applied over translate properties, while the node layout is not modified. This means for your case that the circle will follow the path defined by the rectangle, but your node will keep rotating over the circle's initial position.
So we need to find a way to update the circle's position at any instant, so the node could rotate over it at that position.
Based on this answer, one possible approach is using two AnimationTimer
s, and a way to interpolate the path at any instant and update the position accordingly.
The first step is converting the original path into one that only use linear elements:
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
import javafx.geometry.Point2D;
import javafx.scene.shape.ClosePath;
import javafx.scene.shape.CubicCurveTo;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javafx.scene.shape.PathElement;
import javafx.scene.shape.QuadCurveTo;
/**
*
* @author jpereda
*/
public class LinearPath {
private final Path originalPath;
public LinearPath(Path path){
this.originalPath=path;
}
public Path generateLinePath(){
/*
Generate a list of points interpolating the original path
*/
originalPath.getElements().forEach(this::getPoints);
/*
Create a path only with MoveTo,LineTo
*/
Path path = new Path(new MoveTo(list.get(0).getX(),list.get(0).getY()));
list.stream().skip(1).forEach(p->path.getElements().add(new LineTo(p.getX(),p.getY())));
path.getElements().add(new ClosePath());
return path;
}
private Point2D p0;
private List<Point2D> list;
private final int POINTS_CURVE=5;
private void getPoints(PathElement elem){
if(elem instanceof MoveTo){
list=new ArrayList<>();
p0=new Point2D(((MoveTo)elem).getX(),((MoveTo)elem).getY());
list.add(p0);
} else if(elem instanceof LineTo){
list.add(new Point2D(((LineTo)elem).getX(),((LineTo)elem).getY()));
} else if(elem instanceof CubicCurveTo){
Point2D ini = (list.size()>0?list.get(list.size()-1):p0);
IntStream.rangeClosed(1, POINTS_CURVE).forEach(i->list.add(evalCubicBezier((CubicCurveTo)elem, ini, ((double)i)/POINTS_CURVE)));
} else if(elem instanceof QuadCurveTo){
Point2D ini = (list.size()>0?list.get(list.size()-1):p0);
IntStream.rangeClosed(1, POINTS_CURVE).forEach(i->list.add(evalQuadBezier((QuadCurveTo)elem, ini, ((double)i)/POINTS_CURVE)));
} else if(elem instanceof ClosePath){
list.add(p0);
}
}
private Point2D evalCubicBezier(CubicCurveTo c, Point2D ini, double t){
Point2D p=new Point2D(Math.pow(1-t,3)*ini.getX()+
3*t*Math.pow(1-t,2)*c.getControlX1()+
3*(1-t)*t*t*c.getControlX2()+
Math.pow(t, 3)*c.getX(),
Math.pow(1-t,3)*ini.getY()+
3*t*Math.pow(1-t, 2)*c.getControlY1()+
3*(1-t)*t*t*c.getControlY2()+
Math.pow(t, 3)*c.getY());
return p;
}
private Point2D evalQuadBezier(QuadCurveTo c, Point2D ini, double t){
Point2D p=new Point2D(Math.pow(1-t,2)*ini.getX()+
2*(1-t)*t*c.getControlX()+
Math.pow(t, 2)*c.getX(),
Math.pow(1-t,2)*ini.getY()+
2*(1-t)*t*c.getControlY()+
Math.pow(t, 2)*c.getY());
return p;
}
}
Now, based on javafx.animation.PathTransition.Segment
inner class, and removing all the private or deprecated API, this class allows public interpolator
methods, with or without translation:
import java.util.ArrayList;
import javafx.geometry.Bounds;
import javafx.scene.Node;
import javafx.scene.shape.ClosePath;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
/**
* Based on javafx.animation.PathTransition
*
* @author jpereda
*/
public class PathInterpolator {
private final Path originalPath;
private final Node node;
private double totalLength = 0;
private static final int SMOOTH_ZONE = 10;
private final ArrayList<Segment> segments = new ArrayList<>();
private Segment moveToSeg = Segment.getZeroSegment();
private Segment lastSeg = Segment.getZeroSegment();
public PathInterpolator(Path path, Node node){
this.originalPath=path;
this.node=node;
calculateSegments();
}
public PathInterpolator(Shape shape, Node node){
this.originalPath=(Path)Shape.subtract(shape, new Rectangle(0,0));
this.node=node;
calculateSegments();
}
private void calculateSegments() {
segments.clear();
Path linePath = new LinearPath(originalPath).generateLinePath();
linePath.getElements().forEach(elem->{
Segment newSeg = null;
if(elem instanceof MoveTo){
moveToSeg = Segment.newMoveTo(((MoveTo)elem).getX(),((MoveTo)elem).getY(), lastSeg.accumLength);
newSeg = moveToSeg;
} else if(elem instanceof LineTo){
newSeg = Segment.newLineTo(lastSeg, ((LineTo)elem).getX(),((LineTo)elem).getY());
} else if(elem instanceof ClosePath){
newSeg = Segment.newClosePath(lastSeg, moveToSeg);
if (newSeg == null) {
lastSeg.convertToClosePath(moveToSeg);
}
}
if (newSeg != null) {
segments.add(newSeg);
lastSeg = newSeg;
}
});
totalLength = lastSeg.accumLength;
}
public void interpolate(double frac) {
interpolate(frac,0,0);
}
public void interpolate(double frac, double translateX, double translateY) {
double part = totalLength * Math.min(1, Math.max(0, frac));
int segIdx = findSegment(0, segments.size() - 1, part);
Segment seg = segments.get(segIdx);
double lengthBefore = seg.accumLength - seg.length;
double partLength = part - lengthBefore;
double ratio = partLength / seg.length;
Segment prevSeg = seg.prevSeg;
double x = prevSeg.toX + (seg.toX - prevSeg.toX) * ratio;
double y = prevSeg.toY + (seg.toY - prevSeg.toY) * ratio;
double rotateAngle = seg.rotateAngle;
// provide smooth rotation on segment bounds
double z = Math.min(SMOOTH_ZONE, seg.length / 2);
if (partLength < z && !prevSeg.isMoveTo) {
//interpolate rotation to previous segment
rotateAngle = interpolateAngle(
prevSeg.rotateAngle, seg.rotateAngle,
partLength / z / 2 + 0.5F);
} else {
double dist = seg.length - partLength;
Segment nextSeg = seg.nextSeg;
if (dist < z && nextSeg != null) {
//interpolate rotation to next segment
if (!nextSeg.isMoveTo) {
rotateAngle = interpolateAngle(
seg.rotateAngle, nextSeg.rotateAngle,
(z - dist) / z / 2);
}
}
}
node.setTranslateX(x - getPivotX() + translateX);
node.setTranslateY(y - getPivotY() + translateY);
node.setRotate(rotateAngle);
}
private double getPivotX() {
final Bounds bounds = node.getLayoutBounds();
return bounds.getMinX() + bounds.getWidth()/2;
}
private double getPivotY() {
final Bounds bounds = node.getLayoutBounds();
return bounds.getMinY() + bounds.getHeight()/2;
}
/**
* Returns the index of the first segment having accumulated length
* from the path beginning, greater than {@code length}
*/
private int findSegment(int begin, int end, double length) {
// check for search termination
if (begin == end) {
// find last non-moveTo segment for given length
return segments.get(begin).isMoveTo && begin > 0
? findSegment(begin - 1, begin - 1, length)
: begin;
}
// otherwise continue binary search
int middle = begin + (end - begin) / 2;
return segments.get(middle).accumLength > length
? findSegment(begin, middle, length)
: findSegment(middle + 1, end, length);
}
/** Interpolates angle according to rate,
* with correct 0->360 and 360->0 transitions
*/
private static double interpolateAngle(double fromAngle, double toAngle, double ratio) {
double delta = toAngle - fromAngle;
if (Math.abs(delta) > 180) {
toAngle += delta > 0 ? -360 : 360;
}
return normalize(fromAngle + ratio * (toAngle - fromAngle));
}
/** Converts angle to range 0-360
*/
private static double normalize(double angle) {
while (angle > 360) {
angle -= 360;
}
while (angle < 0) {
angle += 360;
}
return angle;
}
private static class Segment {
private static final Segment zeroSegment = new Segment(true, 0, 0, 0, 0, 0);
boolean isMoveTo;
double length;
// total length from the path's beginning to the end of this segment
double accumLength;
// end point of this segment
double toX;
double toY;
// segment's rotation angle in degrees
double rotateAngle;
Segment prevSeg;
Segment nextSeg;
private Segment(boolean isMoveTo, double toX, double toY,
double length, double lengthBefore, double rotateAngle) {
this.isMoveTo = isMoveTo;
this.toX = toX;
this.toY = toY;
this.length = length;
this.accumLength = lengthBefore + length;
this.rotateAngle = rotateAngle;
}
public static Segment getZeroSegment() {
return zeroSegment;
}
public static Segment newMoveTo(double toX, double toY,
double accumLength) {
return new Segment(true, toX, toY, 0, accumLength, 0);
}
public static Segment newLineTo(Segment fromSeg, double toX, double toY) {
double deltaX = toX - fromSeg.toX;
double deltaY = toY - fromSeg.toY;
double length = Math.sqrt((deltaX * deltaX) + (deltaY * deltaY));
if ((length >= 1) || fromSeg.isMoveTo) { // filtering out flattening noise
double sign = Math.signum(deltaY == 0 ? deltaX : deltaY);
double angle = (sign * Math.acos(deltaX / length));
angle = normalize(angle / Math.PI * 180);
Segment newSeg = new Segment(false, toX, toY,
length, fromSeg.accumLength, angle);
fromSeg.nextSeg = newSeg;
newSeg.prevSeg = fromSeg;
return newSeg;
}
return null;
}
public static Segment newClosePath(Segment fromSeg, Segment moveToSeg) {
Segment newSeg = newLineTo(fromSeg, moveToSeg.toX, moveToSeg.toY);
if (newSeg != null) {
newSeg.convertToClosePath(moveToSeg);
}
return newSeg;
}
public void convertToClosePath(Segment moveToSeg) {
Segment firstLineToSeg = moveToSeg.nextSeg;
nextSeg = firstLineToSeg;
firstLineToSeg.prevSeg = this;
}
}
}
Basically, once you have a linear path, for every line it generates a Segment
. Now with the list of these segments you can call the interpolate
method to calculate the position and rotation of the node at any fraction between 0 and 1, and in the case of the second transition, update the position of the shape accordingly.
And finally you can create two AnimationTimer
s in your application:
@Override
public void start(Stage primaryStage) {
Pane root = new Pane();
Polygon poly = new Polygon( 0, 0, 30, 15, 0, 30);
poly.setFill(Color.YELLOW);
poly.setStroke(Color.RED);
root.getChildren().add(poly);
Rectangle rectangle = new Rectangle(200, 100, 100, 400);
rectangle.setFill(Color.TRANSPARENT);
rectangle.setStroke(Color.BLUE);
Circle circle = new Circle(50);
circle.setFill(Color.TRANSPARENT);
circle.setStroke(Color.RED);
circle.setStrokeWidth(3);
root.getChildren().add(rectangle);
root.getChildren().add(circle);
PathInterpolator in1=new PathInterpolator(rectangle, circle);
PathInterpolator in2=new PathInterpolator(circle, poly);
AnimationTimer timer1 = new AnimationTimer() {
@Override
public void handle(long now) {
double millis=(now/1_000_000)%10000;
in1.interpolate(millis/10000);
}
};
AnimationTimer timer2 = new AnimationTimer() {
@Override
public void handle(long now) {
double millis=(now/1_000_000)%2000;
// Interpolate over the translated circle
in2.interpolate(millis/2000,
circle.getTranslateX(),
circle.getTranslateY());
}
};
timer2.start();
timer1.start();
Scene scene = new Scene(root, 800, 600);
primaryStage.setScene(scene);
primaryStage.show();
}
Note that you can apply different speed to the animations.
This pic takes two instants of this animation.