What the balloon is
A hot-air balloon stands in the cottage's backyard, 24 m behind the back wall: an envelope about 20 m from its mouth to its crown, painted in 24 gores with bands of chevrons, a short skirt below the mouth, and under it a burner on a dark steel frame, a square ring 2.75 m above the basket's floor holding one copper-wound burner can over the middle. Below hangs a wicker basket, 1.9 m square inside, its walls 1.1 m high, open at the front so you can walk straight in. Inside, on the back rail, is the launch button: a red cap 14 cm across. Even on the ground the burner fires: a roaring flame 3 s on in every 4, with a small pilot light that never goes out.
Click the cap and it dips 2 cm for a quarter of a second. If the balloon is on the ground, it takes off on the ride: 170 seconds up through twelve points, straight up 25 m clear of the pines, out over the lawn and the east lowland, to its highest point 150 m up over the mouth of the cove, round over the west coast and the west lowland, and down behind the cottage onto its spot. It starts from rest and speeds up over the first 20 s, flies at one speed, and slows to rest over the last 20 s. As it goes it turns, so that a rider standing at the open front faces a chosen view at each point: across the lowland, up the path to the hill, the whole island from the top. The burner keeps time with the ride: 3.2 s in every 5 while it climbs, a short 1.5 s burst every 20 s after the top, and silence for the last 45 s as it sinks to land. Whoever stands in the basket is carried. A click during the ride dips the cap but changes nothing else.
The listing
part balloon, at: (-10, land.height((-10, 0, 300)), 300) // the backyard, 24 m behind the cottage's back wall
field seed : count = 1783 // every random draw in it: the Montgolfiers' year
field launched: moment = never, mutable: true, lives: server // when the last ride began: the one stored value
field riding : bool = false
derive(riding, launched != never && now(sim) - launched < ride.length())
// ===== Fixed values its shapes read by name (common: one value, never passed) =====
field gores : count = 24, common: true // a multiple of 4: the cables gather at the frame's corners
field fat : number = 1.04, common: true // the envelope's width, 1 as photographed
field bottom : meters = -5.58 m, common: true // the rig in meters from the mouth: the basket's bottom,
field floorTop : meters = -5.46 m, common: true // its floor's top (12 cm thick),
field wallTop : meters = floorTop + 1.1 m, common: true // its walls' top,
field rimTop : meters = -4.30 m, common: true // the leather roll's top,
field inside : meters = 0.95 m, common: true // half its inside's width (1.9 m square),
field outside : meters = 1.02 m, common: true // half its outside's (walls 7 cm thick),
field ringY : meters = -2.71 m, common: true // the burner's square ring (2.75 m over the floor),
field ring : meters = 0.55 m, common: true // the ring's corners, out along each axis,
field nozzle : meters = ringY + 0.55 m, common: true // and the burner can's nozzle
field meterUnit: number = 1 / 9, common: true // a meter in the envelope's own units (it is drawn 9 m to a unit)
field panel : List[color] = [srgbFraction(0.98, 0.86, 0.06), srgbFraction(0.02, 0.60, 0.58),
srgbFraction(0.16, 0.34, 0.78), srgbFraction(0.13, 0.15, 0.40),
srgbFraction(0.02, 0.60, 0.58), srgbFraction(0.98, 0.86, 0.06)],
common: true // the chevrons' six: yellow, teal, blue, navy, teal, yellow
part paints // the balloon's own materials
part envelopePaint: [shiny(white, highlight: 0.12, shininess: 12),
paintedBy(painted(size: (gores * 32 texels, 1024 texels), samples: 2, colorAt: chevronAt))]
part skirtPaint : plain(color(0.405, 0.005, 0.0032)) // the red paint's numbers squared and halved
part cableBlack : plain(color(0.04, 0.04, 0.04))
part charcoalSteel: shiny(srgb(44, 46, 50), highlight: 0.45, shininess: 30)
part oldCopper : shiny(srgb(150, 68, 36), highlight: 0.9, shininess: 40)
part brass : shiny(srgb(176, 136, 44), highlight: 0.9, shininess: 50)
part wicker : [plain(white), paintedBy(painted(size: (256 texels, 256 texels), wraps: true, colorAt: wickerAt))]
part darkLeather : shiny(srgb(62, 36, 22), highlight: 0.15, shininess: 10)
part buttonRed : shiny(srgb(210, 30, 24), highlight: 0.6, shininess: 40)
// ===== The paints: functions of a texel's place =====
fn tri(x: number) number { abs(2 * fract(x) - 1) } // a triangle wave, 1 to 0 to 1
fn chevronAt(uv: (number, number)) color { // u round the envelope, v up from the mouth
let g = floor(uv.0 * gores); // which gore
let paint = if uv.1 < 0.3 { panel[g % 3] } // plain gores at the foot ...
else if uv.1 > 0.84 { panel[(g + 1) % 3] } // ... and at the crown; between, bands dipping
else { panel[[3, 4, 3, 5][floor((uv.1 - 0.3) * 8 + 1.4 * tri(uv.0 * gores / 4 + 0.5)) % 4]] };
if fract(uv.0 * gores) < 1 / 16 { mix(black, paint, 0.75, in: srgb) } else { paint } // the load tape, darker
}
fn wickerAt(uv: (number, number)) color { // strands over and under, 16 cells a tile each way
let cx = uv.0 * 16;
let cy = uv.1 * 16;
let cell = (floor(cx), floor(cy));
let strand = if (cell.0 + cell.1) % 2 == 0 { sin(180 deg * fract(cy)) } else { sin(180 deg * fract(cx)) };
let shade = (0.45 + 0.55 * strand) * (1 + 0.12 * (2 * hash(seed, cell) - 1)); // each cell jittered
srgbFraction(0.80 * shade, 0.52 * shade, 0.26 * shade) // the wicker's color, shaded on its sRGB numbers
}
// ===== The shapes: the envelope's in its own units, the rig's in meters =====
fn balloonEnvelope() shape { // 24 gores, in units of 9 m
let lathed = lathe(profile: outline.bezier(controls: [(0.24, 0), (0.95 * fat, 0.62), (1.5 * fat, 2.3), (0, 2.2)],
segments: 24),
axis: up, angle: 360 deg, steps: gores * 4);
let onTape = fn(p: surfacePoint) bool { fract(p.along * gores + 0.001) < 0.002 && p.around < 0.999 };
let taped = crease(lathed, edges: where(onTape), sharpness: 2); // the load tapes
let bulged = displace(taped, by: fn(p: surfacePoint) meters {
0.045 * length((p.position.x, 0, p.position.z)) * sin(180 deg * fract(p.along * gores))
});
subdivide(bulged, levels: 2)
}
fn skirtCone() shape { // below the mouth, narrower at its foot
solidify(lathe(profile: [(0.2, -0.15), (0.225, -0.07), (0.24, 0)], steps: gores * 2),
thickness: 0.006, offset: 0, even: false, rim: true)
}
fn cableSet() shape { // from each tape's foot to the frame's nearest corner, in meters
let corner = (length((ring, 0 m, ring)), ringY, 0); // a corner of the ring, turned onto +x
let tapes = List[shape]() <-
each k in (1 - gores / 8) ..= gores / 8 yield // six tapes: a quarter of the 24
sweep(profile: outline.polygon(sides: 3, radius: 1.44 cm), // the skirt's foot: 0.2 and -0.15 of a unit
spine: spine.line(from: (1.8 m * cos(360 deg * k / gores), -1.35 m, -1.8 m * sin(360 deg * k / gores)),
to: corner),
caps: false);
let quarter = join(tapes);
turn(arrayRadial(quarter, count: 4, axis: up), by: 45 deg, about: up) // four corners, on the diagonals
}
fn tube(from: point, to: point, radius: meters) shape { // a straight six-sided tube
sweep(profile: outline.polygon(sides: 6, radius: radius), spine: spine.line(from: from, to: to))
}
fn burnerRing() shape { // the frame, in meters
let corners = [(ring, ringY, ring), (ring, ringY, -ring), (-ring, ringY, -ring), (-ring, ringY, ring)];
let pieces = List[shape]() <-
sweep(profile: outline.polygon(sides: 6, radius: 3 cm), spine: spine.points(points: corners, closed: true),
caps: false), // the square ring
each (sx, sz) in [(1, 1), (1, -1), (-1, -1), (-1, 1)] yield // four uprights, rim corner to ring corner
tube(from: (sx * (outside - 5 cm), rimTop, sz * (outside - 5 cm)), to: (sx * ring, ringY, sz * ring),
radius: 3.5 cm),
each (dx, dz) in [(1, 0), (-1, 0), (0, 1), (0, -1)] yield // four spokes in to the can
tube(from: (dx * 0.24, ringY, dz * 0.24), to: (dx * ring, ringY, dz * ring), radius: 3 cm),
lathe(profile: [(0, ringY - 9 cm), (0.26, ringY - 9 cm), (0.26, ringY + 42 cm),
(0.2, ringY + 50 cm), (0.17, nozzle), (0, nozzle)], steps: 24), // the one burner can
box(x: -0.62 ..= -0.42, y: -4.42 ..= -4.24, z: -inside ..= -inside + 12 cm); // the button's mount
join(pieces)
}
fn wickerBasket() shape { // floor, side walls, back; open at the front (+z)
let walls = join([
box(x: -outside ..= outside, y: bottom ..= floorTop, z: -outside ..= outside), // the floor
box(x: -outside ..= -inside, y: bottom ..= wallTop, z: -outside ..= outside), // left
box(x: inside ..= outside, y: bottom ..= wallTop, z: -outside ..= outside), // right
box(x: -inside ..= inside, y: bottom ..= wallTop, z: -outside ..= -inside)]); // back
uvBox(walls, size: 0.4 m) // the wicker repeats every 40 cm
}
fn buttonCap() shape { // a red disc 14 cm across, flat-shaded
creaseAngle(lathe(profile: [(0, 0), (7 cm, 0), (7 cm, 3.5 cm), (0, 3.5 cm)], steps: 32), angle: 0 deg)
}
// burnerCoil (spine.helix, mirrored), valveBlock and basketRim are written the same way, not shown
// ===== The moving tree: the envelope is its root, 9 m a unit, turned 0.3 rad (17.2 deg) as it stands =====
part envelope: balloonEnvelope(), material: paints.envelopePaint,
at: (0, -bottom, 0), rotation: rotation(0.3 rad), scale: 9
part skirt : skirtCone(), material: paints.skirtPaint // no at: at the envelope's origin
part cables: cableSet(), material: paints.cableBlack, at: (0, 0, 0), unscaled: true // in true meters
part burner: burnerRing(), material: paints.charcoalSteel, at: (0, 0, 0), unscaled: true // in true meters
part coil : burnerCoil(), material: paints.oldCopper
part valves: valveBlock(), material: paints.brass
part flame : effect(vfx.layers([core, tongue, embers]), seed: seed), at: (0, nozzle, 0)
part pilot : effect(vfx.layers([pilot]), seed: seed, start: sim(0 s), stop: never), at: (5 cm, nozzle + 1 cm, 0)
part roar : audiomixer.fire(crackle: 0.4, roar: 1, heardTo: 140 m, seed: 31, bus: effects, refDistance: 3 m,
rolloff: 1.0, priority: 3, attack: 0.1 s, release: 0.5 s), at: (0, nozzle, 0)
part basket: scale(wickerBasket(), by: meterUnit), material: paints.wicker // 1.9 m square inside, open at the front; scaled with the envelope
platform(self, floor: floorTop * meterUnit, // the numbers are in the envelope's units: meters times unit
standOn: box(x: -inside * meterUnit ..= inside * meterUnit, z: -inside * meterUnit ..= outside * meterUnit),
walls: (wallTop - floorTop) * meterUnit, on: [left, right, back])
area(self, box(x: -inside * meterUnit ..= inside * meterUnit, y: (floorTop - 0.1 m) * meterUnit ..= (floorTop + 1.9 m) * meterUnit,
z: -inside * meterUnit ..= outside * meterUnit)) // who steps in
part rim: scale(basketRim(), by: meterUnit), material: paints.darkLeather
part cap: scale(buttonCap(), by: meterUnit), material: paints.buttonRed,
at: (-0.52 m, -4.24 m, -inside + 6 cm) * meterUnit // on the mount, at the back rail
// the course: meters from the balloon's rest place (east, up, south), in the balloon's frame
field course: spine = spine.through(points: [
(0, 0, 0), // on its spot
(0, 25, 0), // straight up, clear of the pines
(40, 60, 50), // over the lawn
(120, 110, 140), // the east lowland
(70, 140, 260), // over the cove's east shore
(-20, 150, 350), // the top
(-220, 140, 280), // the west coast
(-250, 130, 120), // the west lowland
(-150, 100, 0), // north-west
(-50, 50, -30), // coming down behind the cottage
(0, 25, 0), // over the spot
(0, 0, 0)], // landed
pointNames: [(summit, 5)]) // the sixth point, counting from 0, is called summit
// the view a rider should face at each point, a flat point (x, z) in meters from the spot, or None: face as it stands
field views: List[?(meters, meters)] = [None, None, (-190, 200), (-190, 0), (-110, -50), (-10, -300),
(110, -50), (210, 220), (110, 260), (0, 150), None, None]
field ride: animator = along(envelope.place, spine: course, over: sim(170 s),
ease: ends(sim(20 s)), face: byPoint(views))
// the launch button: every click dips the cap; a click on the ground starts the ride
on(clicked(envelope.basket.cap), {
start(keys(envelope.basket.cap.offset,
[key(sim(0 s), (0, -2 cm, 0)), key(sim(0.25 s), (0, 0, 0))], ease: steps(1)));
if !riding {
set(launched, now(sim));
start(ride);
}
})
// the burner's burst in force at a moment: a few named steps, one stored moment
fn burst(t: moment) span {
let landed = launched + ride.length(); // 170 s after the launch
let reachedSummit = launched + ride.timeAt(along: course.pointNamed(summit)) // about 70 s in
let quiet = landed - sim(45 s);
if launched == never {
pulse(from: start(sim), every: sim(4 s), for: sim(3 s), at: t) // on the ground, never flown: counted from the world's start
} else if t >= landed {
pulse(from: landed, every: sim(4 s), for: sim(3 s), at: t) // on the ground again
} else if t < reachedSummit {
pulse(from: launched, every: sim(5 s), for: sim(3.2 s), at: t) // climbing
} else if t < quiet {
pulse(from: reachedSummit, every: sim(20 s), for: sim(1.5 s), at: t) // cruising
} else {
until(pulse(from: reachedSummit, every: sim(20 s), for: sim(1.5 s), at: quiet), quiet) // sinking
}
}
derive(envelope.burner.flame.firing, burst(now(sim)).contains(now(sim)))
derive(envelope.burner.roar.gain, if envelope.burner.flame.firing { 1.5 } else { 0 })
Walking through it
One part, one stored moment
balloon is a single part placed at its spot in the world's frame. Of all its fields, only launched is ever written: the moment of the last launch, never until the first, kept on the server (Distribution). Everything else that changes is worked out from it and the clock. riding is a subscription: true while less than the ride's length has passed since the launch; it is never stored and never set, so to end a ride early the content would change launched, not riding. never is the empty moment (Values: moments), tested first because a moment minus never has no meaning.
The balloon holds its own materials in a frameless child, paints, named paints.wicker from inside the balloon; they last as long as the balloon does. The numbers are sRGB colors with each Blinn-Phong highlight's strength and tightness (Materials: the kinds). The envelope and the wicker are painted by the balloon's own functions (Textures: painting an image): chevronAt gives each of the 768 by 1024 texels of the envelope's image (32 across each gore) the mean of 2 by 2 samples of the chevron pattern, read through the lathe's texture coordinates, which run once round and once up the outline by length; wickerAt paints a tiling image (wraps: true) of 256 by 256 texels, one sample each, 16 strands each way, over and under, each cell's shade jittered by hash(seed, cell), a fixed number for the cell, defined to the bit so every machine draws the same weave (Expressions: what hash is); the basket's uvBox repeats it every 40 cm. The paints are the sRGB fractions of the balloon's paint table, srgbFraction(...), and both images darken by a share of the sRGB numbers, as a paint program does: the tape with mix(black, paint, 0.75, in: srgb), the wicker by multiplying its sRGB fractions by the shade. Their samples are averaged on the sRGB numbers too (Values: colors; Textures: painting an image). The skirt's color is the red paint's numbers squared and halved.
The envelope's shape
The envelope is a construction pipeline: a shape function with no inputs, fn balloonEnvelope() shape { ... }, written as a block, each step's product named and handed to the next, each changing what the last made. It lathes a curve from the mouth to the crown in 96 steps round, four to each of 24 gores. It creases the load tapes: where(onTape) chooses the edges up each gore's side, where the lathe's along (the fraction of the turn) is a whole number of 24ths, short of the crown, and crease keeps them sharp for two levels of smoothing, so a valley stays at each. It pushes each gore out between its tapes by up to 0.045 of its distance from the axis, reading along again. It subdivides twice. The order is the meaning: the gores are bulged on the coarse cage so the smoothing rounds each one between sharp tapes. Every name in it is in the Appendix: shape tools; the browser cuts the result into triangles when the part draws it (Sculpting).
The moving tree
The envelope part is the root of everything that flies: the skirt, the cables, the burner and everything on it, the basket and its rim and cap are indented under it, so they are placed in its frame and move when it moves (Frames: a path is a chain of frames). It stands 5.58 m up in the balloon's frame (-bottom), so the basket's bottom is on the ground, and is drawn 9 m to a unit and turned 0.3 rad (about 17°) as it stands, so the basket's open front faces south-south-east down the lawn route; rotation: rotation(0.3 rad) and scale: 9 are options of the placing (rotation(angle, about: direction = up) turns about up unless told otherwise), and the scale is a frame scale: it shares the one built shape and rebuilds nothing (Frames: a turn and a scale; Construction: two kinds of scale).
Two sets of units. Everything inside the envelope is in the envelope's own units, before its scale: the envelope's outline is about 1 unit wide, and the skirt, at the envelope's origin with no at, is drawn in units too (Construction: units in a scaled part). The rig was measured in meters (a basket 1.9 m square, a floor 5.46 m below the mouth). The burner and the cables are placed with unscaled: true: their own units are true meters, whatever the envelope's scale, while they still turn and fly with it (Frames: a turn and a scale), so their shapes and the flame's place are written in meters, as measured. The basket is not: it is scaled with the envelope, like the skirt, and so is everything under it. Its shape is the measured one scaled by meterUnit (a ninth), and its platform, its area and the cap's place are written in the envelope's units, each meter number times meterUnit. The meters come out the same. The cables start at the skirt's foot, 0.2 and 0.15 of a unit out and down, which is 1.8 m and 1.35 m.
Fixed values, read by name. The gores, the width and the rig's measurements are fixed common fields of the balloon, and its shapes read them without being passed them (Construction: what a shape reads). burnerRing calls the balloon's own shape function tube eight times, as it would a tool. The envelope's bulge is 0.045 of a point's distance from the axis, length((x, 0, z)), which is a length: units follow algebra (Values). The cap is shaded flat, with no smoothing across any edge: creaseAngle(..., angle: 0 deg).
The burner holds an effect and a sound as parts. flame is three layers of particles (a short bright core, a long orange tongue, embers) with the balloon's seed; pilot starts at once and its stop never comes, a part of its own so that a burst can end without putting it out; roar is the audio mixer's fire recipe, placed at the nozzle, so it follows the balloon as it flies. The flame, the pilot and the roar stand at nozzle, the can's top, in the burner's meters. The cap stands on its mount 0.52 m to a rider's right as they face out of the front, 6 cm in from the back wall.
Riders carried by a platform
Two calls in the basket's body, each naming the part as self, say what it is, for the engines (Physics). platform(self, ...): its floor's top at floorTop, 12 cm above its bottom, holds up whoever stands in the rectangle standOn, inside the walls from the back wall out to the open front's edge; its left, right and back walls, 1.1 m high (wallTop - floorTop), stop them; and when the basket moves they are carried with it (Physics: platforms). area(self, box(...)): a box the width of its inside, from 0.1 m under the floor's top to 1.9 m over it, registered once with the basket and moving with it; who steps in is told to the world, and occupied(basket) reads it as a value (Physics: areas). Carrying is execution state, not content: a rider in the basket keeps their own path, and the engine moves them with the floor (Parts: a path is identity, not location). Nothing in the listing moves a rider; the platform does.
The course and the ride
course is a field holding a spine, a line in space (Appendix: shape tools): spine.through makes a smooth centripetal Catmull-Rom curve (a curve through every point that never overshoots into a loop, however unevenly the points are spaced) through the twelve points. The points are offsets from the envelope's rest place, in the balloon's frame, which is the envelope's parent's: the first is (0, 0, 0), the rest place itself, and the ride adds each offset to it. Points 1 and 10 are 25 m straight over the first, so the balloon leaves and lands straight up and down. The highest point, 150 m up over the cove's mouth, is called summit in pointNames so the burner can ask when it is reached: course.pointNamed(summit) is its distance along the course.
views gives, for each of the twelve points, a flat point (x, z, in meters from the spot) that a rider should face, or None: face as the balloon stands. The first, second, tenth and eleventh points are None; the other eight name a view.
ride is one animator, built once and held in a field of type animator: along carries envelope.place along the course over 170 s of simulation time. It moves at one speed by arc length, so the speed is even however far apart the points are; ease: ends(sim(20 s)) eases only the ends, rising from rest over the first 20 s and falling to rest over the last 20 s, the ramps fixed durations so that a longer ride lengthens only the steady part; face: byPoint(views) turns the envelope towards each point's view, each heading taken the short way round from the one before, so over the whole ride it turns once round. The heading is a second value the same position drives, not a second animator (Animators: along a curve). The ride is pure: its value at any moment is a formula of the time since it started, so a visitor who arrives mid-ride sees the balloon where it is.
The launch button
The click handler is an event with an acting block in braces (Blocks). Any visitor may click it, from any distance: the click picks the nearest part along the eye's ray, and the basket's walls hide the cap from outside, so it is reachable only from inside (Events: clicked). Its first act dips the cap: a keys animator on the cap's offset (Frames: a part's offset) of two keys, down 2 cm at once, up again 0.25 s later, with ease: steps(1) so it jumps rather than glides (Actions: a timed step). A second click during the dip starts a new one, which takes over, so the cap stays down 0.25 s after the latest click. Its second act is guarded by Cone's if (Control flow): only on the ground does a click set launched and start the ride. "The newest takes over" would restart the ride from wherever it had got to; the guard is how this exception is written (Animators: open questions).
The burner's schedule
burst is a function on the balloon, a value block in a few named steps: when the ride lands (launch plus the ride's length), when it reaches the summit (launch plus the time the ride reaches the point named summit, about 70 s in), when the quiet begins (45 s before landing). Then one if ... else if ... else chooses the rhythm: on the ground 3 s on in every 4, counted from the moment the world began, start(sim); climbing 3.2 s in every 5; after the summit 1.5 s in every 20; in the last 45 s nothing, a burst in progress cut short where the quiet begins. Each choice is a span, the burst of the period that holds the moment (Blocks: the calls it uses). The names are summit (a point of the course) and reachedSummit (a moment): one name for both would read as a moment defined from itself, which the hiding rule refuses (Blocks: local names). ride.length() and ride.timeAt(along: ...) ask the animator questions (Animators: asking an animator); moving a point of the course moves the schedule with it.
Two subscriptions bind the result: the flame fires while the burst in force holds the present moment, and the roar's gain is 1.5 exactly when the flame burns and 0 otherwise, swelling with a short attack and dying away with a longer release. Each burst's particles are seeded from the balloon's seed and the moment the burst starts, so every machine draws the same burst and no two bursts look alike (Effects). The whole schedule is a formula of the clock and one stored moment: nothing has to be sent while the balloon flies.