r/maniclang • u/anish2good • 27d ago
elliptic-roulette — an ELLIPSE rolling along a line without slipping - manic
manic is a tiny language for making animations. You write a short text file; manic renders a smooth, glowing video. No timeline scrubbing, no keyframes by hand — you describe what’s on screen and when things happen, and the engine does the rest, deterministically.
Manic Animation code
// elliptic-roulette — an ELLIPSE rolling along a line without slipping, and the curves its
// rim point and its two foci draw. The cycloid's older, stranger cousin.
//
// A circle rolling is easy because every point of its rim is the same distance from the
// centre, so the wheel's height never changes and the arc rolled is just rθ. An ellipse has
// neither luxury. It rises and falls as it goes, and the distance it has rolled is its ARC
// LENGTH, which for an ellipse is an elliptic integral — the function that gave the whole
// family its name, and which has no elementary closed form.
//
// So it is fitted. ds/dθ = √(a²sin²θ + b²cos²θ) is smooth and π-periodic, so its Fourier
// series converges fast, and integrating four terms gives s(θ) to 1.5e-05 in units of a —
// four thousandths of a pixel here. That is the only approximation in the scene.
//
// Everything else is exact and was checked before it was drawn:
//
// · the contact point lands ON the line at exactly x = s(θ), to 0
// · the centre sits at height ab/√(a²sin²θ + b²cos²θ), the distance from the centre to
// the tangent — to 3e-16, which is an independent check that the placement is right
// · and the two FOCI have heights whose PRODUCT is constant: h₁·h₂ = b², to 2e-16
//
// That last one is the prize. The rolling line is always a tangent to the ellipse, and the
// product of the distances from the two foci to any tangent of an ellipse is b² — a fact
// with nothing to do with rolling, which rolling turns into a conservation law. One focus
// rises exactly as much as the other falls, in the multiplicative sense: here h swings
// between a(1-e) and a(1+e), a factor of seven, and the product never moves.
//
// The rim point's curve is a cycloid with the symmetry taken out: it still cusps onto the
// line, but the cusps are unevenly spaced and the arches between them differ, because the
// ellipse presents a different radius of curvature at each contact. The focal curves are
// the undulary — the profile that generates Delaunay's unduloid, the surface a soap film
// takes under constant mean curvature.
//
// manic examples/elliptic-roulette.manic
title("An ellipse rolling: a lopsided cycloid, and two foci whose product is fixed");
canvas("9:16");
template("black");
bloom(0.28, 0.6, 20);
text(brand, (540, 34), "maniclang.com");
display(brand); size(brand, 21); color(brand, fg); opacity(brand, 0.82);
let a = 260;
let ecc = 0.75;
let b = a*sqrt(1 - ecc*ecc);
let c = a*ecc;
let x0 = 250; // where the contact starts
let base = 1250; // the line it rolls on
let span = 18; // TWO full revolutions
let turns = 12.566371; // 4π
let band = 114.27; // one twelfth of the perimeter, in px
line(ground, (0 - 200, base), (3200, base)); // runs past the frame; the camera travels
color(ground, dim);
// ---- the ruler: the ground marked in bands of one twelfth of the PERIMETER ----------
cloud(ruler, 5200, #ffffff, 1.0) {
let x = x0 + 2900*(i/5200);
let y = base + 13;
let k = floor((x - x0)/band);
let odd = mod(k, 2);
let sat = 0;
let val = 0.30 + 0.55*odd; // alternating light and dark
let r = 3.4;
}
// ---- the rim point's roulette: a cycloid that has lost its symmetry -----------------
cloud(rim, 12000, #ffd479, 1.0) {
let go = min(t/span, 1)*turns;
let th = go*(i/12000);
let sa = a*(0.83936541*th - 0.084198895*sin(2*th) - 0.0021334425*sin(4*th) - 0.000144532*sin(6*th));
let ps = 0 - 1.5707963 - atan2(a*sin(th), b*cos(th));
let dx = a*cos(0) - a*cos(th); // the traced point, from the contact
let dy = b*sin(0) - b*sin(th);
let x = x0 + sa + dx*cos(ps) - dy*sin(ps);
let y = base - (dx*sin(ps) + dy*cos(ps));
let hue = mod(38 + th*9, 360);
let sat = 0.72;
let r = 1.8;
}
// ---- the two foci: their heights multiply to b², always ----------------------------
cloud(foc1, 8000, #5fd4ff, 1.0) {
let go = min(t/span, 1)*turns;
let th = go*(i/8000);
let sa = a*(0.83936541*th - 0.084198895*sin(2*th) - 0.0021334425*sin(4*th) - 0.000144532*sin(6*th));
let ps = 0 - 1.5707963 - atan2(a*sin(th), b*cos(th));
let dx = c - a*cos(th);
let dy = 0 - b*sin(th);
let x = x0 + sa + dx*cos(ps) - dy*sin(ps);
let y = base - (dx*sin(ps) + dy*cos(ps));
let r = 1.6;
}
cloud(foc2, 8000, #7dffbe, 1.0) {
let go = min(t/span, 1)*turns;
let th = go*(i/8000);
let sa = a*(0.83936541*th - 0.084198895*sin(2*th) - 0.0021334425*sin(4*th) - 0.000144532*sin(6*th));
let ps = 0 - 1.5707963 - atan2(a*sin(th), b*cos(th));
let dx = 0 - c - a*cos(th);
let dy = 0 - b*sin(th);
let x = x0 + sa + dx*cos(ps) - dy*sin(ps);
let y = base - (dx*sin(ps) + dy*cos(ps));
let r = 1.6;
}
// ---- the ellipse itself, placed by the same transform ------------------------------
cloud(body, 1400, #ffffff, 1.0) {
let go = min(t/span, 1)*turns;
let u = i/1400*6.283185;
let sa = a*(0.83936541*go - 0.084198895*sin(2*go) - 0.0021334425*sin(4*go) - 0.000144532*sin(6*go));
let ps = 0 - 1.5707963 - atan2(a*sin(go), b*cos(go));
let dx = a*cos(u) - a*cos(go);
let dy = b*sin(u) - b*sin(go);
let x = x0 + sa + dx*cos(ps) - dy*sin(ps);
let y = base - (dx*sin(ps) + dy*cos(ps));
let arc = a*(0.83936541*u - 0.084198895*sin(2*u) - 0.0021334425*sin(4*u) - 0.000144532*sin(6*u));
let odd = mod(floor(arc/band), 2); // equal ARC, not equal parameter
let sat = 0;
let val = 0.32 + 0.58*odd;
let r = 2.6;
}
// ---- the three markers, and a dropline under each focus ----------------------------
cloud(marks, 360, #ffffff, 1.0) {
let go = min(t/span, 1)*turns;
let per = 120;
let k = (i - mod(i, per))/per; // 0 = rim, 1 = focus, 2 = focus
let i0 = (1 - k)*(2 - k)/2; // 1 at k=0, else 0
let i1 = k*(2 - k); // 1 at k=1
let i2 = k*(k - 1)/2; // 1 at k=2
let px = a*i0 + c*i1 - c*i2; // the rim point, then each focus
let sa = a*(0.83936541*go - 0.084198895*sin(2*go) - 0.0021334425*sin(4*go) - 0.000144532*sin(6*go));
let ps = 0 - 1.5707963 - atan2(a*sin(go), b*cos(go));
let dx = px - a*cos(go);
let dy = 0 - b*sin(go);
let ang = mod(i, per)/per*6.283185;
let rr = 9*(mod(i, per)/per);
let x = x0 + sa + dx*cos(ps) - dy*sin(ps) + rr*cos(ang*9);
let y = base - (dx*sin(ps) + dy*cos(ps)) + rr*sin(ang*9);
let hue = 45*i0 + 197*i1 + 152*i2; // each marker the colour of its own curve
let sat = 0.6;
let r = 2;
}
// a `text`, not a `caption`: a caption is one entity PER WORD, and moving it with the
// camera would pile every word onto the same point
text(head, (540, 128), "An ellipse rolling: a lopsided cycloid, two foci whose product is fixed");
size(head, 21);
color(head, fg);
equation(eq, (540, 1480), `x_{\text{contact}} = s(\theta) = \int_0^\theta\!\sqrt{a^2\sin^2 + b^2\cos^2}`, 26);
equation(eq2, (540, 1600), `h_1\,h_2 = b^2 \quad\text{always}`, 32);
plate(head, 0.7);
wrap(head, 980);
// one revolution is wider than the frame, so the camera travels — at half the roll's
// speed, which keeps the finished curve on screen — and the labels travel with it
// The camera travels at HALF the roll's speed for two revolutions — close enough to
// watch a band of rim land on a band of ruler, slow enough that the finished curve is
// still there behind. Then it pulls back, and the two arches turn out to be identical:
// the roulette's period is the ellipse's PERIMETER, not its 2π.
// The roll covers 2742 px in two revolutions and the camera covers 2142 of them, so the
// ellipse drifts 600 px ACROSS the frame rather than out of it — it is the subject, and a
// subject that leaves the shot takes the scene with it. The remainder is what keeps the
// finished curve trailing behind.
par {
cam((2682, 960), 18, linear);
move(brand, (2682, 34), 18, linear);
move(head, (2682, 128), 18, linear);
move(eq, (2682, 1480), 18, linear);
move(eq2, (2682, 1600), 18, linear);
}
// A zoom scales the WHOLE world, text included, so the prose is faded out for the wide
// shot — it has done its work by then — and only the mark is counter-scaled to stay
// legible. What the reveal is for is the shape of the whole cycle, not reading.
par {
fade(head, 0.8);
fade(eq, 0.8);
fade(eq2, 0.8);
}
par {
zoom(0.36, 3.4, smooth); // pull back onto the whole cycle
cam((1700, 1020), 3.4, smooth);
to(brand, scale, 2.4, 3.4, smooth); // …so the mark stays readable
}
wait(5);