在没有 PathTransition 的情况下沿路径移动节点

Move node along path without PathTransition

问题

我想沿着路径移动对象。 PathTransition 根据持续时间工作,但我需要在 AnimationTimer 中使用沿路径的移动。

问题

有谁知道通过 AnimationTimer 沿给定路径移动节点的方法吗?

或者如果有人有更好的想法来平滑锐边处的节点沿硬 waypoints 的旋转,这也足够了。

代码

我需要它来沿着陡峭的路径移动物体,但旋转应该有平滑的转弯。下面的代码沿着 waypoints(黑色)绘制路径。

我认为这样做的一种方法是缩短路径段(红色)而不是硬 LineTo 制作 CubicCurveTo(黄色)。

PathTransition 可以方便地沿着路径移动节点,并在边缘进行正确的旋转,但不幸的是,它只能在 Duration 基础上工作。

import java.util.ArrayList;
import java.util.List;

import javafx.animation.PathTransition;
import javafx.animation.PathTransition.OrientationType;
import javafx.animation.Transition;
import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.scene.Scene;
import javafx.scene.SnapshotParameters;
import javafx.scene.image.ImageView;
import javafx.scene.image.WritableImage;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.CubicCurveTo;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javafx.scene.shape.Polygon;
import javafx.scene.shape.StrokeType;
import javafx.stage.Stage;
import javafx.util.Duration;

/**
 * Cut a given path.
 * Black = original
 * Red = cut off
 * Yellow = smoothed using bezier curve
 */
public class Main extends Application {

    /**
     * Pixels that are cut off from start and end of the paths in order to shorten them and make the path smoother.
     */
    private double SMOOTHNESS = 30;

    @Override
    public void start(Stage primaryStage) {

        Pane root = new Pane();
        Scene scene = new Scene(root,1600,900);
        primaryStage.setScene(scene);
        primaryStage.show();


        // get waypoints for path
        List<Point2D> waypoints = getWayPoints();

        // draw a path with sharp edges
        // --------------------------------------------
        Path sharpPath = createSharpPath( waypoints);

        sharpPath.setStroke(Color.BLACK);
        sharpPath.setStrokeWidth(8);
        sharpPath.setStrokeType(StrokeType.CENTERED);   

        root.getChildren().add( sharpPath);


        // draw a path with shortened edges
        // --------------------------------------------
        Path shortenedPath = createShortenedPath(waypoints, SMOOTHNESS);

        shortenedPath.setStroke(Color.RED);
        shortenedPath.setStrokeWidth(5);
        shortenedPath.setStrokeType(StrokeType.CENTERED);   

        root.getChildren().add( shortenedPath);


        // draw a path with smooth edges
        // --------------------------------------------
        Path smoothPath = createSmoothPath(waypoints, SMOOTHNESS);

        smoothPath.setStroke(Color.YELLOW);
        smoothPath.setStrokeWidth(2);
        smoothPath.setStrokeType(StrokeType.CENTERED);  

        root.getChildren().add( smoothPath);

        // move arrow on path
        // --------------------------------------------
        ImageView arrow = createArrow(30,30);
        root.getChildren().add( arrow);

        PathTransition pt = new PathTransition( Duration.millis(10000), smoothPath);
        pt.setNode(arrow);
        pt.setAutoReverse(true);
        pt.setCycleCount( Transition.INDEFINITE);
        pt.setOrientation(OrientationType.ORTHOGONAL_TO_TANGENT);
        pt.play();

    }

    /**
     * Create a path from the waypoints
     * @param waypoints
     * @return
     */
    private Path createSharpPath( List<Point2D> waypoints) {

        Path path = new Path();

        for( Point2D point: waypoints) {
            if( path.getElements().isEmpty()) {
                path.getElements().add(new MoveTo( point.getX(), point.getY()));
            }
            else {
                path.getElements().add(new LineTo( point.getX(), point.getY()));
            }
        }

        return path;
    }

    /**
     * Create a path from the waypoints, shorten the path and create a line segment between segments
     * @param smoothness Pixels that are cut of from start and end.
     * @return
     */
    private Path createShortenedPath( List<Point2D> waypoints, double smoothness) {

        Path path = new Path();

        // waypoints to path
        Point2D prev = null;
        double x;
        double y;

        for( int i=0; i < waypoints.size(); i++) {

            Point2D curr = waypoints.get( i);

            if( i == 0) {

                path.getElements().add(new MoveTo( curr.getX(), curr.getY()));

                x = curr.getX();
                y = curr.getY();

            }
            else {

                // shorten previous path
                double distanceX = curr.getX() - prev.getX();
                double distanceY = curr.getY() - prev.getY();

                double rad = Math.atan2(distanceY,  distanceX);

                double distance = Math.sqrt( distanceX * distanceX + distanceY * distanceY);

                // cut off the paths except the last one
                if( i != waypoints.size() - 1) {
                    distance -= smoothness;
                }

                x = prev.getX() + distance * Math.cos(rad);
                y = prev.getY() + distance * Math.sin(rad);

                path.getElements().add(new LineTo( x, y));

                // shorten current path
                if( i + 1 < waypoints.size()) {

                    Point2D next = waypoints.get( i+1);

                    distanceX = next.getX() - curr.getX();
                    distanceY = next.getY() - curr.getY();

                    distance = smoothness;

                    rad = Math.atan2(distanceY,  distanceX);

                    x = curr.getX() + distance * Math.cos(rad);
                    y = curr.getY() + distance * Math.sin(rad);

                    path.getElements().add(new LineTo( x, y));
                }
            }

            prev = curr;

        }

        return path;
    }

