Ollin

Drape

A washing line in the wind: a banner pegged along its top edge that flaps, a sheet thrown over a crate, and a beach ball you can let the air out of. Drag any of them. Shows addSoftBody, pinned:, pressure, applyForce, drawSoftBody, and grabSoftBody.

Run
swift run --package-path Examples Example-3D-Physics-Drape
Imports
OllinPhysics
Source
Examples/3D/Physics/Drape/Sketch.swift
import Ollin
import OllinPhysics

/// A washing line in the wind. Three soft bodies share the scene: a banner
/// pegged along its top edge that flaps, a sheet thrown over a rail and a crate
/// so it has to find their shape, and a beach ball you can let the air out of.
/// **Drag** any of them to take hold of it; **space** tosses the ball back up.
///
/// The soft-body showcase for `World3D`. Everything here is one call:
/// `try? world.addSoftBody(from:)` turns a mesh into simulated particles, `pinned:`
/// decides which of them are held, `pressure` is the air inside the ball, and
/// `drawSoftBody` draws whatever shape the simulation arrived at.
@main
final class Drape: Sketch {
    let world = World3D()
    var banner: SoftBody3D?
    var sheet: SoftBody3D?
    var ball: SoftBody3D?
    var grip: SoftGrip?

    /// How hard the wind blows along the line, in newtons.
    @Param(0 ... 12, icon: "wind") var wind = 3.2
    /// The air in the beach ball, in gravities of outward push. (Named `air`
    /// rather than `pressure`, which the sketch already uses for the pointer.)
    @Param(0 ... 5, icon: "circle.circle") var air = 3.0

    let lineY = 3.4, lineSpan = 5.2
    let bannerColor = Color(hex: 0xE05B4B)
    let sheetColor = Color(hex: 0xEDE6D8)
    let ballColor = Color(hex: 0x3FA9A0)

    /// One piece of the scenery, kept so the same numbers build the collider
    /// and draw the shape.
    struct Prop {
        var size: Vector3
        var at: Vector3
    }
    var props: [Prop] = []

    override func setup() {
        world.ground = 0
        buildStage()
        buildCloth()
    }

    /// The scenery the cloth has to answer to: two posts carrying the line, and
    /// a crate for the sheet to be thrown over.
    func buildStage() {
        for side in [-1.0, 1.0] {
            props.append(Prop(size: Vector3(0.16, lineY, 0.16),
                              at: Vector3(side * lineSpan / 2, lineY / 2, -1.4)))
        }
        props.append(Prop(size: Vector3(1.0, 1.0, 1.0), at: Vector3(1.5, 0.5, 1.6)))

        for prop in props {
            world.addBody(.box(width: prop.size.x, height: prop.size.y,
                               depth: prop.size.z),
                          at: prop.at, kind: .static, friction: 0.9)
        }
        // The line the banner is pegged to. Nothing hangs off it in the
        // simulation (the pegs hold the cloth directly), so it is a thin static
        // rod there only to be seen and bumped into.
        world.addBody(.cylinder(height: lineSpan, radius: 0.02),
                      at: Vector3(0, lineY, -1.4), kind: .static,
                      rotated: .pi / 2, axis: Vector3(0, 0, 1))
    }

