torus knot

[github]

a trefoil knot drawn as a lit tube, turning slowly

shapes 60×28 30 fps 6.2 kB

use it

html

<script type="module" src="https://ascii.rest/ascii.js"></script>

<ascii-art piece="torus-knot"></ascii-art>

No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.

react

// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { torusKnot } from "ascii.rest/pieces";

<Ascii piece={torusKnot} />

A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="torus-knot", to fetch the piece only when it mounts.

astro

---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---

<Ascii piece="torus-knot" />

Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.

typescript

// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { torusKnot } from "ascii.rest/pieces";

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, torusKnot);

mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.

torus-knot.ts

[js] [github]
/*
 * torus-knot: a (2,3) trefoil knot as a lit tube, z-buffered so the strands
 * pass over and under each other, turning slowly about its axis.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "torus knot",
  category: "shapes",
  note: "a trefoil knot drawn as a lit tube, turning slowly",
  cols: 60,
  rows: 28,
  fps: 30,
} satisfies Meta;

const RAMP = ".:-=+*#%@";

export default function torusKnot(): Frame {
  const { cols, rows } = meta;
  const TUBE = 0.28;
  const NU = 720, NV = 32;
  // The knot's centre line: twice round the z axis, three times through the hole.
  const curve = (u: number, o: number[]) => {
    const r = 2 + Math.cos(3 * u);
    o[0] = r * Math.cos(2 * u);
    o[1] = r * Math.sin(2 * u);
    o[2] = -Math.sin(3 * u);
  };
  // Surface points and normals, built once; each frame only rotates them.
  const px = new Float32Array(NU * NV), py = new Float32Array(NU * NV), pz = new Float32Array(NU * NV);
  const nx = new Float32Array(NU * NV), ny = new Float32Array(NU * NV), nz = new Float32Array(NU * NV);
  const c = [0, 0, 0], d = [0, 0, 0];
  for (let i = 0; i < NU; i++) {
    const u = (i / NU) * 6.2832;
    curve(u, c);
    curve(u + 1e-3, d);
    let tx = d[0] - c[0], ty = d[1] - c[1], tz = d[2] - c[2];
    const tl = Math.hypot(tx, ty, tz);
    tx /= tl; ty /= tl; tz /= tl;
    // A frame across the tube: one axis in the xy plane, the other from a cross product.
    let ax = ty, ay = -tx;
    const al = Math.hypot(ax, ay);
    ax /= al; ay /= al;
    const bx = -tz * ay, by = tz * ax, bz = tx * ay - ty * ax;
    for (let j = 0; j < NV; j++) {
      const v = (j / NV) * 6.2832;
      const cv = Math.cos(v), sv = Math.sin(v);
      const k = i * NV + j;
      nx[k] = ax * cv + bx * sv;
      ny[k] = ay * cv + by * sv;
      nz[k] = bz * sv;
      px[k] = c[0] + TUBE * nx[k];
      py[k] = c[1] + TUBE * ny[k];
      pz[k] = c[2] + TUBE * nz[k];
    }
  }
  const D = 11; // eye to centre
  const ASPECT = 0.5;
  // Spin about the knot's own axis, nodding between 0.2 and 0.76 rad of tilt
  // so the over and under strands separate in depth as it turns.
  const view = (t: number) => [t * 0.3, 0.48 - 0.28 * Math.cos(t * 0.35)];
  let mx = 0, my = 0;
  for (let a = 0; a < 6.283; a += 0.1) {
    for (let T = 0.2; T <= 0.77; T += 0.07) {
      const cS = Math.cos(a), sS = Math.sin(a), cT = Math.cos(T), sT = Math.sin(T);
      for (let k = 0; k < NU * NV; k += 7) {
        const x = cS * px[k] - sS * py[k];
        const y = cT * (sS * px[k] + cS * py[k]) - sT * pz[k];
        const z = sT * (sS * px[k] + cS * py[k]) + cT * pz[k];
        mx = Math.max(mx, Math.abs(x) / (D - z));
        my = Math.max(my, Math.abs(y) / (D - z));
      }
    }
  }
  const K = Math.min((cols - 2) / 2 / mx, (rows - 2) / 2 / ASPECT / my);
  const GAP = TUBE * 0.9; // a depth step this big, between far apart parts of the knot, is a crossing
  const m = Math.hypot(-0.35, 0.45, 1);
  const [lx, ly, lz] = [-0.35 / m, 0.45 / m, 1 / m];
  // Half vector for a specular glint, between the light and the eye (+z).
  const hm = Math.hypot(lx, ly, lz + 1);
  const [hx, hy, hz] = [lx / hm, ly / hm, (lz + 1) / hm];
  const n = cols * rows;
  const out = new Array<string>(n);
  const depth = new Float32Array(n);
  const along = new Int16Array(n); // which step of the centre line each cell shows
  const cut = new Uint8Array(n);
  const stack = new Int32Array(n), part = new Int32Array(n);
  const N = RAMP.length - 1;

  return (t, { paper = false } = {}) => {
    const [S, T] = view(t);
    const cS = Math.cos(S), sS = Math.sin(S), cT = Math.cos(T), sT = Math.sin(T);
    const r00 = cS, r01 = -sS;
    const r10 = cT * sS, r11 = cT * cS, r12 = -sT;
    const r20 = sT * sS, r21 = sT * cS, r22 = cT;
    out.fill(" ");
    depth.fill(0);
    for (let k = 0; k < NU * NV; k++) {
      const wz = r20 * nx[k] + r21 * ny[k] + r22 * nz[k];
      if (wz < -0.15) continue; // faces away from the eye
      const x = r00 * px[k] + r01 * py[k];
      const y = r10 * px[k] + r11 * py[k] + r12 * pz[k];
      const z = r20 * px[k] + r21 * py[k] + r22 * pz[k];
      const ooz = 1 / (D - z);
      const col = Math.floor(cols / 2 + K * ooz * x);
      const row = Math.floor(rows / 2 - K * ASPECT * ooz * y);
      if (col < 0 || col >= cols || row < 0 || row >= rows) continue;
      const q = col + row * cols;
      if (z + 20 <= depth[q]) continue;
      depth[q] = z + 20;
      along[q] = (k / NV) | 0;
      const wx = r00 * nx[k] + r01 * ny[k];
      const wy = r10 * nx[k] + r11 * ny[k] + r12 * nz[k];
      const diff = Math.max(0, wx * lx + wy * ly + wz * lz);
      const spec = Math.max(0, wx * hx + wy * hy + wz * hz) ** 24;
      const i = Math.round(Math.min(1, 0.08 + diff * 0.8 + spec * 0.5) * N);
      out[q] = RAMP[paper ? N - i : i];
    }
    // Where one strand passes over another, clear the one cell of the lower
    // strand beside the upper one's outline, so the crossing reads as a gap.
    const over = (q: number, p: number) => {
      if (depth[p] <= depth[q] + GAP) return false;
      const s = Math.abs(along[p] - along[q]);
      return Math.min(s, NU - s) > NU / 16;
    };
    for (let q = 0; q < n; q++) {
      const c = q % cols;
      cut[q] = depth[q] > 0 && ((c > 0 && over(q, q - 1)) || (c < cols - 1 && over(q, q + 1)) ||
        (q >= cols && over(q, q - cols)) || (q < n - cols && over(q, q + cols))) ? 1 : 0;
    }
    for (let q = 0; q < n; q++) if (cut[q]) { out[q] = " "; depth[q] = 0; }
    // Drop the small scraps a gap can leave, so each strand stays one piece.
    part.fill(0);
    for (let q = 0; q < n; q++) {
      if (out[q] === " " || part[q]) continue;
      let top = 0, size = 0;
      stack[top++] = q;
      part[q] = 1;
      const seen: number[] = [];
      while (top) {
        const p = stack[--top], c = p % cols;
        seen.push(p);
        size++;
        for (const o of [c > 0 ? p - 1 : -1, c < cols - 1 ? p + 1 : -1, p - cols, p + cols]) {
          if (o >= 0 && o < n && !part[o] && out[o] !== " ") { part[o] = 1; stack[top++] = o; }
        }
      }
      if (size < 7) for (const p of seen) out[p] = " ";
    }
    const lines: string[] = [];
    for (let r = 0; r < rows; r++) lines.push(out.slice(r * cols, (r + 1) * cols).join(""));
    return lines.join("\n");
  };
}