Hyperbezier.kt
src/jvmMain/kotlin/afield/geometry/path/Hyperbezier.kt · 513 lines
package afield.geometry.path
import afield.lang.remotejs.JsFun
import afield.ui.visualization.l
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.hypot
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
The hyperbezier curve family, ported from Raph Levien's hyperbezier
(linebender.org/blog/hyperbezier/, August 2026); fitHyperbezierCubics is what draws one into an
FLine.
A hyperbezier is the plane curve traced at unit speed whose curvature is a rational function of
arc length s:
kappa(s) = (a*s + b) / q(s)^1.5, q(s) = c*s^2 + d*s + 1
over s in [0, 1], then similarity-transformed so it runs from (0, 0) to (1, 0), which is
what the rest of this file calls the chord frame. q is a dial between two regimes a single
cubic has to compromise between: q constant gives circular arcs and Euler spirals exactly,
while q dipping towards zero gives a corner-like curvature spike. The family also contains the
log-aesthetic curves and the circle involute exactly. Its curvature numerator is linear in s, so
it has at most one inflection, and that is what it gives up relative to a cubic: following a cubic
with two inflections takes more than one hyperbezier.
theta(s), the integral of kappa, has a closed form. The position integral of e^(i*theta)
does not, being Fresnel-like, so that part is done numerically with Gauss-Legendre quadrature.
Construct a curve from a cubic-shaped control polygon with hyperbezierFor; it is rarely useful
to pick a, b, c and d by hand.
class Hyperbezier
One hyperbezier, as the four coefficients of its curvature function, evaluated in the chord frame.
Build one with hyperbezierFor rather than by choosing the coefficients directly.
class Hyperbezier(
/** Linear coefficient of the curvature numerator. */
val a: Double,
/** Constant coefficient of the curvature numerator. */
val b: Double,
/** Quadratic coefficient of the denominator `q`. */
val c: Double,
/** Linear coefficient of the denominator `q`. */
val d: Double,
) {
private val theta = ThetaFn(a, b, c, d)
The endpoint position integral, before normalization to the chord frame.
private val endIntegralX: Double
private val endIntegralY: Double
val chordScale: Double
Length of the endpoint integral. Everything in the chord frame is the raw curve scaled by
1 / chordScale, so raw curvatures multiply by this and raw lengths divide by it.
Angle the raw curve's chord makes with the raw curve's starting tangent.
private val chordAngle: Double
init
init {
val i1 = positionIntegral(a, b, c, d, 1.0, theta)
endIntegralX = i1[0]
endIntegralY = i1[1]
chordScale = hypot(endIntegralX, endIntegralY)
chordAngle = atan2(endIntegralY, endIntegralX)
}
val isEulerFamily: Boolean get() = abs(c) < 1e-9 && abs(d) < 1e-9
True when q is constant, i.e. the curve is exactly a circular arc (a == 0) or an Euler
spiral. Control polygons whose arms are exactly the "parabola rule" length circleArm land
here at every angle.
val arcLength: Double get() = 1.0 / chordScale
Total arc length of the curve in the chord frame, where the chord itself has length 1.
fun q(t: Double): Double = (c * t + d) * t + 1.0
The denominator quadratic q(t) = c*t^2 + d*t + 1.
fun pointAt(t: Double): DoubleArray
Position at arc-length fraction t in the chord frame: pointAt(0) is (0, 0) and
pointAt(1) is (1, 0), to within the accuracy of the quadrature. Returned as
doubleArrayOf(x, y) — the caller converts to Field's single-precision Vec2/Vec3 once,
at the end, rather than losing digits at every step of a fit.
fun pointAt(t: Double): DoubleArray {
val z = positionIntegral(a, b, c, d, t, theta)
val n2 = endIntegralX * endIntegralX + endIntegralY * endIntegralY
if (n2 < 1e-300) return doubleArrayOf(0.0, 0.0)
// z / I1, as a complex division: rotate by -arg(I1) and scale by 1/|I1|.
return doubleArrayOf(
(z[0] * endIntegralX + z[1] * endIntegralY) / n2,
(z[1] * endIntegralX - z[0] * endIntegralY) / n2,
)
}
fun tangentAngleAt(t: Double): Double = theta(t) - chordAngle
Tangent angle at arc-length fraction t, in radians, measured counter-clockwise from the
chord. tangentAngleAt(0) and tangentAngleAt(1) reproduce the th0 and th1 handed to
hyperbezierFor — that G1 interpolation is what the solver is solving for.
fun curvatureAt(t: Double): Double
Signed curvature at arc-length fraction t, in the chord frame.
fun curvatureAt(t: Double): Double {
val qq = q(t)
if (qq <= 0.0) return 0.0
return (a * t + b) / (qq * sqrt(qq)) * chordScale
}
fun featureParameters(): List<Double>
Parameters strictly inside (0, 1) that are natural places to split the curve when
approximating it: the minimum of q — the corner, where curvature spikes — and the extrema
of curvature. Sorted ascending.
fun featureParameters(): List<Double> {
val out = sortedSetOf<Double>()
if (c > 1e-14) {
val tv = -d / (2 * c)
if (tv > 1e-6 && tv < 1 - 1e-6) out.add(tv)
}
val k0 = abs(curvatureAt(0.0))
val k1 = abs(curvatureAt(1.0))
val reference = max(1e-12, max(k0, k1))
for (r in curvatureExtrema(a, b, c, d)) {
// Constant curvature makes every point a formal extremum, the quadratic degenerates,
// and the root it reports is float noise. Keep only extrema where curvature moves.
val kr = abs(curvatureAt(r))
if (max(abs(kr - k0), abs(kr - k1)) > 1e-6 * reference) out.add(r)
}
return out.toList()
}
override fun toString(): String = "Hyperbezier(a=$a, b=$b, c=$c, d=$d)"
internal class ThetaFn
Closed form of theta(t) = integral of (a*s+b)/q^1.5 ds from 0 to t, in the gauge q(0) = 1.
The result has the shape (A*t + B)/sqrt(q) + const. The third branch is an algebraic
rearrangement of the same quantity that avoids catastrophic cancellation when d^2 is close to
4c — the denominator quadratic is then a near-perfect square, which includes the q == 1
Euler/circle case that the first branch handles exactly.
internal class ThetaFn(
private val a: Double,
private val b: Double,
private val c: Double,
private val d: Double,
) {
private companion object {
const val POLYNOMIAL = 0
const val CLOSED_FORM = 1
const val CANCELLATION_SAFE = 2
}
private val mode: Int
private val numerA: Double
private val numerB: Double
init
init {
if (abs(c) < 1e-12 && abs(d) < 1e-12) {
mode = POLYNOMIAL
numerA = 0.0
numerB = 0.0
} else {
val disc = d * d - 4 * c
val scale = max(1.0, max(abs(d * d), abs(4 * c)))
if (abs(disc) > 0.02 * scale) {
mode = CLOSED_FORM
numerB = (4 * a - 2 * b * d) / disc
numerA = b + 0.5 * numerB * d
} else {
mode = CANCELLATION_SAFE
numerA = 0.0
numerB = 0.0
}
}
}
operator fun invoke(t: Double): Double
operator fun invoke(t: Double): Double = when (mode) {
POLYNOMIAL -> b * t + 0.5 * a * t * t
CLOSED_FORM -> {
val qq = (c * t + d) * t + 1.0
(numerA * t + numerB) / sqrt(qq) - numerB
}
else -> {
val qq = (c * t + d) * t + 1.0
val sq = sqrt(qq)
val h = 2 * c * t + d
(t / sq) * (b + t * (4 * a - 2 * b * d) *
(1 + (h * h + 4 * c) / (d * h + 4 * c * sq)) / (4 * (sq + 1) * (sq + 1)))
}
}
}
Gauss-Legendre nodes and weights on [-1, 1], by Newton iteration on Legendre polynomials.
private object GaussLegendre32 {
const val N = 32
val x = DoubleArray(N)
val w = DoubleArray(N)
init {
for (i in 0 until N) {
var t = cos(PI * (i + 0.75) / (N + 0.5))
for (iteration in 0 until 100) {
val dt = t.let {
var p0 = 1.0
var p1 = it
for (k in 2..N) {
val p2 = ((2 * k - 1) * it * p1 - (k - 1) * p0) / k
p0 = p1
p1 = p2
}
p1 / (N * (it * p1 - p0) / (it * it - 1))
}
t -= dt
if (abs(dt) < 1e-15) break
}
var p0 = 1.0
var p1 = t
for (k in 2..N) {
val p2 = ((2 * k - 1) * t * p1 - (k - 1) * p0) / k
p0 = p1
p1 = p2
}
val dp = N * (t * p1 - p0) / (t * t - 1)
x[i] = t
w[i] = 2 / ((1 - t * t) * dp * dp)
}
}
}
internal fun wrapAngle(angle: Double): Double
Wrap an angle into (-pi, pi].
internal fun wrapAngle(angle: Double): Double {
val turn = 2 * PI
return ((angle + PI) % turn + turn) % turn - PI
}
internal fun curvatureExtrema(a: Double, b: Double, c: Double, d: Double): List<Double>
Roots of kappa'(t) inside (0, 1) — a quadratic. Used to pick subdivision points where a
cubic has the hardest time tracking the curve.
internal fun curvatureExtrema(a: Double, b: Double, c: Double, d: Double): List<Double> {
val qa = -4 * a * c
val qb = -(a * d + 6 * b * c)
val qc = 2 * a - 3 * b * d
val roots = when {
abs(qa) < 1e-14 -> if (abs(qb) > 1e-14) listOf(-qc / qb) else emptyList()
else -> {
val disc = qb * qb - 4 * qa * qc
if (disc < 0) emptyList() else {
val s = sqrt(disc)
listOf((-qb - s) / (2 * qa), (-qb + s) / (2 * qa))
}
}
}
return roots.filter { it > 0 && it < 1 }.sorted()
}
Quadrature cuts: the vertex of the denominator quadratic, and only when it is a minimum
(c > 0). Gauss-Legendre nodes cluster quadratically at panel ends, so putting the q-minimum
on a panel boundary resolves the curvature spike sitting there. For c < 0 the vertex is a
maximum and any dips are at the interval ends, which the clustering already covers.
private fun cutPoints(c: Double, d: Double, t: Double): DoubleArray {
if (c > 1e-14) {
val tv = -d / (2 * c)
if (tv > 1e-9 && tv < t - 1e-9) return doubleArrayOf(0.0, tv, t)
}
return doubleArrayOf(0.0, t)
}
Re-express the curve on [t0, t1] as a fresh, well-conditioned [0, 1] chart.
private fun subsegment(a: Double, b: Double, c: Double, d: Double, t0: Double, t1: Double): DoubleArray {
val dt = t1 - t0
val e = (c * t0 + d) * t0 + 1
val s = 1 / e
val ps = dt * s * sqrt(s)
return doubleArrayOf(a * dt * ps, (b + a * t0) * ps, c * dt * dt * s, (d + 2 * c * t0) * dt * s)
}
internal fun positionIntegral
The position integral z(t) = integral of e^(i*theta) ds from 0 to t, by Gauss-Legendre on
each cut interval. Intervals that do not start at zero are re-charted through subsegment so
each gets its own well-conditioned theta.
internal fun positionIntegral(
a: Double,
b: Double,
c: Double,
d: Double,
t: Double,
theta: ThetaFn,
): DoubleArray {
val cuts = cutPoints(c, d, t)
var zx = 0.0
var zy = 0.0
for (i in 0 until cuts.size - 1) {
val w0 = cuts[i]
val w1 = cuts[i + 1]
if (w1 - w0 < 1e-15) continue
var sx = 0.0
var sy = 0.0
if (w0 == 0.0) {
for (k in 0 until GaussLegendre32.N) {
val th = theta(0.5 * w1 * (GaussLegendre32.x[k] + 1))
sx += GaussLegendre32.w[k] * cos(th)
sy += GaussLegendre32.w[k] * sin(th)
}
} else {
val sp = subsegment(a, b, c, d, w0, w1)
val thetaSub = ThetaFn(sp[0], sp[1], sp[2], sp[3])
val offset = theta(w0)
for (k in 0 until GaussLegendre32.N) {
val th = offset + thetaSub(0.5 * (GaussLegendre32.x[k] + 1))
sx += GaussLegendre32.w[k] * cos(th)
sy += GaussLegendre32.w[k] * sin(th)
}
}
zx += (w1 - w0) * 0.5 * sx
zy += (w1 - w0) * 0.5 * sy
}
return doubleArrayOf(zx, zy)
}
fun circleArm(angle: Double): Double = 2.0 / (3.0 * max(1e-3, 1.0 + cos(angle)))
The "parabola rule" arm length for a tangent that leaves the chord at angle radians: the arm at
which a cubic's midpoint coincides exactly with a circular arc's apex, at every angle. Arms are
measured against this, so an arm of circleArm(theta) means "circle-like", and
hyperbezierFor returns an exact arc or Euler spiral there.
From Levien's "Cleaner parallel curves with Euler spirals" (2021).
Given the denominator quadratic, solve (a, b) so the curve leaves at th0 and arrives at
th1.
theta is linear in (a, b), so the net-turn condition theta(1) = th1 - th0 eliminates b
exactly — and pins the winding class, which is what keeps stray full loops out of the solution
set. What is left is a scalar Newton solve on a, whose residual is the start-tangent error read
off the argument of the endpoint position integral.
Two initial guesses are tried in order: the ghost cubic's midpoint tangent (which, with the
net-turn condition, is a closed-form 2x2), then the small-angle Euler estimate. A converged
solution whose interior theta wanders outside the principal band — a hidden +-2pi round trip —
is rejected and the next guess tried.
Returns [a, b], or null if neither guess converged.
source — 82 lines, from line 372
private fun fitNumerator(
c: Double,
d: Double,
th0: Double,
th1: Double,
l0: Double,
l1: Double,
): DoubleArray? {
val thetaF = ThetaFn(1.0, 0.0, c, d)
val thetaG = ThetaFn(0.0, 1.0, c, d)
val f1 = thetaF(1.0)
val g1 = thetaG(1.0)
val netTurn = th1 - th0
if (abs(g1) < 1e-30) return null
fun bOf(a: Double) = (netTurn - a * f1) / g1
// Start-tangent residual: how far the curve's own chord direction is from where th0 wants it.
fun residual(a: Double): Double? {
val b = bOf(a)
val i1 = positionIntegral(a, b, c, d, 1.0, ThetaFn(a, b, c, d))
if (hypot(i1[0], i1[1]) < 1e-12) return null
return wrapAngle(-atan2(i1[1], i1[0]) - th0)
}
val inits = ArrayList<Double>(2)
run {
val dx = 2 - l0 * cos(th0) - l1 * cos(th1)
val dy = -l0 * sin(th0) - l1 * sin(th1)
val raw = if (hypot(dx, dy) > 1e-9) atan2(-dy, dx) else th0 + 0.5 * netTurn
val midTangent = 0.5 * netTurn + wrapAngle(raw - th0 - 0.5 * netTurn)
val fh = thetaF(0.5)
val gh = thetaG(0.5)
val det = fh * g1 - gh * f1
if (abs(det) > 1e-10 * max(max(abs(fh * g1), abs(gh * f1)), 1e-300)) {
inits.add((midTangent * g1 - gh * netTurn) / det)
}
}
inits.add(6 * (th0 + th1))
for (a0 in inits) {
var a = a0
var r = residual(a) ?: continue
for (iteration in 0 until 30) {
if (abs(r) < 1e-11) break
val h = 1e-7 * max(1.0, abs(a))
val rp = residual(a + h)
if (rp == null) { r = 1.0; break }
val dr = (rp - r) / h
if (dr == 0.0) { r = 1.0; break }
val step = -r / dr
var lambda = 1.0
var improved = false
for (ls in 0 until 12) {
val rn = residual(a + lambda * step)
if (rn != null && abs(rn) < abs(r)) {
a += lambda * step
r = rn
improved = true
break
}
lambda *= 0.5
}
if (!improved) break
}
if (abs(r) >= 1e-8) continue
val b = bOf(a)
// Reject solutions whose net turn is right but which sneak a round trip in on the way.
if (abs(a) > 1e-14) {
val tx = -b / a
if (tx > 0 && tx < 1) {
val excursion = a * thetaF(tx) + b * thetaG(tx)
val lo = min(0.0, netTurn) - PI - 0.3
val hi = max(0.0, netTurn) + PI + 0.3
if (excursion <= lo || excursion >= hi) continue
}
}
return doubleArrayOf(a, b)
}
return null
}
fun hyperbezierFor
The control-polygon to hyperbezier map: turn a cubic-Bezier-shaped control polygon, expressed in the chord frame, into the curve that replaces it.
@param th0 angle of the outgoing tangent at the start, measured counter-clockwise from the chord.
@param th1 angle of the incoming tangent at the end, same convention.
@param l0 length of the start arm as a fraction of the chord.
@param l1 length of the end arm as a fraction of the chord.
@param cornerBias how sharply arms longer than circleArm steepen into a corner. 1.0 is the
blog's default; larger values reach corner-like shapes at shorter arms. Purely an aesthetic
dial — it does not affect the u = v = 1 circle/Euler case.
@param armResponse baseline slope of ln q in ln u, the family's small-angle response. 2.0
matches a cubic Bezier exactly in the small-angle limit and is better at high tension; 2.2
fits cubics slightly better across the mid range.
@return the solved curve, or null if the Newton solve stalled — which callers should treat as
"keep the cubic you already had" rather than as an error.
fun hyperbezierFor(
th0: Double,
th1: Double,
l0: Double,
l1: Double,
cornerBias: Double = 1.0,
armResponse: Double = 2.0,
): Hyperbezier? {
// Arms normalized by the parabola rule, with a guard so u fades to 0 continuously as a tangent
// approaches +-180 degrees. That fade is what makes swinging a control point through the back
// direction continuous: the +-2pi winding ambiguity collapses into a sub-pixel curl.
val u = l0 * 1.5 * max(1 + cos(th0), 1e-12)
val v = l1 * 1.5 * max(1 + cos(th1), 1e-12)
// A smooth p-norm clamp on ln q: corner headroom above, deep enough below that the residual
// seam at +-180 degrees is ~3e-5 of the chord. Note softClamp(0) == 0, so u == v == 1 gives
// q0 == q1 == 1 -- an exact Euler spiral or circle at every angle.
val limitAbove = min(8.0, 5 + 0.75 * cornerBias)
fun softClamp(value: Double): Double {
val x = max(value, -60.0)
val l = if (x >= 0) limitAbove else 15.0
return x / (1 + (x / l).pow(4)).pow(0.25)
}
val lu = ln(max(u, 1e-26))
val lv = ln(max(v, 1e-26))
val q0 = exp(softClamp(armResponse * lu + cornerBias * max(lu, 0.0).pow(3)))
val q1 = exp(softClamp(armResponse * lv + cornerBias * max(lv, 0.0).pow(3)))
// This gauge fixes the family's one-parameter scaling freedom by normalizing
// integral of q^-1.5 over [0,1] to 1, with (q0, qm, q1) the Bernstein weights of q. The
// antiderivative of q^-1.5 is elementary and the condition telescopes to this closed form.
val qm = 1 / sqrt(q0) + 1 / sqrt(q1) - sqrt(q0 * q1)
// Convert to the q(0) = 1 gauge the rest of the file uses, then solve the angles.
val c = (q0 - 2 * qm + q1) / q0
val d = 2 * (qm - q0) / q0
val ab = fitNumerator(c, d, th0, th1, l0, l1) ?: return null
return Hyperbezier(ab[0], ab[1], c, d)
}