How I Turned a Google Sheet Into a Rotating 3D Donut and Lost My Mind Doing It (The Π-Torus Engine)

by googlesheeetsfinalboss in Design > 3D Design

366 Views, 2 Favorites, 0 Comments

How I Turned a Google Sheet Into a Rotating 3D Donut and Lost My Mind Doing It (The Π-Torus Engine)

Gemini_Generated_Image_sqv15csqv15csqv1.png
Screenshot (25).png
"It's just a spreadsheet," they said. "You can't render 3D graphics in a spreadsheet," they said.
Reader, I rendered 3D graphics in a spreadsheet.


Why Does This Even Exist

Okay so here's the thing. A torus — the mathematical donut shape — is literally built out of π. Not "uses π a little bit." I mean its surface area is 4π²Rr. Its volume is 2π²Rr². Every single point on its surface is computed by spinning an angle from 0 to 2π, then spinning that from 0 to 2π again. It is π eating π eating π.

And I thought: what if I made Google Sheets render one. Spinning. In real time. Using only a cell grid and some JavaScript that I definitely didn't rewrite six times.

This is that guide. It will work. You will feel insane. That's correct.

You'll end up with:

  1. A 🍩 Dashboard tab where you control R, r, speed, and light
  2. A 🎬 Display tab that renders a spinning ASCII+colour torus every frame
  3. An Apps Script that does all the actual math (rotation matrices, z-buffer, Lambertian shading — all of it, all inside JS, all drenched in π)
  4. Bragging rights that are technically accurate


Check the video below for the demonstration :)

Downloads

Supplies

Screenshot 2026-03-27 140904.png

Thing Why you need it


A Google account

Obviously

A new blank Google Sheet

One tab is fine, the script makes the rest

10 minutes

Maybe 15 if you type slowly

Zero math knowledge

The script handles all the π. You just type numbers.

A soul willing to be mildly broken

Optional but recommended



You do not need:

  1. Any external libraries
  2. Any paid tools
  3. Any understanding of what a z-buffer is (though I'll explain it anyway because it's cool)

Create Your Google Sheet and Name the Dashboard Tab

Screenshot (6).png

Open a fresh Google Sheet. You'll see one tab at the bottom called Sheet1.

Right-click it → Rename → type exactly:

🍩 Dashboard

Yes, with the donut emoji. Yes, it matters. The script looks for that exact name.

Pro-tip: Copy the emoji from here rather than trying to find it yourself. Emoji keyboards are a lawless wasteland and you will end up with the wrong donut.

Your sheet should now look profoundly empty with a tab that has a donut on it. Perfect. You're doing great.

Build the Dashboard Controls (The Π Command Centre)

Screenshot (8).png
Screenshot (10).png
Screenshot (9).png
Screenshot (13).png

This is the only sheet you'll ever manually edit. Everything here feeds the engine. Here's exactly what to type, cell by cell:

The π Constant Block

(Column A = label, Column B = value, Column C = what it does)

Cell A1 — type:

π-TORUS ENGINE · CONTROL PANEL

Cell A3:

── π CONSTANTS ──

Now type these in rows 4–8:

Cell | Label | Formula | Note

---------------------------------------------------------------

A4 | π | =PI() | The constant. The legend.

A5 | 2π (full rotation) | =2*PI() | One complete spin in radians

A6 | π² | = | Appears in torus surface area

A7 | 4π² | =4*PI()^2 | The surface area coefficient

A8 | 2π² | =2*PI()^2 | The volume coefficient

Format column B cells: font Courier New, colour #4FC3F7 (electric blue), background #0A0018.

Why all the π cells? Because every time you look at this sheet I want you to feel the weight of what's happening. The torus you're about to render is made of these numbers. They're not decorative.

The Torus Parameters Block

Cell A10:

── TORUS PARAMETERS (edit these) ──

Same purple header treatment.


Cell | Label | Value | Note

---------------------------------------------------------------

A11 | R · major radius | 9 | Distance from centre to tube centre

A12 | r · minor radius | 4 | Radius of the tube itself

Format B11 and B12: text colour #FFA500 (orange) because these are your user inputs — the only numbers you'll ever change. Background #1E1E2E.

What do R and r actually do?
  1. Big R = the hole size. Crank it up and you get a thin elegant ring. Drop it low and the donut gets fat and weird.
  2. Little r = tube thickness. Push r close to R and the torus starts eating itself. Don't do that. Or do. I'm not your supervisor.
  3. The constraint that keeps things sane: R > r. Always.

