Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add geometry package and a rasterizer #520

Merged
merged 16 commits into from
Sep 7, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package eu.joaocosta.minart.geometry

/** An Axis Aligned Bounding Box.
*
* Represents a rectangular region aligned to the (x, y) axis and provides some basic functionality.
*
* This API is *experimental* and might change in the near future.
*
* @param x x position of the top-left coordinate
* @param y y position of the top-left coordinate
* @param width box width in pixels
* @param height box height in pixels
*/
final case class AxisAlignedBoundingBox(
x: Int,
y: Int,
width: Int,
height: Int
) {

/** Leftmost position */
inline def x1 = x

/** Topmost position */
inline def y1 = y

/** Rightmost position */
inline def x2 = x + width

/** Bottommost position */
inline def y2 = y + height

/** Horizontal center */
inline def centerX = x + width / 2

/** Vertical center */
inline def centerY = y + height / 2

/** Returns true if the rectangle has no area
*/
def isEmpty: Boolean = width <= 0 || height <= 0

/** Checks if this bounding box contains a point.
*
* @param x x coordinates of the point
* @param y y coordinates of the point
* @return true if the point is inside the bounding box (edges included)
*/
def contains(x: Int, y: Int): Boolean =
x >= x1 && x <= x2 && y >= y1 && y <= y2

/** Checks if this bounding box contains another bounding box.
*
* @param that bounding box to test
* @return true if the bounding box is inside this bounding box (edges included)
*/
def contains(that: AxisAlignedBoundingBox): Boolean =
this.contains(that.x1, that.y1) && this.contains(that.x2, that.y2)

/** Checks if this bounding box collides with another bounding box.
*
* @param that bounding box to test
* @return true if the bounding boxes collide
*/
def collides(that: AxisAlignedBoundingBox): Boolean =
Math.abs(2 * (this.x - that.x) + (this.width - that.width)) <= (this.width + that.width) &&
Math.abs(2 * (this.y - that.y) + (this.height - that.height)) <= (this.height + that.height)

/** Merges this bounding box with another one.
*
* Gaps between the boxes will also be considered as part of the final area.
*/
def union(that: AxisAlignedBoundingBox): AxisAlignedBoundingBox = {
val minX = math.min(this.x1, that.x1)
val maxX = math.max(this.x2, that.x2)
val minY = math.min(this.y1, that.y1)
val maxY = math.max(this.y2, that.y2)
AxisAlignedBoundingBox(x = minX, y = minY, width = maxX - minX, height = maxY - minY)
}

/** Intersects this bounding box with another one.
*/
def intersect(that: AxisAlignedBoundingBox): AxisAlignedBoundingBox = {
val maxX1 = math.max(this.x1, that.x1)
val maxY1 = math.max(this.y1, that.y1)
val minX2 = math.min(this.x2, that.x2)
val minY2 = math.min(this.y2, that.y2)
AxisAlignedBoundingBox(x = maxX1, y = maxY1, width = minX2 - maxX1, height = minY2 - maxY1)
}

/** Performs a side effect for every pixel in the bounding box.
*
* @param f side effect, receiving (x, y) points
*/
def foreach(f: (Int, Int) => Unit): Unit =
for {
y <- (y1 until y2).iterator
x <- (x1 until x2).iterator
} f(x, y)
}
125 changes: 125 additions & 0 deletions core/shared/src/main/scala/eu/joaocosta/minart/geometry/Circle.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package eu.joaocosta.minart.geometry

