From 8b132015bb08ba1e5ba88e7040cda3896f9fa5b2 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 8 Jan 2020 07:59:54 -0800 Subject: [PATCH] Add CatmullRomCurve and CatmullRomSpline (#47547) This adds CatmullRomCurve animation curve, and a CatmullRomSpline, which is what it uses to do interpolation. This allows us to create animation curves which can smoothly interpolate the values given to the curve. Since I've introduced a 2D spline curve, I also created a Curve2D base class for such parametric curves. --- .../lib/geometry/curves_bench.dart | 60 ++ .../lib/geometry/rrect_contains_bench.dart | 4 +- .../flutter/lib/src/animation/curves.dart | 824 +++++++++++++++++- .../flutter/test/animation/curves_test.dart | 332 +++++++ 4 files changed, 1200 insertions(+), 20 deletions(-) create mode 100644 dev/benchmarks/microbenchmarks/lib/geometry/curves_bench.dart diff --git a/dev/benchmarks/microbenchmarks/lib/geometry/curves_bench.dart b/dev/benchmarks/microbenchmarks/lib/geometry/curves_bench.dart new file mode 100644 index 0000000000..7d403200ba --- /dev/null +++ b/dev/benchmarks/microbenchmarks/lib/geometry/curves_bench.dart @@ -0,0 +1,60 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; +import 'package:flutter/animation.dart'; + +import '../common.dart'; + +const int _kNumIters = 10000; + +void _testCurve(Curve curve, {String name, String description, BenchmarkResultPrinter printer}) { + final Stopwatch watch = Stopwatch(); + print('$description benchmark...'); + watch.start(); + for (int i = 0; i < _kNumIters; i += 1) { + final double t = i / _kNumIters.toDouble(); + curve.transform(t); + } + watch.stop(); + + printer.addResult( + description: description, + value: watch.elapsedMicroseconds / _kNumIters, + unit: 'µs per iteration', + name: name, + ); +} + +void main() { + assert(false, "Don't run benchmarks in checked mode! Use 'flutter run --release'."); + final BenchmarkResultPrinter printer = BenchmarkResultPrinter(); + _testCurve( + const Cubic(0.0, 0.25, 0.5, 1.0), + name: 'cubic_animation_transform_iteration', + description: 'Cubic animation transform', + printer: printer, + ); + + final CatmullRomCurve catmullRomCurve = CatmullRomCurve(const [ + Offset(0.09, 0.99), + Offset(0.21, 0.01), + Offset(0.28, 0.99), + Offset(0.38, -0.00), + Offset(0.43, 0.99), + Offset(0.54, -0.01), + Offset(0.59, 0.98), + Offset(0.70, 0.04), + Offset(0.78, 0.98), + Offset(0.88, -0.00), + ], tension: 0.00); + _testCurve( + catmullRomCurve, + name: 'catmullrom_transform_iteration', + description: 'CatmullRomCurve animation transform', + printer: printer, + ); + + printer.printToStdout(); +} diff --git a/dev/benchmarks/microbenchmarks/lib/geometry/rrect_contains_bench.dart b/dev/benchmarks/microbenchmarks/lib/geometry/rrect_contains_bench.dart index e2fce52514..50cb41a736 100644 --- a/dev/benchmarks/microbenchmarks/lib/geometry/rrect_contains_bench.dart +++ b/dev/benchmarks/microbenchmarks/lib/geometry/rrect_contains_bench.dart @@ -14,8 +14,8 @@ void main() { print('RRect contains benchmark...'); watch.start(); for (int i = 0; i < _kNumIters; i += 1) { - final RRect outter = RRect.fromLTRBR(10, 10, 20, 20, const Radius.circular(2.0)); - outter.contains(const Offset(15, 15)); + final RRect outer = RRect.fromLTRBR(10, 10, 20, 20, const Radius.circular(2.0)); + outer.contains(const Offset(15, 15)); } watch.stop(); diff --git a/packages/flutter/lib/src/animation/curves.dart b/packages/flutter/lib/src/animation/curves.dart index 873cb4678f..9849eefa54 100644 --- a/packages/flutter/lib/src/animation/curves.dart +++ b/packages/flutter/lib/src/animation/curves.dart @@ -3,16 +3,60 @@ // found in the LICENSE file. import 'dart:math' as math; +import 'dart:ui'; import 'package:flutter/foundation.dart'; -/// An easing curve, i.e. a mapping of the unit interval to the unit interval. +/// An abstract class providing an interface for evaluating a parametric curve. +/// +/// A parametric curve transforms a parameter (hence the name) `t` along a curve +/// to the value of the curve at that value of `t`. The curve can be of +/// arbitrary dimension, but is typically a 1D, 2D, or 3D curve. +/// +/// See also: +/// +/// * [Curve], a 1D animation easing curve that starts at 0.0 and ends at 1.0. +/// * [Curve2D], a parametric curve that transforms the parameter to a 2D point. +abstract class ParametricCurve { + /// Abstract const constructor to enable subclasses to provide + /// const constructors so that they can be used in const expressions. + const ParametricCurve(); + + /// Returns the value of the curve at point `t`. + /// + /// This method asserts that t is between 0 and 1 before delegating to + /// [transformInternal]. + /// + /// It is recommended that subclasses override [transformInternal] instead of + /// this function, as the above case is already handled in the default + /// implementation of [transform], which delegates the remaining logic to + /// [transformInternal]. + T transform(double t) { + assert(t != null); + assert(t >= 0.0 && t <= 1.0, 'parametric value $t is outside of [0, 1] range.'); + return transformInternal(t); + } + + /// Returns the value of the curve at point `t`. + /// + /// The given parametric value `t` will be between 0.0 and 1.0, inclusive. + @protected + T transformInternal(double t) { + throw UnimplementedError(); + } + + @override + String toString() => '$runtimeType'; +} + +/// An parametric animation easing curve, i.e. a mapping of the unit interval to +/// the unit interval. /// /// Easing curves are used to adjust the rate of change of an animation over /// time, allowing them to speed up and slow down, rather than moving at a /// constant rate. /// -/// A curve must map t=0.0 to 0.0 and t=1.0 to 1.0. +/// A [Curve] must map t=0.0 to 0.0 and t=1.0 to 1.0. /// /// See also: /// @@ -23,8 +67,8 @@ import 'package:flutter/foundation.dart'; /// * [Animatable], for a more flexible interface that maps fractions to /// arbitrary values. @immutable -abstract class Curve { - /// Abstract const constructor. This constructor enables subclasses to provide +abstract class Curve extends ParametricCurve { + /// Abstract const constructor to enable subclasses to provide /// const constructors so that they can be used in const expressions. const Curve(); @@ -39,19 +83,12 @@ abstract class Curve { /// this function, as the above cases are already handled in the default /// implementation of [transform], which delegates the remaining logic to /// [transformInternal]. + @override double transform(double t) { - assert(t >= 0.0 && t <= 1.0); if (t == 0.0 || t == 1.0) { return t; } - return transformInternal(t); - } - - /// Returns the value of the curve at point `t`, in cases where - /// 1.0 > `t` > 0.0. - @protected - double transformInternal(double t) { - throw UnimplementedError(); + return super.transform(t); } /// Returns a new curve that is the reversed inversion of this one. @@ -67,11 +104,6 @@ abstract class Curve { /// * [ReverseAnimation], which reverses an [Animation] rather than a [Curve]. /// * [CurvedAnimation], which can take a separate curve and reverse curve. Curve get flipped => FlippedCurve(this); - - @override - String toString() { - return '$runtimeType'; - } } /// The identity map over the unit interval. @@ -200,6 +232,11 @@ class Threshold extends Curve { /// {@animation 464 192 https://flutter.github.io/assets-for-api-docs/assets/animation/curve_ease_in_out.mp4} /// /// The [Cubic] class implements third-order Bézier curves. +/// +/// See also: +/// +/// * [Curves], where many more predefined curves are available. +/// * [CatmullRomCurve], a curve which passes through specific values. class Cubic extends Curve { /// Creates a cubic curve. /// @@ -267,6 +304,757 @@ class Cubic extends Curve { } } +/// Abstract class that defines an API for evaluating 2D parametric curves. +/// +/// [Curve2D] differs from [Curve] in that the values interpolated are [Offset] +/// values instead of [double] values, hence the "2D" in the name. They both +/// take a single double `t` that has a range of 0.0 to 1.0, inclusive, as input +/// to the [transform] function . Unlike [Curve], [Curve2D] is not required to +/// map `t=0.0` and `t=1.0` to specific output values. +/// +/// The interpolated `t` value given to [transform] represents the progression +/// along the curve, but it doesn't necessarily progress at a constant velocity, so +/// incrementing `t` by, say, 0.1 might move along the curve by quite a lot at one +/// part of the curve, or hardly at all in another part of the curve, depending +/// on the definition of the curve. +/// +/// {@tool snippet --template=stateless_widget_material} +/// This example shows how to use a [Curve2D] to modify the position of a widget +/// so that it can follow an arbitrary path. +/// +/// ```dart preamble +/// // This is the path that the child will follow. It's a CatmullRomSpline so +/// // that the coordinates can be specified that it must pass through. If the +/// // tension is set to 1.0, it will linearly interpolate between those points, +/// // instead of interpolating smoothly. +/// final CatmullRomSpline path = CatmullRomSpline( +/// const [ +/// Offset(0.05, 0.75), +/// Offset(0.18, 0.23), +/// Offset(0.32, 0.04), +/// Offset(0.73, 0.5), +/// Offset(0.42, 0.74), +/// Offset(0.73, 0.01), +/// Offset(0.93, 0.93), +/// Offset(0.05, 0.75), +/// ], +/// startHandle: Offset(0.93, 0.93), +/// endHandle: Offset(0.18, 0.23), +/// tension: 0.0, +/// ); +/// +/// class FollowCurve2D extends StatefulWidget { +/// const FollowCurve2D({ +/// Key key, +/// @required this.path, +/// this.curve = Curves.easeInOut, +/// @required this.child, +/// this.duration = const Duration(seconds: 1), +/// }) : assert(path != null), +/// assert(curve != null), +/// assert(child != null), +/// assert(duration != null), +/// super(key: key); +/// +/// final Curve2D path; +/// final Curve curve; +/// final Duration duration; +/// final Widget child; +/// +/// @override +/// _FollowCurve2DState createState() => _FollowCurve2DState(); +/// } +/// +/// class _FollowCurve2DState extends State with TickerProviderStateMixin { +/// // The animation controller for this animation. +/// AnimationController controller; +/// // The animation that will be used to apply the widget's animation curve. +/// Animation animation; +/// +/// @override +/// void initState() { +/// super.initState(); +/// controller = AnimationController(duration: widget.duration, vsync: this); +/// animation = CurvedAnimation(parent: controller, curve: widget.curve); +/// // Have the controller repeat indefinitely. If you want it to "bounce" back +/// // and forth, set the reverse parameter to true. +/// controller.repeat(reverse: false); +/// controller.addListener(() => setState(() {})); +/// } +/// +/// @override +/// void dispose() { +/// super.dispose(); +/// // Always have to dispose of animation controllers when done. +/// controller.dispose(); +/// } +/// +/// @override +/// Widget build(BuildContext context) { +/// // Scale the path values to match the -1.0 to 1.0 domain of the Alignment widget. +/// final Offset position = widget.path.transform(animation.value) * 2.0 - Offset(1.0, 1.0); +/// return Align( +/// alignment: Alignment(position.dx, position.dy), +/// child: widget.child, +/// ); +/// } +/// } +/// ``` +/// +/// ```dart +/// Widget build(BuildContext context) { +/// return Container( +/// color: Colors.white, +/// alignment: Alignment.center, +/// child: FollowCurve2D( +/// path: path, +/// curve: Curves.easeInOut, +/// duration: const Duration(seconds: 3), +/// child: CircleAvatar( +/// backgroundColor: Colors.yellow, +/// child: DefaultTextStyle( +/// style: Theme.of(context).textTheme.title, +/// child: Text("B"), // Buzz, buzz! +/// ), +/// ), +/// ), +/// ); +/// } +/// ``` +/// {@end-tool} +/// +abstract class Curve2D extends ParametricCurve { + /// Abstract const constructor to enable subclasses to provide const + /// constructors so that they can be used in const expressions. + const Curve2D(); + + /// Generates a list of samples with a recursive subdivision until a tolerance + /// of `tolerance` is reached. + /// + /// Samples are generated in order. + /// + /// Samples can be used to render a curve efficiently, since the samples + /// constitute line segments which vary in size with the curvature of the + /// curve. They can also be used to quickly approximate the value of the curve + /// by searching for the desired range in X and linearly interpolating between + /// samples to obtain an approximation of Y at the desired X value. The + /// implementation of [CatmullRomCurve] uses samples for this purpose + /// internally. + /// + /// The tolerance is computed as the area of a triangle formed by a new point + /// and the preceding and following point. + /// + /// See also: + /// + /// * Luiz Henrique de Figueire's Graphics Gem on [the algorithm](http://ariel.chronotext.org/dd/defigueiredo93adaptive.pdf). + Iterable generateSamples({ + double start = 0.0, + double end = 1.0, + double tolerance = 1e-10, + }) { + // The sampling algorithm is: + // 1. Evaluate the area of the triangle (a proxy for the "flatness" of the + // curve) formed by two points and a test point. + // 2. If the area of the triangle is small enough (below tolerance), then + // the two points form the final segment. + // 3. If the area is still too large, divide the interval into two parts + // using a random subdivision point to avoid aliasing. + // 4. Recursively sample the two parts. + // + // This algorithm concentrates samples in areas of high curvature. + assert(tolerance != null); + assert(start != null); + assert(end != null); + assert(end > start); + // We want to pick a random seed that will keep the result stable if + // evaluated again, so we use the first non-generated control point. + final math.Random rand = math.Random(samplingSeed); + bool isFlat(Offset p, Offset q, Offset r) { + // Calculates the area of the triangle given by the three points. + final Offset pr = p - r; + final Offset qr = q - r; + final double z = pr.dx * qr.dy - qr.dx * pr.dy; + return (z * z) < tolerance; + } + + final Curve2DSample first = Curve2DSample(start, transform(start)); + final Curve2DSample last = Curve2DSample(end, transform(end)); + final List samples = [first]; + void sample(Curve2DSample p, Curve2DSample q, {bool forceSubdivide = false}) { + // Pick a random point somewhat near the center, which avoids aliasing + // problems with periodic curves. + final double t = p.t + (0.45 + 0.1 * rand.nextDouble()) * (q.t - p.t); + final Curve2DSample r = Curve2DSample(t, transform(t)); + + if (!forceSubdivide && isFlat(p.value, q.value, r.value)) { + samples.add(q); + } else { + sample(p, r); + sample(r, q); + } + } + // If the curve starts and ends on the same point, then we force it to + // subdivide at least once, because otherwise it will terminate immediately. + sample( + first, + last, + forceSubdivide: (first.value.dx - last.value.dx).abs() < tolerance && (first.value.dy - last.value.dy).abs() < tolerance, + ); + return samples; + } + + /// Returns a seed value used by [generateSamples] to seed a random number + /// generator to avoid sample aliasing. + /// + /// Subclasses should override this and provide a custom seed. + /// + /// The value returned should be the same each time it is called, unless the + /// curve definition changes. + @protected + int get samplingSeed => 0; + + /// Returns the parameter `t` that corresponds to the given x value of the spline. + /// + /// This will only work properly for curves which are single-valued in x + /// (where every value of `x` maps to only one value in 'y', i.e. the curve + /// does not loop or curve back over itself). For curves that are not + /// single-valued, it will return the parameter for only one of the values at + /// the given `x` location. + double findInverse(double x) { + assert(x != null); + double start = 0.0; + double end = 1.0; + double mid; + double offsetToOrigin(double pos) => x - transform(pos).dx; + // Use a binary search to find the inverse point within 1e-6, or 100 + // subdivisions, whichever comes first. + const double errorLimit = 1e-6; + int count = 100; + final double startValue = offsetToOrigin(start); + while ((end - start) / 2.0 > errorLimit && count > 0) { + mid = (end + start) / 2.0; + final double value = offsetToOrigin(mid); + if (value.sign == startValue.sign) { + start = mid; + } else { + end = mid; + } + count--; + } + return mid; + } +} + +/// A class that holds a sample of a 2D parametric curve, containing the [value] +/// (the X, Y coordinates) of the curve at the parametric value [t]. +/// +/// See also: +/// +/// * [Curve2D.generateSamples], which generates samples of this type. +/// * [Curve2D], a parametric curve that maps a double parameter to a 2D location. +class Curve2DSample { + /// A const constructor for the sample so that subclasses can be const. + /// + /// All arguments must not be null. + const Curve2DSample(this.t, this.value) : assert(t != null), assert(value != null); + + /// The parametric location of this sample point along the curve. + final double t; + + /// The value (the X, Y coordinates) of the curve at parametric value [t]. + final Offset value; + + @override + String toString() { + return '[(${value.dx.toStringAsFixed(2)}, ${value.dy.toStringAsFixed(2)}), ${t.toStringAsFixed(2)}]'; + } +} + +/// A 2D spline that passes smoothly through the given control points using a +/// centripetal Catmull-Rom spline. +/// +/// When the curve is evaluated with [transform], the output values will move +/// smoothly from one control point to the next, passing through the control +/// points. +/// +/// {@template flutter.animation.curves.catmull_rom_description} +/// Unlike most cubic splines, Catmull-Rom splines have the advantage that their +/// curves pass through the control points given to them. They are cubic +/// polynomial representations, and, in fact, Catmull-Rom splines can be +/// converted mathematically into cubic splines. This class implements a +/// "centripetal" Catmull-Rom spline. The term centripetal implies that it won't +/// form loops or self-intersections within a single segment. +/// {@endtemplate} +/// +/// See also: +/// * [Centripetal Catmull–Rom splines](https://en.wikipedia.org/wiki/Centripetal_Catmull%E2%80%93Rom_spline) +/// on Wikipedia. +/// * [Parameterization and Applications of Catmull-Rom Curves](http://faculty.cs.tamu.edu/schaefer/research/cr_cad.pdf), +/// a paper on using Catmull-Rom splines. +/// * [CatmullRomCurve], an animation curve that uses a [CatmullRomSpline] as its +/// internal representation. +class CatmullRomSpline extends Curve2D { + /// Constructs a centripetal Catmull-Rom spline curve. + /// + /// The `controlPoints` argument is a list of four or more points that + /// describe the points that the curve must pass through. + /// + /// The optional `tension` argument controls how tightly the curve approaches + /// the given `controlPoints`. It must be in the range 0.0 to 1.0, inclusive. It + /// defaults to 0.0, which provides the smoothest curve. A value of 1.0 + /// produces a linear interpolation between points. + /// + /// The optional `endHandle` and `startHandle` points are the beginning and + /// ending handle positions. If not specified, they are created automatically + /// by extending the line formed by the first and/or last line segment in the + /// `controlPoints`, respectively. The spline will not go through these handle + /// points, but they will affect the slope of the line at the beginning and + /// end of the spline. The spline will attempt to match the slope of the line + /// formed by the start or end handle and the neighboring first or last + /// control point. The default is chosen so that the slope of the line at the + /// ends matches that of the first or last line segment in the control points. + /// + /// The `tension` and `controlPoints` arguments must not be null, and the + /// `controlPoints` list must contain at least four control points to + /// interpolate. + /// + /// The internal curve data structures are lazily computed the first time + /// [transform] is called. If you would rather pre-compute the structures, + /// use [CatmullRomSpline.precompute] instead. + CatmullRomSpline( + List controlPoints, { + double tension = 0.0, + Offset startHandle, + Offset endHandle, + }) : assert(controlPoints != null), + assert(tension != null), + assert(tension <= 1.0, 'tension $tension must not be greater than 1.0.'), + assert(tension >= 0.0, 'tension $tension must not be negative.'), + assert(controlPoints.length > 3, 'There must be at least four control points to create a CatmullRomSpline.'), + _controlPoints = controlPoints, + _startHandle = startHandle, + _endHandle = endHandle, + _tension = tension, + _cubicSegments = >[]; + + /// Constructs a centripetal Catmull-Rom spline curve. + /// + /// The same as [new CatmullRomSpline], except that the internal data + /// structures are precomputed instead of being computed lazily. + CatmullRomSpline.precompute( + List controlPoints, { + double tension = 0.0, + Offset startHandle, + Offset endHandle, + }) : assert(controlPoints != null), + assert(tension != null), + assert(tension <= 1.0, 'tension $tension must not be greater than 1.0.'), + assert(tension >= 0.0, 'tension $tension must not be negative.'), + assert(controlPoints.length > 3, 'There must be at least four control points to create a CatmullRomSpline.'), + _controlPoints = null, + _startHandle = null, + _endHandle = null, + _tension = null, + _cubicSegments = _computeSegments(controlPoints, tension, startHandle: startHandle, endHandle: endHandle); + + + static List> _computeSegments( + List controlPoints, + double tension, { + Offset startHandle, + Offset endHandle, + }) { + // If not specified, select the first and last control points (which are + // handles: they are not intersected by the resulting curve) so that they + // extend the first and last segments, respectively. + startHandle ??= controlPoints[0] * 2.0 - controlPoints[1]; + endHandle ??= controlPoints.last * 2.0 - controlPoints[controlPoints.length - 2]; + final List allPoints = [ + startHandle, + ...controlPoints, + endHandle, + ]; + + // An alpha of 0.5 is what makes it a centripetal Catmull-Rom spline. A + // value of 0.0 would make it a uniform Catmull-Rom spline, and a value of + // 1.0 would make it a chordal Catmull-Rom spline. Non-centripetal values + // for alpha can give self-intersecting behavior or looping within a + // segment. + const double alpha = 0.5; + final double reverseTension = 1.0 - tension; + final List> result = >[]; + for (int i = 0; i < allPoints.length - 3; ++i) { + final List curve = [allPoints[i], allPoints[i + 1], allPoints[i + 2], allPoints[i + 3]]; + final Offset diffCurve10 = curve[1] - curve[0]; + final Offset diffCurve21 = curve[2] - curve[1]; + final Offset diffCurve32 = curve[3] - curve[2]; + final double t01 = math.pow(diffCurve10.distance, alpha).toDouble(); + final double t12 = math.pow(diffCurve21.distance, alpha).toDouble(); + final double t23 = math.pow(diffCurve32.distance, alpha).toDouble(); + + final Offset m1 = (diffCurve21 + (diffCurve10 / t01 - (curve[2] - curve[0]) / (t01 + t12)) * t12) * reverseTension; + final Offset m2 = (diffCurve21 + (diffCurve32 / t23 - (curve[3] - curve[1]) / (t12 + t23)) * t12) * reverseTension; + final Offset sumM12 = m1 + m2; + + final List segment = [ + diffCurve21 * -2.0 + sumM12, + diffCurve21 * 3.0 - m1 - sumM12, + m1, + curve[1], + ]; + result.add(segment); + } + return result; + } + + // The list of control point lists for each cubic segment of the spline. + final List> _cubicSegments; + + // This is non-empty only if the _cubicSegments are being computed lazily. + final List _controlPoints; + final Offset _startHandle; + final Offset _endHandle; + final double _tension; + + void _initializeIfNeeded() { + if (_cubicSegments.isNotEmpty) { + return; + } + _cubicSegments.addAll( + _computeSegments(_controlPoints, _tension, startHandle: _startHandle, endHandle: _endHandle), + ); + } + + @override + @protected + int get samplingSeed { + _initializeIfNeeded(); + final Offset seedPoint = _cubicSegments[0][1]; + return ((seedPoint.dx + seedPoint.dy) * 10000).round(); + } + + @override + Offset transformInternal(double t) { + _initializeIfNeeded(); + final double length = _cubicSegments.length.toDouble(); + double position; + double localT; + int index; + if (t < 1.0) { + position = t * length; + localT = position % 1.0; + index = position.floor(); + } else { + position = length; + localT = 1.0; + index = _cubicSegments.length - 1; + } + final List cubicControlPoints = _cubicSegments[index]; + final double localT2 = localT * localT; + return cubicControlPoints[0] * localT2 * localT + + cubicControlPoints[1] * localT2 + + cubicControlPoints[2] * localT + + cubicControlPoints[3]; + } +} + +/// An animation easing curve that passes smoothly through the given control +/// points using a centripetal Catmull-Rom spline. +/// +/// When this curve is evaluated with [transform], the values will interpolate +/// smoothly from one control point to the next, passing through (0.0, 0.0), the +/// given points, and then (1.0, 1.0). +/// +/// {@macro flutter.animation.curves.catmull_rom_description} +/// +/// This class uses a centripetal Catmull-Rom curve (a [CatmullRomSpline]) as +/// its internal representation. The term centripetal implies that it won't form +/// loops or self-intersections within a single segment, and corresponds to a +/// Catmull-Rom α (alpha) value of 0.5. +/// +/// See also: +/// +/// * [CatmullRomSpline], the 2D spline that this curve uses to generate its values. +/// * A Wikipedia article on [centripetal Catmull-Rom splines](https://en.wikipedia.org/wiki/Centripetal_Catmull%E2%80%93Rom_spline). +/// * [new CatmullRomCurve] for a description of the constraints put on the +/// input control points. +/// * This [paper on using Catmull-Rom splines](http://faculty.cs.tamu.edu/schaefer/research/cr_cad.pdf). +class CatmullRomCurve extends Curve { + /// Constructs a centripetal [CatmullRomCurve]. + /// + /// It takes a list of two or more points that describe the points that the + /// curve must pass through. See [controlPoints] for a description of the + /// restrictions placed on control points. In addition to the given + /// [controlPoints], the curve will begin with an implicit control point at + /// (0.0, 0.0) and end with an implicit control point at (1.0, 1.0), so that + /// the curve begins and ends at those points. + /// + /// The optional [tension] argument controls how tightly the curve approaches + /// the given `controlPoints`. It must be in the range 0.0 to 1.0, inclusive. It + /// defaults to 0.0, which provides the smoothest curve. A value of 1.0 + /// is equivalent to a linear interpolation between points. + /// + /// The internal curve data structures are lazily computed the first time + /// [transform] is called. If you would rather pre-compute the curve, use + /// [CatmullRomCurve.precompute] instead. + /// + /// All of the arguments must not be null. + /// + /// See also: + /// + /// * This [paper on using Catmull-Rom splines](http://faculty.cs.tamu.edu/schaefer/research/cr_cad.pdf). + CatmullRomCurve(this.controlPoints, {this.tension = 0.0}) + : assert(tension != null), + assert(() { + return validateControlPoints( + controlPoints, + tension: tension, + reasons: _debugAssertReasons..clear(), + ); + }(), 'control points $controlPoints could not be validated:\n ${_debugAssertReasons.join('\n ')}'), + // Pre-compute samples so that we don't have to evaluate the spline's inverse + // all the time in transformInternal. + _precomputedSamples = []; + + /// Constructs a centripetal [CatmullRomCurve]. + /// + /// Same as [new CatmullRomCurve], but it precomputes the internal curve data + /// structures for a more predictable computation load. + CatmullRomCurve.precompute(this.controlPoints, {this.tension = 0.0}) + : assert(tension != null), + assert(() { + return validateControlPoints( + controlPoints, + tension: tension, + reasons: _debugAssertReasons..clear(), + ); + }(), 'control points $controlPoints could not be validated:\n ${_debugAssertReasons.join('\n ')}'), + // Pre-compute samples so that we don't have to evaluate the spline's inverse + // all the time in transformInternal. + _precomputedSamples = _computeSamples(controlPoints, tension); + + static List _computeSamples(List controlPoints, double tension) { + return CatmullRomSpline.precompute( + // Force the first and last control points for the spline to be (0, 0) + // and (1, 1), respectively. + [Offset.zero, ...controlPoints, const Offset(1.0, 1.0)], + tension: tension, + ).generateSamples(start: 0.0, end: 1.0, tolerance: 1e-12).toList(); + } + + /// A static accumulator for assertion failures. Not used in release mode. + static final List _debugAssertReasons = []; + + // The precomputed approximation curve, so that evaluation of the curve is + // efficient. + // + // If the curve is constructed lazily, then this will be empty, and will be filled + // the first time transform is called. + final List _precomputedSamples; + + /// The control points used to create this curve. + /// + /// The `dx` value of each [Offset] in [controlPoints] represents the + /// animation value at which the curve should pass through the `dy` value of + /// the same control point. + /// + /// The [controlPoints] list must meet the following criteria: + /// + /// * The list must contain at least two points. + /// * The X value of each point must be greater than 0.0 and less then 1.0. + /// * The X values of each point must be greater than the + /// previous point's X value (i.e. monotonically increasing). The Y values + /// are not constrained. + /// * The resulting spline must be single-valued in X. That is, for each X + /// value, there must be exactly one Y value. This means that the control + /// points must not generated a spline that loops or overlaps itself. + /// + /// The static function [validateControlPoints] can be used to check that + /// these conditions are met, and will return true if they are. In debug mode, + /// it will also optionally return a list of reasons in text form. In debug + /// mode, the constructor will assert that these conditions are met and print + /// the reasons if the assert fires. + /// + /// When the curve is evaluated with [transform], the values will interpolate + /// smoothly from one control point to the next, passing through (0.0, 0.0), the + /// given control points, and (1.0, 1.0). + final List controlPoints; + + /// The "tension" of the curve. + /// + /// The `tension` attribute controls how tightly the curve approaches the + /// given [controlPoints]. It must be in the range 0.0 to 1.0, inclusive. It + /// is optional, and defaults to 0.0, which provides the smoothest curve. A + /// value of 1.0 is equivalent to a linear interpolation between control + /// points. + final double tension; + + /// Validates that a given set of control points for a [CatmullRomCurve] is + /// well-formed and will not produce a spline that self-intersects. + /// + /// This method is also used in debug mode to validate a curve to make sure + /// that it won't violate the contract for the [new CatmullRomCurve] + /// constructor. + /// + /// If in debug mode, and `reasons` is non-null, this function will fill in + /// `reasons` with descriptions of the problems encountered. The `reasons` + /// argument is ignored in release mode. + /// + /// In release mode, this function can be used to decide if a proposed + /// modification to the curve will result in a valid curve. + static bool validateControlPoints( + List controlPoints, { + double tension = 0.0, + List reasons, + }) { + assert(tension != null); + if (controlPoints == null) { + assert(() { + reasons?.add('Supplied control points cannot be null'); + return true; + }()); + return false; + } + + if (controlPoints.length < 2) { + assert(() { + reasons?.add('There must be at least two points supplied to create a valid curve.'); + return true; + }()); + return false; + } + + controlPoints = [Offset.zero, ...controlPoints, const Offset(1.0, 1.0)]; + final Offset startHandle = controlPoints[0] * 2.0 - controlPoints[1]; + final Offset endHandle = controlPoints.last * 2.0 - controlPoints[controlPoints.length - 2]; + controlPoints = [startHandle, ...controlPoints, endHandle]; + double lastX = -double.infinity; + for (int i = 0; i < controlPoints.length; ++i) { + if (i > 1 && + i < controlPoints.length - 2 && + (controlPoints[i].dx <= 0.0 || controlPoints[i].dx >= 1.0)) { + assert(() { + reasons?.add('Control points must have X values between 0.0 and 1.0, exclusive. ' + 'Point $i has an x value (${controlPoints[i].dx}) which is outside the range.'); + return true; + }()); + return false; + } + if (controlPoints[i].dx <= lastX) { + assert(() { + reasons?.add('Each X coordinate must be greater than the preceding X coordinate ' + '(i.e. must be monotonically increasing in X). Point $i has an x value of ' + '${controlPoints[i].dx}, which is not greater than $lastX'); + return true; + }()); + return false; + } + lastX = controlPoints[i].dx; + } + + bool success = true; + + // An empirical test to make sure things are single-valued in X. + lastX = -double.infinity; + const double tolerance = 1e-3; + final CatmullRomSpline testSpline = CatmullRomSpline(controlPoints, tension: tension); + final double start = testSpline.findInverse(0.0); + final double end = testSpline.findInverse(1.0); + final Iterable samplePoints = testSpline.generateSamples(start: start, end: end); + /// If the first and last points in the samples aren't at (0,0) or (1,1) + /// respectively, then the curve is multi-valued at the ends. + if (samplePoints.first.value.dy.abs() > tolerance || (1.0 - samplePoints.last.value.dy).abs() > tolerance) { + bool bail = true; + success = false; + assert(() { + reasons?.add('The curve has more than one Y value at X = ${samplePoints.first.value.dx}. ' + 'Try moving some control points further away from this value of X, or increasing ' + 'the tension.'); + // No need to keep going if we're not giving reasons. + bail = reasons == null; + return true; + }()); + if (bail) { + // If we're not in debug mode, then we want to bail immediately + // instead of checking everything else. + return false; + } + } + for (final Curve2DSample sample in samplePoints) { + final Offset point = sample.value; + final double t = sample.t; + final double x = point.dx; + if (t >= start && t <= end && (x < -1e-3 || x > 1.0 + 1e-3)) { + bool bail = true; + success = false; + assert(() { + reasons?.add('The resulting curve has an X value ($x) which is outside ' + 'the range [0.0, 1.0], inclusive.'); + // No need to keep going if we're not giving reasons. + bail = reasons == null; + return true; + }()); + if (bail) { + // If we're not in debug mode, then we want to bail immediately + // instead of checking all the segments. + return false; + } + } + if (x < lastX) { + bool bail = true; + success = false; + assert(() { + reasons?.add('The curve has more than one Y value at x = $x. Try moving ' + 'some control points further apart in X, or increasing the tension.'); + // No need to keep going if we're not giving reasons. + bail = reasons == null; + return true; + }()); + if (bail) { + // If we're not in debug mode, then we want to bail immediately + // instead of checking all the segments. + return false; + } + } + lastX = x; + } + return success; + } + + @override + double transformInternal(double t) { + // Linearly interpolate between the two closest samples generated when the + // curve was created. + if (_precomputedSamples.isEmpty) { + // Compute the samples now if we were constructed lazily. + _precomputedSamples.addAll(_computeSamples(controlPoints, tension)); + } + int start = 0; + int end = _precomputedSamples.length - 1; + int mid; + Offset value; + Offset startValue = _precomputedSamples[start].value; + Offset endValue = _precomputedSamples[end].value; + // Use a binary search to find the index of the sample point that is just + // before t. + while (end - start > 1) { + mid = (end + start) ~/ 2; + value = _precomputedSamples[mid].value; + if (t >= value.dx) { + start = mid; + startValue = value; + } else { + end = mid; + endValue = value; + } + } + + // Now interpolate between the found sample and the next one. + final double t2 = (t - startValue.dx) / (endValue.dx - startValue.dx); + return lerpDouble(startValue.dy, endValue.dy, t2); + } +} + /// A curve that is the reversed inversion of its given curve. /// /// This curve evaluates the given curve in reverse (i.e., from 1.0 to 0.0 as t diff --git a/packages/flutter/test/animation/curves_test.dart b/packages/flutter/test/animation/curves_test.dart index c4d35b90bb..9086e6e6fa 100644 --- a/packages/flutter/test/animation/curves_test.dart +++ b/packages/flutter/test/animation/curves_test.dart @@ -244,4 +244,336 @@ void main() { expect(Curves.bounceInOut.transform(1), 1); }); + test('CatmullRomSpline interpolates values properly', () { + final CatmullRomSpline curve = CatmullRomSpline( + const [ + Offset(0.0, 0.0), + Offset(0.01, 0.25), + Offset(0.2, 0.25), + Offset(0.33, 0.25), + Offset(0.5, 1.0), + Offset(0.66, 0.75), + Offset(1.0, 1.0), + ], + tension: 0.0, + startHandle: const Offset(0.0, -0.3), + endHandle: const Offset(1.3, 1.3), + ); + expect(curve.transform(0.0).dx, closeTo(0.0, 1e-6)); + expect(curve.transform(0.0).dy, closeTo(0.0, 1e-6)); + expect(curve.transform(0.25).dx, closeTo(0.0966945, 1e-6)); + expect(curve.transform(0.25).dy, closeTo(0.2626806, 1e-6)); + expect(curve.transform(0.5).dx, closeTo(0.33, 1e-6)); + expect(curve.transform(0.5).dy, closeTo(0.25, 1e-6)); + expect(curve.transform(0.75).dx, closeTo(0.570260, 1e-6)); + expect(curve.transform(0.75).dy, closeTo(0.883085, 1e-6)); + expect(curve.transform(1.0).dx, closeTo(1.0, 1e-6)); + expect(curve.transform(1.0).dy, closeTo(1.0, 1e-6)); + }); + test('CatmullRomSpline enforces contract', () { + expect(() { + CatmullRomSpline(null); + }, throwsAssertionError); + expect(() { + CatmullRomSpline(const []); + }, throwsAssertionError); + expect(() { + CatmullRomSpline(const [Offset.zero]); + }, throwsAssertionError); + expect(() { + CatmullRomSpline(const [Offset.zero, Offset.zero]); + }, throwsAssertionError); + expect(() { + CatmullRomSpline(const [Offset.zero, Offset.zero, Offset.zero]); + }, throwsAssertionError); + expect(() { + CatmullRomSpline(const [Offset.zero, Offset.zero, Offset.zero, Offset.zero], tension: -1.0); + }, throwsAssertionError); + expect(() { + CatmullRomSpline(const [Offset.zero, Offset.zero, Offset.zero, Offset.zero], tension: 2.0); + }, throwsAssertionError); + }); + test('CatmullRomSpline interpolates values properly when precomputed', () { + final CatmullRomSpline curve = CatmullRomSpline.precompute( + const [ + Offset(0.0, 0.0), + Offset(0.01, 0.25), + Offset(0.2, 0.25), + Offset(0.33, 0.25), + Offset(0.5, 1.0), + Offset(0.66, 0.75), + Offset(1.0, 1.0), + ], + tension: 0.0, + startHandle: const Offset(0.0, -0.3), + endHandle: const Offset(1.3, 1.3), + ); + expect(curve.transform(0.0).dx, closeTo(0.0, 1e-6)); + expect(curve.transform(0.0).dy, closeTo(0.0, 1e-6)); + expect(curve.transform(0.25).dx, closeTo(0.0966945, 1e-6)); + expect(curve.transform(0.25).dy, closeTo(0.2626806, 1e-6)); + expect(curve.transform(0.5).dx, closeTo(0.33, 1e-6)); + expect(curve.transform(0.5).dy, closeTo(0.25, 1e-6)); + expect(curve.transform(0.75).dx, closeTo(0.570260, 1e-6)); + expect(curve.transform(0.75).dy, closeTo(0.883085, 1e-6)); + expect(curve.transform(1.0).dx, closeTo(1.0, 1e-6)); + expect(curve.transform(1.0).dy, closeTo(1.0, 1e-6)); + }); + test('CatmullRomSpline enforces contract when precomputed', () { + expect(() { + CatmullRomSpline.precompute(null); + }, throwsAssertionError); + expect(() { + CatmullRomSpline.precompute(const []); + }, throwsAssertionError); + expect(() { + CatmullRomSpline.precompute(const [Offset.zero]); + }, throwsAssertionError); + expect(() { + CatmullRomSpline.precompute(const [Offset.zero, Offset.zero]); + }, throwsAssertionError); + expect(() { + CatmullRomSpline.precompute(const [Offset.zero, Offset.zero, Offset.zero]); + }, throwsAssertionError); + expect(() { + CatmullRomSpline.precompute(const [Offset.zero, Offset.zero, Offset.zero, Offset.zero], tension: -1.0); + }, throwsAssertionError); + expect(() { + CatmullRomSpline.precompute(const [Offset.zero, Offset.zero, Offset.zero, Offset.zero], tension: 2.0); + }, throwsAssertionError); + }); + test('CatmullRomCurve interpolates given points correctly', () { + final CatmullRomCurve curve = CatmullRomCurve( + const [ + Offset(0.2, 0.25), + Offset(0.33, 0.25), + Offset(0.5, 1.0), + Offset(0.8, 0.75), + ], + ); + + // These values are approximations. + const double tolerance = 1e-6; + expect(curve.transform(0.0), closeTo(0.0, tolerance)); + expect(curve.transform(0.01), closeTo(0.012874734350170863, tolerance)); + expect(curve.transform(0.2), closeTo(0.24989646045277542, tolerance)); + expect(curve.transform(0.33), closeTo(0.250037698527661, tolerance)); + expect(curve.transform(0.5), closeTo(0.9999057323235939, tolerance)); + expect(curve.transform(0.6), closeTo(0.9357294964536621, tolerance)); + expect(curve.transform(0.8), closeTo(0.7500423402378034, tolerance)); + expect(curve.transform(1.0), closeTo(1.0, tolerance)); + }); + test('CatmullRomCurve interpolates given points correctly when precomputed', () { + final CatmullRomCurve curve = CatmullRomCurve.precompute( + const [ + Offset(0.2, 0.25), + Offset(0.33, 0.25), + Offset(0.5, 1.0), + Offset(0.8, 0.75), + ], + ); + + // These values are approximations. + const double tolerance = 1e-6; + expect(curve.transform(0.0), closeTo(0.0, tolerance)); + expect(curve.transform(0.01), closeTo(0.012874734350170863, tolerance)); + expect(curve.transform(0.2), closeTo(0.24989646045277542, tolerance)); + expect(curve.transform(0.33), closeTo(0.250037698527661, tolerance)); + expect(curve.transform(0.5), closeTo(0.9999057323235939, tolerance)); + expect(curve.transform(0.6), closeTo(0.9357294964536621, tolerance)); + expect(curve.transform(0.8), closeTo(0.7500423402378034, tolerance)); + expect(curve.transform(1.0), closeTo(1.0, tolerance)); + }); + test('CatmullRomCurve enforces contract', () { + expect(() { + CatmullRomCurve(null); + }, throwsAssertionError); + expect(() { + CatmullRomCurve(const []); + }, throwsAssertionError); + expect(() { + CatmullRomCurve(const [Offset.zero]); + }, throwsAssertionError); + expect(() { + CatmullRomCurve(const [Offset.zero, Offset.zero]); + }, throwsAssertionError); + + // Monotonically increasing in X. + expect( + CatmullRomCurve.validateControlPoints( + const [ + Offset(0.2, 0.25), + Offset(0.01, 0.25), + ], + tension: 0.0, + ), + isFalse); + expect(() { + CatmullRomCurve( + const [ + Offset(0.2, 0.25), + Offset(0.01, 0.25), + ], + tension: 0.0, + ); + }, throwsAssertionError); + + // X within range (0.0, 1.0). + expect( + CatmullRomCurve.validateControlPoints( + const [ + Offset(0.2, 0.25), + Offset(1.01, 0.25), + ], + tension: 0.0, + ), + isFalse); + expect(() { + CatmullRomCurve( + const [ + Offset(0.2, 0.25), + Offset(1.01, 0.25), + ], + tension: 0.0, + ); + }, throwsAssertionError); + + // Not multi-valued in Y at x=0.0. + expect( + CatmullRomCurve.validateControlPoints( + const [ + Offset(0.05, 0.50), + Offset(0.50, 0.50), + Offset(0.75, 0.75), + ], + tension: 0.0, + ), + isFalse, + ); + expect(() { + CatmullRomCurve( + const [ + Offset(0.05, 0.50), + Offset(0.50, 0.50), + Offset(0.75, 0.75), + ], + tension: 0.0, + ); + }, throwsAssertionError); + + // Not multi-valued in Y at x=1.0. + expect( + CatmullRomCurve.validateControlPoints( + const [ + Offset(0.25, 0.25), + Offset(0.50, 0.50), + Offset(0.95, 0.51), + ], + tension: 0.0, + ), + isFalse, + ); + expect(() { + CatmullRomCurve( + const [ + Offset(0.25, 0.25), + Offset(0.50, 0.50), + Offset(0.95, 0.51), + ], + tension: 0.0, + ); + }, throwsAssertionError); + + // Not multi-valued in Y in between x = 0.0 and x = 1.0. + expect( + CatmullRomCurve.validateControlPoints( + const [ + Offset(0.5, 0.05), + Offset(0.5, 0.95), + ], + tension: 0.0, + ), + isFalse, + ); + expect(() { + CatmullRomCurve( + const [ + Offset(0.5, 0.05), + Offset(0.5, 0.95), + ], + tension: 0.0, + ); + }, throwsAssertionError); + }); + test('CatmullRomCurve enforces contract when precomputed', () { + expect(() { + CatmullRomCurve.precompute(null); + }, throwsAssertionError); + expect(() { + CatmullRomCurve.precompute(const []); + }, throwsAssertionError); + expect(() { + CatmullRomCurve.precompute(const [Offset.zero]); + }, throwsAssertionError); + expect(() { + CatmullRomCurve.precompute(const [Offset.zero, Offset.zero]); + }, throwsAssertionError); + + // Monotonically increasing in X. + expect(() { + CatmullRomCurve.precompute( + const [ + Offset(0.2, 0.25), + Offset(0.01, 0.25), + ], + tension: 0.0, + ); + }, throwsAssertionError); + + // X within range (0.0, 1.0). + expect(() { + CatmullRomCurve.precompute( + const [ + Offset(0.2, 0.25), + Offset(1.01, 0.25), + ], + tension: 0.0, + ); + }, throwsAssertionError); + + // Not multi-valued in Y at x=0.0. + expect(() { + CatmullRomCurve.precompute( + const [ + Offset(0.05, 0.50), + Offset(0.50, 0.50), + Offset(0.75, 0.75), + ], + tension: 0.0, + ); + }, throwsAssertionError); + + // Not multi-valued in Y at x=1.0. + expect(() { + CatmullRomCurve.precompute( + const [ + Offset(0.25, 0.25), + Offset(0.50, 0.50), + Offset(0.95, 0.51), + ], + tension: 0.0, + ); + }, throwsAssertionError); + + // Not multi-valued in Y in between x = 0.0 and x = 1.0. + expect(() { + CatmullRomCurve.precompute( + const [ + Offset(0.5, 0.05), + Offset(0.5, 0.95), + ], + tension: 0.0, + ); + }, throwsAssertionError); + }); }