The Live Geometry Block (Pure π Formulas)

Cell A14:

── LIVE GEOMETRY (all driven by π) ──

Cell | Label | Formula | Note

---------------------------------------------------------------------------

A15 | Surface area | =4*PI()^2*B11*B12 | 4π²Rr — updates live

A16 | Volume | =2*PI()^2*B11*B12^2 | 2π²Rr² — updates live

A17 | Tube circumference | =2*PI()*B12 | 2πr

A18 | Centre circumference | =2*PI()*B11 | 2πR

A19 | θ step (radians) | =2*PI()/120 | 2π/120 — angle per sample


The θ step cell is my favourite. It's 2π/120. That means the script samples the torus at 120 evenly-spaced angles around each circle — each one exactly 2π/120 = 0.05236... radians apart. The entire render loop is just that number, repeated, multiplied, rotated. π all the way down.


The Live Angle Readout Block

The script writes back to the sheet every frame so you can see the math moving.

Cell | Label | Formula | Note

---------------------------------------------------------------

A22 | θ_A (current, radians) | | Script writes to B22

A23 | θ_B (current, radians) | | Script writes to B23

A24 | θ_A as × of π | =B22/PI() |

A25 | θ_B as × of π | =B23/PI() |

B22 and B23 start empty — the script will fill them. B24 and B25 will show you the angle expressed as a multiple of π, which is genuinely satisfying to watch tick past 0.5π, π, 1.5π, 2π and wrap around.


The Light Source Block


Cell | Label | Value / Formula | Note

---------------------------------------------------------------------------

A28 | L_x | 0.6 | Right/left

A29 | L_y | 0.4 | Up/down

A30 | L_z | -0.7 | Near/far

A31 | ‖L‖ magnitude | =SQRT(B28^2+B29^2+B30^2) | Auto-normalises

The script currently uses hardcoded light values. These dashboard cells are for you to see and understand the light geometry. In Step 6 I'll show you how to wire B28:B30 into the script so moving a cell actually moves the light.

Dress the Dashboard Up

Screenshot (14).png
Screenshot (15).png
Screenshot (16).png

Spend 3 minutes making this look like a proper control panel, not a homework sheet:

  1. Column A width: 220px — labels need room
  2. Column B width: 160px — values need room
  3. Sheet background: select everything, fill #0A0018 (near-black)
  4. TEXT- select everything, fill the text with your desired colour and change the fonts if you like.
  5. Hide gridlines: View → uncheck "Show gridlines"
  6. Tab colour: right-click tab → Change colour → pick a deep purple

When you're done it should look like a spaceship dashboard. If it looks like a tax form, redo from step 3.

Open Apps Script

Screenshot (17).png

In Google Sheets: Extensions → Apps Script

A new tab opens. You'll see a code editor with a blank function myFunction() {}.

Delete everything. Select all, delete. Clean slate.

Now paste the entire script below. This is the updated version

// ── π-Torus Engine · Apps Script Renderer ─────────────────────────────────
// π is everywhere here. Every angle is a fraction of 2π.
// Every rotation matrix entry is cos(nπ) or sin(nπ).
// The torus itself exists because of π². Welcome.

const COLS = 60;
const ROWS = 30;
const DISPLAY_SHEET = "🎬 Display";
const DASH_SHEET = "🍩 Dashboard";

// ── Shade characters: space (dark) → ◉ (brightest) ──────────────────────
const SHADES = [" ", "·", "░", "▒", "▓", "█", "█", "◉"];

// ── Colour ramp: void black → π-purple → gold ────────────────────────────
const COLOURS = [
"#0A0018","#1A0030","#2D0A55","#4A1080",
"#6A0DAD","#9B3FD4","#D4A800","#FFD700"
];

// ── Angle state (persists across frames within a session) ─────────────────
let angleA = 0; // rotation around Z axis
let angleB = 0; // rotation around X axis

// ─────────────────────────────────────────────────────────────────────────
// rotateTorus()
// The entire 3D engine in one function.
// Called every frame — either by time trigger or manual button.
// ─────────────────────────────────────────────────────────────────────────
function rotateTorus() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const dash = ss.getSheetByName(DASH_SHEET);

