[orx-shapes] Add geometric arrangement together with two demos. (#324)

This commit is contained in:
Steven van den Broek
2024-01-02 20:45:11 +01:00
committed by GitHub
parent 3b57c3ce26
commit 2fb2b11180
4 changed files with 571 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ kotlin {
implementation(project(":orx-parameters")) implementation(project(":orx-parameters"))
implementation(project(":orx-shader-phrases")) implementation(project(":orx-shader-phrases"))
implementation(project(":orx-color")) implementation(project(":orx-color"))
implementation(project(":orx-kdtree"))
implementation(libs.openrndr.application) implementation(libs.openrndr.application)
implementation(libs.openrndr.draw) implementation(libs.openrndr.draw)
implementation(libs.openrndr.filter) implementation(libs.openrndr.filter)

View File

@@ -0,0 +1,389 @@
package org.openrndr.extra.shapes
import org.openrndr.extra.kdtree.buildKDTree
import org.openrndr.extra.kdtree.vector2Mapper
import org.openrndr.math.Vector2
import org.openrndr.math.YPolarity
import org.openrndr.shape.*
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.min
// === Helpers ===
private val ShapeContour.start get() = segments.first().start
private val ShapeContour.startContourPoint get() =
ContourPoint(
this,
0.0,
segments.first(),
0.0,
segments.first().start,
)
private val ShapeContour.end get() = segments.last().end
private val ShapeContour.endContourPoint get() =
ContourPoint(
this,
1.0,
segments.last(),
1.0,
segments.last().start,
)
private fun ShapeContour.direction(ut: Double): Vector2 = normal(ut).perpendicular(polarity.opposite)
private val YPolarity.opposite get() =
when(this) {
YPolarity.CCW_POSITIVE_Y -> YPolarity.CW_NEGATIVE_Y
YPolarity.CW_NEGATIVE_Y -> YPolarity.CCW_POSITIVE_Y
}
private fun angleBetween(v: Vector2, w: Vector2) = atan2(w.y*v.x - w.x*v.y, w.x*v.x + w.y*v.y)
private fun <K, V> MutableMap<K, MutableList<V>>.add(key: K, value: V) {
val ml = get(key)
if (ml != null)
ml.add(value)
else set(key, mutableListOf(value))
}
private fun <K, V> MutableMap<K, MutableList<V>>.addAll(key: K, values: Collection<V>) {
val ml = get(key)
if (ml != null)
ml.addAll(values)
else set(key, values.toMutableList())
}
/**
* Vertex of an arrangement, which represents an intersection (X) between two shapes.
*/
data class XVertex(val pos: Vector2) {
/** The half-edges leaving this vertex */
val outgoing = mutableListOf<XHalfEdge>()
/** The half-edges entering this vertex */
val incoming = mutableListOf<XHalfEdge>()
}
/**
* Edge of an arrangement.
* @property contour Geometric representation of the edge, for drawing purposes.
* @property origin The shape that the edge originates from.
*/
data class XEdge(val source: XVertex, val target: XVertex, val contour: ShapeContour, val origin: ShapeProvider) {
val start get() = contour.start
val end get() = contour.end
/** The two half-edges that correspond to this edge. */
lateinit var halfEdges: Pair<XHalfEdge, XHalfEdge>
init {
// Once an edge is created, also create half-edges and do the necessary bookkeeping.
splitAndAdd()
}
private fun split(): Pair<XHalfEdge, XHalfEdge> {
val hes = XHalfEdge(source, target, contour, this) to XHalfEdge(target, source, contour.reversed, this)
hes.first.twin = hes.second
hes.second.twin = hes.first
halfEdges = hes
return hes
}
private fun splitAndAdd(): Pair<XHalfEdge, XHalfEdge> {
val (a, b) = split()
source.outgoing.add(a)
target.outgoing.add(b)
source.incoming.add(b)
target.incoming.add(a)
return halfEdges
}
}
/**
* Half-edge of an arrangement.
* Each edge is split length-wise into two half-edges of opposite orientation.
* Half-edges can be used to traverse an arrangement.
* @property contour Geometric representation of the edge, for drawing purposes.
* @property original The edge that was split into this half-edge and its [twin].
*/
data class XHalfEdge(val source: XVertex, val target: XVertex, val contour: ShapeContour, val original: XEdge) {
val start get() = contour.start
val end get() = contour.end
/** The shape that the half-edge originates from. */
val origin get() = original.origin
/** The half-edge of opposite direction originating from [original].
* This is useful for traversing to a different face. */
lateinit var twin: XHalfEdge
/** The face to the right of this half-edge. */
lateinit var face: XFace
/** The next half-edge of the [face]. */
val next by lazy {
if (target.outgoing.size == 2 && this in target.outgoing) return@lazy this
val y = target.pos
val x = y - contour.direction(1.0)
val candidates = target.outgoing.filterNot { it == twin }
.map {
val z = y + it.contour.direction(0.0)
it to angleBetween(z - y, x - y)
}.filter { it.second > -1E-6 || it.second < -PI + 1E-6 }
if (candidates.size == 1) {
candidates[0].first
} else if (candidates.isEmpty()) {
twin
} else {
val cand = candidates.minBy { abs(it.second) }
if (cand.second > 1E-6 && candidates.all { it == cand || abs(it.second) - 1E-6 > abs(cand.second) }) return@lazy cand.first
val maxR = min(candidates.minOf { it.first.end.distanceTo(target.pos) }, start.distanceTo(target.pos))
val c = Circle(target.pos, maxR / 2.0)
// if more than one intersection with c then we make a guess
val x_ = c.contour.intersections(contour)[0]
val newCandidates = candidates.map { (e, _) ->
val inters = e.contour.intersections(c.contour)
// if (inters.size != 1) then we make a guess
e to angleBetween(inters[0].position - y, x_.position - y)
}
newCandidates.filter { it.second > -1E-6 || it.second < -PI + 1E-6 }.minBy { abs(it.second) }.first
}
}
}
/**
* Face of an arrangement.
* @property edge An arbitrary half-edge incident to this face.
* @property origins The shapes of which this face is a subset.
*/
open class XFace(val edge: XHalfEdge, val origins: List<ShapeProvider>)
/**
* A bounded face of an arrangement.
* @property edge An arbitrary half-edge incident to this face.
* @property origins The shapes of which this face is a subset.
* @property contour The geometric representation of this face.
*/
class BoundedFace(edge: XHalfEdge, origins: List<ShapeProvider>, val contour: ShapeContour): XFace(edge, origins)
/**
* Create an arrangement of a list of [ShapeProvider] objects, like [Shape]s or [ShapeContour]s.
* @property maxIters The maximum number of edges incident to a face, used to detect infinite loops so that an error is thrown instead.
*/
data class Arrangement(val shapes: List<ShapeProvider>, val maxIters: Int = 1000) {
constructor(vararg shapes: ShapeProvider): this(shapes.toList())
/** Maps a contour to the vertices incident to it. */
val cVertsMap = mutableMapOf<ShapeContour, MutableList<Pair<XVertex, Double>>>()
/** Maps a shape to the edges incident to it. */
val hEdgesMap = mutableMapOf<ShapeProvider, MutableList<XEdge>>()
/** Maps a shape to the faces that it is a superset of. */
val hFacesMap = mutableMapOf<ShapeProvider, MutableList<XFace>>()
/** All vertices of the arrangement. */
val vertices by lazy { cVertsMap.flatMap { it.value.map { it.first } }.toSet().toList() }
/** All edges of the arrangement. */
val edges by lazy { hEdgesMap.flatMap { it.value } }
/** All half-edges of the arrangement. */
val halfEdges by lazy {
edges.flatMap {
it.halfEdges.toList()
}
}
/** All faces of the arrangement. */
val faces = mutableListOf<XFace>()
val boundedFaces by lazy { faces.filterIsInstance<BoundedFace>() }
val unboundedFaces by lazy { faces.filter{ it !is BoundedFace } }
/** The faces that are a subset of some input shape. */
val originFaces by lazy { boundedFaces.filter { it.origins.isNotEmpty() } }
/** The bounded faces that are not a subset of any input shape. */
val holes by lazy { boundedFaces.filter { it.origins.isEmpty() } }
/** The outer boundary contours of each connected component. */
val boundaries: List<ShapeContour> by lazy {
unboundedFaces.map { f ->
val start = f.edge
var current = start.next
var contour = start.contour
while (current != start) {
contour += current.contour
current = current.next
}
contour
}
}
/** A list containing an arbitrary half-edge for each connected component. */
val components by lazy {
unboundedFaces.map { it.edge }
}
init {
createVertices()
createEdges()
createFaces()
}
private fun createVertices() {
data class CandidateVertex(val position: Vector2, val contourPoints: List<ContourPoint>)
val candidates = buildList {
// For open contours, add the start and end points as (candidate) vertices.
for (s in shapes) {
for (c in s.shape.contours) {
if (!c.closed) {
add(CandidateVertex(c.start, listOf(c.startContourPoint)))
add(CandidateVertex(c.end, listOf(c.endContourPoint)))
}
}
}
// Compute pairwise intersections between contours.
for (i in shapes.indices) {
val s1 = shapes[i]
for (j in i + 1 until shapes.size) {
val s2 = shapes[j]
val inters = s1.shape.contours.flatMap {
c1 -> s2.shape.contours.flatMap { c2 ->
c1.intersections(c2)
}
}
for (inter in inters) {
add(CandidateVertex(inter.position, listOf(inter.a, inter.b)))
}
}
}
}
// We will merge vertices that lie close together. For this compute a kd-tree of all candidate vertices.
val tree = buildKDTree(candidates.toMutableList(), 2) { v, d ->
vector2Mapper(v.position, d)
}
val unvisited = candidates.toMutableSet()
val new = mutableListOf<XVertex>()
while (unvisited.isNotEmpty()) {
val inter = unvisited.first()
val inters = tree.findAllInRadius(inter, 1E-1, includeQuery = true).filter { it in unvisited }
unvisited.removeAll(inters)
if (inters.size == 1) {
val v = XVertex(inters[0].position)
new.add(v)
for (cp in inters[0].contourPoints)
cVertsMap.add(cp.contour, v to cp.contourT)
} else if (inters.size > 1) {
val center = inters.fold(Vector2.ZERO) { acc, x -> acc + x.position } / inters.size.toDouble()
val v = XVertex(center)
val cps = mutableSetOf<ContourPoint>()
for (cp in inters.flatMap { it.contourPoints }) {
if (cps.any { it.contour == cp.contour && abs(it.contourT - cp.contourT) < 1E-2 }) continue
cps.add(cp)
}
for (cp in cps) {
cVertsMap.add(cp.contour, v to cp.contourT)
}
new.add(v)
} else {
error("Impossible")
}
}
}
private fun createEdges() {
for (s in shapes) {
// If a shape is passed in twice to compute self-intersections skip edge construction for the second one.
if (s in hEdgesMap.keys) continue
for (c in s.shape.contours) {
// Determine whether a contour is closed and not intersected by anything.
// There are no vertices on such a contour. We add a dummy one; it is not added to the vertices list.
if (cVertsMap[c]?.isEmpty() != false) {
val dummy = XVertex(c.start)
val e = XEdge(dummy, dummy, c, s)
hEdgesMap.add(s, e)
continue
}
// Create edges between consecutive vertices of this contour.
val tValues = cVertsMap[c]!!.sortedBy { it.second }
val middleEdges = tValues.zipWithNext { (v1, t1), (v2, t2) ->
val piece = c.sub(t1, t2)
if (piece.empty) {
null
} else {
val e = XEdge(v1, v2, piece, s)
e
}
}.filterNotNull()
hEdgesMap.addAll(s, middleEdges)
// If the contour is closed, make the last edge connecting the ends.
if (c.closed) {
val (lastV, lastT) = tValues.last()
val (firstV, firstT) = tValues.first()
val lastPiece = c.sub(lastT, 1.0) + c.sub(0.0, firstT)
if (!lastPiece.empty) {
val lastEdge = XEdge(lastV, firstV, lastPiece, s)
hEdgesMap.add(s, lastEdge)
}
}
}
}
}
private fun createFaces() {
val remainingHalfEdges = halfEdges.toMutableList()
while(remainingHalfEdges.isNotEmpty()) {
// Pick an arbitrary half-edge that has not been handled yet
val heStart = remainingHalfEdges.first()
val visited = mutableListOf(heStart)
var current = heStart
var faceContour = heStart.contour
// Repeatedly go to the next half-edge, until we arrive back where we started.
var iters = 0
while (current.next != heStart && iters < maxIters) {
current = current.next
visited.add(current)
faceContour += current.contour
iters++
}
if (iters >= maxIters) {
error("Arrangement: A face seems to consist of more than maxIters ($maxIters) edges. This is likely " +
"a robustness issue arising from using input shapes that would result in small or thin faces.")
}
remainingHalfEdges.removeAll(visited)
val facePt = heStart.contour.position(0.5) + heStart.contour.normal(0.5) * -0.01
val origins = shapes.filter { s -> s.shape.closedContours.isNotEmpty() && facePt in s.shape }
val closed = if (faceContour.closed) faceContour else faceContour.close()
val f = if (closed.winding == Winding.CLOCKWISE) BoundedFace(heStart, origins, closed)
else XFace(heStart, emptyList())
faces.add(f)
for (s in origins) {
hFacesMap.add(s, f)
}
visited.forEach { e ->
e.face = f
}
}
}
}

View File

@@ -0,0 +1,82 @@
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.extra.color.spaces.ColorOKHSVa
import org.openrndr.extra.shapes.Arrangement
import org.openrndr.extra.shapes.hobbyCurve
import org.openrndr.math.Vector2
import org.openrndr.shape.Circle
import org.openrndr.shape.LineSegment
import org.openrndr.shape.Rectangle
import org.openrndr.shape.Shape
import kotlin.random.Random
fun main() = application {
configure {
width = 800
height = 800
}
program {
// Create some shapes
val outer = Circle(drawer.bounds.center, 200.0)
val inner = Circle(drawer.bounds.center, 150.0)
val annulus = Shape(listOf(outer.contour.clockwise, inner.contour.counterClockwise))
val rectangle = Rectangle.fromCenter(drawer.bounds.center, 200.0, 400.0)
val line = LineSegment(100.0, 400.0, 700.0, 400.0)
val circle2 = Circle(200.0, 300.0, 100.0)
val hobbyPts = listOf(
Vector2(750.0, 100.0),
Vector2(700.0, 300.0),
Vector2(600.0, 350.0),
Vector2(450.0, 450.0),
Vector2(475.0, 200.0),
)
val hobby = hobbyCurve(hobbyPts, closed=true)
val lineBelow = hobbyCurve(listOf(
Vector2(100.0, 700.0),
Vector2(300.0, 725.0),
Vector2(500.0, 675.0),
Vector2(700.0, 700.0),
))
val circleAbove = Circle(100.0, 100.0, 50.0)
// Construct an arrangement
val arrangement = Arrangement(annulus, rectangle, circle2, hobby, line, lineBelow, circleAbove)
extend {
drawer.apply {
clear(ColorRGBa.WHITE)
// Draw the faces that originate from (are a subset of) some input shape
val faces = arrangement.originFaces
for ((i, f) in faces.shuffled(Random(0)).withIndex()) {
stroke = null
fill = ColorOKHSVa(i * 360.0 / faces.size, 0.75, 1.0).toRGBa()
contour(f.contour)
}
// Draw the edges
for (e in arrangement.edges) {
strokeWeight = 2.0
stroke = ColorRGBa.BLACK
fill = null
contour(e.contour)
}
// Thicken the outer boundaries of each connected component and the 'holes' of the arrangement.
// Holes are faces that are not a subset of an input shape.
strokeWeight = 4.0
contours(arrangement.boundaries)
contours(arrangement.holes.map { it.contour })
// Draw the vertices
for (v in arrangement.vertices) {
strokeWeight = 2.5
stroke = ColorRGBa.BLACK
fill = ColorRGBa.WHITE
circle(v.pos, 6.0)
}
}
}
}
}

View File

@@ -0,0 +1,99 @@
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.isolated
import org.openrndr.extra.color.spaces.ColorOKHSVa
import org.openrndr.extra.noise.poissonDiskSampling
import org.openrndr.extra.shapes.Arrangement
import org.openrndr.extra.shapes.BoundedFace
import org.openrndr.extra.shapes.hobbyCurve
import kotlin.random.Random
fun main() = application {
configure {
width = 800
height = 800
}
program {
// Create a nice curve that intersects itself
val uniformPoints = poissonDiskSampling(drawer.bounds.offsetEdges(-200.0), 100.0, random=Random(10579))
val curve = hobbyCurve(uniformPoints, closed=true)
// Construct an arrangement of the curve. In order to obtain an arrangement dealing with self intersections,
// the curve is passed in twice.
val arrangement = Arrangement(curve, curve)
// We will color each bounded face.
val faces = arrangement.boundedFaces
val colors = faces.withIndex().associate { (i, f) ->
f to ColorOKHSVa(i * 360.0 / faces.size, 0.75, 1.0).toRGBa()
}
extend {
drawer.apply {
clear(ColorRGBa.WHITE)
isolated {
// Shrink the drawing
translate(drawer.bounds.center)
scale(0.5)
translate(-drawer.bounds.center)
// Draw each face
stroke = null
for (f in faces) {
fill = colors[f]
contour(f.contour)
}
// Draw the curve on top
fill = null
stroke = ColorRGBa.BLACK
strokeWeight = 4.0
contour(curve)
strokeWeight = 4.0
stroke = ColorRGBa.BLACK
fill = ColorRGBa.WHITE
circles(arrangement.vertices.map { it.pos }, 12.0)
}
// We are going to draw the neighborhood of each vertex in the arrangement
for (v in arrangement.vertices) {
isolated {
// Shrink the drawing quite a bit
translate(v.pos)
scale(0.35)
translate(-v.pos)
// Move the drawing in the direction of the vertex
translate((v.pos - drawer.bounds.center).normalized * 300.0)
// For each outgoing half-edge, draw the associated face
for (e in v.outgoing) {
val f = e.face as? BoundedFace
if (f != null) {
stroke = null
fill = colors[f]!!.opacify(0.5)
contour(f.contour)
}
}
// For each outgoing half-edge, draw the edge
for (e in v.outgoing) {
strokeWeight = 2.0/0.35
stroke = ColorRGBa.BLACK
contour(e.contour)
}
// Draw the vertex
strokeWeight = 2 / 0.35
stroke = ColorRGBa.BLACK
fill = ColorRGBa.WHITE
circle(v.pos, 6.0 / 0.35)
}
}
}
}
}
}