From c2cb3f69256a5faf3b5d583073fc9b61238ac5dd Mon Sep 17 00:00:00 2001 From: Peter G Date: Tue, 23 Jun 2026 12:37:28 +0200 Subject: [PATCH 1/3] ConcaveConvexSlice refactoring: - significantly simplifying implementation - correct mental model of aforementioned shape - free of any artifacts - shape consists of single coherent path without multiple subpaths as in previous implementation --- .../koalaplot/core/pie/PieChartShapes.kt | 366 ++++++------------ 1 file changed, 114 insertions(+), 252 deletions(-) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/pie/PieChartShapes.kt b/src/commonMain/kotlin/io/github/koalaplot/core/pie/PieChartShapes.kt index 25e2bdcf..a603cc6f 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/pie/PieChartShapes.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/pie/PieChartShapes.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.semantics.Role @@ -27,7 +28,6 @@ import io.github.koalaplot.core.util.polarToCartesian import io.github.koalaplot.core.util.rad import io.github.koalaplot.core.util.toDegrees import io.github.koalaplot.core.util.toRadians -import kotlin.math.abs import kotlin.math.asin import kotlin.math.max import kotlin.math.sin @@ -173,6 +173,115 @@ public fun PieSliceScope.ConcaveConvexSlice( ) {} } +/** + * Creates a pie chart slice shape with a total angular extent of [angle] degrees with an + * optional holeSize that is specified as a percentage of the overall slice radius. + * The pie diameter is equal to the Shape's size width. The slice is positioned with its vertex + * at the center. + * + * The slice shape starts with a concave and ends with a convex shape. + */ +private class ConcaveConvexSlice( + private val startAngle: Float, + private val angle: Float, + private val innerRadius: Float = 0.5F, + private val outerRadius: Float = 1.0F, +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline { + val radius = size.width / 2f * outerRadius + val holeRadius = size.width / 2f * innerRadius + val center = Offset(size.width / 2f, size.width / 2f) + + val innerRect = Rect(center, holeRadius) + val outerRect = Rect(center, radius) + + // Clamp to a valid range; sweeps > 360° degenerate the same way as exactly 360°. + val sweepAngle = angle.coerceIn(0f, 360f) + val innerCircleRadius = (radius - holeRadius) / 2f + val innerCircleCenterRadius = (radius + holeRadius) / 2f + + return Path().apply { + // Full-circle special case: a 360° sweep cannot be drawn as an arc. + // Skia reduces an arc with a 360° sweep to a single point — the start and + // end unit vectors coincide, so arcTo()/addArc() emit only a moveTo/lineTo + // and no curve — which would render nothing at all. The 0.01f tolerance also + // catches sweeps that land just below 360° through float accumulation (e.g. + // value * 360f / total): those are equally fragile and would otherwise leave + // a seam with overlapping caps instead of a closed ring. + // A full ring has no start/end caps, so draw the outer and inner ovals and + // cut the hole with the even-odd fill rule. + if (sweepAngle >= 360f - 0.01f) { + addOval(outerRect) + if (holeRadius > 0f) addOval(innerRect) + fillType = PathFillType.EvenOdd + return@apply + } + + // Outer arc. forceMoveTo = true starts a fresh contour at the arc's start + // point; on an empty Path the current point is the origin, so 'false' here + // would draw a stray line from (0, 0). + arcTo( + rect = outerRect, + startAngleDegrees = startAngle, + sweepAngleDegrees = sweepAngle, + forceMoveTo = true + ) + + // Convex cap at the end angle, bridging the outer to the inner radius. It is + // a half-circle (InnerCircleSweepAngleDegrees == 180°), so its endpoints land + // exactly on the inner/outer radius — which is what lets the arcs chain. + // forceMoveTo = false from here on keeps everything in ONE contour: a single + // closed path fills correctly, whereas separate subpaths each self-close on + // fill and produce the overlapping wedge artifacts. + arcTo( + rect = Rect( + center = center + polarToCartesian( + radius = innerCircleCenterRadius, + angle = (startAngle + sweepAngle).deg, + ), + radius = innerCircleRadius, + ), + startAngleDegrees = startAngle + sweepAngle, + sweepAngleDegrees = InnerCircleSweepAngleDegrees, + forceMoveTo = false, + ) + + // Inner arc, traversed backwards (negative sweep) so the contour stays + // continuous; it ends back on the inner radius at the start angle. + arcTo( + rect = innerRect, + startAngleDegrees = startAngle + sweepAngle, + sweepAngleDegrees = -sweepAngle, + forceMoveTo = false + ) + + // Concave cap at the start angle, traversed backwards (inner -> outer) to + // close the loop. Its end point coincides with the outer arc's start point. + arcTo( + rect = Rect( + center = center + polarToCartesian( + radius = innerCircleCenterRadius, + angle = startAngle.deg, + ), + radius = innerCircleRadius, + ), + startAngleDegrees = startAngle + InnerCircleSweepAngleDegrees, + sweepAngleDegrees = -InnerCircleSweepAngleDegrees, + forceMoveTo = false, + ) + + // Seal the contour. The end already meets the start, so this adds at most a + // zero-length line, but it marks the seam as a join (not two stroke caps) and + // guarantees a watertight region for fill and clip(). + close() + }.let(Outline::Generic) + } +} + /** * Creates a pie chart slice shape with a total angular extent of [angle] degrees with an * optional holeSize that is specified as a percentage of the overall slice radius. @@ -239,6 +348,10 @@ private class BiConvexSlice( // the donut slice gradually transitions into a smaller circular shape. // This prevents rendering artifacts and keeps the shape visually consistent. // As a result, the slice consists of two convex arcs forming a smooth circular shape. + + // Calculates the maximum radius of the inner circle. + // This corresponds to the opposite side of the triangle: + // Max inner circle radius = opposite side = sin(angle) * hypotenuse val maxInnerCircleRadius = sin((sweepAngle / 2).deg.toRadians().value).toFloat() * innerCircleCenterRadius @@ -281,255 +394,4 @@ private class BiConvexSlice( } } -/** - * Creates a pie chart slice shape with a total angular extent of [angle] degrees with an - * optional holeSize that is specified as a percentage of the overall slice radius. - * The pie diameter is equal to the Shape's size width. The slice is positioned with its vertex - * at the center. - * - * The slice shape starts with a concave and ends with a convex shape. - */ -private class ConcaveConvexSlice( - private val startAngle: Float, - private val angle: Float, - private val innerRadius: Float = 0.5F, - private val outerRadius: Float = 1.0F, -) : Shape { - override fun createOutline( - size: Size, - layoutDirection: LayoutDirection, - density: Density, - ): Outline { - val radius = size.width / 2F * outerRadius - val holeRadius = size.width / 2F * innerRadius - val center = Offset(size.width / 2F, size.width / 2F) - - val innerRect = Rect(center, holeRadius) - val outerRect = Rect(center, radius) - val layout = Layout( - center = center, - innerRect = innerRect, - outerRect = outerRect, - ) - - // Gap can lead to negative sweep angle which causes rendering issues - val sweepAngle = max(0F, angle) - val innerCircleRadius = (radius - holeRadius) / 2F - val innerCircleCenterRadius = (radius + holeRadius) / 2F - - val innerCircleDegrees = - asin(innerCircleRadius / innerCircleCenterRadius) - .rad - .toDegrees() - .value - .toFloat() - val innerCircle = InnerCircle( - innerCircleCenterRadius = innerCircleCenterRadius, - innerCircleDegrees = innerCircleDegrees, - innerCircleRadius = innerCircleRadius, - ) - - val concaveRingSlice = concaveRingSlice( - layout = layout, - startAngle = startAngle, - sweepAngle = sweepAngle, - innerCircle = innerCircle, - ) - - val ringSlice = ringSlice( - layout = layout, - startAngle = startAngle, - sweepAngle = sweepAngle, - innerCircle = innerCircle, - ) - - val convexRingSlice = convexRingSlice( - layout = layout, - startAngle = startAngle, - sweepAngle = sweepAngle, - innerCircle = innerCircle, - ) - - return Path() - .apply { - addPath(convexRingSlice) - addPath(ringSlice) - addPath(concaveRingSlice) - }.let(Outline::Generic) - } -} - private const val InnerCircleSweepAngleDegrees = 180F - -/** - * Path provider function for concave part of ring/donut slice. - * - * @param layout Specifies layout of pie chart. - * @param startAngle The start angle of the slice. - * @param sweepAngle The sweepAngle of the slice. - * @param innerCircle Specifies shape of concave/convex part of ring/donut slice. - */ -private fun concaveRingSlice( - layout: Layout, - startAngle: Float, - sweepAngle: Float, - innerCircle: InnerCircle, -): Path { - val (center, innerRect, outerRect) = layout - val (innerCircleCenterRadius, innerCircleDegrees, innerCircleRadius) = innerCircle - val deltaSmallSweepAngle = - if (sweepAngle < innerCircleDegrees) abs(sweepAngle - innerCircleDegrees) else 0F - - val toOuterStartAngleDegrees = startAngle - innerCircleDegrees / 2F - val toInnerStartAngleDegrees = startAngle + innerCircleDegrees / 2F - deltaSmallSweepAngle - val outerSweepAngleDegrees = innerCircleDegrees - deltaSmallSweepAngle - - val slice = Path().apply { - arcTo( - rect = outerRect, - startAngleDegrees = toOuterStartAngleDegrees, - sweepAngleDegrees = outerSweepAngleDegrees, - false, - ) - arcTo( - rect = innerRect, - startAngleDegrees = toInnerStartAngleDegrees, - sweepAngleDegrees = -outerSweepAngleDegrees, - false, - ) - } - - val toInnerCircleDegrees = startAngle - (innerCircleDegrees / 2F) - - val convexSemicircle = Path().apply { - addArc( - oval = Rect( - center = center + polarToCartesian( - radius = innerCircleCenterRadius, - angle = toInnerCircleDegrees.deg, - ), - radius = innerCircleRadius, - ), - startAngleDegrees = toInnerCircleDegrees, - sweepAngleDegrees = InnerCircleSweepAngleDegrees, - ) - } - return slice - convexSemicircle -} - -/** - * Path provider function for ring part of ring/donut slice. - * Returns empty path if slice consists only of concave/convex pieces. - * - * @param layout Specifies layout of pie chart. - * @param startAngle The start angle of the slice. - * @param sweepAngle The sweepAngle of the slice. - * @param innerCircle Specifies shape of concave/convex part of ring/donut slice. - */ -private fun ringSlice( - layout: Layout, - startAngle: Float, - sweepAngle: Float, - innerCircle: InnerCircle, -): Path { - val (_, innerRect, outerRect) = layout - val (_, innerCircleDegrees, _) = innerCircle - if (sweepAngle <= innerCircleDegrees) return Path() - - val toOuterStartAngleDegrees = startAngle + (innerCircleDegrees / 2F) - val toInnerStartAngleDegrees = startAngle + sweepAngle - innerCircleDegrees / 2F - val outerSweepAngleDegrees = sweepAngle - innerCircleDegrees - - return Path().apply { - addArc( - oval = outerRect, - startAngleDegrees = toOuterStartAngleDegrees, - sweepAngleDegrees = outerSweepAngleDegrees, - ) - arcTo( - rect = innerRect, - startAngleDegrees = toInnerStartAngleDegrees, - sweepAngleDegrees = -outerSweepAngleDegrees, - forceMoveTo = false, - ) - } -} - -/** - * Path provider function for convex part of ring/donut slice. - * - * @param layout Specifies layout of pie chart. - * @param startAngle The start angle of the slice. - * @param sweepAngle The sweepAngle of the slice. - * @param innerCircle Specifies shape of concave/convex part of ring/donut slice. - */ -private fun convexRingSlice( - layout: Layout, - startAngle: Float, - sweepAngle: Float, - innerCircle: InnerCircle, -): Path { - val (center, _, _) = layout - val (innerCircleCenterRadius, innerCircleDegrees, innerCircleRadius) = innerCircle - val toInnerCircleDegrees = (startAngle + sweepAngle - innerCircleDegrees / 2F) - - val convexSemicircle = Path().apply { - addArc( - oval = Rect( - center = center + polarToCartesian( - radius = innerCircleCenterRadius, - angle = toInnerCircleDegrees.deg, - ), - radius = innerCircleRadius, - ), - startAngleDegrees = toInnerCircleDegrees, - sweepAngleDegrees = InnerCircleSweepAngleDegrees, - ) - } - - if (sweepAngle < innerCircleDegrees) { - val toConcaveInnerCircleDegrees = startAngle - (innerCircleDegrees / 2F) - val concaveSemicircle = Path().apply { - addArc( - oval = Rect( - center = center + polarToCartesian( - radius = innerCircleCenterRadius, - angle = toConcaveInnerCircleDegrees.deg, - ), - radius = innerCircleRadius, - ), - startAngleDegrees = toConcaveInnerCircleDegrees, - sweepAngleDegrees = InnerCircleSweepAngleDegrees, - ) - } - - return convexSemicircle - concaveSemicircle - } - return convexSemicircle -} - -/** - * Parameter class specifying layout of concave/convex shaped pie chart slices. - * - * @param center The center of the pie chart. - * @param innerRect Rect corresponding to pie chart's hole. - * @param outerRect Rect corresponding to pie chart's outer radius. - */ -private data class Layout( - val center: Offset, - val innerRect: Rect, - val outerRect: Rect, -) - -/** - * Parameter class providing inner circle values specifying shape of concave/convex part of ring/donut slice. - * - * @param innerCircleCenterRadius Radius pointing to average of outer and inner radius. - * @param innerCircleDegrees Angle from center which encompasses slice's inner circle. - * @param innerCircleRadius Radius of slice's inner circle. - */ -private data class InnerCircle( - val innerCircleCenterRadius: Float, - val innerCircleDegrees: Float, - val innerCircleRadius: Float, -) From 94b8e209474fb10908871ed87f050ff379149206 Mon Sep 17 00:00:00 2001 From: Peter G Date: Wed, 24 Jun 2026 08:49:54 +0200 Subject: [PATCH 2/3] Rewrite BiConvexSlice as a single coherent path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix rendering artifacts that appeared for certain data configurations, caused by the shape being assembled from multiple subpaths (each implicitly closed on fill) - Build the slice as one continuous, self-closing path from a correct geometric model - Render holeRadius = 0 (pie instead of donut) as a plain sector (wedge) instead of the distorted ring slice the cap geometry produced - Handle the full-circle (360°) sweep explicitly - Significantly simplify the implementation --- .../koalaplot/core/pie/PieChartShapes.kt | 284 +++++++++++------- 1 file changed, 172 insertions(+), 112 deletions(-) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/pie/PieChartShapes.kt b/src/commonMain/kotlin/io/github/koalaplot/core/pie/PieChartShapes.kt index a603cc6f..3431553e 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/pie/PieChartShapes.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/pie/PieChartShapes.kt @@ -23,13 +23,11 @@ import androidx.compose.ui.unit.LayoutDirection import io.github.koalaplot.core.util.ExperimentalKoalaPlotApi import io.github.koalaplot.core.util.deg import io.github.koalaplot.core.util.min -import io.github.koalaplot.core.util.moveTo import io.github.koalaplot.core.util.polarToCartesian import io.github.koalaplot.core.util.rad import io.github.koalaplot.core.util.toDegrees import io.github.koalaplot.core.util.toRadians import kotlin.math.asin -import kotlin.math.max import kotlin.math.sin /** @@ -179,9 +177,19 @@ public fun PieSliceScope.ConcaveConvexSlice( * The pie diameter is equal to the Shape's size width. The slice is positioned with its vertex * at the center. * - * The slice shape starts with a concave and ends with a convex shape. + * Each slice features a convex shape on both its starting and ending sides. + * For very small values, the slice gradually transitions into a shrinking circle to ensure accurate rendering + * and maintain the intended visual appearance. + * + * The convex caps round the angular ends of a ring and therefore require a non-zero ring + * thickness. When [innerRadius] is 0 there is no ring to round, so a holeless slice falls back to + * a plain pie sector: two straight radii from the center to the rim joined by the outer arc, + * yielding sharp angular corners instead of rounded ones. This makes the shape usable for solid + * pie charts as well as donut charts. The shrinking-circle behavior for small values applies to + * the donut case only; a thin sector has no caps that could collide and is simply drawn as a + * narrow wedge. */ -private class ConcaveConvexSlice( +private class BiConvexSlice( private val startAngle: Float, private val angle: Float, private val innerRadius: Float = 0.5F, @@ -192,90 +200,143 @@ private class ConcaveConvexSlice( layoutDirection: LayoutDirection, density: Density, ): Outline { - val radius = size.width / 2f * outerRadius - val holeRadius = size.width / 2f * innerRadius - val center = Offset(size.width / 2f, size.width / 2f) + val radius = size.width / 2F * outerRadius + val holeRadius = size.width / 2F * innerRadius + val center = Offset(size.width / 2F, size.width / 2F) val innerRect = Rect(center, holeRadius) val outerRect = Rect(center, radius) // Clamp to a valid range; sweeps > 360° degenerate the same way as exactly 360°. - val sweepAngle = angle.coerceIn(0f, 360f) - val innerCircleRadius = (radius - holeRadius) / 2f - val innerCircleCenterRadius = (radius + holeRadius) / 2f + val sweepAngle = angle.coerceIn(0f, FullAngleDegrees) + val innerCircleRadius = (radius - holeRadius) / 2F + val innerCircleCenterRadius = (radius + holeRadius) / 2F + + // Half-angle that one cap circle subtends from the donut center. The cap circle + // (radius innerCircleRadius, centered at innerCircleCenterRadius) is seen under + // tangent lines at ±asin(r / R). Each cap is inset by this angle so the rounded + // slice occupies exactly [startAngle, startAngle + sweepAngle] and never spills + // into the neighboring slice. + val innerCircleDegrees = asin(innerCircleRadius / innerCircleCenterRadius).rad.toDegrees().value.toFloat() return Path().apply { - // Full-circle special case: a 360° sweep cannot be drawn as an arc. - // Skia reduces an arc with a 360° sweep to a single point — the start and - // end unit vectors coincide, so arcTo()/addArc() emit only a moveTo/lineTo - // and no curve — which would render nothing at all. The 0.01f tolerance also - // catches sweeps that land just below 360° through float accumulation (e.g. - // value * 360f / total): those are equally fragile and would otherwise leave - // a seam with overlapping caps instead of a closed ring. - // A full ring has no start/end caps, so draw the outer and inner ovals and + // Full-circle special case: a 360° sweep cannot be drawn as an arc. Skia reduces + // it to a single point — start and end unit vectors coincide, so arcTo()/addArc() + // emit only a moveTo/lineTo and no curve — which would render nothing at all. The + // 0.01f tolerance also catches sweeps that land just below 360° through float + // accumulation (e.g. value * 360f / total): those are equally fragile and would + // otherwise leave a seam with overlapping caps instead of a closed shape. A full + // circle has no caps, so draw the outer oval (plus the inner oval for donuts) and // cut the hole with the even-odd fill rule. - if (sweepAngle >= 360f - 0.01f) { + if (sweepAngle >= FullAngleDegrees - FullAngleToleranceDegrees) { addOval(outerRect) if (holeRadius > 0f) addOval(innerRect) fillType = PathFillType.EvenOdd return@apply } - // Outer arc. forceMoveTo = true starts a fresh contour at the arc's start - // point; on an empty Path the current point is the origin, so 'false' here - // would draw a stray line from (0, 0). + // Pie mode (no hole): the convex caps round the angular ends of a *ring*, so they + // are undefined without a ring thickness. At holeRadius == 0 the cap radius would + // be radius / 2 and innerCircleDegrees would be 90°, which both bulges the ends into + // blobs and pushes the thin-slice threshold up to 180°, so most slices would + // mis-render. A holeless slice is therefore drawn as a plain sector: forceMoveTo = + // false turns the implicit move-to-arc-start into the first radius (center -> rim), + // the arc draws the outer edge, and close() draws the second radius back to center. + if (holeRadius <= 0f) { + moveTo(center.x, center.y) + arcTo(rect = outerRect, startAngleDegrees = startAngle, sweepAngleDegrees = sweepAngle, forceMoveTo = false) + close() + return@apply + } + + // Thin-slice fallback (donut only). Below this threshold the two convex caps meet + // and the outer arc's sweep (sweepAngle - 2 * innerCircleDegrees) would be <= 0, so + // the four-arc path would self-overlap and produce artifacts. Draw a single circle + // instead, sized to shrink smoothly toward a point as the sweep approaches zero. At + // exactly the threshold the two semicircular caps coincide into this circle, so the + // transition into the arc-based shape is seamless. + if (sweepAngle <= 2 * innerCircleDegrees) { + // Largest circle that fits inside the angular wedge, tangent to both slice + // edges. In the right triangle (center -> cap center -> edge tangent point) the + // wedge half-angle sits at the center, innerCircleCenterRadius is the hypotenuse, + // and the inscribed radius is the opposite side = sin(sweep / 2) * hypotenuse. + val maxInnerCircleRadius = sin((sweepAngle / 2).deg.toRadians().value).toFloat() * innerCircleCenterRadius + + // Never exceed the ring thickness (the regular cap radius). + val resultingInnerCircleRadius = min(innerCircleRadius, maxInnerCircleRadius) + + // Center offset from startAngle: half the sweep puts the circle on the slice's + // angular bisector (clamped so it never moves past the regular cap position). + val resultingInnerCircleDegrees = min(innerCircleDegrees, sweepAngle / 2) + + addOval( + oval = Rect( + center = center + polarToCartesian( + radius = innerCircleCenterRadius, + angle = (startAngle + resultingInnerCircleDegrees).deg, + ), + radius = resultingInnerCircleRadius, + ) + ) + return@apply + } + + // Outer arc, shortened by innerCircleDegrees at each end to leave room for the caps. + // forceMoveTo = true starts a fresh contour at the arc's start point; on an empty + // Path the current point is the origin, so 'false' here would draw a stray line + // from (0, 0). arcTo( rect = outerRect, - startAngleDegrees = startAngle, - sweepAngleDegrees = sweepAngle, + startAngleDegrees = startAngle + innerCircleDegrees, + sweepAngleDegrees = sweepAngle - 2 * innerCircleDegrees, forceMoveTo = true ) - // Convex cap at the end angle, bridging the outer to the inner radius. It is - // a half-circle (InnerCircleSweepAngleDegrees == 180°), so its endpoints land - // exactly on the inner/outer radius — which is what lets the arcs chain. - // forceMoveTo = false from here on keeps everything in ONE contour: a single - // closed path fills correctly, whereas separate subpaths each self-close on - // fill and produce the overlapping wedge artifacts. + // Convex end cap: a half-circle (assumes InnerCircleSweepAngleDegrees == 180°) + // centered on the mid-radius and tangent to both the outer and inner circle at this + // angle, so its endpoints land exactly on outerRect and innerRect. forceMoveTo = + // false from here on keeps everything in ONE contour: a single closed path fills + // correctly, whereas separate subpaths each self-close on fill and produce the + // overlapping-wedge artifacts. arcTo( rect = Rect( center = center + polarToCartesian( radius = innerCircleCenterRadius, - angle = (startAngle + sweepAngle).deg, + angle = (startAngle + sweepAngle - innerCircleDegrees).deg, ), radius = innerCircleRadius, ), - startAngleDegrees = startAngle + sweepAngle, + startAngleDegrees = startAngle + sweepAngle - innerCircleDegrees, sweepAngleDegrees = InnerCircleSweepAngleDegrees, forceMoveTo = false, ) - // Inner arc, traversed backwards (negative sweep) so the contour stays - // continuous; it ends back on the inner radius at the start angle. + // Inner arc, traversed backwards (negative sweep) so the contour stays continuous; + // it ends back on the inner radius where the start cap begins. arcTo( rect = innerRect, - startAngleDegrees = startAngle + sweepAngle, - sweepAngleDegrees = -sweepAngle, + startAngleDegrees = startAngle + sweepAngle - innerCircleDegrees, + sweepAngleDegrees = -(sweepAngle - 2 * innerCircleDegrees), forceMoveTo = false ) - // Concave cap at the start angle, traversed backwards (inner -> outer) to - // close the loop. Its end point coincides with the outer arc's start point. + // Convex start cap, mirroring the end cap. Its end point coincides with the outer + // arc's start point, closing the loop. arcTo( rect = Rect( center = center + polarToCartesian( radius = innerCircleCenterRadius, - angle = startAngle.deg, + angle = (startAngle + innerCircleDegrees).deg, ), radius = innerCircleRadius, ), - startAngleDegrees = startAngle + InnerCircleSweepAngleDegrees, - sweepAngleDegrees = -InnerCircleSweepAngleDegrees, + startAngleDegrees = startAngle + innerCircleDegrees + InnerCircleSweepAngleDegrees, + sweepAngleDegrees = InnerCircleSweepAngleDegrees, forceMoveTo = false, ) // Seal the contour. The end already meets the start, so this adds at most a - // zero-length line, but it marks the seam as a join (not two stroke caps) and + // zero-length line, but it marks the seam as a join (not two-stroke caps) and // guarantees a watertight region for fill and clip(). close() }.let(Outline::Generic) @@ -288,11 +349,9 @@ private class ConcaveConvexSlice( * The pie diameter is equal to the Shape's size width. The slice is positioned with its vertex * at the center. * - * Each slice features a convex shape on both its starting and ending sides. - * For very small values, the slice gradually transitions into a shrinking circle to ensure accurate rendering - * and maintain the intended visual appearance. + * The slice shape starts with a concave and ends with a convex shape. */ -private class BiConvexSlice( +private class ConcaveConvexSlice( private val startAngle: Float, private val angle: Float, private val innerRadius: Float = 0.5F, @@ -303,95 +362,96 @@ private class BiConvexSlice( layoutDirection: LayoutDirection, density: Density, ): Outline { - val radius = size.width / 2F * outerRadius - val holeRadius = size.width / 2F * innerRadius - val center = Offset(size.width / 2F, size.width / 2F) + val radius = size.width / 2f * outerRadius + val holeRadius = size.width / 2f * innerRadius + val center = Offset(size.width / 2f, size.width / 2f) val innerRect = Rect(center, holeRadius) val outerRect = Rect(center, radius) - // Gap can lead to negative sweep angle which causes rendering issues - val sweepAngle = max(0F, angle) - val innerCircleRadius = (radius - holeRadius) / 2F - val innerCircleCenterRadius = (radius + holeRadius) / 2F + // Clamp to a valid range; sweeps > 360° degenerate the same way as exactly 360°. + val sweepAngle = angle.coerceIn(0f, FullAngleDegrees) + val innerCircleRadius = (radius - holeRadius) / 2f + val innerCircleCenterRadius = (radius + holeRadius) / 2f - val innerCircleDegrees = - asin(innerCircleRadius / innerCircleCenterRadius) - .rad - .toDegrees() - .value - .toFloat() + return Path().apply { + // Full-circle special case: a 360° sweep cannot be drawn as an arc. + // Skia reduces an arc with a 360° sweep to a single point — the start and + // end unit vectors coincide, so arcTo()/addArc() emit only a moveTo/lineTo + // and no curve — which would render nothing at all. The 0.01f tolerance also + // catches sweeps that land just below 360° through float accumulation (e.g. + // value * 360f / total): those are equally fragile and would otherwise leave + // a seam with overlapping caps instead of a closed ring. + // A full ring has no start/end caps, so draw the outer and inner ovals and + // cut the hole with the even-odd fill rule. + if (sweepAngle >= FullAngleDegrees - FullAngleToleranceDegrees) { + addOval(outerRect) + if (holeRadius > 0f) addOval(innerRect) + fillType = PathFillType.EvenOdd + return@apply + } - val outerPieSweepAngle = max(sweepAngle - 2 * innerCircleDegrees, 0F) - val outerPie = Path().apply { - moveTo(center) + // Outer arc. forceMoveTo = true starts a fresh contour at the arc's start + // point; on an empty Path the current point is the origin, so 'false' here + // would draw a stray line from (0, 0). arcTo( rect = outerRect, - startAngleDegrees = startAngle + innerCircleDegrees, - sweepAngleDegrees = outerPieSweepAngle, - forceMoveTo = false, + startAngleDegrees = startAngle, + sweepAngleDegrees = sweepAngle, + forceMoveTo = true ) - } - val innerPieSweepAngle = max(sweepAngle - 2 * innerCircleDegrees, 0F) - val innerPie = Path().apply { - moveTo(center) + // Convex cap at the end angle, bridging the outer to the inner radius. It is + // a half-circle (InnerCircleSweepAngleDegrees == 180°), so its endpoints land + // exactly on the inner/outer radius — which is what lets the arcs chain. + // forceMoveTo = false from here on keeps everything in ONE contour: a single + // closed path fills correctly, whereas separate subpaths each self-close on + // fill and produce the overlapping wedge artifacts. arcTo( - rect = innerRect, - startAngleDegrees = startAngle + innerCircleDegrees, - sweepAngleDegrees = innerPieSweepAngle, - forceMoveTo = false, - ) - } - - // The following calculations ensure that for very small sweep angles (thin slices), - // the donut slice gradually transitions into a smaller circular shape. - // This prevents rendering artifacts and keeps the shape visually consistent. - // As a result, the slice consists of two convex arcs forming a smooth circular shape. - - // Calculates the maximum radius of the inner circle. - // This corresponds to the opposite side of the triangle: - // Max inner circle radius = opposite side = sin(angle) * hypotenuse - val maxInnerCircleRadius = - sin((sweepAngle / 2).deg.toRadians().value).toFloat() * innerCircleCenterRadius - - val resultingInnerCircleRadius = - min(innerCircleRadius, maxInnerCircleRadius) - - val resultingInnerCircleDegrees = - min(innerCircleDegrees, sweepAngle / 2) - - val convexPaths = Path().apply { - addArc( - oval = Rect( + rect = Rect( center = center + polarToCartesian( radius = innerCircleCenterRadius, - angle = (startAngle + resultingInnerCircleDegrees).deg, + angle = (startAngle + sweepAngle).deg, ), - radius = resultingInnerCircleRadius, + radius = innerCircleRadius, ), - startAngleDegrees = startAngle + resultingInnerCircleDegrees, - sweepAngleDegrees = -InnerCircleSweepAngleDegrees, + startAngleDegrees = startAngle + sweepAngle, + sweepAngleDegrees = InnerCircleSweepAngleDegrees, + forceMoveTo = false, + ) + + // Inner arc, traversed backwards (negative sweep) so the contour stays + // continuous; it ends back on the inner radius at the start angle. + arcTo( + rect = innerRect, + startAngleDegrees = startAngle + sweepAngle, + sweepAngleDegrees = -sweepAngle, + forceMoveTo = false ) - addArc( - oval = Rect( + + // Concave cap at the start angle, traversed backwards (inner -> outer) to + // close the loop. Its end point coincides with the outer arc's start point. + arcTo( + rect = Rect( center = center + polarToCartesian( radius = innerCircleCenterRadius, - angle = (startAngle + sweepAngle - resultingInnerCircleDegrees).deg, + angle = startAngle.deg, ), - radius = resultingInnerCircleRadius, + radius = innerCircleRadius, ), - startAngleDegrees = startAngle + sweepAngle - resultingInnerCircleDegrees, - sweepAngleDegrees = InnerCircleSweepAngleDegrees, + startAngleDegrees = startAngle + InnerCircleSweepAngleDegrees, + sweepAngleDegrees = -InnerCircleSweepAngleDegrees, + forceMoveTo = false, ) - } - return Path() - .apply { - addPath(outerPie - innerPie) - addPath(convexPaths) - }.let(Outline::Generic) + // Seal the contour. The end already meets the start, so this adds at most a + // zero-length line, but it marks the seam as a join (not two-stroke caps) and + // guarantees a watertight region for fill and clip(). + close() + }.let(Outline::Generic) } } private const val InnerCircleSweepAngleDegrees = 180F +private const val FullAngleDegrees = 360f +private const val FullAngleToleranceDegrees = 0.01f From 97333788f3eb1520f3637149c5cff83d66f51af2 Mon Sep 17 00:00:00 2001 From: Peter G Date: Thu, 25 Jun 2026 13:17:08 +0200 Subject: [PATCH 3/3] fixes detekt and ktlint issues --- .../koalaplot/core/pie/PieChartShapes.kt | 385 +++++++++--------- 1 file changed, 194 insertions(+), 191 deletions(-) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/pie/PieChartShapes.kt b/src/commonMain/kotlin/io/github/koalaplot/core/pie/PieChartShapes.kt index 3431553e..b5300e3c 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/pie/PieChartShapes.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/pie/PieChartShapes.kt @@ -217,129 +217,131 @@ private class BiConvexSlice( // tangent lines at ±asin(r / R). Each cap is inset by this angle so the rounded // slice occupies exactly [startAngle, startAngle + sweepAngle] and never spills // into the neighboring slice. - val innerCircleDegrees = asin(innerCircleRadius / innerCircleCenterRadius).rad.toDegrees().value.toFloat() - - return Path().apply { - // Full-circle special case: a 360° sweep cannot be drawn as an arc. Skia reduces - // it to a single point — start and end unit vectors coincide, so arcTo()/addArc() - // emit only a moveTo/lineTo and no curve — which would render nothing at all. The - // 0.01f tolerance also catches sweeps that land just below 360° through float - // accumulation (e.g. value * 360f / total): those are equally fragile and would - // otherwise leave a seam with overlapping caps instead of a closed shape. A full - // circle has no caps, so draw the outer oval (plus the inner oval for donuts) and - // cut the hole with the even-odd fill rule. - if (sweepAngle >= FullAngleDegrees - FullAngleToleranceDegrees) { - addOval(outerRect) - if (holeRadius > 0f) addOval(innerRect) - fillType = PathFillType.EvenOdd - return@apply - } - - // Pie mode (no hole): the convex caps round the angular ends of a *ring*, so they - // are undefined without a ring thickness. At holeRadius == 0 the cap radius would - // be radius / 2 and innerCircleDegrees would be 90°, which both bulges the ends into - // blobs and pushes the thin-slice threshold up to 180°, so most slices would - // mis-render. A holeless slice is therefore drawn as a plain sector: forceMoveTo = - // false turns the implicit move-to-arc-start into the first radius (center -> rim), - // the arc draws the outer edge, and close() draws the second radius back to center. - if (holeRadius <= 0f) { - moveTo(center.x, center.y) - arcTo(rect = outerRect, startAngleDegrees = startAngle, sweepAngleDegrees = sweepAngle, forceMoveTo = false) - close() - return@apply - } - - // Thin-slice fallback (donut only). Below this threshold the two convex caps meet - // and the outer arc's sweep (sweepAngle - 2 * innerCircleDegrees) would be <= 0, so - // the four-arc path would self-overlap and produce artifacts. Draw a single circle - // instead, sized to shrink smoothly toward a point as the sweep approaches zero. At - // exactly the threshold the two semicircular caps coincide into this circle, so the - // transition into the arc-based shape is seamless. - if (sweepAngle <= 2 * innerCircleDegrees) { - // Largest circle that fits inside the angular wedge, tangent to both slice - // edges. In the right triangle (center -> cap center -> edge tangent point) the - // wedge half-angle sits at the center, innerCircleCenterRadius is the hypotenuse, - // and the inscribed radius is the opposite side = sin(sweep / 2) * hypotenuse. - val maxInnerCircleRadius = sin((sweepAngle / 2).deg.toRadians().value).toFloat() * innerCircleCenterRadius - - // Never exceed the ring thickness (the regular cap radius). - val resultingInnerCircleRadius = min(innerCircleRadius, maxInnerCircleRadius) - - // Center offset from startAngle: half the sweep puts the circle on the slice's - // angular bisector (clamped so it never moves past the regular cap position). - val resultingInnerCircleDegrees = min(innerCircleDegrees, sweepAngle / 2) - - addOval( - oval = Rect( - center = center + polarToCartesian( - radius = innerCircleCenterRadius, - angle = (startAngle + resultingInnerCircleDegrees).deg, + val innerCircleDegrees = asin(innerCircleRadius / innerCircleCenterRadius) + .rad + .toDegrees() + .value + .toFloat() + + return Path() + .apply { + // Full-circle special case: a 360° sweep cannot be drawn as an arc. Skia reduces + // it to a single point — start and end unit vectors coincide, so arcTo()/addArc() + // emit only a moveTo/lineTo and no curve — which would render nothing at all. The + // 0.01f tolerance also catches sweeps that land just below 360° through float + // accumulation (e.g. value * 360f / total): those are equally fragile and would + // otherwise leave a seam with overlapping caps instead of a closed shape. A full + // circle has no caps, so draw the outer oval (plus the inner oval for donuts) and + // cut the hole with the even-odd fill rule. + if (sweepAngle >= FullAngleDegrees - FullAngleToleranceDegrees) { + addOval(outerRect) + if (holeRadius > 0f) addOval(innerRect) + fillType = PathFillType.EvenOdd + return@apply + } + + // Pie mode (no hole): the convex caps round the angular ends of a *ring*, so they + // are undefined without a ring thickness. At holeRadius == 0 the cap radius would + // be radius / 2 and innerCircleDegrees would be 90°, which both bulges the ends into + // blobs and pushes the thin-slice threshold up to 180°, so most slices would + // mis-render. A holeless slice is therefore drawn as a plain sector: forceMoveTo = + // false turns the implicit move-to-arc-start into the first radius (center -> rim), + // the arc draws the outer edge, and close() draws the second radius back to center. + if (holeRadius <= 0f) { + moveTo(center.x, center.y) + arcTo(rect = outerRect, startAngleDegrees = startAngle, sweepAngleDegrees = sweepAngle, forceMoveTo = false) + close() + return@apply + } + + // Thin-slice fallback (donut only). Below this threshold the two convex caps meet + // and the outer arc's sweep (sweepAngle - 2 * innerCircleDegrees) would be <= 0, so + // the four-arc path would self-overlap and produce artifacts. Draw a single circle + // instead, sized to shrink smoothly toward a point as the sweep approaches zero. At + // exactly the threshold the two semicircular caps coincide into this circle, so the + // transition into the arc-based shape is seamless. + if (sweepAngle <= 2 * innerCircleDegrees) { + // Largest circle that fits inside the angular wedge, tangent to both slice + // edges. In the right triangle (center -> cap center -> edge tangent point) the + // wedge half-angle sits at the center, innerCircleCenterRadius is the hypotenuse, + // and the inscribed radius is the opposite side = sin(sweep / 2) * hypotenuse. + val maxInnerCircleRadius = sin((sweepAngle / 2).deg.toRadians().value).toFloat() * innerCircleCenterRadius + + // Never exceed the ring thickness (the regular cap radius). + val resultingInnerCircleRadius = min(innerCircleRadius, maxInnerCircleRadius) + + // Center offset from startAngle: half the sweep puts the circle on the slice's + // angular bisector (clamped so it never moves past the regular cap position). + val resultingInnerCircleDegrees = min(innerCircleDegrees, sweepAngle / 2) + + addOval( + oval = Rect( + center = center + polarToCartesian( + radius = innerCircleCenterRadius, + angle = (startAngle + resultingInnerCircleDegrees).deg, + ), + radius = resultingInnerCircleRadius, ), - radius = resultingInnerCircleRadius, ) + return@apply + } + + // Outer arc, shortened by innerCircleDegrees at each end to leave room for the caps. + // forceMoveTo = true starts a fresh contour at the arc's start point; on an empty + // Path the current point is the origin, so 'false' here would draw a stray line + // from (0, 0). + arcTo( + rect = outerRect, + startAngleDegrees = startAngle + innerCircleDegrees, + sweepAngleDegrees = sweepAngle - 2 * innerCircleDegrees, + forceMoveTo = true, ) - return@apply - } - - // Outer arc, shortened by innerCircleDegrees at each end to leave room for the caps. - // forceMoveTo = true starts a fresh contour at the arc's start point; on an empty - // Path the current point is the origin, so 'false' here would draw a stray line - // from (0, 0). - arcTo( - rect = outerRect, - startAngleDegrees = startAngle + innerCircleDegrees, - sweepAngleDegrees = sweepAngle - 2 * innerCircleDegrees, - forceMoveTo = true - ) - - // Convex end cap: a half-circle (assumes InnerCircleSweepAngleDegrees == 180°) - // centered on the mid-radius and tangent to both the outer and inner circle at this - // angle, so its endpoints land exactly on outerRect and innerRect. forceMoveTo = - // false from here on keeps everything in ONE contour: a single closed path fills - // correctly, whereas separate subpaths each self-close on fill and produce the - // overlapping-wedge artifacts. - arcTo( - rect = Rect( - center = center + polarToCartesian( - radius = innerCircleCenterRadius, - angle = (startAngle + sweepAngle - innerCircleDegrees).deg, + + // Convex end cap: a half-circle (assumes InnerCircleSweepAngleDegrees == 180°) + // centered on the mid-radius and tangent to both the outer and inner circle at this + // angle, so its endpoints land exactly on outerRect and innerRect. forceMoveTo = + // false from here on keeps everything in ONE contour: a single closed path fills + // correctly, whereas separate subpaths each self-close on fill and produce the + // overlapping-wedge artifacts. + arcTo( + rect = Rect( + center = center + polarToCartesian( + radius = innerCircleCenterRadius, + angle = (startAngle + sweepAngle - innerCircleDegrees).deg, + ), + radius = innerCircleRadius, ), - radius = innerCircleRadius, - ), - startAngleDegrees = startAngle + sweepAngle - innerCircleDegrees, - sweepAngleDegrees = InnerCircleSweepAngleDegrees, - forceMoveTo = false, - ) - - // Inner arc, traversed backwards (negative sweep) so the contour stays continuous; - // it ends back on the inner radius where the start cap begins. - arcTo( - rect = innerRect, - startAngleDegrees = startAngle + sweepAngle - innerCircleDegrees, - sweepAngleDegrees = -(sweepAngle - 2 * innerCircleDegrees), - forceMoveTo = false - ) - - // Convex start cap, mirroring the end cap. Its end point coincides with the outer - // arc's start point, closing the loop. - arcTo( - rect = Rect( - center = center + polarToCartesian( - radius = innerCircleCenterRadius, - angle = (startAngle + innerCircleDegrees).deg, + startAngleDegrees = startAngle + sweepAngle - innerCircleDegrees, + sweepAngleDegrees = InnerCircleSweepAngleDegrees, + forceMoveTo = false, + ) + + // Inner arc, traversed backwards (negative sweep) so the contour stays continuous; + // it ends back on the inner radius where the start cap begins. + arcTo( + rect = innerRect, + startAngleDegrees = startAngle + sweepAngle - innerCircleDegrees, + sweepAngleDegrees = -(sweepAngle - 2 * innerCircleDegrees), + forceMoveTo = false, + ) + + // Convex start cap, mirroring the end cap. Its end point coincides with the outer + // arc's start point, closing the loop. + arcTo( + rect = Rect( + center = center + polarToCartesian(radius = innerCircleCenterRadius, angle = (startAngle + innerCircleDegrees).deg), + radius = innerCircleRadius, ), - radius = innerCircleRadius, - ), - startAngleDegrees = startAngle + innerCircleDegrees + InnerCircleSweepAngleDegrees, - sweepAngleDegrees = InnerCircleSweepAngleDegrees, - forceMoveTo = false, - ) - - // Seal the contour. The end already meets the start, so this adds at most a - // zero-length line, but it marks the seam as a join (not two-stroke caps) and - // guarantees a watertight region for fill and clip(). - close() - }.let(Outline::Generic) + startAngleDegrees = startAngle + innerCircleDegrees + InnerCircleSweepAngleDegrees, + sweepAngleDegrees = InnerCircleSweepAngleDegrees, + forceMoveTo = false, + ) + + // Seal the contour. The end already meets the start, so this adds at most a + // zero-length line, but it marks the seam as a join (not two-stroke caps) and + // guarantees a watertight region for fill and clip(). + close() + }.let(Outline::Generic) } } @@ -374,81 +376,82 @@ private class ConcaveConvexSlice( val innerCircleRadius = (radius - holeRadius) / 2f val innerCircleCenterRadius = (radius + holeRadius) / 2f - return Path().apply { - // Full-circle special case: a 360° sweep cannot be drawn as an arc. - // Skia reduces an arc with a 360° sweep to a single point — the start and - // end unit vectors coincide, so arcTo()/addArc() emit only a moveTo/lineTo - // and no curve — which would render nothing at all. The 0.01f tolerance also - // catches sweeps that land just below 360° through float accumulation (e.g. - // value * 360f / total): those are equally fragile and would otherwise leave - // a seam with overlapping caps instead of a closed ring. - // A full ring has no start/end caps, so draw the outer and inner ovals and - // cut the hole with the even-odd fill rule. - if (sweepAngle >= FullAngleDegrees - FullAngleToleranceDegrees) { - addOval(outerRect) - if (holeRadius > 0f) addOval(innerRect) - fillType = PathFillType.EvenOdd - return@apply - } - - // Outer arc. forceMoveTo = true starts a fresh contour at the arc's start - // point; on an empty Path the current point is the origin, so 'false' here - // would draw a stray line from (0, 0). - arcTo( - rect = outerRect, - startAngleDegrees = startAngle, - sweepAngleDegrees = sweepAngle, - forceMoveTo = true - ) - - // Convex cap at the end angle, bridging the outer to the inner radius. It is - // a half-circle (InnerCircleSweepAngleDegrees == 180°), so its endpoints land - // exactly on the inner/outer radius — which is what lets the arcs chain. - // forceMoveTo = false from here on keeps everything in ONE contour: a single - // closed path fills correctly, whereas separate subpaths each self-close on - // fill and produce the overlapping wedge artifacts. - arcTo( - rect = Rect( - center = center + polarToCartesian( - radius = innerCircleCenterRadius, - angle = (startAngle + sweepAngle).deg, + return Path() + .apply { + // Full-circle special case: a 360° sweep cannot be drawn as an arc. + // Skia reduces an arc with a 360° sweep to a single point — the start and + // end unit vectors coincide, so arcTo()/addArc() emit only a moveTo/lineTo + // and no curve — which would render nothing at all. The 0.01f tolerance also + // catches sweeps that land just below 360° through float accumulation (e.g. + // value * 360f / total): those are equally fragile and would otherwise leave + // a seam with overlapping caps instead of a closed ring. + // A full ring has no start/end caps, so draw the outer and inner ovals and + // cut the hole with the even-odd fill rule. + if (sweepAngle >= FullAngleDegrees - FullAngleToleranceDegrees) { + addOval(outerRect) + if (holeRadius > 0f) addOval(innerRect) + fillType = PathFillType.EvenOdd + return@apply + } + + // Outer arc. forceMoveTo = true starts a fresh contour at the arc's start + // point; on an empty Path the current point is the origin, so 'false' here + // would draw a stray line from (0, 0). + arcTo( + rect = outerRect, + startAngleDegrees = startAngle, + sweepAngleDegrees = sweepAngle, + forceMoveTo = true, + ) + + // Convex cap at the end angle, bridging the outer to the inner radius. It is + // a half-circle (InnerCircleSweepAngleDegrees == 180°), so its endpoints land + // exactly on the inner/outer radius — which is what lets the arcs chain. + // forceMoveTo = false from here on keeps everything in ONE contour: a single + // closed path fills correctly, whereas separate subpaths each self-close on + // fill and produce the overlapping wedge artifacts. + arcTo( + rect = Rect( + center = center + polarToCartesian( + radius = innerCircleCenterRadius, + angle = (startAngle + sweepAngle).deg, + ), + radius = innerCircleRadius, ), - radius = innerCircleRadius, - ), - startAngleDegrees = startAngle + sweepAngle, - sweepAngleDegrees = InnerCircleSweepAngleDegrees, - forceMoveTo = false, - ) - - // Inner arc, traversed backwards (negative sweep) so the contour stays - // continuous; it ends back on the inner radius at the start angle. - arcTo( - rect = innerRect, - startAngleDegrees = startAngle + sweepAngle, - sweepAngleDegrees = -sweepAngle, - forceMoveTo = false - ) - - // Concave cap at the start angle, traversed backwards (inner -> outer) to - // close the loop. Its end point coincides with the outer arc's start point. - arcTo( - rect = Rect( - center = center + polarToCartesian( - radius = innerCircleCenterRadius, - angle = startAngle.deg, + startAngleDegrees = startAngle + sweepAngle, + sweepAngleDegrees = InnerCircleSweepAngleDegrees, + forceMoveTo = false, + ) + + // Inner arc, traversed backwards (negative sweep) so the contour stays + // continuous; it ends back on the inner radius at the start angle. + arcTo( + rect = innerRect, + startAngleDegrees = startAngle + sweepAngle, + sweepAngleDegrees = -sweepAngle, + forceMoveTo = false, + ) + + // Concave cap at the start angle, traversed backwards (inner -> outer) to + // close the loop. Its end point coincides with the outer arc's start point. + arcTo( + rect = Rect( + center = center + polarToCartesian( + radius = innerCircleCenterRadius, + angle = startAngle.deg, + ), + radius = innerCircleRadius, ), - radius = innerCircleRadius, - ), - startAngleDegrees = startAngle + InnerCircleSweepAngleDegrees, - sweepAngleDegrees = -InnerCircleSweepAngleDegrees, - forceMoveTo = false, - ) - - // Seal the contour. The end already meets the start, so this adds at most a - // zero-length line, but it marks the seam as a join (not two-stroke caps) and - // guarantees a watertight region for fill and clip(). - close() - }.let(Outline::Generic) + startAngleDegrees = startAngle + InnerCircleSweepAngleDegrees, + sweepAngleDegrees = -InnerCircleSweepAngleDegrees, + forceMoveTo = false, + ) + + // Seal the contour. The end already meets the start, so this adds at most a + // zero-length line, but it marks the seam as a join (not two-stroke caps) and + // guarantees a watertight region for fill and clip(). + close() + }.let(Outline::Generic) } }