Ollin

HapticRidges

a plate of ridges you drag across and feel: three strips of different spacing and crispness knock under the pointer as it crosses them, holding the button plays a hum whose strength follows the hand, and the bottom edge draws what the hardware was actually asked for (HapticPattern, playHaptic, TrackpadPlan)

Run
swift run --package-path Examples Example-Integration-HapticRidges
Imports
OllinHaptics
Source
Examples/Integration/HapticRidges/Sketch.swift
import Ollin
import OllinHaptics

/// A plate of ridges you drag across, and feel.
///
/// Three strips, each with its own ridges. Drag the pointer sideways through
/// one and every ridge you cross knocks under your finger. The top strip is
/// dull and far apart, the middle one is in between, and the bottom one is
/// crisp and close together, so the three feelings the hardware offers sit
/// side by side and can be compared in one drag. How fast you drag decides how
/// hard each knock lands.
///
/// Hold the space bar for a hum instead of knocks. Move left and right
/// while you hold it: the hum gets stronger toward the right, and on a
/// trackpad you feel that as a faster train of knocks rather than a harder
/// one, because a trackpad has one strength and many rates.
///
/// Drag with the button held. A trackpad actuates only while a finger is
/// pressing it, so a knock asked for during a plain pointer move is delivered
/// to nobody; the plan along the bottom still shows it was asked for.
///
/// The strip along the bottom draws what was last asked of the hardware, so
/// the eye can check what the hand felt. On a machine that cannot be felt at
/// all the sketch says so and keeps drawing.
@main
final class HapticRidgesExample: Sketch {

    @Param(0 ... 1, icon: "speaker.wave.2") var strength = 1.0
    @Param(icon: "waveform.path") var showPlan = true

    /// One band of the plate: where it sits, how far apart its ridges are, and
    /// how crisp they feel.
    private struct Strip {
        let name: String
        let top: Double
        let height: Double
        let spacing: Double
        let sharpness: Double
        let ink: Color
    }

    private var strips: [Strip] = []
    private var crossed: [Int: Double] = [:]   // ridge index -> how fresh it is
    private var lastPlan: [TrackpadKnock] = []
    private var planAge = 0.0
    private var humming = false

    override var canvasSize: CanvasSize { .square(1080) }

    override func setup() {
        let margin = 120.0
        let band = (Double(height) - margin * 2) / 3
        strips = [
            Strip(name: "far apart, dull", top: margin, height: band,
                  spacing: 90, sharpness: 0.1, ink: Color(hex: 0x2A4D69)),
            Strip(name: "even, in between", top: margin + band, height: band,
                  spacing: 54, sharpness: 0.5, ink: Color(hex: 0x4B86B4)),
            Strip(name: "close, crisp", top: margin + band * 2, height: band,
                  spacing: 26, sharpness: 0.95, ink: Color(hex: 0xADCBE3)),
        ]
    }

    override func draw() {
        hapticStrength(strength)
        background(Color(white: 0.92))
        fade()

        drawPlate()
        respondToTheHand()
        drawPointer()
        drawCaptions()
        if showPlan { drawLastPlan() }
    }

    // MARK: What the hand does

    private func respondToTheHand() {
        // Holding the space bar runs one long hum instead of the ridges, so
        // the two ways of asking for touch are next to each other.
        if isKeyDown(" ") {
            if !humming { startHum() }
            return
        }
        if humming { stopHum() }

        guard let strip = strip(under: mouse.y) else { return }
        guard let ridge = ridgeCrossed(in: strip) else { return }

        // A quick hand hits harder, the way a stick dragged along railings
        // does. Speed is measured in canvas points per frame.
        let speed = (mouse - previousMouse).length
        let force = min(1, 0.35 + speed / 40)
        play(.tap(intensity: force, sharpness: strip.sharpness))
        crossed[ridge] = 1
    }

    private func startHum() {
        humming = true
        // Long enough to hold under the finger, and started again below as it
        // runs out, so the strength can follow the hand.
        play(.hum(0.5, intensity: humStrength, sharpness: 0.2, fadeIn: 0.05))
    }

    private func stopHum() {
        humming = false
        stopHaptics()
    }

    /// How strong the hum is, from where the hand is across the canvas.
    private var humStrength: Double {
        min(1, max(0.05, mouse.x / Double(width)))
    }