    func buildCloth() {
        // A banner pegged along its top edge. The mesh is authored flat in the
        // ground plane, so it is stood upright on its way into the world and
        // the `pinned` test still reads the mesh's own coordinates: its far
        // edge in z becomes the top edge once it is up.
        // `at:` places the mesh's own origin, and this plane's origin is its
        // center, so the body is dropped half its depth below the line for its
        // pegged edge to land on it.
        banner = try? world.addSoftBody(from: .plane(width: 3.2, depth: 2.2, segments: 20),
                                   at: Vector3(-1.0, lineY - 1.1, -1.4),
                                   rotated: -.pi / 2, axis: Vector3(1, 0, 0),
                                   mass: 0.4, stiffness: 0.9, bend: 0.06,
                                   damping: 0.25, friction: 0.4, vertexRadius: 0.01,
                                   pinned: { $0.z > 1.0 })

        // A sheet dropped over the rail and the crate: nothing pins it, so the
        // shape it takes is the shape of what is under it.
        sheet = try? world.addSoftBody(from: .plane(width: 2.6, depth: 2.6, segments: 20),
                                  at: Vector3(1.5, 2.6, 1.2),
                                  mass: 0.6, stiffness: 0.95, bend: 0.25,
                                  friction: 0.9, vertexRadius: 0.015)

        // A closed surface, so the air inside it is what holds its shape.
        ball = try? world.addSoftBody(from: .icosphere(radius: 0.55, subdivisions: 3),
                                 at: Vector3(-1.7, 2.2, 1.9),
                                 mass: 0.5, bend: 0.4, pressure: air,
                                 damping: 0.05, friction: 0.4, restitution: 0.3,
                                 vertexRadius: 0.01)
    }

    override func mousePressed() {
        grip = grabSoftBody(at: mouse, in: world)
    }

    override func mouseReleased() {
        if let grip { releaseSoftGrip(grip) }
        grip = nil
    }

    override func keyPressed() {
        if key == " " { tossBall() }
    }

    func tossBall() {
        ball?.wake()
        ball?.applyForce(Vector3(random(-60, 60), 900, random(-60, 60)))
    }

    override func draw() {
        background(Color(hex: 0x101722))
        environment(.courtyard.lightingOnly())
        lightingPreset(.studio)
        castShadows()
        cameraShowcase(.autoOrbit(period: 34), from: Camera3D
            .perspective(eye: Vector3(0.4, 3.4, 8.4), target: Vector3(0, 1.9, 0)))

        // Both parameters reach the solver between steps, which is the point of
        // them being live: the ball deflates under your hand.
        ball?.pressure = air
        blow()
        if let grip { dragSoftGrip(grip, to: mouse) }
        world.advance(by: deltaTime)

        drawStage()
        drawCloth()
        drawCaption("drag the cloth      space tosses the ball")
    }

    /// A gusting wind along the line. The banner catches most of it and the
    /// sheet a little: a force is spread over a body's whole surface, so what
    /// matters is how much of that surface is free to move.
    func blow() {
        let gust = wind * (0.55 + 0.45 * signedNoise(time * 0.55))
        let sway = signedNoise(time * 0.31 + 40) * 0.4
        banner?.applyForce(Vector3(sway * gust, 0, gust))
        sheet?.applyForce(Vector3(sway * gust * 0.2, 0, gust * 0.15))
    }

    func drawStage() {
        fill(Color(hex: 0x1A2230))
        material(.dielectric(roughness: 0.92))
        drawGround(size: 40)

        fill(Color(hex: 0x6B5138))
        material(.dielectric(roughness: 0.7))
        for prop in props {
            withState {
                translate(prop.at)
                drawBox(width: prop.size.x, height: prop.size.y, depth: prop.size.z)
            }
        }
        withState {
            translate(0, lineY, -1.4)
            rotate(.pi / 2, axis: Vector3(0, 0, 1))
            drawCylinder(radius: 0.02, height: lineSpan)
        }
    }

    /// Drawing a soft body is one call: the simulation hands back the mesh it
    /// arrived at, already in world space.
    func drawCloth() {
        material(.dielectric(roughness: 0.55))
        if let banner {
            fill(bannerColor)
            drawSoftBody(banner)
        }
        if let sheet {
            fill(sheetColor)
            drawSoftBody(sheet)
        }
        if let ball {
            // A deflating ball reads duller as well as flatter.
            fill(Color.mix(ballColor, Color(hex: 0x9AA6A4), 1 - min(1, air / 3)))
            material(.dielectric(roughness: 0.35))
            drawSoftBody(ball)
        }
    }
}

↑↓ move ↩ open