    /**
     * Create a path from the waypoints, shorten the path and create a smoothing cubic curve segment between segments
     * @param smoothness Pixels that are cut of from start and end.
     * @return
     */
    private Path createSmoothPath( List<Point2D> waypoints, double smoothness) {

        Path smoothPath = new Path();
        smoothPath.setStroke(Color.YELLOW);
        smoothPath.setStrokeWidth(2);
        smoothPath.setStrokeType(StrokeType.CENTERED);  

        // waypoints to path
        Point2D ctrl1;
        Point2D ctrl2;
        Point2D prev = null;
        double x;
        double y;

        for( int i=0; i < waypoints.size(); i++) {

            Point2D curr = waypoints.get( i);

            if( i == 0) {

                smoothPath.getElements().add(new MoveTo( curr.getX(), curr.getY()));

                x = curr.getX();
                y = curr.getY();

            }
            else {

                // shorten previous path
                double distanceX = curr.getX() - prev.getX();
                double distanceY = curr.getY() - prev.getY();

                double rad = Math.atan2(distanceY,  distanceX);

                double distance = Math.sqrt( distanceX * distanceX + distanceY * distanceY);

                // cut off the paths except the last one
                if( i != waypoints.size() - 1) {
                    distance -= smoothness;
                }
                // System.out.println( "Segment " + i + ", angle: " + Math.toDegrees( rad) + ", distance: " + distance);

                x = prev.getX() + distance * Math.cos(rad);
                y = prev.getY() + distance * Math.sin(rad);

                smoothPath.getElements().add(new LineTo( x, y));

                // shorten current path and add a smoothing segment to it
                if( i + 1 < waypoints.size()) {

                    Point2D next = waypoints.get( i+1);

                    distanceX = next.getX() - curr.getX();
                    distanceY = next.getY() - curr.getY();

                    distance = smoothness;

                    rad = Math.atan2(distanceY,  distanceX);

                    x = curr.getX() + distance * Math.cos(rad);
                    y = curr.getY() + distance * Math.sin(rad);

                    ctrl1 = curr;
                    ctrl2 = curr;
                    smoothPath.getElements().add(new CubicCurveTo(ctrl1.getX(), ctrl1.getY(), ctrl2.getX(), ctrl2.getY(), x, y));
                }
            }

            prev = curr;

        }

        return smoothPath;
    }

    /**
     * Waypoints for the path
     * @return
     */
    public List<Point2D> getWayPoints() {
        List<Point2D> path = new ArrayList<>();

        // rectangle
//      path.add(new Point2D( 100, 100));
//      path.add(new Point2D( 400, 100));
//      path.add(new Point2D( 400, 400));
//      path.add(new Point2D( 100, 400));
//      path.add(new Point2D( 100, 100));


        // rectangle with peak on right
        path.add(new Point2D( 100, 100));
        path.add(new Point2D( 400, 100));
        path.add(new Point2D( 450, 250));
        path.add(new Point2D( 400, 400));
        path.add(new Point2D( 100, 400));
        path.add(new Point2D( 100, 100));

        return path;
    }

    /**
     * Create an arrow as ImageView 
     * @param width
     * @param height
     * @return
     */
    private ImageView createArrow( double width, double height) {

        WritableImage wi;

        Polygon arrow = new Polygon( 0, 0, width, height / 2, 0, height); // left/right lines of the arrow

        SnapshotParameters parameters = new SnapshotParameters();
        parameters.setFill(Color.TRANSPARENT); 

        wi = new WritableImage( (int) width, (int) height);
        arrow.snapshot(parameters, wi);

        return new ImageView( wi);

    }

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

非常感谢您的帮助!

PathTransition 有一个 public interpolate 方法可以在 0(开始)和 1(结束)之间的任何分数中调用,但遗憾的是它不是为用户设计的,并且只能在路径转换为运行.

时调用

如果您看一下 interpolate 的工作原理,它会使用内部 class,称为 Segment,基于路径中的线性段。

所以第一步是将您的原始路径转换为线性路径:

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

现在,基于 PathTransition.Segment class,并删除所有私有或弃用的 API,我想出了这个 class 和 public 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();
    }

    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) {
        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 = interpolate(
                    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 = interpolate(
                            seg.rotateAngle, nextSeg.rotateAngle,
                            (z - dist) / z / 2);
                }
            }
        }
        node.setTranslateX(x - getPivotX());
        node.setTranslateY(y - getPivotY());
        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 interpolate(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 之间的任何分数处的位置和旋转。

最后您可以在您的应用程序中创建一个 AnimationTimer

@Override
public void start(Stage primaryStage) {
    ...
    // move arrow on path
    // --------------------------------------------
    ImageView arrow = createArrow(30,30);
    root.getChildren().add( arrow);

    PathInterpolator interpolator=new PathInterpolator(smoothPath, arrow);

    AnimationTimer timer = new AnimationTimer() {

        @Override
        public void handle(long now) {
            double millis=(now/1_000_000)%10000;
            interpolator.interpolate(millis/10000);
        }
    };
    timer.start();
}