CubicShape.kt

/*
 * 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 android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF

/**
 * This shape is defined by the list of [Cubic] curves with which it is created.
 * The list is contiguous. That is, a path based on this list
 * starts at the first anchor point of the first cubic, with each new cubic starting
 * at the end of each current cubic (i.e., the second anchor point of each cubic
 * is the same as the first anchor point of the next cubic). The final
 * cubic ends at the first anchor point of the initial cubic.
 */
class CubicShape internal constructor() {

    /**
     * Constructs a [CubicShape] with the given list of [Cubic]s. The list is copied
     * internally to ensure immutability of this shape.
     * @throws IllegalArgumentException The last point of each cubic must match the
     * first point of the next cubic (with the final cubic's last point matching
     * the first point of the first cubic in the list).
     */
    constructor(cubics: List<Cubic>) : this() {
        val copy = mutableListOf<Cubic>()
        var prevCubic = cubics[cubics.size - 1]
        var index = 0
        for (cubic in cubics) {
            if (cubic.p0 != prevCubic.p3) {
                throw IllegalArgumentException("CubicShapes must be contiguous, with the anchor " +
                        "points of all curves matching the anchor points of the preceding and " +
                        "succeeding cubics")
            }
            prevCubic = cubic
            copy.add(Cubic(cubic.p0, cubic.p1, cubic.p2, cubic.p3))
            index++
        }
        updateCubics(copy)
    }

    constructor(sourceShape: CubicShape) : this(sourceShape.cubics)

    /**
     * The ordered list of cubic curves that define this shape.
     */
    lateinit var cubics: List<Cubic>
        private set

    /**
     * The bounds of a shape are a simple min/max bounding box of the points in all of
     * the [Cubic] objects. Note that this is not the same as the bounds of the resulting
     * shape, but is a reasonable (and cheap) way to estimate the bounds. These bounds
     * can be used to, for example, determine the size to scale the object when drawing it.
     */
    var bounds: RectF = RectF()
        internal set

    /**
     * This path object is used for drawing the shape. Callers can retrieve a copy of it with
     * the [toPath] function. The path is updated automatically whenever the shape's
     * [cubics] are updated.
     */
    private val path: Path = Path()

    /**
     * Transforms (scales, rotates, and translates) the shape by the given matrix.
     * Note that this operation alters the points in the shape directly; the original
     * points are not retained, nor is the matrix itself. This calling this function
     * twice with the same matrix will composite the effect. For example, a matrix which
     * scales by 2 will scale the shape by 2. Calling transform twice with that matrix
     * will have the effect os scaling the shape size by 4.
     *
     * @param matrix The matrix used to transform the curve
     * @param points Optional array of Floats used internally. Supplying this array of floats saves
     * allocating the array internally when not provided. Must have size equal to or larger than 8.
     * @throws IllegalArgumentException if [points] is provided but is not large enough to
     * hold 8 values.
     */
    @JvmOverloads
    fun transform(matrix: Matrix, points: FloatArray = FloatArray(8)) {
        if (points.size < 8) {
            throw IllegalArgumentException("points array must be of size >= 8")
        }
        for (cubic in cubics) {
            cubic.transform(matrix, points)
        }
        updateCubics(cubics)
    }

    /**
     * This is called by Polygon's constructor. It should not generally be called later;
     * CubicShape should be immutable.
     */
    internal fun updateCubics(cubics: List<Cubic>) {
        this.cubics = cubics
        calculateBounds()
        updatePath()
    }

    /**
     * A CubicShape is rendered as a [Path]. A copy of the underlying [Path] object can be
     * retrieved for use outside of this class. Note that this function returns a copy of
     * the internal [Path] to maintain immutability, thus there is some overhead in retrieving
     * and using the path with this function.
     */
    fun toPath(): Path {
        return Path(path)
    }

    /**
     * Internal function to update the Path object whenever the cubics are updated.
     * The Path should not be needed until drawing (or being retrieved via [toPath]),
     * but might as well update it immediately since the cubics should not change
     * in the meantime.
     */
    private fun updatePath() {
        path.rewind()
        if (cubics.size > 0) {
            path.moveTo(cubics[0].p0.x, cubics[0].p0.y)
            for (bezier in cubics) {
                path.cubicTo(
                    bezier.p1.x, bezier.p1.y,
                    bezier.p2.x, bezier.p2.y,
                    bezier.p3.x, bezier.p3.y
                )
            }
        }
    }

    internal fun draw(canvas: Canvas, paint: Paint) {
        canvas.drawPath(path, paint)
    }

    /**
     * Calculates estimated bounds of the object, using the min/max bounding box of
     * all points in the cubics that make up the shape.
     */
    private fun calculateBounds() {
        var minX = Float.MAX_VALUE
        var minY = Float.MAX_VALUE
        var maxX = Float.MIN_VALUE
        var maxY = Float.MIN_VALUE
        for (bezier in cubics) {
            with(bezier.p0) {
                if (x < minX) minX = x
                if (y < minY) minY = y
                if (x > maxX) maxX = x
                if (y > maxY) maxY = y
            }
            with(bezier.p1) {
                if (x < minX) minX = x
                if (y < minY) minY = y
                if (x > maxX) maxX = x
                if (y > maxY) maxY = y
            }
            with(bezier.p2) {
                if (x < minX) minX = x
                if (y < minY) minY = y
                if (x > maxX) maxX = x
                if (y > maxY) maxY = y
            }
            // No need to use p3, since it is already taken into account in the next
            // curve's p0 point.
        }
        bounds.set(minX, minY, maxX, maxY)
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as CubicShape

        val otherCubics = other.cubics
        if (cubics.size != otherCubics.size) return false
        for (i in 0 until cubics.size) {
            val cubic = cubics[i]
            val otherCubic = otherCubics[i]
            if (!cubic.equals(otherCubic)) return false
        }

        return true
    }

    override fun hashCode(): Int {
        return cubics.hashCode()
    }
}

/**
 * Extension function which draws the given [CubicShape] object into this [Canvas]. Rendering
 * occurs by drawing the underlying path for the object; callers can optionally retrieve the
 * path and draw it directly via [CubicShape.toPath] (though that function copies the underlying
 * path. This extension function avoids that overhead when rendering).
 *
 * @param shape The object to be drawn
 * @param paint The attributes
 */
fun Canvas.drawCubicShape(shape: CubicShape, paint: Paint) {
    shape.draw(this, paint)
}