    /// Which ridge the hand has just crossed, if any.
    ///
    /// The pointer can move a long way between two frames, so this asks which
    /// ridge lies between where it was and where it is, and takes the nearest
    /// one. Firing every ridge in a fast sweep would ask the hardware for more
    /// knocks than it can give, and the plan would drop them anyway.
    private func ridgeCrossed(in strip: Strip) -> Int? {
        let from = min(previousMouse.x, mouse.x)
        let to = max(previousMouse.x, mouse.x)
        guard to > from else { return nil }
        let first = Int((from / strip.spacing).rounded(.up))
        let last = Int((to / strip.spacing).rounded(.down))
        guard first <= last else { return nil }
        return mouse.x >= previousMouse.x ? first : last
    }

    private func strip(under y: Double) -> Strip? {
        strips.first { y >= $0.top && y < $0.top + $0.height }
    }

    /// Play a pattern, and keep a copy of what the hardware was asked for.
    private func play(_ pattern: HapticPattern) {
        playHaptic(pattern)
        lastPlan = TrackpadPlan.knocks(for: pattern, strength: strength)
        planAge = 0
    }

    // MARK: What the eye sees

    /// Ridges dim after they are crossed, so a sweep leaves a trail.
    private func fade() {
        planAge += deltaTime
        for (ridge, level) in crossed {
            let next = level - deltaTime * 2
            if next <= 0 { crossed[ridge] = nil } else { crossed[ridge] = next }
        }
        if humming, planAge > 0.4 {
            // Ask again before the last one runs out, so the strength can
            // follow the hand while it holds.
            play(.hum(0.5, intensity: humStrength, sharpness: 0.2))
        }
    }

    private func drawPlate() {
        for strip in strips {
            noStroke()
            fill(Color(white: 1))
            drawRect(0, strip.top, Double(width), strip.height - 6)

            var ridge = 0
            var x = 0.0
            while x <= Double(width) {
                let fresh = crossed[ridge] ?? 0
                stroke(strip.ink.withAlpha(0.35 + fresh * 0.65))
                strokeWeight(fresh > 0 ? 5 : 2)
                drawLine(x, strip.top + 14, x, strip.top + strip.height - 20)
                x += strip.spacing
                ridge += 1
            }

            noStroke()
            fill(Color(white: 0.45))
            textSize(17)
            drawText(strip.name, 16, strip.top + strip.height - 4)
        }
    }

    private func drawPointer() {
        // Before any hand has arrived, and in an export, the pointer reads as
        // the top left corner. Show it in the middle instead of half off the
        // edge.
        let at = mouse == .zero ? center : mouse
        noFill()
        stroke(humming ? Color(hex: 0xE07A5F) : Color(white: 0.2))
        strokeWeight(humming ? 4 : 2)
        drawCircle(at.x, at.y, humming ? 26 : 18)
        if humming {
            noStroke()
            fill(Color(hex: 0xE07A5F).withAlpha(humStrength))
            drawCircle(at.x, at.y, 12)
        }
    }

    private func drawCaptions() {
        noStroke()
        fill(Color(white: 0.25))
        textSize(21)
        let heading = switch hapticHardware {
        case .engine: "A haptic engine: patterns arrive as written."
        case .trackpad: "A trackpad: three feelings, one strength, so strength becomes rate."
        case .none: hapticsUnavailableReason ?? "Nothing to feel here."
        }
        drawText(heading, 16, 44)
        fill(Color(white: 0.5))
        textSize(17)
        drawText("Drag sideways through a strip with the button held. Hold the space bar for a hum.", 16, 72)
    }

    /// The knocks the hardware was last asked for, laid out in time.
    private func drawLastPlan() {
        guard !lastPlan.isEmpty else { return }
        let span = max(0.4, lastPlan.last!.time + 0.05)
        let base = Double(height) - 54.0
        noStroke()
        fill(Color(white: 0.9))
        drawRect(16, base - 22, Double(width) - 32, 30)

        for knock in lastPlan {
            let x = 24 + (Double(width) - 48) * (knock.time / span)
            let tall = switch knock.feel {
            case .soft: 8.0
            case .level: 14.0
            case .crisp: 20.0
            }
            stroke(Color(hex: 0x2A4D69))
            strokeWeight(2)
            drawLine(x, base + 4, x, base + 4 - tall)
        }

        noStroke()
        fill(Color(white: 0.45))
        textSize(15)
        drawText("asked of the hardware: \(lastPlan.count) knocks over \(String(format: "%.2f", span)) s",
                 16, base + 30)
    }
}

↑↓ move ↩ open