gestureReplay.kt
src/jvmMain/kotlin/afield/graphics/paint/gestureReplay.kt · 155 lines
@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.graphics.paint
import afield.ui.visualization.GestureHandle
import afield.animation.keyframes.KeyframeEvaluator
import afield.ui.visualization.PenSample
import afield.app.protocol.Rect
import afield.animation.gesture.lanesToTake
import kotlin.math.abs
import kotlin.math.atan
import kotlin.math.atan2
import kotlin.math.hypot
import kotlin.math.tan
Gesture pad to brush: a take recorded on a gesture(...) pad, or its x and y keyframe lanes after
editing, painted onto a canvas as a TimedGesture. The pad's square is laid over a rectangle of
the canvas and the take's own timing is kept, so how fast the hand moved is how fast the brush
moves.
A pad carries pressure, tilt and twist only when it was made with pen = true, and only where the
pointer reported them: a mouse reports a flat 0.5 pressure while down and nothing else, and a
dimension that never left its resting value reads as unrecorded. Each one missing is filled the
way PaintCanvas.gesture(line, …) fills it from a path, which is to say a steady press, a steady
lean toward where the stroke is going, and a flat held across its path.
fun PaintCanvas.gesture
The take samples, recorded on a pad spanning xRange by yRange (y up, as the pad shows it),
as a TimedGesture on this canvas: the pad's square laid over into (in this canvas's FLine
coordinates, y down; by default the largest square in the middle 80% of the canvas), its timing kept and stretched by
timeScale.
Where the take carries the pen's pressure it is multiplied by pressureScale (a pen pressed
fully, 1, is 1.2 by default; a mouse, which reports 0.5 while down, is 0.6, the canvas's usual
press); a take without pressure presses pressure throughout. Where it carries the pen's tilt,
the handle leans as the pen did; a take with no tilt (a mouse, or a pad without pen) leans
tilt degrees, toward where it is going if lean. A recorded twist turns the handle about its
own axis; without one a flat is held across its path, as PaintCanvas.gesture holds it.
body — 44 lines, from line 41
fun PaintCanvas.gesture(
samples: List<PenSample>,
xRange: Pair<Double, Double> = -1.0 to 1.0,
yRange: Pair<Double, Double> = -1.0 to 1.0,
into: Rect? = null,
pressure: Double = 0.6,
pressureScale: Double = 1.2,
tilt: Double = 30.0,
lean: Boolean = true,
timeScale: Double = 1.0,
): TimedGesture {
require(samples.isNotEmpty()) { "an empty take paints nothing: record one on the pad first" }
require(timeScale > 0.0) { "timeScale stretches time: above 0" }
val r = into ?: defaultReplayRect()
val (x0, x1) = xRange
val (y0, y1) = yRange
fun cx(x: Double) = r.x + (x - x0) / ((x1 - x0).takeIf { it != 0.0 } ?: 1.0) * r.w
fun cy(y: Double) = r.y + (1.0 - (y - y0) / ((y1 - y0).takeIf { it != 0.0 } ?: 1.0)) * r.h
// a dimension the pointer never moved off its resting value was not recorded
val hasTilt = samples.any { it.tiltX != 0.0 || it.tiltY != 0.0 }
val hasTwist = samples.any { it.twist != 0.0 }
val t0 = samples.first().t
var hand: Double? = null
val out = samples.mapIndexed { i, s ->
val x = cx(s.x); val y = cy(s.y)
// heading on the canvas, unwrapped and held through sharp reversals as PaintCanvas.gesture does
val (a, b) = if (i + 1 < samples.size) s to samples[i + 1] else samples[(i - 1).coerceAtLeast(0)] to s
val dx = cx(b.x) - cx(a.x); val dy = cy(b.y) - cy(a.y)
val heading = if (hypot(dx, dy) < 1e-9) hand ?: 0.0 else {
val raw = Math.toDegrees(atan2(dy, dx))
hand?.let { prev -> val turn = (raw - prev + 540.0) % 360.0 - 180.0; if (abs(turn) > 120.0) prev else prev + turn } ?: raw
}
hand = heading
val (penTilt, penAzimuth) = penPose(s.tiltX, s.tiltY)
TimedGesture.Sample(
x, y, (s.t - t0) * timeScale,
if (s.pressure.isFinite()) s.pressure * pressureScale else pressure,
tilt = if (hasTilt) penTilt else tilt,
azimuth = if (hasTilt) penAzimuth else if (lean) heading else 0.0,
roll = if (hasTwist) s.twist else heading + 90.0,
)
}
return TimedGesture(out)
}
fun PaintCanvas.gesture
The take recorded on pad as a TimedGesture on this canvas; see the List<PenSample>
overload. A pad made with pen = true brings its pressure, tilt and twist along.
fun PaintCanvas.gesture(
pad: GestureHandle,
into: Rect? = null,
pressure: Double = 0.6,
pressureScale: Double = 1.2,
tilt: Double = 30.0,
lean: Boolean = true,
timeScale: Double = 1.0,
): TimedGesture {
require(pad.hasTake) { "the pad has no take: Shift-drag (or press rec) on it to record one" }
return gesture(pad.penSamples, pad.xRange, pad.yRange, into, pressure, pressureScale, tilt, lean, timeScale)
}
fun PaintCanvas.gesture
Keyframe lanes x and y — a take baked with bakeGesture and edited in the keyframe editor —
as a TimedGesture on this canvas, sampled samplesPerSecond times a second and at every key.
pressureLane (pen pressure, 0 to 1, as GestureHandle.penLane("pressure") bakes it) presses
the brush where it is given; otherwise pressure does, throughout. tiltXLane, tiltYLane and
twistLane (degrees, penLane("tiltX") and so on) hold the handle as the pen was held. The rest
is as in the List<PenSample> overload.
fun PaintCanvas.gesture(
x: KeyframeEvaluator,
y: KeyframeEvaluator,
xRange: Pair<Double, Double> = -1.0 to 1.0,
yRange: Pair<Double, Double> = -1.0 to 1.0,
into: Rect? = null,
pressureLane: KeyframeEvaluator? = null,
tiltXLane: KeyframeEvaluator? = null,
tiltYLane: KeyframeEvaluator? = null,
twistLane: KeyframeEvaluator? = null,
pressure: Double = 0.6,
pressureScale: Double = 1.2,
tilt: Double = 30.0,
lean: Boolean = true,
timeScale: Double = 1.0,
samplesPerSecond: Double = 60.0,
): TimedGesture {
val start = (x.getKeyframeTimes() + y.getKeyframeTimes()).minOrNull()
?: throw IllegalArgumentException("the lanes have no keys: bake a take into them first")
val take = lanesToTake(x, y, samplesPerSecond)
fun KeyframeEvaluator?.at(t: Double, otherwise: Double) = this?.takeIf { it.getKeyframeCount() > 0 }?.evaluateValueAt(t + start) ?: otherwise
val samples = take.map { (t, v) ->
PenSample(t, v.x.toDouble(), v.y.toDouble(), pressureLane.at(t, Double.NaN), tiltXLane.at(t, 0.0), tiltYLane.at(t, 0.0), twistLane.at(t, 0.0))
}
return gesture(samples, xRange, yRange, into, pressure, pressureScale, tilt, lean, timeScale)
}
fun penPose(tiltX: Double, tiltY: Double): Pair<Double, Double>
A pen's PointerEvent tiltX/tiltY (degrees toward +x and toward +y) as a TimedGesture.Sample's
tilt from upright and the azimuth it leans toward, both degrees. Upright leans toward 0.
fun penPose(tiltX: Double, tiltY: Double): Pair<Double, Double> {
val tx = tan(Math.toRadians(tiltX.coerceIn(-89.9, 89.9)))
val ty = tan(Math.toRadians(tiltY.coerceIn(-89.9, 89.9)))
val r = hypot(tx, ty)
return Math.toDegrees(atan(r)) to if (r < 1e-12) 0.0 else Math.toDegrees(atan2(ty, tx))
}
The pad is square: the largest square in the middle 80% of the canvas (or its view).
private fun PaintCanvas.defaultReplayRect(): Rect {
val v = view ?: Rect(0.0, 0.0, 1000.0, heightUnits)
val side = 0.8 * minOf(abs(v.w), abs(v.h))
return Rect(v.x + (v.w - side) / 2, v.y + (v.h - side) / 2, side, side)
}