From 5d93b2e71b3380bd0d2e4f0daf14379fcbd64a4d Mon Sep 17 00:00:00 2001 From: Tong Mu Date: Tue, 20 Jan 2026 10:36:24 -0800 Subject: [PATCH] Improve the algorithm for rounded superellipse paths to work better at very large ratio (#180453) This PR uses a new algorithm that approximates the superellipse curve with two conic curves, instead of one cubic curves. This algorithm works much better at very large ratio. Fixes https://github.com/flutter/flutter/issues/179875 . A new playground is added in this PR to show enlarged corner of a rounded superellipse at very large ratio and compares its path version and filled version.
For comparison: The best result possible with one cubic curve As the following video shows, the flat segment of the superellipse curve is the hardest to approximate, since most of its curvature happens near the right end. The best that a single cubic curve can do is close to a straight line. https://github.com/user-attachments/assets/2ed1666d-f96c-4465-9d5c-1f8f4a660bba Although the largest deviation is only ~1e-6, the "feeling" from the lack of curvature is sensible as shown in the following screenshot. image
**Result after this PR:** https://github.com/user-attachments/assets/d0012e62-f61f-4d69-95a5-27646c0bb750 The following screenshot shows the reproduction app as commented in https://github.com/flutter/flutter/issues/179875#issuecomment-3672426282 after the PR: image For comparison, the following screenshot shows the same shape drawn as filled: image **Before the PR:** image Also, a new golden test: image ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../geometry/dl_path_builder_unittests.cc | 158 +++++++++++----- .../impeller/entity/entity_unittests.cc | 98 ++++++++++ .../geometry/round_superellipse_param.cc | 174 +++++++++++++----- .../geometry/round_superellipse_unittests.cc | 33 ++++ .../lib/web_ui/lib/rsuperellipse_param.dart | 124 ++++++++----- .../test/ui/rsuperellipse_contains_test.dart | 4 +- .../src/flutter/testing/dart/path_test.dart | 2 +- .../rounded_superellipse_border_test.dart | 159 ++++++++++------ 8 files changed, 556 insertions(+), 196 deletions(-) diff --git a/engine/src/flutter/display_list/geometry/dl_path_builder_unittests.cc b/engine/src/flutter/display_list/geometry/dl_path_builder_unittests.cc index 195504133c4..7d884e50a27 100644 --- a/engine/src/flutter/display_list/geometry/dl_path_builder_unittests.cc +++ b/engine/src/flutter/display_list/geometry/dl_path_builder_unittests.cc @@ -10,6 +10,11 @@ namespace { using ::testing::Return; + +MATCHER_P(ScalarEq, a, "") { + *result_listener << "isn't equal to " << a; + return abs(arg - a) <= impeller::kEhCloseEnough; +} } // namespace namespace flutter { @@ -316,55 +321,112 @@ TEST(DisplayListPathBuilder, AddRoundSuperellipse) { ::testing::InSequence sequence; EXPECT_CALL(mock_receiver, MoveTo(DlPoint(46, 10), true)); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(97.3149f, 9.99998f)), - PointEq(DlPoint(95.8867f, 9.99486f)), - PointEq(DlPoint(97.1856f, 10.2338f)))); + + // 1 + EXPECT_CALL(mock_receiver, ConicTo(PointEq(DlPoint(94.8672, 9.99998)), + PointEq(DlPoint(95.7708, 10.0493)), + ScalarEq(3.63127851))) + .WillOnce(Return(true)); + EXPECT_CALL(mock_receiver, ConicTo(PointEq(DlPoint(96.6558, 10.0976)), + PointEq(DlPoint(97.1856, 10.2338)), + ScalarEq(1.22087204))) + .WillOnce(Return(true)); EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(97.9096f, 10.367f)), - PointEq(DlPoint(98.5976f, 11.6373f)), - PointEq(DlPoint(99.1213f, 13.8076f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(99.5784f, 15.7987f)), - PointEq(DlPoint(99.8618f, 18.4156f)), - PointEq(DlPoint(99.9232f, 21.2114f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(100.011f, 25.1954f)), - PointEq(DlPoint(100.0f, 29.7897f)), - PointEq(DlPoint(100.0f, 51.7857f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(100.0f, 77.1657f)), - PointEq(DlPoint(100.018f, 82.4668f)), - PointEq(DlPoint(99.872f, 87.0638f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(99.7697f, 90.2897f)), - PointEq(DlPoint(99.2973f, 93.3092f)), - PointEq(DlPoint(98.5355f, 95.6066f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(97.6834f, 98.0734f)), - PointEq(DlPoint(96.5625f, 99.532f)), - PointEq(DlPoint(95.3799f, 99.7131f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(93.4105f, 100.015f)), - PointEq(DlPoint(93.4217f, 100.0f)), - PointEq(DlPoint(50.0f, 100.0f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(15.2626f, 100.0f)), - PointEq(DlPoint(15.2716f, 100.014f)), - PointEq(DlPoint(13.6961f, 99.7323f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(12.75f, 99.5632f)), - PointEq(DlPoint(11.8533f, 98.2018f)), - PointEq(DlPoint(11.1716f, 95.8995f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(10.5569f, 93.7323f)), - PointEq(DlPoint(10.1777f, 90.8822f)), - PointEq(DlPoint(10.0993f, 87.8409f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(9.98623f, 83.4541f)), - PointEq(DlPoint(10.0f, 78.5304f)), - PointEq(DlPoint(10.0f, 51.5385f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(10.0f, 28.4025f)), - PointEq(DlPoint(9.99311f, 24.1822f)), - PointEq(DlPoint(10.0496f, 20.4221f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(10.0888f, 17.8153f)), - PointEq(DlPoint(10.2785f, 15.3723f)), - PointEq(DlPoint(10.5858f, 13.5147f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(10.9349f, 11.5114f)), - PointEq(DlPoint(11.3936f, 10.3388f)), - PointEq(DlPoint(11.8762f, 10.2158f)))); - EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(12.7422f, 9.99527f)), - PointEq(DlPoint(11.79f, 10.0f)), - PointEq(DlPoint(46.0f, 10.0f)))); - EXPECT_CALL(mock_receiver, LineTo(DlPoint(46, 10))); + PointEq(DlPoint(98.5976, 11.6373)), + PointEq(DlPoint(99.1213, 13.8076)))); + + // 2 + EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(99.5784, 15.7987)), + PointEq(DlPoint(99.8618, 18.4156)), + PointEq(DlPoint(99.9232, 21.2114)))); + EXPECT_CALL(mock_receiver, ConicTo(PointEq(DlPoint(99.9636, 23.0102)), + PointEq(DlPoint(99.9805, 25.6229)), + ScalarEq(1.17059147))) + .WillOnce(Return(true)); + EXPECT_CALL(mock_receiver, + ConicTo(PointEq(DlPoint(100, 28.6213)), + PointEq(DlPoint(100, 51.7857)), ScalarEq(2.12785244))) + .WillOnce(Return(true)); + + // 3 + EXPECT_CALL(mock_receiver, ConicTo(PointEq(DlPoint(100, 78.514)), + PointEq(DlPoint(99.9675, 81.9736)), + ScalarEq(2.12785244))) + .WillOnce(Return(true)); + EXPECT_CALL(mock_receiver, ConicTo(PointEq(DlPoint(99.9393, 84.9882)), + PointEq(DlPoint(99.872, 87.0638)), + ScalarEq(1.17059147))) + .WillOnce(Return(true)); + EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(99.7697, 90.2897)), + PointEq(DlPoint(99.2973, 93.3092)), + PointEq(DlPoint(98.5355, 95.6066)))); + + // 4 + EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(97.6834, 98.0734)), + PointEq(DlPoint(96.5625, 99.532)), + PointEq(DlPoint(95.3799, 99.7131)))); + EXPECT_CALL(mock_receiver, ConicTo(PointEq(DlPoint(94.5196, 99.8799)), + PointEq(DlPoint(93.1037, 99.9395)), + ScalarEq(1.16898668))) + .WillOnce(Return(true)); + EXPECT_CALL(mock_receiver, + ConicTo(PointEq(DlPoint(91.6668, 100)), + PointEq(DlPoint(50, 100)), ScalarEq(3.63127851))) + .WillOnce(Return(true)); + + // 5 + EXPECT_CALL(mock_receiver, ConicTo(PointEq(DlPoint(16.6667, 100)), + PointEq(DlPoint(15.5171, 99.9435)), + ScalarEq(3.63127851))) + .WillOnce(Return(true)); + EXPECT_CALL(mock_receiver, ConicTo(PointEq(DlPoint(14.3843, 99.8879)), + PointEq(DlPoint(13.6961, 99.7323)), + ScalarEq(1.16898668))) + .WillOnce(Return(true)); + EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(12.75, 99.5632)), + PointEq(DlPoint(11.8533, 98.2018)), + PointEq(DlPoint(11.1716, 95.8995)))); + + // 6 + EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(10.5569, 93.7323)), + PointEq(DlPoint(10.1777, 90.8822)), + PointEq(DlPoint(10.0993, 87.8409)))); + EXPECT_CALL(mock_receiver, ConicTo(PointEq(DlPoint(10.0462, 85.8435)), + PointEq(DlPoint(10.0244, 82.8947)), + ScalarEq(1.2416352))) + .WillOnce(Return(true)); + EXPECT_CALL(mock_receiver, + ConicTo(PointEq(DlPoint(10, 79.594)), + PointEq(DlPoint(10, 51.5385)), ScalarEq(1.77972043))) + .WillOnce(Return(true)); + + // 7 + EXPECT_CALL(mock_receiver, ConicTo(PointEq(DlPoint(10, 27.4909)), + PointEq(DlPoint(10.0122, 24.6616)), + ScalarEq(1.77972043))) + .WillOnce(Return(true)); + EXPECT_CALL(mock_receiver, ConicTo(PointEq(DlPoint(10.0231, 22.1342)), + PointEq(DlPoint(10.0496, 20.4221)), + ScalarEq(1.2416352))) + .WillOnce(Return(true)); + EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(10.0888, 17.8153)), + PointEq(DlPoint(10.2785, 15.3723)), + PointEq(DlPoint(10.5858, 13.5147)))); + + // 8 + EXPECT_CALL(mock_receiver, CubicTo(PointEq(DlPoint(10.9349, 11.5114)), + PointEq(DlPoint(11.3936, 10.3388)), + PointEq(DlPoint(11.8762, 10.2158)))); + EXPECT_CALL(mock_receiver, ConicTo(PointEq(DlPoint(12.2295, 10.0901)), + PointEq(DlPoint(12.8195, 10.0455)), + ScalarEq(1.22087204))) + .WillOnce(Return(true)); + EXPECT_CALL(mock_receiver, + ConicTo(PointEq(DlPoint(13.4216, 10)), PointEq(DlPoint(46, 10)), + ScalarEq(3.63127851))) + .WillOnce(Return(true)); + + EXPECT_CALL(mock_receiver, LineTo(PointEq(DlPoint(46, 10)))); EXPECT_CALL(mock_receiver, Close()); } diff --git a/engine/src/flutter/impeller/entity/entity_unittests.cc b/engine/src/flutter/impeller/entity/entity_unittests.cc index 8109aac8d27..86c39c5db8d 100644 --- a/engine/src/flutter/impeller/entity/entity_unittests.cc +++ b/engine/src/flutter/impeller/entity/entity_unittests.cc @@ -2622,6 +2622,104 @@ TEST_P(EntityTest, DrawRoundSuperEllipse) { ASSERT_TRUE(OpenPlaygroundHere(callback)); } +TEST_P(EntityTest, DrawRoundSuperEllipseWithLargeN) { + // This playground shows the enlarged corner of a rounded superellipse to + // compare pathing algorithm against the filling algorithm (benchmark) at very + // large ratio. + auto callback = [&](ContentContext& context, RenderPass& pass) -> bool { + constexpr float corner_radius = 100.0; + + // UI state. + static float logarithm_of_ratio = 1.5; // ratio = size / corner_radius + + float ratio = std::exp(logarithm_of_ratio); + + float rect_size = corner_radius * ratio; + Rect rect = Rect::MakeLTRB(0, 0, rect_size, rect_size); + constexpr float screen_canvas_padding = 200.0f; + constexpr float screen_canvas_size = 1000.0f; + + // Scale so that "corner radius" is as long as half the canvas. + float scale = screen_canvas_size / 2 / corner_radius; + + ImGui::Begin("Controls", nullptr, ImGuiWindowFlags_AlwaysAutoResize); + { + ImGui::SliderFloat("log(Ratio)", &logarithm_of_ratio, 1.0, 8.0); + ImGui::LabelText("Ratio", "%.2g", static_cast(ratio)); + ImGui::Text(" where Ratio = RectSize / CornerRadius"); + } + ImGui::End(); + + auto top_right = Vector2(screen_canvas_size * 1.3f, screen_canvas_padding); + auto transform = Matrix::MakeTranslation(top_right) * + Matrix::MakeScale(Vector2(scale, scale)) * + Matrix::MakeTranslation(Vector2(-rect_size, 0)); + bool success = true; + + auto fill_geom = + std::make_unique(rect, corner_radius); + // Fill + { + auto contents = std::make_shared(); + contents->SetColor(Color::Red()); + contents->SetGeometry(fill_geom.get()); + + Entity entity; + entity.SetContents(contents); + entity.SetTransform(transform); + + success = success && entity.Render(context, pass); + } + + // Stroke + auto path = flutter::DlPath::MakeRoundSuperellipse( + RoundSuperellipse::MakeRectRadius(rect, corner_radius)); + auto stroke_geom = Geometry::MakeStrokePath(path, {.width = 2 / scale}); + { + auto contents = std::make_shared(); + contents->SetColor(Color::Blue()); + contents->SetGeometry(stroke_geom.get()); + + Entity entity; + entity.SetContents(contents); + entity.SetTransform(transform); + + success = success && entity.Render(context, pass); + } + + // Draw a ruler to show the length in portion of rect size. + auto screen_top_right = + Matrix::MakeScale(GetContentScale()).Invert() * top_right; + constexpr float font_size = 13.0f; + for (int i = -1; i < 100; i++) { + float screen_offset_y = static_cast(i) * 20.0f; + std::string label; + if (i == -1) { + label = "Ruler: (in portion of rect size)"; + } else if (i == 0) { + label = "- 0.0"; + } else { + float portion_of_rect = + screen_offset_y * GetContentScale().y / scale / rect_size; + label = std::format("- {:.2g}", portion_of_rect); + } + ImGui::GetBackgroundDrawList()->AddText( + nullptr, font_size, + // Draw the ruler at around the flat part of the curve, which is + // somewhere to the left of the top right corner. + // + // Offset vertically by font_size/2 so that the hyphen aligns with the + // top of shape. + {screen_top_right.x - 500, + screen_top_right.y + screen_offset_y - font_size / 2}, + IM_COL32_WHITE, label.c_str()); + } + return success; + }; + + ASSERT_TRUE(OpenPlaygroundHere(callback)); +} + TEST_P(EntityTest, CanDrawRoundSuperEllipseWithTinyRadius) { // Regression test for https://github.com/flutter/flutter/issues/176894 // Verify that a radius marginally below the minimum threshold can be diff --git a/engine/src/flutter/impeller/geometry/round_superellipse_param.cc b/engine/src/flutter/impeller/geometry/round_superellipse_param.cc index 56b3d14dbf7..a22c92812a8 100644 --- a/engine/src/flutter/impeller/geometry/round_superellipse_param.cc +++ b/engine/src/flutter/impeller/geometry/round_superellipse_param.cc @@ -306,6 +306,19 @@ bool CornerContains(const RoundSuperellipseParam::Quadrant& param, } class RoundSuperellipseBuilder { + private: + // The parameters that describes a conic curve. + struct ConicParam { + // The end point closer to point A. + Point p1; + // The control point. + Point c; + // The end point closer to point J. + Point p2; + // The conic weight. + Scalar weight; + }; + public: explicit RoundSuperellipseBuilder(PathReceiver& receiver) : receiver_(receiver) {} @@ -346,21 +359,37 @@ class RoundSuperellipseBuilder { } private: - std::array SuperellipseArcPoints( + std::tuple SuperellipseArcPoints( const RoundSuperellipseParam::Octant& param) { - Point start = {0, param.se_a}; - const Point& end = param.circle_start; - constexpr Point start_tangent = {1, 0}; - Point circle_start_vector = param.circle_start - param.circle_center; - Point end_tangent = - Point{-circle_start_vector.y, circle_start_vector.x}.Normalize(); + // The superellipse arc consists of two conic curves: A-H and H-J. + Point A = {0, param.se_a}; + const Point& J = param.circle_start; - std::array factors = SuperellipseBezierFactors(param.se_n); + std::tuple factors = SuperellipseBezierFactors( + param.se_n, J.x / param.se_a, J.y / param.se_a); + Scalar weight1 = std::get<0>(factors); + Scalar weight2 = std::get<1>(factors); + Scalar yHOverA = std::get<2>(factors); - return std::array{ - start, start + start_tangent * factors[0] * param.se_a, - end + end_tangent * factors[1] * param.se_a, end}; - }; + Point H = { + powf(1.f - powf(yHOverA, param.se_n), 1.f / param.se_n) * param.se_a, + yHOverA * param.se_a}; + + Scalar kA = 0; + Scalar kJ = -powf(J.x / J.y, param.se_n - 1.f); + Scalar kH = -powf(H.x / H.y, param.se_n - 1.f); + + // The control points are determined by the intersection of the tangents for + // smoothness. + return {ConicParam{.p1 = A, + .c = Intersection(A, kA, H, kH), + .p2 = H, + .weight = weight1}, + ConicParam{.p1 = H, + .c = Intersection(H, kH, J, kJ), + .p2 = J, + .weight = weight2}}; + } std::array CircularArcPoints( const RoundSuperellipseParam::Octant& param) { @@ -401,11 +430,15 @@ class RoundSuperellipseBuilder { } auto circle_points = CircularArcPoints(param); - auto se_points = SuperellipseArcPoints(param); + auto se_conics = SuperellipseArcPoints(param); if (!reverse) { - receiver_.CubicTo(transform * se_points[1], transform * se_points[2], - transform * se_points[3]); + receiver_.ConicTo(transform * std::get<0>(se_conics).c, + transform * std::get<0>(se_conics).p2, + std::get<0>(se_conics).weight); + receiver_.ConicTo(transform * std::get<1>(se_conics).c, + transform * std::get<1>(se_conics).p2, + std::get<1>(se_conics).weight); receiver_.CubicTo(transform * circle_points[1], transform * circle_points[2], transform * circle_points[3]); @@ -413,34 +446,51 @@ class RoundSuperellipseBuilder { receiver_.CubicTo(transform * circle_points[2], transform * circle_points[1], transform * circle_points[0]); - receiver_.CubicTo(transform * se_points[2], transform * se_points[1], - transform * se_points[0]); + receiver_.ConicTo(transform * std::get<1>(se_conics).c, + transform * std::get<1>(se_conics).p1, + std::get<1>(se_conics).weight); + receiver_.ConicTo(transform * std::get<0>(se_conics).c, + transform * std::get<0>(se_conics).p1, + std::get<0>(se_conics).weight); } }; // Get the Bezier factor for the superellipse arc in a rounded superellipse. // - // The result will be assigned to output, where [0] will be the factor for the - // starting tangent and [1] for the ending tangent. - // - // These values are computed by brute-force searching for the minimal distance - // on a rounded superellipse and are not for general purpose superellipses. - std::array SuperellipseBezierFactors(Scalar n) { - constexpr Scalar kPrecomputedVariables[][2] = { - /*n=2.0*/ {0.01339448, 0.05994973}, - /*n=3.0*/ {0.13664115, 0.13592082}, - /*n=4.0*/ {0.24545546, 0.14099516}, - /*n=5.0*/ {0.32353151, 0.12808021}, - /*n=6.0*/ {0.39093068, 0.11726264}, - /*n=7.0*/ {0.44847800, 0.10808278}, - /*n=8.0*/ {0.49817452, 0.10026175}, - /*n=9.0*/ {0.54105583, 0.09344429}, - /*n=10.0*/ {0.57812578, 0.08748984}, - /*n=11.0*/ {0.61050961, 0.08224722}, - /*n=12.0*/ {0.63903989, 0.07759639}, - /*n=13.0*/ {0.66416338, 0.07346530}, - /*n=14.0*/ {0.68675338, 0.06974996}, - /*n=15.0*/ {0.70678034, 0.06529512}}; + // The resulting tuple consists of: + // [0] weight1, weight for the conic A-H + // [1] weight2, weight for the conic H-J + // [2] yHOverA + std::tuple SuperellipseBezierFactors(Scalar n, + Scalar xJOverA, + Scalar yJOverA) { + // Precomputed (factor1, factor2) tuples for interpolation and + // extrapolation. + // + // These factors are normalized conic weights defined as: + // weight1 = factor1 * sqrt(n) + // weight2 = factor2 * xJOverA + // + // Rationale: Empirical analysis shows these quotients converge to stable + // constants as n -> infinity. Normalizing by sqrt(n) and xJOverA ensures + // the table remains well-behaved for linear extrapolation when 'n' exceeds + // the precomputed range. + // + // Optimal weights were originally derived via brute-force search for + // minimal curve distance on a rounded superellipse. + constexpr Scalar kPrecomputedVariables[][2] = {/*n= 2.0*/ {0.7078, 8.3194}, + /*n= 3.0*/ {0.7895, 2.4523}, + /*n= 4.0*/ {0.8379, 1.8528}, + /*n= 5.0*/ {0.8701, 1.6891}, + /*n= 6.0*/ {0.8932, 1.5806}, + /*n= 7.0*/ {0.9107, 1.5043}, + /*n= 8.0*/ {0.9244, 1.4470}, + /*n= 9.0*/ {0.9355, 1.4037}, + /*n=10.0*/ {0.9448, 1.3701}, + /*n=11.0*/ {0.9526, 1.3431}, + /*n=12.0*/ {0.9594, 1.3212}, + /*n=13.0*/ {0.9653, 1.3032}, + /*n=14.0*/ {0.9705, 1.2880}}; constexpr size_t kNumRecords = sizeof(kPrecomputedVariables) / sizeof(kPrecomputedVariables[0]); constexpr Scalar kStep = 1.00f; @@ -448,20 +498,54 @@ class RoundSuperellipseBuilder { constexpr Scalar kMaxN = kMinN + (kNumRecords - 1) * kStep; if (n >= kMaxN) { - // Heuristic formula derived from fitting. - return {1.07f - expf(1.307649835) * powf(n, -0.8568516731), - -0.01f + expf(-0.9287690322) * powf(n, -0.6120901398)}; + // The optimized factors stabilize as n grows large. + n = kMaxN; } + // Compute weight1 and weight 2 + Scalar steps = std::clamp((n - kMinN) / kStep, 0, kNumRecords - 1); size_t left = std::clamp(static_cast(std::floor(steps)), 0, kNumRecords - 2); Scalar frac = steps - left; - return std::array{(1 - frac) * kPrecomputedVariables[left][0] + - frac * kPrecomputedVariables[left + 1][0], - (1 - frac) * kPrecomputedVariables[left][1] + - frac * kPrecomputedVariables[left + 1][1]}; + Scalar weight1 = (1.f - frac) * kPrecomputedVariables[left][0] + + frac * kPrecomputedVariables[left + 1][0] * sqrt(n); + Scalar weight2 = (1.f - frac) * kPrecomputedVariables[left][1] + + frac * kPrecomputedVariables[left + 1][1] * xJOverA; + + // Compute yHOverA + + // H, the splitting point between the two conic curves, is picked by its y + // coordinate proportionally between A and J. + // + // The proportion between (yA-yH) and (yH-yJ) is positively correlated to n, + // so that when n increases, H moves closer to A. This is because the + // flatter segment of the superellipse curve is much harder to approximate. + // The exact formula of sqrt(n) is found empirically. + Scalar yH_proportion = sqrt(n); + constexpr Scalar yAOverA = 1.0; + Scalar yHOverA = + (yAOverA * yH_proportion + yJOverA) / (yH_proportion + 1.0f); + + return {weight1, weight2, yHOverA}; + } + + // Find the intersection point of two lines defined by two points and their + // slopes. + static Point Intersection(Point p1, Scalar k1, Point p2, Scalar k2) { + if (std::fabs(k1 - k2) < kEhCloseEnough) { + return (p1 + p2) / 2; + } + + // Line 1: y - y1 = k1(x - x1) + // Line 2: y - y2 = k2(x - x2) + // => x = (k1*x1 - k2*x2 + y2 - y1) / (k1 - k2) + + Scalar x = (k1 * p1.x - k2 * p2.x + p2.y - p1.y) / (k1 - k2); + Scalar y = k1 * (x - p1.x) + p1.y; + + return Point(x, y); } PathReceiver& receiver_; diff --git a/engine/src/flutter/impeller/geometry/round_superellipse_unittests.cc b/engine/src/flutter/impeller/geometry/round_superellipse_unittests.cc index 3752bc5c654..78c054a1a32 100644 --- a/engine/src/flutter/impeller/geometry/round_superellipse_unittests.cc +++ b/engine/src/flutter/impeller/geometry/round_superellipse_unittests.cc @@ -26,10 +26,14 @@ class SpyPathReceiver : public PathReceiver { using CubicSegment = std::function; + using ConicSegment = std::function; + void SpyLineTo(LineSegment line_to) { line_to_ = std::move(line_to); } void SpyCubicTo(CubicSegment cubic_to) { cubic_to_ = std::move(cubic_to); } + void SpyConicTo(ConicSegment conic_to) { conic_to_ = std::move(conic_to); } + // |PathReceiver| void MoveTo(const Point& p2, bool will_be_closed) override {} // |PathReceiver| @@ -46,12 +50,20 @@ class SpyPathReceiver : public PathReceiver { cubic_to_(cp1, cp2, p2); } } + bool ConicTo(const Point& cp, const Point& p2, Scalar weight) override { + if (conic_to_) { + conic_to_(cp, p2, weight); + return true; + } + return false; + } // |PathReceiver| void Close() override {} private: LineSegment line_to_; CubicSegment cubic_to_; + ConicSegment conic_to_; }; } // namespace @@ -779,5 +791,26 @@ TEST(RoundSuperellipseTest, rr.Dispatch(receiver); } +TEST(RoundSuperellipseTest, PathForLongRseShouldBeCorrect) { + Rect bounds = Rect::MakeLTRB(0, 0, 300, 100000); + // Regression test for https://github.com/flutter/flutter/issues/179875. + + auto rr = RoundSuperellipseParam::MakeBoundsRadius(bounds, 100); + SpyPathReceiver receiver; + receiver.SpyLineTo( + [&](const Point& p2) { EXPECT_TRUE(bounds.ContainsInclusive(p2)); }); + receiver.SpyCubicTo([&](const Point& cp1, const Point& cp2, const Point& p2) { + EXPECT_TRUE(bounds.ContainsInclusive(cp1)); + EXPECT_TRUE(bounds.ContainsInclusive(cp2)); + EXPECT_TRUE(bounds.ContainsInclusive(p2)); + }); + receiver.SpyConicTo([&](const Point& cp, const Point& p2, Scalar weight) { + EXPECT_TRUE(bounds.ContainsInclusive(cp)); + EXPECT_TRUE(bounds.ContainsInclusive(p2)); + }); + + rr.Dispatch(receiver); +} + } // namespace testing } // namespace impeller diff --git a/engine/src/flutter/lib/web_ui/lib/rsuperellipse_param.dart b/engine/src/flutter/lib/web_ui/lib/rsuperellipse_param.dart index a742e005c8d..c4aafb61f85 100644 --- a/engine/src/flutter/lib/web_ui/lib/rsuperellipse_param.dart +++ b/engine/src/flutter/lib/web_ui/lib/rsuperellipse_param.dart @@ -35,11 +35,35 @@ extension type _RSuperellipsePath(Path path) { path.cubicTo(p2.dx, p2.dy, p3.dx, p3.dy, p4.dx, p4.dy); } + void conicToPoints(Offset cp, Offset p2, double weight) { + path.conicTo(cp.dx, cp.dy, p2.dx, p2.dy, weight); + } + void lineToPoint(Offset p) { path.lineTo(p.dx, p.dy); } } +class _ConicParam { + const _ConicParam({required this.p1, required this.cp, required this.p2, required this.weight}); + + final Offset p1; + final Offset cp; + final Offset p2; + final double weight; +} + +Offset _intersection(Offset p1, double k1, Offset p2, double k2) { + if ((k1 - k2).abs() < 1e-5) { + return (p1 + p2) / 2; + } + + final double x = (k1 * p1.dx - k2 * p2.dx + p2.dy - p1.dy) / (k1 - k2); + final double y = k1 * (x - p1.dx) + p1.dy; + + return Offset(x, y); +} + // An octant of an RSuperellipse, used in _RSuperellipseQuadrant. class _RSuperellipseOctant { factory _RSuperellipseOctant(Offset center, double a, double radius) { @@ -121,14 +145,11 @@ class _RSuperellipseOctant { } final List circlePoints = _circularArcPoints(); - final List sePoints = _superellipseArcPoints(); + final (_ConicParam conic1, _ConicParam conic2) = _superellipseArcPoints(); if (!reverse) { - path.cubicToPoints( - transform.apply(sePoints[1]), - transform.apply(sePoints[2]), - transform.apply(sePoints[3]), - ); + path.conicToPoints(transform.apply(conic1.cp), transform.apply(conic1.p2), conic1.weight); + path.conicToPoints(transform.apply(conic2.cp), transform.apply(conic2.p2), conic2.weight); path.cubicToPoints( transform.apply(circlePoints[1]), transform.apply(circlePoints[2]), @@ -140,29 +161,31 @@ class _RSuperellipseOctant { transform.apply(circlePoints[1]), transform.apply(circlePoints[0]), ); - path.cubicToPoints( - transform.apply(sePoints[2]), - transform.apply(sePoints[1]), - transform.apply(sePoints[0]), - ); + path.conicToPoints(transform.apply(conic2.cp), transform.apply(conic2.p1), conic2.weight); + path.conicToPoints(transform.apply(conic1.cp), transform.apply(conic1.p1), conic1.weight); } } - List _superellipseArcPoints() { - final start = Offset(0, seA); - final Offset end = circleStart; - const startTangent = Offset(1, 0); - final Offset circleStartVector = circleStart - circleCenter; - final Offset endTangent = - Offset(-circleStartVector.dy, circleStartVector.dx) / circleStartVector.distance; + (_ConicParam, _ConicParam) _superellipseArcPoints() { + final posA = Offset(0, seA); + final Offset posJ = circleStart; - final (double startFactor, double endFactor) = _superellipseBezierFactors(seN); - return [ - start, - start + startTangent * startFactor * seA, - end + endTangent * endFactor * seA, - end, - ]; + final (:double weight1, :double weight2, :double yHOverA) = _superellipseBezierFactors( + seN, + posJ.dx / seA, + posJ.dy / seA, + ); + + final posH = Offset(math.pow(1 - math.pow(yHOverA, seN), 1 / seN) * seA, yHOverA * seA); + + const kA = 0.0; + final kJ = -math.pow(posJ.dx / posJ.dy, seN - 1) as double; + final kH = -math.pow(posH.dx / posH.dy, seN - 1) as double; + + return ( + _ConicParam(p1: posA, cp: _intersection(posA, kA, posH, kH), p2: posH, weight: weight1), + _ConicParam(p1: posH, cp: _intersection(posH, kH, posJ, kJ), p2: posJ, weight: weight2), + ); } List _circularArcPoints() { @@ -188,22 +211,25 @@ class _RSuperellipseOctant { return Offset(p.dx * cosine - p.dy * sine, p.dx * sine + p.dy * cosine); } - static (double, double) _superellipseBezierFactors(double n) { + static ({double weight1, double weight2, double yHOverA}) _superellipseBezierFactors( + double n, + double xJOverA, + double yJOverA, + ) { const kPrecomputedVariables = <(double, double)>[ - /*n= 2.0*/ (0.01339448, 0.05994973), - /*n= 3.0*/ (0.13664115, 0.13592082), - /*n= 4.0*/ (0.24545546, 0.14099516), - /*n= 5.0*/ (0.32353151, 0.12808021), - /*n= 6.0*/ (0.39093068, 0.11726264), - /*n= 7.0*/ (0.44847800, 0.10808278), - /*n= 8.0*/ (0.49817452, 0.10026175), - /*n= 9.0*/ (0.54105583, 0.09344429), - /*n=10.0*/ (0.57812578, 0.08748984), - /*n=11.0*/ (0.61050961, 0.08224722), - /*n=12.0*/ (0.63903989, 0.07759639), - /*n=13.0*/ (0.66416338, 0.07346530), - /*n=14.0*/ (0.68675338, 0.06974996), - /*n=15.0*/ (0.70678034, 0.06529512), + /*n= 2.0*/ (0.7078, 8.3194), + /*n= 3.0*/ (0.7895, 2.4523), + /*n= 4.0*/ (0.8379, 1.8528), + /*n= 5.0*/ (0.8701, 1.6891), + /*n= 6.0*/ (0.8932, 1.5806), + /*n= 7.0*/ (0.9107, 1.5043), + /*n= 8.0*/ (0.9244, 1.4470), + /*n= 9.0*/ (0.9355, 1.4037), + /*n=10.0*/ (0.9448, 1.3701), + /*n=11.0*/ (0.9526, 1.3431), + /*n=12.0*/ (0.9594, 1.3212), + /*n=13.0*/ (0.9653, 1.3032), + /*n=14.0*/ (0.9705, 1.2880), ]; final int kNumRecords = kPrecomputedVariables.length; const kStep = 1.00; @@ -211,19 +237,25 @@ class _RSuperellipseOctant { final double kMaxN = kMinN + (kNumRecords - 1) * kStep; if (n >= kMaxN) { - return ( - 1.07 - math.exp(1.307649835) * math.pow(n, -0.8568516731), - -0.01 + math.exp(-0.9287690322) * math.pow(n, -0.6120901398), - ); + n = kMaxN; } final double steps = ((n - kMinN) / kStep).clamp(0, kNumRecords - 1); final int left = steps.floor().clamp(0, kNumRecords - 2); final double frac = steps - left; + final double factor1 = + (1 - frac) * kPrecomputedVariables[left].$1 + frac * kPrecomputedVariables[left + 1].$1; + final double factor2 = + (1 - frac) * kPrecomputedVariables[left].$2 + frac * kPrecomputedVariables[left + 1].$2; + + final double yHProportion = math.sqrt(n); + const yAOverA = 1.0; + return ( - (1 - frac) * kPrecomputedVariables[left].$1 + frac * kPrecomputedVariables[left + 1].$1, - (1 - frac) * kPrecomputedVariables[left].$2 + frac * kPrecomputedVariables[left + 1].$2, + weight1: factor1 * math.sqrt(n), + weight2: factor2 * xJOverA, + yHOverA: (yAOverA * yHProportion + yJOverA) / (yHProportion + 1.0), ); } diff --git a/engine/src/flutter/lib/web_ui/test/ui/rsuperellipse_contains_test.dart b/engine/src/flutter/lib/web_ui/test/ui/rsuperellipse_contains_test.dart index f32f05029d1..e257a816504 100644 --- a/engine/src/flutter/lib/web_ui/test/ui/rsuperellipse_contains_test.dart +++ b/engine/src/flutter/lib/web_ui/test/ui/rsuperellipse_contains_test.dart @@ -115,7 +115,7 @@ void testMain() { checkPointAndMirrors(const Offset(39.99, 94.73)); checkPointAndMirrors(const Offset(44.13, 89.99)); checkPointAndMirrors(const Offset(48.46, 79.99)); - checkPointAndMirrors(const Offset(49.68, 69.99)); + checkPointAndMirrors(const Offset(49.70, 69.99)); checkPointAndMirrors(const Offset(49.97, 59.99)); checkPointAndMirrors(const Offset(49.99, 49.99)); // Right mid-edge }); @@ -151,7 +151,7 @@ void testMain() { } // A few other points along the edge - checkDiagonalPoints(const Offset(-40.0, -49.59)); + checkDiagonalPoints(const Offset(-40.0, -49.58)); checkDiagonalPoints(const Offset(-20.0, -45.64)); checkDiagonalPoints(const Offset(0.0, -37.01)); checkDiagonalPoints(const Offset(20.0, -21.96)); diff --git a/engine/src/flutter/testing/dart/path_test.dart b/engine/src/flutter/testing/dart/path_test.dart index f3ddb0c2b1f..4c932b2e133 100644 --- a/engine/src/flutter/testing/dart/path_test.dart +++ b/engine/src/flutter/testing/dart/path_test.dart @@ -290,7 +290,7 @@ void main() { } // A few other points along the edge - checkDiagonalPoints(const Offset(-40.0, -49.59)); + checkDiagonalPoints(const Offset(-40.0, -49.58)); checkDiagonalPoints(const Offset(-20.0, -45.64)); checkDiagonalPoints(const Offset(0.0, -37.01)); checkDiagonalPoints(const Offset(20.0, -21.96)); diff --git a/packages/flutter/test/painting/rounded_superellipse_border_test.dart b/packages/flutter/test/painting/rounded_superellipse_border_test.dart index 191370a44fd..f1cd2ed3a21 100644 --- a/packages/flutter/test/painting/rounded_superellipse_border_test.dart +++ b/packages/flutter/test/painting/rounded_superellipse_border_test.dart @@ -206,7 +206,7 @@ void main() { expect(ShapeBorder.lerp(rInside, rOutside, 0.5), rCenter); }); - testWidgets('RoundedSuperellipseBorder looks correct', (WidgetTester tester) async { + group('RoundedSuperellipseBorder looks correct', () { Widget containerWithBorder(Size size, BorderRadiusGeometry radius) { return Center( child: Container( @@ -223,62 +223,113 @@ void main() { ); } - await tester.pumpWidget(containerWithBorder(const Size(120, 300), BorderRadius.zero)); - await expectLater( - find.byType(Container), - matchesGoldenFile('painting.rounded_superellipse_border.all_zero.png'), - ); + testWidgets('for all zero corners', (WidgetTester tester) async { + await tester.pumpWidget(containerWithBorder(const Size(120, 300), BorderRadius.zero)); + await expectLater( + find.byType(Container), + matchesGoldenFile('painting.rounded_superellipse_border.all_zero.png'), + ); + }); - await tester.pumpWidget( - containerWithBorder(const Size(120, 300), const BorderRadius.all(Radius.circular(36))), - ); - await expectLater( - find.byType(Container), - matchesGoldenFile('painting.rounded_superellipse_border.all_circular.png'), - ); + testWidgets('for all circular corners', (WidgetTester tester) async { + await tester.pumpWidget( + containerWithBorder(const Size(120, 300), const BorderRadius.all(Radius.circular(36))), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('painting.rounded_superellipse_border.all_circular.png'), + ); + }); - await tester.pumpWidget( - containerWithBorder(const Size(120, 300), const BorderRadius.all(Radius.elliptical(20, 50))), - ); - await expectLater( - find.byType(Container), - matchesGoldenFile('painting.rounded_superellipse_border.all_elliptical.png'), - ); - - await tester.pumpWidget( - containerWithBorder(const Size(120, 300), const BorderRadius.all(Radius.circular(600))), - ); - await expectLater( - find.byType(Container), - matchesGoldenFile('painting.rounded_superellipse_border.clamping_uniform.png'), - ); - - await tester.pumpWidget( - containerWithBorder( - const Size(120, 300), - const BorderRadius.only( - topLeft: Radius.elliptical(1000, 1000), - topRight: Radius.elliptical(0, 1000), - bottomRight: Radius.elliptical(800, 1000), - bottomLeft: Radius.elliptical(100, 500), + testWidgets('for all elliptical corners', (WidgetTester tester) async { + await tester.pumpWidget( + containerWithBorder( + const Size(120, 300), + const BorderRadius.all(Radius.elliptical(20, 50)), ), - ), - ); - await expectLater( - find.byType(Container), - matchesGoldenFile('painting.rounded_superellipse_border.clamping_non_uniform.png'), - ); + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('painting.rounded_superellipse_border.all_elliptical.png'), + ); + }); - // Regression test for https://github.com/flutter/flutter/issues/170593 - await tester.pumpWidget( - containerWithBorder( - const Size(120, 300), - const BorderRadius.only(topLeft: Radius.circular(16), topRight: Radius.circular(16)), - ), - ); - await expectLater( - find.byType(Container), - matchesGoldenFile('painting.rounded_superellipse_border.regression_1.png'), - ); + testWidgets('for clamping uniform corners', (WidgetTester tester) async { + await tester.pumpWidget( + containerWithBorder(const Size(120, 300), const BorderRadius.all(Radius.circular(600))), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('painting.rounded_superellipse_border.clamping_uniform.png'), + ); + }); + + testWidgets('for clamping non-uniform corners', (WidgetTester tester) async { + await tester.pumpWidget( + containerWithBorder( + const Size(120, 300), + const BorderRadius.only( + topLeft: Radius.elliptical(1000, 1000), + topRight: Radius.elliptical(0, 1000), + bottomRight: Radius.elliptical(800, 1000), + bottomLeft: Radius.elliptical(100, 500), + ), + ), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('painting.rounded_superellipse_border.clamping_non_uniform.png'), + ); + }); + + testWidgets('for non-uniform sharp corners', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/170593 + await tester.pumpWidget( + containerWithBorder( + const Size(120, 300), + const BorderRadius.only(topLeft: Radius.circular(16), topRight: Radius.circular(16)), + ), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('painting.rounded_superellipse_border.regression_1.png'), + ); + }); + + testWidgets('for very large ratio', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/179875 + await tester.pumpWidget( + ClipRect( + child: OverflowBox( + maxWidth: double.infinity, + maxHeight: double.infinity, + alignment: Alignment.topRight, + child: Center( + child: Padding( + padding: const EdgeInsets.all(50.0), + child: Container( + width: 1e5, + height: 500, + decoration: const ShapeDecoration( + color: Color.fromARGB(255, 120, 120, 120), + shape: RoundedSuperellipseBorder( + side: BorderSide(color: Color.fromARGB(255, 255, 0, 0)), + borderRadius: BorderRadius.only( + topRight: Radius.circular(100), + bottomRight: Radius.circular(30), + ), + ), + ), + ), + ), + ), + ), + ), + ); + await expectLater( + find.byType(ClipRect), + matchesGoldenFile('painting.rounded_superellipse_border.long.png'), + ); + }); }); }