/** Circle shape.
*
* It's considered to be facing the viewer if the radius is positive.
*
* This API is *experimental* and might change in the near future.
*
* @param center center of the circle.
* @param radius circle radius
*/
final case class Circle(center: Shape.Point, radius: Int) extends Shape {

/** The absolute radius */
val absRadius = math.abs(radius)

/** The squared radius */
val squareRadius = radius * radius

val aabb: AxisAlignedBoundingBox = {
val x = center.x - absRadius
val y = center.y - absRadius
val d = absRadius * 2
AxisAlignedBoundingBox(x, y, d, d)
}

val knownFace: Option[Shape.Face] =
if (radius > 0) Shape.someFront
else if (radius < 0) Shape.someBack
else None

def faceAt(x: Int, y: Int): Option[Shape.Face] = {
if ((x - center.x) * (x - center.x) + (y - center.y) * (y - center.y) <= squareRadius)
knownFace
else None
}

/** Checks if this circle contains a circle.
*
* @param that circle to check
* @return true if that circle is contained in this circle
*/
def contains(that: Circle): Boolean =
if (this.absRadius >= that.absRadius) {
val innerRadius = this.absRadius - that.absRadius
(this.center.x - that.center.x) * (this.center.x - that.center.x) +
(this.center.y - that.center.y) * (this.center.y - that.center.y) <= innerRadius * innerRadius
} else false

/** Checks if this circle collides with another circle.
*
* @param that circle to check
* @return true if both circles collide
*/
def collides(that: Circle): Boolean =
val sumRadius = this.absRadius + that.absRadius
(this.center.x - that.center.x) * (this.center.x - that.center.x) +
(this.center.y - that.center.y) * (this.center.y - that.center.y) <= sumRadius * sumRadius

override def translate(dx: Double, dy: Double): Shape =
if (dx == 0 && dy == 0) this
else Circle.PreciseCircle(center.x + dx, center.y + dy, radius)

override def flipH: Circle =
Circle(Shape.Point(-center.x, center.y), -radius)

override def flipV: Circle =
Circle(Shape.Point(center.x, -center.y), -radius)

override def scale(s: Double): Shape =
Circle.PreciseCircle(center.x * s, center.y * s, radius * s)

override def rotate(theta: Double): Shape = {
val matrix = Matrix.rotation(theta)
if (matrix == Matrix.identity) this
else {
Circle.PreciseCircle(
matrix.applyX(center.x.toDouble, center.y.toDouble),
matrix.applyY(center.x.toDouble, center.y.toDouble),
radius
)
}
}

override def transpose: Circle =
Circle(center = Shape.Point(center.y, center.x), -radius)
}

