Ollin

WavyLettering

lettering that follows a wave: a word's glyph outlines (textToShapes) are bent from their straight baseline along a wave that travels (Shape.warped(from:along:)), and a hand tugs the letters it passes (pulled(toward:amount:radius:)). Hold the mouse to be the hand

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

/// Lettering that follows a wave. The glyph outlines of a word are taken out
/// as shapes (`textToShapes`) and bent from the straight baseline they were
/// set on along a wave that travels (`Shape.warped(from:along:)`): every
/// point of a letter is read as how far along the baseline it sits and how
/// far above or below it, and put back at the same place along the wave.
/// Tall letters stay tall, counters bend with their outlines, and the
/// straight edges bend too, since the warp adds points along them first.
///
/// A hand tugs the letters as well (`pulled(toward:amount:radius:)`): the
/// part of every outline within `reach` of the hand is drawn toward it,
/// fading smoothly to nothing at the edge of the reach, so the word bulges
/// where the hand is. Hold the mouse down to be the hand; otherwise it
/// circles on its own.
///
/// ```sh
/// swift run Example-Shapes-WavyLettering --export-loop /tmp/wavy-lettering.gif
/// ```
///
/// Every letter is a plain `Shape` on the way out, so `--export-svg` writes
/// the bent word as outlines.
@main
final class WavyLettering: Sketch {
    @Param(0 ... 160, icon: "water.waves") var amplitude = 80.0
    @Param(300 ... 1400, icon: "ruler") var wavelength = 760.0
    @Param(0 ... 1, icon: "hand.point.up.left") var tug = 0.55
    @Param(60 ... 500, icon: "circle.dashed") var reach = 240.0

    private let face = OutlineFont(name: "AvenirNext-Heavy") ?? .systemBold
    private let word = "bending"
    private var glyphs: [Shape] = []
    private var baseline = Contour([], closed: false)
    private let period = 12.0
    override var loopDuration: Double? { period }

    override func setup() {
        textFont(face)
        textAlign(.center, .middle)
        textSize(196 * scale)
        glyphs = textToShapes(word, width / 2, height / 2)
        baseline = Contour([Vector2(0, height / 2), Vector2(width, height / 2)], closed: false)
    }

    override func draw() {
        background(Color(hex: 0x14161C))
        let phase = loopProgress(over: period) * .tau

        // The wave the baseline becomes: one lap of the loop slides it one
        // whole wavelength, so the clip repeats seamlessly.
        let ripple = Contour((0 ... 60).map { i -> Vector2 in
            let x = Double(i) / 60 * width
            let y = height / 2 + amplitude * scale * sin(x / (wavelength * scale) * .tau - phase)
            return Vector2(x, y)
        }, closed: false)

        // The hand: the mouse while it is held, otherwise a point that
        // swings round the outside of the word twice a lap, so the letters
        // it passes bulge toward it.
        let hand = mouseIsPressed
            ? mouse
            : Vector2(width / 2 + cos(phase * 2) * 470 * scale, height / 2 + sin(phase * 2) * 250 * scale)

        noStroke()
        for (i, glyph) in glyphs.enumerated() {
            let bent = glyph.warped(from: baseline, along: ripple)
                .pulled(toward: hand, amount: tug, radius: reach * scale)
            fill(Color(hue: 0.08 + Double(i) / Double(glyphs.count) * 0.5, saturation: 0.55, brightness: 0.96))
            drawShape(bent)
        }

        // The wave itself, faint, and the hand.
        noFill()
        stroke(Color(hex: 0xF4EAD6).withAlpha(0.18))
        strokeWeight(2 * scale)
        drawPolyline(ripple.points)
        noStroke()
        fill(Color(hex: 0xF4EAD6).withAlpha(mouseIsPressed ? 0.9 : 0.6))
        drawCircle(center: hand, radius: 9 * scale)
    }
}

↑↓ move ↩ open