From 1fccb310cb93ddc40bea2e7402f1c5c184331166 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Fri, 21 Aug 2026 19:17:31 +0200 Subject: [PATCH 01/59] Copy ksokolovskyi/material_shapes into material_ui. --- .../lib/src/shapes/material_shape_border.dart | 248 +++ .../lib/src/shapes/material_shapes.dart | 471 ++++++ .../src/shapes/shapes/corner_rounding.dart | 55 + .../lib/src/shapes/shapes/cubic.dart | 407 +++++ .../src/shapes/shapes/feature_mapping.dart | 189 +++ .../lib/src/shapes/shapes/features.dart | 215 +++ .../lib/src/shapes/shapes/float_mapping.dart | 147 ++ .../lib/src/shapes/shapes/morph.dart | 258 +++ .../lib/src/shapes/shapes/point.dart | 153 ++ .../src/shapes/shapes/polygon_measure.dart | 409 +++++ .../src/shapes/shapes/rounded_polygon.dart | 1447 +++++++++++++++++ .../lib/src/shapes/shapes/shapes.dart | 17 + .../lib/src/shapes/shapes/utils.dart | 358 ++++ .../test/shapes/corner_rounding_test.dart | 29 + .../material_ui/test/shapes/cubic_test.dart | 249 +++ .../test/shapes/feature_mapping_test.dart | 149 ++ .../test/shapes/features_test.dart | 62 + .../test/shapes/float_mapping_test.dart | 89 + .../material_ui/test/shapes/morph_test.dart | 103 ++ .../test/shapes/polygon_measure_test.dart | 233 +++ .../material_ui/test/shapes/polygon_test.dart | 245 +++ .../test/shapes/rounded_polygon_test.dart | 376 +++++ .../material_ui/test/shapes/shapes_test.dart | 201 +++ .../material_ui/test/shapes/test_utils.dart | 138 ++ 24 files changed, 6248 insertions(+) create mode 100644 packages/material_ui/lib/src/shapes/material_shape_border.dart create mode 100644 packages/material_ui/lib/src/shapes/material_shapes.dart create mode 100644 packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart create mode 100644 packages/material_ui/lib/src/shapes/shapes/cubic.dart create mode 100644 packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart create mode 100644 packages/material_ui/lib/src/shapes/shapes/features.dart create mode 100644 packages/material_ui/lib/src/shapes/shapes/float_mapping.dart create mode 100644 packages/material_ui/lib/src/shapes/shapes/morph.dart create mode 100644 packages/material_ui/lib/src/shapes/shapes/point.dart create mode 100644 packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart create mode 100644 packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart create mode 100644 packages/material_ui/lib/src/shapes/shapes/shapes.dart create mode 100644 packages/material_ui/lib/src/shapes/shapes/utils.dart create mode 100644 packages/material_ui/test/shapes/corner_rounding_test.dart create mode 100644 packages/material_ui/test/shapes/cubic_test.dart create mode 100644 packages/material_ui/test/shapes/feature_mapping_test.dart create mode 100644 packages/material_ui/test/shapes/features_test.dart create mode 100644 packages/material_ui/test/shapes/float_mapping_test.dart create mode 100644 packages/material_ui/test/shapes/morph_test.dart create mode 100644 packages/material_ui/test/shapes/polygon_measure_test.dart create mode 100644 packages/material_ui/test/shapes/polygon_test.dart create mode 100644 packages/material_ui/test/shapes/rounded_polygon_test.dart create mode 100644 packages/material_ui/test/shapes/shapes_test.dart create mode 100644 packages/material_ui/test/shapes/test_utils.dart diff --git a/packages/material_ui/lib/src/shapes/material_shape_border.dart b/packages/material_ui/lib/src/shapes/material_shape_border.dart new file mode 100644 index 000000000000..b11b59c2d96e --- /dev/null +++ b/packages/material_ui/lib/src/shapes/material_shape_border.dart @@ -0,0 +1,248 @@ +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/shapes.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 { + MaterialShapeBorder({ + required RoundedPolygon this.shape, + super.side, + this.squash = 0, + }) : _cubics = shape.cubics, + assert(squash >= 0 && squash <= 1, 'squash has to be in range [0, 1]'); + + const MaterialShapeBorder._fromCubics({ + required List cubics, + super.side, + this.squash = 0, + }) : shape = null, + _cubics = cubics, + assert(squash >= 0 && squash <= 1, 'squash has to be in range [0, 1]'); + + /// The shape this border represents. + /// + /// This value could be `null` if border is the result of lerp. + 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; + + @override + ShapeBorder scale(double t) { + final shape = this.shape; + + if (shape != null) { + return MaterialShapeBorder( + shape: shape, + side: side.scale(t), + squash: squash, + ); + } + + return MaterialShapeBorder._fromCubics( + cubics: _cubics, + side: side.scale(t), + squash: squash, + ); + } + + @override + ShapeBorder? lerpFrom(ShapeBorder? a, double t) { + if (t == 0) { + return a; + } + + if (t == 1.0) { + return this; + } + + if (a is MaterialShapeBorder) { + final aShape = a.shape; + final shape = this.shape; + + if (aShape == null || shape == null) { + throw StateError( + 'Lerping requires both MaterialShapeBorders to have non-null shapes. ' + 'This border is likely the result of a previous lerp and cannot be ' + 'used for further interpolation.', + ); + } + + return MaterialShapeBorder._fromCubics( + cubics: Morph(aShape, shape).asCubics(t), + side: BorderSide.lerp(a.side, side, t), + squash: ui.lerpDouble(a.squash, squash, 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) { + final bShape = b.shape; + final shape = this.shape; + + if (bShape == null || shape == null) { + throw StateError( + 'Lerping requires both MaterialShapeBorders to have non-null shapes. ' + 'This border is likely the result of a previous lerp and cannot be ' + 'used for further interpolation.', + ); + } + + return MaterialShapeBorder._fromCubics( + cubics: Morph( + shape, + bShape, + ).asCubics(t), + side: BorderSide.lerp(side, b.side, t), + squash: ui.lerpDouble(squash, b.squash, 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 oldShape = this.shape; + + if (oldShape != null) { + return MaterialShapeBorder( + shape: oldShape, + side: side ?? this.side, + squash: squash ?? this.squash, + ); + } + + return MaterialShapeBorder._fromCubics( + cubics: _cubics, + side: side ?? this.side, + squash: squash ?? this.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 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( + path: Path(), + startAngle: 0, + repeatPath: false, + closePath: true, + cubics: _cubics, + rotationPivotX: 0, + rotationPivotY: 0, + ).transform(matrix.storage); + } + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + final adjustedRect = rect.deflate(side.strokeInset); + return _getPathFromRect(adjustedRect); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + final 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 adjustedRect = rect.inflate(side.strokeOffset / 2); + final 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 && + other._cubics == _cubics && + other.side == side && + other.squash == squash; + } + + @override + int get hashCode => Object.hash(shape, _cubics, squash, side.hashCode); + + @override + String toString() { + return '${objectRuntimeType(this, 'MaterialShapeBorder')}' + '(side: $side, squash: $squash)'; + } +} diff --git a/packages/material_ui/lib/src/shapes/material_shapes.dart b/packages/material_ui/lib/src/shapes/material_shapes.dart new file mode 100644 index 000000000000..f8f4e79abbac --- /dev/null +++ b/packages/material_ui/lib/src/shapes/material_shapes.dart @@ -0,0 +1,471 @@ +import 'dart:collection'; +import 'dart:math' as math; + +import 'package:vector_math/vector_math_64.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 +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 _negative45Radians = -45 * math.pi / 180; + static const _negative90Radians = -90 * math.pi / 180; + static const _negative135Radians = -135 * math.pi / 180; + + /// A circle shape. + static final circle = RoundedPolygon.circle( + numVertices: 10, + radius: 0.5, + centerX: 0.5, + centerY: 0.5, + ); + + /// A square shape. + static final square = RoundedPolygon.rectangle( + width: 1, + height: 1, + rounding: _cornerRound30, + centerX: 0.5, + centerY: 0.5, + ); + + /// A slanted square shape. + static final 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 arch = + RoundedPolygon.fromVerticesNum( + 4, + perVertexRounding: const [ + _cornerRound100, + _cornerRound100, + _cornerRound20, + _cornerRound20, + ], + ) + .transformed((Matrix4.identity()..rotateZ(_negative135Radians)).asPointTransformer()) + .normalized(); + + /// A semi-circle shape. + static final semiCircle = RoundedPolygon.rectangle( + width: 1.6, + height: 1, + perVertexRounding: const [_cornerRound20, _cornerRound20, _cornerRound100, _cornerRound100], + ).normalized(); + + /// An oval shape. + static final oval = RoundedPolygon.circle() + .transformed( + (Matrix4.identity() + ..rotateZ(_negative45Radians) + ..scale(1.0, 0.64)) + .asPointTransformer(), + ) + .normalized(); + + /// An pill shape. + static final 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 triangle = RoundedPolygon.fromVerticesNum(3, rounding: _cornerRound20) + .transformed((Matrix4.identity()..rotateZ(_negative90Radians)).asPointTransformer()) + .normalized(); + + /// An arrow shape. + static final 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 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 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 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 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 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 sunny = RoundedPolygon.star( + numVerticesPerRadius: 8, + innerRadius: 0.8, + rounding: _cornerRound15, + ).normalized(); + + /// A very-sunny shape. + static final 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 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 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 cookie7Sided = + RoundedPolygon.star(numVerticesPerRadius: 7, innerRadius: 0.75, rounding: _cornerRound50) + .transformed((Matrix4.identity()..rotateZ(_negative90Radians)).asPointTransformer()) + .normalized(); + + /// A 9-sided cookie shape. + static final cookie9Sided = + RoundedPolygon.star(numVerticesPerRadius: 9, innerRadius: 0.8, rounding: _cornerRound50) + .transformed((Matrix4.identity()..rotateZ(_negative90Radians)).asPointTransformer()) + .normalized(); + + /// A 12-sided cookie shape. + static final cookie12Sided = + RoundedPolygon.star(numVerticesPerRadius: 12, innerRadius: 0.8, rounding: _cornerRound50) + .transformed((Matrix4.identity()..rotateZ(_negative90Radians)).asPointTransformer()) + .normalized(); + + /// A 4-leaf clover shape. + static final 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 actualPoints = _doRepeat(pnr, reps, center, mirroring); + + final vertices = List.filled(actualPoints.length * 2, 0); + final perVertexRounding = List.filled( + actualPoints.length, + CornerRounding.unrounded, + ); + + for (var i = 0; i < actualPoints.length; i++) { + final ap = actualPoints[i]; + perVertexRounding[i] = ap.r; + + final j = i * 2; + vertices[j] = ap.p.x; + vertices[j + 1] = ap.p.y; + } + + return RoundedPolygon.fromVertices( + vertices, + perVertexRounding: perVertexRounding, + centerX: center.x, + centerY: center.y, + ); + } + + static List<_PointNRound> _doRepeat( + List<_PointNRound> points, + int reps, + Point center, + bool mirroring, + ) { + final result = <_PointNRound>[]; + + if (mirroring) { + final measures = List.generate(points.length, (i) { + final point = points[i]; + final off = point.p - center; + return (angle: off.angleRadians, distance: off.getDistance()); + }); + final actualReps = reps * 2; + final sectionAngle = math.pi * 2 / actualReps; + + for (var r = 0; r < actualReps; r++) { + for (var index = 0; index < points.length; index++) { + final i = (r.isEven) ? index : points.length - 1 - index; + if (i > 0 || r.isEven) { + final a = + sectionAngle * r + + ((r.isEven) + ? measures[i].angle + : sectionAngle - measures[i].angle + 2 * measures[0].angle); + + final finalPoint = Point(math.cos(a), math.sin(a)) * measures[i].distance + center; + + result.add(_PointNRound(finalPoint, points[i].r)); + } + } + } + } else { + final np = points.length; + for (var i = 0; i < np * reps; i++) { + final 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/shapes/corner_rounding.dart b/packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart new file mode 100644 index 000000000000..213e396e0890 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart @@ -0,0 +1,55 @@ +part of 'shapes.dart'; + +/// Defines the amount and quality 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. +/// +/// [radius] is a value of 0 or greater, representing 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. Note that this radius is an +/// absolute size that should relate to the overall size of its shape. Thus if +/// the shape is in screen coordinate size, the radius should be sized +/// appropriately. If the shape is in some canonical form (bounds of (-1,-1) to +/// (1,1), for example, which is the default when creating a [RoundedPolygon] +/// from a number of vertices), then the radius should be relative to that +/// size. The radius will be scaled if the shape itself is transformed, since +/// it will produce curves which round the corner and thus get transformed +/// along with the overall shape. +/// +/// [smoothing] is 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 (no smoothing) indicates that the corner is rounded by only a circular +/// arc; there are no flanking curves. A value of 1 indicates that there is no +/// circular arc in the center; the flanking curves on either side meet at the +/// middle. +class CornerRounding { + static const unrounded = CornerRounding(); + + const CornerRounding({ + this.radius = 0, + this.smoothing = 0, + }) : assert(radius >= 0, 'radius has to be greater that zero'), + assert( + smoothing >= 0 && smoothing <= 1, + 'smoothing has to be in range [0, 1]', + ); + + final double radius; + + final double smoothing; +} diff --git a/packages/material_ui/lib/src/shapes/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/shapes/cubic.dart new file mode 100644 index 000000000000..d4f46f93e49d --- /dev/null +++ b/packages/material_ui/lib/src/shapes/shapes/cubic.dart @@ -0,0 +1,407 @@ +part of 'shapes.dart'; + +/// This class holds the anchor and control point data for a single cubic +/// Bézier curve, with anchor points ([anchor0X], [anchor0Y]) and ([anchor1X], +/// [anchor1Y]) at either end and control points ([control0X], [control0Y]) +/// and ([control1X], [control1Y]) determining the slope of the curve between +/// the anchor points. +@immutable +class Cubic { + /// Creates a Cubic that holds the anchor and control point data for a + /// single Bézier curve, with anchor points ([anchor0X], [anchor0Y]) and + /// ([anchor1X], [anchor1Y]) at either end and control points ([control0X], + /// [control0Y]) and ([control1X], [control1Y]) determining the slope of the + /// curve between the anchor points. + Cubic( + double anchor0X, + double anchor0Y, + double control0X, + double control0Y, + double control1X, + double control1Y, + double anchor1X, + double anchor1Y, + ) : this._raw([ + anchor0X, + anchor0Y, + control0X, + control0Y, + control1X, + control1Y, + anchor1X, + anchor1Y, + ]); + + const Cubic._raw(List points) + : assert(points.length == 8, 'Points array size should be 8.'), + _points = points; + + @internal + Cubic.fromPoints( + Point anchor0, + Point control0, + Point control1, + Point anchor1, + ) : this._raw([ + anchor0.x, + anchor0.y, + control0.x, + control0.y, + control1.x, + control1.y, + anchor1.x, + anchor1.y, + ]); + + /// Generates a bezier curve that is a straight line between the given anchor + /// points. The control points lie 1/3 of the distance from their respective + /// anchor points. + factory Cubic.straightLine( + double x0, + double y0, + double x1, + double y1, + ) { + return Cubic._raw([ + x0, + y0, + lerp(x0, x1, 1 / 3), + lerp(y0, y1, 1 / 3), + lerp(x0, x1, 2 / 3), + lerp(y0, y1, 2 / 3), + x1, + y1, + ]); + } + + /// Generates a bezier curve that approximates a circular arc, 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 the center. + // TODO: consider a more general function (maybe in addition to this) that + // allows caller to get a list of curves surpassing 180 degrees. + factory Cubic.circularArc( + double centerX, + double centerY, + double x0, + double y0, + double x1, + double y1, + ) { + final p0d = directionVector(x0 - centerX, y0 - centerY); + final p1d = directionVector(x1 - centerX, y1 - centerY); + final rotatedP0 = p0d.rotate90(); + final rotatedP1 = p1d.rotate90(); + final clockwise = rotatedP0.dotProductXY(x1 - centerX, y1 - centerY) >= 0; + final cosa = p0d.dotProduct(p1d); + + // p0 ~= p1 + if (cosa > 0.999) { + return Cubic.straightLine(x0, y0, x1, y1); + } + + final k = distance(x0 - centerX, y0 - centerY) * + 4 / + 3 * + (math.sqrt(2 * (1 - cosa)) - math.sqrt(1 - cosa * cosa)) / + (1 - cosa) * + (clockwise ? 1 : -1); + + return Cubic( + x0, + y0, + x0 + rotatedP0.x * k, + y0 + rotatedP0.y * k, + x1 - rotatedP1.x * k, + y1 - rotatedP1.y * k, + x1, + y1, + ); + } + + /// Generates an empty Cubic defined at (x0, y0). + Cubic.empty(double x0, double y0) + : this._raw([x0, y0, x0, y0, x0, y0, x0, y0]); + + final List _points; + + List get points => UnmodifiableListView(_points); + + double get anchor0X => _points[0]; + + double get anchor0Y => _points[1]; + + double get control0X => _points[2]; + + double get control0Y => _points[3]; + + double get control1X => _points[4]; + + double get control1Y => _points[5]; + + double get anchor1X => _points[6]; + + double get anchor1Y => _points[7]; + + /// Returns a point on the curve for parameter [t], representing the + /// proportional distance along the curve between its starting point at + /// anchor0 and ending point at anchor1. + /// + /// [t] is the distance along the curve between the anchor points, where 0 + /// is at anchor0 and 1 is at anchor1 + Point pointOnCurve(double t) { + final u = 1 - t; + return Point( + 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), + ); + } + + bool zeroLength() => + (anchor0X - anchor1X).abs() < distanceEpsilon && + (anchor0Y - anchor1Y).abs() < distanceEpsilon; + + bool convexTo(Cubic next) { + final prevVertex = Point(anchor0X, anchor0Y); + final currVertex = Point(anchor1X, anchor1Y); + final nextVertex = Point(next.anchor1X, next.anchor1Y); + return convex(prevVertex, currVertex, nextVertex); + } + + bool _zeroIsh(double value) => value.abs() < distanceEpsilon; + + /// Returns the true bounds of this curve, filling [bounds] with the + /// axis-aligned bounding box values for left, top, right, and bottom, + /// in that order. + void calculateBounds(List bounds, {bool approximate = false}) { + assert(bounds.length == 4, 'Bounds array size should be 4.'); + + // A curve might be of zero-length, with both anchors co-lated. + // Just return the point itself. + if (zeroLength()) { + bounds[0] = anchor0X; + bounds[1] = anchor0Y; + bounds[2] = anchor0X; + bounds[3] = anchor0Y; + return; + } + + var minX = math.min(anchor0X, anchor1X); + var minY = math.min(anchor0Y, anchor1Y); + var maxX = math.max(anchor0X, anchor1X); + var maxY = math.max(anchor0Y, anchor1Y); + + if (approximate) { + // Approximate bounds use the bounding box of all anchors and + // controls. + bounds[0] = math.min(minX, math.min(control0X, control1X)); + bounds[1] = math.min(minY, math.min(control0Y, control1Y)); + bounds[2] = math.max(maxX, math.max(control0X, control1X)); + bounds[3] = math.max(maxY, math.max(control0Y, control1Y)); + return; + } + + // Find the derivative, which is a quadratic Bezier. Then we can solve + // for t using the quadratic formula. + final xa = -anchor0X + 3 * control0X - 3 * control1X + anchor1X; + final xb = 2 * anchor0X - 4 * control0X + 2 * control1X; + final 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 t = 2 * xc / (-2 * xb); + if (t >= 0 && t <= 1) { + final x = pointOnCurve(t).x; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + } + } + } else { + final xs = xb * xb - 4 * xa * xc; + if (xs >= 0) { + final t1 = (-xb + math.sqrt(xs)) / (2 * xa); + if (t1 >= 0 && t1 <= 1) { + final x = pointOnCurve(t1).x; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + } + + final t2 = (-xb - math.sqrt(xs)) / (2 * xa); + if (t2 >= 0 && t2 <= 1) { + final x = pointOnCurve(t2).x; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + } + } + } + + // Repeat the above for y coordinate + final ya = -anchor0Y + 3 * control0Y - 3 * control1Y + anchor1Y; + final yb = 2 * anchor0Y - 4 * control0Y + 2 * control1Y; + final yc = -anchor0Y + control0Y; + + if (_zeroIsh(ya)) { + if (yb != 0) { + final t = 2 * yc / (-2 * yb); + if (t >= 0 && t <= 1) { + final y = pointOnCurve(t).y; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + } else { + final ys = yb * yb - 4 * ya * yc; + if (ys >= 0) { + final t1 = (-yb + math.sqrt(ys)) / (2 * ya); + if (t1 >= 0 && t1 <= 1) { + final y = pointOnCurve(t1).y; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + + final t2 = (-yb - math.sqrt(ys)) / (2 * ya); + if (t2 >= 0 && t2 <= 1) { + final y = pointOnCurve(t2).y; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + } + + bounds[0] = minX; + bounds[1] = minY; + bounds[2] = maxX; + bounds[3] = maxY; + } + + /// Returns two Cubics, created by splitting this curve at the given + /// distance of [t] between the original starting and ending anchor points. + // TODO: cartesian optimization? + (Cubic, Cubic) split(double t) { + final u = 1 - t; + final point = pointOnCurve(t); + + return ( + Cubic( + 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, + ), + Cubic( + // TODO: should calculate once and share the result. + 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, + ), + ); + } + + /// Utility function to reverse the control/anchor points for this curve. + Cubic reverse() => Cubic( + anchor1X, + anchor1Y, + control1X, + control1Y, + control0X, + control0Y, + anchor0X, + anchor0Y, + ); + + Cubic operator +(Cubic o) => + Cubic._raw(List.generate(8, (i) => _points[i] + o._points[i])); + + Cubic operator *(double x) => + Cubic._raw(List.generate(8, (i) => _points[i] * x)); + + Cubic operator /(double x) => this * (1.0 / x); + + Cubic transformed(PointTransformer f) { + final newCubic = _MutableCubic(); + for (var i = 0; i < 8; i++) { + newCubic._points[i] = _points[i]; + } + newCubic.transform(f); + return newCubic; + } + + @override + String toString() { + return 'anchor0: ($anchor0X, $anchor0Y) ' + 'control0: ($control0X, $control0Y), ' + 'control1: ($control1X, $control1Y), ' + 'anchor1: ($anchor1X, $anchor1Y)'; + } + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + + if (other is! Cubic) { + return false; + } + + if (_points.length != other._points.length) { + return false; + } + + for (var index = 0; index < _points.length; index += 1) { + if (_points[index] != other._points[index]) { + return false; + } + } + + return true; + } + + @override + int get hashCode => _points.hashCode; +} + +/// Mutable version of [Cubic], used mostly for performance critical paths so +/// we can avoid creating new [Cubic]s +/// +/// This is used in Morph.forEachCubic, reusing a [_MutableCubic] instance to +/// avoid creating new [Cubic]s. +class _MutableCubic extends Cubic { + _MutableCubic() : super._raw(List.filled(8, 0)); + + void _transformOnePoint(PointTransformer f, int ix) { + final 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); + } + + void interpolate(Cubic c1, Cubic c2, double progress) { + for (var i = 0; i < 8; i++) { + _points[i] = lerp(c1._points[i], c2._points[i], progress); + } + } +} diff --git a/packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart b/packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart new file mode 100644 index 000000000000..3f16c9cd200d --- /dev/null +++ b/packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart @@ -0,0 +1,189 @@ +part of 'shapes.dart'; + +/// MeasuredFeatures contains a list of all features in a polygon along with +/// the [0..1] progress at that feature. +typedef MeasuredFeatures = List; + +class ProgressableFeature { + const ProgressableFeature(this.progress, this.feature); + + final double progress; + + final Feature feature; +} + +class DistanceVertex { + const DistanceVertex(this.distance, this.f1, this.f2); + + final double distance; + + final ProgressableFeature f1; + + final ProgressableFeature f2; +} + +/// Creates a mapping between the "features" (rounded corners) of two shapes. +DoubleMapper featureMapper( + MeasuredFeatures features1, + MeasuredFeatures 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 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 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 pair of consecutive elements, and the +/// last element to first element). +List<(double, double)> doMapping( + List features1, + List features2, +) { + final distanceVertexList = []; + + for (final f1 in features1) { + for (final f2 in features2) { + final 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 d = distanceVertexList.first; + + final f1 = d.f1.progress; + final 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 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 insertionIndex = -index - 1; + final n = mapping.length; + + // We can always add the first 1 element. + if (n >= 1) { + final (before1, before2) = mapping[(insertionIndex + n - 1) % n]; + final (after1, 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 distance along overall shape between 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). +double featureDistSquared(Feature f1, Feature f2) { + // TODO: We might want to enable concave-convex matching in some situations. + // If so, the approach below will not work + 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)) + .getDistanceSquared(); +} + +// TODO: b/378441547 - Move to explicit parameter / expose? +Point featureRepresentativePoint(Feature feature) { + final cubics = feature.cubics; + final x = (cubics.first.anchor0X + cubics.last.anchor1X) / 2; + final y = (cubics.first.anchor0Y + cubics.last.anchor1Y) / 2; + return Point(x, y); +} diff --git a/packages/material_ui/lib/src/shapes/shapes/features.dart b/packages/material_ui/lib/src/shapes/shapes/features.dart new file mode 100644 index 000000000000..70555b22154f --- /dev/null +++ b/packages/material_ui/lib/src/shapes/shapes/features.dart @@ -0,0 +1,215 @@ +part of 'shapes.dart'; + +/// While a polygon's shape can be drawn solely using a list of [Cubic] 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.buildIgnorableFeature] are ignored in the default mapping. +/// +/// By using features, you can manipulate polygon shapes with more context and +/// control. +abstract class Feature { + const Feature(List cubics) : _cubics = cubics; + + /// Group a list of [Cubic] objects to 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.buildIgnorableFeature(List cubics) => + _validated(EdgeFeature(cubics)); + + /// Group a [Cubic] object to an edge (neither inward or outward + /// identification in a shape). + /// + /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. + factory Feature.buildEdge(Cubic cubic) => EdgeFeature([cubic]); + + /// Group a list of [Cubic] objects to a convex corner (outward indentation + /// in a shape). + /// + /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics + factory Feature.buildConvexCorner(List cubics) => + _validated(CornerFeature(cubics)); + + /// Group a list of [Cubic] objects to a concave corner (inward indentation + /// in a shape). + /// + /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics + factory Feature.buildConcaveCorner(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; + var prevCubic = feature._cubics.first; + for (var i = 1; i < feature._cubics.length; i++) { + final 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; + + /// Returns unmodifiable list of [Cubic]. + List get cubics => UnmodifiableListView(_cubics); + + /// Whether this Feature gets ignored in the Morph mapping. See + /// [Feature.buildIgnorableFeature] for more details + bool get isIgnorableFeature; + + /// 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 [PointTransformer] + /// and returns a new [Feature]. + Feature transformed(PointTransformer f); + + /// Returns a new [Feature] with the points that define the shape of this + /// [Feature] in reversed order. + Feature reversed(); +} + +/// 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 [Cubic] curves). +class EdgeFeature extends Feature { + EdgeFeature(super._cubics); + + @override + Feature transformed(PointTransformer f) => EdgeFeature( + List.generate( + _cubics.length, + (i) => _cubics[i].transformed(f), + ), + ); + + @override + Feature reversed() => EdgeFeature( + List.generate( + _cubics.length, + (i) => _cubics[_cubics.length - 1 - i].reverse(), + ), + ); + + @override + bool get isIgnorableFeature => true; + + @override + bool get isEdge => true; + + @override + bool get isCorner => false; + + @override + bool get isConvexCorner => false; + + @override + bool get isConcaveCorner => false; + + @override + String toString() => 'Edge'; +} + +/// 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. +class CornerFeature extends Feature { + const CornerFeature(super._cubics, {this.convex = true}); + + final bool convex; + + @override + Feature transformed(PointTransformer f) => CornerFeature( + List.generate( + _cubics.length, + (i) => _cubics[i].transformed(f), + ), + convex: convex, + ); + + @override + Feature reversed() => CornerFeature( + List.generate( + _cubics.length, + (i) => _cubics[_cubics.length - 1 - i].reverse(), + ), + // TODO: b/369320447 - Revert flag negation when [RoundedPolygon] + // ignores orientation for setting the flag. + convex: !convex, + ); + + @override + bool get isIgnorableFeature => 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 'Corner: cubics=${_cubics.map((c) => '[$c]').join(', ')} ' + 'convex=$convex'; + } +} diff --git a/packages/material_ui/lib/src/shapes/shapes/float_mapping.dart b/packages/material_ui/lib/src/shapes/shapes/float_mapping.dart new file mode 100644 index 000000000000..d2e2feba2f99 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/shapes/float_mapping.dart @@ -0,0 +1,147 @@ +part of 'shapes.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. +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. +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 segmentEndIndex = (segmentStartIndex + 1) % xValues.length; + final segmentSizeX = positiveModulo( + xValues[segmentEndIndex] - xValues[segmentStartIndex], + 1, + ); + final segmentSizeY = positiveModulo( + yValues[segmentEndIndex] - yValues[segmentStartIndex], + 1, + ); + final 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. +class DoubleMapper { + static final identity = DoubleMapper([ + (0.0, 0.0), + (0.5, 0.5), + ]); + + 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 pair = mappings[i]; + _sourceValues[i] = pair.$1; + _targetValues[i] = pair.$2; + } + validateProgress(_sourceValues); + validateProgress(_targetValues); + } + + 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. +void validateProgress(List p) { + if (p.isEmpty) { + throw ArgumentError('List is empty.'); + } + + var prev = p.last; + var wraps = 0; + + for (var i = 0; i < p.length; i++) { + final curr = p[i]; + + if (curr < 0 || curr >= 1) { + throw ArgumentError( + 'FloatMapping - Progress outside of range: ${p.join(', ')}', + ); + } + + if (progressDistance(curr, prev).abs() <= distanceEpsilon) { + throw ArgumentError( + 'FloatMapping - Progress repeats a value: ${p.join(', ')}', + ); + } + + if (curr < prev) { + wraps++; + if (wraps > 1) { + throw ArgumentError( + 'FloatMapping - 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. +double progressDistance(double p1, double p2) { + final diff = (p1 - p2).abs(); + return math.min(diff, 1.0 - diff); +} diff --git a/packages/material_ui/lib/src/shapes/shapes/morph.dart b/packages/material_ui/lib/src/shapes/shapes/morph.dart new file mode 100644 index 000000000000..c240e27f1aa4 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/shapes/morph.dart @@ -0,0 +1,258 @@ +part of 'shapes.dart'; + +/// This class is used to animate between start and end polygons objects. +/// +/// 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 [Cubic] 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. +class Morph { + Morph(RoundedPolygon start, RoundedPolygon end) + : _start = start, + _end = end { + _morphMatch = _match(start, end); + } + + final RoundedPolygon _start; + + 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. + late final List<(Cubic, Cubic)> _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 Cubic + /// 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<(Cubic, Cubic)> _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.measurePolygon( + const LengthMeasurer(), + p1, + ); + final measuredPolygon2 = MeasuredPolygon.measurePolygon( + 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 features1 = measuredPolygon1.features; + final 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 = featureMapper(features1, features2); + + // cut point on poly2 is the mapping of the 0 point on poly1. + final 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 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 = <(Cubic, Cubic)>[]; + // 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. + var b1 = bs1.getOrNull(i1++); + var b2 = bs2.getOrNull(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 b1a = (i1 == bs1.length) ? 1.0 : b1.endOutlineProgress; + final b2a = (i2 == bs2.length) + ? 1.0 + : doubleMapper.mapBack( + positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1), + ); + final 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 (seg1, newb1) = (b1a > minb + angleEpsilon) + ? b1.cutAtProgress(minb) + : (b1, bs1.getOrNull(i1++)); + + final (seg2, newb2) = (b2a > minb + angleEpsilon) + ? b2.cutAtProgress( + positiveModulo(doubleMapper.map(minb) - polygon2CutPoint, 1), + ) + : (b2, bs2.getOrNull(i2++)); + + ret.add((seg1.cubic, seg2.cubic)); + b1 = newb1; + b2 = newb2; + } + + assert( + b1 == null && b2 == null, + "Expected both Polygon's Cubic to be fully matched", + ); + + return ret; + } + + /// Calculates the axis-aligned bounds of the object. + /// + /// [approximate] when true, uses a faster calculation to create the bounding + /// box based on the min/max values of all anchor and control points that + /// make up the shape. Default value is true. + /// + /// [bounds] is a buffer to hold the results. If not supplied, a temporary + /// buffer will be created. + /// + /// Returns the axis-aligned bounding box for this object, where the + /// rectangles left, top, right, and bottom values will be stored in entries + /// 0, 1, 2, and 3, in that order. + List calculateBounds({ + List? bounds, + bool approximate = true, + }) { + bounds ??= List.filled(4, 0); + _start.calculateBounds(bounds: bounds, approximate: approximate); + final minX = bounds[0]; + final minY = bounds[1]; + final maxX = bounds[2]; + final maxY = bounds[3]; + _end.calculateBounds(bounds: bounds, approximate: approximate); + bounds[0] = math.min(minX, bounds[0]); + bounds[1] = math.min(minY, bounds[1]); + bounds[2] = math.max(maxX, bounds[2]); + bounds[3] = math.max(maxY, bounds[3]); + return bounds; + } + + /// Like [calculateBounds], this function calculates the axis-aligned bounds + /// of the object and returns that rectangle. But this function determines + /// the max dimension of the shape (by calculating the distance from its + /// center to the start and midpoint of each curve) and returns a square + /// which can be used to hold the object in any rotation. This function can + /// be used, for example, to calculate the max size of a UI element meant to + /// hold this shape in any rotation. + /// + /// [bounds] is a buffer to hold the results. If not supplied, a temporary + /// buffer will be created. + /// + /// Returns the axis-aligned max bounding box for this object, where the + /// rectangles left, top, right, and bottom values will be stored in entries + /// 0, 1, 2, and 3, in that order. + List calculateMaxBounds([List? bounds]) { + bounds ??= List.filled(4, 0); + _start.calculateMaxBounds(bounds); + final minX = bounds[0]; + final minY = bounds[1]; + final maxX = bounds[2]; + final maxY = bounds[3]; + _end.calculateMaxBounds(bounds); + bounds[0] = math.min(minX, bounds[0]); + bounds[1] = math.min(minY, bounds[1]); + bounds[2] = math.max(maxX, bounds[2]); + bounds[3] = math.max(maxY, bounds[3]); + return bounds; + } + + /// Returns a representation of the morph object at a given [progress] value + /// as a list of [Cubic]s. Note that this function causes a new list to be + /// created and populated, so there is some + /// overhead. + /// + /// [progress] is a value from 0 to 1 that determines the morph's current + /// shape, between the start and end shapes provided at construction time. A + /// value of 0 results in the start shape, a value of 1 results in the end + /// shape, and any value in between results in a shape which is a linear + /// interpolation between those two shapes. + /// + /// The range is generally [0..1] and values outside could result in + /// undefined shapes, but values close to (but outside) the range can be used + /// to get an exaggerated effect (e.g., for a bounce or overshoot animation). + List asCubics(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. + Cubic? firstCubic; + Cubic? lastCubic; + + for (var i = 0; i < _morphMatch.length; i++) { + final cubic = Cubic._raw( + List.generate(8, (j) { + return lerp( + _morphMatch[i].$1.points[j], + _morphMatch[i].$2.points[j], + progress, + ); + }), + ); + + firstCubic ??= cubic; + if (lastCubic != null) { + result.add(lastCubic); + } + lastCubic = cubic; + } + + if (lastCubic != null && firstCubic != null) { + result.add( + Cubic( + lastCubic.anchor0X, + lastCubic.anchor0Y, + lastCubic.control0X, + lastCubic.control0Y, + lastCubic.control1X, + lastCubic.control1Y, + firstCubic.anchor0X, + firstCubic.anchor0Y, + ), + ); + } + + return result; + } +} diff --git a/packages/material_ui/lib/src/shapes/shapes/point.dart b/packages/material_ui/lib/src/shapes/shapes/point.dart new file mode 100644 index 000000000000..8a45d1f57511 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/shapes/point.dart @@ -0,0 +1,153 @@ +part of 'shapes.dart'; + +typedef PointTransformer = (double, double) Function(double x, double y); + +@immutable +class Point { + static const zero = Point(0, 0); + + const Point(this.x, this.y); + + final double x; + + final double y; + + Point copy() => Point(x, y); + + Point rotate90() => Point(-y, x); + + Point rotate(double degrees, {Point center = Point.zero}) { + final radians = degrees * math.pi / 180; + final off = this - center; + final cos = math.cos(radians); + final sin = math.sin(radians); + return Point( + off.x * cos - off.y * sin, + off.x * sin + off.y * cos, + ) + + center; + } + + Point translate(double dx, double dy) => Point(x + dx, y + dy); + + Point scale(double sx, double sy) => Point(x * sx, y * sy); + + double get angleDegrees => angleRadians * math.pi / 180; + + double get angleRadians => math.atan2(y, x); + + /// The magnitude of the [Point], which is the distance of this point from + /// (0, 0). + /// + /// If you need this value to compare it to another [Point]'s distance, + /// consider using [getDistanceSquared] instead, since it is cheaper to + /// compute. + double getDistance() => math.sqrt(x * x + y * y); + + /// The square of the magnitude (which is the distance of this point from + /// (0, 0)) of the [Point]. + /// + /// This is cheaper than computing the [getDistance] itself. + double getDistanceSquared() => x * x + y * y; + + double dotProduct(Point other) => x * other.x + y * other.y; + + double dotProductXY(double otherX, double otherY) => x * otherX + y * otherY; + + /// Compute the Z coordinate of the cross product of two vectors, to check + /// if the second vector is going clockwise ( > 0 ) or counterclockwise + /// (< 0) compared with the first one. It could also be 0, if the vectors + /// are co-linear. + bool clockwise(Point other) => (x * other.y - y * other.x) > 0; + + Point getDirection() { + final d = getDistance(); + assert(d > 0, "Can't get the direction of a 0-length vector"); + return this / d; + } + + /// Unary negation operator. + /// + /// Returns a [Point] with the coordinates negated. + /// + /// If the [Point] represents an arrow on a plane, this operator returns the + /// same arrow but pointing in the reverse direction. + Point operator -() => Point(-x, -y); + + /// Binary subtraction operator. + /// + /// Returns a Point whose [x] value is the left-hand-side operand's [x] + /// minus the right-hand-side operand's [x] and whose [y] value is the + /// left-hand-side operand's [y] minus the right-hand-side operand's [y]. + Point operator -(Point operand) => Point(x - operand.x, y - operand.y); + + /// Binary addition operator. + /// + /// Returns a Point whose [x] value is the sum of the [x] values of the two + /// operands, and whose [y] value is the sum of the [y] values of the two + /// operands. + Point operator +(Point operand) => Point(x + operand.x, y + operand.y); + + /// Multiplication operator. + /// + /// Returns a Point whose coordinates are the coordinates of the + /// left-hand-side operand (a [Point]) multiplied by the scalar + /// right-hand-side operand (a [double]). + Point operator *(double operand) => Point(x * operand, y * operand); + + /// Division operator. + /// + /// Returns a Point whose coordinates are the coordinates of the + /// left-hand-side operand (a [Point]) divided by the scalar + /// right-hand-side operand (a [double]). + Point operator /(double operand) => Point(x / operand, y / operand); + + /// Modulo (remainder) operator. + /// + /// Returns a Point whose coordinates are the remainder of dividing the + /// coordinates of the left-hand-side operand (a [Point]) by the scalar + /// right-hand-side operand (a [double]). + Point operator %(double operand) => Point(x % operand, y % operand); + + Point transformed(PointTransformer f) { + final result = f(x, y); + return Point(result.$1, result.$2); + } + + @override + String toString() => 'Point($x, $y)'; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + + if (other is! Point) { + return false; + } + + return other.x == x && other.y == y; + } + + @override + int get hashCode => Object.hashAll([x, y]); +} + +/// 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). +Point interpolate(Point start, Point stop, double fraction) { + return Point( + lerp(start.x, stop.x, fraction), + lerp(start.y, stop.y, fraction), + ); +} diff --git a/packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart new file mode 100644 index 000000000000..233b7d83e350 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart @@ -0,0 +1,409 @@ +part of 'shapes.dart'; + +class MeasuredPolygon { + MeasuredPolygon._({ + required Measurer measurer, + required List 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, + _features = features { + 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.measurePolygon( + 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 = 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 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 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? getOrNull(int index) { + final length = _cubics.length; + + if (index < 0 || index >= length) { + return null; + } + + return _cubics[index]; + } + + /// Finds the point in the input list of measured cubics that pass 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.4f 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 targetIndex = _cubics.indexWhere( + (c) => + cuttingPoint >= c._startOutlineProgress && + cuttingPoint <= c._endOutlineProgress, + ); + final target = _cubics[targetIndex]; + + // Cut the target cubic. + // b1, b2 are two resulting cubics after cut + final (b1, 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 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 cubicIndex = (targetIndex + i - 1) % _cubics.length; + retOutlineProgress[i] = positiveModulo( + _cubics[cubicIndex]._endOutlineProgress - cuttingPoint, + 1, + ); + } + } + + // Shift the feature's outline progress too. + final 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. +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 Cubic 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; + } + + /// Cut 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 boundedCutOutlineProgress = cutOutlineProgress.coerceIn( + _startOutlineProgress, + _endOutlineProgress, + ); + final outlineProgressSize = _endOutlineProgress - _startOutlineProgress; + final 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 relativeProgress = progressFromStart / outlineProgressSize; + final t = measurer.findCubicCutPoint( + cubic, + relativeProgress * measuredSize, + ); + + if (t < 0 || t > 1) { + throw ArgumentError('Cubic 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 (c1, 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. +abstract interface class Measurer { + 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(Cubic c); + + /// 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(Cubic c, double m); +} + +/// 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. +class LengthMeasurer implements Measurer { + const LengthMeasurer(); + + // The minimum number needed to achieve up to 98.5% accuracy from the true + // arc length See PolygonMeasureTest.measureCircle + static const _segments = 3; + + @override + double measureCubic(Cubic c) { + return _closestProgressTo(c, double.infinity).$2; + } + + @override + double findCubicCutPoint(Cubic c, double m) { + return _closestProgressTo(c, m).$1; + } + + (double, double) _closestProgressTo(Cubic cubic, double threshold) { + var total = 0.0; + var remainder = threshold; + var prev = Point(cubic.anchor0X, cubic.anchor0Y); + + for (var i = 0; i <= _segments; i++) { + final progress = i / _segments; + final point = cubic.pointOnCurve(progress); + final segment = (point - prev).getDistance(); + + 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/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart new file mode 100644 index 000000000000..dee9e2f455e8 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart @@ -0,0 +1,1447 @@ +part of 'shapes.dart'; + +/// The RoundedPolygon class allows simple construction of polygonal shapes +/// with optional rounding at the vertices. Polygons can be constructed with +/// either the number of vertices desired or an ordered list of vertices. +@immutable +class RoundedPolygon { + RoundedPolygon._( + this.features, + this.center, + ) : cubics = [] { + _initCubics(); + + assert(() { + var prevCubic = cubics[cubics.length - 1]; + + for (var index = 0; index < cubics.length; index++) { + final cubic = cubics[index]; + + 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; + } + + return true; + }()); + } + + /// This constructor takes the number of vertices in the resulting polygon. + /// These vertices are positioned on a virtual circle around a given center + /// with each vertex positioned [radius] distance from that center, equally + /// spaced (with equal angles between them). If no radius is supplied, the + /// shape will be created with a default radius of 1, resulting in a shape + /// whose vertices lie on a unit circle, with width/height of 2. That default + /// polygon will probably need to be rescaled using [transformed] into the + /// appropriate size for the UI in which it will be drawn. + /// + /// The [rounding] and [perVertexRounding] parameters are optional. If not + /// supplied, the result will be a regular polygon with straight edges and + /// unrounded corners. + /// + /// [numVertices] is the number of vertices in this polygon. + /// + /// [radius] is the radius of the polygon, in pixels. This radius determines + /// the initial size of the object, but it can be transformed later by using + /// the [transformed] function. + /// + /// [centerX] is the X coordinate of the center of the polygon, around which + /// all vertices will be placed. The default center is at (0,0). + /// + /// [centerY] is the Y coordinate of the center of the polygon, around which + /// all vertices will be placed. The default center is at (0,0). + /// + /// [rounding] is the [CornerRounding] properties of all vertices. If some + /// vertices should have different rounding properties, then use + /// [perVertexRounding] instead. The default rounding value is + /// [CornerRounding.unrounded], meaning that the polygon will use the + /// vertices themselves in the final shape and not curves rounded around the + /// vertices. + /// + /// [perVertexRounding] is the [CornerRounding] properties of every vertex. + /// If this parameter is not null, then it must have [numVertices] elements. + /// If this parameter is null, then the polygon will use the [rounding] + /// parameter for every vertex instead. The default value is null. + /// + /// Throws [ArgumentError] if [perVertexRounding] is not null and its size + /// is not equal to [numVertices]. + /// Throws [ArgumentError] when [numVertices] is less than 3. + factory RoundedPolygon.fromVerticesNum( + int numVertices, { + double radius = 1, + double centerX = 0, + double centerY = 0, + CornerRounding rounding = CornerRounding.unrounded, + List? perVertexRounding, + }) { + if (numVertices < 3) { + throw ArgumentError('numVertices must be at least 3.'); + } + + return RoundedPolygon.fromVertices( + _verticesFromNumVerts(numVertices, radius, centerX, centerY), + rounding: rounding, + perVertexRounding: perVertexRounding, + centerX: centerX, + centerY: centerY, + ); + } + + /// Creates a copy of the given [RoundedPolygon]. + RoundedPolygon.from(RoundedPolygon roundedPolygon) + : this._(roundedPolygon.features, roundedPolygon.center); + + /// This function takes the vertices (either supplied or calculated, + /// depending on the constructor called), plus [CornerRounding] parameters, + /// and creates the actual [RoundedPolygon] shape, rounding around the + /// vertices (or not) as specified. The result is a list of [Cubic] curves + /// which represent the geometry of the final shape. + /// + /// [vertices] is the list of vertices in this polygon specified as pairs of + /// x/y coordinates in this `List`. This should be an ordered list + /// (with the outline of the shape going from each vertex to the next in + /// order of this list), otherwise the results will be undefined. + /// + /// [rounding] is the [CornerRounding] properties of all vertices. If some + /// vertices should have different rounding properties, then use + /// [perVertexRounding] instead. The default rounding value is + /// [CornerRounding.unrounded], meaning that the polygon will use the + /// vertices themselves in the final shape and not curves rounded around the + /// vertices. + /// + /// [perVertexRounding] is the [CornerRounding] properties of all vertices. + /// If this parameter is not null, then it must have the same size as + /// [vertices]. If this parameter is null, then the polygon will use the + /// [rounding] parameter for every vertex instead. The default value is null. + /// + /// [centerX] is the X coordinate of the center of the polygon, around which + /// all vertices will be placed. The default center is at (0,0). + /// + /// [centerY] is the Y coordinate of the center of the polygon, around which + /// all vertices will be placed. The default center is at (0,0). + /// + /// Throws [ArgumentError] if the number of vertices is less than 3 (the + /// [vertices] parameter has less than 6 Floats). Or if the + /// [perVertexRounding] parameter is not null and the size doesn't match the + /// number vertices. + /// + // TODO(performance): Update the map calls to more efficient code that + // doesn't allocate Iterators unnecessarily. + factory RoundedPolygon.fromVertices( + List vertices, { + CornerRounding rounding = CornerRounding.unrounded, + List? perVertexRounding, + double centerX = double.minPositive, + double centerY = double.minPositive, + }) { + if (vertices.length < 6) { + throw ArgumentError('Polygons must have at least 3 vertices.'); + } + if (vertices.length.isOdd) { + throw ArgumentError('The vertices array should have even size.'); + } + if (perVertexRounding != null && + perVertexRounding.length * 2 != vertices.length) { + throw ArgumentError('perVertexRounding list should be either null or ' + 'the same size as the number of vertices (vertices.size / 2).'); + } + final corners = >[]; + final n = vertices.length ~/ 2; + final roundedCorners = <_RoundedCorner>[]; + for (var i = 0; i < n; i++) { + final vtxRounding = perVertexRounding?[i] ?? rounding; + final prevIndex = ((i + n - 1) % n) * 2; + final nextIndex = ((i + 1) % n) * 2; + roundedCorners.add( + _RoundedCorner( + Point(vertices[prevIndex], vertices[prevIndex + 1]), + Point(vertices[i * 2], vertices[i * 2 + 1]), + Point(vertices[nextIndex], vertices[nextIndex + 1]), + 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 cutAdjusts = List.generate(n, (ix) { + final expectedRoundCut = roundedCorners[ix].expectedRoundCut + + roundedCorners[(ix + 1) % n].expectedRoundCut; + final expectedCut = roundedCorners[ix].expectedCut + + roundedCorners[(ix + 1) % n].expectedCut; + final vtxX = vertices[ix * 2]; + final vtxY = vertices[ix * 2 + 1]; + final nextVtxX = vertices[((ix + 1) % n) * 2]; + final nextVtxY = vertices[((ix + 1) % n) * 2 + 1]; + final sideSize = distance(vtxX - nextVtxX, vtxY - nextVtxY); + + // 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); + } else if (expectedCut > sideSize) { + // We can do full rounding, but not full smoothing. + return ( + 1, + (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut) + ); + } else { + // There is enough room for rounding & smoothing. + return (1, 1); + } + }); + + // 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 (roundCutRatio, 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++) { + // Note that these indices are for pairs of values (points), they need to + // be doubled to access the xy values in the vertices float array. + final prevVtxIndex = (i + n - 1) % n; + final nextVtxIndex = (i + 1) % n; + final currVertex = Point(vertices[i * 2], vertices[i * 2 + 1]); + final prevVertex = Point( + vertices[prevVtxIndex * 2], + vertices[prevVtxIndex * 2 + 1], + ); + final nextVertex = Point( + vertices[nextVtxIndex * 2], + vertices[nextVtxIndex * 2 + 1], + ); + final cvx = convex(prevVertex, currVertex, nextVertex); + tempFeatures + ..add(CornerFeature(corners[i], convex: cvx)) + ..add( + EdgeFeature( + [ + Cubic.straightLine( + corners[i].last.anchor1X, + corners[i].last.anchor1Y, + corners[(i + 1) % n].first.anchor0X, + corners[(i + 1) % n].first.anchor0Y, + ), + ], + ), + ); + } + + final double cX; + final double cY; + + if (centerX == double.minPositive || centerY == double.minPositive) { + final center = calculateCenter(vertices); + cX = center.x; + cY = center.y; + } else { + cX = centerX; + cY = centerY; + } + + return RoundedPolygon.fromFeatures(tempFeatures, centerX: cX, centerY: cY); + } + + /// Takes a list of [Feature] objects that define the polygon's shape and + /// curves. By specifying the features directly, the summarization of [Cubic] + /// objects to curves can be precisely controlled. This affects [Morph]'s + /// default mapping, as curves with the same type (convex or concave) are + /// mapped with each other. For example, if you have a convex curve in your + /// start polygon, [Morph] will map it to another convex curve in the end + /// polygon. + /// + /// The [centerX] and [centerY] parameters are optional. If not supplied, + /// they will be estimated by calculating the average of all cubic anchor + /// points. + /// + /// [features] are the [Feature]s that describe the characteristics of each + /// outline segment of the polygon. + /// + /// [centerX] is the X coordinate of the center of the polygon, around which + /// all vertices will be placed. If none provided, the center will be + /// averaged. + /// + /// [centerY] is the Y coordinate of the center of the polygon, around which + /// all vertices will be placed. If none provided, the center will be + /// averaged. + /// + /// Throws [ArgumentError] if [features] length is less than 2 or if they + /// don't describe a closed shape. + @internal + factory RoundedPolygon.fromFeatures( + List features, { + double centerX = double.nan, + double centerY = double.nan, + }) { + if (features.length < 2) { + throw ArgumentError('Polygons must have at least 2 features.'); + } + + if (centerX.isNaN || centerY.isNaN) { + final vertices = []; + + for (final feature in features) { + for (final cubic in feature.cubics) { + vertices + ..add(cubic.anchor0X) + ..add(cubic.anchor0Y); + } + } + + final center = calculateCenter(vertices); + + final cX = centerX.isNaN ? center.x : centerX; + final cY = centerY.isNaN ? center.y : centerY; + + return RoundedPolygon._(features, Point(cX, cY)); + } + + return RoundedPolygon._(features, Point(centerX, centerY)); + } + + /// Creates a circular shape, approximating the rounding of the shape around + /// the underlying polygon + /// vertices. + /// + /// [numVertices] is the number of vertices in the underlying polygon with + /// which to approximate the circle, default value is 8. + /// + /// [radius] is the optional radius for the circle, default value is 1.0. + /// + /// [centerX] is the X coordinate of optional center for the circle, default + /// value is 0. + /// + /// [centerY] is the Y coordinate of optional center for the circle, default + /// value is 0. + /// + /// Throws [ArgumentError] when [numVertices] is less than 3. + factory RoundedPolygon.circle({ + int numVertices = 8, + double radius = 1, + double centerX = 0, + double centerY = 0, + }) { + if (numVertices < 3) { + throw ArgumentError('Circle must have at least three vertices.'); + } + + // Half of the angle between two adjacent vertices on the polygon. + final theta = math.pi / numVertices; + // Radius of the underlying RoundedPolygon object given the desired radius + // of the circle. + final polygonRadius = radius / math.cos(theta); + return RoundedPolygon.fromVerticesNum( + numVertices, + radius: polygonRadius, + centerX: centerX, + centerY: centerY, + rounding: CornerRounding(radius: radius), + ); + } + + /// Creates a rectangular shape with the given width/height around the given + /// center. Optional rounding parameters can be used to create a rounded + /// rectangle instead. + /// + /// As with all [RoundedPolygon] objects, if this shape is created with + /// default dimensions and center, it is sized to fit within the 2x2 + /// bounding box around a center of (0, 0) and will need to be scaled and + /// moved using [RoundedPolygon.transformed] to fit the intended area in a UI. + /// + /// [width] is the width of the rectangle, default value is 2. + /// + /// [height] is the height of the rectangle, default value is 2. + /// + /// [rounding] is the [CornerRounding] properties of every vertex. If some + /// vertices should have different rounding properties, then use + /// [perVertexRounding] instead. The default rounding value is + /// [CornerRounding.unrounded], meaning that the polygon will use the + /// vertices themselves in the final shape and not curves rounded around the + /// vertices. + /// + /// [perVertexRounding] is the [CornerRounding] properties of every vertex. + /// If this parameter is not null, then it must be of size 4 for the four + /// corners of the shape. If this parameter is null, then the polygon will + /// use the [rounding] parameter for every vertex instead. The default value + /// is null. + /// + /// [centerX] is the X coordinate of the center of the rectangle, around which + /// all vertices will be placed equidistantly. The default center is at (0,0). + /// + /// [centerY] is the Y coordinate of the center of the rectangle, around + /// which all vertices will be placed equidistantly. The default center is + /// at (0,0). + factory RoundedPolygon.rectangle({ + double width = 2, + double height = 2, + CornerRounding rounding = CornerRounding.unrounded, + List? perVertexRounding, + double centerX = 0, + double centerY = 0, + }) { + final left = centerX - width / 2; + final top = centerY - height / 2; + final right = centerX + width / 2; + final bottom = centerY + height / 2; + + return RoundedPolygon.fromVertices( + [right, bottom, left, bottom, left, top, right, top], + rounding: rounding, + perVertexRounding: perVertexRounding, + centerX: centerX, + centerY: centerY, + ); + } + + /// Creates a star polygon, which is like a regular polygon except every + /// other vertex is on either an inner or outer radius. The two radii + /// specified in the constructor must both both nonzero. If the radii are + /// equal, the result will be a regular (not star) polygon with twice the + /// number of vertices specified in [numVerticesPerRadius]. + /// + /// [numVerticesPerRadius] is the number of vertices along each of the two + /// radii. + /// + /// [radius] is the outer radius for this star shape, must be greater than 0. + /// Default value is 1. + /// + /// [innerRadius] is the inner radius for this star shape, must be greater + /// than 0 and less than or equal to [radius]. Note that equal radii would + /// be the same as creating a [RoundedPolygon] directly, but with + /// 2 * [numVerticesPerRadius] vertices. Default value is 0.5. + /// + /// [rounding] is the [CornerRounding] properties of every vertex. If some + /// vertices should have different rounding properties, then use + /// [perVertexRounding] instead. The default rounding value is + /// [CornerRounding.unrounded], meaning that the polygon will use the + /// vertices themselves in the final shape and not curves rounded around the + /// vertices. + /// + /// [innerRounding] is the optional rounding parameters for the vertices on + /// the [innerRadius]. If null (the default value), inner vertices will use + /// the [rounding] or [perVertexRounding] parameters instead. + /// + /// [perVertexRounding] is the the [CornerRounding] properties of every + /// vertex. If this parameter is not null, then it must have the same size as + /// 2 * [numVerticesPerRadius]. If this parameter is null, then the polygon + /// will use the [rounding] parameter for every vertex instead. The default + /// value is null. + /// + /// [centerX] is the X coordinate of the center of the polygon, around which + /// all vertices will be placed. The default center is at (0,0). + /// + /// [centerY] is the Y coordinate of the center of the polygon, around which + /// all vertices will be placed. The default center is at (0,0). + /// + /// Throws [ArgumentError] if either [radius] or [innerRadius] are <= 0 or + /// [innerRadius] > [radius]. + factory RoundedPolygon.star({ + required int numVerticesPerRadius, + double radius = 1, + double innerRadius = 0.5, + CornerRounding rounding = CornerRounding.unrounded, + CornerRounding? innerRounding, + List? perVertexRounding, + double centerX = 0, + double centerY = 0, + }) { + 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, + centerX, + centerY, + ), + rounding: rounding, + perVertexRounding: pvRounding, + centerX: centerX, + centerY: centerY, + ); + } + + /// A pill shape consists of a rectangle shape bounded by two semicircles at + /// either of the long ends of the rectangle. + /// + /// [width] is the width of the resulting shape. + /// + /// [height is the height of the resulting shape. + /// + /// [smoothing] the amount by which the arc is "smoothed" by extending the + /// curve from the circular arc on each endcap to the edge between the + /// endcaps. A value of 0 (no smoothing) indicates that the corner is rounded + /// by only a circular arc. + /// + /// [centerX] is the X coordinate of the center of the polygon, around which + /// all vertices will be placed. The default center is at (0,0). + /// + /// [centerY] is the Y coordinate of the center of the polygon, around which + /// all vertices will be placed. The default center is at (0,0). + /// + /// Throws [ArgumentError] if either [width] or [height] are <= 0. + factory RoundedPolygon.pill({ + double width = 2, + double height = 1, + double smoothing = 0, + double centerX = 0, + double centerY = 0, + }) { + if (width <= 0 || height <= 0) { + throw ArgumentError('Pill shapes must have positive width and height.'); + } + + final wHalf = width / 2; + final hHalf = height / 2; + + return RoundedPolygon.fromVertices( + [ + wHalf + centerX, + hHalf + centerY, + -wHalf + centerX, + hHalf + centerY, + -wHalf + centerX, + -hHalf + centerY, + wHalf + centerX, + -hHalf + centerY, + ], + rounding: CornerRounding( + radius: math.min(wHalf, hHalf), + smoothing: smoothing, + ), + centerX: centerX, + centerY: centerY, + ); + } + + /// A pillStar shape is like a [RoundedPolygon.pill] except it has inner and + /// outer radii along its pill-shaped outline, just like a + /// [RoundedPolygon.star] has inner and outer radii along its circular + /// outline. The parameters for a [RoundedPolygon.pillStar] are similar to + /// those of a [RoundedPolygon.star] except, like [RoundedPolygon.pill], it + /// has a [width] and [height] to determine the general shape of the + /// underlying pill. Also, there is a subtle complication with the way that + /// inner and outer vertices proceed along the circular ends of the + /// shape, depending on the magnitudes of the [rounding], [innerRounding], + /// and [innerRadiusRatio] parameters. For example, a shape with outer + /// vertices that lie along the curved end outline will necessarily have + /// inner vertices that are closer to each other, because of the curvature of + /// that part of the shape. Conversely, if the inner vertices are lined up + /// along the pill outline at the ends, then the outer vertices will be much + /// further apart from each other. + /// + /// The default approach, reflected by the default value of [vertexSpacing], + /// is to use the average of the outer and inner radii, such that each set of + /// vertices falls equally to the other side of the pill outline on the + /// curved ends. Depending on the values used for the various rounding + /// and radius parameters, you may want to change that value to suit the + /// look you want. A value of 0 for [vertexSpacing] is equivalent to aligning + /// the inner vertices along the circular curve, and a value of 1 is + /// equivalent to aligning the outer vertices along that curve. + /// + /// [width] is the width of the resulting shape. + /// + /// [height] is the height of the resulting shape. + /// + /// [numVerticesPerRadius] is the number of vertices along each of the two + /// radii. + /// + /// [innerRadiusRatio] is the Inner radius ratio for this star shape, must be + /// greater than 0 and less than or equal to 1. Note that a value of 1 would + /// be similar to creating a [RoundedPolygon.pill], but with more vertices. + /// The default value is 0.5. + /// + /// [rounding] is the [CornerRounding] properties of every vertex. If some + /// vertices should have different rounding properties, then use + /// [perVertexRounding] instead. The default rounding value is + /// [CornerRounding.unrounded], meaning that the polygon will use the + /// vertices themselves in the final shape and not curves rounded around the + /// vertices. + /// + /// [innerRounding] is the optional rounding parameters for the vertices on + /// the [innerRadiusRatio]. If null (the default value), inner vertices will + /// use the [rounding] or [perVertexRounding] parameters instead. + /// [perVertexRounding] is the [CornerRounding] properties of every vertex. + /// If this parameter is not null, then it must have the same size as + /// 2 * [numVerticesPerRadius]. If this parameter is null, then the polygon + /// will use the [rounding] parameter for every vertex instead. The default + /// value is null. + /// + /// [vertexSpacing] is the factor, which determines how the vertices on the + /// circular ends are laid out along the outline. A value of 0 aligns spaces + /// the inner vertices the same as those along the straight edges, with the + /// outer vertices then being spaced further apart. A value of 1 does the + /// opposite, with the outer vertices spaced the same as the vertices on the + /// straight edges. The default value is .5, which takes the average of these + /// two extremes. + /// + /// [startLocation] is a value from 0 to 1 which determines how far along + /// the perimeter of this shape to start the underlying curves of which it is + /// comprised. This is not usually needed or noticed by the user. But if the + /// caller wants to manually and gradually stroke the path when drawing it, + /// it might matter where that path outline begins and ends. The default + /// value is 0. + /// + /// [centerX] is the X coordinate of the center of the polygon, around which + /// all vertices will be placed. The default center is at (0,0). + /// + /// [centerY] is the Y coordinate of the center of the polygon, around which + /// all vertices will be placed. The default center is at (0,0). + /// + /// Throws [ArgumentError] if either [width] or [height] are <= 0 or + /// if [innerRadiusRatio] is outside the range of (0, 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, + double centerX = 0, + double centerY = 0, + }) { + if (width <= 0 || height <= 0) { + throw ArgumentError('Pill shapes must have positive width and height.'); + } + if (innerRadiusRatio <= 0 || innerRadiusRatio > 1) { + throw ArgumentError('innerRadius must 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, + centerX, + centerY, + ), + rounding: rounding, + perVertexRounding: pvRounding, + centerX: centerX, + centerY: centerY, + ); + } + + final List features; + + final Point center; + + /// A flattened version of the [Feature]s, as a `List`. + final List cubics; + + double get centerX => center.x; + + double get centerY => center.y; + + void _initCubics() { + // 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. + Cubic? firstCubic; + Cubic? lastCubic; + List? firstFeatureSplitStart; + List? firstFeatureSplitEnd; + + if (features.isNotEmpty && features[0].cubics.length == 3) { + final centerCubic = features[0].cubics[1]; + final (start, 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 cubic = featureCubics[j]; + + if (!cubic.zeroLength()) { + 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 points = lastCubic.points.toList(); + points[6] = cubic.anchor1X; + points[7] = cubic.anchor1Y; + lastCubic = Cubic._raw(points); + } + } + } + } + + if (lastCubic != null && firstCubic != null) { + cubics.add( + Cubic( + lastCubic.anchor0X, + lastCubic.anchor0Y, + lastCubic.control0X, + lastCubic.control0Y, + lastCubic.control1X, + lastCubic.control1Y, + firstCubic.anchor0X, + firstCubic.anchor0Y, + ), + ); + } else { + // Empty / 0-sized polygon. + cubics.add( + Cubic( + centerX, + centerY, + centerX, + centerY, + centerX, + centerY, + centerX, + centerY, + ), + ); + } + } + + /// Transforms (scales/translates/etc.) this [RoundedPolygon] with the given + /// [PointTransformer] and returns a new [RoundedPolygon]. This is a low + /// level API and there should be more platform idiomatic ways to transform + /// a [RoundedPolygon] provided by the platform specific wrapper. + /// + /// [f] is the [PointTransformer] used to transform this [RoundedPolygon]. + RoundedPolygon transformed(PointTransformer f) { + final center = this.center.transformed(f); + return RoundedPolygon._( + [ + for (var i = 0; i < features.length; i++) features[i].transformed(f), + ], + center, + ); + } + + /// Creates a new RoundedPolygon, moving and resizing this one, so it's + /// completely inside the (0, 0) -> (1, 1) square, centered if there extra + /// space in one direction. + RoundedPolygon normalized() { + final bounds = calculateBounds(); + final width = bounds[2] - bounds[0]; + final height = bounds[3] - bounds[1]; + final side = math.max(width, height); + + // Center the shape if bounds are not a square. + final offsetX = (side - width) / 2 - bounds[0]; /* left */ + final offsetY = (side - height) / 2 - bounds[1]; /* top */ + + return transformed( + (x, y) => ((x + offsetX) / side, (y + offsetY) / side), + ); + } + + /// Like [calculateBounds], this function calculates the axis-aligned bounds + /// of the object and returns that rectangle. But this function determines + /// the max dimension of the shape (by calculating the distance from its + /// center to the start and midpoint of each curve) and returns a square + /// which can be used to hold the object in any rotation. This function can + /// be used, for example, to calculate the max size of a UI element meant to + /// hold this shape in any rotation. + /// + /// [bounds] is a buffer to hold the results. If not supplied, a temporary + /// buffer will be created. + /// + /// Returns the axis-aligned max bounding box for this object, where the + /// rectangles left, top, right, and bottom values will be stored in entries + /// 0, 1, 2, and 3, in that order. + List calculateMaxBounds([List? bounds]) { + bounds ??= List.filled(4, 0); + + if (bounds.length < 4) { + throw ArgumentError('Required bounds size of 4.'); + } + + var maxDistSquared = 0.0; + for (var i = 0; i < cubics.length; i++) { + final cubic = cubics[i]; + final anchorDistance = + distanceSquared(cubic.anchor0X - centerX, cubic.anchor0Y - centerY); + final middlePoint = cubic.pointOnCurve(0.5); + final middleDistance = + distanceSquared(middlePoint.x - centerX, middlePoint.y - centerY); + maxDistSquared = + math.max(maxDistSquared, math.max(anchorDistance, middleDistance)); + } + + final distance = math.sqrt(maxDistSquared); + + bounds[0] = centerX - distance; + bounds[1] = centerY - distance; + bounds[2] = centerX + distance; + bounds[3] = centerY + distance; + + return bounds; + } + + /// Calculates the axis-aligned bounds of the object. + /// + /// [bounds] is a buffer to hold the results. If not supplied, a temporary + /// buffer will be created. + /// + /// [approximate] when true, uses a faster calculation to create the bounding + /// box based on the min/max values of all anchor and control points that + /// make up the shape. Default value is true. + /// + /// Returns the axis-aligned bounding box for this object, where the + /// rectangles left, top, right, and bottom values will be stored in entries + /// 0, 1, 2, and 3, in that order. + List calculateBounds({ + List? bounds, + bool approximate = true, + }) { + bounds ??= List.filled(4, 0); + + if (bounds.length < 4) { + throw ArgumentError('Required bounds size of 4.'); + } + + var minX = double.maxFinite; + var minY = double.maxFinite; + var maxX = double.minPositive; + var maxY = double.minPositive; + + for (var i = 0; i < cubics.length; i++) { + cubics[i].calculateBounds(bounds, approximate: approximate); + minX = math.min(minX, bounds[0]); + minY = math.min(minY, bounds[1]); + maxX = math.max(maxX, bounds[2]); + maxY = math.max(maxY, bounds[3]); + } + + bounds[0] = minX; + bounds[1] = minY; + bounds[2] = maxX; + bounds[3] = maxY; + + return bounds; + } + + @override + String toString() { + return '[RoundedPolygon. ' + 'Cubics = ${cubics.join(", ")}' + ' || Features = ${features.join(", ")}' + ' || Center = ($centerX, $centerY)]'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other is! RoundedPolygon) { + return false; + } + + if (features.length != other.features.length) { + return false; + } + + for (var index = 0; index < features.length; index += 1) { + if (features[index] != other.features[index]) { + return false; + } + } + + return true; + } + + @override + int get hashCode => features.hashCode; +} + +/// 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. +Point calculateCenter(List vertices) { + var cumulativeX = 0.0; + var cumulativeY = 0.0; + var index = 0; + while (index < vertices.length) { + cumulativeX += vertices[index++]; + cumulativeY += vertices[index++]; + } + return Point( + cumulativeX / (vertices.length / 2), + cumulativeY / (vertices.length / 2), + ); +} + +/// Private utility class that holds the information about each corner in a +/// polygon. The shape of the corner can be returned by calling the [getCubics] +/// function, which will return a list of curves representing the corner +/// geometry. The shape of the corner depends on the [rounding] constructor +/// parameter. +/// +/// If rounding is null, there is no rounding; the corner will simply be a +/// single point at [p1]. This point will be represented by a [Cubic] of length +/// 0 at that point. +/// +/// If rounding is not null, the corner will be rounded either with a curve +/// approximating a circular arc of the radius specified in [rounding], or with +/// three curves if [rounding] has a nonzero smoothing parameter. These three +/// curves are a circular arc in the middle and two symmetrical flanking curves +/// on either side. The smoothing parameter determines the curvature of the +/// flanking curves. +/// +/// This is a class because we usually need to do the work in 2 steps, and +/// prefer to keep state between: 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) +/// +/// [p0] is the vertex before the one being rounded. +/// +/// [p1] is the vertex of this rounded corner. +/// +/// [p2] the vertex after the one being rounded. +/// +/// [rounding] the optional parameters specifying how this corner should be +/// rounded. +class _RoundedCorner { + _RoundedCorner( + this.p0, + this.p1, + this.p2, + this.rounding, + ) { + final v01 = p0 - p1; + final v21 = p2 - p1; + final d01 = v01.getDistance(); + final d21 = v21.getDistance(); + + 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 the + /// middle of the three curves if smoothing is requested). + /// The center is the same as [p0] 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 allowedCut = math.min(allowedCut0, allowedCut1); + + // Nothing to do, just use lines, or a point + if (expectedRoundCut < distanceEpsilon || + allowedCut < distanceEpsilon || + cornerRadius < distanceEpsilon) { + center = p1; + return [Cubic.straightLine(p1.x, p1.y, p1.x, p1.y)]; + } + + // How much of the cut is required for the rounding part. + final 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 actualSmoothing0 = _calculateActualSmoothingValue(allowedCut0); + final actualSmoothing1 = _calculateActualSmoothingValue(allowedCut1); + // Scale the radius if needed + final actualR = cornerRadius * actualRoundCut / expectedRoundCut; + // Distance from the corner (p1) to the center + final centerDistance = math.sqrt( + square(actualR) + square(actualRoundCut), + ); + // Center of the arc we will use for rounding + center = p1 + ((d1 + d2) / 2).getDirection() * centerDistance; + final circleIntersection0 = p1 + d1 * actualRoundCut; + final circleIntersection2 = p1 + d2 * actualRoundCut; + final flanking0 = _computeFlankingCurve( + actualRoundCut, + actualSmoothing0, + p1, + p0, + circleIntersection0, + circleIntersection2, + center, + actualR, + ); + final flanking2 = _computeFlankingCurve( + actualRoundCut, + actualSmoothing1, + p1, + p2, + circleIntersection2, + circleIntersection0, + center, + actualR, + ).reverse(); + + return [ + flanking0, + Cubic.circularArc( + center.x, + center.y, + flanking0.anchor1X, + flanking0.anchor1Y, + flanking2.anchor0X, + flanking2.anchor0Y, + ), + 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; + } + } + + /// Compute a Bezier to connect the linear segment defined by [corner] and + /// [sideStart] with the circular segment defined by [circleCenter], + /// [circleSegmentIntersection], [otherCircleSegmentIntersection] and + /// [actualR]. The bezier will start at the linear segment and end on the + /// circular segment. + /// + /// [actualRoundCut] is how much we are cutting of the corner to add the + /// circular segment (this is before smoothing, that will cut some more). + /// + /// [actualSmoothingValues] is how much we want to smooth (this is the smooth + /// parameter, adjusted down if there is not enough room). + /// + /// [corner] is the point at which the linear side ends. + /// + /// [sideStart] is the point at which the linear side starts. + /// + /// [circleSegmentIntersection] is the point at which the linear side and the + /// circle intersect. + /// + /// [otherCircleSegmentIntersection] is the point at which the opposing + /// linear side and the circle intersect. + /// + /// [circleCenter] is the center of the circle. + /// + /// [actualR] is the radius of the circle. + /// + /// Returns a Bezier cubic curve that connects from the (cut) linear side + /// and the (cut) circular segment in a smooth way. + Cubic _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 sideDirection = (sideStart - corner).getDirection(); + final 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. + // TODO: revisit this, it can be problematic as it approaches 180 degrees + final p = interpolate( + circleSegmentIntersection, + (circleSegmentIntersection + otherCircleSegmentIntersection) / 2, + actualSmoothingValues, + ); + + // The flanking curve ends on the circle + final curveEnd = circleCenter + + directionVector(p.x - circleCenter.x, p.y - circleCenter.y) * 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 circleTangent = (curveEnd - circleCenter).rotate90(); + final 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 anchorStart = (curveStart + anchorEnd * 2) / 3; + + return Cubic.fromPoints(curveStart, anchorStart, anchorEnd, curveEnd); + } + + /// Returns the intersection point of the two lines d0->d1 and p0->p1, or + /// null if the lines do not intersect. + Point? _lineIntersection(Point p0, Point d0, Point p1, Point d1) { + final rotatedD1 = d1.rotate90(); + final den = d0.dotProduct(rotatedD1); + + if (den.abs() < distanceEpsilon) { + return null; + } + + final 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 k = num / den; + return p0 + d0 * k; + } +} + +List _verticesFromNumVerts( + int numVertices, + double radius, + double centerX, + double centerY, +) { + final result = List.filled(numVertices * 2, 0); + + var arrayIndex = 0; + for (var i = 0; i < numVertices; i++) { + final vertex = radialToCartesian( + radius, + math.pi / numVertices * 2 * i, + ) + + Point(centerX, centerY); + + result[arrayIndex++] = vertex.x; + result[arrayIndex++] = vertex.y; + } + + return result; +} + +List _pillStarVerticesFromNumVerts( + int numVerticesPerRadius, + double width, + double height, + double innerRadius, + double vertexSpacing, + double startLocation, + double centerX, + double centerY, +) { + // 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 endcapRadius = math.min(width, height); + final vSegLen = (height - width).coerceAtLeast(0); + final hSegLen = (width - height).coerceAtLeast(0); + final vSegHalf = vSegLen / 2; + final 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 circlePerimeter = + twoPi * 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 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 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; + var 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. + var t = startLocation * perimeter; + // The list of vertices to be returned. + final result = List.filled(numVerticesPerRadius * 4, 0); + var arrayIndex = 0; + 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 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 tInSection = boundedT - secStart; + final 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 currRadius = inner ? (endcapRadius * innerRadius) : endcapRadius; + final 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[arrayIndex++] = vertex.x + centerX; + result[arrayIndex++] = vertex.y + centerY; + t += tPerVertex; + inner = !inner; + } + + return result; +} + +List _starVerticesFromNumVerts( + int numVerticesPerRadius, + double radius, + double innerRadius, + double centerX, + double centerY, +) { + final result = List.filled(numVerticesPerRadius * 4, 0); + var arrayIndex = 0; + + for (var i = 0; i < numVerticesPerRadius; i++) { + var vertex = radialToCartesian( + radius, + math.pi / numVerticesPerRadius * 2 * i, + ); + result[arrayIndex++] = vertex.x + centerX; + result[arrayIndex++] = vertex.y + centerY; + vertex = radialToCartesian( + innerRadius, + math.pi / numVerticesPerRadius * (2 * i + 1), + ); + result[arrayIndex++] = vertex.x + centerX; + result[arrayIndex++] = vertex.y + centerY; + } + + return result; +} diff --git a/packages/material_ui/lib/src/shapes/shapes/shapes.dart b/packages/material_ui/lib/src/shapes/shapes/shapes.dart new file mode 100644 index 000000000000..742e206f0e33 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/shapes/shapes.dart @@ -0,0 +1,17 @@ +import 'dart:collection'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:vector_math/vector_math_64.dart'; + +part 'corner_rounding.dart'; +part 'cubic.dart'; +part 'feature_mapping.dart'; +part 'features.dart'; +part 'float_mapping.dart'; +part 'morph.dart'; +part 'point.dart'; +part 'polygon_measure.dart'; +part 'rounded_polygon.dart'; +part 'utils.dart'; diff --git a/packages/material_ui/lib/src/shapes/shapes/utils.dart b/packages/material_ui/lib/src/shapes/shapes/utils.dart new file mode 100644 index 000000000000..76dce682ee14 --- /dev/null +++ b/packages/material_ui/lib/src/shapes/shapes/utils.dart @@ -0,0 +1,358 @@ +part of 'shapes.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. +const distanceEpsilon = 1e-5; +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. +const relaxedDistanceEpsilon = 5e-3; + +const twoPi = math.pi * 2; + +double distance(double x, double y) => math.sqrt(x * x + y * y); + +double distanceSquared(double x, double y) => x * x + y * y; + +/// Returns unit vector representing the direction to this point from (0, 0). +Point directionVector(double x, double y) { + final d = distance(x, y); + assert(d > 0, 'Required distance greater than zero.'); + return Point(x / d, y / d); +} + +Point directionVectorFromAngle(double angleRadians) => + Point(math.cos(angleRadians), math.sin(angleRadians)); + +Point radialToCartesian( + double radius, + double angleRadians, [ + Point center = Point.zero, +]) => + directionVectorFromAngle(angleRadians) * radius + center; + +double square(double x) => x * x; + +/// Linearly interpolates between [start] and [stop] with [fraction] fraction +/// between them. +double lerp(double start, double stop, double fraction) { + return start * (1 - fraction) + stop * 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. +double positiveModulo(double num, double mod) => (num % mod + mod) % mod; + +/// Returns whether C is on the line defined by the two points AB. +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 ab = Point(bX - aX, bY - aY).rotate90(); + final ac = Point(cX - aX, cY - aY); + final dotProduct = ab.dotProduct(ac).abs(); + final relativeTolerance = tolerance * ab.getDistance() * ac.getDistance(); + + 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. +bool convex(Point previous, Point current, Point next) { + // TODO: b/369320447 - This is a fast, but not reliable calculation. + return (current - previous).clockwise(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. +/// +// NTS: Does it make sense to split the function f in 2, one to generate a +// candidate, of a custom type T (i.e. (Float) -> T), and one to evaluate it +// ( (T) -> Float )? +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 c1 = (2 * a + b) / 3; + final 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. +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; + var max = end; + final key = value; + while (min < max) { + final mid = min + ((max - min) >> 1); + final element = sortedList[mid]; + final comp = compare(keyOf(element), key); + if (comp == 0) return mid; + if (comp < 0) { + min = mid + 1; + } else { + max = mid; + } + } + return -min - 1; +} + +extension DoubleCoerceExtensions on double { + double coerceAtLeast(double minimumValue) => + this < minimumValue ? minimumValue : this; + + double coerceAtMost(double maximumValue) { + return this > maximumValue ? maximumValue : this; + } + + double coerceIn(double minimumValue, double maximumValue) { + if (this < minimumValue) return minimumValue; + if (this > maximumValue) return maximumValue; + return this; + } +} + +extension Matrix4PointTransformer on Matrix4 { + PointTransformer asPointTransformer() { + return (x, y) { + final vector = transform3(Vector3(x, y, 0)); + return (vector.x, vector.y); + }; + } +} + +extension RoundedPolygonToPathExtension on RoundedPolygon { + /// Returns a [Path] representation for a [RoundedPolygon] shape. Note that + /// there is some rounding happening (to the nearest thousandth), to work + /// around rendering artifacts introduced by some points being just slightly + /// off from each other (far less than a pixel). This also allows for a more + /// optimal path, as redundant curves (usually a single point) can be + /// detected and not added to the resulting path. + /// + /// [path] is a [Path] to reset and set with the new path data. + /// + /// [startAngle] is an angle (in degrees) to rotate the [Path] to start + /// drawing from. The rotation pivot is set to be the polygon's centerX and + /// centerY coordinates. If [startAngle] is non zero, then caller has to use + /// the returned [Path], as path transformation creates a new path. + /// + /// [repeatPath] is whether or not to repeat the [Path] twice before closing + /// it. This flag 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 a motion as a Star circular + /// progress indicator advances). + /// + /// [closePath] is whether or not to close the created [Path]. + Path toPath({ + int startAngle = 0, + bool repeatPath = false, + bool closePath = true, + Path? path, + }) { + return pathFromCubics( + path: path ?? Path(), + startAngle: startAngle, + repeatPath: repeatPath, + closePath: closePath, + cubics: cubics, + rotationPivotX: centerX, + rotationPivotY: centerY, + ); + } +} + +extension MorphToPathExtension on Morph { + /// Returns a [Path] for a [Morph]. + /// + /// [progress] is the [Morph]'s progress. + /// + /// [path] is a [Path] to reset and set with the new path data. + /// + /// [startAngle] is an angle (in degrees) to rotate the [Path] to start + /// drawing from. If [startAngle] is non zero, then caller has to use the + /// returned [Path], as path transformation creates a new path. + /// + /// [repeatPath] is whether or not to repeat the [Path] twice before closing + /// it. This flag 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 a motion as a Star circular + /// progress indicator advances). + /// + /// [closePath] is whether or not to close the created [Path]. + /// + /// [rotationPivotX] is the rotation pivot on the X axis. By default it's set + /// to 0, and that should align with Morph instances that were created for + /// [RoundedPolygon] with zero centerX. In case the [RoundedPolygon] were + /// normalized (i. e. moved to (0.5, 0.5)), or where created with a different + /// centerX coordinated, this pivot point may need to be aligned to support a + /// proper rotation. + /// + /// [rotationPivotY] is the rotation pivot on the Y axis. By default it's set + /// to 0, and that should align with Morph instances that were created for + /// [RoundedPolygon] with zero centerY. In case the RoundedPolygon were + /// normalized (i. e. moves to (0.5, 0.5)), or where created with a different + /// centerY coordinated, this pivot point may need to be aligned to support a + /// proper rotation. + Path toPath({ + required double progress, + int startAngle = 0, + bool repeatPath = false, + bool closePath = true, + double rotationPivotX = 0, + double rotationPivotY = 0, + Path? path, + }) { + return pathFromCubics( + path: path ?? Path(), + startAngle: startAngle, + repeatPath: repeatPath, + closePath: closePath, + cubics: asCubics(progress), + rotationPivotX: rotationPivotX, + rotationPivotY: rotationPivotY, + ); + } +} + +/// Returns a [Path] for a [Cubic] list. +/// +/// [path] is a [Path] to reset and set with the new path data. +/// +/// [startAngle] is an angle (in degrees) to rotate the [Path] to start +/// drawing from. If [startAngle] is non zero, then caller has to use the +/// returned [Path], as path transformation creates a new path. +/// +/// [repeatPath] is whether or not to repeat the [Path] twice before closing +/// it. This flag 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 a motion as a Star circular +/// progress indicator advances). +/// +/// [closePath] is whether or not to close the created [Path]. +/// +/// [cubics] is list of [Cubic]s to build path from. +/// +/// [rotationPivotX] is the rotation pivot on the X axis. +/// +/// [rotationPivotY] is the rotation pivot on the Y axis. +Path pathFromCubics({ + required Path path, + required int startAngle, + required bool repeatPath, + required bool closePath, + required List cubics, + required double rotationPivotX, + required double rotationPivotY, +}) { + var first = true; + Cubic? firstCubic; + + path.reset(); + + 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 angleToFirstCubic = math.atan2( + cubics[0].anchor0Y - rotationPivotY, + cubics[0].anchor0X - rotationPivotX, + ); + // Rotate the Path to to start from the given angle. + path = path.transform( + (Matrix4.identity() + ..rotateZ( + -angleToFirstCubic + (startAngle * math.pi / 180), + )) + .storage, + ); + } + + return path; +} 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..9e780a067c09 --- /dev/null +++ b/packages/material_ui/test/shapes/corner_rounding_test.dart @@ -0,0 +1,29 @@ +// ignore_for_file: document_ignores + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/shapes/shapes.dart'; + +void main() { + test('$CornerRounding()', () { + // ignore: use_named_constants + const defaultCorner = CornerRounding(); + expect(defaultCorner.radius, 0); + expect(defaultCorner.smoothing, 0); + + const 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); + }); +} 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..9be0544fff15 --- /dev/null +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -0,0 +1,249 @@ +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/shapes/shapes.dart'; + +import 'test_utils.dart'; + +void main() { + group('$Cubic', () { + // These points create a roughly circular arc in the upper-right quadrant + // around (0,0). + const 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 = Cubic.fromPoints(p0, p1, p2, p3); + + test('fromPoints', () { + expect(p0, Point(cubic.anchor0X, cubic.anchor0Y)); + expect(p1, Point(cubic.control0X, cubic.control0Y)); + expect(p2, Point(cubic.control1X, cubic.control1Y)); + expect(p3, Point(cubic.anchor1X, cubic.anchor1Y)); + }); + + test('circularArc', () { + final arcCubic = Cubic.circularArc( + zero.x, + zero.y, + p0.x, + p0.y, + p3.x, + p3.y, + ); + expect(p0, Point(arcCubic.anchor0X, arcCubic.anchor0Y)); + expect(p3, Point(arcCubic.anchor1X, arcCubic.anchor1Y)); + }); + + test('div', () { + var divCubic = cubic / 1; + expectCubicsEqualish(cubic, divCubic); + divCubic = cubic / 1; + expectCubicsEqualish(cubic, divCubic); + divCubic = cubic / 2; + expectPointsEqualish( + p0 / 2, + Point(divCubic.anchor0X, divCubic.anchor0Y), + ); + expectPointsEqualish( + p1 / 2, + Point(divCubic.control0X, divCubic.control0Y), + ); + expectPointsEqualish( + p2 / 2, + Point(divCubic.control1X, divCubic.control1Y), + ); + expectPointsEqualish( + p3 / 2, + Point(divCubic.anchor1X, divCubic.anchor1Y), + ); + divCubic = cubic / 2; + expectPointsEqualish( + p0 / 2, + Point(divCubic.anchor0X, divCubic.anchor0Y), + ); + expectPointsEqualish( + p1 / 2, + Point(divCubic.control0X, divCubic.control0Y), + ); + expectPointsEqualish( + p2 / 2, + Point(divCubic.control1X, divCubic.control1Y), + ); + expectPointsEqualish( + p3 / 2, + Point(divCubic.anchor1X, divCubic.anchor1Y), + ); + }); + + test('times', () { + var timesCubic = cubic * 1; + expect(p0, Point(timesCubic.anchor0X, timesCubic.anchor0Y)); + expect(p1, Point(timesCubic.control0X, timesCubic.control0Y)); + expect(p2, Point(timesCubic.control1X, timesCubic.control1Y)); + expect(p3, Point(timesCubic.anchor1X, timesCubic.anchor1Y)); + timesCubic = cubic * 1; + expect(p0, Point(timesCubic.anchor0X, timesCubic.anchor0Y)); + expect(p1, Point(timesCubic.control0X, timesCubic.control0Y)); + expect(p2, Point(timesCubic.control1X, timesCubic.control1Y)); + expect(p3, Point(timesCubic.anchor1X, timesCubic.anchor1Y)); + timesCubic = cubic * 2; + expectPointsEqualish( + p0 * 2, + Point(timesCubic.anchor0X, timesCubic.anchor0Y), + ); + expectPointsEqualish( + p1 * 2, + Point(timesCubic.control0X, timesCubic.control0Y), + ); + expectPointsEqualish( + p2 * 2, + Point(timesCubic.control1X, timesCubic.control1Y), + ); + expectPointsEqualish( + p3 * 2, + Point(timesCubic.anchor1X, timesCubic.anchor1Y), + ); + timesCubic = cubic * 2; + expectPointsEqualish( + p0 * 2, + Point(timesCubic.anchor0X, timesCubic.anchor0Y), + ); + expectPointsEqualish( + p1 * 2, + Point(timesCubic.control0X, timesCubic.control0Y), + ); + expectPointsEqualish( + p2 * 2, + Point(timesCubic.control1X, timesCubic.control1Y), + ); + expectPointsEqualish( + p3 * 2, + Point(timesCubic.anchor1X, timesCubic.anchor1Y), + ); + }); + + test('plus', () { + final offsetCubic = cubic * 2; + final plusCubic = cubic + offsetCubic; + expectPointsEqualish( + p0 + Point(offsetCubic.anchor0X, offsetCubic.anchor0Y), + Point(plusCubic.anchor0X, plusCubic.anchor0Y), + ); + expectPointsEqualish( + p1 + Point(offsetCubic.control0X, offsetCubic.control0Y), + Point(plusCubic.control0X, plusCubic.control0Y), + ); + expectPointsEqualish( + p2 + Point(offsetCubic.control1X, offsetCubic.control1Y), + Point(plusCubic.control1X, plusCubic.control1Y), + ); + expectPointsEqualish( + p3 + Point(offsetCubic.anchor1X, offsetCubic.anchor1Y), + Point(plusCubic.anchor1X, plusCubic.anchor1Y), + ); + }); + + test('reverse', () { + final reverseCubic = cubic.reverse(); + expect(p3, Point(reverseCubic.anchor0X, reverseCubic.anchor0Y)); + expect(p2, Point(reverseCubic.control0X, reverseCubic.control0Y)); + expect(p1, Point(reverseCubic.control1X, reverseCubic.control1Y)); + expect(p0, Point(reverseCubic.anchor1X, reverseCubic.anchor1Y)); + }); + + void expectBetween(Point end0, Point end1, Point actual) { + final minX = math.min(end0.x, end1.x); + final minY = math.min(end0.y, end1.y); + final maxX = math.max(end0.x, end1.x); + final 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 = Cubic.straightLine(p0.x, p0.y, p3.x, p3.y); + expect(p0, Point(lineCubic.anchor0X, lineCubic.anchor0Y)); + expect(p3, Point(lineCubic.anchor1X, lineCubic.anchor1Y)); + expectBetween(p0, p3, Point(lineCubic.control0X, lineCubic.control0Y)); + expectBetween(p0, p3, Point(lineCubic.control1X, lineCubic.control1Y)); + }); + + test('split', () { + final (split0, split1) = cubic.split(0.5); + expect( + Point(cubic.anchor0X, cubic.anchor0Y), + Point(split0.anchor0X, split0.anchor0Y), + ); + expect( + Point(cubic.anchor1X, cubic.anchor1Y), + Point(split1.anchor1X, split1.anchor1Y), + ); + expectBetween( + Point(cubic.anchor0X, cubic.anchor0Y), + Point(cubic.anchor1X, cubic.anchor1Y), + Point(split0.anchor1X, split0.anchor1Y), + ); + expectBetween( + Point(cubic.anchor0X, cubic.anchor0Y), + Point(cubic.anchor1X, cubic.anchor1Y), + Point(split1.anchor0X, split1.anchor0Y), + ); + }); + + test('pointOnCurve', () { + var halfway = cubic.pointOnCurve(0.5); + expectBetween( + Point(cubic.anchor0X, cubic.anchor0Y), + Point(cubic.anchor1X, cubic.anchor1Y), + halfway, + ); + final straightLineCubic = Cubic.straightLine(p0.x, p0.y, p3.x, p3.y); + halfway = straightLineCubic.pointOnCurve(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', () { + var transform = identityTransform(); + var 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( + Point(cubic.anchor0X, cubic.anchor0Y) + translationVector, + Point(transformedCubic.anchor0X, transformedCubic.anchor0Y), + ); + expectPointsEqualish( + Point(cubic.control0X, cubic.control0Y) + translationVector, + Point(transformedCubic.control0X, transformedCubic.control0Y), + ); + expectPointsEqualish( + Point(cubic.control1X, cubic.control1Y) + translationVector, + Point(transformedCubic.control1X, transformedCubic.control1Y), + ); + expectPointsEqualish( + Point(cubic.anchor1X, cubic.anchor1Y) + translationVector, + Point(transformedCubic.anchor1X, transformedCubic.anchor1Y), + ); + }); + + test('empty Cubic has zero length', () { + expect(Cubic.empty(10, 10).zeroLength(), isTrue); + }); + }); +} 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..bd695f6065f9 --- /dev/null +++ b/packages/material_ui/test/shapes/feature_mapping_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/shapes/shapes.dart'; + +import 'test_utils.dart'; + +void main() { + group('FeatureMapping', () { + final triangleWithRoundings = RoundedPolygon.fromVerticesNum( + 3, + rounding: const CornerRounding(radius: 0.2), + ); + final triangle = RoundedPolygon.fromVerticesNum(3); + final square = RoundedPolygon.fromVerticesNum(4); + final squareRotated = RoundedPolygon.fromVerticesNum(4).transformed( + pointRotator(45), + ); + + void verifyMapping( + RoundedPolygon p1, + RoundedPolygon p2, + void Function(List) validator, + ) { + final f1 = MeasuredPolygon.measurePolygon( + const LengthMeasurer(), + p1, + ).features; + final f2 = MeasuredPolygon.measurePolygon( + const LengthMeasurer(), + p2, + ).features; + + // Maps progress in p1 to progress in p2. + final 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 feature1 = f1.firstWhere((f) => f.progress == progress1); + final 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 checkmark = RoundedPolygon.fromVertices( + const [ + 400, + -304, + 240, + -464, + 296, + -520, + 400, + -416, + 664, + -680, + 720, + -624, + 400, + -304, + ], + ).normalized(); + + final 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..a50bd745ad4f --- /dev/null +++ b/packages/material_ui/test/shapes/features_test.dart @@ -0,0 +1,62 @@ +// ignore_for_file: avoid_redundant_argument_values, document_ignores + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/shapes/shapes.dart'; + +import 'test_utils.dart'; + +void main() { + group('$Feature', () { + test('Cannot build empty features', () { + expect(() => Feature.buildConvexCorner([]), throwsArgumentError); + expect(() => Feature.buildConcaveCorner([]), throwsArgumentError); + expect(() => Feature.buildIgnorableFeature([]), throwsArgumentError); + }); + + test('Cannot build non continuous features', () { + final cubic1 = Cubic.straightLine(0, 0, 1, 1); + final cubic2 = Cubic.straightLine(10, 10, 11, 11); + + expect( + () => Feature.buildConvexCorner([cubic1, cubic2]), + throwsArgumentError, + ); + expect( + () => Feature.buildConcaveCorner([cubic1, cubic2]), + throwsArgumentError, + ); + expect( + () => Feature.buildIgnorableFeature([cubic1, cubic2]), + throwsArgumentError, + ); + }); + + test('Builds concave corner', () { + final cubic = Cubic.straightLine(0, 0, 1, 0); + final actual = Feature.buildConcaveCorner([cubic]); + final expected = CornerFeature([cubic], convex: false); + expectFeaturesEqualish(expected, actual); + }); + + test('Builds convex corner', () { + final cubic = Cubic.straightLine(0, 0, 1, 0); + final actual = Feature.buildConvexCorner([cubic]); + final expected = CornerFeature([cubic], convex: true); + expectFeaturesEqualish(expected, actual); + }); + + test('Builds edge', () { + final cubic = Cubic.straightLine(0, 0, 1, 0); + final actual = Feature.buildEdge(cubic); + final expected = EdgeFeature([cubic]); + expectFeaturesEqualish(expected, actual); + }); + + test('Builds ignorable as edge', () { + final cubic = Cubic.straightLine(0, 0, 1, 0); + final actual = Feature.buildIgnorableFeature([cubic]); + final expected = EdgeFeature([cubic]); + expectFeaturesEqualish(expected, actual); + }); + }); +} diff --git a/packages/material_ui/test/shapes/float_mapping_test.dart b/packages/material_ui/test/shapes/float_mapping_test.dart new file mode 100644 index 000000000000..46a71127d3ab --- /dev/null +++ b/packages/material_ui/test/shapes/float_mapping_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/shapes/shapes.dart'; + +import 'test_utils.dart'; + +void main() { + group('FloatMapping', () { + void validateMapping( + DoubleMapper mapper, + double Function(double) expectedFunction, + ) { + for (var i = 0; i < 10000; i++) { + final source = i / 10000; + final 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/morph_test.dart b/packages/material_ui/test/shapes/morph_test.dart new file mode 100644 index 000000000000..31f519c8a8a8 --- /dev/null +++ b/packages/material_ui/test/shapes/morph_test.dart @@ -0,0 +1,103 @@ +// ignore_for_file: cascade_invocations, document_ignores + +import 'dart:ui' as ui; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/shapes/shapes.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.fromVerticesNum(3, centerX: 0.5, centerY: 0.5); + final poly2 = RoundedPolygon.fromVerticesNum(4, centerX: 0.5, centerY: 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 p1Cubics = poly1.cubics; + final cubics11 = morph11.asCubics(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 picture = recorder.endRecording(); + return picture.toImage(side.toInt(), side.toInt()); + } + + Future comparePathsVisually(ui.Path a, ui.Path b, double side) async { + final imageA = await drawPathToImage(a, side); + final imageB = await drawPathToImage(b, side); + + final bytesA = await imageA.toByteData(); + final 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 poly1Path = poly1.toPath().transform(matrix.storage); + final poly2Path = poly2.toPath().transform(matrix.storage); + final morph120Path = + morph12.toPath(progress: 0).transform(matrix.storage); + final morph121Path = + morph12.toPath(progress: 1).transform(matrix.storage); + + await comparePathsVisually(poly1Path, morph120Path, radius * 2); + await comparePathsVisually(poly2Path, morph121Path, radius * 2); + }); + }); +} 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..681d38925a9f --- /dev/null +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -0,0 +1,233 @@ +// ignore_for_file: avoid_redundant_argument_values, document_ignores + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/shapes/shapes.dart'; + +import 'test_utils.dart'; + +void main() { + group('PolygonMeasure', () { + const measurer = LengthMeasurer(); + + void irregularPolygonMeasure( + RoundedPolygon polygon, [ + void Function(MeasuredPolygon)? extraChecks, + ]) { + final measuredPolygon = MeasuredPolygon.measurePolygon( + measurer, + polygon, + ); + + expect(0, measuredPolygon.first.startOutlineProgress); + expect(1, measuredPolygon.last.endOutlineProgress); + + for (var index = 0; index < measuredPolygon.length; index++) { + final 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 = 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.fromVerticesNum(sides, rounding: rounding), + (measuredPolygon) { + expect(sides, measuredPolygon.length); + + for (var index = 0; index < measuredPolygon.length; index++) { + final 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 = 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.fromVerticesNum( + 6, + rounding: const CornerRounding(radius: 0.15), + ), + ); + }); + + test('measure medium rounded hexagon', () { + irregularPolygonMeasure( + RoundedPolygon.fromVerticesNum( + 6, + rounding: const CornerRounding(radius: 0.5), + ), + ); + }); + + test('measure maximum rounded hexagon', () { + irregularPolygonMeasure( + RoundedPolygon.fromVerticesNum( + 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 actualLength = polygon.cubics.fold( + 0, + (sum, cubic) => sum + const LengthMeasurer().measureCubic(cubic), + ); + const expectedLength = 2 * math.pi; + + expect( + expectedLength, + moreOrLessEquals(actualLength, epsilon: 0.015 * expectedLength), + ); + }); + + test('measure irregular triangle angle', () { + irregularPolygonMeasure( + RoundedPolygon.fromVertices( + const [0, -1, 1, 1, 0, 0.5, -1, 1], + perVertexRounding: const [ + CornerRounding(radius: 0.2, smoothing: 0.5), + CornerRounding(radius: 0.2, smoothing: 0.5), + CornerRounding(radius: 0.4, smoothing: 0), + CornerRounding(radius: 0.2, smoothing: 0.5), + ], + ), + ); + }); + + test('measure quarter angle', () { + irregularPolygonMeasure( + RoundedPolygon.fromVertices( + const [-1, -1, 1, -1, 1, 1, -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; + final coordinates = [ + // lower glass + 0, + 0, + unit, + unit, + -unit, + unit, + // upper glass + 0, + 0, + -unit, + -unit, + unit, + -unit, + ]; + + final diagonal = math.sqrt(unit * unit + unit * unit); + const horizontal = 2 * unit; + final 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.buildConvexCorner([Cubic.straightLine(0, 0, 1, 1)]), + Feature.buildConvexCorner([Cubic.straightLine(1, 1, 1, 0)]), + Feature.buildConvexCorner([Cubic.straightLine(1, 0, 0, 0)]), + // Empty feature at the end. + Feature.buildConvexCorner([Cubic.straightLine(0, 0, 0, 0)]), + ]); + + irregularPolygonMeasure(triangle); + }); + }); +} 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..2684fbfc25f9 --- /dev/null +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -0,0 +1,245 @@ +// ignore_for_file: avoid_redundant_argument_values, document_ignores + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/shapes/shapes.dart'; + +import 'test_utils.dart'; + +void main() { + group('Polygon', () { + final square = RoundedPolygon.fromVerticesNum(4); + final roundedSquare = RoundedPolygon.fromVerticesNum( + 4, + rounding: const CornerRounding(radius: 0.2), + ); + final pentagon = RoundedPolygon.fromVerticesNum(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.fromVerticesNum(4, radius: 2); + min = min * 2; + max = max * 2; + expectInBounds(doubleSquare.cubics, min, max); + + final offsetSquare = + RoundedPolygon.fromVerticesNum(4, centerX: 1, centerY: 2); + min = const Point(0, 1); + max = const Point(2, 3); + expectInBounds(offsetSquare.cubics, min, max); + + final squareCopy = RoundedPolygon.from(square); + min = const Point(-1, -1); + max = const Point(1, 1); + expectInBounds(squareCopy.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([ + p0.x, + p0.y, + p1.x, + p1.y, + p2.x, + p2.y, + p3.x, + p3.y, + ]); + min = const Point(-1, -1); + max = const Point(1, 1); + expectInBounds(manualSquare.cubics, min, max); + + const offset = Point(1, 2); + final p0Offset = p0 + offset; + final p1Offset = p1 + offset; + final p2Offset = p2 + offset; + final p3Offset = p3 + offset; + final manualSquareOffset = RoundedPolygon.fromVertices( + [ + p0Offset.x, + p0Offset.y, + p1Offset.x, + p1Offset.y, + p2Offset.x, + p2Offset.y, + p3Offset.x, + p3Offset.y, + ], + centerX: offset.x, + centerY: offset.y, + ); + min = const Point(0, 1); + max = const Point(2, 3); + expectInBounds(manualSquareOffset.cubics, min, max); + }); + + test('bounds', () { + var bounds = square.calculateBounds(); + expectEqualish(-1, bounds[0]); // Left + expectEqualish(-1, bounds[1]); // Top + expectEqualish(1, bounds[2]); // Right + expectEqualish(1, bounds[3]); // Bottom + + var betterBounds = square.calculateBounds(approximate: false); + expectEqualish(-1, betterBounds[0]); // Left + expectEqualish(-1, betterBounds[1]); // Top + expectEqualish(1, betterBounds[2]); // Right + expectEqualish(1, betterBounds[3]); // Bottom + + // roundedSquare's approximate bounds will be larger due to control + // points. + bounds = roundedSquare.calculateBounds(); + betterBounds = roundedSquare.calculateBounds(approximate: false); + expect( + betterBounds[2] - betterBounds[0] < bounds[2] - bounds[0], + isTrue, + reason: + 'bounds ${bounds[0]}, ${bounds[1]}, ${bounds[2]}, ${bounds[3]}, ' + 'betterBounds = ${betterBounds[0]}, ${betterBounds[1]}, ' + '${betterBounds[2]}, ${betterBounds[3]}', + ); + + bounds = pentagon.calculateBounds(); + final maxBounds = pentagon.calculateMaxBounds(); + expect(maxBounds[2] - maxBounds[0] > bounds[2] - bounds[0], isTrue); + }); + + test('center', () { + expectPointsEqualish(Point.zero, Point(square.centerX, square.centerY)); + }); + + test('transform', () { + // First, make sure the shape doesn't change when transformed by the + // identity. + final squareCopy = square.transformed(identityTransform()); + final 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 squareCubics = square.cubics; + final translator = translateTransform(offset.x, offset.y); + final translatedSquareCubics = square.transformed(translator).cubics; + + for (var i = 0; i < squareCubics.length; i++) { + expectPointsEqualish( + Point(squareCubics[i].anchor0X, squareCubics[i].anchor0Y) + offset, + Point( + translatedSquareCubics[i].anchor0X, + translatedSquareCubics[i].anchor0Y, + ), + ); + expectPointsEqualish( + Point(squareCubics[i].control0X, squareCubics[i].control0Y) + offset, + Point( + translatedSquareCubics[i].control0X, + translatedSquareCubics[i].control0Y, + ), + ); + expectPointsEqualish( + Point(squareCubics[i].control1X, squareCubics[i].control1Y) + offset, + Point( + translatedSquareCubics[i].control1X, + translatedSquareCubics[i].control1Y, + ), + ); + expectPointsEqualish( + Point(squareCubics[i].anchor1X, squareCubics[i].anchor1Y) + offset, + Point( + translatedSquareCubics[i].anchor1X, + translatedSquareCubics[i].anchor1Y, + ), + ); + } + }); + + test('features', () { + List nonZeroCubics(List original) { + return original.where((c) => !c.zeroLength()).toList(); + } + + final 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. + var nonzeroCubics = nonZeroCubics( + squareFeatures.expand((f) => f.cubics).toList(), + ); + expectCubicListsEqualish(square.cubics, nonzeroCubics); + + // Same as the first polygon test, but with a copy of that polygon. + final squareCopy = RoundedPolygon.from(square); + final squareCopyFeatures = squareCopy.features; + nonzeroCubics = nonZeroCubics( + squareCopyFeatures.expand((f) => f.cubics).toList(), + ); + expectCubicListsEqualish(squareCopy.cubics, nonzeroCubics); + }); + + test('transform keeps contiguous anchors equal', () { + final poly = RoundedPolygon.fromVerticesNum( + 4, + radius: 1, + rounding: const CornerRounding(radius: 7 / 15), + ).transformed( + (x, y) { + final 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.fromVerticesNum( + 6, + radius: 0, + rounding: const CornerRounding(radius: 0.1), + ); + expect(poly.cubics.length, 1); + + final stillEmpty = poly.transformed(scaleTransform(10, 20)); + expect(stillEmpty.cubics.length, 1); + expect(stillEmpty.cubics.first.zeroLength(), isTrue); + }); + + test('empty side', () { + // Triangle with one point repeated. + final poly1 = RoundedPolygon.fromVertices( + const [0, 0, 1, 0, 1, 0, 0, 1], + ); + // Triangle. + final poly2 = RoundedPolygon.fromVertices( + const [0, 0, 1, 0, 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..6e402adfb2e3 --- /dev/null +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -0,0 +1,376 @@ +// ignore_for_file: document_ignores, avoid_redundant_argument_values + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/shapes/shapes.dart'; + +import 'test_utils.dart'; + +void main() { + group('$RoundedPolygon', () { + const rounding = CornerRounding(radius: 0.1); + final perVtxRounded = [rounding, rounding, rounding, rounding]; + + test('fromVerticesNum', () { + expect( + () => RoundedPolygon.fromVerticesNum(2), + throwsArgumentError, + ); + + final square = RoundedPolygon.fromVerticesNum(4); + var min = const Point(-1, -1); + var max = const Point(1, 1); + expectInBounds(square.cubics, min, max); + + final doubleSquare = RoundedPolygon.fromVerticesNum(4, radius: 2); + min *= 2; + max *= 2; + expectInBounds(doubleSquare.cubics, min, max); + + final squareRounded = RoundedPolygon.fromVerticesNum( + 4, + rounding: rounding, + ); + min = const Point(-1, -1); + max = const Point(1, 1); + expectInBounds(squareRounded.cubics, min, max); + + final squarePVRounded = RoundedPolygon.fromVerticesNum( + 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); + final verts = [p0.x, p0.y, p1.x, p1.y, p2.x, p2.y, p3.x, p3.y]; + + expect( + () => RoundedPolygon.fromVertices([p0.x, p0.y, p1.x, p1.y]), + 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 offsetVerts = [ + p0.x + offset.x, + p0.y + offset.y, + p1.x + offset.x, + p1.y + offset.y, + p2.x + offset.x, + p2.y + offset.y, + p3.x + offset.x, + p3.y + offset.y, + ]; + final manualSquareOffset = RoundedPolygon.fromVertices( + offsetVerts, + centerX: offset.x, + centerY: offset.y, + ); + 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([Cubic.empty(0, 0)]), + ]), + throwsArgumentError, + ); + }); + + test('throws for non continuous features', () { + final cubic1 = Cubic.straightLine(0, 0, 1, 0); + final cubic2 = Cubic.straightLine(10, 10, 20, 20); + expect( + () => RoundedPolygon.fromFeatures([ + Feature.buildEdge(cubic1), + Feature.buildEdge(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 [0, 0, 1, 0, 0, 1, 1, 1], + ); + expect(0.5, polygon.centerX); + expect(0.5, polygon.centerY); + }); + + List pointsToFloats(List points) { + final result = List.filled(points.length * 2, 0); + var index = 0; + for (final point in points) { + result[index++] = point.x; + result[index++] = point.y; + } + return result; + } + + test('rounding space usage', () { + const p0 = Point.zero; + const p1 = Point(1, 0); + const p2 = Point(0.5, 1); + final pvRounding = [ + const CornerRounding(radius: 1, smoothing: 0), + const CornerRounding(radius: 1, smoothing: 1), + CornerRounding.unrounded, + ]; + final polygon = RoundedPolygon.fromVertices( + pointsToFloats([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 lowerEdgeFeature = + polygon.features.firstWhere((f) => f is EdgeFeature); + expect(1, lowerEdgeFeature.cubics.length); + + final 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 p0 = Point.zero; + const p1 = Point(5, 0); + const p2 = Point(5, 1); + const p3 = Point(0, 1); + + final pvRounding = [ + rounding0, + CornerRounding.unrounded, + CornerRounding.unrounded, + rounding3, + ]; + final polygon = RoundedPolygon.fromVertices( + pointsToFloats([p0, p1, p2, p3]), + perVertexRounding: pvRounding, + ); + + final [e01, _, _, 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 smooth = i / points; + doUnevenSmoothTest( + rounding0: CornerRounding(radius: 0.4, smoothing: smooth), + expectedV0SX: 0.4 * (1 + smooth), + expectedV0SY: (0.4 * (1 + smooth)).coerceAtMost(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 smooth = i / points; + + final smoothWantedV0 = 0.4 * smooth; + const smoothWantedV3 = 0.2; + + // There is 0.4 room for smoothing. + final factor = + (0.4 / (smoothWantedV0 + smoothWantedV3)).coerceAtMost(1); + 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 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 innerRadius = radius * innerRadiusFactor; + const roundingFactor = 0.32; + + final fullSizeShape = RoundedPolygon.star( + numVerticesPerRadius: 4, + radius: radius, + innerRadius: innerRadius, + rounding: const CornerRounding(radius: radius * roundingFactor), + innerRounding: const CornerRounding(radius: radius * roundingFactor), + centerX: radius, + centerY: radius, + ).transformed((x, y) => ((x - radius) / radius, (y - radius) / radius)); + + final canonicalShape = RoundedPolygon.star( + numVerticesPerRadius: 4, + radius: 1, + innerRadius: innerRadiusFactor, + rounding: const CornerRounding(radius: roundingFactor), + innerRounding: const CornerRounding(radius: roundingFactor), + ); + + final cubics = canonicalShape.cubics; + final cubics1 = fullSizeShape.cubics; + expect(cubics.length, cubics1.length); + + for (var i = 0; i < cubics.length; i++) { + final cubic = cubics[i]; + final 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..42ed66637b94 --- /dev/null +++ b/packages/material_ui/test/shapes/shapes_test.dart @@ -0,0 +1,201 @@ +// ignore_for_file: document_ignores, avoid_redundant_argument_values, lines_longer_than_80_chars + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/shapes/shapes.dart'; + +import 'test_utils.dart'; + +void main() { + group('Shapes', () { + const zero = Point.zero; + const epsilon = 0.01; + + double distance(Point start, Point end) { + final 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 dist = distance(center, point); + try { + expect(radius1, moreOrLessEquals(dist, epsilon: epsilon)); + } on TestFailure catch (_) { + expect(radius2, moreOrLessEquals(dist, epsilon: epsilon)); + } + } + + void expectCubicOnRadii( + Cubic cubic, + double radius1, [ + double? radius2, + Point center = zero, + ]) { + expectPointOnRadii( + Point(cubic.anchor0X, cubic.anchor0Y), + radius1, + radius2, + center, + ); + expectPointOnRadii( + Point(cubic.anchor1X, cubic.anchor1Y), + 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(Cubic cubic, double radius, Point center) { + var t = 0.0; + while (t <= 1) { + final pointOnCurve = cubic.pointOnCurve(t); + final 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( + centerX: center.x, + centerY: center.y, + ); + 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, + innerRadius: 0.5, + ); + var 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, + centerX: center.x, + centerY: center.y, + ); + 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, + innerRadius: 0.5, + rounding: rounding, + ); + expectInBounds(star.cubics, min, max); + + star = RoundedPolygon.star( + numVerticesPerRadius: 4, + innerRadius: 0.5, + innerRounding: innerRounding, + ); + expectInBounds(star.cubics, min, max); + + star = RoundedPolygon.star( + numVerticesPerRadius: 4, + innerRadius: 0.5, + rounding: rounding, + innerRounding: innerRounding, + ); + expectInBounds(star.cubics, min, max); + + star = RoundedPolygon.star( + numVerticesPerRadius: 4, + innerRadius: 0.5, + perVertexRounding: perVtxRounded, + ); + expectInBounds(star.cubics, min, max); + + expect( + () => RoundedPolygon.star( + numVerticesPerRadius: 6, + innerRadius: 0.5, + 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..4fa48198c040 --- /dev/null +++ b/packages/material_ui/test/shapes/test_utils.dart @@ -0,0 +1,138 @@ +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/src/shapes/shapes/shapes.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(Cubic c0, Cubic c1) { + return pointsEqualish( + Point(c0.anchor0X, c0.anchor0Y), + Point(c1.anchor0X, c1.anchor0Y), + ) && + pointsEqualish( + Point(c0.anchor1X, c0.anchor1Y), + Point(c1.anchor1X, c1.anchor1Y), + ) && + pointsEqualish( + Point(c0.control0X, c0.control0Y), + Point(c1.control0X, c1.control0Y), + ) && + pointsEqualish( + Point(c0.control1X, c0.control1Y), + Point(c1.control1X, c1.control1Y), + ); +} + +// 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(Cubic expected, Cubic actual) { + expectPointsEqualish( + Point(expected.anchor0X, expected.anchor0Y), + Point(actual.anchor0X, actual.anchor0Y), + ); + expectPointsEqualish( + Point(expected.control0X, expected.control0Y), + Point(actual.control0X, actual.control0Y), + ); + expectPointsEqualish( + Point(expected.control1X, expected.control1Y), + Point(actual.control1X, actual.control1Y), + ); + expectPointsEqualish( + Point(expected.anchor1X, expected.anchor1Y), + Point(actual.anchor1X, actual.anchor1Y), + ); +} + +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, Point(cubic.anchor0X, cubic.anchor0Y)); + expectPointLessish(maxPoint, Point(cubic.anchor0X, cubic.anchor0Y)); + expectPointGreaterish(minPoint, Point(cubic.control0X, cubic.control0Y)); + expectPointLessish(maxPoint, Point(cubic.control0X, cubic.control0Y)); + expectPointGreaterish(minPoint, Point(cubic.control1X, cubic.control1Y)); + expectPointLessish(maxPoint, Point(cubic.control1X, cubic.control1Y)); + expectPointGreaterish(minPoint, Point(cubic.anchor1X, cubic.anchor1Y)); + expectPointLessish(maxPoint, Point(cubic.anchor1X, cubic.anchor1Y)); + } +} + +PointTransformer identityTransform() => (x, y) => (x, y); + +PointTransformer pointRotator(double angleDegrees) { + final 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); From 6c460a7618ab8e5426a02afb1d9ed9d44455e7e0 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 22 Aug 2026 13:21:17 +0200 Subject: [PATCH 02/59] Apply dart format and fix. --- .../lib/src/shapes/material_shape_border.dart | 70 ++-- .../lib/src/shapes/material_shapes.dart | 101 ++--- .../src/shapes/shapes/corner_rounding.dart | 13 +- .../lib/src/shapes/shapes/cubic.dart | 199 ++++----- .../src/shapes/shapes/feature_mapping.dart | 36 +- .../lib/src/shapes/shapes/features.dart | 46 +-- .../lib/src/shapes/shapes/float_mapping.dart | 40 +- .../lib/src/shapes/shapes/morph.dart | 78 ++-- .../lib/src/shapes/shapes/point.dart | 27 +- .../src/shapes/shapes/polygon_measure.dart | 156 +++---- .../src/shapes/shapes/rounded_polygon.dart | 388 +++++++----------- .../lib/src/shapes/shapes/utils.dart | 68 ++- .../test/shapes/corner_rounding_test.dart | 2 +- .../material_ui/test/shapes/cubic_test.dart | 132 ++---- .../test/shapes/feature_mapping_test.dart | 148 +++---- .../test/shapes/features_test.dart | 15 +- .../test/shapes/float_mapping_test.dart | 34 +- .../material_ui/test/shapes/morph_test.dart | 25 +- .../test/shapes/polygon_measure_test.dart | 103 ++--- .../material_ui/test/shapes/polygon_test.dart | 89 ++-- .../test/shapes/rounded_polygon_test.dart | 84 ++-- .../material_ui/test/shapes/shapes_test.dart | 68 +-- .../material_ui/test/shapes/test_utils.dart | 43 +- 23 files changed, 748 insertions(+), 1217 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/material_shape_border.dart b/packages/material_ui/lib/src/shapes/material_shape_border.dart index b11b59c2d96e..316791ce22de 100644 --- a/packages/material_ui/lib/src/shapes/material_shape_border.dart +++ b/packages/material_ui/lib/src/shapes/material_shape_border.dart @@ -12,20 +12,13 @@ import 'shapes/shapes.dart'; /// /// Typically used with a [ShapeDecoration] to draw a material-shaped border. class MaterialShapeBorder extends OutlinedBorder { - MaterialShapeBorder({ - required RoundedPolygon this.shape, - super.side, - this.squash = 0, - }) : _cubics = shape.cubics, - assert(squash >= 0 && squash <= 1, 'squash has to be in range [0, 1]'); - - const MaterialShapeBorder._fromCubics({ - required List cubics, - super.side, - this.squash = 0, - }) : shape = null, - _cubics = cubics, - assert(squash >= 0 && squash <= 1, 'squash has to be in range [0, 1]'); + MaterialShapeBorder({required RoundedPolygon this.shape, super.side, this.squash = 0}) + : _cubics = shape.cubics, + assert(squash >= 0 && squash <= 1, 'squash has to be in range [0, 1]'); + + const MaterialShapeBorder._fromCubics({required this._cubics, 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. /// @@ -54,21 +47,13 @@ class MaterialShapeBorder extends OutlinedBorder { @override ShapeBorder scale(double t) { - final shape = this.shape; + final RoundedPolygon? shape = this.shape; if (shape != null) { - return MaterialShapeBorder( - shape: shape, - side: side.scale(t), - squash: squash, - ); + return MaterialShapeBorder(shape: shape, side: side.scale(t), squash: squash); } - return MaterialShapeBorder._fromCubics( - cubics: _cubics, - side: side.scale(t), - squash: squash, - ); + return MaterialShapeBorder._fromCubics(cubics: _cubics, side: side.scale(t), squash: squash); } @override @@ -82,8 +67,8 @@ class MaterialShapeBorder extends OutlinedBorder { } if (a is MaterialShapeBorder) { - final aShape = a.shape; - final shape = this.shape; + final RoundedPolygon? aShape = a.shape; + final RoundedPolygon? shape = this.shape; if (aShape == null || shape == null) { throw StateError( @@ -114,8 +99,8 @@ class MaterialShapeBorder extends OutlinedBorder { } if (b is MaterialShapeBorder) { - final bShape = b.shape; - final shape = this.shape; + final RoundedPolygon? bShape = b.shape; + final RoundedPolygon? shape = this.shape; if (bShape == null || shape == null) { throw StateError( @@ -126,10 +111,7 @@ class MaterialShapeBorder extends OutlinedBorder { } return MaterialShapeBorder._fromCubics( - cubics: Morph( - shape, - bShape, - ).asCubics(t), + cubics: Morph(shape, bShape).asCubics(t), side: BorderSide.lerp(side, b.side, t), squash: ui.lerpDouble(squash, b.squash, t)!, ); @@ -139,11 +121,7 @@ class MaterialShapeBorder extends OutlinedBorder { } @override - MaterialShapeBorder copyWith({ - RoundedPolygon? shape, - BorderSide? side, - double? squash, - }) { + MaterialShapeBorder copyWith({RoundedPolygon? shape, BorderSide? side, double? squash}) { if (shape != null) { return MaterialShapeBorder( shape: shape, @@ -152,7 +130,7 @@ class MaterialShapeBorder extends OutlinedBorder { ); } - final oldShape = this.shape; + final RoundedPolygon? oldShape = this.shape; if (oldShape != null) { return MaterialShapeBorder( @@ -178,10 +156,8 @@ class MaterialShapeBorder extends OutlinedBorder { scale = Offset(squash * scale.dx + (1 - squash) * scale.dy, scale.dy); } - final actualRect = Offset( - rect.left + (rect.width - scale.dx) / 2, - rect.top + (rect.height - scale.dy) / 2, - ) & + 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() @@ -201,13 +177,13 @@ class MaterialShapeBorder extends OutlinedBorder { @override Path getInnerPath(Rect rect, {TextDirection? textDirection}) { - final adjustedRect = rect.deflate(side.strokeInset); + final Rect adjustedRect = rect.deflate(side.strokeInset); return _getPathFromRect(adjustedRect); } @override Path getOuterPath(Rect rect, {TextDirection? textDirection}) { - final adjustedRect = rect.inflate(side.strokeOutset); + final Rect adjustedRect = rect.inflate(side.strokeOutset); return _getPathFromRect(adjustedRect); } @@ -218,8 +194,8 @@ class MaterialShapeBorder extends OutlinedBorder { return; case BorderStyle.solid: - final adjustedRect = rect.inflate(side.strokeOffset / 2); - final path = _getPathFromRect(adjustedRect); + final Rect adjustedRect = rect.inflate(side.strokeOffset / 2); + final Path path = _getPathFromRect(adjustedRect); canvas.drawPath(path, side.toPaint()); } } diff --git a/packages/material_ui/lib/src/shapes/material_shapes.dart b/packages/material_ui/lib/src/shapes/material_shapes.dart index f8f4e79abbac..679c8214f4ee 100644 --- a/packages/material_ui/lib/src/shapes/material_shapes.dart +++ b/packages/material_ui/lib/src/shapes/material_shapes.dart @@ -18,9 +18,9 @@ abstract final class MaterialShapes { static const _cornerRound50 = CornerRounding(radius: 0.5); static const _cornerRound100 = CornerRounding(radius: 1); - static const _negative45Radians = -45 * math.pi / 180; - static const _negative90Radians = -90 * math.pi / 180; - static const _negative135Radians = -135 * math.pi / 180; + 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( @@ -40,13 +40,13 @@ abstract final class MaterialShapes { ); /// A slanted square shape. - static final slanted = _customPolygon(const [ + 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 arch = + static final RoundedPolygon arch = RoundedPolygon.fromVerticesNum( 4, perVertexRounding: const [ @@ -60,14 +60,14 @@ abstract final class MaterialShapes { .normalized(); /// A semi-circle shape. - static final semiCircle = RoundedPolygon.rectangle( + static final RoundedPolygon semiCircle = RoundedPolygon.rectangle( width: 1.6, height: 1, perVertexRounding: const [_cornerRound20, _cornerRound20, _cornerRound100, _cornerRound100], ).normalized(); /// An oval shape. - static final oval = RoundedPolygon.circle() + static final RoundedPolygon oval = RoundedPolygon.circle() .transformed( (Matrix4.identity() ..rotateZ(_negative45Radians) @@ -77,7 +77,7 @@ abstract final class MaterialShapes { .normalized(); /// An pill shape. - static final pill = _customPolygon( + static final RoundedPolygon pill = _customPolygon( [ const _PointNRound(Point(0.961, 0.039), CornerRounding(radius: 0.426)), const _PointNRound(Point(1.001, 0.428)), @@ -88,12 +88,12 @@ abstract final class MaterialShapes { ).normalized(); /// A triangle shape. - static final triangle = RoundedPolygon.fromVerticesNum(3, rounding: _cornerRound20) + static final RoundedPolygon triangle = RoundedPolygon.fromVerticesNum(3, rounding: _cornerRound20) .transformed((Matrix4.identity()..rotateZ(_negative90Radians)).asPointTransformer()) .normalized(); /// An arrow shape. - static final arrow = _customPolygon([ + 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)), @@ -101,7 +101,7 @@ abstract final class MaterialShapes { ], 1).normalized(); /// A fan shape. - static final fan = _customPolygon([ + 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)), @@ -109,20 +109,20 @@ abstract final class MaterialShapes { ], 1).normalized(); /// A diamond shape. - static final diamond = _customPolygon([ + 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 clamShell = _customPolygon([ + 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 pentagon = _customPolygon( + 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)), @@ -133,7 +133,7 @@ abstract final class MaterialShapes { ).normalized(); /// A gem shape. - static final gem = _customPolygon( + 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)), @@ -145,50 +145,50 @@ abstract final class MaterialShapes { ).normalized(); /// A sunny shape. - static final sunny = RoundedPolygon.star( + static final RoundedPolygon sunny = RoundedPolygon.star( numVerticesPerRadius: 8, innerRadius: 0.8, rounding: _cornerRound15, ).normalized(); /// A very-sunny shape. - static final verySunny = _customPolygon([ + 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 cookie4Sided = _customPolygon([ + 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 cookie6Sided = _customPolygon([ + 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 cookie7Sided = + 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 cookie9Sided = + 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 cookie12Sided = + 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 clover4Leaf = _customPolygon( + static final RoundedPolygon clover4Leaf = _customPolygon( [ const _PointNRound(Point(0.5, 0.074)), const _PointNRound(Point(0.725, -0.099), CornerRounding(radius: 0.476)), @@ -198,31 +198,31 @@ abstract final class MaterialShapes { ).normalized(); /// A 8-leaf clover shape. - static final clover8Leaf = _customPolygon([ + 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 burst = _customPolygon([ + 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 softBurst = _customPolygon([ + 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 boom = _customPolygon([ + 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 softBoom = _customPolygon( + static final RoundedPolygon softBoom = _customPolygon( [ const _PointNRound(Point(0.733, 0.454)), const _PointNRound(Point(0.839, 0.437), CornerRounding(radius: 0.532)), @@ -234,7 +234,7 @@ abstract final class MaterialShapes { ).normalized(); /// A flower shape. - static final flower = _customPolygon( + static final RoundedPolygon flower = _customPolygon( [ const _PointNRound(Point(0.370, 0.187)), const _PointNRound(Point(0.416, 0.049), CornerRounding(radius: 0.381)), @@ -245,7 +245,7 @@ abstract final class MaterialShapes { ).normalized(); /// A puffy shape. - static final puffy = _customPolygon( + static final RoundedPolygon puffy = _customPolygon( [ const _PointNRound(Point(0.5, 0.053)), const _PointNRound(Point(0.545, -0.04), CornerRounding(radius: 0.405)), @@ -264,7 +264,7 @@ abstract final class MaterialShapes { ).transformed((Matrix4.identity()..scale(1.0, 0.742)).asPointTransformer()).normalized(); /// A puffy-diamond shape. - static final puffyDiamond = _customPolygon( + static final RoundedPolygon puffyDiamond = _customPolygon( [ const _PointNRound(Point(0.87, 0.13), CornerRounding(radius: 0.146)), const _PointNRound(Point(0.818, 0.357)), @@ -275,7 +275,7 @@ abstract final class MaterialShapes { ).normalized(); /// A ghostish shape. - static final ghostish = _customPolygon( + static final RoundedPolygon ghostish = _customPolygon( [ const _PointNRound(Point(0.5, 0), CornerRounding(radius: 1)), const _PointNRound(Point(1, 0), CornerRounding(radius: 1)), @@ -287,7 +287,7 @@ abstract final class MaterialShapes { ).normalized(); /// A pixel-circle shape. - static final pixelCircle = _customPolygon( + static final RoundedPolygon pixelCircle = _customPolygon( [ const _PointNRound(Point(0.5, 0)), const _PointNRound(Point(0.704, 0)), @@ -303,7 +303,7 @@ abstract final class MaterialShapes { ).normalized(); /// A pixel-triangle shape. - static final pixelTriangle = _customPolygon( + static final RoundedPolygon pixelTriangle = _customPolygon( [ const _PointNRound(Point(0.11, 0.5)), const _PointNRound(Point(0.113, 0)), @@ -324,7 +324,7 @@ abstract final class MaterialShapes { ).normalized(); /// A bun shape. - static final bun = _customPolygon( + static final RoundedPolygon bun = _customPolygon( [ const _PointNRound(Point(0.796, 0.5)), const _PointNRound(Point(0.853, 0.518), CornerRounding(radius: 1)), @@ -336,7 +336,7 @@ abstract final class MaterialShapes { ).normalized(); /// A heart shape. - static final heart = _customPolygon( + 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)), @@ -348,7 +348,7 @@ abstract final class MaterialShapes { ).normalized(); /// A list of all available shapes. - static final all = UnmodifiableListView([ + static final UnmodifiableListView all = UnmodifiableListView([ MaterialShapes.circle, MaterialShapes.square, MaterialShapes.slanted, @@ -392,7 +392,7 @@ abstract final class MaterialShapes { Point center = const Point(0.5, 0.5), bool mirroring = false, }) { - final actualPoints = _doRepeat(pnr, reps, center, mirroring); + final List<_PointNRound> actualPoints = _doRepeat(pnr, reps, center, mirroring); final vertices = List.filled(actualPoints.length * 2, 0); final perVertexRounding = List.filled( @@ -401,10 +401,10 @@ abstract final class MaterialShapes { ); for (var i = 0; i < actualPoints.length; i++) { - final ap = actualPoints[i]; + final _PointNRound ap = actualPoints[i]; perVertexRounding[i] = ap.r; - final j = i * 2; + final int j = i * 2; vertices[j] = ap.p.x; vertices[j + 1] = ap.p.y; } @@ -426,34 +426,35 @@ abstract final class MaterialShapes { final result = <_PointNRound>[]; if (mirroring) { - final measures = List.generate(points.length, (i) { - final point = points[i]; - final off = point.p - center; + 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.angleRadians, distance: off.getDistance()); }); - final actualReps = reps * 2; - final sectionAngle = math.pi * 2 / actualReps; + 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 i = (r.isEven) ? index : points.length - 1 - index; + final int i = (r.isEven) ? index : points.length - 1 - index; if (i > 0 || r.isEven) { - final a = + final double a = sectionAngle * r + ((r.isEven) ? measures[i].angle : sectionAngle - measures[i].angle + 2 * measures[0].angle); - final finalPoint = Point(math.cos(a), math.sin(a)) * measures[i].distance + center; + final Point finalPoint = + Point(math.cos(a), math.sin(a)) * measures[i].distance + center; result.add(_PointNRound(finalPoint, points[i].r)); } } } } else { - final np = points.length; + final int np = points.length; for (var i = 0; i < np * reps; i++) { - final point = points[i % np].p.rotate((i ~/ np) * 360 / reps, center: center); + final Point point = points[i % np].p.rotate((i ~/ np) * 360 / reps, center: center); result.add(_PointNRound(point, points[i % np].r)); } } diff --git a/packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart b/packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart index 213e396e0890..2c966c8fceda 100644 --- a/packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart +++ b/packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart @@ -38,16 +38,11 @@ part of 'shapes.dart'; /// circular arc in the center; the flanking curves on either side meet at the /// middle. class CornerRounding { - static const unrounded = CornerRounding(); + const CornerRounding({this.radius = 0, this.smoothing = 0}) + : assert(radius >= 0, 'radius has to be greater that zero'), + assert(smoothing >= 0 && smoothing <= 1, 'smoothing has to be in range [0, 1]'); - const CornerRounding({ - this.radius = 0, - this.smoothing = 0, - }) : assert(radius >= 0, 'radius has to be greater that zero'), - assert( - smoothing >= 0 && smoothing <= 1, - 'smoothing has to be in range [0, 1]', - ); + static const unrounded = CornerRounding(); final double radius; diff --git a/packages/material_ui/lib/src/shapes/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/shapes/cubic.dart index d4f46f93e49d..ab739433ece7 100644 --- a/packages/material_ui/lib/src/shapes/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/shapes/cubic.dart @@ -22,46 +22,37 @@ class Cubic { double anchor1X, double anchor1Y, ) : this._raw([ - anchor0X, - anchor0Y, - control0X, - control0Y, - control1X, - control1Y, - anchor1X, - anchor1Y, - ]); + anchor0X, + anchor0Y, + control0X, + control0Y, + control1X, + control1Y, + anchor1X, + anchor1Y, + ]); const Cubic._raw(List points) - : assert(points.length == 8, 'Points array size should be 8.'), - _points = points; + : assert(points.length == 8, 'Points array size should be 8.'), + _points = points; @internal - Cubic.fromPoints( - Point anchor0, - Point control0, - Point control1, - Point anchor1, - ) : this._raw([ - anchor0.x, - anchor0.y, - control0.x, - control0.y, - control1.x, - control1.y, - anchor1.x, - anchor1.y, - ]); + Cubic.fromPoints(Point anchor0, Point control0, Point control1, Point anchor1) + : this._raw([ + anchor0.x, + anchor0.y, + control0.x, + control0.y, + control1.x, + control1.y, + anchor1.x, + anchor1.y, + ]); /// Generates a bezier curve that is a straight line between the given anchor /// points. The control points lie 1/3 of the distance from their respective /// anchor points. - factory Cubic.straightLine( - double x0, - double y0, - double x1, - double y1, - ) { + factory Cubic.straightLine(double x0, double y0, double x1, double y1) { return Cubic._raw([ x0, y0, @@ -79,8 +70,6 @@ class Cubic { /// 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 the center. - // TODO: consider a more general function (maybe in addition to this) that - // allows caller to get a list of curves surpassing 180 degrees. factory Cubic.circularArc( double centerX, double centerY, @@ -89,19 +78,20 @@ class Cubic { double x1, double y1, ) { - final p0d = directionVector(x0 - centerX, y0 - centerY); - final p1d = directionVector(x1 - centerX, y1 - centerY); - final rotatedP0 = p0d.rotate90(); - final rotatedP1 = p1d.rotate90(); - final clockwise = rotatedP0.dotProductXY(x1 - centerX, y1 - centerY) >= 0; - final cosa = p0d.dotProduct(p1d); + final Point p0d = directionVector(x0 - centerX, y0 - centerY); + final Point p1d = directionVector(x1 - centerX, y1 - centerY); + final Point rotatedP0 = p0d.rotate90(); + final Point rotatedP1 = p1d.rotate90(); + final bool clockwise = rotatedP0.dotProductXY(x1 - centerX, y1 - centerY) >= 0; + final double cosa = p0d.dotProduct(p1d); // p0 ~= p1 if (cosa > 0.999) { return Cubic.straightLine(x0, y0, x1, y1); } - final k = distance(x0 - centerX, y0 - centerY) * + final double k = + distance(x0 - centerX, y0 - centerY) * 4 / 3 * (math.sqrt(2 * (1 - cosa)) - math.sqrt(1 - cosa * cosa)) / @@ -121,8 +111,7 @@ class Cubic { } /// Generates an empty Cubic defined at (x0, y0). - Cubic.empty(double x0, double y0) - : this._raw([x0, y0, x0, y0, x0, y0, x0, y0]); + Cubic.empty(double x0, double y0) : this._raw([x0, y0, x0, y0, x0, y0, x0, y0]); final List _points; @@ -151,7 +140,7 @@ class Cubic { /// [t] is the distance along the curve between the anchor points, where 0 /// is at anchor0 and 1 is at anchor1 Point pointOnCurve(double t) { - final u = 1 - t; + final double u = 1 - t; return Point( anchor0X * (u * u * u) + control0X * (3 * t * u * u) + @@ -193,10 +182,10 @@ class Cubic { return; } - var minX = math.min(anchor0X, anchor1X); - var minY = math.min(anchor0Y, anchor1Y); - var maxX = math.max(anchor0X, anchor1X); - var maxY = math.max(anchor0Y, anchor1Y); + 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 @@ -210,68 +199,92 @@ class Cubic { // Find the derivative, which is a quadratic Bezier. Then we can solve // for t using the quadratic formula. - final xa = -anchor0X + 3 * control0X - 3 * control1X + anchor1X; - final xb = 2 * anchor0X - 4 * control0X + 2 * control1X; - final xc = -anchor0X + control0X; + 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 t = 2 * xc / (-2 * xb); + final double t = 2 * xc / (-2 * xb); if (t >= 0 && t <= 1) { - final x = pointOnCurve(t).x; - if (x < minX) minX = x; - if (x > maxX) maxX = x; + final double x = pointOnCurve(t).x; + if (x < minX) { + minX = x; + } + if (x > maxX) { + maxX = x; + } } } } else { - final xs = xb * xb - 4 * xa * xc; + final double xs = xb * xb - 4 * xa * xc; if (xs >= 0) { - final t1 = (-xb + math.sqrt(xs)) / (2 * xa); + final double t1 = (-xb + math.sqrt(xs)) / (2 * xa); if (t1 >= 0 && t1 <= 1) { - final x = pointOnCurve(t1).x; - if (x < minX) minX = x; - if (x > maxX) maxX = x; + final double x = pointOnCurve(t1).x; + if (x < minX) { + minX = x; + } + if (x > maxX) { + maxX = x; + } } - final t2 = (-xb - math.sqrt(xs)) / (2 * xa); + final double t2 = (-xb - math.sqrt(xs)) / (2 * xa); if (t2 >= 0 && t2 <= 1) { - final x = pointOnCurve(t2).x; - if (x < minX) minX = x; - if (x > maxX) maxX = x; + final double x = pointOnCurve(t2).x; + if (x < minX) { + minX = x; + } + if (x > maxX) { + maxX = x; + } } } } // Repeat the above for y coordinate - final ya = -anchor0Y + 3 * control0Y - 3 * control1Y + anchor1Y; - final yb = 2 * anchor0Y - 4 * control0Y + 2 * control1Y; - final yc = -anchor0Y + control0Y; + 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 t = 2 * yc / (-2 * yb); + final double t = 2 * yc / (-2 * yb); if (t >= 0 && t <= 1) { - final y = pointOnCurve(t).y; - if (y < minY) minY = y; - if (y > maxY) maxY = y; + final double y = pointOnCurve(t).y; + if (y < minY) { + minY = y; + } + if (y > maxY) { + maxY = y; + } } } } else { - final ys = yb * yb - 4 * ya * yc; + final double ys = yb * yb - 4 * ya * yc; if (ys >= 0) { - final t1 = (-yb + math.sqrt(ys)) / (2 * ya); + final double t1 = (-yb + math.sqrt(ys)) / (2 * ya); if (t1 >= 0 && t1 <= 1) { - final y = pointOnCurve(t1).y; - if (y < minY) minY = y; - if (y > maxY) maxY = y; + final double y = pointOnCurve(t1).y; + if (y < minY) { + minY = y; + } + if (y > maxY) { + maxY = y; + } } - final t2 = (-yb - math.sqrt(ys)) / (2 * ya); + final double t2 = (-yb - math.sqrt(ys)) / (2 * ya); if (t2 >= 0 && t2 <= 1) { - final y = pointOnCurve(t2).y; - if (y < minY) minY = y; - if (y > maxY) maxY = y; + final double y = pointOnCurve(t2).y; + if (y < minY) { + minY = y; + } + if (y > maxY) { + maxY = y; + } } } } @@ -284,10 +297,9 @@ class Cubic { /// Returns two Cubics, created by splitting this curve at the given /// distance of [t] between the original starting and ending anchor points. - // TODO: cartesian optimization? (Cubic, Cubic) split(double t) { - final u = 1 - t; - final point = pointOnCurve(t); + final double u = 1 - t; + final Point point = pointOnCurve(t); return ( Cubic( @@ -301,7 +313,6 @@ class Cubic { point.y, ), Cubic( - // TODO: should calculate once and share the result. point.x, point.y, control0X * (u * u) + control1X * (2 * u * t) + anchor1X * (t * t), @@ -315,22 +326,12 @@ class Cubic { } /// Utility function to reverse the control/anchor points for this curve. - Cubic reverse() => Cubic( - anchor1X, - anchor1Y, - control1X, - control1Y, - control0X, - control0Y, - anchor0X, - anchor0Y, - ); + Cubic reverse() => + Cubic(anchor1X, anchor1Y, control1X, control1Y, control0X, control0Y, anchor0X, anchor0Y); - Cubic operator +(Cubic o) => - Cubic._raw(List.generate(8, (i) => _points[i] + o._points[i])); + Cubic operator +(Cubic o) => Cubic._raw(List.generate(8, (i) => _points[i] + o._points[i])); - Cubic operator *(double x) => - Cubic._raw(List.generate(8, (i) => _points[i] * x)); + Cubic operator *(double x) => Cubic._raw(List.generate(8, (i) => _points[i] * x)); Cubic operator /(double x) => this * (1.0 / x); @@ -387,7 +388,7 @@ class _MutableCubic extends Cubic { _MutableCubic() : super._raw(List.filled(8, 0)); void _transformOnePoint(PointTransformer f, int ix) { - final result = f(_points[ix], _points[ix + 1]); + final (double, double) result = f(_points[ix], _points[ix + 1]); _points[ix] = result.$1; _points[ix + 1] = result.$2; } diff --git a/packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart b/packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart index 3f16c9cd200d..60647a375dbb 100644 --- a/packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart +++ b/packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart @@ -23,10 +23,7 @@ class DistanceVertex { } /// Creates a mapping between the "features" (rounded corners) of two shapes. -DoubleMapper featureMapper( - MeasuredFeatures features1, - MeasuredFeatures features2, -) { +DoubleMapper featureMapper(MeasuredFeatures features1, MeasuredFeatures features2) { // We only use corners for this mapping. final filteredFeatures1 = []; for (var i = 0; i < features1.length; i++) { @@ -42,7 +39,7 @@ DoubleMapper featureMapper( } } - final featureProgressMapping = doMapping( + final List<(double, double)> featureProgressMapping = doMapping( filteredFeatures1, filteredFeatures2, ); @@ -73,7 +70,7 @@ List<(double, double)> doMapping( for (final f1 in features1) { for (final f2 in features2) { - final d = featureDistSquared(f1.feature, f2.feature); + final double d = featureDistSquared(f1.feature, f2.feature); if (d != double.maxFinite) { distanceVertexList.add(DistanceVertex(d, f1, f2)); } @@ -88,10 +85,10 @@ List<(double, double)> doMapping( } if (distanceVertexList.length == 1) { - final d = distanceVertexList.first; + final DistanceVertex d = distanceVertexList.first; - final f1 = d.f1.progress; - final f2 = d.f2.progress; + final double f1 = d.f1.progress; + final double f2 = d.f2.progress; return [(f1, f2), ((f1 + 0.5) % 1, (f2 + 0.5) % 1)]; } @@ -122,7 +119,7 @@ class _MappingHelper { } // List is sorted, find where we need to insert this new mapping. - final index = binarySearchBy<(double, double), double>( + final int index = binarySearchBy<(double, double), double>( mapping, (it) => it.$1, (a, b) => a.compareTo(b), @@ -133,13 +130,13 @@ class _MappingHelper { throw StateError("There can't be two features with the same progress."); } - final insertionIndex = -index - 1; - final n = mapping.length; + final int insertionIndex = -index - 1; + final int n = mapping.length; // We can always add the first 1 element. if (n >= 1) { - final (before1, before2) = mapping[(insertionIndex + n - 1) % n]; - final (after1, after2) = mapping[insertionIndex % n]; + 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. @@ -168,7 +165,6 @@ class _MappingHelper { /// different shapes. This information is used to determine how to map features /// (and the curves that make up those features). double featureDistSquared(Feature f1, Feature f2) { - // TODO: We might want to enable concave-convex matching in some situations. // If so, the approach below will not work 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 @@ -176,14 +172,12 @@ double featureDistSquared(Feature f1, Feature f2) { return double.maxFinite; } - return (featureRepresentativePoint(f1) - featureRepresentativePoint(f2)) - .getDistanceSquared(); + return (featureRepresentativePoint(f1) - featureRepresentativePoint(f2)).getDistanceSquared(); } -// TODO: b/378441547 - Move to explicit parameter / expose? Point featureRepresentativePoint(Feature feature) { - final cubics = feature.cubics; - final x = (cubics.first.anchor0X + cubics.last.anchor1X) / 2; - final y = (cubics.first.anchor0Y + cubics.last.anchor1Y) / 2; + 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/shapes/features.dart b/packages/material_ui/lib/src/shapes/shapes/features.dart index 70555b22154f..dcb805343226 100644 --- a/packages/material_ui/lib/src/shapes/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/shapes/features.dart @@ -40,8 +40,7 @@ abstract class Feature { /// squares' outer corners. /// /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. - factory Feature.buildIgnorableFeature(List cubics) => - _validated(EdgeFeature(cubics)); + factory Feature.buildIgnorableFeature(List cubics) => _validated(EdgeFeature(cubics)); /// Group a [Cubic] object to an edge (neither inward or outward /// identification in a shape). @@ -53,8 +52,7 @@ abstract class Feature { /// in a shape). /// /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics - factory Feature.buildConvexCorner(List cubics) => - _validated(CornerFeature(cubics)); + factory Feature.buildConvexCorner(List cubics) => _validated(CornerFeature(cubics)); /// Group a list of [Cubic] objects to a concave corner (inward indentation /// in a shape). @@ -80,9 +78,9 @@ abstract class Feature { static bool _isContinuous(Feature feature) { const distanceEpsilon = 1e-5; - var prevCubic = feature._cubics.first; + Cubic prevCubic = feature._cubics.first; for (var i = 1; i < feature._cubics.length; i++) { - final cubic = feature._cubics[i]; + final Cubic cubic = feature._cubics[i]; if ((cubic.anchor0X - prevCubic.anchor1X).abs() > distanceEpsilon || (cubic.anchor0Y - prevCubic.anchor1Y).abs() > distanceEpsilon) { return false; @@ -129,20 +127,12 @@ class EdgeFeature extends Feature { EdgeFeature(super._cubics); @override - Feature transformed(PointTransformer f) => EdgeFeature( - List.generate( - _cubics.length, - (i) => _cubics[i].transformed(f), - ), - ); + Feature transformed(PointTransformer f) => + EdgeFeature(List.generate(_cubics.length, (i) => _cubics[i].transformed(f))); @override - Feature reversed() => EdgeFeature( - List.generate( - _cubics.length, - (i) => _cubics[_cubics.length - 1 - i].reverse(), - ), - ); + Feature reversed() => + EdgeFeature(List.generate(_cubics.length, (i) => _cubics[_cubics.length - 1 - i].reverse())); @override bool get isIgnorableFeature => true; @@ -174,23 +164,15 @@ class CornerFeature extends Feature { @override Feature transformed(PointTransformer f) => CornerFeature( - List.generate( - _cubics.length, - (i) => _cubics[i].transformed(f), - ), - convex: convex, - ); + List.generate(_cubics.length, (i) => _cubics[i].transformed(f)), + convex: convex, + ); @override Feature reversed() => CornerFeature( - List.generate( - _cubics.length, - (i) => _cubics[_cubics.length - 1 - i].reverse(), - ), - // TODO: b/369320447 - Revert flag negation when [RoundedPolygon] - // ignores orientation for setting the flag. - convex: !convex, - ); + List.generate(_cubics.length, (i) => _cubics[_cubics.length - 1 - i].reverse()), + convex: !convex, + ); @override bool get isIgnorableFeature => false; diff --git a/packages/material_ui/lib/src/shapes/shapes/float_mapping.dart b/packages/material_ui/lib/src/shapes/shapes/float_mapping.dart index d2e2feba2f99..2a96ad7170fd 100644 --- a/packages/material_ui/lib/src/shapes/shapes/float_mapping.dart +++ b/packages/material_ui/lib/src/shapes/shapes/float_mapping.dart @@ -31,23 +31,20 @@ double linearMap(List xValues, List yValues, double x) { throw StateError('segmentStartIndex not found.'); } - final segmentEndIndex = (segmentStartIndex + 1) % xValues.length; - final segmentSizeX = positiveModulo( + final int segmentEndIndex = (segmentStartIndex + 1) % xValues.length; + final double segmentSizeX = positiveModulo( xValues[segmentEndIndex] - xValues[segmentStartIndex], 1, ); - final segmentSizeY = positiveModulo( + final double segmentSizeY = positiveModulo( yValues[segmentEndIndex] - yValues[segmentStartIndex], 1, ); - final positionInSegment = segmentSizeX < 0.001 + final double positionInSegment = segmentSizeX < 0.001 ? 0.5 : positiveModulo(x - xValues[segmentStartIndex], 1) / segmentSizeX; - return positiveModulo( - yValues[segmentStartIndex] + segmentSizeY * positionInSegment, - 1, - ); + return positiveModulo(yValues[segmentStartIndex] + segmentSizeY * positionInSegment, 1); } /// [DoubleMapper] creates mappings from values in the [0..1) source space to @@ -71,16 +68,11 @@ double linearMap(List xValues, List yValues, double x) { /// progress values between the start and end shape, which is then used to /// insert new curves and match curves overall. class DoubleMapper { - static final identity = DoubleMapper([ - (0.0, 0.0), - (0.5, 0.5), - ]); - 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 pair = mappings[i]; + final (double, double) pair = mappings[i]; _sourceValues[i] = pair.$1; _targetValues[i] = pair.$2; } @@ -88,6 +80,8 @@ class DoubleMapper { validateProgress(_targetValues); } + static final identity = DoubleMapper([(0.0, 0.0), (0.5, 0.5)]); + late final List _sourceValues; late final List _targetValues; @@ -108,30 +102,24 @@ void validateProgress(List p) { throw ArgumentError('List is empty.'); } - var prev = p.last; + double prev = p.last; var wraps = 0; for (var i = 0; i < p.length; i++) { - final curr = p[i]; + final double curr = p[i]; if (curr < 0 || curr >= 1) { - throw ArgumentError( - 'FloatMapping - Progress outside of range: ${p.join(', ')}', - ); + throw ArgumentError('FloatMapping - Progress outside of range: ${p.join(', ')}'); } if (progressDistance(curr, prev).abs() <= distanceEpsilon) { - throw ArgumentError( - 'FloatMapping - Progress repeats a value: ${p.join(', ')}', - ); + throw ArgumentError('FloatMapping - Progress repeats a value: ${p.join(', ')}'); } if (curr < prev) { wraps++; if (wraps > 1) { - throw ArgumentError( - 'FloatMapping - Progress wraps more than once: ${p.join(', ')}', - ); + throw ArgumentError('FloatMapping - Progress wraps more than once: ${p.join(', ')}'); } } @@ -142,6 +130,6 @@ void validateProgress(List p) { /// Distance between two progress values, considering wrap-around. /// For example, the distance between 0.99 and 0.0 is 0.01. double progressDistance(double p1, double p2) { - final diff = (p1 - p2).abs(); + final double diff = (p1 - p2).abs(); return math.min(diff, 1.0 - diff); } diff --git a/packages/material_ui/lib/src/shapes/shapes/morph.dart b/packages/material_ui/lib/src/shapes/shapes/morph.dart index c240e27f1aa4..618bd4461a92 100644 --- a/packages/material_ui/lib/src/shapes/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/shapes/morph.dart @@ -17,9 +17,7 @@ part of 'shapes.dart'; /// splitting curves when the shapes do not have the same number of curves or /// when the curve placement within the shapes is very different. class Morph { - Morph(RoundedPolygon start, RoundedPolygon end) - : _start = start, - _end = end { + Morph(RoundedPolygon start, RoundedPolygon end) : _start = start, _end = end { _morphMatch = _match(start, end); } @@ -50,30 +48,24 @@ class Morph { static List<(Cubic, Cubic)> _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.measurePolygon( - const LengthMeasurer(), - p1, - ); - final measuredPolygon2 = MeasuredPolygon.measurePolygon( - const LengthMeasurer(), - p2, - ); + final measuredPolygon1 = MeasuredPolygon.measurePolygon(const LengthMeasurer(), p1); + final measuredPolygon2 = MeasuredPolygon.measurePolygon(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 features1 = measuredPolygon1.features; - final features2 = measuredPolygon2.features; + 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 = featureMapper(features1, features2); + final DoubleMapper doubleMapper = featureMapper(features1, features2); // cut point on poly2 is the mapping of the 0 point on poly1. - final polygon2CutPoint = doubleMapper.map(0); + final double polygon2CutPoint = doubleMapper.map(0); // Cut and rotate. // Polygons start at progress 0, and the featureMapper has decided that we @@ -83,7 +75,7 @@ class Morph { // matching. The resulting bs1/2 are MeasuredPolygons, whose MeasuredCubics // start from outlineProgress=0 and increasing until outlineProgress=1. final bs1 = measuredPolygon1; - final bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint); + final MeasuredPolygon bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint); // Match. // Now we can compare the two lists of measured cubics and create a list of @@ -96,32 +88,28 @@ class Morph { var i1 = 0; var i2 = 0; // b1, b2 are the current measured cubic for each polygon. - var b1 = bs1.getOrNull(i1++); - var b2 = bs2.getOrNull(i2++); + MeasuredCubic? b1 = bs1.getOrNull(i1++); + MeasuredCubic? b2 = bs2.getOrNull(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 b1a = (i1 == bs1.length) ? 1.0 : b1.endOutlineProgress; - final b2a = (i2 == bs2.length) + 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 minb = math.min(b1a, b2a); + : 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 (seg1, newb1) = (b1a > minb + angleEpsilon) + final (MeasuredCubic seg1, MeasuredCubic? newb1) = (b1a > minb + angleEpsilon) ? b1.cutAtProgress(minb) : (b1, bs1.getOrNull(i1++)); - final (seg2, newb2) = (b2a > minb + angleEpsilon) - ? b2.cutAtProgress( - positiveModulo(doubleMapper.map(minb) - polygon2CutPoint, 1), - ) + final (MeasuredCubic seg2, MeasuredCubic? newb2) = (b2a > minb + angleEpsilon) + ? b2.cutAtProgress(positiveModulo(doubleMapper.map(minb) - polygon2CutPoint, 1)) : (b2, bs2.getOrNull(i2++)); ret.add((seg1.cubic, seg2.cubic)); @@ -129,10 +117,7 @@ class Morph { b2 = newb2; } - assert( - b1 == null && b2 == null, - "Expected both Polygon's Cubic to be fully matched", - ); + assert(b1 == null && b2 == null, "Expected both Polygon's Cubic to be fully matched"); return ret; } @@ -149,16 +134,13 @@ class Morph { /// Returns the axis-aligned bounding box for this object, where the /// rectangles left, top, right, and bottom values will be stored in entries /// 0, 1, 2, and 3, in that order. - List calculateBounds({ - List? bounds, - bool approximate = true, - }) { + List calculateBounds({List? bounds, bool approximate = true}) { bounds ??= List.filled(4, 0); _start.calculateBounds(bounds: bounds, approximate: approximate); - final minX = bounds[0]; - final minY = bounds[1]; - final maxX = bounds[2]; - final maxY = bounds[3]; + final double minX = bounds[0]; + final double minY = bounds[1]; + final double maxX = bounds[2]; + final double maxY = bounds[3]; _end.calculateBounds(bounds: bounds, approximate: approximate); bounds[0] = math.min(minX, bounds[0]); bounds[1] = math.min(minY, bounds[1]); @@ -184,10 +166,10 @@ class Morph { List calculateMaxBounds([List? bounds]) { bounds ??= List.filled(4, 0); _start.calculateMaxBounds(bounds); - final minX = bounds[0]; - final minY = bounds[1]; - final maxX = bounds[2]; - final maxY = bounds[3]; + final double minX = bounds[0]; + final double minY = bounds[1]; + final double maxX = bounds[2]; + final double maxY = bounds[3]; _end.calculateMaxBounds(bounds); bounds[0] = math.min(minX, bounds[0]); bounds[1] = math.min(minY, bounds[1]); @@ -223,11 +205,7 @@ class Morph { for (var i = 0; i < _morphMatch.length; i++) { final cubic = Cubic._raw( List.generate(8, (j) { - return lerp( - _morphMatch[i].$1.points[j], - _morphMatch[i].$2.points[j], - progress, - ); + return lerp(_morphMatch[i].$1.points[j], _morphMatch[i].$2.points[j], progress); }), ); diff --git a/packages/material_ui/lib/src/shapes/shapes/point.dart b/packages/material_ui/lib/src/shapes/shapes/point.dart index 8a45d1f57511..38f2c7c8d00d 100644 --- a/packages/material_ui/lib/src/shapes/shapes/point.dart +++ b/packages/material_ui/lib/src/shapes/shapes/point.dart @@ -4,10 +4,10 @@ typedef PointTransformer = (double, double) Function(double x, double y); @immutable class Point { - static const zero = Point(0, 0); - const Point(this.x, this.y); + static const zero = Point(0, 0); + final double x; final double y; @@ -17,15 +17,11 @@ class Point { Point rotate90() => Point(-y, x); Point rotate(double degrees, {Point center = Point.zero}) { - final radians = degrees * math.pi / 180; - final off = this - center; - final cos = math.cos(radians); - final sin = math.sin(radians); - return Point( - off.x * cos - off.y * sin, - off.x * sin + off.y * cos, - ) + - center; + 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; } Point translate(double dx, double dy) => Point(x + dx, y + dy); @@ -61,7 +57,7 @@ class Point { bool clockwise(Point other) => (x * other.y - y * other.x) > 0; Point getDirection() { - final d = getDistance(); + final double d = getDistance(); assert(d > 0, "Can't get the direction of a 0-length vector"); return this / d; } @@ -110,7 +106,7 @@ class Point { Point operator %(double operand) => Point(x % operand, y % operand); Point transformed(PointTransformer f) { - final result = f(x, y); + final (double, double) result = f(x, y); return Point(result.$1, result.$2); } @@ -146,8 +142,5 @@ class Point { /// and values greater than 1.0 are valid (and can easily be generated by /// curves). Point interpolate(Point start, Point stop, double fraction) { - return Point( - lerp(start.x, stop.x, fraction), - lerp(start.y, stop.y, fraction), - ); + return Point(lerp(start.x, stop.x, fraction), lerp(start.y, stop.y, fraction)); } diff --git a/packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart index 233b7d83e350..ff580fd57798 100644 --- a/packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart +++ b/packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart @@ -3,23 +3,16 @@ part of 'shapes.dart'; class MeasuredPolygon { MeasuredPolygon._({ required Measurer measurer, - required List features, + 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, - _features = features { + }) : 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++) { @@ -39,30 +32,20 @@ class MeasuredPolygon { } // We could have removed empty cubics at the end. Ensure the last measured // cubic ends at 1. - measuredCubics[measuredCubics.length - 1].updateProgressRange( - endOutlineProgress: 1, - ); + measuredCubics[measuredCubics.length - 1].updateProgressRange(endOutlineProgress: 1); _cubics = measuredCubics; } - factory MeasuredPolygon.measurePolygon( - Measurer measurer, - RoundedPolygon polygon, - ) { + factory MeasuredPolygon.measurePolygon(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 = polygon.features[featureIndex]; - for (var cubicIndex = 0; - cubicIndex < feature.cubics.length; - cubicIndex++) { - if (feature is CornerFeature && - cubicIndex == feature.cubics.length ~/ 2) { + 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]); @@ -73,11 +56,9 @@ class MeasuredPolygon { var totalMeasure = 0.0; for (var i = 0; i < cubics.length; i++) { - final measure = measurer.measureCubic(cubics[i]); + final double measure = measurer.measureCubic(cubics[i]); if (measure < 0) { - throw StateError( - 'Measured cubic is expected to be greater or equal to zero', - ); + throw StateError('Measured cubic is expected to be greater or equal to zero'); } totalMeasure += measure; measures[i + 1] = totalMeasure; @@ -88,19 +69,13 @@ class MeasuredPolygon { outlineProgress[i] = measures[i] / totalMeasure; } - final features = List.generate( - featureToCubic.length, - (i) { - final ix = featureToCubic[i].$2; - return ProgressableFeature( - positiveModulo( - (outlineProgress[ix] + outlineProgress[ix + 1]) / 2, - 1, - ), - featureToCubic[i].$1, - ); - }, - ); + 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, @@ -127,7 +102,7 @@ class MeasuredPolygon { MeasuredCubic operator [](int index) => _cubics[index]; MeasuredCubic? getOrNull(int index) { - final length = _cubics.length; + final int length = _cubics.length; if (index < 0 || index >= length) { return null; @@ -158,26 +133,26 @@ class MeasuredPolygon { throw ArgumentError('Cutting point is expected to be between 0 and 1'); } - if (cuttingPoint < distanceEpsilon) return this; + if (cuttingPoint < distanceEpsilon) { + return this; + } // Find the index of cubic we want to cut - final targetIndex = _cubics.indexWhere( - (c) => - cuttingPoint >= c._startOutlineProgress && - cuttingPoint <= c._endOutlineProgress, + final int targetIndex = _cubics.indexWhere( + (c) => cuttingPoint >= c._startOutlineProgress && cuttingPoint <= c._endOutlineProgress, ); - final target = _cubics[targetIndex]; + final MeasuredCubic target = _cubics[targetIndex]; // Cut the target cubic. // b1, b2 are two resulting cubics after cut - final (b1, b2) = target.cutAtProgress(cuttingPoint); + 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 retCubics = [b2.cubic]; + final List retCubics = [b2.cubic]; for (var i = 1; i < _cubics.length; i++) { retCubics.add(_cubics[(i + targetIndex) % _cubics.length].cubic); } @@ -200,7 +175,7 @@ class MeasuredPolygon { } else if (i == _cubics.length + 1) { retOutlineProgress[i] = 1; } else { - final cubicIndex = (targetIndex + i - 1) % _cubics.length; + final int cubicIndex = (targetIndex + i - 1) % _cubics.length; retOutlineProgress[i] = positiveModulo( _cubics[cubicIndex]._endOutlineProgress - cuttingPoint, 1, @@ -209,7 +184,7 @@ class MeasuredPolygon { } // Shift the feature's outline progress too. - final newFeatures = [ + final List newFeatures = [ for (var i = 0; i < _features.length; i++) ProgressableFeature( positiveModulo(_features[i].progress - cuttingPoint, 1), @@ -242,21 +217,21 @@ class MeasuredCubic { 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 { + }) : 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); } @@ -274,10 +249,7 @@ class MeasuredCubic { double get endOutlineProgress => _endOutlineProgress; - void updateProgressRange({ - double? startOutlineProgress, - double? endOutlineProgress, - }) { + void updateProgressRange({double? startOutlineProgress, double? endOutlineProgress}) { startOutlineProgress ??= _startOutlineProgress; endOutlineProgress ??= _endOutlineProgress; @@ -297,21 +269,18 @@ class MeasuredCubic { // 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 boundedCutOutlineProgress = cutOutlineProgress.coerceIn( + final double boundedCutOutlineProgress = cutOutlineProgress.coerceIn( _startOutlineProgress, _endOutlineProgress, ); - final outlineProgressSize = _endOutlineProgress - _startOutlineProgress; - final progressFromStart = boundedCutOutlineProgress - _startOutlineProgress; + 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 relativeProgress = progressFromStart / outlineProgressSize; - final t = measurer.findCubicCutPoint( - cubic, - relativeProgress * measuredSize, - ); + final double relativeProgress = progressFromStart / outlineProgressSize; + final double t = measurer.findCubicCutPoint(cubic, relativeProgress * measuredSize); if (t < 0 || t > 1) { throw ArgumentError('Cubic cut point is expected to be between 0 and 1.'); @@ -319,7 +288,7 @@ class MeasuredCubic { // c1/c2 are the two new cubics, then we return MeasuredCubics created // from them. - final (c1, c2) = cubic.split(t); + final (Cubic c1, Cubic c2) = cubic.split(t); return ( MeasuredCubic( measurer: measurer, @@ -332,7 +301,7 @@ class MeasuredCubic { cubic: c2, startOutlineProgress: boundedCutOutlineProgress, endOutlineProgress: _endOutlineProgress, - ) + ), ); } @@ -388,15 +357,12 @@ class LengthMeasurer implements Measurer { var prev = Point(cubic.anchor0X, cubic.anchor0Y); for (var i = 0; i <= _segments; i++) { - final progress = i / _segments; - final point = cubic.pointOnCurve(progress); - final segment = (point - prev).getDistance(); + final double progress = i / _segments; + final Point point = cubic.pointOnCurve(progress); + final double segment = (point - prev).getDistance(); if (segment >= remainder) { - return ( - progress - (1.0 - remainder / segment) / _segments, - threshold, - ); + return (progress - (1.0 - remainder / segment) / _segments, threshold); } remainder -= segment; diff --git a/packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart index dee9e2f455e8..f7b4fe2475d1 100644 --- a/packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart @@ -5,17 +5,14 @@ part of 'shapes.dart'; /// either the number of vertices desired or an ordered list of vertices. @immutable class RoundedPolygon { - RoundedPolygon._( - this.features, - this.center, - ) : cubics = [] { + RoundedPolygon._(this.features, this.center) : cubics = [] { _initCubics(); assert(() { - var prevCubic = cubics[cubics.length - 1]; + Cubic prevCubic = cubics[cubics.length - 1]; for (var index = 0; index < cubics.length; index++) { - final cubic = cubics[index]; + final Cubic cubic = cubics[index]; if ((cubic.anchor0X - prevCubic.anchor1X).abs() > distanceEpsilon || (cubic.anchor0Y - prevCubic.anchor1Y).abs() > distanceEpsilon) { @@ -95,7 +92,7 @@ class RoundedPolygon { /// Creates a copy of the given [RoundedPolygon]. RoundedPolygon.from(RoundedPolygon roundedPolygon) - : this._(roundedPolygon.features, roundedPolygon.center); + : this._(roundedPolygon.features, roundedPolygon.center); /// This function takes the vertices (either supplied or calculated, /// depending on the constructor called), plus [CornerRounding] parameters, @@ -146,18 +143,19 @@ class RoundedPolygon { if (vertices.length.isOdd) { throw ArgumentError('The vertices array should have even size.'); } - if (perVertexRounding != null && - perVertexRounding.length * 2 != vertices.length) { - throw ArgumentError('perVertexRounding list should be either null or ' - 'the same size as the number of vertices (vertices.size / 2).'); + if (perVertexRounding != null && perVertexRounding.length * 2 != vertices.length) { + throw ArgumentError( + 'perVertexRounding list should be either null or ' + 'the same size as the number of vertices (vertices.size / 2).', + ); } final corners = >[]; - final n = vertices.length ~/ 2; + final int n = vertices.length ~/ 2; final roundedCorners = <_RoundedCorner>[]; for (var i = 0; i < n; i++) { - final vtxRounding = perVertexRounding?[i] ?? rounding; - final prevIndex = ((i + n - 1) % n) * 2; - final nextIndex = ((i + 1) % n) * 2; + final CornerRounding vtxRounding = perVertexRounding?[i] ?? rounding; + final int prevIndex = ((i + n - 1) % n) * 2; + final int nextIndex = ((i + 1) % n) * 2; roundedCorners.add( _RoundedCorner( Point(vertices[prevIndex], vertices[prevIndex + 1]), @@ -175,16 +173,16 @@ class RoundedPolygon { // 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 cutAdjusts = List.generate(n, (ix) { - final expectedRoundCut = roundedCorners[ix].expectedRoundCut + - roundedCorners[(ix + 1) % n].expectedRoundCut; - final expectedCut = roundedCorners[ix].expectedCut + - roundedCorners[(ix + 1) % n].expectedCut; - final vtxX = vertices[ix * 2]; - final vtxY = vertices[ix * 2 + 1]; - final nextVtxX = vertices[((ix + 1) % n) * 2]; - final nextVtxY = vertices[((ix + 1) % n) * 2 + 1]; - final sideSize = distance(vtxX - nextVtxX, vtxY - nextVtxY); + final List<(num, num)> 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 double vtxX = vertices[ix * 2]; + final double vtxY = vertices[ix * 2 + 1]; + final double nextVtxX = vertices[((ix + 1) % n) * 2]; + final double nextVtxY = vertices[((ix + 1) % n) * 2 + 1]; + final double sideSize = distance(vtxX - nextVtxX, vtxY - nextVtxY); // Check expectedRoundCut first, and ensure we fulfill rounding needs // first for both corners before using space for smoothing. @@ -193,10 +191,7 @@ class RoundedPolygon { return (sideSize / expectedRoundCut, 0); } else if (expectedCut > sideSize) { // We can do full rounding, but not full smoothing. - return ( - 1, - (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut) - ); + return (1, (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)); } else { // There is enough room for rounding & smoothing. return (1, 1); @@ -210,17 +205,13 @@ class RoundedPolygon { final allowedCuts = List.filled(2, 0); for (var delta = 0; delta <= 1; delta++) { - final (roundCutRatio, cutRatio) = cutAdjusts[(i + n - 1 + delta) % n]; + final (num roundCutRatio, num cutRatio) = cutAdjusts[(i + n - 1 + delta) % n]; allowedCuts[delta] = roundedCorners[i].expectedRoundCut * roundCutRatio + - (roundedCorners[i].expectedCut - - roundedCorners[i].expectedRoundCut) * - cutRatio; + (roundedCorners[i].expectedCut - roundedCorners[i].expectedRoundCut) * cutRatio; } - corners.add( - roundedCorners[i].getCubics(allowedCuts[0], allowedCuts[1]), - ); + corners.add(roundedCorners[i].getCubics(allowedCuts[0], allowedCuts[1])); } // Finally, store the calculated cubics. This includes all of the rounded @@ -230,31 +221,23 @@ class RoundedPolygon { for (var i = 0; i < n; i++) { // Note that these indices are for pairs of values (points), they need to // be doubled to access the xy values in the vertices float array. - final prevVtxIndex = (i + n - 1) % n; - final nextVtxIndex = (i + 1) % n; + final int prevVtxIndex = (i + n - 1) % n; + final int nextVtxIndex = (i + 1) % n; final currVertex = Point(vertices[i * 2], vertices[i * 2 + 1]); - final prevVertex = Point( - vertices[prevVtxIndex * 2], - vertices[prevVtxIndex * 2 + 1], - ); - final nextVertex = Point( - vertices[nextVtxIndex * 2], - vertices[nextVtxIndex * 2 + 1], - ); - final cvx = convex(prevVertex, currVertex, nextVertex); + final prevVertex = Point(vertices[prevVtxIndex * 2], vertices[prevVtxIndex * 2 + 1]); + final nextVertex = Point(vertices[nextVtxIndex * 2], vertices[nextVtxIndex * 2 + 1]); + final bool cvx = convex(prevVertex, currVertex, nextVertex); tempFeatures ..add(CornerFeature(corners[i], convex: cvx)) ..add( - EdgeFeature( - [ - Cubic.straightLine( - corners[i].last.anchor1X, - corners[i].last.anchor1Y, - corners[(i + 1) % n].first.anchor0X, - corners[(i + 1) % n].first.anchor0Y, - ), - ], - ), + EdgeFeature([ + Cubic.straightLine( + corners[i].last.anchor1X, + corners[i].last.anchor1Y, + corners[(i + 1) % n].first.anchor0X, + corners[(i + 1) % n].first.anchor0Y, + ), + ]), ); } @@ -262,7 +245,7 @@ class RoundedPolygon { final double cY; if (centerX == double.minPositive || centerY == double.minPositive) { - final center = calculateCenter(vertices); + final Point center = calculateCenter(vertices); cX = center.x; cY = center.y; } else { @@ -312,17 +295,17 @@ class RoundedPolygon { final vertices = []; for (final feature in features) { - for (final cubic in feature.cubics) { + for (final Cubic cubic in feature.cubics) { vertices ..add(cubic.anchor0X) ..add(cubic.anchor0Y); } } - final center = calculateCenter(vertices); + final Point center = calculateCenter(vertices); - final cX = centerX.isNaN ? center.x : centerX; - final cY = centerY.isNaN ? center.y : centerY; + final double cX = centerX.isNaN ? center.x : centerX; + final double cY = centerY.isNaN ? center.y : centerY; return RoundedPolygon._(features, Point(cX, cY)); } @@ -357,10 +340,10 @@ class RoundedPolygon { } // Half of the angle between two adjacent vertices on the polygon. - final theta = math.pi / numVertices; + final double theta = math.pi / numVertices; // Radius of the underlying RoundedPolygon object given the desired radius // of the circle. - final polygonRadius = radius / math.cos(theta); + final double polygonRadius = radius / math.cos(theta); return RoundedPolygon.fromVerticesNum( numVertices, radius: polygonRadius, @@ -410,10 +393,10 @@ class RoundedPolygon { double centerX = 0, double centerY = 0, }) { - final left = centerX - width / 2; - final top = centerY - height / 2; - final right = centerX + width / 2; - final bottom = centerY + height / 2; + final double left = centerX - width / 2; + final double top = centerY - height / 2; + final double right = centerX + width / 2; + final double bottom = centerY + height / 2; return RoundedPolygon.fromVertices( [right, bottom, left, bottom, left, top, right, top], @@ -489,23 +472,14 @@ class RoundedPolygon { // parameters. if (pvRounding == null && innerRounding != null) { pvRounding = [ - for (var i = 0; i < numVerticesPerRadius; i++) ...[ - rounding, - innerRounding, - ], + 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, - centerX, - centerY, - ), + _starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, centerX, centerY), rounding: rounding, perVertexRounding: pvRounding, centerX: centerX, @@ -543,8 +517,8 @@ class RoundedPolygon { throw ArgumentError('Pill shapes must have positive width and height.'); } - final wHalf = width / 2; - final hHalf = height / 2; + final double wHalf = width / 2; + final double hHalf = height / 2; return RoundedPolygon.fromVertices( [ @@ -557,10 +531,7 @@ class RoundedPolygon { wHalf + centerX, -hHalf + centerY, ], - rounding: CornerRounding( - radius: math.min(wHalf, hHalf), - smoothing: smoothing, - ), + rounding: CornerRounding(radius: math.min(wHalf, hHalf), smoothing: smoothing), centerX: centerX, centerY: centerY, ); @@ -674,10 +645,7 @@ class RoundedPolygon { // parameters. if (pvRounding == null && innerRounding != null) { pvRounding = [ - for (var i = 0; i < numVerticesPerRadius; i++) ...[ - rounding, - innerRounding, - ], + for (var i = 0; i < numVerticesPerRadius; i++) ...[rounding, innerRounding], ]; } @@ -721,8 +689,8 @@ class RoundedPolygon { List? firstFeatureSplitEnd; if (features.isNotEmpty && features[0].cubics.length == 3) { - final centerCubic = features[0].cubics[1]; - final (start, end) = centerCubic.split(0.5); + final Cubic centerCubic = features[0].cubics[1]; + final (Cubic start, Cubic end) = centerCubic.split(0.5); firstFeatureSplitStart = [features[0].cubics[0], start]; firstFeatureSplitEnd = [end, features[0].cubics[2]]; } @@ -747,10 +715,12 @@ class RoundedPolygon { for (var j = 0; j < featureCubics.length; j++) { // Skip zero-length curves; they add nothing and can trigger rendering // artifacts. - final cubic = featureCubics[j]; + final Cubic cubic = featureCubics[j]; if (!cubic.zeroLength()) { - if (lastCubic != null) cubics.add(lastCubic); + if (lastCubic != null) { + cubics.add(lastCubic); + } lastCubic = cubic; firstCubic ??= cubic; } else { @@ -759,7 +729,7 @@ class RoundedPolygon { // 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 points = lastCubic.points.toList(); + final List points = lastCubic.points.toList(); points[6] = cubic.anchor1X; points[7] = cubic.anchor1Y; lastCubic = Cubic._raw(points); @@ -783,18 +753,7 @@ class RoundedPolygon { ); } else { // Empty / 0-sized polygon. - cubics.add( - Cubic( - centerX, - centerY, - centerX, - centerY, - centerX, - centerY, - centerX, - centerY, - ), - ); + cubics.add(Cubic(centerX, centerY, centerX, centerY, centerX, centerY, centerX, centerY)); } } @@ -805,31 +764,26 @@ class RoundedPolygon { /// /// [f] is the [PointTransformer] used to transform this [RoundedPolygon]. RoundedPolygon transformed(PointTransformer f) { - final center = this.center.transformed(f); - return RoundedPolygon._( - [ - for (var i = 0; i < features.length; i++) features[i].transformed(f), - ], - center, - ); + final Point center = this.center.transformed(f); + return RoundedPolygon._([ + for (var i = 0; i < features.length; i++) features[i].transformed(f), + ], center); } /// Creates a new RoundedPolygon, moving and resizing this one, so it's /// completely inside the (0, 0) -> (1, 1) square, centered if there extra /// space in one direction. RoundedPolygon normalized() { - final bounds = calculateBounds(); - final width = bounds[2] - bounds[0]; - final height = bounds[3] - bounds[1]; - final side = math.max(width, height); + final List bounds = calculateBounds(); + final double width = bounds[2] - bounds[0]; + final double height = bounds[3] - bounds[1]; + final double side = math.max(width, height); // Center the shape if bounds are not a square. - final offsetX = (side - width) / 2 - bounds[0]; /* left */ - final offsetY = (side - height) / 2 - bounds[1]; /* top */ + final double offsetX = (side - width) / 2 - bounds[0]; /* left */ + final double offsetY = (side - height) / 2 - bounds[1]; /* top */ - return transformed( - (x, y) => ((x + offsetX) / side, (y + offsetY) / side), - ); + return transformed((x, y) => ((x + offsetX) / side, (y + offsetY) / side)); } /// Like [calculateBounds], this function calculates the axis-aligned bounds @@ -855,17 +809,20 @@ class RoundedPolygon { var maxDistSquared = 0.0; for (var i = 0; i < cubics.length; i++) { - final cubic = cubics[i]; - final anchorDistance = - distanceSquared(cubic.anchor0X - centerX, cubic.anchor0Y - centerY); - final middlePoint = cubic.pointOnCurve(0.5); - final middleDistance = - distanceSquared(middlePoint.x - centerX, middlePoint.y - centerY); - maxDistSquared = - math.max(maxDistSquared, math.max(anchorDistance, middleDistance)); + final Cubic cubic = cubics[i]; + final double anchorDistance = distanceSquared( + cubic.anchor0X - centerX, + cubic.anchor0Y - centerY, + ); + final Point middlePoint = cubic.pointOnCurve(0.5); + final double middleDistance = distanceSquared( + middlePoint.x - centerX, + middlePoint.y - centerY, + ); + maxDistSquared = math.max(maxDistSquared, math.max(anchorDistance, middleDistance)); } - final distance = math.sqrt(maxDistSquared); + final double distance = math.sqrt(maxDistSquared); bounds[0] = centerX - distance; bounds[1] = centerY - distance; @@ -887,20 +844,17 @@ class RoundedPolygon { /// Returns the axis-aligned bounding box for this object, where the /// rectangles left, top, right, and bottom values will be stored in entries /// 0, 1, 2, and 3, in that order. - List calculateBounds({ - List? bounds, - bool approximate = true, - }) { + List calculateBounds({List? bounds, bool approximate = true}) { bounds ??= List.filled(4, 0); if (bounds.length < 4) { throw ArgumentError('Required bounds size of 4.'); } - var minX = double.maxFinite; - var minY = double.maxFinite; - var maxX = double.minPositive; - var maxY = double.minPositive; + double minX = double.maxFinite; + double minY = double.maxFinite; + double maxX = double.minPositive; + double maxY = double.minPositive; for (var i = 0; i < cubics.length; i++) { cubics[i].calculateBounds(bounds, approximate: approximate); @@ -970,10 +924,7 @@ Point calculateCenter(List vertices) { cumulativeX += vertices[index++]; cumulativeY += vertices[index++]; } - return Point( - cumulativeX / (vertices.length / 2), - cumulativeY / (vertices.length / 2), - ); + return Point(cumulativeX / (vertices.length / 2), cumulativeY / (vertices.length / 2)); } /// Private utility class that holds the information about each corner in a @@ -1007,16 +958,11 @@ Point calculateCenter(List vertices) { /// [rounding] the optional parameters specifying how this corner should be /// rounded. class _RoundedCorner { - _RoundedCorner( - this.p0, - this.p1, - this.p2, - this.rounding, - ) { - final v01 = p0 - p1; - final v21 = p2 - p1; - final d01 = v01.getDistance(); - final d21 = v21.getDistance(); + _RoundedCorner(this.p0, this.p1, this.p2, this.rounding) { + final Point v01 = p0 - p1; + final Point v21 = p2 - p1; + final double d01 = v01.getDistance(); + final double d21 = v21.getDistance(); if (d01 > 0 && d21 > 0) { d1 = v01 / d01; @@ -1036,8 +982,7 @@ class _RoundedCorner { // 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; + 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; @@ -1084,7 +1029,7 @@ class _RoundedCorner { 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 allowedCut = math.min(allowedCut0, allowedCut1); + final double allowedCut = math.min(allowedCut0, allowedCut1); // Nothing to do, just use lines, or a point if (expectedRoundCut < distanceEpsilon || @@ -1095,24 +1040,22 @@ class _RoundedCorner { } // How much of the cut is required for the rounding part. - final actualRoundCut = math.min(allowedCut, expectedRoundCut); + 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 actualSmoothing0 = _calculateActualSmoothingValue(allowedCut0); - final actualSmoothing1 = _calculateActualSmoothingValue(allowedCut1); + final double actualSmoothing0 = _calculateActualSmoothingValue(allowedCut0); + final double actualSmoothing1 = _calculateActualSmoothingValue(allowedCut1); // Scale the radius if needed - final actualR = cornerRadius * actualRoundCut / expectedRoundCut; + final double actualR = cornerRadius * actualRoundCut / expectedRoundCut; // Distance from the corner (p1) to the center - final centerDistance = math.sqrt( - square(actualR) + square(actualRoundCut), - ); + final double centerDistance = math.sqrt(square(actualR) + square(actualRoundCut)); // Center of the arc we will use for rounding center = p1 + ((d1 + d2) / 2).getDirection() * centerDistance; - final circleIntersection0 = p1 + d1 * actualRoundCut; - final circleIntersection2 = p1 + d2 * actualRoundCut; - final flanking0 = _computeFlankingCurve( + final Point circleIntersection0 = p1 + d1 * actualRoundCut; + final Point circleIntersection2 = p1 + d2 * actualRoundCut; + final Cubic flanking0 = _computeFlankingCurve( actualRoundCut, actualSmoothing0, p1, @@ -1122,7 +1065,7 @@ class _RoundedCorner { center, actualR, ); - final flanking2 = _computeFlankingCurve( + final Cubic flanking2 = _computeFlankingCurve( actualRoundCut, actualSmoothing1, p1, @@ -1154,9 +1097,7 @@ class _RoundedCorner { if (allowedCut > expectedCut) { return smoothing; } else if (allowedCut > expectedRoundCut) { - return smoothing * - (allowedCut - expectedRoundCut) / - (expectedCut - expectedRoundCut); + return smoothing * (allowedCut - expectedRoundCut) / (expectedCut - expectedRoundCut); } else { return 0; } @@ -1201,39 +1142,33 @@ class _RoundedCorner { double actualR, ) { // sideStart is the anchor, 'anchor' is actual control point - final sideDirection = (sideStart - corner).getDirection(); - final curveStart = - corner + sideDirection * actualRoundCut * (1 + actualSmoothingValues); + final Point sideDirection = (sideStart - corner).getDirection(); + 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. - // TODO: revisit this, it can be problematic as it approaches 180 degrees - final p = interpolate( + final Point p = interpolate( circleSegmentIntersection, (circleSegmentIntersection + otherCircleSegmentIntersection) / 2, actualSmoothingValues, ); // The flanking curve ends on the circle - final curveEnd = circleCenter + - directionVector(p.x - circleCenter.x, p.y - circleCenter.y) * actualR; + final Point curveEnd = + circleCenter + directionVector(p.x - circleCenter.x, p.y - circleCenter.y) * 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 circleTangent = (curveEnd - circleCenter).rotate90(); - final anchorEnd = _lineIntersection( - sideStart, - sideDirection, - curveEnd, - circleTangent, - ) ?? + 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 anchorStart = (curveStart + anchorEnd * 2) / 3; + final Point anchorStart = (curveStart + anchorEnd * 2) / 3; return Cubic.fromPoints(curveStart, anchorStart, anchorEnd, curveEnd); } @@ -1241,14 +1176,14 @@ class _RoundedCorner { /// Returns the intersection point of the two lines d0->d1 and p0->p1, or /// null if the lines do not intersect. Point? _lineIntersection(Point p0, Point d0, Point p1, Point d1) { - final rotatedD1 = d1.rotate90(); - final den = d0.dotProduct(rotatedD1); + final Point rotatedD1 = d1.rotate90(); + final double den = d0.dotProduct(rotatedD1); if (den.abs() < distanceEpsilon) { return null; } - final num = (p1 - p0).dotProduct(rotatedD1); + 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 @@ -1256,26 +1191,18 @@ class _RoundedCorner { return null; } - final k = num / den; + final double k = num / den; return p0 + d0 * k; } } -List _verticesFromNumVerts( - int numVertices, - double radius, - double centerX, - double centerY, -) { +List _verticesFromNumVerts(int numVertices, double radius, double centerX, double centerY) { final result = List.filled(numVertices * 2, 0); var arrayIndex = 0; for (var i = 0; i < numVertices; i++) { - final vertex = radialToCartesian( - radius, - math.pi / numVertices * 2 * i, - ) + - Point(centerX, centerY); + final Point vertex = + radialToCartesian(radius, math.pi / numVertices * 2 * i) + Point(centerX, centerY); result[arrayIndex++] = vertex.x; result[arrayIndex++] = vertex.y; @@ -1303,22 +1230,21 @@ List _pillStarVerticesFromNumVerts( // 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 endcapRadius = math.min(width, height); - final vSegLen = (height - width).coerceAtLeast(0); - final hSegLen = (width - height).coerceAtLeast(0); - final vSegHalf = vSegLen / 2; - final hSegHalf = hSegLen / 2; + final double endcapRadius = math.min(width, height); + final double vSegLen = (height - width).coerceAtLeast(0); + final double hSegLen = (width - height).coerceAtLeast(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 circlePerimeter = - twoPi * endcapRadius * lerp(innerRadius, 1, vertexSpacing); + final double circlePerimeter = twoPi * 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 perimeter = 2 * hSegLen + 2 * vSegLen + circlePerimeter; + 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 @@ -1339,7 +1265,7 @@ List _pillStarVerticesFromNumVerts( // "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 tPerVertex = perimeter / (2 * numVerticesPerRadius); + 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. @@ -1350,11 +1276,11 @@ List _pillStarVerticesFromNumVerts( // secStart/End are used to determine how far along a given vertex is in the // section in which it lands. var secStart = 0.0; - var secEnd = sections[1]; + 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. - var t = startLocation * perimeter; + double t = startLocation * perimeter; // The list of vertices to be returned. final result = List.filled(numVerticesPerRadius * 4, 0); var arrayIndex = 0; @@ -1368,8 +1294,10 @@ List _pillStarVerticesFromNumVerts( 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 boundedT = t % perimeter; - if (boundedT < secStart) currSecIndex = 0; + 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]; @@ -1377,8 +1305,8 @@ List _pillStarVerticesFromNumVerts( } // find t in section and its proportion of that section's total length - final tInSection = boundedT - secStart; - final tProportion = tInSection / (secEnd - secStart); + 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 @@ -1386,26 +1314,16 @@ List _pillStarVerticesFromNumVerts( // 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 currRadius = inner ? (endcapRadius * innerRadius) : endcapRadius; - final vertex = switch (currSecIndex) { + 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, + 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, + 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, + 7 => radialToCartesian(currRadius, math.pi * 1.5 + (tProportion * math.pi / 2)) + rectTR, // 8 _ => Point(currRadius, -vSegHalf + tProportion * vSegHalf), }; @@ -1429,16 +1347,10 @@ List _starVerticesFromNumVerts( var arrayIndex = 0; for (var i = 0; i < numVerticesPerRadius; i++) { - var vertex = radialToCartesian( - radius, - math.pi / numVerticesPerRadius * 2 * i, - ); + Point vertex = radialToCartesian(radius, math.pi / numVerticesPerRadius * 2 * i); result[arrayIndex++] = vertex.x + centerX; result[arrayIndex++] = vertex.y + centerY; - vertex = radialToCartesian( - innerRadius, - math.pi / numVerticesPerRadius * (2 * i + 1), - ); + vertex = radialToCartesian(innerRadius, math.pi / numVerticesPerRadius * (2 * i + 1)); result[arrayIndex++] = vertex.x + centerX; result[arrayIndex++] = vertex.y + centerY; } diff --git a/packages/material_ui/lib/src/shapes/shapes/utils.dart b/packages/material_ui/lib/src/shapes/shapes/utils.dart index 76dce682ee14..63daa07c6021 100644 --- a/packages/material_ui/lib/src/shapes/shapes/utils.dart +++ b/packages/material_ui/lib/src/shapes/shapes/utils.dart @@ -13,7 +13,7 @@ const angleEpsilon = 1e-6; // that allow higher tolerances. const relaxedDistanceEpsilon = 5e-3; -const twoPi = math.pi * 2; +const double twoPi = math.pi * 2; double distance(double x, double y) => math.sqrt(x * x + y * y); @@ -21,7 +21,7 @@ double distanceSquared(double x, double y) => x * x + y * y; /// Returns unit vector representing the direction to this point from (0, 0). Point directionVector(double x, double y) { - final d = distance(x, y); + final double d = distance(x, y); assert(d > 0, 'Required distance greater than zero.'); return Point(x / d, y / d); } @@ -29,11 +29,7 @@ Point directionVector(double x, double y) { Point directionVectorFromAngle(double angleRadians) => Point(math.cos(angleRadians), math.sin(angleRadians)); -Point radialToCartesian( - double radius, - double angleRadians, [ - Point center = Point.zero, -]) => +Point radialToCartesian(double radius, double angleRadians, [Point center = Point.zero]) => directionVectorFromAngle(angleRadians) * radius + center; double square(double x) => x * x; @@ -63,10 +59,10 @@ bool collinearIsh( // 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 ab = Point(bX - aX, bY - aY).rotate90(); + final Point ab = Point(bX - aX, bY - aY).rotate90(); final ac = Point(cX - aX, cY - aY); - final dotProduct = ab.dotProduct(ac).abs(); - final relativeTolerance = tolerance * ab.getDistance() * ac.getDistance(); + final double dotProduct = ab.dotProduct(ac).abs(); + final double relativeTolerance = tolerance * ab.getDistance() * ac.getDistance(); return dotProduct < tolerance || dotProduct < relativeTolerance; } @@ -74,7 +70,6 @@ bool collinearIsh( /// Approximates whether corner at this vertex is concave or convex, based on /// the relationship of the prev->curr/curr->next vectors. bool convex(Point previous, Point current, Point next) { - // TODO: b/369320447 - This is a fast, but not reliable calculation. return (current - previous).clockwise(next - current); } @@ -85,18 +80,13 @@ bool convex(Point previous, Point current, Point next) { // NTS: Does it make sense to split the function f in 2, one to generate a // candidate, of a custom type T (i.e. (Float) -> T), and one to evaluate it // ( (T) -> Float )? -double findMinimum( - double v0, - double v1, - double Function(double) f, { - double tolerance = 1e-3, -}) { +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 c1 = (2 * a + b) / 3; - final c2 = (2 * b + a) / 3; + final double c1 = (2 * a + b) / 3; + final double c2 = (2 * b + a) / 3; if (f(c1) < f(c2)) { b = c2; @@ -129,13 +119,15 @@ int binarySearchBy( ]) { end = RangeError.checkValidRange(start, end, sortedList.length); var min = start; - var max = end; + int max = end; final key = value; while (min < max) { - final mid = min + ((max - min) >> 1); - final element = sortedList[mid]; - final comp = compare(keyOf(element), key); - if (comp == 0) return mid; + 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 { @@ -146,16 +138,19 @@ int binarySearchBy( } extension DoubleCoerceExtensions on double { - double coerceAtLeast(double minimumValue) => - this < minimumValue ? minimumValue : this; + double coerceAtLeast(double minimumValue) => this < minimumValue ? minimumValue : this; double coerceAtMost(double maximumValue) { return this > maximumValue ? maximumValue : this; } double coerceIn(double minimumValue, double maximumValue) { - if (this < minimumValue) return minimumValue; - if (this > maximumValue) return maximumValue; + if (this < minimumValue) { + return minimumValue; + } + if (this > maximumValue) { + return maximumValue; + } return this; } } @@ -163,7 +158,7 @@ extension DoubleCoerceExtensions on double { extension Matrix4PointTransformer on Matrix4 { PointTransformer asPointTransformer() { return (x, y) { - final vector = transform3(Vector3(x, y, 0)); + final Vector3 vector = transform3(Vector3(x, y, 0)); return (vector.x, vector.y); }; } @@ -191,12 +186,7 @@ extension RoundedPolygonToPathExtension on RoundedPolygon { /// progress indicator advances). /// /// [closePath] is whether or not to close the created [Path]. - Path toPath({ - int startAngle = 0, - bool repeatPath = false, - bool closePath = true, - Path? path, - }) { + Path toPath({int startAngle = 0, bool repeatPath = false, bool closePath = true, Path? path}) { return pathFromCubics( path: path ?? Path(), startAngle: startAngle, @@ -340,17 +330,13 @@ Path pathFromCubics({ } if (startAngle != 0 && firstCubic != null) { - final angleToFirstCubic = math.atan2( + final double angleToFirstCubic = math.atan2( cubics[0].anchor0Y - rotationPivotY, cubics[0].anchor0X - rotationPivotX, ); // Rotate the Path to to start from the given angle. path = path.transform( - (Matrix4.identity() - ..rotateZ( - -angleToFirstCubic + (startAngle * math.pi / 180), - )) - .storage, + (Matrix4.identity()..rotateZ(-angleToFirstCubic + (startAngle * math.pi / 180))).storage, ); } diff --git a/packages/material_ui/test/shapes/corner_rounding_test.dart b/packages/material_ui/test/shapes/corner_rounding_test.dart index 9e780a067c09..bb97b3fe8324 100644 --- a/packages/material_ui/test/shapes/corner_rounding_test.dart +++ b/packages/material_ui/test/shapes/corner_rounding_test.dart @@ -10,7 +10,7 @@ void main() { expect(defaultCorner.radius, 0); expect(defaultCorner.smoothing, 0); - const unrounded = CornerRounding.unrounded; + const CornerRounding unrounded = CornerRounding.unrounded; expect(unrounded.radius, 0); expect(unrounded.smoothing, 0); diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index 9be0544fff15..f83dce9a591a 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -9,7 +9,7 @@ void main() { group('$Cubic', () { // These points create a roughly circular arc in the upper-right quadrant // around (0,0). - const zero = Point.zero; + const Point zero = Point.zero; const p0 = Point(1, 0); const p1 = Point(1, 0.5); const p2 = Point(0.5, 1); @@ -24,61 +24,30 @@ void main() { }); test('circularArc', () { - final arcCubic = Cubic.circularArc( - zero.x, - zero.y, - p0.x, - p0.y, - p3.x, - p3.y, - ); + final arcCubic = Cubic.circularArc(zero.x, zero.y, p0.x, p0.y, p3.x, p3.y); expect(p0, Point(arcCubic.anchor0X, arcCubic.anchor0Y)); expect(p3, Point(arcCubic.anchor1X, arcCubic.anchor1Y)); }); test('div', () { - var divCubic = cubic / 1; + Cubic divCubic = cubic / 1; expectCubicsEqualish(cubic, divCubic); divCubic = cubic / 1; expectCubicsEqualish(cubic, divCubic); divCubic = cubic / 2; - expectPointsEqualish( - p0 / 2, - Point(divCubic.anchor0X, divCubic.anchor0Y), - ); - expectPointsEqualish( - p1 / 2, - Point(divCubic.control0X, divCubic.control0Y), - ); - expectPointsEqualish( - p2 / 2, - Point(divCubic.control1X, divCubic.control1Y), - ); - expectPointsEqualish( - p3 / 2, - Point(divCubic.anchor1X, divCubic.anchor1Y), - ); + expectPointsEqualish(p0 / 2, Point(divCubic.anchor0X, divCubic.anchor0Y)); + expectPointsEqualish(p1 / 2, Point(divCubic.control0X, divCubic.control0Y)); + expectPointsEqualish(p2 / 2, Point(divCubic.control1X, divCubic.control1Y)); + expectPointsEqualish(p3 / 2, Point(divCubic.anchor1X, divCubic.anchor1Y)); divCubic = cubic / 2; - expectPointsEqualish( - p0 / 2, - Point(divCubic.anchor0X, divCubic.anchor0Y), - ); - expectPointsEqualish( - p1 / 2, - Point(divCubic.control0X, divCubic.control0Y), - ); - expectPointsEqualish( - p2 / 2, - Point(divCubic.control1X, divCubic.control1Y), - ); - expectPointsEqualish( - p3 / 2, - Point(divCubic.anchor1X, divCubic.anchor1Y), - ); + expectPointsEqualish(p0 / 2, Point(divCubic.anchor0X, divCubic.anchor0Y)); + expectPointsEqualish(p1 / 2, Point(divCubic.control0X, divCubic.control0Y)); + expectPointsEqualish(p2 / 2, Point(divCubic.control1X, divCubic.control1Y)); + expectPointsEqualish(p3 / 2, Point(divCubic.anchor1X, divCubic.anchor1Y)); }); test('times', () { - var timesCubic = cubic * 1; + Cubic timesCubic = cubic * 1; expect(p0, Point(timesCubic.anchor0X, timesCubic.anchor0Y)); expect(p1, Point(timesCubic.control0X, timesCubic.control0Y)); expect(p2, Point(timesCubic.control1X, timesCubic.control1Y)); @@ -89,44 +58,20 @@ void main() { expect(p2, Point(timesCubic.control1X, timesCubic.control1Y)); expect(p3, Point(timesCubic.anchor1X, timesCubic.anchor1Y)); timesCubic = cubic * 2; - expectPointsEqualish( - p0 * 2, - Point(timesCubic.anchor0X, timesCubic.anchor0Y), - ); - expectPointsEqualish( - p1 * 2, - Point(timesCubic.control0X, timesCubic.control0Y), - ); - expectPointsEqualish( - p2 * 2, - Point(timesCubic.control1X, timesCubic.control1Y), - ); - expectPointsEqualish( - p3 * 2, - Point(timesCubic.anchor1X, timesCubic.anchor1Y), - ); + expectPointsEqualish(p0 * 2, Point(timesCubic.anchor0X, timesCubic.anchor0Y)); + expectPointsEqualish(p1 * 2, Point(timesCubic.control0X, timesCubic.control0Y)); + expectPointsEqualish(p2 * 2, Point(timesCubic.control1X, timesCubic.control1Y)); + expectPointsEqualish(p3 * 2, Point(timesCubic.anchor1X, timesCubic.anchor1Y)); timesCubic = cubic * 2; - expectPointsEqualish( - p0 * 2, - Point(timesCubic.anchor0X, timesCubic.anchor0Y), - ); - expectPointsEqualish( - p1 * 2, - Point(timesCubic.control0X, timesCubic.control0Y), - ); - expectPointsEqualish( - p2 * 2, - Point(timesCubic.control1X, timesCubic.control1Y), - ); - expectPointsEqualish( - p3 * 2, - Point(timesCubic.anchor1X, timesCubic.anchor1Y), - ); + expectPointsEqualish(p0 * 2, Point(timesCubic.anchor0X, timesCubic.anchor0Y)); + expectPointsEqualish(p1 * 2, Point(timesCubic.control0X, timesCubic.control0Y)); + expectPointsEqualish(p2 * 2, Point(timesCubic.control1X, timesCubic.control1Y)); + expectPointsEqualish(p3 * 2, Point(timesCubic.anchor1X, timesCubic.anchor1Y)); }); test('plus', () { - final offsetCubic = cubic * 2; - final plusCubic = cubic + offsetCubic; + final Cubic offsetCubic = cubic * 2; + final Cubic plusCubic = cubic + offsetCubic; expectPointsEqualish( p0 + Point(offsetCubic.anchor0X, offsetCubic.anchor0Y), Point(plusCubic.anchor0X, plusCubic.anchor0Y), @@ -146,7 +91,7 @@ void main() { }); test('reverse', () { - final reverseCubic = cubic.reverse(); + final Cubic reverseCubic = cubic.reverse(); expect(p3, Point(reverseCubic.anchor0X, reverseCubic.anchor0Y)); expect(p2, Point(reverseCubic.control0X, reverseCubic.control0Y)); expect(p1, Point(reverseCubic.control1X, reverseCubic.control1Y)); @@ -154,10 +99,10 @@ void main() { }); void expectBetween(Point end0, Point end1, Point actual) { - final minX = math.min(end0.x, end1.x); - final minY = math.min(end0.y, end1.y); - final maxX = math.max(end0.x, end1.x); - final maxY = math.max(end0.y, end1.y); + 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); @@ -173,15 +118,9 @@ void main() { }); test('split', () { - final (split0, split1) = cubic.split(0.5); - expect( - Point(cubic.anchor0X, cubic.anchor0Y), - Point(split0.anchor0X, split0.anchor0Y), - ); - expect( - Point(cubic.anchor1X, cubic.anchor1Y), - Point(split1.anchor1X, split1.anchor1Y), - ); + final (Cubic split0, Cubic split1) = cubic.split(0.5); + expect(Point(cubic.anchor0X, cubic.anchor0Y), Point(split0.anchor0X, split0.anchor0Y)); + expect(Point(cubic.anchor1X, cubic.anchor1Y), Point(split1.anchor1X, split1.anchor1Y)); expectBetween( Point(cubic.anchor0X, cubic.anchor0Y), Point(cubic.anchor1X, cubic.anchor1Y), @@ -195,7 +134,7 @@ void main() { }); test('pointOnCurve', () { - var halfway = cubic.pointOnCurve(0.5); + Point halfway = cubic.pointOnCurve(0.5); expectBetween( Point(cubic.anchor0X, cubic.anchor0Y), Point(cubic.anchor1X, cubic.anchor1Y), @@ -203,16 +142,13 @@ void main() { ); final straightLineCubic = Cubic.straightLine(p0.x, p0.y, p3.x, p3.y); halfway = straightLineCubic.pointOnCurve(0.5); - final computedHalfway = Point( - p0.x + 0.5 * (p3.x - p0.x), - p0.y + 0.5 * (p3.y - p0.y), - ); + final computedHalfway = Point(p0.x + 0.5 * (p3.x - p0.x), p0.y + 0.5 * (p3.y - p0.y)); expectPointsEqualish(computedHalfway, halfway); }); test('transform', () { - var transform = identityTransform(); - var transformedCubic = cubic.transformed(transform); + PointTransformer transform = identityTransform(); + Cubic transformedCubic = cubic.transformed(transform); expectCubicsEqualish(cubic, transformedCubic); transform = scaleTransform(3, 3); diff --git a/packages/material_ui/test/shapes/feature_mapping_test.dart b/packages/material_ui/test/shapes/feature_mapping_test.dart index bd695f6065f9..514543960739 100644 --- a/packages/material_ui/test/shapes/feature_mapping_test.dart +++ b/packages/material_ui/test/shapes/feature_mapping_test.dart @@ -11,37 +11,35 @@ void main() { ); final triangle = RoundedPolygon.fromVerticesNum(3); final square = RoundedPolygon.fromVerticesNum(4); - final squareRotated = RoundedPolygon.fromVerticesNum(4).transformed( - pointRotator(45), - ); + final RoundedPolygon squareRotated = RoundedPolygon.fromVerticesNum( + 4, + ).transformed(pointRotator(45)); void verifyMapping( RoundedPolygon p1, RoundedPolygon p2, void Function(List) validator, ) { - final f1 = MeasuredPolygon.measurePolygon( + final List f1 = MeasuredPolygon.measurePolygon( const LengthMeasurer(), p1, ).features; - final f2 = MeasuredPolygon.measurePolygon( + final List f2 = MeasuredPolygon.measurePolygon( const LengthMeasurer(), p2, ).features; // Maps progress in p1 to progress in p2. - final map = doMapping(f1, f2); + 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 feature1 = f1.firstWhere((f) => f.progress == progress1); - final feature2 = f2.firstWhere((f) => f.progress == progress2); - distances.add( - featureDistSquared(feature1.feature, feature2.feature), - ); + 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)); @@ -49,101 +47,79 @@ void main() { } test('feature mapping triangles', () { - verifyMapping( - triangleWithRoundings, - triangle, - (distances) { - for (final d in distances) { - expect(d, lessThan(0.1)); - } - }, - ); + 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)); - }, - ); + 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)); - }, - ); + 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)); - }, - ); + 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 checkmark = RoundedPolygon.fromVertices( - const [ - 400, - -304, - 240, - -464, - 296, - -520, - 400, - -416, - 664, - -680, - 720, - -624, - 400, - -304, - ], - ).normalized(); + final RoundedPolygon checkmark = RoundedPolygon.fromVertices(const [ + 400, + -304, + 240, + -464, + 296, + -520, + 400, + -416, + 664, + -680, + 720, + -624, + 400, + -304, + ]).normalized(); - final verySunny = RoundedPolygon.star( + 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)); - }, - ); + 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 index a50bd745ad4f..6f24a36ee42f 100644 --- a/packages/material_ui/test/shapes/features_test.dart +++ b/packages/material_ui/test/shapes/features_test.dart @@ -17,18 +17,9 @@ void main() { final cubic1 = Cubic.straightLine(0, 0, 1, 1); final cubic2 = Cubic.straightLine(10, 10, 11, 11); - expect( - () => Feature.buildConvexCorner([cubic1, cubic2]), - throwsArgumentError, - ); - expect( - () => Feature.buildConcaveCorner([cubic1, cubic2]), - throwsArgumentError, - ); - expect( - () => Feature.buildIgnorableFeature([cubic1, cubic2]), - throwsArgumentError, - ); + expect(() => Feature.buildConvexCorner([cubic1, cubic2]), throwsArgumentError); + expect(() => Feature.buildConcaveCorner([cubic1, cubic2]), throwsArgumentError); + expect(() => Feature.buildIgnorableFeature([cubic1, cubic2]), throwsArgumentError); }); test('Builds concave corner', () { diff --git a/packages/material_ui/test/shapes/float_mapping_test.dart b/packages/material_ui/test/shapes/float_mapping_test.dart index 46a71127d3ab..6f6e0c9d2d14 100644 --- a/packages/material_ui/test/shapes/float_mapping_test.dart +++ b/packages/material_ui/test/shapes/float_mapping_test.dart @@ -5,13 +5,10 @@ import 'test_utils.dart'; void main() { group('FloatMapping', () { - void validateMapping( - DoubleMapper mapper, - double Function(double) expectedFunction, - ) { + void validateMapping(DoubleMapper mapper, double Function(double) expectedFunction) { for (var i = 0; i < 10000; i++) { - final source = i / 10000; - final target = expectedFunction(source); + final double source = i / 10000; + final double target = expectedFunction(source); expectEqualish(target, mapper.map(source)); expectEqualish(source, mapper.mapBack(target)); @@ -56,20 +53,17 @@ void main() { }); 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; - } - }, - ); + 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', () { diff --git a/packages/material_ui/test/shapes/morph_test.dart b/packages/material_ui/test/shapes/morph_test.dart index 31f519c8a8a8..ad00967fdb74 100644 --- a/packages/material_ui/test/shapes/morph_test.dart +++ b/packages/material_ui/test/shapes/morph_test.dart @@ -1,5 +1,6 @@ // ignore_for_file: cascade_invocations, document_ignores +import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter_test/flutter_test.dart'; @@ -21,8 +22,8 @@ void main() { // 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 p1Cubics = poly1.cubics; - final cubics11 = morph11.asCubics(0); + final List p1Cubics = poly1.cubics; + final List cubics11 = morph11.asCubics(0); expect(cubics11, isNotEmpty); // The structure of a morph and its component shapes may not match @@ -55,16 +56,16 @@ void main() { ..color = const ui.Color(0xFFFFFFFF), ); - final picture = recorder.endRecording(); + 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 imageA = await drawPathToImage(a, side); - final imageB = await drawPathToImage(b, side); + final ui.Image imageA = await drawPathToImage(a, side); + final ui.Image imageB = await drawPathToImage(b, side); - final bytesA = await imageA.toByteData(); - final bytesB = await imageB.toByteData(); + 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'); @@ -89,12 +90,10 @@ void main() { ..translate(scale / 2, scale / 2) ..scale(scale, scale); - final poly1Path = poly1.toPath().transform(matrix.storage); - final poly2Path = poly2.toPath().transform(matrix.storage); - final morph120Path = - morph12.toPath(progress: 0).transform(matrix.storage); - final morph121Path = - morph12.toPath(progress: 1).transform(matrix.storage); + final ui.Path poly1Path = poly1.toPath().transform(matrix.storage); + final ui.Path poly2Path = poly2.toPath().transform(matrix.storage); + final ui.Path morph120Path = morph12.toPath(progress: 0).transform(matrix.storage); + final ui.Path morph121Path = morph12.toPath(progress: 1).transform(matrix.storage); await comparePathsVisually(poly1Path, morph120Path, radius * 2); await comparePathsVisually(poly2Path, morph121Path, radius * 2); diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart index 681d38925a9f..abc991d48853 100644 --- a/packages/material_ui/test/shapes/polygon_measure_test.dart +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -15,37 +15,28 @@ void main() { RoundedPolygon polygon, [ void Function(MeasuredPolygon)? extraChecks, ]) { - final measuredPolygon = MeasuredPolygon.measurePolygon( - measurer, - polygon, - ); + final measuredPolygon = MeasuredPolygon.measurePolygon(measurer, polygon); expect(0, measuredPolygon.first.startOutlineProgress); expect(1, measuredPolygon.last.endOutlineProgress); for (var index = 0; index < measuredPolygon.length; index++) { - final measuredCubic = measuredPolygon[index]; + final MeasuredCubic measuredCubic = measuredPolygon[index]; if (index > 0) { - expect( - measuredPolygon[index - 1].endOutlineProgress, - measuredCubic.startOutlineProgress, - ); + expect(measuredPolygon[index - 1].endOutlineProgress, measuredCubic.startOutlineProgress); } - expect( - measuredCubic.endOutlineProgress >= - measuredCubic.startOutlineProgress, - isTrue, - ); + expect(measuredCubic.endOutlineProgress >= measuredCubic.startOutlineProgress, isTrue); } for (var index = 0; index < measuredPolygon.features.length; index++) { - final progressableFeature = measuredPolygon.features[index]; + final ProgressableFeature progressableFeature = measuredPolygon.features[index]; expect( progressableFeature.progress >= 0 && progressableFeature.progress < 1, isTrue, - reason: 'Feature #$index has invalid progress: ' + reason: + 'Feature #$index has invalid progress: ' '${progressableFeature.progress}', ); } @@ -53,21 +44,17 @@ void main() { extraChecks?.call(measuredPolygon); } - void regularPolygonMeasure( - int sides, [ - CornerRounding rounding = CornerRounding.unrounded, - ]) { - irregularPolygonMeasure( - RoundedPolygon.fromVerticesNum(sides, rounding: rounding), - (measuredPolygon) { - expect(sides, measuredPolygon.length); - - for (var index = 0; index < measuredPolygon.length; index++) { - final measuredCubic = measuredPolygon[index]; - expectEqualish(index / sides, measuredCubic.startOutlineProgress); - } - }, - ); + void regularPolygonMeasure(int sides, [CornerRounding rounding = CornerRounding.unrounded]) { + irregularPolygonMeasure(RoundedPolygon.fromVerticesNum(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) { @@ -75,11 +62,10 @@ void main() { expect(measuredPolygon.length, progresses.length); for (var index = 0; index < measuredPolygon.length; index++) { - final measuredCubic = measuredPolygon[index]; + final MeasuredCubic measuredCubic = measuredPolygon[index]; expectEqualish( progresses[index], - measuredCubic.endOutlineProgress - - measuredCubic.startOutlineProgress, + measuredCubic.endOutlineProgress - measuredCubic.startOutlineProgress, ); } }); @@ -107,28 +93,19 @@ void main() { test('measure slightly rounded hexagon', () { irregularPolygonMeasure( - RoundedPolygon.fromVerticesNum( - 6, - rounding: const CornerRounding(radius: 0.15), - ), + RoundedPolygon.fromVerticesNum(6, rounding: const CornerRounding(radius: 0.15)), ); }); test('measure medium rounded hexagon', () { irregularPolygonMeasure( - RoundedPolygon.fromVerticesNum( - 6, - rounding: const CornerRounding(radius: 0.5), - ), + RoundedPolygon.fromVerticesNum(6, rounding: const CornerRounding(radius: 0.5)), ); }); test('measure maximum rounded hexagon', () { irregularPolygonMeasure( - RoundedPolygon.fromVerticesNum( - 6, - rounding: const CornerRounding(radius: 1), - ), + RoundedPolygon.fromVerticesNum(6, rounding: const CornerRounding(radius: 1)), ); }); @@ -139,16 +116,13 @@ void main() { const vertices = 4; final polygon = RoundedPolygon.circle(numVertices: vertices); - final actualLength = polygon.cubics.fold( + final double actualLength = polygon.cubics.fold( 0, (sum, cubic) => sum + const LengthMeasurer().measureCubic(cubic), ); - const expectedLength = 2 * math.pi; + const double expectedLength = 2 * math.pi; - expect( - expectedLength, - moreOrLessEquals(actualLength, epsilon: 0.015 * expectedLength), - ); + expect(expectedLength, moreOrLessEquals(actualLength, epsilon: 0.015 * expectedLength)); }); test('measure irregular triangle angle', () { @@ -200,22 +174,19 @@ void main() { -unit, ]; - final diagonal = math.sqrt(unit * unit + unit * unit); - const horizontal = 2 * unit; - final total = 4 * diagonal + 2 * horizontal; + 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, - ], - ); + customPolygonMeasure(polygon, [ + diagonal / total, + horizontal / total, + diagonal / total, + diagonal / total, + horizontal / total, + diagonal / total, + ]); }); test('handles empty feature last', () { diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 2684fbfc25f9..5f8ffa569e09 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -26,8 +26,7 @@ void main() { max = max * 2; expectInBounds(doubleSquare.cubics, min, max); - final offsetSquare = - RoundedPolygon.fromVerticesNum(4, centerX: 1, centerY: 2); + final offsetSquare = RoundedPolygon.fromVerticesNum(4, centerX: 1, centerY: 2); min = const Point(0, 1); max = const Point(2, 3); expectInBounds(offsetSquare.cubics, min, max); @@ -56,10 +55,10 @@ void main() { expectInBounds(manualSquare.cubics, min, max); const offset = Point(1, 2); - final p0Offset = p0 + offset; - final p1Offset = p1 + offset; - final p2Offset = p2 + offset; - final p3Offset = p3 + offset; + 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.x, @@ -80,13 +79,13 @@ void main() { }); test('bounds', () { - var bounds = square.calculateBounds(); + List bounds = square.calculateBounds(); expectEqualish(-1, bounds[0]); // Left expectEqualish(-1, bounds[1]); // Top expectEqualish(1, bounds[2]); // Right expectEqualish(1, bounds[3]); // Bottom - var betterBounds = square.calculateBounds(approximate: false); + List betterBounds = square.calculateBounds(approximate: false); expectEqualish(-1, betterBounds[0]); // Left expectEqualish(-1, betterBounds[1]); // Top expectEqualish(1, betterBounds[2]); // Right @@ -106,7 +105,7 @@ void main() { ); bounds = pentagon.calculateBounds(); - final maxBounds = pentagon.calculateMaxBounds(); + final List maxBounds = pentagon.calculateMaxBounds(); expect(maxBounds[2] - maxBounds[0] > bounds[2] - bounds[0], isTrue); }); @@ -117,8 +116,8 @@ void main() { test('transform', () { // First, make sure the shape doesn't change when transformed by the // identity. - final squareCopy = square.transformed(identityTransform()); - final n = square.cubics.length; + final RoundedPolygon squareCopy = square.transformed(identityTransform()); + final int n = square.cubics.length; expect(n, squareCopy.cubics.length); for (var i = 0; i < n; i++) { @@ -128,38 +127,26 @@ void main() { // 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 squareCubics = square.cubics; - final translator = translateTransform(offset.x, offset.y); - final translatedSquareCubics = square.transformed(translator).cubics; + 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( Point(squareCubics[i].anchor0X, squareCubics[i].anchor0Y) + offset, - Point( - translatedSquareCubics[i].anchor0X, - translatedSquareCubics[i].anchor0Y, - ), + Point(translatedSquareCubics[i].anchor0X, translatedSquareCubics[i].anchor0Y), ); expectPointsEqualish( Point(squareCubics[i].control0X, squareCubics[i].control0Y) + offset, - Point( - translatedSquareCubics[i].control0X, - translatedSquareCubics[i].control0Y, - ), + Point(translatedSquareCubics[i].control0X, translatedSquareCubics[i].control0Y), ); expectPointsEqualish( Point(squareCubics[i].control1X, squareCubics[i].control1Y) + offset, - Point( - translatedSquareCubics[i].control1X, - translatedSquareCubics[i].control1Y, - ), + Point(translatedSquareCubics[i].control1X, translatedSquareCubics[i].control1Y), ); expectPointsEqualish( Point(squareCubics[i].anchor1X, squareCubics[i].anchor1Y) + offset, - Point( - translatedSquareCubics[i].anchor1X, - translatedSquareCubics[i].anchor1Y, - ), + Point(translatedSquareCubics[i].anchor1X, translatedSquareCubics[i].anchor1Y), ); } }); @@ -169,38 +156,32 @@ void main() { return original.where((c) => !c.zeroLength()).toList(); } - final squareFeatures = square.features; + 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. - var nonzeroCubics = nonZeroCubics( - squareFeatures.expand((f) => f.cubics).toList(), - ); + List nonzeroCubics = nonZeroCubics(squareFeatures.expand((f) => f.cubics).toList()); expectCubicListsEqualish(square.cubics, nonzeroCubics); // Same as the first polygon test, but with a copy of that polygon. final squareCopy = RoundedPolygon.from(square); - final squareCopyFeatures = squareCopy.features; - nonzeroCubics = nonZeroCubics( - squareCopyFeatures.expand((f) => f.cubics).toList(), - ); + final List squareCopyFeatures = squareCopy.features; + nonzeroCubics = nonZeroCubics(squareCopyFeatures.expand((f) => f.cubics).toList()); expectCubicListsEqualish(squareCopy.cubics, nonzeroCubics); }); test('transform keeps contiguous anchors equal', () { - final poly = RoundedPolygon.fromVerticesNum( - 4, - radius: 1, - rounding: const CornerRounding(radius: 7 / 15), - ).transformed( - (x, y) { - final point = - Point(x, y).rotate(45).scale(648, 648).translate(540, 1212); - return (point.x, point.y); - }, - ); + final RoundedPolygon poly = + RoundedPolygon.fromVerticesNum( + 4, + radius: 1, + 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. @@ -225,20 +206,16 @@ void main() { ); expect(poly.cubics.length, 1); - final stillEmpty = poly.transformed(scaleTransform(10, 20)); + final RoundedPolygon stillEmpty = poly.transformed(scaleTransform(10, 20)); expect(stillEmpty.cubics.length, 1); expect(stillEmpty.cubics.first.zeroLength(), isTrue); }); test('empty side', () { // Triangle with one point repeated. - final poly1 = RoundedPolygon.fromVertices( - const [0, 0, 1, 0, 1, 0, 0, 1], - ); + final poly1 = RoundedPolygon.fromVertices(const [0, 0, 1, 0, 1, 0, 0, 1]); // Triangle. - final poly2 = RoundedPolygon.fromVertices( - const [0, 0, 1, 0, 0, 1], - ); + final poly2 = RoundedPolygon.fromVertices(const [0, 0, 1, 0, 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 index 6e402adfb2e3..8ab71eab6cc9 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -11,10 +11,7 @@ void main() { final perVtxRounded = [rounding, rounding, rounding, rounding]; test('fromVerticesNum', () { - expect( - () => RoundedPolygon.fromVerticesNum(2), - throwsArgumentError, - ); + expect(() => RoundedPolygon.fromVerticesNum(2), throwsArgumentError); final square = RoundedPolygon.fromVerticesNum(4); var min = const Point(-1, -1); @@ -26,18 +23,12 @@ void main() { max *= 2; expectInBounds(doubleSquare.cubics, min, max); - final squareRounded = RoundedPolygon.fromVerticesNum( - 4, - rounding: rounding, - ); + final squareRounded = RoundedPolygon.fromVerticesNum(4, rounding: rounding); min = const Point(-1, -1); max = const Point(1, 1); expectInBounds(squareRounded.cubics, min, max); - final squarePVRounded = RoundedPolygon.fromVerticesNum( - 4, - perVertexRounding: perVtxRounded, - ); + final squarePVRounded = RoundedPolygon.fromVerticesNum(4, perVertexRounding: perVtxRounded); min = const Point(-1, -1); max = const Point(1, 1); expectInBounds(squarePVRounded.cubics, min, max); @@ -48,12 +39,9 @@ void main() { const p1 = Point(0, 1); const p2 = Point(-1, 0); const p3 = Point(0, -1); - final verts = [p0.x, p0.y, p1.x, p1.y, p2.x, p2.y, p3.x, p3.y]; + final List verts = [p0.x, p0.y, p1.x, p1.y, p2.x, p2.y, p3.x, p3.y]; - expect( - () => RoundedPolygon.fromVertices([p0.x, p0.y, p1.x, p1.y]), - throwsArgumentError, - ); + expect(() => RoundedPolygon.fromVertices([p0.x, p0.y, p1.x, p1.y]), throwsArgumentError); final manualSquare = RoundedPolygon.fromVertices(verts); var min = const Point(-1, -1); @@ -61,7 +49,7 @@ void main() { expectInBounds(manualSquare.cubics, min, max); const offset = Point(1, 2); - final offsetVerts = [ + final List offsetVerts = [ p0.x + offset.x, p0.y + offset.y, p1.x + offset.x, @@ -80,10 +68,7 @@ void main() { max = const Point(2, 3); expectInBounds(manualSquareOffset.cubics, min, max); - final manualSquareRounded = RoundedPolygon.fromVertices( - verts, - rounding: rounding, - ); + final manualSquareRounded = RoundedPolygon.fromVertices(verts, rounding: rounding); min = const Point(-1, -1); max = const Point(1, 1); expectInBounds(manualSquareRounded.cubics, min, max); @@ -99,10 +84,7 @@ void main() { group('fromFeatures', () { test('throws for too few features', () { - expect( - () => RoundedPolygon.fromFeatures(const []), - throwsArgumentError, - ); + expect(() => RoundedPolygon.fromFeatures(const []), throwsArgumentError); expect( () => RoundedPolygon.fromFeatures([ CornerFeature([Cubic.empty(0, 0)]), @@ -115,10 +97,7 @@ void main() { final cubic1 = Cubic.straightLine(0, 0, 1, 0); final cubic2 = Cubic.straightLine(10, 10, 20, 20); expect( - () => RoundedPolygon.fromFeatures([ - Feature.buildEdge(cubic1), - Feature.buildEdge(cubic2), - ]), + () => RoundedPolygon.fromFeatures([Feature.buildEdge(cubic1), Feature.buildEdge(cubic2)]), throwsArgumentError, ); }); @@ -180,9 +159,7 @@ void main() { }); test('computes center', () { - final polygon = RoundedPolygon.fromVertices( - const [0, 0, 1, 0, 0, 1, 1, 1], - ); + final polygon = RoundedPolygon.fromVertices(const [0, 0, 1, 0, 0, 1, 1, 1]); expect(0.5, polygon.centerX); expect(0.5, polygon.centerY); }); @@ -198,10 +175,10 @@ void main() { } test('rounding space usage', () { - const p0 = Point.zero; + const Point p0 = Point.zero; const p1 = Point(1, 0); const p2 = Point(0.5, 1); - final pvRounding = [ + final List pvRounding = [ const CornerRounding(radius: 1, smoothing: 0), const CornerRounding(radius: 1, smoothing: 1), CornerRounding.unrounded, @@ -214,11 +191,10 @@ void main() { // 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 lowerEdgeFeature = - polygon.features.firstWhere((f) => f is EdgeFeature); + final Feature lowerEdgeFeature = polygon.features.firstWhere((f) => f is EdgeFeature); expect(1, lowerEdgeFeature.cubics.length); - final lowerEdge = lowerEdgeFeature.cubics.first; + final Cubic lowerEdge = lowerEdgeFeature.cubics.first; expectEqualish(0.5, lowerEdge.anchor0X); expectEqualish(0, lowerEdge.anchor0Y); expectEqualish(0.5, lowerEdge.anchor1X); @@ -251,12 +227,12 @@ void main() { // Corner rounding parameter for vertex 3 (bottom left). CornerRounding rounding3 = const CornerRounding(radius: 0.5), }) { - const p0 = Point.zero; + const Point p0 = Point.zero; const p1 = Point(5, 0); const p2 = Point(5, 1); const p3 = Point(0, 1); - final pvRounding = [ + final List pvRounding = [ rounding0, CornerRounding.unrounded, CornerRounding.unrounded, @@ -267,8 +243,9 @@ void main() { perVertexRounding: pvRounding, ); - final [e01, _, _, e30] = - polygon.features.whereType().toList(); + 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); @@ -279,7 +256,7 @@ void main() { // 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 smooth = i / points; + final double smooth = i / points; doUnevenSmoothTest( rounding0: CornerRounding(radius: 0.4, smoothing: smooth), expectedV0SX: 0.4 * (1 + smooth), @@ -294,14 +271,13 @@ void main() { // 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 smooth = i / points; + final double smooth = i / points; - final smoothWantedV0 = 0.4 * smooth; + final double smoothWantedV0 = 0.4 * smooth; const smoothWantedV3 = 0.2; // There is 0.4 room for smoothing. - final factor = - (0.4 / (smoothWantedV0 + smoothWantedV3)).coerceAtMost(1); + final double factor = (0.4 / (smoothWantedV0 + smoothWantedV3)).coerceAtMost(1); doUnevenSmoothTest( rounding0: CornerRounding(radius: 0.4, smoothing: smooth), expectedV0SX: 0.4 * (1 + smooth), @@ -318,7 +294,7 @@ void main() { // 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 smooth = i / points; + final double smooth = i / points; doUnevenSmoothTest( rounding0: CornerRounding(radius: 0.4, smoothing: smooth), @@ -333,10 +309,10 @@ void main() { test('full size creation', () { const radius = 400.0; const innerRadiusFactor = 0.35; - const innerRadius = radius * innerRadiusFactor; + const double innerRadius = radius * innerRadiusFactor; const roundingFactor = 0.32; - final fullSizeShape = RoundedPolygon.star( + final RoundedPolygon fullSizeShape = RoundedPolygon.star( numVerticesPerRadius: 4, radius: radius, innerRadius: innerRadius, @@ -354,13 +330,13 @@ void main() { innerRounding: const CornerRounding(radius: roundingFactor), ); - final cubics = canonicalShape.cubics; - final cubics1 = fullSizeShape.cubics; + final List cubics = canonicalShape.cubics; + final List cubics1 = fullSizeShape.cubics; expect(cubics.length, cubics1.length); for (var i = 0; i < cubics.length; i++) { - final cubic = cubics[i]; - final cubic1 = cubics1[i]; + final Cubic cubic = cubics[i]; + final Cubic cubic1 = cubics1[i]; expectEqualish(cubic.anchor0X, cubic1.anchor0X); expectEqualish(cubic.anchor0Y, cubic1.anchor0Y); diff --git a/packages/material_ui/test/shapes/shapes_test.dart b/packages/material_ui/test/shapes/shapes_test.dart index 42ed66637b94..856d6ab0d34b 100644 --- a/packages/material_ui/test/shapes/shapes_test.dart +++ b/packages/material_ui/test/shapes/shapes_test.dart @@ -9,25 +9,20 @@ import 'test_utils.dart'; void main() { group('Shapes', () { - const zero = Point.zero; + const Point zero = Point.zero; const epsilon = 0.01; double distance(Point start, Point end) { - final vector = end - start; + 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, - ]) { + void expectPointOnRadii(Point point, double radius1, [double? radius2, Point center = zero]) { radius2 ??= radius1; - final dist = distance(center, point); + final double dist = distance(center, point); try { expect(radius1, moreOrLessEquals(dist, epsilon: epsilon)); } on TestFailure catch (_) { @@ -35,24 +30,9 @@ void main() { } } - void expectCubicOnRadii( - Cubic cubic, - double radius1, [ - double? radius2, - Point center = zero, - ]) { - expectPointOnRadii( - Point(cubic.anchor0X, cubic.anchor0Y), - radius1, - radius2, - center, - ); - expectPointOnRadii( - Point(cubic.anchor1X, cubic.anchor1Y), - radius1, - radius2, - center, - ); + void expectCubicOnRadii(Cubic cubic, double radius1, [double? radius2, Point center = zero]) { + expectPointOnRadii(Point(cubic.anchor0X, cubic.anchor0Y), radius1, radius2, center); + expectPointOnRadii(Point(cubic.anchor1X, cubic.anchor1Y), radius1, radius2, center); } // Tests points along the curve of the cubic by comparing the distance @@ -62,18 +42,14 @@ void main() { void expectCircularCubic(Cubic cubic, double radius, Point center) { var t = 0.0; while (t <= 1) { - final pointOnCurve = cubic.pointOnCurve(t); - final distanceToPoint = distance(center, pointOnCurve); + final Point pointOnCurve = cubic.pointOnCurve(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, - }) { + void expectCircleShape(List shape, {double radius = 1, Point center = zero}) { for (final cubic in shape) { expectCircularCubic(cubic, radius, center); } @@ -95,10 +71,7 @@ void main() { expectCircleShape(bigCircle.cubics, radius: 3); const center = Point(1, 2); - final offsetCircle = RoundedPolygon.circle( - centerX: center.x, - centerY: center.y, - ); + final offsetCircle = RoundedPolygon.circle(centerX: center.x, centerY: center.y); expectCircleShape(offsetCircle.cubics, center: center); }); @@ -106,11 +79,8 @@ void main() { // 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, - innerRadius: 0.5, - ); - var shape = star.cubics; + var star = RoundedPolygon.star(numVerticesPerRadius: 4, innerRadius: 0.5); + List shape = star.cubics; var radius = 1.0; var innerRadius = 0.5; @@ -132,11 +102,7 @@ void main() { radius = 4; innerRadius = 2; - star = RoundedPolygon.star( - numVerticesPerRadius: 4, - radius: radius, - innerRadius: innerRadius, - ); + star = RoundedPolygon.star(numVerticesPerRadius: 4, radius: radius, innerRadius: innerRadius); shape = star.cubics; for (final cubic in shape) { expectCubicOnRadii(cubic, radius, innerRadius); @@ -159,11 +125,7 @@ void main() { const min = Point(-1, -1); const max = Point(1, 1); - var star = RoundedPolygon.star( - numVerticesPerRadius: 4, - innerRadius: 0.5, - rounding: rounding, - ); + var star = RoundedPolygon.star(numVerticesPerRadius: 4, innerRadius: 0.5, rounding: rounding); expectInBounds(star.cubics, min, max); star = RoundedPolygon.star( diff --git a/packages/material_ui/test/shapes/test_utils.dart b/packages/material_ui/test/shapes/test_utils.dart index 4fa48198c040..c16251960850 100644 --- a/packages/material_ui/test/shapes/test_utils.dart +++ b/packages/material_ui/test/shapes/test_utils.dart @@ -15,37 +15,17 @@ bool pointsEqualish(Point p0, Point p1) { } bool cubicsEqualish(Cubic c0, Cubic c1) { - return pointsEqualish( - Point(c0.anchor0X, c0.anchor0Y), - Point(c1.anchor0X, c1.anchor0Y), - ) && - pointsEqualish( - Point(c0.anchor1X, c0.anchor1Y), - Point(c1.anchor1X, c1.anchor1Y), - ) && - pointsEqualish( - Point(c0.control0X, c0.control0Y), - Point(c1.control0X, c1.control0Y), - ) && - pointsEqualish( - Point(c0.control1X, c0.control1Y), - Point(c1.control1X, c1.control1Y), - ); + return pointsEqualish(Point(c0.anchor0X, c0.anchor0Y), Point(c1.anchor0X, c1.anchor0Y)) && + pointsEqualish(Point(c0.anchor1X, c0.anchor1Y), Point(c1.anchor1X, c1.anchor1Y)) && + pointsEqualish(Point(c0.control0X, c0.control0Y), Point(c1.control0X, c1.control0Y)) && + pointsEqualish(Point(c0.control1X, c0.control1Y), Point(c1.control1X, c1.control1Y)); } // 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, - ); + expect(expected.x, moreOrLessEquals(actual.x, epsilon: _epsilon), reason: msg); + expect(expected.y, moreOrLessEquals(actual.y, epsilon: _epsilon), reason: msg); } void expectCubicsEqualish(Cubic expected, Cubic actual) { @@ -103,11 +83,7 @@ void expectPointLessish(Point expected, Point actual) { } void expectEqualish(double expected, double actual, [String? message]) { - expect( - expected, - moreOrLessEquals(actual, epsilon: _epsilon), - reason: message, - ); + expect(expected, moreOrLessEquals(actual, epsilon: _epsilon), reason: message); } void expectInBounds(List shape, Point minPoint, Point maxPoint) { @@ -123,10 +99,11 @@ void expectInBounds(List shape, Point minPoint, Point maxPoint) { } } -PointTransformer identityTransform() => (x, y) => (x, y); +PointTransformer identityTransform() => + (x, y) => (x, y); PointTransformer pointRotator(double angleDegrees) { - final angleRadians = angleDegrees * math.pi / 180; + final double angleRadians = angleDegrees * math.pi / 180; final matrix = Matrix4.identity()..rotateZ(angleRadians); return matrix.asPointTransformer(); } From e17a104b39b9f955242e8d5761bcb6a8e15db9f6 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 22 Aug 2026 13:48:03 +0200 Subject: [PATCH 03/59] Add license headers. --- .../material_ui/lib/src/shapes/material_shape_border.dart | 4 ++++ packages/material_ui/lib/src/shapes/material_shapes.dart | 4 ++++ .../material_ui/lib/src/shapes/shapes/corner_rounding.dart | 4 ++++ packages/material_ui/lib/src/shapes/shapes/cubic.dart | 4 ++++ .../material_ui/lib/src/shapes/shapes/feature_mapping.dart | 4 ++++ packages/material_ui/lib/src/shapes/shapes/features.dart | 4 ++++ packages/material_ui/lib/src/shapes/shapes/float_mapping.dart | 4 ++++ packages/material_ui/lib/src/shapes/shapes/morph.dart | 4 ++++ packages/material_ui/lib/src/shapes/shapes/point.dart | 4 ++++ .../material_ui/lib/src/shapes/shapes/polygon_measure.dart | 4 ++++ .../material_ui/lib/src/shapes/shapes/rounded_polygon.dart | 4 ++++ packages/material_ui/lib/src/shapes/shapes/shapes.dart | 4 ++++ packages/material_ui/lib/src/shapes/shapes/utils.dart | 4 ++++ packages/material_ui/test/shapes/corner_rounding_test.dart | 4 +++- packages/material_ui/test/shapes/cubic_test.dart | 4 ++++ packages/material_ui/test/shapes/feature_mapping_test.dart | 4 ++++ packages/material_ui/test/shapes/features_test.dart | 4 +++- packages/material_ui/test/shapes/float_mapping_test.dart | 4 ++++ packages/material_ui/test/shapes/morph_test.dart | 4 +++- packages/material_ui/test/shapes/polygon_measure_test.dart | 4 +++- packages/material_ui/test/shapes/polygon_test.dart | 4 +++- packages/material_ui/test/shapes/rounded_polygon_test.dart | 4 +++- packages/material_ui/test/shapes/shapes_test.dart | 4 +++- packages/material_ui/test/shapes/test_utils.dart | 4 ++++ 24 files changed, 89 insertions(+), 7 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/material_shape_border.dart b/packages/material_ui/lib/src/shapes/material_shape_border.dart index 316791ce22de..c4171e99b664 100644 --- a/packages/material_ui/lib/src/shapes/material_shape_border.dart +++ b/packages/material_ui/lib/src/shapes/material_shape_border.dart @@ -1,3 +1,7 @@ +// 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'; diff --git a/packages/material_ui/lib/src/shapes/material_shapes.dart b/packages/material_ui/lib/src/shapes/material_shapes.dart index 679c8214f4ee..ab33852f9a05 100644 --- a/packages/material_ui/lib/src/shapes/material_shapes.dart +++ b/packages/material_ui/lib/src/shapes/material_shapes.dart @@ -1,3 +1,7 @@ +// 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 'dart:math' as math; diff --git a/packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart b/packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart index 2c966c8fceda..177ec7269d89 100644 --- a/packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart +++ b/packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart @@ -1,3 +1,7 @@ +// 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. + part of 'shapes.dart'; /// Defines the amount and quality around a given vertex of a shape. diff --git a/packages/material_ui/lib/src/shapes/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/shapes/cubic.dart index ab739433ece7..05328f813187 100644 --- a/packages/material_ui/lib/src/shapes/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/shapes/cubic.dart @@ -1,3 +1,7 @@ +// 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. + part of 'shapes.dart'; /// This class holds the anchor and control point data for a single cubic diff --git a/packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart b/packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart index 60647a375dbb..034759d98a2c 100644 --- a/packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart +++ b/packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart @@ -1,3 +1,7 @@ +// 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. + part of 'shapes.dart'; /// MeasuredFeatures contains a list of all features in a polygon along with diff --git a/packages/material_ui/lib/src/shapes/shapes/features.dart b/packages/material_ui/lib/src/shapes/shapes/features.dart index dcb805343226..7a98e1f990d4 100644 --- a/packages/material_ui/lib/src/shapes/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/shapes/features.dart @@ -1,3 +1,7 @@ +// 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. + part of 'shapes.dart'; /// While a polygon's shape can be drawn solely using a list of [Cubic] objects diff --git a/packages/material_ui/lib/src/shapes/shapes/float_mapping.dart b/packages/material_ui/lib/src/shapes/shapes/float_mapping.dart index 2a96ad7170fd..ef4ac26f5118 100644 --- a/packages/material_ui/lib/src/shapes/shapes/float_mapping.dart +++ b/packages/material_ui/lib/src/shapes/shapes/float_mapping.dart @@ -1,3 +1,7 @@ +// 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. + part of 'shapes.dart'; /// Checks if the given progress is in the given progress range. diff --git a/packages/material_ui/lib/src/shapes/shapes/morph.dart b/packages/material_ui/lib/src/shapes/shapes/morph.dart index 618bd4461a92..a4d1c82db582 100644 --- a/packages/material_ui/lib/src/shapes/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/shapes/morph.dart @@ -1,3 +1,7 @@ +// 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. + part of 'shapes.dart'; /// This class is used to animate between start and end polygons objects. diff --git a/packages/material_ui/lib/src/shapes/shapes/point.dart b/packages/material_ui/lib/src/shapes/shapes/point.dart index 38f2c7c8d00d..9708ec278c57 100644 --- a/packages/material_ui/lib/src/shapes/shapes/point.dart +++ b/packages/material_ui/lib/src/shapes/shapes/point.dart @@ -1,3 +1,7 @@ +// 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. + part of 'shapes.dart'; typedef PointTransformer = (double, double) Function(double x, double y); diff --git a/packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart index ff580fd57798..18ab5018395e 100644 --- a/packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart +++ b/packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart @@ -1,3 +1,7 @@ +// 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. + part of 'shapes.dart'; class MeasuredPolygon { diff --git a/packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart index f7b4fe2475d1..bad5dfde4728 100644 --- a/packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart @@ -1,3 +1,7 @@ +// 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. + part of 'shapes.dart'; /// The RoundedPolygon class allows simple construction of polygonal shapes diff --git a/packages/material_ui/lib/src/shapes/shapes/shapes.dart b/packages/material_ui/lib/src/shapes/shapes/shapes.dart index 742e206f0e33..5a1647c6bed6 100644 --- a/packages/material_ui/lib/src/shapes/shapes/shapes.dart +++ b/packages/material_ui/lib/src/shapes/shapes/shapes.dart @@ -1,3 +1,7 @@ +// 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 'dart:math' as math; import 'dart:ui'; diff --git a/packages/material_ui/lib/src/shapes/shapes/utils.dart b/packages/material_ui/lib/src/shapes/shapes/utils.dart index 63daa07c6021..866ba4f96bd3 100644 --- a/packages/material_ui/lib/src/shapes/shapes/utils.dart +++ b/packages/material_ui/lib/src/shapes/shapes/utils.dart @@ -1,3 +1,7 @@ +// 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. + part of 'shapes.dart'; // These epsilon values are used internally to determine when two points are diff --git a/packages/material_ui/test/shapes/corner_rounding_test.dart b/packages/material_ui/test/shapes/corner_rounding_test.dart index bb97b3fe8324..91e9c2623f9b 100644 --- a/packages/material_ui/test/shapes/corner_rounding_test.dart +++ b/packages/material_ui/test/shapes/corner_rounding_test.dart @@ -1,4 +1,6 @@ -// ignore_for_file: document_ignores +// 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/shapes/shapes.dart'; diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index f83dce9a591a..35ea25ef8c27 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -1,3 +1,7 @@ +// 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'; diff --git a/packages/material_ui/test/shapes/feature_mapping_test.dart b/packages/material_ui/test/shapes/feature_mapping_test.dart index 514543960739..21d2e0c86a3e 100644 --- a/packages/material_ui/test/shapes/feature_mapping_test.dart +++ b/packages/material_ui/test/shapes/feature_mapping_test.dart @@ -1,3 +1,7 @@ +// 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/shapes/shapes.dart'; diff --git a/packages/material_ui/test/shapes/features_test.dart b/packages/material_ui/test/shapes/features_test.dart index 6f24a36ee42f..8d879f4d6d95 100644 --- a/packages/material_ui/test/shapes/features_test.dart +++ b/packages/material_ui/test/shapes/features_test.dart @@ -1,4 +1,6 @@ -// ignore_for_file: avoid_redundant_argument_values, document_ignores +// 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/shapes/shapes.dart'; diff --git a/packages/material_ui/test/shapes/float_mapping_test.dart b/packages/material_ui/test/shapes/float_mapping_test.dart index 6f6e0c9d2d14..ff57dec5d19e 100644 --- a/packages/material_ui/test/shapes/float_mapping_test.dart +++ b/packages/material_ui/test/shapes/float_mapping_test.dart @@ -1,3 +1,7 @@ +// 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/shapes/shapes.dart'; diff --git a/packages/material_ui/test/shapes/morph_test.dart b/packages/material_ui/test/shapes/morph_test.dart index ad00967fdb74..28da1415c342 100644 --- a/packages/material_ui/test/shapes/morph_test.dart +++ b/packages/material_ui/test/shapes/morph_test.dart @@ -1,4 +1,6 @@ -// ignore_for_file: cascade_invocations, document_ignores +// 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; diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart index abc991d48853..e618a97f1694 100644 --- a/packages/material_ui/test/shapes/polygon_measure_test.dart +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -1,4 +1,6 @@ -// ignore_for_file: avoid_redundant_argument_values, document_ignores +// 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; diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 5f8ffa569e09..772f3ae4a859 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -1,4 +1,6 @@ -// ignore_for_file: avoid_redundant_argument_values, document_ignores +// 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/shapes/shapes.dart'; diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index 8ab71eab6cc9..28c155c1c996 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -1,4 +1,6 @@ -// ignore_for_file: document_ignores, avoid_redundant_argument_values +// 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/shapes/shapes.dart'; diff --git a/packages/material_ui/test/shapes/shapes_test.dart b/packages/material_ui/test/shapes/shapes_test.dart index 856d6ab0d34b..254d7dcddee2 100644 --- a/packages/material_ui/test/shapes/shapes_test.dart +++ b/packages/material_ui/test/shapes/shapes_test.dart @@ -1,4 +1,6 @@ -// ignore_for_file: document_ignores, avoid_redundant_argument_values, lines_longer_than_80_chars +// 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; diff --git a/packages/material_ui/test/shapes/test_utils.dart b/packages/material_ui/test/shapes/test_utils.dart index c16251960850..100aa4ee3d66 100644 --- a/packages/material_ui/test/shapes/test_utils.dart +++ b/packages/material_ui/test/shapes/test_utils.dart @@ -1,3 +1,7 @@ +// 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'; From 33011e8bc42c3ef6d07718f1ba0a09bebd33a4a9 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 22 Aug 2026 16:52:56 +0200 Subject: [PATCH 04/59] Collapse the doubled shapes directory. --- .../lib/src/{shapes => }/material_shape_border.dart | 0 .../material_ui/lib/src/{shapes => }/material_shapes.dart | 3 +++ .../lib/src/shapes/{shapes => }/corner_rounding.dart | 0 .../material_ui/lib/src/shapes/{shapes => }/cubic.dart | 0 .../lib/src/shapes/{shapes => }/feature_mapping.dart | 0 .../material_ui/lib/src/shapes/{shapes => }/features.dart | 0 .../lib/src/shapes/{shapes => }/float_mapping.dart | 0 .../material_ui/lib/src/shapes/{shapes => }/morph.dart | 0 .../material_ui/lib/src/shapes/{shapes => }/point.dart | 0 .../lib/src/shapes/{shapes => }/polygon_measure.dart | 0 .../lib/src/shapes/{shapes => }/rounded_polygon.dart | 0 .../material_ui/lib/src/shapes/{shapes => }/shapes.dart | 7 +++++++ .../material_ui/lib/src/shapes/{shapes => }/utils.dart | 0 packages/material_ui/test/shapes/corner_rounding_test.dart | 2 +- packages/material_ui/test/shapes/cubic_test.dart | 2 +- packages/material_ui/test/shapes/feature_mapping_test.dart | 2 +- packages/material_ui/test/shapes/features_test.dart | 2 +- packages/material_ui/test/shapes/float_mapping_test.dart | 2 +- packages/material_ui/test/shapes/morph_test.dart | 2 +- packages/material_ui/test/shapes/polygon_measure_test.dart | 2 +- packages/material_ui/test/shapes/polygon_test.dart | 2 +- packages/material_ui/test/shapes/rounded_polygon_test.dart | 2 +- packages/material_ui/test/shapes/shapes_test.dart | 2 +- packages/material_ui/test/shapes/test_utils.dart | 2 +- 24 files changed, 21 insertions(+), 11 deletions(-) rename packages/material_ui/lib/src/{shapes => }/material_shape_border.dart (100%) rename packages/material_ui/lib/src/{shapes => }/material_shapes.dart (98%) rename packages/material_ui/lib/src/shapes/{shapes => }/corner_rounding.dart (100%) rename packages/material_ui/lib/src/shapes/{shapes => }/cubic.dart (100%) rename packages/material_ui/lib/src/shapes/{shapes => }/feature_mapping.dart (100%) rename packages/material_ui/lib/src/shapes/{shapes => }/features.dart (100%) rename packages/material_ui/lib/src/shapes/{shapes => }/float_mapping.dart (100%) rename packages/material_ui/lib/src/shapes/{shapes => }/morph.dart (100%) rename packages/material_ui/lib/src/shapes/{shapes => }/point.dart (100%) rename packages/material_ui/lib/src/shapes/{shapes => }/polygon_measure.dart (100%) rename packages/material_ui/lib/src/shapes/{shapes => }/rounded_polygon.dart (100%) rename packages/material_ui/lib/src/shapes/{shapes => }/shapes.dart (66%) rename packages/material_ui/lib/src/shapes/{shapes => }/utils.dart (100%) diff --git a/packages/material_ui/lib/src/shapes/material_shape_border.dart b/packages/material_ui/lib/src/material_shape_border.dart similarity index 100% rename from packages/material_ui/lib/src/shapes/material_shape_border.dart rename to packages/material_ui/lib/src/material_shape_border.dart diff --git a/packages/material_ui/lib/src/shapes/material_shapes.dart b/packages/material_ui/lib/src/material_shapes.dart similarity index 98% rename from packages/material_ui/lib/src/shapes/material_shapes.dart rename to packages/material_ui/lib/src/material_shapes.dart index ab33852f9a05..218cc869ce19 100644 --- a/packages/material_ui/lib/src/shapes/material_shapes.dart +++ b/packages/material_ui/lib/src/material_shapes.dart @@ -2,6 +2,9 @@ // 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; diff --git a/packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart b/packages/material_ui/lib/src/shapes/corner_rounding.dart similarity index 100% rename from packages/material_ui/lib/src/shapes/shapes/corner_rounding.dart rename to packages/material_ui/lib/src/shapes/corner_rounding.dart diff --git a/packages/material_ui/lib/src/shapes/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart similarity index 100% rename from packages/material_ui/lib/src/shapes/shapes/cubic.dart rename to packages/material_ui/lib/src/shapes/cubic.dart diff --git a/packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart b/packages/material_ui/lib/src/shapes/feature_mapping.dart similarity index 100% rename from packages/material_ui/lib/src/shapes/shapes/feature_mapping.dart rename to packages/material_ui/lib/src/shapes/feature_mapping.dart diff --git a/packages/material_ui/lib/src/shapes/shapes/features.dart b/packages/material_ui/lib/src/shapes/features.dart similarity index 100% rename from packages/material_ui/lib/src/shapes/shapes/features.dart rename to packages/material_ui/lib/src/shapes/features.dart diff --git a/packages/material_ui/lib/src/shapes/shapes/float_mapping.dart b/packages/material_ui/lib/src/shapes/float_mapping.dart similarity index 100% rename from packages/material_ui/lib/src/shapes/shapes/float_mapping.dart rename to packages/material_ui/lib/src/shapes/float_mapping.dart diff --git a/packages/material_ui/lib/src/shapes/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart similarity index 100% rename from packages/material_ui/lib/src/shapes/shapes/morph.dart rename to packages/material_ui/lib/src/shapes/morph.dart diff --git a/packages/material_ui/lib/src/shapes/shapes/point.dart b/packages/material_ui/lib/src/shapes/point.dart similarity index 100% rename from packages/material_ui/lib/src/shapes/shapes/point.dart rename to packages/material_ui/lib/src/shapes/point.dart diff --git a/packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/polygon_measure.dart similarity index 100% rename from packages/material_ui/lib/src/shapes/shapes/polygon_measure.dart rename to packages/material_ui/lib/src/shapes/polygon_measure.dart diff --git a/packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart similarity index 100% rename from packages/material_ui/lib/src/shapes/shapes/rounded_polygon.dart rename to packages/material_ui/lib/src/shapes/rounded_polygon.dart diff --git a/packages/material_ui/lib/src/shapes/shapes/shapes.dart b/packages/material_ui/lib/src/shapes/shapes.dart similarity index 66% rename from packages/material_ui/lib/src/shapes/shapes/shapes.dart rename to packages/material_ui/lib/src/shapes/shapes.dart index 5a1647c6bed6..563f3fc9e5ca 100644 --- a/packages/material_ui/lib/src/shapes/shapes/shapes.dart +++ b/packages/material_ui/lib/src/shapes/shapes.dart @@ -2,6 +2,13 @@ // 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; + import 'dart:collection'; import 'dart:math' as math; import 'dart:ui'; diff --git a/packages/material_ui/lib/src/shapes/shapes/utils.dart b/packages/material_ui/lib/src/shapes/utils.dart similarity index 100% rename from packages/material_ui/lib/src/shapes/shapes/utils.dart rename to packages/material_ui/lib/src/shapes/utils.dart diff --git a/packages/material_ui/test/shapes/corner_rounding_test.dart b/packages/material_ui/test/shapes/corner_rounding_test.dart index 91e9c2623f9b..b21d37dfe998 100644 --- a/packages/material_ui/test/shapes/corner_rounding_test.dart +++ b/packages/material_ui/test/shapes/corner_rounding_test.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/shapes.dart'; void main() { test('$CornerRounding()', () { diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index 35ea25ef8c27..2cc91e93ddba 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -5,7 +5,7 @@ import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/shapes.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/feature_mapping_test.dart b/packages/material_ui/test/shapes/feature_mapping_test.dart index 21d2e0c86a3e..01a483a75ce6 100644 --- a/packages/material_ui/test/shapes/feature_mapping_test.dart +++ b/packages/material_ui/test/shapes/feature_mapping_test.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/shapes.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/features_test.dart b/packages/material_ui/test/shapes/features_test.dart index 8d879f4d6d95..c39cb51e2a5b 100644 --- a/packages/material_ui/test/shapes/features_test.dart +++ b/packages/material_ui/test/shapes/features_test.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/shapes.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/float_mapping_test.dart b/packages/material_ui/test/shapes/float_mapping_test.dart index ff57dec5d19e..10469108ec0c 100644 --- a/packages/material_ui/test/shapes/float_mapping_test.dart +++ b/packages/material_ui/test/shapes/float_mapping_test.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/shapes.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/morph_test.dart b/packages/material_ui/test/shapes/morph_test.dart index 28da1415c342..b27eefdf085d 100644 --- a/packages/material_ui/test/shapes/morph_test.dart +++ b/packages/material_ui/test/shapes/morph_test.dart @@ -6,7 +6,7 @@ import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/shapes.dart'; import 'package:vector_math/vector_math_64.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart index e618a97f1694..29ce64daa0ad 100644 --- a/packages/material_ui/test/shapes/polygon_measure_test.dart +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -5,7 +5,7 @@ import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/shapes.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 772f3ae4a859..9880fab9f294 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/shapes.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index 28c155c1c996..5d904002983f 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/shapes.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/shapes_test.dart b/packages/material_ui/test/shapes/shapes_test.dart index 254d7dcddee2..4221f65d6bc3 100644 --- a/packages/material_ui/test/shapes/shapes_test.dart +++ b/packages/material_ui/test/shapes/shapes_test.dart @@ -5,7 +5,7 @@ import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/shapes.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/test_utils.dart b/packages/material_ui/test/shapes/test_utils.dart index 100aa4ee3d66..c3f5cab21cd2 100644 --- a/packages/material_ui/test/shapes/test_utils.dart +++ b/packages/material_ui/test/shapes/test_utils.dart @@ -5,7 +5,7 @@ import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/shapes.dart'; import 'package:vector_math/vector_math_64.dart'; const _epsilon = 1e-4; From 3e688cf22141f96133fbf70a294439ef2e6a777e Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 22 Aug 2026 17:12:24 +0200 Subject: [PATCH 05/59] Split the shapes part library into separate libraries. --- .../lib/src/material_shape_border.dart | 4 +- .../material_ui/lib/src/material_shapes.dart | 1 + .../lib/src/shapes/corner_rounding.dart | 2 - .../material_ui/lib/src/shapes/cubic.dart | 120 +++++++++- .../lib/src/shapes/feature_mapping.dart | 6 +- .../material_ui/lib/src/shapes/features.dart | 5 +- .../lib/src/shapes/float_mapping.dart | 4 +- .../material_ui/lib/src/shapes/morph.dart | 63 +++++- .../material_ui/lib/src/shapes/point.dart | 25 +-- .../lib/src/shapes/polygon_measure.dart | 9 +- .../lib/src/shapes/rounded_polygon.dart | 46 +++- .../material_ui/lib/src/shapes/shapes.dart | 23 +- .../material_ui/lib/src/shapes/utils.dart | 207 ++---------------- .../test/shapes/corner_rounding_test.dart | 2 +- .../material_ui/test/shapes/cubic_test.dart | 3 +- .../test/shapes/feature_mapping_test.dart | 5 +- .../test/shapes/features_test.dart | 3 +- .../test/shapes/float_mapping_test.dart | 2 +- .../material_ui/test/shapes/morph_test.dart | 4 +- .../test/shapes/polygon_measure_test.dart | 7 +- .../material_ui/test/shapes/polygon_test.dart | 6 +- .../test/shapes/rounded_polygon_test.dart | 7 +- .../material_ui/test/shapes/shapes_test.dart | 5 +- .../material_ui/test/shapes/test_utils.dart | 5 +- 24 files changed, 313 insertions(+), 251 deletions(-) diff --git a/packages/material_ui/lib/src/material_shape_border.dart b/packages/material_ui/lib/src/material_shape_border.dart index c4171e99b664..4258ee8398e0 100644 --- a/packages/material_ui/lib/src/material_shape_border.dart +++ b/packages/material_ui/lib/src/material_shape_border.dart @@ -9,7 +9,9 @@ import 'package:flutter/painting.dart'; import 'package:vector_math/vector_math_64.dart' show Matrix4; -import 'shapes/shapes.dart'; +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. diff --git a/packages/material_ui/lib/src/material_shapes.dart b/packages/material_ui/lib/src/material_shapes.dart index 218cc869ce19..e9064edd2e19 100644 --- a/packages/material_ui/lib/src/material_shapes.dart +++ b/packages/material_ui/lib/src/material_shapes.dart @@ -10,6 +10,7 @@ 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 diff --git a/packages/material_ui/lib/src/shapes/corner_rounding.dart b/packages/material_ui/lib/src/shapes/corner_rounding.dart index 177ec7269d89..9646f9d4e5e6 100644 --- a/packages/material_ui/lib/src/shapes/corner_rounding.dart +++ b/packages/material_ui/lib/src/shapes/corner_rounding.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -part of 'shapes.dart'; - /// Defines the amount and quality 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 diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 05328f813187..71d626ec3bf9 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -2,7 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -part of 'shapes.dart'; +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'; /// This class holds the anchor and control point data for a single cubic /// Bézier curve, with anchor points ([anchor0X], [anchor0Y]) and ([anchor1X], @@ -25,7 +33,7 @@ class Cubic { double control1Y, double anchor1X, double anchor1Y, - ) : this._raw([ + ) : this.raw([ anchor0X, anchor0Y, control0X, @@ -36,13 +44,16 @@ class Cubic { anchor1Y, ]); - const Cubic._raw(List points) + /// Creates a Cubic directly from the flat list of its eight anchor and + /// control point coordinates, in the order used by [points]. + @internal + const Cubic.raw(List points) : assert(points.length == 8, 'Points array size should be 8.'), _points = points; @internal Cubic.fromPoints(Point anchor0, Point control0, Point control1, Point anchor1) - : this._raw([ + : this.raw([ anchor0.x, anchor0.y, control0.x, @@ -57,7 +68,7 @@ class Cubic { /// points. The control points lie 1/3 of the distance from their respective /// anchor points. factory Cubic.straightLine(double x0, double y0, double x1, double y1) { - return Cubic._raw([ + return Cubic.raw([ x0, y0, lerp(x0, x1, 1 / 3), @@ -115,7 +126,7 @@ class Cubic { } /// Generates an empty Cubic defined at (x0, y0). - Cubic.empty(double x0, double y0) : this._raw([x0, y0, x0, y0, x0, y0, x0, y0]); + Cubic.empty(double x0, double y0) : this.raw([x0, y0, x0, y0, x0, y0, x0, y0]); final List _points; @@ -333,9 +344,9 @@ class Cubic { Cubic reverse() => Cubic(anchor1X, anchor1Y, control1X, control1Y, control0X, control0Y, anchor0X, anchor0Y); - Cubic operator +(Cubic o) => Cubic._raw(List.generate(8, (i) => _points[i] + o._points[i])); + Cubic operator +(Cubic o) => Cubic.raw(List.generate(8, (i) => _points[i] + o._points[i])); - Cubic operator *(double x) => Cubic._raw(List.generate(8, (i) => _points[i] * x)); + Cubic operator *(double x) => Cubic.raw(List.generate(8, (i) => _points[i] * x)); Cubic operator /(double x) => this * (1.0 / x); @@ -389,7 +400,7 @@ class Cubic { /// This is used in Morph.forEachCubic, reusing a [_MutableCubic] instance to /// avoid creating new [Cubic]s. class _MutableCubic extends Cubic { - _MutableCubic() : super._raw(List.filled(8, 0)); + _MutableCubic() : super.raw(List.filled(8, 0)); void _transformOnePoint(PointTransformer f, int ix) { final (double, double) result = f(_points[ix], _points[ix + 1]); @@ -410,3 +421,94 @@ class _MutableCubic extends Cubic { } } } + +/// Returns a [Path] for a [Cubic] list. +/// +/// [path] is a [Path] to reset and set with the new path data. +/// +/// [startAngle] is an angle (in degrees) to rotate the [Path] to start +/// drawing from. If [startAngle] is non zero, then caller has to use the +/// returned [Path], as path transformation creates a new path. +/// +/// [repeatPath] is whether or not to repeat the [Path] twice before closing +/// it. This flag 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 a motion as a Star circular +/// progress indicator advances). +/// +/// [closePath] is whether or not to close the created [Path]. +/// +/// [cubics] is list of [Cubic]s to build path from. +/// +/// [rotationPivotX] is the rotation pivot on the X axis. +/// +/// [rotationPivotY] is the rotation pivot on the Y axis. +Path pathFromCubics({ + required Path path, + required int startAngle, + required bool repeatPath, + required bool closePath, + required List cubics, + required double rotationPivotX, + required double rotationPivotY, +}) { + var first = true; + Cubic? firstCubic; + + path.reset(); + + 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 - rotationPivotY, + cubics[0].anchor0X - rotationPivotX, + ); + // Rotate the Path to to start from the given angle. + path = path.transform( + (Matrix4.identity()..rotateZ(-angleToFirstCubic + (startAngle * math.pi / 180))).storage, + ); + } + + return path; +} diff --git a/packages/material_ui/lib/src/shapes/feature_mapping.dart b/packages/material_ui/lib/src/shapes/feature_mapping.dart index 034759d98a2c..ec20ba7819c9 100644 --- a/packages/material_ui/lib/src/shapes/feature_mapping.dart +++ b/packages/material_ui/lib/src/shapes/feature_mapping.dart @@ -2,7 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -part of 'shapes.dart'; +import 'cubic.dart'; +import 'features.dart'; +import 'float_mapping.dart'; +import 'point.dart'; +import 'utils.dart'; /// MeasuredFeatures contains a list of all features in a polygon along with /// the [0..1] progress at that feature. diff --git a/packages/material_ui/lib/src/shapes/features.dart b/packages/material_ui/lib/src/shapes/features.dart index 7a98e1f990d4..7aeaf553940d 100644 --- a/packages/material_ui/lib/src/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/features.dart @@ -2,7 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -part of 'shapes.dart'; +import 'dart:collection'; + +import 'cubic.dart'; +import 'point.dart'; /// While a polygon's shape can be drawn solely using a list of [Cubic] objects /// representing its raw curves and lines, features add an extra layer of diff --git a/packages/material_ui/lib/src/shapes/float_mapping.dart b/packages/material_ui/lib/src/shapes/float_mapping.dart index ef4ac26f5118..cbf4c0ce1b19 100644 --- a/packages/material_ui/lib/src/shapes/float_mapping.dart +++ b/packages/material_ui/lib/src/shapes/float_mapping.dart @@ -2,7 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -part of 'shapes.dart'; +import 'dart:math' as math; + +import 'utils.dart'; /// Checks if the given progress is in the given progress range. /// diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index a4d1c82db582..03313fd1b743 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -2,7 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -part of 'shapes.dart'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'cubic.dart'; +import 'feature_mapping.dart'; +import 'float_mapping.dart'; +import 'polygon_measure.dart'; +import 'rounded_polygon.dart'; +import 'utils.dart'; /// This class is used to animate between start and end polygons objects. /// @@ -207,7 +215,7 @@ class Morph { Cubic? lastCubic; for (var i = 0; i < _morphMatch.length; i++) { - final cubic = Cubic._raw( + final cubic = Cubic.raw( List.generate(8, (j) { return lerp(_morphMatch[i].$1.points[j], _morphMatch[i].$2.points[j], progress); }), @@ -237,4 +245,55 @@ class Morph { return result; } + + /// Returns a [Path] for a [Morph]. + /// + /// [progress] is the [Morph]'s progress. + /// + /// [path] is a [Path] to reset and set with the new path data. + /// + /// [startAngle] is an angle (in degrees) to rotate the [Path] to start + /// drawing from. If [startAngle] is non zero, then caller has to use the + /// returned [Path], as path transformation creates a new path. + /// + /// [repeatPath] is whether or not to repeat the [Path] twice before closing + /// it. This flag 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 a motion as a Star circular + /// progress indicator advances). + /// + /// [closePath] is whether or not to close the created [Path]. + /// + /// [rotationPivotX] is the rotation pivot on the X axis. By default it's set + /// to 0, and that should align with Morph instances that were created for + /// [RoundedPolygon] with zero centerX. In case the [RoundedPolygon] were + /// normalized (i. e. moved to (0.5, 0.5)), or where created with a different + /// centerX coordinated, this pivot point may need to be aligned to support a + /// proper rotation. + /// + /// [rotationPivotY] is the rotation pivot on the Y axis. By default it's set + /// to 0, and that should align with Morph instances that were created for + /// [RoundedPolygon] with zero centerY. In case the RoundedPolygon were + /// normalized (i. e. moves to (0.5, 0.5)), or where created with a different + /// centerY coordinated, this pivot point may need to be aligned to support a + /// proper rotation. + Path toPath({ + required double progress, + int startAngle = 0, + bool repeatPath = false, + bool closePath = true, + double rotationPivotX = 0, + double rotationPivotY = 0, + Path? path, + }) { + return pathFromCubics( + path: path ?? Path(), + startAngle: startAngle, + repeatPath: repeatPath, + closePath: closePath, + cubics: asCubics(progress), + rotationPivotX: rotationPivotX, + rotationPivotY: rotationPivotY, + ); + } } diff --git a/packages/material_ui/lib/src/shapes/point.dart b/packages/material_ui/lib/src/shapes/point.dart index 9708ec278c57..64eecae18380 100644 --- a/packages/material_ui/lib/src/shapes/point.dart +++ b/packages/material_ui/lib/src/shapes/point.dart @@ -2,7 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -part of 'shapes.dart'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:vector_math/vector_math_64.dart' show Matrix4, Vector3; typedef PointTransformer = (double, double) Function(double x, double y); @@ -134,17 +137,11 @@ class Point { int get hashCode => Object.hashAll([x, y]); } -/// 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). -Point interpolate(Point start, Point stop, double fraction) { - return Point(lerp(start.x, stop.x, fraction), lerp(start.y, stop.y, fraction)); +extension Matrix4PointTransformer on Matrix4 { + 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 index 18ab5018395e..95d966b86e80 100644 --- a/packages/material_ui/lib/src/shapes/polygon_measure.dart +++ b/packages/material_ui/lib/src/shapes/polygon_measure.dart @@ -2,7 +2,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -part of 'shapes.dart'; +import 'dart:collection'; + +import 'cubic.dart'; +import 'feature_mapping.dart'; +import 'features.dart'; +import 'point.dart'; +import 'rounded_polygon.dart'; +import 'utils.dart'; class MeasuredPolygon { MeasuredPolygon._({ diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index bad5dfde4728..97e809bf7a4e 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -2,7 +2,16 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -part of 'shapes.dart'; +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'; /// The RoundedPolygon class allows simple construction of polygonal shapes /// with optional rounding at the vertices. Polygons can be constructed with @@ -736,7 +745,7 @@ class RoundedPolygon { final List points = lastCubic.points.toList(); points[6] = cubic.anchor1X; points[7] = cubic.anchor1Y; - lastCubic = Cubic._raw(points); + lastCubic = Cubic.raw(points); } } } @@ -876,6 +885,39 @@ class RoundedPolygon { return bounds; } + /// Returns a [Path] representation for a [RoundedPolygon] shape. Note that + /// there is some rounding happening (to the nearest thousandth), to work + /// around rendering artifacts introduced by some points being just slightly + /// off from each other (far less than a pixel). This also allows for a more + /// optimal path, as redundant curves (usually a single point) can be + /// detected and not added to the resulting path. + /// + /// [path] is a [Path] to reset and set with the new path data. + /// + /// [startAngle] is an angle (in degrees) to rotate the [Path] to start + /// drawing from. The rotation pivot is set to be the polygon's centerX and + /// centerY coordinates. If [startAngle] is non zero, then caller has to use + /// the returned [Path], as path transformation creates a new path. + /// + /// [repeatPath] is whether or not to repeat the [Path] twice before closing + /// it. This flag 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 a motion as a Star circular + /// progress indicator advances). + /// + /// [closePath] is whether or not to close the created [Path]. + Path toPath({int startAngle = 0, bool repeatPath = false, bool closePath = true, Path? path}) { + return pathFromCubics( + path: path ?? Path(), + startAngle: startAngle, + repeatPath: repeatPath, + closePath: closePath, + cubics: cubics, + rotationPivotX: centerX, + rotationPivotY: centerY, + ); + } + @override String toString() { return '[RoundedPolygon. ' diff --git a/packages/material_ui/lib/src/shapes/shapes.dart b/packages/material_ui/lib/src/shapes/shapes.dart index 563f3fc9e5ca..be6f435d4b8a 100644 --- a/packages/material_ui/lib/src/shapes/shapes.dart +++ b/packages/material_ui/lib/src/shapes/shapes.dart @@ -9,20 +9,9 @@ /// between them. 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'; - -part 'corner_rounding.dart'; -part 'cubic.dart'; -part 'feature_mapping.dart'; -part 'features.dart'; -part 'float_mapping.dart'; -part 'morph.dart'; -part 'point.dart'; -part 'polygon_measure.dart'; -part 'rounded_polygon.dart'; -part 'utils.dart'; +export 'corner_rounding.dart' show CornerRounding; +export 'cubic.dart' show Cubic; +export 'features.dart' show Feature; +export 'morph.dart' show Morph; +export 'point.dart' show 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 index 866ba4f96bd3..c028a5dbed11 100644 --- a/packages/material_ui/lib/src/shapes/utils.dart +++ b/packages/material_ui/lib/src/shapes/utils.dart @@ -2,7 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -part of 'shapes.dart'; +import 'dart:math' as math; + +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 @@ -44,6 +46,21 @@ 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). +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 @@ -158,191 +175,3 @@ extension DoubleCoerceExtensions on double { return this; } } - -extension Matrix4PointTransformer on Matrix4 { - PointTransformer asPointTransformer() { - return (x, y) { - final Vector3 vector = transform3(Vector3(x, y, 0)); - return (vector.x, vector.y); - }; - } -} - -extension RoundedPolygonToPathExtension on RoundedPolygon { - /// Returns a [Path] representation for a [RoundedPolygon] shape. Note that - /// there is some rounding happening (to the nearest thousandth), to work - /// around rendering artifacts introduced by some points being just slightly - /// off from each other (far less than a pixel). This also allows for a more - /// optimal path, as redundant curves (usually a single point) can be - /// detected and not added to the resulting path. - /// - /// [path] is a [Path] to reset and set with the new path data. - /// - /// [startAngle] is an angle (in degrees) to rotate the [Path] to start - /// drawing from. The rotation pivot is set to be the polygon's centerX and - /// centerY coordinates. If [startAngle] is non zero, then caller has to use - /// the returned [Path], as path transformation creates a new path. - /// - /// [repeatPath] is whether or not to repeat the [Path] twice before closing - /// it. This flag 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 a motion as a Star circular - /// progress indicator advances). - /// - /// [closePath] is whether or not to close the created [Path]. - Path toPath({int startAngle = 0, bool repeatPath = false, bool closePath = true, Path? path}) { - return pathFromCubics( - path: path ?? Path(), - startAngle: startAngle, - repeatPath: repeatPath, - closePath: closePath, - cubics: cubics, - rotationPivotX: centerX, - rotationPivotY: centerY, - ); - } -} - -extension MorphToPathExtension on Morph { - /// Returns a [Path] for a [Morph]. - /// - /// [progress] is the [Morph]'s progress. - /// - /// [path] is a [Path] to reset and set with the new path data. - /// - /// [startAngle] is an angle (in degrees) to rotate the [Path] to start - /// drawing from. If [startAngle] is non zero, then caller has to use the - /// returned [Path], as path transformation creates a new path. - /// - /// [repeatPath] is whether or not to repeat the [Path] twice before closing - /// it. This flag 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 a motion as a Star circular - /// progress indicator advances). - /// - /// [closePath] is whether or not to close the created [Path]. - /// - /// [rotationPivotX] is the rotation pivot on the X axis. By default it's set - /// to 0, and that should align with Morph instances that were created for - /// [RoundedPolygon] with zero centerX. In case the [RoundedPolygon] were - /// normalized (i. e. moved to (0.5, 0.5)), or where created with a different - /// centerX coordinated, this pivot point may need to be aligned to support a - /// proper rotation. - /// - /// [rotationPivotY] is the rotation pivot on the Y axis. By default it's set - /// to 0, and that should align with Morph instances that were created for - /// [RoundedPolygon] with zero centerY. In case the RoundedPolygon were - /// normalized (i. e. moves to (0.5, 0.5)), or where created with a different - /// centerY coordinated, this pivot point may need to be aligned to support a - /// proper rotation. - Path toPath({ - required double progress, - int startAngle = 0, - bool repeatPath = false, - bool closePath = true, - double rotationPivotX = 0, - double rotationPivotY = 0, - Path? path, - }) { - return pathFromCubics( - path: path ?? Path(), - startAngle: startAngle, - repeatPath: repeatPath, - closePath: closePath, - cubics: asCubics(progress), - rotationPivotX: rotationPivotX, - rotationPivotY: rotationPivotY, - ); - } -} - -/// Returns a [Path] for a [Cubic] list. -/// -/// [path] is a [Path] to reset and set with the new path data. -/// -/// [startAngle] is an angle (in degrees) to rotate the [Path] to start -/// drawing from. If [startAngle] is non zero, then caller has to use the -/// returned [Path], as path transformation creates a new path. -/// -/// [repeatPath] is whether or not to repeat the [Path] twice before closing -/// it. This flag 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 a motion as a Star circular -/// progress indicator advances). -/// -/// [closePath] is whether or not to close the created [Path]. -/// -/// [cubics] is list of [Cubic]s to build path from. -/// -/// [rotationPivotX] is the rotation pivot on the X axis. -/// -/// [rotationPivotY] is the rotation pivot on the Y axis. -Path pathFromCubics({ - required Path path, - required int startAngle, - required bool repeatPath, - required bool closePath, - required List cubics, - required double rotationPivotX, - required double rotationPivotY, -}) { - var first = true; - Cubic? firstCubic; - - path.reset(); - - 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 - rotationPivotY, - cubics[0].anchor0X - rotationPivotX, - ); - // Rotate the Path to to start from the given angle. - path = path.transform( - (Matrix4.identity()..rotateZ(-angleToFirstCubic + (startAngle * math.pi / 180))).storage, - ); - } - - return path; -} diff --git a/packages/material_ui/test/shapes/corner_rounding_test.dart b/packages/material_ui/test/shapes/corner_rounding_test.dart index b21d37dfe998..5d374b289cf9 100644 --- a/packages/material_ui/test/shapes/corner_rounding_test.dart +++ b/packages/material_ui/test/shapes/corner_rounding_test.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/corner_rounding.dart'; void main() { test('$CornerRounding()', () { diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index 2cc91e93ddba..e14dc0e13e50 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -5,7 +5,8 @@ import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/cubic.dart'; +import 'package:material_ui/src/shapes/point.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/feature_mapping_test.dart b/packages/material_ui/test/shapes/feature_mapping_test.dart index 01a483a75ce6..a185ca6a34fd 100644 --- a/packages/material_ui/test/shapes/feature_mapping_test.dart +++ b/packages/material_ui/test/shapes/feature_mapping_test.dart @@ -3,7 +3,10 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes.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/polygon_measure.dart'; +import 'package:material_ui/src/shapes/rounded_polygon.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/features_test.dart b/packages/material_ui/test/shapes/features_test.dart index c39cb51e2a5b..800b57146295 100644 --- a/packages/material_ui/test/shapes/features_test.dart +++ b/packages/material_ui/test/shapes/features_test.dart @@ -3,7 +3,8 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/cubic.dart'; +import 'package:material_ui/src/shapes/features.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/float_mapping_test.dart b/packages/material_ui/test/shapes/float_mapping_test.dart index 10469108ec0c..cb684b78c613 100644 --- a/packages/material_ui/test/shapes/float_mapping_test.dart +++ b/packages/material_ui/test/shapes/float_mapping_test.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/float_mapping.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/morph_test.dart b/packages/material_ui/test/shapes/morph_test.dart index b27eefdf085d..b70f96c5c896 100644 --- a/packages/material_ui/test/shapes/morph_test.dart +++ b/packages/material_ui/test/shapes/morph_test.dart @@ -6,7 +6,9 @@ import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes.dart'; +import 'package:material_ui/src/shapes/cubic.dart'; +import 'package:material_ui/src/shapes/morph.dart'; +import 'package:material_ui/src/shapes/rounded_polygon.dart'; import 'package:vector_math/vector_math_64.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart index 29ce64daa0ad..822411f45db3 100644 --- a/packages/material_ui/test/shapes/polygon_measure_test.dart +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -5,7 +5,12 @@ import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes.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/polygon_measure.dart'; +import 'package:material_ui/src/shapes/rounded_polygon.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 9880fab9f294..0d14d1d5ddba 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -3,7 +3,11 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes.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'; diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index 5d904002983f..70112018391a 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -3,7 +3,12 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes.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 'package:material_ui/src/shapes/utils.dart'; import 'test_utils.dart'; diff --git a/packages/material_ui/test/shapes/shapes_test.dart b/packages/material_ui/test/shapes/shapes_test.dart index 4221f65d6bc3..11d2b9bb0ff8 100644 --- a/packages/material_ui/test/shapes/shapes_test.dart +++ b/packages/material_ui/test/shapes/shapes_test.dart @@ -5,7 +5,10 @@ import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes.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'; diff --git a/packages/material_ui/test/shapes/test_utils.dart b/packages/material_ui/test/shapes/test_utils.dart index c3f5cab21cd2..8f14e5f2dbd0 100644 --- a/packages/material_ui/test/shapes/test_utils.dart +++ b/packages/material_ui/test/shapes/test_utils.dart @@ -5,7 +5,10 @@ import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/shapes.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; From 729715b8460e929916f35ad478356102f4b568e6 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 22 Aug 2026 17:29:12 +0200 Subject: [PATCH 06/59] Rename Cubic to CubicBezier. --- .../lib/src/material_shape_border.dart | 2 +- .../material_ui/lib/src/shapes/cubic.dart | 91 ++++++++++--------- .../lib/src/shapes/feature_mapping.dart | 2 +- .../material_ui/lib/src/shapes/features.dart | 40 ++++---- .../material_ui/lib/src/shapes/morph.dart | 26 +++--- .../lib/src/shapes/polygon_measure.dart | 30 +++--- .../lib/src/shapes/rounded_polygon.dart | 65 +++++++------ .../material_ui/lib/src/shapes/shapes.dart | 2 +- .../material_ui/test/shapes/cubic_test.dart | 28 +++--- .../test/shapes/features_test.dart | 12 +-- .../material_ui/test/shapes/morph_test.dart | 4 +- .../test/shapes/polygon_measure_test.dart | 8 +- .../material_ui/test/shapes/polygon_test.dart | 10 +- .../test/shapes/rounded_polygon_test.dart | 16 ++-- .../material_ui/test/shapes/shapes_test.dart | 13 ++- .../material_ui/test/shapes/test_utils.dart | 8 +- 16 files changed, 191 insertions(+), 166 deletions(-) diff --git a/packages/material_ui/lib/src/material_shape_border.dart b/packages/material_ui/lib/src/material_shape_border.dart index 4258ee8398e0..504efd066331 100644 --- a/packages/material_ui/lib/src/material_shape_border.dart +++ b/packages/material_ui/lib/src/material_shape_border.dart @@ -49,7 +49,7 @@ class MaterialShapeBorder extends OutlinedBorder { /// Defaults to zero, and must be between zero and one, inclusive. final double squash; - final List _cubics; + final List _cubics; @override ShapeBorder scale(double t) { diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 71d626ec3bf9..cd3ed16ae841 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -18,13 +18,13 @@ import 'utils.dart'; /// and ([control1X], [control1Y]) determining the slope of the curve between /// the anchor points. @immutable -class Cubic { - /// Creates a Cubic that holds the anchor and control point data for a +class CubicBezier { + /// Creates a [CubicBezier] that holds the anchor and control point data for a /// single Bézier curve, with anchor points ([anchor0X], [anchor0Y]) and /// ([anchor1X], [anchor1Y]) at either end and control points ([control0X], /// [control0Y]) and ([control1X], [control1Y]) determining the slope of the /// curve between the anchor points. - Cubic( + CubicBezier( double anchor0X, double anchor0Y, double control0X, @@ -44,15 +44,15 @@ class Cubic { anchor1Y, ]); - /// Creates a Cubic directly from the flat list of its eight anchor and + /// Creates a [CubicBezier] directly from the flat list of its eight anchor and /// control point coordinates, in the order used by [points]. @internal - const Cubic.raw(List points) + const CubicBezier.raw(List points) : assert(points.length == 8, 'Points array size should be 8.'), _points = points; @internal - Cubic.fromPoints(Point anchor0, Point control0, Point control1, Point anchor1) + CubicBezier.fromPoints(Point anchor0, Point control0, Point control1, Point anchor1) : this.raw([ anchor0.x, anchor0.y, @@ -67,8 +67,8 @@ class Cubic { /// Generates a bezier curve that is a straight line between the given anchor /// points. The control points lie 1/3 of the distance from their respective /// anchor points. - factory Cubic.straightLine(double x0, double y0, double x1, double y1) { - return Cubic.raw([ + factory CubicBezier.straightLine(double x0, double y0, double x1, double y1) { + return CubicBezier.raw([ x0, y0, lerp(x0, x1, 1 / 3), @@ -85,7 +85,7 @@ class Cubic { /// 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 the center. - factory Cubic.circularArc( + factory CubicBezier.circularArc( double centerX, double centerY, double x0, @@ -102,7 +102,7 @@ class Cubic { // p0 ~= p1 if (cosa > 0.999) { - return Cubic.straightLine(x0, y0, x1, y1); + return CubicBezier.straightLine(x0, y0, x1, y1); } final double k = @@ -113,7 +113,7 @@ class Cubic { (1 - cosa) * (clockwise ? 1 : -1); - return Cubic( + return CubicBezier( x0, y0, x0 + rotatedP0.x * k, @@ -125,8 +125,8 @@ class Cubic { ); } - /// Generates an empty Cubic defined at (x0, y0). - Cubic.empty(double x0, double y0) : this.raw([x0, y0, x0, y0, x0, y0, x0, y0]); + /// Generates an empty [CubicBezier] defined at (x0, y0). + CubicBezier.empty(double x0, double y0) : this.raw([x0, y0, x0, y0, x0, y0, x0, y0]); final List _points; @@ -172,7 +172,7 @@ class Cubic { (anchor0X - anchor1X).abs() < distanceEpsilon && (anchor0Y - anchor1Y).abs() < distanceEpsilon; - bool convexTo(Cubic next) { + bool convexTo(CubicBezier next) { final prevVertex = Point(anchor0X, anchor0Y); final currVertex = Point(anchor1X, anchor1Y); final nextVertex = Point(next.anchor1X, next.anchor1Y); @@ -310,14 +310,14 @@ class Cubic { bounds[3] = maxY; } - /// Returns two Cubics, created by splitting this curve at the given + /// Returns two [CubicBezier]s, created by splitting this curve at the given /// distance of [t] between the original starting and ending anchor points. - (Cubic, Cubic) split(double t) { + (CubicBezier, CubicBezier) split(double t) { final double u = 1 - t; final Point point = pointOnCurve(t); return ( - Cubic( + CubicBezier( anchor0X, anchor0Y, anchor0X * u + control0X * t, @@ -327,7 +327,7 @@ class Cubic { point.x, point.y, ), - Cubic( + CubicBezier( point.x, point.y, control0X * (u * u) + control1X * (2 * u * t) + anchor1X * (t * t), @@ -341,17 +341,26 @@ class Cubic { } /// Utility function to reverse the control/anchor points for this curve. - Cubic reverse() => - Cubic(anchor1X, anchor1Y, control1X, control1Y, control0X, control0Y, anchor0X, anchor0Y); - - Cubic operator +(Cubic o) => Cubic.raw(List.generate(8, (i) => _points[i] + o._points[i])); - - Cubic operator *(double x) => Cubic.raw(List.generate(8, (i) => _points[i] * x)); - - Cubic operator /(double x) => this * (1.0 / x); - - Cubic transformed(PointTransformer f) { - final newCubic = _MutableCubic(); + CubicBezier reverse() => CubicBezier( + anchor1X, + anchor1Y, + control1X, + control1Y, + control0X, + control0Y, + anchor0X, + anchor0Y, + ); + + CubicBezier operator +(CubicBezier o) => + CubicBezier.raw(List.generate(8, (i) => _points[i] + o._points[i])); + + CubicBezier operator *(double x) => CubicBezier.raw(List.generate(8, (i) => _points[i] * x)); + + CubicBezier operator /(double x) => this * (1.0 / x); + + CubicBezier transformed(PointTransformer f) { + final newCubic = _MutableCubicBezier(); for (var i = 0; i < 8; i++) { newCubic._points[i] = _points[i]; } @@ -373,7 +382,7 @@ class Cubic { return true; } - if (other is! Cubic) { + if (other is! CubicBezier) { return false; } @@ -394,13 +403,13 @@ class Cubic { int get hashCode => _points.hashCode; } -/// Mutable version of [Cubic], used mostly for performance critical paths so -/// we can avoid creating new [Cubic]s +/// Mutable version of [CubicBezier], used mostly for performance critical paths +/// so we can avoid creating new [CubicBezier]s /// -/// This is used in Morph.forEachCubic, reusing a [_MutableCubic] instance to -/// avoid creating new [Cubic]s. -class _MutableCubic extends Cubic { - _MutableCubic() : super.raw(List.filled(8, 0)); +/// This is used in Morph.forEachCubic, reusing a [_MutableCubicBezier] instance +/// to avoid 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]); @@ -415,14 +424,14 @@ class _MutableCubic extends Cubic { _transformOnePoint(f, 6); } - void interpolate(Cubic c1, Cubic c2, double progress) { + void interpolate(CubicBezier c1, CubicBezier c2, double progress) { for (var i = 0; i < 8; i++) { _points[i] = lerp(c1._points[i], c2._points[i], progress); } } } -/// Returns a [Path] for a [Cubic] list. +/// Returns a [Path] for a [CubicBezier] list. /// /// [path] is a [Path] to reset and set with the new path data. /// @@ -438,7 +447,7 @@ class _MutableCubic extends Cubic { /// /// [closePath] is whether or not to close the created [Path]. /// -/// [cubics] is list of [Cubic]s to build path from. +/// [cubics] is list of [CubicBezier]s to build path from. /// /// [rotationPivotX] is the rotation pivot on the X axis. /// @@ -448,12 +457,12 @@ Path pathFromCubics({ required int startAngle, required bool repeatPath, required bool closePath, - required List cubics, + required List cubics, required double rotationPivotX, required double rotationPivotY, }) { var first = true; - Cubic? firstCubic; + CubicBezier? firstCubic; path.reset(); diff --git a/packages/material_ui/lib/src/shapes/feature_mapping.dart b/packages/material_ui/lib/src/shapes/feature_mapping.dart index ec20ba7819c9..4442d0b61b28 100644 --- a/packages/material_ui/lib/src/shapes/feature_mapping.dart +++ b/packages/material_ui/lib/src/shapes/feature_mapping.dart @@ -184,7 +184,7 @@ double featureDistSquared(Feature f1, Feature f2) { } Point featureRepresentativePoint(Feature feature) { - final List cubics = feature.cubics; + 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 index 7aeaf553940d..3b0627b5be9e 100644 --- a/packages/material_ui/lib/src/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/features.dart @@ -2,14 +2,17 @@ // 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 'cubic.dart'; import 'point.dart'; -/// While a polygon's shape can be drawn solely using a list of [Cubic] objects -/// representing its raw curves and lines, features add an extra layer of -/// context to groups of cubics. Features group cubics into (straight) edges, +/// 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: @@ -24,9 +27,9 @@ import 'point.dart'; /// By using features, you can manipulate polygon shapes with more context and /// control. abstract class Feature { - const Feature(List cubics) : _cubics = cubics; + const Feature(List cubics) : _cubics = cubics; - /// Group a list of [Cubic] objects to a feature that should be ignored in + /// Group a list of [CubicBezier] objects to 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. @@ -47,25 +50,26 @@ abstract class Feature { /// squares' outer corners. /// /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. - factory Feature.buildIgnorableFeature(List cubics) => _validated(EdgeFeature(cubics)); + factory Feature.buildIgnorableFeature(List cubics) => + _validated(EdgeFeature(cubics)); - /// Group a [Cubic] object to an edge (neither inward or outward + /// Group a [CubicBezier] object to an edge (neither inward or outward /// identification in a shape). /// /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. - factory Feature.buildEdge(Cubic cubic) => EdgeFeature([cubic]); + factory Feature.buildEdge(CubicBezier cubic) => EdgeFeature([cubic]); - /// Group a list of [Cubic] objects to a convex corner (outward indentation + /// Group a list of [CubicBezier] objects to a convex corner (outward indentation /// in a shape). /// /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics - factory Feature.buildConvexCorner(List cubics) => _validated(CornerFeature(cubics)); + factory Feature.buildConvexCorner(List cubics) => _validated(CornerFeature(cubics)); - /// Group a list of [Cubic] objects to a concave corner (inward indentation + /// Group a list of [CubicBezier] objects to a concave corner (inward indentation /// in a shape). /// /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics - factory Feature.buildConcaveCorner(List cubics) => + factory Feature.buildConcaveCorner(List cubics) => _validated(CornerFeature(cubics, convex: false)); static Feature _validated(Feature feature) { @@ -85,9 +89,9 @@ abstract class Feature { static bool _isContinuous(Feature feature) { const distanceEpsilon = 1e-5; - Cubic prevCubic = feature._cubics.first; + CubicBezier prevCubic = feature._cubics.first; for (var i = 1; i < feature._cubics.length; i++) { - final Cubic cubic = feature._cubics[i]; + final CubicBezier cubic = feature._cubics[i]; if ((cubic.anchor0X - prevCubic.anchor1X).abs() > distanceEpsilon || (cubic.anchor0Y - prevCubic.anchor1Y).abs() > distanceEpsilon) { return false; @@ -97,10 +101,10 @@ abstract class Feature { return true; } - final List _cubics; + final List _cubics; - /// Returns unmodifiable list of [Cubic]. - List get cubics => UnmodifiableListView(_cubics); + /// Returns unmodifiable list of [CubicBezier]. + List get cubics => UnmodifiableListView(_cubics); /// Whether this Feature gets ignored in the Morph mapping. See /// [Feature.buildIgnorableFeature] for more details @@ -129,7 +133,7 @@ abstract class Feature { /// 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 [Cubic] curves). +/// straight lines (represented by [CubicBezier] curves). class EdgeFeature extends Feature { EdgeFeature(super._cubics); diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index 03313fd1b743..87a4b70627f3 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -21,7 +21,7 @@ import 'utils.dart'; /// 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 [Cubic] objects, thus the start and end shapes use similar +/// 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 @@ -41,13 +41,13 @@ class Morph { /// 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. - late final List<(Cubic, Cubic)> _morphMatch; + late 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 Cubic + /// 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 @@ -57,7 +57,7 @@ class Morph { /// 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<(Cubic, Cubic)> _match(RoundedPolygon p1, RoundedPolygon p2) { + 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.measurePolygon(const LengthMeasurer(), p1); @@ -94,7 +94,7 @@ class Morph { // 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 = <(Cubic, Cubic)>[]; + final ret = <(CubicBezier, CubicBezier)>[]; // i1/i2 are the indices of the current cubic on the start (1) and end (2) // shapes. var i1 = 0; @@ -129,7 +129,7 @@ class Morph { b2 = newb2; } - assert(b1 == null && b2 == null, "Expected both Polygon's Cubic to be fully matched"); + assert(b1 == null && b2 == null, "Expected both Polygon's CubicBezier to be fully matched"); return ret; } @@ -191,7 +191,7 @@ class Morph { } /// Returns a representation of the morph object at a given [progress] value - /// as a list of [Cubic]s. Note that this function causes a new list to be + /// as a list of [CubicBezier]s. Note that this function causes a new list to be /// created and populated, so there is some /// overhead. /// @@ -204,18 +204,18 @@ class Morph { /// The range is generally [0..1] and values outside could result in /// undefined shapes, but values close to (but outside) the range can be used /// to get an exaggerated effect (e.g., for a bounce or overshoot animation). - List asCubics(double progress) { - final result = []; + List asCubics(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. - Cubic? firstCubic; - Cubic? lastCubic; + CubicBezier? firstCubic; + CubicBezier? lastCubic; for (var i = 0; i < _morphMatch.length; i++) { - final cubic = Cubic.raw( + final cubic = CubicBezier.raw( List.generate(8, (j) { return lerp(_morphMatch[i].$1.points[j], _morphMatch[i].$2.points[j], progress); }), @@ -230,7 +230,7 @@ class Morph { if (lastCubic != null && firstCubic != null) { result.add( - Cubic( + CubicBezier( lastCubic.anchor0X, lastCubic.anchor0Y, lastCubic.control0X, diff --git a/packages/material_ui/lib/src/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/polygon_measure.dart index 95d966b86e80..acf6a99280c1 100644 --- a/packages/material_ui/lib/src/shapes/polygon_measure.dart +++ b/packages/material_ui/lib/src/shapes/polygon_measure.dart @@ -15,7 +15,7 @@ class MeasuredPolygon { MeasuredPolygon._({ required Measurer measurer, required this._features, - required List cubics, + required List cubics, required List outlineProgress, }) : assert( outlineProgress.length == cubics.length + 1, @@ -48,7 +48,7 @@ class MeasuredPolygon { } factory MeasuredPolygon.measurePolygon(Measurer measurer, RoundedPolygon polygon) { - final cubics = []; + final cubics = []; final featureToCubic = <(Feature, int)>[]; // Get the cubics from the polygon, at the same time, extract the features @@ -123,7 +123,7 @@ class MeasuredPolygon { } /// Finds the point in the input list of measured cubics that pass the given - /// outline progress, and generates a new MeasuredPolygon (equivalent to + /// 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.4f and measured cubics on these @@ -163,7 +163,7 @@ class MeasuredPolygon { // * 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]; + final List retCubics = [b2.cubic]; for (var i = 1; i < _cubics.length; i++) { retCubics.add(_cubics[(i + targetIndex) % _cubics.length].cubic); } @@ -248,7 +248,7 @@ class MeasuredCubic { final Measurer measurer; - final Cubic cubic; + final CubicBezier cubic; late final double measuredSize; @@ -294,12 +294,12 @@ class MeasuredCubic { final double t = measurer.findCubicCutPoint(cubic, relativeProgress * measuredSize); if (t < 0 || t > 1) { - throw ArgumentError('Cubic cut point is expected to be between 0 and 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 (Cubic c1, Cubic c2) = cubic.split(t); + final (CubicBezier c1, CubicBezier c2) = cubic.split(t); return ( MeasuredCubic( measurer: measurer, @@ -332,12 +332,12 @@ abstract interface class 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(Cubic c); + double measureCubic(CubicBezier c); /// 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(Cubic c, double m); + /// 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 c, double m); } /// Approximates the arc lengths of cubics by splitting the arc into segments @@ -349,20 +349,20 @@ class LengthMeasurer implements Measurer { const LengthMeasurer(); // The minimum number needed to achieve up to 98.5% accuracy from the true - // arc length See PolygonMeasureTest.measureCircle + // arc length. static const _segments = 3; @override - double measureCubic(Cubic c) { + double measureCubic(CubicBezier c) { return _closestProgressTo(c, double.infinity).$2; } @override - double findCubicCutPoint(Cubic c, double m) { + double findCubicCutPoint(CubicBezier c, double m) { return _closestProgressTo(c, m).$1; } - (double, double) _closestProgressTo(Cubic cubic, double threshold) { + (double, double) _closestProgressTo(CubicBezier cubic, double threshold) { var total = 0.0; var remainder = threshold; var prev = Point(cubic.anchor0X, cubic.anchor0Y); diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 97e809bf7a4e..c832e905f702 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -2,6 +2,9 @@ // 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'; @@ -18,14 +21,14 @@ import 'utils.dart'; /// either the number of vertices desired or an ordered list of vertices. @immutable class RoundedPolygon { - RoundedPolygon._(this.features, this.center) : cubics = [] { + RoundedPolygon._(this.features, this.center) : cubics = [] { _initCubics(); assert(() { - Cubic prevCubic = cubics[cubics.length - 1]; + CubicBezier prevCubic = cubics[cubics.length - 1]; for (var index = 0; index < cubics.length; index++) { - final Cubic cubic = cubics[index]; + final CubicBezier cubic = cubics[index]; if ((cubic.anchor0X - prevCubic.anchor1X).abs() > distanceEpsilon || (cubic.anchor0Y - prevCubic.anchor1Y).abs() > distanceEpsilon) { @@ -110,7 +113,7 @@ class RoundedPolygon { /// This function takes the vertices (either supplied or calculated, /// depending on the constructor called), plus [CornerRounding] parameters, /// and creates the actual [RoundedPolygon] shape, rounding around the - /// vertices (or not) as specified. The result is a list of [Cubic] curves + /// vertices (or not) as specified. The result is a list of [CubicBezier] curves /// which represent the geometry of the final shape. /// /// [vertices] is the list of vertices in this polygon specified as pairs of @@ -162,7 +165,7 @@ class RoundedPolygon { 'the same size as the number of vertices (vertices.size / 2).', ); } - final corners = >[]; + final corners = >[]; final int n = vertices.length ~/ 2; final roundedCorners = <_RoundedCorner>[]; for (var i = 0; i < n; i++) { @@ -244,7 +247,7 @@ class RoundedPolygon { ..add(CornerFeature(corners[i], convex: cvx)) ..add( EdgeFeature([ - Cubic.straightLine( + CubicBezier.straightLine( corners[i].last.anchor1X, corners[i].last.anchor1Y, corners[(i + 1) % n].first.anchor0X, @@ -270,7 +273,7 @@ class RoundedPolygon { } /// Takes a list of [Feature] objects that define the polygon's shape and - /// curves. By specifying the features directly, the summarization of [Cubic] + /// curves. By specifying the features directly, the summarization of [CubicBezier] /// objects to curves can be precisely controlled. This affects [Morph]'s /// default mapping, as curves with the same type (convex or concave) are /// mapped with each other. For example, if you have a convex curve in your @@ -308,7 +311,7 @@ class RoundedPolygon { final vertices = []; for (final feature in features) { - for (final Cubic cubic in feature.cubics) { + for (final CubicBezier cubic in feature.cubics) { vertices ..add(cubic.anchor0X) ..add(cubic.anchor0Y); @@ -684,8 +687,8 @@ class RoundedPolygon { final Point center; - /// A flattened version of the [Feature]s, as a `List`. - final List cubics; + /// A flattened version of the [Feature]s, as a `List`. + final List cubics; double get centerX => center.x; @@ -696,14 +699,14 @@ class RoundedPolygon { // 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. - Cubic? firstCubic; - Cubic? lastCubic; - List? firstFeatureSplitStart; - List? firstFeatureSplitEnd; + CubicBezier? firstCubic; + CubicBezier? lastCubic; + List? firstFeatureSplitStart; + List? firstFeatureSplitEnd; if (features.isNotEmpty && features[0].cubics.length == 3) { - final Cubic centerCubic = features[0].cubics[1]; - final (Cubic start, Cubic end) = centerCubic.split(0.5); + 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]]; } @@ -711,7 +714,7 @@ class RoundedPolygon { // 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; + final List featureCubics; if (i == 0 && firstFeatureSplitEnd != null) { featureCubics = firstFeatureSplitEnd; @@ -728,7 +731,7 @@ class RoundedPolygon { for (var j = 0; j < featureCubics.length; j++) { // Skip zero-length curves; they add nothing and can trigger rendering // artifacts. - final Cubic cubic = featureCubics[j]; + final CubicBezier cubic = featureCubics[j]; if (!cubic.zeroLength()) { if (lastCubic != null) { @@ -745,7 +748,7 @@ class RoundedPolygon { final List points = lastCubic.points.toList(); points[6] = cubic.anchor1X; points[7] = cubic.anchor1Y; - lastCubic = Cubic.raw(points); + lastCubic = CubicBezier.raw(points); } } } @@ -753,7 +756,7 @@ class RoundedPolygon { if (lastCubic != null && firstCubic != null) { cubics.add( - Cubic( + CubicBezier( lastCubic.anchor0X, lastCubic.anchor0Y, lastCubic.control0X, @@ -766,7 +769,9 @@ class RoundedPolygon { ); } else { // Empty / 0-sized polygon. - cubics.add(Cubic(centerX, centerY, centerX, centerY, centerX, centerY, centerX, centerY)); + cubics.add( + CubicBezier(centerX, centerY, centerX, centerY, centerX, centerY, centerX, centerY), + ); } } @@ -822,7 +827,7 @@ class RoundedPolygon { var maxDistSquared = 0.0; for (var i = 0; i < cubics.length; i++) { - final Cubic cubic = cubics[i]; + final CubicBezier cubic = cubics[i]; final double anchorDistance = distanceSquared( cubic.anchor0X - centerX, cubic.anchor0Y - centerY, @@ -980,7 +985,7 @@ Point calculateCenter(List vertices) { /// parameter. /// /// If rounding is null, there is no rounding; the corner will simply be a -/// single point at [p1]. This point will be represented by a [Cubic] of length +/// single point at [p1]. This point will be represented by a [CubicBezier] of length /// 0 at that point. /// /// If rounding is not null, the corner will be rounded either with a curve @@ -1072,7 +1077,7 @@ class _RoundedCorner { /// The center is the same as [p0] if there is no rounding. Point center = Point.zero; - List getCubics(double allowedCut0, double allowedCut1) { + 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); @@ -1082,7 +1087,7 @@ class _RoundedCorner { allowedCut < distanceEpsilon || cornerRadius < distanceEpsilon) { center = p1; - return [Cubic.straightLine(p1.x, p1.y, p1.x, p1.y)]; + return [CubicBezier.straightLine(p1.x, p1.y, p1.x, p1.y)]; } // How much of the cut is required for the rounding part. @@ -1101,7 +1106,7 @@ class _RoundedCorner { center = p1 + ((d1 + d2) / 2).getDirection() * centerDistance; final Point circleIntersection0 = p1 + d1 * actualRoundCut; final Point circleIntersection2 = p1 + d2 * actualRoundCut; - final Cubic flanking0 = _computeFlankingCurve( + final CubicBezier flanking0 = _computeFlankingCurve( actualRoundCut, actualSmoothing0, p1, @@ -1111,7 +1116,7 @@ class _RoundedCorner { center, actualR, ); - final Cubic flanking2 = _computeFlankingCurve( + final CubicBezier flanking2 = _computeFlankingCurve( actualRoundCut, actualSmoothing1, p1, @@ -1124,7 +1129,7 @@ class _RoundedCorner { return [ flanking0, - Cubic.circularArc( + CubicBezier.circularArc( center.x, center.y, flanking0.anchor1X, @@ -1177,7 +1182,7 @@ class _RoundedCorner { /// /// Returns a Bezier cubic curve that connects from the (cut) linear side /// and the (cut) circular segment in a smooth way. - Cubic _computeFlankingCurve( + CubicBezier _computeFlankingCurve( double actualRoundCut, double actualSmoothingValues, Point corner, @@ -1216,7 +1221,7 @@ class _RoundedCorner { // 2/3 seems to come from design tools? final Point anchorStart = (curveStart + anchorEnd * 2) / 3; - return Cubic.fromPoints(curveStart, anchorStart, anchorEnd, curveEnd); + return CubicBezier.fromPoints(curveStart, anchorStart, anchorEnd, curveEnd); } /// Returns the intersection point of the two lines d0->d1 and p0->p1, or diff --git a/packages/material_ui/lib/src/shapes/shapes.dart b/packages/material_ui/lib/src/shapes/shapes.dart index be6f435d4b8a..b6259748220b 100644 --- a/packages/material_ui/lib/src/shapes/shapes.dart +++ b/packages/material_ui/lib/src/shapes/shapes.dart @@ -10,7 +10,7 @@ library; export 'corner_rounding.dart' show CornerRounding; -export 'cubic.dart' show Cubic; +export 'cubic.dart' show CubicBezier; export 'features.dart' show Feature; export 'morph.dart' show Morph; export 'point.dart' show PointTransformer; diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index e14dc0e13e50..2713f5440a05 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -11,7 +11,7 @@ import 'package:material_ui/src/shapes/point.dart'; import 'test_utils.dart'; void main() { - group('$Cubic', () { + group('$CubicBezier', () { // These points create a roughly circular arc in the upper-right quadrant // around (0,0). const Point zero = Point.zero; @@ -19,7 +19,7 @@ void main() { const p1 = Point(1, 0.5); const p2 = Point(0.5, 1); const p3 = Point(0, 1); - final cubic = Cubic.fromPoints(p0, p1, p2, p3); + final cubic = CubicBezier.fromPoints(p0, p1, p2, p3); test('fromPoints', () { expect(p0, Point(cubic.anchor0X, cubic.anchor0Y)); @@ -29,13 +29,13 @@ void main() { }); test('circularArc', () { - final arcCubic = Cubic.circularArc(zero.x, zero.y, p0.x, p0.y, p3.x, p3.y); + final arcCubic = CubicBezier.circularArc(zero.x, zero.y, p0.x, p0.y, p3.x, p3.y); expect(p0, Point(arcCubic.anchor0X, arcCubic.anchor0Y)); expect(p3, Point(arcCubic.anchor1X, arcCubic.anchor1Y)); }); test('div', () { - Cubic divCubic = cubic / 1; + CubicBezier divCubic = cubic / 1; expectCubicsEqualish(cubic, divCubic); divCubic = cubic / 1; expectCubicsEqualish(cubic, divCubic); @@ -52,7 +52,7 @@ void main() { }); test('times', () { - Cubic timesCubic = cubic * 1; + CubicBezier timesCubic = cubic * 1; expect(p0, Point(timesCubic.anchor0X, timesCubic.anchor0Y)); expect(p1, Point(timesCubic.control0X, timesCubic.control0Y)); expect(p2, Point(timesCubic.control1X, timesCubic.control1Y)); @@ -75,8 +75,8 @@ void main() { }); test('plus', () { - final Cubic offsetCubic = cubic * 2; - final Cubic plusCubic = cubic + offsetCubic; + final CubicBezier offsetCubic = cubic * 2; + final CubicBezier plusCubic = cubic + offsetCubic; expectPointsEqualish( p0 + Point(offsetCubic.anchor0X, offsetCubic.anchor0Y), Point(plusCubic.anchor0X, plusCubic.anchor0Y), @@ -96,7 +96,7 @@ void main() { }); test('reverse', () { - final Cubic reverseCubic = cubic.reverse(); + final CubicBezier reverseCubic = cubic.reverse(); expect(p3, Point(reverseCubic.anchor0X, reverseCubic.anchor0Y)); expect(p2, Point(reverseCubic.control0X, reverseCubic.control0Y)); expect(p1, Point(reverseCubic.control1X, reverseCubic.control1Y)); @@ -115,7 +115,7 @@ void main() { } test('straightLine', () { - final lineCubic = Cubic.straightLine(p0.x, p0.y, p3.x, p3.y); + final lineCubic = CubicBezier.straightLine(p0.x, p0.y, p3.x, p3.y); expect(p0, Point(lineCubic.anchor0X, lineCubic.anchor0Y)); expect(p3, Point(lineCubic.anchor1X, lineCubic.anchor1Y)); expectBetween(p0, p3, Point(lineCubic.control0X, lineCubic.control0Y)); @@ -123,7 +123,7 @@ void main() { }); test('split', () { - final (Cubic split0, Cubic split1) = cubic.split(0.5); + final (CubicBezier split0, CubicBezier split1) = cubic.split(0.5); expect(Point(cubic.anchor0X, cubic.anchor0Y), Point(split0.anchor0X, split0.anchor0Y)); expect(Point(cubic.anchor1X, cubic.anchor1Y), Point(split1.anchor1X, split1.anchor1Y)); expectBetween( @@ -145,7 +145,7 @@ void main() { Point(cubic.anchor1X, cubic.anchor1Y), halfway, ); - final straightLineCubic = Cubic.straightLine(p0.x, p0.y, p3.x, p3.y); + final straightLineCubic = CubicBezier.straightLine(p0.x, p0.y, p3.x, p3.y); halfway = straightLineCubic.pointOnCurve(0.5); final computedHalfway = Point(p0.x + 0.5 * (p3.x - p0.x), p0.y + 0.5 * (p3.y - p0.y)); expectPointsEqualish(computedHalfway, halfway); @@ -153,7 +153,7 @@ void main() { test('transform', () { PointTransformer transform = identityTransform(); - Cubic transformedCubic = cubic.transformed(transform); + CubicBezier transformedCubic = cubic.transformed(transform); expectCubicsEqualish(cubic, transformedCubic); transform = scaleTransform(3, 3); @@ -183,8 +183,8 @@ void main() { ); }); - test('empty Cubic has zero length', () { - expect(Cubic.empty(10, 10).zeroLength(), isTrue); + test('empty CubicBezier has zero length', () { + expect(CubicBezier.empty(10, 10).zeroLength(), isTrue); }); }); } diff --git a/packages/material_ui/test/shapes/features_test.dart b/packages/material_ui/test/shapes/features_test.dart index 800b57146295..07100f2408d8 100644 --- a/packages/material_ui/test/shapes/features_test.dart +++ b/packages/material_ui/test/shapes/features_test.dart @@ -17,8 +17,8 @@ void main() { }); test('Cannot build non continuous features', () { - final cubic1 = Cubic.straightLine(0, 0, 1, 1); - final cubic2 = Cubic.straightLine(10, 10, 11, 11); + final cubic1 = CubicBezier.straightLine(0, 0, 1, 1); + final cubic2 = CubicBezier.straightLine(10, 10, 11, 11); expect(() => Feature.buildConvexCorner([cubic1, cubic2]), throwsArgumentError); expect(() => Feature.buildConcaveCorner([cubic1, cubic2]), throwsArgumentError); @@ -26,28 +26,28 @@ void main() { }); test('Builds concave corner', () { - final cubic = Cubic.straightLine(0, 0, 1, 0); + final cubic = CubicBezier.straightLine(0, 0, 1, 0); final actual = Feature.buildConcaveCorner([cubic]); final expected = CornerFeature([cubic], convex: false); expectFeaturesEqualish(expected, actual); }); test('Builds convex corner', () { - final cubic = Cubic.straightLine(0, 0, 1, 0); + final cubic = CubicBezier.straightLine(0, 0, 1, 0); final actual = Feature.buildConvexCorner([cubic]); final expected = CornerFeature([cubic], convex: true); expectFeaturesEqualish(expected, actual); }); test('Builds edge', () { - final cubic = Cubic.straightLine(0, 0, 1, 0); + final cubic = CubicBezier.straightLine(0, 0, 1, 0); final actual = Feature.buildEdge(cubic); final expected = EdgeFeature([cubic]); expectFeaturesEqualish(expected, actual); }); test('Builds ignorable as edge', () { - final cubic = Cubic.straightLine(0, 0, 1, 0); + final cubic = CubicBezier.straightLine(0, 0, 1, 0); final actual = Feature.buildIgnorableFeature([cubic]); final expected = EdgeFeature([cubic]); expectFeaturesEqualish(expected, actual); diff --git a/packages/material_ui/test/shapes/morph_test.dart b/packages/material_ui/test/shapes/morph_test.dart index b70f96c5c896..ec0477168e01 100644 --- a/packages/material_ui/test/shapes/morph_test.dart +++ b/packages/material_ui/test/shapes/morph_test.dart @@ -26,8 +26,8 @@ void main() { // 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.asCubics(0); + final List p1Cubics = poly1.cubics; + final List cubics11 = morph11.asCubics(0); expect(cubics11, isNotEmpty); // The structure of a morph and its component shapes may not match diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart index 822411f45db3..c209b65d3c52 100644 --- a/packages/material_ui/test/shapes/polygon_measure_test.dart +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -198,11 +198,11 @@ void main() { test('handles empty feature last', () { final triangle = RoundedPolygon.fromFeatures([ - Feature.buildConvexCorner([Cubic.straightLine(0, 0, 1, 1)]), - Feature.buildConvexCorner([Cubic.straightLine(1, 1, 1, 0)]), - Feature.buildConvexCorner([Cubic.straightLine(1, 0, 0, 0)]), + Feature.buildConvexCorner([CubicBezier.straightLine(0, 0, 1, 1)]), + Feature.buildConvexCorner([CubicBezier.straightLine(1, 1, 1, 0)]), + Feature.buildConvexCorner([CubicBezier.straightLine(1, 0, 0, 0)]), // Empty feature at the end. - Feature.buildConvexCorner([Cubic.straightLine(0, 0, 0, 0)]), + Feature.buildConvexCorner([CubicBezier.straightLine(0, 0, 0, 0)]), ]); irregularPolygonMeasure(triangle); diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 0d14d1d5ddba..1059590427df 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -133,9 +133,9 @@ void main() { // 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 List squareCubics = square.cubics; final PointTransformer translator = translateTransform(offset.x, offset.y); - final List translatedSquareCubics = square.transformed(translator).cubics; + final List translatedSquareCubics = square.transformed(translator).cubics; for (var i = 0; i < squareCubics.length; i++) { expectPointsEqualish( @@ -158,7 +158,7 @@ void main() { }); test('features', () { - List nonZeroCubics(List original) { + List nonZeroCubics(List original) { return original.where((c) => !c.zeroLength()).toList(); } @@ -168,7 +168,9 @@ void main() { // 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. - List nonzeroCubics = nonZeroCubics(squareFeatures.expand((f) => f.cubics).toList()); + List nonzeroCubics = nonZeroCubics( + squareFeatures.expand((f) => f.cubics).toList(), + ); expectCubicListsEqualish(square.cubics, nonzeroCubics); // Same as the first polygon test, but with a copy of that polygon. diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index 70112018391a..3fbda07bb795 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -94,15 +94,15 @@ void main() { expect(() => RoundedPolygon.fromFeatures(const []), throwsArgumentError); expect( () => RoundedPolygon.fromFeatures([ - CornerFeature([Cubic.empty(0, 0)]), + CornerFeature([CubicBezier.empty(0, 0)]), ]), throwsArgumentError, ); }); test('throws for non continuous features', () { - final cubic1 = Cubic.straightLine(0, 0, 1, 0); - final cubic2 = Cubic.straightLine(10, 10, 20, 20); + final cubic1 = CubicBezier.straightLine(0, 0, 1, 0); + final cubic2 = CubicBezier.straightLine(10, 10, 20, 20); expect( () => RoundedPolygon.fromFeatures([Feature.buildEdge(cubic1), Feature.buildEdge(cubic2)]), throwsArgumentError, @@ -201,7 +201,7 @@ void main() { final Feature lowerEdgeFeature = polygon.features.firstWhere((f) => f is EdgeFeature); expect(1, lowerEdgeFeature.cubics.length); - final Cubic lowerEdge = lowerEdgeFeature.cubics.first; + final CubicBezier lowerEdge = lowerEdgeFeature.cubics.first; expectEqualish(0.5, lowerEdge.anchor0X); expectEqualish(0, lowerEdge.anchor0Y); expectEqualish(0.5, lowerEdge.anchor1X); @@ -337,13 +337,13 @@ void main() { innerRounding: const CornerRounding(radius: roundingFactor), ); - final List cubics = canonicalShape.cubics; - final List cubics1 = fullSizeShape.cubics; + final List cubics = canonicalShape.cubics; + final List cubics1 = fullSizeShape.cubics; expect(cubics.length, cubics1.length); for (var i = 0; i < cubics.length; i++) { - final Cubic cubic = cubics[i]; - final Cubic cubic1 = cubics1[i]; + final CubicBezier cubic = cubics[i]; + final CubicBezier cubic1 = cubics1[i]; expectEqualish(cubic.anchor0X, cubic1.anchor0X); expectEqualish(cubic.anchor0Y, cubic1.anchor0Y); diff --git a/packages/material_ui/test/shapes/shapes_test.dart b/packages/material_ui/test/shapes/shapes_test.dart index 11d2b9bb0ff8..f95bc79bcbed 100644 --- a/packages/material_ui/test/shapes/shapes_test.dart +++ b/packages/material_ui/test/shapes/shapes_test.dart @@ -35,7 +35,12 @@ void main() { } } - void expectCubicOnRadii(Cubic cubic, double radius1, [double? radius2, Point center = zero]) { + void expectCubicOnRadii( + CubicBezier cubic, + double radius1, [ + double? radius2, + Point center = zero, + ]) { expectPointOnRadii(Point(cubic.anchor0X, cubic.anchor0Y), radius1, radius2, center); expectPointOnRadii(Point(cubic.anchor1X, cubic.anchor1Y), radius1, radius2, center); } @@ -44,7 +49,7 @@ void main() { // 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(Cubic cubic, double radius, Point center) { + void expectCircularCubic(CubicBezier cubic, double radius, Point center) { var t = 0.0; while (t <= 1) { final Point pointOnCurve = cubic.pointOnCurve(t); @@ -54,7 +59,7 @@ void main() { } } - void expectCircleShape(List shape, {double radius = 1, Point center = zero}) { + void expectCircleShape(List shape, {double radius = 1, Point center = zero}) { for (final cubic in shape) { expectCircularCubic(cubic, radius, center); } @@ -85,7 +90,7 @@ void main() { // versions, just check that the shape is within the appropriate bounds. test('star', () { var star = RoundedPolygon.star(numVerticesPerRadius: 4, innerRadius: 0.5); - List shape = star.cubics; + List shape = star.cubics; var radius = 1.0; var innerRadius = 0.5; diff --git a/packages/material_ui/test/shapes/test_utils.dart b/packages/material_ui/test/shapes/test_utils.dart index 8f14e5f2dbd0..bd3f95f22d5b 100644 --- a/packages/material_ui/test/shapes/test_utils.dart +++ b/packages/material_ui/test/shapes/test_utils.dart @@ -21,7 +21,7 @@ bool pointsEqualish(Point p0, Point p1) { return equalish(p0.x, p1.x, _epsilon) && equalish(p0.y, p1.y, _epsilon); } -bool cubicsEqualish(Cubic c0, Cubic c1) { +bool cubicsEqualish(CubicBezier c0, CubicBezier c1) { return pointsEqualish(Point(c0.anchor0X, c0.anchor0Y), Point(c1.anchor0X, c1.anchor0Y)) && pointsEqualish(Point(c0.anchor1X, c0.anchor1Y), Point(c1.anchor1X, c1.anchor1Y)) && pointsEqualish(Point(c0.control0X, c0.control0Y), Point(c1.control0X, c1.control0Y)) && @@ -35,7 +35,7 @@ void expectPointsEqualish(Point expected, Point actual) { expect(expected.y, moreOrLessEquals(actual.y, epsilon: _epsilon), reason: msg); } -void expectCubicsEqualish(Cubic expected, Cubic actual) { +void expectCubicsEqualish(CubicBezier expected, CubicBezier actual) { expectPointsEqualish( Point(expected.anchor0X, expected.anchor0Y), Point(actual.anchor0X, actual.anchor0Y), @@ -54,7 +54,7 @@ void expectCubicsEqualish(Cubic expected, Cubic actual) { ); } -void expectCubicListsEqualish(List expected, List actual) { +void expectCubicListsEqualish(List expected, List actual) { expect(expected.length, actual.length); for (var i = 0; i < expected.length; i++) { expectCubicsEqualish(expected[i], actual[i]); @@ -93,7 +93,7 @@ void expectEqualish(double expected, double actual, [String? message]) { expect(expected, moreOrLessEquals(actual, epsilon: _epsilon), reason: message); } -void expectInBounds(List shape, Point minPoint, Point maxPoint) { +void expectInBounds(List shape, Point minPoint, Point maxPoint) { for (final cubic in shape) { expectPointGreaterish(minPoint, Point(cubic.anchor0X, cubic.anchor0Y)); expectPointLessish(maxPoint, Point(cubic.anchor0X, cubic.anchor0Y)); From 07a87d1f1fc9396624d9e815d059161ee074c9af Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 23 Aug 2026 12:24:35 +0200 Subject: [PATCH 07/59] Align shapes visibility with the AndroidX public API. --- .../lib/src/material_shape_border.dart | 10 +--- .../material_ui/lib/src/shapes/cubic.dart | 50 +++++++++++++++---- .../lib/src/shapes/feature_mapping.dart | 21 ++++++++ .../material_ui/lib/src/shapes/features.dart | 7 +++ .../lib/src/shapes/float_mapping.dart | 7 +++ .../material_ui/lib/src/shapes/morph.dart | 4 +- .../material_ui/lib/src/shapes/point.dart | 28 +++++++++++ .../lib/src/shapes/polygon_measure.dart | 13 ++++- .../lib/src/shapes/rounded_polygon.dart | 15 ++++-- .../material_ui/lib/src/shapes/shapes.dart | 4 +- .../material_ui/lib/src/shapes/utils.dart | 38 +++++++------- .../test/shapes/rounded_polygon_test.dart | 7 +-- 12 files changed, 153 insertions(+), 51 deletions(-) diff --git a/packages/material_ui/lib/src/material_shape_border.dart b/packages/material_ui/lib/src/material_shape_border.dart index 504efd066331..2989b7e41b8f 100644 --- a/packages/material_ui/lib/src/material_shape_border.dart +++ b/packages/material_ui/lib/src/material_shape_border.dart @@ -170,15 +170,7 @@ class MaterialShapeBorder extends OutlinedBorder { ..translate(actualRect.left, actualRect.top) ..scale(scale.dx, scale.dy); - return pathFromCubics( - path: Path(), - startAngle: 0, - repeatPath: false, - closePath: true, - cubics: _cubics, - rotationPivotX: 0, - rotationPivotY: 0, - ).transform(matrix.storage); + return pathFromCubics(cubics: _cubics).transform(matrix.storage); } @override diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index cd3ed16ae841..316a67e96396 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -2,6 +2,10 @@ // 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'; @@ -126,10 +130,19 @@ class CubicBezier { } /// Generates an empty [CubicBezier] defined at (x0, y0). + /// + /// Both anchor points and both control points coincide, so the curve has + /// zero length. See [zeroLength]. CubicBezier.empty(double x0, double y0) : this.raw([x0, y0, x0, y0, x0, y0, x0, y0]); 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); double get anchor0X => _points[0]; @@ -154,6 +167,7 @@ class CubicBezier { /// /// [t] is the distance along the curve between the anchor points, where 0 /// is at anchor0 and 1 is at anchor1 + @internal Point pointOnCurve(double t) { final double u = 1 - t; return Point( @@ -168,10 +182,18 @@ class CubicBezier { ); } + /// 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 zeroLength() => (anchor0X - anchor1X).abs() < distanceEpsilon && (anchor0Y - anchor1Y).abs() < distanceEpsilon; + /// Whether the corner formed by this curve and [next] turns convexly. + @internal bool convexTo(CubicBezier next) { final prevVertex = Point(anchor0X, anchor0Y); final currVertex = Point(anchor1X, anchor1Y); @@ -184,6 +206,7 @@ class CubicBezier { /// Returns the true bounds of this curve, filling [bounds] with the /// axis-aligned bounding box values for left, top, right, and bottom, /// in that order. + @internal void calculateBounds(List bounds, {bool approximate = false}) { assert(bounds.length == 4, 'Bounds array size should be 4.'); @@ -400,7 +423,7 @@ class CubicBezier { } @override - int get hashCode => _points.hashCode; + int get hashCode => Object.hashAll(_points); } /// Mutable version of [CubicBezier], used mostly for performance critical paths @@ -431,9 +454,14 @@ class _MutableCubicBezier extends CubicBezier { } } -/// Returns a [Path] for a [CubicBezier] list. +/// 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.asCubics] directly. /// -/// [path] is a [Path] to reset and set with the new path data. +/// [path] is a [Path] to reset and set with the new path data. A new [Path] is +/// created when none is given. /// /// [startAngle] is an angle (in degrees) to rotate the [Path] to start /// drawing from. If [startAngle] is non zero, then caller has to use the @@ -447,20 +475,20 @@ class _MutableCubicBezier extends CubicBezier { /// /// [closePath] is whether or not to close the created [Path]. /// -/// [cubics] is list of [CubicBezier]s to build path from. -/// /// [rotationPivotX] is the rotation pivot on the X axis. /// /// [rotationPivotY] is the rotation pivot on the Y axis. Path pathFromCubics({ - required Path path, - required int startAngle, - required bool repeatPath, - required bool closePath, required List cubics, - required double rotationPivotX, - required double rotationPivotY, + Path? path, + int startAngle = 0, + bool repeatPath = false, + bool closePath = true, + double rotationPivotX = 0, + double rotationPivotY = 0, }) { + path ??= Path(); + var first = true; CubicBezier? firstCubic; diff --git a/packages/material_ui/lib/src/shapes/feature_mapping.dart b/packages/material_ui/lib/src/shapes/feature_mapping.dart index 4442d0b61b28..dfed7d3e1c0e 100644 --- a/packages/material_ui/lib/src/shapes/feature_mapping.dart +++ b/packages/material_ui/lib/src/shapes/feature_mapping.dart @@ -2,6 +2,8 @@ // 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 'features.dart'; import 'float_mapping.dart'; @@ -10,27 +12,41 @@ import 'utils.dart'; /// MeasuredFeatures contains a list of all features in a polygon along with /// the [0..1] progress at that feature. +@internal typedef MeasuredFeatures = List; +/// 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(MeasuredFeatures features1, MeasuredFeatures features2) { // We only use corners for this mapping. final filteredFeatures1 = []; @@ -70,6 +86,7 @@ DoubleMapper featureMapper(MeasuredFeatures features1, MeasuredFeatures features /// the second elements of each pair are monotonically increasing, except /// maybe one time (Counting all pair of consecutive elements, and the /// last element to first element). +@internal List<(double, double)> doMapping( List features1, List features2, @@ -172,6 +189,7 @@ class _MappingHelper { /// Returns distance along overall shape between 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 so, the approach below will not work if (f1 is CornerFeature && f2 is CornerFeature && f1.convex != f2.convex) { @@ -183,6 +201,9 @@ double featureDistSquared(Feature f1, Feature f2) { return (featureRepresentativePoint(f1) - featureRepresentativePoint(f2)).getDistanceSquared(); } +/// 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; diff --git a/packages/material_ui/lib/src/shapes/features.dart b/packages/material_ui/lib/src/shapes/features.dart index 3b0627b5be9e..48e3405e702c 100644 --- a/packages/material_ui/lib/src/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/features.dart @@ -7,6 +7,8 @@ library; import 'dart:collection'; +import 'package:flutter/foundation.dart'; + import 'cubic.dart'; import 'point.dart'; @@ -134,7 +136,9 @@ abstract class Feature { /// 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. EdgeFeature(super._cubics); @override @@ -168,9 +172,12 @@ class EdgeFeature extends Feature { /// 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}); + /// Whether this corner is convex. final bool convex; @override diff --git a/packages/material_ui/lib/src/shapes/float_mapping.dart b/packages/material_ui/lib/src/shapes/float_mapping.dart index cbf4c0ce1b19..260dd5c84904 100644 --- a/packages/material_ui/lib/src/shapes/float_mapping.dart +++ b/packages/material_ui/lib/src/shapes/float_mapping.dart @@ -4,6 +4,8 @@ import 'dart:math' as math; +import 'package:flutter/foundation.dart'; + import 'utils.dart'; /// Checks if the given progress is in the given progress range. @@ -11,6 +13,7 @@ import 'utils.dart'; /// 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; @@ -21,6 +24,7 @@ bool progressInRange(double progress, double progressFrom, double 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'); @@ -73,6 +77,7 @@ double linearMap(List xValues, List yValues, double x) { /// [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); @@ -103,6 +108,7 @@ class DoubleMapper { /// 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.'); @@ -135,6 +141,7 @@ void validateProgress(List p) { /// 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/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index 87a4b70627f3..1bac1c82307d 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -287,11 +287,11 @@ class Morph { Path? path, }) { return pathFromCubics( - path: path ?? Path(), + cubics: asCubics(progress), + path: path, startAngle: startAngle, repeatPath: repeatPath, closePath: closePath, - cubics: asCubics(progress), rotationPivotX: rotationPivotX, rotationPivotY: rotationPivotY, ); diff --git a/packages/material_ui/lib/src/shapes/point.dart b/packages/material_ui/lib/src/shapes/point.dart index 64eecae18380..8746721ae099 100644 --- a/packages/material_ui/lib/src/shapes/point.dart +++ b/packages/material_ui/lib/src/shapes/point.dart @@ -2,13 +2,24 @@ // 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 '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); +@internal @immutable class Point { const Point(this.x, this.y); @@ -137,7 +148,24 @@ class Point { int get hashCode => Object.hashAll([x, y]); } +/// 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)); diff --git a/packages/material_ui/lib/src/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/polygon_measure.dart index acf6a99280c1..9fd4a75d1e6d 100644 --- a/packages/material_ui/lib/src/shapes/polygon_measure.dart +++ b/packages/material_ui/lib/src/shapes/polygon_measure.dart @@ -4,6 +4,8 @@ import 'dart:collection'; +import 'package:flutter/foundation.dart'; + import 'cubic.dart'; import 'feature_mapping.dart'; import 'features.dart'; @@ -11,6 +13,9 @@ 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, @@ -222,6 +227,7 @@ class MeasuredPolygon { /// /// 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, @@ -280,7 +286,8 @@ class MeasuredCubic { // 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 = cutOutlineProgress.coerceIn( + final double boundedCutOutlineProgress = clampDouble( + cutOutlineProgress, _startOutlineProgress, _endOutlineProgress, ); @@ -326,7 +333,9 @@ class MeasuredCubic { /// 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 @@ -345,7 +354,9 @@ abstract interface class Measurer { /// 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 diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index c832e905f702..0af82801d932 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -297,7 +297,6 @@ class RoundedPolygon { /// /// Throws [ArgumentError] if [features] length is less than 2 or if they /// don't describe a closed shape. - @internal factory RoundedPolygon.fromFeatures( List features, { double centerX = double.nan, @@ -683,15 +682,20 @@ class RoundedPolygon { ); } + /// The [Feature]s this polygon is composed of. final List features; + /// The center of this polygon, around which all vertices are placed. + @internal final Point center; /// A flattened version of the [Feature]s, as a `List`. final List cubics; + /// The X coordinate of the center of this polygon. double get centerX => center.x; + /// The Y coordinate of the center of this polygon. double get centerY => center.y; void _initCubics() { @@ -913,11 +917,11 @@ class RoundedPolygon { /// [closePath] is whether or not to close the created [Path]. Path toPath({int startAngle = 0, bool repeatPath = false, bool closePath = true, Path? path}) { return pathFromCubics( - path: path ?? Path(), + cubics: cubics, + path: path, startAngle: startAngle, repeatPath: repeatPath, closePath: closePath, - cubics: cubics, rotationPivotX: centerX, rotationPivotY: centerY, ); @@ -967,6 +971,7 @@ class RoundedPolygon { /// 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; @@ -1282,8 +1287,8 @@ List _pillStarVerticesFromNumVerts( // length zero (whichever dimension is smaller gets only circular curvature // for the pill shape). final double endcapRadius = math.min(width, height); - final double vSegLen = (height - width).coerceAtLeast(0); - final double hSegLen = (width - height).coerceAtLeast(0); + 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 diff --git a/packages/material_ui/lib/src/shapes/shapes.dart b/packages/material_ui/lib/src/shapes/shapes.dart index b6259748220b..a15657c90ed8 100644 --- a/packages/material_ui/lib/src/shapes/shapes.dart +++ b/packages/material_ui/lib/src/shapes/shapes.dart @@ -10,8 +10,8 @@ library; export 'corner_rounding.dart' show CornerRounding; -export 'cubic.dart' show CubicBezier; +export 'cubic.dart' show CubicBezier, pathFromCubics; export 'features.dart' show Feature; export 'morph.dart' show Morph; -export 'point.dart' show PointTransformer; +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 index c028a5dbed11..2e870909a2fe 100644 --- a/packages/material_ui/lib/src/shapes/utils.dart +++ b/packages/material_ui/lib/src/shapes/utils.dart @@ -4,44 +4,58 @@ 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 const double twoPi = math.pi * 2; +@internal double distance(double x, double y) => math.sqrt(x * x + y * y); +@internal double distanceSquared(double x, double y) => x * x + y * y; /// Returns unit vector representing the direction to this point from (0, 0). +@internal Point directionVector(double x, double y) { final double d = distance(x, y); assert(d > 0, 'Required distance greater than zero.'); return Point(x / d, y / d); } +@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; } @@ -57,6 +71,7 @@ double lerp(double start, double stop, double fraction) { /// 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)); } @@ -65,9 +80,11 @@ Point interpolate(Point start, Point stop, double fraction) { /// /// 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, @@ -90,6 +107,7 @@ bool collinearIsh( /// 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).clockwise(next - current); } @@ -101,6 +119,7 @@ bool convex(Point previous, Point current, Point next) { // NTS: Does it make sense to split the function f in 2, one to generate a // candidate, of a custom type T (i.e. (Float) -> T), and one to evaluate it // ( (T) -> Float )? +@internal double findMinimum(double v0, double v1, double Function(double) f, {double tolerance = 1e-3}) { var a = v0; var b = v1; @@ -130,6 +149,7 @@ double findMinimum(double v0, double v1, double Function(double) f, {double tole /// /// 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, @@ -157,21 +177,3 @@ int binarySearchBy( } return -min - 1; } - -extension DoubleCoerceExtensions on double { - double coerceAtLeast(double minimumValue) => this < minimumValue ? minimumValue : this; - - double coerceAtMost(double maximumValue) { - return this > maximumValue ? maximumValue : this; - } - - double coerceIn(double minimumValue, double maximumValue) { - if (this < minimumValue) { - return minimumValue; - } - if (this > maximumValue) { - return maximumValue; - } - return this; - } -} diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index 3fbda07bb795..198d59868686 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -2,13 +2,14 @@ // 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/features.dart'; import 'package:material_ui/src/shapes/point.dart'; import 'package:material_ui/src/shapes/rounded_polygon.dart'; -import 'package:material_ui/src/shapes/utils.dart'; import 'test_utils.dart'; @@ -267,7 +268,7 @@ void main() { doUnevenSmoothTest( rounding0: CornerRounding(radius: 0.4, smoothing: smooth), expectedV0SX: 0.4 * (1 + smooth), - expectedV0SY: (0.4 * (1 + smooth)).coerceAtMost(0.5), + expectedV0SY: math.min(0.4 * (1 + smooth), 0.5), expectedV3SY: 0.5, ); } @@ -284,7 +285,7 @@ void main() { const smoothWantedV3 = 0.2; // There is 0.4 room for smoothing. - final double factor = (0.4 / (smoothWantedV0 + smoothWantedV3)).coerceAtMost(1); + final double factor = math.min(0.4 / (smoothWantedV0 + smoothWantedV3), 1.0); doUnevenSmoothTest( rounding0: CornerRounding(radius: 0.4, smoothing: smooth), expectedV0SX: 0.4 * (1 + smooth), From 36c877073050ea6d4bcd04f70989de220a6a1260 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 23 Aug 2026 12:36:40 +0200 Subject: [PATCH 08/59] Document the public members of the shapes API. --- .../lib/src/material_shape_border.dart | 1 + .../lib/src/shapes/corner_rounding.dart | 50 +++++++++++-------- .../material_ui/lib/src/shapes/cubic.dart | 16 ++++++ .../material_ui/lib/src/shapes/features.dart | 5 ++ .../material_ui/lib/src/shapes/morph.dart | 5 ++ 5 files changed, 57 insertions(+), 20 deletions(-) diff --git a/packages/material_ui/lib/src/material_shape_border.dart b/packages/material_ui/lib/src/material_shape_border.dart index 2989b7e41b8f..0f299fa0cd32 100644 --- a/packages/material_ui/lib/src/material_shape_border.dart +++ b/packages/material_ui/lib/src/material_shape_border.dart @@ -18,6 +18,7 @@ import 'shapes/rounded_polygon.dart'; /// /// 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, assert(squash >= 0 && squash <= 1, 'squash has to be in range [0, 1]'); diff --git a/packages/material_ui/lib/src/shapes/corner_rounding.dart b/packages/material_ui/lib/src/shapes/corner_rounding.dart index 9646f9d4e5e6..cd7541d1ddce 100644 --- a/packages/material_ui/lib/src/shapes/corner_rounding.dart +++ b/packages/material_ui/lib/src/shapes/corner_rounding.dart @@ -2,6 +2,9 @@ // 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; + /// Defines the amount and quality 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 @@ -19,34 +22,41 @@ /// 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. -/// -/// [radius] is a value of 0 or greater, representing 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. Note that this radius is an -/// absolute size that should relate to the overall size of its shape. Thus if -/// the shape is in screen coordinate size, the radius should be sized -/// appropriately. If the shape is in some canonical form (bounds of (-1,-1) to -/// (1,1), for example, which is the default when creating a [RoundedPolygon] -/// from a number of vertices), then the radius should be relative to that -/// size. The radius will be scaled if the shape itself is transformed, since -/// it will produce curves which round the corner and thus get transformed -/// along with the overall shape. -/// -/// [smoothing] is 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 (no smoothing) indicates that the corner is rounded by only a circular -/// arc; there are no flanking curves. A value of 1 indicates that there is no -/// circular arc in the center; the flanking curves on either side meet at the -/// middle. class CornerRounding { + /// Creates a [CornerRounding]. const CornerRounding({this.radius = 0, this.smoothing = 0}) : assert(radius >= 0, 'radius has to be greater that 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.fromVerticesNum] 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; } diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 316a67e96396..25637446737d 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -55,6 +55,8 @@ class CubicBezier { : assert(points.length == 8, 'Points array size should be 8.'), _points = points; + /// Creates a [CubicBezier] from its two anchor points and its two control + /// points. @internal CubicBezier.fromPoints(Point anchor0, Point control0, Point control1, Point anchor1) : this.raw([ @@ -145,20 +147,28 @@ class CubicBezier { /// that expects a coordinate buffer. List get points => UnmodifiableListView(_points); + /// 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 [anchor0X]. double get control0X => _points[2]; + /// The Y coordinate of the control point closest to [anchor0Y]. double get control0Y => _points[3]; + /// The X coordinate of the control point closest to [anchor1X]. double get control1X => _points[4]; + /// The Y coordinate of the control point closest to [anchor1Y]. 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 a point on the curve for parameter [t], representing the @@ -375,13 +385,19 @@ class CubicBezier { anchor0Y, ); + /// Returns a curve whose coordinates are the sums of this curve's and [o]'s + /// corresponding coordinates. CubicBezier operator +(CubicBezier o) => CubicBezier.raw(List.generate(8, (i) => _points[i] + o._points[i])); + /// Returns a curve whose coordinates are this curve's multiplied by [x]. CubicBezier operator *(double x) => CubicBezier.raw(List.generate(8, (i) => _points[i] * 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 [f] applied to each of its anchor and + /// control points. CubicBezier transformed(PointTransformer f) { final newCubic = _MutableCubicBezier(); for (var i = 0; i < 8; i++) { diff --git a/packages/material_ui/lib/src/shapes/features.dart b/packages/material_ui/lib/src/shapes/features.dart index 48e3405e702c..d2cf5a5db6b0 100644 --- a/packages/material_ui/lib/src/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/features.dart @@ -29,6 +29,11 @@ import 'point.dart'; /// By using features, you can manipulate polygon shapes with more context and /// control. abstract class Feature { + /// Creates a [Feature] spanning the given [cubics]. + /// + /// Prefer the [Feature.buildEdge], [Feature.buildConvexCorner], + /// [Feature.buildConcaveCorner] and [Feature.buildIgnorableFeature] + /// factories, which validate that the cubics form a continuous run. const Feature(List cubics) : _cubics = cubics; /// Group a list of [CubicBezier] objects to a feature that should be ignored in diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index 1bac1c82307d..54e2ec626bbd 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -29,6 +29,11 @@ import 'utils.dart'; /// splitting curves when the shapes do not have the same number of curves or /// when the curve placement within the shapes is very different. 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(RoundedPolygon start, RoundedPolygon end) : _start = start, _end = end { _morphMatch = _match(start, end); } From 0e1f0fa753a6c2120ed96f8471ee18f246125a82 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 23 Aug 2026 12:53:27 +0200 Subject: [PATCH 09/59] Fix analyzer issues. --- .../test/shapes/features_test.dart | 2 +- .../test/shapes/polygon_measure_test.dart | 2 +- .../material_ui/test/shapes/polygon_test.dart | 1 - .../test/shapes/rounded_polygon_test.dart | 3 +-- .../material_ui/test/shapes/shapes_test.dart | 23 ++++--------------- 5 files changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/material_ui/test/shapes/features_test.dart b/packages/material_ui/test/shapes/features_test.dart index 07100f2408d8..f02be4531d1b 100644 --- a/packages/material_ui/test/shapes/features_test.dart +++ b/packages/material_ui/test/shapes/features_test.dart @@ -35,7 +35,7 @@ void main() { test('Builds convex corner', () { final cubic = CubicBezier.straightLine(0, 0, 1, 0); final actual = Feature.buildConvexCorner([cubic]); - final expected = CornerFeature([cubic], convex: true); + final expected = CornerFeature([cubic]); expectFeaturesEqualish(expected, actual); }); diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart index c209b65d3c52..87bf6be83309 100644 --- a/packages/material_ui/test/shapes/polygon_measure_test.dart +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -139,7 +139,7 @@ void main() { perVertexRounding: const [ CornerRounding(radius: 0.2, smoothing: 0.5), CornerRounding(radius: 0.2, smoothing: 0.5), - CornerRounding(radius: 0.4, smoothing: 0), + CornerRounding(radius: 0.4), CornerRounding(radius: 0.2, smoothing: 0.5), ], ), diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 1059590427df..87c0b984a6a6 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -184,7 +184,6 @@ void main() { final RoundedPolygon poly = RoundedPolygon.fromVerticesNum( 4, - radius: 1, rounding: const CornerRounding(radius: 7 / 15), ).transformed((x, y) { final Point point = Point(x, y).rotate(45).scale(648, 648).translate(540, 1212); diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index 198d59868686..7672cfa9bf3f 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -187,7 +187,7 @@ void main() { const p1 = Point(1, 0); const p2 = Point(0.5, 1); final List pvRounding = [ - const CornerRounding(radius: 1, smoothing: 0), + const CornerRounding(radius: 1), const CornerRounding(radius: 1, smoothing: 1), CornerRounding.unrounded, ]; @@ -332,7 +332,6 @@ void main() { final canonicalShape = RoundedPolygon.star( numVerticesPerRadius: 4, - radius: 1, innerRadius: innerRadiusFactor, rounding: const CornerRounding(radius: roundingFactor), innerRounding: const CornerRounding(radius: roundingFactor), diff --git a/packages/material_ui/test/shapes/shapes_test.dart b/packages/material_ui/test/shapes/shapes_test.dart index f95bc79bcbed..810f9c935f9c 100644 --- a/packages/material_ui/test/shapes/shapes_test.dart +++ b/packages/material_ui/test/shapes/shapes_test.dart @@ -89,7 +89,7 @@ void main() { // 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, innerRadius: 0.5); + var star = RoundedPolygon.star(numVerticesPerRadius: 4); List shape = star.cubics; var radius = 1.0; var innerRadius = 0.5; @@ -135,37 +135,24 @@ void main() { const min = Point(-1, -1); const max = Point(1, 1); - var star = RoundedPolygon.star(numVerticesPerRadius: 4, innerRadius: 0.5, rounding: rounding); + var star = RoundedPolygon.star(numVerticesPerRadius: 4, rounding: rounding); expectInBounds(star.cubics, min, max); - star = RoundedPolygon.star( - numVerticesPerRadius: 4, - innerRadius: 0.5, - innerRounding: innerRounding, - ); + star = RoundedPolygon.star(numVerticesPerRadius: 4, innerRounding: innerRounding); expectInBounds(star.cubics, min, max); star = RoundedPolygon.star( numVerticesPerRadius: 4, - innerRadius: 0.5, rounding: rounding, innerRounding: innerRounding, ); expectInBounds(star.cubics, min, max); - star = RoundedPolygon.star( - numVerticesPerRadius: 4, - innerRadius: 0.5, - perVertexRounding: perVtxRounded, - ); + star = RoundedPolygon.star(numVerticesPerRadius: 4, perVertexRounding: perVtxRounded); expectInBounds(star.cubics, min, max); expect( - () => RoundedPolygon.star( - numVerticesPerRadius: 6, - innerRadius: 0.5, - perVertexRounding: perVtxRounded, - ), + () => RoundedPolygon.star(numVerticesPerRadius: 6, perVertexRounding: perVtxRounded), throwsArgumentError, ); }); From dcb65e93ab2d2fa2acbb1b5ae42d3e9032741ad4 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 23 Aug 2026 13:20:33 +0200 Subject: [PATCH 10/59] Expose material shapes from material_ui. --- packages/material_ui/lib/material_ui.dart | 3 +++ 1 file changed, 3 insertions(+) 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'; From 9bcdadfd12ac2546dc3855810e2af831e1e5e86a Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 23 Aug 2026 15:35:21 +0200 Subject: [PATCH 11/59] Add material shapes example. --- packages/material_ui/example/lib/main.dart | 7 + .../material_shapes/material_shapes.0.dart | 191 ++++++++++++++++++ .../material_ui/example/test/main_test.dart | 3 + .../material_shapes.0_test.dart | 99 +++++++++ .../material_ui/lib/src/material_shapes.dart | 12 ++ .../change_2026_08_23_material_shapes.yaml | 6 + 6 files changed, 318 insertions(+) create mode 100644 packages/material_ui/example/lib/material_shapes/material_shapes.0.dart create mode 100644 packages/material_ui/example/test/material_shapes/material_shapes.0_test.dart create mode 100644 packages/material_ui/pending_changelogs/change_2026_08_23_material_shapes.yaml 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..c4c93e202ddf --- /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: 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: 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/src/material_shapes.dart b/packages/material_ui/lib/src/material_shapes.dart index e9064edd2e19..7204b3746bb7 100644 --- a/packages/material_ui/lib/src/material_shapes.dart +++ b/packages/material_ui/lib/src/material_shapes.dart @@ -19,6 +19,18 @@ import 'shapes/shapes.dart'; /// 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); diff --git a/packages/material_ui/pending_changelogs/change_2026_08_23_material_shapes.yaml b/packages/material_ui/pending_changelogs/change_2026_08_23_material_shapes.yaml new file mode 100644 index 000000000000..5031468738eb --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_2026_08_23_material_shapes.yaml @@ -0,0 +1,6 @@ +changelog: | + - Adds `MaterialShapes`, the catalog of Material Design shapes, built on the new + `RoundedPolygon`, `Morph` and `CornerRounding` geometry APIs. + - Adds `MaterialShapeBorder` for drawing a `RoundedPolygon` as an `OutlinedBorder`. + - Adds a `MaterialShapes` example that morphs through the shapes in `MaterialShapes.all`. +version: minor From 92a37079afc6808a2c50e8470e41303ee3246adc Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 23 Aug 2026 15:54:32 +0200 Subject: [PATCH 12/59] Add golden tests for all MaterialShapes. --- .../test/material_shapes_test.dart | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/material_ui/test/material_shapes_test.dart 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; +} From 5907f4958dd469b7088d560af040843df88cc37f Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 30 Aug 2026 14:09:05 +0200 Subject: [PATCH 13/59] Make the internal Point an alias for Offset. --- .../material_ui/lib/src/shapes/point.dart | 102 ++++-------------- 1 file changed, 22 insertions(+), 80 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/point.dart b/packages/material_ui/lib/src/shapes/point.dart index 8746721ae099..9bbd51a6d6a4 100644 --- a/packages/material_ui/lib/src/shapes/point.dart +++ b/packages/material_ui/lib/src/shapes/point.dart @@ -9,6 +9,7 @@ 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; @@ -19,21 +20,27 @@ import 'package:vector_math/vector_math_64.dart' show Matrix4, Vector3; /// [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 -@immutable -class Point { - const Point(this.x, this.y); +typedef Point = Offset; - static const zero = Point(0, 0); - - final double x; +/// The geometry [Offset] does not provide. +@internal +extension PointGeometry on Offset { + /// The horizontal coordinate of this point. + double get x => dx; - final double y; + /// The vertical coordinate of this point. + double get y => dy; - Point copy() => Point(x, y); + /// The angle of this point in radians, measured clockwise from the positive + /// X axis. + double get angleRadians => direction; + /// Returns this point rotated a quarter turn counterclockwise around (0, 0). 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; @@ -42,30 +49,24 @@ class Point { return Point(off.x * cos - off.y * sin, off.x * sin + off.y * cos) + center; } - Point translate(double dx, double dy) => Point(x + dx, y + dy); - - Point scale(double sx, double sy) => Point(x * sx, y * sy); - - double get angleDegrees => angleRadians * math.pi / 180; - - double get angleRadians => math.atan2(y, x); - /// The magnitude of the [Point], which is the distance of this point from /// (0, 0). /// /// If you need this value to compare it to another [Point]'s distance, /// consider using [getDistanceSquared] instead, since it is cheaper to /// compute. - double getDistance() => math.sqrt(x * x + y * y); + double getDistance() => distance; /// The square of the magnitude (which is the distance of this point from /// (0, 0)) of the [Point]. /// /// This is cheaper than computing the [getDistance] itself. - double getDistanceSquared() => x * x + y * y; + double getDistanceSquared() => distanceSquared; + /// The dot product of this point and [other], both taken as vectors. double dotProduct(Point other) => x * other.x + y * other.y; + /// The dot product of this point and the vector ([otherX], [otherY]). double dotProductXY(double otherX, double otherY) => x * otherX + y * otherY; /// Compute the Z coordinate of the cross product of two vectors, to check @@ -74,78 +75,19 @@ class Point { /// are co-linear. bool clockwise(Point other) => (x * other.y - y * other.x) > 0; + /// Returns the unit vector representing the direction to this point from + /// (0, 0). Point getDirection() { final double d = getDistance(); assert(d > 0, "Can't get the direction of a 0-length vector"); return this / d; } - /// Unary negation operator. - /// - /// Returns a [Point] with the coordinates negated. - /// - /// If the [Point] represents an arrow on a plane, this operator returns the - /// same arrow but pointing in the reverse direction. - Point operator -() => Point(-x, -y); - - /// Binary subtraction operator. - /// - /// Returns a Point whose [x] value is the left-hand-side operand's [x] - /// minus the right-hand-side operand's [x] and whose [y] value is the - /// left-hand-side operand's [y] minus the right-hand-side operand's [y]. - Point operator -(Point operand) => Point(x - operand.x, y - operand.y); - - /// Binary addition operator. - /// - /// Returns a Point whose [x] value is the sum of the [x] values of the two - /// operands, and whose [y] value is the sum of the [y] values of the two - /// operands. - Point operator +(Point operand) => Point(x + operand.x, y + operand.y); - - /// Multiplication operator. - /// - /// Returns a Point whose coordinates are the coordinates of the - /// left-hand-side operand (a [Point]) multiplied by the scalar - /// right-hand-side operand (a [double]). - Point operator *(double operand) => Point(x * operand, y * operand); - - /// Division operator. - /// - /// Returns a Point whose coordinates are the coordinates of the - /// left-hand-side operand (a [Point]) divided by the scalar - /// right-hand-side operand (a [double]). - Point operator /(double operand) => Point(x / operand, y / operand); - - /// Modulo (remainder) operator. - /// - /// Returns a Point whose coordinates are the remainder of dividing the - /// coordinates of the left-hand-side operand (a [Point]) by the scalar - /// right-hand-side operand (a [double]). - Point operator %(double operand) => Point(x % operand, y % operand); - + /// 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); } - - @override - String toString() => 'Point($x, $y)'; - - @override - bool operator ==(Object other) { - if (identical(other, this)) { - return true; - } - - if (other is! Point) { - return false; - } - - return other.x == x && other.y == y; - } - - @override - int get hashCode => Object.hashAll([x, y]); } /// Adapts a [Matrix4] into a [PointTransformer]. From eb8195c1f83703e5606d9d1d3f4cf87fccdfcb5d Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 30 Aug 2026 16:38:56 +0200 Subject: [PATCH 14/59] Return Rect from the shapes bounds methods. --- .../material_ui/lib/src/shapes/cubic.dart | 36 ++++------ .../material_ui/lib/src/shapes/morph.dart | 52 +++----------- .../lib/src/shapes/rounded_polygon.dart | 70 ++++--------------- .../material_ui/test/shapes/polygon_test.dart | 35 +++++----- 4 files changed, 56 insertions(+), 137 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 25637446737d..b319e2a2f299 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -213,21 +213,17 @@ class CubicBezier { bool _zeroIsh(double value) => value.abs() < distanceEpsilon; - /// Returns the true bounds of this curve, filling [bounds] with the - /// axis-aligned bounding box values for left, top, right, and bottom, - /// in that order. - @internal - void calculateBounds(List bounds, {bool approximate = false}) { - assert(bounds.length == 4, 'Bounds array size should be 4.'); - + /// Calculates the axis-aligned bounding box of this curve. + /// + /// When [approximate] is true, uses a faster calculation 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 the true bounds, + /// but can be larger. Defaults to false. + Rect calculateBounds({bool approximate = false}) { // A curve might be of zero-length, with both anchors co-lated. // Just return the point itself. if (zeroLength()) { - bounds[0] = anchor0X; - bounds[1] = anchor0Y; - bounds[2] = anchor0X; - bounds[3] = anchor0Y; - return; + return Rect.fromLTRB(anchor0X, anchor0Y, anchor0X, anchor0Y); } double minX = math.min(anchor0X, anchor1X); @@ -238,11 +234,12 @@ class CubicBezier { if (approximate) { // Approximate bounds use the bounding box of all anchors and // controls. - bounds[0] = math.min(minX, math.min(control0X, control1X)); - bounds[1] = math.min(minY, math.min(control0Y, control1Y)); - bounds[2] = math.max(maxX, math.max(control0X, control1X)); - bounds[3] = math.max(maxY, math.max(control0Y, control1Y)); - return; + 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 @@ -337,10 +334,7 @@ class CubicBezier { } } - bounds[0] = minX; - bounds[1] = minY; - bounds[2] = maxX; - bounds[3] = maxY; + return Rect.fromLTRB(minX, minY, maxX, maxY); } /// Returns two [CubicBezier]s, created by splitting this curve at the given diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index 54e2ec626bbd..3692b5b4ea59 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -141,29 +141,13 @@ class Morph { /// Calculates the axis-aligned bounds of the object. /// - /// [approximate] when true, uses a faster calculation to create the bounding - /// box based on the min/max values of all anchor and control points that - /// make up the shape. Default value is true. - /// - /// [bounds] is a buffer to hold the results. If not supplied, a temporary - /// buffer will be created. - /// - /// Returns the axis-aligned bounding box for this object, where the - /// rectangles left, top, right, and bottom values will be stored in entries - /// 0, 1, 2, and 3, in that order. - List calculateBounds({List? bounds, bool approximate = true}) { - bounds ??= List.filled(4, 0); - _start.calculateBounds(bounds: bounds, approximate: approximate); - final double minX = bounds[0]; - final double minY = bounds[1]; - final double maxX = bounds[2]; - final double maxY = bounds[3]; - _end.calculateBounds(bounds: bounds, approximate: approximate); - bounds[0] = math.min(minX, bounds[0]); - bounds[1] = math.min(minY, bounds[1]); - bounds[2] = math.max(maxX, bounds[2]); - bounds[3] = math.max(maxY, bounds[3]); - return bounds; + /// When [approximate] is true, uses a faster calculation to create the + /// bounding box based on the min/max values of all anchor and control points + /// that make up the shape. Defaults to true. + Rect calculateBounds({bool approximate = true}) { + return _start + .calculateBounds(approximate: approximate) + .expandToInclude(_end.calculateBounds(approximate: approximate)); } /// Like [calculateBounds], this function calculates the axis-aligned bounds @@ -173,26 +157,8 @@ class Morph { /// which can be used to hold the object in any rotation. This function can /// be used, for example, to calculate the max size of a UI element meant to /// hold this shape in any rotation. - /// - /// [bounds] is a buffer to hold the results. If not supplied, a temporary - /// buffer will be created. - /// - /// Returns the axis-aligned max bounding box for this object, where the - /// rectangles left, top, right, and bottom values will be stored in entries - /// 0, 1, 2, and 3, in that order. - List calculateMaxBounds([List? bounds]) { - bounds ??= List.filled(4, 0); - _start.calculateMaxBounds(bounds); - final double minX = bounds[0]; - final double minY = bounds[1]; - final double maxX = bounds[2]; - final double maxY = bounds[3]; - _end.calculateMaxBounds(bounds); - bounds[0] = math.min(minX, bounds[0]); - bounds[1] = math.min(minY, bounds[1]); - bounds[2] = math.max(maxX, bounds[2]); - bounds[3] = math.max(maxY, bounds[3]); - return bounds; + Rect calculateMaxBounds() { + return _start.calculateMaxBounds().expandToInclude(_end.calculateMaxBounds()); } /// Returns a representation of the morph object at a given [progress] value diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 0af82801d932..a1b5f0980b8b 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -796,14 +796,12 @@ class RoundedPolygon { /// completely inside the (0, 0) -> (1, 1) square, centered if there extra /// space in one direction. RoundedPolygon normalized() { - final List bounds = calculateBounds(); - final double width = bounds[2] - bounds[0]; - final double height = bounds[3] - bounds[1]; - final double side = math.max(width, height); + final Rect bounds = calculateBounds(); + final double side = math.max(bounds.width, bounds.height); // Center the shape if bounds are not a square. - final double offsetX = (side - width) / 2 - bounds[0]; /* left */ - final double offsetY = (side - height) / 2 - bounds[1]; /* top */ + 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)); } @@ -815,20 +813,7 @@ class RoundedPolygon { /// which can be used to hold the object in any rotation. This function can /// be used, for example, to calculate the max size of a UI element meant to /// hold this shape in any rotation. - /// - /// [bounds] is a buffer to hold the results. If not supplied, a temporary - /// buffer will be created. - /// - /// Returns the axis-aligned max bounding box for this object, where the - /// rectangles left, top, right, and bottom values will be stored in entries - /// 0, 1, 2, and 3, in that order. - List calculateMaxBounds([List? bounds]) { - bounds ??= List.filled(4, 0); - - if (bounds.length < 4) { - throw ArgumentError('Required bounds size of 4.'); - } - + Rect calculateMaxBounds() { var maxDistSquared = 0.0; for (var i = 0; i < cubics.length; i++) { final CubicBezier cubic = cubics[i]; @@ -846,51 +831,26 @@ class RoundedPolygon { final double distance = math.sqrt(maxDistSquared); - bounds[0] = centerX - distance; - bounds[1] = centerY - distance; - bounds[2] = centerX + distance; - bounds[3] = centerY + distance; - - return bounds; + return Rect.fromLTRB( + centerX - distance, + centerY - distance, + centerX + distance, + centerY + distance, + ); } /// Calculates the axis-aligned bounds of the object. /// - /// [bounds] is a buffer to hold the results. If not supplied, a temporary - /// buffer will be created. - /// /// [approximate] when true, uses a faster calculation to create the bounding /// box based on the min/max values of all anchor and control points that /// make up the shape. Default value is true. - /// - /// Returns the axis-aligned bounding box for this object, where the - /// rectangles left, top, right, and bottom values will be stored in entries - /// 0, 1, 2, and 3, in that order. - List calculateBounds({List? bounds, bool approximate = true}) { - bounds ??= List.filled(4, 0); + Rect calculateBounds({bool approximate = true}) { + Rect bounds = cubics.first.calculateBounds(approximate: approximate); - if (bounds.length < 4) { - throw ArgumentError('Required bounds size of 4.'); + for (var i = 1; i < cubics.length; i++) { + bounds = bounds.expandToInclude(cubics[i].calculateBounds(approximate: approximate)); } - double minX = double.maxFinite; - double minY = double.maxFinite; - double maxX = double.minPositive; - double maxY = double.minPositive; - - for (var i = 0; i < cubics.length; i++) { - cubics[i].calculateBounds(bounds, approximate: approximate); - minX = math.min(minX, bounds[0]); - minY = math.min(minY, bounds[1]); - maxX = math.max(maxX, bounds[2]); - maxY = math.max(maxY, bounds[3]); - } - - bounds[0] = minX; - bounds[1] = minY; - bounds[2] = maxX; - bounds[3] = maxY; - return bounds; } diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 87c0b984a6a6..370193daa5a3 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -2,6 +2,8 @@ // 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'; @@ -85,34 +87,31 @@ void main() { }); test('bounds', () { - List bounds = square.calculateBounds(); - expectEqualish(-1, bounds[0]); // Left - expectEqualish(-1, bounds[1]); // Top - expectEqualish(1, bounds[2]); // Right - expectEqualish(1, bounds[3]); // Bottom - - List betterBounds = square.calculateBounds(approximate: false); - expectEqualish(-1, betterBounds[0]); // Left - expectEqualish(-1, betterBounds[1]); // Top - expectEqualish(1, betterBounds[2]); // Right - expectEqualish(1, betterBounds[3]); // Bottom + Rect bounds = square.calculateBounds(); + expectEqualish(-1, bounds.left); + expectEqualish(-1, bounds.top); + expectEqualish(1, bounds.right); + expectEqualish(1, bounds.bottom); + + Rect betterBounds = square.calculateBounds(approximate: false); + 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.calculateBounds(); betterBounds = roundedSquare.calculateBounds(approximate: false); expect( - betterBounds[2] - betterBounds[0] < bounds[2] - bounds[0], + betterBounds.width < bounds.width, isTrue, - reason: - 'bounds ${bounds[0]}, ${bounds[1]}, ${bounds[2]}, ${bounds[3]}, ' - 'betterBounds = ${betterBounds[0]}, ${betterBounds[1]}, ' - '${betterBounds[2]}, ${betterBounds[3]}', + reason: 'bounds = $bounds, betterBounds = $betterBounds', ); bounds = pentagon.calculateBounds(); - final List maxBounds = pentagon.calculateMaxBounds(); - expect(maxBounds[2] - maxBounds[0] > bounds[2] - bounds[0], isTrue); + final Rect maxBounds = pentagon.calculateMaxBounds(); + expect(maxBounds.width > bounds.width, isTrue); }); test('center', () { From ff1cae72cb9781d7128164e68a24dfd28e09ac55 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 30 Aug 2026 17:32:27 +0200 Subject: [PATCH 15/59] Use an Offset center in the RoundedPolygon API. --- .../material_ui/lib/src/material_shapes.dart | 9 +- .../material_ui/lib/src/shapes/morph.dart | 16 +- .../lib/src/shapes/rounded_polygon.dart | 255 +++++++----------- .../material_ui/test/shapes/morph_test.dart | 5 +- .../material_ui/test/shapes/polygon_test.dart | 28 +- .../test/shapes/rounded_polygon_test.dart | 12 +- .../material_ui/test/shapes/shapes_test.dart | 9 +- 7 files changed, 123 insertions(+), 211 deletions(-) diff --git a/packages/material_ui/lib/src/material_shapes.dart b/packages/material_ui/lib/src/material_shapes.dart index 7204b3746bb7..844c0242dd53 100644 --- a/packages/material_ui/lib/src/material_shapes.dart +++ b/packages/material_ui/lib/src/material_shapes.dart @@ -46,8 +46,7 @@ abstract final class MaterialShapes { static final circle = RoundedPolygon.circle( numVertices: 10, radius: 0.5, - centerX: 0.5, - centerY: 0.5, + center: const Point(0.5, 0.5), ); /// A square shape. @@ -55,8 +54,7 @@ abstract final class MaterialShapes { width: 1, height: 1, rounding: _cornerRound30, - centerX: 0.5, - centerY: 0.5, + center: const Point(0.5, 0.5), ); /// A slanted square shape. @@ -432,8 +430,7 @@ abstract final class MaterialShapes { return RoundedPolygon.fromVertices( vertices, perVertexRounding: perVertexRounding, - centerX: center.x, - centerY: center.y, + center: center, ); } diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index 3692b5b4ea59..a568050000e3 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -237,17 +237,17 @@ class Morph { /// /// [rotationPivotX] is the rotation pivot on the X axis. By default it's set /// to 0, and that should align with Morph instances that were created for - /// [RoundedPolygon] with zero centerX. In case the [RoundedPolygon] were - /// normalized (i. e. moved to (0.5, 0.5)), or where created with a different - /// centerX coordinated, this pivot point may need to be aligned to support a - /// proper rotation. + /// [RoundedPolygon] with a zero [RoundedPolygon.center]. In case the + /// [RoundedPolygon] was normalized (i.e. moved to (0.5, 0.5)), or was + /// created with a different center, this pivot point may need to be aligned + /// to support a proper rotation. /// /// [rotationPivotY] is the rotation pivot on the Y axis. By default it's set /// to 0, and that should align with Morph instances that were created for - /// [RoundedPolygon] with zero centerY. In case the RoundedPolygon were - /// normalized (i. e. moves to (0.5, 0.5)), or where created with a different - /// centerY coordinated, this pivot point may need to be aligned to support a - /// proper rotation. + /// [RoundedPolygon] with a zero [RoundedPolygon.center]. In case the + /// [RoundedPolygon] was normalized (i.e. moved to (0.5, 0.5)), or was + /// created with a different center, this pivot point may need to be aligned + /// to support a proper rotation. Path toPath({ required double progress, int startAngle = 0, diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index a1b5f0980b8b..db8c1c950ea8 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -21,7 +21,7 @@ import 'utils.dart'; /// either the number of vertices desired or an ordered list of vertices. @immutable class RoundedPolygon { - RoundedPolygon._(this.features, this.center) : cubics = [] { + RoundedPolygon._(this.features, this._center) : cubics = [] { _initCubics(); assert(() { @@ -64,11 +64,8 @@ class RoundedPolygon { /// the initial size of the object, but it can be transformed later by using /// the [transformed] function. /// - /// [centerX] is the X coordinate of the center of the polygon, around which - /// all vertices will be placed. The default center is at (0,0). - /// - /// [centerY] is the Y coordinate of the center of the polygon, around which - /// all vertices will be placed. The default center is at (0,0). + /// [center] is the center of the polygon, around which all vertices will be + /// placed. The default center is at (0,0). /// /// [rounding] is the [CornerRounding] properties of all vertices. If some /// vertices should have different rounding properties, then use @@ -88,8 +85,7 @@ class RoundedPolygon { factory RoundedPolygon.fromVerticesNum( int numVertices, { double radius = 1, - double centerX = 0, - double centerY = 0, + Offset center = Offset.zero, CornerRounding rounding = CornerRounding.unrounded, List? perVertexRounding, }) { @@ -98,11 +94,10 @@ class RoundedPolygon { } return RoundedPolygon.fromVertices( - _verticesFromNumVerts(numVertices, radius, centerX, centerY), + _verticesFromNumVerts(numVertices, radius, center), rounding: rounding, perVertexRounding: perVertexRounding, - centerX: centerX, - centerY: centerY, + center: center, ); } @@ -133,11 +128,9 @@ class RoundedPolygon { /// [vertices]. If this parameter is null, then the polygon will use the /// [rounding] parameter for every vertex instead. The default value is null. /// - /// [centerX] is the X coordinate of the center of the polygon, around which - /// all vertices will be placed. The default center is at (0,0). - /// - /// [centerY] is the Y coordinate of the center of the polygon, around which - /// all vertices will be placed. The default center is at (0,0). + /// [center] is the center of the polygon, around which all vertices will be + /// placed. If `null` (the default value), the center is estimated by + /// averaging the [vertices]. /// /// Throws [ArgumentError] if the number of vertices is less than 3 (the /// [vertices] parameter has less than 6 Floats). Or if the @@ -150,8 +143,7 @@ class RoundedPolygon { List vertices, { CornerRounding rounding = CornerRounding.unrounded, List? perVertexRounding, - double centerX = double.minPositive, - double centerY = double.minPositive, + Offset? center, }) { if (vertices.length < 6) { throw ArgumentError('Polygons must have at least 3 vertices.'); @@ -257,19 +249,7 @@ class RoundedPolygon { ); } - final double cX; - final double cY; - - if (centerX == double.minPositive || centerY == double.minPositive) { - final Point center = calculateCenter(vertices); - cX = center.x; - cY = center.y; - } else { - cX = centerX; - cY = centerY; - } - - return RoundedPolygon.fromFeatures(tempFeatures, centerX: cX, centerY: cY); + return RoundedPolygon.fromFeatures(tempFeatures, center: center ?? calculateCenter(vertices)); } /// Takes a list of [Feature] objects that define the polygon's shape and @@ -280,52 +260,37 @@ class RoundedPolygon { /// start polygon, [Morph] will map it to another convex curve in the end /// polygon. /// - /// The [centerX] and [centerY] parameters are optional. If not supplied, - /// they will be estimated by calculating the average of all cubic anchor - /// points. + /// The [center] parameter is optional. If not supplied, it will be estimated + /// by calculating the average of all cubic anchor points. /// /// [features] are the [Feature]s that describe the characteristics of each /// outline segment of the polygon. /// - /// [centerX] is the X coordinate of the center of the polygon, around which - /// all vertices will be placed. If none provided, the center will be - /// averaged. - /// - /// [centerY] is the Y coordinate of the center of the polygon, around which - /// all vertices will be placed. If none provided, the center will be - /// averaged. + /// [center] is the center of the polygon, around which all vertices will be + /// placed. If null (the default value), the center will be averaged. /// /// Throws [ArgumentError] if [features] length is less than 2 or if they /// don't describe a closed shape. - factory RoundedPolygon.fromFeatures( - List features, { - double centerX = double.nan, - double centerY = double.nan, - }) { + factory RoundedPolygon.fromFeatures(List features, {Offset? center}) { if (features.length < 2) { throw ArgumentError('Polygons must have at least 2 features.'); } - if (centerX.isNaN || centerY.isNaN) { - final vertices = []; - - for (final feature in features) { - for (final CubicBezier cubic in feature.cubics) { - vertices - ..add(cubic.anchor0X) - ..add(cubic.anchor0Y); - } - } - - final Point center = calculateCenter(vertices); + if (center != null) { + return RoundedPolygon._(features, center); + } - final double cX = centerX.isNaN ? center.x : centerX; - final double cY = centerY.isNaN ? center.y : centerY; + final vertices = []; - return RoundedPolygon._(features, Point(cX, cY)); + for (final feature in features) { + for (final CubicBezier cubic in feature.cubics) { + vertices + ..add(cubic.anchor0X) + ..add(cubic.anchor0Y); + } } - return RoundedPolygon._(features, Point(centerX, centerY)); + return RoundedPolygon._(features, calculateCenter(vertices)); } /// Creates a circular shape, approximating the rounding of the shape around @@ -337,18 +302,14 @@ class RoundedPolygon { /// /// [radius] is the optional radius for the circle, default value is 1.0. /// - /// [centerX] is the X coordinate of optional center for the circle, default - /// value is 0. - /// - /// [centerY] is the Y coordinate of optional center for the circle, default - /// value is 0. + /// [center] is the optional center for the circle, default value is + /// [Offset.zero]. /// /// Throws [ArgumentError] when [numVertices] is less than 3. factory RoundedPolygon.circle({ int numVertices = 8, double radius = 1, - double centerX = 0, - double centerY = 0, + Offset center = Offset.zero, }) { if (numVertices < 3) { throw ArgumentError('Circle must have at least three vertices.'); @@ -362,8 +323,7 @@ class RoundedPolygon { return RoundedPolygon.fromVerticesNum( numVertices, radius: polygonRadius, - centerX: centerX, - centerY: centerY, + center: center, rounding: CornerRounding(radius: radius), ); } @@ -394,31 +354,25 @@ class RoundedPolygon { /// use the [rounding] parameter for every vertex instead. The default value /// is null. /// - /// [centerX] is the X coordinate of the center of the rectangle, around which - /// all vertices will be placed equidistantly. The default center is at (0,0). - /// - /// [centerY] is the Y coordinate of the center of the rectangle, around - /// which all vertices will be placed equidistantly. The default center is - /// at (0,0). + /// [center] is the center of the rectangle, around which all vertices will + /// be placed equidistantly. The default center is at (0,0). factory RoundedPolygon.rectangle({ double width = 2, double height = 2, CornerRounding rounding = CornerRounding.unrounded, List? perVertexRounding, - double centerX = 0, - double centerY = 0, + Offset center = Offset.zero, }) { - final double left = centerX - width / 2; - final double top = centerY - height / 2; - final double right = centerX + width / 2; - final double bottom = centerY + height / 2; + 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( [right, bottom, left, bottom, left, top, right, top], rounding: rounding, perVertexRounding: perVertexRounding, - centerX: centerX, - centerY: centerY, + center: center, ); } @@ -456,11 +410,8 @@ class RoundedPolygon { /// will use the [rounding] parameter for every vertex instead. The default /// value is null. /// - /// [centerX] is the X coordinate of the center of the polygon, around which - /// all vertices will be placed. The default center is at (0,0). - /// - /// [centerY] is the Y coordinate of the center of the polygon, around which - /// all vertices will be placed. The default center is at (0,0). + /// [center] is the center of the polygon, around which all vertices will be + /// placed. The default center is at (0,0). /// /// Throws [ArgumentError] if either [radius] or [innerRadius] are <= 0 or /// [innerRadius] > [radius]. @@ -471,8 +422,7 @@ class RoundedPolygon { CornerRounding rounding = CornerRounding.unrounded, CornerRounding? innerRounding, List? perVertexRounding, - double centerX = 0, - double centerY = 0, + Offset center = Offset.zero, }) { if (radius <= 0 || innerRadius <= 0) { throw ArgumentError('Star radii must both be greater than 0.'); @@ -494,11 +444,10 @@ class RoundedPolygon { // 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, centerX, centerY), + _starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, center), rounding: rounding, perVertexRounding: pvRounding, - centerX: centerX, - centerY: centerY, + center: center, ); } @@ -514,19 +463,15 @@ class RoundedPolygon { /// endcaps. A value of 0 (no smoothing) indicates that the corner is rounded /// by only a circular arc. /// - /// [centerX] is the X coordinate of the center of the polygon, around which - /// all vertices will be placed. The default center is at (0,0). - /// - /// [centerY] is the Y coordinate of the center of the polygon, around which - /// all vertices will be placed. The default center is at (0,0). + /// [center] is the center of the polygon, around which all vertices will be + /// placed. The default center is at (0,0). /// /// Throws [ArgumentError] if either [width] or [height] are <= 0. factory RoundedPolygon.pill({ double width = 2, double height = 1, double smoothing = 0, - double centerX = 0, - double centerY = 0, + Offset center = Offset.zero, }) { if (width <= 0 || height <= 0) { throw ArgumentError('Pill shapes must have positive width and height.'); @@ -537,18 +482,17 @@ class RoundedPolygon { return RoundedPolygon.fromVertices( [ - wHalf + centerX, - hHalf + centerY, - -wHalf + centerX, - hHalf + centerY, - -wHalf + centerX, - -hHalf + centerY, - wHalf + centerX, - -hHalf + centerY, + wHalf + center.x, + hHalf + center.y, + -wHalf + center.x, + hHalf + center.y, + -wHalf + center.x, + -hHalf + center.y, + wHalf + center.x, + -hHalf + center.y, ], rounding: CornerRounding(radius: math.min(wHalf, hHalf), smoothing: smoothing), - centerX: centerX, - centerY: centerY, + center: center, ); } @@ -620,11 +564,8 @@ class RoundedPolygon { /// it might matter where that path outline begins and ends. The default /// value is 0. /// - /// [centerX] is the X coordinate of the center of the polygon, around which - /// all vertices will be placed. The default center is at (0,0). - /// - /// [centerY] is the Y coordinate of the center of the polygon, around which - /// all vertices will be placed. The default center is at (0,0). + /// [center] is the center of the polygon, around which all vertices will be + /// placed. The default center is at (0,0). /// /// Throws [ArgumentError] if either [width] or [height] are <= 0 or /// if [innerRadiusRatio] is outside the range of (0, 1]. @@ -638,8 +579,7 @@ class RoundedPolygon { List? perVertexRounding, double vertexSpacing = 0.5, double startLocation = 0, - double centerX = 0, - double centerY = 0, + Offset center = Offset.zero, }) { if (width <= 0 || height <= 0) { throw ArgumentError('Pill shapes must have positive width and height.'); @@ -672,31 +612,24 @@ class RoundedPolygon { innerRadiusRatio, vertexSpacing, startLocation, - centerX, - centerY, + center, ), rounding: rounding, perVertexRounding: pvRounding, - centerX: centerX, - centerY: centerY, + center: center, ); } /// The [Feature]s this polygon is composed of. final List features; - /// The center of this polygon, around which all vertices are placed. - @internal - final Point center; + final Point _center; /// A flattened version of the [Feature]s, as a `List`. final List cubics; - /// The X coordinate of the center of this polygon. - double get centerX => center.x; - - /// The Y coordinate of the center of this polygon. - double get centerY => center.y; + /// The center of this polygon, around which all vertices are placed. + Offset get center => _center; void _initCubics() { // The first/last mechanism here ensures that the final anchor point in the @@ -773,9 +706,9 @@ class RoundedPolygon { ); } else { // Empty / 0-sized polygon. - cubics.add( - CubicBezier(centerX, centerY, centerX, centerY, centerX, centerY, centerX, centerY), - ); + final double cX = _center.x; + final double cY = _center.y; + cubics.add(CubicBezier(cX, cY, cX, cY, cX, cY, cX, cY)); } } @@ -786,10 +719,9 @@ class RoundedPolygon { /// /// [f] is the [PointTransformer] used to transform this [RoundedPolygon]. RoundedPolygon transformed(PointTransformer f) { - final Point center = this.center.transformed(f); return RoundedPolygon._([ for (var i = 0; i < features.length; i++) features[i].transformed(f), - ], center); + ], _center.transformed(f)); } /// Creates a new RoundedPolygon, moving and resizing this one, so it's @@ -818,13 +750,13 @@ class RoundedPolygon { for (var i = 0; i < cubics.length; i++) { final CubicBezier cubic = cubics[i]; final double anchorDistance = distanceSquared( - cubic.anchor0X - centerX, - cubic.anchor0Y - centerY, + cubic.anchor0X - _center.x, + cubic.anchor0Y - _center.y, ); final Point middlePoint = cubic.pointOnCurve(0.5); final double middleDistance = distanceSquared( - middlePoint.x - centerX, - middlePoint.y - centerY, + middlePoint.x - _center.x, + middlePoint.y - _center.y, ); maxDistSquared = math.max(maxDistSquared, math.max(anchorDistance, middleDistance)); } @@ -832,10 +764,10 @@ class RoundedPolygon { final double distance = math.sqrt(maxDistSquared); return Rect.fromLTRB( - centerX - distance, - centerY - distance, - centerX + distance, - centerY + distance, + _center.x - distance, + _center.y - distance, + _center.x + distance, + _center.y + distance, ); } @@ -864,9 +796,9 @@ class RoundedPolygon { /// [path] is a [Path] to reset and set with the new path data. /// /// [startAngle] is an angle (in degrees) to rotate the [Path] to start - /// drawing from. The rotation pivot is set to be the polygon's centerX and - /// centerY coordinates. If [startAngle] is non zero, then caller has to use - /// the returned [Path], as path transformation creates a new path. + /// drawing from. The rotation pivot is set to be the polygon's [center]. + /// If [startAngle] is non zero, then caller has to use the returned [Path], + /// as path transformation creates a new path. /// /// [repeatPath] is whether or not to repeat the [Path] twice before closing /// it. This flag is useful when the caller would like to draw parts of the @@ -882,8 +814,8 @@ class RoundedPolygon { startAngle: startAngle, repeatPath: repeatPath, closePath: closePath, - rotationPivotX: centerX, - rotationPivotY: centerY, + rotationPivotX: _center.x, + rotationPivotY: _center.y, ); } @@ -892,7 +824,7 @@ class RoundedPolygon { return '[RoundedPolygon. ' 'Cubics = ${cubics.join(", ")}' ' || Features = ${features.join(", ")}' - ' || Center = ($centerX, $centerY)]'; + ' || Center = (${_center.x}, ${_center.y})]'; } @override @@ -1212,13 +1144,12 @@ class _RoundedCorner { } } -List _verticesFromNumVerts(int numVertices, double radius, double centerX, double centerY) { +List _verticesFromNumVerts(int numVertices, double radius, Point center) { final result = List.filled(numVertices * 2, 0); var arrayIndex = 0; for (var i = 0; i < numVertices; i++) { - final Point vertex = - radialToCartesian(radius, math.pi / numVertices * 2 * i) + Point(centerX, centerY); + final Point vertex = radialToCartesian(radius, math.pi / numVertices * 2 * i) + center; result[arrayIndex++] = vertex.x; result[arrayIndex++] = vertex.y; @@ -1234,8 +1165,7 @@ List _pillStarVerticesFromNumVerts( double innerRadius, double vertexSpacing, double startLocation, - double centerX, - double centerY, + 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 @@ -1343,8 +1273,8 @@ List _pillStarVerticesFromNumVerts( // 8 _ => Point(currRadius, -vSegHalf + tProportion * vSegHalf), }; - result[arrayIndex++] = vertex.x + centerX; - result[arrayIndex++] = vertex.y + centerY; + result[arrayIndex++] = vertex.x + center.x; + result[arrayIndex++] = vertex.y + center.y; t += tPerVertex; inner = !inner; } @@ -1356,19 +1286,18 @@ List _starVerticesFromNumVerts( int numVerticesPerRadius, double radius, double innerRadius, - double centerX, - double centerY, + Point center, ) { final result = List.filled(numVerticesPerRadius * 4, 0); var arrayIndex = 0; for (var i = 0; i < numVerticesPerRadius; i++) { Point vertex = radialToCartesian(radius, math.pi / numVerticesPerRadius * 2 * i); - result[arrayIndex++] = vertex.x + centerX; - result[arrayIndex++] = vertex.y + centerY; + result[arrayIndex++] = vertex.x + center.x; + result[arrayIndex++] = vertex.y + center.y; vertex = radialToCartesian(innerRadius, math.pi / numVerticesPerRadius * (2 * i + 1)); - result[arrayIndex++] = vertex.x + centerX; - result[arrayIndex++] = vertex.y + centerY; + result[arrayIndex++] = vertex.x + center.x; + result[arrayIndex++] = vertex.y + center.y; } return result; diff --git a/packages/material_ui/test/shapes/morph_test.dart b/packages/material_ui/test/shapes/morph_test.dart index ec0477168e01..c314d0046678 100644 --- a/packages/material_ui/test/shapes/morph_test.dart +++ b/packages/material_ui/test/shapes/morph_test.dart @@ -8,6 +8,7 @@ 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'; @@ -18,8 +19,8 @@ void main() { const radius = 50.0; const scale = radius; - final poly1 = RoundedPolygon.fromVerticesNum(3, centerX: 0.5, centerY: 0.5); - final poly2 = RoundedPolygon.fromVerticesNum(4, centerX: 0.5, centerY: 0.5); + final poly1 = RoundedPolygon.fromVerticesNum(3, center: const Point(0.5, 0.5)); + final poly2 = RoundedPolygon.fromVerticesNum(4, center: const Point(0.5, 0.5)); final morph11 = Morph(poly1, poly1); final morph12 = Morph(poly1, poly2); diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 370193daa5a3..31cd6b2cea87 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -34,7 +34,7 @@ void main() { max = max * 2; expectInBounds(doubleSquare.cubics, min, max); - final offsetSquare = RoundedPolygon.fromVerticesNum(4, centerX: 1, centerY: 2); + final offsetSquare = RoundedPolygon.fromVerticesNum(4, center: const Point(1, 2)); min = const Point(0, 1); max = const Point(2, 3); expectInBounds(offsetSquare.cubics, min, max); @@ -67,20 +67,16 @@ void main() { final Point p1Offset = p1 + offset; final Point p2Offset = p2 + offset; final Point p3Offset = p3 + offset; - final manualSquareOffset = RoundedPolygon.fromVertices( - [ - p0Offset.x, - p0Offset.y, - p1Offset.x, - p1Offset.y, - p2Offset.x, - p2Offset.y, - p3Offset.x, - p3Offset.y, - ], - centerX: offset.x, - centerY: offset.y, - ); + final manualSquareOffset = RoundedPolygon.fromVertices([ + p0Offset.x, + p0Offset.y, + p1Offset.x, + p1Offset.y, + p2Offset.x, + p2Offset.y, + p3Offset.x, + p3Offset.y, + ], center: offset); min = const Point(0, 1); max = const Point(2, 3); expectInBounds(manualSquareOffset.cubics, min, max); @@ -115,7 +111,7 @@ void main() { }); test('center', () { - expectPointsEqualish(Point.zero, Point(square.centerX, square.centerY)); + expectPointsEqualish(Point.zero, square.center); }); test('transform', () { diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index 7672cfa9bf3f..72eab5cf5881 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -67,11 +67,7 @@ void main() { p3.x + offset.x, p3.y + offset.y, ]; - final manualSquareOffset = RoundedPolygon.fromVertices( - offsetVerts, - centerX: offset.x, - centerY: offset.y, - ); + final manualSquareOffset = RoundedPolygon.fromVertices(offsetVerts, center: offset); min = const Point(0, 1); max = const Point(2, 3); expectInBounds(manualSquareOffset.cubics, min, max); @@ -168,8 +164,7 @@ void main() { test('computes center', () { final polygon = RoundedPolygon.fromVertices(const [0, 0, 1, 0, 0, 1, 1, 1]); - expect(0.5, polygon.centerX); - expect(0.5, polygon.centerY); + expect(const Point(0.5, 0.5), polygon.center); }); List pointsToFloats(List points) { @@ -326,8 +321,7 @@ void main() { innerRadius: innerRadius, rounding: const CornerRounding(radius: radius * roundingFactor), innerRounding: const CornerRounding(radius: radius * roundingFactor), - centerX: radius, - centerY: radius, + center: const Point(radius, radius), ).transformed((x, y) => ((x - radius) / radius, (y - radius) / radius)); final canonicalShape = RoundedPolygon.star( diff --git a/packages/material_ui/test/shapes/shapes_test.dart b/packages/material_ui/test/shapes/shapes_test.dart index 810f9c935f9c..fe68da272ef4 100644 --- a/packages/material_ui/test/shapes/shapes_test.dart +++ b/packages/material_ui/test/shapes/shapes_test.dart @@ -81,7 +81,7 @@ void main() { expectCircleShape(bigCircle.cubics, radius: 3); const center = Point(1, 2); - final offsetCircle = RoundedPolygon.circle(centerX: center.x, centerY: center.y); + final offsetCircle = RoundedPolygon.circle(center: center); expectCircleShape(offsetCircle.cubics, center: center); }); @@ -99,12 +99,7 @@ void main() { } const center = Point(1, 2); - star = RoundedPolygon.star( - numVerticesPerRadius: 4, - innerRadius: innerRadius, - centerX: center.x, - centerY: center.y, - ); + star = RoundedPolygon.star(numVerticesPerRadius: 4, innerRadius: innerRadius, center: center); shape = star.cubics; for (final cubic in shape) { expectCubicOnRadii(cubic, radius, innerRadius, center); From 2492e0b74653acee56886f7ebd9202466df168aa Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 30 Aug 2026 18:02:36 +0200 Subject: [PATCH 16/59] Use a List for the RoundedPolygon vertices. --- .../material_ui/lib/src/material_shapes.dart | 19 +-- .../lib/src/shapes/rounded_polygon.dart | 126 +++++++----------- .../test/shapes/feature_mapping_test.dart | 22 ++- .../test/shapes/polygon_measure_test.dart | 25 ++-- .../material_ui/test/shapes/polygon_test.dart | 32 ++--- .../test/shapes/rounded_polygon_test.dart | 36 ++--- 6 files changed, 89 insertions(+), 171 deletions(-) diff --git a/packages/material_ui/lib/src/material_shapes.dart b/packages/material_ui/lib/src/material_shapes.dart index 844c0242dd53..db99507dbee0 100644 --- a/packages/material_ui/lib/src/material_shapes.dart +++ b/packages/material_ui/lib/src/material_shapes.dart @@ -412,24 +412,9 @@ abstract final class MaterialShapes { }) { final List<_PointNRound> actualPoints = _doRepeat(pnr, reps, center, mirroring); - final vertices = List.filled(actualPoints.length * 2, 0); - final perVertexRounding = List.filled( - actualPoints.length, - CornerRounding.unrounded, - ); - - for (var i = 0; i < actualPoints.length; i++) { - final _PointNRound ap = actualPoints[i]; - perVertexRounding[i] = ap.r; - - final int j = i * 2; - vertices[j] = ap.p.x; - vertices[j + 1] = ap.p.y; - } - return RoundedPolygon.fromVertices( - vertices, - perVertexRounding: perVertexRounding, + actualPoints.map((_PointNRound ap) => ap.p).toList(), + perVertexRounding: actualPoints.map((_PointNRound ap) => ap.r).toList(), center: center, ); } diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index db8c1c950ea8..122e38a6a16b 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -111,10 +111,9 @@ class RoundedPolygon { /// vertices (or not) as specified. The result is a list of [CubicBezier] curves /// which represent the geometry of the final shape. /// - /// [vertices] is the list of vertices in this polygon specified as pairs of - /// x/y coordinates in this `List`. This should be an ordered list - /// (with the outline of the shape going from each vertex to the next in - /// order of this list), otherwise the results will be undefined. + /// [vertices] is the list of vertices in this polygon. This should be an + /// ordered list (with the outline of the shape going from each vertex to the + /// next in order of this list), otherwise the results will be undefined. /// /// [rounding] is the [CornerRounding] properties of all vertices. If some /// vertices should have different rounding properties, then use @@ -132,45 +131,36 @@ class RoundedPolygon { /// placed. If `null` (the default value), the center is estimated by /// averaging the [vertices]. /// - /// Throws [ArgumentError] if the number of vertices is less than 3 (the - /// [vertices] parameter has less than 6 Floats). Or if the - /// [perVertexRounding] parameter is not null and the size doesn't match the - /// number vertices. + /// Throws [ArgumentError] if the number of vertices is less than 3, or if + /// the [perVertexRounding] parameter is not null and its size doesn't match + /// the number of vertices. /// // TODO(performance): Update the map calls to more efficient code that // doesn't allocate Iterators unnecessarily. factory RoundedPolygon.fromVertices( - List vertices, { + List vertices, { CornerRounding rounding = CornerRounding.unrounded, List? perVertexRounding, Offset? center, }) { - if (vertices.length < 6) { + if (vertices.length < 3) { throw ArgumentError('Polygons must have at least 3 vertices.'); } - if (vertices.length.isOdd) { - throw ArgumentError('The vertices array should have even size.'); - } - if (perVertexRounding != null && perVertexRounding.length * 2 != vertices.length) { + if (perVertexRounding != null && perVertexRounding.length != vertices.length) { throw ArgumentError( 'perVertexRounding list should be either null or ' - 'the same size as the number of vertices (vertices.size / 2).', + 'the same size as the number of vertices.', ); } final corners = >[]; - final int n = vertices.length ~/ 2; + 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) * 2; - final int nextIndex = ((i + 1) % n) * 2; + final int prevIndex = (i + n - 1) % n; + final int nextIndex = (i + 1) % n; roundedCorners.add( - _RoundedCorner( - Point(vertices[prevIndex], vertices[prevIndex + 1]), - Point(vertices[i * 2], vertices[i * 2 + 1]), - Point(vertices[nextIndex], vertices[nextIndex + 1]), - vtxRounding, - ), + _RoundedCorner(vertices[prevIndex], vertices[i], vertices[nextIndex], vtxRounding), ); } @@ -186,11 +176,9 @@ class RoundedPolygon { roundedCorners[ix].expectedRoundCut + roundedCorners[(ix + 1) % n].expectedRoundCut; final double expectedCut = roundedCorners[ix].expectedCut + roundedCorners[(ix + 1) % n].expectedCut; - final double vtxX = vertices[ix * 2]; - final double vtxY = vertices[ix * 2 + 1]; - final double nextVtxX = vertices[((ix + 1) % n) * 2]; - final double nextVtxY = vertices[((ix + 1) % n) * 2 + 1]; - final double sideSize = distance(vtxX - nextVtxX, vtxY - nextVtxY); + final Point vtx = vertices[ix]; + final Point nextVtx = vertices[(ix + 1) % n]; + final double sideSize = distance(vtx.x - nextVtx.x, vtx.y - nextVtx.y); // Check expectedRoundCut first, and ensure we fulfill rounding needs // first for both corners before using space for smoothing. @@ -227,13 +215,9 @@ class RoundedPolygon { // those corners. final tempFeatures = []; for (var i = 0; i < n; i++) { - // Note that these indices are for pairs of values (points), they need to - // be doubled to access the xy values in the vertices float array. - final int prevVtxIndex = (i + n - 1) % n; - final int nextVtxIndex = (i + 1) % n; - final currVertex = Point(vertices[i * 2], vertices[i * 2 + 1]); - final prevVertex = Point(vertices[prevVtxIndex * 2], vertices[prevVtxIndex * 2 + 1]); - final nextVertex = Point(vertices[nextVtxIndex * 2], vertices[nextVtxIndex * 2 + 1]); + 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)) @@ -280,13 +264,11 @@ class RoundedPolygon { return RoundedPolygon._(features, center); } - final vertices = []; + final vertices = []; for (final feature in features) { for (final CubicBezier cubic in feature.cubics) { - vertices - ..add(cubic.anchor0X) - ..add(cubic.anchor0Y); + vertices.add(Point(cubic.anchor0X, cubic.anchor0Y)); } } @@ -369,7 +351,7 @@ class RoundedPolygon { final double bottom = center.y + height / 2; return RoundedPolygon.fromVertices( - [right, bottom, left, bottom, left, top, right, top], + [Point(right, bottom), Point(left, bottom), Point(left, top), Point(right, top)], rounding: rounding, perVertexRounding: perVertexRounding, center: center, @@ -482,14 +464,10 @@ class RoundedPolygon { return RoundedPolygon.fromVertices( [ - wHalf + center.x, - hHalf + center.y, - -wHalf + center.x, - hHalf + center.y, - -wHalf + center.x, - -hHalf + center.y, - 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), + Point(wHalf + center.x, -hHalf + center.y), ], rounding: CornerRounding(radius: math.min(wHalf, hHalf), smoothing: smoothing), center: center, @@ -864,15 +842,14 @@ class RoundedPolygon { /// 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) { +Point calculateCenter(List vertices) { var cumulativeX = 0.0; var cumulativeY = 0.0; - var index = 0; - while (index < vertices.length) { - cumulativeX += vertices[index++]; - cumulativeY += vertices[index++]; + for (final vertex in vertices) { + cumulativeX += vertex.x; + cumulativeY += vertex.y; } - return Point(cumulativeX / (vertices.length / 2), cumulativeY / (vertices.length / 2)); + return Point(cumulativeX / vertices.length, cumulativeY / vertices.length); } /// Private utility class that holds the information about each corner in a @@ -1144,21 +1121,14 @@ class _RoundedCorner { } } -List _verticesFromNumVerts(int numVertices, double radius, Point center) { - final result = List.filled(numVertices * 2, 0); - - var arrayIndex = 0; - for (var i = 0; i < numVertices; i++) { - final Point vertex = radialToCartesian(radius, math.pi / numVertices * 2 * i) + center; - - result[arrayIndex++] = vertex.x; - result[arrayIndex++] = vertex.y; - } - - return result; +List _verticesFromNumVerts(int numVertices, double radius, Point center) { + return List.generate( + numVertices, + (i) => radialToCartesian(radius, math.pi / numVertices * 2 * i) + center, + ); } -List _pillStarVerticesFromNumVerts( +List _pillStarVerticesFromNumVerts( int numVerticesPerRadius, double width, double height, @@ -1228,8 +1198,7 @@ List _pillStarVerticesFromNumVerts( // 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 * 4, 0); - var arrayIndex = 0; + final result = List.filled(numVerticesPerRadius * 2, Point.zero); final rectBR = Point(hSegHalf, vSegHalf); final rectBL = Point(-hSegHalf, vSegHalf); final rectTL = Point(-hSegHalf, -vSegHalf); @@ -1273,8 +1242,7 @@ List _pillStarVerticesFromNumVerts( // 8 _ => Point(currRadius, -vSegHalf + tProportion * vSegHalf), }; - result[arrayIndex++] = vertex.x + center.x; - result[arrayIndex++] = vertex.y + center.y; + result[i] = vertex + center; t += tPerVertex; inner = !inner; } @@ -1282,22 +1250,20 @@ List _pillStarVerticesFromNumVerts( return result; } -List _starVerticesFromNumVerts( +List _starVerticesFromNumVerts( int numVerticesPerRadius, double radius, double innerRadius, Point center, ) { - final result = List.filled(numVerticesPerRadius * 4, 0); + final result = List.filled(numVerticesPerRadius * 2, Point.zero); var arrayIndex = 0; for (var i = 0; i < numVerticesPerRadius; i++) { - Point vertex = radialToCartesian(radius, math.pi / numVerticesPerRadius * 2 * i); - result[arrayIndex++] = vertex.x + center.x; - result[arrayIndex++] = vertex.y + center.y; - vertex = radialToCartesian(innerRadius, math.pi / numVerticesPerRadius * (2 * i + 1)); - result[arrayIndex++] = vertex.x + center.x; - result[arrayIndex++] = vertex.y + center.y; + 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/test/shapes/feature_mapping_test.dart b/packages/material_ui/test/shapes/feature_mapping_test.dart index a185ca6a34fd..c56658a348cb 100644 --- a/packages/material_ui/test/shapes/feature_mapping_test.dart +++ b/packages/material_ui/test/shapes/feature_mapping_test.dart @@ -5,6 +5,7 @@ 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'; @@ -98,20 +99,13 @@ void main() { // Verify that complicated shapes can me matched (this used to crash // before). final RoundedPolygon checkmark = RoundedPolygon.fromVertices(const [ - 400, - -304, - 240, - -464, - 296, - -520, - 400, - -416, - 664, - -680, - 720, - -624, - 400, - -304, + 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( diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart index 87bf6be83309..117197c58124 100644 --- a/packages/material_ui/test/shapes/polygon_measure_test.dart +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -9,6 +9,7 @@ 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'; @@ -135,7 +136,7 @@ void main() { test('measure irregular triangle angle', () { irregularPolygonMeasure( RoundedPolygon.fromVertices( - const [0, -1, 1, 1, 0, 0.5, -1, 1], + 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), @@ -149,7 +150,7 @@ void main() { test('measure quarter angle', () { irregularPolygonMeasure( RoundedPolygon.fromVertices( - const [-1, -1, 1, -1, 1, 1, -1, 1], + const [Point(-1, -1), Point(1, -1), Point(1, 1), Point(-1, 1)], perVertexRounding: const [ CornerRounding.unrounded, CornerRounding.unrounded, @@ -164,21 +165,15 @@ void main() { // Regression test: Legacy measurer (AngleMeasurer) would skip the // diagonal sides as they are 0 degrees from the center. const unit = 1.0; - final coordinates = [ + const coordinates = [ // lower glass - 0, - 0, - unit, - unit, - -unit, - unit, + Point.zero, + Point(unit, unit), + Point(-unit, unit), // upper glass - 0, - 0, - -unit, - -unit, - unit, - -unit, + Point.zero, + Point(-unit, -unit), + Point(unit, -unit), ]; final double diagonal = math.sqrt(unit * unit + unit * unit); diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 31cd6b2cea87..8944c961e87d 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -48,16 +48,7 @@ void main() { const p1 = Point(0, 1); const p2 = Point(-1, 0); const p3 = Point(0, -1); - final manualSquare = RoundedPolygon.fromVertices([ - p0.x, - p0.y, - p1.x, - p1.y, - p2.x, - p2.y, - p3.x, - p3.y, - ]); + final manualSquare = RoundedPolygon.fromVertices(const [p0, p1, p2, p3]); min = const Point(-1, -1); max = const Point(1, 1); expectInBounds(manualSquare.cubics, min, max); @@ -68,14 +59,10 @@ void main() { final Point p2Offset = p2 + offset; final Point p3Offset = p3 + offset; final manualSquareOffset = RoundedPolygon.fromVertices([ - p0Offset.x, - p0Offset.y, - p1Offset.x, - p1Offset.y, - p2Offset.x, - p2Offset.y, - p3Offset.x, - p3Offset.y, + p0Offset, + p1Offset, + p2Offset, + p3Offset, ], center: offset); min = const Point(0, 1); max = const Point(2, 3); @@ -215,9 +202,14 @@ void main() { test('empty side', () { // Triangle with one point repeated. - final poly1 = RoundedPolygon.fromVertices(const [0, 0, 1, 0, 1, 0, 0, 1]); + final poly1 = RoundedPolygon.fromVertices(const [ + Point.zero, + Point(1, 0), + Point(1, 0), + Point(0, 1), + ]); // Triangle. - final poly2 = RoundedPolygon.fromVertices(const [0, 0, 1, 0, 0, 1]); + 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 index 72eab5cf5881..b4be65fd1497 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -47,9 +47,9 @@ void main() { const p1 = Point(0, 1); const p2 = Point(-1, 0); const p3 = Point(0, -1); - final List verts = [p0.x, p0.y, p1.x, p1.y, p2.x, p2.y, p3.x, p3.y]; + const verts = [p0, p1, p2, p3]; - expect(() => RoundedPolygon.fromVertices([p0.x, p0.y, p1.x, p1.y]), throwsArgumentError); + expect(() => RoundedPolygon.fromVertices(const [p0, p1]), throwsArgumentError); final manualSquare = RoundedPolygon.fromVertices(verts); var min = const Point(-1, -1); @@ -57,16 +57,7 @@ void main() { expectInBounds(manualSquare.cubics, min, max); const offset = Point(1, 2); - final List offsetVerts = [ - p0.x + offset.x, - p0.y + offset.y, - p1.x + offset.x, - p1.y + offset.y, - p2.x + offset.x, - p2.y + offset.y, - p3.x + offset.x, - p3.y + offset.y, - ]; + 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); @@ -163,20 +154,15 @@ void main() { }); test('computes center', () { - final polygon = RoundedPolygon.fromVertices(const [0, 0, 1, 0, 0, 1, 1, 1]); + 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); }); - List pointsToFloats(List points) { - final result = List.filled(points.length * 2, 0); - var index = 0; - for (final point in points) { - result[index++] = point.x; - result[index++] = point.y; - } - return result; - } - test('rounding space usage', () { const Point p0 = Point.zero; const p1 = Point(1, 0); @@ -187,7 +173,7 @@ void main() { CornerRounding.unrounded, ]; final polygon = RoundedPolygon.fromVertices( - pointsToFloats([p0, p1, p2]), + const [p0, p1, p2], perVertexRounding: pvRounding, ); @@ -242,7 +228,7 @@ void main() { rounding3, ]; final polygon = RoundedPolygon.fromVertices( - pointsToFloats([p0, p1, p2, p3]), + const [p0, p1, p2, p3], perVertexRounding: pvRounding, ); From 25304dfa8ddd2db6dc1ea04f2a2ecd03c65492ed Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 10:32:22 +0200 Subject: [PATCH 17/59] Use Offset in the CubicBezier API. --- .../material_ui/lib/src/shapes/cubic.dart | 178 ++++++++---------- .../material_ui/lib/src/shapes/morph.dart | 4 +- .../lib/src/shapes/rounded_polygon.dart | 28 +-- .../material_ui/test/shapes/cubic_test.dart | 150 ++++++--------- .../test/shapes/features_test.dart | 12 +- .../test/shapes/polygon_measure_test.dart | 10 +- .../material_ui/test/shapes/polygon_test.dart | 20 +- .../test/shapes/rounded_polygon_test.dart | 25 +-- .../material_ui/test/shapes/shapes_test.dart | 4 +- .../material_ui/test/shapes/test_utils.dart | 44 ++--- 10 files changed, 191 insertions(+), 284 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index b319e2a2f299..9ee26f54a11e 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -17,48 +17,16 @@ import 'point.dart'; import 'utils.dart'; /// This class holds the anchor and control point data for a single cubic -/// Bézier curve, with anchor points ([anchor0X], [anchor0Y]) and ([anchor1X], -/// [anchor1Y]) at either end and control points ([control0X], [control0Y]) -/// and ([control1X], [control1Y]) determining the slope of the curve between -/// the anchor points. +/// Bézier curve, with anchor points [anchor0] and [anchor1] at either end and +/// control points [control0] and [control1] determining the slope of the curve +/// between the anchor points. @immutable class CubicBezier { /// Creates a [CubicBezier] that holds the anchor and control point data for a - /// single Bézier curve, with anchor points ([anchor0X], [anchor0Y]) and - /// ([anchor1X], [anchor1Y]) at either end and control points ([control0X], - /// [control0Y]) and ([control1X], [control1Y]) determining the slope of the - /// curve between the anchor points. - CubicBezier( - double anchor0X, - double anchor0Y, - double control0X, - double control0Y, - double control1X, - double control1Y, - double anchor1X, - double anchor1Y, - ) : this.raw([ - anchor0X, - anchor0Y, - control0X, - control0Y, - control1X, - control1Y, - anchor1X, - anchor1Y, - ]); - - /// 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; - - /// Creates a [CubicBezier] from its two anchor points and its two control - /// points. - @internal - CubicBezier.fromPoints(Point anchor0, Point control0, Point control1, Point anchor1) + /// single Bézier curve, with anchor points [anchor0] and [anchor1] at either + /// end and control points [control0] and [control1] determining the slope of + /// the curve between the anchor points. + CubicBezier(Offset anchor0, Offset control0, Offset control1, Offset anchor1) : this.raw([ anchor0.x, anchor0.y, @@ -70,72 +38,74 @@ class CubicBezier { 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. The control points lie 1/3 of the distance from their respective - /// anchor points. - factory CubicBezier.straightLine(double x0, double y0, double x1, double y1) { + /// 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([ - x0, - y0, - lerp(x0, x1, 1 / 3), - lerp(y0, y1, 1 / 3), - lerp(x0, x1, 2 / 3), - lerp(y0, y1, 2 / 3), - x1, - y1, + 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, 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 the center. - factory CubicBezier.circularArc( - double centerX, - double centerY, - double x0, - double y0, - double x1, - double y1, - ) { - final Point p0d = directionVector(x0 - centerX, y0 - centerY); - final Point p1d = directionVector(x1 - centerX, y1 - centerY); + /// 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 p0d = directionVector(p0.x - center.x, p0.y - center.y); + final Point p1d = directionVector(p1.x - center.x, p1.y - center.y); final Point rotatedP0 = p0d.rotate90(); final Point rotatedP1 = p1d.rotate90(); - final bool clockwise = rotatedP0.dotProductXY(x1 - centerX, y1 - centerY) >= 0; + final bool clockwise = rotatedP0.dotProductXY(p1.x - center.x, p1.y - center.y) >= 0; final double cosa = p0d.dotProduct(p1d); // p0 ~= p1 if (cosa > 0.999) { - return CubicBezier.straightLine(x0, y0, x1, y1); + return CubicBezier.straightLine(p0, p1); } final double k = - distance(x0 - centerX, y0 - centerY) * + distance(p0.x - center.x, p0.y - center.y) * 4 / 3 * (math.sqrt(2 * (1 - cosa)) - math.sqrt(1 - cosa * cosa)) / (1 - cosa) * (clockwise ? 1 : -1); - return CubicBezier( - x0, - y0, - x0 + rotatedP0.x * k, - y0 + rotatedP0.y * k, - x1 - rotatedP1.x * k, - y1 - rotatedP1.y * k, - x1, - y1, - ); + 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 an empty [CubicBezier] defined at (x0, y0). + /// Generates an empty [CubicBezier] defined at [point]. /// /// Both anchor points and both control points coincide, so the curve has /// zero length. See [zeroLength]. - CubicBezier.empty(double x0, double y0) : this.raw([x0, y0, x0, y0, x0, y0, x0, y0]); + CubicBezier.empty(Offset point) + : this.raw([point.x, point.y, point.x, point.y, point.x, point.y, point.x, point.y]); final List _points; @@ -147,22 +117,34 @@ class CubicBezier { /// 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 [anchor0X]. + /// The X coordinate of the control point closest to [anchor0]. double get control0X => _points[2]; - /// The Y coordinate of the control point closest to [anchor0Y]. + /// The Y coordinate of the control point closest to [anchor0]. double get control0Y => _points[3]; - /// The X coordinate of the control point closest to [anchor1X]. + /// The X coordinate of the control point closest to [anchor1]. double get control1X => _points[4]; - /// The Y coordinate of the control point closest to [anchor1Y]. + /// 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. @@ -173,14 +155,13 @@ class CubicBezier { /// Returns a point on the curve for parameter [t], representing the /// proportional distance along the curve between its starting point at - /// anchor0 and ending point at anchor1. + /// [anchor0] and ending point at [anchor1]. /// /// [t] is the distance along the curve between the anchor points, where 0 - /// is at anchor0 and 1 is at anchor1 - @internal - Point pointOnCurve(double t) { + /// is at [anchor0] and 1 is at [anchor1]. + Offset pointOnCurve(double t) { final double u = 1 - t; - return Point( + return Offset( anchor0X * (u * u * u) + control0X * (3 * t * u * u) + control1X * (3 * t * t * u) + @@ -204,12 +185,7 @@ class CubicBezier { /// Whether the corner formed by this curve and [next] turns convexly. @internal - bool convexTo(CubicBezier next) { - final prevVertex = Point(anchor0X, anchor0Y); - final currVertex = Point(anchor1X, anchor1Y); - final nextVertex = Point(next.anchor1X, next.anchor1Y); - return convex(prevVertex, currVertex, nextVertex); - } + bool convexTo(CubicBezier next) => convex(anchor0, anchor1, next.anchor1); bool _zeroIsh(double value) => value.abs() < distanceEpsilon; @@ -344,7 +320,7 @@ class CubicBezier { final Point point = pointOnCurve(t); return ( - CubicBezier( + CubicBezier.raw([ anchor0X, anchor0Y, anchor0X * u + control0X * t, @@ -353,8 +329,8 @@ class CubicBezier { anchor0Y * (u * u) + control0Y * (2 * u * t) + control1Y * (t * t), point.x, point.y, - ), - CubicBezier( + ]), + CubicBezier.raw([ point.x, point.y, control0X * (u * u) + control1X * (2 * u * t) + anchor1X * (t * t), @@ -363,12 +339,12 @@ class CubicBezier { control1Y * u + anchor1Y * t, anchor1X, anchor1Y, - ), + ]), ); } /// Utility function to reverse the control/anchor points for this curve. - CubicBezier reverse() => CubicBezier( + CubicBezier reverse() => CubicBezier.raw([ anchor1X, anchor1Y, control1X, @@ -377,7 +353,7 @@ class CubicBezier { control0Y, anchor0X, anchor0Y, - ); + ]); /// Returns a curve whose coordinates are the sums of this curve's and [o]'s /// corresponding coordinates. diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index a568050000e3..ba83d3c96fdc 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -201,7 +201,7 @@ class Morph { if (lastCubic != null && firstCubic != null) { result.add( - CubicBezier( + CubicBezier.raw([ lastCubic.anchor0X, lastCubic.anchor0Y, lastCubic.control0X, @@ -210,7 +210,7 @@ class Morph { lastCubic.control1Y, firstCubic.anchor0X, firstCubic.anchor0Y, - ), + ]), ); } diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 122e38a6a16b..5ca9285b965d 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -223,12 +223,7 @@ class RoundedPolygon { ..add(CornerFeature(corners[i], convex: cvx)) ..add( EdgeFeature([ - CubicBezier.straightLine( - corners[i].last.anchor1X, - corners[i].last.anchor1Y, - corners[(i + 1) % n].first.anchor0X, - corners[(i + 1) % n].first.anchor0Y, - ), + CubicBezier.straightLine(corners[i].last.anchor1, corners[(i + 1) % n].first.anchor0), ]), ); } @@ -671,7 +666,7 @@ class RoundedPolygon { if (lastCubic != null && firstCubic != null) { cubics.add( - CubicBezier( + CubicBezier.raw([ lastCubic.anchor0X, lastCubic.anchor0Y, lastCubic.control0X, @@ -680,13 +675,11 @@ class RoundedPolygon { lastCubic.control1Y, firstCubic.anchor0X, firstCubic.anchor0Y, - ), + ]), ); } else { // Empty / 0-sized polygon. - final double cX = _center.x; - final double cY = _center.y; - cubics.add(CubicBezier(cX, cY, cX, cY, cX, cY, cX, cY)); + cubics.add(CubicBezier.empty(_center)); } } @@ -961,7 +954,7 @@ class _RoundedCorner { allowedCut < distanceEpsilon || cornerRadius < distanceEpsilon) { center = p1; - return [CubicBezier.straightLine(p1.x, p1.y, p1.x, p1.y)]; + return [CubicBezier.straightLine(p1, p1)]; } // How much of the cut is required for the rounding part. @@ -1003,14 +996,7 @@ class _RoundedCorner { return [ flanking0, - CubicBezier.circularArc( - center.x, - center.y, - flanking0.anchor1X, - flanking0.anchor1Y, - flanking2.anchor0X, - flanking2.anchor0Y, - ), + CubicBezier.circularArc(center, flanking0.anchor1, flanking2.anchor0), flanking2, ]; } @@ -1095,7 +1081,7 @@ class _RoundedCorner { // 2/3 seems to come from design tools? final Point anchorStart = (curveStart + anchorEnd * 2) / 3; - return CubicBezier.fromPoints(curveStart, anchorStart, anchorEnd, curveEnd); + return CubicBezier(curveStart, anchorStart, anchorEnd, curveEnd); } /// Returns the intersection point of the two lines d0->d1 and p0->p1, or diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index 2713f5440a05..657a95358345 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -19,19 +19,19 @@ void main() { const p1 = Point(1, 0.5); const p2 = Point(0.5, 1); const p3 = Point(0, 1); - final cubic = CubicBezier.fromPoints(p0, p1, p2, p3); + final cubic = CubicBezier(p0, p1, p2, p3); - test('fromPoints', () { - expect(p0, Point(cubic.anchor0X, cubic.anchor0Y)); - expect(p1, Point(cubic.control0X, cubic.control0Y)); - expect(p2, Point(cubic.control1X, cubic.control1Y)); - expect(p3, Point(cubic.anchor1X, cubic.anchor1Y)); + 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.x, zero.y, p0.x, p0.y, p3.x, p3.y); - expect(p0, Point(arcCubic.anchor0X, arcCubic.anchor0Y)); - expect(p3, Point(arcCubic.anchor1X, arcCubic.anchor1Y)); + final arcCubic = CubicBezier.circularArc(zero, p0, p3); + expect(p0, arcCubic.anchor0); + expect(p3, arcCubic.anchor1); }); test('div', () { @@ -40,67 +40,55 @@ void main() { divCubic = cubic / 1; expectCubicsEqualish(cubic, divCubic); divCubic = cubic / 2; - expectPointsEqualish(p0 / 2, Point(divCubic.anchor0X, divCubic.anchor0Y)); - expectPointsEqualish(p1 / 2, Point(divCubic.control0X, divCubic.control0Y)); - expectPointsEqualish(p2 / 2, Point(divCubic.control1X, divCubic.control1Y)); - expectPointsEqualish(p3 / 2, Point(divCubic.anchor1X, divCubic.anchor1Y)); + 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, Point(divCubic.anchor0X, divCubic.anchor0Y)); - expectPointsEqualish(p1 / 2, Point(divCubic.control0X, divCubic.control0Y)); - expectPointsEqualish(p2 / 2, Point(divCubic.control1X, divCubic.control1Y)); - expectPointsEqualish(p3 / 2, Point(divCubic.anchor1X, divCubic.anchor1Y)); + 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, Point(timesCubic.anchor0X, timesCubic.anchor0Y)); - expect(p1, Point(timesCubic.control0X, timesCubic.control0Y)); - expect(p2, Point(timesCubic.control1X, timesCubic.control1Y)); - expect(p3, Point(timesCubic.anchor1X, timesCubic.anchor1Y)); + expect(p0, timesCubic.anchor0); + expect(p1, timesCubic.control0); + expect(p2, timesCubic.control1); + expect(p3, timesCubic.anchor1); timesCubic = cubic * 1; - expect(p0, Point(timesCubic.anchor0X, timesCubic.anchor0Y)); - expect(p1, Point(timesCubic.control0X, timesCubic.control0Y)); - expect(p2, Point(timesCubic.control1X, timesCubic.control1Y)); - expect(p3, Point(timesCubic.anchor1X, timesCubic.anchor1Y)); + expect(p0, timesCubic.anchor0); + expect(p1, timesCubic.control0); + expect(p2, timesCubic.control1); + expect(p3, timesCubic.anchor1); timesCubic = cubic * 2; - expectPointsEqualish(p0 * 2, Point(timesCubic.anchor0X, timesCubic.anchor0Y)); - expectPointsEqualish(p1 * 2, Point(timesCubic.control0X, timesCubic.control0Y)); - expectPointsEqualish(p2 * 2, Point(timesCubic.control1X, timesCubic.control1Y)); - expectPointsEqualish(p3 * 2, Point(timesCubic.anchor1X, timesCubic.anchor1Y)); + 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, Point(timesCubic.anchor0X, timesCubic.anchor0Y)); - expectPointsEqualish(p1 * 2, Point(timesCubic.control0X, timesCubic.control0Y)); - expectPointsEqualish(p2 * 2, Point(timesCubic.control1X, timesCubic.control1Y)); - expectPointsEqualish(p3 * 2, Point(timesCubic.anchor1X, timesCubic.anchor1Y)); + 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 + Point(offsetCubic.anchor0X, offsetCubic.anchor0Y), - Point(plusCubic.anchor0X, plusCubic.anchor0Y), - ); - expectPointsEqualish( - p1 + Point(offsetCubic.control0X, offsetCubic.control0Y), - Point(plusCubic.control0X, plusCubic.control0Y), - ); - expectPointsEqualish( - p2 + Point(offsetCubic.control1X, offsetCubic.control1Y), - Point(plusCubic.control1X, plusCubic.control1Y), - ); - expectPointsEqualish( - p3 + Point(offsetCubic.anchor1X, offsetCubic.anchor1Y), - Point(plusCubic.anchor1X, plusCubic.anchor1Y), - ); + 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('reverse', () { final CubicBezier reverseCubic = cubic.reverse(); - expect(p3, Point(reverseCubic.anchor0X, reverseCubic.anchor0Y)); - expect(p2, Point(reverseCubic.control0X, reverseCubic.control0Y)); - expect(p1, Point(reverseCubic.control1X, reverseCubic.control1Y)); - expect(p0, Point(reverseCubic.anchor1X, reverseCubic.anchor1Y)); + expect(p3, reverseCubic.anchor0); + expect(p2, reverseCubic.control0); + expect(p1, reverseCubic.control1); + expect(p0, reverseCubic.anchor1); }); void expectBetween(Point end0, Point end1, Point actual) { @@ -115,37 +103,25 @@ void main() { } test('straightLine', () { - final lineCubic = CubicBezier.straightLine(p0.x, p0.y, p3.x, p3.y); - expect(p0, Point(lineCubic.anchor0X, lineCubic.anchor0Y)); - expect(p3, Point(lineCubic.anchor1X, lineCubic.anchor1Y)); - expectBetween(p0, p3, Point(lineCubic.control0X, lineCubic.control0Y)); - expectBetween(p0, p3, Point(lineCubic.control1X, lineCubic.control1Y)); + 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(Point(cubic.anchor0X, cubic.anchor0Y), Point(split0.anchor0X, split0.anchor0Y)); - expect(Point(cubic.anchor1X, cubic.anchor1Y), Point(split1.anchor1X, split1.anchor1Y)); - expectBetween( - Point(cubic.anchor0X, cubic.anchor0Y), - Point(cubic.anchor1X, cubic.anchor1Y), - Point(split0.anchor1X, split0.anchor1Y), - ); - expectBetween( - Point(cubic.anchor0X, cubic.anchor0Y), - Point(cubic.anchor1X, cubic.anchor1Y), - Point(split1.anchor0X, split1.anchor0Y), - ); + 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('pointOnCurve', () { Point halfway = cubic.pointOnCurve(0.5); - expectBetween( - Point(cubic.anchor0X, cubic.anchor0Y), - Point(cubic.anchor1X, cubic.anchor1Y), - halfway, - ); - final straightLineCubic = CubicBezier.straightLine(p0.x, p0.y, p3.x, p3.y); + expectBetween(cubic.anchor0, cubic.anchor1, halfway); + final straightLineCubic = CubicBezier.straightLine(p0, p3); halfway = straightLineCubic.pointOnCurve(0.5); final computedHalfway = Point(p0.x + 0.5 * (p3.x - p0.x), p0.y + 0.5 * (p3.y - p0.y)); expectPointsEqualish(computedHalfway, halfway); @@ -165,26 +141,14 @@ void main() { const translationVector = Point(tx, ty); transform = translateTransform(tx, ty); transformedCubic = cubic.transformed(transform); - expectPointsEqualish( - Point(cubic.anchor0X, cubic.anchor0Y) + translationVector, - Point(transformedCubic.anchor0X, transformedCubic.anchor0Y), - ); - expectPointsEqualish( - Point(cubic.control0X, cubic.control0Y) + translationVector, - Point(transformedCubic.control0X, transformedCubic.control0Y), - ); - expectPointsEqualish( - Point(cubic.control1X, cubic.control1Y) + translationVector, - Point(transformedCubic.control1X, transformedCubic.control1Y), - ); - expectPointsEqualish( - Point(cubic.anchor1X, cubic.anchor1Y) + translationVector, - Point(transformedCubic.anchor1X, transformedCubic.anchor1Y), - ); + 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('empty CubicBezier has zero length', () { - expect(CubicBezier.empty(10, 10).zeroLength(), isTrue); + expect(CubicBezier.empty(const Point(10, 10)).zeroLength(), isTrue); }); }); } diff --git a/packages/material_ui/test/shapes/features_test.dart b/packages/material_ui/test/shapes/features_test.dart index f02be4531d1b..8e8fb6c748b2 100644 --- a/packages/material_ui/test/shapes/features_test.dart +++ b/packages/material_ui/test/shapes/features_test.dart @@ -17,8 +17,8 @@ void main() { }); test('Cannot build non continuous features', () { - final cubic1 = CubicBezier.straightLine(0, 0, 1, 1); - final cubic2 = CubicBezier.straightLine(10, 10, 11, 11); + final cubic1 = CubicBezier.straightLine(Offset.zero, const Offset(1, 1)); + final cubic2 = CubicBezier.straightLine(const Offset(10, 10), const Offset(11, 11)); expect(() => Feature.buildConvexCorner([cubic1, cubic2]), throwsArgumentError); expect(() => Feature.buildConcaveCorner([cubic1, cubic2]), throwsArgumentError); @@ -26,28 +26,28 @@ void main() { }); test('Builds concave corner', () { - final cubic = CubicBezier.straightLine(0, 0, 1, 0); + final cubic = CubicBezier.straightLine(Offset.zero, const Offset(1, 0)); final actual = Feature.buildConcaveCorner([cubic]); final expected = CornerFeature([cubic], convex: false); expectFeaturesEqualish(expected, actual); }); test('Builds convex corner', () { - final cubic = CubicBezier.straightLine(0, 0, 1, 0); + final cubic = CubicBezier.straightLine(Offset.zero, const Offset(1, 0)); final actual = Feature.buildConvexCorner([cubic]); final expected = CornerFeature([cubic]); expectFeaturesEqualish(expected, actual); }); test('Builds edge', () { - final cubic = CubicBezier.straightLine(0, 0, 1, 0); + final cubic = CubicBezier.straightLine(Offset.zero, const Offset(1, 0)); final actual = Feature.buildEdge(cubic); final expected = EdgeFeature([cubic]); expectFeaturesEqualish(expected, actual); }); test('Builds ignorable as edge', () { - final cubic = CubicBezier.straightLine(0, 0, 1, 0); + final cubic = CubicBezier.straightLine(Offset.zero, const Offset(1, 0)); final actual = Feature.buildIgnorableFeature([cubic]); final expected = EdgeFeature([cubic]); expectFeaturesEqualish(expected, actual); diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart index 117197c58124..c4b577ccde11 100644 --- a/packages/material_ui/test/shapes/polygon_measure_test.dart +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -193,11 +193,13 @@ void main() { test('handles empty feature last', () { final triangle = RoundedPolygon.fromFeatures([ - Feature.buildConvexCorner([CubicBezier.straightLine(0, 0, 1, 1)]), - Feature.buildConvexCorner([CubicBezier.straightLine(1, 1, 1, 0)]), - Feature.buildConvexCorner([CubicBezier.straightLine(1, 0, 0, 0)]), + Feature.buildConvexCorner([CubicBezier.straightLine(Offset.zero, const Offset(1, 1))]), + Feature.buildConvexCorner([ + CubicBezier.straightLine(const Offset(1, 1), const Offset(1, 0)), + ]), + Feature.buildConvexCorner([CubicBezier.straightLine(const Offset(1, 0), Offset.zero)]), // Empty feature at the end. - Feature.buildConvexCorner([CubicBezier.straightLine(0, 0, 0, 0)]), + Feature.buildConvexCorner([CubicBezier.straightLine(Offset.zero, Offset.zero)]), ]); irregularPolygonMeasure(triangle); diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 8944c961e87d..381c5ba4f653 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -120,22 +120,10 @@ void main() { final List translatedSquareCubics = square.transformed(translator).cubics; for (var i = 0; i < squareCubics.length; i++) { - expectPointsEqualish( - Point(squareCubics[i].anchor0X, squareCubics[i].anchor0Y) + offset, - Point(translatedSquareCubics[i].anchor0X, translatedSquareCubics[i].anchor0Y), - ); - expectPointsEqualish( - Point(squareCubics[i].control0X, squareCubics[i].control0Y) + offset, - Point(translatedSquareCubics[i].control0X, translatedSquareCubics[i].control0Y), - ); - expectPointsEqualish( - Point(squareCubics[i].control1X, squareCubics[i].control1Y) + offset, - Point(translatedSquareCubics[i].control1X, translatedSquareCubics[i].control1Y), - ); - expectPointsEqualish( - Point(squareCubics[i].anchor1X, squareCubics[i].anchor1Y) + offset, - Point(translatedSquareCubics[i].anchor1X, translatedSquareCubics[i].anchor1Y), - ); + 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); } }); diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index b4be65fd1497..5b5aca870a2f 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -82,15 +82,15 @@ void main() { expect(() => RoundedPolygon.fromFeatures(const []), throwsArgumentError); expect( () => RoundedPolygon.fromFeatures([ - CornerFeature([CubicBezier.empty(0, 0)]), + CornerFeature([CubicBezier.empty(Point.zero)]), ]), throwsArgumentError, ); }); test('throws for non continuous features', () { - final cubic1 = CubicBezier.straightLine(0, 0, 1, 0); - final cubic2 = CubicBezier.straightLine(10, 10, 20, 20); + 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.buildEdge(cubic1), Feature.buildEdge(cubic2)]), throwsArgumentError, @@ -172,10 +172,11 @@ void main() { const CornerRounding(radius: 1, smoothing: 1), CornerRounding.unrounded, ]; - final polygon = RoundedPolygon.fromVertices( - const [p0, p1, p2], - perVertexRounding: pvRounding, - ); + 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 @@ -227,10 +228,12 @@ void main() { CornerRounding.unrounded, rounding3, ]; - final polygon = RoundedPolygon.fromVertices( - const [p0, p1, p2, p3], - perVertexRounding: pvRounding, - ); + final polygon = RoundedPolygon.fromVertices(const [ + p0, + p1, + p2, + p3, + ], perVertexRounding: pvRounding); final [EdgeFeature e01, _, _, EdgeFeature e30] = polygon.features .whereType() diff --git a/packages/material_ui/test/shapes/shapes_test.dart b/packages/material_ui/test/shapes/shapes_test.dart index fe68da272ef4..cac3859287bc 100644 --- a/packages/material_ui/test/shapes/shapes_test.dart +++ b/packages/material_ui/test/shapes/shapes_test.dart @@ -41,8 +41,8 @@ void main() { double? radius2, Point center = zero, ]) { - expectPointOnRadii(Point(cubic.anchor0X, cubic.anchor0Y), radius1, radius2, center); - expectPointOnRadii(Point(cubic.anchor1X, cubic.anchor1Y), radius1, radius2, center); + expectPointOnRadii(cubic.anchor0, radius1, radius2, center); + expectPointOnRadii(cubic.anchor1, radius1, radius2, center); } // Tests points along the curve of the cubic by comparing the distance diff --git a/packages/material_ui/test/shapes/test_utils.dart b/packages/material_ui/test/shapes/test_utils.dart index bd3f95f22d5b..68a95d6c948b 100644 --- a/packages/material_ui/test/shapes/test_utils.dart +++ b/packages/material_ui/test/shapes/test_utils.dart @@ -22,10 +22,10 @@ bool pointsEqualish(Point p0, Point p1) { } bool cubicsEqualish(CubicBezier c0, CubicBezier c1) { - return pointsEqualish(Point(c0.anchor0X, c0.anchor0Y), Point(c1.anchor0X, c1.anchor0Y)) && - pointsEqualish(Point(c0.anchor1X, c0.anchor1Y), Point(c1.anchor1X, c1.anchor1Y)) && - pointsEqualish(Point(c0.control0X, c0.control0Y), Point(c1.control0X, c1.control0Y)) && - pointsEqualish(Point(c0.control1X, c0.control1Y), Point(c1.control1X, c1.control1Y)); + 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. @@ -36,22 +36,10 @@ void expectPointsEqualish(Point expected, Point actual) { } void expectCubicsEqualish(CubicBezier expected, CubicBezier actual) { - expectPointsEqualish( - Point(expected.anchor0X, expected.anchor0Y), - Point(actual.anchor0X, actual.anchor0Y), - ); - expectPointsEqualish( - Point(expected.control0X, expected.control0Y), - Point(actual.control0X, actual.control0Y), - ); - expectPointsEqualish( - Point(expected.control1X, expected.control1Y), - Point(actual.control1X, actual.control1Y), - ); - expectPointsEqualish( - Point(expected.anchor1X, expected.anchor1Y), - Point(actual.anchor1X, actual.anchor1Y), - ); + 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) { @@ -95,14 +83,14 @@ void expectEqualish(double expected, double actual, [String? message]) { void expectInBounds(List shape, Point minPoint, Point maxPoint) { for (final cubic in shape) { - expectPointGreaterish(minPoint, Point(cubic.anchor0X, cubic.anchor0Y)); - expectPointLessish(maxPoint, Point(cubic.anchor0X, cubic.anchor0Y)); - expectPointGreaterish(minPoint, Point(cubic.control0X, cubic.control0Y)); - expectPointLessish(maxPoint, Point(cubic.control0X, cubic.control0Y)); - expectPointGreaterish(minPoint, Point(cubic.control1X, cubic.control1Y)); - expectPointLessish(maxPoint, Point(cubic.control1X, cubic.control1Y)); - expectPointGreaterish(minPoint, Point(cubic.anchor1X, cubic.anchor1Y)); - expectPointLessish(maxPoint, Point(cubic.anchor1X, cubic.anchor1Y)); + 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); } } From 5147b0032050e36bb7c3750fef01ab79ca34875a Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 13:29:47 +0200 Subject: [PATCH 18/59] Clean up the shapes toPath parameters and fix the rotation pivot. --- .../material_shapes/material_shapes.0.dart | 2 +- .../lib/src/material_shape_border.dart | 2 +- .../material_ui/lib/src/shapes/cubic.dart | 44 ++++++++-------- .../material_ui/lib/src/shapes/morph.dart | 45 ++++++---------- .../lib/src/shapes/rounded_polygon.dart | 27 ++++------ .../material_ui/test/shapes/cubic_test.dart | 52 +++++++++++++++++++ .../material_ui/test/shapes/morph_test.dart | 4 +- .../test/shapes/rounded_polygon_test.dart | 24 ++++++++- .../material_ui/test/shapes/test_utils.dart | 4 ++ 9 files changed, 131 insertions(+), 73 deletions(-) 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 index c4c93e202ddf..fceb2648895a 100644 --- a/packages/material_ui/example/lib/material_shapes/material_shapes.0.dart +++ b/packages/material_ui/example/lib/material_shapes/material_shapes.0.dart @@ -177,7 +177,7 @@ class _MorphPainter extends CustomPainter { canvas ..save() ..scale(size.width) - ..drawPath(morph.value.toPath(progress: progress.value), _paint) + ..drawPath(morph.value.toPath(progress.value), _paint) ..restore(); } diff --git a/packages/material_ui/lib/src/material_shape_border.dart b/packages/material_ui/lib/src/material_shape_border.dart index 0f299fa0cd32..8cc4fcebc516 100644 --- a/packages/material_ui/lib/src/material_shape_border.dart +++ b/packages/material_ui/lib/src/material_shape_border.dart @@ -171,7 +171,7 @@ class MaterialShapeBorder extends OutlinedBorder { ..translate(actualRect.left, actualRect.top) ..scale(scale.dx, scale.dy); - return pathFromCubics(cubics: _cubics).transform(matrix.storage); + return pathFromCubics(_cubics).transform(matrix.storage); } @override diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 9ee26f54a11e..3b80622c5eee 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -446,12 +446,12 @@ class _MutableCubicBezier extends CubicBezier { /// [Morph.toPath], and is useful when working with a list of curves obtained /// from [Morph.asCubics] directly. /// -/// [path] is a [Path] to reset and set with the new path data. A new [Path] is -/// created when none is given. -/// -/// [startAngle] is an angle (in degrees) to rotate the [Path] to start -/// drawing from. If [startAngle] is non zero, then caller has to use the -/// returned [Path], as path transformation creates a new path. +/// [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. /// /// [repeatPath] is whether or not to repeat the [Path] twice before closing /// it. This flag is useful when the caller would like to draw parts of the @@ -461,25 +461,21 @@ class _MutableCubicBezier extends CubicBezier { /// /// [closePath] is whether or not to close the created [Path]. /// -/// [rotationPivotX] is the rotation pivot on the X axis. -/// -/// [rotationPivotY] is the rotation pivot on the Y axis. -Path pathFromCubics({ - required List cubics, - Path? path, - int startAngle = 0, +/// [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, - double rotationPivotX = 0, - double rotationPivotY = 0, + Offset rotationPivot = Offset.zero, }) { - path ??= Path(); + var path = Path(); var first = true; CubicBezier? firstCubic; - path.reset(); - for (final cubic in cubics) { if (first) { path.moveTo(cubic.anchor0X, cubic.anchor0Y); @@ -524,12 +520,16 @@ Path pathFromCubics({ if (startAngle != 0 && firstCubic != null) { final double angleToFirstCubic = math.atan2( - cubics[0].anchor0Y - rotationPivotY, - cubics[0].anchor0X - rotationPivotX, + cubics[0].anchor0Y - rotationPivot.dy, + cubics[0].anchor0X - rotationPivot.dx, ); - // Rotate the Path to to start from the given angle. + // Rotate the path around the pivot so that it starts from the given angle. path = path.transform( - (Matrix4.identity()..rotateZ(-angleToFirstCubic + (startAngle * math.pi / 180))).storage, + (Matrix4.identity() + ..translateByDouble(rotationPivot.dx, rotationPivot.dy, 0, 1) + ..rotateZ(-angleToFirstCubic + startAngle) + ..translateByDouble(-rotationPivot.dx, -rotationPivot.dy, 0, 1)) + .storage, ); } diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index ba83d3c96fdc..6e8c29e381fb 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -221,11 +221,12 @@ class Morph { /// /// [progress] is the [Morph]'s progress. /// - /// [path] is a [Path] to reset and set with the new path data. - /// - /// [startAngle] is an angle (in degrees) to rotate the [Path] to start - /// drawing from. If [startAngle] is non zero, then caller has to use the - /// returned [Path], as path transformation creates a new path. + /// [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 [asCubics] produced them. /// /// [repeatPath] is whether or not to repeat the [Path] twice before closing /// it. This flag is useful when the caller would like to draw parts of the @@ -235,36 +236,24 @@ class Morph { /// /// [closePath] is whether or not to close the created [Path]. /// - /// [rotationPivotX] is the rotation pivot on the X axis. By default it's set - /// to 0, and that should align with Morph instances that were created for - /// [RoundedPolygon] with a zero [RoundedPolygon.center]. In case the - /// [RoundedPolygon] was normalized (i.e. moved to (0.5, 0.5)), or was - /// created with a different center, this pivot point may need to be aligned - /// to support a proper rotation. - /// - /// [rotationPivotY] is the rotation pivot on the Y axis. By default it's set - /// to 0, and that should align with Morph instances that were created for - /// [RoundedPolygon] with a zero [RoundedPolygon.center]. In case the - /// [RoundedPolygon] was normalized (i.e. moved to (0.5, 0.5)), or was - /// created with a different center, this pivot point may need to be aligned - /// to support a proper rotation. - Path toPath({ - required double progress, - int startAngle = 0, + /// [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, - double rotationPivotX = 0, - double rotationPivotY = 0, - Path? path, + Offset rotationPivot = Offset.zero, }) { return pathFromCubics( - cubics: asCubics(progress), - path: path, + asCubics(progress), startAngle: startAngle, repeatPath: repeatPath, closePath: closePath, - rotationPivotX: rotationPivotX, - rotationPivotY: rotationPivotY, + rotationPivot: rotationPivot, ); } } diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 5ca9285b965d..756bf9ad61f7 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -757,19 +757,14 @@ class RoundedPolygon { return bounds; } - /// Returns a [Path] representation for a [RoundedPolygon] shape. Note that - /// there is some rounding happening (to the nearest thousandth), to work - /// around rendering artifacts introduced by some points being just slightly - /// off from each other (far less than a pixel). This also allows for a more - /// optimal path, as redundant curves (usually a single point) can be - /// detected and not added to the resulting path. + /// Returns a [Path] representation for a [RoundedPolygon] shape. /// - /// [path] is a [Path] to reset and set with the new path data. - /// - /// [startAngle] is an angle (in degrees) to rotate the [Path] to start - /// drawing from. The rotation pivot is set to be the polygon's [center]. - /// If [startAngle] is non zero, then caller has to use the returned [Path], - /// as path transformation creates a new path. + /// [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. /// /// [repeatPath] is whether or not to repeat the [Path] twice before closing /// it. This flag is useful when the caller would like to draw parts of the @@ -778,15 +773,13 @@ class RoundedPolygon { /// progress indicator advances). /// /// [closePath] is whether or not to close the created [Path]. - Path toPath({int startAngle = 0, bool repeatPath = false, bool closePath = true, Path? path}) { + Path toPath({double startAngle = 0, bool repeatPath = false, bool closePath = true}) { return pathFromCubics( - cubics: cubics, - path: path, + cubics, startAngle: startAngle, repeatPath: repeatPath, closePath: closePath, - rotationPivotX: _center.x, - rotationPivotY: _center.y, + rotationPivot: _center, ); } diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index 657a95358345..0e271cd269eb 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -3,6 +3,7 @@ // 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'; @@ -151,4 +152,55 @@ void main() { expect(CubicBezier.empty(const Point(10, 10)).zeroLength(), isTrue); }); }); + + 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/morph_test.dart b/packages/material_ui/test/shapes/morph_test.dart index c314d0046678..f3f775fb3ca2 100644 --- a/packages/material_ui/test/shapes/morph_test.dart +++ b/packages/material_ui/test/shapes/morph_test.dart @@ -97,8 +97,8 @@ void main() { final ui.Path poly1Path = poly1.toPath().transform(matrix.storage); final ui.Path poly2Path = poly2.toPath().transform(matrix.storage); - final ui.Path morph120Path = morph12.toPath(progress: 0).transform(matrix.storage); - final ui.Path morph121Path = morph12.toPath(progress: 1).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); diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index 5b5aca870a2f..8b237f3791a6 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -3,6 +3,7 @@ // 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'; @@ -163,6 +164,26 @@ void main() { expect(const Point(0.5, 0.5), polygon.center); }); + 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); @@ -192,8 +213,7 @@ void main() { }); // In the following tests, we check how much was cut for the top left - // (vertex 0) and bottom - // left corner (vertex 3). + // (vertex 0) and bottom left corner (vertex 3). // In particular, both vertex are competing for space in the left side. // // Vertex 0 Vertex 1 diff --git a/packages/material_ui/test/shapes/test_utils.dart b/packages/material_ui/test/shapes/test_utils.dart index 68a95d6c948b..e504e8f3e749 100644 --- a/packages/material_ui/test/shapes/test_utils.dart +++ b/packages/material_ui/test/shapes/test_utils.dart @@ -3,6 +3,7 @@ // 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'; @@ -94,6 +95,9 @@ void expectInBounds(List shape, Point minPoint, Point maxPoint) { } } +// The point a path starts drawing from. +Point pathStartPoint(Path path) => path.computeMetrics().first.getTangentForOffset(0)!.position; + PointTransformer identityTransform() => (x, y) => (x, y); From b0d2c7a33518320e41fc17dc8f6f01e19c2f160b Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 17:13:37 +0200 Subject: [PATCH 19/59] Fix the shapes equality and hashCode list-identity bugs. --- .../lib/src/material_shape_border.dart | 4 +- .../lib/src/shapes/rounded_polygon.dart | 2 +- .../test/material_shape_border_test.dart | 80 +++++++++++++++++++ .../test/shapes/rounded_polygon_test.dart | 9 +++ 4 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 packages/material_ui/test/material_shape_border_test.dart diff --git a/packages/material_ui/lib/src/material_shape_border.dart b/packages/material_ui/lib/src/material_shape_border.dart index 8cc4fcebc516..747419b3d8a4 100644 --- a/packages/material_ui/lib/src/material_shape_border.dart +++ b/packages/material_ui/lib/src/material_shape_border.dart @@ -207,13 +207,13 @@ class MaterialShapeBorder extends OutlinedBorder { return other is MaterialShapeBorder && other.shape == shape && - other._cubics == _cubics && + listEquals(other._cubics, _cubics) && other.side == side && other.squash == squash; } @override - int get hashCode => Object.hash(shape, _cubics, squash, side.hashCode); + int get hashCode => Object.hash(shape, Object.hashAll(_cubics), side, squash); @override String toString() { diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 756bf9ad61f7..ab83f7b6fbf9 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -815,7 +815,7 @@ class RoundedPolygon { } @override - int get hashCode => features.hashCode; + int get hashCode => Object.hashAll(features); } /// Calculates an estimated center position for the polygon, returning it. This 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..a0a4d15a6f6c --- /dev/null +++ b/packages/material_ui/test/material_shape_border_test.dart @@ -0,0 +1,80 @@ +// 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', () { + 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('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))); + }); + }); +} diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index 8b237f3791a6..9cf1846175bd 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -164,6 +164,15 @@ void main() { expect(const Point(0.5, 0.5), polygon.center); }); + 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('toPath rotates around the polygon center', () { // A diamond filling the unit square, with its first vertex at angle zero // from its center. From 0bd1924ac1edb220bf4b9b1e104c1822dddf63f9 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 17:41:46 +0200 Subject: [PATCH 20/59] Add value equality to the shapes features. --- .../material_ui/lib/src/shapes/features.dart | 24 ++++- .../test/material_shape_border_test.dart | 8 ++ .../test/shapes/features_test.dart | 90 ++++++++++++++++++- .../test/shapes/rounded_polygon_test.dart | 11 +++ 4 files changed, 129 insertions(+), 4 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/features.dart b/packages/material_ui/lib/src/shapes/features.dart index d2cf5a5db6b0..89882da66b02 100644 --- a/packages/material_ui/lib/src/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/features.dart @@ -28,6 +28,7 @@ import 'point.dart'; /// /// By using features, you can manipulate polygon shapes with more context and /// control. +@immutable abstract class Feature { /// Creates a [Feature] spanning the given [cubics]. /// @@ -136,6 +137,20 @@ abstract class Feature { /// Returns a new [Feature] with the points that define the shape of this /// [Feature] in reversed order. Feature 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 @@ -144,7 +159,7 @@ abstract class Feature { @internal class EdgeFeature extends Feature { /// Creates an [EdgeFeature] from the given cubics. - EdgeFeature(super._cubics); + const EdgeFeature(super._cubics); @override Feature transformed(PointTransformer f) => @@ -217,4 +232,11 @@ class CornerFeature extends Feature { return 'Corner: cubics=${_cubics.map((c) => '[$c]').join(', ')} ' '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/test/material_shape_border_test.dart b/packages/material_ui/test/material_shape_border_test.dart index a0a4d15a6f6c..e0e1d17b28b6 100644 --- a/packages/material_ui/test/material_shape_border_test.dart +++ b/packages/material_ui/test/material_shape_border_test.dart @@ -62,6 +62,14 @@ void main() { 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, diff --git a/packages/material_ui/test/shapes/features_test.dart b/packages/material_ui/test/shapes/features_test.dart index 8e8fb6c748b2..a7eda23e2bb6 100644 --- a/packages/material_ui/test/shapes/features_test.dart +++ b/packages/material_ui/test/shapes/features_test.dart @@ -11,9 +11,9 @@ import 'test_utils.dart'; void main() { group('$Feature', () { test('Cannot build empty features', () { - expect(() => Feature.buildConvexCorner([]), throwsArgumentError); - expect(() => Feature.buildConcaveCorner([]), throwsArgumentError); - expect(() => Feature.buildIgnorableFeature([]), throwsArgumentError); + expect(() => Feature.buildConvexCorner(const []), throwsArgumentError); + expect(() => Feature.buildConcaveCorner(const []), throwsArgumentError); + expect(() => Feature.buildIgnorableFeature(const []), throwsArgumentError); }); test('Cannot build non continuous features', () { @@ -52,5 +52,89 @@ void main() { 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]), + ); + }); }); } diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index 9cf1846175bd..a37dfef15ac8 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -173,6 +173,17 @@ void main() { 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. From b0d14b2b3c205b2c2a671455ad0bf87a2adbedc5 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 18:13:38 +0200 Subject: [PATCH 21/59] Add more MaterialShapeBorder tests. --- .../test/material_shape_border_test.dart | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/packages/material_ui/test/material_shape_border_test.dart b/packages/material_ui/test/material_shape_border_test.dart index e0e1d17b28b6..f5a7d35498d5 100644 --- a/packages/material_ui/test/material_shape_border_test.dart +++ b/packages/material_ui/test/material_shape_border_test.dart @@ -7,6 +7,12 @@ 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); @@ -84,5 +90,261 @@ void main() { 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 makes a lerped border lerpable again', () { + 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)); + expect(() => restored.lerpTo(end, 0.5), returnsNormally); + }); + + 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 throws when a border is already the result of a lerp', () { + 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); + + expect(() => lerped.lerpTo(end, 0.5), throwsStateError); + expect(() => lerped.lerpFrom(start, 0.5), throwsStateError); + expect(() => start.lerpTo(lerped, 0.5), throwsStateError); + expect(() => end.lerpFrom(lerped, 0.5), throwsStateError); + // ShapeBorder.lerp tries lerpFrom on the second border first. + expect(() => ShapeBorder.lerp(lerped, end, 0.5), throwsStateError); + }); + + 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)); + }); }); } From 70be6044f0df1849c8edc72319e76c86cab49916 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 20:37:00 +0200 Subject: [PATCH 22/59] Cache the MaterialShapeBorder morph across lerps. --- .../lib/src/material_shape_border.dart | 86 ++++++++++++++++++- .../test/material_shape_border_test.dart | 42 +++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/packages/material_ui/lib/src/material_shape_border.dart b/packages/material_ui/lib/src/material_shape_border.dart index 747419b3d8a4..f177455baaf3 100644 --- a/packages/material_ui/lib/src/material_shape_border.dart +++ b/packages/material_ui/lib/src/material_shape_border.dart @@ -52,6 +52,28 @@ class MaterialShapeBorder extends OutlinedBorder { final List _cubics; + // The number 5 was chosen without any real science or research behind it. It + // just seemed like a number that's not too big (a handful of morphs fits in + // memory comfortably) and not too small (few screens animate between more + // than 5 distinct pairs of shapes 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 previously + /// computed one when it is still cached. + /// + /// A [Morph] matches the curves of its two shapes at construction time, which + /// is far more expensive than evaluating it at a progress value, and the + /// mapping it produces depends only on those two shapes. A transition asks + /// for the same pair on every frame, so computing the mapping once and + /// keeping it is what makes lerping affordable. + static Morph _morphBetween(RoundedPolygon start, RoundedPolygon end) { + return _morphCache.putIfAbsent(_MorphCacheKey(start, end), () => Morph(start, end)); + } + @override ShapeBorder scale(double t) { final RoundedPolygon? shape = this.shape; @@ -86,7 +108,7 @@ class MaterialShapeBorder extends OutlinedBorder { } return MaterialShapeBorder._fromCubics( - cubics: Morph(aShape, shape).asCubics(t), + cubics: _morphBetween(aShape, shape).asCubics(t), side: BorderSide.lerp(a.side, side, t), squash: ui.lerpDouble(a.squash, squash, t)!, ); @@ -118,7 +140,7 @@ class MaterialShapeBorder extends OutlinedBorder { } return MaterialShapeBorder._fromCubics( - cubics: Morph(shape, bShape).asCubics(t), + cubics: _morphBetween(shape, bShape).asCubics(t), side: BorderSide.lerp(side, b.side, t), squash: ui.lerpDouble(squash, b.squash, t)!, ); @@ -221,3 +243,63 @@ class MaterialShapeBorder extends OutlinedBorder { '(side: $side, squash: $squash)'; } } + +/// The pair of shapes a cached [Morph] was built from. +/// +/// Keys compare by identity rather than by value. [RoundedPolygon.hashCode] +/// walks every coordinate of every feature, which would cost a sizeable +/// fraction of the work the cache saves, on every lookup rather than only on a +/// miss. A pair that misses is simply rebuilt, so identity only ever costs a +/// cache hit. +@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/test/material_shape_border_test.dart b/packages/material_ui/test/material_shape_border_test.dart index f5a7d35498d5..1d77470a4dfe 100644 --- a/packages/material_ui/test/material_shape_border_test.dart +++ b/packages/material_ui/test/material_shape_border_test.dart @@ -335,6 +335,48 @@ void main() { expect(() => ShapeBorder.lerp(lerped, end, 0.5), throwsStateError); }); + 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)); From 214221df78ac40d0cbc4b31c02ddb7a3e2a3f1f7 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 22:56:36 +0200 Subject: [PATCH 23/59] Fix the MaterialShapeBorder crash on an interrupted transition. --- .../lib/src/material_shape_border.dart | 199 ++++++++++++------ .../test/material_shape_border_test.dart | 138 +++++++++++- 2 files changed, 269 insertions(+), 68 deletions(-) diff --git a/packages/material_ui/lib/src/material_shape_border.dart b/packages/material_ui/lib/src/material_shape_border.dart index f177455baaf3..423eb77525f0 100644 --- a/packages/material_ui/lib/src/material_shape_border.dart +++ b/packages/material_ui/lib/src/material_shape_border.dart @@ -21,15 +21,25 @@ 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, super.side, this.squash = 0}) - : shape = 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 could be `null` if border is the result of lerp. + /// 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. @@ -52,28 +62,123 @@ class MaterialShapeBorder extends OutlinedBorder { final List _cubics; - // The number 5 was chosen without any real science or research behind it. It - // just seemed like a number that's not too big (a handful of morphs fits in - // memory comfortably) and not too small (few screens animate between more - // than 5 distinct pairs of shapes at once). + // 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 previously - /// computed one when it is still cached. + /// Returns the [Morph] between [start] and [end], reusing a cached one when + /// possible. /// - /// A [Morph] matches the curves of its two shapes at construction time, which - /// is far more expensive than evaluating it at a progress value, and the - /// mapping it produces depends only on those two shapes. A transition asks - /// for the same pair on every frame, so computing the mapping once and - /// keeping it is what makes lerping affordable. + /// 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).asCubics(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; @@ -82,7 +187,7 @@ class MaterialShapeBorder extends OutlinedBorder { return MaterialShapeBorder(shape: shape, side: side.scale(t), squash: squash); } - return MaterialShapeBorder._fromCubics(cubics: _cubics, side: side.scale(t), squash: squash); + return _lerpResultWith(side: side.scale(t)); } @override @@ -96,22 +201,7 @@ class MaterialShapeBorder extends OutlinedBorder { } if (a is MaterialShapeBorder) { - final RoundedPolygon? aShape = a.shape; - final RoundedPolygon? shape = this.shape; - - if (aShape == null || shape == null) { - throw StateError( - 'Lerping requires both MaterialShapeBorders to have non-null shapes. ' - 'This border is likely the result of a previous lerp and cannot be ' - 'used for further interpolation.', - ); - } - - return MaterialShapeBorder._fromCubics( - cubics: _morphBetween(aShape, shape).asCubics(t), - side: BorderSide.lerp(a.side, side, t), - squash: ui.lerpDouble(a.squash, squash, t)!, - ); + return _lerp(a, this, t); } return super.lerpFrom(a, t); @@ -128,22 +218,7 @@ class MaterialShapeBorder extends OutlinedBorder { } if (b is MaterialShapeBorder) { - final RoundedPolygon? bShape = b.shape; - final RoundedPolygon? shape = this.shape; - - if (bShape == null || shape == null) { - throw StateError( - 'Lerping requires both MaterialShapeBorders to have non-null shapes. ' - 'This border is likely the result of a previous lerp and cannot be ' - 'used for further interpolation.', - ); - } - - return MaterialShapeBorder._fromCubics( - cubics: _morphBetween(shape, bShape).asCubics(t), - side: BorderSide.lerp(side, b.side, t), - squash: ui.lerpDouble(squash, b.squash, t)!, - ); + return _lerp(this, b, t); } return super.lerpTo(b, t); @@ -169,11 +244,7 @@ class MaterialShapeBorder extends OutlinedBorder { ); } - return MaterialShapeBorder._fromCubics( - cubics: _cubics, - side: side ?? this.side, - squash: squash ?? this.squash, - ); + return _lerpResultWith(side: side, squash: squash); } Path _getPathFromRect(Rect rect) { @@ -230,12 +301,23 @@ class MaterialShapeBorder extends OutlinedBorder { 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), side, squash); + int get hashCode => Object.hash( + shape, + Object.hashAll(_cubics), + _lerpStart, + _lerpEnd, + _lerpProgress, + side, + squash, + ); @override String toString() { @@ -246,11 +328,10 @@ class MaterialShapeBorder extends OutlinedBorder { /// The pair of shapes a cached [Morph] was built from. /// -/// Keys compare by identity rather than by value. [RoundedPolygon.hashCode] -/// walks every coordinate of every feature, which would cost a sizeable -/// fraction of the work the cache saves, on every lookup rather than only on a -/// miss. A pair that misses is simply rebuilt, so identity only ever costs a -/// cache hit. +/// 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); diff --git a/packages/material_ui/test/material_shape_border_test.dart b/packages/material_ui/test/material_shape_border_test.dart index 1d77470a4dfe..92912d7d99ce 100644 --- a/packages/material_ui/test/material_shape_border_test.dart +++ b/packages/material_ui/test/material_shape_border_test.dart @@ -272,7 +272,7 @@ void main() { expect(copy.copyWith(side: lerped.side, squash: lerped.squash), lerped); }); - test('copyWith with a shape makes a lerped border lerpable again', () { + 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); @@ -280,7 +280,29 @@ void main() { final MaterialShapeBorder restored = lerped.copyWith(shape: MaterialShapes.square); expect(restored.shape, same(MaterialShapes.square)); - expect(() => restored.lerpTo(end, 0.5), returnsNormally); + // 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', () { @@ -320,19 +342,117 @@ void main() { expect(backward.squash, 0.25); }); - test('lerp throws when a border is already the result of a lerp', () { + 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); - expect(() => lerped.lerpTo(end, 0.5), throwsStateError); - expect(() => lerped.lerpFrom(start, 0.5), throwsStateError); - expect(() => start.lerpTo(lerped, 0.5), throwsStateError); - expect(() => end.lerpFrom(lerped, 0.5), throwsStateError); - // ShapeBorder.lerp tries lerpFrom on the second border first. - expect(() => ShapeBorder.lerp(lerped, end, 0.5), throwsStateError); + // 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', () { From ee727775f50bb4166f66c6ef2ea9c8f7581f7644 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 23:31:08 +0200 Subject: [PATCH 24/59] Rename CubicBezier.zeroLength() to isZeroLength. --- packages/material_ui/lib/src/shapes/cubic.dart | 6 +++--- packages/material_ui/lib/src/shapes/rounded_polygon.dart | 2 +- packages/material_ui/test/shapes/cubic_test.dart | 2 +- packages/material_ui/test/shapes/polygon_test.dart | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 3b80622c5eee..00dbbfc435a6 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -103,7 +103,7 @@ class CubicBezier { /// Generates an empty [CubicBezier] defined at [point]. /// /// Both anchor points and both control points coincide, so the curve has - /// zero length. See [zeroLength]. + /// zero length. See [isZeroLength]. CubicBezier.empty(Offset point) : this.raw([point.x, point.y, point.x, point.y, point.x, point.y, point.x, point.y]); @@ -179,7 +179,7 @@ class CubicBezier { /// 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 zeroLength() => + bool get isZeroLength => (anchor0X - anchor1X).abs() < distanceEpsilon && (anchor0Y - anchor1Y).abs() < distanceEpsilon; @@ -198,7 +198,7 @@ class CubicBezier { Rect calculateBounds({bool approximate = false}) { // A curve might be of zero-length, with both anchors co-lated. // Just return the point itself. - if (zeroLength()) { + if (isZeroLength) { return Rect.fromLTRB(anchor0X, anchor0Y, anchor0X, anchor0Y); } diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index ab83f7b6fbf9..7facbbb4a97c 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -643,7 +643,7 @@ class RoundedPolygon { // artifacts. final CubicBezier cubic = featureCubics[j]; - if (!cubic.zeroLength()) { + if (!cubic.isZeroLength) { if (lastCubic != null) { cubics.add(lastCubic); } diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index 0e271cd269eb..a03288772fd2 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -149,7 +149,7 @@ void main() { }); test('empty CubicBezier has zero length', () { - expect(CubicBezier.empty(const Point(10, 10)).zeroLength(), isTrue); + expect(CubicBezier.empty(const Point(10, 10)).isZeroLength, isTrue); }); }); diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 381c5ba4f653..56a34eb7f4c3 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -129,7 +129,7 @@ void main() { test('features', () { List nonZeroCubics(List original) { - return original.where((c) => !c.zeroLength()).toList(); + return original.where((c) => !c.isZeroLength).toList(); } final List squareFeatures = square.features; @@ -185,7 +185,7 @@ void main() { final RoundedPolygon stillEmpty = poly.transformed(scaleTransform(10, 20)); expect(stillEmpty.cubics.length, 1); - expect(stillEmpty.cubics.first.zeroLength(), isTrue); + expect(stillEmpty.cubics.first.isZeroLength, isTrue); }); test('empty side', () { From fd2e02b15e1827ac3bf4a9a6e4d783170fef3db6 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 23:33:50 +0200 Subject: [PATCH 25/59] Rename Morph.asCubics() to toCubics(). --- packages/material_ui/lib/src/material_shape_border.dart | 2 +- packages/material_ui/lib/src/shapes/cubic.dart | 2 +- packages/material_ui/lib/src/shapes/morph.dart | 6 +++--- packages/material_ui/test/shapes/morph_test.dart | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/material_ui/lib/src/material_shape_border.dart b/packages/material_ui/lib/src/material_shape_border.dart index 423eb77525f0..e54480ec6548 100644 --- a/packages/material_ui/lib/src/material_shape_border.dart +++ b/packages/material_ui/lib/src/material_shape_border.dart @@ -128,7 +128,7 @@ class MaterialShapeBorder extends OutlinedBorder { } return MaterialShapeBorder._fromCubics( - cubics: _morphBetween(start, end).asCubics(progress), + cubics: _morphBetween(start, end).toCubics(progress), lerpStart: start, lerpEnd: end, lerpProgress: progress, diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 00dbbfc435a6..98f6e08a72b6 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -444,7 +444,7 @@ class _MutableCubicBezier extends CubicBezier { /// /// This is the building block behind [RoundedPolygon.toPath] and /// [Morph.toPath], and is useful when working with a list of curves obtained -/// from [Morph.asCubics] directly. +/// 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. diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index 6e8c29e381fb..a9b477490d5e 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -175,7 +175,7 @@ class Morph { /// The range is generally [0..1] and values outside could result in /// undefined shapes, but values close to (but outside) the range can be used /// to get an exaggerated effect (e.g., for a bounce or overshoot animation). - List asCubics(double progress) { + List toCubics(double progress) { final result = []; // The first/last mechanism here ensures that the final anchor point in the @@ -226,7 +226,7 @@ class Morph { /// 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 [asCubics] produced them. + /// the curves as [toCubics] produced them. /// /// [repeatPath] is whether or not to repeat the [Path] twice before closing /// it. This flag is useful when the caller would like to draw parts of the @@ -249,7 +249,7 @@ class Morph { Offset rotationPivot = Offset.zero, }) { return pathFromCubics( - asCubics(progress), + toCubics(progress), startAngle: startAngle, repeatPath: repeatPath, closePath: closePath, diff --git a/packages/material_ui/test/shapes/morph_test.dart b/packages/material_ui/test/shapes/morph_test.dart index f3f775fb3ca2..035b924cf107 100644 --- a/packages/material_ui/test/shapes/morph_test.dart +++ b/packages/material_ui/test/shapes/morph_test.dart @@ -28,7 +28,7 @@ void main() { // curves equivalent to those in that shape. test('cubics', () { final List p1Cubics = poly1.cubics; - final List cubics11 = morph11.asCubics(0); + final List cubics11 = morph11.toCubics(0); expect(cubics11, isNotEmpty); // The structure of a morph and its component shapes may not match From 60094390b343e6a4705d6a3e9a8bcbd3f113afe5 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 23:41:02 +0200 Subject: [PATCH 26/59] Make RoundedPolygon.fromVerticesNum the default constructor. --- .../material_ui/lib/src/material_shapes.dart | 4 +- .../lib/src/shapes/corner_rounding.dart | 2 +- .../lib/src/shapes/rounded_polygon.dart | 63 +++++++++---------- .../test/shapes/feature_mapping_test.dart | 13 ++-- .../material_ui/test/shapes/morph_test.dart | 4 +- .../test/shapes/polygon_measure_test.dart | 16 ++--- .../material_ui/test/shapes/polygon_test.dart | 26 +++----- .../test/shapes/rounded_polygon_test.dart | 12 ++-- 8 files changed, 57 insertions(+), 83 deletions(-) diff --git a/packages/material_ui/lib/src/material_shapes.dart b/packages/material_ui/lib/src/material_shapes.dart index db99507dbee0..19222493fbd2 100644 --- a/packages/material_ui/lib/src/material_shapes.dart +++ b/packages/material_ui/lib/src/material_shapes.dart @@ -65,7 +65,7 @@ abstract final class MaterialShapes { /// An arch shape. static final RoundedPolygon arch = - RoundedPolygon.fromVerticesNum( + RoundedPolygon( 4, perVertexRounding: const [ _cornerRound100, @@ -106,7 +106,7 @@ abstract final class MaterialShapes { ).normalized(); /// A triangle shape. - static final RoundedPolygon triangle = RoundedPolygon.fromVerticesNum(3, rounding: _cornerRound20) + static final RoundedPolygon triangle = RoundedPolygon(3, rounding: _cornerRound20) .transformed((Matrix4.identity()..rotateZ(_negative90Radians)).asPointTransformer()) .normalized(); diff --git a/packages/material_ui/lib/src/shapes/corner_rounding.dart b/packages/material_ui/lib/src/shapes/corner_rounding.dart index cd7541d1ddce..eae0ebd0cb8f 100644 --- a/packages/material_ui/lib/src/shapes/corner_rounding.dart +++ b/packages/material_ui/lib/src/shapes/corner_rounding.dart @@ -41,7 +41,7 @@ class CornerRounding { /// 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.fromVerticesNum] produces by + /// (-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. diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 7facbbb4a97c..2141194ef62b 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -21,30 +21,6 @@ import 'utils.dart'; /// either the number of vertices desired or an ordered list of vertices. @immutable class RoundedPolygon { - RoundedPolygon._(this.features, this._center) : cubics = [] { - _initCubics(); - - assert(() { - CubicBezier prevCubic = cubics[cubics.length - 1]; - - for (var index = 0; index < cubics.length; index++) { - final CubicBezier cubic = cubics[index]; - - 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; - } - - return true; - }()); - } - /// This constructor takes the number of vertices in the resulting polygon. /// These vertices are positioned on a virtual circle around a given center /// with each vertex positioned [radius] distance from that center, equally @@ -82,7 +58,7 @@ class RoundedPolygon { /// Throws [ArgumentError] if [perVertexRounding] is not null and its size /// is not equal to [numVertices]. /// Throws [ArgumentError] when [numVertices] is less than 3. - factory RoundedPolygon.fromVerticesNum( + factory RoundedPolygon( int numVertices, { double radius = 1, Offset center = Offset.zero, @@ -101,9 +77,33 @@ class RoundedPolygon { ); } + RoundedPolygon._raw(this.features, this._center) : cubics = [] { + _initCubics(); + + assert(() { + CubicBezier prevCubic = cubics[cubics.length - 1]; + + for (var index = 0; index < cubics.length; index++) { + final CubicBezier cubic = cubics[index]; + + 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; + } + + return true; + }()); + } + /// Creates a copy of the given [RoundedPolygon]. RoundedPolygon.from(RoundedPolygon roundedPolygon) - : this._(roundedPolygon.features, roundedPolygon.center); + : this._raw(roundedPolygon.features, roundedPolygon.center); /// This function takes the vertices (either supplied or calculated, /// depending on the constructor called), plus [CornerRounding] parameters, @@ -134,9 +134,6 @@ class RoundedPolygon { /// Throws [ArgumentError] if the number of vertices is less than 3, or if /// the [perVertexRounding] parameter is not null and its size doesn't match /// the number of vertices. - /// - // TODO(performance): Update the map calls to more efficient code that - // doesn't allocate Iterators unnecessarily. factory RoundedPolygon.fromVertices( List vertices, { CornerRounding rounding = CornerRounding.unrounded, @@ -256,7 +253,7 @@ class RoundedPolygon { } if (center != null) { - return RoundedPolygon._(features, center); + return RoundedPolygon._raw(features, center); } final vertices = []; @@ -267,7 +264,7 @@ class RoundedPolygon { } } - return RoundedPolygon._(features, calculateCenter(vertices)); + return RoundedPolygon._raw(features, calculateCenter(vertices)); } /// Creates a circular shape, approximating the rounding of the shape around @@ -297,7 +294,7 @@ class RoundedPolygon { // Radius of the underlying RoundedPolygon object given the desired radius // of the circle. final double polygonRadius = radius / math.cos(theta); - return RoundedPolygon.fromVerticesNum( + return RoundedPolygon( numVertices, radius: polygonRadius, center: center, @@ -690,7 +687,7 @@ class RoundedPolygon { /// /// [f] is the [PointTransformer] used to transform this [RoundedPolygon]. RoundedPolygon transformed(PointTransformer f) { - return RoundedPolygon._([ + return RoundedPolygon._raw([ for (var i = 0; i < features.length; i++) features[i].transformed(f), ], _center.transformed(f)); } diff --git a/packages/material_ui/test/shapes/feature_mapping_test.dart b/packages/material_ui/test/shapes/feature_mapping_test.dart index c56658a348cb..c7b08b4f0d01 100644 --- a/packages/material_ui/test/shapes/feature_mapping_test.dart +++ b/packages/material_ui/test/shapes/feature_mapping_test.dart @@ -13,15 +13,10 @@ import 'test_utils.dart'; void main() { group('FeatureMapping', () { - final triangleWithRoundings = RoundedPolygon.fromVerticesNum( - 3, - rounding: const CornerRounding(radius: 0.2), - ); - final triangle = RoundedPolygon.fromVerticesNum(3); - final square = RoundedPolygon.fromVerticesNum(4); - final RoundedPolygon squareRotated = RoundedPolygon.fromVerticesNum( - 4, - ).transformed(pointRotator(45)); + 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, diff --git a/packages/material_ui/test/shapes/morph_test.dart b/packages/material_ui/test/shapes/morph_test.dart index 035b924cf107..056523a8014c 100644 --- a/packages/material_ui/test/shapes/morph_test.dart +++ b/packages/material_ui/test/shapes/morph_test.dart @@ -19,8 +19,8 @@ void main() { const radius = 50.0; const scale = radius; - final poly1 = RoundedPolygon.fromVerticesNum(3, center: const Point(0.5, 0.5)); - final poly2 = RoundedPolygon.fromVerticesNum(4, center: const Point(0.5, 0.5)); + 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); diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart index c4b577ccde11..14aff1614cd1 100644 --- a/packages/material_ui/test/shapes/polygon_measure_test.dart +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -53,9 +53,7 @@ void main() { } void regularPolygonMeasure(int sides, [CornerRounding rounding = CornerRounding.unrounded]) { - irregularPolygonMeasure(RoundedPolygon.fromVerticesNum(sides, rounding: rounding), ( - measuredPolygon, - ) { + irregularPolygonMeasure(RoundedPolygon(sides, rounding: rounding), (measuredPolygon) { expect(sides, measuredPolygon.length); for (var index = 0; index < measuredPolygon.length; index++) { @@ -100,21 +98,15 @@ void main() { }); test('measure slightly rounded hexagon', () { - irregularPolygonMeasure( - RoundedPolygon.fromVerticesNum(6, rounding: const CornerRounding(radius: 0.15)), - ); + irregularPolygonMeasure(RoundedPolygon(6, rounding: const CornerRounding(radius: 0.15))); }); test('measure medium rounded hexagon', () { - irregularPolygonMeasure( - RoundedPolygon.fromVerticesNum(6, rounding: const CornerRounding(radius: 0.5)), - ); + irregularPolygonMeasure(RoundedPolygon(6, rounding: const CornerRounding(radius: 0.5))); }); test('measure maximum rounded hexagon', () { - irregularPolygonMeasure( - RoundedPolygon.fromVerticesNum(6, rounding: const CornerRounding(radius: 1)), - ); + irregularPolygonMeasure(RoundedPolygon(6, rounding: const CornerRounding(radius: 1))); }); test('measure circle', () { diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 56a34eb7f4c3..d8df2967dd4a 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -15,12 +15,9 @@ import 'test_utils.dart'; void main() { group('Polygon', () { - final square = RoundedPolygon.fromVerticesNum(4); - final roundedSquare = RoundedPolygon.fromVerticesNum( - 4, - rounding: const CornerRounding(radius: 0.2), - ); - final pentagon = RoundedPolygon.fromVerticesNum(5); + 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 @@ -29,12 +26,12 @@ void main() { var max = const Point(1, 1); expectInBounds(square.cubics, min, max); - final doubleSquare = RoundedPolygon.fromVerticesNum(4, radius: 2); + final doubleSquare = RoundedPolygon(4, radius: 2); min = min * 2; max = max * 2; expectInBounds(doubleSquare.cubics, min, max); - final offsetSquare = RoundedPolygon.fromVerticesNum(4, center: const Point(1, 2)); + final offsetSquare = RoundedPolygon(4, center: const Point(1, 2)); min = const Point(0, 1); max = const Point(2, 3); expectInBounds(offsetSquare.cubics, min, max); @@ -151,11 +148,8 @@ void main() { }); test('transform keeps contiguous anchors equal', () { - final RoundedPolygon poly = - RoundedPolygon.fromVerticesNum( - 4, - rounding: const CornerRounding(radius: 7 / 15), - ).transformed((x, y) { + 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); }); @@ -176,11 +170,7 @@ void main() { }); test('empty', () { - final poly = RoundedPolygon.fromVerticesNum( - 6, - radius: 0, - rounding: const CornerRounding(radius: 0.1), - ); + 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)); diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index a37dfef15ac8..e3c4d1fc62bd 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -19,25 +19,25 @@ void main() { const rounding = CornerRounding(radius: 0.1); final perVtxRounded = [rounding, rounding, rounding, rounding]; - test('fromVerticesNum', () { - expect(() => RoundedPolygon.fromVerticesNum(2), throwsArgumentError); + test('default constructor', () { + expect(() => RoundedPolygon(2), throwsArgumentError); - final square = RoundedPolygon.fromVerticesNum(4); + final square = RoundedPolygon(4); var min = const Point(-1, -1); var max = const Point(1, 1); expectInBounds(square.cubics, min, max); - final doubleSquare = RoundedPolygon.fromVerticesNum(4, radius: 2); + final doubleSquare = RoundedPolygon(4, radius: 2); min *= 2; max *= 2; expectInBounds(doubleSquare.cubics, min, max); - final squareRounded = RoundedPolygon.fromVerticesNum(4, rounding: rounding); + final squareRounded = RoundedPolygon(4, rounding: rounding); min = const Point(-1, -1); max = const Point(1, 1); expectInBounds(squareRounded.cubics, min, max); - final squarePVRounded = RoundedPolygon.fromVerticesNum(4, perVertexRounding: perVtxRounded); + final squarePVRounded = RoundedPolygon(4, perVertexRounding: perVtxRounded); min = const Point(-1, -1); max = const Point(1, 1); expectInBounds(squarePVRounded.cubics, min, max); From fb5d3d8284143523e1ada1687e329f1ca1f39fce Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 23:44:02 +0200 Subject: [PATCH 27/59] Rename the Feature factories off their Kotlin build* prefix. --- .../material_ui/lib/src/shapes/features.dart | 25 +++++++++---------- .../test/shapes/features_test.dart | 20 +++++++-------- .../test/shapes/polygon_measure_test.dart | 10 +++----- .../test/shapes/rounded_polygon_test.dart | 2 +- 4 files changed, 27 insertions(+), 30 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/features.dart b/packages/material_ui/lib/src/shapes/features.dart index 89882da66b02..c402170374c1 100644 --- a/packages/material_ui/lib/src/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/features.dart @@ -24,7 +24,7 @@ import 'point.dart'; /// process. /// - Curve Type Mapping: [Morph] maps similar curve types (convex, concave) /// together. Note that edges or features created with -/// [Feature.buildIgnorableFeature] are ignored in the default mapping. +/// [Feature.ignorable] are ignored in the default mapping. /// /// By using features, you can manipulate polygon shapes with more context and /// control. @@ -32,9 +32,9 @@ import 'point.dart'; abstract class Feature { /// Creates a [Feature] spanning the given [cubics]. /// - /// Prefer the [Feature.buildEdge], [Feature.buildConvexCorner], - /// [Feature.buildConcaveCorner] and [Feature.buildIgnorableFeature] - /// factories, which validate that the cubics form a continuous run. + /// Prefer the [Feature.edge], [Feature.convexCorner], + /// [Feature.concaveCorner] and [Feature.ignorable] factories, which validate + /// that the cubics form a continuous run. const Feature(List cubics) : _cubics = cubics; /// Group a list of [CubicBezier] objects to a feature that should be ignored in @@ -58,26 +58,25 @@ abstract class Feature { /// squares' outer corners. /// /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. - factory Feature.buildIgnorableFeature(List cubics) => - _validated(EdgeFeature(cubics)); + factory Feature.ignorable(List cubics) => _validated(EdgeFeature(cubics)); /// Group a [CubicBezier] object to an edge (neither inward or outward /// identification in a shape). /// /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. - factory Feature.buildEdge(CubicBezier cubic) => EdgeFeature([cubic]); + factory Feature.edge(CubicBezier cubic) => EdgeFeature([cubic]); /// Group a list of [CubicBezier] objects to a convex corner (outward indentation /// in a shape). /// /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics - factory Feature.buildConvexCorner(List cubics) => _validated(CornerFeature(cubics)); + factory Feature.convexCorner(List cubics) => _validated(CornerFeature(cubics)); /// Group a list of [CubicBezier] objects to a concave corner (inward indentation /// in a shape). /// /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics - factory Feature.buildConcaveCorner(List cubics) => + factory Feature.concaveCorner(List cubics) => _validated(CornerFeature(cubics, convex: false)); static Feature _validated(Feature feature) { @@ -115,8 +114,8 @@ abstract class Feature { List get cubics => UnmodifiableListView(_cubics); /// Whether this Feature gets ignored in the Morph mapping. See - /// [Feature.buildIgnorableFeature] for more details - bool get isIgnorableFeature; + /// [Feature.ignorable] for more details + bool get isIgnorable; /// Whether this Feature is an Edge with no inward or outward indentation. bool get isEdge; @@ -170,7 +169,7 @@ class EdgeFeature extends Feature { EdgeFeature(List.generate(_cubics.length, (i) => _cubics[_cubics.length - 1 - i].reverse())); @override - bool get isIgnorableFeature => true; + bool get isIgnorable => true; @override bool get isEdge => true; @@ -213,7 +212,7 @@ class CornerFeature extends Feature { ); @override - bool get isIgnorableFeature => false; + bool get isIgnorable => false; @override bool get isEdge => false; diff --git a/packages/material_ui/test/shapes/features_test.dart b/packages/material_ui/test/shapes/features_test.dart index a7eda23e2bb6..d2968e0c6bf8 100644 --- a/packages/material_ui/test/shapes/features_test.dart +++ b/packages/material_ui/test/shapes/features_test.dart @@ -11,44 +11,44 @@ import 'test_utils.dart'; void main() { group('$Feature', () { test('Cannot build empty features', () { - expect(() => Feature.buildConvexCorner(const []), throwsArgumentError); - expect(() => Feature.buildConcaveCorner(const []), throwsArgumentError); - expect(() => Feature.buildIgnorableFeature(const []), throwsArgumentError); + 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.buildConvexCorner([cubic1, cubic2]), throwsArgumentError); - expect(() => Feature.buildConcaveCorner([cubic1, cubic2]), throwsArgumentError); - expect(() => Feature.buildIgnorableFeature([cubic1, cubic2]), throwsArgumentError); + 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.buildConcaveCorner([cubic]); + 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.buildConvexCorner([cubic]); + 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.buildEdge(cubic); + 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.buildIgnorableFeature([cubic]); + final actual = Feature.ignorable([cubic]); final expected = EdgeFeature([cubic]); expectFeaturesEqualish(expected, actual); }); diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart index 14aff1614cd1..7d903a5d3e62 100644 --- a/packages/material_ui/test/shapes/polygon_measure_test.dart +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -185,13 +185,11 @@ void main() { test('handles empty feature last', () { final triangle = RoundedPolygon.fromFeatures([ - Feature.buildConvexCorner([CubicBezier.straightLine(Offset.zero, const Offset(1, 1))]), - Feature.buildConvexCorner([ - CubicBezier.straightLine(const Offset(1, 1), const Offset(1, 0)), - ]), - Feature.buildConvexCorner([CubicBezier.straightLine(const Offset(1, 0), Offset.zero)]), + 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.buildConvexCorner([CubicBezier.straightLine(Offset.zero, Offset.zero)]), + Feature.convexCorner([CubicBezier.straightLine(Offset.zero, Offset.zero)]), ]); irregularPolygonMeasure(triangle); diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index e3c4d1fc62bd..1c7a267cc9c1 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -93,7 +93,7 @@ void main() { 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.buildEdge(cubic1), Feature.buildEdge(cubic2)]), + () => RoundedPolygon.fromFeatures([Feature.edge(cubic1), Feature.edge(cubic2)]), throwsArgumentError, ); }); From a9eacd7e4730bb024f3a4ae9838f06b0f8317b55 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 23:46:31 +0200 Subject: [PATCH 28/59] Unify the shapes reverse naming on a reversed getter. --- packages/material_ui/lib/src/shapes/cubic.dart | 5 +++-- packages/material_ui/lib/src/shapes/features.dart | 14 +++++++------- .../lib/src/shapes/rounded_polygon.dart | 2 +- packages/material_ui/test/shapes/cubic_test.dart | 4 ++-- .../material_ui/test/shapes/features_test.dart | 4 ++-- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 98f6e08a72b6..6abcab07ecd7 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -343,8 +343,9 @@ class CubicBezier { ); } - /// Utility function to reverse the control/anchor points for this curve. - CubicBezier reverse() => CubicBezier.raw([ + /// 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, diff --git a/packages/material_ui/lib/src/shapes/features.dart b/packages/material_ui/lib/src/shapes/features.dart index c402170374c1..25ad5b18c79a 100644 --- a/packages/material_ui/lib/src/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/features.dart @@ -133,9 +133,9 @@ abstract class Feature { /// and returns a new [Feature]. Feature transformed(PointTransformer f); - /// Returns a new [Feature] with the points that define the shape of this - /// [Feature] in reversed order. - Feature reversed(); + /// A new [Feature] with the points that define the shape of this [Feature] + /// in reversed order. + Feature get reversed; @override bool operator ==(Object other) { @@ -165,8 +165,8 @@ class EdgeFeature extends Feature { EdgeFeature(List.generate(_cubics.length, (i) => _cubics[i].transformed(f))); @override - Feature reversed() => - EdgeFeature(List.generate(_cubics.length, (i) => _cubics[_cubics.length - 1 - i].reverse())); + Feature get reversed => + EdgeFeature(List.generate(_cubics.length, (i) => _cubics[_cubics.length - 1 - i].reversed)); @override bool get isIgnorable => true; @@ -206,8 +206,8 @@ class CornerFeature extends Feature { ); @override - Feature reversed() => CornerFeature( - List.generate(_cubics.length, (i) => _cubics[_cubics.length - 1 - i].reverse()), + Feature get reversed => CornerFeature( + List.generate(_cubics.length, (i) => _cubics[_cubics.length - 1 - i].reversed), convex: !convex, ); diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 2141194ef62b..10c834d1fced 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -982,7 +982,7 @@ class _RoundedCorner { circleIntersection0, center, actualR, - ).reverse(); + ).reversed; return [ flanking0, diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index a03288772fd2..06918c88c116 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -84,8 +84,8 @@ void main() { expectPointsEqualish(p3 + offsetCubic.anchor1, plusCubic.anchor1); }); - test('reverse', () { - final CubicBezier reverseCubic = cubic.reverse(); + test('reversed', () { + final CubicBezier reverseCubic = cubic.reversed; expect(p3, reverseCubic.anchor0); expect(p2, reverseCubic.control0); expect(p1, reverseCubic.control1); diff --git a/packages/material_ui/test/shapes/features_test.dart b/packages/material_ui/test/shapes/features_test.dart index d2968e0c6bf8..8885d59e3c5d 100644 --- a/packages/material_ui/test/shapes/features_test.dart +++ b/packages/material_ui/test/shapes/features_test.dart @@ -124,13 +124,13 @@ void main() { const Offset(4, 2), ); - expect(EdgeFeature([cubic]).reversed(), EdgeFeature([reversedCubic])); + 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]).reversed, CornerFeature([reversedCubic], convex: false)); expect( CornerFeature([cubic]).transformed(translateTransform(1, 2)), CornerFeature([translatedCubic]), From 64fc89fa0ccd16a0c81ef3a4704db10c1fc91b48 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 23:48:51 +0200 Subject: [PATCH 29/59] Turn the shapes bounds methods into getters. --- .../material_ui/lib/src/shapes/cubic.dart | 19 ++++++--- .../material_ui/lib/src/shapes/morph.dart | 38 ++++++++--------- .../lib/src/shapes/rounded_polygon.dart | 41 +++++++++++-------- .../material_ui/test/shapes/polygon_test.dart | 12 +++--- 4 files changed, 63 insertions(+), 47 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 6abcab07ecd7..f5b769430bee 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -189,13 +189,20 @@ class CubicBezier { bool _zeroIsh(double value) => value.abs() < distanceEpsilon; - /// Calculates the axis-aligned bounding box of this curve. + /// The axis-aligned bounding box of this curve. /// - /// When [approximate] is true, uses a faster calculation 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 the true bounds, - /// but can be larger. Defaults to false. - Rect calculateBounds({bool approximate = false}) { + /// 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-lated. // Just return the point itself. if (isZeroLength) { diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index a9b477490d5e..a25fb2cafbcc 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -139,27 +139,27 @@ class Morph { return ret; } - /// Calculates the axis-aligned bounds of the object. + /// The axis-aligned bounds of this morph, covering both of its shapes. /// - /// When [approximate] is true, uses a faster calculation to create the - /// bounding box based on the min/max values of all anchor and control points - /// that make up the shape. Defaults to true. - Rect calculateBounds({bool approximate = true}) { - return _start - .calculateBounds(approximate: approximate) - .expandToInclude(_end.calculateBounds(approximate: approximate)); - } + /// 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); - /// Like [calculateBounds], this function calculates the axis-aligned bounds - /// of the object and returns that rectangle. But this function determines - /// the max dimension of the shape (by calculating the distance from its - /// center to the start and midpoint of each curve) and returns a square - /// which can be used to hold the object in any rotation. This function can - /// be used, for example, to calculate the max size of a UI element meant to - /// hold this shape in any rotation. - Rect calculateMaxBounds() { - return _start.calculateMaxBounds().expandToInclude(_end.calculateMaxBounds()); - } + /// 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 a representation of the morph object at a given [progress] value /// as a list of [CubicBezier]s. Note that this function causes a new list to be diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 10c834d1fced..d5b5e275dd0a 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -696,7 +696,7 @@ class RoundedPolygon { /// completely inside the (0, 0) -> (1, 1) square, centered if there extra /// space in one direction. RoundedPolygon normalized() { - final Rect bounds = calculateBounds(); + final Rect bounds = approximateBounds; final double side = math.max(bounds.width, bounds.height); // Center the shape if bounds are not a square. @@ -706,14 +706,14 @@ class RoundedPolygon { return transformed((x, y) => ((x + offsetX) / side, (y + offsetY) / side)); } - /// Like [calculateBounds], this function calculates the axis-aligned bounds - /// of the object and returns that rectangle. But this function determines - /// the max dimension of the shape (by calculating the distance from its - /// center to the start and midpoint of each curve) and returns a square - /// which can be used to hold the object in any rotation. This function can - /// be used, for example, to calculate the max size of a UI element meant to - /// hold this shape in any rotation. - Rect calculateMaxBounds() { + /// 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]; @@ -739,16 +739,25 @@ class RoundedPolygon { ); } - /// Calculates the axis-aligned bounds of the object. + /// 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. /// - /// [approximate] when true, uses a faster calculation to create the bounding - /// box based on the min/max values of all anchor and control points that - /// make up the shape. Default value is true. - Rect calculateBounds({bool approximate = true}) { - Rect bounds = cubics.first.calculateBounds(approximate: approximate); + /// 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++) { - bounds = bounds.expandToInclude(cubics[i].calculateBounds(approximate: approximate)); + final CubicBezier cubic = cubics[i]; + bounds = bounds.expandToInclude(approximate ? cubic.approximateBounds : cubic.bounds); } return bounds; diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index d8df2967dd4a..d7d027f65a19 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -67,13 +67,13 @@ void main() { }); test('bounds', () { - Rect bounds = square.calculateBounds(); + Rect bounds = square.approximateBounds; expectEqualish(-1, bounds.left); expectEqualish(-1, bounds.top); expectEqualish(1, bounds.right); expectEqualish(1, bounds.bottom); - Rect betterBounds = square.calculateBounds(approximate: false); + Rect betterBounds = square.bounds; expectEqualish(-1, betterBounds.left); expectEqualish(-1, betterBounds.top); expectEqualish(1, betterBounds.right); @@ -81,16 +81,16 @@ void main() { // roundedSquare's approximate bounds will be larger due to control // points. - bounds = roundedSquare.calculateBounds(); - betterBounds = roundedSquare.calculateBounds(approximate: false); + bounds = roundedSquare.approximateBounds; + betterBounds = roundedSquare.bounds; expect( betterBounds.width < bounds.width, isTrue, reason: 'bounds = $bounds, betterBounds = $betterBounds', ); - bounds = pentagon.calculateBounds(); - final Rect maxBounds = pentagon.calculateMaxBounds(); + bounds = pentagon.approximateBounds; + final Rect maxBounds = pentagon.maxBounds; expect(maxBounds.width > bounds.width, isTrue); }); From f4f02a8f09ae5c9424ac463bbaf617ca0bc8a452 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 23:50:26 +0200 Subject: [PATCH 30/59] Turn RoundedPolygon.normalized into a getter. --- .../material_ui/lib/src/material_shapes.dart | 106 +++++++++--------- .../lib/src/shapes/rounded_polygon.dart | 8 +- .../test/shapes/feature_mapping_test.dart | 4 +- 3 files changed, 57 insertions(+), 61 deletions(-) diff --git a/packages/material_ui/lib/src/material_shapes.dart b/packages/material_ui/lib/src/material_shapes.dart index 19222493fbd2..2d3cb8ecb215 100644 --- a/packages/material_ui/lib/src/material_shapes.dart +++ b/packages/material_ui/lib/src/material_shapes.dart @@ -61,28 +61,20 @@ abstract final class MaterialShapes { 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(); + ], 2).normalized; /// An arch shape. - static final RoundedPolygon arch = - RoundedPolygon( - 4, - perVertexRounding: const [ - _cornerRound100, - _cornerRound100, - _cornerRound20, - _cornerRound20, - ], - ) - .transformed((Matrix4.identity()..rotateZ(_negative135Radians)).asPointTransformer()) - .normalized(); + 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(); + ).normalized; /// An oval shape. static final RoundedPolygon oval = RoundedPolygon.circle() @@ -92,7 +84,7 @@ abstract final class MaterialShapes { ..scale(1.0, 0.64)) .asPointTransformer(), ) - .normalized(); + .normalized; /// An pill shape. static final RoundedPolygon pill = _customPolygon( @@ -103,12 +95,13 @@ abstract final class MaterialShapes { ], 2, mirroring: true, - ).normalized(); + ).normalized; /// A triangle shape. - static final RoundedPolygon triangle = RoundedPolygon(3, rounding: _cornerRound20) - .transformed((Matrix4.identity()..rotateZ(_negative90Radians)).asPointTransformer()) - .normalized(); + static final RoundedPolygon triangle = RoundedPolygon( + 3, + rounding: _cornerRound20, + ).transformed((Matrix4.identity()..rotateZ(_negative90Radians)).asPointTransformer()).normalized; /// An arrow shape. static final RoundedPolygon arrow = _customPolygon([ @@ -116,7 +109,7 @@ abstract final class MaterialShapes { 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(); + ], 1).normalized; /// A fan shape. static final RoundedPolygon fan = _customPolygon([ @@ -124,20 +117,20 @@ abstract final class MaterialShapes { 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(); + ], 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(); + ], 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(); + ], 2).normalized; /// A pentagon shape. static final RoundedPolygon pentagon = _customPolygon( @@ -148,7 +141,7 @@ abstract final class MaterialShapes { ], 1, mirroring: true, - ).normalized(); + ).normalized; /// A gem shape. static final RoundedPolygon gem = _customPolygon( @@ -160,50 +153,53 @@ abstract final class MaterialShapes { ], 1, mirroring: true, - ).normalized(); + ).normalized; /// A sunny shape. static final RoundedPolygon sunny = RoundedPolygon.star( numVerticesPerRadius: 8, innerRadius: 0.8, rounding: _cornerRound15, - ).normalized(); + ).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(); + ], 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(); + ], 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(); + ], 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(); + 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(); + 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(); + 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( @@ -213,31 +209,31 @@ abstract final class MaterialShapes { ], 4, mirroring: true, - ).normalized(); + ).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(); + ], 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(); + ], 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(); + ], 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(); + ], 15).normalized; /// A soft-boom shape. static final RoundedPolygon softBoom = _customPolygon( @@ -249,7 +245,7 @@ abstract final class MaterialShapes { ], 16, mirroring: true, - ).normalized(); + ).normalized; /// A flower shape. static final RoundedPolygon flower = _customPolygon( @@ -260,7 +256,7 @@ abstract final class MaterialShapes { ], 8, mirroring: true, - ).normalized(); + ).normalized; /// A puffy shape. static final RoundedPolygon puffy = _customPolygon( @@ -279,7 +275,7 @@ abstract final class MaterialShapes { ], 2, mirroring: true, - ).transformed((Matrix4.identity()..scale(1.0, 0.742)).asPointTransformer()).normalized(); + ).transformed((Matrix4.identity()..scale(1.0, 0.742)).asPointTransformer()).normalized; /// A puffy-diamond shape. static final RoundedPolygon puffyDiamond = _customPolygon( @@ -290,7 +286,7 @@ abstract final class MaterialShapes { ], 4, mirroring: true, - ).normalized(); + ).normalized; /// A ghostish shape. static final RoundedPolygon ghostish = _customPolygon( @@ -302,7 +298,7 @@ abstract final class MaterialShapes { ], 1, mirroring: true, - ).normalized(); + ).normalized; /// A pixel-circle shape. static final RoundedPolygon pixelCircle = _customPolygon( @@ -318,7 +314,7 @@ abstract final class MaterialShapes { ], 2, mirroring: true, - ).normalized(); + ).normalized; /// A pixel-triangle shape. static final RoundedPolygon pixelTriangle = _customPolygon( @@ -339,7 +335,7 @@ abstract final class MaterialShapes { ], 1, mirroring: true, - ).normalized(); + ).normalized; /// A bun shape. static final RoundedPolygon bun = _customPolygon( @@ -351,7 +347,7 @@ abstract final class MaterialShapes { ], 2, mirroring: true, - ).normalized(); + ).normalized; /// A heart shape. static final RoundedPolygon heart = _customPolygon( @@ -363,7 +359,7 @@ abstract final class MaterialShapes { ], 1, mirroring: true, - ).normalized(); + ).normalized; /// A list of all available shapes. static final UnmodifiableListView all = UnmodifiableListView([ diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index d5b5e275dd0a..ff1b794340dc 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -692,10 +692,10 @@ class RoundedPolygon { ], _center.transformed(f)); } - /// Creates a new RoundedPolygon, moving and resizing this one, so it's - /// completely inside the (0, 0) -> (1, 1) square, centered if there extra - /// space in one direction. - RoundedPolygon normalized() { + /// A new [RoundedPolygon], moving and resizing this one, so it's completely + /// inside the (0, 0) -> (1, 1) square, centered if there extra space in one + /// direction. + RoundedPolygon get normalized { final Rect bounds = approximateBounds; final double side = math.max(bounds.width, bounds.height); diff --git a/packages/material_ui/test/shapes/feature_mapping_test.dart b/packages/material_ui/test/shapes/feature_mapping_test.dart index c7b08b4f0d01..d2966b66d274 100644 --- a/packages/material_ui/test/shapes/feature_mapping_test.dart +++ b/packages/material_ui/test/shapes/feature_mapping_test.dart @@ -101,13 +101,13 @@ void main() { Point(664, -680), Point(720, -624), Point(400, -304), - ]).normalized(); + ]).normalized; final RoundedPolygon verySunny = RoundedPolygon.star( numVerticesPerRadius: 8, innerRadius: 0.65, rounding: const CornerRounding(radius: 0.15), - ).normalized(); + ).normalized; verifyMapping(checkmark, verySunny, (distances) { // Most vertices on the checkmark map to a feature in the second From 628df26068757ad158c6e93d0db2769effe942cf Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 23:52:40 +0200 Subject: [PATCH 31/59] Name the shapes transformed() parameter transformer. --- packages/material_ui/lib/src/shapes/cubic.dart | 8 ++++---- packages/material_ui/lib/src/shapes/features.dart | 14 +++++++------- .../lib/src/shapes/rounded_polygon.dart | 9 +++++---- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index f5b769430bee..e2a37f58ddb8 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -374,14 +374,14 @@ class CubicBezier { /// 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 [f] applied to each of its anchor and - /// control points. - CubicBezier transformed(PointTransformer f) { + /// 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(f); + newCubic.transform(transformer); return newCubic; } diff --git a/packages/material_ui/lib/src/shapes/features.dart b/packages/material_ui/lib/src/shapes/features.dart index 25ad5b18c79a..7c35d1ecf126 100644 --- a/packages/material_ui/lib/src/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/features.dart @@ -129,9 +129,9 @@ abstract class Feature { /// Whether this Feature is a concave corner (inward indentation in a shape). bool get isConcaveCorner; - /// Transforms the points in this [Feature] with the given [PointTransformer] - /// and returns a new [Feature]. - Feature transformed(PointTransformer f); + /// 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. @@ -161,8 +161,8 @@ class EdgeFeature extends Feature { const EdgeFeature(super._cubics); @override - Feature transformed(PointTransformer f) => - EdgeFeature(List.generate(_cubics.length, (i) => _cubics[i].transformed(f))); + Feature transformed(PointTransformer transformer) => + EdgeFeature(List.generate(_cubics.length, (i) => _cubics[i].transformed(transformer))); @override Feature get reversed => @@ -200,8 +200,8 @@ class CornerFeature extends Feature { final bool convex; @override - Feature transformed(PointTransformer f) => CornerFeature( - List.generate(_cubics.length, (i) => _cubics[i].transformed(f)), + Feature transformed(PointTransformer transformer) => CornerFeature( + List.generate(_cubics.length, (i) => _cubics[i].transformed(transformer)), convex: convex, ); diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index ff1b794340dc..302b2f14cbda 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -685,11 +685,12 @@ class RoundedPolygon { /// level API and there should be more platform idiomatic ways to transform /// a [RoundedPolygon] provided by the platform specific wrapper. /// - /// [f] is the [PointTransformer] used to transform this [RoundedPolygon]. - RoundedPolygon transformed(PointTransformer f) { + /// [transformer] is the [PointTransformer] used to transform this + /// [RoundedPolygon]. + RoundedPolygon transformed(PointTransformer transformer) { return RoundedPolygon._raw([ - for (var i = 0; i < features.length; i++) features[i].transformed(f), - ], _center.transformed(f)); + 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 From 06fd5463a6e2899cf1f23b2a419bc645265dd8d2 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 23:54:30 +0200 Subject: [PATCH 32/59] Delete the RoundedPolygon copy constructor. --- .../material_ui/lib/src/shapes/rounded_polygon.dart | 4 ---- packages/material_ui/test/shapes/polygon_test.dart | 13 +------------ 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 302b2f14cbda..59dc7ca95ab7 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -101,10 +101,6 @@ class RoundedPolygon { }()); } - /// Creates a copy of the given [RoundedPolygon]. - RoundedPolygon.from(RoundedPolygon roundedPolygon) - : this._raw(roundedPolygon.features, roundedPolygon.center); - /// This function takes the vertices (either supplied or calculated, /// depending on the constructor called), plus [CornerRounding] parameters, /// and creates the actual [RoundedPolygon] shape, rounding around the diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index d7d027f65a19..cb1aa65e5693 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -36,11 +36,6 @@ void main() { max = const Point(2, 3); expectInBounds(offsetSquare.cubics, min, max); - final squareCopy = RoundedPolygon.from(square); - min = const Point(-1, -1); - max = const Point(1, 1); - expectInBounds(squareCopy.cubics, min, max); - const p0 = Point(1, 0); const p1 = Point(0, 1); const p2 = Point(-1, 0); @@ -135,16 +130,10 @@ void main() { // 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. - List nonzeroCubics = nonZeroCubics( + final List nonzeroCubics = nonZeroCubics( squareFeatures.expand((f) => f.cubics).toList(), ); expectCubicListsEqualish(square.cubics, nonzeroCubics); - - // Same as the first polygon test, but with a copy of that polygon. - final squareCopy = RoundedPolygon.from(square); - final List squareCopyFeatures = squareCopy.features; - nonzeroCubics = nonZeroCubics(squareCopyFeatures.expand((f) => f.cubics).toList()); - expectCubicListsEqualish(squareCopy.cubics, nonzeroCubics); }); test('transform keeps contiguous anchors equal', () { From 0a2d2fb66bc7f61c7eeb82fdd505300d92684fec Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 23:56:07 +0200 Subject: [PATCH 33/59] Rename CubicBezier.pointOnCurve() to pointAt(). --- packages/material_ui/lib/src/shapes/cubic.dart | 16 ++++++++-------- .../lib/src/shapes/polygon_measure.dart | 2 +- .../lib/src/shapes/rounded_polygon.dart | 2 +- packages/material_ui/test/shapes/cubic_test.dart | 6 +++--- .../material_ui/test/shapes/shapes_test.dart | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index e2a37f58ddb8..67dd7ec72e9b 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -159,7 +159,7 @@ class CubicBezier { /// /// [t] is the distance along the curve between the anchor points, where 0 /// is at [anchor0] and 1 is at [anchor1]. - Offset pointOnCurve(double t) { + Offset pointAt(double t) { final double u = 1 - t; return Offset( anchor0X * (u * u * u) + @@ -236,7 +236,7 @@ class CubicBezier { if (xb != 0) { final double t = 2 * xc / (-2 * xb); if (t >= 0 && t <= 1) { - final double x = pointOnCurve(t).x; + final double x = pointAt(t).x; if (x < minX) { minX = x; } @@ -250,7 +250,7 @@ class CubicBezier { if (xs >= 0) { final double t1 = (-xb + math.sqrt(xs)) / (2 * xa); if (t1 >= 0 && t1 <= 1) { - final double x = pointOnCurve(t1).x; + final double x = pointAt(t1).x; if (x < minX) { minX = x; } @@ -261,7 +261,7 @@ class CubicBezier { final double t2 = (-xb - math.sqrt(xs)) / (2 * xa); if (t2 >= 0 && t2 <= 1) { - final double x = pointOnCurve(t2).x; + final double x = pointAt(t2).x; if (x < minX) { minX = x; } @@ -281,7 +281,7 @@ class CubicBezier { if (yb != 0) { final double t = 2 * yc / (-2 * yb); if (t >= 0 && t <= 1) { - final double y = pointOnCurve(t).y; + final double y = pointAt(t).y; if (y < minY) { minY = y; } @@ -295,7 +295,7 @@ class CubicBezier { if (ys >= 0) { final double t1 = (-yb + math.sqrt(ys)) / (2 * ya); if (t1 >= 0 && t1 <= 1) { - final double y = pointOnCurve(t1).y; + final double y = pointAt(t1).y; if (y < minY) { minY = y; } @@ -306,7 +306,7 @@ class CubicBezier { final double t2 = (-yb - math.sqrt(ys)) / (2 * ya); if (t2 >= 0 && t2 <= 1) { - final double y = pointOnCurve(t2).y; + final double y = pointAt(t2).y; if (y < minY) { minY = y; } @@ -324,7 +324,7 @@ class CubicBezier { /// distance of [t] between the original starting and ending anchor points. (CubicBezier, CubicBezier) split(double t) { final double u = 1 - t; - final Point point = pointOnCurve(t); + final Point point = pointAt(t); return ( CubicBezier.raw([ diff --git a/packages/material_ui/lib/src/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/polygon_measure.dart index 9fd4a75d1e6d..0eee290d3098 100644 --- a/packages/material_ui/lib/src/shapes/polygon_measure.dart +++ b/packages/material_ui/lib/src/shapes/polygon_measure.dart @@ -380,7 +380,7 @@ class LengthMeasurer implements Measurer { for (var i = 0; i <= _segments; i++) { final double progress = i / _segments; - final Point point = cubic.pointOnCurve(progress); + final Point point = cubic.pointAt(progress); final double segment = (point - prev).getDistance(); if (segment >= remainder) { diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 59dc7ca95ab7..bdbe6f45f32c 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -718,7 +718,7 @@ class RoundedPolygon { cubic.anchor0X - _center.x, cubic.anchor0Y - _center.y, ); - final Point middlePoint = cubic.pointOnCurve(0.5); + final Point middlePoint = cubic.pointAt(0.5); final double middleDistance = distanceSquared( middlePoint.x - _center.x, middlePoint.y - _center.y, diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index 06918c88c116..cd24ac133367 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -119,11 +119,11 @@ void main() { expectBetween(cubic.anchor0, cubic.anchor1, split1.anchor0); }); - test('pointOnCurve', () { - Point halfway = cubic.pointOnCurve(0.5); + test('pointAt', () { + Point halfway = cubic.pointAt(0.5); expectBetween(cubic.anchor0, cubic.anchor1, halfway); final straightLineCubic = CubicBezier.straightLine(p0, p3); - halfway = straightLineCubic.pointOnCurve(0.5); + 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); }); diff --git a/packages/material_ui/test/shapes/shapes_test.dart b/packages/material_ui/test/shapes/shapes_test.dart index cac3859287bc..3018846f8666 100644 --- a/packages/material_ui/test/shapes/shapes_test.dart +++ b/packages/material_ui/test/shapes/shapes_test.dart @@ -52,7 +52,7 @@ void main() { void expectCircularCubic(CubicBezier cubic, double radius, Point center) { var t = 0.0; while (t <= 1) { - final Point pointOnCurve = cubic.pointOnCurve(t); + final Point pointOnCurve = cubic.pointAt(t); final double distanceToPoint = distance(center, pointOnCurve); expect(radius, moreOrLessEquals(distanceToPoint, epsilon: epsilon)); t += 0.1; From 99e3732c50cae350717ec3b76c4b732d2ed35343 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 31 Aug 2026 23:58:10 +0200 Subject: [PATCH 34/59] Rename CubicBezier.empty() to CubicBezier.point(). --- packages/material_ui/lib/src/shapes/cubic.dart | 4 ++-- packages/material_ui/lib/src/shapes/rounded_polygon.dart | 2 +- packages/material_ui/test/shapes/cubic_test.dart | 4 ++-- packages/material_ui/test/shapes/rounded_polygon_test.dart | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 67dd7ec72e9b..0d1a4cbad8d5 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -100,11 +100,11 @@ class CubicBezier { ]); } - /// Generates an empty [CubicBezier] defined at [point]. + /// Generates a zero-length [CubicBezier] at [point]. /// /// Both anchor points and both control points coincide, so the curve has /// zero length. See [isZeroLength]. - CubicBezier.empty(Offset point) + 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; diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index bdbe6f45f32c..9ef5b1459e31 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -672,7 +672,7 @@ class RoundedPolygon { ); } else { // Empty / 0-sized polygon. - cubics.add(CubicBezier.empty(_center)); + cubics.add(CubicBezier.point(_center)); } } diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index cd24ac133367..8dc83de36f6f 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -148,8 +148,8 @@ void main() { expectPointsEqualish(cubic.anchor1 + translationVector, transformedCubic.anchor1); }); - test('empty CubicBezier has zero length', () { - expect(CubicBezier.empty(const Point(10, 10)).isZeroLength, isTrue); + test('point CubicBezier has zero length', () { + expect(CubicBezier.point(const Point(10, 10)).isZeroLength, isTrue); }); }); diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index 1c7a267cc9c1..8b0dfba40c1d 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -83,7 +83,7 @@ void main() { expect(() => RoundedPolygon.fromFeatures(const []), throwsArgumentError); expect( () => RoundedPolygon.fromFeatures([ - CornerFeature([CubicBezier.empty(Point.zero)]), + CornerFeature([CubicBezier.point(Point.zero)]), ]), throwsArgumentError, ); From 239ed781f91e2bd1efcaa2b25b1db284e16f75e7 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Fri, 4 Sep 2026 22:37:15 +0200 Subject: [PATCH 35/59] Add value equality to CornerRounding. --- .../lib/src/shapes/corner_rounding.dart | 21 ++++++++++++ .../test/shapes/corner_rounding_test.dart | 34 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/packages/material_ui/lib/src/shapes/corner_rounding.dart b/packages/material_ui/lib/src/shapes/corner_rounding.dart index eae0ebd0cb8f..16cdad214484 100644 --- a/packages/material_ui/lib/src/shapes/corner_rounding.dart +++ b/packages/material_ui/lib/src/shapes/corner_rounding.dart @@ -5,6 +5,8 @@ /// @docImport 'rounded_polygon.dart'; library; +import 'package:flutter/foundation.dart'; + /// Defines the amount and quality 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 @@ -22,6 +24,7 @@ library; /// 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}) @@ -59,4 +62,22 @@ class CornerRounding { /// /// 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, smoothing: $smoothing)'; + } } diff --git a/packages/material_ui/test/shapes/corner_rounding_test.dart b/packages/material_ui/test/shapes/corner_rounding_test.dart index 5d374b289cf9..1719866cc35b 100644 --- a/packages/material_ui/test/shapes/corner_rounding_test.dart +++ b/packages/material_ui/test/shapes/corner_rounding_test.dart @@ -28,4 +28,38 @@ void main() { expect(roundedAndSmoothed.radius, 5); expect(roundedAndSmoothed.smoothing, 0.5); }); + + 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)', + ); + }); } From f593808ed213944defb24f9a24479cfd580abaf4 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Fri, 4 Sep 2026 22:43:11 +0200 Subject: [PATCH 36/59] Add value equality and public shapes to Morph. --- .../material_ui/lib/src/shapes/morph.dart | 39 ++++++++++++++----- .../material_ui/test/shapes/morph_test.dart | 22 +++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index a25fb2cafbcc..359b94132964 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -5,6 +5,8 @@ import 'dart:math' as math; import 'dart:ui'; +import 'package:flutter/foundation.dart'; + import 'cubic.dart'; import 'feature_mapping.dart'; import 'float_mapping.dart'; @@ -28,25 +30,26 @@ import 'utils.dart'; /// 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(RoundedPolygon start, RoundedPolygon end) : _start = start, _end = end { - _morphMatch = _match(start, end); - } + Morph(this.start, this.end) : _morphMatch = _match(start, end); - final RoundedPolygon _start; + /// The shape this morph produces at a progress of 0. + final RoundedPolygon start; - final RoundedPolygon _end; + /// 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. - late final List<(CubicBezier, CubicBezier)> _morphMatch; + 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 @@ -144,13 +147,13 @@ class Morph { /// 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); + 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); + 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 @@ -159,7 +162,7 @@ class Morph { /// /// 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); + Rect get maxBounds => start.maxBounds.expandToInclude(end.maxBounds); /// Returns a representation of the morph object at a given [progress] value /// as a list of [CubicBezier]s. Note that this function causes a new list to be @@ -256,4 +259,22 @@ class Morph { 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/test/shapes/morph_test.dart b/packages/material_ui/test/shapes/morph_test.dart index 056523a8014c..5ea056415783 100644 --- a/packages/material_ui/test/shapes/morph_test.dart +++ b/packages/material_ui/test/shapes/morph_test.dart @@ -103,5 +103,27 @@ void main() { 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: ')); + }); }); } From bb43b010bc8039ac8c8c0b3faf8f272022183ffc Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 16:46:44 +0200 Subject: [PATCH 37/59] Make RoundedPolygon.cubics and .features unmodifiable. --- .../lib/src/shapes/rounded_polygon.dart | 19 +++++++++++----- .../material_ui/test/shapes/polygon_test.dart | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 9ef5b1459e31..37b90a438240 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -77,9 +77,10 @@ class RoundedPolygon { ); } - RoundedPolygon._raw(this.features, this._center) : cubics = [] { - _initCubics(); - + RoundedPolygon._raw(List features, Point center) + : features = List.unmodifiable(features), + _center = center, + cubics = List.unmodifiable(_buildCubics(features, center)) { assert(() { CubicBezier prevCubic = cubics[cubics.length - 1]; @@ -587,17 +588,23 @@ class RoundedPolygon { } /// 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; - void _initCubics() { + 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 @@ -672,8 +679,10 @@ class RoundedPolygon { ); } else { // Empty / 0-sized polygon. - cubics.add(CubicBezier.point(_center)); + cubics.add(CubicBezier.point(center)); } + + return cubics; } /// Transforms (scales/translates/etc.) this [RoundedPolygon] with the given diff --git a/packages/material_ui/test/shapes/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index cb1aa65e5693..5f06fae8e684 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -136,6 +136,28 @@ void main() { 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('transform keeps contiguous anchors equal', () { final RoundedPolygon poly = RoundedPolygon(4, rounding: const CornerRounding(radius: 7 / 15)) .transformed((x, y) { From 866d7b710753d61ebdadfbadf8be260029d9d957 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 16:51:34 +0200 Subject: [PATCH 38/59] Make the Feature generative constructor private. --- packages/material_ui/lib/src/shapes/features.dart | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/features.dart b/packages/material_ui/lib/src/shapes/features.dart index 7c35d1ecf126..581a9e0794c7 100644 --- a/packages/material_ui/lib/src/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/features.dart @@ -31,11 +31,7 @@ import 'point.dart'; @immutable abstract class Feature { /// Creates a [Feature] spanning the given [cubics]. - /// - /// Prefer the [Feature.edge], [Feature.convexCorner], - /// [Feature.concaveCorner] and [Feature.ignorable] factories, which validate - /// that the cubics form a continuous run. - const Feature(List cubics) : _cubics = cubics; + const Feature._(List cubics) : _cubics = cubics; /// Group a list of [CubicBezier] objects to a feature that should be ignored in /// the default [Morph] mapping. The feature can have any indentation. @@ -158,7 +154,7 @@ abstract class Feature { @internal class EdgeFeature extends Feature { /// Creates an [EdgeFeature] from the given cubics. - const EdgeFeature(super._cubics); + const EdgeFeature(super._cubics) : super._(); @override Feature transformed(PointTransformer transformer) => @@ -194,7 +190,7 @@ class EdgeFeature extends Feature { @internal class CornerFeature extends Feature { /// Creates a [CornerFeature] from the given cubics. - const CornerFeature(super._cubics, {this.convex = true}); + const CornerFeature(super._cubics, {this.convex = true}) : super._(); /// Whether this corner is convex. final bool convex; From ebf0f7788089b56d57efa8f8585e7596e93aca82 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 16:58:30 +0200 Subject: [PATCH 39/59] Name the type in the shapes toString implementations. --- packages/material_ui/lib/src/shapes/cubic.dart | 5 +++-- .../material_ui/lib/src/shapes/features.dart | 6 +++--- .../lib/src/shapes/rounded_polygon.dart | 6 ++---- .../material_ui/test/shapes/cubic_test.dart | 8 ++++++++ .../material_ui/test/shapes/features_test.dart | 18 ++++++++++++++++++ .../material_ui/test/shapes/polygon_test.dart | 10 ++++++++++ 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 0d1a4cbad8d5..8f4393e635f5 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -387,10 +387,11 @@ class CubicBezier { @override String toString() { - return 'anchor0: ($anchor0X, $anchor0Y) ' + return '${objectRuntimeType(this, 'CubicBezier')}' + '(anchor0: ($anchor0X, $anchor0Y), ' 'control0: ($control0X, $control0Y), ' 'control1: ($control1X, $control1Y), ' - 'anchor1: ($anchor1X, $anchor1Y)'; + 'anchor1: ($anchor1X, $anchor1Y))'; } @override diff --git a/packages/material_ui/lib/src/shapes/features.dart b/packages/material_ui/lib/src/shapes/features.dart index 581a9e0794c7..91aef8b87f5c 100644 --- a/packages/material_ui/lib/src/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/features.dart @@ -180,7 +180,7 @@ class EdgeFeature extends Feature { bool get isConcaveCorner => false; @override - String toString() => 'Edge'; + String toString() => '${objectRuntimeType(this, 'EdgeFeature')}(cubics: $_cubics)'; } /// Corners contain the list of cubic curves which describe how the corner is @@ -224,8 +224,8 @@ class CornerFeature extends Feature { @override String toString() { - return 'Corner: cubics=${_cubics.map((c) => '[$c]').join(', ')} ' - 'convex=$convex'; + return '${objectRuntimeType(this, 'CornerFeature')}' + '(cubics: $_cubics, convex: $convex)'; } @override diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 37b90a438240..99e5ffaa8a17 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -797,10 +797,8 @@ class RoundedPolygon { @override String toString() { - return '[RoundedPolygon. ' - 'Cubics = ${cubics.join(", ")}' - ' || Features = ${features.join(", ")}' - ' || Center = (${_center.x}, ${_center.y})]'; + return '${objectRuntimeType(this, 'RoundedPolygon')}' + '(center: $center, features: $features, cubics: $cubics)'; } @override diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index 8dc83de36f6f..284229375924 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -151,6 +151,14 @@ void main() { test('point CubicBezier has zero length', () { expect(CubicBezier.point(const Point(10, 10)).isZeroLength, isTrue); }); + + 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', () { diff --git a/packages/material_ui/test/shapes/features_test.dart b/packages/material_ui/test/shapes/features_test.dart index 8885d59e3c5d..817d5bff1478 100644 --- a/packages/material_ui/test/shapes/features_test.dart +++ b/packages/material_ui/test/shapes/features_test.dart @@ -136,5 +136,23 @@ void main() { 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/polygon_test.dart b/packages/material_ui/test/shapes/polygon_test.dart index 5f06fae8e684..ecdf6c6dce92 100644 --- a/packages/material_ui/test/shapes/polygon_test.dart +++ b/packages/material_ui/test/shapes/polygon_test.dart @@ -158,6 +158,16 @@ void main() { 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) { From 09e3573000a449182b9efb7e675609e644945fed Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 17:06:22 +0200 Subject: [PATCH 40/59] Update CornerRounding assert message. --- packages/material_ui/lib/src/shapes/corner_rounding.dart | 2 +- packages/material_ui/test/shapes/corner_rounding_test.dart | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/material_ui/lib/src/shapes/corner_rounding.dart b/packages/material_ui/lib/src/shapes/corner_rounding.dart index 16cdad214484..23a3c8979728 100644 --- a/packages/material_ui/lib/src/shapes/corner_rounding.dart +++ b/packages/material_ui/lib/src/shapes/corner_rounding.dart @@ -28,7 +28,7 @@ import 'package:flutter/foundation.dart'; class CornerRounding { /// Creates a [CornerRounding]. const CornerRounding({this.radius = 0, this.smoothing = 0}) - : assert(radius >= 0, 'radius has to be greater that zero'), + : 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 diff --git a/packages/material_ui/test/shapes/corner_rounding_test.dart b/packages/material_ui/test/shapes/corner_rounding_test.dart index 1719866cc35b..3fcb461ea438 100644 --- a/packages/material_ui/test/shapes/corner_rounding_test.dart +++ b/packages/material_ui/test/shapes/corner_rounding_test.dart @@ -29,6 +29,12 @@ void main() { 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), From 8adf348ed3610f95938bd516b58e8983c195006a Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 17:13:03 +0200 Subject: [PATCH 41/59] Check RoundedPolygon contiguity outside an assert. --- .../lib/src/shapes/rounded_polygon.dart | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 99e5ffaa8a17..3b59df9ac550 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -81,25 +81,19 @@ class RoundedPolygon { : features = List.unmodifiable(features), _center = center, cubics = List.unmodifiable(_buildCubics(features, center)) { - assert(() { - CubicBezier prevCubic = cubics[cubics.length - 1]; - - for (var index = 0; index < cubics.length; index++) { - final CubicBezier cubic = cubics[index]; - - 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; + 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.', + ); } - - return true; - }()); + prevCubic = cubic; + } } /// This function takes the vertices (either supplied or calculated, From 288f3135256acd123af075e8ec74e1cc2c245c37 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 17:43:29 +0200 Subject: [PATCH 42/59] Use Offset's own geometry in PointGeometry. --- .../material_ui/lib/src/material_shapes.dart | 2 +- .../material_ui/lib/src/shapes/cubic.dart | 4 +-- .../lib/src/shapes/feature_mapping.dart | 2 +- .../material_ui/lib/src/shapes/point.dart | 27 +++---------------- .../lib/src/shapes/polygon_measure.dart | 2 +- .../lib/src/shapes/rounded_polygon.dart | 10 +++---- .../material_ui/lib/src/shapes/utils.dart | 8 +++--- 7 files changed, 18 insertions(+), 37 deletions(-) diff --git a/packages/material_ui/lib/src/material_shapes.dart b/packages/material_ui/lib/src/material_shapes.dart index 2d3cb8ecb215..dff1994e8b7b 100644 --- a/packages/material_ui/lib/src/material_shapes.dart +++ b/packages/material_ui/lib/src/material_shapes.dart @@ -427,7 +427,7 @@ abstract final class MaterialShapes { 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.angleRadians, distance: off.getDistance()); + return (angle: off.direction, distance: off.distance); }); final int actualReps = reps * 2; final double sectionAngle = math.pi * 2 / actualReps; diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 8f4393e635f5..5a277c5414c8 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -68,8 +68,8 @@ class CubicBezier { /// one arc together. Note that [p0] and [p1] should be equidistant from /// [center]. factory CubicBezier.circularArc(Offset center, Offset p0, Offset p1) { - final Point p0d = directionVector(p0.x - center.x, p0.y - center.y); - final Point p1d = directionVector(p1.x - center.x, p1.y - center.y); + final Point p0d = unitVector(p0.x - center.x, p0.y - center.y); + final Point p1d = unitVector(p1.x - center.x, p1.y - center.y); final Point rotatedP0 = p0d.rotate90(); final Point rotatedP1 = p1d.rotate90(); final bool clockwise = rotatedP0.dotProductXY(p1.x - center.x, p1.y - center.y) >= 0; diff --git a/packages/material_ui/lib/src/shapes/feature_mapping.dart b/packages/material_ui/lib/src/shapes/feature_mapping.dart index dfed7d3e1c0e..a57ff9d2206d 100644 --- a/packages/material_ui/lib/src/shapes/feature_mapping.dart +++ b/packages/material_ui/lib/src/shapes/feature_mapping.dart @@ -198,7 +198,7 @@ double featureDistSquared(Feature f1, Feature f2) { return double.maxFinite; } - return (featureRepresentativePoint(f1) - featureRepresentativePoint(f2)).getDistanceSquared(); + return (featureRepresentativePoint(f1) - featureRepresentativePoint(f2)).distanceSquared; } /// Returns the point that best represents [feature] when matching features diff --git a/packages/material_ui/lib/src/shapes/point.dart b/packages/material_ui/lib/src/shapes/point.dart index 9bbd51a6d6a4..afbb1e5bde5c 100644 --- a/packages/material_ui/lib/src/shapes/point.dart +++ b/packages/material_ui/lib/src/shapes/point.dart @@ -33,10 +33,6 @@ extension PointGeometry on Offset { /// The vertical coordinate of this point. double get y => dy; - /// The angle of this point in radians, measured clockwise from the positive - /// X axis. - double get angleRadians => direction; - /// Returns this point rotated a quarter turn counterclockwise around (0, 0). Point rotate90() => Point(-y, x); @@ -49,20 +45,6 @@ extension PointGeometry on Offset { return Point(off.x * cos - off.y * sin, off.x * sin + off.y * cos) + center; } - /// The magnitude of the [Point], which is the distance of this point from - /// (0, 0). - /// - /// If you need this value to compare it to another [Point]'s distance, - /// consider using [getDistanceSquared] instead, since it is cheaper to - /// compute. - double getDistance() => distance; - - /// The square of the magnitude (which is the distance of this point from - /// (0, 0)) of the [Point]. - /// - /// This is cheaper than computing the [getDistance] itself. - double getDistanceSquared() => distanceSquared; - /// The dot product of this point and [other], both taken as vectors. double dotProduct(Point other) => x * other.x + y * other.y; @@ -75,11 +57,10 @@ extension PointGeometry on Offset { /// are co-linear. bool clockwise(Point other) => (x * other.y - y * other.x) > 0; - /// Returns the unit vector representing the direction to this point from - /// (0, 0). - Point getDirection() { - final double d = getDistance(); - assert(d > 0, "Can't get the direction of a 0-length vector"); + /// 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; } diff --git a/packages/material_ui/lib/src/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/polygon_measure.dart index 0eee290d3098..a50cd7102289 100644 --- a/packages/material_ui/lib/src/shapes/polygon_measure.dart +++ b/packages/material_ui/lib/src/shapes/polygon_measure.dart @@ -381,7 +381,7 @@ class LengthMeasurer implements Measurer { for (var i = 0; i <= _segments; i++) { final double progress = i / _segments; final Point point = cubic.pointAt(progress); - final double segment = (point - prev).getDistance(); + final double segment = (point - prev).distance; if (segment >= remainder) { return (progress - (1.0 - remainder / segment) / _segments, threshold); diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 3b59df9ac550..f0493ac3add6 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -876,8 +876,8 @@ 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.getDistance(); - final double d21 = v21.getDistance(); + final double d01 = v01.distance; + final double d21 = v21.distance; if (d01 > 0 && d21 > 0) { d1 = v01 / d01; @@ -967,7 +967,7 @@ class _RoundedCorner { // 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).getDirection() * centerDistance; + center = p1 + ((d1 + d2) / 2).unitVector * centerDistance; final Point circleIntersection0 = p1 + d1 * actualRoundCut; final Point circleIntersection2 = p1 + d2 * actualRoundCut; final CubicBezier flanking0 = _computeFlankingCurve( @@ -1050,7 +1050,7 @@ class _RoundedCorner { double actualR, ) { // sideStart is the anchor, 'anchor' is actual control point - final Point sideDirection = (sideStart - corner).getDirection(); + 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 @@ -1064,7 +1064,7 @@ class _RoundedCorner { // The flanking curve ends on the circle final Point curveEnd = - circleCenter + directionVector(p.x - circleCenter.x, p.y - circleCenter.y) * actualR; + circleCenter + unitVector(p.x - circleCenter.x, p.y - circleCenter.y) * 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 diff --git a/packages/material_ui/lib/src/shapes/utils.dart b/packages/material_ui/lib/src/shapes/utils.dart index 2e870909a2fe..67a5afa40187 100644 --- a/packages/material_ui/lib/src/shapes/utils.dart +++ b/packages/material_ui/lib/src/shapes/utils.dart @@ -34,11 +34,11 @@ double distance(double x, double y) => math.sqrt(x * x + y * y); @internal double distanceSquared(double x, double y) => x * x + y * y; -/// Returns unit vector representing the direction to this point from (0, 0). +/// Returns the unit vector pointing from (0, 0) towards ([x], [y]). @internal -Point directionVector(double x, double y) { +Point unitVector(double x, double y) { final double d = distance(x, y); - assert(d > 0, 'Required distance greater than zero.'); + assert(d > 0, "Can't compute the unit vector of a zero-length vector"); return Point(x / d, y / d); } @@ -100,7 +100,7 @@ bool collinearIsh( 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.getDistance() * ac.getDistance(); + final double relativeTolerance = tolerance * ab.distance * ac.distance; return dotProduct < tolerance || dotProduct < relativeTolerance; } From db5ea6f4e46995555801191d66c2dbc7e59e9c52 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 18:00:04 +0200 Subject: [PATCH 43/59] Get rid of x/y-argument geometry twins. --- packages/material_ui/lib/src/shapes/cubic.dart | 10 ++++++---- packages/material_ui/lib/src/shapes/point.dart | 3 --- .../lib/src/shapes/rounded_polygon.dart | 17 +++++------------ packages/material_ui/lib/src/shapes/utils.dart | 17 ----------------- 4 files changed, 11 insertions(+), 36 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 5a277c5414c8..039f2db3a5db 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -68,11 +68,13 @@ class CubicBezier { /// one arc together. Note that [p0] and [p1] should be equidistant from /// [center]. factory CubicBezier.circularArc(Offset center, Offset p0, Offset p1) { - final Point p0d = unitVector(p0.x - center.x, p0.y - center.y); - final Point p1d = unitVector(p1.x - center.x, p1.y - center.y); + 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.dotProductXY(p1.x - center.x, p1.y - center.y) >= 0; + final bool clockwise = rotatedP0.dotProduct(p1FromCenter) >= 0; final double cosa = p0d.dotProduct(p1d); // p0 ~= p1 @@ -81,7 +83,7 @@ class CubicBezier { } final double k = - distance(p0.x - center.x, p0.y - center.y) * + p0FromCenter.distance * 4 / 3 * (math.sqrt(2 * (1 - cosa)) - math.sqrt(1 - cosa * cosa)) / diff --git a/packages/material_ui/lib/src/shapes/point.dart b/packages/material_ui/lib/src/shapes/point.dart index afbb1e5bde5c..16794e661878 100644 --- a/packages/material_ui/lib/src/shapes/point.dart +++ b/packages/material_ui/lib/src/shapes/point.dart @@ -48,9 +48,6 @@ extension PointGeometry on Offset { /// The dot product of this point and [other], both taken as vectors. double dotProduct(Point other) => x * other.x + y * other.y; - /// The dot product of this point and the vector ([otherX], [otherY]). - double dotProductXY(double otherX, double otherY) => x * otherX + y * otherY; - /// Compute the Z coordinate of the cross product of two vectors, to check /// if the second vector is going clockwise ( > 0 ) or counterclockwise /// (< 0) compared with the first one. It could also be 0, if the vectors diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index f0493ac3add6..361261380ea2 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -166,7 +166,7 @@ class RoundedPolygon { roundedCorners[ix].expectedCut + roundedCorners[(ix + 1) % n].expectedCut; final Point vtx = vertices[ix]; final Point nextVtx = vertices[(ix + 1) % n]; - final double sideSize = distance(vtx.x - nextVtx.x, vtx.y - nextVtx.y); + final double sideSize = (vtx - nextVtx).distance; // Check expectedRoundCut first, and ensure we fulfill rounding needs // first for both corners before using space for smoothing. @@ -717,15 +717,9 @@ class RoundedPolygon { var maxDistSquared = 0.0; for (var i = 0; i < cubics.length; i++) { final CubicBezier cubic = cubics[i]; - final double anchorDistance = distanceSquared( - cubic.anchor0X - _center.x, - cubic.anchor0Y - _center.y, - ); + final double anchorDistance = (cubic.anchor0 - _center).distanceSquared; final Point middlePoint = cubic.pointAt(0.5); - final double middleDistance = distanceSquared( - middlePoint.x - _center.x, - middlePoint.y - _center.y, - ); + final double middleDistance = (middlePoint - _center).distanceSquared; maxDistSquared = math.max(maxDistSquared, math.max(anchorDistance, middleDistance)); } @@ -1063,8 +1057,7 @@ class _RoundedCorner { ); // The flanking curve ends on the circle - final Point curveEnd = - circleCenter + unitVector(p.x - circleCenter.x, p.y - circleCenter.y) * actualR; + 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 @@ -1140,7 +1133,7 @@ List _pillStarVerticesFromNumVerts( // 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 = twoPi * endcapRadius * lerp(innerRadius, 1, vertexSpacing); + 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; diff --git a/packages/material_ui/lib/src/shapes/utils.dart b/packages/material_ui/lib/src/shapes/utils.dart index 67a5afa40187..e0fb907266b0 100644 --- a/packages/material_ui/lib/src/shapes/utils.dart +++ b/packages/material_ui/lib/src/shapes/utils.dart @@ -25,23 +25,6 @@ const angleEpsilon = 1e-6; @internal const relaxedDistanceEpsilon = 5e-3; -@internal -const double twoPi = math.pi * 2; - -@internal -double distance(double x, double y) => math.sqrt(x * x + y * y); - -@internal -double distanceSquared(double x, double y) => x * x + y * y; - -/// Returns the unit vector pointing from (0, 0) towards ([x], [y]). -@internal -Point unitVector(double x, double y) { - final double d = distance(x, y); - assert(d > 0, "Can't compute the unit vector of a zero-length vector"); - return Point(x / d, y / d); -} - @internal Point directionVectorFromAngle(double angleRadians) => Point(math.cos(angleRadians), math.sin(angleRadians)); From d837c0a777c56929763c0edd61b94ea1af93c34e Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 19:36:58 +0200 Subject: [PATCH 44/59] Rename PointGeometry.clockwise to turnsClockwiseTo. --- packages/material_ui/lib/src/shapes/point.dart | 12 +++++++----- packages/material_ui/lib/src/shapes/utils.dart | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/point.dart b/packages/material_ui/lib/src/shapes/point.dart index 16794e661878..c92d0a667061 100644 --- a/packages/material_ui/lib/src/shapes/point.dart +++ b/packages/material_ui/lib/src/shapes/point.dart @@ -48,11 +48,13 @@ extension PointGeometry on Offset { /// The dot product of this point and [other], both taken as vectors. double dotProduct(Point other) => x * other.x + y * other.y; - /// Compute the Z coordinate of the cross product of two vectors, to check - /// if the second vector is going clockwise ( > 0 ) or counterclockwise - /// (< 0) compared with the first one. It could also be 0, if the vectors - /// are co-linear. - bool clockwise(Point other) => (x * other.y - y * other.x) > 0; + /// 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 { diff --git a/packages/material_ui/lib/src/shapes/utils.dart b/packages/material_ui/lib/src/shapes/utils.dart index e0fb907266b0..8a816020610d 100644 --- a/packages/material_ui/lib/src/shapes/utils.dart +++ b/packages/material_ui/lib/src/shapes/utils.dart @@ -92,7 +92,7 @@ bool collinearIsh( /// the relationship of the prev->curr/curr->next vectors. @internal bool convex(Point previous, Point current, Point next) { - return (current - previous).clockwise(next - current); + return (current - previous).turnsClockwiseTo(next - current); } /// Does a ternary search in [v0..v1] to find the parameter that minimizes the From 1a01e46e863eaa3f6fd4578646f2b3213130d37a Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 19:45:15 +0200 Subject: [PATCH 45/59] Type cutAdjusts as (double, double) rather than (num, num). --- .../material_ui/lib/src/shapes/rounded_polygon.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 361261380ea2..8dd4ef66b871 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -159,7 +159,7 @@ class RoundedPolygon { // 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<(num, num)> cutAdjusts = List.generate(n, (ix) { + final List<(double, double)> cutAdjusts = List.generate(n, (ix) { final double expectedRoundCut = roundedCorners[ix].expectedRoundCut + roundedCorners[(ix + 1) % n].expectedRoundCut; final double expectedCut = @@ -172,13 +172,13 @@ class RoundedPolygon { // 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); + return (sideSize / expectedRoundCut, 0.0); } else if (expectedCut > sideSize) { // We can do full rounding, but not full smoothing. - return (1, (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)); + return (1.0, (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)); } else { // There is enough room for rounding & smoothing. - return (1, 1); + return (1.0, 1.0); } }); @@ -189,7 +189,7 @@ class RoundedPolygon { final allowedCuts = List.filled(2, 0); for (var delta = 0; delta <= 1; delta++) { - final (num roundCutRatio, num cutRatio) = cutAdjusts[(i + n - 1 + delta) % n]; + final (double roundCutRatio, double cutRatio) = cutAdjusts[(i + n - 1 + delta) % n]; allowedCuts[delta] = roundedCorners[i].expectedRoundCut * roundCutRatio + (roundedCorners[i].expectedCut - roundedCorners[i].expectedRoundCut) * cutRatio; From 28ddebfec16695fca7ad1657990697a1d16e1d8e Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 20:22:33 +0200 Subject: [PATCH 46/59] Use listEquals in the CubicBezier and RoundedPolygon equality. --- packages/material_ui/lib/src/shapes/cubic.dart | 16 +--------------- .../lib/src/shapes/rounded_polygon.dart | 16 +--------------- packages/material_ui/test/shapes/cubic_test.dart | 10 ++++++++++ 3 files changed, 12 insertions(+), 30 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 039f2db3a5db..5c8906900075 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -402,21 +402,7 @@ class CubicBezier { return true; } - if (other is! CubicBezier) { - return false; - } - - if (_points.length != other._points.length) { - return false; - } - - for (var index = 0; index < _points.length; index += 1) { - if (_points[index] != other._points[index]) { - return false; - } - } - - return true; + return other is CubicBezier && listEquals(other._points, _points); } @override diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 8dd4ef66b871..62900b5703d2 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -795,21 +795,7 @@ class RoundedPolygon { return true; } - if (other is! RoundedPolygon) { - return false; - } - - if (features.length != other.features.length) { - return false; - } - - for (var index = 0; index < features.length; index += 1) { - if (features[index] != other.features[index]) { - return false; - } - } - - return true; + return other is RoundedPolygon && listEquals(other.features, features); } @override diff --git a/packages/material_ui/test/shapes/cubic_test.dart b/packages/material_ui/test/shapes/cubic_test.dart index 284229375924..b3e07c45840c 100644 --- a/packages/material_ui/test/shapes/cubic_test.dart +++ b/packages/material_ui/test/shapes/cubic_test.dart @@ -152,6 +152,16 @@ void main() { 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(), From b88ec774c8953bdeb7d345fa804b3b48070fc140 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 20:32:31 +0200 Subject: [PATCH 47/59] Rename float_mapping.dart to double_mapping.dart. --- .../src/shapes/{float_mapping.dart => double_mapping.dart} | 6 +++--- packages/material_ui/lib/src/shapes/feature_mapping.dart | 2 +- packages/material_ui/lib/src/shapes/morph.dart | 2 +- .../{float_mapping_test.dart => double_mapping_test.dart} | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) rename packages/material_ui/lib/src/shapes/{float_mapping.dart => double_mapping.dart} (94%) rename packages/material_ui/test/shapes/{float_mapping_test.dart => double_mapping_test.dart} (96%) diff --git a/packages/material_ui/lib/src/shapes/float_mapping.dart b/packages/material_ui/lib/src/shapes/double_mapping.dart similarity index 94% rename from packages/material_ui/lib/src/shapes/float_mapping.dart rename to packages/material_ui/lib/src/shapes/double_mapping.dart index 260dd5c84904..9ca6313b6522 100644 --- a/packages/material_ui/lib/src/shapes/float_mapping.dart +++ b/packages/material_ui/lib/src/shapes/double_mapping.dart @@ -121,17 +121,17 @@ void validateProgress(List p) { final double curr = p[i]; if (curr < 0 || curr >= 1) { - throw ArgumentError('FloatMapping - Progress outside of range: ${p.join(', ')}'); + throw ArgumentError('Progress outside of range: ${p.join(', ')}'); } if (progressDistance(curr, prev).abs() <= distanceEpsilon) { - throw ArgumentError('FloatMapping - Progress repeats a value: ${p.join(', ')}'); + throw ArgumentError('Progress repeats a value: ${p.join(', ')}'); } if (curr < prev) { wraps++; if (wraps > 1) { - throw ArgumentError('FloatMapping - Progress wraps more than once: ${p.join(', ')}'); + throw ArgumentError('Progress wraps more than once: ${p.join(', ')}'); } } diff --git a/packages/material_ui/lib/src/shapes/feature_mapping.dart b/packages/material_ui/lib/src/shapes/feature_mapping.dart index a57ff9d2206d..230a39e5bdd8 100644 --- a/packages/material_ui/lib/src/shapes/feature_mapping.dart +++ b/packages/material_ui/lib/src/shapes/feature_mapping.dart @@ -5,8 +5,8 @@ import 'package:flutter/foundation.dart'; import 'cubic.dart'; +import 'double_mapping.dart'; import 'features.dart'; -import 'float_mapping.dart'; import 'point.dart'; import 'utils.dart'; diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index 359b94132964..0daa776ffac7 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -8,8 +8,8 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'cubic.dart'; +import 'double_mapping.dart'; import 'feature_mapping.dart'; -import 'float_mapping.dart'; import 'polygon_measure.dart'; import 'rounded_polygon.dart'; import 'utils.dart'; diff --git a/packages/material_ui/test/shapes/float_mapping_test.dart b/packages/material_ui/test/shapes/double_mapping_test.dart similarity index 96% rename from packages/material_ui/test/shapes/float_mapping_test.dart rename to packages/material_ui/test/shapes/double_mapping_test.dart index cb684b78c613..02f098855d8f 100644 --- a/packages/material_ui/test/shapes/float_mapping_test.dart +++ b/packages/material_ui/test/shapes/double_mapping_test.dart @@ -3,12 +3,12 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/src/shapes/float_mapping.dart'; +import 'package:material_ui/src/shapes/double_mapping.dart'; import 'test_utils.dart'; void main() { - group('FloatMapping', () { + group('$DoubleMapper', () { void validateMapping(DoubleMapper mapper, double Function(double) expectedFunction) { for (var i = 0; i < 10000; i++) { final double source = i / 10000; From edd17b0d564fffa467387ffda55041ff75f94b05 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 20:36:27 +0200 Subject: [PATCH 48/59] Delete the MeasuredFeatures typedef. --- .../material_ui/lib/src/shapes/feature_mapping.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/feature_mapping.dart b/packages/material_ui/lib/src/shapes/feature_mapping.dart index 230a39e5bdd8..d22d3a29a003 100644 --- a/packages/material_ui/lib/src/shapes/feature_mapping.dart +++ b/packages/material_ui/lib/src/shapes/feature_mapping.dart @@ -10,11 +10,6 @@ import 'features.dart'; import 'point.dart'; import 'utils.dart'; -/// MeasuredFeatures contains a list of all features in a polygon along with -/// the [0..1] progress at that feature. -@internal -typedef MeasuredFeatures = List; - /// A [Feature] paired with the [0..1] progress at which it sits along the /// outline of its polygon. @internal @@ -47,7 +42,10 @@ class DistanceVertex { /// Creates a mapping between the "features" (rounded corners) of two shapes. @internal -DoubleMapper featureMapper(MeasuredFeatures features1, MeasuredFeatures features2) { +DoubleMapper featureMapper( + List features1, + List features2, +) { // We only use corners for this mapping. final filteredFeatures1 = []; for (var i = 0; i < features1.length; i++) { From 2dc43fe94bf7324ae2b8e41c13c87d3d0a83c881 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 20:39:38 +0200 Subject: [PATCH 49/59] Rename the MeasuredPolygon measure and cubic accessors. --- packages/material_ui/lib/src/shapes/morph.dart | 12 ++++++------ .../material_ui/lib/src/shapes/polygon_measure.dart | 4 ++-- .../test/shapes/feature_mapping_test.dart | 4 ++-- .../test/shapes/polygon_measure_test.dart | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index 0daa776ffac7..4f2d3c2c3af1 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -68,8 +68,8 @@ class Morph { 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.measurePolygon(const LengthMeasurer(), p1); - final measuredPolygon2 = MeasuredPolygon.measurePolygon(const LengthMeasurer(), p2); + 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. @@ -108,8 +108,8 @@ class Morph { var i1 = 0; var i2 = 0; // b1, b2 are the current measured cubic for each polygon. - MeasuredCubic? b1 = bs1.getOrNull(i1++); - MeasuredCubic? b2 = bs2.getOrNull(i2++); + 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 @@ -126,11 +126,11 @@ class Morph { // If one curve extends beyond, we need to cut it. final (MeasuredCubic seg1, MeasuredCubic? newb1) = (b1a > minb + angleEpsilon) ? b1.cutAtProgress(minb) - : (b1, bs1.getOrNull(i1++)); + : (b1, bs1.cubicAtOrNull(i1++)); final (MeasuredCubic seg2, MeasuredCubic? newb2) = (b2a > minb + angleEpsilon) ? b2.cutAtProgress(positiveModulo(doubleMapper.map(minb) - polygon2CutPoint, 1)) - : (b2, bs2.getOrNull(i2++)); + : (b2, bs2.cubicAtOrNull(i2++)); ret.add((seg1.cubic, seg2.cubic)); b1 = newb1; diff --git a/packages/material_ui/lib/src/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/polygon_measure.dart index a50cd7102289..272e8e65c843 100644 --- a/packages/material_ui/lib/src/shapes/polygon_measure.dart +++ b/packages/material_ui/lib/src/shapes/polygon_measure.dart @@ -52,7 +52,7 @@ class MeasuredPolygon { _cubics = measuredCubics; } - factory MeasuredPolygon.measurePolygon(Measurer measurer, RoundedPolygon polygon) { + factory MeasuredPolygon.measure(Measurer measurer, RoundedPolygon polygon) { final cubics = []; final featureToCubic = <(Feature, int)>[]; @@ -117,7 +117,7 @@ class MeasuredPolygon { MeasuredCubic operator [](int index) => _cubics[index]; - MeasuredCubic? getOrNull(int index) { + MeasuredCubic? cubicAtOrNull(int index) { final int length = _cubics.length; if (index < 0 || index >= length) { diff --git a/packages/material_ui/test/shapes/feature_mapping_test.dart b/packages/material_ui/test/shapes/feature_mapping_test.dart index d2966b66d274..659aeae04962 100644 --- a/packages/material_ui/test/shapes/feature_mapping_test.dart +++ b/packages/material_ui/test/shapes/feature_mapping_test.dart @@ -23,11 +23,11 @@ void main() { RoundedPolygon p2, void Function(List) validator, ) { - final List f1 = MeasuredPolygon.measurePolygon( + final List f1 = MeasuredPolygon.measure( const LengthMeasurer(), p1, ).features; - final List f2 = MeasuredPolygon.measurePolygon( + final List f2 = MeasuredPolygon.measure( const LengthMeasurer(), p2, ).features; diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart index 7d903a5d3e62..2485c8cd7334 100644 --- a/packages/material_ui/test/shapes/polygon_measure_test.dart +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -23,7 +23,7 @@ void main() { RoundedPolygon polygon, [ void Function(MeasuredPolygon)? extraChecks, ]) { - final measuredPolygon = MeasuredPolygon.measurePolygon(measurer, polygon); + final measuredPolygon = MeasuredPolygon.measure(measurer, polygon); expect(0, measuredPolygon.first.startOutlineProgress); expect(1, measuredPolygon.last.endOutlineProgress); From f6edb8eb6f6ecb30e2cfedecc394639386890468 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sat, 5 Sep 2026 20:41:37 +0200 Subject: [PATCH 50/59] Name the Measurer parameters cubic and measure. --- .../material_ui/lib/src/shapes/polygon_measure.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/polygon_measure.dart index 272e8e65c843..5e00b8705bfc 100644 --- a/packages/material_ui/lib/src/shapes/polygon_measure.dart +++ b/packages/material_ui/lib/src/shapes/polygon_measure.dart @@ -341,12 +341,12 @@ abstract interface class 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 c); + 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 c, double m); + double findCubicCutPoint(CubicBezier cubic, double measure); } /// Approximates the arc lengths of cubics by splitting the arc into segments @@ -364,13 +364,13 @@ class LengthMeasurer implements Measurer { static const _segments = 3; @override - double measureCubic(CubicBezier c) { - return _closestProgressTo(c, double.infinity).$2; + double measureCubic(CubicBezier cubic) { + return _closestProgressTo(cubic, double.infinity).$2; } @override - double findCubicCutPoint(CubicBezier c, double m) { - return _closestProgressTo(c, m).$1; + double findCubicCutPoint(CubicBezier cubic, double measure) { + return _closestProgressTo(cubic, measure).$1; } (double, double) _closestProgressTo(CubicBezier cubic, double threshold) { From 6c16dce8a3c0ce484e39dcdf06f934e2d2c2b537 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 6 Sep 2026 17:13:16 +0200 Subject: [PATCH 51/59] Update docs and comments. --- .../lib/src/shapes/corner_rounding.dart | 12 +- .../material_ui/lib/src/shapes/cubic.dart | 50 +- .../lib/src/shapes/double_mapping.dart | 18 +- .../lib/src/shapes/feature_mapping.dart | 37 +- .../material_ui/lib/src/shapes/features.dart | 30 +- .../material_ui/lib/src/shapes/morph.dart | 38 +- .../material_ui/lib/src/shapes/point.dart | 3 +- .../lib/src/shapes/polygon_measure.dart | 28 +- .../lib/src/shapes/rounded_polygon.dart | 447 ++++++------------ .../material_ui/lib/src/shapes/utils.dart | 9 +- 10 files changed, 247 insertions(+), 425 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/corner_rounding.dart b/packages/material_ui/lib/src/shapes/corner_rounding.dart index 23a3c8979728..a74c70827874 100644 --- a/packages/material_ui/lib/src/shapes/corner_rounding.dart +++ b/packages/material_ui/lib/src/shapes/corner_rounding.dart @@ -7,11 +7,13 @@ library; import 'package:flutter/foundation.dart'; -/// Defines the amount and quality 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. +/// 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). diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 5c8906900075..4b83a908e687 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -16,16 +16,14 @@ import 'package:vector_math/vector_math_64.dart' show Matrix4; import 'point.dart'; import 'utils.dart'; -/// This class holds the anchor and control point data for a single cubic -/// Bézier curve, with anchor points [anchor0] and [anchor1] at either end and -/// control points [control0] and [control1] determining the slope of the curve -/// between the anchor points. +/// 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 [CubicBezier] that holds the anchor and control point data for a - /// single Bézier curve, with anchor points [anchor0] and [anchor1] at either - /// end and control points [control0] and [control1] determining the slope of - /// the curve between the anchor points. + /// 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, @@ -155,12 +153,8 @@ class CubicBezier { /// The Y coordinate of the anchor point at the end of the curve. double get anchor1Y => _points[7]; - /// Returns a point on the curve for parameter [t], representing the - /// proportional distance along the curve between its starting point at - /// [anchor0] and ending point at [anchor1]. - /// - /// [t] is the distance along the curve between the anchor points, where 0 - /// is at [anchor0] and 1 is at [anchor1]. + /// 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( @@ -205,7 +199,7 @@ class CubicBezier { Rect get approximateBounds => _calculateBounds(approximate: true); Rect _calculateBounds({required bool approximate}) { - // A curve might be of zero-length, with both anchors co-lated. + // 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); @@ -409,11 +403,9 @@ class CubicBezier { int get hashCode => Object.hashAll(_points); } -/// Mutable version of [CubicBezier], used mostly for performance critical paths -/// so we can avoid creating new [CubicBezier]s -/// -/// This is used in Morph.forEachCubic, reusing a [_MutableCubicBezier] instance -/// to avoid creating new [CubicBezier]s. +/// 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)); @@ -429,12 +421,6 @@ class _MutableCubicBezier extends CubicBezier { _transformOnePoint(f, 4); _transformOnePoint(f, 6); } - - void interpolate(CubicBezier c1, CubicBezier c2, double progress) { - for (var i = 0; i < 8; i++) { - _points[i] = lerp(c1._points[i], c2._points[i], progress); - } - } } /// Returns a [Path] built from the given [cubics]. @@ -450,13 +436,13 @@ class _MutableCubicBezier extends CubicBezier { /// The default of zero is special: it skips the rotation entirely and leaves /// the curves as given. /// -/// [repeatPath] is whether or not to repeat the [Path] twice before closing -/// it. This flag 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 a motion as a Star circular -/// progress indicator advances). +/// 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. /// -/// [closePath] is whether or not to close the created [Path]. +/// 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 diff --git a/packages/material_ui/lib/src/shapes/double_mapping.dart b/packages/material_ui/lib/src/shapes/double_mapping.dart index 9ca6313b6522..de626c61d672 100644 --- a/packages/material_ui/lib/src/shapes/double_mapping.dart +++ b/packages/material_ui/lib/src/shapes/double_mapping.dart @@ -22,8 +22,10 @@ bool progressInRange(double progress, double progressFrom, double 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. +/// 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'); @@ -58,9 +60,11 @@ double linearMap(List xValues, List yValues, double x) { } /// [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. +/// 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 @@ -91,6 +95,9 @@ class DoubleMapper { 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; @@ -140,6 +147,7 @@ void validateProgress(List p) { } /// 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) { diff --git a/packages/material_ui/lib/src/shapes/feature_mapping.dart b/packages/material_ui/lib/src/shapes/feature_mapping.dart index d22d3a29a003..cf309e1ef004 100644 --- a/packages/material_ui/lib/src/shapes/feature_mapping.dart +++ b/packages/material_ui/lib/src/shapes/feature_mapping.dart @@ -69,21 +69,23 @@ DoubleMapper featureMapper( 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 by such distance +/// 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 pair of consecutive elements, and the -/// last element to first element). +/// 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, @@ -184,12 +186,13 @@ class _MappingHelper { } } -/// Returns distance along overall shape between 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). +/// 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 so, the approach below will not work 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. diff --git a/packages/material_ui/lib/src/shapes/features.dart b/packages/material_ui/lib/src/shapes/features.dart index 91aef8b87f5c..e15033903505 100644 --- a/packages/material_ui/lib/src/shapes/features.dart +++ b/packages/material_ui/lib/src/shapes/features.dart @@ -33,8 +33,9 @@ abstract class Feature { /// Creates a [Feature] spanning the given [cubics]. const Feature._(List cubics) : _cubics = cubics; - /// Group a list of [CubicBezier] objects to a feature that should be ignored in - /// the default [Morph] mapping. The feature can have any indentation. + /// 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 @@ -56,22 +57,20 @@ abstract class Feature { /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. factory Feature.ignorable(List cubics) => _validated(EdgeFeature(cubics)); - /// Group a [CubicBezier] object to an edge (neither inward or outward - /// identification in a shape). - /// - /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. + /// Groups a [CubicBezier] object into an edge (neither inward nor outward + /// indentation in a shape). factory Feature.edge(CubicBezier cubic) => EdgeFeature([cubic]); - /// Group a list of [CubicBezier] objects to a convex corner (outward indentation - /// in a shape). + /// 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 + /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. factory Feature.convexCorner(List cubics) => _validated(CornerFeature(cubics)); - /// Group a list of [CubicBezier] objects to a concave corner (inward indentation - /// in a shape). + /// 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 + /// Throws [ArgumentError] for lists of empty cubics or non-continuous cubics. factory Feature.concaveCorner(List cubics) => _validated(CornerFeature(cubics, convex: false)); @@ -106,11 +105,12 @@ abstract class Feature { final List _cubics; - /// Returns unmodifiable list of [CubicBezier]. + /// 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 + /// 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. diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index 4f2d3c2c3af1..bfe56676f639 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -14,7 +14,7 @@ import 'polygon_measure.dart'; import 'rounded_polygon.dart'; import 'utils.dart'; -/// This class is used to animate between start and end polygons objects. +/// 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 @@ -164,20 +164,14 @@ class Morph { /// meant to hold this morph in any rotation. Rect get maxBounds => start.maxBounds.expandToInclude(end.maxBounds); - /// Returns a representation of the morph object at a given [progress] value - /// as a list of [CubicBezier]s. Note that this function causes a new list to be - /// created and populated, so there is some - /// overhead. + /// Returns this morph's shape at [progress] as a list of [CubicBezier]s. /// - /// [progress] is a value from 0 to 1 that determines the morph's current - /// shape, between the start and end shapes provided at construction time. A - /// value of 0 results in the start shape, a value of 1 results in the end - /// shape, and any value in between results in a shape which is a linear - /// interpolation between those two shapes. + /// [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. /// - /// The range is generally [0..1] and values outside could result in - /// undefined shapes, but values close to (but outside) the range can be used - /// to get an exaggerated effect (e.g., for a bounce or overshoot animation). + /// This creates and populates a new list on every call. List toCubics(double progress) { final result = []; @@ -220,9 +214,11 @@ class Morph { return result; } - /// Returns a [Path] for a [Morph]. + /// Returns a [Path] for this morph's shape at [progress]. /// - /// [progress] is the [Morph]'s 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. @@ -231,13 +227,13 @@ class Morph { /// The default of zero is special: it skips the rotation entirely and leaves /// the curves as [toCubics] produced them. /// - /// [repeatPath] is whether or not to repeat the [Path] twice before closing - /// it. This flag 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 a motion as a Star circular - /// progress indicator advances). + /// 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. /// - /// [closePath] is whether or not to close the created [Path]. + /// 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 diff --git a/packages/material_ui/lib/src/shapes/point.dart b/packages/material_ui/lib/src/shapes/point.dart index c92d0a667061..82112319d5fb 100644 --- a/packages/material_ui/lib/src/shapes/point.dart +++ b/packages/material_ui/lib/src/shapes/point.dart @@ -33,7 +33,8 @@ extension PointGeometry on Offset { /// The vertical coordinate of this point. double get y => dy; - /// Returns this point rotated a quarter turn counterclockwise around (0, 0). + /// 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]. diff --git a/packages/material_ui/lib/src/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/polygon_measure.dart index 5e00b8705bfc..d942fa8e5d87 100644 --- a/packages/material_ui/lib/src/shapes/polygon_measure.dart +++ b/packages/material_ui/lib/src/shapes/polygon_measure.dart @@ -127,11 +127,13 @@ class MeasuredPolygon { return _cubics[index]; } - /// Finds the point in the input list of measured cubics that pass 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.4f and measured cubics on these + /// 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] @@ -145,7 +147,7 @@ class MeasuredPolygon { /// /// 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) { + if (cuttingPoint < 0 || cuttingPoint > 1) { throw ArgumentError('Cutting point is expected to be between 0 and 1'); } @@ -219,11 +221,13 @@ class MeasuredPolygon { } } -/// A MeasuredCubic holds information about the cubic itself, the feature +/// 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). +/// 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. @@ -281,7 +285,7 @@ class MeasuredCubic { _endOutlineProgress = endOutlineProgress; } - /// Cut this [MeasuredCubic] into two at the given outline progress value. + /// 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 @@ -378,7 +382,7 @@ class LengthMeasurer implements Measurer { var remainder = threshold; var prev = Point(cubic.anchor0X, cubic.anchor0Y); - for (var i = 0; i <= _segments; i++) { + for (var i = 1; i <= _segments; i++) { final double progress = i / _segments; final Point point = cubic.pointAt(progress); final double segment = (point - prev).distance; diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 62900b5703d2..63248fa2ad6d 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -16,48 +16,25 @@ import 'features.dart'; import 'point.dart'; import 'utils.dart'; -/// The RoundedPolygon class allows simple construction of polygonal shapes -/// with optional rounding at the vertices. Polygons can be constructed with -/// either the number of vertices desired or an ordered list of vertices. +/// 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 { - /// This constructor takes the number of vertices in the resulting polygon. - /// These vertices are positioned on a virtual circle around a given center - /// with each vertex positioned [radius] distance from that center, equally - /// spaced (with equal angles between them). If no radius is supplied, the - /// shape will be created with a default radius of 1, resulting in a shape - /// whose vertices lie on a unit circle, with width/height of 2. That default - /// polygon will probably need to be rescaled using [transformed] into the - /// appropriate size for the UI in which it will be drawn. - /// - /// The [rounding] and [perVertexRounding] parameters are optional. If not - /// supplied, the result will be a regular polygon with straight edges and - /// unrounded corners. - /// - /// [numVertices] is the number of vertices in this polygon. + /// Creates a regular polygon with [numVertices] vertices, equally spaced + /// around a circle of the given [radius] about [center]. /// - /// [radius] is the radius of the polygon, in pixels. This radius determines - /// the initial size of the object, but it can be transformed later by using - /// the [transformed] function. + /// 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. /// - /// [center] is the center of the polygon, around which all vertices will be - /// placed. The default center is at (0,0). + /// [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. /// - /// [rounding] is the [CornerRounding] properties of all vertices. If some - /// vertices should have different rounding properties, then use - /// [perVertexRounding] instead. The default rounding value is - /// [CornerRounding.unrounded], meaning that the polygon will use the - /// vertices themselves in the final shape and not curves rounded around the - /// vertices. - /// - /// [perVertexRounding] is the [CornerRounding] properties of every vertex. - /// If this parameter is not null, then it must have [numVertices] elements. - /// If this parameter is null, then the polygon will use the [rounding] - /// parameter for every vertex instead. The default value is null. - /// - /// Throws [ArgumentError] if [perVertexRounding] is not null and its size - /// is not equal to [numVertices]. - /// Throws [ArgumentError] when [numVertices] is less than 3. + /// Throws [ArgumentError] if [numVertices] is less than 3, or if + /// [perVertexRounding] has the wrong number of elements. factory RoundedPolygon( int numVertices, { double radius = 1, @@ -96,35 +73,20 @@ class RoundedPolygon { } } - /// This function takes the vertices (either supplied or calculated, - /// depending on the constructor called), plus [CornerRounding] parameters, - /// and creates the actual [RoundedPolygon] shape, rounding around the - /// vertices (or not) as specified. The result is a list of [CubicBezier] curves - /// which represent the geometry of the final shape. - /// - /// [vertices] is the list of vertices in this polygon. This should be an - /// ordered list (with the outline of the shape going from each vertex to the - /// next in order of this list), otherwise the results will be undefined. + /// Creates a polygon with the given [vertices]. /// - /// [rounding] is the [CornerRounding] properties of all vertices. If some - /// vertices should have different rounding properties, then use - /// [perVertexRounding] instead. The default rounding value is - /// [CornerRounding.unrounded], meaning that the polygon will use the - /// vertices themselves in the final shape and not curves rounded around the - /// 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. /// - /// [perVertexRounding] is the [CornerRounding] properties of all vertices. - /// If this parameter is not null, then it must have the same size as - /// [vertices]. If this parameter is null, then the polygon will use the - /// [rounding] parameter for every vertex instead. The default value is null. + /// [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] is the center of the polygon, around which all vertices will be - /// placed. If `null` (the default value), the center is estimated by - /// averaging the [vertices]. + /// [center] defaults to the average of [vertices]. /// - /// Throws [ArgumentError] if the number of vertices is less than 3, or if - /// the [perVertexRounding] parameter is not null and its size doesn't match - /// the number 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, @@ -219,25 +181,18 @@ class RoundedPolygon { return RoundedPolygon.fromFeatures(tempFeatures, center: center ?? calculateCenter(vertices)); } - /// Takes a list of [Feature] objects that define the polygon's shape and - /// curves. By specifying the features directly, the summarization of [CubicBezier] - /// objects to curves can be precisely controlled. This affects [Morph]'s - /// default mapping, as curves with the same type (convex or concave) are - /// mapped with each other. For example, if you have a convex curve in your - /// start polygon, [Morph] will map it to another convex curve in the end - /// polygon. + /// Creates a polygon from [features], which describe each segment of its + /// outline. /// - /// The [center] parameter is optional. If not supplied, it will be estimated - /// by calculating the average of all cubic anchor points. + /// 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. /// - /// [features] are the [Feature]s that describe the characteristics of each - /// outline segment of the polygon. + /// [center] defaults to the average of every cubic's starting anchor point. /// - /// [center] is the center of the polygon, around which all vertices will be - /// placed. If null (the default value), the center will be averaged. - /// - /// Throws [ArgumentError] if [features] length is less than 2 or if they - /// don't describe a closed shape. + /// 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.'); @@ -258,19 +213,10 @@ class RoundedPolygon { return RoundedPolygon._raw(features, calculateCenter(vertices)); } - /// Creates a circular shape, approximating the rounding of the shape around - /// the underlying polygon - /// vertices. - /// - /// [numVertices] is the number of vertices in the underlying polygon with - /// which to approximate the circle, default value is 8. - /// - /// [radius] is the optional radius for the circle, default value is 1.0. + /// Creates a circle of the given [radius] about [center], approximated by + /// rounding a polygon of [numVertices] vertices. /// - /// [center] is the optional center for the circle, default value is - /// [Offset.zero]. - /// - /// Throws [ArgumentError] when [numVertices] is less than 3. + /// Throws [ArgumentError] if [numVertices] is less than 3. factory RoundedPolygon.circle({ int numVertices = 8, double radius = 1, @@ -293,34 +239,16 @@ class RoundedPolygon { ); } - /// Creates a rectangular shape with the given width/height around the given - /// center. Optional rounding parameters can be used to create a rounded - /// rectangle instead. - /// - /// As with all [RoundedPolygon] objects, if this shape is created with - /// default dimensions and center, it is sized to fit within the 2x2 - /// bounding box around a center of (0, 0) and will need to be scaled and - /// moved using [RoundedPolygon.transformed] to fit the intended area in a UI. - /// - /// [width] is the width of the rectangle, default value is 2. + /// Creates a rectangle [width] wide and [height] high about [center], with + /// optional rounding at its four corners. /// - /// [height] is the height of the rectangle, default value is 2. + /// 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] is the [CornerRounding] properties of every vertex. If some - /// vertices should have different rounding properties, then use - /// [perVertexRounding] instead. The default rounding value is - /// [CornerRounding.unrounded], meaning that the polygon will use the - /// vertices themselves in the final shape and not curves rounded around the - /// vertices. - /// - /// [perVertexRounding] is the [CornerRounding] properties of every vertex. - /// If this parameter is not null, then it must be of size 4 for the four - /// corners of the shape. If this parameter is null, then the polygon will - /// use the [rounding] parameter for every vertex instead. The default value - /// is null. - /// - /// [center] is the center of the rectangle, around which all vertices will - /// be placed equidistantly. The default center is at (0,0). + /// [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, @@ -341,45 +269,21 @@ class RoundedPolygon { ); } - /// Creates a star polygon, which is like a regular polygon except every - /// other vertex is on either an inner or outer radius. The two radii - /// specified in the constructor must both both nonzero. If the radii are - /// equal, the result will be a regular (not star) polygon with twice the - /// number of vertices specified in [numVerticesPerRadius]. - /// - /// [numVerticesPerRadius] is the number of vertices along each of the two - /// radii. - /// - /// [radius] is the outer radius for this star shape, must be greater than 0. - /// Default value is 1. - /// - /// [innerRadius] is the inner radius for this star shape, must be greater - /// than 0 and less than or equal to [radius]. Note that equal radii would - /// be the same as creating a [RoundedPolygon] directly, but with - /// 2 * [numVerticesPerRadius] vertices. Default value is 0.5. - /// - /// [rounding] is the [CornerRounding] properties of every vertex. If some - /// vertices should have different rounding properties, then use - /// [perVertexRounding] instead. The default rounding value is - /// [CornerRounding.unrounded], meaning that the polygon will use the - /// vertices themselves in the final shape and not curves rounded around the - /// vertices. + /// Creates a star about [center], with [numVerticesPerRadius] vertices on + /// the outer [radius] alternating with as many on the [innerRadius]. /// - /// [innerRounding] is the optional rounding parameters for the vertices on - /// the [innerRadius]. If null (the default value), inner vertices will use - /// the [rounding] or [perVertexRounding] parameters instead. + /// Both radii must be greater than 0, and [innerRadius] must be less than + /// [radius]. /// - /// [perVertexRounding] is the the [CornerRounding] properties of every - /// vertex. If this parameter is not null, then it must have the same size as - /// 2 * [numVerticesPerRadius]. If this parameter is null, then the polygon - /// will use the [rounding] parameter for every vertex instead. The default - /// value is null. + /// [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. /// - /// [center] is the center of the polygon, around which all vertices will be - /// placed. The default center is at (0,0). - /// - /// Throws [ArgumentError] if either [radius] or [innerRadius] are <= 0 or - /// [innerRadius] > [radius]. + /// 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, @@ -416,22 +320,15 @@ class RoundedPolygon { ); } - /// A pill shape consists of a rectangle shape bounded by two semicircles at - /// either of the long ends of the rectangle. - /// - /// [width] is the width of the resulting shape. - /// - /// [height is the height of the resulting shape. + /// Creates a pill about [center], [width] wide and [height] high: a + /// rectangle capped by a semicircle at either end of its longer dimension. /// - /// [smoothing] the amount by which the arc is "smoothed" by extending the - /// curve from the circular arc on each endcap to the edge between the - /// endcaps. A value of 0 (no smoothing) indicates that the corner is rounded - /// by only a circular arc. + /// [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. /// - /// [center] is the center of the polygon, around which all vertices will be - /// placed. The default center is at (0,0). - /// - /// Throws [ArgumentError] if either [width] or [height] are <= 0. + /// Throws [ArgumentError] if either [width] or [height] is not greater + /// than 0. factory RoundedPolygon.pill({ double width = 2, double height = 1, @@ -457,79 +354,36 @@ class RoundedPolygon { ); } - /// A pillStar shape is like a [RoundedPolygon.pill] except it has inner and - /// outer radii along its pill-shaped outline, just like a - /// [RoundedPolygon.star] has inner and outer radii along its circular - /// outline. The parameters for a [RoundedPolygon.pillStar] are similar to - /// those of a [RoundedPolygon.star] except, like [RoundedPolygon.pill], it - /// has a [width] and [height] to determine the general shape of the - /// underlying pill. Also, there is a subtle complication with the way that - /// inner and outer vertices proceed along the circular ends of the - /// shape, depending on the magnitudes of the [rounding], [innerRounding], - /// and [innerRadiusRatio] parameters. For example, a shape with outer - /// vertices that lie along the curved end outline will necessarily have - /// inner vertices that are closer to each other, because of the curvature of - /// that part of the shape. Conversely, if the inner vertices are lined up - /// along the pill outline at the ends, then the outer vertices will be much - /// further apart from each other. - /// - /// The default approach, reflected by the default value of [vertexSpacing], - /// is to use the average of the outer and inner radii, such that each set of - /// vertices falls equally to the other side of the pill outline on the - /// curved ends. Depending on the values used for the various rounding - /// and radius parameters, you may want to change that value to suit the - /// look you want. A value of 0 for [vertexSpacing] is equivalent to aligning - /// the inner vertices along the circular curve, and a value of 1 is - /// equivalent to aligning the outer vertices along that curve. - /// - /// [width] is the width of the resulting shape. - /// - /// [height] is the height of the resulting shape. - /// - /// [numVerticesPerRadius] is the number of vertices along each of the two - /// radii. - /// - /// [innerRadiusRatio] is the Inner radius ratio for this star shape, must be - /// greater than 0 and less than or equal to 1. Note that a value of 1 would - /// be similar to creating a [RoundedPolygon.pill], but with more vertices. - /// The default value is 0.5. - /// - /// [rounding] is the [CornerRounding] properties of every vertex. If some - /// vertices should have different rounding properties, then use - /// [perVertexRounding] instead. The default rounding value is - /// [CornerRounding.unrounded], meaning that the polygon will use the - /// vertices themselves in the final shape and not curves rounded around the - /// vertices. - /// - /// [innerRounding] is the optional rounding parameters for the vertices on - /// the [innerRadiusRatio]. If null (the default value), inner vertices will - /// use the [rounding] or [perVertexRounding] parameters instead. - /// [perVertexRounding] is the [CornerRounding] properties of every vertex. - /// If this parameter is not null, then it must have the same size as - /// 2 * [numVerticesPerRadius]. If this parameter is null, then the polygon - /// will use the [rounding] parameter for every vertex instead. The default - /// value is null. - /// - /// [vertexSpacing] is the factor, which determines how the vertices on the - /// circular ends are laid out along the outline. A value of 0 aligns spaces - /// the inner vertices the same as those along the straight edges, with the - /// outer vertices then being spaced further apart. A value of 1 does the - /// opposite, with the outer vertices spaced the same as the vertices on the - /// straight edges. The default value is .5, which takes the average of these - /// two extremes. - /// - /// [startLocation] is a value from 0 to 1 which determines how far along - /// the perimeter of this shape to start the underlying curves of which it is - /// comprised. This is not usually needed or noticed by the user. But if the - /// caller wants to manually and gradually stroke the path when drawing it, - /// it might matter where that path outline begins and ends. The default - /// value is 0. - /// - /// [center] is the center of the polygon, around which all vertices will be - /// placed. The default center is at (0,0). - /// - /// Throws [ArgumentError] if either [width] or [height] are <= 0 or - /// if [innerRadiusRatio] is outside the range of (0, 1]. + /// 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, @@ -679,13 +533,8 @@ class RoundedPolygon { return cubics; } - /// Transforms (scales/translates/etc.) this [RoundedPolygon] with the given - /// [PointTransformer] and returns a new [RoundedPolygon]. This is a low - /// level API and there should be more platform idiomatic ways to transform - /// a [RoundedPolygon] provided by the platform specific wrapper. - /// - /// [transformer] is the [PointTransformer] used to transform this - /// [RoundedPolygon]. + /// 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), @@ -693,8 +542,8 @@ class RoundedPolygon { } /// A new [RoundedPolygon], moving and resizing this one, so it's completely - /// inside the (0, 0) -> (1, 1) square, centered if there extra space in one - /// direction. + /// 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); @@ -757,7 +606,7 @@ class RoundedPolygon { return bounds; } - /// Returns a [Path] representation for a [RoundedPolygon] shape. + /// 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 @@ -766,13 +615,13 @@ class RoundedPolygon { /// The default of zero is special: it skips the rotation entirely and leaves /// the polygon as it was built. /// - /// [repeatPath] is whether or not to repeat the [Path] twice before closing - /// it. This flag 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 a motion as a Star circular - /// progress indicator advances). + /// 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. /// - /// [closePath] is whether or not to close the created [Path]. + /// If [closePath] is false, the returned [Path] is left open. Path toPath({double startAngle = 0, bool repeatPath = false, bool closePath = true}) { return pathFromCubics( cubics, @@ -822,36 +671,26 @@ Point calculateCenter(List vertices) { return Point(cumulativeX / vertices.length, cumulativeY / vertices.length); } -/// Private utility class that holds the information about each corner in a -/// polygon. The shape of the corner can be returned by calling the [getCubics] -/// function, which will return a list of curves representing the corner -/// geometry. The shape of the corner depends on the [rounding] constructor -/// parameter. +/// The geometry of a single corner of a polygon, rounded according to +/// [rounding]. /// -/// If rounding is null, there is no rounding; the corner will simply be a -/// single point at [p1]. This point will be represented by a [CubicBezier] of length -/// 0 at that point. +/// [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 not null, the corner will be rounded either with a curve -/// approximating a circular arc of the radius specified in [rounding], or with -/// three curves if [rounding] has a nonzero smoothing parameter. These three -/// curves are a circular arc in the middle and two symmetrical flanking curves -/// on either side. The smoothing parameter determines the curvature of the -/// flanking curves. +/// 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. /// -/// This is a class because we usually need to do the work in 2 steps, and -/// prefer to keep state between: 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) +/// 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. /// -/// [p0] is the vertex before the one being rounded. -/// -/// [p1] is the vertex of this rounded corner. -/// -/// [p2] the vertex after the one being rounded. -/// -/// [rounding] the optional parameters specifying how this corner should be -/// rounded. +/// 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; @@ -916,9 +755,10 @@ class _RoundedCorner { // doubles it. double get expectedCut => (1 + smoothing) * expectedRoundCut; - /// The center of the circle approximated by the rounding curve (or the - /// middle of the three curves if smoothing is requested). - /// The center is the same as [p0] if there is no rounding. + /// 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) { @@ -991,34 +831,19 @@ class _RoundedCorner { } } - /// Compute a Bezier to connect the linear segment defined by [corner] and - /// [sideStart] with the circular segment defined by [circleCenter], - /// [circleSegmentIntersection], [otherCircleSegmentIntersection] and - /// [actualR]. The bezier will start at the linear segment and end on the + /// 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 we are cutting of the corner to add the - /// circular segment (this is before smoothing, that will cut some more). - /// - /// [actualSmoothingValues] is how much we want to smooth (this is the smooth - /// parameter, adjusted down if there is not enough room). - /// - /// [corner] is the point at which the linear side ends. - /// - /// [sideStart] is the point at which the linear side starts. - /// - /// [circleSegmentIntersection] is the point at which the linear side and the - /// circle intersect. - /// - /// [otherCircleSegmentIntersection] is the point at which the opposing - /// linear side and the circle intersect. - /// - /// [circleCenter] is the center of the circle. - /// - /// [actualR] is the radius of the circle. + /// [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. /// - /// Returns a Bezier cubic curve that connects from the (cut) linear side - /// and the (cut) circular segment in a smooth way. + /// [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, @@ -1060,8 +885,8 @@ class _RoundedCorner { return CubicBezier(curveStart, anchorStart, anchorEnd, curveEnd); } - /// Returns the intersection point of the two lines d0->d1 and p0->p1, or - /// null if the lines do not intersect. + /// 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); diff --git a/packages/material_ui/lib/src/shapes/utils.dart b/packages/material_ui/lib/src/shapes/utils.dart index 8a816020610d..b24cdaebcad5 100644 --- a/packages/material_ui/lib/src/shapes/utils.dart +++ b/packages/material_ui/lib/src/shapes/utils.dart @@ -95,13 +95,10 @@ 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. +/// Does a ternary search in [v0]..[v1] to find the parameter that minimizes +/// the given function. /// -// NTS: Does it make sense to split the function f in 2, one to generate a -// candidate, of a custom type T (i.e. (Float) -> T), and one to evaluate it -// ( (T) -> Float )? +/// 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; From 95fdb704afef94735a2edc5702e743b9750111c9 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 6 Sep 2026 17:22:17 +0200 Subject: [PATCH 52/59] Add missing const. --- .../example/lib/material_shapes/material_shapes.0.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index fceb2648895a..ba5fd75655c1 100644 --- a/packages/material_ui/example/lib/material_shapes/material_shapes.0.dart +++ b/packages/material_ui/example/lib/material_shapes/material_shapes.0.dart @@ -22,7 +22,7 @@ class MaterialShapesExampleApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( theme: ThemeData(colorSchemeSeed: const Color(0xFF6750A4)), - home: MaterialShapesExample(), + home: const MaterialShapesExample(), ); } } From 2e3f48246eabf72e73530a0c34153a704d6458da Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 6 Sep 2026 17:43:43 +0200 Subject: [PATCH 53/59] Update pending changelog. --- .../change_2026_08_23_material_shapes.yaml | 6 ------ .../change_2026_09_06_material_shapes.yaml | 7 +++++++ 2 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 packages/material_ui/pending_changelogs/change_2026_08_23_material_shapes.yaml create mode 100644 packages/material_ui/pending_changelogs/change_2026_09_06_material_shapes.yaml diff --git a/packages/material_ui/pending_changelogs/change_2026_08_23_material_shapes.yaml b/packages/material_ui/pending_changelogs/change_2026_08_23_material_shapes.yaml deleted file mode 100644 index 5031468738eb..000000000000 --- a/packages/material_ui/pending_changelogs/change_2026_08_23_material_shapes.yaml +++ /dev/null @@ -1,6 +0,0 @@ -changelog: | - - Adds `MaterialShapes`, the catalog of Material Design shapes, built on the new - `RoundedPolygon`, `Morph` and `CornerRounding` geometry APIs. - - Adds `MaterialShapeBorder` for drawing a `RoundedPolygon` as an `OutlinedBorder`. - - Adds a `MaterialShapes` example that morphs through the shapes in `MaterialShapes.all`. -version: minor 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 From be59266a1d8c900bcaa6120c08d7abcb223abb36 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 6 Sep 2026 18:47:58 +0200 Subject: [PATCH 54/59] Fix typo in innerRadiusRatio error message. --- packages/material_ui/lib/src/shapes/rounded_polygon.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 63248fa2ad6d..46213e7e7579 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -400,7 +400,7 @@ class RoundedPolygon { throw ArgumentError('Pill shapes must have positive width and height.'); } if (innerRadiusRatio <= 0 || innerRadiusRatio > 1) { - throw ArgumentError('innerRadius must in (0, 1] range.'); + throw ArgumentError('innerRadiusRatio must be in (0, 1] range.'); } if (vertexSpacing < 0 || vertexSpacing > 1) { throw ArgumentError('vertexSpacing must be in [0, 1] range.'); From 1d11158a55d17b056634886dc73c195271e5e24c Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 6 Sep 2026 18:50:16 +0200 Subject: [PATCH 55/59] Remove List.generate calls in CubicBezier + and * operators. --- .../material_ui/lib/src/shapes/cubic.dart | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/cubic.dart b/packages/material_ui/lib/src/shapes/cubic.dart index 4b83a908e687..ad1235558e51 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -361,11 +361,28 @@ class CubicBezier { /// Returns a curve whose coordinates are the sums of this curve's and [o]'s /// corresponding coordinates. - CubicBezier operator +(CubicBezier o) => - CubicBezier.raw(List.generate(8, (i) => _points[i] + o._points[i])); + 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(List.generate(8, (i) => _points[i] * 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); From dc776d05abdd73042b8ffa037a06af7f0acfc9e1 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 6 Sep 2026 18:52:48 +0200 Subject: [PATCH 56/59] Remove List.generate call in Morph.toCubics function. --- packages/material_ui/lib/src/shapes/morph.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/morph.dart b/packages/material_ui/lib/src/shapes/morph.dart index bfe56676f639..fa9231666cde 100644 --- a/packages/material_ui/lib/src/shapes/morph.dart +++ b/packages/material_ui/lib/src/shapes/morph.dart @@ -183,11 +183,17 @@ class Morph { CubicBezier? lastCubic; for (var i = 0; i < _morphMatch.length; i++) { - final cubic = CubicBezier.raw( - List.generate(8, (j) { - return lerp(_morphMatch[i].$1.points[j], _morphMatch[i].$2.points[j], progress); - }), - ); + 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) { From 3ca9ee68f8812f59ea5fe0c18ad8dff19ed674d3 Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 6 Sep 2026 19:25:52 +0200 Subject: [PATCH 57/59] Guard LengthMeasurer._closestProgressTo against dividing by zero. --- packages/material_ui/lib/src/shapes/polygon_measure.dart | 4 ++++ .../material_ui/test/shapes/polygon_measure_test.dart | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/material_ui/lib/src/shapes/polygon_measure.dart b/packages/material_ui/lib/src/shapes/polygon_measure.dart index d942fa8e5d87..f728e92aaa33 100644 --- a/packages/material_ui/lib/src/shapes/polygon_measure.dart +++ b/packages/material_ui/lib/src/shapes/polygon_measure.dart @@ -378,6 +378,10 @@ class LengthMeasurer implements Measurer { } (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); diff --git a/packages/material_ui/test/shapes/polygon_measure_test.dart b/packages/material_ui/test/shapes/polygon_measure_test.dart index 2485c8cd7334..1e5ed023105c 100644 --- a/packages/material_ui/test/shapes/polygon_measure_test.dart +++ b/packages/material_ui/test/shapes/polygon_measure_test.dart @@ -194,5 +194,13 @@ void main() { 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); + }); }); } From 01b88fc10e482a8390b232c514158db3249d129a Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Sun, 6 Sep 2026 19:26:42 +0200 Subject: [PATCH 58/59] Guard RoundedPolygon.normalized against dividing by zero for a point-sized polygon. --- .../material_ui/lib/src/shapes/rounded_polygon.dart | 4 ++++ .../material_ui/test/shapes/rounded_polygon_test.dart | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/material_ui/lib/src/shapes/rounded_polygon.dart b/packages/material_ui/lib/src/shapes/rounded_polygon.dart index 46213e7e7579..53141e7b5122 100644 --- a/packages/material_ui/lib/src/shapes/rounded_polygon.dart +++ b/packages/material_ui/lib/src/shapes/rounded_polygon.dart @@ -548,6 +548,10 @@ class RoundedPolygon { 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; diff --git a/packages/material_ui/test/shapes/rounded_polygon_test.dart b/packages/material_ui/test/shapes/rounded_polygon_test.dart index 8b0dfba40c1d..f890170d0ef4 100644 --- a/packages/material_ui/test/shapes/rounded_polygon_test.dart +++ b/packages/material_ui/test/shapes/rounded_polygon_test.dart @@ -164,6 +164,17 @@ void main() { 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); From df808f74f1391c5b9b127eb51b817fc5a46762eb Mon Sep 17 00:00:00 2001 From: Kostia Sokolovskyi Date: Mon, 7 Sep 2026 08:10:00 +0200 Subject: [PATCH 59/59] Format doubles in CubicBezier.toString and CornerRounding.toString with toStringAsFixed. --- packages/material_ui/lib/src/shapes/corner_rounding.dart | 2 +- packages/material_ui/lib/src/shapes/cubic.dart | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/material_ui/lib/src/shapes/corner_rounding.dart b/packages/material_ui/lib/src/shapes/corner_rounding.dart index a74c70827874..8ee046b00ad9 100644 --- a/packages/material_ui/lib/src/shapes/corner_rounding.dart +++ b/packages/material_ui/lib/src/shapes/corner_rounding.dart @@ -80,6 +80,6 @@ class CornerRounding { @override String toString() { return '${objectRuntimeType(this, 'CornerRounding')}' - '(radius: $radius, smoothing: $smoothing)'; + '(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 index ad1235558e51..7e8a4185b4d9 100644 --- a/packages/material_ui/lib/src/shapes/cubic.dart +++ b/packages/material_ui/lib/src/shapes/cubic.dart @@ -401,10 +401,10 @@ class CubicBezier { @override String toString() { return '${objectRuntimeType(this, 'CubicBezier')}' - '(anchor0: ($anchor0X, $anchor0Y), ' - 'control0: ($control0X, $control0Y), ' - 'control1: ($control1X, $control1Y), ' - 'anchor1: ($anchor1X, $anchor1Y))'; + '(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