// ── Read parameters from Dashboard ──────────────────────────────────────
// B11 = R (major radius), B12 = r (minor radius)
// Default to 9 and 4 if cells are empty
const R = dash.getRange("B11").getValue() || 9;
const r = dash.getRange("B12").getValue() || 4;

// Read light direction from Dashboard (B28:B30)
// Falls back to hardcoded values if cells are empty
const lx = dash.getRange("B28").getValue() || 0.6;
const ly = dash.getRange("B29").getValue() || 0.4;
const lz = dash.getRange("B30").getValue() || -0.7;

// Normalise light vector so brightness is consistent regardless of magnitude
// ‖L‖ = √(lx² + ly² + lz²) — pure Pythagoras, no π needed here
const lLen = Math.sqrt(lx*lx + ly*ly + lz*lz) || 1;
const nlx = lx / lLen, nly = ly / lLen, nlz = lz / lLen;

// ── Advance rotation angles ──────────────────────────────────────────────
// Angles are always kept in [0, 2π) — the full circle.
// 2π appears explicitly here: modulo 2π wraps the angle cleanly.
const TWO_PI = 2 * Math.PI; // = 6.28318... τ = 2π
angleA = (angleA + 0.07) % TWO_PI;
angleB = (angleB + 0.12) % TWO_PI;

// ── Write live angles back to Dashboard ─────────────────────────────────
// B22 and B23 show the raw radian values.
// B24 and B25 (formula cells) divide by π() to show "how many π" the angle is.
dash.getRange("B22").setValue(angleA);
dash.getRange("B23").setValue(angleB);

// ── Pre-compute rotation matrix components ───────────────────────────────
// Rotation matrices are built entirely from cos and sin of angles.
// cos(θ) and sin(θ) where θ ∈ [0, 2π] — this is where π lives in 3D graphics.
//
// Rot_Z(angleA): Rot_X(angleB):
// | cosA -sinA 0 | | 1 0 0 |
// | sinA cosA 0 | | 0 cosB -sinB |
// | 0 0 1 | | 0 sinB cosB |
const cosA = Math.cos(angleA), sinA = Math.sin(angleA);
const cosB = Math.cos(angleB), sinB = Math.sin(angleB);

// ── Z-buffer and luminance buffer ────────────────────────────────────────
// zbuf stores the closest point depth at each screen cell (1/z = closer = bigger)
// lbuf stores the luminance (brightness) of that closest point
const zbuf = new Array(COLS * ROWS).fill(-Infinity);
const lbuf = new Array(COLS * ROWS).fill(0);

// ── Sample the torus surface ─────────────────────────────────────────────
// The torus is parameterised by two angles, both ranging 0 → 2π:
// θ (theta) — around the tube cross-section
// φ (phi) — around the central axis
//
// X(θ,φ) = (R + r·cos θ) · cos φ
// Y(θ,φ) = r · sin θ
// Z(θ,φ) = (R + r·cos θ) · sin φ
//
// We sample THETA=120 steps of θ and PHI=60 steps of φ.
// Each step = 2π/120 or 2π/60 radians. π is the step size.

const THETA_STEPS = 120;
const PHI_STEPS = 60;
const dTheta = TWO_PI / THETA_STEPS; // = 2π/120
const dPhi = TWO_PI / PHI_STEPS; // = 2π/60

