Переместить узел по пути без PathTransition
проблема
Я хочу переместить объект по Пути. PathTransition работает с точки зрения длительности, но мне нужно использовать движение вдоль пути в AnimationTimer.
Вопрос
Кто-нибудь знает способ перемещения узла по заданному пути через AnimationTimer?
Или, если у кого-то есть лучшая идея сгладить вращение узлов на острых краях вдоль трудных путевых точек, этого также будет достаточно.
Код
Мне это нужно для перемещения объекта по острому пути, но вращение должно иметь плавные повороты. Код ниже рисует путь вдоль путевых точек (черный цвет).
Я думал, что средство сделать это будет сократить отрезки пути (красный цвет) и вместо жесткого LineTo сделать CubicCurveTo (желтый цвет).
PathTransition удобно будет перемещать узел вдоль пути с правильным вращением по краям, но, к сожалению, он работает только на основе продолжительности.
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);
}
}
Большое спасибо за помощь!
1 ответ
PathTransition
имеет общественность interpolate
метод, который может быть вызван в любой части от 0 (начало) до 1 (конец), но, к сожалению, он не предназначен для пользователя, и его можно вызывать только во время выполнения перехода по пути.
Если вы посмотрите, как interpolate
работает, он использует внутренний класс под названием 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
класс, и удалив все частные или устаревшие 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();
}
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();
}