1 /*
   2  * Copyright (c) 2010, 2016, Oracle and/or its affiliates. All rights reserved.
   3  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
   4  *
   5  * This code is free software; you can redistribute it and/or modify it
   6  * under the terms of the GNU General Public License version 2 only, as
   7  * published by the Free Software Foundation.  Oracle designates this
   8  * particular file as subject to the "Classpath" exception as provided
   9  * by Oracle in the LICENSE file that accompanied this code.
  10  *
  11  * This code is distributed in the hope that it will be useful, but WITHOUT
  12  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  13  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
  14  * version 2 for more details (a copy is included in the LICENSE file that
  15  * accompanied this code).
  16  *
  17  * You should have received a copy of the GNU General Public License version
  18  * 2 along with this work; if not, write to the Free Software Foundation,
  19  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
  20  *
  21  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
  22  * or visit www.oracle.com if you need additional information or have any
  23  * questions.
  24  */
  25 
  26 package javafx.animation;
  27 
  28 import javafx.beans.property.ObjectProperty;
  29 import javafx.beans.property.ObjectPropertyBase;
  30 import javafx.beans.property.SimpleObjectProperty;
  31 import javafx.scene.Node;
  32 import javafx.scene.shape.Shape;
  33 import javafx.util.Duration;
  34 
  35 import com.sun.javafx.geom.Path2D;
  36 import com.sun.javafx.geom.PathIterator;
  37 import com.sun.javafx.geom.transform.BaseTransform;
  38 import com.sun.javafx.scene.shape.ShapeHelper;
  39 import java.util.ArrayList;
  40 
  41 /**
  42  * This {@code Transition} creates a path animation that spans its
  43  * {@link #duration}. The translation along the path is done by updating the
  44  * {@code translateX} and {@code translateY} variables of the {@code node}, and
  45  * the {@code rotate} variable will get updated if {@code orientation} is set to
  46  * {@code OrientationType.ORTHOGONAL_TO_TANGENT}, at regular interval.
  47  * <p>
  48  * The animated path is defined by the outline of a shape.
  49  *
  50  * <p>
  51  * Code Segment Example:
  52  * </p>
  53  *
  54  * <pre>
  55  * <code>
  56  * import javafx.scene.shape.*;
  57  * import javafx.animation.*;
  58  *
  59  * ...
  60  *
  61  *     Rectangle rect = new Rectangle (100, 40, 100, 100);
  62  *     rect.setArcHeight(50);
  63  *     rect.setArcWidth(50);
  64  *     rect.setFill(Color.VIOLET);
  65  *
  66  *
  67  *     Path path = new Path();
  68  *     path.getElements().add (new MoveTo (0f, 50f));
  69  *     path.getElements().add (new CubicCurveTo (40f, 10f, 390f, 240f, 1904, 50f));
  70  *
  71  *     pathTransition.setDuration(Duration.millis(10000));
  72  *     pathTransition.setNode(rect);
  73  *     pathTransition.setPath(path);
  74  *     pathTransition.setOrientation(OrientationType.ORTHOGONAL_TO_TANGENT);
  75  *     pathTransition.setCycleCount(4f);
  76  *     pathTransition.setAutoReverse(true);
  77  *
  78  *     pathTransition.play();
  79  *
  80  * ...
  81  *
  82  * </code>
  83  * </pre>
  84  *
  85  * @see Transition
  86  * @see Animation
  87  *
  88  * @since JavaFX 2.0
  89  */
  90 public final class PathTransition extends Transition {
  91 
  92     /**
  93      * The target node of this {@code PathTransition}.
  94      * <p>
  95      * It is not possible to change the target {@code node} of a running
  96      * {@code PathTransition}. If the value of {@code node} is changed for a
  97      * running {@code PathTransition}, the animation has to be stopped and
  98      * started again to pick up the new value.
  99      */
 100     private ObjectProperty<Node> node;
 101     private double totalLength = 0;
 102     private final ArrayList<Segment> segments = new ArrayList<>();
 103 
 104     private static final Node DEFAULT_NODE = null;
 105     private static final int SMOOTH_ZONE = 10;
 106 
 107     public final void setNode(Node value) {
 108         if ((node != null) || (value != null /* DEFAULT_NODE */)) {
 109             nodeProperty().set(value);
 110         }
 111     }
 112 
 113     public final Node getNode() {
 114         return (node == null)? DEFAULT_NODE : node.get();
 115     }
 116 
 117     public final ObjectProperty<Node> nodeProperty() {
 118         if (node == null) {
 119             node = new SimpleObjectProperty<Node>(this, "node", DEFAULT_NODE);
 120         }
 121         return node;
 122     }
 123 
 124     private Node cachedNode;
 125 
 126     /**
 127      * The duration of this {@code Transition}.
 128      * <p>
 129      * It is not possible to change the {@code duration} of a running
 130      * {@code PathTransition}. If the value of {@code duration} is changed for a
 131      * running {@code PathTransition}, the animation has to be stopped and
 132      * started again to pick up the new value.
 133      * <p>
 134      * Note: While the unit of {@code duration} is a millisecond, the
 135      * granularity depends on the underlying operating system and will in
 136      * general be larger. For example animations on desktop systems usually run
 137      * with a maximum of 60fps which gives a granularity of ~17 ms.
 138      *
 139      * Setting duration to value lower than {@link Duration#ZERO} will result
 140      * in {@link IllegalArgumentException}.
 141      *
 142      * @defaultValue 400ms
 143      */
 144     private ObjectProperty<Duration> duration;
 145     private static final Duration DEFAULT_DURATION = Duration.millis(400);
 146 
 147     public final void setDuration(Duration value) {
 148         if ((duration != null) || (!DEFAULT_DURATION.equals(value))) {
 149             durationProperty().set(value);
 150         }
 151     }
 152 
 153     public final Duration getDuration() {
 154         return (duration == null)? DEFAULT_DURATION : duration.get();
 155     }
 156 
 157     public final ObjectProperty<Duration> durationProperty() {
 158         if (duration == null) {
 159             duration = new ObjectPropertyBase<Duration>(DEFAULT_DURATION) {
 160 
 161                 @Override
 162                 public void invalidated() {
 163                     try {
 164                         setCycleDuration(getDuration());
 165                     } catch (IllegalArgumentException e) {
 166                         if (isBound()) {
 167                             unbind();
 168                         }
 169                         set(getCycleDuration());
 170                         throw e;
 171                     }
 172                 }
 173 
 174                 @Override
 175                 public Object getBean() {
 176                     return PathTransition.this;
 177                 }
 178 
 179                 @Override
 180                 public String getName() {
 181                     return "duration";
 182                 }
 183             };
 184         }
 185         return duration;
 186     }
 187 
 188     /**
 189      * The shape on which outline the node should be animated.
 190      * <p>
 191      * It is not possible to change the {@code path} of a running
 192      * {@code PathTransition}. If the value of {@code path} is changed for a
 193      * running {@code PathTransition}, the animation has to be stopped and
 194      * started again to pick up the new value.
 195      *
 196      * @defaultValue null
 197      */
 198     private ObjectProperty<Shape> path;
 199     private static final Shape DEFAULT_PATH = null;
 200 
 201     public final void setPath(Shape value) {
 202         if ((path != null) || (value != null /* DEFAULT_PATH */)) {
 203             pathProperty().set(value);
 204         }
 205     }
 206 
 207     public final Shape getPath() {
 208         return (path == null)? DEFAULT_PATH : path.get();
 209     }
 210 
 211     public final ObjectProperty<Shape> pathProperty() {
 212         if (path == null) {
 213             path = new SimpleObjectProperty<Shape>(this, "path", DEFAULT_PATH);
 214         }
 215         return path;
 216     }
 217 
 218     /**
 219      * Specifies the upright orientation of {@code node} along the {@code path}.
 220      * @since JavaFX 2.0
 221      */
 222     public static enum OrientationType {
 223 
 224         /**
 225          * The targeted {@code node}'s rotation matrix stays unchange along the
 226          * geometric path.
 227          */
 228         NONE,
 229 
 230         /**
 231          * The targeted node's rotation matrix is set to keep {@code node}
 232          * perpendicular to the path's tangent along the geometric path.
 233          */
 234         ORTHOGONAL_TO_TANGENT
 235     }
 236 
 237     /**
 238      * Specifies the upright orientation of {@code node} along the {@code path}.
 239      * The default orientation is set to {@link OrientationType#NONE}.
 240      * <p>
 241      * It is not possible to change the {@code orientation} of a running
 242      * {@code PathTransition}. If the value of {@code orientation} is changed
 243      * for a running {@code PathTransition}, the animation has to be stopped and
 244      * started again to pick up the new value.
 245      *
 246      * @defaultValue NONE
 247      */
 248     private ObjectProperty<OrientationType> orientation;
 249     private static final OrientationType DEFAULT_ORIENTATION = OrientationType.NONE;
 250 
 251     public final void setOrientation(OrientationType value) {
 252         if ((orientation != null) || (!DEFAULT_ORIENTATION.equals(value))) {
 253             orientationProperty().set(value);
 254         }
 255     }
 256 
 257     public final OrientationType getOrientation() {
 258         return (orientation == null)? OrientationType.NONE : orientation.get();
 259     }
 260 
 261     public final ObjectProperty<OrientationType> orientationProperty() {
 262         if (orientation == null) {
 263             orientation = new SimpleObjectProperty<OrientationType>(this, "orientation", DEFAULT_ORIENTATION);
 264         }
 265         return orientation;
 266     }
 267 
 268     private boolean cachedIsNormalRequired;
 269 
 270     /**
 271      * The constructor of {@code PathTransition}.
 272      *
 273      * @param duration
 274      *            The {@link #duration} of this {@code PathTransition}
 275      * @param path
 276      *            The {@link #path} of this {@code PathTransition}
 277      * @param node
 278      *            The {@link #node} of this {@code PathTransition}
 279      */
 280     public PathTransition(Duration duration, Shape path, Node node) {
 281         setDuration(duration);
 282         setPath(path);
 283         setNode(node);
 284         setCycleDuration(duration);
 285     }
 286 
 287     /**
 288      * The constructor of {@code PathTransition}.
 289      *
 290      * @param duration
 291      *            The {@link #duration} of this {@code PathTransition}
 292      * @param path
 293      *            The {@link #path} of this {@code PathTransition}
 294      */
 295     public PathTransition(Duration duration, Shape path) {
 296         this(duration, path, null);
 297     }
 298 
 299     /**
 300      * The constructor of {@code PathTransition}.
 301      */
 302     public PathTransition() {
 303         this(DEFAULT_DURATION, null, null);
 304     }
 305 
 306     /**
 307      * {@inheritDoc}
 308      */
 309     @Override
 310     public void interpolate(double frac) {
 311         double part = totalLength * Math.min(1, Math.max(0, frac));
 312         int segIdx = findSegment(0, segments.size() - 1, part);
 313         Segment seg = segments.get(segIdx);
 314 
 315         double lengthBefore = seg.accumLength - seg.length;
 316 
 317         double partLength = part - lengthBefore;
 318 
 319         double ratio = partLength / seg.length;
 320         Segment prevSeg = seg.prevSeg;
 321         double x = prevSeg.toX + (seg.toX - prevSeg.toX) * ratio;
 322         double y = prevSeg.toY + (seg.toY - prevSeg.toY) * ratio;
 323         double rotateAngle = seg.rotateAngle;
 324 
 325         // provide smooth rotation on segment bounds
 326         double z = Math.min(SMOOTH_ZONE, seg.length / 2);
 327         if (partLength < z && !prevSeg.isMoveTo) {
 328             //interpolate rotation to previous segment
 329             rotateAngle = interpolate(
 330                     prevSeg.rotateAngle, seg.rotateAngle,
 331                     partLength / z / 2 + 0.5F);
 332         } else {
 333             double dist = seg.length - partLength;
 334             Segment nextSeg = seg.nextSeg;
 335             if (dist < z && nextSeg != null) {
 336                 //interpolate rotation to next segment
 337                 if (!nextSeg.isMoveTo) {
 338                     rotateAngle = interpolate(
 339                             seg.rotateAngle, nextSeg.rotateAngle,
 340                             (z - dist) / z / 2);
 341                 }
 342             }
 343         }
 344         cachedNode.setTranslateX(x - cachedNode.impl_getPivotX());
 345         cachedNode.setTranslateY(y - cachedNode.impl_getPivotY());
 346         // Need to handle orientation if it is requested
 347         if (cachedIsNormalRequired) {
 348             cachedNode.setRotate(rotateAngle);
 349         }
 350     }
 351 
 352     private Node getTargetNode() {
 353         final Node node = getNode();
 354         return (node != null) ? node : getParentTargetNode();
 355     }
 356 
 357     @Override
 358     boolean startable(boolean forceSync) {
 359         return super.startable(forceSync)
 360                 && (((getTargetNode() != null) && (getPath() != null) && !getPath().getLayoutBounds().isEmpty()) || (!forceSync
 361                         && (cachedNode != null)));
 362     }
 363 
 364     @Override
 365     void sync(boolean forceSync) {
 366         super.sync(forceSync);
 367         if (forceSync || (cachedNode == null)) {
 368             cachedNode = getTargetNode();
 369             recomputeSegments();
 370             cachedIsNormalRequired = getOrientation() == OrientationType.ORTHOGONAL_TO_TANGENT;
 371         }
 372     }
 373 
 374     private void recomputeSegments() {
 375         segments.clear();
 376         final Shape p = getPath();
 377         Segment moveToSeg = Segment.getZeroSegment();
 378         Segment lastSeg = Segment.getZeroSegment();
 379 
 380         float[] coords = new float[6];
 381         for (PathIterator i = ShapeHelper.configShape(p).getPathIterator(p.impl_getLeafTransform(), 1.0f); !i.isDone(); i.next()) {
 382             Segment newSeg = null;
 383             int segType = i.currentSegment(coords);
 384             double x = coords[0];
 385             double y = coords[1];
 386 
 387             switch (segType) {
 388                 case PathIterator.SEG_MOVETO:
 389                     moveToSeg = Segment.newMoveTo(x, y, lastSeg.accumLength);
 390                     newSeg = moveToSeg;
 391                     break;
 392                 case PathIterator.SEG_CLOSE:
 393                     newSeg = Segment.newClosePath(lastSeg, moveToSeg);
 394                     if (newSeg == null) {
 395                         // make the last segment to close the path
 396                         lastSeg.convertToClosePath(moveToSeg);
 397                     }
 398                     break;
 399                 case PathIterator.SEG_LINETO:
 400                     newSeg = Segment.newLineTo(lastSeg, x, y);
 401                     break;
 402             }
 403 
 404             if (newSeg != null) {
 405                 segments.add(newSeg);
 406                 lastSeg = newSeg;
 407             }
 408         }
 409         totalLength = lastSeg.accumLength;
 410     }
 411 
 412     /**
 413      * Returns the index of the first segment having accumulated length
 414      * from the path beginning, greater than {@code length}
 415      */
 416     private int findSegment(int begin, int end, double length) {
 417         // check for search termination
 418         if (begin == end) {
 419             // find last non-moveTo segment for given length
 420             return segments.get(begin).isMoveTo && begin > 0
 421                     ? findSegment(begin - 1, begin - 1, length)
 422                     : begin;
 423         }
 424         // otherwise continue binary search
 425         int middle = begin + (end - begin) / 2;
 426         return segments.get(middle).accumLength > length
 427                 ? findSegment(begin, middle, length)
 428                 : findSegment(middle + 1, end, length);
 429     }
 430 
 431 
 432     /** Interpolates angle according to rate,
 433      *  with correct 0->360 and 360->0 transitions
 434      */
 435     private static double interpolate(double fromAngle, double toAngle, double ratio) {
 436         double delta = toAngle - fromAngle;
 437         if (Math.abs(delta) > 180) {
 438             toAngle += delta > 0 ? -360 : 360;
 439         }
 440         return normalize(fromAngle + ratio * (toAngle - fromAngle));
 441     }
 442 
 443     /** Converts angle to range 0-360
 444      */
 445     private static double normalize(double angle) {
 446         while (angle > 360) {
 447             angle -= 360;
 448         }
 449         while (angle < 0) {
 450             angle += 360;
 451         }
 452         return angle;
 453     }
 454 
 455     private static class Segment {
 456 
 457         private static final Segment zeroSegment = new Segment(true, 0, 0, 0, 0, 0);
 458         boolean isMoveTo;
 459         double length;
 460         // total length from the path's beginning to the end of this segment
 461         double accumLength;
 462         // end point of this segment
 463         double toX;
 464         double toY;
 465         // segment's rotation angle in degrees
 466         double rotateAngle;
 467         Segment prevSeg;
 468         Segment nextSeg;
 469 
 470         private Segment(boolean isMoveTo, double toX, double toY,
 471                 double length, double lengthBefore, double rotateAngle) {
 472             this.isMoveTo = isMoveTo;
 473             this.toX = toX;
 474             this.toY = toY;
 475             this.length = length;
 476             this.accumLength = lengthBefore + length;
 477             this.rotateAngle = rotateAngle;
 478         }
 479 
 480         public static Segment getZeroSegment() {
 481             return zeroSegment;
 482         }
 483 
 484         public static Segment newMoveTo(double toX, double toY,
 485                 double accumLength) {
 486             return new Segment(true, toX, toY, 0, accumLength, 0);
 487         }
 488 
 489         public static Segment newLineTo(Segment fromSeg, double toX, double toY) {
 490             double deltaX = toX - fromSeg.toX;
 491             double deltaY = toY - fromSeg.toY;
 492             double length = Math.sqrt((deltaX * deltaX) + (deltaY * deltaY));
 493             if ((length >= 1) || fromSeg.isMoveTo) { // filtering out flattening noise
 494                 double sign = Math.signum(deltaY == 0 ? deltaX : deltaY);
 495                 double angle = (sign * Math.acos(deltaX / length));
 496                 angle = normalize(angle / Math.PI * 180);
 497                 Segment newSeg = new Segment(false, toX, toY,
 498                         length, fromSeg.accumLength, angle);
 499                 fromSeg.nextSeg = newSeg;
 500                 newSeg.prevSeg = fromSeg;
 501                 return newSeg;
 502             }
 503             return null;
 504         }
 505 
 506         public static Segment newClosePath(Segment fromSeg, Segment moveToSeg) {
 507             Segment newSeg = newLineTo(fromSeg, moveToSeg.toX, moveToSeg.toY);
 508             if (newSeg != null) {
 509                 newSeg.convertToClosePath(moveToSeg);
 510             }
 511             return newSeg;
 512         }
 513 
 514         public void convertToClosePath(Segment moveToSeg) {
 515             Segment firstLineToSeg = moveToSeg.nextSeg;
 516             nextSeg = firstLineToSeg;
 517             firstLineToSeg.prevSeg = this;
 518         }
 519 
 520     }
 521 
 522 }