for (let ti = 0; ti < THETA_STEPS; ti++) {
const theta = ti * dTheta; // θ ∈ [0, 2π)
const cosT = Math.cos(theta);
const sinT = Math.sin(theta);

for (let pi2 = 0; pi2 < PHI_STEPS; pi2++) {
const phi = pi2 * dPhi; // φ ∈ [0, 2π)
const cosP = Math.cos(phi);
const sinP = Math.sin(phi);

// ── Torus surface point in object space ───────────────────────────
const ox = (R + r * cosT) * cosP; // X = (R + r·cosθ)·cosφ
const oy = r * sinT; // Y = r·sinθ
const oz = (R + r * cosT) * sinP; // Z = (R + r·cosθ)·sinφ

// ── Apply Rot_Z(angleA) ───────────────────────────────────────────
// x' = x·cos(angleA) − y·sin(angleA)
// y' = x·sin(angleA) + y·cos(angleA)
// z' = z (unchanged — Z-axis rotation doesn't touch z)
const x1 = ox * cosA - oy * sinA;
const y1 = ox * sinA + oy * cosA;
const z1 = oz;

// ── Apply Rot_X(angleB) ───────────────────────────────────────────
// x'' = x' (unchanged — X-axis rotation doesn't touch x)
// y'' = y'·cos(angleB) − z'·sin(angleB)
// z'' = y'·sin(angleB) + z'·cos(angleB)
const x2 = x1;
const y2 = y1 * cosB - z1 * sinB;
const z2 = y1 * sinB + z1 * cosB;

// ── Perspective projection ────────────────────────────────────────
// A point further away (larger z) appears smaller on screen.
// We add an offset so the torus is always in front of the camera.
// K1 = 1/(z + offset) — the perspective scaling factor.
const zOff = z2 + R + r + 20;
if (zOff <= 0) continue; // behind the camera — skip
const K1 = 1 / zOff;

// Map 3D point to 2D screen cell
const sx = Math.round(COLS / 2 + K1 * x2 * 18);
const sy = Math.round(ROWS / 2 - K1 * y2 * 9);

if (sx < 0 || sx >= COLS || sy < 0 || sy >= ROWS) continue;

// ── Surface normal (same rotations, different vector) ─────────────
// The outward normal at (θ,φ) in object space is:
// N = (cosθ·cosφ, sinθ, cosθ·sinφ)
// Apply the same two rotations to get N in world space.
const nx0 = cosT * cosP;
const ny0 = sinT;
const nz0 = cosT * sinP;

// Rot_Z
const nx1 = nx0 * cosA - ny0 * sinA;
const ny1 = nx0 * sinA + ny0 * cosA;
// nz1 = nz0 (z unchanged by Rot_Z)

// Rot_X
// nx2 = nx1 (x unchanged by Rot_X)
const ny2 = ny1 * cosB - nz0 * sinB;
const nz2 = ny1 * sinB + nz0 * cosB;

// ── Lambertian luminance: N⃗ · L⃗ ─────────────────────────────────
// Dot product of surface normal and light direction.
// = 1 when surface faces light directly, 0 at grazing angle, <0 on back face.
// The normalised light vector (nlx, nly, nlz) makes magnitude irrelevant.
const L = nx1 * nlx + ny2 * nly + nz2 * nlz;

// ── Z-buffer test ─────────────────────────────────────────────────
// Only keep the closest surface point at each screen cell.
// K1 = 1/z, so larger K1 = closer to camera = wins the buffer.
const idx = sy * COLS + sx;
if (K1 > zbuf[idx]) {
zbuf[idx] = K1;
lbuf[idx] = L;
}
}
}

// ── Write to Display sheet ───────────────────────────────────────────────
let display = ss.getSheetByName(DISPLAY_SHEET);
if (!display) {
display = ss.insertSheet(DISPLAY_SHEET);
_setupDisplaySheet(display);
}

// Build 2D arrays for batch write (one API call instead of 1800)
const values = [];
const bgs = [];

for (let row = 0; row < ROWS; row++) {
const vRow = [], bRow = [];
for (let col = 0; col < COLS; col++) {
const idx = row * COLS + col;
const K1 = zbuf[idx];

if (K1 === -Infinity) {
// Empty space — void black
vRow.push("");
bRow.push("#0A0018");
} else {
// Map luminance [0,1] → shade index [0,7]
// lbuf can be negative (back face) — clamp to 0, scale to 1
const lum = Math.max(0, Math.min(1, lbuf[idx] * 0.5 + 0.5));
const idxLum = Math.min(7, (lum * 8) | 0);
vRow.push(SHADES[idxLum]);
bRow.push(COLOURS[idxLum]);
}
}
values.push(vRow);
bgs.push(bRow);
}

// Two batch calls: one for text, one for backgrounds
// Far faster than setting cells individually
display.getRange(2, 2, ROWS, COLS).setValues(values);
display.getRange(2, 2, ROWS, COLS).setBackgrounds(bgs);
}

