Unverified Commit 8b132015 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

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.
parent 505af78a
// 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>[
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();
}
......@@ -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();
......
......@@ -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<T> {
/// 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<double> {
/// 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>[
/// 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<FollowCurve2D> with TickerProviderStateMixin {
/// // The animation controller for this animation.
/// AnimationController controller;
/// // The animation that will be used to apply the widget's animation curve.
/// Animation<double> 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<Offset> {
/// 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<Curve2DSample> 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<Curve2DSample> samples = <Curve2DSample>[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<Offset> 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 = <List<Offset>>[];
/// 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<Offset> 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<List<Offset>> _computeSegments(
List<Offset> 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<Offset> allPoints = <Offset>[
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<List<Offset>> result = <List<Offset>>[];
for (int i = 0; i < allPoints.length - 3; ++i) {
final List<Offset> curve = <Offset>[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<Offset> segment = <Offset>[
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<List<Offset>> _cubicSegments;
// This is non-empty only if the _cubicSegments are being computed lazily.
final List<Offset> _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<Offset> 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 = <Curve2DSample>[];
/// 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<Curve2DSample> _computeSamples(List<Offset> 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>[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<String> _debugAssertReasons = <String>[];
// 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<Curve2DSample> _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<Offset> 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<Offset> controlPoints, {
double tension = 0.0,
List<String> 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>[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 = <Offset>[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<Curve2DSample> 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
......
......@@ -244,4 +244,336 @@ void main() {
expect(Curves.bounceInOut.transform(1), 1);
});
test('CatmullRomSpline interpolates values properly', () {
final CatmullRomSpline curve = CatmullRomSpline(
const <Offset>[
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 <Offset>[]);
}, throwsAssertionError);
expect(() {
CatmullRomSpline(const <Offset>[Offset.zero]);
}, throwsAssertionError);
expect(() {
CatmullRomSpline(const <Offset>[Offset.zero, Offset.zero]);
}, throwsAssertionError);
expect(() {
CatmullRomSpline(const <Offset>[Offset.zero, Offset.zero, Offset.zero]);
}, throwsAssertionError);
expect(() {
CatmullRomSpline(const <Offset>[Offset.zero, Offset.zero, Offset.zero, Offset.zero], tension: -1.0);
}, throwsAssertionError);
expect(() {
CatmullRomSpline(const <Offset>[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>[
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 <Offset>[]);
}, throwsAssertionError);
expect(() {
CatmullRomSpline.precompute(const <Offset>[Offset.zero]);
}, throwsAssertionError);
expect(() {
CatmullRomSpline.precompute(const <Offset>[Offset.zero, Offset.zero]);
}, throwsAssertionError);
expect(() {
CatmullRomSpline.precompute(const <Offset>[Offset.zero, Offset.zero, Offset.zero]);
}, throwsAssertionError);
expect(() {
CatmullRomSpline.precompute(const <Offset>[Offset.zero, Offset.zero, Offset.zero, Offset.zero], tension: -1.0);
}, throwsAssertionError);
expect(() {
CatmullRomSpline.precompute(const <Offset>[Offset.zero, Offset.zero, Offset.zero, Offset.zero], tension: 2.0);
}, throwsAssertionError);
});
test('CatmullRomCurve interpolates given points correctly', () {
final CatmullRomCurve curve = CatmullRomCurve(
const <Offset>[
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>[
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 <Offset>[]);
}, throwsAssertionError);
expect(() {
CatmullRomCurve(const <Offset>[Offset.zero]);
}, throwsAssertionError);
expect(() {
CatmullRomCurve(const <Offset>[Offset.zero, Offset.zero]);
}, throwsAssertionError);
// Monotonically increasing in X.
expect(
CatmullRomCurve.validateControlPoints(
const <Offset>[
Offset(0.2, 0.25),
Offset(0.01, 0.25),
],
tension: 0.0,
),
isFalse);
expect(() {
CatmullRomCurve(
const <Offset>[
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>[
Offset(0.2, 0.25),
Offset(1.01, 0.25),
],
tension: 0.0,
),
isFalse);
expect(() {
CatmullRomCurve(
const <Offset>[
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>[
Offset(0.05, 0.50),
Offset(0.50, 0.50),
Offset(0.75, 0.75),
],
tension: 0.0,
),
isFalse,
);
expect(() {
CatmullRomCurve(
const <Offset>[
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>[
Offset(0.25, 0.25),
Offset(0.50, 0.50),
Offset(0.95, 0.51),
],
tension: 0.0,
),
isFalse,
);
expect(() {
CatmullRomCurve(
const <Offset>[
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>[
Offset(0.5, 0.05),
Offset(0.5, 0.95),
],
tension: 0.0,
),
isFalse,
);
expect(() {
CatmullRomCurve(
const <Offset>[
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 <Offset>[]);
}, throwsAssertionError);
expect(() {
CatmullRomCurve.precompute(const <Offset>[Offset.zero]);
}, throwsAssertionError);
expect(() {
CatmullRomCurve.precompute(const <Offset>[Offset.zero, Offset.zero]);
}, throwsAssertionError);
// Monotonically increasing in X.
expect(() {
CatmullRomCurve.precompute(
const <Offset>[
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>[
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>[
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>[
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>[
Offset(0.5, 0.05),
Offset(0.5, 0.95),
],
tension: 0.0,
);
}, throwsAssertionError);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment