From 4baf7266ffb62550efb1a6fa8695ed19978e3b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Costa?= Date: Sat, 5 Oct 2024 14:06:00 +0100 Subject: [PATCH] Support stroke rasterization --- .../joaocosta/minart/backend/SdlCanvas.scala | 1 - .../geometry/AxisAlignedBoundingBox.scala | 2 +- .../eu/joaocosta/minart/geometry/Circle.scala | 20 +++--- .../minart/geometry/ConvexPolygon.scala | 42 +++++++++-- .../eu/joaocosta/minart/geometry/Point.scala | 5 ++ .../eu/joaocosta/minart/geometry/Shape.scala | 25 ++++--- .../eu/joaocosta/minart/geometry/Stroke.scala | 8 +++ .../minart/graphics/MutableSurface.scala | 34 ++++++++- .../minart/graphics/Rasterizer.scala | 70 ++++++++++++++++++- .../eu/joaocosta/minart/runtime/AppLoop.scala | 1 - .../minart/geometry/CircleSpec.scala | 2 - .../minart/geometry/ConvexPolygonSpec.scala | 2 - examples/snapshot/10-vector-shapes.md | 22 ++++-- 13 files changed, 194 insertions(+), 40 deletions(-) create mode 100644 core/shared/src/main/scala/eu/joaocosta/minart/geometry/Point.scala create mode 100644 core/shared/src/main/scala/eu/joaocosta/minart/geometry/Stroke.scala diff --git a/backend/native/src/main/scala/eu/joaocosta/minart/backend/SdlCanvas.scala b/backend/native/src/main/scala/eu/joaocosta/minart/backend/SdlCanvas.scala index 7322b43a..4f341fd7 100644 --- a/backend/native/src/main/scala/eu/joaocosta/minart/backend/SdlCanvas.scala +++ b/backend/native/src/main/scala/eu/joaocosta/minart/backend/SdlCanvas.scala @@ -7,7 +7,6 @@ import sdl2.all.* import sdl2.enumerations.SDL_BlendMode.* import sdl2.enumerations.SDL_EventType.* import sdl2.enumerations.SDL_InitFlag.* -import sdl2.enumerations.SDL_KeyCode.* import sdl2.enumerations.SDL_WindowFlags.* import eu.joaocosta.minart.graphics.* diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/geometry/AxisAlignedBoundingBox.scala b/core/shared/src/main/scala/eu/joaocosta/minart/geometry/AxisAlignedBoundingBox.scala index 2ea771cb..c3770883 100644 --- a/core/shared/src/main/scala/eu/joaocosta/minart/geometry/AxisAlignedBoundingBox.scala +++ b/core/shared/src/main/scala/eu/joaocosta/minart/geometry/AxisAlignedBoundingBox.scala @@ -129,7 +129,7 @@ object AxisAlignedBoundingBox { this } - def add(point: Shape.Point): this.type = add(point.x, point.y) + def add(point: Point): this.type = add(point.x, point.y) def result(): AxisAlignedBoundingBox = if (x1 > x2 || y1 > y2) AxisAlignedBoundingBox(0, 0, 0, 0) diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Circle.scala b/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Circle.scala index a094a11f..156c0b80 100644 --- a/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Circle.scala +++ b/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Circle.scala @@ -9,7 +9,7 @@ package eu.joaocosta.minart.geometry * @param center center of the circle. * @param radius circle radius */ -final case class Circle(center: Shape.Point, radius: Double) extends Shape { +final case class Circle(center: Point, radius: Double) extends Shape.ShapeWithContour { /** The absolute radius */ val absRadius = math.abs(radius) @@ -29,6 +29,8 @@ final case class Circle(center: Shape.Point, radius: Double) extends Shape { else if (radius < 0) Shape.someBack else None + def contour: Vector[Stroke] = Vector(Stroke.Circle(center, radius)) + def faceAt(x: Int, y: Int): Option[Shape.Face] = { if ((x - center.x) * (x - center.x) + (y - center.y) * (y - center.y) <= squareRadius) knownFace @@ -57,26 +59,26 @@ final case class Circle(center: Shape.Point, radius: Double) extends Shape { (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 = + override def translate(dx: Double, dy: Double): Circle = if (dx == 0 && dy == 0) this - else Circle(Shape.Point(center.x + dx, center.y + dy), radius) + else Circle(Point(center.x + dx, center.y + dy), radius) override def flipH: Circle = - Circle(Shape.Point(-center.x, center.y), -radius) + Circle(Point(-center.x, center.y), -radius) override def flipV: Circle = - Circle(Shape.Point(center.x, -center.y), -radius) + Circle(Point(center.x, -center.y), -radius) - override def scale(s: Double): Shape = + override def scale(s: Double): Circle = if (s == 1.0) this - else Circle(Shape.Point(center.x * s, center.y * s), radius * s) + else Circle(Point(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( - Shape.Point( + Point( matrix.applyX(center.x.toDouble, center.y.toDouble), matrix.applyY(center.x.toDouble, center.y.toDouble) ), @@ -86,5 +88,5 @@ final case class Circle(center: Shape.Point, radius: Double) extends Shape { } override def transpose: Circle = - Circle(center = Shape.Point(center.y, center.x), -radius) + Circle(center = Point(center.y, center.x), -radius) } diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/geometry/ConvexPolygon.scala b/core/shared/src/main/scala/eu/joaocosta/minart/geometry/ConvexPolygon.scala index d9433109..6a2c13b9 100644 --- a/core/shared/src/main/scala/eu/joaocosta/minart/geometry/ConvexPolygon.scala +++ b/core/shared/src/main/scala/eu/joaocosta/minart/geometry/ConvexPolygon.scala @@ -11,7 +11,7 @@ package eu.joaocosta.minart.geometry * * @param vertices ordered sequence of vertices. */ -final case class ConvexPolygon(vertices: Vector[Shape.Point]) extends Shape { +final case class ConvexPolygon(vertices: Vector[Point]) extends Shape.ShapeWithContour { val size = vertices.size require(size >= 3, "A polygon needs at least 3 vertices") @@ -24,10 +24,18 @@ final case class ConvexPolygon(vertices: Vector[Shape.Point]) extends Shape { lazy val knownFace: Option[Shape.Face] = faceAt(vertices.head) + lazy val contour: Vector[Stroke] = + (vertices :+ vertices.head) + .sliding(2) + .collect { case Vector(a, b) => + Stroke.Line(a, b) + } + .toVector + private def edgeFunction(x1: Double, y1: Double, x2: Double, y2: Double, x3: Double, y3: Double): Double = (x2 - x1) * (y3 - y1) - (y2 - y1) * (x3 - x1) - private def edgeFunction(p1: Shape.Point, p2: Shape.Point, p3: Shape.Point): Double = + private def edgeFunction(p1: Point, p2: Point, p3: Point): Double = edgeFunction(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y) private def rawWeights(x: Double, y: Double): Iterator[Double] = { @@ -129,20 +137,37 @@ final case class ConvexPolygon(vertices: Vector[Shape.Point]) extends Shape { * @param point point to test * @return weight */ - def edgeWeights(point: Shape.Point): Vector[Double] = { + def edgeWeights(point: Point): Vector[Double] = { rawWeights(point.x, point.y).map(_ / maxWeight.toDouble).toVector } - override def mapMatrix(matrix: Matrix) = + override def mapMatrix(matrix: Matrix): Shape.ShapeWithContour = if (matrix == Matrix.identity) this else ConvexPolygon.MatrixPolygon(matrix, this) + + // Overrides to refine the type signature + override def contramapMatrix(matrix: Matrix): Shape.ShapeWithContour = + mapMatrix(matrix.inverse) + override def translate(dx: Double, dy: Double): Shape.ShapeWithContour = + mapMatrix(Matrix.translation(dx, dy)) + override def flipH: Shape.ShapeWithContour = mapMatrix(Matrix.flipH) + override def flipV: Shape.ShapeWithContour = mapMatrix(Matrix.flipV) + override def scale(sx: Double, sy: Double): Shape.ShapeWithContour = + mapMatrix(Matrix.scaling(sx, sy)) + override def scale(s: Double): Shape.ShapeWithContour = scale(s, s) + override def rotate(theta: Double): Shape.ShapeWithContour = + mapMatrix(Matrix.rotation(theta)) + override def shear(sx: Double, sy: Double): Shape.ShapeWithContour = + mapMatrix(Matrix.shear(sx, sy)) + override def transpose: Shape.ShapeWithContour = mapMatrix(Matrix.transpose) } object ConvexPolygon { - private[ConvexPolygon] final case class MatrixPolygon(matrix: Matrix, polygon: ConvexPolygon) extends Shape { + private[ConvexPolygon] final case class MatrixPolygon(matrix: Matrix, polygon: ConvexPolygon) + extends Shape.ShapeWithContour { lazy val toConvexPolygon = ConvexPolygon( vertices = polygon.vertices.map { point => - Shape.Point( + Point( matrix.applyX(point.x, point.y), matrix.applyY(point.x, point.y) ) @@ -151,12 +176,15 @@ object ConvexPolygon { def knownFace: Option[Shape.Face] = toConvexPolygon.knownFace def aabb: AxisAlignedBoundingBox = toConvexPolygon.aabb + def contour = toConvexPolygon.contour 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) = + override def mapMatrix(matrix: Matrix): MatrixPolygon = if (matrix == Matrix.identity) this else MatrixPolygon(matrix.multiply(this.matrix), polygon) + override def translate(dx: Double, dy: Double): MatrixPolygon = + mapMatrix(Matrix.translation(dx, dy)) } } diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Point.scala b/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Point.scala new file mode 100644 index 00000000..1ba4fcef --- /dev/null +++ b/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Point.scala @@ -0,0 +1,5 @@ +package eu.joaocosta.minart.geometry + +/** Coordinates of a point in the shape or stroke. + */ +final case class Point(x: Double, y: Double) diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Shape.scala b/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Shape.scala index 75bf70e8..492977fd 100644 --- a/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Shape.scala +++ b/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Shape.scala @@ -2,7 +2,7 @@ package eu.joaocosta.minart.geometry /** Abstract shape. * - * Can be combined with other shapes and can check if a point is inside of it. + * Can be combined with other shapes and can check if a point is inside of it. * * This API is *experimental* and might change in the near future. */ @@ -40,7 +40,7 @@ trait Shape { * @param y y coordinates of the point * @return None if the point is not contained, Some(face) if the point is contained. */ - final def faceAt(point: Shape.Point): Option[Shape.Face] = faceAt(point.x.toInt, point.y.toInt) + final def faceAt(point: Point): Option[Shape.Face] = faceAt(point.x.toInt, point.y.toInt) /** Checks if this shape contains a point. * @@ -60,7 +60,7 @@ trait Shape { * @param y y coordinates of the point * @return None if the point is not contained, Some(face) if the point is contained. */ - final def contains(point: Shape.Point): Boolean = contains(point.x.toInt, point.y.toInt) + final def contains(point: Point): Boolean = contains(point.x.toInt, point.y.toInt) /** Contramaps the points in this shape using a matrix. * @@ -89,14 +89,14 @@ trait Shape { * Internally, this method will invert the matrix, so for performance sensitive operations it is recommended to use * mapMatrix with the direct matrix instead. */ - def contramapMatrix(matrix: Matrix) = + def contramapMatrix(matrix: Matrix): Shape = mapMatrix(matrix.inverse) /** Maps this the points in this shape using a matrix. * * This method can be chained multiple times efficiently. */ - def mapMatrix(matrix: Matrix) = + def mapMatrix(matrix: Matrix): Shape = if (matrix == Matrix.identity) this else Shape.MatrixShape(matrix, this) @@ -132,10 +132,6 @@ trait Shape { object Shape { - /** Coordinates of a point in the shape. - */ - final case class Point(x: Double, y: Double) - /** The shape of a circle. * * If the radius is positive, the circle's front face is facing the viewer. @@ -194,6 +190,17 @@ object Shape { case Back } + /** Shape with a contour that can be rendered. + * + * It's OK for some shapes to not provide a contour. + */ + trait ShapeWithContour extends Shape { + + /** Contour of this shape as a list of strokes. + */ + def contour: Vector[Stroke] + } + // Preallocated values to avoid repeated allocations private[geometry] val someFront = Some(Face.Front) private[geometry] val someBack = Some(Face.Back) diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Stroke.scala b/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Stroke.scala new file mode 100644 index 00000000..940ae764 --- /dev/null +++ b/core/shared/src/main/scala/eu/joaocosta/minart/geometry/Stroke.scala @@ -0,0 +1,8 @@ +package eu.joaocosta.minart.geometry + +/** Represents lines, curves or countours that can be stroked. + */ +enum Stroke { + case Line(p1: Point, p2: Point) + case Circle(center: Point, radius: Double) +} diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/graphics/MutableSurface.scala b/core/shared/src/main/scala/eu/joaocosta/minart/graphics/MutableSurface.scala index 2edaed01..6fb24ef0 100644 --- a/core/shared/src/main/scala/eu/joaocosta/minart/graphics/MutableSurface.scala +++ b/core/shared/src/main/scala/eu/joaocosta/minart/graphics/MutableSurface.scala @@ -109,6 +109,22 @@ trait MutableSurface extends Surface { blit(surface, blendMode)(0, 0) } + /** Draws a stroke on top of this surface. + * + * This API is *experimental* and might change in the near future. + * + * @param stroke shape to draw + * @param color color of the line + * @param x position of the shape origin on the destination surface + * @param y position of the shape origin on the destination surface + */ + def rasterizeStroke( + stroke: Stroke, + color: Color + )(x: Int, y: Int): Unit = { + Rasterizer.rasterizeStroke(this, stroke, color, x, y) + } + /** Draws a shape on top of this surface. * * This API is *experimental* and might change in the near future. @@ -120,7 +136,7 @@ trait MutableSurface extends Surface { * @param x position of the shape origin on the destination surface * @param y position of the shape origin on the destination surface */ - def rasterize( + def rasterizeShape( shape: Shape, frontfaceColor: Option[Color], backfaceColor: Option[Color] = None, @@ -129,6 +145,22 @@ trait MutableSurface extends Surface { Rasterizer.rasterizeShape(this, shape.translate(x, y), frontfaceColor, backfaceColor, blendMode) } + /** Draws the contour of a shape on top of this surface. + * + * This API is *experimental* and might change in the near future. + * + * @param shape shape whose countour to draw + * @param color color of the line + * @param x position of the shape origin on the destination surface + * @param y position of the shape origin on the destination surface + */ + def rasterizeContour( + shape: Shape.ShapeWithContour, + color: Color + )(x: Int, y: Int): Unit = { + shape.contour.foreach(stroke => rasterizeStroke(stroke, color)(x, y)) + } + /** Modifies this surface using surface view transformations * * @param f operations to apply diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/graphics/Rasterizer.scala b/core/shared/src/main/scala/eu/joaocosta/minart/graphics/Rasterizer.scala index e96e3c9c..d219e45b 100644 --- a/core/shared/src/main/scala/eu/joaocosta/minart/graphics/Rasterizer.scala +++ b/core/shared/src/main/scala/eu/joaocosta/minart/graphics/Rasterizer.scala @@ -27,7 +27,7 @@ private[graphics] object Rasterizer { } } - private def rasterizeShapeBothFaces( + def rasterizeShapeBothFaces( dest: MutableSurface, area: AxisAlignedBoundingBox, shape: Shape, @@ -92,4 +92,72 @@ private[graphics] object Rasterizer { } } + // From https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm + def rasterizeLine(dest: MutableSurface, stroke: Stroke.Line, color: Color): Unit = { + val x1 = math.round(stroke.p1.x).toInt + val y1 = math.round(stroke.p1.y).toInt + val x2 = math.round(stroke.p2.x).toInt + val y2 = math.round(stroke.p2.y).toInt + + val dx = math.abs(x2 - x1) + val sx = if (x1 < x2) 1 else -1 + + val dy = -math.abs(y2 - y1) + val sy = if (y1 < y2) 1 else -1 + + var x = x1 + var y = y1 + var error = dx + dy + + while (!(x == x2 && y == y2)) { + dest.putPixel(x, y, color) + val doubleError = 2 * error + if (doubleError >= dy && x != x2) { + error = error + dy + x = x + sx + } + if (doubleError <= dx && y != y2) { + error = error + dx + y = y + sy + } + } + dest.putPixel(x, y, color) + } + + // From https://en.wikipedia.org/wiki/Midpoint_circle_algorithm + def rasterizeCircle(dest: MutableSurface, stroke: Stroke.Circle, color: Color): Unit = { + val cx = math.round(stroke.center.x).toInt + val cy = math.round(stroke.center.y).toInt + + var t = math.round(stroke.radius / 16).toInt + var dx = math.round(stroke.radius).toInt + var dy = 0 + + while (dx >= dy) { + dest.putPixel(cx + dx, cy + dy, color) + dest.putPixel(cx + dx, cy - dy, color) + dest.putPixel(cx - dx, cy + dy, color) + dest.putPixel(cx - dx, cy - dy, color) + + dest.putPixel(cx + dy, cy + dx, color) + dest.putPixel(cx + dy, cy - dx, color) + dest.putPixel(cx - dy, cy + dx, color) + dest.putPixel(cx - dy, cy - dx, color) + + dy = dy + 1 + t = t + dy + if (t - dx >= 0) { + t = t - dx + dx = dx - 1 + } + } + } + + def rasterizeStroke(dest: MutableSurface, stroke: Stroke, color: Color, dx: Int, dy: Int): Unit = stroke match { + case Stroke.Line(Point(x1, y1), Point(x2, y2)) => + rasterizeLine(dest, Stroke.Line(Point(x1 + dx, y1 + dy), Point(x2 + dx, y2 + dy)), color) + case Stroke.Circle(Point(cx, cy), radius) => + rasterizeCircle(dest, Stroke.Circle(Point(cx + dx, cy + dy), radius), color) + } + } diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/runtime/AppLoop.scala b/core/shared/src/main/scala/eu/joaocosta/minart/runtime/AppLoop.scala index 8c0579d7..fc00325c 100644 --- a/core/shared/src/main/scala/eu/joaocosta/minart/runtime/AppLoop.scala +++ b/core/shared/src/main/scala/eu/joaocosta/minart/runtime/AppLoop.scala @@ -4,7 +4,6 @@ import eu.joaocosta.minart.audio.* import eu.joaocosta.minart.backend.defaults.* import eu.joaocosta.minart.backend.subsystem.* import eu.joaocosta.minart.graphics.* -import eu.joaocosta.minart.runtime.* /** App loop that keeps an internal state that is passed to every iteration. */ diff --git a/core/shared/src/test/scala/eu/joaocosta/minart/geometry/CircleSpec.scala b/core/shared/src/test/scala/eu/joaocosta/minart/geometry/CircleSpec.scala index 20f9d333..0896a4bf 100644 --- a/core/shared/src/test/scala/eu/joaocosta/minart/geometry/CircleSpec.scala +++ b/core/shared/src/test/scala/eu/joaocosta/minart/geometry/CircleSpec.scala @@ -1,7 +1,5 @@ package eu.joaocosta.minart.geometry -import eu.joaocosta.minart.geometry.Shape.Point - class CircleSpec extends munit.FunSuite { test("Computes the bounding box of a circle") { val circle = Circle( diff --git a/core/shared/src/test/scala/eu/joaocosta/minart/geometry/ConvexPolygonSpec.scala b/core/shared/src/test/scala/eu/joaocosta/minart/geometry/ConvexPolygonSpec.scala index 86ceef21..bad7b4fd 100644 --- a/core/shared/src/test/scala/eu/joaocosta/minart/geometry/ConvexPolygonSpec.scala +++ b/core/shared/src/test/scala/eu/joaocosta/minart/geometry/ConvexPolygonSpec.scala @@ -1,7 +1,5 @@ package eu.joaocosta.minart.geometry -import eu.joaocosta.minart.geometry.Shape.Point - class ConvexPolygonSpec extends munit.FunSuite { test("Computes the bounding box of a polygon") { val polygon = ConvexPolygon( diff --git a/examples/snapshot/10-vector-shapes.md b/examples/snapshot/10-vector-shapes.md index deec79a5..3dbeb29a 100644 --- a/examples/snapshot/10-vector-shapes.md +++ b/examples/snapshot/10-vector-shapes.md @@ -27,7 +27,7 @@ Minart already comes with some basic shapes, such as circle and convex polygons, First, let's create a few shapes with those methods. ```scala -import eu.joaocosta.minart.geometry.Shape.Point +import eu.joaocosta.minart.geometry.Point val triangle = Shape.triangle(Point(-16, 16), Point(0, -16), Point(16, 16)) val square = Shape.rectangle(Point(-16, -16), Point(16, 16)) @@ -57,20 +57,30 @@ This is helpful if, for some reason, you know you don't want to draw back faces. ### Rasterizing -Now we just need to use the `rasterize` operation, just like we did with `blit`. +Now we just need to use the `rasterizeShape` operation, just like we did with `blit`. In this example we will also scale our images with time, to show how the color changes when the face flips. +There's also a `rasterizeStroke` operation to draw lines and a `rasterizeContour` operation to draw only the shape +contours (note that not all shapes support this, some transformations can make the contour computation impossible). + ```scala val frontfaceColor = Color(255, 0, 0) val backfaceColor = Color(0, 255, 0) +val contourColor = Color(255, 255, 255) def application(t: Double, canvas: Canvas): Unit = { val scale = math.sin(t) - canvas.rasterize(triangle.scale(scale, 1.0), Some(frontfaceColor), Some(backfaceColor))(32, 32) - canvas.rasterize(square.scale(scale, 1.0), Some(frontfaceColor), Some(backfaceColor))(64, 32) - canvas.rasterize(octagon.scale(scale, 1.0), Some(frontfaceColor), Some(backfaceColor))(32, 64) - canvas.rasterize(circle.scale(scale, 1.0), Some(frontfaceColor), Some(backfaceColor))(64, 64) + canvas.rasterizeShape(triangle.scale(scale, 1.0), Some(frontfaceColor), Some(backfaceColor))(32, 32) + canvas.rasterizeShape(square.scale(scale, 1.0), Some(frontfaceColor), Some(backfaceColor))(64, 32) + canvas.rasterizeShape(octagon.scale(scale, 1.0), Some(frontfaceColor), Some(backfaceColor))(32, 64) + canvas.rasterizeShape(circle.scale(scale, 1.0), Some(frontfaceColor), Some(backfaceColor))(64, 64) + + canvas.rasterizeContour(triangle.scale(scale, 1.0), contourColor)(32, 32) + canvas.rasterizeContour(square.scale(scale, 1.0), contourColor)(64, 32) + canvas.rasterizeContour(octagon.scale(scale, 1.0), contourColor)(32, 64) + // Can't compute the contour of a circle scaled on only one dimension + //canvas.rasterizeContour(circle.scale(scale, 1.0), contourColor)(64, 64) } ```