From 2fb2b11180ab59048f035558c5e040d3aa9f34a6 Mon Sep 17 00:00:00 2001 From: Steven van den Broek <30909373+Yvee1@users.noreply.github.com> Date: Tue, 2 Jan 2024 20:45:11 +0100 Subject: [PATCH] [orx-shapes] Add geometric arrangement together with two demos. (#324) --- orx-shapes/build.gradle.kts | 1 + .../src/commonMain/kotlin/Arrangement.kt | 389 ++++++++++++++++++ .../src/jvmDemo/kotlin/DemoArrangement01.kt | 82 ++++ .../src/jvmDemo/kotlin/DemoArrangement02.kt | 99 +++++ 4 files changed, 571 insertions(+) create mode 100644 orx-shapes/src/commonMain/kotlin/Arrangement.kt create mode 100644 orx-shapes/src/jvmDemo/kotlin/DemoArrangement01.kt create mode 100644 orx-shapes/src/jvmDemo/kotlin/DemoArrangement02.kt diff --git a/orx-shapes/build.gradle.kts b/orx-shapes/build.gradle.kts index 77d0aef0..96e19e61 100644 --- a/orx-shapes/build.gradle.kts +++ b/orx-shapes/build.gradle.kts @@ -10,6 +10,7 @@ kotlin { implementation(project(":orx-parameters")) implementation(project(":orx-shader-phrases")) implementation(project(":orx-color")) + implementation(project(":orx-kdtree")) implementation(libs.openrndr.application) implementation(libs.openrndr.draw) implementation(libs.openrndr.filter) diff --git a/orx-shapes/src/commonMain/kotlin/Arrangement.kt b/orx-shapes/src/commonMain/kotlin/Arrangement.kt new file mode 100644 index 00000000..f4e16689 --- /dev/null +++ b/orx-shapes/src/commonMain/kotlin/Arrangement.kt @@ -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 MutableMap>.add(key: K, value: V) { + val ml = get(key) + if (ml != null) + ml.add(value) + else set(key, mutableListOf(value)) +} + +private fun MutableMap>.addAll(key: K, values: Collection) { + 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() + + /** The half-edges entering this vertex */ + val incoming = mutableListOf() +} + +/** + * 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 + + init { + // Once an edge is created, also create half-edges and do the necessary bookkeeping. + splitAndAdd() + } + + private fun split(): Pair { + 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 { + 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) + +/** + * 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, 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, val maxIters: Int = 1000) { + constructor(vararg shapes: ShapeProvider): this(shapes.toList()) + + /** Maps a contour to the vertices incident to it. */ + val cVertsMap = mutableMapOf>>() + + /** Maps a shape to the edges incident to it. */ + val hEdgesMap = mutableMapOf>() + + /** Maps a shape to the faces that it is a superset of. */ + val hFacesMap = mutableMapOf>() + + /** 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() + + val boundedFaces by lazy { faces.filterIsInstance() } + 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 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) + + 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() + + 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() + 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 + } + } + } +} diff --git a/orx-shapes/src/jvmDemo/kotlin/DemoArrangement01.kt b/orx-shapes/src/jvmDemo/kotlin/DemoArrangement01.kt new file mode 100644 index 00000000..dc0e6a19 --- /dev/null +++ b/orx-shapes/src/jvmDemo/kotlin/DemoArrangement01.kt @@ -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) + } + } + } + } +} \ No newline at end of file diff --git a/orx-shapes/src/jvmDemo/kotlin/DemoArrangement02.kt b/orx-shapes/src/jvmDemo/kotlin/DemoArrangement02.kt new file mode 100644 index 00000000..370273f3 --- /dev/null +++ b/orx-shapes/src/jvmDemo/kotlin/DemoArrangement02.kt @@ -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) + } + } + } + } + } +} \ No newline at end of file