Files
orx/orx-fcurve/src/commonMain/kotlin/FCurve.kt

534 lines
16 KiB
Kotlin

package org.openrndr.extra.fcurve
import kotlinx.serialization.Serializable
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
/**
* Find the (first) t value for a given [x] value
*/
private fun Segment2D.tForX(x: Double): Double {
if (x == start.x) return 0.0
if (x == end.x) return 1.0
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 Segment2D.yForX(x: Double): Double {
val t = tForX(x)
return position(t).y
}
/**
* Scale tangents such that tangent lines do not overlap
*/
fun Segment2D.scaleTangents(axis: Vector2 = Vector2.UNIT_X): Segment2D {
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<Segment2D>) {
/**
* 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) })
}
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
*/
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: Segment2D? = 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 {
end - start
}
}
/**
* The unitless start position of the Fcurve
*/
val start: Double
get() {
return if (segments.isEmpty()) {
0.0
} else {
segments.first().start.x
}
}
/**
* The unitless end position of the Fcurve
*/
val end: 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: Segment2D? = 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: Segment2D? = null): Pair<Double, Segment2D?> {
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(1.0, -1.0), offset: Vector2 = Vector2.ZERO): List<ShapeContour> {
var active = mutableListOf<Segment2D>()
val result = mutableListOf<ShapeContour>()
for (segment in segments) {
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 - tsegment.start.y)
if (dy > 1E-3) {
result.add(ShapeContour.fromSegments(active, false))
active = mutableListOf()
}
active.add(tsegment)
}
}
if (active.isNotEmpty()) {
result.add(ShapeContour.fromSegments(active, false))
}
return result
}
}
/**
* Fcurve builder
*/
class FCurveBuilder {
val segments = mutableListOf<Segment2D>()
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(Segment2D(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(
Segment2D(
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(
Segment2D(
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
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"
}
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(
Segment2D(
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()
)
cursor = Vector2(cursor.x + x, cursor.y * r + y)
path += "${if (relative) "s" else "S"}$x1,$y1,$x,$y"
}
fun hold(x: Double, relative: Boolean = true) {
if (relative) {
lineTo(x, cursor.y)
} else {
require(segments.isEmpty()) { "absolute hold (H $x) is only allowed when used as first command" }
cursor = cursor.copy(x = x)
}
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
*/
fun fCurveCommands(d: String): List<String> {
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<String>): 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() ?: Segment2D(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 = popNumber()
val y = popNumber()
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 = 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
}
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 = popNumber()
val y = popNumber()
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 = popNumber()
val y = popNumber()
continueTo(x, y, isRelative)
}
else -> error("unknown command: $command in ${parts}")
}
}
}
}
fun fcurve(d: String): FCurve {
val constantExpression = d.toDoubleOrNull()
if (constantExpression != null) {
return FCurve(listOf(Segment2D(Vector2(0.0, constantExpression), Vector2(0.0, constantExpression))))
}
return evaluateFCurveCommands(fCurveCommands(d))
}