Ollin

HobbySpline

the same drifting points threaded by the default spline and by Hobby's fit (drawCurve(spline: .hobby)), an open run on the left and a filled loop on the right, with tension and curl as parameters

Run
swift run --package-path Examples Example-Shapes-HobbySpline
Source
Examples/Shapes/HobbySpline/Sketch.swift
import Ollin

/// `drawCurve(points, spline: .hobby)` threads a curve through points the
/// way a practiced hand does: every gap becomes a Bézier whose control points
/// are solved together, so the bend flows evenly from one point to the next.
/// The default spline decides each tangent from the two neighbors alone,
/// which flattens between far-apart points and swells between close ones.
///
/// Top, an open run: the straight polyline faint, the default curve in gray,
/// Hobby's fit in ink. Bottom, the same comparison on a closed loop, the fit
/// filled. Both runs space their points unevenly on purpose, since a close
/// pair beside a wide gap is where the two curves disagree most. The points
/// drift on slow orbits so the curves can be watched against each other;
/// `tension` pulls the fit toward the chords, and `curl` lets the open ends
/// bend (1) or run straight (0).
@main
final class HobbySpline: Sketch {
    @Param(0.75...3) var tension = 1.0
    @Param(0...2) var curl = 1.0

    override func draw() {
        background(Color(white: 0.96))

        let grid = Grid(in: bounds, columns: 1, rows: 2, padding: .all(60 * scale))
        for cell in grid.cells {
            let closed = cell.row == 1
            let frame = cell.frame.inset(by: .all(40 * scale))
            let points = closed ? loop(in: frame) : run(in: frame)

            withState {
                noFill()
                strokeCap(.round)
                strokeJoin(.round)

                stroke(Color(white: 0.82))
                strokeWeight(2 * scale)
                if closed { drawPolygon(points) } else { drawPolyline(points) }

                stroke(Color(white: 0.62))
                strokeWeight(4 * scale)
                drawCurve(points, closed: closed)

                if closed { fill(Color(hex: 0xE8C170, alpha: 0.55)) }
                stroke(Color(white: 0.1))
                strokeWeight(5 * scale)
                drawCurve(points, closed: closed, spline: .hobby(tension: tension, curl: curl))
            }

            noStroke()
            fill(Color(hex: 0xC1442E))
            for p in points { drawCircle(center: p, radius: 7 * scale) }
        }
    }

    /// Six points across the frame, rising and falling, with the third close
    /// on the heels of the second and a wide gap after it.
    private func run(in frame: Rectangle) -> [Vector2] {
        let spots = [Vector2(0.07, 0.26), Vector2(0.25, -0.26), Vector2(0.35, -0.2),
                     Vector2(0.62, 0.26), Vector2(0.8, -0.26), Vector2(0.93, 0.1)]
        return spots.enumerated().map { i, spot in
            let base = Vector2(frame.x + spot.x * frame.width, frame.center.y + spot.y * frame.height)
            return base + drift(i, in: frame)
        }
    }

    /// Seven points around an ellipse at uneven turns, two of them close.
    private func loop(in frame: Rectangle) -> [Vector2] {
        let turns = [0.0, 0.07, 0.27, 0.42, 0.5, 0.72, 0.86]
        return turns.enumerated().map { i, t in
            let a = t * .pi * 2 - .pi / 2
            let base = frame.center + Vector2(cos(a) * frame.width * 0.44, sin(a) * frame.height * 0.44)
            return base + drift(i, in: frame)
        }
    }

    /// A slow orbit of its own for each point.
    private func drift(_ i: Int, in frame: Rectangle) -> Vector2 {
        let phase = Double(i) * 1.7
        return Vector2(sin(time * 0.35 + phase) * frame.width * 0.02,
                       cos(time * 0.27 + phase * 1.3) * frame.height * 0.06)
    }
}

↑↓ move ↩ open