Add radius clamping to web RSuperellipse (#172254)

This PR fixes rendering errors on Web when the provided corner radii sum
up larger than the size. It implements radius scaling using the same
algorithm as in [the C++
implementation](b2d4210b37/engine/src/flutter/impeller/geometry/rounding_radii.cc).

Before: (error emerges for r>100, since the height is 200)
<img width="664" height="509" alt="image"
src="https://github.com/user-attachments/assets/eb526338-84d9-4eca-975b-d44bee0c11ac"
/>

After: (it stays this way for r>100)
<img width="611" height="471" alt="image"
src="https://github.com/user-attachments/assets/08ca2499-d5f7-47e1-9ecf-29f60c968016"
/>

It also fixes a bug that uses an incorrect starting point. 

Both changes are backed by the new test cases in
`rounded_superellipse_border_test.dart`.

## 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].

<!-- Links -->
[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
This commit is contained in:
Tong Mu 2025-08-05 16:53:14 -07:00 committed by GitHub
parent a374668798
commit de3dedaa74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 119 additions and 5 deletions

View File

@ -1164,6 +1164,18 @@ class RSuperellipse extends _RRectLike<RSuperellipse> {
@override
final bool _uniformRadii;
static (double, double) _normalizeEmptyToZero(double inputX, double inputY) {
return (inputX > 0 && inputY > 0) ? (inputX, inputY) : (0, 0);
}
static double _adjustScale(double radius1, double radius2, double dimension, double scale) {
assert(radius1 >= 0.0 && radius2 >= 0.0 && dimension > 0.0);
if (radius1 + radius2 > dimension) {
return math.min(scale, dimension / (radius1 + radius2));
}
return scale;
}
/// (Web only) Returns a [Path] for this shape and an [Offset] for its
/// placement.
///
@ -1180,9 +1192,9 @@ class RSuperellipse extends _RRectLike<RSuperellipse> {
/// `path` and the `offset` to the `addPath` method.
(Path, Offset) toPathOffset() {
if (_uniformRadii) {
return (_RSuperellipseCache.instance.get(width, height, tlRadius), center);
return (_RSuperellipseCache.instance.get(width, height, _scaledUniformRadii()), center);
} else {
return (_RSuperellipsePathBuilder.exact(this).path, Offset.zero);
return (_RSuperellipsePathBuilder.exact(_toScaledRadii()).path, Offset.zero);
}
}
@ -1211,6 +1223,81 @@ class RSuperellipse extends _RRectLike<RSuperellipse> {
);
}
/// Returns a [RSuperellipse] whose corner radii are scaled based on this one,
/// ensuring that the sum of the corner radii on each side does not exceed the
/// width or height of the given bounds.
///
/// See the [Skia scaling
/// implementation](https://github.com/google/skia/blob/main/src/core/SkRRect.cpp)
/// for more details.
RSuperellipse _toScaledRadii() {
if (!(width > 0 && height > 0)) {
return RSuperellipse.fromLTRBXY(left, top, right, bottom, 0.0, 0.0);
}
// If any corner is flat or has a negative value, normalize it to zeros
// We do this first so that the unnecessary non-flat part of that radius
// does not contribute to the global scaling below.
final (double tlRadiusX, double tlRadiusY) = _normalizeEmptyToZero(
this.tlRadiusX,
this.tlRadiusY,
);
final (double trRadiusX, double trRadiusY) = _normalizeEmptyToZero(
this.trRadiusX,
this.trRadiusY,
);
final (double blRadiusX, double blRadiusY) = _normalizeEmptyToZero(
this.blRadiusX,
this.blRadiusY,
);
final (double brRadiusX, double brRadiusY) = _normalizeEmptyToZero(
this.brRadiusX,
this.brRadiusY,
);
// Now determine a global scale to apply to all of the radii to ensure
// that none of the adjacent pairs of radius values sum to larger than
// the corresponding dimension of the rectangle.
double scale = 1.0;
scale = _adjustScale(tlRadiusX, trRadiusX, width, scale);
scale = _adjustScale(blRadiusX, brRadiusX, width, scale);
scale = _adjustScale(tlRadiusY, blRadiusY, height, scale);
scale = _adjustScale(trRadiusY, brRadiusY, height, scale);
if (scale < 1.0) {
return _create(
left: left,
top: top,
right: right,
bottom: bottom,
tlRadiusX: tlRadiusX * scale,
tlRadiusY: tlRadiusY * scale,
trRadiusX: trRadiusX * scale,
trRadiusY: trRadiusY * scale,
brRadiusX: brRadiusX * scale,
brRadiusY: brRadiusY * scale,
blRadiusX: blRadiusX * scale,
blRadiusY: blRadiusY * scale,
uniformRadii: _uniformRadii,
);
} else {
return this;
}
}
// A variation of `_toScaledRadii` that deals with uniform radii and returns a
// `Radius`.
Radius _scaledUniformRadii() {
assert(_uniformRadii);
if (!(width > 0 && height > 0)) {
return Radius.zero;
}
final (double radiusX, double radiusY) = _normalizeEmptyToZero(tlRadiusX, tlRadiusY);
double scale = 1.0;
scale = _adjustScale(radiusX, radiusX, width, scale);
scale = _adjustScale(radiusY, radiusY, height, scale);
return Radius.elliptical(radiusX * scale, radiusY * scale);
}
static const RSuperellipse zero = RSuperellipse._raw();
bool contains(Offset point) {

View File

@ -336,6 +336,8 @@ class _RSuperellipseQuadrant {
final _RSuperellipseOctant top;
final _RSuperellipseOctant right;
bool get isSharpCorner => top.seN < 2 || right.seN < 2;
void addToPath(
_RSuperellipsePath path, {
required bool reverse,
@ -345,7 +347,7 @@ class _RSuperellipseQuadrant {
_Transform.makeTranslate(offset),
_Transform.makeScale(signedScale.scale(extraScale.width, extraScale.height)),
);
if (top.seN < 2 || right.seN < 2) {
if (isSharpCorner) {
if (!reverse) {
final _Transform transformOctant = _Transform.makeComposite(
transform,
@ -406,13 +408,14 @@ class _RSuperellipsePathBuilder {
// Build a path for an RSuperellipse with arbitrary position and radii.
_RSuperellipsePathBuilder.exact(RSuperellipse r) : path = Path() {
final _RSuperellipsePath p = _RSuperellipsePath(path);
final Offset start = Offset((r.left + r.right) / 2, r.top);
path.moveTo(start.dx, start.dy);
final double topSplit = _split(r.left, r.right, r.tlRadiusX, r.trRadiusX);
final double rightSplit = _split(r.top, r.bottom, r.trRadiusY, r.brRadiusY);
final double bottomSplit = _split(r.left, r.right, r.blRadiusX, r.brRadiusX);
final double leftSplit = _split(r.top, r.bottom, r.tlRadiusY, r.blRadiusY);
final Offset start = Offset(topSplit, r.top);
path.moveTo(start.dx, start.dy);
_RSuperellipseQuadrant(
Offset(topSplit, rightSplit),
Offset(r.right, r.top),

View File

@ -253,6 +253,30 @@ void main() {
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),
),
),
);
await expectLater(
find.byType(Container),
matchesGoldenFile('painting.rounded_superellipse_border.clamping_non_uniform.png'),
);
// Regression test for https://github.com/flutter/flutter/issues/170593
await tester.pumpWidget(
containerWithBorder(