| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "flutter/impeller/geometry/round_superellipse_param.h" |
| |
| namespace impeller { |
| |
| namespace { |
| |
| // Return the value that splits the range from `left` to `right` into two |
| // portions whose ratio equals to `ratio_left` : `ratio_right`. |
| Scalar Split(Scalar left, Scalar right, Scalar ratio_left, Scalar ratio_right) { |
| if (ratio_left == 0 && ratio_right == 0) { |
| return (left + right) / 2; |
| } |
| return (left * ratio_right + right * ratio_left) / (ratio_left + ratio_right); |
| } |
| |
| // Return the same Point, but each NaN coordinate is replaced by that of |
| // `default_size`. |
| inline Point ReplanceNaNWithDefault(Point in, Size default_size) { |
| return Point{std::isnan(in.x) ? default_size.width : in.x, |
| std::isnan(in.y) ? default_size.height : in.y}; |
| } |
| |
| // Swap the x and y coordinate of a point. |
| // |
| // Effectively mirrors the point by the y=x line. |
| inline Point Flip(Point a) { |
| return Point{a.y, a.x}; |
| } |
| |
| // A look up table with precomputed variables. |
| // |
| // The columns represent the following variabls respectively: |
| // |
| // * n |
| // * k_xJ, which is defined as 1 / (1 - xJ / a) |
| // |
| // For definition of the variables, see ComputeOctant. |
| constexpr Scalar kPrecomputedVariables[][2] = { |
| /*ratio=2.00*/ {2.00000000, 1.13276676}, |
| /*ratio=2.10*/ {2.18349805, 1.20311921}, |
| /*ratio=2.20*/ {2.33888662, 1.28698796}, |
| /*ratio=2.30*/ {2.48660575, 1.36351941}, |
| /*ratio=2.40*/ {2.62226596, 1.44717976}, |
| /*ratio=2.50*/ {2.75148990, 1.53385819}, |
| /*ratio=3.00*/ {3.36298265, 1.98288283}, |
| /*ratio=3.50*/ {4.08649929, 2.23811846}, |
| /*ratio=4.00*/ {4.85481134, 2.47563463}, |
| /*ratio=4.50*/ {5.62945551, 2.72948597}, |
| /*ratio=5.00*/ {6.43023796, 2.98020421}}; |
| |
| constexpr Scalar kMinRatio = 2.00; |
| |
| // The curve is split into 3 parts: |
| // * The first part uses a denser look up table. |
| // * The second part uses a sparser look up table. |
| // * The third part uses a straight line. |
| constexpr Scalar kFirstStepInverse = 10; // = 1 / 0.10 |
| constexpr Scalar kFirstMaxRatio = 2.50; |
| constexpr Scalar kFirstNumRecords = 6; |
| |
| constexpr Scalar kSecondStepInverse = 2; // = 1 / 0.50 |
| constexpr Scalar kSecondMaxRatio = 5.00; |
| |
| constexpr Scalar kThirdNSlope = 1.559599389; |
| constexpr Scalar kThirdKxjSlope = 0.522807185; |
| |
| constexpr size_t kNumRecords = |
| sizeof(kPrecomputedVariables) / sizeof(kPrecomputedVariables[0]); |
| |
| // Compute the `n` and `xJ / a` for the given ratio. |
| std::array<Scalar, 2> ComputeNAndXj(Scalar ratio) { |
| if (ratio > kSecondMaxRatio) { |
| Scalar n = kThirdNSlope * (ratio - kSecondMaxRatio) + |
| kPrecomputedVariables[kNumRecords - 1][0]; |
| Scalar k_xJ = kThirdKxjSlope * (ratio - kSecondMaxRatio) + |
| kPrecomputedVariables[kNumRecords - 1][1]; |
| return {n, 1 - 1 / k_xJ}; |
| } |
| ratio = std::clamp(ratio, kMinRatio, kSecondMaxRatio); |
| Scalar steps; |
| if (ratio < kFirstMaxRatio) { |
| steps = (ratio - kMinRatio) * kFirstStepInverse; |
| } else { |
| steps = |
| (ratio - kFirstMaxRatio) * kSecondStepInverse + kFirstNumRecords - 1; |
| } |
| |
| size_t left = std::clamp<size_t>(static_cast<size_t>(std::floor(steps)), 0, |
| kNumRecords - 2); |
| Scalar frac = steps - left; |
| |
| Scalar n = (1 - frac) * kPrecomputedVariables[left][0] + |
| frac * kPrecomputedVariables[left + 1][0]; |
| Scalar k_xJ = (1 - frac) * kPrecomputedVariables[left][1] + |
| frac * kPrecomputedVariables[left + 1][1]; |
| return {n, 1 - 1 / k_xJ}; |
| } |
| |
| // Find the center of the circle that passes the given two points and have the |
| // given radius. |
| Point FindCircleCenter(Point a, Point b, Scalar r) { |
| /* Denote the middle point of A and B as M. The key is to find the center of |
| * the circle. |
| * A --__ |
| * / ⟍ `、 |
| * / M ⟍\ |
| * / ⟋ B |
| * / ⟋ ↗ |
| * / ⟋ |
| * / ⟋ r |
| * C ᜱ ↙ |
| */ |
| |
| Point a_to_b = b - a; |
| Point m = (a + b) / 2; |
| Point c_to_m = Point(-a_to_b.y, a_to_b.x); |
| Scalar distance_am = a_to_b.GetLength() / 2; |
| Scalar distance_cm = sqrt(r * r - distance_am * distance_am); |
| return m - distance_cm * c_to_m.Normalize(); |
| } |
| |
| // Compute parameters for a square-like rounded superellipse with a symmetrical |
| // radius. |
| RoundSuperellipseParam::Octant ComputeOctant(Point center, |
| Scalar a, |
| Scalar radius) { |
| /* The following figure shows the first quadrant of a square-like rounded |
| * superellipse. |
| * |
| * superelipse |
| * A ↓ circular arc |
| * ---------...._J ↙ |
| * | / `⟍ M (where x=y) |
| * | / ⟋ ⟍ |
| * | / ⟋ \ |
| * | / ⟋ | |
| * | ᜱD | |
| * | ⟋ | |
| * | ⟋ | |
| * |⟋ | |
| * +--------------------| A' |
| * O |
| * ←-------- a ---------→ |
| */ |
| |
| if (radius <= kEhCloseEnough) { |
| // Corners with really small radii are treated as sharp corners, since they |
| // might lead to NaNs due to `ratio` being too large. |
| return RoundSuperellipseParam::Octant{ |
| .offset = center, |
| |
| .se_a = a, |
| .se_n = 0, |
| |
| .circle_start = {a, a}, |
| }; |
| } |
| |
| Scalar ratio = a * 2 / radius; |
| Scalar g = RoundSuperellipseParam::kGapFactor * radius; |
| |
| auto precomputed_vars = ComputeNAndXj(ratio); |
| Scalar n = precomputed_vars[0]; |
| Scalar xJ = precomputed_vars[1] * a; |
| Scalar yJ = pow(1 - pow(precomputed_vars[1], n), 1 / n) * a; |
| Scalar max_theta = asinf(pow(precomputed_vars[1], n / 2)); |
| |
| Scalar tan_phiJ = pow(xJ / yJ, n - 1); |
| Scalar d = (xJ - tan_phiJ * yJ) / (1 - tan_phiJ); |
| Scalar R = (a - d - g) * sqrt(2); |
| |
| Point pointM{a - g, a - g}; |
| Point pointJ = Point{xJ, yJ}; |
| Point circle_center = |
| radius == 0 ? pointM : FindCircleCenter(pointJ, pointM, R); |
| Radians circle_max_angle = |
| radius == 0 ? Radians(0) |
| : (pointM - circle_center).AngleTo(pointJ - circle_center); |
| |
| return RoundSuperellipseParam::Octant{ |
| .offset = center, |
| |
| .se_a = a, |
| .se_n = n, |
| .se_max_theta = max_theta, |
| |
| .circle_start = pointJ, |
| .circle_center = circle_center, |
| .circle_max_angle = circle_max_angle, |
| }; |
| } |
| |
| // Compute parameters for a quadrant of a rounded superellipse with asymmetrical |
| // radii. |
| // |
| // The `corner` is the coordinate of the corner point in the same coordinate |
| // space as `center`, which specifies the half size of the bounding box. |
| // |
| // The `sign` is a vector of {±1, ±1} that specifies which quadrant the curve |
| // should be, which should have the same sign as `corner - center` except that |
| // the latter may have a 0. |
| RoundSuperellipseParam::Quadrant ComputeQuadrant(Point center, |
| Point corner, |
| Size in_radii, |
| Size sign) { |
| Point corner_vector = corner - center; |
| Size radii = {std::min(std::abs(in_radii.width), std::abs(corner_vector.x)), |
| std::min(std::abs(in_radii.height), std::abs(corner_vector.y))}; |
| |
| // The prefix "norm" is short for "normalized", meaning a rounded superellipse |
| // that has a uniform radius. The quadrant is scaled from this normalized one. |
| // |
| // Be extra careful to avoid NaNs in cases that some coordinates of `in_radii` |
| // or `corner_vector` are zero. |
| Scalar norm_radius = radii.MinDimension(); |
| Size forward_scale = norm_radius == 0 ? Size{1, 1} : radii / norm_radius; |
| Point norm_half_size = corner_vector.Abs() / forward_scale; |
| Point signed_scale = |
| ReplanceNaNWithDefault(corner_vector / norm_half_size, sign); |
| |
| // Each quadrant curve is composed of two octant curves, each of which belongs |
| // to a square-like rounded rectangle. For the two octants to connect at the |
| // circular arc, the centers these two square-like rounded rectangle must be |
| // offset from the quadrant center by a same distance in different directions. |
| // The distance is denoted as `c`. |
| Scalar c = norm_half_size.x - norm_half_size.y; |
| |
| return RoundSuperellipseParam::Quadrant{ |
| .offset = center, |
| .signed_scale = signed_scale, |
| .top = ComputeOctant(Point{0, -c}, norm_half_size.x, norm_radius), |
| .right = ComputeOctant(Point{c, 0}, norm_half_size.y, norm_radius), |
| }; |
| } |
| |
| // Checks whether the given point is contained in the first octant of the given |
| // square-like rounded superellipse. |
| // |
| // The first octant refers to the region that spans from 0 to pi/4 starting from |
| // positive Y axis clockwise. |
| // |
| // If the point is not within this octant at all, then this function always |
| // returns true. Otherwise this function returns whether the point is contained |
| // within the rounded superellipse. |
| // |
| // The `param.offset` is ignored. The input point should have been transformed |
| // to the coordinate space where the rounded superellipse is centered at the |
| // origin. |
| bool OctantContains(const RoundSuperellipseParam::Octant& param, |
| const Point& p) { |
| // Check whether the point is within the octant. |
| if (p.x < 0 || p.y < 0 || p.y < p.x) { |
| return true; |
| } |
| // Check if the point is within the superellipsoid segment. |
| if (p.x <= param.circle_start.x) { |
| Point p_se = p / param.se_a; |
| return powf(p_se.x, param.se_n) + powf(p_se.y, param.se_n) <= 1; |
| } |
| Scalar circle_radius = |
| param.circle_start.GetDistanceSquared(param.circle_center); |
| Point p_circle = p - param.circle_center; |
| return p_circle.GetDistanceSquared(Point()) < circle_radius; |
| } |
| |
| // Determine if p is inside the corner curve defined by the indicated corner |
| // param. |
| // |
| // The coordinates of p should be within the same coordinate space with |
| // `param.offset`. |
| // |
| // If `check_quadrant` is true, then this function first checks if the point is |
| // within the quadrant of given corner. If not, this function returns true, |
| // otherwise this method continues to check whether the point is contained in |
| // the rounded superellipse. |
| // |
| // If `check_quadrant` is false, then the first step above is skipped, and the |
| // function checks whether the absolute (relative to the center) coordinate of p |
| // is contained in the rounded superellipse. |
| bool CornerContains(const RoundSuperellipseParam::Quadrant& param, |
| const Point& p, |
| bool check_quadrant = true) { |
| Point norm_point = (p - param.offset) / param.signed_scale; |
| if (check_quadrant) { |
| if (norm_point.x < 0 || norm_point.y < 0) { |
| return true; |
| } |
| } else { |
| norm_point = norm_point.Abs(); |
| } |
| if (param.top.se_n < 2 || param.right.se_n < 2) { |
| // A rectangular corner. The top and left sides contain the borders |
| // while the bottom and right sides don't (see `Rect.contains`). |
| Scalar x_delta = param.right.offset.x + param.right.se_a - norm_point.x; |
| Scalar y_delta = param.top.offset.y + param.top.se_a - norm_point.y; |
| bool x_within = x_delta > 0 || (x_delta == 0 && param.signed_scale.x < 0); |
| bool y_within = y_delta > 0 || (y_delta == 0 && param.signed_scale.y < 0); |
| return x_within && y_within; |
| } |
| return OctantContains(param.top, norm_point - param.top.offset) && |
| OctantContains(param.right, Flip(norm_point - param.right.offset)); |
| } |
| |
| class RoundSuperellipseBuilder { |
| public: |
| explicit RoundSuperellipseBuilder(PathReceiver& receiver) |
| : receiver_(receiver) {} |
| |
| // Draws an arc representing 1/4 of a rounded superellipse. |
| // |
| // If `reverse` is false, the resulting arc spans from 0 to pi/2, moving |
| // clockwise starting from the positive Y-axis. Otherwise it moves from pi/2 |
| // to 0. |
| // |
| // The `scale_sign` is an additional scaling transformation that potentially |
| // flips the result. This is useful for uniform radii where the same quadrant |
| // parameter set should be drawn to 4 quadrants. |
| void AddQuadrant(const RoundSuperellipseParam::Quadrant& param, |
| bool reverse, |
| Point scale_sign = Point(1, 1)) { |
| auto transform = Matrix::MakeTranslateScale(param.signed_scale * scale_sign, |
| param.offset); |
| if (param.top.se_n < 2 || param.right.se_n < 2) { |
| receiver_.LineTo(transform * (param.top.offset + |
| Point(param.top.se_a, param.top.se_a))); |
| if (!reverse) { |
| receiver_.LineTo(transform * |
| (param.right.offset + Point(param.right.se_a, 0))); |
| } else { |
| receiver_.LineTo(transform * |
| (param.top.offset + Point(0, param.top.se_a))); |
| } |
| return; |
| } |
| if (!reverse) { |
| AddOctant(param.top, /*reverse=*/false, /*flip=*/false, transform); |
| AddOctant(param.right, /*reverse=*/true, /*flip=*/true, transform); |
| } else { |
| AddOctant(param.right, /*reverse=*/false, /*flip=*/true, transform); |
| AddOctant(param.top, /*reverse=*/true, /*flip=*/false, transform); |
| } |
| } |
| |
| private: |
| std::array<Point, 4> 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(); |
| |
| std::array<Scalar, 2> factors = SuperellipseBezierFactors(param.se_n); |
| |
| return std::array<Point, 4>{ |
| start, start + start_tangent * factors[0] * param.se_a, |
| end + end_tangent * factors[1] * param.se_a, end}; |
| }; |
| |
| std::array<Point, 4> CircularArcPoints( |
| const RoundSuperellipseParam::Octant& param) { |
| Point start_vector = param.circle_start - param.circle_center; |
| Point end_vector = |
| start_vector.Rotate(Radians(-param.circle_max_angle.radians)); |
| Point circle_end = param.circle_center + end_vector; |
| Point start_tangent = Point{start_vector.y, -start_vector.x}.Normalize(); |
| Point end_tangent = Point{-end_vector.y, end_vector.x}.Normalize(); |
| Scalar bezier_factor = std::tan(param.circle_max_angle.radians / 4) * 4 / 3; |
| Scalar radius = start_vector.GetLength(); |
| |
| return std::array<Point, 4>{ |
| param.circle_start, |
| param.circle_start + start_tangent * bezier_factor * radius, |
| circle_end + end_tangent * bezier_factor * radius, circle_end}; |
| }; |
| |
| // Draws an arc representing 1/8 of a rounded superellipse. |
| // |
| // If `reverse` is false, the resulting arc spans from 0 to pi/4, moving |
| // clockwise starting from the positive Y-axis. Otherwise it moves from pi/4 |
| // to 0. |
| // |
| // If `flip` is true, all points have their X and Y coordinates swapped, |
| // effectively mirrowing each point by the y=x line. |
| // |
| // All points are transformed by `external_transform` after the optional |
| // flipping before being used as control points for the cubic curves. |
| void AddOctant(const RoundSuperellipseParam::Octant& param, |
| bool reverse, |
| bool flip, |
| const Matrix& external_transform) { |
| Matrix transform = |
| external_transform * Matrix::MakeTranslation(param.offset); |
| if (flip) { |
| transform = transform * kFlip; |
| } |
| |
| auto circle_points = CircularArcPoints(param); |
| auto se_points = SuperellipseArcPoints(param); |
| |
| if (!reverse) { |
| receiver_.CubicTo(transform * se_points[1], transform * se_points[2], |
| transform * se_points[3]); |
| receiver_.CubicTo(transform * circle_points[1], |
| transform * circle_points[2], |
| transform * circle_points[3]); |
| } else { |
| 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]); |
| } |
| }; |
| |
| // 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<Scalar, 2> 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}}; |
| constexpr size_t kNumRecords = |
| sizeof(kPrecomputedVariables) / sizeof(kPrecomputedVariables[0]); |
| constexpr Scalar kStep = 1.00f; |
| constexpr Scalar kMinN = 2.00f; |
| 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)}; |
| } |
| |
| Scalar steps = std::clamp<Scalar>((n - kMinN) / kStep, 0, kNumRecords - 1); |
| size_t left = std::clamp<size_t>(static_cast<size_t>(std::floor(steps)), 0, |
| kNumRecords - 2); |
| Scalar frac = steps - left; |
| |
| return std::array<Scalar, 2>{(1 - frac) * kPrecomputedVariables[left][0] + |
| frac * kPrecomputedVariables[left + 1][0], |
| (1 - frac) * kPrecomputedVariables[left][1] + |
| frac * kPrecomputedVariables[left + 1][1]}; |
| } |
| |
| PathReceiver& receiver_; |
| |
| // A matrix that swaps the coordinates of a point. |
| // clang-format off |
| static constexpr Matrix kFlip = Matrix( |
| 0.0f, 1.0f, 0.0f, 0.0f, |
| 1.0f, 0.0f, 0.0f, 0.0f, |
| 0.0f, 0.0f, 1.0f, 0.0f, |
| 0.0f, 0.0f, 0.0f, 1.0f); |
| // clang-format on |
| }; |
| |
| } // namespace |
| |
| RoundSuperellipseParam RoundSuperellipseParam::MakeBoundsRadius( |
| const Rect& bounds, |
| Scalar radius) { |
| return RoundSuperellipseParam{ |
| .top_right = ComputeQuadrant(bounds.GetCenter(), bounds.GetRightTop(), |
| {radius, radius}, {-1, 1}), |
| .all_corners_same = true, |
| }; |
| } |
| |
| RoundSuperellipseParam RoundSuperellipseParam::MakeBoundsRadii( |
| const Rect& bounds, |
| const RoundingRadii& radii) { |
| if (radii.AreAllCornersSame() && !radii.top_left.IsEmpty()) { |
| // Having four empty corners indicate a rectangle, which needs special |
| // treatment on border containment and therefore is not `all_corners_same`. |
| return RoundSuperellipseParam{ |
| .top_right = ComputeQuadrant(bounds.GetCenter(), bounds.GetRightTop(), |
| radii.top_right, {-1, 1}), |
| .all_corners_same = true, |
| }; |
| } |
| Scalar top_split = Split(bounds.GetLeft(), bounds.GetRight(), |
| radii.top_left.width, radii.top_right.width); |
| Scalar right_split = Split(bounds.GetTop(), bounds.GetBottom(), |
| radii.top_right.height, radii.bottom_right.height); |
| Scalar bottom_split = |
| Split(bounds.GetLeft(), bounds.GetRight(), radii.bottom_left.width, |
| radii.bottom_right.width); |
| Scalar left_split = Split(bounds.GetTop(), bounds.GetBottom(), |
| radii.top_left.height, radii.bottom_left.height); |
| |
| return RoundSuperellipseParam{ |
| .top_right = |
| ComputeQuadrant(Point{top_split, right_split}, bounds.GetRightTop(), |
| radii.top_right, {1, -1}), |
| .bottom_right = |
| ComputeQuadrant(Point{bottom_split, right_split}, |
| bounds.GetRightBottom(), radii.bottom_right, {1, 1}), |
| .bottom_left = |
| ComputeQuadrant(Point{bottom_split, left_split}, |
| bounds.GetLeftBottom(), radii.bottom_left, {-1, 1}), |
| .top_left = |
| ComputeQuadrant(Point{top_split, left_split}, bounds.GetLeftTop(), |
| radii.top_left, {-1, -1}), |
| .all_corners_same = false, |
| }; |
| } |
| |
| void RoundSuperellipseParam::Dispatch(PathReceiver& path_receiver) const { |
| RoundSuperellipseBuilder builder(path_receiver); |
| |
| Point start = top_right.offset + |
| top_right.signed_scale * |
| (top_right.top.offset + Point(0, top_right.top.se_a)); |
| path_receiver.MoveTo(start, true); |
| |
| if (all_corners_same) { |
| builder.AddQuadrant(top_right, /*reverse=*/false, Point(1, 1)); |
| builder.AddQuadrant(top_right, /*reverse=*/true, Point(1, -1)); |
| builder.AddQuadrant(top_right, /*reverse=*/false, Point(-1, -1)); |
| builder.AddQuadrant(top_right, /*reverse=*/true, Point(-1, 1)); |
| } else { |
| builder.AddQuadrant(top_right, /*reverse=*/false); |
| builder.AddQuadrant(bottom_right, /*reverse=*/true); |
| builder.AddQuadrant(bottom_left, /*reverse=*/false); |
| builder.AddQuadrant(top_left, /*reverse=*/true); |
| } |
| |
| path_receiver.LineTo(start); |
| path_receiver.Close(); |
| } |
| |
| bool RoundSuperellipseParam::Contains(const Point& point) const { |
| if (all_corners_same) { |
| return CornerContains(top_right, point, /*check_quadrant=*/false); |
| } |
| return CornerContains(top_right, point) && |
| CornerContains(bottom_right, point) && |
| CornerContains(bottom_left, point) && CornerContains(top_left, point); |
| } |
| |
| } // namespace impeller |