// ─────────────────────────────────────────────────────────────────────────
// _setupDisplaySheet()
// Creates the render grid one time. Never call this manually.
// ─────────────────────────────────────────────────────────────────────────
function _setupDisplaySheet(sheet) {
sheet.setTabColor("#6A0DAD");
sheet.setFrozenRows(1);
sheet.setFrozenColumns(1);

// Title row
sheet.getRange("A1")
.setValue("π-TORUS LIVE RENDERER · 60×30 cells · z-buffered · Lambertian shading")
.setFontColor("#FFD700")
.setBackground("#1A0030")
.setFontWeight("bold")
.setFontSize(10)
.setFontFamily("Courier New");

sheet.getRange(1, 2, 1, COLS).setBackground("#1A0030");

// Render area: monospace, tiny, centred
sheet.getRange(2, 2, ROWS, COLS)
.setFontFamily("Courier New")
.setFontSize(8)
.setHorizontalAlignment("center")
.setVerticalAlignment("middle")
.setFontColor("#E0CCFF");

// Tight grid: 12px rows, 14px columns
for (let r = 2; r <= ROWS + 1; r++) sheet.setRowHeight(r, 12);
for (let c = 2; c <= COLS + 1; c++) sheet.setColumnWidth(c, 14);
sheet.setColumnWidth(1, 60);

// Row number labels down column A
for (let r = 0; r < ROWS; r++) {
sheet.getRange(r + 2, 1)
.setValue(r + 1)
.setFontColor("#333355")
.setBackground("#0A0018")
.setFontSize(8)
.setFontFamily("Arial");
}
}

// ─────────────────────────────────────────────────────────────────────────
// startAnimation()
// Run this ONCE from the Apps Script editor to set up the time trigger.
// Google Sheets minimum trigger interval = 1 minute.
// ─────────────────────────────────────────────────────────────────────────
function startAnimation() {
stopAnimation();

ScriptApp.newTrigger("rotateTorus")
.timeBased()
.everyMinutes(1)
.create();

rotateTorus(); // render immediately, don't wait a minute

SpreadsheetApp.getUi().alert(
"π-Torus Engine is running!\n\n" +
"• Torus advances every 1 minute (Google's minimum)\n" +
"• For faster: run runFrames() from the editor\n" +
"• Or spam-click a button linked to nextFrame()\n" +
"• To stop: run stopAnimation()\n\n" +
"Check the 🎬 Display tab!"
);
}

// ─────────────────────────────────────────────────────────────────────────
// nextFrame()
// One frame. Assign this to a button for manual control.
// Insert → Drawing → draw a shape → ⋮ → Assign script → nextFrame
// ─────────────────────────────────────────────────────────────────────────
function nextFrame() {
rotateTorus();
}

// ─────────────────────────────────────────────────────────────────────────
// runFrames()
// Runs 200 frames at ~20fps. Call from the editor for a smooth spin session.
// WARNING: locks the sheet for ~10 seconds. That's the price of π.
// ─────────────────────────────────────────────────────────────────────────
function runFrames() {
stopAnimation();
const frames = 200;
const delay = 50; // ms — 50ms ≈ 20fps (as fast as Sheets will go)
for (let i = 0; i < frames; i++) {
rotateTorus();
Utilities.sleep(delay);
}
}

// ─────────────────────────────────────────────────────────────────────────
// autoStartFast()
// ─────────────────────────────────────────────────────────────────────────
function autoStartFast() {
stopAnimation();
const frames = 200;
const delay = 50;
for (let i = 0; i < frames; i++) {
rotateTorus();
Utilities.sleep(delay);
}
}

// ─────────────────────────────────────────────────────────────────────────
// stopAnimation()
// Deletes all rotateTorus time triggers.
// ─────────────────────────────────────────────────────────────────────────
function stopAnimation() {
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === "rotateTorus")
.forEach(t => ScriptApp.deleteTrigger(t));
}

Run It for the First Time

Screenshot (18).png
Screenshot (19).png
Screenshot (20).png
  1. In the Apps Script editor, make sure startAnimation is selected in the function dropdown at the top
  2. Click ▶ Run
  3. A permissions popup will appear — click Review permissions → Allow
  4. Switch back to your spreadsheet
  5. A new tab 🎬 Display will appear automatically
  6. Click it

You should see a torus. A real, shaded, rotating mathematical donut. Made of cells.

It looks wrong / blank?
Check that your Dashboard tab is named exactly 🍩 Dashboard — the emoji included. The script will silently fail to find it if the name doesn't match exactly. Also make sure B11 has a number (try 9) and B12 has a number (try 4).


Wire the Light Controls to the Dashboard

Right now the script reads B28, B29, B30 from Dashboard for the light direction. If you built the dashboard in Step 2, those cells already exist.

