glxgears

[github]

three meshing gears turning, after glxgears

shapes 56×31 24 fps 6.3 kB

use it

html

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

<ascii-art piece="glxgears"></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 { glxgears } from "ascii.rest/pieces";

<Ascii piece={glxgears} />

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

astro

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

<Ascii piece="glxgears" />

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 { glxgears } from "ascii.rest/pieces";

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

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.

glxgears.ts

[js] [github]
/*
 * glxgears: the three gears of gears.c, with their sizes, places and view, in
 * block shades. Each gear's face keeps one shade, as each has one colour
 * there; the walls of the teeth and the bores are bright where they catch the light.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "glxgears",
  category: "shapes",
  note: "three meshing gears turning, after glxgears",
  cols: 56,
  rows: 31,
  fps: 24,
} satisfies Meta;

const TAU = Math.PI * 2;
const RAD = Math.PI / 180;
const SCALE = 4.2; // columns per unit
const DS = 0.05; // spacing of sampled points, in units
const SPEED = 17; // degrees a second, for the big gear
const SHADE = " ░▒▓█";
const DEPTH = 0.8; // tooth depth; gears.c has 0.7

interface Gear {
  r0: number;
  r: number;
  w: number;
  n: number;
  x: number;
  y: number;
  k: number;
  off: number;
  face: number;
  r1: number;
  r2: number;
}

// gear(inner, outer, width, teeth), its place, its turn as k * a + off, and
// its face's shade. Twelve and six teeth for twenty and ten, so each tooth is
// wide enough to read.
const GEARS = [
  { r0: 1.0, r: 4.0, w: 1.0, n: 12, x: -3.0, y: -2.0, k: 1, off: 0, face: 3 },
  { r0: 0.5, r: 2.0, w: 2.0, n: 6, x: 3.1, y: -2.0, k: -2, face: 2 },
  { r0: 1.3, r: 2.0, w: 0.5, n: 6, x: -3.1, y: 4.2, k: -2, face: 2 },
] as Gear[];
// Offsets that set each small gear's gap on a tooth of the big one.
for (const g of GEARS) {
  g.r1 = g.r - DEPTH / 2;
  g.r2 = g.r + DEPTH / 2;
  if (g.k === 1) continue;
  const big = GEARS[0], dir = Math.atan2(g.y - big.y, g.x - big.x);
  g.off = (dir + Math.PI - ((0.875 - (dir * big.n) / TAU + 0.375) * TAU) / g.n) / RAD;
}

// glRotatef(view_rotx = 20) then glRotatef(view_roty = 30); light at (5, 5, 10).
const cY = Math.cos(30 * RAD), sY = Math.sin(30 * RAD);
const cX = Math.cos(20 * RAD), sX = Math.sin(20 * RAD);
const LIGHT = [5, 5, 10].map((c) => c / Math.hypot(5, 5, 10));
const across = (x: number, z: number) => x * cY + z * sY;
const up = (x: number, y: number, z: number) => y * cX + (x * sY - z * cY) * sX;

// The tooth outline as radius by angle: up a flank, across, down, the root.
function radius(g: Gear, th: number): number {
  const u = (th * g.n) / TAU - Math.floor((th * g.n) / TAU);
  const rise = u < 0.25 ? u * 4 : u < 0.5 ? 1 : u < 0.75 ? (0.75 - u) * 4 : 0;
  return g.r1 + (g.r2 - g.r1) * rise;
}

export default function glxgears(): Frame {
  const { cols, rows } = meta;
  const W = cols * 2, H = rows * 2; // two samples a cell each way
  const depth = new Float32Array(W * H);
  const tone = new Int8Array(W * H); // a shade, 1 to 4
  const who = new Int8Array(W * H);
  const lines: string[] = new Array(rows);

  // Centre the gears' outer circles in the frame.
  let left = Infinity, right = -Infinity, high = -Infinity, low = Infinity;
  for (const g of GEARS) {
    for (let th = 0; th < TAU; th += 0.02) {
      for (const z of [-g.w / 2, g.w / 2]) {
        const x = g.x + g.r2 * Math.cos(th), y = g.y + g.r2 * Math.sin(th);
        left = Math.min(left, across(x, z)), (right = Math.max(right, across(x, z)));
        high = Math.max(high, up(x, y, z)), (low = Math.min(low, up(x, y, z)));
      }
    }
  }
  const LEFT = left - (cols / SCALE - (right - left)) / 2;
  const HIGH = high + ((2 * rows) / SCALE - (high - low)) / 2;

  return (t, { paper = false } = {}) => {
    const a = SPEED * t;
    depth.fill(-Infinity);
    who.fill(-1);

    // A point and its normal in gear space; a wall's normal has no z.
    function plot(i: number, x: number, y: number, z: number, nx: number, ny: number, face?: boolean) {
      const col = Math.floor((across(x, z) - LEFT) * SCALE * 2);
      const row = Math.floor((HIGH - up(x, y, z)) * SCALE);
      if (col < 0 || col >= W || row < 0 || row >= H) return;
      const k = row * W + col, d = y * sX + (z * cY - x * sY) * cX;
      if (d <= depth[k]) return;
      const vx = nx * cY, vy = ny * cX + nx * sY * sX, vz = ny * sX - nx * sY * cX;
      if (!face && vz < 0) return;
      depth[k] = d;
      who[k] = i;
      // A wall is full where it faces the light, and faint where it does not.
      tone[k] = face ? GEARS[i].face : vx * LIGHT[0] + vy * LIGHT[1] + vz * LIGHT[2] > 0.42 ? 4 : 1;
    }

    GEARS.forEach((g, i) => {
      const spin = (g.k * a + g.off) * RAD, h = g.w / 2;
      // The front face, ring by ring. The back face never turns to the eye.
      for (let r = g.r0; r < g.r2; r += DS) {
        for (let th = 0; th < TAU; th += DS / r) {
          if (r <= radius(g, th)) plot(i, g.x + r * Math.cos(th + spin), g.y + r * Math.sin(th + spin), h, 0, 0, true);
        }
      }
      // The outer wall, walked by arc length so the flanks are as dense as the tips.
      for (let th = 0; th < TAU; ) {
        const r = radius(g, th), dr = (radius(g, th + 1e-4) - radius(g, th - 1e-4)) / 2e-4;
        const l = Math.hypot(r, dr), c = Math.cos(th + spin), s = Math.sin(th + spin);
        for (let z = -h; z <= h; z += DS) plot(i, g.x + r * c, g.y + r * s, z, (r * c + dr * s) / l, (r * s - dr * c) / l);
        th += DS / l;
      }
      // The bore, facing in.
      for (let th = 0; th < TAU; th += DS / g.r0) {
        const c = Math.cos(th), s = Math.sin(th);
        for (let z = -h; z <= h; z += DS) plot(i, g.x + g.r0 * c, g.y + g.r0 * s, z, -c, -s);
      }
    });

    // A cell shows the shade that covers most of its four samples, the nearer
    // on a tie, and nothing if fewer than two are covered, so the gaps between
    // teeth stay open. On paper the shades turn over, so ink is still shadow.
    const count: number[] = new Array(15), near: number[] = new Array(15);
    for (let row = 0; row < rows; row++) {
      let line = "";
      for (let col = 0; col < cols; col++) {
        count.fill(0), near.fill(-Infinity);
        let covered = 0, best = -1;
        for (const k of [2 * row * W + 2 * col, 2 * row * W + 2 * col + 1, (2 * row + 1) * W + 2 * col, (2 * row + 1) * W + 2 * col + 1]) {
          if (who[k] < 0) continue;
          const c = who[k] * 5 + tone[k];
          covered++, count[c]++, (near[c] = Math.max(near[c], depth[k]));
          if (best < 0 || count[c] > count[best] || (count[c] === count[best] && near[c] > near[best])) best = c;
        }
        const v = best % 5;
        line += covered < 2 ? " " : SHADE[paper ? 5 - v : v];
      }
      lines[row] = line;
    }
    return lines.join("\n");
  };
}