blob: 375ab64e9279312a50ad6a0750c38e962df812ea [file] [log] [blame]
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.graphics.shapes
import androidx.annotation.IntRange
import androidx.collection.MutableFloatList
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
/**
* The RoundedPolygon class allows simple construction of polygonal shapes with optional rounding
* at the vertices. Polygons can be constructed with either the number of vertices
* desired or an ordered list of vertices.
*
*/
class RoundedPolygon internal constructor(
internal val features: List<Feature>,
val centerX: Float,
val centerY: Float
) {
/**
* A flattened version of the [Feature]s, as a List<Cubic>.
*/
val cubics = buildList {
// Equivalent to `features.flatMap { it.cubics }` but without Iterator allocation.
for (i in features.indices) {
addAll(features[i].cubics)
}
}
init {
var prevCubic = cubics[cubics.size - 1]
debugLog("RoundedPolygon") { "Cubic-1 = $prevCubic" }
for (index in cubics.indices) {
val cubic = cubics[index]
if (abs(cubic.anchor0X - prevCubic.anchor1X) > DistanceEpsilon ||
abs(cubic.anchor0Y - prevCubic.anchor1Y) > DistanceEpsilon
) {
debugLog("RoundedPolygon") { "Cubic = $cubic" }
debugLog("RoundedPolygon") {
"Ix: $index | (${cubic.anchor0X},${cubic.anchor0Y}) vs " +
"$prevCubic"
}
throw IllegalArgumentException(
"RoundedPolygon must be contiguous, with the " +
"anchor points of all curves matching the anchor points of the preceding " +
"and succeeding cubics"
)
}
prevCubic = cubic
}
}
/**
* Transforms (scales/translates/etc.) this [RoundedPolygon] with the given [PointTransformer]
* and returns a new [RoundedPolygon].
* This is a low level API and there should be more platform idiomatic ways to transform
* a [RoundedPolygon] provided by the platform specific wrapper.
*
* @param f The [PointTransformer] used to transform this [RoundedPolygon]
*/
fun transformed(f: PointTransformer): RoundedPolygon {
val center = Point(centerX, centerY).transformed(f)
return RoundedPolygon(
buildList {
for (i in features.indices) {
add(features[i].transformed(f))
}
},
center.x,
center.y
)
}
/**
* Creates a new RoundedPolygon, moving and resizing this one, so it's completely inside the
* (0, 0) -> (1, 1) square, centered if there extra space in one direction
*/
fun normalized(): RoundedPolygon {
val bounds = calculateBounds()
val width = bounds[2] - bounds[0]
val height = bounds[3] - bounds[1]
val side = max(width, height)
// Center the shape if bounds are not a square
val offsetX = (side - width) / 2 - bounds[0] /* left */
val offsetY = (side - height) / 2 - bounds[1] /* top */
return transformed { x, y -> TransformResult((x + offsetX) / side, (y + offsetY) / side) }
}
override fun toString(): String = "[RoundedPolygon." +
" Cubics = " + cubics.joinToString() +
" || Features = " + features.joinToString() +
" || Center = ($centerX, $centerY)]"
/**
* Calculates estimated bounds of the object, using the min/max bounding box of
* all points in the cubics that make up the shape.
* This is a library-internal API, prefer the appropriate wrapper in your platform.
*/
fun calculateBounds(bounds: FloatArray = FloatArray(4)): FloatArray {
require(bounds.size >= 4) { "Required bounds size of 4" }
var minX = Float.MAX_VALUE
var minY = Float.MAX_VALUE
var maxX = Float.MIN_VALUE
var maxY = Float.MIN_VALUE
for (i in cubics.indices) {
val bezier = cubics[i]
if (bezier.anchor0X < minX) minX = bezier.anchor0X
if (bezier.anchor0Y < minY) minY = bezier.anchor0Y
if (bezier.anchor0X > maxX) maxX = bezier.anchor0X
if (bezier.anchor0Y > maxY) maxY = bezier.anchor0Y
if (bezier.control0X < minX) minX = bezier.control0X
if (bezier.control0Y < minY) minY = bezier.control0Y
if (bezier.control0X > maxX) maxX = bezier.control0X
if (bezier.control0Y > maxY) maxY = bezier.control0Y
if (bezier.control1X < minX) minX = bezier.control1X
if (bezier.control1Y < minY) minY = bezier.control1Y
if (bezier.control1X > maxX) maxX = bezier.control1X
if (bezier.control1Y > maxY) maxY = bezier.control1Y
// No need to use x3/y3, since it is already taken into account in the next
// curve's x0/y0 point.
}
bounds[0] = minX
bounds[1] = minY
bounds[2] = maxX
bounds[3] = maxY
return bounds
}
companion object {}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RoundedPolygon) return false
return features == other.features
}
override fun hashCode(): Int {
return features.hashCode()
}
}
/**
* This constructor takes the number of vertices in the resulting polygon. These vertices are
* positioned on a virtual circle around a given center with each vertex positioned [radius]
* distance from that center, equally spaced (with equal angles between them). If no radius
* is supplied, the shape will be created with a default radius of 1, resulting in a shape
* whose vertices lie on a unit circle, with width/height of 2. That default polygon will
* probably need to be rescaled using [transformed] into the appropriate size for the UI in
* which it will be drawn.
*
* The [rounding] and [perVertexRounding] parameters are optional. If not supplied, the result
* will be a regular polygon with straight edges and unrounded corners.
*
* @param numVertices The number of vertices in this polygon.
* @param radius The radius of the polygon, in pixels. This radius determines the
* initial size of the object, but it can be transformed later by using the [transformed] function.
* @param centerX The X coordinate of the center of the polygon, around which all vertices
* will be placed. The default center is at (0,0).
* @param centerY The Y coordinate of the center of the polygon, around which all vertices
* will be placed. The default center is at (0,0).
* @param rounding The [CornerRounding] properties of all vertices. If some vertices should
* have different rounding properties, then use [perVertexRounding] instead. The default
* rounding value is [CornerRounding.Unrounded], meaning that the polygon will use the vertices
* themselves in the final shape and not curves rounded around the vertices.
* @param perVertexRounding The [CornerRounding] properties of every vertex. If this
* parameter is not null, then it must have [numVertices] elements. If this parameter
* is null, then the polygon will use the [rounding] parameter for every vertex instead. The
* default value is null.
*
* @throws IllegalArgumentException If [perVertexRounding] is not null and its size is not
* equal to [numVertices].
* @throws IllegalArgumentException [numVertices] must be at least 3.
*/
@JvmOverloads
fun RoundedPolygon(
@IntRange(from = 3) numVertices: Int,
radius: Float = 1f,
centerX: Float = 0f,
centerY: Float = 0f,
rounding: CornerRounding = CornerRounding.Unrounded,
perVertexRounding: List<CornerRounding>? = null
) = RoundedPolygon(
verticesFromNumVerts(numVertices, radius, centerX, centerY),
rounding = rounding,
perVertexRounding = perVertexRounding,
centerX = centerX,
centerY = centerY
)
/**
* Creates a copy of the given [RoundedPolygon]
*/
fun RoundedPolygon(source: RoundedPolygon) =
RoundedPolygon(source.features, source.centerX, source.centerY)
/**
* This function takes the vertices (either supplied or calculated, depending on the
* constructor called), plus [CornerRounding] parameters, and creates the actual
* [RoundedPolygon] shape, rounding around the vertices (or not) as specified. The result
* is a list of [Cubic] curves which represent the geometry of the final shape.
*
* @param vertices The list of vertices in this polygon specified as pairs of x/y coordinates in
* this FloatArray. This should be an ordered list (with the outline of the shape going from each
* vertex to the next in order of this list), otherwise the results will be undefined.
* @param rounding The [CornerRounding] properties of all vertices. If some vertices should
* have different rounding properties, then use [perVertexRounding] instead. The default
* rounding value is [CornerRounding.Unrounded], meaning that the polygon will use the vertices
* themselves in the final shape and not curves rounded around the vertices.
* @param perVertexRounding The [CornerRounding] properties of all vertices. If this
* parameter is not null, then it must have the same size as [vertices]. If this parameter
* is null, then the polygon will use the [rounding] parameter for every vertex instead. The
* default value is null.
* @param centerX The X coordinate of the center of the polygon, around which all vertices
* will be placed. The default center is at (0,0).
* @param centerY The Y coordinate of the center of the polygon, around which all vertices
* will be placed. The default center is at (0,0).
* @throws IllegalArgumentException if the number of vertices is less than 3 (the [vertices]
* parameter has less than 6 Floats). Or if the [perVertexRounding] parameter is not null and the
* size doesn't match the number vertices.
*/
// TODO(performance): Update the map calls to more efficient code that doesn't allocate Iterators
// unnecessarily.
@JvmOverloads
fun RoundedPolygon(
vertices: FloatArray,
rounding: CornerRounding = CornerRounding.Unrounded,
perVertexRounding: List<CornerRounding>? = null,
centerX: Float = Float.MIN_VALUE,
centerY: Float = Float.MIN_VALUE
): RoundedPolygon {
if (vertices.size < 6) {
throw IllegalArgumentException("Polygons must have at least 3 vertices")
}
if (vertices.size % 2 == 1) {
throw IllegalArgumentException("The vertices array should have even size")
}
if (perVertexRounding != null && perVertexRounding.size * 2 != vertices.size) {
throw IllegalArgumentException(
"perVertexRounding list should be either null or " +
"the same size as the number of vertices (vertices.size / 2)"
)
}
val corners = mutableListOf<List<Cubic>>()
val n = vertices.size / 2
val roundedCorners = mutableListOf<RoundedCorner>()
for (i in 0 until n) {
val vtxRounding = perVertexRounding?.get(i) ?: rounding
val prevIndex = ((i + n - 1) % n) * 2
val nextIndex = ((i + 1) % n) * 2
roundedCorners.add(
RoundedCorner(
Point(vertices[prevIndex], vertices[prevIndex + 1]),
Point(vertices[i * 2], vertices[i * 2 + 1]),
Point(vertices[nextIndex], vertices[nextIndex + 1]),
vtxRounding
)
)
}
// For each side, check if we have enough space to do the cuts needed, and if not split
// the available space, first for round cuts, then for smoothing if there is space left.
// Each element in this list is a pair, that represent how much we can do of the cut for
// the given side (side i goes from corner i to corner i+1), the elements of the pair are:
// first is how much we can use of expectedRoundCut, second how much of expectedCut
val cutAdjusts = (0 until n).map { ix ->
val expectedRoundCut = roundedCorners[ix].expectedRoundCut +
roundedCorners[(ix + 1) % n].expectedRoundCut
val expectedCut = roundedCorners[ix].expectedCut +
roundedCorners[(ix + 1) % n].expectedCut
val vtxX = vertices[ix * 2]
val vtxY = vertices[ix * 2 + 1]
val nextVtxX = vertices[((ix + 1) % n) * 2]
val nextVtxY = vertices[((ix + 1) % n) * 2 + 1]
val sideSize = distance(vtxX - nextVtxX, vtxY - nextVtxY)
// Check expectedRoundCut first, and ensure we fulfill rounding needs first for
// both corners before using space for smoothing
if (expectedRoundCut > sideSize) {
// Not enough room for fully rounding, see how much we can actually do.
sideSize / expectedRoundCut to 0f
} else if (expectedCut > sideSize) {
// We can do full rounding, but not full smoothing.
1f to (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)
} else {
// There is enough room for rounding & smoothing.
1f to 1f
}
}
// Create and store list of beziers for each [potentially] rounded corner
for (i in 0 until n) {
// allowedCuts[0] is for the side from the previous corner to this one,
// allowedCuts[1] is for the side from this corner to the next one.
val allowedCuts = MutableFloatList(2)
for (delta in 0..1) {
val (roundCutRatio, cutRatio) = cutAdjusts[(i + n - 1 + delta) % n]
allowedCuts.add(
roundedCorners[i].expectedRoundCut * roundCutRatio +
(roundedCorners[i].expectedCut - roundedCorners[i].expectedRoundCut) * cutRatio
)
}
corners.add(
roundedCorners[i].getCubics(
allowedCut0 = allowedCuts[0],
allowedCut1 = allowedCuts[1]
)
)
}
// Finally, store the calculated cubics. This includes all of the rounded corners
// from above, along with new cubics representing the edges between those corners.
val tempFeatures = mutableListOf<Feature>()
for (i in 0 until n) {
// Determine whether corner at this vertex is concave or convex, based on the
// relationship of the prev->curr/curr->next vectors
// Note that these indices are for pairs of values (points), they need to be
// doubled to access the xy values in the vertices float array
val prevVtxIndex = (i + n - 1) % n
val nextVtxIndex = (i + 1) % n
val currVertex = Point(vertices[i * 2], vertices[i * 2 + 1])
val prevVertex = Point(vertices[prevVtxIndex * 2], vertices[prevVtxIndex * 2 + 1])
val nextVertex = Point(vertices[nextVtxIndex * 2], vertices[nextVtxIndex * 2 + 1])
val convex = (currVertex - prevVertex).clockwise(nextVertex - currVertex)
tempFeatures.add(
Feature.Corner(
corners[i], currVertex, roundedCorners[i].center,
convex
)
)
tempFeatures.add(
Feature.Edge(
listOf(
Cubic.straightLine(
corners[i].last().anchor1X, corners[i].last().anchor1Y,
corners[(i + 1) % n].first().anchor0X, corners[(i + 1) % n].first().anchor0Y
)
)
)
)
}
val (cx, cy) = if (centerX == Float.MIN_VALUE || centerY == Float.MIN_VALUE) {
calculateCenter(vertices)
} else {
Point(centerX, centerY)
}
return RoundedPolygon(tempFeatures, cx, cy)
}
/**
* Calculates an estimated center position for the polygon, returning it.
* This function should only be called if the center is not already calculated or provided.
* The Polygon constructor which takes `numVertices` calculates its own center, since it
* knows exactly where it is centered, at (0, 0).
*
* Note that this center will be transformed whenever the shape itself is transformed.
* Any transforms that occur before the center is calculated will be taken into account
* automatically since the center calculation is an average of the current location of
* all cubic anchor points.
*/
private fun calculateCenter(vertices: FloatArray): Point {
var cumulativeX = 0f
var cumulativeY = 0f
var index = 0
while (index < vertices.size) {
cumulativeX += vertices[index++]
cumulativeY += vertices[index++]
}
return Point(cumulativeX / vertices.size / 2, cumulativeY / vertices.size / 2)
}
/**
* Private utility class that holds the information about each corner in a polygon. The shape
* of the corner can be returned by calling the [getCubics] function, which will return a list
* of curves representing the corner geometry. The shape of the corner depends on the [rounding]
* constructor parameter.
*
* If rounding is null, there is no rounding; the corner will simply be a single point at [p1].
* This point will be represented by a [Cubic] of length 0 at that point.
*
* If rounding is not null, the corner will be rounded either with a curve approximating a circular
* arc of the radius specified in [rounding], or with three curves if [rounding] has a nonzero
* smoothing parameter. These three curves are a circular arc in the middle and two symmetrical
* flanking curves on either side. The smoothing parameter determines the curvature of the
* flanking curves.
*
* This is a class because we usually need to do the work in 2 steps, and prefer to keep state
* between: first we determine how much we want to cut to comply with the parameters, then we are
* given how much we can actually cut (because of space restrictions outside this corner)
*
* @param p0 the vertex before the one being rounded
* @param p1 the vertex of this rounded corner
* @param p2 the vertex after the one being rounded
* @param rounding the optional parameters specifying how this corner should be rounded
*/
private class RoundedCorner(
val p0: Point,
val p1: Point,
val p2: Point,
val rounding: CornerRounding? = null
) {
val d1 = (p0 - p1).getDirection()
val d2 = (p2 - p1).getDirection()
val cornerRadius = rounding?.radius ?: 0f
val smoothing = rounding?.smoothing ?: 0f
// cosine of angle at p1 is dot product of unit vectors to the other two vertices
val cosAngle = d1.dotProduct(d2)
// identity: sin^2 + cos^2 = 1
// sinAngle gives us the intersection
val sinAngle = sqrt(1 - square(cosAngle))
// How much we need to cut, as measured on a side, to get the required radius
// calculating where the rounding circle hits the edge
// This uses the identity of tan(A/2) = sinA/(1 + cosA), where tan(A/2) = radius/cut
val expectedRoundCut =
if (sinAngle > 1e-3) {
cornerRadius * (cosAngle + 1) / sinAngle
} else {
0f
}
// smoothing changes the actual cut. 0 is same as expectedRoundCut, 1 doubles it
val expectedCut: Float
get() = ((1 + smoothing) * expectedRoundCut)
// the center of the circle approximated by the rounding curve (or the middle of the three
// curves if smoothing is requested). The center is the same as p0 if there is no rounding.
var center: Point = Point(0f, 0f)
@JvmOverloads
fun getCubics(allowedCut0: Float, allowedCut1: Float = allowedCut0):
List<Cubic> {
// We use the minimum of both cuts to determine the radius, but if there is more space
// in one side we can use it for smoothing.
val allowedCut = min(allowedCut0, allowedCut1)
// Nothing to do, just use lines, or a point
if (expectedRoundCut < DistanceEpsilon ||
allowedCut < DistanceEpsilon ||
cornerRadius < DistanceEpsilon
) {
center = p1
return listOf(Cubic.straightLine(p1.x, p1.y, p1.x, p1.y))
}
// How much of the cut is required for the rounding part.
val actualRoundCut = min(allowedCut, expectedRoundCut)
// We have two smoothing values, one for each side of the vertex
// Space is used for rounding values first. If there is space left over, then we
// apply smoothing, if it was requested
val actualSmoothing0 = calculateActualSmoothingValue(allowedCut0)
val actualSmoothing1 = calculateActualSmoothingValue(allowedCut1)
// Scale the radius if needed
val actualR = cornerRadius * actualRoundCut / expectedRoundCut
// Distance from the corner (p1) to the center
val centerDistance = sqrt(square(actualR) + square(actualRoundCut))
// Center of the arc we will use for rounding
center = p1 + ((d1 + d2) / 2f).getDirection() * centerDistance
val circleIntersection0 = p1 + d1 * actualRoundCut
val circleIntersection2 = p1 + d2 * actualRoundCut
val flanking0 = computeFlankingCurve(
actualRoundCut, actualSmoothing0, p1, p0,
circleIntersection0, circleIntersection2, center, actualR
)
val flanking2 = computeFlankingCurve(
actualRoundCut, actualSmoothing1, p1, p2,
circleIntersection2, circleIntersection0, center, actualR
).reverse()
return listOf(
flanking0,
Cubic.circularArc(
center.x, center.y, flanking0.anchor1X, flanking0.anchor1Y,
flanking2.anchor0X, flanking2.anchor0Y
),
flanking2
)
}
/**
* If allowedCut (the amount we are able to cut) is greater than the expected cut
* (without smoothing applied yet), then there is room to apply smoothing and we
* calculate the actual smoothing value here.
*/
private fun calculateActualSmoothingValue(allowedCut: Float): Float {
return if (allowedCut > expectedCut) {
smoothing
} else if (allowedCut > expectedRoundCut) {
smoothing * (allowedCut - expectedRoundCut) / (expectedCut - expectedRoundCut)
} else {
0f
}
}
/**
* Compute a Bezier to connect the linear segment defined by corner and sideStart
* with the circular segment defined by circleCenter, circleSegmentIntersection,
* otherCircleSegmentIntersection and actualR.
* The bezier will start at the linear segment and end on the circular segment.
*
* @param actualRoundCut How much we are cutting of the corner to add the circular segment
* (this is before smoothing, that will cut some more).
* @param actualSmoothingValues How much we want to smooth (this is the smooth parameter,
* adjusted down if there is not enough room).
* @param corner The point at which the linear side ends
* @param sideStart The point at which the linear side starts
* @param circleSegmentIntersection The point at which the linear side and the circle intersect.
* @param otherCircleSegmentIntersection The point at which the opposing linear side and the
* circle intersect.
* @param circleCenter The center of the circle.
* @param actualR The radius of the circle.
*
* @return a Bezier cubic curve that connects from the (cut) linear side and the (cut) circular
* segment in a smooth way.
*/
private fun computeFlankingCurve(
actualRoundCut: Float,
actualSmoothingValues: Float,
corner: Point,
sideStart: Point,
circleSegmentIntersection: Point,
otherCircleSegmentIntersection: Point,
circleCenter: Point,
actualR: Float
): Cubic {
// sideStart is the anchor, 'anchor' is actual control point
val sideDirection = (sideStart - corner).getDirection()
val curveStart = corner + sideDirection * actualRoundCut * (1 + actualSmoothingValues)
// We use an approximation to cut a part of the circle section proportional to 1 - smooth,
// When smooth = 0, we take the full section, when smooth = 1, we take nothing.
// TODO: revisit this, it can be problematic as it approaches 180 degrees
val p = interpolate(
circleSegmentIntersection,
(circleSegmentIntersection + otherCircleSegmentIntersection) / 2f,
actualSmoothingValues
)
// The flanking curve ends on the circle
val curveEnd = circleCenter +
directionVector(p.x - circleCenter.x, p.y - circleCenter.y) * actualR
// The anchor on the circle segment side is in the intersection between the tangent to the
// circle in the circle/flanking curve boundary and the linear segment.
val circleTangent = (curveEnd - circleCenter).rotate90()
val anchorEnd = lineIntersection(sideStart, sideDirection, curveEnd, circleTangent)
?: circleSegmentIntersection
// From what remains, we pick a point for the start anchor.
// 2/3 seems to come from design tools?
val anchorStart = (curveStart + anchorEnd * 2f) / 3f
return Cubic(curveStart, anchorStart, anchorEnd, curveEnd)
}
/**
* Returns the intersection point of the two lines d0->d1 and p0->p1, or null if the
* lines do not intersect
*/
private fun lineIntersection(p0: Point, d0: Point, p1: Point, d1: Point): Point? {
val rotatedD1 = d1.rotate90()
val den = d0.dotProduct(rotatedD1)
if (abs(den) < AngleEpsilon) return null
val k = (p1 - p0).dotProduct(rotatedD1) / den
return p0 + d0 * k
}
}