Try this: change B28 from 0.6 to -0.6. Then run nextFrame() from the script editor. The shading flips — the bright side of the donut moves to the opposite side. That's a real-time light source you just moved.

Play with:

  1. B28 (L_x): negative = light from left, positive = light from right
  2. B29 (L_y): positive = light from above, negative = below
  3. B30 (L_z): negative = light toward viewer, positive = behind

The script normalises the vector automatically so the total brightness stays consistent regardless of the numbers you put in.

Add a "StarT" Button (The Fun Part)

Screenshot (21).png
Screenshot (22).png
Screenshot (23).png

The time trigger only fires every minute. For actually watching the thing spin you want a button.

  1. In your spreadsheet: Insert → Drawing
  2. Draw any shape — a rectangle, a circle, whatever
  3. Type ▶ SPIN inside it
  4. Style it
  5. Click Save and Close
  6. The drawing appears on the sheet — click the three dots ⋮ in its corner
  7. Select Assign script
  8. Type: autoStartFast
  9. Click OK

Now every click of that button fires one frame of the torus engine. Click rapidly and you get animation. It's manual. It's absurd. It's perfect.

Pro-tip: Add a second button linked to runFrames and label it 🔄 AUTO (200 frames). Click it and walk away — it'll spin for about 10 seconds at maximum Sheets speed (~20fps) then stop. Come back to a torus that has rotated ~24° and feels genuinely alive.

Tweak the Torus Shape

Screenshot (24).png

Go back to 🍩 Dashboard. Change:

B11 (R) — major radius:

  1. 9 → default, nice balanced donut
  2. 14 → huge ring, tiny hole, elegant
  3. 5 → chonky donut, almost spherical

B12 (r) — minor radius:

  1. 4 → default
  2. 6 → very fat tube (keep R bigger than this or it gets weird)
  3. 2 → thin delicate tube, looks like a hula hoop

After each change, click your ▶ SPIN button and watch the geometry update live. The surface area and volume cells in the dashboard update instantly too — you can literally watch 4π²Rr change in real time as you reshape your mathematical donut.

What's Actually Happening (The π Bit)

Every frame, the script:

  1. Samples 7,200 points on the torus surface (120 × 60 angles, both from 0 to 2π)
  2. Rotates each point using two 3×3 rotation matrices built from cos(angleA) and sin(angleA) — where angleA is always a value between 0 and 2π
  3. Projects each 3D point onto a 2D grid using perspective division
  4. Runs a z-buffer test to only keep the closest surface at each cell
  5. Computes luminance via dot product N⃗ · L⃗ — the surface normal vector dotted with the light direction
  6. Maps luminance [0,1] → shade character [" " → "◉"] and colour ["#0A0018" → "#FFD700"]
  7. Batch-writes the whole 60×30 grid in two API calls

The rotation matrices look like this at any given frame:

Rot_Z(angleA) = [ cos(angleA) -sin(angleA) 0 ]
[ sin(angleA) cos(angleA) 0 ]
[ 0 0 1 ]

Rot_X(angleB) = [ 1 0 0 ]
[ 0 cos(angleB) -sin(angleB) ]
[ 0 sin(angleB) cos(angleB) ]

angleA and angleB tick forward by 0.07 and 0.12 radians per frame respectively, always wrapping at 2π. The torus never stops. π never ends.

Troubleshooting (Things I Broke So You Don't Have To)

"The Display sheet is blank"

→ The script ran but found nothing to render. Check that B11 ≥ 1 and B12 ≥ 1 on the Dashboard. If R and r are 0 or empty the torus collapses to a point.

"TypeError: Cannot read properties of null"

→ The sheet name doesn't match. Copy-paste 🍩 Dashboard from this doc into the tab name. Don't retype it.

"The torus looks like a smear / blob"

→ R is smaller than r. Make R bigger than r. Always. The torus mathematically self-intersects otherwise and the z-buffer goes bananas.

"It only updates once a minute"

→ That's Google's hard limit on time triggers. Use the button + nextFrame() for interactive control, or runFrames() for a burst of animation.

"I want it faster"

→ Reduce THETA_STEPS and PHI_STEPS in the script (e.g. try 60 and 30). The torus gets blockier but the render is ~4× faster. It's a tradeoff and only you can decide how many π samples your soul requires.


Built with π. All angles are fractions of 2π. The donut was inevitable.