Ollin

Joints

All four joint kinds side by side, each as one labeled rig hung from a static anchor: a revolute pendulum, a distance ball on a springy rope, a weld tee that swings as one rigid piece, and a prismatic slider that can only travel along its rail. Click a piece to grab and fling it, and press space to reset (JointKind, Body.kind = .static).

Run
swift run --package-path Examples Example-Physics-Joints
Imports
OllinPhysics
Source
Examples/Physics/Joints/Sketch.swift
import Foundation
import Ollin
import OllinPhysics

/// The four `JointKind` cases side by side, one small rig each, all hung from
/// static anchors (`Body.kind = .static`, pieces the solver never moves): a
/// **revolute** pendulum swinging on a free hinge; a **distance** ball on a
/// slightly springy rope; a **weld** tee, two boxes locked into one rigid piece
/// that swings and tumbles together; and a **prismatic** slider that can only
/// travel along its rail, pulled home by a soft distance rod. Each rig is
/// labeled with its case name.
///
/// **Click a moving piece to grab it** (a cursor-drag mouse joint) and fling it
/// around; **click again to let go**. Space resets. Every rig is one
/// `World.connect(_:_:_:)` with a different `JointKind`, and each piece is
/// drawn straight from its `position`/`angle`.
@main
final class Joints: Sketch {
    let world = World()

    /// The dynamic pieces a click can grab.
    var grabbable: [Body] = []
    /// The active cursor-drag joint, if a piece is currently held.
    var held: Joint?
    /// Rope and rod links (the distance joints) drawn as lines between bodies.
    var tethers: [(Body, Body)] = []

    enum Form {
        case peg(Double)
        case ball(Double)
        case capsule(Vector2, Vector2, Double)
        case box(Double, Double)
    }
    final class Look {
        let form: Form
        let color: Color
        init(_ form: Form, _ color: Color) { self.form = form; self.color = color }
    }

    let pegColor = Color(white: 0.45)
    let armColor = Color(red: 0.45, green: 0.77, blue: 0.71)
    let ballColor = Color(red: 0.95, green: 0.49, blue: 0.34)
    let plankColor = Color(red: 0.55, green: 0.57, blue: 0.88)
    let barColor = Color(red: 0.97, green: 0.71, blue: 0.30)
    let sliderColor = Color(red: 0.42, green: 0.72, blue: 0.94)
    let railColor = Color(white: 0.30)

    /// One column per case, left to right in declaration order.
    var columns: [Double] { (0 ..< 4).map { width * (0.125 + 0.25 * Double($0)) } }
    var anchorY: Double { height * 0.46 }

    override func setup() {
        world.gravity = Vector2(0, 2000)
        world.bounds = bounds              // keep flung pieces on-canvas
        world.drag = 0                     // no air, so the rigs keep swinging
        build()
        noStroke()
    }

    func build() {
        grabbable.removeAll()
        tethers.removeAll()
        let x = columns
        buildRevolute(at: Vector2(x[0], anchorY))
        buildDistance(at: Vector2(x[1], anchorY))
        buildWeld(at: Vector2(x[2], anchorY))
        buildPrismatic(at: Vector2(x[3], anchorY))
    }

    /// A static anchor peg: the one body in each rig the solver never moves.
    @discardableResult
    func peg(at point: Vector2) -> Body {
        let peg = world.addBody(.circle(radius: 10 * scale), at: point, kind: .static)
        peg.userData = Look(.peg(10 * scale), pegColor)
        return peg
    }

    /// `.revolute`: a capsule arm sharing a free pivot with the peg, started at
    /// a lean so it swings on launch. The plain pendulum.
    func buildRevolute(at top: Vector2) {
        let anchor = peg(at: top)
        let length = 230 * scale
        let unit = Vector2(angle: .pi / 2 - 1.0, length: 1)   // leaned off straight down
        let half = unit * (length * 0.46)
        let arm = world.addBody(.capsule(from: -half, to: half, radius: 12 * scale),
                                at: top + unit * (length * 0.5), density: 1, friction: 0.5)
        arm.userData = Look(.capsule(-half, half, 12 * scale), armColor)
        world.connect(anchor, arm, .revolute(at: top))
        grabbable.append(arm)
    }

    /// `.distance`: a heavy ball held a rope's length from the peg. The low
    /// stiffness softens the rod into a spring, so the ball bobs as it swings.
    func buildDistance(at top: Vector2) {
        let anchor = peg(at: top)
        let drop = Vector2(angle: .pi / 2 + 0.85, length: 235 * scale)
        let ball = world.addBody(.circle(radius: 28 * scale), at: top + drop,
                                 density: 4, friction: 0.5)
        ball.userData = Look(.ball(28 * scale), ballColor)
        world.connect(anchor, ball, .distance(from: top, to: ball.position,
                                              length: nil, stiffness: 0.2))
        grabbable.append(ball)
        tethers.append((anchor, ball))
    }

