Forces
The force API in one windy yard. A gust pushes on every loose shape through applyForce, so the leaves scatter while the crates only shuffle. A click kicks the nearest shape with applyImpulse. A paddle spun by applyTorque hits whatever drifts through it, and a kinematic sweeper pushes along the floor, which nothing can stop. A pinned lattice colors its springs by Spring.strain, and World.unitsPerMeter is bound to a parameter (applyForce, applyImpulse, applyTorque, Spring.pin()).
- Run
swift run --package-path Examples Example-Physics-Forces- Imports
OllinPhysics- Source
- Examples/Physics/Forces/Sketch.swift
import Foundation
import Ollin
import OllinPhysics
/// The force API in one windy yard. A gusting wind leans on every loose shape
/// through `applyForce`, so the light leaves skitter while the heavy crates
/// barely shuffle. **Click to kick** the nearest shape away from the cursor
/// with `applyImpulse` (an impulse is mass times velocity change, so the same
/// kick launches a leaf and only nudges a crate). A paddle spun up by
/// `applyTorque` bats whatever drifts through it. And a white sweeper of
/// `kind: .kinematic` plows along the floor, moved only by the velocity it is
/// given each frame and unstoppable by wind, kicks, or collisions. Space
/// resets.
///
/// In the corner hangs a pinned cloth: a lattice of particles (the pole column
/// held by `pin()`) whose springs are tinted by `Spring.strain`, teal where
/// compressed, coral where stretched, so the wind load is visible traveling
/// through the weave.
///
/// `World.unitsPerMeter` is set by a parameter. It maps sketch points onto the rigid
/// solver's meters, so raising it makes every collider smaller in meters and
/// therefore lighter, and the same wind and kick numbers toss the shapes much
/// harder; the scene rebuilds when it changes, because the walls and masses
/// are all sized through the mapping. The particle side works directly in
/// points, so the cloth ignores the parameter entirely.
@main
final class Forces: Sketch {
@Param(25 ... 400, icon: "ruler", group: "World") var unitsPerMeter = 100.0
@Param(0 ... 2, icon: "wind", group: "Wind") var windStrength = 1.0
let world = World()
/// The paddle `applyTorque` spins.
var paddle: Body?
/// The kinematic sweeper, driven by velocity alone.
var sweeper: Body?
/// A click's fading ring, so the kick has a visible source.
struct Kick { let at: Vector2; let born: Double }
var kicks: [Kick] = []
/// A shape's geometry (in body-local space) plus its color, hung off
/// `Body.userData`, the same pattern the other rigid examples use.
enum Form {
case box(Double, Double)
case ball(Double)
case polygon([Vector2])
}
final class Look {
let form: Form
let color: Color
init(_ form: Form, _ color: Color) { self.form = form; self.color = color }
}
/// Random picks for the leaves.
let palette: [Color] = [
Color(red: 0.95, green: 0.49, blue: 0.34), Color(red: 0.97, green: 0.71, blue: 0.30),
Color(red: 0.45, green: 0.77, blue: 0.71), Color(red: 0.55, green: 0.57, blue: 0.88),
Color(red: 0.88, green: 0.53, blue: 0.70), Color(red: 0.62, green: 0.80, blue: 0.42)
]
let crateColor = Color(red: 0.62, green: 0.50, blue: 0.38)
let paddleColor = Color(red: 0.55, green: 0.57, blue: 0.88)
let restColor = Color(white: 0.55)
let tautColor = Color(red: 0.95, green: 0.49, blue: 0.34)
let slackColor = Color(red: 0.45, green: 0.77, blue: 0.71)
/// Where the turnstile hinges (also anchors its label).
var hubPoint: Vector2 { Vector2(width * 0.64, height * 0.44) }
/// Where the cloth hangs.
var polePoint: Vector2 { Vector2(width * 0.10, height * 0.10) }
override func setup() {
world.gravity = Vector2(0, 700) // light enough for the wind to matter
world.restitution = 0.25
world.unitsPerMeter = unitsPerMeter
world.bounds = bounds
build()
noStroke()
}
func build() {
kicks.removeAll()
// Leaves: light shapes the wind can carry.
for _ in 0 ..< 40 {
let at = Vector2(random(width * 0.06, width * 0.94),
random(height * 0.10, height * 0.72))
let form = leafForm()
let body = world.addBody(collider(for: form), at: at,
density: 0.2, friction: 0.4,
restitution: random(0.1, 0.35))
body.angle = random(.tau)
body.angularVelocity = random(-3, 3)
body.userData = Look(form, palette[Int(random(Double(palette.count)))])
}
// Crates: the same wind and the same kicks, many times the mass.
for i in 0 ..< 4 {
let w = random(96, 132) * scale
let h = random(72, 104) * scale
let body = world.addBody(.box(width: w, height: h),
at: Vector2(width * (0.2 + 0.2 * Double(i)), height * 0.86),
density: 1.2, friction: 0.6, restitution: 0.05)
body.userData = Look(.box(w, h), crateColor)
}
// The turnstile: a paddle on a free hinge, spun up by applyTorque each
// frame until it reaches cruising speed.
let hub = world.addBody(.circle(radius: 12 * scale), at: hubPoint, kind: .static)
hub.userData = Look(.ball(12 * scale), Color(white: 0.45))
let blade = world.addBody(.box(width: 330 * scale, height: 20 * scale), at: hubPoint,
density: 0.8, friction: 0.3)
blade.userData = Look(.box(330 * scale, 20 * scale), paddleColor)
world.connect(hub, blade, .revolute(at: hubPoint))
paddle = blade
// The sweeper: kinematic, so forces and contacts never move it; it
// goes only where the velocity set in draw() sends it.
let broom = world.addBody(.box(width: 46 * scale, height: 180 * scale),
at: Vector2(width / 2, height - 100 * scale),
kind: .kinematic, friction: 0.8)
broom.userData = Look(.box(46 * scale, 180 * scale), Color(white: 0.95))
sweeper = broom
buildCloth()
}
/// A lattice of particles pinned along its pole edge, wired with structural
/// and shear springs. The wind blows it through `Particle.applyForce`; the
/// springs are drawn tinted by `Spring.strain`.
func buildCloth() {
let columnCount = 13, rowCount = 8
let spacing = width * 0.015
var grid: [[Particle]] = []
for r in 0 ..< rowCount {
var row: [Particle] = []
for c in 0 ..< columnCount {
let p = world.addParticle(at: polePoint + Vector2(Double(c) * spacing,
Double(r) * spacing))
if c == 0 { p.pin() } // the pole edge holds still
row.append(p)
}
grid.append(row)
}
for r in 0 ..< rowCount {
for c in 0 ..< columnCount {
if c + 1 < columnCount { world.connect(grid[r][c], grid[r][c + 1], stiffness: 0.95) }
if r + 1 < rowCount { world.connect(grid[r][c], grid[r + 1][c], stiffness: 0.95) }
if c + 1 < columnCount, r + 1 < rowCount {
world.connect(grid[r][c], grid[r + 1][c + 1], stiffness: 0.4)
world.connect(grid[r][c + 1], grid[r + 1][c], stiffness: 0.4)
}
}
}
}
/// A random light shape, sized in canvas points.
func leafForm() -> Form {
let s = random(11, 20) * scale
switch Int(random(3)) {
case 0: return .box(s * 2, random(0.5, 0.9) * s * 1.6)
case 1: return .ball(s)
default:
let sides = Int(random(3, 5.99))
let points = (0 ..< sides).map { i -> Vector2 in
let angle = Double(i) / Double(sides) * .tau + random(-0.2, 0.2)
return Vector2(angle: angle, length: s * random(0.8, 1.15))
}
return .polygon(points)
}
}
func collider(for form: Form) -> Collider {
switch form {
case .box(let w, let h): return .box(width: w, height: h)
case .ball(let r): return .circle(radius: r)
case .polygon(let points): return .polygon(points)
}
}
/// The shared wind: a unit-scale vector that gusts and lulls, with a slight
/// lift so the leaves loft instead of only piling. The prevailing direction
/// turns right around on a slower cycle, so the yard is swept both ways
/// rather than emptying against one wall and staying there.
func gust() -> Vector2 {
let swell = Swift.max(0, sin(time * 0.5) + 0.4 * sin(time * 1.7)) / 1.4
let strength = 0.3 + 0.7 * swell
let across = strength * sin(time * 0.12)
return Vector2(across, -0.18 * strength)
}
/// Kick the nearest dynamic body away from the click. The impulse is a raw
/// momentum change, not scaled by mass, so a leaf flies and a crate nudges.
override func mousePressed() {
let cursor = mouse
var nearest: Body?
var nearestDistance = Double.infinity
for body in world.bodies where body.kind == .dynamic {
let d = body.position.distance(to: cursor)
if d < nearestDistance { nearestDistance = d; nearest = body }
}
guard let body = nearest else { return }
let offset = body.position - cursor
let aim = offset.length > 0.001 ? offset / offset.length : Vector2(0, -1)
// Forces and impulses push a mass, and drawn area stands in for mass
// here, so the magnitudes carry scale squared to keep their feel at
// export scales.
body.applyImpulse(aim * (22 * scale * scale))
kicks.append(Kick(at: cursor, born: time))
}
override func keyPressed() {
if key == " " { reset() }
}
/// Rebuild everything under the current parameter values. Reassigning `bounds`
/// rebuilds the walls, which are also sized through `unitsPerMeter`.
func reset() {
world.removeAll()
world.unitsPerMeter = unitsPerMeter
world.bounds = bounds
paddle = nil
sweeper = nil
build()
}
override func draw() {
background(Color(white: 0.12))
// The parameter remaps points onto the solver's meters; every wall and mass
// is sized through the mapping, so a change rebuilds the scene.
if world.unitsPerMeter != unitsPerMeter { reset() }
// The wind: a steady per-frame force on every dynamic body, and on
// every cloth particle (whose masses are 1, so the force is smaller).
let wind = gust() * windStrength
for body in world.bodies where body.kind == .dynamic {
body.applyForce(wind * (10 * scale * scale))
}
for particle in world.particles {
particle.applyForce(wind * (850 * scale))
}
// Spin the paddle up to cruising speed, then let it coast.
if let paddle, paddle.angularVelocity < 3.4 {
paddle.applyTorque(6)
}
driveSweeper()
world.advance(by: deltaTime)
drawBodies()
drawCloth()
drawKicks()
drawCaptions()
}
/// Steer the kinematic sweeper along its patrol by velocity alone: aim it
/// at a moving target so it tracks smoothly without drifting off its lane.
func driveSweeper() {
guard let sweeper else { return }
let target = Vector2(width / 2 + sin(time * 0.5) * width * 0.34,
height - 100 * scale)
sweeper.velocity = (target - sweeper.position) * 6
sweeper.angularVelocity = 0
}
func drawBodies() {
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 .box(let w, let h):
drawRect(center: .zero, width: w, height: h, cornerRadius: 3 * scale)
case .ball(let r):
drawCircle(center: .zero, radius: r)
case .polygon(let points):
drawPolygon(points)
}
}
}
}
func drawCloth() {
// The pole the pinned column hangs from.
stroke(Color(white: 0.5))
strokeWeight(6 * scale)
drawLine(polePoint + Vector2(0, -30 * scale), polePoint + Vector2(0, 190 * scale))
// Springs tinted by strain: teal compressed, coral stretched.
strokeWeight(2 * scale)
for spring in world.springs {
let t = Swift.max(-1, Swift.min(1, spring.strain / 0.12))
let color = t >= 0
? restColor.mixed(with: tautColor, t)
: restColor.mixed(with: slackColor, -t)
stroke(color)
drawLine(spring.a.position, spring.b.position)
}
noStroke()
}
func drawKicks() {
kicks.removeAll { time - $0.born > 0.5 }
noFill()
for kick in kicks {
let age = (time - kick.born) / 0.5
stroke(Color(white: 1, alpha: (1 - age) * 0.6))
strokeWeight(2.5 * scale)
drawCircle(center: kick.at, radius: (18 + age * 240) * scale)
}
noStroke()
}
func drawCaptions() {
withState {
textFont(.system)
textAlign(.center)
noStroke()
// The parameter caption: what unitsPerMeter changes.
textSize(width * 0.0165)
fill(Color(white: 0.7))
drawText("unitsPerMeter \(Int(world.unitsPerMeter.rounded())): raise it and the shapes read lighter, so the same wind carries them further",
at: Vector2(width / 2, height * 0.045))
// Station labels.
textSize(width * 0.015)
fill(Color(white: 0.55))
drawText("applyTorque", at: hubPoint + Vector2(0, 200 * scale))
drawText("pin() + strain", at: polePoint + Vector2(width * 0.06, 290 * scale))
if let sweeper {
drawText(".kinematic", at: sweeper.position + Vector2(0, -115 * scale))
}
}
}
}