diff --git a/orx-fcurve/README.md b/orx-fcurve/README.md new file mode 100644 index 00000000..5128b04b --- /dev/null +++ b/orx-fcurve/README.md @@ -0,0 +1,84 @@ +# orx-fcurve + +FCurves are 1 dimensional function curves constructed from 2D bezier functions. + +The language to express Fcurves is similar to SVG's path language. + +| Command | Description | +|-----------------------|-------------------------------------------------------------| +| `m/M y` | move the pen only in the y-direction | +| `h/H x` | draw a horizontal line | +| `l/L x y` | line to (x, y) | +| `q/Q x0 y0 x y` | quadratic bezier to (x,y) and control-point (x0, y0) | +| `c/C x0 y0 x1 y1 x y` | cubic bezier to (x,y) and control-points (x0, y0), (x1, y1) | +| `t/T x y` | quadratic smooth to (x, y) | +| `s/S x1 y1 x y` | cubic smooth to (x,y) and control point (x1, y1) | + +## Example Fcurves + +`M0 l5,10 q4,-10` or `M0 l5 10 q4 -10` + +`M0 h10 c3,10,5,-10,8,0.5 L5,5` + +New lines are allowed, which can help in formatting the Fcurve +``` +M0 h10 +c3,10,5,-10,8,0.5 +L5,5 +``` + +# EFCurves + +EFCurves are Fcurves with an additional preprocessing step in which scalar expressions are evaluated. + +## Comments + +EFCurves add support for comments using the `#` character. + +`M0 h10 c3,10,5,-10,8,0.5 # L5,5` + + +``` +M0 h10 # setup the initial y value and hold it for 10 units. +c3,10,5,-10,8,0.5 # relative cubic bezier curve +# and a final line-to +L5,5 +``` + +## Expressions + +For example: `M0 L_3 * 4_,4` evaluates to `M0 L12,4`. + +`orx-expression-evaluator` is used to evaluate the expressions, please refer to its +documentation for details on the expression language used. + +## Repetitions + +EFCurves add support for repetitions. Repetitions are expanded by replacing +occurrences of `||[]` with `number-of-repetitions` copies +of `text-to-repeat`. + +For example: + * `M0 |h1 m1|[3]` expands to `M0 h1 m1 h1 m1 h1 m1` + * `M0 |h1 m1|[0]` expands to `M0` + +### Nested repetitions + +Repetitions can be nested. + +For example `|M0 |h1 m1|[3]|[2]` expands to `M0 h1 m1 h1 m1 h1 m1 M0 h1 m1 h1 m1 h1 m1`. + +### Interaction between repetitions and expressions + +`M0 |H_it + 1_ m1][3]` expands to `M0 H1 m1 H2 m1 H3 m1` + + + +`M0 |H_index + 1_ m_it_]{1.2, 1.3, 1.4}` expands to `M0 H1 m1.2 H2 m1.3 H3 m1.4` + + + +# References + * https://x.com/ruby0x1/status/1258252352672247814 + * https://blender.stackexchange.com/questions/52403/what-is-the-mathematical-basis-for-f-curves/52468#52468 + * https://pomax.github.io/bezierinfo/#yforx \ No newline at end of file diff --git a/orx-fcurve/build.gradle.kts b/orx-fcurve/build.gradle.kts new file mode 100644 index 00000000..540613ca --- /dev/null +++ b/orx-fcurve/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + org.openrndr.extra.convention.`kotlin-multiplatform` + id(libs.plugins.kotlin.serialization.get().pluginId) +} + +kotlin { + sourceSets { + @Suppress("UNUSED_VARIABLE") + val commonMain by getting { + dependencies { + implementation(project(":orx-parameters")) + implementation(project(":orx-expression-evaluator")) + implementation(libs.openrndr.application) + implementation(libs.openrndr.draw) + implementation(libs.openrndr.filter) + implementation(libs.kotlin.reflect) + implementation(libs.kotlin.serialization.core) + } + } + + @Suppress("UNUSED_VARIABLE") + val jvmDemo by getting { + dependencies { + implementation(project(":orx-fcurve")) + implementation(project(":orx-noise")) + } + } + } +} \ No newline at end of file diff --git a/orx-fcurve/src/commonMain/kotlin/EFCurve.kt b/orx-fcurve/src/commonMain/kotlin/EFCurve.kt new file mode 100644 index 00000000..1f70d829 --- /dev/null +++ b/orx-fcurve/src/commonMain/kotlin/EFCurve.kt @@ -0,0 +1,89 @@ +package org.openrndr.extra.fcurve + +import org.openrndr.extra.expressions.evaluateExpression + +/** + * expand efcurve to fcurve + * @param ef an efcurve string + * @param constants a map of constants that is passed to [evaluateExpression] + */ +fun efcurve(ef: String, constants: Map = emptyMap()): String { + val expression = Regex("_([^_]+)_") + val repetition = Regex("\\|([^|]+)\\|\\[([^\\[\\]]+)]") + val list = Regex("\\|([^|]+)\\|\\{([^\\[\\]]+)}") + + /** + * perform comment substitution + * (?m) enables multiline mode + */ + var curve = Regex("(?m)(#.*)$").replace(ef, "") + + /** + * Allow for nested repetitions and lists + */ + do { + val referenceCurve = curve + + /** + * perform list expansion |text|{items} + */ + curve = list.replace(curve) { occ -> + val listText = expression.replace(occ.groupValues[2]) { exp -> + val expressionText = exp.groupValues[1] + evaluateExpression(expressionText, constants)?.toString() + ?: error("parse error in repetition count expression '$expressionText'") + } + val listTokens = listText.split(Regex("[,;][\t\n ]*|[\t\n ]+")) + val listItems = listTokens.filter { it.isNotEmpty() } + .map { it.trim().toDoubleOrNull() ?: error("'$it' is not a number in $listTokens") } + + listItems.mapIndexed { index, value -> + expression.replace(occ.groupValues[1]) { exp -> + val expressionText = exp.groupValues[1] + evaluateExpression( + exp.groupValues[1], + constants + mapOf("index" to index.toDouble(), "it" to value) + )?.toString() ?: error("parse error in repeated expression '$expressionText'") + } + }.joinToString(" ") + } + + /** + * perform repetition expansion |text|[repeat-count] + */ + curve = repetition.replace(curve) { occ -> + val repetitions = expression.replace(occ.groupValues[2]) { exp -> + val expressionText = exp.groupValues[1] + evaluateExpression(expressionText, constants)?.toInt()?.toString() + ?: error("parse error in repetition count expression '$expressionText'") + }.toInt() + List(repetitions) { repetition -> + expression.replace(occ.groupValues[1]) { exp -> + val expressionText = exp.groupValues[1] + evaluateExpression(exp.groupValues[1], constants + mapOf("it" to repetition.toDouble()))?.toString() + ?: error("parse error in repeated expression '$expressionText'") + } + }.joinToString(" ") + } + } while (curve != referenceCurve) + + /** + * evaluate expression in expansion + */ + return (expression.replace(curve) { ef -> + evaluateExpression(ef.groupValues[1], constants)?.toString() ?: error("parse error in '$curve") + }) +} + +fun main() { + efcurve("""M1 |h5 m3|{ + |10.3 # toch wel handig zo'n comment + |11.2 + |14.5 + |} + """.trimMargin()) + + println(efcurve("|M0 |h4 m3|[2]|[5]")) + + println(efcurve("""M0|h4 m_it_|{|_cos(it * PI * 0.5)_ |[4]}""")) +} \ No newline at end of file diff --git a/orx-fcurve/src/commonMain/kotlin/FCurve.kt b/orx-fcurve/src/commonMain/kotlin/FCurve.kt new file mode 100644 index 00000000..f55adad2 --- /dev/null +++ b/orx-fcurve/src/commonMain/kotlin/FCurve.kt @@ -0,0 +1,478 @@ +package org.openrndr.extra.fcurve + +import kotlinx.serialization.Serializable +import org.openrndr.math.Vector2 +import org.openrndr.math.transforms.buildTransform +import org.openrndr.shape.Segment +import org.openrndr.shape.ShapeContour +import kotlin.math.abs + +/** + * Find the (first) t value for a given [x] value + */ +private fun Segment.tForX(x: Double): Double { + if (linear) { + return (x - start.x) / (end.x - start.x) + } else { + val cb = this.cubic + val a = cb.start.x - x + val b = cb.control[0].x - x + val c = cb.control[1].x - x + val d = cb.end.x - x + + val t = getCubicRoots(a, b, c, d).firstOrNull() ?: 0.0 + + return t + } +} + +/** + * Find the y value for a given [x] value + */ +private fun Segment.yForX(x: Double): Double { + val t = tForX(x) + return position(t).y +} + +/** + * Scale tangents such that tangent lines do not overlap + */ +fun Segment.scaleTangents(axis: Vector2 = Vector2.UNIT_X): Segment { + if (linear) { + return this + } else { + val c = this.cubic + val width = end.distanceTo(start) + + val d = c.end - c.start + val cd0 = (c.control[0] - c.start).projectedOn(axis) + val cd0a = cd0.dot(axis) + val cd1 = (c.control[1] - c.end).projectedOn(-axis) + val cd1a = cd1.dot(-axis) + val handleWidth = cd0.length + cd1.length + + val r = width / handleWidth + val c0 = (if (handleWidth > width) (c.control[0] - c.start) * r + c.start else c.control[0]).let { + if (cd0a <= 0.0) { + (it - c.start).projectedOn((axis).perpendicular()) + c.start + } else { + it + } + } + val c1 = (if (handleWidth > width) (c.control[1] - c.end) * r + c.end else c.control[1]).let { + if (cd1a <= 0.0) { + (it - c.end).projectedOn((-axis).perpendicular()) + c.end + } else { + it + } + } + return copy(control = listOf(c0, c1)) + } +} + +/** + * Fcurve class + */ +@Serializable +data class FCurve(val segments: List) { + + /** + * Reverse the fcurve + */ + fun reverse(): FCurve { + val d = duration + val t = buildTransform { + translate(d, 0.0) + scale(-1.0, 1.0) + } + return FCurve(segments.map { it.reverse.transform(t) }) + } + + /** + * Change the duration of the Fcurve + */ + fun changeSpeed(speed: Double): FCurve { + val c = if (speed < 0.0) reverse() else this + return if (speed == 1.0) c else { + val t = buildTransform { + scale(1.0 / speed, 1.0) + } + FCurve(c.segments.map { it.transform(t) }) + } + } + + /** + * Create a sampler or function from the Fcurve + */ + fun sampler(normalized: Boolean = false): (Double) -> Double { + var cachedSegment: Segment? = null + if (!normalized) { + return { t -> + val r = valueWithSegment(t, cachedSegment) + cachedSegment = r.second + r.first + } + } else { + val d = duration + return { t -> + val r = valueWithSegment(t * d, cachedSegment) + cachedSegment = r.second + r.first + } + } + } + + /** + * The unitless duration of the Fcurve + */ + val duration: Double + get() { + return if (segments.isEmpty()) { + 0.0 + } else { + segments.last().end.x + } + } + + /** + * Evaluate the Fcurve at [t] + * @param segment an optional segment that can be used to speed up scanning for the relevant segment + * @see valueWithSegment + */ + fun value(t: Double, segment: Segment? = null): Double = valueWithSegment(t, segment).first + + /** + * Evaluate the Fcurve at [t] + * @param segment an optional segment that can be used to speed up scanning for the relevant segment + */ + fun valueWithSegment(t: Double, cachedSegment: Segment? = null): Pair { + if (cachedSegment != null) { + if (t >= cachedSegment.start.x && t < cachedSegment.end.x) { + return Pair(cachedSegment.yForX(t), cachedSegment) + } + } + + if (segments.isEmpty()) { + return Pair(0.0, null) + } + if (t < segments.first().start.x) { + val segment = segments.first() + return Pair(segment.start.y, segment) + } else if (t > segments.last().end.x) { + val segment = segments.last() + return Pair(segment.end.y, segment) + } else { + val segmentIndex = segments.binarySearch { + if (t < it.start.x) { + 1 + } else if (t > it.end.x) { + -1 + } else { + 0 + } + } + val segment = segments.getOrNull(segmentIndex) + return Pair(segment?.yForX(t) ?: 0.0, segment) + } + } + + /** + * Return a list of contours that can be used to visualize the Fcurve + */ + fun contours(scale: Vector2 = Vector2.ONE): List { + var active = mutableListOf() + val result = mutableListOf() + + for (segment in segments) { + if (active.isEmpty()) { + active.add(segment.transform(buildTransform { + scale(scale.x, scale.y) + })) + } else { + val dy = abs(active.last().end.y - segment.start.y) + if (dy > 1E-3) { + result.add(ShapeContour.fromSegments(active, false)) + active = mutableListOf() + } + active.add(segment.transform(buildTransform { + scale(scale.x, scale.y) + })) + } + } + if (active.isNotEmpty()) { + result.add(ShapeContour.fromSegments(active, false)) + } + return result + } +} + +/** + * Fcurve builder + */ +class FCurveBuilder { + val segments = mutableListOf() + var cursor = Vector2(0.0, 0.0) + + var path = "" + + fun moveTo(y: Double, relative: Boolean = false) { + cursor = if (!relative) cursor.copy(y = y) else cursor.copy(y = cursor.y + y) + path += "${if (relative) "m" else "M"}$y" + } + + fun lineTo(x: Double, y: Double, relative: Boolean = false) { + val r = if (relative) 1.0 else 0.0 + segments.add(Segment(cursor, Vector2(x + cursor.x, y + cursor.y * r))) + cursor = Vector2(cursor.x + x, cursor.y * r + y) + path += "${if (relative) "l" else "L"}$x,$y" + } + + fun curveTo( + x0: Double, y0: Double, + x: Double, y: Double, + relative: Boolean = false + ) { + val r = if (relative) 1.0 else 0.0 + segments.add( + Segment( + cursor, + Vector2(cursor.x + x0, cursor.y * r + y0), + Vector2(cursor.x + x, cursor.y * r + y) + ) + ) + cursor = Vector2(cursor.x + x, cursor.y * r + y) + path += "${if (relative) "q" else "Q"}$x0,$y0,$x,$y" + } + + fun curveTo( + x0: Double, y0: Double, + x1: Double, y1: Double, + x: Double, y: Double, relative: Boolean = false + ) { + val r = if (relative) 1.0 else 0.0 + segments.add( + Segment( + cursor, + Vector2(cursor.x + x0, cursor.y * r + y0), + Vector2(cursor.x + x1, cursor.y * r + y1), + Vector2(cursor.x + x, cursor.y * r + y) + ).scaleTangents() + ) + cursor = Vector2(cursor.x + x, cursor.y * r + y) + path += "${if (relative) "c" else "C"}$x0,$y0,$x,$y" + } + + fun continueTo(x: Double, y: Double, relative: Boolean = false) { + val r = if (relative) 1.0 else 0.0 + val lastSegment = segments.last() + val lastDuration = lastSegment.end.x - lastSegment.start.x + val outTangent = segments.last().cubic.control.last() + val outPos = lastSegment.end + val dx = outPos.x - outTangent.x + val dy = outPos.y - outTangent.y + val ts = x / lastDuration + segments.add( + Segment( + cursor, + Vector2(cursor.x + dx * ts, cursor.y + dy), + Vector2(cursor.x + x * 0.66, cursor.y * r + y), + Vector2(cursor.x + x, cursor.y * r + y) + ).scaleTangents() + ) + cursor = Vector2(cursor.x + x, cursor.y * r + y) + path += "${if (relative) "t" else "T"}$x,$y" + } + + fun continueTo(x1: Double, y1: Double, x: Double, y: Double, relative: Boolean = false) { + val r = if (relative) 1.0 else 0.0 + val lastSegment = segments.last() + val outTangent = if (lastSegment.linear) lastSegment.position(0.5) else segments.last().control.last() + val dx = cursor.x - outTangent.x + val dy = cursor.y - outTangent.y + segments.add( + Segment( + cursor, + Vector2(cursor.x + dx, cursor.y + dy), + Vector2(cursor.x + x1, cursor.y * r + y1), + Vector2(cursor.x + x, cursor.y * r + y) + ).scaleTangents() + ) + path += "${if (relative) "s" else "S"}$x1,$y1,$x,$y" + } + + fun hold(x: Double, relative: Boolean = true) { + if (relative) { + lineTo(x, cursor.y) + } else { + val d = x - cursor.x + require(d >= 0.0) { + "requested to hold until $x, but cursor is already at ${cursor.x}" + } + lineTo(d, cursor.y) + } + path += "h$x" + } + + /** + * build the Fcurve + */ + fun build(): FCurve { + return FCurve(segments) + } +} + +/** + * build an Fcurve + * @see FCurveBuilder + */ +fun fcurve(builder: FCurveBuilder.() -> Unit): FCurve { + val fb = FCurveBuilder() + fb.builder() + return fb.build() +} + +/** + * Split an Fcurve string in to command parts + */ +private fun fCurveCommands(d: String): List { + val svgCommands = "mMlLqQsStTcChH" + val number = "0-9.\\-E%" + + return d.split(Regex("(?:[\t ,]|\r?\n)+|(?<=[$svgCommands])(?=[$number])|(?<=[$number])(?=[$svgCommands])")).filter { it.isNotBlank() } +} + +private fun evaluateFCurveCommands(parts: List): FCurve { + val mparts = parts.reversed().toMutableList() + + fun popToken(): String = mparts.removeLast() + + fun popNumber(): Double = mparts.removeLast().toDoubleOrNull() ?: error("not a number") + + fun String.numberOrFactorOf(percentageOf: (Double) -> Double): Double { + return if (endsWith("%")) { + val f = (dropLast(1).toDoubleOrNull() ?: error("'$this' is not a percentage")) / 100.0 + percentageOf(f) + } else { + toDoubleOrNull() ?: error("'$this' is not a number") + } + } + + fun String.numberOrPercentageOf(percentageOf: () -> Double): Double { + return numberOrFactorOf { f -> f * percentageOf() } + } + + fun popNumberOrPercentageOf(percentageOf: () -> Double): Double { + return mparts.removeLast().numberOrPercentageOf(percentageOf) + } + + /** + * Use the [fcurve] builder to construct the FCurve + */ + return fcurve { + fun dx(): Double { + val lastSegment = segments.lastOrNull() ?: Segment(Vector2.ZERO, Vector2.ZERO) + return lastSegment.end.x - lastSegment.start.x + } + + while (mparts.isNotEmpty()) { + val command = mparts.removeLast() + when (command) { + + /** + * Handle move cursor command + */ + "m", "M" -> { + val isRelative = command.first().isLowerCase() + moveTo(popNumberOrPercentageOf { cursor.y }, isRelative) + } + + /** + * Handle line command + */ + "l", "L" -> { + val isRelative = command.first().isLowerCase() + val x = popNumberOrPercentageOf { dx() } + val y = popNumberOrPercentageOf { cursor.y } + lineTo(x, y, isRelative) + } + + /** + * Handle cubic bezier command + */ + "c", "C" -> { + val relative = command.first().isLowerCase() + val tcx0 = popToken() + val tcy0 = popToken() + val tcx1 = popToken() + val tcy1 = popToken() + val x = popNumberOrPercentageOf { dx() } + val y = popNumberOrPercentageOf { cursor.y } + val x0 = tcx0.numberOrPercentageOf { x } + val y0 = tcy0.numberOrFactorOf { factor -> + if (relative) y * factor else cursor.y * (1.0 - factor).coerceAtLeast(0.0) + y * factor + } + val x1 = tcx1.numberOrPercentageOf { x } + val y1 = tcy1.numberOrFactorOf { factor -> + if (relative) y * factor else cursor.y * (1.0 - factor).coerceAtLeast(0.0) + y * factor + } + curveTo(x0, y0, x1, y1, x, y, relative) + } + + /** + * Handle quadratic bezier command + */ + "q", "Q" -> { + val relative = command.first().isLowerCase() + val tcx0 = popToken() + val tcy0 = popToken() + val x = popNumberOrPercentageOf { dx() } + val y = popNumberOrPercentageOf { cursor.y } + val x0 = tcx0.numberOrPercentageOf { x } + val y0 = tcy0.numberOrFactorOf { factor -> + if (relative) y * factor else cursor.y * (1.0 - factor).coerceAtLeast(0.0) + y * factor + } + curveTo(x0, y0, x, y, relative) + } + + /** + * Handle horizontal line (or hold) command + */ + "h", "H" -> { + val isRelative = command.first().isLowerCase() + hold(popNumberOrPercentageOf { dx() }, isRelative) + } + + /** + * Handle cubic smooth to command + */ + "s", "S" -> { + val relative = command.first().isLowerCase() + val tcx0 = popToken() + val tcy0 = popToken() + val x = popNumberOrPercentageOf { dx() } + val y = popNumberOrPercentageOf { cursor.y } + val x1 = tcx0.numberOrPercentageOf { x } + val y1 = tcy0.numberOrPercentageOf { y } + continueTo(x1, y1, x, y, relative) + } + + /** + * Handle quadratic smooth to command + */ + "t", "T" -> { + val isRelative = command.first().isLowerCase() + val x = popNumberOrPercentageOf { dx() } + val y = popNumberOrPercentageOf { cursor.y } + continueTo(x, y, isRelative) + } + + else -> error("unknown command: $command in ${parts}") + } + } + } +} + +fun fcurve(d: String): FCurve { + return evaluateFCurveCommands(fCurveCommands(d)) +} + diff --git a/orx-fcurve/src/commonMain/kotlin/MultiFCurve.kt b/orx-fcurve/src/commonMain/kotlin/MultiFCurve.kt new file mode 100644 index 00000000..3d44b114 --- /dev/null +++ b/orx-fcurve/src/commonMain/kotlin/MultiFCurve.kt @@ -0,0 +1,113 @@ +package org.openrndr.extra.fcurve +import org.openrndr.color.ColorRGBa +import org.openrndr.math.Vector2 +import org.openrndr.math.Vector3 + +abstract class CompoundFCurve(val compounds: List) { + val duration: Double + get() { + return compounds.maxOf { it?.duration ?: 0.0 } + } + + abstract fun sampler(normalized: Boolean = false): (Double) -> T +} + + +class BooleanFCurve(value: FCurve?, val default: Boolean = true) : + CompoundFCurve(listOf(value)) { + override fun sampler(normalized: Boolean): (Double) -> Boolean { + val sampler = compounds[0]?.sampler(normalized) ?: { if (default) 1.0 else 0.0 } + return { t -> sampler(t) >= 1.0 } + } +} + + +class DoubleFCurve(value: FCurve?, val default: Double = 0.0) : + CompoundFCurve(listOf(value)) { + override fun sampler(normalized: Boolean): (Double) -> Double { + val sampler = compounds[0]?.sampler(normalized) ?: { default } + return { t -> sampler(t) } + } +} + + +class IntFCurve(value: FCurve?, val default: Int = 0) : + CompoundFCurve(listOf(value)) { + override fun sampler(normalized: Boolean): (Double) -> Int { + val sampler = compounds[0]?.sampler(normalized) ?: { default.toDouble() } + return { t -> sampler(t).toInt() } + } +} + +class Vector2FCurve(x: FCurve?, y: FCurve?, val default: Vector2 = Vector2.ZERO) : + CompoundFCurve(listOf(x, y)) { + override fun sampler(normalized: Boolean): (Double) -> Vector2 { + val xSampler = compounds[0]?.sampler(normalized) ?: { default.x } + val ySampler = compounds[1]?.sampler(normalized) ?: { default.y } + return { t -> Vector2(xSampler(t), ySampler(t)) } + } +} + +class Vector3FCurve(x: FCurve?, y: FCurve?, z: FCurve?, val default: Vector3 = Vector3.ZERO) : + CompoundFCurve(listOf(x, y, z)) { + override fun sampler(normalized: Boolean): (Double) -> Vector3 { + val xSampler = compounds[0]?.sampler(normalized) ?: { default.x } + val ySampler = compounds[1]?.sampler(normalized) ?: { default.y } + val zSampler = compounds[2]?.sampler(normalized) ?: { default.z } + return { t -> Vector3(xSampler(t), ySampler(t), zSampler(t)) } + } +} + +class RgbFCurve(r: FCurve?, g: FCurve?, b: FCurve?, val default: ColorRGBa = ColorRGBa.WHITE) : + CompoundFCurve(listOf(r, g, b)) { + override fun sampler(normalized: Boolean): (Double) -> ColorRGBa { + val rSampler = compounds[0]?.sampler(normalized) ?: { default.r } + val gSampler = compounds[1]?.sampler(normalized) ?: { default.g } + val bSampler = compounds[2]?.sampler(normalized) ?: { default.b } + return { t -> ColorRGBa(rSampler(t), gSampler(t), bSampler(t)) } + } +} + +class RgbaFCurve(r: FCurve?, g: FCurve?, b: FCurve?, a: FCurve?, val default: ColorRGBa = ColorRGBa.WHITE) : + CompoundFCurve(listOf(r, g, b, a)) { + override fun sampler(normalized: Boolean): (Double) -> ColorRGBa { + val rSampler = compounds[0]?.sampler(normalized) ?: { default.r } + val gSampler = compounds[1]?.sampler(normalized) ?: { default.g } + val bSampler = compounds[2]?.sampler(normalized) ?: { default.b } + val aSampler = compounds[3]?.sampler(normalized) ?: { default.alpha } + return { t -> ColorRGBa(rSampler(t), gSampler(t), bSampler(t), aSampler(t)) } + } + +} + + +open class MultiFCurve(val compounds: Map) { + fun changeSpeed(speed: Double): MultiFCurve { + if (speed == 1.0) { + return this + } else { + return MultiFCurve(compounds.mapValues { it.value?.changeSpeed(speed) }) + } + } + + val duration by lazy { compounds.values.maxOfOrNull { it?.duration ?: 0.0 } ?: 0.0 } + operator fun get(name: String): FCurve? { + return compounds[name] + } + + fun boolean(value: String, default: Boolean = true) = BooleanFCurve(this[value], default) + fun double(value: String, default: Double = 0.0) = DoubleFCurve(this[value], default) + + fun int(value: String, default: Int = 0) = IntFCurve(this[value], default) + + fun vector2(x: String, y: String, default: Vector2 = Vector2.ZERO) = Vector2FCurve(this[x], this[y], default) + fun vector3(x: String, y: String, z: String, default: Vector3 = Vector3.ZERO) = + Vector3FCurve(this[x], this[y], this[z], default) + + fun rgb(r: String, g: String, b: String, default: ColorRGBa = ColorRGBa.WHITE) = + RgbFCurve(this[r], this[g], this[b], default) + + fun rgba(r: String, g: String, b: String, a: String, default: ColorRGBa = ColorRGBa.WHITE) = + RgbaFCurve(this[r], this[g], this[b], this[a], default) +} + diff --git a/orx-fcurve/src/commonMain/kotlin/Roots.kt b/orx-fcurve/src/commonMain/kotlin/Roots.kt new file mode 100644 index 00000000..66c604b1 --- /dev/null +++ b/orx-fcurve/src/commonMain/kotlin/Roots.kt @@ -0,0 +1,117 @@ +/* +This is a direct port of the root finding code from https://pomax.github.io/bezierinfo/#extremities + +A copy of the original license: +The following license terms apply to this repository and its derivative website and repositories: + +- you may use any illustrative code found in the `docs/chapters` directories without crediting. +- you may use any illustrative graphics found in the `docs/images` directories without crediting. +- you may quote up to two paragraphs from the `docs/chapters` markdown documents without crediting. +- you may quote an entire section from any chapter, as long as it's credited and links back to the chapter that section is in on the official website. + +If you wish to quote more than one section of a chapter (such as an entire chapter, or more than one chapter), you may do so only after requesting, and getting, explicit permission. Permission should be sought by filing an issue in this repository, which will act as permanent record of the granted permissions. + +Outside of the above permissions, the following prohibitions and copyrights apply: + +- You may not put up a clone of the entire work (meaning that if you fork the project, you may not turn on gh-pages to get it automatically hosted by github itself on your own account domain). +- The code in `docs/js/graphics-element/lib` consists of third party libraries governed by their own licenses. + +Any other material not explicitly covered by this license is to be treated as having all rights reserved. +Please file an issue for license clarification questions. + + */ + +package org.openrndr.extra.fcurve +import kotlin.math.* + +// A helper function to filter for values in the [0,1] interval: +private fun accept(t: Double): Boolean { + return t in 0.0..1.0 +} + + +// A real-cuberoots-only function: +private fun cuberoot(v: Double): Double { + if (v < 0) return -(-v).pow(1.0 / 3); + return v.pow(1.0 / 3) +} + +internal fun approximately(a: Double, b: Double, epsilon: Double = 1E-8): Boolean { + return abs(a - b) < epsilon +} + +// Now then: given cubic coordinates {pa, pb, pc, pd} find all roots. +internal fun getCubicRoots(pa: Double, pb: Double, pc: Double, pd: Double): List { + var a = (3 * pa - 6 * pb + 3 * pc) + var b = (-3 * pa + 3 * pb) + var c = pa + val d = (-pa + 3 * pb - 3 * pc + pd); + + // do a check to see whether we even need cubic solving: + if (approximately(d, 0.0)) { + // this is not a cubic curve. + if (approximately(a, 0.0)) { + // in fact, this is not a quadratic curve either. + if (approximately(b, 0.0)) { + // in fact in fact, there are no solutions. + return emptyList() + } + // linear solution + return listOf(-c / b).filter(::accept) + } + // quadratic solution + val q = sqrt(b * b - 4 * a * c) + val twoA = 2 * a + return listOf((q - b) / twoA, (-b - q) / twoA).filter(::accept) + } + + // at this point, we know we need a cubic solution. + + a /= d + b /= d + c /= d + + val p = (3 * b - a * a) / 3 + val p3 = p / 3 + val q = (2 * a * a * a - 9 * a * b + 27 * c) / 27 + val q2 = q / 2 + val discriminant = q2 * q2 + p3 * p3 * p3 + + // and some variables we're going to use later on: + var u1 = 0.0 + var v1 = 0.0 + var root1 = 0.0 + var root2 = 0.0 + var root3 = 0.0 + + // three possible real roots: + if (discriminant < 0) { + var mp3 = -p / 3 + val mp33 = mp3 * mp3 * mp3 + val r = sqrt(mp33) + val t = -q / (2 * r) + val cosphi = if (t < -1) -1.0 else if (t > 1) 1.0 else t + val phi = acos(cosphi) + val crtr = cuberoot(r) + val t1 = 2 * crtr + root1 = t1 * cos(phi / 3) - a / 3 + root2 = t1 * cos((phi + 2 * PI) / 3) - a / 3 + root3 = t1 * cos((phi + 4 * PI) / 3) - a / 3 + return listOf(root1, root2, root3).filter(::accept) + } + + // three real roots, but two of them are equal: + if (discriminant == 0.0) { + u1 = if(q2 < 0) cuberoot(-q2) else -cuberoot(q2) + root1 = 2 * u1 - a / 3 + root2 = -u1 - a / 3 + return listOf(root1, root2).filter(::accept) + } + + // one real root, two complex roots + val sd = sqrt(discriminant) + u1 = cuberoot(sd - q2) + v1 = cuberoot(sd + q2) + root1 = u1 - v1 - a / 3 + return listOf(root1).filter(::accept) +} \ No newline at end of file diff --git a/orx-fcurve/src/jvmDemo/kotlin/DemoFCurve01.kt b/orx-fcurve/src/jvmDemo/kotlin/DemoFCurve01.kt new file mode 100644 index 00000000..6453c409 --- /dev/null +++ b/orx-fcurve/src/jvmDemo/kotlin/DemoFCurve01.kt @@ -0,0 +1,19 @@ +import org.openrndr.application +import org.openrndr.extra.fcurve.fcurve + +fun main() { + application { + configure { + width = 720 + height = 720 + } + program { + val xpos = fcurve("M0 Q4,360,5,720").sampler() + val ypos = fcurve("M360 H5").sampler() + + extend { + drawer.circle(xpos(seconds.mod(5.0)), ypos(seconds.mod(5.0)), 100.0) + } + } + } +} \ No newline at end of file diff --git a/orx-fcurve/src/jvmDemo/kotlin/DemoFCurve02.kt b/orx-fcurve/src/jvmDemo/kotlin/DemoFCurve02.kt new file mode 100644 index 00000000..7d8fff4b --- /dev/null +++ b/orx-fcurve/src/jvmDemo/kotlin/DemoFCurve02.kt @@ -0,0 +1,30 @@ +import org.openrndr.application +import org.openrndr.color.ColorRGBa +import org.openrndr.extra.fcurve.fcurve +import org.openrndr.extra.parameters.ParameterType +import org.openrndr.math.Vector2 + +fun main() { + application { + configure { + width = 720 + height = 720 + } + program { + val xposCurve = fcurve("M0 Q4,360,5,720") + val xpos = xposCurve.sampler() + val yposCurve = fcurve("M360 H5") + val ypos = yposCurve.sampler() + + + extend { + drawer.circle(xpos(seconds.mod(5.0)), ypos(seconds.mod(5.0)), 100.0) + drawer.stroke = ColorRGBa.PINK + drawer.contours(xposCurve.contours(Vector2(720.0 / 5.0, 1.0))) + drawer.contours(yposCurve.contours(Vector2(720.0 / 5.0, 1.0))) + drawer.translate(seconds.mod(5.0)*(720.0/5.0), 0.0) + drawer.lineSegment(0.0, 0.0, 0.0, 720.0) + } + } + } +} \ No newline at end of file diff --git a/orx-fcurve/src/jvmDemo/kotlin/DemoMultiFCurve01.kt b/orx-fcurve/src/jvmDemo/kotlin/DemoMultiFCurve01.kt new file mode 100644 index 00000000..824a52ca --- /dev/null +++ b/orx-fcurve/src/jvmDemo/kotlin/DemoMultiFCurve01.kt @@ -0,0 +1,27 @@ +import org.openrndr.application +import org.openrndr.extra.fcurve.MultiFCurve +import org.openrndr.extra.fcurve.fcurve + +fun main() { + application { + configure { + width = 720 + height = 720 + } + program { + class XYAnimation : MultiFCurve(mapOf( + "x" to fcurve("M0 Q4,360,5,720"), + "y" to fcurve("M360 H5") + )) { + val position = vector2("x", "y") + } + + val xyAnimation = XYAnimation() + val position = xyAnimation.position.sampler() + + extend { + drawer.circle(position(seconds.mod(5.0)), 100.0) + } + } + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index f569c8d9..452fe95f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,6 +28,7 @@ include( "orx-easing", "orx-envelopes", "orx-expression-evaluator", + "orx-fcurve", "orx-jvm:orx-file-watcher", "orx-parameters", "orx-fx",