twisted ring

[github]

a square ring twisted three quarters, so it has one face

shapes 46×21 30 fps 6.1 kB

use it

html

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

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

<Ascii piece={twistedRing} />

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

astro

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

<Ascii piece="twisted-ring" />

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

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

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.

twisted-ring.ts

[js] [github]
/*
 * twisted-ring: a ring of square section that twists three quarters of a turn
 * in one lap, so its four faces join into one. The faces are flat shaded and
 * the corners between them inked as lines. Turned about its own axis.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "twisted ring",
  category: "shapes",
  note: "a square ring twisted three quarters, so it has one face",
  cols: 46,
  rows: 21,
  fps: 30,
} satisfies Meta;

const FACE = ".:+*#@"; // a face's mark by its light, least to most
const ASPECT = 0.5; // cell width over cell height
const R = 0.75, AP = 0.22; // ring radius, and the section's apothem
const TWIST = 3; // quarter turns of the section in one lap; any odd number gives one face
const TILT = 0.92, EYE = 6, SCALE = 9.2; // TILT from edge on; SCALE: rows per unit at the ring's centre
const LIFT = 0.5; // rows the ring sits above centre, since its near side looks bigger
const SPIN = 0.32, P0 = 0.4; // radians a second, and the angle at t = 0
const LAPS = 720, ACROSS = 14; // samples round the ring and across each face

const unit = (v: number[]) => v.map((c) => c / Math.hypot(...v));
const LIGHT = unit([-0.5, 0.75, 0.6]);
const HALF = unit([LIGHT[0], LIGHT[1], LIGHT[2] + 1]);

export default function twistedRing(): 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 Uint8Array(W * H);
  const ink = new Array<string>(cols * rows); // a corner's line through a cell, if one shows
  const near = new Float32Array(cols * rows);
  const ct = Math.cos(TILT), st = Math.sin(TILT);
  const lines = new Array<string>(rows);
  const P = [0, 0, 0, 0, 0, 0], Q = [0, 0, 0];

  return (t, { paper = false } = {}) => {
    const p = P0 + SPIN * t, cs = Math.cos(p), ss = Math.sin(p);
    // A point on the ring, at angle a round it and (qr, qy) in its section,
    // spun about the axis and tipped toward the eye: its place on the sample
    // grid, its nearness, and where it is in the world.
    const place = (ca: number, sa: number, qr: number, qy: number) => {
      const x = (R + qr) * ca, z = (R + qr) * sa;
      const wx = cs * x + ss * z, z1 = -ss * x + cs * z;
      const wy = ct * qy - st * z1, wz = st * qy + ct * z1;
      const ooz = 1 / (EYE - wz), k = SCALE * EYE * ooz;
      P[0] = 2 * (cols / 2 + (k * wx) / ASPECT);
      P[1] = 2 * (rows / 2 - LIFT - k * wy);
      (P[2] = ooz), (P[3] = wx), (P[4] = wy), (P[5] = wz);
    };
    // The world normal of the face whose normal makes angle n in the section
    // at angle a round the ring, and whether it turns toward the eye from P.
    const normal = (ca: number, sa: number, n: number) => {
      const ox = Math.cos(n) * ca, oz = Math.cos(n) * sa, z1 = -ss * ox + cs * oz;
      (Q[0] = cs * ox + ss * oz), (Q[1] = ct * Math.sin(n) - st * z1), (Q[2] = st * Math.sin(n) + ct * z1);
    };
    const toward = () => -Q[0] * P[3] - Q[1] * P[4] + Q[2] * (EYE - P[5]) > 0;

    depth.fill(0);
    for (let i = 0; i < LAPS; i++) {
      const a = (i / LAPS) * 2 * Math.PI - Math.PI, ca = Math.cos(a), sa = Math.sin(a);
      for (let f = 0; f < 4; f++) {
        // Face f's normal, in the plane of the section, turns as it goes round.
        const n = (TWIST * a) / 4 + ((2 * f + 1) * Math.PI) / 4, cn = Math.cos(n), sn = Math.sin(n);
        normal(ca, sa, n);
        // One mark across the face's width, in four flat steps of light, and
        // the top one only where the face turns the light straight back.
        const d = Math.max(0, Q[0] * LIGHT[0] + Q[1] * LIGHT[1] + Q[2] * LIGHT[2]);
        const h = Q[0] * HALF[0] + Q[1] * HALF[1] + Q[2] * HALF[2];
        const k = h > 0.998 ? 5 : d < 0.2 ? 0 : d < 0.45 ? 1 : d < 0.7 ? 2 : d < 0.9 ? 3 : 4;
        for (let j = 0; j <= ACROSS; j++) {
          const s = (2 * j) / ACROSS - 1;
          place(ca, sa, AP * (cn - s * sn), AP * (sn + s * cn));
          if (!toward()) continue;
          const c = Math.floor(P[0]), r = Math.floor(P[1]);
          if (c < 0 || c >= W || r < 0 || r >= H) continue;
          const o = r * W + c;
          if (P[2] > depth[o]) (depth[o] = P[2]), (tone[o] = k);
        }
      }
    }

    // The four corners, each a line round the ring, inked where both faces
    // that meet there turn to the eye and nothing nearer covers it, with the
    // stroke that follows its way across the cell.
    ink.fill("");
    near.fill(0);
    const D = AP * Math.SQRT2, N = LAPS * 2;
    for (let f = 0; f < 4; f++) {
      let px = 0, py = 0;
      for (let i = 0; i <= N; i++) {
        const a = (i / N) * 2 * Math.PI - Math.PI, m = (TWIST * a) / 4 + (f * Math.PI) / 2;
        const ca = Math.cos(a), sa = Math.sin(a);
        place(ca, sa, D * Math.cos(m), D * Math.sin(m));
        const dx = P[0] - px, dy = P[1] - py;
        (px = P[0]), (py = P[1]);
        if (!i) continue;
        normal(ca, sa, m - Math.PI / 4);
        if (!toward()) continue;
        normal(ca, sa, m + Math.PI / 4);
        if (!toward()) continue;
        const c = Math.floor(px), r = Math.floor(py), o = r * W + c;
        if (c < 0 || c >= W || r < 0 || r >= H || !depth[o] || P[2] < depth[o] - 0.0025) continue;
        const cell = (r >> 1) * cols + (c >> 1);
        if (P[2] <= near[cell]) continue;
        near[cell] = P[2];
        const ang = Math.atan2(Math.abs(dy), Math.abs(dx) * ASPECT);
        ink[cell] = ang < 0.42 ? "-" : ang > 1.2 ? "|" : dx * dy < 0 ? "/" : "\\";
      }
    }

    // Each cell takes the nearest of its samples, or a corner's line.
    for (let r = 0; r < rows; r++) {
      let line = "";
      for (let c = 0; c < cols; c++) {
        if (ink[r * cols + c]) {
          line += ink[r * cols + c];
          continue;
        }
        const o = 2 * r * W + 2 * c;
        let hits = 0, best = -1;
        for (const q of [o, o + 1, o + W, o + W + 1]) {
          if (!depth[q]) continue;
          hits++;
          if (best < 0 || depth[q] > depth[best]) best = q;
        }
        line += hits < 2 ? " " : FACE[paper ? FACE.length - 1 - tone[best] : tone[best]];
      }
      lines[r] = line;
    }
    return lines.join("\n");
  };
}