All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.io.nacular.doodle.geometry.Polygon.kt Maven / Gradle / Ivy

There is a newer version: 0.10.4
Show newest version
package io.nacular.doodle.geometry

import io.nacular.doodle.drawing.AffineTransform.Companion.Identity
import io.nacular.measured.units.Angle
import io.nacular.measured.units.Angle.Companion.cos
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.Angle.Companion.sin
import io.nacular.measured.units.Measure
import io.nacular.measured.units.times
import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.min
import kotlin.math.sqrt

/**
 * A [Shape] defined by a set of line segments that connect to enclose a region.
 */
public abstract class Polygon: Shape {

    /** Points representing the vertices */
    public abstract val points: List

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

        if (points != other.points) return false

        return true
    }

    override fun hashCode(): Int = points.hashCode()

    /**
     * Gives the smallest [Rectangle] that fully contains this Polygon.
     *
     * ```
     * ┌─────────────────────┐
     * │     **********      │
     * │   ************      │
     * │ **************      │
     * │*********************│
     * │*********************│
     * │******************** │
     * │*******************  │
     * │       ************  │
     * │        ***********  │
     * │         **********  │
     * └─────────────────────┘
     * ```
     */
    override val boundingRectangle: Rectangle by lazy {
        val minX = points.minByOrNull { it.x }!!.x
        val minY = points.minByOrNull { it.y }!!.y
        val maxX = points.maxByOrNull { it.x }!!.x
        val maxY = points.maxByOrNull { it.y }!!.y

        Rectangle(Point(minX, minY), Size(maxX - minX, maxY - minY))
    }
}

/**
 * A Polygon with internal angles all <= 180°
 */
public abstract class ConvexPolygon: Polygon() {
    private class Line(val start: Point, val end: Point)

    // https://en.wikipedia.org/wiki/Shoelace_formula
    override val area: Double by lazy {
        var area = 0.0              // Accumulates area in the loop
        var j    = points.size - 1  // The last vertex is the 'previous' one to the first
        var i    = 0

        while (i < points.size) {
            val ith = points[i]
            val jth = points[j]

            area += (jth.x + ith.x) * (jth.y - ith.y)
            j = i  //j is previous vertex to i
            i++
        }

        abs(area / 2)
    }

    override val empty: Boolean by lazy { area == 0.0 }

    /**
     * Uses winding-number approach
     * http://geomalgorithms.com/a03-_inclusion.html
     */
    override fun contains(point: Point): Boolean {
        var result = 0 // the winding number counter

        points.forEachIndexed { i, vertex ->
            val index = (i+1).rem(points.size)

            when {
                vertex.y <= point.y ->
                    if (points[index].y > point.y && isLeft(Line(vertex, points[index]), point) > 0) {
                        ++result
                    }
                    // have a valid up intersect
                else ->
                    if (points[index].y <= point.y && isLeft(Line(vertex, points[index]), point) < 0) {
                        --result
                    }
                    // have a valid down intersect
            }
        }

        return result != 0
    }

    /** @return ```true``` IFF the given rectangle falls within the boundaries of this Polygon */
    override fun contains(rectangle: Rectangle): Boolean = rectangle.points.all { contains(it) }

    override fun intersects(rectangle: Rectangle): Boolean = TODO("not implemented")

    private fun isLeft(line: Line, point: Point): Int = ((line.end.x - line.start.x) * (point.y - line.start.y) - (point.x - line.start.x) * (line.end.y - line.start.y)).toInt()

    public companion object {
        public operator fun invoke(first: Point, second: Point, third: Point): ConvexPolygon {
            return ConvexPolygonImpl(first, second, third)
        }

        // FIXME: Make this internal to avoid invalid "convex" poly creation
        public operator fun invoke(first: Point, second: Point, third: Point, vararg remaining: Point): ConvexPolygon {
            return ConvexPolygonImpl(first, second, third, *remaining)
        }
    }
}

private class ConvexPolygonImpl(override val points: List): ConvexPolygon() {
    constructor(first: Point, second: Point, third: Point, vararg remaining: Point): this(listOf(first, second, third) + remaining)
}

/**
 * Creates a [Regular polygon](https://en.wikipedia.org/wiki/Regular_polygon) by inscribing it within the given circle.
 *
 * @param circle to inscribe the polygon in
 * @param sides the polygon should have
 * @param rotation of the polygon's first point around the circle
 * @return the polygon
 */
public fun inscribed(circle: Circle, sides: Int, rotation: Measure = 0 * degrees): ConvexPolygon? {
    if (sides < 3) return null

    val interPointSweep = 360 / sides * degrees
    val topOfCircle     = circle.center - Point(0.0, circle.radius)

    val points = mutableListOf(Point(topOfCircle.x + circle.radius * sin(rotation), topOfCircle.y + circle.radius * (1 - cos(rotation))))

    var current: Point

    repeat(sides - 1) {
        val angle = interPointSweep * (it + 1) + rotation
        current = Point(topOfCircle.x + circle.radius * sin(angle), topOfCircle.y + circle.radius * (1 - cos(angle)))
        points += current
    }

    return ConvexPolygonImpl(points)
}

