Ollin

WavingBanner

a banner waving on its pole: three stripes and a word are drawn flat in a rectangle and bent into a four-sided patch whose top and bottom are waves (CoonsPatch, Shape.warped(from:into:)), each upright strip of cloth lit by the slope of the wave. Hold the mouse to take the far edge in your hand

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

/// A banner waving on its pole. The banner is drawn flat, in a plain
/// rectangle: three stripes and a word. Then every outline is bent from that
/// rectangle into a four-sided patch whose top and bottom edges are waves
/// traveling away from the pole (`CoonsPatch`, `Shape.warped(from:into:)`).
/// A point a third of the way across the flat banner and halfway down lands
/// a third of the way along the waving top and bottom, halfway between them,
/// so the stripes and the letters ride the cloth together, and their
/// straight edges bend, since the warp adds points along them first.
///
/// The cloth is cut into narrow upright strips, each lit by how the wave
/// faces at its place along the banner, so the folds read as light and
/// shade. The far edge flutters more than the near one, which is held by the
/// pole. Hold the mouse down to take the far edge in your hand: the right
/// side of the patch becomes a curve through the mouse, and the whole
/// banner, word and all, follows it.
///
/// ```sh
/// swift run Example-Shapes-WavingBanner --export-loop /tmp/waving-banner.gif
/// ```
///
/// Every stripe and letter is a plain `Shape` on the way out, so
/// `--export-svg` writes the waving banner as outlines.
@main
final class WavingBanner: Sketch {
    @Param(0 ... 120, icon: "wind") var wind = 70.0
    @Param(1 ... 3, icon: "water.waves") var folds = 1.5
    @Param(0 ... 1, icon: "sun.max") var shading = 0.6

    private let face = OutlineFont(name: "AvenirNext-Heavy") ?? .systemBold
    private let word = "OLLIN"
    /// The flat banner, the rectangle everything is set in before it waves.
    private var flat = Rectangle(x: 0, y: 0, width: 1, height: 1)
    private var glyphs: [Shape] = []
    private let period = 6.0
    override var loopDuration: Double? { period }

    private let stripes = [Color(hex: 0x2B4C7E), Color(hex: 0xF2E6CF), Color(hex: 0xE3A13B)]

    override func setup() {
        flat = Rectangle(x: 200 * scale, y: 300 * scale, width: 760 * scale, height: 420 * scale)
        textFont(face)
        textAlign(.center, .middle)
        textSize(170 * scale)
        glyphs = textToShapes(word, flat.center.x, flat.center.y)
    }

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

        // One edge of the cloth: a wave that travels away from the pole,
        // growing from nothing at the pole to the full wind at the far end.
        // The bottom lags the top a little, so the cloth twists.
        func edge(y: Double, lag: Double) -> Contour {
            Contour((0 ... 48).map { i -> Vector2 in
                let s = Double(i) / 48
                let swing = wind * scale * pow(s, 1.2)
                let wave = sin(s * folds * .tau - phase - lag)
                // The cloth shortens a little where it folds deepest.
                let x = flat.x + s * flat.width * (1 - 0.04 * wind / 120 * (1 + wave) * s)
                return Vector2(x, y + swing * wave)
            }, closed: false)
        }
        let top = edge(y: flat.y, lag: 0)
        let bottom = edge(y: flat.y + flat.height, lag: 0.5)
        let pole = Contour([flat.topLeft, flat.bottomLeft], closed: false)
        let topEnd = top.points[top.points.count - 1], bottomEnd = bottom.points[bottom.points.count - 1]
        let far = mouseIsPressed
            ? Contour(curveThrough: [topEnd, mouse, bottomEnd], closed: false)
            : Contour([topEnd, bottomEnd], closed: false)
        let cloth = CoonsPatch(top: top, right: far, bottom: bottom, left: pole)

        // The pole.
        noStroke()
        fill(Color(hex: 0x8A7A66))
        drawRect(flat.x - 22 * scale, flat.y - 80 * scale, 14 * scale, flat.height + 560 * scale)
        fill(Color(hex: 0xC9B79C))
        drawCircle(flat.x - 15 * scale, flat.y - 84 * scale, 13 * scale)

        // The stripes, cut into upright strips, each lit by the slope of
        // the wave at its place along the banner.
        let strips = 36
        for (k, color) in stripes.enumerated() {
            let band = flat.height / Double(stripes.count)
            for j in 0 ..< strips {
                let s0 = Double(j) / Double(strips), s1 = Double(j + 1) / Double(strips)
                let piece = Rectangle(x: flat.x + s0 * flat.width, y: flat.y + Double(k) * band,
                                      width: (s1 - s0) * flat.width + 0.5, height: band + 0.5)
                let s = (s0 + s1) / 2
                let slope = cos(s * folds * .tau - phase - 0.25) * pow(s, 0.6)
                let light = 1 + shading * 0.35 * slope
                fill(Color.mix(color, light >= 1 ? .white : .black, abs(light - 1)))
                drawShape(Shape(piece.contour.points).warped(from: flat, into: cloth, spacing: 6 * scale))
            }
        }

        // The word, set flat in the middle of the banner, rides the cloth.
        fill(Color(hex: 0x14161C))
        for glyph in glyphs {
            drawShape(glyph.warped(from: flat, into: cloth, spacing: 3 * scale))
        }

        // The cloth's edge, and the hand while it holds the far side.
        noFill()
        stroke(Color(hex: 0x14161C).withAlpha(0.5))
        strokeWeight(2 * scale)
        drawPolyline(cloth.contour.points, closed: true)
        if mouseIsPressed {
            noStroke()
            fill(Color(hex: 0xF4EAD6).withAlpha(0.9))
            drawCircle(center: mouse, radius: 9 * scale)
        }
    }
}

↑↓ move ↩ open