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'), + ); + }); }); }