534 lines
16 KiB
Kotlin
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))
|
|
}
|