Spent half a day making a rotating ASCII donut in Rust. Followed the math in a1k0n's donut explanation, then wrote the implementation myself.
drawing
Started with printf and a small drawing API to put a character at a position and keep the math separate. Found the escape codes through Ghostty's terminal docs. \x1b[2J clears the display, \x1b[{r};{v}H positions the cursor, and \x1b[?25l hides it. Terminal coordinates start at one, so r = y + 1 and v = x + 1.
Considered clearing and redrawing every frame. Ended up keeping everything between draws in a Window struct. Width w, height h, current frame, previous p_frame, output buffer and depth zbuf.
The frame buffers are flat Vec<u8> arrays with w * h cells, indexed in row-major order with y * w + x. Each cell stores a shade index, one byte instead of Rust's four-byte char. The small shades table holds the actual characters.
Each frame resets frame and zbuf, keeping p_frame for comparison. Collect cursor moves and changed characters into the reusable output buffer, lock stdout once, write and flush. Spaces erase whatever moved away. Used terminal_size for dimensions. Resizing rebuilds Window and recalculates f, with a full display clear only then and at startup.
the math
Start with a circle, move it away from the origin, rotate it around the y-axis. That's the torus. Two more rotations, and , animate the whole thing. Expanded the rotation matrices directly using Rust's sin_cos() for the trig. Sample more densely around the larger ring, 300 steps for phi, 180 for thetha.
projection
The camera sits at the origin looking down z. In my notes, rC, rT, and ofo become radiusCirle, radiusTorus, and offsetFromOrigo in the code. Initially missed + ofo in z, so nothing rendered. The notes try different framing values. This is what I used in the implementation.
const preF: f32 = offsetFromOrigo / ((radiusCirle + radiusTorus) * 4.0);
let f = preF * h as f32;
let zInv = 1.0 / z;
let xP = (x * zInv * f) as i32 + (w / 2) as i32;
let yP = -(y * zInv * f) as i32 + (h / 2) as i32;
h is terminal height, w is width. Similar triangles give the x / z and y / z scaling, with f setting the image size. The casts truncate to cells, adding half the dimensions centers the result, and negating y makes rows run downwards. z already includes offsetFromOrigo. Reuse zInv for depth too, larger means closer. Skip projected cells outside the terminal before indexing.
lighting
Started by deriving the reflection vector, then realized I only needed Lambert diffuse shading. Normal dotted with light direction gives L. The code uses , so divide L by to normalize its scale.
For positive L, idxL = (L * 9.0 / SQRT_2 + 1.0) as u8. Multiply by nine and add one, reserving index zero for a space. Store idxL in frame, then look up the character in shades when drawing. More light gives a denser character.