Modifying contours with ContourAdjuster

A small demo of the contour adjuster. I accidentally found this shape and I found it interesting :slight_smile:

imports
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.extra.shapes.adjust.adjustContour
import org.openrndr.shape.Circle
import org.openrndr.shape.Rectangle
fun main() {
    application {
        program {
            extend {
                val cir = adjustContour(Circle(drawer.bounds.center, 200.0).contour) {
                    vertices.forEach {
                        it.rotate(75.0)
                    }
                }
                val rect = adjustContour(Rectangle.fromCenter(drawer.bounds.center, 200.0).contour) {
                    vertices.forEach {
                        it.rotate(75.0)
                    }
                }
                drawer.clear(ColorRGBa.WHITE)
                drawer.stroke = ColorRGBa.BLACK.opacify(0.7)
                drawer.fill = ColorRGBa.PINK
                drawer.contour(cir)
                drawer.fill = ColorRGBa.PINK.shade(1.1)
                drawer.contour(rect)
            }
        }
    }
}

This program uses two adjustContour operations to alter all vertices in a circle and in a rectangle. The larger shape used to be a circle, which is internally constructed out of 4 bezier curves. The smaller shape was a square with 4 straight segments.

The program iterates over all (4) vertices of each shape, and uses the rotate operation to rotate the control points on each side of each vertex, producing what you see in the image. It’s a bit like sculpting 2D shapes.

I will continue exploring the ContourAdjuster and maybe share other interesting results.

1 Like

Another adjuster example:

Adjust-2024-05-26-13.11.15

Imports
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.extra.shapes.adjust.adjustContour
import org.openrndr.shape.Circle
import org.openrndr.shape.ShapeContour
fun main() {
    application {
        program {
            val cir = adjustContour(Circle(drawer.bounds.center, 200.0).contour.resample(12)) {
                selectVertices { i, _ -> i % 2 == 0 }
                vertices.forEach {
                    it.moveBy(it.normal * 20.0, true)
                }
            }
            extend {
                drawer.clear(ColorRGBa.WHITE)
                drawer.stroke = ColorRGBa.BLACK.opacify(0.7)
                drawer.fill = ColorRGBa.PINK
                drawer.contour(cir)
            }
        }
    }
}

For this to work I wrote a resample() function which you can find below.
What the code does is this: select all the even vertices and displace them following the curve normal at that point by 20 pixels. The true argument is the default which makes it update the control points next to each vertex.

By default a Circle only has 4 segments. I wanted to have a circle with more segments so I wrote the following:

/**
 * Returns a modified ShapeContour in which segments have been split into [subdivisions] parts.
 * The new contour should be visually identical to the original.
 */
fun ShapeContour.resample(subdivisions: Int): ShapeContour {
    require(subdivisions >= 2) { "subdivisions must be >= 2 but is actually $subdivisions" }
    return ShapeContour(
        segments.map { seg ->
            List(subdivisions) {
                val t = it / subdivisions.toDouble()
                seg.sub(t, t + 1.0 / subdivisions)
            }
        }.flatten(), closed
    )
}

Now we can play a bit. If you toggle the true argument (updateTangents) in moveBy to false the vertices are displaced but the control points are stuck. It looks like this:

Adjust-2024-05-26-13.19.15

Same, but using -20.0 instead of 20.0:

Adjust-2024-05-26-13.21.36

Or shifting 2 out of every 8 vertices inwards:

            val cir = adjustContour(Circle(drawer.bounds.center, 200.0).contour.resample(10)) {
                selectVertices { i, _ -> i % 8 < 2 }
                vertices.forEach {
                    it.moveBy(it.normal * -50.0, true)
                }
            }

Adjust-2024-05-26-13.25.45

Or just go a bit crazy :smile:

Adjust-2024-05-26-13.36.09

1 Like

Adjusting edges

In the examples above we altered the vertices of contours, but it is also possible to alter the edges.

In the following example we rotate all the edges of a contour by a varying amount:

AdjustBW2-2024-06-04-15.50.19

