Ollin

GestureMarks3D

The gesture marks' members in space: one drawn stroke, heavy at the start and lifting toward the end, as a tube as wide as the mark at every point (GestureMark.tube), candy-striped through its texture coordinates, wound round the path with wobble, or in polished metal reflecting the flat marks drawn into a layer (environment(.feed(layer))), and as a chain of cubes at the flat chain's stations (GestureMark.solids). The stroke grows over the loop and nothing behind the pen moves.

Run
swift run --package-path Examples Example-3D-Geometry-GestureMarks3D
Source
Examples/3D/Geometry/GestureMarks3D/Sketch.swift
import Ollin
import Foundation

/// The gesture marks' members in space. One drawn stroke, heavy at the start
/// and lifting toward the end, as a tube whose thickness is the mark's width
/// at every point (`GestureMark.tube`), and as a chain of solids strung along
/// it at the flat chain's stations (`GestureMark.solids`). Four looks on a
/// picker:
///
/// - **candy**: the tube under a diagonal stripe that tiles once a width, so
///   it runs round the tube as a candy stripe.
/// - **wobble**: the tube wound round the path instead of along it, a phone
///   cord's coil, in clay.
/// - **metal**: a polished tube that reflects the canvas, since the layer the
///   flat marks are drawn into is the scene's environment (`environment(.feed(layer))`).
/// - **solids**: a chain of flat-shaded cubes, each as big as the width there
///   and turned along the path, where the flat chain's squares stand.
///
/// The stroke grows over the loop and holds, and nothing behind the pen moves:
/// every ring and every solid stands by its distance from the start. The same
/// gesture's hull and strips, flat, lie on the sheet under the solid.
@main
final class GestureMarks3D: Sketch {
    enum Look: String, CaseIterable, ParamOption { case candy, wobble, metal, solids }

    @Param(style: .segmented, icon: "scribble.variable") var look = Look.candy
    @Param("Width", 30 ... 150, icon: "lineweight") var weight = 90.0
    @Param("Seed", 1 ... 99, icon: "dice") var seed = 7
    @Param("Wobble", 0 ... 1.2, icon: "waveform.path") var wobble = 0.6
    @Param("Pen draws it", icon: "pencil.and.outline") var penDraws = true

    override var loopDuration: Double? { 12 }

    let paper = Color(hex: 0xF3EEE3)
    let palette = [Color(hex: 0x1F2B5B), Color(hex: 0xC4473A), Color(hex: 0xE0A43A),
                   Color(hex: 0x2F7A78), Color(hex: 0xE07A8F)]
    /// The candy stripe: a diagonal band that tiles once a width along the tube.
    lazy var stripes: Image = Self.stripeImage(Color(hex: 0xC4473A), Color(hex: 0xF7F2E8))
    lazy var cube = Mesh.roundedBox(size: 1, radius: 0.16, segments: 3)

