diff --git a/orx-fcurve/README.md b/orx-fcurve/README.md index 2bdbda71..9cb6f9eb 100644 --- a/orx-fcurve/README.md +++ b/orx-fcurve/README.md @@ -23,7 +23,7 @@ This is an example of a flat horizontal FCurve: ```kotlin // set the initial value to 0.5, hold that value for 1 seconds -val sizeCurve = fcurve("M0.5 H1") +val sizeCurve = fcurve("M0.5 h1") ``` Two horizontal segments at different heights: @@ -36,12 +36,6 @@ val sizeCurve = fcurve("M0.4 h0.5 M0.6 h0.5") Note that `x` values are relative, except for `H` where `x` is absolute. For `y` values, lower case commands are relative and upper case commands are absolute. -The last example can be written with absolute times as: - -```kotlin -// hold value 0.4 until time 0.5, then hold value 0.6 until time 1.0 -val sizeCurve = fcurve("M0.4 H0.5 M0.6 H1.0") -``` ### Line @@ -103,11 +97,11 @@ commands require the presence of a previous segment, otherwise the program will ```kotlin // Hold the value 0.5 during 0.2 seconds // then draw a smooth curve down to 0.5, up to 0.7 down to 0.3 and up to 0.7 -val smoothCurveT = fcurve("M0.5 H0.2 T0.2,0.3 T0.2,0.7 T0.2,0.3 T0.2,0.7") +val smoothCurveT = fcurve("M0.5 h0.2 T0.2,0.3 T0.2,0.7 T0.2,0.3 T0.2,0.7") // Hold the value 0.5 during 0.2 seconds // then draw a smooth with 4 repetitions where we move up slowly and down quickly -val smoothCurveS = fcurve("M0.5 H0.2 S0.2,0.0,0.2,0.5 S0.2,0.0,0.2,0.5 S0.2,0.0,0.2,0.5 S0.2,0.0,0.2,0.5") +val smoothCurveS = fcurve("M0.5 h0.2 S0.2,0.0,0.2,0.5 S0.2,0.0,0.2,0.5 S0.2,0.0,0.2,0.5 S0.2,0.0,0.2,0.5") ``` ## Useful FCurve methods diff --git a/orx-fcurve/src/commonMain/kotlin/EFCurve.kt b/orx-fcurve/src/commonMain/kotlin/EFCurve.kt index 2f2d4efa..4ad7f254 100644 --- a/orx-fcurve/src/commonMain/kotlin/EFCurve.kt +++ b/orx-fcurve/src/commonMain/kotlin/EFCurve.kt @@ -3,6 +3,34 @@ package org.openrndr.extra.fcurve import org.openrndr.extra.expressions.FunctionExtensions import org.openrndr.extra.expressions.evaluateExpression +/** + * expand mfcurve to fcurve + */ +fun mfcurve( + mf: String, + constants: Map = emptyMap(), + functions: FunctionExtensions = FunctionExtensions.EMPTY +): String { + /** + * perform comment substitution + */ + val stripped = Regex("(#.*)$", RegexOption.MULTILINE).replace(mf, "") + + /** + * detect modifier + */ + val parts = stripped.split("|") + + val efcurve = parts.getOrElse(0) { "" } + val modifier = parts.getOrNull(1) + + var fcurve = efcurve(efcurve, constants, functions) + if (modifier != null) { + fcurve = modifyFCurve(fcurve, modifier, constants, functions) + } + return fcurve +} + /** * expand efcurve to fcurve * @param ef an efcurve string @@ -12,7 +40,6 @@ fun efcurve( ef: String, constants: Map = emptyMap(), functions: FunctionExtensions = FunctionExtensions.EMPTY - ): String { // IntelliJ falsely reports a redundant escape character. the escape character is required when running the regular // expression on a javascript target. Removing the escape character will result in a `Lone quantifier brackets` diff --git a/orx-fcurve/src/commonMain/kotlin/FCurve.kt b/orx-fcurve/src/commonMain/kotlin/FCurve.kt index 37fd834e..136bec9b 100644 --- a/orx-fcurve/src/commonMain/kotlin/FCurve.kt +++ b/orx-fcurve/src/commonMain/kotlin/FCurve.kt @@ -5,6 +5,7 @@ import org.openrndr.math.Vector2 import org.openrndr.math.transforms.buildTransform import org.openrndr.shape.Segment2D import org.openrndr.shape.ShapeContour +import org.openrndr.shape.bounds import kotlin.math.abs /** @@ -91,6 +92,19 @@ data class FCurve(val segments: List) { return FCurve(segments.map { it.reverse.transform(t) }) } + val bounds by lazy { + segments.map { it.bounds }.bounds + } + val min: Double + get() { + if (segments.isEmpty()) return 0.0 else return bounds.position(0.0, 0.0).y + } + + val max: Double + get() { + if (segments.isEmpty()) return 0.0 else return bounds.position(1.0, 1.0).y + } + /** * Change the duration of the Fcurve */ @@ -146,9 +160,8 @@ data class FCurve(val segments: List) { 0.0 } else { segments.first().start.x - + } } - } /** * The unitless end position of the Fcurve @@ -208,24 +221,28 @@ data class FCurve(val segments: List) { /** * Return a list of contours that can be used to visualize the Fcurve */ - fun contours(scale: Vector2 = Vector2.ONE): List { + fun contours(scale: Vector2 = Vector2(1.0, -1.0), offset: Vector2 = Vector2.ZERO): List { var active = mutableListOf() val result = mutableListOf() for (segment in segments) { - if (active.isEmpty()) { - active.add(segment.transform(buildTransform { + + val tsegment = segment.transform( + buildTransform { + translate(offset) scale(scale.x, scale.y) - })) + } + ) + + if (active.isEmpty()) { + active.add(tsegment) } else { - val dy = abs(active.last().end.y - segment.start.y) + val dy = abs(active.last().end.y - tsegment.start.y) if (dy > 1E-3) { result.add(ShapeContour.fromSegments(active, false)) active = mutableListOf() } - active.add(segment.transform(buildTransform { - scale(scale.x, scale.y) - })) + active.add(tsegment) } } if (active.isNotEmpty()) { @@ -293,21 +310,28 @@ class FCurveBuilder { 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( - Segment2D( - 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() - ) + + if (segments.isNotEmpty()) { + val lastSegment = segments.last() + val lastDuration = lastSegment.end.x - lastSegment.start.x + val outTangent = if (segments.last().linear) lastSegment.end else segments.last().control.last() + val outPos = lastSegment.end + val d = outPos - outTangent + //val dn = d.normalized + val ts = 1.0// x / lastDuration + segments.add( + Segment2D( + cursor, + cursor + d * ts, + Vector2(cursor.x + x, cursor.y * r + y) + ).scaleTangents() + ) + } else { + segments.add( + Segment2D(cursor, + Vector2(cursor.x + x, cursor.y * r + y)).quadratic + ) + } cursor = Vector2(cursor.x + x, cursor.y * r + y) path += "${if (relative) "t" else "T"}$x,$y" } @@ -334,7 +358,7 @@ class FCurveBuilder { if (relative) { lineTo(x, cursor.y) } else { - require(segments.isEmpty()) { "absolute hold (H $x) is only allowed when used as first command"} + require(segments.isEmpty()) { "absolute hold (H $x) is only allowed when used as first command" } cursor = cursor.copy(x = x) } path += "h$x" @@ -361,11 +385,12 @@ fun fcurve(builder: FCurveBuilder.() -> Unit): FCurve { /** * Split an Fcurve string in to command parts */ -private fun fCurveCommands(d: String): List { +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() } + return d.split(Regex("(?:[\t ,]|\r?\n)+|(?<=[$svgCommands])(?=[$number])|(?<=[$number])(?=[$svgCommands])")) + .filter { it.isNotBlank() } } private fun evaluateFCurveCommands(parts: List): FCurve { @@ -418,8 +443,8 @@ private fun evaluateFCurveCommands(parts: List): FCurve { */ "l", "L" -> { val isRelative = command.first().isLowerCase() - val x = popNumberOrPercentageOf { dx() } - val y = popNumberOrPercentageOf { cursor.y } + val x = popNumber() + val y = popNumber() lineTo(x, y, isRelative) } @@ -432,8 +457,8 @@ private fun evaluateFCurveCommands(parts: List): FCurve { val tcy0 = popToken() val tcx1 = popToken() val tcy1 = popToken() - val x = popNumberOrPercentageOf { dx() } - val y = popNumberOrPercentageOf { cursor.y } + val x = popNumber() + val y = popNumber() 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 @@ -476,8 +501,8 @@ private fun evaluateFCurveCommands(parts: List): FCurve { val relative = command.first().isLowerCase() val tcx0 = popToken() val tcy0 = popToken() - val x = popNumberOrPercentageOf { dx() } - val y = popNumberOrPercentageOf { cursor.y } + val x = popNumber() + val y = popNumber() val x1 = tcx0.numberOrPercentageOf { x } val y1 = tcy0.numberOrPercentageOf { y } continueTo(x1, y1, x, y, relative) @@ -488,8 +513,8 @@ private fun evaluateFCurveCommands(parts: List): FCurve { */ "t", "T" -> { val isRelative = command.first().isLowerCase() - val x = popNumberOrPercentageOf { dx() } - val y = popNumberOrPercentageOf { cursor.y } + val x = popNumber() + val y = popNumber() continueTo(x, y, isRelative) } diff --git a/orx-fcurve/src/commonMain/kotlin/FCurveModifier.kt b/orx-fcurve/src/commonMain/kotlin/FCurveModifier.kt new file mode 100644 index 00000000..c295f96f --- /dev/null +++ b/orx-fcurve/src/commonMain/kotlin/FCurveModifier.kt @@ -0,0 +1,200 @@ +package org.openrndr.extra.fcurve + +import org.openrndr.extra.expressions.FunctionExtensions +import org.openrndr.extra.expressions.compileFunction1 +import org.openrndr.math.Vector2 + +/** + * Modify an [fcurve] string using a [modifiers] string + */ +fun modifyFCurve( + fcurve: String, + modifiers: String, + constants: Map = emptyMap(), + functions: FunctionExtensions = FunctionExtensions.EMPTY +): String { + val parts = fCurveCommands(fcurve) + val mparts = parts.reversed().toMutableList() + + @Suppress("RegExpRedundantEscape") + val modifier = Regex("([xy])=\\{([^{}]+)\\}") + + val modifierExpressions = modifier.findAll(modifiers).map { it.groupValues[1] to it.groupValues[2] }.toMap() + + val xModifierExpression = modifierExpressions["x"] + val xModifier = + if (xModifierExpression != null) compileFunction1(xModifierExpression, "x", constants, functions) else { + { x: Double -> x } + } + + val yModifierExpression = modifierExpressions["y"] + val yModifier = + if (yModifierExpression != null) compileFunction1(yModifierExpression, "y", constants, functions) else { + { y: Double -> y } + } + + 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() } + } + + var cursor = Vector2.ZERO + var modified = "" + fun emit(command: String, vararg ops: Double, relative: Boolean, x: Double, y: Double) { + modified = modified + " " + command + " " + ops.joinToString(" ") + cursor = if (relative) { + Vector2(x + cursor.x, y + cursor.y) + } else { + Vector2(x + cursor.x, y) + } + } + + while (mparts.isNotEmpty()) { + val command = mparts.removeLast() + + when (command) { + + /** + * Handle move cursor command + */ + "m", "M" -> { + val relative = command.first().isLowerCase() + val rf = if (relative) 1.0 else 0.0 + val y = popNumber() + rf * cursor.y + emit("M", yModifier(y), relative = false, x = 0.0, y = y) + } + + /** + * Handle line command + */ + "l", "L" -> { + val relative = command.first().isLowerCase() + val rf = if (relative) 1.0 else 0.0 + val x = popNumber() + val y = popNumber() + rf * cursor.y + + emit("L", xModifier(x), yModifier(y), relative = false, x = x, y = y) + } + + /** + * Handle cubic bezier command + */ + "c", "C" -> { + val relative = command.first().isLowerCase() + val rf = if (relative) 1.0 else 0.0 + + val tcx0 = popToken() + val tcy0 = popToken() + val tcx1 = popToken() + val tcy1 = popToken() + val x = popNumber() + val y = popNumber() + val ay = y + cursor.y * rf + 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 + } + cursor.y * rf + 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 + } + cursor.y * rf + emit( + "C", + xModifier(x0), + yModifier(y0), + xModifier(x1), + yModifier(y1), + xModifier(x), + xModifier(ay), + relative = false, + x = x, + y = ay + ) + } + + /** + * Handle quadratic bezier command + */ + "q", "Q" -> { + val relative = command.first().isLowerCase() + val rf = if (relative) 1.0 else 0.0 + val tcx0 = popToken() + val tcy0 = popToken() + val x = popNumber() + val y = popNumber() + val ay = y + cursor.y * rf + 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 + } + rf * cursor.y + emit("Q", xModifier(x0), yModifier(y0), xModifier(x), yModifier(ay), relative = false, x = x, y = ay) + } + + /** + * Handle horizontal line (or hold) command + */ + "h", "H" -> { + if (command == "H") { + val x = popNumber() + emit(command, xModifier(x), relative = false, x = x, y = cursor.y) + cursor = Vector2(x, cursor.y) + } else { + val x = popNumber() + emit(command, xModifier(x), relative = false, x = x, y = cursor.y) + } + } + + /** + * Handle cubic smooth to command + */ + "s", "S" -> { + val relative = command.first().isLowerCase() + val rf = if (relative) 1.0 else 0.0 + val tcx0 = popToken() + val tcy0 = popToken() + val x = popNumber() + val y = popNumber() + val ay = y + cursor.y * rf + val x1 = tcx0.numberOrPercentageOf { x } + val y1 = tcy0.numberOrFactorOf { factor -> + if (relative) y * factor else cursor.y * (1.0 - factor).coerceAtLeast(0.0) + y * factor + } + rf * cursor.y + emit("S", xModifier(x1), yModifier(y1), xModifier(x), yModifier(ay), relative = false, x = x, y = ay) + } + + /** + * Handle quadratic smooth to command + */ + "t", "T" -> { + val relative = command.first().isLowerCase() + val rf = if (relative) 1.0 else 0.0 + val x = popNumber() + val y = popNumber() + cursor.y * rf + emit("T", xModifier(x), yModifier(y), relative = false, x = x, y = y) + } + + else -> error("unknown command: $command in ${parts}") + } + } + return modified +} + +fun main() { + val f = "l 10 10 h 4 t 20.0 20.0 s 5% 50% 30.0 30.0" + println(modifyFCurve(f, "x={sqrt(x)} y={y * 2.0}")) + + val mf = "l 10 10 h 4 t 20.0 20.0 s 5% 50% 30.0 30.0 | x={2.0 * x} y={-3.0 * y}" + val f2 = mfcurve(mf) + println(f2) +} \ No newline at end of file diff --git a/orx-fcurve/src/jvmDemo/kotlin/DemoFCurve02.kt b/orx-fcurve/src/jvmDemo/kotlin/DemoFCurve02.kt index 1d457c60..5ccccdbf 100644 --- a/orx-fcurve/src/jvmDemo/kotlin/DemoFCurve02.kt +++ b/orx-fcurve/src/jvmDemo/kotlin/DemoFCurve02.kt @@ -1,7 +1,6 @@ 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() { @@ -16,12 +15,11 @@ fun main() { 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.contours(xposCurve.contours(Vector2(720.0 / 5.0, -1.0), Vector2(0.0, height * 1.0))) + drawer.contours(yposCurve.contours(Vector2(720.0 / 5.0, -1.0), Vector2(0.0, height * 1.0))) drawer.translate(seconds.mod(5.0)*(720.0/5.0), 0.0) drawer.lineSegment(0.0, 0.0, 0.0, 720.0) } diff --git a/orx-fcurve/src/jvmDemo/kotlin/DemoFCurveSheet01.kt b/orx-fcurve/src/jvmDemo/kotlin/DemoFCurveSheet01.kt new file mode 100644 index 00000000..4f565d7d --- /dev/null +++ b/orx-fcurve/src/jvmDemo/kotlin/DemoFCurveSheet01.kt @@ -0,0 +1,54 @@ +import org.openrndr.application +import org.openrndr.color.ColorRGBa +import org.openrndr.draw.loadFont +import org.openrndr.extra.fcurve.efcurve +import org.openrndr.extra.fcurve.fcurve +import org.openrndr.math.Vector2 + +fun main() = application { + configure { + width = 720 + height = 720 + } + program { + val fcurveTexts = listOf( + //"(l 35.0 25.0 h {175-35})[4]", // linear steps + "(c 33% 0% 67% 67% 35.0 25.0 h {175-35})[4]", // ease-in steps + "(c 50% 50% 50% 100% 35.0 25.0 h {175-35})[4]", // ease-out steps + "(c 50% 0% 50% 100% 35.0 25.0 h {175-35})[4]", // ease-in-out steps + "(c 95% 0% 100% 100% 35.0 25.0 h {175-35})[4]", // arc-in steps + "(c 0% 0% 5% 100% 35.0 25.0 h {175-35})[4]", // arc-out steps + "(c 95% 0% 100% 100% 17.5 12.5 c 0% 0% 5% 100% 17.5 12.5 h {175-35})[4]", // arc-out steps + ) + + val fcurves = fcurveTexts.map { fcurve(efcurve(it)) } + + extend { + drawer.clear(ColorRGBa.WHITE) + + drawer.fontMap = loadFont("demo-data/fonts/IBMPlexMono-Regular.ttf", 16.0) + drawer.translate(10.0, 20.0) + + drawer.stroke = ColorRGBa.PINK + drawer.lineSegment(mouse.position.x - 10.0, 0.0, mouse.position.x - 10.0, height * 1.0) + + fun color(i: Int): ColorRGBa = + ColorRGBa.BLUE.toHSVa().shiftHue(i * 30.0).saturate(0.5).shade(0.9).toRGBa() + + for (i in fcurveTexts.indices) { + drawer.fill = color(i) + drawer.text(fcurveTexts[i], 0.0, 120.0) + + drawer.stroke = color(i).opacify(0.25) + drawer.lineSegment(0.0, 100.0, width - 20.0, 100.0) + + drawer.stroke = color(i) + val y = 100.0 - fcurves[i].value(mouse.position.x - 10.0) + drawer.contours(fcurves[i].contours(offset = Vector2(0.0, 100.0))) + drawer.circle(mouse.position.x - 10.0, y, 10.0) + + drawer.translate(0.0, 110.0) + } + } + } +} \ No newline at end of file