object Circle {
private[Circle] final case class PreciseCircle(centerX: Double, centerY: Double, radius: Double) extends Shape {
lazy val toCircle = Circle(
center = Shape.Point(centerX.toInt, centerY.toInt),
radius = radius.toInt
)

def knownFace: Option[Shape.Face] = toCircle.knownFace
def aabb: AxisAlignedBoundingBox = toCircle.aabb
def faceAt(x: Int, y: Int): Option[Shape.Face] =
toCircle.faceAt(x, y)
override def contains(x: Int, y: Int): Boolean =
toCircle.contains(x, y)
override def translate(dx: Double, dy: Double) =
if (dx == 0 && dy == 0) this
else copy(centerX = centerX + dx, centerY = centerY + dy)
override def flipH: Shape =
copy(centerX = -centerX, radius = -radius)
override def flipV: Shape =
copy(centerY = -centerY, radius = -radius)
override def scale(s: Double): Shape =
Circle.PreciseCircle(centerX * s, centerY * s, radius * s)
override def rotate(theta: Double): Shape = {
val matrix = Matrix.rotation(theta)
if (matrix == Matrix.identity) this
else {
Circle.PreciseCircle(
matrix.applyX(centerX, centerY),
matrix.applyY(centerX, centerY),
radius
)
}
}
override def transpose: Shape =
Circle.PreciseCircle(centerY, centerX, -radius)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package eu.joaocosta.minart.geometry

/** Convex polygon constructed from a series of vertices.
*
* It's considered to be facing the viewer if the vertices are clockwise.
*
* There is no check in place to guarantee that the generated polygon is actually convex.
* If this is not the case, the methods may return wrong results.
*
* This API is *experimental* and might change in the near future.
*
* @param vertices ordered sequence of vertices.
*/
final case class ConvexPolygon(vertices: Vector[Shape.Point]) extends Shape {
require(vertices.size >= 3, "A polygon needs at least 3 vertices")

val aabb: AxisAlignedBoundingBox = {
val x1 = vertices.iterator.minBy(_.x).x
val y1 = vertices.iterator.minBy(_.y).y
val x2 = vertices.iterator.maxBy(_.x).x
val y2 = vertices.iterator.maxBy(_.y).y
AxisAlignedBoundingBox(x1, y1, x2 - x1, y2 - y1)
}

val knownFace: Option[Shape.Face] =
faceAt(aabb.centerX, aabb.centerY)

private def edgeFunction(x1: Int, y1: Int, x2: Int, y2: Int, x3: Int, y3: Int): Int =
(x2 - x1) * (y3 - y1) - (y2 - y1) * (x3 - x1)

private def edgeFunction(p1: Shape.Point, p2: Shape.Point, p3: Shape.Point): Int =
edgeFunction(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y)

private def rawWeights(x: Int, y: Int): Iterator[Int] =
(0 until vertices.size).iterator.map(idx =>
val current = vertices(idx)
val next = if (idx + 1 >= vertices.size) vertices(0) else vertices(idx + 1)
edgeFunction(current.x, current.y, next.x, next.y, x, y)
)

private val maxWeight: Int =
(vertices.tail)
.sliding(2)
.collect { case Vector(b, c) =>
edgeFunction(vertices.head, b, c)
}
.sum

def faceAt(x: Int, y: Int): Option[Shape.Face] = {
val sides = rawWeights(x, y).filter(_ != 0).map(_ >= 0).distinct.toVector
if (sides.size == 1) {
if (sides.head) Shape.someFront else Shape.someBack
} else None
}

override def contains(x: Int, y: Int): Boolean = {
rawWeights(x, y).filter(_ != 0).map(_ >= 0).distinct.size == 1
}

/** Checks if this polygon contains another polygon.
*
* @param that polygon to check
* @return true if that polygon is contained in this polygon
*/
def contains(that: ConvexPolygon): Boolean =
that.vertices.forall(p => this.contains(p))

/** Checks if this polygon collides with another polygon.
*
* @param that polygon to check
* @return true if the polygons collide
*/
def collides(that: ConvexPolygon): Boolean =
this.vertices.exists(v => that.contains(v)) || that.vertices.exists(v => this.contains(v))

/** Normalized distance from an edge. Useful for some tricks like color interpolation.
*
* This can be a bit confusing on polygons with more than 3 edges, but on a triangle is similar
* vertex weights.
*
* On a triangle:
* - a value of 0 means that the point is on top of the edge,
* - a value of 1 means that the point is on the vertex opposite to the edge.
* - a negative value means that the point is on the wrong side of the edge
*
* So, on a triangle, and edge weight can be seen as the vetex weight of the vertex opposed to the edge.
*
* @param x x coordinates of the point
* @param y x coordinates of the point
* @return weight
*/
def edgeWeights(x: Int, y: Int): Vector[Double] = {
rawWeights(x, y).map(_ / maxWeight.toDouble).toVector
}

/** Normalized distance from an edge. Useful for some tricks like color interpolation.
*
* This can be a bit confusing on polygons with more than 3 edges, but on a triangle is similar
* vertex weights.
*
* On a triangle:
* - a value of 0 means that the point is on top of the edge,
* - a value of 1 means that the point is on the vertex opposite to the edge.
* - a negative value means that the point is on the wrong side of the edge
*
* So, on a triangle, and edge weight can be seen as the vetex weight of the vertex opposed to the edge.
*
* @param point point to test
* @return weight
*/
def edgeWeights(point: Shape.Point): Vector[Double] = {
rawWeights(point.x, point.y).map(_ / maxWeight.toDouble).toVector
}

override def mapMatrix(matrix: Matrix) =
if (matrix == Matrix.identity) this
else ConvexPolygon.MatrixPolygon(matrix, this)
}

object ConvexPolygon {
private[ConvexPolygon] final case class MatrixPolygon(matrix: Matrix, polygon: ConvexPolygon) extends Shape {
lazy val toConvexPolygon = ConvexPolygon(
vertices = polygon.vertices.map { point =>
Shape.Point(
matrix.applyX(point.x, point.y),
matrix.applyY(point.x, point.y)
)
}
)

def knownFace: Option[Shape.Face] = toConvexPolygon.knownFace
def aabb: AxisAlignedBoundingBox = toConvexPolygon.aabb
def faceAt(x: Int, y: Int): Option[Shape.Face] =
toConvexPolygon.faceAt(x, y)
override def contains(x: Int, y: Int): Boolean =
toConvexPolygon.contains(x, y)
override def mapMatrix(matrix: Matrix) =
if (matrix == Matrix.identity) this
else MatrixPolygon(matrix.multiply(this.matrix), polygon)
}
}
Loading
Loading