diff --git a/packages/material_ui/example/lib/main.dart b/packages/material_ui/example/lib/main.dart index 5dc6c88ccca5..d8daf779b322 100644 --- a/packages/material_ui/example/lib/main.dart +++ b/packages/material_ui/example/lib/main.dart @@ -28,6 +28,7 @@ import 'progress_indicator/linear_progress_indicator.0.dart' import 'progress_indicator/circular_progress_indicator.0.dart' as circular_progress_indicator_0; import 'dropdown_menu/dropdown_menu.1.dart' as dropdown_menu_1; +import 'material_shapes/material_shapes.0.dart' as material_shapes_0; import 'navigation_bar/navigation_bar.1.dart' as navigation_bar_1; import 'navigation_rail/navigation_rail.0.dart' as navigation_rail_0; import 'navigation_drawer/navigation_drawer.0.dart' as navigation_drawer_0; @@ -161,6 +162,12 @@ class ExampleApp extends StatelessWidget { builder: (BuildContext context) => const circular_progress_indicator_0.ProgressIndicatorExampleApp(), ), + _Example( + filepath: 'material_shapes/material_shapes.0.dart', + title: 'Material shapes', + builder: (BuildContext context) => + const material_shapes_0.MaterialShapesExampleApp(), + ), _Example( filepath: 'dropdown_menu/dropdown_menu.1.dart', title: 'Menu', diff --git a/packages/material_ui/example/lib/material_shapes/material_shapes.0.dart b/packages/material_ui/example/lib/material_shapes/material_shapes.0.dart new file mode 100644 index 000000000000..ba5fd75655c1 --- /dev/null +++ b/packages/material_ui/example/lib/material_shapes/material_shapes.0.dart @@ -0,0 +1,191 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// #region body +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/physics.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [MaterialShapes]. + +void main() { + runApp(const MaterialShapesExampleApp()); +} + +class MaterialShapesExampleApp extends StatelessWidget { + const MaterialShapesExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xFF6750A4)), + home: const MaterialShapesExample(), + ); + } +} + +class MaterialShapesExample extends StatefulWidget { + const MaterialShapesExample({super.key}); + + @override + State createState() => _MaterialShapesExampleState(); +} + +class _MaterialShapesExampleState extends State + with SingleTickerProviderStateMixin { + /// The shapes to morph through, in the order they are listed in + /// [MaterialShapes.all]. + static final List _shapes = MaterialShapes.all; + + /// A [Morph] from each shape in [_shapes] to the next one, wrapping around + /// from the last shape back to the first. + /// + /// These are built up front because a [Morph] computes the mapping between + /// its two shapes when it is constructed. + late final List _morphs; + + /// The morph that is currently animating. + late final ValueNotifier _morph; + + /// Drives the progress of [_morph]. + /// + /// The controller is unbounded so that the springs below can overshoot past a + /// progress of 1, which carries the morph slightly beyond its end shape. + late final AnimationController _controller; + + int _morphIndex = 0; + + Timer? _startTimer; + + final _bouncySimulation = SpringSimulation( + SpringDescription.withDampingRatio(ratio: 0.5, stiffness: 400, mass: 1), + 0, + 1, + 5, + snapToEnd: true, + ); + + // Overshooting past a progress of 1 extrapolates a morph beyond its end + // shape, which distorts the heart into sharp spikes. The heart therefore + // animates with a spring that starts at rest and barely overshoots. + final _lessBouncySimulation = SpringSimulation( + SpringDescription.withDampingRatio(ratio: 0.8, stiffness: 300, mass: 1), + 0, + 1, + 0, + snapToEnd: true, + ); + + /// The shape that the currently animating morph ends on. + RoundedPolygon get _targetShape => + _shapes[(_morphIndex + 1) % _shapes.length]; + + @override + void initState() { + super.initState(); + + _morphs = [ + for (var i = 0; i < _shapes.length; i++) + Morph(_shapes[i], _shapes[(i + 1) % _shapes.length]), + ]; + _morph = ValueNotifier(_morphs.first); + _controller = AnimationController.unbounded(vsync: this); + + // Hold the first shape for a moment so that it can be seen before it starts + // morphing. + _startTimer = Timer(const Duration(seconds: 1), _morphThroughShapes); + } + + @override + void dispose() { + _startTimer?.cancel(); + _controller.dispose(); + _morph.dispose(); + super.dispose(); + } + + /// Animates the morphs in [_morphs] one after another, looping back to the + /// first one after the last, until this widget is disposed. + /// + /// Waiting for each spring to settle, rather than advancing on a fixed + /// interval, keeps a morph from being cut short and popping to the next + /// shape. + Future _morphThroughShapes() async { + while (mounted) { + _morph.value = _morphs[_morphIndex]; + _controller.value = 0; + await _controller.animateWith( + _targetShape == MaterialShapes.heart + ? _lessBouncySimulation + : _bouncySimulation, + ); + _morphIndex = (_morphIndex + 1) % _morphs.length; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Material Shapes Sample')), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300, maxHeight: 300), + child: AspectRatio( + aspectRatio: 1, + child: RepaintBoundary( + child: CustomPaint( + painter: _MorphPainter( + morph: _morph, + progress: _controller, + color: Theme.of(context).colorScheme.primary, + ), + willChange: true, + child: const SizedBox.expand(), + ), + ), + ), + ), + ), + ); + } +} + +class _MorphPainter extends CustomPainter { + _MorphPainter({ + required this.morph, + required this.progress, + required this.color, + }) : super(repaint: Listenable.merge([morph, progress])); + + final ValueListenable morph; + + final Animation progress; + + final Color color; + + late final Paint _paint = Paint() + ..style = .fill + ..color = color; + + @override + void paint(Canvas canvas, Size size) { + // Every shape in MaterialShapes is normalized, so its path fits in a one by + // one square that has to be scaled up to the size being painted. + canvas + ..save() + ..scale(size.width) + ..drawPath(morph.value.toPath(progress.value), _paint) + ..restore(); + } + + @override + bool shouldRepaint(_MorphPainter oldDelegate) { + return oldDelegate.morph != morph || + oldDelegate.progress != progress || + oldDelegate.color != color; + } +} +// #endregion body diff --git a/packages/material_ui/example/test/main_test.dart b/packages/material_ui/example/test/main_test.dart index ffe53d271ba4..5f6330ddc820 100644 --- a/packages/material_ui/example/test/main_test.dart +++ b/packages/material_ui/example/test/main_test.dart @@ -42,6 +42,8 @@ import 'package:material_ui_examples/progress_indicator/circular_progress_indica as circular_progress_indicator_0; import 'package:material_ui_examples/dropdown_menu/dropdown_menu.1.dart' as dropdown_menu_1; +import 'package:material_ui_examples/material_shapes/material_shapes.0.dart' + as material_shapes_0; import 'package:material_ui_examples/navigation_bar/navigation_bar.1.dart' as navigation_bar_1; import 'package:material_ui_examples/navigation_rail/navigation_rail.0.dart' @@ -87,6 +89,7 @@ const Map _examples = { linear_progress_indicator_0.ProgressIndicatorExampleApp, 'Circular progress indicators': circular_progress_indicator_0.ProgressIndicatorExampleApp, + 'Material shapes': material_shapes_0.MaterialShapesExampleApp, 'Menu': dropdown_menu_1.DropdownMenuApp, 'Navigation bar': navigation_bar_1.NavigationBarApp, 'Navigation rail': navigation_rail_0.NavigationRailExampleApp, diff --git a/packages/material_ui/example/test/material_shapes/material_shapes.0_test.dart b/packages/material_ui/example/test/material_shapes/material_shapes.0_test.dart new file mode 100644 index 000000000000..75fb4338244f --- /dev/null +++ b/packages/material_ui/example/test/material_shapes/material_shapes.0_test.dart @@ -0,0 +1,99 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/material_shapes/material_shapes.0.dart' + as example; + +void main() { + /// Comfortably longer than it takes either of the example's springs to + /// settle. + const Duration morphDuration = Duration(seconds: 3); + + /// Samples points on a grid over the unit square that are clearly [inside] + /// (or outside) of [path]. + List samplePoints(Path path, {required bool inside}) { + const step = 0.09; + const margin = 0.02; + + final List points = []; + for (var x = step; x < 1; x += step) { + for (var y = step; y < 1; y += step) { + final bool clearlyInOrOut = [ + Offset(x, y), + Offset(x - margin, y), + Offset(x + margin, y), + Offset(x, y - margin), + Offset(x, y + margin), + ].every((Offset point) => path.contains(point) == inside); + if (clearlyInOrOut) { + points.add(Offset(x, y)); + } + } + } + return points; + } + + testWidgets('Material shapes morph through every shape and wrap around', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.MaterialShapesExampleApp()); + + expect(find.text('Material Shapes Sample'), findsOne); + + final Finder shape = find.byWidgetPredicate( + (Widget widget) => widget is CustomPaint && widget.willChange, + ); + expect(tester.getSize(shape), const Size.square(300)); + + final ThemeData theme = Theme.of( + tester.element(find.byType(example.MaterialShapesExample)), + ); + + // The circle is painted first, scaled up to fill the paint area, and is + // held still before the morphing starts. + expect( + shape, + paints + ..scale(x: 300) + ..path( + color: theme.colorScheme.primary, + style: PaintingStyle.fill, + includes: const [Offset(0.5, 0.5)], + excludes: const [Offset(0.1, 0.1)], + ), + ); + expect(tester.hasRunningAnimations, isFalse); + + // The morphing starts after holding the first shape for a second. + await tester.pump(const Duration(seconds: 1)); + + // Morph through the whole list of shapes, wrapping around from the last + // shape back to the first one. + final shapeCount = MaterialShapes.all.length; + for (var i = 0; i < shapeCount; i++) { + expect(tester.hasRunningAnimations, isTrue); + await tester.pump(morphDuration); + + final RoundedPolygon target = MaterialShapes.all[(i + 1) % shapeCount]; + final Path targetPath = target.toPath(); + expect( + shape, + paints..path( + color: theme.colorScheme.primary, + style: PaintingStyle.fill, + includes: samplePoints(targetPath, inside: true), + excludes: samplePoints(targetPath, inside: false), + ), + reason: + 'Morph $i should have settled on shape ${(i + 1) % shapeCount}.', + ); + } + + // Disposing the example stops the animation. + await tester.pumpWidget(const SizedBox()); + expect(tester.hasRunningAnimations, isFalse); + }); +} diff --git a/packages/material_ui/lib/material_ui.dart b/packages/material_ui/lib/material_ui.dart index c884f04dec79..a80ae6f9ced4 100644 --- a/packages/material_ui/lib/material_ui.dart +++ b/packages/material_ui/lib/material_ui.dart @@ -120,6 +120,8 @@ export 'src/magnifier.dart'; export 'src/material.dart'; export 'src/material_button.dart'; export 'src/material_localizations.dart'; +export 'src/material_shape_border.dart'; +export 'src/material_shapes.dart'; export 'src/material_state.dart'; export 'src/material_state_mixin.dart'; export 'src/menu_anchor.dart'; @@ -166,6 +168,7 @@ export 'src/segmented_button_theme.dart'; export 'src/selectable_text.dart'; export 'src/selection_area.dart'; export 'src/shadows.dart'; +export 'src/shapes/shapes.dart'; export 'src/slider.dart'; export 'src/slider_parts.dart'; export 'src/slider_theme.dart'; diff --git a/packages/material_ui/lib/src/material_shape_border.dart b/packages/material_ui/lib/src/material_shape_border.dart new file mode 100644 index 000000000000..e54480ec6548 --- /dev/null +++ b/packages/material_ui/lib/src/material_shape_border.dart @@ -0,0 +1,386 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' as ui show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; + +import 'package:vector_math/vector_math_64.dart' show Matrix4; + +import 'shapes/cubic.dart'; +import 'shapes/morph.dart'; +import 'shapes/rounded_polygon.dart'; + +/// A border that fits a material-shaped border within the rectangle of the +/// widget it is applied to. +/// +/// Typically used with a [ShapeDecoration] to draw a material-shaped border. +class MaterialShapeBorder extends OutlinedBorder { + /// Creates a [MaterialShapeBorder]. + MaterialShapeBorder({required RoundedPolygon this.shape, super.side, this.squash = 0}) + : _cubics = shape.cubics, + _lerpStart = null, + _lerpEnd = null, + _lerpProgress = null, + assert(squash >= 0 && squash <= 1, 'squash has to be in range [0, 1]'); + + const MaterialShapeBorder._fromCubics({ + required this._cubics, + required RoundedPolygon this._lerpStart, + required RoundedPolygon this._lerpEnd, + required double this._lerpProgress, + super.side, + this.squash = 0, + }) : shape = null, + assert(squash >= 0 && squash <= 1, 'squash has to be in range [0, 1]'); + + /// The shape this border represents. + /// + /// This value is `null` if the border is the result of a lerp, which stores + /// its morph instead. + final RoundedPolygon? shape; + + /// How much of the aspect ratio of the attached widget to take on. + /// + /// If [squash] is non-zero, the border will match the aspect ratio of the + /// bounding box of the widget that it is attached to, which can give a + /// squashed appearance. + /// + /// The [squash] parameter lets you control how much of that aspect ratio this + /// border takes on. + /// + /// A value of zero means that the border will be drawn with a square aspect + /// ratio at the size of the shortest side of the bounding rectangle, ignoring + /// the aspect ratio of the widget, and a value of one means it will be drawn + /// with the aspect ratio of the widget. The value of [squash] has no effect + /// if the widget is square to begin with. + /// + /// Defaults to zero, and must be between zero and one, inclusive. + final double squash; + + final List _cubics; + + // The morph this border was lerped from, and how far along it the geometry + // sits. All three are null when [shape] is set and non-null otherwise, which + // is what lets an interrupted transition resume along the same morph. + final RoundedPolygon? _lerpStart; + final RoundedPolygon? _lerpEnd; + final double? _lerpProgress; + + // The number 5 was chosen without any real science behind it. It is small + // enough that the cached morphs fit comfortably in memory, and large enough + // for the few pairs of shapes a screen animates between at once. + static const int _morphCacheSize = 5; + + /// Caches the mapping between pairs of shapes to speed up [lerpFrom] and + /// [lerpTo]. + static final _morphCache = _FifoCache<_MorphCacheKey, Morph>(_morphCacheSize); + + /// Returns the [Morph] between [start] and [end], reusing a cached one when + /// possible. + /// + /// Creating a [Morph] matches up the curves of both shapes, which is much + /// more expensive than evaluating it at a progress value. A transition asks + /// for the same pair of shapes on every frame, so the result is worth + /// keeping. + static Morph _morphBetween(RoundedPolygon start, RoundedPolygon end) { + return _morphCache.putIfAbsent(_MorphCacheKey(start, end), () => Morph(start, end)); + } + + /// Interpolates from [a] to [b] at [t], or returns `null` if the two borders + /// have no morph in common. + /// + /// Both [lerpFrom] and [lerpTo] delegate here so that they always give the + /// same answer. [ShapeBorder.lerp] tries them in both directions, so a pair + /// accepted by one but declined by another would animate backwards or never + /// reach the snapping fallback. + static MaterialShapeBorder? _lerp(MaterialShapeBorder a, MaterialShapeBorder b, double t) { + final RoundedPolygon? aShape = a.shape; + final RoundedPolygon? bShape = b.shape; + + final RoundedPolygon start; + final RoundedPolygon end; + final double progress; + + if (aShape != null && bShape != null) { + start = aShape; + end = bShape; + progress = t; + } else { + // One or both sides came from an earlier lerp, as happens when an + // implicit animation is interrupted. Such a border can only be + // interpolated along the morph it came from, so both sides must sit on + // that same morph. + final lerped = aShape == null ? a : b; + start = lerped._lerpStart!; + end = lerped._lerpEnd!; + + final double? from = a._progressAlong(start, end); + final double? to = b._progressAlong(start, end); + + if (from == null || to == null) { + return null; + } + + progress = ui.lerpDouble(from, to, t)!; + } + + return MaterialShapeBorder._fromCubics( + cubics: _morphBetween(start, end).toCubics(progress), + lerpStart: start, + lerpEnd: end, + lerpProgress: progress, + side: BorderSide.lerp(a.side, b.side, t), + squash: ui.lerpDouble(a.squash, b.squash, t)!, + ); + } + + /// How far along the morph from [start] to [end] this border sits, or null if + /// it is not on that morph. + /// + /// Shapes are compared by value, so a border rebuilt with an equal but newly + /// constructed shape still resumes its morph instead of snapping. + /// [_MorphCacheKey] makes the opposite trade, since hashing a polygon is + /// expensive. + double? _progressAlong(RoundedPolygon start, RoundedPolygon end) { + final RoundedPolygon? shape = this.shape; + + if (shape == null) { + return _lerpStart == start && _lerpEnd == end ? _lerpProgress : null; + } + + if (shape == start) { + return 0; + } + + if (shape == end) { + return 1; + } + + return null; + } + + /// Returns a copy of this lerp result with a different [side] or [squash]. + /// + /// The geometry and the morph carry over unchanged, so the copy can still be + /// lerped. Only valid when [shape] is null. + MaterialShapeBorder _lerpResultWith({BorderSide? side, double? squash}) { + assert(shape == null); + + return MaterialShapeBorder._fromCubics( + cubics: _cubics, + lerpStart: _lerpStart!, + lerpEnd: _lerpEnd!, + lerpProgress: _lerpProgress!, + side: side ?? this.side, + squash: squash ?? this.squash, + ); + } + + @override + ShapeBorder scale(double t) { + final RoundedPolygon? shape = this.shape; + + if (shape != null) { + return MaterialShapeBorder(shape: shape, side: side.scale(t), squash: squash); + } + + return _lerpResultWith(side: side.scale(t)); + } + + @override + ShapeBorder? lerpFrom(ShapeBorder? a, double t) { + if (t == 0) { + return a; + } + + if (t == 1.0) { + return this; + } + + if (a is MaterialShapeBorder) { + return _lerp(a, this, t); + } + + return super.lerpFrom(a, t); + } + + @override + ShapeBorder? lerpTo(ShapeBorder? b, double t) { + if (t == 0) { + return this; + } + + if (t == 1.0) { + return b; + } + + if (b is MaterialShapeBorder) { + return _lerp(this, b, t); + } + + return super.lerpTo(b, t); + } + + @override + MaterialShapeBorder copyWith({RoundedPolygon? shape, BorderSide? side, double? squash}) { + if (shape != null) { + return MaterialShapeBorder( + shape: shape, + side: side ?? this.side, + squash: squash ?? this.squash, + ); + } + + final RoundedPolygon? oldShape = this.shape; + + if (oldShape != null) { + return MaterialShapeBorder( + shape: oldShape, + side: side ?? this.side, + squash: squash ?? this.squash, + ); + } + + return _lerpResultWith(side: side, squash: squash); + } + + Path _getPathFromRect(Rect rect) { + var scale = Offset(rect.width, rect.height); + + if (rect.shortestSide == rect.width) { + scale = Offset(scale.dx, squash * scale.dy + (1 - squash) * scale.dx); + } else { + scale = Offset(squash * scale.dx + (1 - squash) * scale.dy, scale.dy); + } + + final Rect actualRect = + Offset(rect.left + (rect.width - scale.dx) / 2, rect.top + (rect.height - scale.dy) / 2) & + Size(scale.dx, scale.dy); + + final matrix = Matrix4.identity() + ..translate(actualRect.left, actualRect.top) + ..scale(scale.dx, scale.dy); + + return pathFromCubics(_cubics).transform(matrix.storage); + } + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + final Rect adjustedRect = rect.deflate(side.strokeInset); + return _getPathFromRect(adjustedRect); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + final Rect adjustedRect = rect.inflate(side.strokeOutset); + return _getPathFromRect(adjustedRect); + } + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { + switch (side.style) { + case BorderStyle.none: + return; + + case BorderStyle.solid: + final Rect adjustedRect = rect.inflate(side.strokeOffset / 2); + final Path path = _getPathFromRect(adjustedRect); + canvas.drawPath(path, side.toPaint()); + } + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + + return other is MaterialShapeBorder && + other.shape == shape && + listEquals(other._cubics, _cubics) && + other._lerpStart == _lerpStart && + other._lerpEnd == _lerpEnd && + other._lerpProgress == _lerpProgress && + other.side == side && + other.squash == squash; + } + + @override + int get hashCode => Object.hash( + shape, + Object.hashAll(_cubics), + _lerpStart, + _lerpEnd, + _lerpProgress, + side, + squash, + ); + + @override + String toString() { + return '${objectRuntimeType(this, 'MaterialShapeBorder')}' + '(side: $side, squash: $squash)'; + } +} + +/// The pair of shapes a cached [Morph] was built from. +/// +/// Keys compare by identity rather than by value, because +/// [RoundedPolygon.hashCode] walks every coordinate of every feature and would +/// cost a sizeable fraction of what the cache saves. A pair that misses is +/// simply rebuilt. +@immutable +class _MorphCacheKey { + const _MorphCacheKey(this.start, this.end); + + final RoundedPolygon start; + + final RoundedPolygon end; + + @override + int get hashCode => Object.hash(identityHashCode(start), identityHashCode(end)); + + @override + bool operator ==(Object other) { + return other is _MorphCacheKey && identical(other.start, start) && identical(other.end, end); + } +} + +/// Cache of objects of limited size that uses the first in first out eviction +/// strategy (a.k.a least recently inserted). +/// +/// The key that was inserted before all other keys is evicted first, i.e. the +/// one inserted least recently. +class _FifoCache { + _FifoCache(this._maximumSize) : assert(_maximumSize > 0); + + /// In Dart the map literal uses a linked hash-map implementation, whose keys + /// are stored such that [Map.keys] returns them in the order they were + /// inserted. + final Map _cache = {}; + + /// Maximum number of entries to store in the cache. + /// + /// Once this many entries have been cached, the entry inserted least recently + /// is evicted when adding a new entry. + final int _maximumSize; + + /// Returns the previously cached value for the given key, if available; + /// if not, calls the given callback to obtain it first. + V putIfAbsent(K key, V Function() loader) { + final V? result = _cache[key]; + + if (result != null) { + return result; + } + + if (_cache.length == _maximumSize) { + _cache.remove(_cache.keys.first); + } + + return _cache[key] = loader(); + } +} diff --git a/packages/material_ui/lib/src/material_shapes.dart b/packages/material_ui/lib/src/material_shapes.dart new file mode 100644 index 000000000000..dff1994e8b7b --- /dev/null +++ b/packages/material_ui/lib/src/material_shapes.dart @@ -0,0 +1,470 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This code is a Dart port of the Compose Material 3 shape catalog: +// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/MaterialShapes.kt + +import 'dart:collection'; +import 'dart:math' as math; + +import 'package:vector_math/vector_math_64.dart'; + +import 'shapes/point.dart'; +import 'shapes/shapes.dart'; + +/// Holds predefined Material Design shapes as [RoundedPolygon]s that can be +/// used at various components as they are, or as part of a [Morph]. +/// +/// Note that each [RoundedPolygon] in this class is normalized. +/// +/// https://developer.android.com/images/reference/androidx/compose/material3/shapes.png +/// +/// +/// +/// This example shows how to animate a [Morph] through every shape in [all]. +/// +// TODO(framework): Replace the following block with a @dartpad directive +// when it's supported. https://github.com/dart-lang/dartdoc/issues/4123 +/// {@macro material_ui.dartpad_guide} +/// +/// {@example /example/lib/material_shapes/material_shapes.0.dart#body} +/// +/// +abstract final class MaterialShapes { + static const _cornerRound15 = CornerRounding(radius: 0.15); + static const _cornerRound20 = CornerRounding(radius: 0.2); + static const _cornerRound30 = CornerRounding(radius: 0.3); + static const _cornerRound50 = CornerRounding(radius: 0.5); + static const _cornerRound100 = CornerRounding(radius: 1); + + static const double _negative45Radians = -45 * math.pi / 180; + static const double _negative90Radians = -90 * math.pi / 180; + static const double _negative135Radians = -135 * math.pi / 180; + + /// A circle shape. + static final circle = RoundedPolygon.circle( + numVertices: 10, + radius: 0.5, + center: const Point(0.5, 0.5), + ); + + /// A square shape. + static final square = RoundedPolygon.rectangle( + width: 1, + height: 1, + rounding: _cornerRound30, + center: const Point(0.5, 0.5), + ); + + /// A slanted square shape. + static final RoundedPolygon slanted = _customPolygon(const [ + _PointNRound(Point(0.926, 0.970), CornerRounding(radius: 0.189, smoothing: 0.811)), + _PointNRound(Point(-0.021, 0.967), CornerRounding(radius: 0.187, smoothing: 0.057)), + ], 2).normalized; + + /// An arch shape. + static final RoundedPolygon arch = RoundedPolygon( + 4, + perVertexRounding: const [_cornerRound100, _cornerRound100, _cornerRound20, _cornerRound20], + ).transformed((Matrix4.identity()..rotateZ(_negative135Radians)).asPointTransformer()).normalized; + + /// A semi-circle shape. + static final RoundedPolygon semiCircle = RoundedPolygon.rectangle( + width: 1.6, + height: 1, + perVertexRounding: const [_cornerRound20, _cornerRound20, _cornerRound100, _cornerRound100], + ).normalized; + + /// An oval shape. + static final RoundedPolygon oval = RoundedPolygon.circle() + .transformed( + (Matrix4.identity() + ..rotateZ(_negative45Radians) + ..scale(1.0, 0.64)) + .asPointTransformer(), + ) + .normalized; + + /// An pill shape. + static final RoundedPolygon pill = _customPolygon( + [ + const _PointNRound(Point(0.961, 0.039), CornerRounding(radius: 0.426)), + const _PointNRound(Point(1.001, 0.428)), + const _PointNRound(Point(1, 0.609), CornerRounding(radius: 1)), + ], + 2, + mirroring: true, + ).normalized; + + /// A triangle shape. + static final RoundedPolygon triangle = RoundedPolygon( + 3, + rounding: _cornerRound20, + ).transformed((Matrix4.identity()..rotateZ(_negative90Radians)).asPointTransformer()).normalized; + + /// An arrow shape. + static final RoundedPolygon arrow = _customPolygon([ + const _PointNRound(Point(0.5, 0.892), CornerRounding(radius: 0.313)), + const _PointNRound(Point(-0.216, 1.05), CornerRounding(radius: 0.207)), + const _PointNRound(Point(0.499, -0.16), CornerRounding(radius: 0.215, smoothing: 1)), + const _PointNRound(Point(1.225, 1.06), CornerRounding(radius: 0.211)), + ], 1).normalized; + + /// A fan shape. + static final RoundedPolygon fan = _customPolygon([ + const _PointNRound(Point(1.004, 1), CornerRounding(radius: 0.148, smoothing: 0.417)), + const _PointNRound(Point(0, 1), CornerRounding(radius: 0.151)), + const _PointNRound(Point(0, -0.003), CornerRounding(radius: 0.148)), + const _PointNRound(Point(0.978, 0.02), CornerRounding(radius: 0.803)), + ], 1).normalized; + + /// A diamond shape. + static final RoundedPolygon diamond = _customPolygon([ + const _PointNRound(Point(0.5, 1.096), CornerRounding(radius: 0.151, smoothing: 0.524)), + const _PointNRound(Point(0.04, 0.5), CornerRounding(radius: .159)), + ], 2).normalized; + + /// A clam-shell shape. + static final RoundedPolygon clamShell = _customPolygon([ + const _PointNRound(Point(0.171, 0.841), CornerRounding(radius: 0.159)), + const _PointNRound(Point(-0.02, 0.5), CornerRounding(radius: 0.140)), + const _PointNRound(Point(0.17, 0.159), CornerRounding(radius: 0.159)), + ], 2).normalized; + + /// A pentagon shape. + static final RoundedPolygon pentagon = _customPolygon( + [ + const _PointNRound(Point(0.5, -0.009), CornerRounding(radius: 0.172)), + const _PointNRound(Point(1.03, 0.365), CornerRounding(radius: 0.164)), + const _PointNRound(Point(0.828, 0.97), CornerRounding(radius: 0.169)), + ], + 1, + mirroring: true, + ).normalized; + + /// A gem shape. + static final RoundedPolygon gem = _customPolygon( + [ + const _PointNRound(Point(0.499, 1.023), CornerRounding(radius: 0.241, smoothing: 0.778)), + const _PointNRound(Point(-0.005, 0.792), CornerRounding(radius: 0.208)), + const _PointNRound(Point(0.073, 0.258), CornerRounding(radius: 0.228)), + const _PointNRound(Point(0.433, -0), CornerRounding(radius: 0.491)), + ], + 1, + mirroring: true, + ).normalized; + + /// A sunny shape. + static final RoundedPolygon sunny = RoundedPolygon.star( + numVerticesPerRadius: 8, + innerRadius: 0.8, + rounding: _cornerRound15, + ).normalized; + + /// A very-sunny shape. + static final RoundedPolygon verySunny = _customPolygon([ + const _PointNRound(Point(0.5, 1.080), CornerRounding(radius: 0.085)), + const _PointNRound(Point(0.358, 0.843), CornerRounding(radius: 0.085)), + ], 8).normalized; + + /// A 4-sided cookie shape. + static final RoundedPolygon cookie4Sided = _customPolygon([ + const _PointNRound(Point(1.237, 1.236), CornerRounding(radius: 0.258)), + const _PointNRound(Point(0.5, 0.918), CornerRounding(radius: 0.233)), + ], 4).normalized; + + /// A 6-sided cookie shape. + static final RoundedPolygon cookie6Sided = _customPolygon([ + const _PointNRound(Point(0.723, 0.884), CornerRounding(radius: 0.394)), + const _PointNRound(Point(0.5, 1.099), CornerRounding(radius: 0.398)), + ], 6).normalized; + + /// A 7-sided cookie shape. + static final RoundedPolygon cookie7Sided = RoundedPolygon.star( + numVerticesPerRadius: 7, + innerRadius: 0.75, + rounding: _cornerRound50, + ).transformed((Matrix4.identity()..rotateZ(_negative90Radians)).asPointTransformer()).normalized; + + /// A 9-sided cookie shape. + static final RoundedPolygon cookie9Sided = RoundedPolygon.star( + numVerticesPerRadius: 9, + innerRadius: 0.8, + rounding: _cornerRound50, + ).transformed((Matrix4.identity()..rotateZ(_negative90Radians)).asPointTransformer()).normalized; + + /// A 12-sided cookie shape. + static final RoundedPolygon cookie12Sided = RoundedPolygon.star( + numVerticesPerRadius: 12, + innerRadius: 0.8, + rounding: _cornerRound50, + ).transformed((Matrix4.identity()..rotateZ(_negative90Radians)).asPointTransformer()).normalized; + + /// A 4-leaf clover shape. + static final RoundedPolygon clover4Leaf = _customPolygon( + [ + const _PointNRound(Point(0.5, 0.074)), + const _PointNRound(Point(0.725, -0.099), CornerRounding(radius: 0.476)), + ], + 4, + mirroring: true, + ).normalized; + + /// A 8-leaf clover shape. + static final RoundedPolygon clover8Leaf = _customPolygon([ + const _PointNRound(Point(0.5, 0.036)), + const _PointNRound(Point(0.758, -0.101), CornerRounding(radius: 0.209)), + ], 8).normalized; + + /// A burst shape. + static final RoundedPolygon burst = _customPolygon([ + const _PointNRound(Point(0.5, -0.006), CornerRounding(radius: 0.006)), + const _PointNRound(Point(0.592, 0.158), CornerRounding(radius: 0.006)), + ], 12).normalized; + + /// A soft-burst shape. + static final RoundedPolygon softBurst = _customPolygon([ + const _PointNRound(Point(0.193, 0.277), CornerRounding(radius: 0.053)), + const _PointNRound(Point(0.176, 0.055), CornerRounding(radius: 0.053)), + ], 10).normalized; + + /// A boom shape. + static final RoundedPolygon boom = _customPolygon([ + const _PointNRound(Point(0.457, 0.296), CornerRounding(radius: 0.007)), + const _PointNRound(Point(0.5, -0.051), CornerRounding(radius: 0.007)), + ], 15).normalized; + + /// A soft-boom shape. + static final RoundedPolygon softBoom = _customPolygon( + [ + const _PointNRound(Point(0.733, 0.454)), + const _PointNRound(Point(0.839, 0.437), CornerRounding(radius: 0.532)), + const _PointNRound(Point(0.949, 0.449), CornerRounding(radius: 0.439, smoothing: 1)), + const _PointNRound(Point(0.998, 0.478), CornerRounding(radius: 0.174)), + ], + 16, + mirroring: true, + ).normalized; + + /// A flower shape. + static final RoundedPolygon flower = _customPolygon( + [ + const _PointNRound(Point(0.370, 0.187)), + const _PointNRound(Point(0.416, 0.049), CornerRounding(radius: 0.381)), + const _PointNRound(Point(0.479, 0.001), CornerRounding(radius: 0.095)), + ], + 8, + mirroring: true, + ).normalized; + + /// A puffy shape. + static final RoundedPolygon puffy = _customPolygon( + [ + const _PointNRound(Point(0.5, 0.053)), + const _PointNRound(Point(0.545, -0.04), CornerRounding(radius: 0.405)), + const _PointNRound(Point(0.670, -0.035), CornerRounding(radius: 0.426)), + const _PointNRound(Point(0.717, 0.066), CornerRounding(radius: 0.574)), + const _PointNRound(Point(0.722, 0.128)), + const _PointNRound(Point(0.777, 0.002), CornerRounding(radius: 0.36)), + const _PointNRound(Point(0.914, 0.149), CornerRounding(radius: 0.66)), + const _PointNRound(Point(0.926, 0.289), CornerRounding(radius: 0.66)), + const _PointNRound(Point(0.881, 0.346)), + const _PointNRound(Point(0.940, 0.344), CornerRounding(radius: 0.126)), + const _PointNRound(Point(1.003, 0.437), CornerRounding(radius: 0.255)), + ], + 2, + mirroring: true, + ).transformed((Matrix4.identity()..scale(1.0, 0.742)).asPointTransformer()).normalized; + + /// A puffy-diamond shape. + static final RoundedPolygon puffyDiamond = _customPolygon( + [ + const _PointNRound(Point(0.87, 0.13), CornerRounding(radius: 0.146)), + const _PointNRound(Point(0.818, 0.357)), + const _PointNRound(Point(1, 0.332), CornerRounding(radius: 0.853)), + ], + 4, + mirroring: true, + ).normalized; + + /// A ghostish shape. + static final RoundedPolygon ghostish = _customPolygon( + [ + const _PointNRound(Point(0.5, 0), CornerRounding(radius: 1)), + const _PointNRound(Point(1, 0), CornerRounding(radius: 1)), + const _PointNRound(Point(1, 1.14), CornerRounding(radius: 0.254, smoothing: 0.106)), + const _PointNRound(Point(0.575, 0.906), CornerRounding(radius: 0.253)), + ], + 1, + mirroring: true, + ).normalized; + + /// A pixel-circle shape. + static final RoundedPolygon pixelCircle = _customPolygon( + [ + const _PointNRound(Point(0.5, 0)), + const _PointNRound(Point(0.704, 0)), + const _PointNRound(Point(0.704, 0.065)), + const _PointNRound(Point(0.843, 0.065)), + const _PointNRound(Point(0.843, 0.148)), + const _PointNRound(Point(0.926, 0.148)), + const _PointNRound(Point(0.926, 0.296)), + const _PointNRound(Point(1, 0.296)), + ], + 2, + mirroring: true, + ).normalized; + + /// A pixel-triangle shape. + static final RoundedPolygon pixelTriangle = _customPolygon( + [ + const _PointNRound(Point(0.11, 0.5)), + const _PointNRound(Point(0.113, 0)), + const _PointNRound(Point(0.287, 0)), + const _PointNRound(Point(0.287, 0.087)), + const _PointNRound(Point(0.421, 0.087)), + const _PointNRound(Point(0.421, 0.17)), + const _PointNRound(Point(0.56, 0.17)), + const _PointNRound(Point(0.56, 0.265)), + const _PointNRound(Point(0.674, 0.265)), + const _PointNRound(Point(0.675, 0.344)), + const _PointNRound(Point(0.789, 0.344)), + const _PointNRound(Point(0.789, 0.439)), + const _PointNRound(Point(0.888, 0.439)), + ], + 1, + mirroring: true, + ).normalized; + + /// A bun shape. + static final RoundedPolygon bun = _customPolygon( + [ + const _PointNRound(Point(0.796, 0.5)), + const _PointNRound(Point(0.853, 0.518), CornerRounding(radius: 1)), + const _PointNRound(Point(0.992, 0.631), CornerRounding(radius: 1)), + const _PointNRound(Point(0.968, 1), CornerRounding(radius: 1)), + ], + 2, + mirroring: true, + ).normalized; + + /// A heart shape. + static final RoundedPolygon heart = _customPolygon( + [ + const _PointNRound(Point(0.5, 0.268), CornerRounding(radius: 0.016)), + const _PointNRound(Point(0.792, -0.066), CornerRounding(radius: 0.958)), + const _PointNRound(Point(1.064, 0.276), CornerRounding(radius: 1)), + const _PointNRound(Point(0.501, 0.946), CornerRounding(radius: 0.129)), + ], + 1, + mirroring: true, + ).normalized; + + /// A list of all available shapes. + static final UnmodifiableListView all = UnmodifiableListView([ + MaterialShapes.circle, + MaterialShapes.square, + MaterialShapes.slanted, + MaterialShapes.arch, + MaterialShapes.semiCircle, + MaterialShapes.oval, + MaterialShapes.pill, + MaterialShapes.triangle, + MaterialShapes.arrow, + MaterialShapes.fan, + MaterialShapes.diamond, + MaterialShapes.clamShell, + MaterialShapes.pentagon, + MaterialShapes.gem, + MaterialShapes.sunny, + MaterialShapes.verySunny, + MaterialShapes.cookie4Sided, + MaterialShapes.cookie6Sided, + MaterialShapes.cookie7Sided, + MaterialShapes.cookie9Sided, + MaterialShapes.cookie12Sided, + MaterialShapes.clover4Leaf, + MaterialShapes.clover8Leaf, + MaterialShapes.burst, + MaterialShapes.softBurst, + MaterialShapes.boom, + MaterialShapes.softBoom, + MaterialShapes.flower, + MaterialShapes.puffy, + MaterialShapes.puffyDiamond, + MaterialShapes.ghostish, + MaterialShapes.pixelCircle, + MaterialShapes.pixelTriangle, + MaterialShapes.bun, + MaterialShapes.heart, + ]); + + static RoundedPolygon _customPolygon( + List<_PointNRound> pnr, + int reps, { + Point center = const Point(0.5, 0.5), + bool mirroring = false, + }) { + final List<_PointNRound> actualPoints = _doRepeat(pnr, reps, center, mirroring); + + return RoundedPolygon.fromVertices( + actualPoints.map((_PointNRound ap) => ap.p).toList(), + perVertexRounding: actualPoints.map((_PointNRound ap) => ap.r).toList(), + center: center, + ); + } + + static List<_PointNRound> _doRepeat( + List<_PointNRound> points, + int reps, + Point center, + bool mirroring, + ) { + final result = <_PointNRound>[]; + + if (mirroring) { + final List<({double angle, double distance})> measures = List.generate(points.length, (i) { + final _PointNRound point = points[i]; + final Point off = point.p - center; + return (angle: off.direction, distance: off.distance); + }); + final int actualReps = reps * 2; + final double sectionAngle = math.pi * 2 / actualReps; + + for (var r = 0; r < actualReps; r++) { + for (var index = 0; index < points.length; index++) { + final int i = (r.isEven) ? index : points.length - 1 - index; + if (i > 0 || r.isEven) { + final double a = + sectionAngle * r + + ((r.isEven) + ? measures[i].angle + : sectionAngle - measures[i].angle + 2 * measures[0].angle); + + final Point finalPoint = + Point(math.cos(a), math.sin(a)) * measures[i].distance + center; + + result.add(_PointNRound(finalPoint, points[i].r)); + } + } + } + } else { + final int np = points.length; + for (var i = 0; i < np * reps; i++) { + final Point point = points[i % np].p.rotate((i ~/ np) * 360 / reps, center: center); + result.add(_PointNRound(point, points[i % np].r)); + } + } + + return result; + } +} + +class _PointNRound { + const _PointNRound(this.p, [this.r = CornerRounding.unrounded]); + + final Point p; + + final CornerRounding r; +} diff --git a/packages/material_ui/lib/src/shapes/corner_rounding.dart b/packages/material_ui/lib/src/shapes/corner_rounding.dart new file mode 100644 index 000000000000..8ee046b00ad9 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/corner_rounding.dart @@ -0,0 +1,85 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'rounded_polygon.dart'; +library; + +import 'package:flutter/foundation.dart'; + +/// Defines the amount and quality of rounding around a given vertex of a +/// shape. +/// +/// [radius] defines the radius of the circle which forms the basis of the +/// rounding for the vertex. [smoothing] defines the amount by which the curve +/// is extended from the circular arc around the corner to the edge between +/// vertices. +/// +/// Each corner of a shape can be thought of as either: +/// 1) unrounded (with a corner radius of 0 and no smoothing). +/// 2) rounded with only a circular arc (with smoothing of 0). In this case, +/// the rounding around the corner follows an approximated circular arc +/// between the edges to adjacent vertices. +/// 3) rounded with three curves: There is an inner circular arc and two +/// symmetric flanking curves. The flanking curves determine the curvature +/// from the inner curve to the edges, with a value of 0 (no smoothing) +/// meaning that it is purely a circular curve and a value of 1 meaning +/// that the flanking curves are maximized between the inner curve and +/// the edges. +@immutable +class CornerRounding { + /// Creates a [CornerRounding]. + const CornerRounding({this.radius = 0, this.smoothing = 0}) + : assert(radius >= 0, 'radius has to be greater than or equal to zero'), + assert(smoothing >= 0 && smoothing <= 1, 'smoothing has to be in range [0, 1]'); + + /// A [CornerRounding] with a radius of zero, producing a sharp corner at a + /// vertex. + static const unrounded = CornerRounding(); + + /// The radius of the circle which defines the inner rounding arc of the + /// corner. + /// + /// A value of 0 indicates that the corner is sharp, or completely unrounded. + /// A positive value is the requested size of the radius. + /// + /// This is an absolute size that should relate to the overall size of the + /// shape. If the shape is in screen coordinates, the radius should be sized + /// accordingly; if the shape is in a canonical form, such as the bounds of + /// (-1, -1) to (1, 1) that [RoundedPolygon.new] produces by + /// default, the radius should be relative to that size. The radius is scaled + /// when the shape itself is transformed, since it produces curves which round + /// the corner and so are transformed along with the overall shape. + /// + /// Must be greater than or equal to zero. + final double radius; + + /// The amount by which the arc is smoothed by extending the curve from the + /// inner circular arc to the edge between vertices. + /// + /// A value of 0 indicates that the corner is rounded by only a circular arc, + /// with no flanking curves. A value of 1 indicates that there is no circular + /// arc in the center, and the flanking curves on either side meet at the + /// middle. + /// + /// Must be in the range 0.0 to 1.0, inclusive. + final double smoothing; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + + return other is CornerRounding && other.radius == radius && other.smoothing == smoothing; + } + + @override + int get hashCode => Object.hash(radius, smoothing); + + @override + String toString() { + return '${objectRuntimeType(this, 'CornerRounding')}' + '(radius: ${radius.toStringAsFixed(1)}, smoothing: ${smoothing.toStringAsFixed(1)})'; + } +} diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart new file mode 100644 index 000000000000..7e8a4185b4d9 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -0,0 +1,537 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'morph.dart'; +/// @docImport 'rounded_polygon.dart'; +library; + +import 'dart:collection'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:vector_math/vector_math_64.dart' show Matrix4; + +import 'point.dart'; +import 'utils.dart'; + +/// A single cubic Bézier curve. +/// +/// The curve runs from [anchor0] to [anchor1], and the control points +/// [control0] and [control1] determine its slope at either end. +@immutable +class CubicBezier { + /// Creates a cubic Bézier curve running from [anchor0] to [anchor1], with + /// [control0] and [control1] determining its slope at either end. + CubicBezier(Offset anchor0, Offset control0, Offset control1, Offset anchor1) + : this.raw([ + anchor0.x, + anchor0.y, + control0.x, + control0.y, + control1.x, + control1.y, + anchor1.x, + anchor1.y, + ]); + + /// Creates a [CubicBezier] directly from the flat list of its eight anchor + /// and control point coordinates, in the order used by [points]. + @internal + const CubicBezier.raw(List points) + : assert(points.length == 8, 'Points array size should be 8.'), + _points = points; + + /// Generates a bezier curve that is a straight line between the given anchor + /// points [p0] and [p1]. The control points lie 1/3 of the distance from + /// their respective anchor points. + factory CubicBezier.straightLine(Offset p0, Offset p1) { + return CubicBezier.raw([ + p0.x, + p0.y, + lerp(p0.x, p1.x, 1 / 3), + lerp(p0.y, p1.y, 1 / 3), + lerp(p0.x, p1.x, 2 / 3), + lerp(p0.y, p1.y, 2 / 3), + p1.x, + p1.y, + ]); + } + + /// Generates a bezier curve that approximates a circular arc around [center], + /// with [p0] and [p1] as the starting and ending anchor points. The curve + /// generated is the smallest of the two possible arcs around the entire + /// 360-degree circle. Arcs of greater than 180 degrees should use more than + /// one arc together. Note that [p0] and [p1] should be equidistant from + /// [center]. + factory CubicBezier.circularArc(Offset center, Offset p0, Offset p1) { + final Point p0FromCenter = p0 - center; + final Point p1FromCenter = p1 - center; + final Point p0d = p0FromCenter.unitVector; + final Point p1d = p1FromCenter.unitVector; + final Point rotatedP0 = p0d.rotate90(); + final Point rotatedP1 = p1d.rotate90(); + final bool clockwise = rotatedP0.dotProduct(p1FromCenter) >= 0; + final double cosa = p0d.dotProduct(p1d); + + // p0 ~= p1 + if (cosa > 0.999) { + return CubicBezier.straightLine(p0, p1); + } + + final double k = + p0FromCenter.distance * + 4 / + 3 * + (math.sqrt(2 * (1 - cosa)) - math.sqrt(1 - cosa * cosa)) / + (1 - cosa) * + (clockwise ? 1 : -1); + + return CubicBezier.raw([ + p0.x, + p0.y, + p0.x + rotatedP0.x * k, + p0.y + rotatedP0.y * k, + p1.x - rotatedP1.x * k, + p1.y - rotatedP1.y * k, + p1.x, + p1.y, + ]); + } + + /// Generates a zero-length [CubicBezier] at [point]. + /// + /// Both anchor points and both control points coincide, so the curve has + /// zero length. See [isZeroLength]. + CubicBezier.point(Offset point) + : this.raw([point.x, point.y, point.x, point.y, point.x, point.y, point.x, point.y]); + + final List _points; + + /// The eight coordinates of this curve as a flat, unmodifiable list, ordered + /// as anchor0, control0, control1, anchor1. + /// + /// Equivalent to reading [anchor0X] through [anchor1Y] in order, and more + /// convenient when serializing a curve or handing its coordinates to code + /// that expects a coordinate buffer. + List get points => UnmodifiableListView(_points); + + /// The anchor point at the start of the curve. + Offset get anchor0 => Offset(_points[0], _points[1]); + + /// The control point closest to [anchor0]. + Offset get control0 => Offset(_points[2], _points[3]); + + /// The control point closest to [anchor1]. + Offset get control1 => Offset(_points[4], _points[5]); + + /// The anchor point at the end of the curve. + Offset get anchor1 => Offset(_points[6], _points[7]); + + /// The X coordinate of the anchor point at the start of the curve. + double get anchor0X => _points[0]; + + /// The Y coordinate of the anchor point at the start of the curve. + double get anchor0Y => _points[1]; + + /// The X coordinate of the control point closest to [anchor0]. + double get control0X => _points[2]; + + /// The Y coordinate of the control point closest to [anchor0]. + double get control0Y => _points[3]; + + /// The X coordinate of the control point closest to [anchor1]. + double get control1X => _points[4]; + + /// The Y coordinate of the control point closest to [anchor1]. + double get control1Y => _points[5]; + + /// The X coordinate of the anchor point at the end of the curve. + double get anchor1X => _points[6]; + + /// The Y coordinate of the anchor point at the end of the curve. + double get anchor1Y => _points[7]; + + /// Returns the point on this curve at [t], the proportional distance along + /// the curve from [anchor0] at 0 to [anchor1] at 1. + Offset pointAt(double t) { + final double u = 1 - t; + return Offset( + anchor0X * (u * u * u) + + control0X * (3 * t * u * u) + + control1X * (3 * t * t * u) + + anchor1X * (t * t * t), + anchor0Y * (u * u * u) + + control0Y * (3 * t * u * u) + + control1Y * (3 * t * t * u) + + anchor1Y * (t * t * t), + ); + } + + /// Whether this curve's two anchor points coincide, and so the curve + /// contributes nothing to an outline. + /// + /// Coincidence is measured with a small tolerance rather than exactly, so a + /// curve whose anchors differ only by rounding error still counts as zero + /// length. Note that the control points are not considered. + bool get isZeroLength => + (anchor0X - anchor1X).abs() < distanceEpsilon && + (anchor0Y - anchor1Y).abs() < distanceEpsilon; + + /// Whether the corner formed by this curve and [next] turns convexly. + @internal + bool convexTo(CubicBezier next) => convex(anchor0, anchor1, next.anchor1); + + bool _zeroIsh(double value) => value.abs() < distanceEpsilon; + + /// The axis-aligned bounding box of this curve. + /// + /// This solves for the curve's actual extrema. See [approximateBounds] for a + /// cheaper result that is never smaller than this one. + Rect get bounds => _calculateBounds(approximate: false); + + /// A cheaper alternative to [bounds], which bounds the two anchor points and + /// the two control points rather than solving for the curve's actual + /// extrema. + /// + /// The result is never smaller than [bounds], but can be larger. + Rect get approximateBounds => _calculateBounds(approximate: true); + + Rect _calculateBounds({required bool approximate}) { + // A curve might be of zero-length, with both anchors co-located. + // Just return the point itself. + if (isZeroLength) { + return Rect.fromLTRB(anchor0X, anchor0Y, anchor0X, anchor0Y); + } + + double minX = math.min(anchor0X, anchor1X); + double minY = math.min(anchor0Y, anchor1Y); + double maxX = math.max(anchor0X, anchor1X); + double maxY = math.max(anchor0Y, anchor1Y); + + if (approximate) { + // Approximate bounds use the bounding box of all anchors and + // controls. + return Rect.fromLTRB( + math.min(minX, math.min(control0X, control1X)), + math.min(minY, math.min(control0Y, control1Y)), + math.max(maxX, math.max(control0X, control1X)), + math.max(maxY, math.max(control0Y, control1Y)), + ); + } + + // Find the derivative, which is a quadratic Bezier. Then we can solve + // for t using the quadratic formula. + final double xa = -anchor0X + 3 * control0X - 3 * control1X + anchor1X; + final double xb = 2 * anchor0X - 4 * control0X + 2 * control1X; + final double xc = -anchor0X + control0X; + + if (_zeroIsh(xa)) { + // Try Muller's method instead; it can find a single root when a is 0. + if (xb != 0) { + final double t = 2 * xc / (-2 * xb); + if (t >= 0 && t <= 1) { + final double x = pointAt(t).x; + if (x < minX) { + minX = x; + } + if (x > maxX) { + maxX = x; + } + } + } + } else { + final double xs = xb * xb - 4 * xa * xc; + if (xs >= 0) { + final double t1 = (-xb + math.sqrt(xs)) / (2 * xa); + if (t1 >= 0 && t1 <= 1) { + final double x = pointAt(t1).x; + if (x < minX) { + minX = x; + } + if (x > maxX) { + maxX = x; + } + } + + final double t2 = (-xb - math.sqrt(xs)) / (2 * xa); + if (t2 >= 0 && t2 <= 1) { + final double x = pointAt(t2).x; + if (x < minX) { + minX = x; + } + if (x > maxX) { + maxX = x; + } + } + } + } + + // Repeat the above for y coordinate + final double ya = -anchor0Y + 3 * control0Y - 3 * control1Y + anchor1Y; + final double yb = 2 * anchor0Y - 4 * control0Y + 2 * control1Y; + final double yc = -anchor0Y + control0Y; + + if (_zeroIsh(ya)) { + if (yb != 0) { + final double t = 2 * yc / (-2 * yb); + if (t >= 0 && t <= 1) { + final double y = pointAt(t).y; + if (y < minY) { + minY = y; + } + if (y > maxY) { + maxY = y; + } + } + } + } else { + final double ys = yb * yb - 4 * ya * yc; + if (ys >= 0) { + final double t1 = (-yb + math.sqrt(ys)) / (2 * ya); + if (t1 >= 0 && t1 <= 1) { + final double y = pointAt(t1).y; + if (y < minY) { + minY = y; + } + if (y > maxY) { + maxY = y; + } + } + + final double t2 = (-yb - math.sqrt(ys)) / (2 * ya); + if (t2 >= 0 && t2 <= 1) { + final double y = pointAt(t2).y; + if (y < minY) { + minY = y; + } + if (y > maxY) { + maxY = y; + } + } + } + } + + return Rect.fromLTRB(minX, minY, maxX, maxY); + } + + /// Returns two [CubicBezier]s, created by splitting this curve at the given + /// distance of [t] between the original starting and ending anchor points. + (CubicBezier, CubicBezier) split(double t) { + final double u = 1 - t; + final Point point = pointAt(t); + + return ( + CubicBezier.raw([ + anchor0X, + anchor0Y, + anchor0X * u + control0X * t, + anchor0Y * u + control0Y * t, + anchor0X * (u * u) + control0X * (2 * u * t) + control1X * (t * t), + anchor0Y * (u * u) + control0Y * (2 * u * t) + control1Y * (t * t), + point.x, + point.y, + ]), + CubicBezier.raw([ + point.x, + point.y, + control0X * (u * u) + control1X * (2 * u * t) + anchor1X * (t * t), + control0Y * (u * u) + control1Y * (2 * u * t) + anchor1Y * (t * t), + control1X * u + anchor1X * t, + control1Y * u + anchor1Y * t, + anchor1X, + anchor1Y, + ]), + ); + } + + /// This curve with its control and anchor points in reverse order, so it + /// runs from [anchor1] to [anchor0]. + CubicBezier get reversed => CubicBezier.raw([ + anchor1X, + anchor1Y, + control1X, + control1Y, + control0X, + control0Y, + anchor0X, + anchor0Y, + ]); + + /// Returns a curve whose coordinates are the sums of this curve's and [o]'s + /// corresponding coordinates. + CubicBezier operator +(CubicBezier o) => CubicBezier.raw([ + _points[0] + o._points[0], + _points[1] + o._points[1], + _points[2] + o._points[2], + _points[3] + o._points[3], + _points[4] + o._points[4], + _points[5] + o._points[5], + _points[6] + o._points[6], + _points[7] + o._points[7], + ]); + + /// Returns a curve whose coordinates are this curve's multiplied by [x]. + CubicBezier operator *(double x) => CubicBezier.raw([ + _points[0] * x, + _points[1] * x, + _points[2] * x, + _points[3] * x, + _points[4] * x, + _points[5] * x, + _points[6] * x, + _points[7] * x, + ]); + + /// Returns a curve whose coordinates are this curve's divided by [x]. + CubicBezier operator /(double x) => this * (1.0 / x); + + /// Returns a copy of this curve with [transformer] applied to each of its + /// anchor and control points. + CubicBezier transformed(PointTransformer transformer) { + final newCubic = _MutableCubicBezier(); + for (var i = 0; i < 8; i++) { + newCubic._points[i] = _points[i]; + } + newCubic.transform(transformer); + return newCubic; + } + + @override + String toString() { + return '${objectRuntimeType(this, 'CubicBezier')}' + '(anchor0: (${anchor0X.toStringAsFixed(1)}, ${anchor0Y.toStringAsFixed(1)}), ' + 'control0: (${control0X.toStringAsFixed(1)}, ${control0Y.toStringAsFixed(1)}), ' + 'control1: (${control1X.toStringAsFixed(1)}, ${control1Y.toStringAsFixed(1)}), ' + 'anchor1: (${anchor1X.toStringAsFixed(1)}, ${anchor1Y.toStringAsFixed(1)}))'; + } + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + + return other is CubicBezier && listEquals(other._points, _points); + } + + @override + int get hashCode => Object.hashAll(_points); +} + +/// A mutable version of [CubicBezier], used by [CubicBezier.transformed] to +/// transform the points of a curve in place without creating new +/// [CubicBezier]s. +class _MutableCubicBezier extends CubicBezier { + _MutableCubicBezier() : super.raw(List.filled(8, 0)); + + void _transformOnePoint(PointTransformer f, int ix) { + final (double, double) result = f(_points[ix], _points[ix + 1]); + _points[ix] = result.$1; + _points[ix + 1] = result.$2; + } + + void transform(PointTransformer f) { + _transformOnePoint(f, 0); + _transformOnePoint(f, 2); + _transformOnePoint(f, 4); + _transformOnePoint(f, 6); + } +} + +/// Returns a [Path] built from the given [cubics]. +/// +/// This is the building block behind [RoundedPolygon.toPath] and +/// [Morph.toPath], and is useful when working with a list of curves obtained +/// from [Morph.toCubics] directly. +/// +/// [startAngle] places the start point of the first curve at that angle, in +/// radians, around [rotationPivot], rotating the whole path to get it there. +/// Zero is to the right of the pivot and `pi / 2` below it, since y grows +/// downwards. +/// The default of zero is special: it skips the rotation entirely and leaves +/// the curves as given. +/// +/// If [repeatPath] is true, the curves are added twice before the [Path] is +/// closed. This is useful when the caller would like to draw parts of the path +/// while offsetting the start and stop positions, for example when phasing and +/// rotating a path to simulate motion as a star-shaped circular progress +/// indicator advances. +/// +/// If [closePath] is false, the returned [Path] is left open. +/// +/// [rotationPivot] is the point [startAngle] rotates the path around, and the +/// point its angle is measured from. It defaults to the origin, which suits +/// curves laid out around [Offset.zero]. +Path pathFromCubics( + List cubics, { + double startAngle = 0, + bool repeatPath = false, + bool closePath = true, + Offset rotationPivot = Offset.zero, +}) { + var path = Path(); + + var first = true; + CubicBezier? firstCubic; + + for (final cubic in cubics) { + if (first) { + path.moveTo(cubic.anchor0X, cubic.anchor0Y); + if (startAngle != 0) { + firstCubic = cubic; + } + first = false; + } + + path.cubicTo( + cubic.control0X, + cubic.control0Y, + cubic.control1X, + cubic.control1Y, + cubic.anchor1X, + cubic.anchor1Y, + ); + } + + if (repeatPath) { + var firstInRepeat = true; + for (final cubic in cubics) { + if (firstInRepeat) { + path.lineTo(cubic.anchor0X, cubic.anchor0Y); + firstInRepeat = false; + } + + path.cubicTo( + cubic.control0X, + cubic.control0Y, + cubic.control1X, + cubic.control1Y, + cubic.anchor1X, + cubic.anchor1Y, + ); + } + } + + if (closePath) { + path.close(); + } + + if (startAngle != 0 && firstCubic != null) { + final double angleToFirstCubic = math.atan2( + cubics[0].anchor0Y - rotationPivot.dy, + cubics[0].anchor0X - rotationPivot.dx, + ); + // Rotate the path around the pivot so that it starts from the given angle. + path = path.transform( + (Matrix4.identity() + ..translateByDouble(rotationPivot.dx, rotationPivot.dy, 0, 1) + ..rotateZ(-angleToFirstCubic + startAngle) + ..translateByDouble(-rotationPivot.dx, -rotationPivot.dy, 0, 1)) + .storage, + ); + } + + return path; +} diff --git a/packages/material_ui/lib/src/shapes/double_mapping.dart b/packages/material_ui/lib/src/shapes/double_mapping.dart new file mode 100644 index 000000000000..de626c61d672 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/double_mapping.dart @@ -0,0 +1,156 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +import 'utils.dart'; + +/// Checks if the given progress is in the given progress range. +/// +/// Since progress is in the [0..1) interval and wraps, there is a special case +/// when [progressTo] < [progressFrom]. For example, if the progress range is +/// 0.7 to 0.2, both 0.8 and 0.1 are inside and 0.5 is outside. +@internal +bool progressInRange(double progress, double progressFrom, double progressTo) { + if (progressTo >= progressFrom) { + return progress >= progressFrom && progress <= progressTo; + } else { + return progress >= progressFrom || progress <= progressTo; + } +} + +/// Maps from one set of progress values to another. +/// +/// This is used to retrieve the value on one shape that maps to the +/// appropriate value on the other. +@internal +double linearMap(List xValues, List yValues, double x) { + assert(x >= 0 && x <= 1, 'Invalid progress $x'); + + var segmentStartIndex = -1; + + for (var i = 0; i < xValues.length; i++) { + if (progressInRange(x, xValues[i], xValues[(i + 1) % xValues.length])) { + segmentStartIndex = i; + break; + } + } + + if (segmentStartIndex == -1) { + throw StateError('segmentStartIndex not found.'); + } + + final int segmentEndIndex = (segmentStartIndex + 1) % xValues.length; + final double segmentSizeX = positiveModulo( + xValues[segmentEndIndex] - xValues[segmentStartIndex], + 1, + ); + final double segmentSizeY = positiveModulo( + yValues[segmentEndIndex] - yValues[segmentStartIndex], + 1, + ); + final double positionInSegment = segmentSizeX < 0.001 + ? 0.5 + : positiveModulo(x - xValues[segmentStartIndex], 1) / segmentSizeX; + + return positiveModulo(yValues[segmentStartIndex] + segmentSizeY * positionInSegment, 1); +} + +/// [DoubleMapper] creates mappings from values in the [0..1) source space to +/// values in the [0..1) target space, and back. +/// +/// This mapping is created given a finite list of representative mappings, and +/// this is extended to the whole interval by linear interpolation, and +/// wrapping around. +/// +/// For example, if we have mappings 0.2 to 0.5 and 0.4 to 0.6, then 0.3 +/// (which is in the middle of the source interval) will be mapped to 0.55 +/// (the middle of the targets for the interval), 0.21 will map to 0.505, and +/// so on. +/// +/// As a more complete example, if we use x to represent a value in the source +/// space and y for the target space, and given as input the mappings 0 to 0, +/// 0.5 to 0.25, this will create a mapping that: { if x in [0 .. 0.5] } +/// y = x / 2 { if x in [0.5 .. 1] } y = 0.25 + (x - 0.5) * 1.5 = x * 1.5 - 0.5 +/// +/// The mapping can also be used the other way around (using the [mapBack] +/// function), resulting in: { if y in [0 .. 0.25] } x = y * 2 { if y in +/// [0.25 .. 1] } x = (y + 0.5) / 1.5 This is used to create mappings of +/// progress values between the start and end shape, which is then used to +/// insert new curves and match curves overall. +@internal +class DoubleMapper { + DoubleMapper(List<(double, double)> mappings) { + _sourceValues = List.filled(mappings.length, 0); + _targetValues = List.filled(mappings.length, 0); + for (var i = 0; i < mappings.length; i++) { + final (double, double) pair = mappings[i]; + _sourceValues[i] = pair.$1; + _targetValues[i] = pair.$2; + } + validateProgress(_sourceValues); + validateProgress(_targetValues); + } + + // Any 2 points in the (x, x) diagonal, with x in the [0, 1) range, define + // the identity mapping. They are spread out as much as possible to minimize + // floating point errors. + static final identity = DoubleMapper([(0.0, 0.0), (0.5, 0.5)]); + + late final List _sourceValues; + + late final List _targetValues; + + /// Maps a value from the source to the target space. + double map(double x) => linearMap(_sourceValues, _targetValues, x); + + /// Maps a value from the target back to the source space. + double mapBack(double x) => linearMap(_targetValues, _sourceValues, x); +} + +/// Verifies that a list of progress values are all in the range [0.0, 1.0) +/// and are monotonically increasing, allowing at most one wraparound. +/// +/// Throws [ArgumentError] if validation fails. +@internal +void validateProgress(List p) { + if (p.isEmpty) { + throw ArgumentError('List is empty.'); + } + + double prev = p.last; + var wraps = 0; + + for (var i = 0; i < p.length; i++) { + final double curr = p[i]; + + if (curr < 0 || curr >= 1) { + throw ArgumentError('Progress outside of range: ${p.join(', ')}'); + } + + if (progressDistance(curr, prev).abs() <= distanceEpsilon) { + throw ArgumentError('Progress repeats a value: ${p.join(', ')}'); + } + + if (curr < prev) { + wraps++; + if (wraps > 1) { + throw ArgumentError('Progress wraps more than once: ${p.join(', ')}'); + } + } + + prev = curr; + } +} + +/// Distance between two progress values, considering wrap-around. +/// +/// For example, the distance between 0.99 and 0.0 is 0.01. +@internal +double progressDistance(double p1, double p2) { + final double diff = (p1 - p2).abs(); + return math.min(diff, 1.0 - diff); +} diff --git a/packages/material_ui/lib/src/shapes/feature_mapping.dart b/packages/material_ui/lib/src/shapes/feature_mapping.dart new file mode 100644 index 000000000000..cf309e1ef004 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/feature_mapping.dart @@ -0,0 +1,213 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import 'cubic.dart'; +import 'double_mapping.dart'; +import 'features.dart'; +import 'point.dart'; +import 'utils.dart'; + +/// A [Feature] paired with the [0..1] progress at which it sits along the +/// outline of its polygon. +@internal +class ProgressableFeature { + /// Creates a [ProgressableFeature]. + const ProgressableFeature(this.progress, this.feature); + + /// The [0..1] progress at which [feature] sits along the polygon outline. + final double progress; + + /// The feature at [progress]. + final Feature feature; +} + +/// A candidate pairing of two features, along with the distance between them. +@internal +class DistanceVertex { + /// Creates a [DistanceVertex]. + const DistanceVertex(this.distance, this.f1, this.f2); + + /// The distance between [f1] and [f2]. + final double distance; + + /// The feature from the first polygon. + final ProgressableFeature f1; + + /// The feature from the second polygon. + final ProgressableFeature f2; +} + +/// Creates a mapping between the "features" (rounded corners) of two shapes. +@internal +DoubleMapper featureMapper( + List features1, + List features2, +) { + // We only use corners for this mapping. + final filteredFeatures1 = []; + for (var i = 0; i < features1.length; i++) { + if (features1[i].feature.isCorner) { + filteredFeatures1.add(features1[i]); + } + } + + final filteredFeatures2 = []; + for (var i = 0; i < features2.length; i++) { + if (features2[i].feature.isCorner) { + filteredFeatures2.add(features2[i]); + } + } + + final List<(double, double)> featureProgressMapping = doMapping( + filteredFeatures1, + filteredFeatures2, + ); + + return DoubleMapper(featureProgressMapping); +} + +/// Returns a mapping of the features between [features1] and [features2]. +/// +/// The return is a list of pairs in which the first element is the progress of +/// a feature in [features1] and the second element is the progress of the +/// feature in [features2] that we mapped it to. The list is sorted by the +/// first element. To do this: +/// +/// 1) Compute the distance for all pairs of features in +/// ([features1] x [features2]). +/// 2) Sort ascending by such distance. +/// 3) Try to add them, from smallest distance to biggest, ensuring that: +/// a) The features we are mapping haven't been mapped yet. +/// b) We are not adding a crossing in the mapping. Since the mapping is +/// sorted by the first element of each pair, this means that the second +/// elements of each pair are monotonically increasing, except maybe one +/// time (counting all pairs of consecutive elements, and the last +/// element to first element). +@internal +List<(double, double)> doMapping( + List features1, + List features2, +) { + final distanceVertexList = []; + + for (final f1 in features1) { + for (final f2 in features2) { + final double d = featureDistSquared(f1.feature, f2.feature); + if (d != double.maxFinite) { + distanceVertexList.add(DistanceVertex(d, f1, f2)); + } + } + } + + distanceVertexList.sort((a, b) => a.distance.compareTo(b.distance)); + + // Special cases. + if (distanceVertexList.isEmpty) { + return [(0.0, 0.0), (0.5, 0.5)]; + } + + if (distanceVertexList.length == 1) { + final DistanceVertex d = distanceVertexList.first; + + final double f1 = d.f1.progress; + final double f2 = d.f2.progress; + + return [(f1, f2), ((f1 + 0.5) % 1, (f2 + 0.5) % 1)]; + } + + final helper = _MappingHelper(); + + for (final d in distanceVertexList) { + helper.addMapping(d.f1, d.f2); + } + + return helper.mapping; +} + +class _MappingHelper { + // List of mappings from progress in the start shape to progress in the + // end shape. + // We keep this list sorted by the first element. + final mapping = <(double, double)>[]; + + // Which features in the start shape have we used and which in the end shape. + final _usedF1 = {}; + final _usedF2 = {}; + + void addMapping(ProgressableFeature f1, ProgressableFeature f2) { + // We don't want to map the same feature twice. + if (_usedF1.contains(f1) || _usedF2.contains(f2)) { + return; + } + + // List is sorted, find where we need to insert this new mapping. + final int index = binarySearchBy<(double, double), double>( + mapping, + (it) => it.$1, + (a, b) => a.compareTo(b), + f1.progress, + ); + + if (index >= 0) { + throw StateError("There can't be two features with the same progress."); + } + + final int insertionIndex = -index - 1; + final int n = mapping.length; + + // We can always add the first 1 element. + if (n >= 1) { + final (double before1, double before2) = mapping[(insertionIndex + n - 1) % n]; + final (double after1, double after2) = mapping[insertionIndex % n]; + + // We don't want features that are way too close to each other, that will + // make the DoubleMapper unstable. + if (progressDistance(f1.progress, before1) < distanceEpsilon || + progressDistance(f1.progress, after1) < distanceEpsilon || + progressDistance(f2.progress, before2) < distanceEpsilon || + progressDistance(f2.progress, after2) < distanceEpsilon) { + return; + } + + // When we have 2 or more elements, we need to ensure we are not adding + // extra crossings. + if (n > 1 && !progressInRange(f2.progress, before2, after2)) { + return; + } + } + + // All good, we can add the mapping. + mapping.insert(insertionIndex, (f1.progress, f2.progress)); + _usedF1.add(f1); + _usedF2.add(f2); + } +} + +/// Returns the squared distance between the representative points of two +/// features on the two different shapes. +/// +/// This information is used to determine how to map features (and the curves +/// that make up those features). +@internal +double featureDistSquared(Feature f1, Feature f2) { + if (f1 is CornerFeature && f2 is CornerFeature && f1.convex != f2.convex) { + // Simple hack to force all features to map only to features of the same + // concavity, by returning an infinitely large distance in that case. + return double.maxFinite; + } + + return (featureRepresentativePoint(f1) - featureRepresentativePoint(f2)).distanceSquared; +} + +/// Returns the point that best represents [feature] when matching features +/// between two shapes. +@internal +Point featureRepresentativePoint(Feature feature) { + final List cubics = feature.cubics; + final double x = (cubics.first.anchor0X + cubics.last.anchor1X) / 2; + final double y = (cubics.first.anchor0Y + cubics.last.anchor1Y) / 2; + return Point(x, y); +} diff --git a/packages/material_ui/lib/src/shapes/features.dart b/packages/material_ui/lib/src/shapes/features.dart new file mode 100644 index 000000000000..e15033903505 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/features.dart @@ -0,0 +1,237 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'morph.dart'; +library; + +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; + +import 'cubic.dart'; +import 'point.dart'; + +/// While a polygon's shape can be drawn solely using a list of [CubicBezier] +/// objects representing its raw curves and lines, features add an extra layer +/// of context to groups of cubics. Features group cubics into (straight) edges, +/// convex corners, or concave corners. For example, rounding a rectangle adds +/// many cubics around its edges, but the rectangle's overall number of corners +/// remains the same. [Morph] therefore uses this grouping for several reasons: +/// - Noise Reduction: Grouping cubics reduces the amount of noise introduced +/// by individual cubics (as seen in the rounded rectangle example). +/// - Mapping Base: The grouping serves as the base set for [Morph]'s mapping +/// process. +/// - Curve Type Mapping: [Morph] maps similar curve types (convex, concave) +/// together. Note that edges or features created with +/// [Feature.ignorable] are ignored in the default mapping. +/// +/// By using features, you can manipulate polygon shapes with more context and +/// control. +@immutable +abstract class Feature { + /// Creates a [Feature] spanning the given [cubics]. + const Feature._(List cubics) : _cubics = cubics; + + /// Groups a list of [CubicBezier] objects into a feature that should be + /// ignored in the default [Morph] mapping. The feature can have any + /// indentation. + /// + /// Sometimes, it's helpful to ignore certain features when morphing shapes. + /// This is because only the features you mark as important will be smoothly + /// transitioned between the start and end shapes. Additionally, the default + /// morph algorithm will try to match convex corners to convex corners and + /// concave to concave. Marking features as ignorable will influence this + /// matching. + /// + /// For example, given a 12-pointed star, marking all concave corners as + /// ignorable will create a [Morph] that only considers the outer corners of + /// the star. As a result, depending on the morphed to shape, the animation + /// may have fewer intersections and rotations. Another example for the other + /// way around is a [Morph] between a pointed up triangle to a square. + /// Marking the square's top edge as a convex corner matches it to the + /// triangle's upper corner. Instead of moving triangle's upper corner to one + /// of rectangle's corners, the animation now splits the triangle to match + /// squares' outer corners. + /// + /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. + factory Feature.ignorable(List cubics) => _validated(EdgeFeature(cubics)); + + /// Groups a [CubicBezier] object into an edge (neither inward nor outward + /// indentation in a shape). + factory Feature.edge(CubicBezier cubic) => EdgeFeature([cubic]); + + /// Groups a list of [CubicBezier] objects into a convex corner (outward + /// indentation in a shape). + /// + /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. + factory Feature.convexCorner(List cubics) => _validated(CornerFeature(cubics)); + + /// Groups a list of [CubicBezier] objects into a concave corner (inward + /// indentation in a shape). + /// + /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. + factory Feature.concaveCorner(List cubics) => + _validated(CornerFeature(cubics, convex: false)); + + static Feature _validated(Feature feature) { + if (feature._cubics.isEmpty) { + throw ArgumentError('Features need at least one cubic.'); + } + + if (!_isContinuous(feature)) { + throw ArgumentError( + 'Feature must be continuous, with the anchor points of all cubics ' + 'matching the anchor points of the preceding and succeeding cubics', + ); + } + + return feature; + } + + static bool _isContinuous(Feature feature) { + const distanceEpsilon = 1e-5; + CubicBezier prevCubic = feature._cubics.first; + for (var i = 1; i < feature._cubics.length; i++) { + final CubicBezier cubic = feature._cubics[i]; + if ((cubic.anchor0X - prevCubic.anchor1X).abs() > distanceEpsilon || + (cubic.anchor0Y - prevCubic.anchor1Y).abs() > distanceEpsilon) { + return false; + } + prevCubic = cubic; + } + return true; + } + + final List _cubics; + + /// The cubic curves defining this feature, as an unmodifiable list. + List get cubics => UnmodifiableListView(_cubics); + + /// Whether this Feature gets ignored in the [Morph] mapping. + /// + /// See [Feature.ignorable] for more details. + bool get isIgnorable; + + /// Whether this Feature is an Edge with no inward or outward indentation. + bool get isEdge; + + /// Whether this Feature is a corner. + bool get isCorner; + + /// Whether this Feature is a convex corner (outward indentation in a shape). + bool get isConvexCorner; + + /// Whether this Feature is a concave corner (inward indentation in a shape). + bool get isConcaveCorner; + + /// Transforms the points in this [Feature] with the given [transformer] and + /// returns a new [Feature]. + Feature transformed(PointTransformer transformer); + + /// A new [Feature] with the points that define the shape of this [Feature] + /// in reversed order. + Feature get reversed; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is Feature && + other.runtimeType == runtimeType && + listEquals(other._cubics, _cubics); + } + + @override + int get hashCode => Object.hashAll(_cubics); +} + +/// Edges have only a list of the cubic curves which make up the edge. Edges +/// lie between corners and have no vertex or concavity; the curves are simply +/// straight lines (represented by [CubicBezier] curves). +@internal +class EdgeFeature extends Feature { + /// Creates an [EdgeFeature] from the given cubics. + const EdgeFeature(super._cubics) : super._(); + + @override + Feature transformed(PointTransformer transformer) => + EdgeFeature(List.generate(_cubics.length, (i) => _cubics[i].transformed(transformer))); + + @override + Feature get reversed => + EdgeFeature(List.generate(_cubics.length, (i) => _cubics[_cubics.length - 1 - i].reversed)); + + @override + bool get isIgnorable => true; + + @override + bool get isEdge => true; + + @override + bool get isCorner => false; + + @override + bool get isConvexCorner => false; + + @override + bool get isConcaveCorner => false; + + @override + String toString() => '${objectRuntimeType(this, 'EdgeFeature')}(cubics: $_cubics)'; +} + +/// Corners contain the list of cubic curves which describe how the corner is +/// rounded (or not), and a flag indicating whether the corner is convex. A +/// regular polygon has all convex corners, while a star polygon generally +/// (but not necessarily) has both convex (outer) and concave (inner) corners. +@internal +class CornerFeature extends Feature { + /// Creates a [CornerFeature] from the given cubics. + const CornerFeature(super._cubics, {this.convex = true}) : super._(); + + /// Whether this corner is convex. + final bool convex; + + @override + Feature transformed(PointTransformer transformer) => CornerFeature( + List.generate(_cubics.length, (i) => _cubics[i].transformed(transformer)), + convex: convex, + ); + + @override + Feature get reversed => CornerFeature( + List.generate(_cubics.length, (i) => _cubics[_cubics.length - 1 - i].reversed), + convex: !convex, + ); + + @override + bool get isIgnorable => false; + + @override + bool get isEdge => false; + + @override + bool get isCorner => true; + + @override + bool get isConvexCorner => convex; + + @override + bool get isConcaveCorner => !convex; + + @override + String toString() { + return '${objectRuntimeType(this, 'CornerFeature')}' + '(cubics: $_cubics, convex: $convex)'; + } + + @override + bool operator ==(Object other) => + super == other && other is CornerFeature && other.convex == convex; + + @override + int get hashCode => Object.hash(Object.hashAll(_cubics), convex); +} diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart new file mode 100644 index 000000000000..fa9231666cde --- /dev/null +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -0,0 +1,282 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +import 'cubic.dart'; +import 'double_mapping.dart'; +import 'feature_mapping.dart'; +import 'polygon_measure.dart'; +import 'rounded_polygon.dart'; +import 'utils.dart'; + +/// An animation between two [RoundedPolygon] shapes. +/// +/// Morphing between arbitrary objects can be problematic because it can be +/// difficult to determine how the points of a given shape map to the points of +/// some other shape. [Morph] simplifies the problem by only operating on +/// [RoundedPolygon] objects, which are known to have similar, contiguous +/// structures. For one thing, the shape of a polygon is contiguous from start +/// to end (compared to an arbitrary [Path] object, which could have one or more +/// `moveTo` operations in the shape). Also, all edges of a polygon shape are +/// represented by [CubicBezier] objects, thus the start and end shapes use similar +/// operations. Two Polygon shapes then only differ in the quantity and +/// placement of their curves. The morph works by determining how to map the +/// curves of the two shapes together (based on proximity and other +/// information, such as distance to polygon vertices and concavity), and +/// splitting curves when the shapes do not have the same number of curves or +/// when the curve placement within the shapes is very different. +@immutable +class Morph { + /// Creates a [Morph] between the [start] and [end] polygons. + /// + /// The mapping between the two shapes is computed once, here, so a [Morph] + /// should be created ahead of time and reused across frames rather than + /// rebuilt for each value of progress. + Morph(this.start, this.end) : _morphMatch = _match(start, end); + + /// The shape this morph produces at a progress of 0. + final RoundedPolygon start; + + /// The shape this morph produces at a progress of 1. + final RoundedPolygon end; + + /// The structure which holds the actual shape being morphed. It contains all + /// cubics necessary to represent the start and end shapes (the original + /// cubics in the shapes may be cut to align the start/end shapes), matched + /// one to one in each pair. + final List<(CubicBezier, CubicBezier)> _morphMatch; + + /// [_match], called at [Morph] construction time, creates the structure used + /// to animate between the start and end shapes. The technique is to match + /// geometry (curves) between the shapes when and where possible, and to + /// create new/placeholder curves when necessary (when one of the shapes has + /// more curves than the other). The result is a list of pairs of CubicBezier + /// curves. Those curves are the matched pairs: the first of each pair holds + /// the geometry of the start shape, the second holds the geometry for the + /// end shape. Changing the progress of a Morph object simply interpolates + /// between all pairs of curves for the morph shape. + /// + /// Curves on both shapes are matched by running the [Measurer] to determine + /// where the points are in each shape (proportionally, along the outline), + /// and then running [featureMapper] which decides how to map (match) all of + /// the curves with each other. + static List<(CubicBezier, CubicBezier)> _match(RoundedPolygon p1, RoundedPolygon p2) { + // Measure polygons, returns lists of measured cubics for each polygon, + // which we then use to match start/end curves. + final measuredPolygon1 = MeasuredPolygon.measure(const LengthMeasurer(), p1); + final measuredPolygon2 = MeasuredPolygon.measure(const LengthMeasurer(), p2); + + // features1 and 2 will contain the list of corners (just the inner + // circular curve) along with the progress at the middle of those corners. + // These measurement values are then used to compare and match between the + // two polygons. + final List features1 = measuredPolygon1.features; + final List features2 = measuredPolygon2.features; + + // Map features: doubleMapper is the result of mapping the features in each + // shape to the closest feature in the other shape. + // Given a progress in one of the shapes it can be used to find the + // corresponding progress in the other shape (in both directions). + final DoubleMapper doubleMapper = featureMapper(features1, features2); + + // cut point on poly2 is the mapping of the 0 point on poly1. + final double polygon2CutPoint = doubleMapper.map(0); + + // Cut and rotate. + // Polygons start at progress 0, and the featureMapper has decided that we + // want to match progress 0 in the first polygon to `polygon2CutPoint` on + // the second polygon. So we need to cut the second polygon there and + // "rotate it", so as we walk through both polygons we can find the + // matching. The resulting bs1/2 are MeasuredPolygons, whose MeasuredCubics + // start from outlineProgress=0 and increasing until outlineProgress=1. + final bs1 = measuredPolygon1; + final MeasuredPolygon bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint); + + // Match. + // Now we can compare the two lists of measured cubics and create a list of + // pairs of cubics [ret], which are the start/end curves that represent the + // Morph object and the start and end shapes, and which can be interpolated + // to animate the between those shapes. + final ret = <(CubicBezier, CubicBezier)>[]; + // i1/i2 are the indices of the current cubic on the start (1) and end (2) + // shapes. + var i1 = 0; + var i2 = 0; + // b1, b2 are the current measured cubic for each polygon. + MeasuredCubic? b1 = bs1.cubicAtOrNull(i1++); + MeasuredCubic? b2 = bs2.cubicAtOrNull(i2++); + // Iterate until all curves are accounted for and matched. + while (b1 != null && b2 != null) { + // Progresses are in shape1's perspective + // b1a, b2a are ending progress values of current measured cubics in + // [0,1] range. + final double b1a = (i1 == bs1.length) ? 1.0 : b1.endOutlineProgress; + final double b2a = (i2 == bs2.length) + ? 1.0 + : doubleMapper.mapBack(positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1)); + final double minb = math.min(b1a, b2a); + // min b is the progress at which the curve that ends first ends. + // If both curves ends roughly there, no cutting is needed, we have a + // match. + // If one curve extends beyond, we need to cut it. + final (MeasuredCubic seg1, MeasuredCubic? newb1) = (b1a > minb + angleEpsilon) + ? b1.cutAtProgress(minb) + : (b1, bs1.cubicAtOrNull(i1++)); + + final (MeasuredCubic seg2, MeasuredCubic? newb2) = (b2a > minb + angleEpsilon) + ? b2.cutAtProgress(positiveModulo(doubleMapper.map(minb) - polygon2CutPoint, 1)) + : (b2, bs2.cubicAtOrNull(i2++)); + + ret.add((seg1.cubic, seg2.cubic)); + b1 = newb1; + b2 = newb2; + } + + assert(b1 == null && b2 == null, "Expected both Polygon's CubicBezier to be fully matched"); + + return ret; + } + + /// The axis-aligned bounds of this morph, covering both of its shapes. + /// + /// This solves for the actual extrema of every curve. See + /// [approximateBounds] for a cheaper result that is never smaller than this + /// one. + Rect get bounds => start.bounds.expandToInclude(end.bounds); + + /// A cheaper alternative to [bounds], based on the min/max values of all + /// anchor and control points that make up the two shapes. + /// + /// The result is never smaller than [bounds], but can be larger. + Rect get approximateBounds => start.approximateBounds.expandToInclude(end.approximateBounds); + + /// Like [bounds], the axis-aligned bounds of this morph, but determining the + /// max dimension of the shapes (by calculating the distance from their + /// center to the start and midpoint of each curve) and returning a square + /// which can be used to hold the morph in any rotation. + /// + /// This can be used, for example, to calculate the max size of a UI element + /// meant to hold this morph in any rotation. + Rect get maxBounds => start.maxBounds.expandToInclude(end.maxBounds); + + /// Returns this morph's shape at [progress] as a list of [CubicBezier]s. + /// + /// [progress] runs from 0 at [start] to 1 at [end], and a value in between + /// gives a linear interpolation of the two. Values a little outside that + /// range give an exaggerated effect, useful for a bounce or an overshoot, + /// but values far outside it produce undefined shapes. + /// + /// This creates and populates a new list on every call. + List toCubics(double progress) { + final result = []; + + // The first/last mechanism here ensures that the final anchor point in the + // shape exactly matches the first anchor point. There can be rendering + // artifacts introduced by those points being slightly off, even by much + // less than a pixel. + CubicBezier? firstCubic; + CubicBezier? lastCubic; + + for (var i = 0; i < _morphMatch.length; i++) { + final (CubicBezier from, CubicBezier to) = _morphMatch[i]; + final cubic = CubicBezier.raw([ + lerp(from.anchor0X, to.anchor0X, progress), + lerp(from.anchor0Y, to.anchor0Y, progress), + lerp(from.control0X, to.control0X, progress), + lerp(from.control0Y, to.control0Y, progress), + lerp(from.control1X, to.control1X, progress), + lerp(from.control1Y, to.control1Y, progress), + lerp(from.anchor1X, to.anchor1X, progress), + lerp(from.anchor1Y, to.anchor1Y, progress), + ]); + + firstCubic ??= cubic; + if (lastCubic != null) { + result.add(lastCubic); + } + lastCubic = cubic; + } + + if (lastCubic != null && firstCubic != null) { + result.add( + CubicBezier.raw([ + lastCubic.anchor0X, + lastCubic.anchor0Y, + lastCubic.control0X, + lastCubic.control0Y, + lastCubic.control1X, + lastCubic.control1Y, + firstCubic.anchor0X, + firstCubic.anchor0Y, + ]), + ); + } + + return result; + } + + /// Returns a [Path] for this morph's shape at [progress]. + /// + /// [progress] runs from 0 at [start] to 1 at [end], and a value in between + /// gives a linear interpolation of the two. See [toCubics] for what values + /// outside that range do. + /// + /// [startAngle] places the start point of the first curve at that angle, in + /// radians, around [rotationPivot], rotating the whole path to get it there. + /// Zero is to the right of the pivot and `pi / 2` below it, since y grows + /// downwards. + /// The default of zero is special: it skips the rotation entirely and leaves + /// the curves as [toCubics] produced them. + /// + /// If [repeatPath] is true, the curves are added twice before the [Path] is + /// closed. This is useful when the caller would like to draw parts of the + /// path while offsetting the start and stop positions, for example when + /// phasing and rotating a path to simulate motion as a star-shaped circular + /// progress indicator advances. + /// + /// If [closePath] is false, the returned [Path] is left open. + /// + /// [rotationPivot] is the point [startAngle] rotates the path around, and the + /// point its angle is measured from. It defaults to the origin, which suits a + /// [Morph] between polygons with a zero [RoundedPolygon.center]. A [Morph] + /// between polygons centered elsewhere should pass their center, (0.5, 0.5) + /// for normalized ones. + Path toPath( + double progress, { + double startAngle = 0, + bool repeatPath = false, + bool closePath = true, + Offset rotationPivot = Offset.zero, + }) { + return pathFromCubics( + toCubics(progress), + startAngle: startAngle, + repeatPath: repeatPath, + closePath: closePath, + rotationPivot: rotationPivot, + ); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + + return other is Morph && other.start == start && other.end == end; + } + + @override + int get hashCode => Object.hash(start, end); + + @override + String toString() { + return '${objectRuntimeType(this, 'Morph')}' + '(start: $start, end: $end)'; + } +} diff --git a/packages/material_ui/lib/src/shapes/point.dart b/packages/material_ui/lib/src/shapes/point.dart new file mode 100644 index 000000000000..82112319d5fb --- /dev/null +++ b/packages/material_ui/lib/src/shapes/point.dart @@ -0,0 +1,98 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'cubic.dart'; +/// @docImport 'features.dart'; +/// @docImport 'morph.dart'; +/// @docImport 'rounded_polygon.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:vector_math/vector_math_64.dart' show Matrix4, Vector3; + +/// Transforms the point (x, y) and returns the transformed coordinates. +/// +/// This is used by [CubicBezier.transformed], [Feature.transformed] and +/// [RoundedPolygon.transformed] to apply arbitrary transformations to a shape. +typedef PointTransformer = (double, double) Function(double x, double y); + +/// A two dimensional coordinate pair used by the shape algorithms. +@internal +typedef Point = Offset; + +/// The geometry [Offset] does not provide. +@internal +extension PointGeometry on Offset { + /// The horizontal coordinate of this point. + double get x => dx; + + /// The vertical coordinate of this point. + double get y => dy; + + /// Returns this point rotated a quarter turn around (0, 0), taking (x, y) + /// to (-y, x). + Point rotate90() => Point(-y, x); + + /// Returns this point rotated by [degrees] around [center]. + Point rotate(double degrees, {Point center = Point.zero}) { + final double radians = degrees * math.pi / 180; + final Point off = this - center; + final double cos = math.cos(radians); + final double sin = math.sin(radians); + return Point(off.x * cos - off.y * sin, off.x * sin + off.y * cos) + center; + } + + /// The dot product of this point and [other], both taken as vectors. + double dotProduct(Point other) => x * other.x + y * other.y; + + /// Whether turning from this point to [other], both taken as vectors, is a + /// clockwise turn. + /// + /// This tests the sign of the Z coordinate of the cross product of the two, + /// which is zero when they are collinear, so collinear vectors are not + /// considered a clockwise turn. + bool turnsClockwiseTo(Point other) => (x * other.y - y * other.x) > 0; + + /// The unit vector pointing from (0, 0) towards this point. + Point get unitVector { + final double d = distance; + assert(d > 0, "Can't compute the unit vector of a zero-length vector"); + return this / d; + } + + /// Returns a copy of this point with [f] applied to it. + Point transformed(PointTransformer f) { + final (double, double) result = f(x, y); + return Point(result.$1, result.$2); + } +} + +/// Adapts a [Matrix4] into a [PointTransformer]. +extension Matrix4PointTransformer on Matrix4 { + /// Returns a [PointTransformer] that applies this matrix. + /// + /// This is the bridge between the transformation types Flutter already uses + /// and the shape transformation methods, so that a matrix built with the + /// usual [Matrix4] helpers can be passed straight to + /// [RoundedPolygon.transformed], [Morph], [Feature.transformed] or + /// [CubicBezier.transformed]: + /// + /// ```dart + /// final RoundedPolygon rotated = polygon.transformed( + /// Matrix4.rotationZ(math.pi / 4).asPointTransformer(), + /// ); + /// ``` + /// + /// Only the X and Y components of the result are used, so the Z translation + /// and perspective rows of the matrix have no effect. + PointTransformer asPointTransformer() { + return (x, y) { + final Vector3 vector = transform3(Vector3(x, y, 0)); + return (vector.x, vector.y); + }; + } +} diff --git a/packages/material_ui/lib/src/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/polygon_measure.dart new file mode 100644 index 000000000000..f728e92aaa33 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/polygon_measure.dart @@ -0,0 +1,405 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; + +import 'cubic.dart'; +import 'feature_mapping.dart'; +import 'features.dart'; +import 'point.dart'; +import 'rounded_polygon.dart'; +import 'utils.dart'; + +/// A [RoundedPolygon] whose cubics have been measured, so that each one is +/// associated with the [0..1] progress range it covers along the outline. +@internal +class MeasuredPolygon { + MeasuredPolygon._({ + required Measurer measurer, + required this._features, + required List cubics, + required List outlineProgress, + }) : assert( + outlineProgress.length == cubics.length + 1, + 'Outline progress length is expected to be the cubics length + 1', + ), + assert(outlineProgress.first == 0, 'First outline progress value is expected to be zero'), + assert(outlineProgress.last == 1, 'Last outline progress value is expected to be one'), + _measurer = measurer { + final measuredCubics = []; + var startOutlineProgress = 0.0; + for (var i = 0; i < cubics.length; i++) { + // Filter out "empty" cubics. + if ((outlineProgress[i + 1] - outlineProgress[i]) > distanceEpsilon) { + measuredCubics.add( + MeasuredCubic( + measurer: measurer, + cubic: cubics[i], + startOutlineProgress: startOutlineProgress, + endOutlineProgress: outlineProgress[i + 1], + ), + ); + // The next measured cubic will start exactly where this one ends. + startOutlineProgress = outlineProgress[i + 1]; + } + } + // We could have removed empty cubics at the end. Ensure the last measured + // cubic ends at 1. + measuredCubics[measuredCubics.length - 1].updateProgressRange(endOutlineProgress: 1); + _cubics = measuredCubics; + } + + factory MeasuredPolygon.measure(Measurer measurer, RoundedPolygon polygon) { + final cubics = []; + final featureToCubic = <(Feature, int)>[]; + + // Get the cubics from the polygon, at the same time, extract the features + // and keep a reference to the representative cubic we will use. + for (var featureIndex = 0; featureIndex < polygon.features.length; featureIndex++) { + final Feature feature = polygon.features[featureIndex]; + for (var cubicIndex = 0; cubicIndex < feature.cubics.length; cubicIndex++) { + if (feature is CornerFeature && cubicIndex == feature.cubics.length ~/ 2) { + featureToCubic.add((feature, cubics.length)); + } + cubics.add(feature.cubics[cubicIndex]); + } + } + + final measures = List.filled(cubics.length + 1, 0); + var totalMeasure = 0.0; + + for (var i = 0; i < cubics.length; i++) { + final double measure = measurer.measureCubic(cubics[i]); + if (measure < 0) { + throw StateError('Measured cubic is expected to be greater or equal to zero'); + } + totalMeasure += measure; + measures[i + 1] = totalMeasure; + } + + final outlineProgress = List.filled(measures.length, 0); + for (var i = 0; i < measures.length; i++) { + outlineProgress[i] = measures[i] / totalMeasure; + } + + final features = List.generate(featureToCubic.length, (i) { + final int ix = featureToCubic[i].$2; + return ProgressableFeature( + positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1), + featureToCubic[i].$1, + ); + }); + + return MeasuredPolygon._( + measurer: measurer, + features: features, + cubics: cubics, + outlineProgress: outlineProgress, + ); + } + + final Measurer _measurer; + + late final List _cubics; + + final List _features; + + List get features => UnmodifiableListView(_features); + + MeasuredCubic get first => _cubics.first; + + MeasuredCubic get last => _cubics.last; + + int get length => _cubics.length; + + MeasuredCubic operator [](int index) => _cubics[index]; + + MeasuredCubic? cubicAtOrNull(int index) { + final int length = _cubics.length; + + if (index < 0 || index >= length) { + return null; + } + + return _cubics[index]; + } + + /// Finds the point in the input list of measured cubics that passes the + /// given outline progress, and generates a new [MeasuredPolygon] (equivalent + /// to this), that starts at that point. + /// + /// This usually means cutting the cubic that crosses the outline progress + /// (unless the cut is at one of its ends). + /// For example, given outline progress 0.4 and measured cubics on these + /// outline progress ranges: + /// + /// c1 [0 -> 0.2] c2 [0.2 -> 0.5] c3 [0.5 -> 1.0] + /// + /// c2 will be cut in two, at the given outline progress, we can name these + /// c2a [0.2 -> 0.4] and c2b [0.4 -> 0.5] + /// + /// The return then will have measured cubics [c2b, c3, c1, c2a], and they + /// will have their outline progress ranges adjusted so the new list starts + /// at 0. + /// + /// c2b [0 -> 0.1] c3 [0.1 -> 0.6] c1 [0.6 -> 0.8] c2a [0.8 -> 1.0] + MeasuredPolygon cutAndShift(double cuttingPoint) { + if (cuttingPoint < 0 || cuttingPoint > 1) { + throw ArgumentError('Cutting point is expected to be between 0 and 1'); + } + + if (cuttingPoint < distanceEpsilon) { + return this; + } + + // Find the index of cubic we want to cut + final int targetIndex = _cubics.indexWhere( + (c) => cuttingPoint >= c._startOutlineProgress && cuttingPoint <= c._endOutlineProgress, + ); + final MeasuredCubic target = _cubics[targetIndex]; + + // Cut the target cubic. + // b1, b2 are two resulting cubics after cut + final (MeasuredCubic b1, MeasuredCubic b2) = target.cutAtProgress(cuttingPoint); + + // Construct the list of the cubics we need: + // * The second part of the target cubic (after the cut) + // * All cubics after the target, until the end + All cubics from the + // start, before the target cubic + // * The first part of the target cubic (before the cut) + final List retCubics = [b2.cubic]; + for (var i = 1; i < _cubics.length; i++) { + retCubics.add(_cubics[(i + targetIndex) % _cubics.length].cubic); + } + retCubics.add(b1.cubic); + + // Construct the array of outline progress. + // For example, if we have 3 cubics with outline progress [0 .. 0.3], + // [0.3 .. 0.8] & [0.8 .. 1.0], and we cut + shift at 0.6: + // 0. 0123456789 + // |--|--/-|-| + // The outline progresses will start at 0 (the cutting point, that shifts + // to 0.0), then 0.8 - 0.6 = 0.2, then 1 - 0.6 = 0.4, then 0.3 - 0.6 + 1 = + // 0.7, then 1 (the cutting point again), all together: (0.0, 0.2, 0.4, + // 0.7, 1.0) + final retOutlineProgress = List.filled(_cubics.length + 2, 0); + + for (var i = 0; i < _cubics.length + 2; i++) { + if (i == 0) { + retOutlineProgress[i] = 0; + } else if (i == _cubics.length + 1) { + retOutlineProgress[i] = 1; + } else { + final int cubicIndex = (targetIndex + i - 1) % _cubics.length; + retOutlineProgress[i] = positiveModulo( + _cubics[cubicIndex]._endOutlineProgress - cuttingPoint, + 1, + ); + } + } + + // Shift the feature's outline progress too. + final List newFeatures = [ + for (var i = 0; i < _features.length; i++) + ProgressableFeature( + positiveModulo(_features[i].progress - cuttingPoint, 1), + _features[i].feature, + ), + ]; + + // Filter out all empty cubics (i.e. start and end anchor are (almost) the + // same point.) + return MeasuredPolygon._( + measurer: _measurer, + features: newFeatures, + cubics: retCubics, + outlineProgress: retOutlineProgress, + ); + } +} + +/// A [MeasuredCubic] holds information about the cubic itself, the feature +/// (if any) associated with it, and the outline progress values (start and +/// end) for the cubic. +/// +/// This information is used to match cubics between shapes that lie at similar +/// outline progress positions along their respective shapes (after matching +/// features and shifting). +/// +/// Outline progress is a value in [0..1) that represents the distance traveled +/// along the overall outline path of the shape. +@internal +class MeasuredCubic { + MeasuredCubic({ + required this.measurer, + required this.cubic, + required double startOutlineProgress, + required double endOutlineProgress, + }) : assert( + startOutlineProgress >= 0 && startOutlineProgress <= 1, + 'startOutlineProgress has to be in [0..1] range', + ), + assert( + endOutlineProgress >= 0 && endOutlineProgress <= 1, + 'endOutlineProgress has to be in range [0..1]', + ), + assert( + endOutlineProgress >= startOutlineProgress, + 'endOutlineProgress is expected to be equal or greater than ' + 'startOutlineProgress', + ), + _startOutlineProgress = startOutlineProgress, + _endOutlineProgress = endOutlineProgress { + measuredSize = measurer.measureCubic(cubic); + } + + final Measurer measurer; + + final CubicBezier cubic; + + late final double measuredSize; + + double _startOutlineProgress; + + double _endOutlineProgress; + + double get startOutlineProgress => _startOutlineProgress; + + double get endOutlineProgress => _endOutlineProgress; + + void updateProgressRange({double? startOutlineProgress, double? endOutlineProgress}) { + startOutlineProgress ??= _startOutlineProgress; + endOutlineProgress ??= _endOutlineProgress; + + if (endOutlineProgress < startOutlineProgress) { + throw ArgumentError( + 'endOutlineProgress is expected to be equal or greater than ' + 'startOutlineProgress', + ); + } + + _startOutlineProgress = startOutlineProgress; + _endOutlineProgress = endOutlineProgress; + } + + /// Cuts this [MeasuredCubic] into two at the given outline progress value. + (MeasuredCubic, MeasuredCubic) cutAtProgress(double cutOutlineProgress) { + // Floating point errors further up can cause cutOutlineProgress to land + // just slightly outside of the start/end progress for this cubic, so we + // limit it to those bounds to avoid further errors later + final double boundedCutOutlineProgress = clampDouble( + cutOutlineProgress, + _startOutlineProgress, + _endOutlineProgress, + ); + final double outlineProgressSize = _endOutlineProgress - _startOutlineProgress; + final double progressFromStart = boundedCutOutlineProgress - _startOutlineProgress; + + // Note that in earlier parts of the computation, we have empty + // MeasuredCubics (cubics with progressSize == 0), but those cubics are + // filtered out before this method is called. + final double relativeProgress = progressFromStart / outlineProgressSize; + final double t = measurer.findCubicCutPoint(cubic, relativeProgress * measuredSize); + + if (t < 0 || t > 1) { + throw ArgumentError('CubicBezier cut point is expected to be between 0 and 1.'); + } + + // c1/c2 are the two new cubics, then we return MeasuredCubics created + // from them. + final (CubicBezier c1, CubicBezier c2) = cubic.split(t); + return ( + MeasuredCubic( + measurer: measurer, + cubic: c1, + startOutlineProgress: _startOutlineProgress, + endOutlineProgress: boundedCutOutlineProgress, + ), + MeasuredCubic( + measurer: measurer, + cubic: c2, + startOutlineProgress: boundedCutOutlineProgress, + endOutlineProgress: _endOutlineProgress, + ), + ); + } + + @override + String toString() { + return 'MeasuredCubic(outlineProgress=' + '[$_startOutlineProgress .. $_endOutlineProgress], ' + 'size=$measuredSize, cubic=$cubic)'; + } +} + +/// Interface for measuring a cubic. Implementations can use whatever algorithm +/// desired to produce these measurement values. +@internal +abstract interface class Measurer { + /// Abstract const constructor. + const Measurer(); + + /// Returns size of given cubic, according to however the implementation + /// wants to measure the size (angle, length, etc). It has to be greater or + /// equal to 0. + double measureCubic(CubicBezier cubic); + + /// Given a cubic and a measure that should be between 0 and the value + /// returned by [measureCubic] (if not, it will be capped), finds the + /// parameter t of the cubic at which that measure is reached. + double findCubicCutPoint(CubicBezier cubic, double measure); +} + +/// Approximates the arc lengths of cubics by splitting the arc into segments +/// and calculating their sizes. The more segments, the more accurate the +/// result will be to the true arc length. The default implementation has at +/// least 98.5% accuracy on the case of a circular arc, which is the +/// worst case for our standard shapes. +@internal +class LengthMeasurer implements Measurer { + /// Creates a [LengthMeasurer]. + const LengthMeasurer(); + + // The minimum number needed to achieve up to 98.5% accuracy from the true + // arc length. + static const _segments = 3; + + @override + double measureCubic(CubicBezier cubic) { + return _closestProgressTo(cubic, double.infinity).$2; + } + + @override + double findCubicCutPoint(CubicBezier cubic, double measure) { + return _closestProgressTo(cubic, measure).$1; + } + + (double, double) _closestProgressTo(CubicBezier cubic, double threshold) { + if (threshold <= 0.0) { + return (0.0, 0.0); + } + + var total = 0.0; + var remainder = threshold; + var prev = Point(cubic.anchor0X, cubic.anchor0Y); + + for (var i = 1; i <= _segments; i++) { + final double progress = i / _segments; + final Point point = cubic.pointAt(progress); + final double segment = (point - prev).distance; + + if (segment >= remainder) { + return (progress - (1.0 - remainder / segment) / _segments, threshold); + } + + remainder -= segment; + total += segment; + prev = point; + } + + return (1.0, total); + } +} diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart new file mode 100644 index 000000000000..53141e7b5122 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -0,0 +1,1061 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'morph.dart'; +library; + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +import 'corner_rounding.dart'; +import 'cubic.dart'; +import 'features.dart'; +import 'point.dart'; +import 'utils.dart'; + +/// A closed polygonal shape, with optional rounding at its vertices. +/// +/// A polygon can be built from a number of vertices, from an ordered list of +/// vertices, or from a list of [Feature]s. +@immutable +class RoundedPolygon { + /// Creates a regular polygon with [numVertices] vertices, equally spaced + /// around a circle of the given [radius] about [center]. + /// + /// The default radius of 1 puts the vertices on the unit circle, giving a + /// shape 2 wide and 2 high, which will usually need rescaling with + /// [transformed] to suit the UI it is drawn in. + /// + /// [rounding] rounds every vertex the same way. [perVertexRounding] + /// overrides it, and must have [numVertices] elements when it is not null. + /// The default leaves the corners sharp and the edges straight. + /// + /// Throws [ArgumentError] if [numVertices] is less than 3, or if + /// [perVertexRounding] has the wrong number of elements. + factory RoundedPolygon( + int numVertices, { + double radius = 1, + Offset center = Offset.zero, + CornerRounding rounding = CornerRounding.unrounded, + List? perVertexRounding, + }) { + if (numVertices < 3) { + throw ArgumentError('numVertices must be at least 3.'); + } + + return RoundedPolygon.fromVertices( + _verticesFromNumVerts(numVertices, radius, center), + rounding: rounding, + perVertexRounding: perVertexRounding, + center: center, + ); + } + + RoundedPolygon._raw(List features, Point center) + : features = List.unmodifiable(features), + _center = center, + cubics = List.unmodifiable(_buildCubics(features, center)) { + CubicBezier prevCubic = cubics.last; + + for (final CubicBezier cubic in cubics) { + if ((cubic.anchor0X - prevCubic.anchor1X).abs() > distanceEpsilon || + (cubic.anchor0Y - prevCubic.anchor1Y).abs() > distanceEpsilon) { + throw ArgumentError( + 'RoundedPolygon must be contiguous, with the anchor points of all ' + 'curves matching the anchor points of the preceding and succeeding ' + 'cubics.', + ); + } + prevCubic = cubic; + } + } + + /// Creates a polygon with the given [vertices]. + /// + /// The list must be ordered: the outline runs from each vertex to the next + /// and closes from the last back to the first. Any other order gives + /// undefined results. + /// + /// [rounding] rounds every vertex the same way. [perVertexRounding] + /// overrides it, and must have the same length as [vertices] when it is not + /// null. The default leaves the corners sharp and the edges straight. + /// + /// [center] defaults to the average of [vertices]. + /// + /// Throws [ArgumentError] if [vertices] has fewer than 3 elements, or if + /// [perVertexRounding] has the wrong number of elements. + factory RoundedPolygon.fromVertices( + List vertices, { + CornerRounding rounding = CornerRounding.unrounded, + List? perVertexRounding, + Offset? center, + }) { + if (vertices.length < 3) { + throw ArgumentError('Polygons must have at least 3 vertices.'); + } + if (perVertexRounding != null && perVertexRounding.length != vertices.length) { + throw ArgumentError( + 'perVertexRounding list should be either null or ' + 'the same size as the number of vertices.', + ); + } + final corners = >[]; + final int n = vertices.length; + final roundedCorners = <_RoundedCorner>[]; + for (var i = 0; i < n; i++) { + final CornerRounding vtxRounding = perVertexRounding?[i] ?? rounding; + final int prevIndex = (i + n - 1) % n; + final int nextIndex = (i + 1) % n; + roundedCorners.add( + _RoundedCorner(vertices[prevIndex], vertices[i], vertices[nextIndex], vtxRounding), + ); + } + + // For each side, check if we have enough space to do the cuts needed, and + // if not split the available space, first for round cuts, then for + // smoothing if there is space left. Each element in this list is a pair, + // that represent how much we can do of the cut for the given side (side i + // goes from corner i to corner i+1), the elements of the pair are: first + // is how much we can use of expectedRoundCut, second how much of + // expectedCut. + final List<(double, double)> cutAdjusts = List.generate(n, (ix) { + final double expectedRoundCut = + roundedCorners[ix].expectedRoundCut + roundedCorners[(ix + 1) % n].expectedRoundCut; + final double expectedCut = + roundedCorners[ix].expectedCut + roundedCorners[(ix + 1) % n].expectedCut; + final Point vtx = vertices[ix]; + final Point nextVtx = vertices[(ix + 1) % n]; + final double sideSize = (vtx - nextVtx).distance; + + // Check expectedRoundCut first, and ensure we fulfill rounding needs + // first for both corners before using space for smoothing. + if (expectedRoundCut > sideSize) { + // Not enough room for fully rounding, see how much we can actually do. + return (sideSize / expectedRoundCut, 0.0); + } else if (expectedCut > sideSize) { + // We can do full rounding, but not full smoothing. + return (1.0, (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)); + } else { + // There is enough room for rounding & smoothing. + return (1.0, 1.0); + } + }); + + // Create and store list of beziers for each [potentially] rounded corner. + for (var i = 0; i < n; i++) { + // allowedCuts[0] is for the side from the previous corner to this one, + // allowedCuts[1] is for the side from this corner to the next one. + final allowedCuts = List.filled(2, 0); + + for (var delta = 0; delta <= 1; delta++) { + final (double roundCutRatio, double cutRatio) = cutAdjusts[(i + n - 1 + delta) % n]; + allowedCuts[delta] = + roundedCorners[i].expectedRoundCut * roundCutRatio + + (roundedCorners[i].expectedCut - roundedCorners[i].expectedRoundCut) * cutRatio; + } + + corners.add(roundedCorners[i].getCubics(allowedCuts[0], allowedCuts[1])); + } + + // Finally, store the calculated cubics. This includes all of the rounded + // corners from above, along with new cubics representing the edges between + // those corners. + final tempFeatures = []; + for (var i = 0; i < n; i++) { + final Point currVertex = vertices[i]; + final Point prevVertex = vertices[(i + n - 1) % n]; + final Point nextVertex = vertices[(i + 1) % n]; + final bool cvx = convex(prevVertex, currVertex, nextVertex); + tempFeatures + ..add(CornerFeature(corners[i], convex: cvx)) + ..add( + EdgeFeature([ + CubicBezier.straightLine(corners[i].last.anchor1, corners[(i + 1) % n].first.anchor0), + ]), + ); + } + + return RoundedPolygon.fromFeatures(tempFeatures, center: center ?? calculateCenter(vertices)); + } + + /// Creates a polygon from [features], which describe each segment of its + /// outline. + /// + /// Specifying the features directly controls precisely how the polygon's + /// [CubicBezier]s are grouped into curves, and it is those groups that + /// [Morph] maps: it pairs each curve with one of the same type in the other + /// shape, convex with convex and concave with concave. + /// + /// [center] defaults to the average of every cubic's starting anchor point. + /// + /// Throws [ArgumentError] if [features] has fewer than 2 elements, or if the + /// features don't describe a closed shape. + factory RoundedPolygon.fromFeatures(List features, {Offset? center}) { + if (features.length < 2) { + throw ArgumentError('Polygons must have at least 2 features.'); + } + + if (center != null) { + return RoundedPolygon._raw(features, center); + } + + final vertices = []; + + for (final feature in features) { + for (final CubicBezier cubic in feature.cubics) { + vertices.add(Point(cubic.anchor0X, cubic.anchor0Y)); + } + } + + return RoundedPolygon._raw(features, calculateCenter(vertices)); + } + + /// Creates a circle of the given [radius] about [center], approximated by + /// rounding a polygon of [numVertices] vertices. + /// + /// Throws [ArgumentError] if [numVertices] is less than 3. + factory RoundedPolygon.circle({ + int numVertices = 8, + double radius = 1, + Offset center = Offset.zero, + }) { + if (numVertices < 3) { + throw ArgumentError('Circle must have at least three vertices.'); + } + + // Half of the angle between two adjacent vertices on the polygon. + final double theta = math.pi / numVertices; + // Radius of the underlying RoundedPolygon object given the desired radius + // of the circle. + final double polygonRadius = radius / math.cos(theta); + return RoundedPolygon( + numVertices, + radius: polygonRadius, + center: center, + rounding: CornerRounding(radius: radius), + ); + } + + /// Creates a rectangle [width] wide and [height] high about [center], with + /// optional rounding at its four corners. + /// + /// The default dimensions and center fit the shape into the 2x2 box around + /// the origin, which will usually need rescaling with [transformed] to suit + /// the UI it is drawn in. + /// + /// [rounding] rounds all four corners the same way. [perVertexRounding] + /// overrides it, and must have 4 elements when it is not null. The default + /// leaves the corners sharp. + factory RoundedPolygon.rectangle({ + double width = 2, + double height = 2, + CornerRounding rounding = CornerRounding.unrounded, + List? perVertexRounding, + Offset center = Offset.zero, + }) { + final double left = center.x - width / 2; + final double top = center.y - height / 2; + final double right = center.x + width / 2; + final double bottom = center.y + height / 2; + + return RoundedPolygon.fromVertices( + [Point(right, bottom), Point(left, bottom), Point(left, top), Point(right, top)], + rounding: rounding, + perVertexRounding: perVertexRounding, + center: center, + ); + } + + /// Creates a star about [center], with [numVerticesPerRadius] vertices on + /// the outer [radius] alternating with as many on the [innerRadius]. + /// + /// Both radii must be greater than 0, and [innerRadius] must be less than + /// [radius]. + /// + /// [rounding] rounds every vertex the same way. [innerRounding] overrides it + /// for the vertices on [innerRadius]. [perVertexRounding] overrides both, + /// and must have 2 * [numVerticesPerRadius] elements when it is not null, + /// alternating outer and inner starting with an outer vertex. The default + /// leaves the corners sharp and the edges straight. + /// + /// Throws [ArgumentError] if either radius is not greater than 0, if + /// [innerRadius] is not less than [radius], or if [perVertexRounding] has + /// the wrong number of elements. + factory RoundedPolygon.star({ + required int numVerticesPerRadius, + double radius = 1, + double innerRadius = 0.5, + CornerRounding rounding = CornerRounding.unrounded, + CornerRounding? innerRounding, + List? perVertexRounding, + Offset center = Offset.zero, + }) { + if (radius <= 0 || innerRadius <= 0) { + throw ArgumentError('Star radii must both be greater than 0.'); + } + if (innerRadius >= radius) { + throw ArgumentError('innerRadius must be less than radius.'); + } + + var pvRounding = perVertexRounding; + // If no per-vertex rounding supplied and caller asked for inner rounding, + // create per-vertex rounding list based on supplied outer/inner rounding + // parameters. + if (pvRounding == null && innerRounding != null) { + pvRounding = [ + for (var i = 0; i < numVerticesPerRadius; i++) ...[rounding, innerRounding], + ]; + } + + // Star polygon is just a polygon with all vertices supplied (where we + // generate those vertices to be on the inner/outer radii). + return RoundedPolygon.fromVertices( + _starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, center), + rounding: rounding, + perVertexRounding: pvRounding, + center: center, + ); + } + + /// Creates a pill about [center], [width] wide and [height] high: a + /// rectangle capped by a semicircle at either end of its longer dimension. + /// + /// [smoothing] extends the curve from the circular arc of each cap towards + /// the edge between the two caps. The default of 0 leaves the caps as + /// circular arcs. + /// + /// Throws [ArgumentError] if either [width] or [height] is not greater + /// than 0. + factory RoundedPolygon.pill({ + double width = 2, + double height = 1, + double smoothing = 0, + Offset center = Offset.zero, + }) { + if (width <= 0 || height <= 0) { + throw ArgumentError('Pill shapes must have positive width and height.'); + } + + final double wHalf = width / 2; + final double hHalf = height / 2; + + return RoundedPolygon.fromVertices( + [ + Point(wHalf + center.x, hHalf + center.y), + Point(-wHalf + center.x, hHalf + center.y), + Point(-wHalf + center.x, -hHalf + center.y), + Point(wHalf + center.x, -hHalf + center.y), + ], + rounding: CornerRounding(radius: math.min(wHalf, hHalf), smoothing: smoothing), + center: center, + ); + } + + /// Creates a pill star about [center], [width] wide and [height] high: a + /// [RoundedPolygon.pill] with inner and outer radii along its outline, the + /// way a [RoundedPolygon.star] has them along a circle, with + /// [numVerticesPerRadius] vertices on each. + /// + /// [innerRadiusRatio] gives the inner radius as a fraction of the outer one. + /// It must be greater than 0 and no greater than 1, and a value of 1 gives a + /// pill with more vertices than [RoundedPolygon.pill] would produce. + /// + /// [rounding] rounds every vertex the same way. [innerRounding] overrides it + /// for the inner vertices. [perVertexRounding] overrides both, and must have + /// 2 * [numVerticesPerRadius] elements when it is not null. + /// + /// How the two sets of vertices proceed along the curved ends is subtler + /// than on a star, because of the curvature there: outer vertices lying + /// along the curved outline force the inner ones closer together, while + /// inner vertices lying along it force the outer ones further apart. + /// [vertexSpacing] chooses between those extremes. A value of 0 spaces the + /// inner vertices as they are spaced along the straight edges, 1 does the + /// same for the outer vertices, and the default of 0.5 averages the two, so + /// that each set falls equally to either side of the pill outline. Which + /// value suits a shape depends on its rounding and radius parameters. + /// + /// [startLocation] is how far along the perimeter the outline's curves + /// begin, from 0 to 1. This is rarely needed or noticed, but it decides + /// where the path starts and ends for a caller stroking it gradually. + /// + /// Throws [ArgumentError] if either [width] or [height] is not greater + /// than 0, if [innerRadiusRatio] is outside the range 0 (exclusive) to 1, + /// or if [vertexSpacing] or [startLocation] is outside the range 0 to 1. + factory RoundedPolygon.pillStar({ + double width = 2, + double height = 1, + int numVerticesPerRadius = 8, + double innerRadiusRatio = 0.5, + CornerRounding rounding = CornerRounding.unrounded, + CornerRounding? innerRounding, + List? perVertexRounding, + double vertexSpacing = 0.5, + double startLocation = 0, + Offset center = Offset.zero, + }) { + if (width <= 0 || height <= 0) { + throw ArgumentError('Pill shapes must have positive width and height.'); + } + if (innerRadiusRatio <= 0 || innerRadiusRatio > 1) { + throw ArgumentError('innerRadiusRatio must be in (0, 1] range.'); + } + if (vertexSpacing < 0 || vertexSpacing > 1) { + throw ArgumentError('vertexSpacing must be in [0, 1] range.'); + } + if (startLocation < 0 || startLocation > 1) { + throw ArgumentError('startLocation must be in [0, 1] range.'); + } + + var pvRounding = perVertexRounding; + // If no per-vertex rounding supplied and caller asked for inner rounding, + // create per-vertex rounding list based on supplied outer/inner rounding + // parameters. + if (pvRounding == null && innerRounding != null) { + pvRounding = [ + for (var i = 0; i < numVerticesPerRadius; i++) ...[rounding, innerRounding], + ]; + } + + return RoundedPolygon.fromVertices( + _pillStarVerticesFromNumVerts( + numVerticesPerRadius, + width, + height, + innerRadiusRatio, + vertexSpacing, + startLocation, + center, + ), + rounding: rounding, + perVertexRounding: pvRounding, + center: center, + ); + } + + /// The [Feature]s this polygon is composed of. + /// + /// This list is unmodifiable. + final List features; + + final Point _center; + + /// A flattened version of the [Feature]s, as a `List`. + /// + /// This list is unmodifiable. + final List cubics; + + /// The center of this polygon, around which all vertices are placed. + Offset get center => _center; + + static List _buildCubics(List features, Point center) { + final cubics = []; + + // The first/last mechanism here ensures that the final anchor point in the + // shape exactly matches the first anchor point. There can be rendering + // artifacts introduced by those points being slightly off, even by much + // less than a pixel. + CubicBezier? firstCubic; + CubicBezier? lastCubic; + List? firstFeatureSplitStart; + List? firstFeatureSplitEnd; + + if (features.isNotEmpty && features[0].cubics.length == 3) { + final CubicBezier centerCubic = features[0].cubics[1]; + final (CubicBezier start, CubicBezier end) = centerCubic.split(0.5); + firstFeatureSplitStart = [features[0].cubics[0], start]; + firstFeatureSplitEnd = [end, features[0].cubics[2]]; + } + + // iterating one past the features list size allows us to insert the + // initial split cubic if it exists. + for (var i = 0; i <= features.length; i++) { + final List featureCubics; + + if (i == 0 && firstFeatureSplitEnd != null) { + featureCubics = firstFeatureSplitEnd; + } else if (i == features.length) { + if (firstFeatureSplitStart != null) { + featureCubics = firstFeatureSplitStart; + } else { + break; + } + } else { + featureCubics = features[i].cubics; + } + + for (var j = 0; j < featureCubics.length; j++) { + // Skip zero-length curves; they add nothing and can trigger rendering + // artifacts. + final CubicBezier cubic = featureCubics[j]; + + if (!cubic.isZeroLength) { + if (lastCubic != null) { + cubics.add(lastCubic); + } + lastCubic = cubic; + firstCubic ??= cubic; + } else { + if (lastCubic != null) { + // Dropping several zero-ish length curves in a row can lead to + // enough discontinuity to throw an exception later, even though the + // distances are quite small. Account for that by making the last + // cubic use the latest anchor point, always. + final List points = lastCubic.points.toList(); + points[6] = cubic.anchor1X; + points[7] = cubic.anchor1Y; + lastCubic = CubicBezier.raw(points); + } + } + } + } + + if (lastCubic != null && firstCubic != null) { + cubics.add( + CubicBezier.raw([ + lastCubic.anchor0X, + lastCubic.anchor0Y, + lastCubic.control0X, + lastCubic.control0Y, + lastCubic.control1X, + lastCubic.control1Y, + firstCubic.anchor0X, + firstCubic.anchor0Y, + ]), + ); + } else { + // Empty / 0-sized polygon. + cubics.add(CubicBezier.point(center)); + } + + return cubics; + } + + /// Returns a new [RoundedPolygon] with every point of this one, including + /// its [center], mapped through [transformer]. + RoundedPolygon transformed(PointTransformer transformer) { + return RoundedPolygon._raw([ + for (var i = 0; i < features.length; i++) features[i].transformed(transformer), + ], _center.transformed(transformer)); + } + + /// A new [RoundedPolygon], moving and resizing this one, so it's completely + /// inside the (0, 0) -> (1, 1) square, centered if there is extra space in + /// one direction. + RoundedPolygon get normalized { + final Rect bounds = approximateBounds; + final double side = math.max(bounds.width, bounds.height); + + if (side < distanceEpsilon) { + return this; + } + + // Center the shape if bounds are not a square. + final double offsetX = (side - bounds.width) / 2 - bounds.left; + final double offsetY = (side - bounds.height) / 2 - bounds.top; + + return transformed((x, y) => ((x + offsetX) / side, (y + offsetY) / side)); + } + + /// Like [bounds], the axis-aligned bounds of this shape, but determining the + /// max dimension of the shape (by calculating the distance from its center + /// to the start and midpoint of each curve) and returning a square which can + /// be used to hold the object in any rotation. + /// + /// This can be used, for example, to calculate the max size of a UI element + /// meant to hold this shape in any rotation. + Rect get maxBounds { + var maxDistSquared = 0.0; + for (var i = 0; i < cubics.length; i++) { + final CubicBezier cubic = cubics[i]; + final double anchorDistance = (cubic.anchor0 - _center).distanceSquared; + final Point middlePoint = cubic.pointAt(0.5); + final double middleDistance = (middlePoint - _center).distanceSquared; + maxDistSquared = math.max(maxDistSquared, math.max(anchorDistance, middleDistance)); + } + + final double distance = math.sqrt(maxDistSquared); + + return Rect.fromLTRB( + _center.x - distance, + _center.y - distance, + _center.x + distance, + _center.y + distance, + ); + } + + /// The axis-aligned bounds of this shape. + /// + /// This solves for the actual extrema of every curve. See + /// [approximateBounds] for a cheaper result that is never smaller than this + /// one. + Rect get bounds => _calculateBounds(approximate: false); + + /// A cheaper alternative to [bounds], based on the min/max values of all + /// anchor and control points that make up this shape. + /// + /// The result is never smaller than [bounds], but can be larger. + Rect get approximateBounds => _calculateBounds(approximate: true); + + Rect _calculateBounds({required bool approximate}) { + Rect bounds = approximate ? cubics.first.approximateBounds : cubics.first.bounds; + + for (var i = 1; i < cubics.length; i++) { + final CubicBezier cubic = cubics[i]; + bounds = bounds.expandToInclude(approximate ? cubic.approximateBounds : cubic.bounds); + } + + return bounds; + } + + /// Returns a [Path] for this polygon. + /// + /// [startAngle] places the start point of the polygon's first curve at that + /// angle, in radians, around the polygon's [center], rotating the polygon + /// about that center to get it there. Zero is to the right of the center and + /// `pi / 2` below it, since y grows downwards. + /// The default of zero is special: it skips the rotation entirely and leaves + /// the polygon as it was built. + /// + /// If [repeatPath] is true, the curves are added twice before the [Path] is + /// closed. This is useful when the caller would like to draw parts of the + /// path while offsetting the start and stop positions, for example when + /// phasing and rotating a path to simulate motion as a star-shaped circular + /// progress indicator advances. + /// + /// If [closePath] is false, the returned [Path] is left open. + Path toPath({double startAngle = 0, bool repeatPath = false, bool closePath = true}) { + return pathFromCubics( + cubics, + startAngle: startAngle, + repeatPath: repeatPath, + closePath: closePath, + rotationPivot: _center, + ); + } + + @override + String toString() { + return '${objectRuntimeType(this, 'RoundedPolygon')}' + '(center: $center, features: $features, cubics: $cubics)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is RoundedPolygon && listEquals(other.features, features); + } + + @override + int get hashCode => Object.hashAll(features); +} + +/// Calculates an estimated center position for the polygon, returning it. This +/// function should only be called if the center is not already calculated or +/// provided. The Polygon constructor which takes `numVertices` calculates its +/// own center, since it knows exactly where it is centered, at (0, 0). +/// +/// Note that this center will be transformed whenever the shape itself is +/// transformed. Any transforms that occur before the center is calculated will +/// be taken into account automatically since the center calculation is an +/// average of the current location of all cubic anchor points. +@internal +Point calculateCenter(List vertices) { + var cumulativeX = 0.0; + var cumulativeY = 0.0; + for (final vertex in vertices) { + cumulativeX += vertex.x; + cumulativeY += vertex.y; + } + return Point(cumulativeX / vertices.length, cumulativeY / vertices.length); +} + +/// The geometry of a single corner of a polygon, rounded according to +/// [rounding]. +/// +/// [p0], [p1] and [p2] are three consecutive vertices of the polygon, [p1] +/// being the one this corner rounds. [getCubics] returns the curves that +/// describe the rounded corner. +/// +/// If [rounding] is null there is no rounding, and the corner is a single +/// point at [p1], represented by a [CubicBezier] of length 0 there. +/// +/// If [rounding] is not null the corner is rounded with a curve approximating +/// a circular arc of the radius it specifies, or with three curves if it also +/// has a nonzero smoothing parameter: a circular arc in the middle and two +/// symmetrical flanking curves on either side, whose curvature the smoothing +/// parameter determines. +/// +/// This is a class because the work usually happens in two steps, with state +/// to keep between them: first we determine how much we want to cut to comply +/// with the parameters, then we are given how much we can actually cut, +/// because of space restrictions outside this corner. +class _RoundedCorner { + _RoundedCorner(this.p0, this.p1, this.p2, this.rounding) { + final Point v01 = p0 - p1; + final Point v21 = p2 - p1; + final double d01 = v01.distance; + final double d21 = v21.distance; + + if (d01 > 0 && d21 > 0) { + d1 = v01 / d01; + d2 = v21 / d21; + cornerRadius = rounding?.radius ?? 0; + smoothing = rounding?.smoothing ?? 0; + + // cosine of angle at p1 is dot product of unit vectors to the other + // two vertices. + cosAngle = d1.dotProduct(d2); + + // identity: sin^2 + cos^2 = 1 + // sinAngle gives us the intersection + sinAngle = math.sqrt(1 - square(cosAngle)); + + // How much we need to cut, as measured on a side, to get the required + // radius calculating where the rounding circle hits the edge. + // This uses the identity of tan(A/2) = sinA/(1 + cosA), where + // tan(A/2) = radius/cut. + expectedRoundCut = (sinAngle > 1e-3) ? cornerRadius * (cosAngle + 1) / sinAngle : 0; + } else { + // One (or both) of the sides is empty, not much we can do. + d1 = Point.zero; + d2 = Point.zero; + cornerRadius = 0; + smoothing = 0; + cosAngle = 0; + sinAngle = 0; + expectedRoundCut = 0; + } + } + + final Point p0; + + final Point p1; + + final Point p2; + + final CornerRounding? rounding; + + late final Point d1; + + late final Point d2; + + late final double cornerRadius; + + late final double smoothing; + + late final double cosAngle; + + late final double sinAngle; + + late final double expectedRoundCut; + + // Smoothing changes the actual cut. 0 is same as expectedRoundCut, 1 + // doubles it. + double get expectedCut => (1 + smoothing) * expectedRoundCut; + + /// The center of the circle approximated by the rounding curve, or by the + /// middle of the three curves if smoothing is requested. + /// + /// This is [p1] itself if there is no rounding. + Point center = Point.zero; + + List getCubics(double allowedCut0, double allowedCut1) { + // We use the minimum of both cuts to determine the radius, but if there is + // more space in one side we can use it for smoothing. + final double allowedCut = math.min(allowedCut0, allowedCut1); + + // Nothing to do, just use lines, or a point + if (expectedRoundCut < distanceEpsilon || + allowedCut < distanceEpsilon || + cornerRadius < distanceEpsilon) { + center = p1; + return [CubicBezier.straightLine(p1, p1)]; + } + + // How much of the cut is required for the rounding part. + final double actualRoundCut = math.min(allowedCut, expectedRoundCut); + + // We have two smoothing values, one for each side of the vertex + // Space is used for rounding values first. If there is space left over, + // then we apply smoothing, if it was requested + final double actualSmoothing0 = _calculateActualSmoothingValue(allowedCut0); + final double actualSmoothing1 = _calculateActualSmoothingValue(allowedCut1); + // Scale the radius if needed + final double actualR = cornerRadius * actualRoundCut / expectedRoundCut; + // Distance from the corner (p1) to the center + final double centerDistance = math.sqrt(square(actualR) + square(actualRoundCut)); + // Center of the arc we will use for rounding + center = p1 + ((d1 + d2) / 2).unitVector * centerDistance; + final Point circleIntersection0 = p1 + d1 * actualRoundCut; + final Point circleIntersection2 = p1 + d2 * actualRoundCut; + final CubicBezier flanking0 = _computeFlankingCurve( + actualRoundCut, + actualSmoothing0, + p1, + p0, + circleIntersection0, + circleIntersection2, + center, + actualR, + ); + final CubicBezier flanking2 = _computeFlankingCurve( + actualRoundCut, + actualSmoothing1, + p1, + p2, + circleIntersection2, + circleIntersection0, + center, + actualR, + ).reversed; + + return [ + flanking0, + CubicBezier.circularArc(center, flanking0.anchor1, flanking2.anchor0), + flanking2, + ]; + } + + /// If [allowedCut] (the amount we are able to cut) is greater than the + /// expected cut (without smoothing applied yet), then there is room to apply + /// smoothing and we calculate the actual smoothing value here. + double _calculateActualSmoothingValue(double allowedCut) { + if (allowedCut > expectedCut) { + return smoothing; + } else if (allowedCut > expectedRoundCut) { + return smoothing * (allowedCut - expectedRoundCut) / (expectedCut - expectedRoundCut); + } else { + return 0; + } + } + + /// Returns a [CubicBezier] smoothly connecting the linear side running from + /// [sideStart] to [corner] with the circular segment of radius [actualR] + /// about [circleCenter], starting on the linear side and ending on the + /// circular segment. + /// + /// [actualRoundCut] is how much of the corner we are cutting to add the + /// circular segment, before smoothing cuts any more, and + /// [actualSmoothingValues] is how much we want to smooth: the smoothing + /// parameter, adjusted down if there is not enough room. + /// + /// [circleSegmentIntersection] is where the linear side and the circle + /// intersect, and [otherCircleSegmentIntersection] is where the opposing + /// side and the circle do. + CubicBezier _computeFlankingCurve( + double actualRoundCut, + double actualSmoothingValues, + Point corner, + Point sideStart, + Point circleSegmentIntersection, + Point otherCircleSegmentIntersection, + Point circleCenter, + double actualR, + ) { + // sideStart is the anchor, 'anchor' is actual control point + final Point sideDirection = (sideStart - corner).unitVector; + final Point curveStart = corner + sideDirection * actualRoundCut * (1 + actualSmoothingValues); + + // We use an approximation to cut a part of the circle section proportional + // to 1 - smooth, When smooth = 0, we take the full section, when + // smooth = 1, we take nothing. + final Point p = interpolate( + circleSegmentIntersection, + (circleSegmentIntersection + otherCircleSegmentIntersection) / 2, + actualSmoothingValues, + ); + + // The flanking curve ends on the circle + final Point curveEnd = circleCenter + (p - circleCenter).unitVector * actualR; + + // The anchor on the circle segment side is in the intersection between the + // tangent to the circle in the circle/flanking curve boundary and the + // linear segment. + final Point circleTangent = (curveEnd - circleCenter).rotate90(); + final Point anchorEnd = + _lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? + circleSegmentIntersection; + + // From what remains, we pick a point for the start anchor. + // 2/3 seems to come from design tools? + final Point anchorStart = (curveStart + anchorEnd * 2) / 3; + + return CubicBezier(curveStart, anchorStart, anchorEnd, curveEnd); + } + + /// Returns the point where the line through [p0] in direction [d0] meets the + /// line through [p1] in direction [d1], or null if the two do not intersect. + Point? _lineIntersection(Point p0, Point d0, Point p1, Point d1) { + final Point rotatedD1 = d1.rotate90(); + final double den = d0.dotProduct(rotatedD1); + + if (den.abs() < distanceEpsilon) { + return null; + } + + final double num = (p1 - p0).dotProduct(rotatedD1); + + // Also check the relative value. This is equivalent to + // (den/num).abs() < distanceEpsilon, but avoid doing a division + if (den.abs() < distanceEpsilon * num.abs()) { + return null; + } + + final double k = num / den; + return p0 + d0 * k; + } +} + +List _verticesFromNumVerts(int numVertices, double radius, Point center) { + return List.generate( + numVertices, + (i) => radialToCartesian(radius, math.pi / numVertices * 2 * i) + center, + ); +} + +List _pillStarVerticesFromNumVerts( + int numVerticesPerRadius, + double width, + double height, + double innerRadius, + double vertexSpacing, + double startLocation, + Point center, +) { + // The general approach here is to get the perimeter of the underlying pill + // outline, then the t value for each vertex as we walk that perimeter. This + // tells us where on the outline to place that vertex, then we figure out + // where to place the vertex depending on which "section" it is in. The + // possible sections are the vertical edges on the sides, the circular + // sections on all four corners, or the horizontal edges on the top and + // bottom. Note that either the vertical or horizontal edges will be of + // length zero (whichever dimension is smaller gets only circular curvature + // for the pill shape). + final double endcapRadius = math.min(width, height); + final double vSegLen = math.max(height - width, 0.0); + final double hSegLen = math.max(width - height, 0.0); + final double vSegHalf = vSegLen / 2; + final double hSegHalf = hSegLen / 2; + // vertexSpacing is used to position the vertices on the end caps. The caller + // has the choice of spacing the inner (0) or outer (1) vertices like those + // along the edges, causing the other vertices to be either further apart (0) + // or closer (1). The default is .5, which averages things. The magnitude of + // the inner and rounding parameters may cause the caller to want a different + // value. + final double circlePerimeter = math.pi * 2 * endcapRadius * lerp(innerRadius, 1, vertexSpacing); + // perimeter is circle perimeter plus horizontal and vertical sections of + // inner rectangle, whether either (or even both) might be of length zero. + final double perimeter = 2 * hSegLen + 2 * vSegLen + circlePerimeter; + + // The sections array holds the t start values of that part of the outline. + // We use these to determine which section a given vertex lies in, based on + // it's t value, as well as where in that section it lies. + final sections = List.filled(11, 0); + sections[0] = 0; + sections[1] = vSegLen / 2; + sections[2] = sections[1] + circlePerimeter / 4; + sections[3] = sections[2] + hSegLen; + sections[4] = sections[3] + circlePerimeter / 4; + sections[5] = sections[4] + vSegLen; + sections[6] = sections[5] + circlePerimeter / 4; + sections[7] = sections[6] + hSegLen; + sections[8] = sections[7] + circlePerimeter / 4; + sections[9] = sections[8] + vSegLen / 2; + sections[10] = perimeter; + + // "t" is the length along the entire pill outline for a given vertex. With + // vertices spaced evenly along this contour, we can determine for any vertex + // where it should lie. + final double tPerVertex = perimeter / (2 * numVerticesPerRadius); + // separate iteration for inner vs outer, unlike the other shapes, because + // the vertices can lie in different quadrants so each needs their own + // calculation. + var inner = false; + // Increment section index as we walk around the pill contour with our + // increasing t values. + var currSecIndex = 0; + // secStart/End are used to determine how far along a given vertex is in the + // section in which it lands. + var secStart = 0.0; + double secEnd = sections[1]; + // t value is used to place each vertex. 0 is on the positive x axis, + // moving into section 0 to begin with. startLocation, a value from 0 to 1, + // varies the location anywhere on the perimeter of the shape. + double t = startLocation * perimeter; + // The list of vertices to be returned. + final result = List.filled(numVerticesPerRadius * 2, Point.zero); + final rectBR = Point(hSegHalf, vSegHalf); + final rectBL = Point(-hSegHalf, vSegHalf); + final rectTL = Point(-hSegHalf, -vSegHalf); + final rectTR = Point(hSegHalf, -vSegHalf); + + // Each iteration through this loop uses the next t value as we walk around + // the shape. + for (var i = 0; i < numVerticesPerRadius * 2; i++) { + // t could start (and end) after 0; extra boundedT logic makes sure it does + // the right thing when crossing the boundary past 0 again. + final double boundedT = t % perimeter; + if (boundedT < secStart) { + currSecIndex = 0; + } + while (boundedT >= sections[(currSecIndex + 1) % sections.length]) { + currSecIndex = (currSecIndex + 1) % sections.length; + secStart = sections[currSecIndex]; + secEnd = sections[(currSecIndex + 1) % sections.length]; + } + + // find t in section and its proportion of that section's total length + final double tInSection = boundedT - secStart; + final double tProportion = tInSection / (secEnd - secStart); + + // The vertex placement in a section varies depending on whether it is on + // one of the semicircle endcaps or along one of the straight edges. For + // the endcaps, we use tProportion to get the angle along that circular cap + // and add the starting angle for that section. For the edges we use a + // straight linear calculation given tProportion and the start/end t values + // for that edge. + final double currRadius = inner ? (endcapRadius * innerRadius) : endcapRadius; + final Point vertex = switch (currSecIndex) { + 0 => Point(currRadius, tProportion * vSegHalf), + 1 => radialToCartesian(currRadius, tProportion * math.pi / 2) + rectBR, + 2 => Point(hSegHalf - tProportion * hSegLen, currRadius), + 3 => radialToCartesian(currRadius, math.pi / 2 + (tProportion * math.pi / 2)) + rectBL, + 4 => Point(-currRadius, vSegHalf - tProportion * vSegLen), + 5 => radialToCartesian(currRadius, math.pi + (tProportion * math.pi / 2)) + rectTL, + 6 => Point(-hSegHalf + tProportion * hSegLen, -currRadius), + 7 => radialToCartesian(currRadius, math.pi * 1.5 + (tProportion * math.pi / 2)) + rectTR, + // 8 + _ => Point(currRadius, -vSegHalf + tProportion * vSegHalf), + }; + result[i] = vertex + center; + t += tPerVertex; + inner = !inner; + } + + return result; +} + +List _starVerticesFromNumVerts( + int numVerticesPerRadius, + double radius, + double innerRadius, + Point center, +) { + final result = List.filled(numVerticesPerRadius * 2, Point.zero); + var arrayIndex = 0; + + for (var i = 0; i < numVerticesPerRadius; i++) { + result[arrayIndex++] = + radialToCartesian(radius, math.pi / numVerticesPerRadius * 2 * i) + center; + result[arrayIndex++] = + radialToCartesian(innerRadius, math.pi / numVerticesPerRadius * (2 * i + 1)) + center; + } + + return result; +} diff --git a/packages/material_ui/lib/src/shapes/shapes.dart b/packages/material_ui/lib/src/shapes/shapes.dart new file mode 100644 index 000000000000..a15657c90ed8 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/shapes.dart @@ -0,0 +1,17 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This code is a Dart port of the AndroidX graphics-shapes library: +// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:graphics/graphics-shapes/ + +/// A geometry engine for describing rounded polygonal shapes and morphing +/// between them. +library; + +export 'corner_rounding.dart' show CornerRounding; +export 'cubic.dart' show CubicBezier, pathFromCubics; +export 'features.dart' show Feature; +export 'morph.dart' show Morph; +export 'point.dart' show Matrix4PointTransformer, PointTransformer; +export 'rounded_polygon.dart' show RoundedPolygon; diff --git a/packages/material_ui/lib/src/shapes/utils.dart b/packages/material_ui/lib/src/shapes/utils.dart new file mode 100644 index 000000000000..b24cdaebcad5 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/utils.dart @@ -0,0 +1,159 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +import 'point.dart'; + +// These epsilon values are used internally to determine when two points are +// the same, within some reasonable roundoff error. The distance epsilon is +// smaller, with the intention that the roundoff should not be larger than a +// pixel on any reasonable sized display. +@internal +const distanceEpsilon = 1e-5; + +@internal +const angleEpsilon = 1e-6; + +// This epsilon is based on the observation that people tend to see e.g. +// collinearity much more relaxed than what is mathematically correct. This +// effect is heightened on smaller displays. Use this epsilon for operations +// that allow higher tolerances. +@internal +const relaxedDistanceEpsilon = 5e-3; + +@internal +Point directionVectorFromAngle(double angleRadians) => + Point(math.cos(angleRadians), math.sin(angleRadians)); + +@internal +Point radialToCartesian(double radius, double angleRadians, [Point center = Point.zero]) => + directionVectorFromAngle(angleRadians) * radius + center; + +@internal +double square(double x) => x * x; + +/// Linearly interpolates between [start] and [stop] with [fraction] fraction +/// between them. +@internal +double lerp(double start, double stop, double fraction) { + return start * (1 - fraction) + stop * fraction; +} + +/// Linearly interpolate between two Points. +/// +/// The [fraction] argument represents position on the timeline, +/// with 0.0 meaning that the interpolation has not started, returning +/// [start] (or something equivalent to [start]), 1.0 meaning that the +/// interpolation has finished, returning [stop] (or something equivalent to +/// [stop]), and values in between meaning that the interpolation is at the +/// relevant point on the timeline between [start] and [stop]. The +/// interpolation can be extrapolated beyond 0.0 and 1.0, so negative values +/// and values greater than 1.0 are valid (and can easily be generated by +/// curves). +@internal +Point interpolate(Point start, Point stop, double fraction) { + return Point(lerp(start.x, stop.x, fraction), lerp(start.y, stop.y, fraction)); +} + +/// Similar to num % mod, but ensures the result is always positive. +/// +/// For example: 4 % 3 = positiveModulo(4, 3) = 1, but: -4 % 3 = -1 +/// positiveModulo(-4, 3) = 2. +@internal +double positiveModulo(double num, double mod) => (num % mod + mod) % mod; + +/// Returns whether C is on the line defined by the two points AB. +@internal +bool collinearIsh( + double aX, + double aY, + double bX, + double bY, + double cX, + double cY, [ + double tolerance = distanceEpsilon, +]) { + // The dot product of a perpendicular angle is 0. By rotating one of the + // vectors, we save the calculations to convert the dot product to degrees + // afterwards. + final Point ab = Point(bX - aX, bY - aY).rotate90(); + final ac = Point(cX - aX, cY - aY); + final double dotProduct = ab.dotProduct(ac).abs(); + final double relativeTolerance = tolerance * ab.distance * ac.distance; + + return dotProduct < tolerance || dotProduct < relativeTolerance; +} + +/// Approximates whether corner at this vertex is concave or convex, based on +/// the relationship of the prev->curr/curr->next vectors. +@internal +bool convex(Point previous, Point current, Point next) { + return (current - previous).turnsClockwiseTo(next - current); +} + +/// Does a ternary search in [v0]..[v1] to find the parameter that minimizes +/// the given function. +/// +/// Stops when the search space size is reduced below the given tolerance. +@internal +double findMinimum(double v0, double v1, double Function(double) f, {double tolerance = 1e-3}) { + var a = v0; + var b = v1; + + while (b - a > tolerance) { + final double c1 = (2 * a + b) / 3; + final double c2 = (2 * b + a) / 3; + + if (f(c1) < f(c2)) { + b = c2; + } else { + a = c1; + } + } + + return (a + b) / 2; +} + +/// Returns a position of the [value] in [sortedList], if it is there. +/// +/// If the list isn't sorted according to the [compare] function on the [keyOf] +/// property of the elements, the result is unpredictable. +/// +/// If [value] is not found, returns `-insertionIndex - 1`, where +/// `insertionIndex` is the index at which [value] should be inserted to +/// maintain sorted order. +/// +/// If [start] and [end] are supplied, only that range is searched, +/// and only that range need to be sorted. +@internal +int binarySearchBy( + List sortedList, + K Function(E element) keyOf, + int Function(K, K) compare, + K value, [ + int start = 0, + int? end, +]) { + end = RangeError.checkValidRange(start, end, sortedList.length); + var min = start; + int max = end; + final key = value; + while (min < max) { + final int mid = min + ((max - min) >> 1); + final E element = sortedList[mid]; + final int comp = compare(keyOf(element), key); + if (comp == 0) { + return mid; + } + if (comp < 0) { + min = mid + 1; + } else { + max = mid; + } + } + return -min - 1; +} diff --git a/packages/material_ui/pending_changelogs/change_2026_09_06_material_shapes.yaml b/packages/material_ui/pending_changelogs/change_2026_09_06_material_shapes.yaml new file mode 100644 index 000000000000..e3b3dbc82c8a --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_2026_09_06_material_shapes.yaml @@ -0,0 +1,7 @@ +changelog: | + - Adds the shapes library for creating and morphing rounded polygonal shapes: + `RoundedPolygon`, `CornerRounding`, `CubicBezier`, `Feature`, and `Morph`. + - Adds `MaterialShapes`, the catalog of predefined Material Design shapes. + - Adds `MaterialShapeBorder` for drawing a `RoundedPolygon` as an `OutlinedBorder`. + - Adds an example that morphs through the shapes in `MaterialShapes.all`. +version: minor diff --git a/packages/material_ui/test/material_shape_border_test.dart b/packages/material_ui/test/material_shape_border_test.dart new file mode 100644 index 000000000000..92912d7d99ce --- /dev/null +++ b/packages/material_ui/test/material_shape_border_test.dart @@ -0,0 +1,512 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + group('$MaterialShapeBorder', () { + final unitSquare = RoundedPolygon.rectangle( + width: 1, + height: 1, + center: const Offset(0.5, 0.5), + ); + + test('== compares lerped cubics by value', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + + final ShapeBorder? first = start.lerpTo(end, 0.5); + final ShapeBorder? second = start.lerpTo(end, 0.5); + final ShapeBorder? reverse = end.lerpFrom(start, 0.5); + + expect(identical(first, second), isFalse); + expect(first, second); + expect(reverse, first); + }); + + test('hashCode hashes lerped cubics by value', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + + final ShapeBorder? first = start.lerpTo(end, 0.5); + final ShapeBorder? second = start.lerpTo(end, 0.5); + final ShapeBorder? reverse = end.lerpFrom(start, 0.5); + + expect(first.hashCode, second.hashCode); + expect(reverse.hashCode, first.hashCode); + }); + + test('== distinguishes lerp progress', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + + expect(start.lerpTo(end, 0.25), isNot(start.lerpTo(end, 0.75))); + }); + + test('== compares lerped side and squash', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + final thickEnd = MaterialShapeBorder( + shape: MaterialShapes.square, + side: const BorderSide(width: 4.0), + squash: 1.0, + ); + + expect(start.lerpTo(end, 0.5), isNot(start.lerpTo(thickEnd, 0.5))); + }); + + test('hashCode agrees with == for equal shapes', () { + final List features = MaterialShapes.circle.features; + final border = MaterialShapeBorder(shape: RoundedPolygon.fromFeatures(features)); + final other = MaterialShapeBorder( + shape: RoundedPolygon.fromFeatures(List.of(features)), + ); + + expect(border, other); + expect(border.hashCode, other.hashCode); + }); + + test('== compares shapes by value', () { + final border = MaterialShapeBorder(shape: RoundedPolygon.circle()); + final other = MaterialShapeBorder(shape: RoundedPolygon.circle()); + + expect(border, other); + expect(border.hashCode, other.hashCode); + }); + + test('copyWith, ==, hashCode', () { + final border = MaterialShapeBorder( + shape: MaterialShapes.circle, + side: const BorderSide(width: 2.0), + squash: 0.5, + ); + + expect(border, border.copyWith()); + expect(border.hashCode, border.copyWith().hashCode); + + expect(border, isNot(border.copyWith(shape: MaterialShapes.square))); + expect(border, isNot(border.copyWith(side: const BorderSide(width: 3.0)))); + expect(border, isNot(border.copyWith(squash: 1.0))); + }); + + test('defaults', () { + final border = MaterialShapeBorder(shape: unitSquare); + + expect(border.shape, same(unitSquare)); + expect(border.side, BorderSide.none); + expect(border.squash, 0.0); + }); + + test('asserts that squash is between zero and one', () { + expect(() => MaterialShapeBorder(shape: unitSquare, squash: -0.1), throwsAssertionError); + expect(() => MaterialShapeBorder(shape: unitSquare, squash: 1.1), throwsAssertionError); + }); + + test('toString', () { + expect( + MaterialShapeBorder(shape: unitSquare, squash: 0.5).toString(), + 'MaterialShapeBorder(side: BorderSide(width: 0.0, style: none), squash: 0.5)', + ); + }); + + test('getOuterPath and getInnerPath inset the shape by the side', () { + const rect = Rect.fromLTWH(0.0, 0.0, 200.0, 100.0); + + final border = MaterialShapeBorder(shape: unitSquare, squash: 1.0); + expect(border.getOuterPath(rect).getBounds(), rect); + expect(border.getInnerPath(rect).getBounds(), rect); + + // The default stroke alignment is inside, so the whole stroke width lies + // within the outer path and nothing lies outside it. + final inside = MaterialShapeBorder( + shape: unitSquare, + side: const BorderSide(width: 10.0), + squash: 1.0, + ); + expect(inside.getOuterPath(rect).getBounds(), rect); + expect(inside.getInnerPath(rect).getBounds(), const Rect.fromLTWH(10.0, 10.0, 180.0, 80.0)); + + final outside = MaterialShapeBorder( + shape: unitSquare, + side: const BorderSide(width: 10.0, strokeAlign: BorderSide.strokeAlignOutside), + squash: 1.0, + ); + expect( + outside.getOuterPath(rect).getBounds(), + const Rect.fromLTWH(-10.0, -10.0, 220.0, 120.0), + ); + expect(outside.getInnerPath(rect).getBounds(), rect); + }); + + test('squash takes on the aspect ratio of a wide rect', () { + const rect = Rect.fromLTWH(0.0, 0.0, 200.0, 100.0); + + // Zero squash draws a centered square the size of the shortest side. + expect( + MaterialShapeBorder(shape: unitSquare).getOuterPath(rect).getBounds(), + const Rect.fromLTWH(50.0, 0.0, 100.0, 100.0), + ); + expect( + MaterialShapeBorder(shape: unitSquare, squash: 0.5).getOuterPath(rect).getBounds(), + const Rect.fromLTWH(25.0, 0.0, 150.0, 100.0), + ); + expect( + MaterialShapeBorder(shape: unitSquare, squash: 1.0).getOuterPath(rect).getBounds(), + rect, + ); + }); + + test('squash takes on the aspect ratio of a tall rect', () { + const rect = Rect.fromLTWH(0.0, 0.0, 100.0, 200.0); + + expect( + MaterialShapeBorder(shape: unitSquare).getOuterPath(rect).getBounds(), + const Rect.fromLTWH(0.0, 50.0, 100.0, 100.0), + ); + expect( + MaterialShapeBorder(shape: unitSquare, squash: 0.5).getOuterPath(rect).getBounds(), + const Rect.fromLTWH(0.0, 25.0, 100.0, 150.0), + ); + expect( + MaterialShapeBorder(shape: unitSquare, squash: 1.0).getOuterPath(rect).getBounds(), + rect, + ); + }); + + test('squash has no effect on a square rect', () { + const rect = Rect.fromLTWH(10.0, 20.0, 100.0, 100.0); + + expect(MaterialShapeBorder(shape: unitSquare).getOuterPath(rect).getBounds(), rect); + expect( + MaterialShapeBorder(shape: unitSquare, squash: 1.0).getOuterPath(rect).getBounds(), + rect, + ); + }); + + test('paint strokes the shape with the side', () { + const rect = Rect.fromLTWH(0.0, 0.0, 100.0, 100.0); + final border = MaterialShapeBorder( + shape: unitSquare, + side: const BorderSide(color: Color(0xFF00FF00), width: 4.0), + squash: 1.0, + ); + + expect( + (Canvas canvas) => border.paint(canvas, rect), + paints..path( + color: const Color(0xFF00FF00), + strokeWidth: 4.0, + style: PaintingStyle.stroke, + // The stroke is centered on the painted path, which the inside + // stroke alignment pulls half a stroke width in from the rect. + includes: const [Offset(3.0, 50.0), Offset(97.0, 50.0)], + excludes: const [Offset(1.0, 50.0), Offset(99.0, 50.0)], + ), + ); + }); + + test('paint draws nothing without a solid side', () { + const rect = Rect.fromLTWH(0.0, 0.0, 100.0, 100.0); + + expect( + (Canvas canvas) => MaterialShapeBorder(shape: unitSquare).paint(canvas, rect), + paintsNothing, + ); + expect( + (Canvas canvas) => MaterialShapeBorder( + shape: unitSquare, + side: const BorderSide(width: 4.0, style: BorderStyle.none), + ).paint(canvas, rect), + paintsNothing, + ); + }); + + test('scale scales the side and leaves the shape alone', () { + final border = MaterialShapeBorder( + shape: unitSquare, + side: const BorderSide(width: 2.0), + squash: 0.5, + ); + final scaled = border.scale(2.0) as MaterialShapeBorder; + + expect(scaled.shape, same(unitSquare)); + expect(scaled.side, const BorderSide(width: 4.0)); + // Squash is a ratio, so scaling it would take it out of range. + expect(scaled.squash, 0.5); + }); + + test('scale keeps the cubics of a lerped border', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder( + shape: MaterialShapes.square, + side: const BorderSide(width: 2.0), + ); + + final lerped = start.lerpTo(end, 0.5)! as MaterialShapeBorder; + final scaled = lerped.scale(2.0) as MaterialShapeBorder; + + expect(scaled.shape, isNull); + expect(scaled.side, lerped.side.scale(2.0)); + expect(scaled.squash, lerped.squash); + // The cubics survive the scale, so restoring the side restores the + // border. + expect(scaled.copyWith(side: lerped.side), lerped); + }); + + test('copyWith keeps the cubics of a lerped border', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + + final lerped = start.lerpTo(end, 0.5)! as MaterialShapeBorder; + final MaterialShapeBorder copy = lerped.copyWith( + side: const BorderSide(width: 3.0), + squash: 0.5, + ); + + expect(copy.shape, isNull); + expect(copy.side, const BorderSide(width: 3.0)); + expect(copy.squash, 0.5); + expect(copy, isNot(lerped)); + expect(copy.copyWith(side: lerped.side, squash: lerped.squash), lerped); + }); + + test('copyWith with a shape turns a lerped border back into a shaped one', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + + final lerped = start.lerpTo(end, 0.5)! as MaterialShapeBorder; + final MaterialShapeBorder restored = lerped.copyWith(shape: MaterialShapes.square); + + expect(restored.shape, same(MaterialShapes.square)); + // A lerped border interpolates on its own now, so this is a way of + // discarding the morph rather than the only way of escaping it. + expect(restored.lerpTo(end, 0.5), end.lerpFrom(restored, 0.5)); + }); + + test('scale and copyWith keep a lerped border on its morph', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + + final lerped = start.lerpTo(end, 0.5)! as MaterialShapeBorder; + final expected = start.lerpTo(end, 0.25)! as MaterialShapeBorder; + + // Both carry the morph through, so a transition interrupted after its + // current value was scaled or copied still resumes instead of snapping. + // The side and the squash are normalized away because those are what + // scale and copyWith set out to change. + final scaled = lerped.scale(2.0) as MaterialShapeBorder; + final resumedFromScale = scaled.lerpTo(start, 0.5)! as MaterialShapeBorder; + expect(resumedFromScale.copyWith(side: expected.side), expected); + + final MaterialShapeBorder copy = lerped.copyWith(squash: 1.0); + final resumedFromCopy = copy.lerpTo(start, 0.5)! as MaterialShapeBorder; + expect(resumedFromCopy.copyWith(squash: expected.squash), expected); + }); + + test('lerp returns the endpoints at zero and one', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + + expect(start.lerpTo(end, 0.0), same(start)); + expect(start.lerpTo(end, 1.0), same(end)); + expect(end.lerpFrom(start, 0.0), same(start)); + expect(end.lerpFrom(start, 1.0), same(end)); + + // Those checks come before the shapes are read, so an already-lerped + // border passes through instead of throwing. + final lerped = start.lerpTo(end, 0.5)! as MaterialShapeBorder; + expect(lerped.lerpTo(end, 0.0), same(lerped)); + expect(lerped.lerpFrom(start, 1.0), same(lerped)); + }); + + test('lerp interpolates the side and the squash', () { + final start = MaterialShapeBorder( + shape: MaterialShapes.circle, + side: const BorderSide(width: 2.0), + ); + final end = MaterialShapeBorder( + shape: MaterialShapes.square, + side: const BorderSide(width: 6.0), + squash: 1.0, + ); + + final forward = start.lerpTo(end, 0.25)! as MaterialShapeBorder; + expect(forward.shape, isNull); + expect(forward.side, const BorderSide(width: 3.0)); + expect(forward.squash, 0.25); + + final backward = end.lerpFrom(start, 0.25)! as MaterialShapeBorder; + expect(backward.side, const BorderSide(width: 3.0)); + expect(backward.squash, 0.25); + }); + + test('lerp resumes the morph of an already lerped border', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + + final lerped = start.lerpTo(end, 0.5)! as MaterialShapeBorder; + expect(lerped.shape, isNull); + + // Halfway from progress 0.5 back to the start is progress 0.25 on the + // same morph, which is what the uninterrupted transition drew there. + // ShapeBorder.lerp reaches this through lerpFrom on the second border. + expect(lerped.lerpTo(start, 0.5), start.lerpTo(end, 0.25)); + expect(ShapeBorder.lerp(lerped, start, 0.5), start.lerpTo(end, 0.25)); + + // The lerped border can sit on either side of the call, and the target + // can be either endpoint of its morph, so there are four orderings and + // each has its own direction to get backwards. + expect(lerped.lerpTo(end, 0.5), start.lerpTo(end, 0.75)); + expect(start.lerpTo(lerped, 0.5), start.lerpTo(end, 0.25)); + expect(end.lerpTo(lerped, 0.5), start.lerpTo(end, 0.75)); + }); + + test('lerp interpolates between two results of the same morph', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + + final quarter = start.lerpTo(end, 0.25)! as MaterialShapeBorder; + final threeQuarters = start.lerpTo(end, 0.75)! as MaterialShapeBorder; + + expect(quarter.lerpTo(threeQuarters, 0.5), start.lerpTo(end, 0.5)); + }); + + test('lerp resumes the morph for an equal but freshly built shape', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + + final lerped = start.lerpTo(end, 0.5)! as MaterialShapeBorder; + + // A widget that builds its border inline hands over a new polygon on + // every build, so matching the endpoints by identity would snap here. + final rebuilt = MaterialShapeBorder( + shape: RoundedPolygon.fromFeatures(List.of(MaterialShapes.circle.features)), + ); + + expect(rebuilt.shape, isNot(same(MaterialShapes.circle))); + expect(lerped.lerpTo(rebuilt, 0.5), start.lerpTo(end, 0.25)); + }); + + test('lerp keeps the morph across a second interruption', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + + final first = start.lerpTo(end, 0.5)! as MaterialShapeBorder; + final second = first.lerpTo(start, 0.5)! as MaterialShapeBorder; + + expect(second, start.lerpTo(end, 0.25)); + // The result of the first interruption is on the morph too, so a second + // one resumes instead of falling off it. + expect(second.lerpTo(end, 0.5), start.lerpTo(end, 0.625)); + }); + + test('lerp snaps instead of throwing when there is no shared morph', () { + final start = MaterialShapeBorder(shape: MaterialShapes.circle); + final end = MaterialShapeBorder(shape: MaterialShapes.square); + final third = MaterialShapeBorder(shape: MaterialShapes.triangle); + + final lerped = start.lerpTo(end, 0.5)! as MaterialShapeBorder; + final other = start.lerpTo(third, 0.5)! as MaterialShapeBorder; + + // A third shape is on neither end of the morph, and the two lerped + // borders are on different morphs. All four of ShapeBorder.lerp's + // attempts decline, which is what lets it reach its own fallback. + expect(lerped.lerpTo(third, 0.5), isNull); + expect(third.lerpFrom(lerped, 0.5), isNull); + expect(lerped.lerpFrom(third, 0.5), isNull); + expect(third.lerpTo(lerped, 0.5), isNull); + expect(lerped.lerpTo(other, 0.5), isNull); + + expect(ShapeBorder.lerp(lerped, third, 0.25), same(lerped)); + expect(ShapeBorder.lerp(lerped, third, 0.75), same(third)); + }); + + testWidgets('an interrupted AnimatedContainer transition does not throw', ( + WidgetTester tester, + ) async { + Widget buildFrame(RoundedPolygon shape) { + return Directionality( + textDirection: TextDirection.ltr, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: 100, + height: 100, + decoration: ShapeDecoration( + color: const Color(0xFF00FF00), + shape: MaterialShapeBorder(shape: shape), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(MaterialShapes.circle)); + await tester.pumpWidget(buildFrame(MaterialShapes.square)); + await tester.pump(const Duration(milliseconds: 150)); + + // Each of these restarts the tween from the half-morphed border that is + // on screen, which is the value that used to have no shape to lerp with. + await tester.pumpWidget(buildFrame(MaterialShapes.circle)); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpWidget(buildFrame(MaterialShapes.triangle)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + + test('lerp keeps a separate morph for each pair of shapes', () { + final circle = MaterialShapeBorder(shape: MaterialShapes.circle); + final square = MaterialShapeBorder(shape: MaterialShapes.square); + final triangle = MaterialShapeBorder(shape: MaterialShapes.triangle); + + final ShapeBorder? toSquare = circle.lerpTo(square, 0.5); + final ShapeBorder? toTriangle = circle.lerpTo(triangle, 0.5); + + expect(toSquare, isNot(toTriangle)); + // Both pairs are cached at once, and neither hands back the other's + // morph. + expect(circle.lerpTo(square, 0.5), toSquare); + expect(circle.lerpTo(triangle, 0.5), toTriangle); + }); + + test('lerp keeps a separate morph for each direction', () { + final circle = MaterialShapeBorder(shape: MaterialShapes.circle); + final square = MaterialShapeBorder(shape: MaterialShapes.square); + + final ShapeBorder? forward = circle.lerpTo(square, 0.25); + final ShapeBorder? backward = square.lerpTo(circle, 0.25); + + expect(forward, isNot(backward)); + expect(circle.lerpTo(square, 0.25), forward); + expect(square.lerpTo(circle, 0.25), backward); + }); + + test('lerp stays correct once the morph cache evicts entries', () { + final circle = MaterialShapeBorder(shape: MaterialShapes.circle); + final square = MaterialShapeBorder(shape: MaterialShapes.square); + + final ShapeBorder? expected = circle.lerpTo(square, 0.5); + + // More distinct pairs than the cache holds, so the pair above is pushed + // out of it. + for (final RoundedPolygon shape in MaterialShapes.all.take(10)) { + circle.lerpTo(MaterialShapeBorder(shape: shape), 0.5); + } + + expect(circle.lerpTo(square, 0.5), expected); + }); + + test('lerp falls back to the superclass for other border types', () { + final border = MaterialShapeBorder(shape: unitSquare, side: const BorderSide(width: 4.0)); + + expect(border.lerpFrom(const CircleBorder(), 0.5), isNull); + expect(border.lerpTo(const CircleBorder(), 0.5), isNull); + + // OutlinedBorder scales towards a missing border instead of + // interpolating. + expect(border.lerpFrom(null, 0.25), border.scale(0.25)); + expect(border.lerpTo(null, 0.25), border.scale(0.75)); + }); + }); +} diff --git a/packages/material_ui/test/material_shapes_test.dart b/packages/material_ui/test/material_shapes_test.dart new file mode 100644 index 000000000000..1facb4e1457f --- /dev/null +++ b/packages/material_ui/test/material_shapes_test.dart @@ -0,0 +1,101 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +void main() { + final namedShapes = { + 'circle': MaterialShapes.circle, + 'square': MaterialShapes.square, + 'slanted': MaterialShapes.slanted, + 'arch': MaterialShapes.arch, + 'semiCircle': MaterialShapes.semiCircle, + 'oval': MaterialShapes.oval, + 'pill': MaterialShapes.pill, + 'triangle': MaterialShapes.triangle, + 'arrow': MaterialShapes.arrow, + 'fan': MaterialShapes.fan, + 'diamond': MaterialShapes.diamond, + 'clamShell': MaterialShapes.clamShell, + 'pentagon': MaterialShapes.pentagon, + 'gem': MaterialShapes.gem, + 'sunny': MaterialShapes.sunny, + 'verySunny': MaterialShapes.verySunny, + 'cookie4Sided': MaterialShapes.cookie4Sided, + 'cookie6Sided': MaterialShapes.cookie6Sided, + 'cookie7Sided': MaterialShapes.cookie7Sided, + 'cookie9Sided': MaterialShapes.cookie9Sided, + 'cookie12Sided': MaterialShapes.cookie12Sided, + 'clover4Leaf': MaterialShapes.clover4Leaf, + 'clover8Leaf': MaterialShapes.clover8Leaf, + 'burst': MaterialShapes.burst, + 'softBurst': MaterialShapes.softBurst, + 'boom': MaterialShapes.boom, + 'softBoom': MaterialShapes.softBoom, + 'flower': MaterialShapes.flower, + 'puffy': MaterialShapes.puffy, + 'puffyDiamond': MaterialShapes.puffyDiamond, + 'ghostish': MaterialShapes.ghostish, + 'pixelCircle': MaterialShapes.pixelCircle, + 'pixelTriangle': MaterialShapes.pixelTriangle, + 'bun': MaterialShapes.bun, + 'heart': MaterialShapes.heart, + }; + + test('every shape in MaterialShapes.all has a golden test', () { + expect( + namedShapes.values, + hasLength(MaterialShapes.all.length), + reason: + 'Every shape in MaterialShapes.all must have a named entry in ' + 'namedShapes so that it gets a golden test.', + ); + expect( + namedShapes.values, + orderedEquals(MaterialShapes.all), + reason: + 'namedShapes must list the same shapes as MaterialShapes.all, in ' + 'the same order.', + ); + }); + + for (final MapEntry entry in namedShapes.entries) { + testWidgets('MaterialShapes.${entry.key} golden', (WidgetTester tester) async { + await tester.pumpWidget( + Center( + child: RepaintBoundary( + child: CustomPaint( + painter: _ShapePainter(entry.value), + child: const SizedBox.square(dimension: 200), + ), + ), + ), + ); + + await expectLater( + find.byType(CustomPaint), + matchesGoldenFile('material_shapes.${entry.key}.png'), + ); + }); + } +} + +class _ShapePainter extends CustomPainter { + const _ShapePainter(this.shape); + + final RoundedPolygon shape; + + @override + void paint(Canvas canvas, Size size) { + canvas + ..save() + ..scale(size.width, size.height) + ..drawPath(shape.toPath(), Paint()..color = const Color(0xFF6750A4)) + ..restore(); + } + + @override + bool shouldRepaint(_ShapePainter oldDelegate) => oldDelegate.shape != shape; +} diff --git a/packages/material_ui/test/shapes/corner_rounding_test.dart b/packages/material_ui/test/shapes/corner_rounding_test.dart new file mode 100644 index 000000000000..3fcb461ea438 --- /dev/null +++ b/packages/material_ui/test/shapes/corner_rounding_test.dart @@ -0,0 +1,71 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/corner_rounding.dart'; + +void main() { + test('$CornerRounding()', () { + // ignore: use_named_constants + const defaultCorner = CornerRounding(); + expect(defaultCorner.radius, 0); + expect(defaultCorner.smoothing, 0); + + const CornerRounding unrounded = CornerRounding.unrounded; + expect(unrounded.radius, 0); + expect(unrounded.smoothing, 0); + + const rounded = CornerRounding(radius: 5); + expect(rounded.radius, 5); + expect(rounded.smoothing, 0); + + const smoothed = CornerRounding(smoothing: 0.5); + expect(smoothed.radius, 0); + expect(smoothed.smoothing, 0.5); + + const roundedAndSmoothed = CornerRounding(radius: 5, smoothing: 0.5); + expect(roundedAndSmoothed.radius, 5); + expect(roundedAndSmoothed.smoothing, 0.5); + }); + + test('$CornerRounding rejects out of range values', () { + expect(() => CornerRounding(radius: -1), throwsAssertionError); + expect(() => CornerRounding(smoothing: -1), throwsAssertionError); + expect(() => CornerRounding(smoothing: 1.1), throwsAssertionError); + }); + + test('$CornerRounding equality', () { + expect( + const CornerRounding(radius: 5, smoothing: 0.5), + const CornerRounding(radius: 5, smoothing: 0.5), + ); + expect( + const CornerRounding(radius: 5, smoothing: 0.5).hashCode, + const CornerRounding(radius: 5, smoothing: 0.5).hashCode, + ); + + // ignore: use_named_constants + expect(const CornerRounding(), CornerRounding.unrounded); + + expect(const CornerRounding(radius: 5), isNot(const CornerRounding(radius: 6))); + expect(const CornerRounding(smoothing: 0.5), isNot(const CornerRounding(smoothing: 0.6))); + expect(const CornerRounding(radius: 1), isNot(const CornerRounding(smoothing: 1))); + + expect( + const CornerRounding(radius: 1, smoothing: 0.5).hashCode, + isNot(const CornerRounding(radius: 1, smoothing: 0.6).hashCode), + ); + expect( + const CornerRounding(radius: 1, smoothing: 0.5).hashCode, + isNot(const CornerRounding(radius: 2, smoothing: 0.5).hashCode), + ); + }); + + test('$CornerRounding toString', () { + expect( + const CornerRounding(radius: 5, smoothing: 0.5).toString(), + 'CornerRounding(radius: 5.0, smoothing: 0.5)', + ); + }); +} diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart new file mode 100644 index 000000000000..b3e07c45840c --- /dev/null +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -0,0 +1,224 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/cubic.dart'; +import 'package:material_ui/src/shapes/point.dart'; + +import 'test_utils.dart'; + +void main() { + group('$CubicBezier', () { + // These points create a roughly circular arc in the upper-right quadrant + // around (0,0). + const Point zero = Point.zero; + const p0 = Point(1, 0); + const p1 = Point(1, 0.5); + const p2 = Point(0.5, 1); + const p3 = Point(0, 1); + final cubic = CubicBezier(p0, p1, p2, p3); + + test('anchors and controls', () { + expect(p0, cubic.anchor0); + expect(p1, cubic.control0); + expect(p2, cubic.control1); + expect(p3, cubic.anchor1); + }); + + test('circularArc', () { + final arcCubic = CubicBezier.circularArc(zero, p0, p3); + expect(p0, arcCubic.anchor0); + expect(p3, arcCubic.anchor1); + }); + + test('div', () { + CubicBezier divCubic = cubic / 1; + expectCubicsEqualish(cubic, divCubic); + divCubic = cubic / 1; + expectCubicsEqualish(cubic, divCubic); + divCubic = cubic / 2; + expectPointsEqualish(p0 / 2, divCubic.anchor0); + expectPointsEqualish(p1 / 2, divCubic.control0); + expectPointsEqualish(p2 / 2, divCubic.control1); + expectPointsEqualish(p3 / 2, divCubic.anchor1); + divCubic = cubic / 2; + expectPointsEqualish(p0 / 2, divCubic.anchor0); + expectPointsEqualish(p1 / 2, divCubic.control0); + expectPointsEqualish(p2 / 2, divCubic.control1); + expectPointsEqualish(p3 / 2, divCubic.anchor1); + }); + + test('times', () { + CubicBezier timesCubic = cubic * 1; + expect(p0, timesCubic.anchor0); + expect(p1, timesCubic.control0); + expect(p2, timesCubic.control1); + expect(p3, timesCubic.anchor1); + timesCubic = cubic * 1; + expect(p0, timesCubic.anchor0); + expect(p1, timesCubic.control0); + expect(p2, timesCubic.control1); + expect(p3, timesCubic.anchor1); + timesCubic = cubic * 2; + expectPointsEqualish(p0 * 2, timesCubic.anchor0); + expectPointsEqualish(p1 * 2, timesCubic.control0); + expectPointsEqualish(p2 * 2, timesCubic.control1); + expectPointsEqualish(p3 * 2, timesCubic.anchor1); + timesCubic = cubic * 2; + expectPointsEqualish(p0 * 2, timesCubic.anchor0); + expectPointsEqualish(p1 * 2, timesCubic.control0); + expectPointsEqualish(p2 * 2, timesCubic.control1); + expectPointsEqualish(p3 * 2, timesCubic.anchor1); + }); + + test('plus', () { + final CubicBezier offsetCubic = cubic * 2; + final CubicBezier plusCubic = cubic + offsetCubic; + expectPointsEqualish(p0 + offsetCubic.anchor0, plusCubic.anchor0); + expectPointsEqualish(p1 + offsetCubic.control0, plusCubic.control0); + expectPointsEqualish(p2 + offsetCubic.control1, plusCubic.control1); + expectPointsEqualish(p3 + offsetCubic.anchor1, plusCubic.anchor1); + }); + + test('reversed', () { + final CubicBezier reverseCubic = cubic.reversed; + expect(p3, reverseCubic.anchor0); + expect(p2, reverseCubic.control0); + expect(p1, reverseCubic.control1); + expect(p0, reverseCubic.anchor1); + }); + + void expectBetween(Point end0, Point end1, Point actual) { + final double minX = math.min(end0.x, end1.x); + final double minY = math.min(end0.y, end1.y); + final double maxX = math.max(end0.x, end1.x); + final double maxY = math.max(end0.y, end1.y); + expect(minX <= actual.x, isTrue); + expect(minY <= actual.y, isTrue); + expect(maxX >= actual.x, isTrue); + expect(maxY >= actual.y, isTrue); + } + + test('straightLine', () { + final lineCubic = CubicBezier.straightLine(p0, p3); + expect(p0, lineCubic.anchor0); + expect(p3, lineCubic.anchor1); + expectBetween(p0, p3, lineCubic.control0); + expectBetween(p0, p3, lineCubic.control1); + }); + + test('split', () { + final (CubicBezier split0, CubicBezier split1) = cubic.split(0.5); + expect(cubic.anchor0, split0.anchor0); + expect(cubic.anchor1, split1.anchor1); + expectBetween(cubic.anchor0, cubic.anchor1, split0.anchor1); + expectBetween(cubic.anchor0, cubic.anchor1, split1.anchor0); + }); + + test('pointAt', () { + Point halfway = cubic.pointAt(0.5); + expectBetween(cubic.anchor0, cubic.anchor1, halfway); + final straightLineCubic = CubicBezier.straightLine(p0, p3); + halfway = straightLineCubic.pointAt(0.5); + final computedHalfway = Point(p0.x + 0.5 * (p3.x - p0.x), p0.y + 0.5 * (p3.y - p0.y)); + expectPointsEqualish(computedHalfway, halfway); + }); + + test('transform', () { + PointTransformer transform = identityTransform(); + CubicBezier transformedCubic = cubic.transformed(transform); + expectCubicsEqualish(cubic, transformedCubic); + + transform = scaleTransform(3, 3); + transformedCubic = cubic.transformed(transform); + expectCubicsEqualish(cubic * 3, transformedCubic); + + const tx = 200.0; + const ty = 300.0; + const translationVector = Point(tx, ty); + transform = translateTransform(tx, ty); + transformedCubic = cubic.transformed(transform); + expectPointsEqualish(cubic.anchor0 + translationVector, transformedCubic.anchor0); + expectPointsEqualish(cubic.control0 + translationVector, transformedCubic.control0); + expectPointsEqualish(cubic.control1 + translationVector, transformedCubic.control1); + expectPointsEqualish(cubic.anchor1 + translationVector, transformedCubic.anchor1); + }); + + test('point CubicBezier has zero length', () { + expect(CubicBezier.point(const Point(10, 10)).isZeroLength, isTrue); + }); + + test('== compares points by value', () { + final equalCubic = CubicBezier(p0, p1, p2, p3); + final otherCubic = CubicBezier(p0, p1, p2, zero); + + expect(identical(cubic, equalCubic), isFalse); + expect(cubic, equalCubic); + expect(cubic.hashCode, equalCubic.hashCode); + expect(cubic, isNot(otherCubic)); + }); + + test('toString', () { + expect( + CubicBezier(Point.zero, const Point(1, 0), const Point(2, 0), const Point(3, 0)).toString(), + 'CubicBezier(anchor0: (0.0, 0.0), control0: (1.0, 0.0), ' + 'control1: (2.0, 0.0), anchor1: (3.0, 0.0))', + ); + }); + }); + + group('pathFromCubics', () { + // A triangle whose first curve starts one unit along the positive X-axis, + // so its start angle around the origin is zero. + final triangle = [ + CubicBezier.straightLine(const Point(1, 0), const Point(0, 1)), + CubicBezier.straightLine(const Point(0, 1), const Point(0, -1)), + CubicBezier.straightLine(const Point(0, -1), const Point(1, 0)), + ]; + + test('startAngle is in radians', () { + // A quarter turn moves the start point to one unit along the positive + // Y-axis. + final Path path = pathFromCubics(triangle, startAngle: math.pi / 2); + expectPointsEqualish(const Point(0, 1), pathStartPoint(path)); + }); + + test('startAngle rotates around rotationPivot', () { + const pivot = Point(5, 5); + // A diamond around the pivot, whose first curve starts at angle zero + // from it. + final diamond = [ + CubicBezier.straightLine(const Point(6, 5), const Point(5, 6)), + CubicBezier.straightLine(const Point(5, 6), const Point(4, 5)), + CubicBezier.straightLine(const Point(4, 5), const Point(5, 4)), + CubicBezier.straightLine(const Point(5, 4), const Point(6, 5)), + ]; + + final Path path = pathFromCubics(diamond, startAngle: math.pi / 2, rotationPivot: pivot); + + // A quarter turn gives back the same diamond, so the bounds stay centered + // on the pivot. Only the start point changes, landing on the next vertex. + // Rotating about the origin would move the bounds instead. + expectPointsEqualish(pivot, path.getBounds().center); + expectPointsEqualish(const Point(5, 6), pathStartPoint(path)); + }); + + test('repeatPath doubles the contour', () { + final double single = pathFromCubics(triangle).computeMetrics().first.length; + final double doubled = pathFromCubics( + triangle, + repeatPath: true, + ).computeMetrics().first.length; + expectEqualish(single * 2, doubled); + }); + + test('closePath closes the contour', () { + expect(pathFromCubics(triangle).computeMetrics().first.isClosed, isTrue); + expect(pathFromCubics(triangle, closePath: false).computeMetrics().first.isClosed, isFalse); + }); + }); +} diff --git a/packages/material_ui/test/shapes/double_mapping_test.dart b/packages/material_ui/test/shapes/double_mapping_test.dart new file mode 100644 index 000000000000..02f098855d8f --- /dev/null +++ b/packages/material_ui/test/shapes/double_mapping_test.dart @@ -0,0 +1,87 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/double_mapping.dart'; + +import 'test_utils.dart'; + +void main() { + group('$DoubleMapper', () { + void validateMapping(DoubleMapper mapper, double Function(double) expectedFunction) { + for (var i = 0; i < 10000; i++) { + final double source = i / 10000; + final double target = expectedFunction(source); + + expectEqualish(target, mapper.map(source)); + expectEqualish(source, mapper.mapBack(target)); + } + } + + test('identity mapping', () { + validateMapping(DoubleMapper.identity, (x) => x); + }); + + test('simple mapping', () { + validateMapping( + // Map the first half of the start source to the first quarter of the + // target. + DoubleMapper([(0, 0), (0.5, 0.25)]), + (x) => (x < 0.5) ? x / 2 : (3 * x - 1) / 2, + ); + }); + + test('target wraps', () { + validateMapping( + // mapping applies a "+ 0.5". + DoubleMapper([(0, 0.5), (0.1, 0.6)]), + (x) => (x + 0.5) % 1, + ); + }); + + test('source wraps', () { + validateMapping( + // Values on the source wrap (this is still the "+ 0.5" function). + DoubleMapper([(0.5, 0), (0.1, 0.6)]), + (x) => (x + 0.5) % 1, + ); + }); + + test('both wrap', () { + validateMapping( + // Just the identity function. + DoubleMapper([(0.5, 0.5), (0.75, 0.75), (0.1, 0.1), (0.49, 0.49)]), + (x) => x, + ); + }); + + test('multiple point', () { + validateMapping(DoubleMapper([(0.4, 0.2), (0.5, 0.22), (0, 0.8)]), (x) { + if (x < 0.4) { + return (0.8 + x) % 1; + } else if (x < 0.5) { + return 0.2 + (x - 0.4) / 5; + } else { + // maps a change of 0.5 in the source to a change 0.58 in the + // target, hence the 1.16. + return 0.22 + (x - 0.5) * 1.16; + } + }); + }); + + test('target double wrap throws', () { + expect( + () => DoubleMapper([(0.0, 0.0), (0.3, 0.6), (0.6, 0.3), (0.9, 0.9)]), + throwsArgumentError, + ); + }); + + test('source double wrap throws', () { + expect( + () => DoubleMapper([(0.0, 0.0), (0.6, 0.3), (0.3, 0.6), (0.9, 0.9)]), + throwsArgumentError, + ); + }); + }); +} diff --git a/packages/material_ui/test/shapes/feature_mapping_test.dart b/packages/material_ui/test/shapes/feature_mapping_test.dart new file mode 100644 index 000000000000..659aeae04962 --- /dev/null +++ b/packages/material_ui/test/shapes/feature_mapping_test.dart @@ -0,0 +1,121 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/corner_rounding.dart'; +import 'package:material_ui/src/shapes/feature_mapping.dart'; +import 'package:material_ui/src/shapes/point.dart'; +import 'package:material_ui/src/shapes/polygon_measure.dart'; +import 'package:material_ui/src/shapes/rounded_polygon.dart'; + +import 'test_utils.dart'; + +void main() { + group('FeatureMapping', () { + final triangleWithRoundings = RoundedPolygon(3, rounding: const CornerRounding(radius: 0.2)); + final triangle = RoundedPolygon(3); + final square = RoundedPolygon(4); + final RoundedPolygon squareRotated = RoundedPolygon(4).transformed(pointRotator(45)); + + void verifyMapping( + RoundedPolygon p1, + RoundedPolygon p2, + void Function(List) validator, + ) { + final List f1 = MeasuredPolygon.measure( + const LengthMeasurer(), + p1, + ).features; + final List f2 = MeasuredPolygon.measure( + const LengthMeasurer(), + p2, + ).features; + + // Maps progress in p1 to progress in p2. + final List<(double, double)> map = doMapping(f1, f2); + + // See which features where actually mapped and the distance between + // their representative points. + final distances = []; + + for (final (progress1, progress2) in map) { + final ProgressableFeature feature1 = f1.firstWhere((f) => f.progress == progress1); + final ProgressableFeature feature2 = f2.firstWhere((f) => f.progress == progress2); + distances.add(featureDistSquared(feature1.feature, feature2.feature)); + } + + distances.sort((a, b) => b.compareTo(a)); + validator(distances); + } + + test('feature mapping triangles', () { + verifyMapping(triangleWithRoundings, triangle, (distances) { + for (final d in distances) { + expect(d, lessThan(0.1)); + } + }); + }); + + test('feature mapping triangle to square', () { + verifyMapping(triangle, square, (distances) { + // We have one exact match (both have points at 0 degrees), and + // 2 close ones. + expect(distances.length, 3); + expectEqualish(distances[0], distances[1]); + expect(distances[0], lessThan(0.3)); + expect(distances[2], lessThan(1e-6)); + }); + }); + + test('feature mapping square to triangle', () { + verifyMapping(square, triangle, (distances) { + // We have one exact match (both have points at 0 degrees), and + // 2 close ones. + expect(distances.length, 3); + expectEqualish(distances[0], distances[1]); + expect(distances[0], lessThan(0.3)); + expect(distances[2], lessThan(1e-6)); + }); + }); + + test('feature mapping square rotated to triangle', () { + verifyMapping(squareRotated, triangle, (distances) { + // We have a very bad mapping (the triangle vertex just in the middle + // of one of the square's sides) and 2 decent ones. + expect(distances.length, 3); + expect(distances[0], greaterThan(0.5)); + expectEqualish(distances[1], distances[2]); + expect(distances[2], lessThan(0.1)); + }); + }); + + test('feature mapping does not crash', () { + // Verify that complicated shapes can me matched (this used to crash + // before). + final RoundedPolygon checkmark = RoundedPolygon.fromVertices(const [ + Point(400, -304), + Point(240, -464), + Point(296, -520), + Point(400, -416), + Point(664, -680), + Point(720, -624), + Point(400, -304), + ]).normalized; + + final RoundedPolygon verySunny = RoundedPolygon.star( + numVerticesPerRadius: 8, + innerRadius: 0.65, + rounding: const CornerRounding(radius: 0.15), + ).normalized; + + verifyMapping(checkmark, verySunny, (distances) { + // Most vertices on the checkmark map to a feature in the second + // shape. + expect(distances.length, 6); + // And they are close enough + expect(distances[0], lessThan(0.15)); + }); + }); + }); +} diff --git a/packages/material_ui/test/shapes/features_test.dart b/packages/material_ui/test/shapes/features_test.dart new file mode 100644 index 000000000000..817d5bff1478 --- /dev/null +++ b/packages/material_ui/test/shapes/features_test.dart @@ -0,0 +1,158 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/cubic.dart'; +import 'package:material_ui/src/shapes/features.dart'; + +import 'test_utils.dart'; + +void main() { + group('$Feature', () { + test('Cannot build empty features', () { + expect(() => Feature.convexCorner(const []), throwsArgumentError); + expect(() => Feature.concaveCorner(const []), throwsArgumentError); + expect(() => Feature.ignorable(const []), throwsArgumentError); + }); + + test('Cannot build non continuous features', () { + final cubic1 = CubicBezier.straightLine(Offset.zero, const Offset(1, 1)); + final cubic2 = CubicBezier.straightLine(const Offset(10, 10), const Offset(11, 11)); + + expect(() => Feature.convexCorner([cubic1, cubic2]), throwsArgumentError); + expect(() => Feature.concaveCorner([cubic1, cubic2]), throwsArgumentError); + expect(() => Feature.ignorable([cubic1, cubic2]), throwsArgumentError); + }); + + test('Builds concave corner', () { + final cubic = CubicBezier.straightLine(Offset.zero, const Offset(1, 0)); + final actual = Feature.concaveCorner([cubic]); + final expected = CornerFeature([cubic], convex: false); + expectFeaturesEqualish(expected, actual); + }); + + test('Builds convex corner', () { + final cubic = CubicBezier.straightLine(Offset.zero, const Offset(1, 0)); + final actual = Feature.convexCorner([cubic]); + final expected = CornerFeature([cubic]); + expectFeaturesEqualish(expected, actual); + }); + + test('Builds edge', () { + final cubic = CubicBezier.straightLine(Offset.zero, const Offset(1, 0)); + final actual = Feature.edge(cubic); + final expected = EdgeFeature([cubic]); + expectFeaturesEqualish(expected, actual); + }); + + test('Builds ignorable as edge', () { + final cubic = CubicBezier.straightLine(Offset.zero, const Offset(1, 0)); + final actual = Feature.ignorable([cubic]); + final expected = EdgeFeature([cubic]); + expectFeaturesEqualish(expected, actual); + }); + + test('== compares cubics by value', () { + final cubic = CubicBezier( + Offset.zero, + const Offset(1, 0), + const Offset(2, 0), + const Offset(3, 0), + ); + final equalCubic = CubicBezier( + Offset.zero, + const Offset(1, 0), + const Offset(2, 0), + const Offset(3, 0), + ); + final otherCubic = CubicBezier( + Offset.zero, + const Offset(1, 0), + const Offset(2, 0), + const Offset(4, 0), + ); + + expect(EdgeFeature([cubic]), EdgeFeature([equalCubic])); + expect(EdgeFeature([cubic]).hashCode, EdgeFeature([equalCubic]).hashCode); + expect(EdgeFeature([cubic]), isNot(EdgeFeature([otherCubic]))); + expect(EdgeFeature([cubic]), isNot(EdgeFeature([cubic, otherCubic]))); + + expect(CornerFeature([cubic]), CornerFeature([equalCubic])); + expect(CornerFeature([cubic]).hashCode, CornerFeature([equalCubic]).hashCode); + expect(CornerFeature([cubic]), isNot(CornerFeature([otherCubic]))); + }); + + test('== distinguishes edges from corners', () { + final cubic = CubicBezier.straightLine(Offset.zero, const Offset(1, 0)); + final edge = EdgeFeature([cubic]); + final corner = CornerFeature([cubic]); + + // Asserted in both directions because only CornerFeature overrides `==` + // to check for its own type, so the edge side is what pins the runtime + // type check on the base class. + expect(edge, isNot(corner)); + expect(corner, isNot(edge)); + }); + + test('== distinguishes convex from concave corners', () { + final cubic = CubicBezier.straightLine(Offset.zero, const Offset(1, 0)); + final convex = CornerFeature([cubic]); + final concave = CornerFeature([cubic], convex: false); + + expect(convex, isNot(concave)); + expect(convex.hashCode, isNot(concave.hashCode)); + }); + + test('== compares reversed and transformed features by value', () { + final cubic = CubicBezier( + Offset.zero, + const Offset(1, 0), + const Offset(2, 0), + const Offset(3, 0), + ); + final reversedCubic = CubicBezier( + const Offset(3, 0), + const Offset(2, 0), + const Offset(1, 0), + Offset.zero, + ); + final translatedCubic = CubicBezier( + const Offset(1, 2), + const Offset(2, 2), + const Offset(3, 2), + const Offset(4, 2), + ); + + expect(EdgeFeature([cubic]).reversed, EdgeFeature([reversedCubic])); + expect( + EdgeFeature([cubic]).transformed(translateTransform(1, 2)), + EdgeFeature([translatedCubic]), + ); + + expect(CornerFeature([cubic]).reversed, CornerFeature([reversedCubic], convex: false)); + expect( + CornerFeature([cubic]).transformed(translateTransform(1, 2)), + CornerFeature([translatedCubic]), + ); + }); + + test('toString names the feature type', () { + final cubic = CubicBezier( + Offset.zero, + const Offset(1, 0), + const Offset(2, 0), + const Offset(3, 0), + ); + const cubicString = + 'CubicBezier(anchor0: (0.0, 0.0), control0: (1.0, 0.0), ' + 'control1: (2.0, 0.0), anchor1: (3.0, 0.0))'; + + expect(EdgeFeature([cubic]).toString(), 'EdgeFeature(cubics: [$cubicString])'); + expect( + CornerFeature([cubic], convex: false).toString(), + 'CornerFeature(cubics: [$cubicString], convex: false)', + ); + }); + }); +} diff --git a/packages/material_ui/test/shapes/morph_test.dart b/packages/material_ui/test/shapes/morph_test.dart new file mode 100644 index 000000000000..5ea056415783 --- /dev/null +++ b/packages/material_ui/test/shapes/morph_test.dart @@ -0,0 +1,129 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/cubic.dart'; +import 'package:material_ui/src/shapes/morph.dart'; +import 'package:material_ui/src/shapes/point.dart'; +import 'package:material_ui/src/shapes/rounded_polygon.dart'; +import 'package:vector_math/vector_math_64.dart'; + +import 'test_utils.dart'; + +void main() { + group('$Morph', () { + const radius = 50.0; + const scale = radius; + + final poly1 = RoundedPolygon(3, center: const Point(0.5, 0.5)); + final poly2 = RoundedPolygon(4, center: const Point(0.5, 0.5)); + final morph11 = Morph(poly1, poly1); + final morph12 = Morph(poly1, poly2); + + // Simple test to verify that a Morph with the same start and end shape has + // curves equivalent to those in that shape. + test('cubics', () { + final List p1Cubics = poly1.cubics; + final List cubics11 = morph11.toCubics(0); + expect(cubics11, isNotEmpty); + + // The structure of a morph and its component shapes may not match + // exactly, because morph calculations may optimize some of the + // zero-length curves out. But in general, every curve in the morph + // *should* exist somewhere in the shape it is based on, so we do an + // exhaustive search for such existence. Note that this assertion only + // works because we constructed the Morph from/to the same shape. A Morph + // between different shapes may not have the curves replicated exactly. + for (final morphCubic in cubics11) { + var matched = false; + for (final p1Cubic in p1Cubics) { + if (cubicsEqualish(morphCubic, p1Cubic)) { + matched = true; + continue; + } + } + expect(matched, isTrue); + } + }); + + Future drawPathToImage(ui.Path path, double side) async { + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder) + ..drawColor(const ui.Color(0xFF000000), ui.BlendMode.src) + ..drawPath( + path, + ui.Paint() + ..style = ui.PaintingStyle.fill + ..color = const ui.Color(0xFFFFFFFF), + ); + + final ui.Picture picture = recorder.endRecording(); + return picture.toImage(side.toInt(), side.toInt()); + } + + Future comparePathsVisually(ui.Path a, ui.Path b, double side) async { + final ui.Image imageA = await drawPathToImage(a, side); + final ui.Image imageB = await drawPathToImage(b, side); + + final ByteData? bytesA = await imageA.toByteData(); + final ByteData? bytesB = await imageB.toByteData(); + + if (bytesA!.lengthInBytes != bytesB!.lengthInBytes) { + fail('byte data length of a has to be equal to byte data length of b'); + } + + for (var i = 0; i < bytesA.lengthInBytes; i++) { + if (bytesA.getUint8(i) != bytesB.getUint8(i)) { + fail('path a is not equal to path b at byte index $i'); + } + } + } + + // This test checks to see whether a morph between two different polygons + // is correct at the start (progress 0) and end (progress 1). The actual + // cubics of the morph vs the polygons it was constructed from may differ, + // due to the way the morph is constructed, but the rendering result should + // be the same. + test('drawing', () async { + // Shapes are in canonical size of 2x2 around center (.5, .5). + // Translate and scale to get a larger path. + final matrix = Matrix4.identity() + ..translate(scale / 2, scale / 2) + ..scale(scale, scale); + + final ui.Path poly1Path = poly1.toPath().transform(matrix.storage); + final ui.Path poly2Path = poly2.toPath().transform(matrix.storage); + final ui.Path morph120Path = morph12.toPath(0).transform(matrix.storage); + final ui.Path morph121Path = morph12.toPath(1).transform(matrix.storage); + + await comparePathsVisually(poly1Path, morph120Path, radius * 2); + await comparePathsVisually(poly2Path, morph121Path, radius * 2); + }); + + test('exposes the shapes it morphs between', () { + expect(morph12.start, poly1); + expect(morph12.end, poly2); + }); + + test('equality', () { + expect(Morph(poly1, poly2), morph12); + expect(Morph(poly1, poly2).hashCode, morph12.hashCode); + + expect(Morph(RoundedPolygon(3, center: const Point(0.5, 0.5)), poly2), morph12); + + expect(morph11, isNot(morph12)); + expect(Morph(poly2, poly1), isNot(morph12)); + expect(morph11.hashCode, isNot(morph12.hashCode)); + expect(Morph(poly2, poly1).hashCode, isNot(morph12.hashCode)); + }); + + test('toString', () { + expect(morph12.toString(), startsWith('Morph(start: ')); + expect(morph12.toString(), contains(', end: ')); + }); + }); +} diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart new file mode 100644 index 000000000000..1e5ed023105c --- /dev/null +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -0,0 +1,206 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/corner_rounding.dart'; +import 'package:material_ui/src/shapes/cubic.dart'; +import 'package:material_ui/src/shapes/feature_mapping.dart'; +import 'package:material_ui/src/shapes/features.dart'; +import 'package:material_ui/src/shapes/point.dart'; +import 'package:material_ui/src/shapes/polygon_measure.dart'; +import 'package:material_ui/src/shapes/rounded_polygon.dart'; + +import 'test_utils.dart'; + +void main() { + group('PolygonMeasure', () { + const measurer = LengthMeasurer(); + + void irregularPolygonMeasure( + RoundedPolygon polygon, [ + void Function(MeasuredPolygon)? extraChecks, + ]) { + final measuredPolygon = MeasuredPolygon.measure(measurer, polygon); + + expect(0, measuredPolygon.first.startOutlineProgress); + expect(1, measuredPolygon.last.endOutlineProgress); + + for (var index = 0; index < measuredPolygon.length; index++) { + final MeasuredCubic measuredCubic = measuredPolygon[index]; + + if (index > 0) { + expect(measuredPolygon[index - 1].endOutlineProgress, measuredCubic.startOutlineProgress); + } + + expect(measuredCubic.endOutlineProgress >= measuredCubic.startOutlineProgress, isTrue); + } + + for (var index = 0; index < measuredPolygon.features.length; index++) { + final ProgressableFeature progressableFeature = measuredPolygon.features[index]; + expect( + progressableFeature.progress >= 0 && progressableFeature.progress < 1, + isTrue, + reason: + 'Feature #$index has invalid progress: ' + '${progressableFeature.progress}', + ); + } + + extraChecks?.call(measuredPolygon); + } + + void regularPolygonMeasure(int sides, [CornerRounding rounding = CornerRounding.unrounded]) { + irregularPolygonMeasure(RoundedPolygon(sides, rounding: rounding), (measuredPolygon) { + expect(sides, measuredPolygon.length); + + for (var index = 0; index < measuredPolygon.length; index++) { + final MeasuredCubic measuredCubic = measuredPolygon[index]; + expectEqualish(index / sides, measuredCubic.startOutlineProgress); + } + }); + } + + void customPolygonMeasure(RoundedPolygon polygon, List progresses) { + irregularPolygonMeasure(polygon, (measuredPolygon) { + expect(measuredPolygon.length, progresses.length); + + for (var index = 0; index < measuredPolygon.length; index++) { + final MeasuredCubic measuredCubic = measuredPolygon[index]; + expectEqualish( + progresses[index], + measuredCubic.endOutlineProgress - measuredCubic.startOutlineProgress, + ); + } + }); + } + + test('measure sharp triangle', () { + regularPolygonMeasure(3); + }); + + test('measure sharp pentagon', () { + regularPolygonMeasure(5); + }); + + test('measure sharp octagon', () { + regularPolygonMeasure(8); + }); + + test('measure sharp dodecagon', () { + regularPolygonMeasure(12); + }); + + test('measure sharp icosagon', () { + regularPolygonMeasure(20); + }); + + test('measure slightly rounded hexagon', () { + irregularPolygonMeasure(RoundedPolygon(6, rounding: const CornerRounding(radius: 0.15))); + }); + + test('measure medium rounded hexagon', () { + irregularPolygonMeasure(RoundedPolygon(6, rounding: const CornerRounding(radius: 0.5))); + }); + + test('measure maximum rounded hexagon', () { + irregularPolygonMeasure(RoundedPolygon(6, rounding: const CornerRounding(radius: 1))); + }); + + test('measure circle', () { + // White box test: As the length measurer approximates arcs by linear + // segments, this test validates if the chosen segment count approximates + // the arc length up to an error of 1.5% from the true length. + const vertices = 4; + final polygon = RoundedPolygon.circle(numVertices: vertices); + + final double actualLength = polygon.cubics.fold( + 0, + (sum, cubic) => sum + const LengthMeasurer().measureCubic(cubic), + ); + const double expectedLength = 2 * math.pi; + + expect(expectedLength, moreOrLessEquals(actualLength, epsilon: 0.015 * expectedLength)); + }); + + test('measure irregular triangle angle', () { + irregularPolygonMeasure( + RoundedPolygon.fromVertices( + const [Point(0, -1), Point(1, 1), Point(0, 0.5), Point(-1, 1)], + perVertexRounding: const [ + CornerRounding(radius: 0.2, smoothing: 0.5), + CornerRounding(radius: 0.2, smoothing: 0.5), + CornerRounding(radius: 0.4), + CornerRounding(radius: 0.2, smoothing: 0.5), + ], + ), + ); + }); + + test('measure quarter angle', () { + irregularPolygonMeasure( + RoundedPolygon.fromVertices( + const [Point(-1, -1), Point(1, -1), Point(1, 1), Point(-1, 1)], + perVertexRounding: const [ + CornerRounding.unrounded, + CornerRounding.unrounded, + CornerRounding(radius: 0.5, smoothing: 0.5), + CornerRounding.unrounded, + ], + ), + ); + }); + + test('measure hour glass', () { + // Regression test: Legacy measurer (AngleMeasurer) would skip the + // diagonal sides as they are 0 degrees from the center. + const unit = 1.0; + const coordinates = [ + // lower glass + Point.zero, + Point(unit, unit), + Point(-unit, unit), + // upper glass + Point.zero, + Point(-unit, -unit), + Point(unit, -unit), + ]; + + final double diagonal = math.sqrt(unit * unit + unit * unit); + const double horizontal = 2 * unit; + final double total = 4 * diagonal + 2 * horizontal; + + final polygon = RoundedPolygon.fromVertices(coordinates); + customPolygonMeasure(polygon, [ + diagonal / total, + horizontal / total, + diagonal / total, + diagonal / total, + horizontal / total, + diagonal / total, + ]); + }); + + test('handles empty feature last', () { + final triangle = RoundedPolygon.fromFeatures([ + Feature.convexCorner([CubicBezier.straightLine(Offset.zero, const Offset(1, 1))]), + Feature.convexCorner([CubicBezier.straightLine(const Offset(1, 1), const Offset(1, 0))]), + Feature.convexCorner([CubicBezier.straightLine(const Offset(1, 0), Offset.zero)]), + // Empty feature at the end. + Feature.convexCorner([CubicBezier.straightLine(Offset.zero, Offset.zero)]), + ]); + + irregularPolygonMeasure(triangle); + }); + + test('findCubicCutPoint at measure zero returns the curve start', () { + final zeroLength = CubicBezier.point(Offset.zero); + expect(measurer.findCubicCutPoint(zeroLength, 0), 0); + + final line = CubicBezier.straightLine(Offset.zero, const Offset(1, 0)); + expect(measurer.findCubicCutPoint(line, 0), 0); + }); + }); +} diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart new file mode 100644 index 000000000000..ecdf6c6dce92 --- /dev/null +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -0,0 +1,215 @@ +// Copyright 2013 The Flutter Authors +// 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_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/corner_rounding.dart'; +import 'package:material_ui/src/shapes/cubic.dart'; +import 'package:material_ui/src/shapes/features.dart'; +import 'package:material_ui/src/shapes/point.dart'; +import 'package:material_ui/src/shapes/rounded_polygon.dart'; + +import 'test_utils.dart'; + +void main() { + group('Polygon', () { + final square = RoundedPolygon(4); + final roundedSquare = RoundedPolygon(4, rounding: const CornerRounding(radius: 0.2)); + final pentagon = RoundedPolygon(5); + + test('construction', () { + // We can't be too specific on how exactly the square is constructed, but + // we can at least test whether all points are within the unit square. + var min = const Point(-1, -1); + var max = const Point(1, 1); + expectInBounds(square.cubics, min, max); + + final doubleSquare = RoundedPolygon(4, radius: 2); + min = min * 2; + max = max * 2; + expectInBounds(doubleSquare.cubics, min, max); + + final offsetSquare = RoundedPolygon(4, center: const Point(1, 2)); + min = const Point(0, 1); + max = const Point(2, 3); + expectInBounds(offsetSquare.cubics, min, max); + + const p0 = Point(1, 0); + const p1 = Point(0, 1); + const p2 = Point(-1, 0); + const p3 = Point(0, -1); + final manualSquare = RoundedPolygon.fromVertices(const [p0, p1, p2, p3]); + min = const Point(-1, -1); + max = const Point(1, 1); + expectInBounds(manualSquare.cubics, min, max); + + const offset = Point(1, 2); + final Point p0Offset = p0 + offset; + final Point p1Offset = p1 + offset; + final Point p2Offset = p2 + offset; + final Point p3Offset = p3 + offset; + final manualSquareOffset = RoundedPolygon.fromVertices([ + p0Offset, + p1Offset, + p2Offset, + p3Offset, + ], center: offset); + min = const Point(0, 1); + max = const Point(2, 3); + expectInBounds(manualSquareOffset.cubics, min, max); + }); + + test('bounds', () { + Rect bounds = square.approximateBounds; + expectEqualish(-1, bounds.left); + expectEqualish(-1, bounds.top); + expectEqualish(1, bounds.right); + expectEqualish(1, bounds.bottom); + + Rect betterBounds = square.bounds; + expectEqualish(-1, betterBounds.left); + expectEqualish(-1, betterBounds.top); + expectEqualish(1, betterBounds.right); + expectEqualish(1, betterBounds.bottom); + + // roundedSquare's approximate bounds will be larger due to control + // points. + bounds = roundedSquare.approximateBounds; + betterBounds = roundedSquare.bounds; + expect( + betterBounds.width < bounds.width, + isTrue, + reason: 'bounds = $bounds, betterBounds = $betterBounds', + ); + + bounds = pentagon.approximateBounds; + final Rect maxBounds = pentagon.maxBounds; + expect(maxBounds.width > bounds.width, isTrue); + }); + + test('center', () { + expectPointsEqualish(Point.zero, square.center); + }); + + test('transform', () { + // First, make sure the shape doesn't change when transformed by the + // identity. + final RoundedPolygon squareCopy = square.transformed(identityTransform()); + final int n = square.cubics.length; + + expect(n, squareCopy.cubics.length); + for (var i = 0; i < n; i++) { + expectCubicsEqualish(square.cubics[i], squareCopy.cubics[i]); + } + + // Now create a function which translates points by (1, 2) and make sure + // the shape is translated similarly by it. + const offset = Point(1, 2); + final List squareCubics = square.cubics; + final PointTransformer translator = translateTransform(offset.x, offset.y); + final List translatedSquareCubics = square.transformed(translator).cubics; + + for (var i = 0; i < squareCubics.length; i++) { + expectPointsEqualish(squareCubics[i].anchor0 + offset, translatedSquareCubics[i].anchor0); + expectPointsEqualish(squareCubics[i].control0 + offset, translatedSquareCubics[i].control0); + expectPointsEqualish(squareCubics[i].control1 + offset, translatedSquareCubics[i].control1); + expectPointsEqualish(squareCubics[i].anchor1 + offset, translatedSquareCubics[i].anchor1); + } + }); + + test('features', () { + List nonZeroCubics(List original) { + return original.where((c) => !c.isZeroLength).toList(); + } + + final List squareFeatures = square.features; + + // Verify that cubics of polygon == nonzero cubics of features of that + // polygon. + // Note the Equalish test since some points may be adjusted in conversion + // from raw cubics in the feature to the cubics list for the shape. + final List nonzeroCubics = nonZeroCubics( + squareFeatures.expand((f) => f.cubics).toList(), + ); + expectCubicListsEqualish(square.cubics, nonzeroCubics); + }); + + test('cubics and features are unmodifiable', () { + final polygon = RoundedPolygon(4); + final edge = Feature.edge(CubicBezier.straightLine(Point.zero, const Point(1, 0))); + + expect(() => polygon.cubics.clear(), throwsUnsupportedError); + expect(() => polygon.cubics.add(edge.cubics.first), throwsUnsupportedError); + expect(() => polygon.features.clear(), throwsUnsupportedError); + expect(() => polygon.features.add(edge), throwsUnsupportedError); + }); + + test('fromFeatures does not alias the list it is given', () { + final List expected = RoundedPolygon(4).features; + final features = List.of(expected); + final polygon = RoundedPolygon.fromFeatures(features); + final int cubicCount = polygon.cubics.length; + + features.clear(); + + expect(polygon.features, expected); + expect(polygon.cubics.length, cubicCount); + }); + + test('toString names the type and its parts', () { + final description = square.toString(); + + expect(description, startsWith('RoundedPolygon(center: Offset(0.0, 0.0), features: [')); + expect(description, contains('EdgeFeature(cubics: [')); + expect(description, contains('CornerFeature(cubics: [')); + expect(description, contains(', cubics: [CubicBezier(anchor0: ')); + expect(description, endsWith(')])')); + }); + + test('transform keeps contiguous anchors equal', () { + final RoundedPolygon poly = RoundedPolygon(4, rounding: const CornerRounding(radius: 7 / 15)) + .transformed((x, y) { + final Point point = Point(x, y).rotate(45).scale(648, 648).translate(540, 1212); + return (point.x, point.y); + }); + + for (var i = 0; i < poly.cubics.length; i++) { + // It has to be the same point. + expect( + poly.cubics[i].anchor1X, + poly.cubics[(i + 1) % poly.cubics.length].anchor0X, + reason: 'Failed at X, index $i', + ); + expect( + poly.cubics[i].anchor1Y, + poly.cubics[(i + 1) % poly.cubics.length].anchor0Y, + reason: 'Failed at Y, index $i', + ); + } + }); + + test('empty', () { + final poly = RoundedPolygon(6, radius: 0, rounding: const CornerRounding(radius: 0.1)); + expect(poly.cubics.length, 1); + + final RoundedPolygon stillEmpty = poly.transformed(scaleTransform(10, 20)); + expect(stillEmpty.cubics.length, 1); + expect(stillEmpty.cubics.first.isZeroLength, isTrue); + }); + + test('empty side', () { + // Triangle with one point repeated. + final poly1 = RoundedPolygon.fromVertices(const [ + Point.zero, + Point(1, 0), + Point(1, 0), + Point(0, 1), + ]); + // Triangle. + final poly2 = RoundedPolygon.fromVertices(const [Point.zero, Point(1, 0), Point(0, 1)]); + expectCubicListsEqualish(poly1.cubics, poly2.cubics); + }); + }); +} diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart new file mode 100644 index 000000000000..f890170d0ef4 --- /dev/null +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -0,0 +1,393 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/corner_rounding.dart'; +import 'package:material_ui/src/shapes/cubic.dart'; +import 'package:material_ui/src/shapes/features.dart'; +import 'package:material_ui/src/shapes/point.dart'; +import 'package:material_ui/src/shapes/rounded_polygon.dart'; + +import 'test_utils.dart'; + +void main() { + group('$RoundedPolygon', () { + const rounding = CornerRounding(radius: 0.1); + final perVtxRounded = [rounding, rounding, rounding, rounding]; + + test('default constructor', () { + expect(() => RoundedPolygon(2), throwsArgumentError); + + final square = RoundedPolygon(4); + var min = const Point(-1, -1); + var max = const Point(1, 1); + expectInBounds(square.cubics, min, max); + + final doubleSquare = RoundedPolygon(4, radius: 2); + min *= 2; + max *= 2; + expectInBounds(doubleSquare.cubics, min, max); + + final squareRounded = RoundedPolygon(4, rounding: rounding); + min = const Point(-1, -1); + max = const Point(1, 1); + expectInBounds(squareRounded.cubics, min, max); + + final squarePVRounded = RoundedPolygon(4, perVertexRounding: perVtxRounded); + min = const Point(-1, -1); + max = const Point(1, 1); + expectInBounds(squarePVRounded.cubics, min, max); + }); + + test('fromVertices', () { + const p0 = Point(1, 0); + const p1 = Point(0, 1); + const p2 = Point(-1, 0); + const p3 = Point(0, -1); + const verts = [p0, p1, p2, p3]; + + expect(() => RoundedPolygon.fromVertices(const [p0, p1]), throwsArgumentError); + + final manualSquare = RoundedPolygon.fromVertices(verts); + var min = const Point(-1, -1); + var max = const Point(1, 1); + expectInBounds(manualSquare.cubics, min, max); + + const offset = Point(1, 2); + final List offsetVerts = [p0 + offset, p1 + offset, p2 + offset, p3 + offset]; + final manualSquareOffset = RoundedPolygon.fromVertices(offsetVerts, center: offset); + min = const Point(0, 1); + max = const Point(2, 3); + expectInBounds(manualSquareOffset.cubics, min, max); + + final manualSquareRounded = RoundedPolygon.fromVertices(verts, rounding: rounding); + min = const Point(-1, -1); + max = const Point(1, 1); + expectInBounds(manualSquareRounded.cubics, min, max); + + final manualSquarePVRounded = RoundedPolygon.fromVertices( + verts, + perVertexRounding: perVtxRounded, + ); + min = const Point(-1, -1); + max = const Point(1, 1); + expectInBounds(manualSquarePVRounded.cubics, min, max); + }); + + group('fromFeatures', () { + test('throws for too few features', () { + expect(() => RoundedPolygon.fromFeatures(const []), throwsArgumentError); + expect( + () => RoundedPolygon.fromFeatures([ + CornerFeature([CubicBezier.point(Point.zero)]), + ]), + throwsArgumentError, + ); + }); + + test('throws for non continuous features', () { + final cubic1 = CubicBezier.straightLine(Point.zero, const Point(1, 0)); + final cubic2 = CubicBezier.straightLine(const Point(10, 10), const Point(20, 20)); + expect( + () => RoundedPolygon.fromFeatures([Feature.edge(cubic1), Feature.edge(cubic2)]), + throwsArgumentError, + ); + }); + + test('reconstructs square', () { + final base = RoundedPolygon.rectangle(); + final actual = RoundedPolygon.fromFeatures(base.features); + expectPolygonsEqualish(base, actual); + }); + + test('reconstructs rounded square', () { + final base = RoundedPolygon.rectangle( + rounding: const CornerRounding(radius: 0.5, smoothing: 0.2), + ); + final actual = RoundedPolygon.fromFeatures(base.features); + expectPolygonsEqualish(base, actual); + }); + + test('reconstructs circles', () { + for (var i = 3; i <= 20; i++) { + final base = RoundedPolygon.circle(numVertices: i); + final actual = RoundedPolygon.fromFeatures(base.features); + expectPolygonsEqualish(base, actual); + } + }); + + test('reconstructs stars', () { + for (var i = 3; i <= 20; i++) { + final base = RoundedPolygon.star(numVerticesPerRadius: i); + final actual = RoundedPolygon.fromFeatures(base.features); + expectPolygonsEqualish(base, actual); + } + }); + + test('reconstructs rounded stars', () { + for (var i = 3; i <= 20; i++) { + final base = RoundedPolygon.star( + numVerticesPerRadius: i, + rounding: const CornerRounding(radius: 0.5, smoothing: 0.2), + ); + final actual = RoundedPolygon.fromFeatures(base.features); + expectPolygonsEqualish(base, actual); + } + }); + + test('reconstructs pill', () { + final base = RoundedPolygon.pill(); + final actual = RoundedPolygon.fromFeatures(base.features); + expectPolygonsEqualish(base, actual); + }); + + test('reconstructs pill star', () { + final base = RoundedPolygon.pillStar( + rounding: const CornerRounding(radius: 0.5, smoothing: 0.2), + ); + final actual = RoundedPolygon.fromFeatures(base.features); + expectPolygonsEqualish(base, actual); + }); + }); + + test('computes center', () { + final polygon = RoundedPolygon.fromVertices(const [ + Point.zero, + Point(1, 0), + Point(0, 1), + Point(1, 1), + ]); + expect(const Point(0.5, 0.5), polygon.center); + }); + + test('normalized handles a degenerate point polygon', () { + final RoundedPolygon degenerate = RoundedPolygon(4).transformed((x, y) => (0.5, 0.5)); + final RoundedPolygon normalized = degenerate.normalized; + + for (final CubicBezier cubic in normalized.cubics) { + for (final double coordinate in cubic.points) { + expect(coordinate.isNaN, isFalse); + } + } + }); + + test('hashCode agrees with ==', () { + final List features = RoundedPolygon.circle().features; + final first = RoundedPolygon.fromFeatures(features); + final second = RoundedPolygon.fromFeatures(List.of(features)); + + expect(first, second); + expect(first.hashCode, second.hashCode); + }); + + test('== compares features by value', () { + final circle = RoundedPolygon.circle(); + final equalCircle = RoundedPolygon.circle(); + final otherCircle = RoundedPolygon.circle(numVertices: 12); + + expect(identical(circle, equalCircle), isFalse); + expect(circle, equalCircle); + expect(circle.hashCode, equalCircle.hashCode); + expect(circle, isNot(otherCircle)); + }); + + test('toPath rotates around the polygon center', () { + // A diamond filling the unit square, with its first vertex at angle zero + // from its center. + final diamond = RoundedPolygon.fromVertices(const [ + Point(1, 0.5), + Point(0.5, 1), + Point(0, 0.5), + Point(0.5, 0), + ]); + expect(diamond.center, const Point(0.5, 0.5)); + + final Path path = diamond.toPath(startAngle: math.pi / 2); + + // A quarter turn gives back the same diamond, so the bounds do not move. + // Only the start point changes, landing on the next vertex. Rotating + // about the origin would push the diamond out of the unit square. + expectPointsEqualish(const Point(0.5, 0.5), path.getBounds().center); + expectPointsEqualish(const Point(0.5, 1), pathStartPoint(path)); + }); + + test('rounding space usage', () { + const Point p0 = Point.zero; + const p1 = Point(1, 0); + const p2 = Point(0.5, 1); + final List pvRounding = [ + const CornerRounding(radius: 1), + const CornerRounding(radius: 1, smoothing: 1), + CornerRounding.unrounded, + ]; + final polygon = RoundedPolygon.fromVertices(const [ + p0, + p1, + p2, + ], perVertexRounding: pvRounding); + + // Since there is not enough room in the p0 -> p1 side even for the + // roundings, we shouldn't take smoothing into account, so the corners + // should end in the middle point. + final Feature lowerEdgeFeature = polygon.features.firstWhere((f) => f is EdgeFeature); + expect(1, lowerEdgeFeature.cubics.length); + + final CubicBezier lowerEdge = lowerEdgeFeature.cubics.first; + expectEqualish(0.5, lowerEdge.anchor0X); + expectEqualish(0, lowerEdge.anchor0Y); + expectEqualish(0.5, lowerEdge.anchor1X); + expectEqualish(0, lowerEdge.anchor1Y); + }); + + // In the following tests, we check how much was cut for the top left + // (vertex 0) and bottom left corner (vertex 3). + // In particular, both vertex are competing for space in the left side. + // + // Vertex 0 Vertex 1 + // *---------------------* + // | | + // *---------------------* + // Vertex 3 Vertex 2 + const points = 20; + + String describe(CornerRounding cr) => '(r=${cr.radius}, s=${cr.smoothing})'; + + void doUnevenSmoothTest({ + // Corner rounding parameter for vertex 0 (top left). + required CornerRounding rounding0, + // Expected total cut from vertex 0 towards vertex 1. + required double expectedV0SX, + // Expected total cut from vertex 0 towards vertex 3. + required double expectedV0SY, + // Expected total cut from vertex 3 towards vertex 0. + required double expectedV3SY, + // Corner rounding parameter for vertex 3 (bottom left). + CornerRounding rounding3 = const CornerRounding(radius: 0.5), + }) { + const Point p0 = Point.zero; + const p1 = Point(5, 0); + const p2 = Point(5, 1); + const p3 = Point(0, 1); + + final List pvRounding = [ + rounding0, + CornerRounding.unrounded, + CornerRounding.unrounded, + rounding3, + ]; + final polygon = RoundedPolygon.fromVertices(const [ + p0, + p1, + p2, + p3, + ], perVertexRounding: pvRounding); + + final [EdgeFeature e01, _, _, EdgeFeature e30] = polygon.features + .whereType() + .toList(); + final msg = 'r0 = ${describe(rounding0)}, r3 = ${describe(rounding3)}'; + expectEqualish(expectedV0SX, e01.cubics.first.anchor0X, msg); + expectEqualish(expectedV0SY, e30.cubics.first.anchor1Y, msg); + expectEqualish(expectedV3SY, 1 - e30.cubics.first.anchor0Y, msg); + } + + test('uneven smoothing 1', () { + // Vertex 3 has the default 0.5 radius, 0 smoothing. + // Vertex 0 has 0.4 radius, and smoothing varying from 0 to 1. + for (var i = 0; i <= points; i++) { + final double smooth = i / points; + doUnevenSmoothTest( + rounding0: CornerRounding(radius: 0.4, smoothing: smooth), + expectedV0SX: 0.4 * (1 + smooth), + expectedV0SY: math.min(0.4 * (1 + smooth), 0.5), + expectedV3SY: 0.5, + ); + } + }); + + test('uneven smoothing 2', () { + // Vertex 3 has 0.2f radius and 0.2f smoothing, so it takes at most 0.4. + // Vertex 0 has 0.4f radius and smoothing varies from 0 to 1, when it + // reaches 0.5 it starts competing with vertex 3 for space. + for (var i = 0; i <= points; i++) { + final double smooth = i / points; + + final double smoothWantedV0 = 0.4 * smooth; + const smoothWantedV3 = 0.2; + + // There is 0.4 room for smoothing. + final double factor = math.min(0.4 / (smoothWantedV0 + smoothWantedV3), 1.0); + doUnevenSmoothTest( + rounding0: CornerRounding(radius: 0.4, smoothing: smooth), + expectedV0SX: 0.4 * (1 + smooth), + expectedV0SY: 0.4 + factor * smoothWantedV0, + expectedV3SY: 0.2 + factor * smoothWantedV3, + rounding3: const CornerRounding(radius: 0.2, smoothing: 1), + ); + } + }); + + test('uneven smoothing 3', () { + // Vertex 3 has 0.6 radius. + // Vertex 0 has 0.4 radius and smoothing varies from 0 to 1. There is no + // room for smoothing on the segment between these vertices, but vertex + // 0 can still have smoothing on the top side. + for (var i = 0; i <= points; i++) { + final double smooth = i / points; + + doUnevenSmoothTest( + rounding0: CornerRounding(radius: 0.4, smoothing: smooth), + expectedV0SX: 0.4 * (1 + smooth), + expectedV0SY: 0.4, + expectedV3SY: 0.6, + rounding3: const CornerRounding(radius: 0.6), + ); + } + }); + + test('full size creation', () { + const radius = 400.0; + const innerRadiusFactor = 0.35; + const double innerRadius = radius * innerRadiusFactor; + const roundingFactor = 0.32; + + final RoundedPolygon fullSizeShape = RoundedPolygon.star( + numVerticesPerRadius: 4, + radius: radius, + innerRadius: innerRadius, + rounding: const CornerRounding(radius: radius * roundingFactor), + innerRounding: const CornerRounding(radius: radius * roundingFactor), + center: const Point(radius, radius), + ).transformed((x, y) => ((x - radius) / radius, (y - radius) / radius)); + + final canonicalShape = RoundedPolygon.star( + numVerticesPerRadius: 4, + innerRadius: innerRadiusFactor, + rounding: const CornerRounding(radius: roundingFactor), + innerRounding: const CornerRounding(radius: roundingFactor), + ); + + final List cubics = canonicalShape.cubics; + final List cubics1 = fullSizeShape.cubics; + expect(cubics.length, cubics1.length); + + for (var i = 0; i < cubics.length; i++) { + final CubicBezier cubic = cubics[i]; + final CubicBezier cubic1 = cubics1[i]; + + expectEqualish(cubic.anchor0X, cubic1.anchor0X); + expectEqualish(cubic.anchor0Y, cubic1.anchor0Y); + expectEqualish(cubic.anchor1X, cubic1.anchor1X); + expectEqualish(cubic.anchor1Y, cubic1.anchor1Y); + expectEqualish(cubic.control0X, cubic1.control0X); + expectEqualish(cubic.control0Y, cubic1.control0Y); + expectEqualish(cubic.control1X, cubic1.control1X); + expectEqualish(cubic.control1Y, cubic1.control1Y); + } + }); + }); +} diff --git a/packages/material_ui/test/shapes/shapes_test.dart b/packages/material_ui/test/shapes/shapes_test.dart new file mode 100644 index 000000000000..3018846f8666 --- /dev/null +++ b/packages/material_ui/test/shapes/shapes_test.dart @@ -0,0 +1,155 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/corner_rounding.dart'; +import 'package:material_ui/src/shapes/cubic.dart'; +import 'package:material_ui/src/shapes/point.dart'; +import 'package:material_ui/src/shapes/rounded_polygon.dart'; + +import 'test_utils.dart'; + +void main() { + group('Shapes', () { + const Point zero = Point.zero; + const epsilon = 0.01; + + double distance(Point start, Point end) { + final Point vector = end - start; + return math.sqrt(vector.x * vector.x + vector.y * vector.y); + } + + // Test that the given point is radius distance away from [center]. If + // two radii are provided it is sufficient to lie on either one (used for + // testing points on stars). + void expectPointOnRadii(Point point, double radius1, [double? radius2, Point center = zero]) { + radius2 ??= radius1; + final double dist = distance(center, point); + try { + expect(radius1, moreOrLessEquals(dist, epsilon: epsilon)); + } on TestFailure catch (_) { + expect(radius2, moreOrLessEquals(dist, epsilon: epsilon)); + } + } + + void expectCubicOnRadii( + CubicBezier cubic, + double radius1, [ + double? radius2, + Point center = zero, + ]) { + expectPointOnRadii(cubic.anchor0, radius1, radius2, center); + expectPointOnRadii(cubic.anchor1, radius1, radius2, center); + } + + // Tests points along the curve of the cubic by comparing the distance + // from that point to the center, compared to the requested radius. The + // test is very lenient since the Circle shape is only a 4x cubic + // approximation of the circle and varies from the true circle. + void expectCircularCubic(CubicBezier cubic, double radius, Point center) { + var t = 0.0; + while (t <= 1) { + final Point pointOnCurve = cubic.pointAt(t); + final double distanceToPoint = distance(center, pointOnCurve); + expect(radius, moreOrLessEquals(distanceToPoint, epsilon: epsilon)); + t += 0.1; + } + } + + void expectCircleShape(List shape, {double radius = 1, Point center = zero}) { + for (final cubic in shape) { + expectCircularCubic(cubic, radius, center); + } + } + + test('circle', () { + expect(() => RoundedPolygon.circle(numVertices: 2), throwsArgumentError); + + final circle = RoundedPolygon.circle(); + expectCircleShape(circle.cubics); + + final simpleCircle = RoundedPolygon.circle(numVertices: 3); + expectCircleShape(simpleCircle.cubics); + + final complexCircle = RoundedPolygon.circle(numVertices: 20); + expectCircleShape(complexCircle.cubics); + + final bigCircle = RoundedPolygon.circle(radius: 3); + expectCircleShape(bigCircle.cubics, radius: 3); + + const center = Point(1, 2); + final offsetCircle = RoundedPolygon.circle(center: center); + expectCircleShape(offsetCircle.cubics, center: center); + }); + + // Stars are complicated. For the unrounded version, we can check whether + // the vertices are the right distance from the center. For the rounded + // versions, just check that the shape is within the appropriate bounds. + test('star', () { + var star = RoundedPolygon.star(numVerticesPerRadius: 4); + List shape = star.cubics; + var radius = 1.0; + var innerRadius = 0.5; + + for (final cubic in shape) { + expectCubicOnRadii(cubic, radius, innerRadius); + } + + const center = Point(1, 2); + star = RoundedPolygon.star(numVerticesPerRadius: 4, innerRadius: innerRadius, center: center); + shape = star.cubics; + for (final cubic in shape) { + expectCubicOnRadii(cubic, radius, innerRadius, center); + } + + radius = 4; + innerRadius = 2; + star = RoundedPolygon.star(numVerticesPerRadius: 4, radius: radius, innerRadius: innerRadius); + shape = star.cubics; + for (final cubic in shape) { + expectCubicOnRadii(cubic, radius, innerRadius); + } + }); + + test('rounded star', () { + const rounding = CornerRounding(radius: 0.1); + const innerRounding = CornerRounding(radius: 0.2); + final perVtxRounded = [ + rounding, + innerRounding, + rounding, + innerRounding, + rounding, + innerRounding, + rounding, + innerRounding, + ]; + const min = Point(-1, -1); + const max = Point(1, 1); + + var star = RoundedPolygon.star(numVerticesPerRadius: 4, rounding: rounding); + expectInBounds(star.cubics, min, max); + + star = RoundedPolygon.star(numVerticesPerRadius: 4, innerRounding: innerRounding); + expectInBounds(star.cubics, min, max); + + star = RoundedPolygon.star( + numVerticesPerRadius: 4, + rounding: rounding, + innerRounding: innerRounding, + ); + expectInBounds(star.cubics, min, max); + + star = RoundedPolygon.star(numVerticesPerRadius: 4, perVertexRounding: perVtxRounded); + expectInBounds(star.cubics, min, max); + + expect( + () => RoundedPolygon.star(numVerticesPerRadius: 6, perVertexRounding: perVtxRounded), + throwsArgumentError, + ); + }); + }); +} diff --git a/packages/material_ui/test/shapes/test_utils.dart b/packages/material_ui/test/shapes/test_utils.dart new file mode 100644 index 000000000000..e504e8f3e749 --- /dev/null +++ b/packages/material_ui/test/shapes/test_utils.dart @@ -0,0 +1,114 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/cubic.dart'; +import 'package:material_ui/src/shapes/features.dart'; +import 'package:material_ui/src/shapes/point.dart'; +import 'package:material_ui/src/shapes/rounded_polygon.dart'; +import 'package:vector_math/vector_math_64.dart'; + +const _epsilon = 1e-4; + +bool equalish(double f0, double f1, double epsilon) { + return (f0 - f1).abs() < epsilon; +} + +bool pointsEqualish(Point p0, Point p1) { + return equalish(p0.x, p1.x, _epsilon) && equalish(p0.y, p1.y, _epsilon); +} + +bool cubicsEqualish(CubicBezier c0, CubicBezier c1) { + return pointsEqualish(c0.anchor0, c1.anchor0) && + pointsEqualish(c0.anchor1, c1.anchor1) && + pointsEqualish(c0.control0, c1.control0) && + pointsEqualish(c0.control1, c1.control1); +} + +// Test points equality within epsilon. +void expectPointsEqualish(Point expected, Point actual) { + final msg = '$expected vs. $actual'; + expect(expected.x, moreOrLessEquals(actual.x, epsilon: _epsilon), reason: msg); + expect(expected.y, moreOrLessEquals(actual.y, epsilon: _epsilon), reason: msg); +} + +void expectCubicsEqualish(CubicBezier expected, CubicBezier actual) { + expectPointsEqualish(expected.anchor0, actual.anchor0); + expectPointsEqualish(expected.control0, actual.control0); + expectPointsEqualish(expected.control1, actual.control1); + expectPointsEqualish(expected.anchor1, actual.anchor1); +} + +void expectCubicListsEqualish(List expected, List actual) { + expect(expected.length, actual.length); + for (var i = 0; i < expected.length; i++) { + expectCubicsEqualish(expected[i], actual[i]); + } +} + +void expectFeaturesEqualish(Feature expected, Feature actual) { + expectCubicListsEqualish(expected.cubics, actual.cubics); + expect(expected.runtimeType, actual.runtimeType); + + if (expected is CornerFeature && actual is CornerFeature) { + expect(expected.convex, actual.convex); + } +} + +void expectPolygonsEqualish(RoundedPolygon expected, RoundedPolygon actual) { + expectCubicListsEqualish(expected.cubics, actual.cubics); + + expect(expected.features.length, actual.features.length); + for (var i = 0; i < expected.features.length; i++) { + expectFeaturesEqualish(expected.features[i], actual.features[i]); + } +} + +void expectPointGreaterish(Point expected, Point actual) { + expect(actual.x >= expected.x - _epsilon, isTrue); + expect(actual.y >= expected.y - _epsilon, isTrue); +} + +void expectPointLessish(Point expected, Point actual) { + expect(actual.x <= expected.x + _epsilon, isTrue); + expect(actual.y <= expected.y + _epsilon, isTrue); +} + +void expectEqualish(double expected, double actual, [String? message]) { + expect(expected, moreOrLessEquals(actual, epsilon: _epsilon), reason: message); +} + +void expectInBounds(List shape, Point minPoint, Point maxPoint) { + for (final cubic in shape) { + expectPointGreaterish(minPoint, cubic.anchor0); + expectPointLessish(maxPoint, cubic.anchor0); + expectPointGreaterish(minPoint, cubic.control0); + expectPointLessish(maxPoint, cubic.control0); + expectPointGreaterish(minPoint, cubic.control1); + expectPointLessish(maxPoint, cubic.control1); + expectPointGreaterish(minPoint, cubic.anchor1); + expectPointLessish(maxPoint, cubic.anchor1); + } +} + +// The point a path starts drawing from. +Point pathStartPoint(Path path) => path.computeMetrics().first.getTangentForOffset(0)!.position; + +PointTransformer identityTransform() => + (x, y) => (x, y); + +PointTransformer pointRotator(double angleDegrees) { + final double angleRadians = angleDegrees * math.pi / 180; + final matrix = Matrix4.identity()..rotateZ(angleRadians); + return matrix.asPointTransformer(); +} + +PointTransformer scaleTransform(double sx, double sy) => + (x, y) => (x * sx, y * sy); + +PointTransformer translateTransform(double dx, double dy) => + (x, y) => (x + dx, y + dy);