    override func draw() {
        background(paper)
        let progress = penDraws ? smoothstep(0, 0.7, loopProgress(over: 12)) : 1
        let (points, widths) = handStroke(seed: seed)
        let count = max(2, Int(progress * Double(points.count)))
        let gesture = GestureMark(points: Array(points.prefix(count)), widths: Array(widths.prefix(count)))

        // The flat marks of the same gesture, into a layer: the table's picture
        // and, for the metal, what the tube reflects.
        let flat = makeRenderTarget()
        withTarget(flat) {
            background(paper)
            noStroke()
            fill(Color(hex: 0xE0A43A))
            drawShape(gesture.hull(padding: weight * 0.5))
            for piece in gesture.strips(rows: 4, length: 2, seed: seed) {
                fill(palette[(piece.row + piece.index) % palette.count])
                drawShape(piece.shape)
            }
        }
        let turn = time / 12 * 2 * .pi
        camera(.orbiting(target: Vector3(0, 0.2, 0), radius: 8.6, azimuth: 0.9 + 0.35 * sin(turn), elevation: 0.95,
                         fieldOfView: .pi / 4.5, near: 0.1, far: 60))
        if look == .metal {
            environment(.feed(flat))
        } else {
            directionalLight(Color(hex: 0xFFF6E8), direction: Vector3(-0.4, -0.85, -0.3), intensity: 1.2,
                             softness: 0.3)
            ambientLight(Color(hex: 0xC6CDD8))
        }
        castShadows(look != .metal)

        // The gesture's plane, canvas units, laid on the table: a unit per 100
        // points, the canvas centered (its down is -y, as the tube stands), the
        // foot of the sheet toward the camera. The sheet is the layer stretched
        // over a quad in that plane, under the tube.
        withState {
            rotateX(-.pi / 2)
            scale(0.01, 0.01, 0.01)
            translate(-width / 2, height / 2, 0)
            withState {
                noLights()
                fill(.white)
                drawMesh(Self.sheet(width: width, height: height, below: weight / 2).textured(flat.image))
            }
            switch look {
            case .candy:
                material(.matte)
                fill(.white)
                drawMesh(gesture.tube(sides: 20).textured(stripes, wrap: .tile))
            case .wobble:
                material(.clay)
                fill(Color(hex: 0x2F7A78))
                drawMesh(gesture.tube(sides: 20, wobble: wobble, wavelength: 3))
            case .metal:
                material(.polishedMetal)
                fill(Color(hex: 0xE8E4DC))
                drawMesh(gesture.tube(sides: 24))
            case .solids:
                material(.matte)
                fill(Color(hex: 0xC4473A))
                drawMesh(cube, instances: gesture.solids(spacing: 0.9, seed: seed))
            }
        }

        noStroke()
        fill(Color(white: 0.4))
        textSize(20)
        textAlign(.center, .bottom)
        drawText("\(look.rawValue)  ·  the tube is as wide as the mark", width / 2, height - 28)
    }

    /// A long stroke across the sheet, heavy where it starts and lifting toward
    /// the end, from the seed.
    func handStroke(seed: Int) -> (points: [Vector2], widths: [Double]) {
        randomSeed(seed)
        let route: [Vector2] = [Vector2(150, 640), Vector2(360, 360), Vector2(590, 560), Vector2(780, 300),
                                Vector2(940, 520)]
        let knots: [Vector2] = route.map { (point: Vector2) -> Vector2 in
            point + Vector2(random(-50, 50), random(-70, 70))
        }
        let points = Contour(curveThrough: knots, closed: false).resampled(spacing: 4).points
        let widths: [Double] = points.indices.map { (i: Int) -> Double in
            let u = Double(i) / Double(max(points.count - 1, 1))
            let lift: Double = 1 - 0.7 * u
            let press: Double = 0.35 + 0.65 * smoothstep(0, 0.05, u)
            return weight * lift * press
        }
        return (points, widths)
    }

    /// A quad the size of the canvas in the gesture's plane (the canvas's down as
    /// -y), `below` units under it, with the picture's top-left at the canvas's.
    static func sheet(width: Double, height: Double, below: Double) -> Mesh {
        Mesh(positions: [Vector3(0, 0, -below), Vector3(width, 0, -below),
                         Vector3(width, -height, -below), Vector3(0, -height, -below)],
             normals: [Vector3](repeating: Vector3(0, 0, 1), count: 4),
             indices: [0, 1, 2, 0, 2, 3],
             uvs: [Vector2(0, 0), Vector2(1, 0), Vector2(1, 1), Vector2(0, 1)])
    }

    /// A diagonal two-color stripe, 64 texels square, drawn once.
    static func stripeImage(_ a: Color, _ b: Color) -> Image {
        let n = 64
        let image = Image(width: n, height: n)
        for y in 0 ..< n {
            for x in 0 ..< n {
                image[x, y] = ((x + y) / 16) % 2 == 0 ? a : b
            }
        }
        return image
    }
}

↑↓ move ↩ open