Chemistry — real molecules
molecule3 reads an MDL structure file — a PubChem Download → SDF, or a .mol from RDKit — and lays out its atoms and bonds as ordinary 3-D entities. Nothing about the shape is authored: the coordinates are the file’s, in Ångströms, so a bond angle on screen is the one in the conformer someone computed. Atoms are CPK/Jmol coloured and sized by covalent radius; the molecule turns about its own axis by default, because a shape only reads in three dimensions. structure is the flat half — the skeletal formula a chemist puts on paper, with carbons as bare vertices, OH and NH2 folded from the file’s explicit hydrogens, double bonds leaning into the ring, and wedge/hash bonds where the file records stereochemistry. See the chemistry guide.
Each block is the whole file — copy it into x.manic and run manic x.manic (live) or --record out (video).
chemistry-kit-promo
Five things a structure file can teach, in one scene and with no coordinate in the script: four molecules with four geometries (bent, pyramidal, tetrahedral, linear); the bond-order ladder, where a C-C, a C=C and a C≡C visibly tighten left to right because the files simply disagree about how far apart two carbons sit; salt against water, where the ionic pair has NO bond in its file and so no stick on the screen; caffeine in all four viewer styles; and cholesterol beside paclitaxel at 74 and 113 atoms.
// Molecules, from files that know their own shape
//
// Six things you can teach from a structure file, and not a coordinate in this script. Every atom
// position comes from a PubChem download, so each claim below is a measurement rather than a
// drawing: shape, bond order, isotope, ionic vs covalent, conformation, and scale.
//
// The molecules turn because that is the default. A shape only reads in three dimensions — flat,
// methane is four lines from a dot.
title("real molecules, real geometry");
canvas("16:9");
template("black");
text(brand, (640, 42), "maniclang.com");
display(brand);
size(brand, 20);
color(brand, dim);
camera3((0, -15, 8.5), (0, 0, 0), 40);
// ── 1. shape: four molecules, four geometries ──
molecule3(waterm, "asset:molecules/water.sdf", (-6.4, 0, 0), 1.5);
molecule3(ammonia, "asset:molecules/ammonia.sdf", (-2.1, 0, 0), 1.5);
molecule3(methane, "asset:molecules/methane.sdf", (2.2, 0, 0), 1.5);
molecule3(co2, "asset:molecules/carbon-dioxide.sdf", (6.6, 0, 0), 1.5);
hidden(waterm); hidden(ammonia); hidden(methane); hidden(co2);
text(h1, (640, 120), "the same four atoms count — four different shapes");
size(h1, 27); color(h1, cyan); hidden(h1);
text(n1, (196, 566), "H2O"); text(v1, (196, 604), "bent");
text(n2, (452, 566), "NH3"); text(v2, (452, 604), "pyramidal");
text(n3, (760, 566), "CH4"); text(v3, (760, 604), "tetrahedral");
text(n4, (1060, 566), "CO2"); text(v4, (1060, 604), "linear");
for i in 1..5 {
size(n{i}, 29); size(v{i}, 21); color(v{i}, dim);
hidden(n{i}); hidden(v{i});
}
color(n1, red); color(n2, blue); color(n3, silver); color(n4, red);
wait(0.5);
show(h1, 0.5);
par { show(waterm, 0.6); show(n1, 0.4); show(v1, 0.4); }
wait(0.5);
par { show(ammonia, 0.6); show(n2, 0.4); show(v2, 0.4); }
wait(0.5);
par { show(methane, 0.6); show(n3, 0.4); show(v3, 0.4); }
wait(0.5);
par { show(co2, 0.6); show(n4, 0.4); show(v4, 0.4); }
wait(2.0);
// ── 2. bond order: a stronger bond is a shorter one ──
//
// Three carbon–carbon bonds, side by side and at one magnification. Nothing scales them to make
// the point; the files simply disagree about how far apart two carbons sit.
par {
fade(waterm, 0.4); fade(ammonia, 0.4); fade(methane, 0.4); fade(co2, 0.4); fade(h1, 0.4);
fade(n1, 0.3); fade(v1, 0.3); fade(n2, 0.3); fade(v2, 0.3);
fade(n3, 0.3); fade(v3, 0.3); fade(n4, 0.3); fade(v4, 0.3);
}
// Hydrogens off and spin off, so each molecule is reduced to the ONE bond being compared and
// holds still long enough to compare it. Ethane comes from record 2 of the bundled alkane file —
// an SDF is a library as often as it is a molecule.
molecule3(ethane, "asset:molecules/alkanes.sdf", (-5.8, 0, 0), 2.6, "hydrogens=0 spin=0 record=2");
molecule3(ethene, "asset:molecules/ethene.sdf", (0, 0, 0), 2.6, "hydrogens=0 spin=0");
molecule3(ethyne, "asset:molecules/ethyne.sdf", (5.8, 0, 0), 2.6, "hydrogens=0 spin=0");
hidden(ethane); hidden(ethene); hidden(ethyne);
text(h2, (640, 120), "one bond, two bonds, three — and it pulls tighter each time");
size(h2, 27); color(h2, lime); hidden(h2);
text(b1, (300, 580), "C-C single");
text(b2, (640, 580), "C=C double");
text(b3, (980, 580), "C≡C triple");
for i in 1..4 { size(b{i}, 22); color(b{i}, dim); hidden(b{i}); }
show(h2, 0.5);
par { show(ethane, 0.6); show(b1, 0.4); }
wait(0.5);
par { show(ethene, 0.6); show(b2, 0.4); }
wait(0.5);
par { show(ethyne, 0.6); show(b3, 0.4); }
wait(2.2);
// ── 3. ionic against covalent ──
//
// Salt has no bonds in its file at all — two ions, drawn apart because nothing joins them. Water
// does. That distinction is in the data, not in the drawing.
par {
fade(ethane, 0.4); fade(ethene, 0.4); fade(ethyne, 0.4); fade(h2, 0.4);
fade(b1, 0.3); fade(b2, 0.3); fade(b3, 0.3);
}
molecule3(salt, "asset:molecules/sodium-chloride.sdf", (-4.4, 0, 0), 1.9);
molecule3(wat2, "asset:molecules/water.sdf", (4.4, 0, 0), 1.9);
hidden(salt); hidden(wat2);
text(h3, (640, 120), "no bond in the file, no stick on the screen");
size(h3, 27); color(h3, amber); hidden(h3);
text(c1, (330, 580), "NaCl - two ions, nothing between them");
text(c2, (980, 580), "H2O - bonded");
for i in 1..3 { size(c{i}, 21); color(c{i}, dim); hidden(c{i}); }
show(h3, 0.5);
par { show(salt, 0.6); show(c1, 0.4); }
wait(0.6);
par { show(wat2, 0.6); show(c2, 0.4); }
wait(2.2);
// ── 4. what a molecule really looks like ──
//
// Caffeine four ways. Ball-and-stick shows what is bonded to what; hiding the hydrogens leaves the
// skeleton a chemist draws; space-filling shows the shape the molecule presents to the world,
// which is the one that decides what it fits into.
par {
fade(salt, 0.4); fade(wat2, 0.4); fade(h3, 0.4); fade(c1, 0.3); fade(c2, 0.3);
}
molecule3(k1, "asset:molecules/caffeine.sdf", (-8.4, 0, 0), 0.52, "style=ball");
molecule3(k2, "asset:molecules/caffeine.sdf", (-2.8, 0, 0), 0.52, "style=sticks");
molecule3(k3, "asset:molecules/caffeine.sdf", (2.8, 0, 0), 0.52, "style=sticks hydrogens=0");
molecule3(k4, "asset:molecules/caffeine.sdf", (8.4, 0, 0), 0.52, "style=spacefill");
hidden(k1); hidden(k2); hidden(k3); hidden(k4);
text(h4, (640, 120), "caffeine - one file, four questions");
size(h4, 27); color(h4, magenta); hidden(h4);
text(s1, (222, 596), "ball & stick");
text(s2, (492, 596), "sticks");
text(s3, (770, 596), "no hydrogens");
text(s4, (1052, 596), "space-filling");
for i in 1..5 { size(s{i}, 20); color(s{i}, dim); hidden(s{i}); }
show(h4, 0.5);
par { show(k1, 0.5); show(s1, 0.4); }
wait(0.45);
par { show(k2, 0.5); show(s2, 0.4); }
wait(0.45);
par { show(k3, 0.5); show(s3, 0.4); }
wait(0.45);
par { show(k4, 0.5); show(s4, 0.4); }
wait(2.4);
// ── 5. and the scale it goes to ──
//
// Cholesterol is 74 atoms; paclitaxel is 113. Same builtin, same three lines.
par {
fade(k1, 0.4); fade(k2, 0.4); fade(k3, 0.4); fade(k4, 0.4); fade(h4, 0.4);
fade(s1, 0.3); fade(s2, 0.3); fade(s3, 0.3); fade(s4, 0.3);
}
molecule3(chol, "asset:molecules/cholesterol.sdf", (-5.2, 0, 0), 0.62, "hydrogens=0");
molecule3(taxol, "asset:molecules/paclitaxel.sdf", (5.0, 0, 0), 0.46, "hydrogens=0");
hidden(chol); hidden(taxol);
text(h5, (640, 120), "and it does not care how big the molecule gets");
size(h5, 27); color(h5, cyan); hidden(h5);
text(g1, (330, 596), "cholesterol - 74 atoms");
text(g2, (980, 596), "paclitaxel - 113 atoms");
for i in 1..3 { size(g{i}, 21); color(g{i}, dim); hidden(g{i}); }
show(h5, 0.5);
par { show(chol, 0.6); show(g1, 0.4); }
wait(0.7);
par { show(taxol, 0.6); show(g2, 0.4); }
wait(2.6);
// ── the close ──
par {
fade(chol, 0.5); fade(taxol, 0.5); fade(h5, 0.4); fade(g1, 0.3); fade(g2, 0.3);
}
molecule3(last, "asset:molecules/caffeine.sdf", (0, 0, 0), 1.15, "spin=18");
hidden(last);
text(end1, (640, 168), "three lines of Manic");
size(end1, 34); color(end1, silver); hidden(end1);
text(end2, (640, 578), "molecule3(caf, \"asset:molecules/caffeine.sdf\");");
size(end2, 23); color(end2, lime); hidden(end2);
text(end3, (640, 616), "every angle measured, not drawn");
size(end3, 21); color(end3, dim); hidden(end3);
par { show(last, 0.8); show(end1, 0.6); }
wait(0.6);
par { show(end2, 0.5); show(end3, 0.5); }
wait(3.2);
chemistry-cinema
The whole kit, in one run, explained by motion. Ten ideas in the order they build on each other, each with a chapter title naming what is on screen — and that is the only prose in it. The rest of what is written is what chemistry writes down anyway: an element symbol, a wavelength, a coefficient, a voltage, a wavenumber. It opens already drawn, on caffeine from its real 2-D record, so the first frame is one worth seeing before anything is pressed. Then an SN2 with curly arrows anchored to actual atoms; an electron falling between computed levels and the colour that falls out of λ = hc/ΔE; water’s three modes and carbon dioxide’s silent one; 72 hard discs reacting only when a collision clears the barrier; coefficients landing one at a time; a crystal coming apart at its corners into hydration shells that face the right way; electrons sent round a wire at a computed current; a bond turning against the energy it costs — and back to the molecule it opened on. Nothing in it is only a picture.
// Chemistry, without a word of it
//
// Eleven ideas in one run, in the order they build on each other. Each act carries a chapter title
// saying what is on screen, and that is the only prose in the film: everything else written down is
// what chemistry writes down anyway — an element symbol, a wavelength, a coefficient, a voltage, a
// wavenumber. The explaining is done by motion, which is the point of the whole kit, since every one
// of these is a thing a still picture cannot say.
//
// 0 A MOLECULE caffeine, from its real record, standing there before anything moves
// 1 LEWIS the electron bookkeeping of NO₃⁻, worked — and a double bond that will not settle
// 2 MECHANISM SN2 — curly arrows aimed at actual atoms, one bond made and one broken
// 3 LIGHT an electron falls between computed levels; the photon's colour comes from λ = hc/ΔE
// 4 MATTER a molecule's real geometry, and the modes it vibrates in
// 5 SILENCE the same for CO₂ — one mode moves no dipole, and its peak is simply absent
// 6 COLLISION a gas reacts only when a collision clears the barrier; the tail does the work
// 7 BALANCE the coefficients that conserve every atom, landing one at a time
// 8 SOLUTION a lattice comes apart at its corners, into hydration shells that face the right way
// 9 CURRENT the same electrons, made to go round a wire instead
// 10 ROTATION a bond turns, and the energy it costs is a curve it rides
//
// It opens on a drawn molecule rather than on black: the first frame is a frame someone might see
// before they press anything.
//
// Nothing here is drawn by hand. The skeletal formula is a real 2-D record, the curly arrows are
// anchored to the atoms and bonds they point at, the levels are −13.606/n², the modes are eigenvectors of a
// mass-weighted Hessian, the reaction is 72 hard discs meeting above an activation energy, the
// coefficients are the null space of the atom matrix, the Lewis structure is counted out from the
// formula, the dissolution order is coordination number,
// the cell's polarity and voltage are its own electrode potentials, and the torsion profile is a
// rigid scan of butane's real geometry. Change any number in the source and the film changes with
// it, because there is nothing in it that is only a picture.
title("chemistry, without a word of it");
canvas("16:9");
template("black");
bloom(0.38, 0.72, 16);
text(brand, (640, 26), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
// ── the chapter titles: each one names what is on screen, and they are the only prose here ──
text(t0, (640, 82), "01 · a molecule");
size(t0, 21);
color(t0, fg);
text(tl, (640, 82), "02 · counting the electrons");
size(tl, 21);
color(tl, fg);
hidden(tl);
text(t1, (640, 82), "03 · substitution, SN2");
size(t1, 21);
color(t1, fg);
hidden(t1);
text(t2, (640, 82), "04 · levels, and the colour of light");
size(t2, 21);
color(t2, fg);
hidden(t2);
text(t3, (640, 82), "05 · how a molecule vibrates");
size(t3, 21);
color(t3, fg);
hidden(t3);
text(t4, (640, 82), "06 · the mode with no peak");
size(t4, 21);
color(t4, fg);
hidden(t4);
text(t5, (640, 82), "07 · collision, and activation energy");
size(t5, 21);
color(t5, fg);
hidden(t5);
text(t6, (640, 82), "08 · balancing an equation");
size(t6, 21);
color(t6, fg);
hidden(t6);
text(t7, (640, 82), "09 · why a salt dissolves");
size(t7, 21);
color(t7, fg);
hidden(t7);
text(t8, (640, 82), "10 · a galvanic cell");
size(t8, 21);
color(t8, fg);
hidden(t8);
text(t9, (640, 82), "11 · turning a single bond");
size(t9, 21);
color(t9, fg);
hidden(t9);
// ── 0 · A MOLECULE — on screen from the first frame ─────────────────────────
structure(caf, "asset:molecules/caffeine-2d.sdf", (640, 368), 116);
color(caf.bonds, fg);
// ── 1 · LEWIS — the electron bookkeeping, worked from the formula ───────────
lewis(lw, "NO3-", (640, 350), 150, 34);
hidden(lw);
// ── 2 · MECHANISM ────────────────────────────────────────────────────────────
//
// Bromoethane and hydroxide. The nucleophile sits below and left of the carbon while the bromine is
// above and right of it — backside attack, which is geometry rather than layout.
structure(sub, "CCBr", (400, 300), 88);
structure(nuc, "[OH-]", (214, 452), 88);
arrow(att, nuc.a0, sub.a1, 62);
color(att, cyan);
stroke(att, 3);
untraced(att);
arrow(go, sub.b1, sub.a2, 40);
color(go, coral);
stroke(go, 3);
untraced(go);
arrow(rxn, (620, 340), (752, 340));
color(rxn, fg);
stroke(rxn, 3);
untraced(rxn);
structure(pro, "CCO", (912, 300), 88);
structure(lea, "[Br-]", (1128, 452), 88);
hidden(sub);
hidden(nuc);
hidden(pro);
hidden(lea);
// ── 2 · LIGHT ────────────────────────────────────────────────────────────────
levels(lv, (330, 350), 250, 320, 6);
emission(spec, lv, (930, 330), 520, 104);
hidden(lv);
hidden(spec);
// ── 3 · MATTER ───────────────────────────────────────────────────────────────
vibration(h2o, "asset:molecules/water.sdf", (390, 350), 150, 22);
irspectrum(irw, h2o, (950, 340), 470, 150, 15);
hidden(h2o);
hidden(irw);
hidden(h2o.readout);
// ── 4 · SILENCE ──────────────────────────────────────────────────────────────
vibration(co2, "asset:molecules/carbon-dioxide.sdf", (390, 350), 150, 22);
irspectrum(irc, co2, (950, 340), 470, 150, 15);
hidden(co2);
hidden(irc);
hidden(co2.readout);
// ── 5 · COLLISION ────────────────────────────────────────────────────────────
gas(gs, (400, 372), 430, 330, 72, "temperature=1.6 radius=7 steps=400 seed=5");
species(gs, A, 0.5, cyan);
species(gs, B, 0.5, magenta);
species(gs, C, gold);
rule(gs, "A + B -> C + C when energy > 3.2");
speeds(gs, (960, 268), 440, 130, 12, 13);
timegraph(gs, (960, 520), 96);
hidden(gs.box);
hidden(gs.particles);
hidden(gs.speeds.axis);
hidden(gs.speeds.bars);
untraced(gs.speeds.mb);
hidden(gs.time.frame);
hidden(gs.time.title);
hidden(gs.time.sweep);
untraced(gs.time.c0);
untraced(gs.time.c1);
color(gs.time.c1, gold);
// ── 6 · BALANCE ──────────────────────────────────────────────────────────────
balance(rx, (640, 288), "Fe + O2 -> Fe2O3", 54);
tally(rx, (640, 470), 250, 42, 24);
hidden(rx);
hidden(rx.tally);
// ── 7 · SOLUTION ─────────────────────────────────────────────────────────────
lattice(salt, "NaCl", (600, 322), 6, 5, 50);
hidden(salt);
hidden(salt.captions);
// ── 8 · CURRENT ──────────────────────────────────────────────────────────────
cell(cl, "Zn|Cu", (640, 300), 640, 280, "resistance=10 carriers=12");
hidden(cl);
hidden(cl.captions);
// ── 9 · ROTATION ─────────────────────────────────────────────────────────────
newman(nm, "asset:molecules/butane.sdf", (330, 350), 150, 18);
profile(pf, nm, (900, 350), 480, 190);
hidden(nm);
hidden(pf);
// ═══════════════════════════════════════════════════════════════════════════
// the run
// ═══════════════════════════════════════════════════════════════════════════
// 0 · A MOLECULE — already there; the heteroatoms are what the rest of it hangs off
wait(1.4);
par { pulse(caf.O); pulse(caf.N); }
wait(1.0);
par { recolor(caf.O, coral, 0.6); recolor(caf.N, cyan, 0.6); }
wait(1.6);
// 1 · LEWIS — count, connect, complete, and then the bond that will not stay still
par { fade(caf, 0.7); fade(t0, 0.5); }
par { show(lw, 0.5); show(tl, 0.5); }
octet(lw, 5.5);
wait(0.6);
resonate(lw, 5.0, 2);
wait(1.0);
// 2 · MECHANISM — a bond made, a bond broken, and the charge leaving with the bromide
par { fade(lw, 0.6); fade(tl, 0.4); }
par { show(sub, 0.7); show(nuc, 0.7); show(t1, 0.5); }
wait(0.6);
par { draw(att, 0.9); pulse(nuc.O); }
wait(0.5);
par { draw(go, 0.8); pulse(sub.Br); }
wait(0.9);
draw(rxn, 0.6);
par { show(pro, 0.7); show(lea, 0.7); }
par { recolor(lea.Br, coral, 0.5); pulse(lea.Br); }
wait(1.6);
// 2 · LIGHT — the ladder, then the falls and the colours they make
par { fade(sub, 0.5); fade(nuc, 0.5); fade(pro, 0.5); fade(lea, 0.5); fade(att, 0.4); fade(go, 0.4); fade(rxn, 0.4); fade(t1, 0.4); }
par { show(lv, 0.8); show(t2, 0.5); }
wait(0.5);
show(spec, 0.7);
wait(0.4);
drop(lv, 3, 2, 1.6);
wait(0.3);
drop(lv, 4, 2, 1.4);
wait(0.3);
drop(lv, 6, 2, 1.4);
wait(0.4);
drop(lv, 2, 1, 1.8);
wait(1.2);
// 3 · MATTER — a molecule, and the three ways it can move
par { fade(lv, 0.6); fade(spec, 0.6); fade(t2, 0.4); }
par { show(h2o, 0.7); show(t3, 0.5); }
wait(0.4);
vibrate(h2o, 1, 1.8);
par { show(irw, 0.7); vibrate(h2o, 2, 1.6); }
vibrate(h2o, 3, 1.6);
wait(1.4);
// 4 · SILENCE — the same again, and the mode that leaves no peak
par { fade(h2o, 0.5); fade(irw, 0.5); fade(t3, 0.4); }
par { show(co2, 0.6); show(t4, 0.5); }
wait(0.3);
vibrate(co2, 1, 1.4);
par { show(irc, 0.6); vibrate(co2, 4, 1.4); }
wait(0.8);
par { pulse(irc.silent); }
vibrate(co2, 3, 2.6);
wait(1.4);
// 5 · COLLISION — molecules meet, and only the hard meetings count
par { fade(co2, 0.5); fade(irc, 0.5); fade(co2.readout, 0.4); fade(t4, 0.4); }
par { show(gs.box, 0.5); show(gs.particles, 0.6); show(t5, 0.5); }
wait(0.3);
par {
run(gs, 9);
draw(gs.time.c0, 9);
draw(gs.time.c1, 9);
seq {
show(gs.speeds.axis, 0.4);
show(gs.speeds.bars, 0.5);
wait(1.0);
draw(gs.speeds.mb, 1.2);
wait(0.8);
show(gs.time.frame, 0.4);
}
}
wait(1.2);
// 6 · BALANCE — atoms are conserved, and here is what that costs
par { fade(gs.box, 0.5); fade(gs.particles, 0.5); fade(gs.speeds, 0.5); fade(gs.time, 0.5); fade(t5, 0.4); }
par { show(rx, 0.6); show(t6, 0.5); }
wait(0.4);
show(rx.tally, 0.5);
wait(1.0);
solve(rx, 2.8);
wait(1.6);
// 7 · SOLUTION — a solid comes apart, corner first, and the water turns round
par { fade(rx, 0.5); fade(rx.tally, 0.5); fade(t6, 0.4); }
par { show(salt, 0.7); show(t7, 0.5); }
wait(0.8);
dissolve(salt, 7, 8);
wait(1.6);
// 8 · CURRENT — the same electrons, sent round a wire
par { fade(salt, 0.7); fade(t7, 0.4); }
par { show(cl, 0.8); show(t8, 0.5); }
wait(0.8);
discharge(cl, 6, 30);
wait(1.4);
// 9 · ROTATION — a bond turns, and rides the energy it costs
par { fade(cl, 0.6); fade(t8, 0.4); }
par { show(nm, 0.6); show(t9, 0.5); }
wait(0.4);
show(pf, 0.7);
wait(0.8);
twist(nm, 120, 1.3);
twist(nm, 60, 1.1);
twist(nm, 0, 1.4);
wait(0.8);
twist(nm, 300, 1.3);
twist(nm, 180, 1.3);
wait(1.4);
// coda — the molecule it opened on, with everything it is made of now lit
par { fade(nm, 0.6); fade(pf, 0.6); fade(t9, 0.4); }
par { show(caf, 0.9); show(t0, 0.6); }
wait(0.5);
par { pulse(caf.O); pulse(caf.N); }
wait(2.6);
lewis-structures
The bookkeeping, worked rather than copied. From a formula and nothing else: count the valence electrons, pick the central atom, spend two on every bond, complete the octets from the outside in, and — if the middle atom is still short — take a lone pair off a neighbour and make it a second bond. The formal charges then fall out, and they have to sum to the ion’s charge or the structure is refused. Two steps are why it is animated: the pair that SWINGS IN to finish carbon’s octet in CO₂ is a move rather than a picture, and resonance is a claim no single drawing can make — nitrate’s double bond is not on one oxygen, it is on all three at once, so it keeps moving while the lone pairs and charges follow it. Where it declines it declines for chemical reasons: nitric oxide has an odd number of valence electrons and cannot be drawn with every electron paired.
// Lewis structures — the bookkeeping, worked rather than copied
//
// It opens on four of them already drawn, because that is what the idea looks like: a single bond, a
// double, a triple, and an ion carrying a charge. Each one is labelled with its own formula by the
// builtin — that much is computable — and with its NAME by this scene, because "water" is a fact
// about usage rather than about the molecule. Every one is DERIVED from its formula rather than
// looked up — count the valence electrons, pick the central atom, spend two on every bond, complete
// the octets from the outside in, and if the middle atom is still short, take a lone pair off a
// neighbour and make it a second bond. The formal charges then fall out, and they have to sum to the
// ion's charge or the structure is refused.
//
// The middle act is the working itself, in the order a course teaches it, and the last is the claim
// no single drawing can make: nitrate's double bond is not on one oxygen, it is on all three at
// once, so it keeps moving while the lone pairs and the charges follow it.
//
// Change a formula in the source and its whole diagram changes — the letters, the lines, the dots
// and the charges are all one calculation.
title("lewis structures");
canvas("16:9");
template("black");
text(brand, (640, 26), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
// ── the title, and four examples standing there from the first frame ─────────
text(head, (640, 74), "Lewis Structures");
size(head, 34);
bold(head);
color(head, fg);
lewis(wat, "H2O", (300, 262), 92, 28);
lewis(cdi, "CO2", (960, 262), 96, 28);
lewis(hcn, "HCN", (300, 560), 96, 28);
lewis(amm, "NH4+", (960, 556), 84, 28);
text(l1, (300, 352), "water — one pair per bond, two left over");
size(l1, 16);
color(l1, dim);
text(l2, (960, 352), "carbon dioxide — two pairs per bond, where an octet needs it");
size(l2, 16);
color(l2, dim);
text(l3, (300, 666), "hydrogen cyanide — three pairs, when that is what it takes");
size(l3, 16);
color(l3, dim);
text(l4, (960, 666), "ammonium — and the charge is what the counting leaves over");
size(l4, 16);
color(l4, dim);
// ── the working, on the one where a pair has to become a bond ──
lewis(big, "CO2", (640, 320), 165, 38);
hidden(big);
text(why, (640, 500), "carbon is short of an octet, so a pair swings in — twice");
size(why, 20);
color(why, gold);
hidden(why);
text(why2, (640, 540), "carbon dioxide: count · connect · complete · then check the charges");
size(why2, 18);
color(why2, dim);
hidden(why2);
// ── and the one no single drawing can say ──
lewis(nit, "NO3-", (410, 350), 145, 34);
hidden(nit);
text(res, (940, 306), "nitrate: the double bond is not on one oxygen —");
size(res, 20);
color(res, fg);
hidden(res);
text(res2, (940, 342), "it is on all three at once, and the");
size(res2, 20);
color(res2, fg);
hidden(res2);
text(res3, (940, 378), "charges move with it");
size(res3, 20);
color(res3, coral);
hidden(res3);
// ── the beats ──
// 1 · the four examples are already on screen; let them be read, then point at the dots
wait(2.2);
par { pulse(wat.pairs); pulse(cdi.pairs); }
wait(0.6);
par { pulse(hcn.pairs); pulse(amm.charges); }
wait(2.0);
// 2 · the working, on carbon dioxide
par {
fade(wat, 0.6); fade(cdi, 0.6); fade(hcn, 0.6); fade(amm, 0.6);
fade(l1, 0.5); fade(l2, 0.5); fade(l3, 0.5); fade(l4, 0.5);
}
par { show(big, 0.5); show(why2, 0.5); }
octet(big, 6.5);
wait(0.4);
show(why, 0.5);
wait(2.2);
// 3 · resonance, where the answer will not hold still
par { fade(big, 0.5); fade(why, 0.4); fade(why2, 0.4); }
show(nit, 0.6);
octet(nit, 5.5);
wait(0.5);
par { show(res, 0.4); show(res2, 0.4); }
wait(0.7);
show(res3, 0.4);
resonate(nit, 7.5, 3);
wait(2.4);
hydrogen-spectrum
Four coloured lines, and every one of them computed. The rungs are placed at Eₙ = −13.606/n² eV (which is why they crowd towards zero), a drop from n to m releases exactly that difference, and λ = hc/ΔE turns it into a wavelength — then each spectral line’s COLOUR is computed from its own wavelength, so Balmer α is red because 656 nm is red rather than because red was chosen. The last beat is the transition with no line at all: 2 → 1 is the biggest jump in the diagram, and its 10.2 eV photon lands at 121 nm, which is ultraviolet. One electron only, on purpose — the Rydberg formula is exact for hydrogen and wrong for anything with two electrons to repel each other.
// The hydrogen spectrum — the colour is the arithmetic
//
// Four coloured lines is all a hydrogen discharge tube gives you, and every one of them is on
// screen for a computed reason. The levels are E_n = -13.606/n² eV, so the rungs are placed at
// their energies (which is why they crowd towards zero); a jump from n to m releases exactly that
// energy difference; and λ = hc/ΔE turns it into a wavelength. The COLOUR of each spectral line is
// then computed from its own wavelength — nothing is chosen, so Balmer alpha is red because 656 nm
// is red.
//
// The last beat is the one a spectrum can't show: the 2 → 1 drop releases 10.2 eV at 121 nm, which
// is ultraviolet. It is the biggest jump in the diagram and it leaves no line at all, because the
// eye's range is a fact about the eye rather than about hydrogen.
title("the hydrogen spectrum");
canvas("16:9");
template("black");
text(brand, (640, 28), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
text(head, (640, 66), "four lines, and every one of them computed");
size(head, 27);
bold(head);
color(head, fg);
hidden(head);
levels(lv, (300, 350), 250, 320, 6);
hidden(lv);
emission(sp, lv, (930, 300), 500, 96);
hidden(sp);
text(rule, (930, 470), "λ = hc/ΔE — the colour is the energy, converted");
size(rule, 18);
color(rule, fg);
hidden(rule);
text(uv, (930, 512), "n = 2 → 1 is the biggest jump of all, and leaves no line:");
size(uv, 18);
color(uv, coral);
hidden(uv);
text(uv2, (930, 540), "10.2 eV is 121 nm, and 121 nm is ultraviolet");
size(uv2, 18);
color(uv2, coral);
hidden(uv2);
text(foot, (640, 690), "one electron only — the Rydberg formula is exact for hydrogen and wrong for anything with two");
size(foot, 15);
color(foot, dim);
hidden(foot);
wait(0.4);
show(head, 0.6);
wait(0.3);
par { show(lv, 0.7); show(foot, 0.4); }
wait(0.5);
show(sp, 0.6);
wait(0.4);
drop(lv, 3, 2, 1.8);
wait(0.5);
drop(lv, 4, 2, 1.6);
wait(0.4);
drop(lv, 5, 2, 1.5);
wait(0.4);
drop(lv, 6, 2, 1.5);
wait(0.5);
show(rule, 0.5);
wait(2.0);
par { show(uv, 0.5); show(uv2, 0.5); }
drop(lv, 2, 1, 2.2);
wait(3.0);
galvanic-cell
The cell decides its own polarity. Give it two metals and the more positive standard reduction
potential becomes the cathode — so cell(c, "Cu|Zn") draws exactly the same cell, and both read
+1.10 V. From that one decision: E°cell = +0.34 − (−0.76), the half-equations the right way round,
and Ohm’s law across the 10 Ω resistor giving 0.110 A. The electrons and the salt-bridge ions move
at a rate set by that current, and the two counters are the exam question — after half an hour,
Q = It = 198 C, and Faraday turns that into the 67 mg of zinc the anode has lost. Change the
resistance in the source and every number moves.
// A galvanic cell — which way round, how many volts, and what it costs the zinc
//
// The cell is not told which electrode is which. Give it two metals and the more positive standard
// reduction potential is the one that gets reduced, so copper becomes the cathode and zinc the
// anode — `cell(c, "Cu|Zn")` would draw exactly the same cell. From that one decision everything
// else follows: E°cell = +0.34 − (−0.76) = 1.10 V, the half-equations are written the right way
// round, and Ohm's law across the 10 Ω external resistor sets the current at 0.110 A.
//
// The electrons and the salt-bridge ions move at a rate set by that current, and the two counters
// are the exam question: after half an hour, Q = It = 198 C, and Faraday's law turns that into the
// zinc the anode has lost, m = MQ/nF = 67 mg. Change the resistance in the source and every one of
// those numbers moves.
title("a galvanic cell, solved");
canvas("16:9");
template("paper");
text(brand, (640, 28), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
text(head, (640, 68), "the cell decides its own polarity");
size(head, 28);
bold(head);
color(head, ink);
hidden(head);
cell(c, "Zn|Cu", (640, 288), 660, 280, "resistance=10 carriers=10");
hidden(c);
hidden(c.captions);
text(why, (640, 640), "zinc is the more negative half-cell, so zinc is oxidised — that is the whole decision");
size(why, 18);
color(why, ink);
hidden(why);
text(law, (640, 674), "Q = It after half an hour, and m = MQ/nF is what the anode lost");
size(law, 18);
color(law, indigo);
hidden(law);
wait(0.4);
show(head, 0.6);
wait(0.3);
par { show(c, 0.8); show(c.captions, 0.8); }
wait(0.8);
show(why, 0.5);
wait(1.6);
show(law, 0.5);
discharge(c, 6, 30);
wait(3.0);
dissolving-salt
Why salt dissolves, and why the corners go first. Two computed things carry the lesson: the ORDER, from each ion’s coordination number (a corner has two neighbours holding it, an edge three, the middle four), so the crystal erodes inwards the way a real one does; and the ORIENTATION, from the sign of each charge — water turns its oxygen towards Na⁺ and its hydrogens towards Cl⁻, so the two kinds of hydration shell face opposite ways for a reason you can read off the screen. And the arithmetic underneath: +787 kJ/mol to pull the lattice apart, −770 back from hydrating the ions, so dissolving salt is very slightly ENDOTHERMIC and what drives it is entropy.
// Why salt dissolves — and why the corners go first
//
// Two things on screen are computed rather than choreographed, and they are the two things the
// lesson is about.
//
// The ORDER. Every ion's coordination number is counted from the lattice: a corner has two
// neighbours holding it, an edge three, an ion in the middle four. `dissolve` takes them in that
// order, so the crystal erodes inwards from its corners — which is what a crystal does, and why a
// cube of salt rounds off as it goes.
//
// The ORIENTATION. Water is a dipole, so it turns its oxygen towards a positive ion and its
// hydrogens towards a negative one. Each hydration shell here is built from the sign of the charge
// it is surrounding, so the sodium shells and the chloride shells face opposite ways — which is the
// picture of why water, specifically, is good at this.
//
// And the arithmetic underneath: pulling the lattice apart costs +787 kJ/mol, hydrating the two
// ions pays back −770, so dissolving salt is very slightly ENDOTHERMIC. It happens anyway, and what
// drives it is entropy. That surprises people, which is exactly why the numbers are on screen.
title("why salt dissolves");
canvas("16:9");
template("black");
text(brand, (640, 28), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
text(head, (640, 66), "a crystal comes apart where it is least held");
size(head, 27);
bold(head);
color(head, fg);
hidden(head);
lattice(x, "NaCl", (430, 306), 6, 5, 48);
hidden(x);
hidden(x.captions);
text(coord, (1010, 232), "coordination:");
size(coord, 20);
color(coord, fg);
hidden(coord);
text(coord2, (1010, 270), "corner 2 · edge 3 · inside 4");
size(coord2, 19);
color(coord2, cyan);
hidden(coord2);
text(dip, (1010, 336), "water is a dipole, so it turns round:");
size(dip, 19);
color(dip, fg);
hidden(dip);
text(dip2, (1010, 370), "oxygen towards Na⁺, hydrogens towards Cl⁻");
size(dip2, 19);
color(dip2, magenta);
hidden(dip2);
text(ent, (1010, 436), "and it is barely downhill at all —");
size(ent, 19);
color(ent, fg);
hidden(ent);
text(ent2, (1010, 470), "what drives it is entropy, not energy");
size(ent2, 19);
color(ent2, gold);
hidden(ent2);
wait(0.4);
show(head, 0.6);
wait(0.3);
show(x, 0.8);
wait(0.6);
par { show(coord, 0.4); show(coord2, 0.5); }
wait(1.2);
par { show(dip, 0.4); show(dip2, 0.5); }
wait(0.6);
par { show(x.captions, 0.4); dissolve(x, 7, 8); }
wait(0.6);
par { show(ent, 0.4); show(ent2, 0.5); }
wait(3.0);
butane-conformers
A single bond turns — but not for free. The bond is chosen for you (the one with the most on both sides, which for butane is the middle C–C), and the whole energy profile is computed by rotating the real geometry degree by degree: a threefold torsional term for the intrinsic preference for staggered, plus Lennard-Jones repulsion between the atoms on opposite sides, which is what makes two methyls mind each other far more than two hydrogens do. Out comes the textbook curve — staggered wells, anti lowest, the methyls eclipsing worst — and the projection and the marker are one calculation, so the molecule and the graph can never disagree. It is a rigid scan, so the peaks read high: the order and the shape are what to take from it.
// Butane's conformers — a Newman projection with the energy it costs
//
// The bond is not chosen by hand: `newman` looks for the single bond with the most on both sides,
// which for butane is the middle C–C. Then it computes the whole profile by ROTATING the real
// geometry degree by degree and scoring each arrangement — a threefold torsional term for the
// intrinsic preference for staggered, plus Lennard-Jones repulsion between the atoms on opposite
// sides, which is what makes two methyl groups mind each other far more than two hydrogens do.
//
// Out of that comes the curve every course draws: staggered wells at 60°, 180° and 300°, the anti
// conformer lowest, eclipsed peaks between them, and the worst arrangement at 0° where the two
// methyls are on top of each other. The projection and the marker are one calculation, so the
// molecule and the graph can never disagree.
//
// It is a rigid scan — the bond turns and nothing else moves — so the peaks come out high: a real
// butane widens its bond angles a little to relieve the syn contact, which is why the measured
// barrier is 19–21 kJ/mol and this one is in the fifties. The order and the shape are what to read.
title("butane: the cost of turning a bond");
canvas("16:9");
template("black");
text(brand, (640, 28), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
text(head, (640, 66), "a single bond turns — but not for free");
size(head, 27);
bold(head);
color(head, fg);
hidden(head);
newman(nm, "asset:molecules/butane.sdf", (300, 330), 150, 18);
hidden(nm);
profile(pf, nm, (880, 330), 500, 200);
hidden(pf);
text(foot, (640, 686), "rigid scan: the peaks come out high because a real molecule widens its angles to relieve them");
size(foot, 15);
color(foot, dim);
hidden(foot);
wait(0.4);
show(head, 0.6);
wait(0.3);
show(nm, 0.7);
wait(0.6);
par { show(pf, 0.7); show(foot, 0.4); }
wait(1.2);
twist(nm, 120, 1.6);
wait(1.0);
twist(nm, 60, 1.4);
wait(1.2);
twist(nm, 0, 1.6);
wait(1.6);
twist(nm, 300, 1.6);
wait(1.0);
twist(nm, 180, 1.6);
wait(3.0);
ir-modes
A mode is its motion — and some motions are invisible. vibration reads the real 3-D
geometry, builds a harmonic force field of bond, angle and out-of-plane springs and
diagonalises the mass-weighted Hessian, so what you watch are the actual eigenvectors: water’s
three modes (3N−6), carbon dioxide’s four (3N−5, two of them a degenerate bend). The
wavenumbers land near the measured ones because the masses and force constants are real —
H–Cl at 2886 cm⁻¹ against a measured 2886, CO₂’s asymmetric stretch at 2374 against 2349. Then
the point: a vibration absorbs infrared only if it changes the dipole moment, and CO₂’s
symmetric stretch does not — both oxygens move out together, the bond dipoles stay equal and
opposite, and the spectrum has no peak there however hard the bond is vibrating. The scene
animates that silent mode against its own gap in the spectrum. The cancellation is computed from
the mode’s displacements, not asserted.
// Infrared: a mode is its motion, and some motions are invisible
//
// Nothing here is drawn by hand or looked up. `vibration` reads the real 3-D geometry out of the
// structure file, builds a harmonic force field of bond, angle and out-of-plane springs with
// tabulated force constants, and diagonalises the mass-weighted Hessian. What comes back is the
// textbook set of modes — 3N−6 of them, or 3N−5 when the molecule is a straight line — each with a
// wavenumber and its own eigenvector, which is what `vibrate` animates.
//
// The numbers land where a spectroscopy table says they should, because the masses and the force
// constants are real: H–Cl comes out at 2886 cm⁻¹ against a measured 2886, and carbon dioxide's
// asymmetric stretch at 2374 against 2349. It is a harmonic model, so treat a wavenumber as good to
// within a hundred or so — anharmonicity, Fermi resonance and overtones are all outside it.
//
// The point of the scene is the mode that ISN'T there. A vibration absorbs infrared only if it
// changes the molecule's dipole moment, and carbon dioxide's symmetric stretch does not: both
// oxygens move out together, the two bond dipoles stay equal and opposite, and the spectrum has no
// peak however hard the bond is vibrating. That cancellation is computed — the intensity is
// |Σ qᵢ·dᵢ|² over the mode's own displacements — so the silent mode is silent for the reason a
// chemist would give, not because a table said so.
title("infrared: the mode that isn't there");
canvas("16:9");
template("black");
text(brand, (640, 28), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
text(head, (640, 66), "a vibration is only visible if the dipole moves");
size(head, 28);
bold(head);
color(head, fg);
hidden(head);
// ── water: three modes, all of them active ──
vibration(h2o, "asset:molecules/water.sdf", (330, 330), 130, 22);
hidden(h2o);
irspectrum(ws, h2o, (930, 320), 540, 170, 15);
hidden(ws);
text(wlab, (330, 150), "water — bent, so 3N−6 = 3 modes");
size(wlab, 20);
color(wlab, fg);
hidden(wlab);
text(wsay, (900, 520), "three modes, three peaks — every one moves the dipole");
size(wsay, 18);
color(wsay, fg);
hidden(wsay);
// ── carbon dioxide: four modes, and one of them is silent ──
vibration(co2, "asset:molecules/carbon-dioxide.sdf", (330, 330), 130, 22);
hidden(co2);
irspectrum(cs, co2, (930, 320), 540, 170, 15);
hidden(cs);
text(clab, (330, 150), "carbon dioxide — linear, so 3N−5 = 4 modes");
size(clab, 20);
color(clab, fg);
hidden(clab);
text(csay, (900, 520), "four modes — and only three peaks");
size(csay, 19);
color(csay, fg);
hidden(csay);
text(cwhy, (900, 556), "the symmetric stretch moves both oxygens out together —");
size(cwhy, 17);
color(cwhy, coral);
hidden(cwhy);
text(cwhy2, (900, 582), "the bond dipoles stay equal and opposite, so nothing absorbs");
size(cwhy2, 17);
color(cwhy2, coral);
hidden(cwhy2);
text(foot, (640, 690), "harmonic model, tabulated force constants — a wavenumber is good to about a hundred");
size(foot, 15);
color(foot, dim);
hidden(foot);
// ── ACT 1: water, mode by mode ──
wait(0.4);
show(head, 0.6);
wait(0.3);
par { show(h2o, 0.6); show(wlab, 0.4); }
wait(0.4);
vibrate(h2o, 1, 2.4); // the scissor bend
wait(0.4);
vibrate(h2o, 2, 2.4); // symmetric stretch
wait(0.4);
vibrate(h2o, 3, 2.4); // asymmetric stretch
wait(0.5);
par { show(ws, 0.7); show(foot, 0.4); }
wait(0.5);
show(wsay, 0.5);
wait(2.4);
// ── ACT 2: carbon dioxide, where one mode goes missing ──
par { fade(h2o, 0.5); fade(ws, 0.5); fade(wlab, 0.4); fade(wsay, 0.4); }
par { show(co2, 0.6); show(clab, 0.4); }
wait(0.4);
vibrate(co2, 1, 2.2); // bend
wait(0.3);
vibrate(co2, 4, 2.2); // asymmetric stretch
wait(0.4);
show(cs, 0.7);
wait(0.6);
show(csay, 0.5);
wait(1.6);
// the one with no peak — animate it against its own gap in the spectrum
par { show(cwhy, 0.5); pulse(cs.silent); }
vibrate(co2, 3, 3.2); // the symmetric stretch: nothing absorbs
show(cwhy2, 0.5);
wait(3.0);
limiting-reagent
The one that runs out is not the one you have least of. 10.0 g of iron and 5.0 g of oxygen:
there is less oxygen by mass and fewer MOLES of oxygen too, and iron is still what runs out
first — because the comparison that decides it is not the amount but the amount DIVIDED BY THE
COEFFICIENT (0.0448 batches against 0.0521). Every number is computed: balance solves the
coefficients, supply converts grams to moles with the standard atomic weights — the ones you
weigh with, not the monoisotopic masses a mass spectrum uses — and react grows the bars, names
the loser and counts the yields up. It closes on the check any stoichiometry answer has to pass:
15.00 g in, 14.30 g of oxide plus 0.70 g of unused oxygen out. Change 10 g to 20 g in the source
and the bars, the winner and every mass move on their own.
// The limiting reagent — the one that runs out is not the one you have least of
//
// 10.0 g of iron and 5.0 g of oxygen. There is less oxygen by mass, and there are fewer MOLES of
// oxygen too (0.156 against 0.179) — and iron is still what runs out first. That is the whole
// lesson, and it is the reason the question is worth asking at all: the comparison that decides it
// is not the amount, it is the amount DIVIDED BY THE COEFFICIENT.
//
// Fe: 0.1791 mol / 4 = 0.0448 batches ← runs out first
// O2: 0.1563 mol / 3 = 0.0521 batches
//
// Every number here is computed. `balance` solves the coefficients (as the null space of the atom
// matrix), `supply` converts grams to moles with the STANDARD atomic weights — the ones you weigh
// with, not the monoisotopic masses a mass spectrum uses, which differ by more than rounding —
// and `limiting` + `react` do the comparison and count the answer up. Change 10 g to 20 g in the
// source and the bars, the winner and every mass on screen move on their own.
//
// The last line is the check any stoichiometry answer has to pass: 15.00 g of reagents in, and
// 14.30 g of oxide plus 0.70 g of unused oxygen out. Nothing was created, and nothing was lost —
// which is the same claim the balanced equation was making, now in grams.
title("the limiting reagent");
canvas("16:9");
template("paper");
text(brand, (640, 30), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
text(head, (640, 74), "which one runs out first?");
size(head, 30);
bold(head);
color(head, ink);
hidden(head);
// ── the reaction, balanced first because the coefficients are the whole point ──
balance(rx, (640, 168), "Fe + O2 -> Fe2O3", 44);
hidden(rx);
supply(rx, "Fe=10g O2=5g");
text(given, (640, 246), "10.0 g of iron, 5.0 g of oxygen");
size(given, 22);
color(given, ink);
hidden(given);
text(guess, (640, 290), "there is less oxygen — by mass AND by moles. So oxygen runs out?");
size(guess, 19);
color(guess, dim);
hidden(guess);
// ── the comparison that actually decides it ──
limiting(rx, (640, 470), 660, 52, 21);
hidden(rx.limit);
text(why, (640, 692), "moles ÷ coefficient — four irons are needed per batch, and only three oxygens");
size(why, 19);
color(why, indigo);
hidden(why);
text(check, (640, 692), "15.00 g in, 15.00 g out — the balanced equation, now in grams");
size(check, 19);
color(check, ink);
hidden(check);
// ── the beats ──
wait(0.4);
show(head, 0.6);
wait(0.3);
show(rx, 0.5);
wait(0.4);
solve(rx, 1.8);
wait(0.6);
show(given, 0.5);
wait(0.8);
show(guess, 0.5);
wait(2.2);
// the bars settle the question
par { fade(guess, 0.4); show(rx.limit, 0.5); }
react(rx, 3.4);
wait(0.4);
show(why, 0.6);
wait(2.6);
// and the answer checks itself
fade(why, 0.4);
show(check, 0.6);
wait(3.0);
balancing
Conservation, worked rather than asserted. balance reads the skeleton equation, counts the
atoms in every formula and solves the coefficients; tally keeps score element by element;
solve writes the coefficients in one at a time and the counts follow, so a half-finished state
is a real one — a species with no coefficient yet counts as one of itself, exactly the arithmetic
a student is doing mid-question. The coefficients are not guessed: one column per species and one
row per element gives a matrix whose NULL SPACE is precisely the set of balanced equations, found
by exact rational elimination (no floating point — the answer is an integer vector) and reduced to
the smallest whole numbers, which is why 4/3/2 comes out and not 8/6/4. The animation is
rewrite, so each coefficient slides in beside a formula that stays exactly where it is. Then the
reason a solver earns its place: 2 KMnO₄ + 3 H₂SO₄ + 5 H₂C₂O₄ → K₂SO₄ + 2 MnSO₄ + 10 CO₂ + 8 H₂O,
which nobody balances by inspection.
// Balancing an equation — conservation, worked rather than asserted
//
// Nothing here is placed by hand. `balance` reads the skeleton equation, counts the atoms in each
// formula, and solves for the coefficients; `tally` keeps score element by element; `solve` writes
// the coefficients in one at a time, and every count on the board updates as it goes. The half-done
// states are real half-done states — a species without its coefficient yet counts as one of itself,
// which is exactly the arithmetic a student is doing mid-question.
//
// The coefficients are not guessed and not searched for. One column per species and one row per
// element gives a matrix whose NULL SPACE is precisely the set of balanced equations:
//
// Fe + O2 -> Fe2O3 Fe [ 1 0 -2 ] (4, 3, 2)ᵀ = 0
// O [ 0 2 -3 ]
//
// so balancing is exact rational elimination — no floating point anywhere, because the answer is an
// integer vector and a float could only ever say "about 4". The result is then reduced to the
// SMALLEST whole numbers, which is why 4/3/2 comes out rather than 8/6/4.
//
// The animation is `rewrite`: each coefficient slides in beside a formula that stays exactly where
// it is, because equal glyphs keep their identity across a rewrite. The equation is never retyped
// and never re-laid-out — which is the difference between watching an equation get balanced and
// watching one picture get replaced by another.
//
// The second half is the reason a solver earns its place: nobody balances permanganate against
// oxalic acid by inspection.
title("balancing: conservation as arithmetic");
canvas("16:9");
template("paper");
text(brand, (640, 30), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
text(head, (640, 74), "an equation is a claim about atoms");
size(head, 30);
bold(head);
color(head, ink);
hidden(head);
// ── ACT 1–3: the one everybody meets first ──
balance(rx, (640, 210), "Fe + O2 -> Fe2O3", 54);
hidden(rx);
tally(rx, (392, 452), 250, 40, 24);
hidden(rx.tally);
text(tallywhy, (392, 566), "atoms in, atoms out");
size(tallywhy, 17);
color(tallywhy, dim);
hidden(tallywhy);
text(notyet, (640, 320), "iron and oxygen do not balance — so this is not an equation yet");
size(notyet, 19);
color(notyet, crimson);
hidden(notyet);
// the working, written out: the coefficients ARE the null space of the atom matrix
equation(mx, (912, 452), `\begin{bmatrix}1 & 0 & -2\\ 0 & 2 & -3\end{bmatrix}\begin{pmatrix}4\\3\\2\end{pmatrix}=\begin{pmatrix}0\\0\end{pmatrix}`, 34);
color(mx, indigo);
hidden(mx);
text(mxwhy, (912, 566), "one row per element — the coefficients are its null space");
size(mxwhy, 16);
color(mxwhy, dim);
hidden(mxwhy);
text(done1, (640, 320), "every atom accounted for — and these are the smallest whole numbers");
size(done1, 19);
color(done1, ink);
hidden(done1);
// ── ACT 4: the one nobody balances by eye ──
balance(ox, (640, 214), "KMnO4 + H2SO4 + H2C2O4 -> K2SO4 + MnSO4 + CO2 + H2O", 32);
hidden(ox);
tally(ox, (392, 466), 250, 34, 22);
hidden(ox.tally);
text(oxlab, (640, 300), "the permanganate–oxalate titration, seven species and six elements");
size(oxlab, 18);
color(oxlab, dim);
hidden(oxlab);
text(oxwhy, (912, 466), "By inspection this takes a page.\nAs a null space it is one answer,\nand it is exact.");
size(oxwhy, 19);
color(oxwhy, ink);
hidden(oxwhy);
text(close, (640, 640), "conservation is not a rule you apply — it is the equation that has to hold");
size(close, 20);
color(close, ink);
hidden(close);
// ── the beats ──
wait(0.4);
show(head, 0.6);
wait(0.3);
show(rx, 0.6);
wait(0.5);
par { show(rx.tally, 0.5); show(tallywhy, 0.4); }
wait(0.8);
show(notyet, 0.5);
wait(1.8);
// the coefficients go in, one at a time, and the tally follows them
fade(notyet, 0.4);
solve(rx, 3.0);
wait(0.6);
show(done1, 0.5);
wait(1.6);
// and where those numbers came from
par { show(mx, 0.6); show(mxwhy, 0.4); }
wait(3.0);
// ── the hard one ──
par {
fade(rx, 0.5);
fade(rx.tally, 0.5);
fade(done1, 0.4);
fade(mx, 0.4);
fade(mxwhy, 0.4);
fade(tallywhy, 0.4);
}
par { show(ox, 0.6); show(oxlab, 0.4); }
wait(0.6);
par { show(ox.tally, 0.5); show(oxwhy, 0.5); }
wait(1.0);
solve(ox, 4.2);
wait(0.8);
show(close, 0.6);
wait(3.0);
collision-theory
Why warming it up speeds it up — measured, not asserted. The same mixture over the same
barrier at two temperatures, and every number on screen comes off one trajectory: gas bounces
72 hard discs elastically, rule(cold, "A + B -> C + C when energy > 3.2") says what happens
when two of them meet hard enough, and that is the whole model. The histogram is a MEASUREMENT —
speeds bins the gas’s own speeds while it runs and the gold line over it is the exact
Maxwell–Boltzmann curve for that temperature, so they agree because they are the same gas. The
threshold is in the same unit as the temperature, which makes Ea/kT the Boltzmann exponent: 4% of
collisions clear the barrier at kT = 1.0 and 28% at kT = 2.5, and for 2-D hard discs that
fraction is exp(-Ea/kT) exactly — reproduced by the sim rather than imposed on it. The cold
gas’s tail cannot reach the line; the hot one spills past it. Nothing in the primitive knows the
word chemistry: the same rule with no when clause is an epidemic ("S + I -> I").
// Collision theory — why warming it up speeds it up
//
// Two runs of the SAME mixture over the SAME barrier, at two temperatures. Nothing on screen is
// choreographed: `gas` integrates 72 hard discs, `rule` says what happens when two of them meet
// hard enough, and everything else — how often that happens, how fast the product appears, what
// shape the speeds take — is measured off that one trajectory.
//
// rule(cold, "A + B -> C + C when energy > 3.2")
//
// The threshold is the relative kinetic energy along the line of centres, in the same unit as
// `temperature`, so Ea/kT is exactly the Boltzmann exponent. For 2-D hard discs the fraction of
// collisions that clear a barrier is exp(-Ea/kT) — and the engine reproduces that to within two
// percentage points across four (T, Ea) pairs, which is what
// `the_fraction_of_collisions_over_the_barrier_is_the_boltzmann_factor` measures. So:
//
// kT = 1.0 exp(-3.2/1.0) = 4% of collisions react
// kT = 2.5 exp(-3.2/2.5) = 28% of collisions react
//
// 2.5x the temperature, and 7x the fraction over the barrier — plus faster molecules colliding
// more often on top of that. That double effect is the whole lesson, and here it is arithmetic
// rather than assertion.
//
// The histogram is a MEASUREMENT: `speeds` bins the gas's own speeds every few frames while it
// runs, and the gold line over it is the exact Maxwell-Boltzmann curve for that temperature, on the
// same scale. They agree because they are the same gas. (Each panel is normalised to its own peak,
// so read the SHAPE and where the tail reaches, not bar height between panels.)
//
// Both panels share one speed axis (`vmax` on `speeds`), so the cold gas visibly cannot reach where
// the hot one lives.
title("collision theory: the barrier and the tail");
canvas("16:9");
template("black");
text(brand, (640, 28), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
text(head, (640, 62), "A + B → C, over a barrier");
size(head, 30);
bold(head);
color(head, fg);
hidden(head);
text(sub, (640, 96), "same mixture, same barrier, two temperatures");
size(sub, 19);
color(sub, fg);
hidden(sub);
// ── the two gases ──
//
// Identical in every respect but kT. Same seed, so they even start from the same arrangement — the
// only difference between the two runs is how hard the discs are moving.
gas(cold, (348, 404), 460, 344, 72, "temperature=1.0 radius=7 steps=400 seed=5");
species(cold, A, 0.5, cyan);
species(cold, B, 0.5, magenta);
species(cold, C, gold);
rule(cold, "A + B -> C + C when energy > 3.2");
speeds(cold, (930, 300), 460, 150, 12, 13, 5.2);
gas(hot, (348, 404), 460, 344, 72, "temperature=2.5 radius=7 steps=400 seed=5");
species(hot, A, 0.5, cyan);
species(hot, B, 0.5, magenta);
species(hot, C, gold);
rule(hot, "A + B -> C + C when energy > 3.2");
speeds(hot, (930, 300), 460, 150, 12, 13, 5.2);
// Hide the parts, not the whole gas: `draw` animates a trace, not opacity, so a curve that is
// meant to be DRAWN must stay visible and untraced rather than hidden.
hidden(cold.box);
hidden(cold.particles);
hidden(cold.speeds.axis);
hidden(cold.speeds.bars);
untraced(cold.speeds.mb);
hidden(hot.box);
hidden(hot.particles);
hidden(hot.speeds.axis);
hidden(hot.speeds.bars);
untraced(hot.speeds.mb);
// ── the barrier, on the speed axis ──
//
// The panel runs 0 to 5.2 in sim speed units across 460 px from x = 700. A lone disc hitting a
// still partner head-on carries E = m*v^2/4, so v = 2*sqrt(Ea/m) = 3.58 is the speed that clears
// 3.2 by itself: x = 700 + 460*3.58/5.2 = 1016. That is what the line marks — one particular way to
// pay the barrier, and the honest label for it.
line(bar, (1016, 375), (1016, 228));
color(bar, gold);
stroke(bar, 2);
untraced(bar);
text(barlab, (1074, 214), "Eₐ = 3.2 kT");
size(barlab, 17);
color(barlab, gold);
hidden(barlab);
text(barwhy, (1118, 246), "clears Eₐ alone");
size(barwhy, 14);
color(barwhy, fg);
hidden(barwhy);
text(spdlab, (930, 398), "speed → (measured bars, Maxwell–Boltzmann line)");
size(spdlab, 16);
color(spdlab, fg);
hidden(spdlab);
// ── the two reaction curves ──
//
// `timegraph` is the generic sim view, and a gas's state variables are its populations — so this is
// the reactant falling and the product rising, with no chemistry-specific vocabulary. Drawn with
// `draw` over the run's own duration, so the curve arrives exactly as the collisions happen.
timegraph(cold, (818, 570), 96);
timegraph(hot, (1046, 570), 96);
hidden(cold.time.frame);
hidden(cold.time.title);
hidden(cold.time.sweep);
hidden(hot.time.frame);
hidden(hot.time.title);
hidden(hot.time.sweep);
color(cold.time.c1, gold);
color(hot.time.c1, gold);
untraced(cold.time.c0);
untraced(cold.time.c1);
untraced(hot.time.c0);
untraced(hot.time.c1);
text(coldlab, (818, 458), "kT = 1.0");
size(coldlab, 16);
color(coldlab, cyan);
hidden(coldlab);
text(hotlab, (1046, 458), "kT = 2.5");
size(hotlab, 16);
color(hotlab, magenta);
hidden(hotlab);
text(mix, (348, 598), "36 A + 36 B, elastic discs");
size(mix, 16);
color(mix, fg);
hidden(mix);
// ── the readings, one per act ──
text(read1, (348, 636), "4% of collisions clear Eₐ");
size(read1, 22);
color(read1, cyan);
hidden(read1);
text(read2, (348, 636), "28% of collisions clear Eₐ");
size(read2, 22);
color(read2, magenta);
hidden(read2);
equation(bolt, (176, 168), `f=e^{-E_\mathrm{a}/kT}`, 26);
color(bolt, fg);
hidden(bolt);
text(point, (640, 690), "2.5× the temperature — 7× the fraction over the barrier, and more collisions besides");
size(point, 17);
color(point, fg);
hidden(point);
// ── ACT 1: a box of moving discs ──
wait(0.4);
par { show(head, 0.6); show(sub, 0.5); }
wait(0.4);
par { show(cold.box, 0.5); show(mix, 0.4); }
show(cold.particles, 0.6);
wait(0.4);
// ── ACT 2: the cold run. The histogram builds itself while the discs move. ──
par {
run(cold, 9);
draw(cold.time.c0, 9);
draw(cold.time.c1, 9);
seq {
show(cold.speeds.axis, 0.4);
par { show(cold.speeds.bars, 0.5); show(spdlab, 0.4); }
wait(1.2);
draw(cold.speeds.mb, 1.2);
wait(0.6);
par { draw(bar, 0.5); show(barlab, 0.4); }
show(barwhy, 0.4);
wait(0.8);
par { show(cold.time.frame, 0.4); show(coldlab, 0.4); }
wait(1.0);
par { show(read1, 0.5); show(bolt, 0.5); }
}
}
wait(1.4);
// ── ACT 3: same barrier, hotter gas. Only kT changed. ──
par { fade(cold.particles, 0.5); fade(cold.speeds.bars, 0.4); fade(cold.speeds.mb, 0.4); fade(read1, 0.4); }
par { show(hot.particles, 0.5); show(hot.speeds.bars, 0.4); }
wait(0.3);
par {
run(hot, 9);
draw(hot.time.c0, 9);
draw(hot.time.c1, 9);
seq {
draw(hot.speeds.mb, 1.0);
wait(0.6);
par { show(hot.time.frame, 0.4); show(hotlab, 0.4); }
wait(1.2);
show(read2, 0.5);
}
}
wait(0.8);
// ── ACT 4: the two curves, side by side ──
par { pulse(cold.time.c1); pulse(hot.time.c1); }
show(point, 0.6);
wait(3.0);
nmr-ethanol
A spectrometer sweeping, in hertz. One pen moves left to right and everything follows it:
the ink appears under the nib, the frequency readout runs, the camera pushes in on whichever
protons the pen has just reached, and their colour arrives on the turning molecule at the moment
their peak does. The whole rig hangs off ONE number — parameter(sw, …) is the sweep position,
and bind wires it to the ink, the δ readout and the Hz readout at once, so the figure on screen
is always the frequency the pen is actually over rather than a caption timed to look right. Then
it rewinds into the CH₂ peak and the scale changes underneath it: at 0–5 ppm a 7 Hz splitting is
0.0175 ppm, two pixels — it was never one line. The coda is the reason the unit matters. δ 3.70 is
1480 Hz at 400 MHz and 222 Hz at 60 MHz, but J stays 7.0 Hz at both, which is why high field
resolves. Nothing here simulates NMR: the trace is a sum of Lorentzians at literature shifts,
which is what a spectrometer’s output is, so the 3 : 2 : 1 integration falls out of the peak
areas instead of being asserted.
// Ethanol, ¹H NMR — a spectrometer sweeping, in hertz
//
// One pen, moving left to right, and everything else follows it: the ink appears under the nib, the
// frequency readout runs, the camera pushes in on whichever protons the pen has just reached, and
// their colour arrives on the molecule at the moment their peak does. Nothing is cross-cut — it is
// one continuous sweep, which is what a spectrometer actually does.
//
// NOTHING here simulates NMR. The trace is a sum of Lorentzian line shapes at literature chemical
// shifts, which is what a spectrometer's output IS, so the curve is computed and the integration
// ratio falls out of the peak areas rather than being asserted. Everything else is `molecule3`,
// `plot`, `parameter` + `bind`, `orbit3` and core verbs.
//
// THE WHOLE RIG HANGS OFF ONE NUMBER. `parameter(sw, …)` is the sweep position, and `bind` wires it
// to the ink (`trace`), the δ readout and the Hz readout. Animating `sw` moves all of them together
// and in step, so the number on screen is always the frequency the pen is actually over — not a
// caption timed to look right.
//
// bind(sw, trace, trace, "y/5") the ink follows the pen
// bind(sw, dread, value, "5-y") δ, counting down the reversed axis
// bind(sw, hread, value, "(5-y)*400") and the same position in hertz, at 400 MHz
//
// A binding formula receives the parameter as **y**, not x — it is evaluated as `node.eval(0, p)`.
// Using `x` silently freezes the readout at its initial value, which is a good hour lost.
//
// Values (CDCl₃, literature):
// CH₃ δ 1.22, triplet, J = 7.0 Hz, 3H
// CH₂ δ 3.70, quartet, J = 7.0 Hz, 2H
// OH δ 2.60, singlet, 1H — this one genuinely moves. The hydroxyl shift depends on
// concentration, temperature and how dry the solvent is,
// because the proton is exchanging; quoted values run
// from about 1.5 to 5. That is the chemistry, not sloppy
// data.
//
// THE AXIS RUNS BACKWARDS on purpose: an NMR spectrum puts δ = 0 on the RIGHT. The plots are written
// in `u = 5 − δ` and the ticks are labelled by hand with the ppm they stand for.
title("ethanol proton NMR");
canvas("16:9");
template("black");
text(brand, (640, 32), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
// ── the molecule, in its own viewport panel ──
camera3((0, -9.5, 3), (0, 0, 0), 38, perspective, (300, 330), 500, 470);
molecule3(mol, "asset:molecules/ethanol.sdf", (0, 0, 0), 1.7, "style=ball spin=16 axis=z");
// Which hydrogen is which, read off the file's own bond block: a0 is the oxygen, a1 the CH₂ carbon,
// a2 the CH₃ carbon — so a3/a4 are the CH₂ protons, a5/a6/a7 the CH₃ protons, a8 the hydroxyl.
text(mlab, (300, 616), "ethanol · CH₃CH₂OH");
size(mlab, 21); color(mlab, fg); hidden(mlab);
// ── the instrument readout: the number that runs ──
text(field, (1062, 96), "400 MHz");
size(field, 16); color(field, dim); hidden(field);
counter(dread, (1062, 138), 5, 2, "δ ", " ppm");
size(dread, 25); color(dread, fg); hidden(dread);
counter(hread, (1062, 190), 2000, 0, "", " Hz");
size(hread, 34); color(hread, cyan); hidden(hread);
// ── the spectrum ──
field(spec, "3/(1+((x-3.78)/0.035)^2) + 1/(1+((x-2.40)/0.035)^2) + 2/(1+((x-1.30)/0.035)^2)");
coords(ax, (672, 580), (0, 5), (0, 3.4), 110, 92, 1);
hidden(ax);
xtick(t0, ax, 0, "5"); xtick(t1, ax, 1, "4"); xtick(t2, ax, 2, "3");
xtick(t3, ax, 3, "2"); xtick(t4, ax, 4, "1"); xtick(t5, ax, 5, "0");
for i in 0..6 { hidden(t{i}); }
text(axlab, (947, 636), "δ / ppm");
size(axlab, 17); color(axlab, dim); hidden(axlab);
plot(trace, (672, 580), 110, 92, "spec(x,0)", (0, 5));
color(trace, cyan);
stroke(trace, 3);
untraced(trace);
// the pen: a nib riding the trace, and the drop line beneath it
curvedot(nib, trace, 0);
color(nib, gold);
size(nib, 7);
hidden(nib);
// A faint full-height sweep bar, so the pen has a leading edge to travel on. It is a `rect` and not
// a `line` on purpose: a line keeps its END point inside the shape and only its START in `pos`, so
// shifting one stretches it into a diagonal rather than sliding it across. A rect is centred on
// `pos` and moves rigidly.
rect(bar, (672, 421), 2, 318);
color(bar, dim);
opacity(bar, 0.30);
hidden(bar);
// ── the driver, and everything wired to it ──
parameter(sw, (1062, 700), 0, 0, 5, "sweep", 2);
hidden(sw);
bind(sw, trace, trace, "y/5");
bind(sw, dread, value, "5-y");
bind(sw, hread, value, "(5-y)*400");
// ── assignments, revealed as the pen reaches each one ──
text(lch2, (815, 366), "CH₂");
size(lch2, 20); color(lch2, gold); hidden(lch2);
text(sch2, (815, 390), "δ 3.70 · 2H");
size(sch2, 14); color(sch2, dim); hidden(sch2);
text(loh, (936, 458), "OH");
size(loh, 20); color(loh, crimson); hidden(loh);
text(soh, (936, 482), "δ 2.60 · 1H");
size(soh, 14); color(soh, dim); hidden(soh);
text(lch3, (1088, 274), "CH₃");
size(lch3, 20); color(lch3, cyan); hidden(lch3);
text(sch3, (1088, 298), "δ 1.22 · 3H");
size(sch3, 14); color(sch3, dim); hidden(sch3);
text(integ, (947, 224), "areas 3 : 2 : 1 — which is how many protons");
size(integ, 18); color(integ, fg); hidden(integ);
// ── and then, inside one peak ──
// Written in `v = Hz + 16` so the frame's ORIGIN sits at the left edge. Centring the origin on the
// multiplet puts the y-axis straight through the middle of it, which is unreadable — and a Hz-offset
// axis has no business having a y-axis in the middle anyway.
field(quartet, "1/(1+((x-5.5)/1.1)^2) + 3/(1+((x-12.5)/1.1)^2) + 3/(1+((x-19.5)/1.1)^2) + 1/(1+((x-26.5)/1.1)^2)");
// `step` is 7 — the coupling constant itself — so the ticks ARE the spacing being measured, and the
// four lines fall halfway between them. Left to auto-number, 33 integers arrive as one grey smear.
coords(zax, (711, 556), (0, 32), (0, 3.6), 15, 74, 1, 7, 0);
hidden(zax);
xtick(z1, zax, 2, "-14"); xtick(z2, zax, 9, "-7"); xtick(z3, zax, 16, "0");
xtick(z4, zax, 23, "+7"); xtick(z5, zax, 30, "+14");
for i in 1..6 { hidden(z{i}); }
plot(zq, (711, 556), 15, 74, "quartet(x,0)", (0, 32));
color(zq, gold); stroke(zq, 3); untraced(zq);
curvedot(znib, zq, 0);
color(znib, cyan);
hidden(znib);
text(zlab, (951, 616), "Hz from the centre of the CH₂ peak");
size(zlab, 16); color(zlab, dim); hidden(zlab);
// a second pen, in hertz, because that is the unit the splitting lives in
parameter(zsw, (1062, 700), 0, 0, 32, "hz", 1);
hidden(zsw);
counter(zread, (1062, 190), -16, 1, "", " Hz");
size(zread, 34); color(zread, gold); hidden(zread);
bind(zsw, zq, trace, "y/32");
bind(zsw, zread, value, "y-16");
// the coupling constant, measured between the two inner lines
line(jbar, (899, 300), (1004, 300));
color(jbar, fg); stroke(jbar, 2); untraced(jbar);
text(jlab, (951, 274), "J = 7.0 Hz");
size(jlab, 21); color(jlab, fg); hidden(jlab);
text(zwhy, (951, 224), "one peak — four lines");
size(zwhy, 21); color(zwhy, gold); hidden(zwhy);
// ── the coda ──
text(k1, (300, 604), "δ 3.70 is 1480 Hz at 400 MHz");
size(k1, 19); color(k1, cyan); hidden(k1);
text(k2, (300, 632), "and 222 Hz at 60 MHz");
size(k2, 19); color(k2, dim); hidden(k2);
text(k3, (300, 672), "J stays 7.0 Hz at both");
size(k3, 20); color(k3, gold); hidden(k3);
text(k4, (300, 700), "which is why high field resolves");
size(k4, 15); color(k4, dim); hidden(k4);
// ═══ ACT 1: the molecule, turning ═══
wait(0.5);
show(mlab, 0.7);
wait(1.0);
// ═══ ACT 2: the instrument comes up ═══
par { show(ax, 0.7); show(axlab, 0.5); show(field, 0.5); }
par { show(t0, 0.3); show(t1, 0.3); show(t2, 0.3); show(t3, 0.3); show(t4, 0.3); show(t5, 0.3); }
par { show(dread, 0.5); show(hread, 0.5); }
par { show(bar, 0.4); show(nib, 0.4); }
wait(0.6);
// ═══ ACT 3: the sweep ═══
//
// Broken into four legs so the pen can be met at each peak. The legs are proportional to the gaps
// between the peaks, so the pen travels at a CONSTANT rate the whole way across — a spectrometer
// does not slow down for the interesting parts.
//
// 5 ppm over 7.0 s = 1.4 s per ppm. Peaks sit at u = 1.30, 2.40, 3.78.
// leg 1 → the CH₂ peak at u 1.30
par {
to(sw, value, 1.30, 1.82); to(nib, x, 1.30, 1.82);
shift(bar, (143, 0), 1.82);
}
// the pen is on it: the CH₂ protons take the pen's colour, and the camera goes to look
par {
recolor(mol.a3, gold, 0.5); recolor(mol.a4, gold, 0.5);
orbit3(24, 20, 7.9, 0.9);
show(lch2, 0.4); show(sch2, 0.4);
}
wait(0.5);
// leg 2 → the hydroxyl at u 2.40
par {
to(sw, value, 2.40, 1.54); to(nib, x, 2.40, 1.54);
shift(bar, (121, 0), 1.54);
orbit3(-8, 16, 8.8, 1.4);
}
par {
recolor(mol.a8, crimson, 0.5);
orbit3(-34, 26, 7.9, 0.9);
show(loh, 0.4); show(soh, 0.4);
}
wait(0.5);
// leg 3 → the methyl at u 3.78, the tallest peak
par {
to(sw, value, 3.78, 1.93); to(nib, x, 3.78, 1.93);
shift(bar, (152, 0), 1.93);
orbit3(6, 18, 9.0, 1.8);
}
par {
recolor(mol.a5, cyan, 0.5); recolor(mol.a6, cyan, 0.5); recolor(mol.a7, cyan, 0.5);
orbit3(40, 24, 7.8, 0.9);
show(lch3, 0.4); show(sch3, 0.4);
}
wait(0.5);
// leg 4 → run out to δ 0, and pull back to see the whole molecule
par {
to(sw, value, 5, 1.71); to(nib, x, 5, 1.71);
shift(bar, (134, 0), 1.71);
orbit3(0, 18, 9.6, 1.7);
}
show(integ, 0.6);
wait(2.0);
// ═══ ACT 4: go back to the CH₂ peak, and go inside it ═══
//
// The pen runs back to the peak it started with, and then the scale changes underneath it: at 0–5
// ppm a 7 Hz splitting is 0.0175 ppm, two pixels. It was never one line.
// The pen rewinds, and `sw` rewinds with it: the readout counts back UP and the ink retracts,
// because the number on screen has to keep meaning the pen's position. Letting the pen travel while
// the readout sat at 0 Hz would break the one promise the scene makes.
par { fade(integ, 0.4); to(sw, value, 1.30, 1.1); to(nib, x, 1.30, 1.1); shift(bar, (-407, 0), 1.1); }
par { pulse(nib); orbit3(24, 20, 7.8, 1.0); }
wait(0.5);
par {
fade(trace, 0.5); fade(ax, 0.5); fade(axlab, 0.4); fade(bar, 0.4);
fade(nib, 0.4); fade(hread, 0.4); fade(dread, 0.4);
fade(lch3, 0.4); fade(sch3, 0.4); fade(loh, 0.4); fade(soh, 0.4);
fade(lch2, 0.4); fade(sch2, 0.4);
}
par { fade(t0, 0.3); fade(t1, 0.3); fade(t2, 0.3); fade(t3, 0.3); fade(t4, 0.3); fade(t5, 0.3); }
// ═══ ACT 5: the same pen, now measured in hertz ═══
par { show(zax, 0.6); show(zlab, 0.5); show(zwhy, 0.5); }
par { show(z1, 0.3); show(z2, 0.3); show(z3, 0.3); show(z4, 0.3); show(z5, 0.3); }
par { show(znib, 0.4); show(zread, 0.5); }
// the second sweep: 32 Hz, end to end, and the four lines arrive under the nib
par { to(zsw, value, 32, 3.4); to(znib, x, 32, 3.4); }
wait(0.4);
par { draw(jbar, 0.5); show(jlab, 0.5); }
par { pulse(jlab); orbit3(-18, 22, 8.0, 1.2); }
wait(1.8);
// ═══ ACT 6: why the unit matters ═══
par { fade(mlab, 0.4); show(k1, 0.6); }
show(k2, 0.5);
wait(0.7);
par { show(k3, 0.6); pulse(jlab); }
show(k4, 0.5);
par { orbit3(0, 16, 10.0, 2.4); }
wait(3.0);
mechanism-sn2
A mechanism, with the curly arrows aimed at real atoms. Hydroxide and bromoethane — the SN2
every organic course opens with. What makes it a mechanism rather than an equation is the two
curly arrows: one pair of electrons arriving to make a bond, one leaving with the bromide. The
thing to notice is in the source — arrow(att, nuc.a0, sub.a1, 62) aims at an ATOM and
arrow(go, sub.b1, sub.a2, 26) aims at a BOND, both resolving to wherever the structure kit
computed them. Change the bond unit from 90 to 120 and every arrow follows the molecule, because
an arrow anchored to a thing stays anchored to it. No new vocabulary: arrow already bowed into
an arc, it just needed to accept an entity where it used to insist on a coordinate.
// A mechanism, with the curly arrows aimed at real atoms
//
// Hydroxide and bromoethane: the SN2 substitution every organic course opens with. What makes it a
// mechanism rather than an equation is the two curly arrows — one showing a pair of electrons
// arriving to make a bond, one showing a pair leaving with the bromide.
//
// The thing worth noticing about the source is where the arrows are aimed. They are not drawn at
// coordinates worked out by hand:
//
// arrow(att, nuc.a0, sub.a1, 62); // from the hydroxide oxygen, to the carbon
// arrow(go, sub.b1, sub.a2, 26); // from the C-Br BOND, to the bromine
//
// `nuc.a0` is an atom, `sub.b1` is a bond, and both resolve to wherever the structure kit computed
// them. Change `unit` from 90 to 120 and every arrow follows the molecule, because an arrow anchored
// to a thing stays anchored to it. That is the whole reason this scene needs no new vocabulary:
// `arrow` already bows into an arc, and it now takes an entity where it used to insist on a point.
//
// A skeletal carbon is a bare vertex with no letter, so it used to have no entity to aim at — which
// is exactly the atom a mechanism arrow points to. Unlettered atoms now carry an invisible anchor
// for this, addressable as `{id}.a{i}` like any other.
title("SN2: where the electrons go");
canvas("16:9");
template("paper");
text(brand, (640, 30), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
// ── the reactants ──
//
// Bromoethane's atoms come out as a0 (the far carbon), a1 (the one bearing bromine) and a2 (the
// bromine); its bonds as b0 (C-C) and b1 (C-Br).
structure(sub, "CCBr", (400, 290), 90);
structure(nuc, "[OH-]", (210, 440), 90);
hidden(sub);
hidden(nuc);
// The hydroxide sits BELOW AND LEFT of the carbon while the bromine is above and right of it: the
// nucleophile comes in on the opposite side from the leaving group, which is what "backside attack"
// means and is not an accident of the layout.
text(nlab, (210, 492), "nucleophile");
size(nlab, 16); color(nlab, indigo); hidden(nlab);
text(llab, (584, 224), "leaving group");
size(llab, 16); color(llab, crimson); hidden(llab);
// ── the two arrows, anchored to atoms and bonds ──
arrow(att, nuc.a0, sub.a1, 62);
color(att, indigo);
stroke(att, 2.5);
untraced(att);
arrow(go, sub.b1, sub.a2, 26);
color(go, crimson);
stroke(go, 2.5);
untraced(go);
text(alab, (250, 356), "a pair arrives");
size(alab, 15); color(alab, indigo); hidden(alab);
text(glab, (566, 296), "a pair leaves");
size(glab, 15); color(glab, crimson); hidden(glab);
// ── the reaction arrow ──
arrow(rx, (612, 330), (740, 330));
color(rx, ink);
stroke(rx, 3);
untraced(rx);
// ── the products ──
structure(pro, "CCO", (900, 290), 90);
structure(lea, "[Br-]", (1122, 440), 90);
hidden(pro);
hidden(lea);
text(plab, (900, 200), "ethanol");
size(plab, 16); color(plab, dim); hidden(plab);
text(blab, (1122, 492), "bromide");
size(blab, 16); color(blab, dim); hidden(blab);
// ── the reading of it ──
text(sum, (640, 646), "one bond made, one bond broken — and the charge left with the bromide");
size(sum, 19); color(sum, ink); hidden(sum);
// ── ACT 1: the reactants, and which end is which ──
wait(0.4);
par { show(sub, 0.7); show(nuc, 0.7); }
par { show(nlab, 0.4); show(llab, 0.4); }
wait(0.7);
// ── ACT 2: the arrow that makes the bond ──
par { draw(att, 0.9); show(alab, 0.4); }
par { recolor(nuc.O, indigo, 0.5); pulse(nuc.O); }
wait(0.9);
// ── ACT 3: and the arrow that breaks one ──
par { draw(go, 0.7); show(glab, 0.4); }
par { recolor(sub.Br, crimson, 0.5); pulse(sub.Br); }
wait(1.1);
// ── ACT 4: what comes out ──
draw(rx, 0.6);
par { show(pro, 0.7); show(plab, 0.4); }
par { show(lea, 0.6); show(blab, 0.4); }
// the charge is on the bromide now, which is the bookkeeping the arrows were doing
recolor(lea.Br, crimson, 0.5);
pulse(lea.Br);
wait(0.8);
show(sum, 0.6);
wait(3.0);
equilibrium
Why “nothing is happening” is the wrong reading. Two views of one reversible reaction, side
by side, because the misconception lives in the gap between them: the concentrations flatten out
and stop moving, while the two RATES meet at a value that is equal and not zero. A still
picture of the left plot says the reaction stopped; the right plot says both directions are
still running at 0.225 mol dm⁻³ s⁻¹ and cancelling. That is dynamic equilibrium, and it is very
hard to say in a static diagram because the evidence for it is exactly what a flat line hides.
Then 0.50 M of A is added: the rates jump apart, the system relaxes, and it settles at a NEW
position with the SAME ratio — Le Chatelier as what the arithmetic does, not a rule to memorise.
Four fields hold the closed-form solution of d[A]/dt = -kf[A] + kr[B]; there is no new
vocabulary in the scene at all.
// Dynamic equilibrium — why "nothing is happening" is the wrong reading
//
// A reversible first-order reaction, A ⇌ B, with kf = 0.9 and kr = 0.3 per second. Two views of the
// same run, side by side, because the misconception lives in the gap between them:
//
// LEFT the concentrations, which flatten out and stop moving
// RIGHT the two rates, which meet — at a value that is EQUAL and NOT ZERO
//
// A still picture of the left-hand plot says "the reaction stopped". The right-hand plot says it did
// not: both directions are still running, at 0.225 mol dm⁻³ s⁻¹ each, and cancelling. That is the
// whole idea of dynamic equilibrium and it is very hard to say in a static diagram, because the
// evidence for it is precisely the thing a flat line hides.
//
// Then the second act: 0.50 M of A is added at t = 6 s. The rates jump apart, the system relaxes,
// and it settles at a NEW position with the SAME ratio — [B]/[A] = 3.00 either side. Le Chatelier is
// not a rule to memorise here; it is what the arithmetic does.
//
// NO NEW VOCABULARY. Four `field`s hold the closed-form solutions, `plot` draws them, and the
// choreography is `draw` / `show` / `pulse` from the core kit. Every number on screen comes out of
//
// [A](t) = A_eq + ([A]₀ − A_eq)·exp(−(kf + kr)·t)
//
// which is the exact solution of d[A]/dt = −kf[A] + kr[B] with [A] + [B] fixed. Nothing is placed by
// eye: change kf or kr and both plots, both equilibrium positions and the ratio all move together.
title("dynamic equilibrium: equal, not zero");
canvas("16:9");
template("paper");
text(brand, (640, 30), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
// ── the chemistry, as closed forms ──
//
// kf = 0.9, kr = 0.3, so K = kf/kr = 3 and the relaxation rate is kf + kr = 1.2 per second.
// Phase 1 starts from pure A at 1.00 M, so A_eq = 1.00 × kr/(kf+kr) = 0.25.
field(a1, "0.25 + 0.75*exp(-1.2*x)");
field(b1, "0.75 - 0.75*exp(-1.2*x)");
// Phase 2: 0.50 M of A added at t = 6, so the total is 1.50 M and A_eq = 1.50 × 0.25 = 0.375.
// [A] restarts from 0.75 (the 0.25 it had reached, plus the 0.50 added).
field(a2, "0.375 + 0.375*exp(-1.2*(x-6))");
field(b2, "1.125 - 0.375*exp(-1.2*(x-6))");
// ── LEFT: concentrations ──
coords(cc, (110, 600), (0, 14), (0, 1.25), 36, 300, 1);
hidden(cc);
// Explicit labels: the auto-numbering rounds to two significant figures, and a tick at 0.25 that
// prints "0.2" is worse than no tick at all — these are numbers the viewer is meant to read off.
ytick(cy1, cc, 0.25, "0.25");
ytick(cy2, cc, 0.75, "0.75");
ytick(cy3, cc, 1.125, "1.125");
for i in 1..4 { hidden(cy{i}); }
text(clab, (300, 208), "concentration / mol dm⁻³");
size(clab, 17); color(clab, dim); hidden(clab);
plot(ca1, (110, 600), 36, 300, "a1(x,0)", (0, 6));
plot(cb1, (110, 600), 36, 300, "b1(x,0)", (0, 6));
plot(ca2, (110, 600), 36, 300, "a2(x,0)", (6, 14));
plot(cb2, (110, 600), 36, 300, "b2(x,0)", (6, 14));
for i in 1..3 {
color(ca{i}, indigo); stroke(ca{i}, 3); untraced(ca{i});
color(cb{i}, crimson); stroke(cb{i}, 3); untraced(cb{i});
}
text(alab, (578, 512), "[A]");
size(alab, 19); color(alab, indigo); hidden(alab);
text(blab, (578, 252), "[B]");
size(blab, 19); color(blab, crimson); hidden(blab);
// ── RIGHT: the rates, which is where the misconception dies ──
//
// Written as k × concentration rather than pre-multiplied, so the source says what a rate IS.
coords(rc, (700, 600), (0, 14), (0, 0.75), 36, 440, 1);
hidden(rc);
ytick(ry, rc, 0.225, "0.225");
hidden(ry);
text(rlab, (900, 252), "rate / mol dm⁻³ s⁻¹");
size(rlab, 17); color(rlab, dim); hidden(rlab);
plot(rf1, (700, 600), 36, 440, "0.9*a1(x,0)", (0, 6));
plot(rr1, (700, 600), 36, 440, "0.3*b1(x,0)", (0, 6));
plot(rf2, (700, 600), 36, 440, "0.9*a2(x,0)", (6, 14));
plot(rr2, (700, 600), 36, 440, "0.3*b2(x,0)", (6, 14));
for i in 1..3 {
color(rf{i}, indigo); stroke(rf{i}, 3); untraced(rf{i});
color(rr{i}, crimson); stroke(rr{i}, 3); untraced(rr{i});
}
text(flab, (812, 322), "forward, kf[A]");
size(flab, 17); color(flab, indigo); hidden(flab);
text(vlab, (812, 566), "reverse, kr[B]");
size(vlab, 17); color(vlab, crimson); hidden(vlab);
// the point of the whole scene
dot(meet, (916, 501), 6);
color(meet, ink);
hidden(meet);
text(key, (1040, 470), "equal — and not zero");
size(key, 19); color(key, ink); hidden(key);
text(key2, (1078, 496), "both directions still running");
size(key2, 15); color(key2, dim); hidden(key2);
// ── the disturbance at t = 6 s ──
//
// [A] jumps instantly, so it is a vertical line rather than part of a curve. Endpoints are the two
// plots' own coordinates: t=6 is x = 110 + 6·36 = 326 on the left and 700 + 6·36 = 916 on the right.
line(jumpc, (326, 525), (326, 375));
color(jumpc, indigo);
stroke(jumpc, 2);
untraced(jumpc);
line(jumpr, (916, 501), (916, 303));
color(jumpr, indigo);
stroke(jumpr, 2);
untraced(jumpr);
text(add, (392, 356), "+0.50 M of A");
size(add, 16); color(add, indigo); hidden(add);
// ── and the reading of it ──
text(ratio, (640, 688), "[B]/[A] = 3.00 either side — the position moved, the ratio did not");
size(ratio, 18); color(ratio, ink); hidden(ratio);
// ── ACT 1: two empty axes ──
wait(0.4);
par { show(cc, 0.6); show(rc, 0.6); }
par { show(clab, 0.4); show(rlab, 0.4); }
par { show(cy1, 0.3); show(cy2, 0.3); show(cy3, 0.3); show(ry, 0.3); }
wait(0.5);
// ── ACT 2: the approach. Both views at once, because they are one run. ──
par {
draw(ca1, 2.6); draw(cb1, 2.6);
draw(rf1, 2.6); draw(rr1, 2.6);
}
par { show(alab, 0.4); show(blab, 0.4); show(flab, 0.4); show(vlab, 0.4); }
wait(0.7);
// ── ACT 3: the reading a flat line hides ──
par { show(meet, 0.4); pulse(meet); }
show(key, 0.5);
show(key2, 0.4);
wait(2.2);
// ── ACT 4: disturb it ──
par { fade(key, 0.4); fade(key2, 0.4); }
par { draw(jumpc, 0.4); draw(jumpr, 0.4); show(add, 0.4); }
wait(0.5);
// ── ACT 5: it settles somewhere new, at the same ratio ──
par {
draw(ca2, 2.4); draw(cb2, 2.4);
draw(rf2, 2.4); draw(rr2, 2.4);
}
wait(0.6);
show(ratio, 0.6);
wait(3.0);
titration
A titration where the curve is solved, not drawn. 25.0 mL of 0.100 M HCl against 0.100 M
NaOH, phenolphthalein. The shape every student memorises — flat, cliff, flat — except the cliff
arrives while you are watching the burette, which is the part a printed curve cannot say. The
pH is the exact solution of the charge balance [H+] - Kw/[H+] = (Ca·Va - Cb·Vb)/(Va + Vb), so
pH 7.00 at 25.0 mL falls out of the arithmetic instead of being placed; change a concentration
and the cliff moves on its own. And there is no new vocabulary in it: the apparatus is rect,
polygon and circle, the curve is plot over a field, and the choreography is draw / shift /
recolor / stagger — real chemistry teaching out of the core kit.
// A titration, with the curve solved rather than drawn
//
// 25.0 mL of 0.100 M hydrochloric acid, titrated with 0.100 M sodium hydroxide, phenolphthalein
// indicator. The shape every chemistry student is asked to memorise — flat, then a cliff, then flat
// again — and the point of animating it is that the cliff arrives *while you are watching the
// burette*, which is the part a printed curve cannot say.
//
// NOTHING here is a new builtin. The apparatus is rectangles and a polygon, the drops are circles,
// the curve is `plot`, and the choreography is `draw` / `shift` / `recolor` / `fade` from the core
// kit. That is the test this scene is meant to pass: real chemistry teaching out of vocabulary that
// already exists.
//
// The curve is not a drawn S-shape. It is the exact solution of the charge balance
//
// [H+] - Kw/[H+] = (Ca·Va - Cb·Vb) / (Va + Vb)
//
// rearranged to a quadratic and solved, so pH = 7.00 at 25.0 mL FALLS OUT of the arithmetic instead
// of being placed by hand. Change a concentration and the equivalence point moves on its own.
title("a titration, solved not drawn");
canvas("16:9");
template("paper");
text(brand, (640, 30), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
// ── the chemistry, as three reusable fields ──
//
// `field` inlines into any formula, so the same expression could feed a plot, a surface or a shader
// and provably be the same chemistry. Written in three steps because that is how the derivation
// reads, not because the engine needs it.
// excess strong acid (positive) or strong base (negative), diluted by the total volume
field(excess, "0.1*(25-x)/(25+x)");
// [H+] is the positive root of [H+]^2 - excess*[H+] - Kw = 0, with Kw = 1.0e-14. It is written
// TWICE, and the reason is arithmetic rather than chemistry: formulas evaluate in f32, and the two
// algebraically identical forms behave very differently there.
//
// acid side (excess > 0): (excess + sqrt(excess^2 + 4Kw)) / 2 — adds, so nothing cancels
// base side (excess < 0): 2Kw / (sqrt(excess^2 + 4Kw) - excess) — the conjugate form
//
// Use the first form past the equivalence point and it subtracts two nearly equal numbers: 4e-14 is
// eight orders below excess^2, vanishes in f32, and [H+] collapses to zero — log(0) is -inf and the
// whole upper branch silently disappears. The conjugate form divides instead of subtracting, so it
// holds. Checked against a f64 evaluation across 0-50 mL: both branches agree to 0.0000 pH, and
// both give exactly 7.000 at 25.0 mL, which is why they meet rather than merely nearly meet.
field(hacid, "(excess(x,0) + sqrt(excess(x,0)*excess(x,0) + 0.00000000000004))/2");
field(hbase, "0.00000000000002/(sqrt(excess(x,0)*excess(x,0) + 0.00000000000004) - excess(x,0))");
// ── the axes ──
coords(ax, (500, 610), (0, 50), (0, 14), 14, 28, 1, 5, 1);
hidden(ax);
// The axis names are placed by hand rather than passed to `coords`, which puts them at the axis
// END — on top of the arrow tip and the last tick numbers.
text(xname, (860, 668), "NaOH added / mL");
size(xname, 16); color(xname, dim); hidden(xname);
text(yname, (474, 196), "pH");
size(yname, 16); color(yname, dim); hidden(yname);
// the two halves of one curve, split at the equivalence point so the indicator can turn there
// pH = -log10[H+], and log10 is ln/ln(10)
plot(before, (500, 610), 14, 28, "-log(hacid(x,0))/2.302585", (0, 25));
plot(after, (500, 610), 14, 28, "-log(hbase(x,0))/2.302585", (25, 50));
color(before, ink);
color(after, ink);
stroke(before, 3);
stroke(after, 3);
untraced(before);
untraced(after);
// ── the apparatus, out of primitives ──
// the burette: a tube, its tap, and the tip the drops leave from
rect(tube, (180, 300), 26, 280);
outlined(tube);
outline(tube, dim);
stroke(tube, 2);
hidden(tube);
rect(titrant, (180, 300), 18, 272);
color(titrant, indigo);
opacity(titrant, 0.30);
hidden(titrant);
rect(tap, (180, 452), 44, 12);
color(tap, dim);
hidden(tap);
polygon(tip, (180, 464), (186, 472), (180, 486), (174, 472));
color(tip, dim);
hidden(tip);
// the flask, and what is in it
polygon(flask, (134, 642), (172, 556), (188, 556), (226, 642));
outlined(flask);
outline(flask, dim);
stroke(flask, 2);
hidden(flask);
// the solution: colourless while there is acid left, pink once there is not
polygon(soln, (140, 640), (167, 598), (193, 598), (220, 640));
color(soln, dim);
opacity(soln, 0.22);
hidden(soln);
text(caption, (196, 690), "0.100 M NaOH into 25.0 mL");
size(caption, 15);
color(caption, dim);
hidden(caption);
// four drops, reused by falling and fading. Declared up top because a constructor is build-time.
for i in 1..5 {
circle(d{i}, (180, 492), 4);
color(d{i}, indigo);
hidden(d{i});
}
// ── the equivalence point, revealed only after the curve has been through it ──
dot(eq, (850, 414), 6);
color(eq, crimson);
hidden(eq);
text(eqlab, (960, 392), "25.0 mL, pH 7.00");
size(eqlab, 18);
color(eqlab, crimson);
hidden(eqlab);
text(eqwhy, (1002, 418), "both branches solve to 7.00");
size(eqwhy, 15);
color(eqwhy, dim);
hidden(eqwhy);
// ── ACT 1: set the bench up ──
wait(0.4);
par { show(tube, 0.5); show(tap, 0.5); show(tip, 0.4); }
par { show(titrant, 0.5); show(flask, 0.5); show(soln, 0.5); }
par { show(ax, 0.7); show(xname, 0.5); show(yname, 0.5); show(caption, 0.5); }
wait(0.6);
// ── ACT 2: the flat part. Drops fall, and almost nothing happens to the pH. ──
//
// This is the half of a titration that surprises people: a quarter of the base is in and the pH has
// moved by less than one unit, because a strong acid buffers itself by sheer excess.
par {
draw(before, 3.4);
stagger(0.55) {
par { show(d1, 0.1); shift(d1, (0, 64), 0.5); fade(d1, 0.15); }
par { show(d2, 0.1); shift(d2, (0, 64), 0.5); fade(d2, 0.15); }
par { show(d3, 0.1); shift(d3, (0, 64), 0.5); fade(d3, 0.15); }
par { show(d4, 0.1); shift(d4, (0, 64), 0.5); fade(d4, 0.15); }
}
}
// ── ACT 3: the endpoint. One drop, and the indicator turns. ──
par { recolor(soln, crimson, 0.45); pulse(soln); }
par { show(eq, 0.4); show(eqlab, 0.4); }
wait(0.9);
show(eqwhy, 0.5);
wait(1.0);
// ── ACT 4: past it, and flat again ──
draw(after, 2.6);
wait(2.6);
reaction-esterification
A + B → C, the way a textbook draws it — and there is no reaction builtin behind it.
Acetic acid and ethanol give ethyl acetate and water; every molecule is a SMILES string laid out
by the chemistry kit, and the plus signs, the arrow, the conditions and the whole choreography
are the core kit. What the kit contributes is that the structures are REAL: the geometry is
computed from the graph, so the ester bond in the product is the same bond that formed. The
teaching beat is the middle one — the acid’s -OH and the alcohol’s -H are marked BEFORE the
arrow, and the same colour turns up in the water afterwards, so “where did the water come from?”
is answered by watching rather than by being told. SMILES atoms are numbered in written order,
which is why acid.a3 is the hydroxyl oxygen that leaves.
// A + B → C, the way a textbook draws it
//
// Esterification: acetic acid and ethanol give ethyl acetate and water. The classic school
// reaction, and the classic school diagram — reactants, a plus, an arrow with its conditions,
// products.
//
// There is no reaction builtin in Manic and this scene does not need one. Every molecule is a
// SMILES string laid out by the chemistry kit; the plus signs, the arrow, the conditions and the
// whole choreography are the core kit. What the kit contributes is that the structures are real:
// the geometry is computed from the graph, not drawn by hand, so the ester bond in the product is
// the same bond that formed.
//
// The teaching beat is the middle one. The acid's -OH and the alcohol's -H are marked BEFORE the
// arrow, and the same colour turns up in the water afterwards — so the question "where did the
// water come from?" is answered by watching, not by being told.
title("esterification");
canvas("16:9");
template("paper");
text(brand, (640, 40), "maniclang.com");
display(brand);
size(brand, 18);
color(brand, dim);
text(heading, (640, 108), "acid + alcohol gives an ester, and water");
size(heading, 30);
color(heading, ink);
hidden(heading);
// ── the cast, all from SMILES strings ──
structure(acid, "CC(=O)O", (190, 348), 76);
structure(alc, "CCO", (487, 348), 76);
structure(ester, "CC(=O)OCC", (900, 348), 66);
structure(water, "O", (1198, 348), 60);
// Everything starts as bare geometry, drawn on beat by beat.
untraced(acid.bonds);
untraced(alc.bonds);
untraced(ester.bonds);
hidden(acid.labels);
hidden(alc.labels);
hidden(ester.labels);
hidden(water);
// names under each structure
text(n1, (190, 500), "acetic acid");
text(n2, (487, 500), "ethanol");
text(n3, (900, 500), "ethyl acetate");
text(n4, (1198, 500), "water");
for i in 1..5 {
size(n{i}, 21);
color(n{i}, dim);
hidden(n{i});
}
// the arithmetic of the diagram
text(plus1, (348, 352), "+");
text(plus2, (1105, 352), "+");
size(plus1, 34); size(plus2, 34);
color(plus1, ink); color(plus2, ink);
hidden(plus1); hidden(plus2);
// the arrow, and what sits over it
arrow(rx, (640, 352), (782, 352));
color(rx, ink);
stroke(rx, 2.6);
untraced(rx);
text(cond, (711, 312), "H2SO4");
size(cond, 19);
color(cond, crimson);
hidden(cond);
text(cond2, (711, 390), "heat");
size(cond2, 19);
color(cond2, dim);
hidden(cond2);
// what the marked atoms become — declared up front, revealed later
text(note, (640, 600), "the acid gives up an OH, the alcohol an H — together, water");
size(note, 22);
color(note, crimson);
hidden(note);
// ── 1. the reactants, written on ──
wait(0.5);
show(heading, 0.6);
wait(0.3);
stagger(0.09) { draw(acid.bonds, 0.45); }
par { show(acid.labels, 0.5); show(n1, 0.4); }
wait(0.35);
show(plus1, 0.3);
stagger(0.09) { draw(alc.bonds, 0.45); }
par { show(alc.labels, 0.5); show(n2, 0.4); }
wait(1.0);
// ── 2. mark what is about to leave ──
//
// SMILES atoms are numbered in the order they are written, so `CC(=O)O` puts its hydroxyl oxygen
// last — a3 — and `CCO` puts its oxygen at a2. That is the -OH and the -H that become water.
par {
recolor(acid.a3, crimson, 0.6);
recolor(alc.a2, crimson, 0.6);
}
par { pulse(acid.a3); pulse(alc.a2); }
wait(1.2);
// ── 3. the arrow ──
par {
draw(rx, 0.7);
show(cond, 0.5);
show(cond2, 0.5);
}
wait(0.8);
// ── 4. the product, and the water it leaves behind ──
stagger(0.07) { draw(ester.bonds, 0.4); }
par { show(ester.labels, 0.5); show(n3, 0.4); }
wait(0.5);
show(plus2, 0.3);
par { show(water, 0.6); show(n4, 0.4); }
// the water carries the colour of the atoms it was made from
recolor(water.O, crimson, 0.7);
par { pulse(water.O); show(note, 0.6); }
wait(2.6);
// ── 5. and the bond that was made ──
framebox(made, ester.O, 16, crimson);
hidden(made);
text(bond, (640, 600), "and the bond that was made joins them");
size(bond, 21);
color(bond, dim);
hidden(bond);
par {
fade(note, 0.4);
show(made, 0.6);
show(bond, 0.5);
}
wait(2.8);
reaction-ord-screen
One real experiment, from the record. Open Reaction Database ord-00550a5de34040cea861e1ce0aca6f9e: a C-N coupling run in a single well of a 1536-well plate. Five solutions assemble across the top in the order the robot added them, each drawn on from the record’s own SMILES, with the record’s own amounts; then the conditions; then the outcome. The scheme is never taken away, so the last frame holds the whole experiment at once — which is the only honest way to show a result that depends on all of it. And the result is the point: 0.000% of the product they were aiming at, 5.201% of the other isomer. The reaction went, just not where they aimed it. The apparatus is RIGGED rather than slid — each instrument is split across two SVG files so its moving part is its own entity, and the head dips, the foil seals, the mixer block shakes and its indicator heats, all on core verbs.
// A C–N coupling, built the way the record reads it
//
// Open Reaction Database ord-00550a5de34040cea861e1ce0aca6f9e — Cernak lab, Michigan,
// doi 10.1021/jacs.6c05959. Sample XZ_01-115-60_3_K3.
//
// The scheme ASSEMBLES and then stays: five solutions across the top in the order the robot added
// them, the arrow and its conditions, then the outcome. Nothing is taken away, so by the last frame
// the whole experiment is on screen at once — which is how the record itself is laid out, and the
// only honest way to show a reaction whose answer depends on all of it.
//
// Every structure is drawn on from the record's own SMILES. Every number is the record's own,
// including the two that matter: 0.000% of the product they wanted, and 5.201% of the isomer they
// did not.
title("a C-N coupling, one well of 1536");
canvas("16:9");
template("paper");
text(brand, (640, 30), "maniclang.com");
display(brand);
size(brand, 15);
color(brand, dim);
// ── the apparatus, RIGGED: parts that move are their own entities ──
//
// Each instrument is split across files so that its moving part is a separate manic entity.
// A machine imported as one file can only be shifted as a blob, which is motion that ignores the
// chemistry; imported as parts, the head travels while the frame holds still and the mixer block
// shakes while its feet do not. Nothing here needs a new builtin — `shift`, `recolor`, `shake` and
// `pulse` are the core kit, and they work because `svg()` emits a native entity per subpath rather
// than a texture.
//
// The rail is a `rect`, not artwork: it is a straight line whose length has to match the row it
// serves, so a primitive is both simpler and parametric.
rect(rail, (574, 43), 1012, 4);
color(rail, dim);
opacity(rail, 0.4);
hidden(rail);
// The head hangs off the rail. Placement is arithmetic, not eyeballing: the two files share one
// coordinate system, so with the head at 62px wide (scale 62/144 = 0.43) its centre sits
// (74 - 127) * 0.43 = 23px below the rail, and the tips reach 30px below that. Rest is 66, so
// the tips sit at 96 and a 10px dip reaches 106 — still clear of the role labels at 118.
svg(hd, (150, 66), "asset:svg/chem/liquid-handler-head.svg", 62);
hidden(hd);
// the plate the additions go into, off at the end of the rail
svg(plate, (1180, 132), "asset:svg/chem/plate-1536.svg", 170);
hidden(plate);
// the foil seal, waiting off-frame to the right — a 0.2 microlitre well does not survive
// eighteen hours at 60 C unsealed
svg(foil, (1292, 132), "asset:svg/chem/foil-seal.svg", 170);
hidden(foil);
// The mixer, in the empty quarter under the arrow. Same trick: body and block are separate files,
// so `shake` moves the block alone.
svg(mxBody, (280, 582), "asset:svg/chem/thermomixer-body.svg", 200);
hidden(mxBody);
svg(mxBlock, (280, 535), "asset:svg/chem/thermomixer-block.svg", 165);
hidden(mxBlock);
// the heat indicator changes over time, so it is a primitive rather than baked artwork
circle(led, (240, 577), 5);
color(led, dim);
hidden(led);
// ── the five solutions, left to right, in addition order ──
//
// Each cell is the reagent and the DMSO it arrived in, because that is what went into the well.
structure(a1, "C[Si](C)(C)[O-].[Na+]", (108, 196), 30);
// The ligand comes from a 2-D depiction FILE, not its SMILES — and the reason is worth knowing.
// The layout grows ALONG the string, so the order the ring closures are written in matters: the
// record's own `COC1=CC=NC2=C3N=CC=C(OC)C3=CC=C12` strands a bond 3.6 lengths long and is refused,
// while PubChem's canonical form of the SAME molecule draws cleanly. Rather than quietly swap in a
// different string than the record's, use the depiction — which is what the refusal points at.
// Everything else in the scene is the record's SMILES, verbatim.
structure(a2, "asset:molecules/dimethoxyphenanthroline-2d.sdf", (330, 200), 21);
structure(a3, "[Cu]O[Cu]", (556, 196), 34);
structure(a4, "C1=CC=C(C2CCNCC2)C=C1", (760, 196), 28);
structure(a5, "IC1=CC=CN=C1", (960, 196), 32);
structure(d1, "CS(C)=O", (196, 196), 22);
structure(d2, "CS(C)=O", (430, 196), 22);
structure(d3, "CS(C)=O", (640, 196), 22);
structure(d4, "CS(C)=O", (856, 196), 22);
structure(d5, "CS(C)=O", (1044, 196), 22);
// Written out rather than looped: `a{i}.bonds` does not interpolate — a loop index reaches an id
// but not a dotted tag on it, which is a known gap logged in CAPABILITIES.
untraced(a1.bonds); untraced(a2.bonds); untraced(a3.bonds); untraced(a4.bonds); untraced(a5.bonds);
untraced(d1.bonds); untraced(d2.bonds); untraced(d3.bonds); untraced(d4.bonds); untraced(d5.bonds);
hidden(a1.labels); hidden(a2.labels); hidden(a3.labels); hidden(a4.labels); hidden(a5.labels);
hidden(d1.labels); hidden(d2.labels); hidden(d3.labels); hidden(d4.labels); hidden(d5.labels);
// role, name, amount — kept to three short lines per cell
text(r1, (150, 118), "base");
text(r2, (378, 118), "ligand");
text(r3, (598, 118), "catalyst");
text(r4, (806, 118), "nucleophile");
text(r5, (1004, 118), "electrophile");
for i in 1..6 { size(r{i}, 19); color(r{i}, ink); hidden(r{i}); }
text(v1, (150, 286), "0.08 umol");
text(v2, (378, 286), "0.004 umol");
text(v3, (598, 286), "0.004 umol");
text(v4, (806, 286), "0.06 umol");
text(v5, (1004, 286), "0.04 umol");
for i in 1..6 { size(v{i}, 17); color(v{i}, crimson); hidden(v{i}); }
// the addition-order strip, which is the whole point of showing them in a row
text(o1, (150, 318), "1");
text(o2, (378, 318), "2");
text(o3, (598, 318), "3");
text(o4, (806, 318), "4");
text(o5, (1004, 318), "5");
for i in 1..6 { size(o{i}, 22); color(o{i}, dim); hidden(o{i}); }
rect(strip, (577, 318), 1010, 34);
color(strip, dim);
outlined(strip);
stroke(strip, 1.2);
opacity(strip, 0.35);
hidden(strip);
// ── the arrow, and what happens over it ──
arrow(rx, (240, 470), (470, 470));
color(rx, ink);
stroke(rx, 3);
untraced(rx);
text(c1, (355, 418), "60 °C · dry nitrogen");
text(c2, (355, 444), "800 rpm · 18 h");
for i in 1..3 { size(c{i}, 19); color(c{i}, ink); hidden(c{i}); }
// ── the outcome ──
structure(p1, "C1(N2CCC(C3=CC=CC=C3)CC2)=CC=CN=C1", (612, 500), 30);
structure(p2, "Cn1c(=O)c2c(ncn2C)n(C)c1=O", (826, 500), 28);
structure(p3, "C1(N2CCC(C3=CC=CC=C3)CC2)=CC=NC=C1", (1010, 500), 30);
untraced(p1.bonds); untraced(p2.bonds); untraced(p3.bonds);
hidden(p1.labels); hidden(p2.labels); hidden(p3.labels);
text(y1, (612, 620), "0.000%");
size(y1, 30); color(y1, crimson); hidden(y1);
text(y2, (826, 620), "standard");
size(y2, 20); color(y2, dim); hidden(y2);
text(y3, (1010, 620), "5.201%");
size(y3, 30); color(y3, indigo); hidden(y3);
text(y1b, (612, 654), "the target");
size(y1b, 17); color(y1b, dim); hidden(y1b);
text(y3b, (1010, 654), "the other isomer");
size(y3b, 17); color(y3b, dim); hidden(y3b);
text(cite, (640, 700), "ORD ord-00550a5de34040cea861e1ce0aca6f9e · doi 10.1021/jacs.6c05959");
size(cite, 13); color(cite, dim); hidden(cite);
// ── ACT 1: where this happens ──
wait(0.4);
par { show(plate, 0.7); show(cite, 0.5); }
par { show(rail, 0.5); show(hd, 0.6); show(strip, 0.5); }
// the head is charged, and stays charged: the tips are `hd.p3`..`hd.p6`, four of the seven
// subpaths in the head file, addressable because an imported SVG is entities and not a picture
par {
recolor(hd.p3, indigo, 0.4);
recolor(hd.p4, indigo, 0.4);
recolor(hd.p5, indigo, 0.4);
recolor(hd.p6, indigo, 0.4);
}
wait(0.6);
// ── ACT 2: five additions. The head DIPS at each one; each cell draws on and STAYS. ──
//
// The dip is the beat: the head goes down as the reagent goes in, so the machine is doing the
// thing the addition-order strip is counting, rather than sliding past it.
par { show(r1, 0.3); show(o1, 0.3); }
par { shift(hd, (0, 10), 0.25); pulse(hd); }
par { draw(a1.bonds, 0.7); draw(d1.bonds, 0.5); }
par { show(a1.labels, 0.4); show(d1.labels, 0.4); show(v1, 0.4); }
shift(hd, (0, -10), 0.25);
par { shift(hd, (228, 0), 0.5); show(r2, 0.3); show(o2, 0.3); }
par { shift(hd, (0, 10), 0.25); pulse(hd); }
par { draw(a2.bonds, 0.9); draw(d2.bonds, 0.5); }
par { show(a2.labels, 0.4); show(d2.labels, 0.4); show(v2, 0.4); }
shift(hd, (0, -10), 0.25);
par { shift(hd, (220, 0), 0.5); show(r3, 0.3); show(o3, 0.3); }
par { shift(hd, (0, 10), 0.25); pulse(hd); }
par { draw(a3.bonds, 0.6); draw(d3.bonds, 0.5); }
par { show(a3.labels, 0.4); show(d3.labels, 0.4); show(v3, 0.4); }
shift(hd, (0, -10), 0.25);
par { shift(hd, (208, 0), 0.5); show(r4, 0.3); show(o4, 0.3); }
par { shift(hd, (0, 10), 0.25); pulse(hd); }
par { draw(a4.bonds, 0.9); draw(d4.bonds, 0.5); }
par { show(a4.labels, 0.4); show(d4.labels, 0.4); show(v4, 0.4); }
shift(hd, (0, -10), 0.25);
par { shift(hd, (198, 0), 0.5); show(r5, 0.3); show(o5, 0.3); }
par { shift(hd, (0, 10), 0.25); pulse(hd); }
par { draw(a5.bonds, 0.7); draw(d5.bonds, 0.5); }
par { show(a5.labels, 0.4); show(d5.labels, 0.4); show(v5, 0.4); }
shift(hd, (0, -10), 0.25);
// the limiting reagent, marked where it stands
par { recolor(v5, indigo, 0.5); pulse(a5.I); }
wait(0.8);
// ── ACT 3: seal it, heat it, shake it. ──
//
// Three separate motions on three separate parts, which is the whole reason the instruments were
// split into files: the foil travels, the block shakes, the indicator changes colour.
par { fade(hd, 0.4); fade(rail, 0.4); }
par { show(foil, 0.3); shift(foil, (-112, 0), 0.7); }
wait(0.2);
par { show(mxBody, 0.5); show(mxBlock, 0.5); show(led, 0.4); }
par { draw(rx, 0.8); show(c1, 0.4); show(c2, 0.4); }
recolor(led, crimson, 0.5); // 60 C, and the block starts to move
shake(mxBlock, 0.5);
shake(mxBlock, 0.5);
shake(mxBlock, 0.5);
shake(mxBlock, 0.5);
wait(0.7);
// ── ACT 4: the outcome — A + B → C, and what actually came out ──
// the bench has done its job; the chemistry is what is left
par { fade(mxBody, 0.5); fade(mxBlock, 0.5); fade(led, 0.4); }
par { draw(p2.bonds, 0.7); show(p2.labels, 0.4); }
show(y2, 0.4);
wait(0.5);
par { draw(p1.bonds, 0.9); show(p1.labels, 0.4); }
par { show(y1, 0.5); show(y1b, 0.4); }
wait(1.0);
par { draw(p3.bonds, 0.9); show(p3.labels, 0.4); }
par { show(y3, 0.5); show(y3b, 0.4); }
wait(1.2);
// the one difference between them, marked on both
par { recolor(p1.N, crimson, 0.6); recolor(p3.N, indigo, 0.6); }
par { pulse(p1.N); pulse(p3.N); }
wait(1.0);
// No closing line. The two marked nitrogens and the two numbers under them are the reading of it,
// and saying it in words as well only tells the viewer what they have just been shown.
wait(3.4);
structure-to-shape
The step students actually get stuck on. A structural formula is a page of lines and
letters; a molecule is a lump in three dimensions, and told they are the same thing most
people nod and privately keep two unrelated pictures. So the formula writes itself on bond by
bond (untraced + stagger { draw(...) } — core verbs, nothing chemistry-specific), then the
same compound’s 3-D conformer appears beside it, turning. Then recolor marks the four
nitrogens on BOTH sides at once, because each kit tags an atom by its element — so “the
nitrogens” is something you can say to the scene rather than a set of indices to look up.
The left side is read from the 2-D depiction record, the right from the 3-D conformer.
// The flat formula and the real shape are the same molecule
//
// This is the step students actually get stuck on. A structural formula is a page of lines and
// letters; a molecule is a lump in three dimensions. Told they are the same thing, most people
// nod and privately keep two unrelated pictures.
//
// So: draw the formula on, bond by bond, the way you would on paper. Then put the real geometry
// beside it, turning, from the same compound in PubChem. Both sides are read from files — the
// left from the 2-D depiction record, the right from the 3-D conformer — so nothing here is an
// artist's impression of the other.
title("the formula and the shape");
canvas("16:9");
template("paper");
text(brand, (640, 42), "maniclang.com");
display(brand);
size(brand, 19);
color(brand, dim);
// The 3-D scene covers the whole canvas, so the molecule is put at the ORIGIN and the camera is
// aimed off to the left instead — which slides the subject into the right half of the frame and
// keeps it there while it turns. Moving the molecule sideways instead pushes it toward the camera
// and it grows and clips.
camera3((0, -18, 5), (-4.2, 0, 0), 32);
// ── the page: a structural formula, drawn by hand ──
structure(flat, "asset:molecules/caffeine-2d.sdf", (350, 372), 54);
untraced(flat.bonds);
hidden(flat.labels);
// ── the substance: the same compound's 3-D conformer ──
molecule3(solid, "asset:molecules/caffeine.sdf", (0, 0, 0), 0.66, "spin=22");
hidden(solid);
text(t1, (350, 128), "caffeine, as it is written");
size(t1, 26); color(t1, ink); hidden(t1);
text(t2, (350, 660), "one line per bond, letters where a chemist letters them");
size(t2, 19); color(t2, dim); hidden(t2);
text(t3, (966, 128), "caffeine, as it is");
size(t3, 26); color(t3, ink); hidden(t3);
text(t4, (966, 660), "the same compound, its computed 3-D conformer");
size(t4, 19); color(t4, dim); hidden(t4);
// ── beat 1: write the formula ──
wait(0.6);
show(t1, 0.5);
// Drawn bond by bond, in the file's own order — the handwriting effect, from the core kit's
// `stagger` and `draw`. Nothing chemistry-specific about it.
//
// Addressed by TAG, not by index: a skeletal drawing skips every C–H bond, so the surviving bond
// ids are the file's own numbering with gaps in it. `{id}.bonds` is whatever was actually drawn.
stagger(0.05) {
draw(flat.bonds, 0.4);
}
show(flat.labels, 0.6);
show(t2, 0.5);
wait(1.4);
// ── beat 2: and here is the thing itself ──
par { show(solid, 0.9); show(t3, 0.5); }
show(t4, 0.5);
wait(2.0);
// ── beat 3: the same nitrogens, on both sides at once ──
//
// Four nitrogens on the page and four in the solid. Recolouring them is one verb per side, because
// both kits tag an atom by its element — so "the nitrogens" is something you can say to the
// scene, not a set of indices you have to look up.
text(t5, (640, 596), "four nitrogens, the same four");
size(t5, 23); color(t5, indigo); hidden(t5);
// `recolor`, not `color`: `color` is a build-time setting and belongs above the timeline, while
// `recolor` is the verb that changes a colour DURING the story.
par {
recolor(flat.N, indigo, 0.7);
recolor(solid.N, indigo, 0.7);
show(t5, 0.5);
}
wait(2.6);
// ── beat 4: and the oxygens ──
text(t6, (640, 596), "and the two oxygens");
size(t6, 23); color(t6, red); hidden(t6);
par { fade(t5, 0.35); }
par {
pulse(flat.O);
pulse(solid.O);
show(t6, 0.5);
}
wait(2.8);
molecule-shapes
Why molecules have shapes — water bent, ammonia pyramidal, methane tetrahedral, carbon dioxide linear, each turning on its own axis. The labels are shape CLASSES, not measurements: the geometry is the file’s and there is no number in the script to disagree with it. Then caffeine four ways — ball & stick, sticks, hydrogens hidden (the skeleton a chemist draws), and space-filling.
// Why molecules have shapes
//
// Four molecules, four shapes — and not one coordinate in this file. Each `molecule3` reads a
// PubChem structure file (a real "Download → SDF"), so every bond angle you see is the one in the
// conformer someone computed: water bent, ammonia pyramidal, methane tetrahedral, carbon dioxide
// dead straight.
//
// They turn on their own axes because that is the default — and it is the whole argument for doing
// this in three dimensions. On a flat page methane is four lines from a dot and "tetrahedral" is a
// claim you take on trust; turning, the shape is simply visible. Same for ammonia's pyramid, which
// flat looks like a T.
title("why molecules have shapes");
canvas("16:9");
template("black");
text(brand, (640, 44), "maniclang.com");
display(brand);
size(brand, 20);
color(brand, dim);
// Looking down at about 30°, not along the plane: caffeine is FLAT, and edge-on a flat molecule
// is a line. The elevation also happens to be what makes methane's tetrahedron read.
camera3((0, -15, 8.5), (0, 0, 0), 40);
// Four PubChem downloads, laid out left to right at one magnification, so they are genuinely
// comparable in size. `spin` is degrees per second — the default; `spin=0` would hold one still.
molecule3(waterm, "asset:molecules/water.sdf", (-6.4, 0, 0), 1.5, "spin=26");
molecule3(ammonia, "asset:molecules/ammonia.sdf", (-2.1, 0, 0), 1.5, "spin=26");
molecule3(methane, "asset:molecules/methane.sdf", (2.2, 0, 0), 1.5, "spin=26");
molecule3(co2, "asset:molecules/carbon-dioxide.sdf", (6.6, 0, 0), 1.5, "spin=26");
hidden(waterm);
hidden(ammonia);
hidden(methane);
hidden(co2);
// Formulae and shape CLASSES — not measurements. The geometry on screen is the file's, and nothing
// here restates it as a number.
text(t1, (196, 560), "H2O");
text(s1, (196, 600), "bent");
text(t2, (452, 560), "NH3");
text(s2, (452, 600), "pyramidal");
text(t3, (760, 560), "CH4");
text(s3, (760, 600), "tetrahedral");
text(t4, (1060, 560), "CO2");
text(s4, (1060, 600), "linear");
for i in 1..5 {
hidden(t{i});
hidden(s{i});
size(t{i}, 30);
size(s{i}, 22);
color(s{i}, dim);
}
color(t1, red);
color(t2, blue);
color(t3, silver);
color(t4, red);
// ── one at a time, each already turning as it arrives ──
wait(0.7);
par { show(waterm, 0.7); show(t1, 0.5); show(s1, 0.5); }
wait(0.9);
par { show(ammonia, 0.7); show(t2, 0.5); show(s2, 0.5); }
wait(0.9);
par { show(methane, 0.7); show(t3, 0.5); show(s3, 0.5); }
wait(0.9);
par { show(co2, 0.7); show(t4, 0.5); show(s4, 0.5); }
wait(1.6);
// ── the four styles a chemistry viewer offers, on the one molecule ──
//
// Same file, same coordinates, four questions. Ball-and-stick shows what is bonded to what;
// sticks gets the spheres out of the way of the geometry; hiding the hydrogens leaves the carbon
// skeleton an organic chemist actually draws; space-filling shows the shape the molecule presents
// to the world — which is the one that explains why it fits somewhere, or does not.
par {
fade(waterm, 0.5); fade(ammonia, 0.5); fade(methane, 0.5); fade(co2, 0.5);
fade(t1, 0.4); fade(s1, 0.4); fade(t2, 0.4); fade(s2, 0.4);
fade(t3, 0.4); fade(s3, 0.4); fade(t4, 0.4); fade(s4, 0.4);
}
// No `tilt` here on purpose. Caffeine is FLAT and lies in the xy plane, so turning it about the
// vertical keeps it in its own plane and it presents the same face to the camera throughout. Lean
// it with `tilt` and the spin walks it through edge-on, where a flat molecule is a line — `tilt`
// and `axis=x|y|z` are there for when you want that (a molecule rolling end over end), not for a
// comparison like this one.
molecule3(c1, "asset:molecules/caffeine.sdf", (-8.4, 0, 0), 0.52, "style=ball");
molecule3(c2, "asset:molecules/caffeine.sdf", (-2.8, 0, 0), 0.52, "style=sticks");
molecule3(c3, "asset:molecules/caffeine.sdf", (2.8, 0, 0), 0.52, "style=sticks hydrogens=0");
molecule3(c4, "asset:molecules/caffeine.sdf", (8.4, 0, 0), 0.52, "style=spacefill");
hidden(c1); hidden(c2); hidden(c3); hidden(c4);
text(cap, (640, 130), "caffeine, four ways to look at it");
hidden(cap);
size(cap, 28);
color(cap, cyan);
text(k1, (222, 600), "ball & stick");
text(k2, (492, 600), "sticks");
text(k3, (770, 600), "no hydrogens");
text(k4, (1052, 600), "space-filling");
for i in 1..5 {
hidden(k{i});
size(k{i}, 21);
color(k{i}, dim);
}
show(cap, 0.5);
par { show(c1, 0.6); show(k1, 0.4); }
wait(0.7);
par { show(c2, 0.6); show(k2, 0.4); }
wait(0.7);
par { show(c3, 0.6); show(k3, 0.4); }
wait(0.7);
par { show(c4, 0.6); show(k4, 0.4); }
wait(3.0);