    /// `.weld`: a plank hinged at the peg, with a crossbar welded to its far
    /// end. The two boxes read as one rigid tee, even flung into a wall.
    func buildWeld(at top: Vector2) {
        let anchor = peg(at: top)
        let lean = -0.65
        let down = Vector2(angle: .pi / 2 + lean, length: 1)
        let plankLength = 180 * scale
        let plank = world.addBody(.box(width: 24 * scale, height: plankLength),
                                  at: top + down * (plankLength * 0.5),
                                  density: 1, friction: 0.5)
        plank.angle = lean
        plank.userData = Look(.box(24 * scale, plankLength), plankColor)
        world.connect(anchor, plank, .revolute(at: top))

        let bar = world.addBody(.box(width: 170 * scale, height: 24 * scale),
                                at: top + down * (plankLength + 12 * scale),
                                density: 1, friction: 0.5)
        bar.angle = lean
        bar.userData = Look(.box(170 * scale, 24 * scale), barColor)
        world.connect(plank, bar, .weld)
        grabbable.append(plank)
        grabbable.append(bar)
    }

    /// `.prismatic`: a box that can only translate along its rail's axis. A
    /// soft distance rod back to the peg is the return spring: its rest length
    /// is the perpendicular drop, so it is satisfied only at the rail's center
    /// and the slider oscillates home when plucked.
    func buildPrismatic(at top: Vector2) {
        let anchor = peg(at: top)
        let railY = top.y + 150 * scale
        let rail = world.addBody(.box(width: 260 * scale, height: 8 * scale),
                                 at: Vector2(top.x, railY), kind: .static)
        rail.userData = Look(.box(260 * scale, 8 * scale), railColor)

        let start = Vector2(top.x - 85 * scale, railY)   // off-center, so it springs back on launch
        let slider = world.addBody(.box(width: 72 * scale, height: 44 * scale), at: start,
                                   density: 1, friction: 0.2)
        slider.userData = Look(.box(72 * scale, 44 * scale), sliderColor)
        world.connect(rail, slider, .prismatic(at: start, axis: Vector2(1, 0)))
        world.connect(anchor, slider, .distance(from: top, to: start,
                                                length: 150 * scale, stiffness: 0.25))
        grabbable.append(slider)
        tethers.append((anchor, slider))
    }

    override func mousePressed() {
        if let held {                       // already holding, so let go
            held.remove()
            self.held = nil
            return
        }
        let cursor = mouse
        var nearest: Body?
        var nearestDistance = 90 * scale    // grab radius
        for body in grabbable {
            let d = body.position.distance(to: cursor)
            if d < nearestDistance { nearestDistance = d; nearest = body }
        }
        if let body = nearest {
            held = world.grab(body, at: cursor)
        }
    }

    override func keyPressed() {
        if key == " " {
            held = nil                      // grab joints are gone with removeAll
            world.removeAll()
            build()
        }
    }

    override func draw() {
        background(Color(white: 0.11))

        held?.target = mouse   // drag the held piece to the cursor
        world.advance(by: deltaTime)

        // Ropes and rods (the distance joints) first, so bodies draw over them.
        stroke(Color(white: 0.8, alpha: 0.5))
        strokeWeight(3 * scale)
        for (a, b) in tethers { drawLine(a.position, b.position) }
        noStroke()

        for body in world.bodies {
            guard let look = body.userData as? Look else { continue }
            withState {
                translate(body.position)
                rotate(body.angle)
                fill(look.color)
                switch look.form {
                case .peg(let r):
                    drawCircle(center: .zero, radius: r)
                case .ball(let r):
                    drawCircle(center: .zero, radius: r)
                case .capsule(let a, let b, let r):
                    stroke(look.color)
                    strokeWeight(2 * r)
                    drawLine(a, b)
                case .box(let w, let h):
                    drawRect(center: .zero, width: w, height: h, cornerRadius: 3 * scale)
                }
            }
        }

        drawLabels()
    }

    /// Each rig's case name, above its anchor.
    func drawLabels() {
        withState {
            textFont(.system)
            textSize(width * 0.021)
            textAlign(.center)
            noStroke()
            fill(Color(white: 0.85))
            for (x, name) in zip(columns, ["revolute", "distance", "weld", "prismatic"]) {
                drawText(".\(name)", at: Vector2(x, height * 0.34))
            }
        }
    }
}

↑↓ move ↩ open