cube
[github]a stippled hidden-line cube turning a quarter at a time
shapes 32×18 24 fps 5.4 kB
+------------------+ / : : : : : : : : /| / : : : : : : : : / | / : : : : : : : : / .| +------------------+ | | . . . . . . . . .| .| |. . . . . . . . . |. | | . . . . . . . . .| .| |. . . . . . . . . |. | | . . . . . . . . .| + |. . . . . . . . . |. / | . . . . . . . . .| / |. . . . . . . . . |/ +------------------+ +------------------+ / . . . . /| / . . . . / | / . . . . / :| +------------------+ : | | . . . . . . . . .|: :| |. . . . . . . . . | : | | . . . . . . . . .|: :| |. . . . . . . . . | : | | . . . . . . . . .|: + |. . . . . . . . . | / | . . . . . . . . .| / |. . . . . . . . . |/ +------------------+
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script> <ascii-art piece="cube"></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 { cube } from "ascii.rest/pieces";
<Ascii piece={cube} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="cube", to fetch the piece only when it mounts.
astro
--- // npm install github:bas3line/ascii import Ascii from "ascii.rest/astro"; --- <Ascii piece="cube" />
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 { cube } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, cube); 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.
/*
* cube: a cube drawn the way a drafting sheet draws it, in parallel projection
* with only the near edges and faces stippled by the light, turning by quarters.
*/
import type { Frame, Meta } from "../types.ts";
export const meta = {
name: "cube",
category: "shapes",
note: "a stippled hidden-line cube turning a quarter at a time",
cols: 32,
rows: 18,
fps: 24,
} satisfies Meta;
const CW = 0.5; // cell width over cell height
const S = 4.95; // rows per half-edge
const REST = 0.21; // the resting yaw: long edges flat, receding edges one column a row
const PITCH = -0.43; // looking down on the top face
const TURN = 4; // seconds to turn a quarter
const HOLD = 2; // seconds at rest after each turn
// A face's stipple by the light it catches, least ink to most: a sparse
// lattice of dots, a checker of dots, a checker of colons.
const TONES = [
(c: number, r: number) => ((c + 2 * r) % 4 === 0 ? "." : " "),
(c: number, r: number) => ((c + r) & 1 ? "." : " "),
(c: number, r: number) => ((c + r) & 1 ? ":" : " "),
];
const V: [number, number, number][] = [];
for (let i = 0; i < 8; i++) V.push([i & 1 ? 1 : -1, i & 2 ? 1 : -1, i & 4 ? 1 : -1]);
const E: [number, number][] = [];
for (let i = 0; i < 8; i++) for (const b of [1, 2, 4]) if (!(i & b)) E.push([i, i | b]);
// Each face as its axis, its sign and its four corners in order around it.
const F: [number, number, number[]][] = [];
for (let k = 0; k < 3; k++) {
const [a, b] = [1, 2, 4].filter((_, j) => j !== k);
for (const s of [0, 1]) {
const base = s << k;
F.push([k, s ? 1 : -1, [base, base | a, base | a | b, base | b]]);
}
}
const unit = (v: number[]) => v.map((c) => c / Math.hypot(...v));
const LIGHT = unit([-0.35, 0.6, -0.72]); // from the upper left, toward the eye at -z
// One edge, between its corner cells but not on them. A shallow edge is runs
// of - only, a row apart; a steeper one takes one / or \ a row, with _
// carrying the edge along the foot of the row to where the next row picks up,
// and one within a few degrees of upright is |.
function line([c0, r0]: [number, number], [c1, r1]: [number, number], put: (c: number, r: number, ch: string) => unknown) {
if (r1 < r0) [c0, r0, c1, r1] = [c1, r1, c0, r0];
const dc = c1 - c0, dr = r1 - r0, s = Math.sign(dc);
if (5 * dr < Math.abs(dc) * CW * 2) {
for (let c = c0 + s; c !== c1; c += s) put(c, Math.round(r0 + (dr * (c - c0)) / dc), "-");
return;
}
const ch = 3 * Math.abs(dc) <= dr ? "|" : s > 0 ? "\\" : "/";
const at = (r: number) => Math.round(c0 + (dc * (r - r0)) / dr);
for (let r = r0; r < r1; r++) {
if (r > r0) put(at(r), r, ch);
for (let c = at(r) + s; (at(r + 1) - c) * s > 0; c += s) put(c, r, "_");
}
}
// Is (c, r) inside the convex quad q, by more than m cells?
function inside(q: [number, number][], c: number, r: number, m: number) {
let sign = 0;
for (let i = 0; i < 4; i++) {
const [x0, y0] = q[i], [x1, y1] = q[(i + 1) % 4];
const ex = (x1 - x0) * CW, ey = y1 - y0;
const d = ((c - x0) * CW * ey - (r - y0) * ex) / Math.hypot(ex, ey);
if (Math.abs(d) < m) return false;
if (sign && Math.sign(d) !== sign) return false;
sign = Math.sign(d);
}
return true;
}
export default function cube(): Frame {
const { cols, rows } = meta;
const ease = (u: number) => (u <= 0 ? 0 : u >= 1 ? 1 : u * u * u * (u * (6 * u - 15) + 10));
const grid: string[][] = Array.from({ length: rows }, () => new Array(cols));
return (t, { paper = false } = {}) => {
const n = Math.floor(t / (TURN + HOLD));
const yaw = REST + (Math.PI / 2) * (n + ease((t - n * (TURN + HOLD)) / TURN));
const cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(PITCH), sp = Math.sin(PITCH);
const turn = ([x, y, z]: number[]) => {
const z1 = z * cy - x * sy;
return [x * cy + z * sy, y * cp - z1 * sp, y * sp + z1 * cp];
};
const P = V.map((v): [number, number] => {
const [x, y] = turn(v);
return [Math.round(cols / 2 - 0.5 + (x * S) / CW), Math.round(rows / 2 - 0.5 - y * S)];
});
for (const row of grid) row.fill(" ");
const put = (c: number, r: number, ch: string) => r >= 0 && r < rows && c >= 0 && c < cols && (grid[r][c] = ch);
// A face shows when it turns toward the eye by more than a sliver; an edge
// shows when either of its faces does.
const shown = F.map(([k, s]) => turn([0, 1, 2].map((j) => (j === k ? s : 0))));
const seen = E.map(([a, b]) => F.some(([, , q], f) => shown[f][2] < -0.12 && q.includes(a) && q.includes(b)));
E.forEach(([a, b], i) => seen[i] && line(P[a], P[b], put));
E.forEach(([a, b], i) => seen[i] && (put(...P[a], "+"), put(...P[b], "+")));
// Every face in view takes a tone by the light it catches, ink meaning
// light on a screen and shadow on paper.
F.forEach(([, , q], f) => {
if (shown[f][2] >= -0.12) return;
const lit = Math.max(0, shown[f][0] * LIGHT[0] + shown[f][1] * LIGHT[1] + shown[f][2] * LIGHT[2]);
const k = Math.min(2, Math.floor(lit * 3));
const tone = TONES[paper ? 2 - k : k];
const quad = q.map((i) => P[i]);
const ys = quad.map((p) => p[1]), xs = quad.map((p) => p[0]);
for (let r = Math.min(...ys); r <= Math.max(...ys); r++) {
for (let c = Math.min(...xs); c <= Math.max(...xs); c++) {
if (grid[r]?.[c] === " " && inside(quad, c, r, 0.45)) grid[r][c] = tone(c, r);
}
}
});
return grid.map((row) => row.join("")).join("\n");
};
}