PackedPhotos
a contact sheet of the twenty bundled photographs at their own proportions, packed onto a page that turns square, wide, and tall (packRectangles(_:in:padding:)), the largest scale that fits found by halving; the photographs glide between layouts. Hold the mouse to drag the page's corner and watch it repack every frame
- Run
swift run --package-path Examples Example-Patterns-PackedPhotos- Imports
OllinSamplePhotos- Source
- Examples/Patterns/PackedPhotos/Sketch.swift
import Ollin
import OllinSamplePhotos
/// A contact sheet of twenty photographs at their own proportions, packed
/// onto a page that keeps changing shape. Each photograph is a rectangle of
/// its own width and height, a few of them given more room than the rest,
/// and `packRectangles(_:in:padding:)` fits them all onto the page without
/// overlapping, a gap apart. The packer keeps every size as given, so the
/// sheet is first scaled: the largest scale at which every photograph still
/// fits is found by halving the gap between a scale that fits and one that
/// does not, twenty times over.
///
/// The page turns square, wide, tall, and back every few seconds, and the
/// photographs glide from where one page put them to where the next does. Hold the mouse down to drag the page's corner: the sheet is packed
/// again every frame as you go, which is what the packer's speed buys.
///
/// ```sh
/// swift run Example-Patterns-PackedPhotos --export-loop /tmp/packed-photos.mp4
/// ```
///
/// The photographs are the framework's bundled samples (`OllinSamplePhotos`),
/// each credited by its photographer in that library.
@main
final class PackedPhotos: Sketch {
@Param(0 ... 40, icon: "square.split.2x2") var gap = 14.0
private var photos: [Image] = []
/// Each photograph's width and height, in units the scale multiplies.
private var sizes: [Vector2] = []
/// The shapes the page takes in turn, as width over height.
private let shapes = [1.0, 1.55, 0.7, 1.2]
private var pages: [Rectangle] = []
private var layouts: [RectanglePacking] = []
private var laidOutGap = -1.0
private let period = 16.0
override var loopDuration: Double? { period }
/// The photographs given more room than the rest.
private let featured: Set<String> = ["portrait", "marigolds", "boats", "dancer"]
override func setup() {
textFont(OutlineFont.system)
for sample in SamplePhoto.all {
let picture = sample.load()
let long = Double(max(picture.width, picture.height))
// A smaller copy is plenty for a sheet, and quicker to draw.
photos.append(picture.resized(width: Int(Double(picture.width) / long * 480),
height: Int(Double(picture.height) / long * 480)))
let weight = featured.contains(sample.name) ? 1.5 : 1.0
sizes.append(Vector2(Double(picture.width), Double(picture.height)) / long * weight)
}
let room = canvasRectangle.inset(by: .all(70 * scale))
pages = shapes.map { Rectangle(fitting: Vector2($0, 1), in: room) }
}
/// The sheet at the largest scale at which every photograph fits `page`.
private func sheet(on page: Rectangle) -> (packing: RectanglePacking, scale: Double) {
let padding = gap * scale
let area = sizes.reduce(0) { $0 + $1.x * $1.y }
// No scale fits more photograph than the page has room for.
var fits = 0.0, tooBig = (page.area / area).squareRoot()
for _ in 0 ..< 20 {
let trial = (fits + tooBig) / 2
if packRectangles(sizes.map { $0 * trial }, in: page, padding: padding).unplaced.isEmpty {
fits = trial
} else {
tooBig = trial
}
}
return (packRectangles(sizes.map { $0 * fits }, in: page, padding: padding), fits)
}
private func mixed(_ a: Rectangle, _ b: Rectangle, _ t: Double) -> Rectangle {
Rectangle(x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t),
width: lerp(a.width, b.width, t), height: lerp(a.height, b.height, t))
}
override func draw() {
background(Color(hex: 0x17181C))
if gap != laidOutGap {
layouts = pages.map { sheet(on: $0).packing }
laidOutGap = gap
}
// Which page, held for most of its turn and then gliding to the next.
let steps = Double(shapes.count)
let t = loopProgress(over: period) * steps
let k = Int(t) % shapes.count, next = (k + 1) % shapes.count
let glide = smoothstep(0.65, 1, t - Double(Int(t)))
let page: Rectangle
var places: [Rectangle]
let caption: RectanglePacking
if mouseIsPressed {
// The page's far corner follows the mouse, packed afresh.
let corner = canvasRectangle.inset(by: .all(30 * scale)).topLeft
page = Rectangle(x: corner.x, y: corner.y,
width: max(mouse.x - corner.x, 80 * scale), height: max(mouse.y - corner.y, 80 * scale))
caption = sheet(on: page).packing
places = caption.placed.map(\.rectangle)
} else {
page = mixed(pages[k], pages[next], glide)
caption = glide < 0.5 ? layouts[k] : layouts[next]
places = zip(layouts[k].placed, layouts[next].placed).map { mixed($0.rectangle, $1.rectangle, glide) }
}
noFill()
stroke(Color(hex: 0xF4EAD6).withAlpha(0.18))
strokeWeight(1.5 * scale)
drawRect(page.inset(by: .all(-12 * scale)))
noStroke()
for (i, place) in places.enumerated() where i < photos.count {
drawImage(photos[caption.placed[i].index], in: place)
}
fill(Color(hex: 0xF4EAD6).withAlpha(0.6))
textAlign(.center, .bottom)
textSize(20 * scale)
drawText("\(caption.placed.count) photographs on \(Int((caption.occupancy * 100).rounded()))% of the page",
width / 2, height - 14 * scale)
}
}