imports
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.extensions.Screenshots
import org.openrndr.extra.shapes.adjust.adjustContour
import org.openrndr.extra.shapes.hobbycurve.hobbyCurve
import org.openrndr.shape.Circle
fun main() {
    application {
        program {
            val cirs = List(10) { n ->
                val c = Circle(drawer.bounds.center, 150.0 + 5.0 * n).contour
                adjustContour(c) {
                    edges.forEach {
                        it.rotate(2.0 * n - 10.0)
                    }
                }
            }
            extend(Screenshots())
            extend {
                drawer.clear(ColorRGBa.WHITE)
                drawer.stroke = ColorRGBa.BLACK
                drawer.fill = null
                drawer.contours(cirs)
            }
        }
    }
}

We could also only alter every other edge:

AdjustBW2-2024-06-04-15.51.45

val cirs = List(10) { n ->
    val c = Circle(drawer.bounds.center, 150.0 + 5.0 * n).contour
    adjustContour(c) {
        selectEdges { i, e -> i % 2 == 0 }
        edges.forEach {
            it.rotate(2.0 * n - 10.0)
        }
    }
}

Lets resample the circle to have 60 segments instead of 4:

AdjustBW2-2024-06-04-15.54.12

val cirs = List(10) { n ->
    val c = Circle(drawer.bounds.center, 150.0 + 5.0 * n).contour.sampleEquidistant(60).hobbyCurve()
    adjustContour(c) {
        selectEdges { i, e -> i % 2 == 0 }
        edges.forEach {
            it.rotate(2.0 * n - 10.0)
        }
    }
}

Lets make it more obvious what adjusting the edges do. We will create a square centered on the screen, then resample it to have 48 vertices (12 per side) and finally shift one from every four segments outwards:

AdjustBW2-2024-06-04-16.06.16

val c = Rectangle.fromCenter(drawer.bounds.center, 200.0).contour.sampleEquidistant(48)
val c2 = adjustContour(c) {
    selectEdges { i, e -> i % 4 == 1 }
    edges.forEach {
        it.moveBy(it.normal(0.5) * 20.0)
    }
}
extend(Screenshots())
extend {
    drawer.clear(ColorRGBa.WHITE)
    drawer.stroke = ColorRGBa.BLACK
    drawer.fill = null
    drawer.contour(c2)
}

By scaling the edges up we can avoid the diagonal lines:

AdjustBW2-2024-06-04-16.19.31

val c = Rectangle.fromCenter(drawer.bounds.center, 200.0).contour.sampleEquidistant(48)
val c2 = adjustContour(c) {
    selectEdges { i, e -> i % 4 == 2 }
    edges.forEach {
        it.moveBy(it.normal(0.5) * 20.0)
        it.scale(3.0)
    }
}

And let’s round it a bit so no one gets hurt with those sharp corners:

AdjustBW2-2024-06-04-16.21.09

val c = Rectangle.fromCenter(drawer.bounds.center, 200.0).contour.sampleEquidistant(48)
val c2 = adjustContour(c) {
    selectEdges { i, e -> i % 4 == 2 }
    edges.forEach {
        it.moveBy(it.normal(0.5) * 20.0)
        it.scale(3.0)
    }
}.roundCorners(8.0)

That’s it for now :slight_smile:

2 Likes

Here a design making use of edge shifting and scaling, and creating shapes with pairs of contours. Note that when constructing a shape, the even elements should be reversed to act as holes of the previous ones.

imports
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.extensions.Screenshots
import org.openrndr.extra.color.presets.ROSY_BROWN
import org.openrndr.extra.noise.Random
import org.openrndr.extra.shapes.adjust.adjustContour
import org.openrndr.extra.shapes.operators.roundCorners
import org.openrndr.shape.Shape
fun main() {
    application {
        configure {
            width = 800
            height = 800
        }
        program {
            val cs = List(8) {
                val c = drawer.bounds.offsetEdges(-30.0 * (it + 4))
                    .contour.sampleEquidistant(4 * Random.int(10, 30))
                val which = Random.int0(4)
                adjustContour(c) {
                    selectEdges { i, _ -> i % 4 == which && Random.bool() }
                    edges.forEach { edge ->
                        edge.moveBy(edge.normal(0.5) * 20.0)
                        edge.scale(3.0)
                    }
                }.roundCorners(5.0)
            }
            val shapes = cs.chunked(2) {
                Shape(listOf(it[0], it[1].reversed))
            }
            extend {
                drawer.clear(ColorRGBa.WHITE)
                drawer.stroke = null
                drawer.fill = ColorRGBa.ROSY_BROWN
                drawer.shapes(shapes)
            }
        }
    }
}
1 Like