From c9daded8560f2e7d8e7812c84cd1d023b90c0fd7 Mon Sep 17 00:00:00 2001 From: Peter G Date: Fri, 26 Jun 2026 12:59:24 +0200 Subject: [PATCH 1/2] Build bar plot shapes as single coherent paths The plano-convex bar shapes assembled their outline from separate subpaths (raw addRect + addArc, and two addPath unions in the parameterized variants). Filling renders this correctly, but stroking the outline via DefaultBar's border draws the rectangle's interior edge as a stray line across the bar at the body/cap junction. - Merge body and cap into one contour via union (+) in DefaultVerticalPlanoConvexShape, DefaultHorizontalPlanoConvexShape and the index != 0 branches of VerticalPlanoConvexShape / HorizontalPlanoConvexShape, removing the internal seam when stroked - Leave the bi-convex shapes unchanged: their closing difference (-) already normalizes the boundary into a clean single contour - index == 0 branches: return the (optionally inverted) path explicitly instead of mutating the wrapped outline in place - Fill area and geometry are unchanged; the fix only affects the stroke/border case --- .../koalaplot/core/bar/BarPlotShapes.kt | 318 +++++++++--------- 1 file changed, 163 insertions(+), 155 deletions(-) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt index b5dd6242b..413e526f4 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt @@ -34,29 +34,35 @@ private val DefaultVerticalPlanoConvexShape: Shape = object : Shape { val shapeHeight = size.height val arcRadius = shapeWidth / 2 - return Path() - .apply { - val rectHeight = max((shapeHeight - arcRadius), 0F) - addRect( - rect = Rect( - offset = Offset(0F, arcRadius), - size = Size(shapeWidth, rectHeight), - ), - ) + val rectHeight = max((shapeHeight - arcRadius), 0F) + val body = Path().apply { + addRect( + rect = Rect( + offset = Offset(0F, arcRadius), + size = Size(shapeWidth, rectHeight), + ), + ) + } - val heightRadiusOffset = max((arcRadius - shapeHeight), 0F) - val heightRadiusOffsetDegrees = - asin(heightRadiusOffset / arcRadius) - .rad - .toDegrees() - .value - .toFloat() - addArc( - oval = Size(shapeWidth, shapeWidth).toRect(), - startAngleDegrees = 180F + heightRadiusOffsetDegrees, - sweepAngleDegrees = 180F - 2 * heightRadiusOffsetDegrees, - ) - }.let(Outline::Generic) + val heightRadiusOffset = max((arcRadius - shapeHeight), 0F) + val heightRadiusOffsetDegrees = + asin(heightRadiusOffset / arcRadius) + .rad + .toDegrees() + .value + .toFloat() + val cap = Path().apply { + addArc( + oval = Size(shapeWidth, shapeWidth).toRect(), + startAngleDegrees = 180F + heightRadiusOffsetDegrees, + sweepAngleDegrees = 180F - 2 * heightRadiusOffsetDegrees, + ) + } + + // Merge body and cap into a single contour. As separate subpaths the rectangle keeps its + // interior top edge, which would be stroked as a stray line when the bar is drawn with a + // border. The union removes that internal edge while leaving the filled area unchanged. + return (body + cap).let(Outline::Generic) } } @@ -76,27 +82,29 @@ private val DefaultHorizontalPlanoConvexShape: Shape = object : Shape { val shapeHeight = size.height val arcRadius = shapeHeight / 2 - return Path() - .apply { - val rectWidth = max((shapeWidth - arcRadius), 0F) - addRect(Size(rectWidth, shapeHeight).toRect()) + val rectWidth = max((shapeWidth - arcRadius), 0F) + val body = Path().apply { addRect(Size(rectWidth, shapeHeight).toRect()) } + + val widthRadiusOffset = max((arcRadius - shapeWidth), 0F) + val widthRadiusOffsetDegrees = + asin(widthRadiusOffset / arcRadius) + .rad + .toDegrees() + .value + .toFloat() + val cap = Path().apply { + addArc( + oval = Rect( + offset = Offset(rectWidth - arcRadius - widthRadiusOffset, 0F), + size = Size(shapeHeight, shapeHeight), + ), + startAngleDegrees = 270F + widthRadiusOffsetDegrees, + sweepAngleDegrees = 180F - 2 * widthRadiusOffsetDegrees, + ) + } - val widthRadiusOffset = max((arcRadius - shapeWidth), 0F) - val widthRadiusOffsetDegrees = - asin(widthRadiusOffset / arcRadius) - .rad - .toDegrees() - .value - .toFloat() - addArc( - oval = Rect( - offset = Offset(rectWidth - arcRadius - widthRadiusOffset, 0F), - size = Size(shapeHeight, shapeHeight), - ), - startAngleDegrees = 270F + widthRadiusOffsetDegrees, - sweepAngleDegrees = 180F - 2 * widthRadiusOffsetDegrees, - ) - }.let(Outline::Generic) + // Single contour (see DefaultVerticalPlanoConvexShape for the rationale). + return (body + cap).let(Outline::Generic) } } @@ -207,16 +215,16 @@ public class VerticalPlanoConvexShape>( val isInverted = value.y.end < value.y.start // Required for proper bar rendering in waterfall charts if (index == 0) { - val outline = - DefaultVerticalPlanoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic - - outline.path.apply { - // Rendering bar in negative direction - if (isInverted) { - inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) - } + val path = + (DefaultVerticalPlanoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic).path + // Explicitly thread the (optionally inverted) path into the result instead of mutating + // the wrapped outline in place. + val result = if (isInverted) { + path.inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) + } else { + path } - return outline + return result.let(Outline::Generic) } val (yZeroOffset, yMinOffset, yMaxOffset) = yAxisModel.yOffsets(value.y.start, value.y.end) @@ -252,51 +260,50 @@ public class VerticalPlanoConvexShape>( .value .toFloat() - Path() + val cap = Path().apply { + addArc( + oval = Size(shapeWidth, shapeWidth).toRect(), + startAngleDegrees = 180F + yMaxZeroArcHeightDegrees, + sweepAngleDegrees = 180F - 2 * yMaxZeroArcHeightDegrees, + ) + } - Path().apply { + addArc( + oval = Rect( + offset = Offset(0F, shapeHeight), + size = Size(shapeWidth, shapeWidth), + ), + startAngleDegrees = 180F, + sweepAngleDegrees = 180F, + ) + } + + val body = Path().apply { + addRect( + rect = Rect( + offset = Offset(0F, arcRadius), + size = Size(shapeWidth, max(shapeHeight - yMinZeroArcHeight, 0F)), + ), + ) + } - Path().apply { + addArc( + oval = Rect( + offset = Offset(0F, shapeHeight), + size = Size(shapeWidth, shapeWidth), + ), + startAngleDegrees = 180F, + sweepAngleDegrees = 180F, + ) + } + + // Union cap and body into a single contour (no internal seam when stroked with a border). + (cap + body) .apply { - ( - Path().apply { - addArc( - oval = Size(shapeWidth, shapeWidth).toRect(), - startAngleDegrees = 180F + yMaxZeroArcHeightDegrees, - sweepAngleDegrees = 180F - 2 * yMaxZeroArcHeightDegrees, - ) - } - Path().apply { - addArc( - oval = Rect( - offset = Offset(0F, shapeHeight), - size = Size(shapeWidth, shapeWidth), - ), - startAngleDegrees = 180F, - sweepAngleDegrees = 180F, - ) - } - ).let(::addPath) - - ( - Path().apply { - addRect( - rect = Rect( - offset = Offset(0F, arcRadius), - size = Size(shapeWidth, max(shapeHeight - yMinZeroArcHeight, 0F)), - ), - ) - } - Path().apply { - addArc( - oval = Rect( - offset = Offset(0F, shapeHeight), - size = Size(shapeWidth, shapeWidth), - ), - startAngleDegrees = 180F, - sweepAngleDegrees = 180F, - ) - } - ).let(::addPath) // Rendering bar in negative direction if (isInverted) { inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) } - }.let(Outline::Generic) + } + .let(Outline::Generic) } } } @@ -329,16 +336,16 @@ public class HorizontalPlanoConvexShape> val isInverted = value.x.end < value.x.start // Required for proper bar rendering in waterfall charts if (index == 0) { - val outline = - DefaultHorizontalPlanoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic - - outline.path.apply { - // Rendering bar in negative direction - if (isInverted) { - inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) - } + val path = + (DefaultHorizontalPlanoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic).path + // Explicitly thread the (optionally inverted) path into the result instead of mutating + // the wrapped outline in place. + val result = if (isInverted) { + path.inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) + } else { + path } - return outline + return result.let(Outline::Generic) } val (xZeroOffset, xMinOffset, xMaxOffset) = xAxisModel.xOffsets(value.x.start, value.x.end) @@ -374,49 +381,49 @@ public class HorizontalPlanoConvexShape> .value .toFloat() - Path() + val cap = Path().apply { + val rectWidth = max((shapeWidth - arcRadius), 0F) + val widthRadiusOffset = max((arcRadius - shapeWidth), 0F) + addArc( + oval = Rect( + offset = Offset(rectWidth - arcRadius - widthRadiusOffset, 0F), + size = Size(shapeHeight, shapeHeight), + ), + startAngleDegrees = 270F + xMaxZeroArcWidthDegrees, + sweepAngleDegrees = 180F - 2 * xMaxZeroArcWidthDegrees, + ) + } - Path().apply { + addArc( + oval = Rect(offset = Offset(-shapeHeight, 0F), size = Size(shapeHeight, shapeHeight)), + startAngleDegrees = 270F, + sweepAngleDegrees = 180F, + ) + } + + val body = Path().apply { + addRect( + rect = Rect( + offset = Offset(-arcRadius + xMinZeroArcWidth, 0F), + size = Size(max(shapeWidth - xMinZeroArcWidth, 0F), shapeHeight), + ), + ) + } - Path().apply { + addArc( + oval = Rect(offset = Offset(-shapeHeight, 0F), size = Size(shapeHeight, shapeHeight)), + startAngleDegrees = 270F, + sweepAngleDegrees = 180F, + ) + } + + // Union cap and body into a single contour (no internal seam when stroked with a border). + (cap + body) .apply { - ( - Path().apply { - val rectWidth = max((shapeWidth - arcRadius), 0F) - val widthRadiusOffset = max((arcRadius - shapeWidth), 0F) - addArc( - oval = Rect( - offset = Offset(rectWidth - arcRadius - widthRadiusOffset, 0F), - size = Size(shapeHeight, shapeHeight), - ), - startAngleDegrees = 270F + xMaxZeroArcWidthDegrees, - sweepAngleDegrees = 180F - 2 * xMaxZeroArcWidthDegrees, - ) - } - Path().apply { - addArc( - oval = Rect(offset = Offset(-shapeHeight, 0F), size = Size(shapeHeight, shapeHeight)), - startAngleDegrees = 270F, - sweepAngleDegrees = 180F, - ) - } - ).let(::addPath) - ( - Path().apply { - addRect( - rect = Rect( - offset = Offset(-arcRadius + xMinZeroArcWidth, 0F), - size = Size(max(shapeWidth - xMinZeroArcWidth, 0F), shapeHeight), - ), - ) - } - Path().apply { - addArc( - oval = Rect(offset = Offset(-shapeHeight, 0F), size = Size(shapeHeight, shapeHeight)), - startAngleDegrees = 270F, - sweepAngleDegrees = 180F, - ) - } - ).let(::addPath) // Rendering bar in negative direction if (isInverted) { inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) } - }.let(Outline::Generic) + } + .let(Outline::Generic) } } } @@ -465,15 +472,16 @@ public class VerticalBiConvexShape> privat val isInverted = value.y.end < value.y.start // Required for proper bar rendering in waterfall charts if (index == 0) { - val outline = DefaultVerticalBiConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic - - outline.path.apply { - // Rendering bar in negative direction - if (isInverted) { - inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) - } + val path = + (DefaultVerticalBiConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic).path + // Explicitly thread the (optionally inverted) path into the result instead of mutating + // the wrapped outline in place. + val result = if (isInverted) { + path.inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) + } else { + path } - return outline + return result.let(Outline::Generic) } val (yZeroOffset, yMinOffset, yMaxOffset) = yAxisModel.yOffsets(value.y.start, value.y.end) @@ -569,16 +577,16 @@ public class HorizontalBiConvexShape> pr val isInverted = value.x.end < value.x.start // Required for proper bar rendering in waterfall charts if (index == 0) { - val outline = - DefaultHorizontalBiConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic - - outline.path.apply { - // Rendering bar in negative direction - if (isInverted) { - inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) - } + val path = + (DefaultHorizontalBiConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic).path + // Explicitly thread the (optionally inverted) path into the result instead of mutating + // the wrapped outline in place. + val result = if (isInverted) { + path.inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) + } else { + path } - return outline + return result.let(Outline::Generic) } val (xZeroOffset, xMinOffset, xMaxOffset) = xAxisModel.xOffsets(value.x.start, value.x.end) From 33f27907d7599f742e1c9f882e269c3591e64cce Mon Sep 17 00:00:00 2001 From: Peter G Date: Fri, 26 Jun 2026 13:59:56 +0200 Subject: [PATCH 2/2] fixes formatting issue --- .../kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt index 413e526f4..90fe2f5c1 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt @@ -302,8 +302,7 @@ public class VerticalPlanoConvexShape>( if (isInverted) { inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) } - } - .let(Outline::Generic) + }.let(Outline::Generic) } } } @@ -422,8 +421,7 @@ public class HorizontalPlanoConvexShape> if (isInverted) { inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) } - } - .let(Outline::Generic) + }.let(Outline::Generic) } } }