[orx-shapes] Add custom tensions to HobbyCurve and related 2D/3D demos and tests
This commit is contained in:
@@ -38,23 +38,39 @@ private fun Vector3.atan22(other: Vector3): Double {
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uses Hobby's algorithm to construct a [ShapeContour] through a given list of points.
|
* Generates a smooth contour passing through a set of points based on Hobby's algorithm.
|
||||||
* @param points The list of points through which the curve should go.
|
*
|
||||||
* @param closed Whether to construct a closed or open curve.
|
* @param points A list of 2D points through which the curve should pass.
|
||||||
* @param curl The 'curl' at the endpoints of the curve; this is only applicable when [closed] is false. Best results for values in [-1, 1], where a higher value makes segments closer to circular arcs.
|
* @param closed A boolean value indicating whether the curve is closed (true)
|
||||||
* @return A [ShapeContour] through [points].
|
* forming a loop, or open (false). The default is false.
|
||||||
|
* @param curl A parameter that controls the curvature of the open-ended curve. Only
|
||||||
|
* applicable if the curve is not closed. The default is 0.0.
|
||||||
|
* @param tensions A lambda function that accepts the chord index (an integer) and returns
|
||||||
|
* a pair of tension values (Double) for the curve's control points. These
|
||||||
|
* tensions influence how tightly the curve conforms to the points.
|
||||||
|
* The default lambda assigns both values as 1.0.
|
||||||
|
* @return A ShapeContour object representing the smoothed curve based on Hobby's algorithm.
|
||||||
*/
|
*/
|
||||||
fun hobbyCurve(points: List<Vector2>, closed: Boolean = false, curl: Double = 0.0): ShapeContour {
|
fun hobbyCurve(
|
||||||
|
points: List<Vector2>, closed: Boolean = false, curl: Double = 0.0,
|
||||||
|
tensions: (chordIndex: Int, inAngleDegrees:Double, outAngleDegrees:Double) -> Pair<Double, Double> = { _, _, _ -> Pair(1.0, 1.0) },
|
||||||
|
): ShapeContour {
|
||||||
if (points.size <= 1) return ShapeContour.EMPTY
|
if (points.size <= 1) return ShapeContour.EMPTY
|
||||||
|
|
||||||
val m = points.size
|
val m = points.size
|
||||||
|
|
||||||
|
/** Chord count */
|
||||||
val n = if (closed) m else m - 1
|
val n = if (closed) m else m - 1
|
||||||
|
|
||||||
|
/** Chords array stores vectors representing line segments between consecutive points
|
||||||
|
Each chord is calculated as the vector difference between the next point and current point */
|
||||||
val chords = Array(n) { points[(it + 1) % m] - points[it] }
|
val chords = Array(n) { points[(it + 1) % m] - points[it] }
|
||||||
val distances = Array(n) { chords[it].length }
|
val distances = Array(n) { chords[it].length }
|
||||||
|
|
||||||
require(distances.all { it > 0.0 })
|
require(distances.all { it > 0.0 })
|
||||||
|
|
||||||
|
/** Array storing turning angles (in radians) between adjacent chords at each point
|
||||||
|
For each point i, gamma[i] represents the angle between chord[i-1] and chord[i] */
|
||||||
val gamma = DoubleArray(m)
|
val gamma = DoubleArray(m)
|
||||||
for (i in (if (closed) 0 else 1) until n) {
|
for (i in (if (closed) 0 else 1) until n) {
|
||||||
gamma[i] = chords[(i - 1).mod(m)].atan22(chords[(i).mod(m)])
|
gamma[i] = chords[(i - 1).mod(m)].atan22(chords[(i).mod(m)])
|
||||||
@@ -67,13 +83,12 @@ fun hobbyCurve(points: List<Vector2>, closed: Boolean = false, curl: Double = 0.
|
|||||||
val d = DoubleArray(m) { 0.0 }
|
val d = DoubleArray(m) { 0.0 }
|
||||||
|
|
||||||
for (i in (if (closed) 0 else 1) until n) {
|
for (i in (if (closed) 0 else 1) until n) {
|
||||||
val j = (i + 1).mod(m)
|
val next = (i + 1).mod(m)
|
||||||
val k = (i - 1).mod(m)
|
val prev = (i - 1).mod(m)
|
||||||
|
a[i] = 1 / distances[prev]
|
||||||
a[i] = 1 / distances[k]
|
b[i] = (2 * distances[prev] + 2 * distances[i]) / (distances[prev] * distances[i])
|
||||||
b[i] = (2 * distances[k] + 2 * distances[i]) / (distances[k] * distances[i])
|
|
||||||
c[i] = 1 / distances[i]
|
c[i] = 1 / distances[i]
|
||||||
d[i] = -(2 * gamma[i] * distances[i] + gamma[j] * distances[k]) / (distances[k] * distances[i])
|
d[i] = -(2 * gamma[i] * distances[i] + gamma[next] * distances[prev]) / (distances[prev] * distances[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
val alpha: DoubleArray
|
val alpha: DoubleArray
|
||||||
@@ -102,7 +117,7 @@ fun hobbyCurve(points: List<Vector2>, closed: Boolean = false, curl: Double = 0.
|
|||||||
val t = c[n - 1]
|
val t = c[n - 1]
|
||||||
c[n - 1] = 0.0
|
c[n - 1] = 0.0
|
||||||
alpha = sherman(a, b, c, d, s, t)
|
alpha = sherman(a, b, c, d, s, t)
|
||||||
beta = DoubleArray(n) { 0.0 }
|
beta = DoubleArray(n)
|
||||||
for (i in 0 until n) {
|
for (i in 0 until n) {
|
||||||
val j = (i + 1) % n
|
val j = (i + 1) % n
|
||||||
beta[i] = -gamma[j] - alpha[j]
|
beta[i] = -gamma[j] - alpha[j]
|
||||||
@@ -114,8 +129,9 @@ fun hobbyCurve(points: List<Vector2>, closed: Boolean = false, curl: Double = 0.
|
|||||||
for (i in 0 until n) {
|
for (i in 0 until n) {
|
||||||
val v1 = rotateAngle(chords[i], alpha[i]).normalized
|
val v1 = rotateAngle(chords[i], alpha[i]).normalized
|
||||||
val v2 = rotateAngle(chords[i], -beta[i]).normalized
|
val v2 = rotateAngle(chords[i], -beta[i]).normalized
|
||||||
c1s.add(points[i % m] + v1 * rho(alpha[i], beta[i]) * distances[i] / 3.0)
|
val t = tensions(i, gamma[i].asDegrees, gamma[(i + 1).mod(m)].asDegrees)
|
||||||
c2s.add(points[(i + 1) % m] - v2 * rho(beta[i], alpha[i]) * distances[i] / 3.0)
|
c1s.add(points[i % m] + v1 * rho(alpha[i], beta[i]) * t.first * distances[i] / 3.0)
|
||||||
|
c2s.add(points[(i + 1) % m] - v2 * rho(beta[i], alpha[i]) * t.second * distances[i] / 3.0)
|
||||||
}
|
}
|
||||||
return ShapeContour(List(n) { Segment2D(points[it], c1s[it], c2s[it], points[(it + 1) % m]) }, closed = closed)
|
return ShapeContour(List(n) { Segment2D(points[it], c1s[it], c2s[it], points[(it + 1) % m]) }, closed = closed)
|
||||||
}
|
}
|
||||||
@@ -132,7 +148,7 @@ fun hobbyCurve(
|
|||||||
points: List<Vector3>,
|
points: List<Vector3>,
|
||||||
closed: Boolean = false,
|
closed: Boolean = false,
|
||||||
curl: Double = 0.0,
|
curl: Double = 0.0,
|
||||||
tensions: (chordIndex: Int) -> Pair<Double, Double> = { _ -> Pair(1.0, 1.0) }
|
tensions: (chordIndex: Int, inAngleDegrees:Double, outAngleDegrees:Double) -> Pair<Double, Double> = { _, _, _ -> Pair(1.0, 1.0) },
|
||||||
): Path3D {
|
): Path3D {
|
||||||
if (points.size <= 1) return Path3D.EMPTY
|
if (points.size <= 1) return Path3D.EMPTY
|
||||||
|
|
||||||
@@ -212,7 +228,7 @@ fun hobbyCurve(
|
|||||||
val r2 = buildTransform { rotate(normals[(i + 1).mod(normals.size)], -beta[i].asDegrees) }
|
val r2 = buildTransform { rotate(normals[(i + 1).mod(normals.size)], -beta[i].asDegrees) }
|
||||||
val v1 = (r1 * chords[i].xyz0).xyz.normalized
|
val v1 = (r1 * chords[i].xyz0).xyz.normalized
|
||||||
val v2 = (r2 * chords[i].xyz0).xyz.normalized
|
val v2 = (r2 * chords[i].xyz0).xyz.normalized
|
||||||
val t = tensions(i)
|
val t = tensions(i, gamma[i].asDegrees, gamma[(i + 1).mod(m)].asDegrees)
|
||||||
c1s.add(points[i % m] + v1 * rho(alpha[i], beta[i]) * distances[i] * t.first / 3.0)
|
c1s.add(points[i % m] + v1 * rho(alpha[i], beta[i]) * distances[i] * t.first / 3.0)
|
||||||
c2s.add(points[(i + 1) % m] - v2 * rho(beta[i], alpha[i]) * distances[i] * t.second / 3.0)
|
c2s.add(points[(i + 1) % m] - v2 * rho(beta[i], alpha[i]) * distances[i] * t.second / 3.0)
|
||||||
}
|
}
|
||||||
@@ -222,9 +238,14 @@ fun hobbyCurve(
|
|||||||
}, closed = closed)
|
}, closed = closed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/** The Thomas algorithm: solve a system of linear equations encoded in a tridiagonal matrix.
|
* Solves a tridiagonal system of equations using the Thomas algorithm.
|
||||||
https://en.wikipedia.org/wiki/Tridiagonal_matrix_algorithm
|
* [Wikipedia](https://en.wikipedia.org/wiki/Tridiagonal_matrix_algorithm)
|
||||||
|
* @param a The subdiagonal elements of the tridiagonal matrix.
|
||||||
|
* @param b The diagonal elements of the tridiagonal matrix.
|
||||||
|
* @param c The superdiagonal elements of the tridiagonal matrix.
|
||||||
|
* @param d The right-hand side vector of the system.
|
||||||
|
* @return A double array representing the solution to the tridiagonal system.
|
||||||
*/
|
*/
|
||||||
private fun thomas(a: DoubleArray, b: DoubleArray, c: DoubleArray, d: DoubleArray): DoubleArray {
|
private fun thomas(a: DoubleArray, b: DoubleArray, c: DoubleArray, d: DoubleArray): DoubleArray {
|
||||||
val n = a.size
|
val n = a.size
|
||||||
@@ -283,11 +304,11 @@ private fun sherman(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates a parameter used in Hobby's algorithm for constructing smooth curves.
|
* Calculates a ratio used in Hobby's curve construction based on the angles between curve segments.
|
||||||
*
|
*
|
||||||
* @param a The first angle in radians, representing the direction of the tangent vector at the start of the segment.
|
* @param a The angle (in radians) at the current point on the curve.
|
||||||
* @param b The second angle in radians, representing the direction of the tangent vector at the end of the segment.
|
* @param b The angle (in radians) at the neighboring point on the curve.
|
||||||
* @return A computed value used to adjust the control points for the curve segment.
|
* @return A calculated ratio used to control the curve's shape.
|
||||||
*/
|
*/
|
||||||
private fun rho(a: Double, b: Double): Double {
|
private fun rho(a: Double, b: Double): Double {
|
||||||
val sa = sin(a)
|
val sa = sin(a)
|
||||||
@@ -301,4 +322,4 @@ private fun rho(a: Double, b: Double): Double {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun rotate(v: Vector2, s: Double, c: Double) = Vector2(v.x * c - v.y * s, v.x * s + v.y * c)
|
private fun rotate(v: Vector2, s: Double, c: Double) = Vector2(v.x * c - v.y * s, v.x * s + v.y * c)
|
||||||
private fun rotateAngle(v: Vector2, alpha: Double) = rotate(v, sin(alpha), cos(alpha))
|
private fun rotateAngle(v: Vector2, alpha: Double) = rotate(v, sin(alpha), cos(alpha))
|
||||||
19
orx-shapes/src/commonTest/kotlin/TestHobbyCurve.kt
Normal file
19
orx-shapes/src/commonTest/kotlin/TestHobbyCurve.kt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import org.openrndr.extra.shapes.hobbycurve.hobbyCurve
|
||||||
|
import org.openrndr.shape.Rectangle
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class TestHobbyCurve {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSymmetric() {
|
||||||
|
val rectangle = Rectangle(0.0, 0.0, 100.0, 100.0).contour
|
||||||
|
val h = rectangle.hobbyCurve()
|
||||||
|
assertTrue(h.closed)
|
||||||
|
assertEquals(4, h.segments.size)
|
||||||
|
assertEquals(-1.0, h.direction(0.25).dot(h.direction(0.75)), 1e-6)
|
||||||
|
assertEquals(-1.0, h.direction(0.125).dot(h.direction(0.625)), 1e-6)
|
||||||
|
assertEquals(-1.0, h.direction(0.375).dot(h.direction(0.875)), 1e-6)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import org.openrndr.math.Vector2
|
|||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
fun main() = application {
|
fun main() = application {
|
||||||
|
configure {
|
||||||
|
width = 720
|
||||||
|
height = 720
|
||||||
|
}
|
||||||
program {
|
program {
|
||||||
val points = List(40) {
|
val points = List(40) {
|
||||||
Vector2(
|
Vector2(
|
||||||
|
|||||||
28
orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve03.kt
Normal file
28
orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve03.kt
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package hobbycurve
|
||||||
|
|
||||||
|
import org.openrndr.application
|
||||||
|
import org.openrndr.color.ColorRGBa
|
||||||
|
import org.openrndr.extra.noise.scatter
|
||||||
|
import org.openrndr.extra.shapes.hobbycurve.hobbyCurve
|
||||||
|
import org.openrndr.extra.shapes.ordering.hilbertOrder
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
fun main() = application {
|
||||||
|
configure {
|
||||||
|
width = 720
|
||||||
|
height = 720
|
||||||
|
}
|
||||||
|
program {
|
||||||
|
extend {
|
||||||
|
for (i in -20..20) {
|
||||||
|
val t = i / 10.0
|
||||||
|
val points = drawer.bounds.offsetEdges(-50.0).scatter(25.0, random = Random(0)).hilbertOrder()
|
||||||
|
drawer.stroke = ColorRGBa.WHITE.opacify(0.5)
|
||||||
|
drawer.fill = null
|
||||||
|
drawer.contour(hobbyCurve(points, closed = false, tensions = { i, inAngle, outAngle ->
|
||||||
|
Pair(t, t)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ fun main() = application {
|
|||||||
val hobby3D = hobbyCurve(
|
val hobby3D = hobbyCurve(
|
||||||
pts.map { it.xy0 + Vector3(0.0, 0.0, Double.uniform(-360.0, 360.0, r)) },
|
pts.map { it.xy0 + Vector3(0.0, 0.0, Double.uniform(-360.0, 360.0, r)) },
|
||||||
true,
|
true,
|
||||||
tensions = { chordIndex: Int ->
|
tensions = { chordIndex, inAngle, outAngle ->
|
||||||
Pair(
|
Pair(
|
||||||
cos(seconds + chordIndex * 0.1) * 0.5 + 0.5,
|
cos(seconds + chordIndex * 0.1) * 0.5 + 0.5,
|
||||||
cos(seconds + (1.0 + chordIndex) * 0.1) * 0.5 + 0.5
|
cos(seconds + (1.0 + chordIndex) * 0.1) * 0.5 + 0.5
|
||||||
|
|||||||
Reference in New Issue
Block a user