diff --git a/orx-shapes/src/commonMain/kotlin/hobbycurve/HobbyCurve.kt b/orx-shapes/src/commonMain/kotlin/hobbycurve/HobbyCurve.kt index 3a29a628..1b144811 100644 --- a/orx-shapes/src/commonMain/kotlin/hobbycurve/HobbyCurve.kt +++ b/orx-shapes/src/commonMain/kotlin/hobbycurve/HobbyCurve.kt @@ -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. - * @param points The list of points through which the curve should go. - * @param closed Whether to construct a closed or open curve. - * @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. - * @return A [ShapeContour] through [points]. + * Generates a smooth contour passing through a set of points based on Hobby's algorithm. + * + * @param points A list of 2D points through which the curve should pass. + * @param closed A boolean value indicating whether the curve is closed (true) + * 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, closed: Boolean = false, curl: Double = 0.0): ShapeContour { +fun hobbyCurve( + points: List, closed: Boolean = false, curl: Double = 0.0, + tensions: (chordIndex: Int, inAngleDegrees:Double, outAngleDegrees:Double) -> Pair = { _, _, _ -> Pair(1.0, 1.0) }, +): ShapeContour { if (points.size <= 1) return ShapeContour.EMPTY val m = points.size + + /** Chord count */ 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 distances = Array(n) { chords[it].length } 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) for (i in (if (closed) 0 else 1) until n) { gamma[i] = chords[(i - 1).mod(m)].atan22(chords[(i).mod(m)]) @@ -67,13 +83,12 @@ fun hobbyCurve(points: List, closed: Boolean = false, curl: Double = 0. val d = DoubleArray(m) { 0.0 } for (i in (if (closed) 0 else 1) until n) { - val j = (i + 1).mod(m) - val k = (i - 1).mod(m) - - a[i] = 1 / distances[k] - b[i] = (2 * distances[k] + 2 * distances[i]) / (distances[k] * distances[i]) + val next = (i + 1).mod(m) + val prev = (i - 1).mod(m) + a[i] = 1 / distances[prev] + b[i] = (2 * distances[prev] + 2 * distances[i]) / (distances[prev] * 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 @@ -102,7 +117,7 @@ fun hobbyCurve(points: List, closed: Boolean = false, curl: Double = 0. val t = c[n - 1] c[n - 1] = 0.0 alpha = sherman(a, b, c, d, s, t) - beta = DoubleArray(n) { 0.0 } + beta = DoubleArray(n) for (i in 0 until n) { val j = (i + 1) % n beta[i] = -gamma[j] - alpha[j] @@ -114,8 +129,9 @@ fun hobbyCurve(points: List, closed: Boolean = false, curl: Double = 0. for (i in 0 until n) { val v1 = rotateAngle(chords[i], alpha[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) - c2s.add(points[(i + 1) % m] - v2 * rho(beta[i], alpha[i]) * distances[i] / 3.0) + val t = tensions(i, gamma[i].asDegrees, gamma[(i + 1).mod(m)].asDegrees) + 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) } @@ -132,7 +148,7 @@ fun hobbyCurve( points: List, closed: Boolean = false, curl: Double = 0.0, - tensions: (chordIndex: Int) -> Pair = { _ -> Pair(1.0, 1.0) } + tensions: (chordIndex: Int, inAngleDegrees:Double, outAngleDegrees:Double) -> Pair = { _, _, _ -> Pair(1.0, 1.0) }, ): Path3D { 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 v1 = (r1 * 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) 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) } - -/** The Thomas algorithm: solve a system of linear equations encoded in a tridiagonal matrix. -https://en.wikipedia.org/wiki/Tridiagonal_matrix_algorithm +/** + * Solves a tridiagonal system of equations using the Thomas 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 { 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 b The second angle in radians, representing the direction of the tangent vector at the end of the segment. - * @return A computed value used to adjust the control points for the curve segment. + * @param a The angle (in radians) at the current point on the curve. + * @param b The angle (in radians) at the neighboring point on the curve. + * @return A calculated ratio used to control the curve's shape. */ private fun rho(a: Double, b: Double): Double { 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 rotateAngle(v: Vector2, alpha: Double) = rotate(v, sin(alpha), cos(alpha)) +private fun rotateAngle(v: Vector2, alpha: Double) = rotate(v, sin(alpha), cos(alpha)) \ No newline at end of file diff --git a/orx-shapes/src/commonTest/kotlin/TestHobbyCurve.kt b/orx-shapes/src/commonTest/kotlin/TestHobbyCurve.kt new file mode 100644 index 00000000..fbdbcbfe --- /dev/null +++ b/orx-shapes/src/commonTest/kotlin/TestHobbyCurve.kt @@ -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) + } +} \ No newline at end of file diff --git a/orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve02.kt b/orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve02.kt index 4a9bf284..7d8dfd24 100644 --- a/orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve02.kt +++ b/orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve02.kt @@ -8,6 +8,10 @@ import org.openrndr.math.Vector2 import kotlin.random.Random fun main() = application { + configure { + width = 720 + height = 720 + } program { val points = List(40) { Vector2( diff --git a/orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve03.kt b/orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve03.kt new file mode 100644 index 00000000..8b542fe2 --- /dev/null +++ b/orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve03.kt @@ -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) + })) + } + } + } +} \ No newline at end of file diff --git a/orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve3D01.kt b/orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve3D01.kt index 81640a87..09948d0d 100644 --- a/orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve3D01.kt +++ b/orx-shapes/src/jvmDemo/kotlin/hobbycurve/DemoHobbyCurve3D01.kt @@ -29,7 +29,7 @@ fun main() = application { val hobby3D = hobbyCurve( pts.map { it.xy0 + Vector3(0.0, 0.0, Double.uniform(-360.0, 360.0, r)) }, true, - tensions = { chordIndex: Int -> + tensions = { chordIndex, inAngle, outAngle -> Pair( cos(seconds + chordIndex * 0.1) * 0.5 + 0.5, cos(seconds + (1.0 + chordIndex) * 0.1) * 0.5 + 0.5