/**
 * Creates a [Star](https://math.stackexchange.com/questions/2135982/math-behind-creating-a-perfect-star) with n points
 * that is described by an outer and inner [Circle], which its concave and convex points respectively.
 *
 * @param circle to inscribe the polygon in
 * @param points the star should have
 * @param rotation of the star's first point around the circle
 * @param innerCircle defining the radius of the inner points
 * @return a star shaped polygon
 */
public fun star(circle     : Circle,
         points     : Int            = 5,
         rotation   : Measure = 0 * degrees,
         innerCircle: Circle         = Circle(center = circle.center, radius = circle.radius * 2 / (3 + sqrt(5.0)))
): Polygon? = inscribed(circle, points, rotation)?.let { outerPoly ->
    inscribed(innerCircle, points, rotation + 360 / (2 * points) * degrees)?.let { innerPoly ->
        ConvexPolygonImpl(outerPoly.points.zip(innerPoly.points).flatMap { (f, s) -> listOf(f, s) })
    }
}

/**
 * Creates a rounded shape from a [Polygon]. The resulting shape is essentially a polygon with
 * the vertices rounded using a semi-circular curve.
 *
 * @see Polygon.rounded with config for control over radius at each point
 * @param radius for each point
 * @param filter deciding which points to apply the radius to
 * @return a [Path] for the new shape
 */
public fun Polygon.rounded(radius: Double, filter: (index: Int, Point) -> Boolean = { _,_ -> true }): Path = rounded { index, point ->
    when (filter(index, point)) {
        true -> radius
        else -> 0.0
    }
}

/**
 * Creates a rounded shape from a [Polygon]. The resulting shape is essentially a polygon with
 * the vertices rounded using a semi-circular curve.
 *
 * @param config determining the radius for each point in the polygon (with the given index)
 * @return a [Path] for the new shape
 */
public fun Polygon.rounded(config: (index: Int, Point) -> Double): Path {
    val newPoints = mutableListOf()
    val radii     = mutableListOf()

    points.forEachIndexed { index, point ->
        radii += config(index, point)
    }

    points.forEachIndexed { index, point ->
        newPoints += when (val radius = radii[index]) {
            0.0  -> { PointRelationShip(point, point, point, 0.0, 0 * degrees, true) }
            else -> {
                val previousIndex = when (index) {
                    0    -> points.size - 1
                    else -> index - 1
                }

                val nextIndex = (index + 1) % points.size

                colinearPoint(points[previousIndex], point, points[nextIndex], radii[previousIndex], radius, radii[nextIndex])
            }
        }
    }

    val builder = path(newPoints[0].previous)

    newPoints.forEachIndexed { index, it ->
        if (index > 0) {
            builder.lineTo(it.previous)
        }

        val r = it.distance / (2 * cos(it.angle / 2))

        builder.arcTo(it.next, r, r, 0 * degrees, largeArch = false, sweep = it.isRight)
    }

    return builder.close()
}

private class PointRelationShip(
        val previous: Point,
        val point   : Point,
        val next    : Point,
        val distance: Double,
        val angle   : Measure,
        val isRight : Boolean
)

private class Vector(val x: Double, val y: Double) {
    operator fun plus (other: Vector) = Vector(x + other.x, y + other.y)
    operator fun minus(other: Vector) = Vector(x - other.x, y - other.y)

    operator fun times(other: Vector) = x * other.x + y * other.y

    val magnitude by lazy { sqrt(x*x + y*y) }
}

private fun Point.asVector() = Vector(x, y)

private fun colinearPoint(
        previous        : Point,
        point           : Point,
        next            : Point,
        previousDistance: Double,
        distance        : Double,
        nextDistance    : Double
): PointRelationShip {
    if (point == previous || point == next) return PointRelationShip(point, point, point, 0.0, 0 * degrees, true)

    val distancePrevious = point.distanceFrom(previous)
    val distanceNext     = point.distanceFrom(next    )

    val left             = when {
        distance + previousDistance > distancePrevious -> distancePrevious / (distance + previousDistance) * distance
        else                                           -> distance
    }

    val right            = when {
        distance + nextDistance > distanceNext -> distanceNext / (distance + nextDistance) * distance
        else                                           -> distance
    }

    val radius = min(left, right)

    val scalePrevious = radius / distancePrevious
    val scaleNext     = radius / distanceNext

    val vector1 = point.asVector() - previous.asVector()
    val vector2 = next.asVector () - point.asVector   ()

    // interior angle := cos(α) = a·b / (|a|·|b|)
    val angle = 180 * degrees - acos(vector1 * vector2 / (vector1.magnitude * vector2.magnitude)) * Angle.radians

    val direction = vector1.x * vector2.y - vector1.y * vector2.x

    val newPrevious = Identity.scale(around = point, x = scalePrevious, y = scalePrevious).invoke(previous)
    val newNext     = Identity.scale(around = point, x = scaleNext,     y = scaleNext    ).invoke(next    )

    return PointRelationShip(
            previous = newPrevious,
            point    = point,
            next     = newNext,
            distance = newPrevious.distanceFrom(newNext),
            angle    = angle,
            isRight  = direction > 0
    )
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy