Вложенные PathTransitions в JavaFX

Я пытаюсь заставить свой узел путешествовать по траектории круга, и в то же время, чтобы этот круг проходил по траектории прямоугольника. Является ли это возможным?

Это то, что я до сих пор:

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();

}

1 ответ

Решение

Теоретически должно быть возможно вложить один переход поверх другого.

Но есть проблема: переходы применяются к свойствам перевода, в то время как расположение узлов не изменяется. Для вашего случая это означает, что круг будет следовать по пути, заданному прямоугольником, но ваш узел будет продолжать вращаться по начальной позиции круга.

Поэтому нам нужно найти способ обновить положение круга в любой момент, чтобы узел мог вращаться над ним в этой позиции.

Исходя из этого ответа, одним из возможных подходов является использование двух AnimationTimers, и способ интерполировать путь в любой момент и соответственно обновлять позицию.

Первым шагом является преобразование исходного пути в тот, который использует только линейные элементы:

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;
    }
}

Теперь на основе javafx.animation.PathTransition.Segment внутренний класс, и удаляя все частные или устаревшие API, этот класс позволяет interpolator методы с переводом или без него:

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;
        }

    }

}

По сути, если у вас есть линейный путь, для каждой строки он генерирует Segment, Теперь со списком этих сегментов вы можете назвать interpolate метод для вычисления положения и поворота узла в любой фракции от 0 до 1, а в случае второго перехода обновите положение формы соответствующим образом.

И, наконец, вы можете создать два AnimationTimers в вашем приложении:

@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();
}

Обратите внимание, что вы можете применять разную скорость анимации.

Эта картинка занимает две минуты этой анимации.

Вложенная анимация

Другие вопросы по тегам