Explanation
Constructions
A construction is a drawing made of parts. Each part is an outline that can draw itself on, fill, turn, move, scale and fade, so you can show how a shape is made: guide lines, compass circles, measurements, and finally the shape itself.
Drag to turn, scroll or pinch to zoom, double click to reset.
import { Construct, Group, Scene, Timeline, circleD, group, lineD, par, tween } from 'cseen';
// A cube drawn flat on the ground, folded shut, turned, and taken apart. The camera is at the true isometric angle, so the ground is a lattice of equal rhombi
// and the squares sit on its cells. Each face has its own hinge, and a hinge is a group that turns.
// The camera zooms about the middle of the region, so the region is shifted to put the finished cube (centre one unit above the origin) there.
const ISO = (Math.atan(1 / Math.SQRT2) * 180) / Math.PI;
const s = new Scene({ view: { yaw: 45, pitch: ISO }, size: [520, 440], padding: 0, region: { x: 33, y: 70, w: 520, h: 440 } });
s.camera.props.zoom = 1.25;
const SIDE = 2, H = SIDE / 2, ZOOM = 2.4;
const INK = 'ink', ACCENT = 'accent';
// Letters as outlines on a 100 x 100 grid: strokes 0.18 thick, one outline per letter (the e has a hole). Faces leave a gap at the edge.
const GAP = 0.035, FACE = 1000;
const glyphs: Record<string, string[]> = {
c: ['0 0 100 0 100 18 18 18 18 82 100 82 100 100 0 100'],
s: ['0 0 100 0 100 18 18 18 18 41 100 41 100 100 0 100 0 82 82 82 82 59 0 59'],
e: ['0 0 100 0 100 59 18 59 18 82 100 82 100 100 0 100', '18 18 18 41 82 41 82 18'],
n: ['0 0 100 0 100 100 82 100 82 18 18 18 18 100 0 100'],
};
// the centre line each letter is written along
const walks: Record<string, string> = { c: '100 9 9 9 9 91 100 91', s: '100 9 9 9 9 50 91 50 91 91 0 91', e: '9 50 91 50 91 9 9 9 9 91 100 91', n: '9 100 9 9 91 9 91 100' };
const pts = (l: string) => { const n = l.split(' ').map((v) => +v / 100); return n.filter((_, i) => i % 2).map((y, i): Pt => [n[2 * i], y]); };
// a letter on a 1000 x 1000 face (y down), mirrored if `flip`, turned by `turn` quarter turns
const place = ([x, y]: Pt, turn: number, flip = false): Pt => { if (flip) x = 1 - x; for (let i = 0; i < turn; i++) [x, y] = [1 - y, x]; return [(GAP + x * (1 - 2 * GAP)) * FACE, (GAP + y * (1 - 2 * GAP)) * FACE]; };
const letterD = (name: string, turn = 0, flip = false) => glyphs[name].map((l) => 'M' + pts(l).map((p) => place(p, turn, flip).join(' ')).join('L') + 'Z').join('');
const walkD = (name: string, turn: number, flip: boolean) => 'M' + pts(walks[name]).map((p) => place(p, turn, flip).join(' ')).join('L');
const dotsD = (name: string, turn: number, flip: boolean) => pts(walks[name]).map((p) => `M${place(p, turn, flip).join(' ')}h0`).join('');
const squareD = `M0 0H${FACE}V${FACE}H0Z`;
const stage = s.add(group());
// the net is longer one way, so it lies one unit to the side of the cube it folds into
const SLIDE: V3 = [-1, 0, 0];
// faint guides through the whole net, in the plane's own units (100 to the world unit)
const floor = stage.add(new Construct({ box: [0, 0, 1000, 800], size: [10, 8], at: [-4, -4, 0], u: [1, 0, 0], v: [0, 1, 0] }));
const fl = (x: number, y: number): Pt => [(x + 4) * 100, (4 - y) * 100];
const guide = (a: Pt, b: Pt, width = 1.4) => floor.add({ d: lineD(a, b), stroke: 'faint', width, props: { draw: 0 } });
const centre = floor.add({ d: circleD(...fl(0, 0), 7), paint: 'accent', stroke: 'none', props: { fill: 0 } });
const axes2 = [guide(fl(-4, 0), fl(6, 0), 1.8), guide(fl(0, -4), fl(0, 4), 1.8)];
const cols = [-3, -1, 1, 3, 5].map((x) => guide(fl(x, -4), fl(x, 4)));
const rows = [-3, -1, 1, 3].map((y) => guide(fl(-4, y), fl(6, y)));
// spin turns the whole cube about the vertical line through the middle of its base. Four sides rise from its edges; the sixth square hangs off one of them and closes the top.
const spin = stage.add(group());
const face = (g: Group, at: V3, u: V3, v: V3) => g.add(new Construct({ box: [0, 0, 1000, 1000], size: [SIDE, SIDE], at, u, v, cull: true }));
// The parts of a square on each plane: paper (opaque, so nothing shows through the cube), the letter's centre line with a dot at each point,
// the square's outline, and the letter. On the flat net the letter is mirrored, since it is printed on the outside.
const dress = (planes: Construct[], name: string, paint: string, turns: number[]) => ({
paper: planes.map((f) => f.add({ d: squareD, paint: 'color-mix(in oklab, var(--c-accent) 8%, var(--c-bg))', stroke: 'none', props: { fill: 0 } })),
spine: planes.map((f, i) => f.add({ d: walkD(name, turns[i], !i), stroke: 'accent', width: 4, props: { draw: 0 } })),
dots: planes.map((f, i) => f.add({ d: dotsD(name, turns[i], !i), stroke: 'accent', width: 20, props: { op: 0 } })),
square: planes.map((f) => f.add({ d: squareD, stroke: 'ink', width: 7, props: { draw: 0 } })),
letter: planes.map((f, i) => f.add({ d: letterD(name, turns[i], !i), paint, stroke: 'ink', width: 5, props: { draw: 0, fill: 0 } })),
});
type Dressed = ReturnType<typeof dress>;
// A square on a hinge, reaching SIDE away from it in direction d. Two planes back to back, each shown only while it faces the camera:
// the upper while the square lies open, the outer once it is on the cube.
const leaf = (hinge: Group, d: [number, number], name: string, paint: string, turns: [number, number] = [0, 0]) => {
const up: V3 = [d[1], -d[0], 0], out: V3 = [-d[1], d[0], 0], v: V3 = [d[0], d[1], 0], from = (u: V3): V3 => [-u[0] * H, -u[1] * H, 0];
return dress([face(hinge, from(up), up, v), face(hinge, from(out), out, v)], name, paint, turns);
};
const baseFace = dress([face(spin, [-H, -H, 0], [1, 0, 0], [0, 1, 0])], 'c', ACCENT, [1]); // a quarter turn, so it differs from the square on top
// a flap hangs off `parent` at `at` (in the parent's own frame), reaching away in direction d
const flap = (parent: Group, at: V3, d: [number, number], name: string, paint: string, turns: [number, number] = [0, 0]) => {
const hinge = parent.add(group({ at }));
return { hinge, up: [90 * d[1], -90 * d[0], 0] as V3, dressed: leaf(hinge, d, name, paint, turns) };
};
// The net is a column of four squares along x (e, c, n, top c) with the two other sides hung off the middle pair, one each way: s off the base, the second e off the n.
// Anticlockwise around the base, so the turning cube shows e e n s e e n s
const sideS = flap(spin, [0, H, 0], [0, 1], 's', INK);
const sideW = flap(spin, [-H, 0, 0], [-1, 0], 'e', INK);
const sideN = flap(spin, [H, 0, 0], [1, 0], 'n', ACCENT);
// it hangs off the n, so its letter is turned to stand as it did when it hung off the base
const sideE = flap(sideN.hinge, [H, -H, 0], [0, -1], 'e', INK, [3, 1]);
const flaps = [sideS, sideW, sideE, sideN];
const folds = [sideS, sideW, sideN, sideE]; // the order they rise: a flap on the n waits for the n
const topHinge = sideN.hinge.add(group({ at: [SIDE, 0, 0] })), CLOSE: V3 = [0, -90, 0];
const dressed: Dressed[] = [baseFace, ...flaps.map((f) => f.dressed), leaf(topHinge, [1, 0], 'c', ACCENT, [2, 2])];
const pick = (k: keyof Dressed) => dressed.map((d) => d[k]);
const soft = (t: number) => (1 - Math.cos(Math.PI * t)) / 2;
const run = (p: Animatable, to: Record<string, number | number[]>, from: Record<string, number | number[]>, at: number, len: number): Anim => tween(p, to, { from, delay: at, duration: len, ease: soft });
// one property of several parts, from a to b, each starting `gap` after the last
const each = (ps: (Animatable | Animatable[])[], prop: string, a: number, b: number, at: number, len: number, gap = 0) =>
ps.flatMap((p, i) => [p].flat().map((q) => run(q, { [prop]: b }, { [prop]: a }, at + i * gap, len)));
const build = (): Anim => par(
tween(spin, { rot: [0, 0, 0] }, { from: { rot: [0, 0, 0] }, duration: 0 }),
// 1 guidelines: the middle, the two axes, then the lines the squares will lie between
...each([centre], 'fill', 0, 1, 0.2, 0.5), ...each(axes2, 'draw', 0, 1, 0.5, 1.2, 0.4), ...each(cols, 'draw', 0, 1, 1.5, 1, 0.15), ...each(rows, 'draw', 0, 1, 2.6, 1, 0.15),
// 2 outlines: the six squares, c first, then the four around it, then the last
...each(pick('square'), 'draw', 0, 1, 4.2, 0.9, 0.4), ...each([...axes2, ...cols, ...rows, centre], 'op', 1, 0, 6.9, 0.8),
// 3 the letters: each square gets the centre line of its letter, the letter is traced around it, then the paper and the colour come in
...each(pick('spine'), 'draw', 0, 1, 7.2, 0.8, 0.1), ...each(pick('dots'), 'op', 0, 1, 7.2, 0.8, 0.1), ...each(pick('letter'), 'draw', 0, 1, 8, 1, 0.45), ...each(pick('spine'), 'op', 1, 0, 11.2, 0.6), ...each(pick('dots'), 'op', 1, 0, 11.2, 0.6),
...each(dressed.map((d) => [...d.paper, ...d.letter]), 'fill', 0, 1, 11.6, 0.6, 0.15), ...each(pick('letter'), 'line', 1, 0, 12.2, 0.6, 0.15),
// 4 the fold, by hand: the four sides are raised one after another, then the last square closes over the top
run(stage, { pos: [0, 0, 0] }, { pos: SLIDE }, 13.6, 4.4), run(s.camera, { zoom: ZOOM }, { zoom: 1.25 }, 14.8, 3),
...folds.map((f, i) => run(f.hinge, { rot: f.up }, { rot: [0, 0, 0] }, 13.6 + i * 0.6, 1.4)), run(topHinge, { rot: CLOSE }, { rot: [0, 0, 0] }, 16.6, 1.6),
...each(pick('square'), 'line', 1, 0, 18.4, 0.8),
);
// the finished and the empty state of every part, found by running the build to its end and back
const built = build();
const probe = new Timeline().play(built);
const targets = [...new Set(built.tweens.map((t) => t.target))];
probe.seek(built.duration); const done = targets.map((t) => t.snapshot());
probe.seek(0); const empty = targets.map((t) => t.snapshot());
const setAll = (states: Record<string, number | number[]>[], from: boolean) => par(...targets.map((t, i) => tween(t, states[i], { duration: 0, from: from ? states[i] : undefined })));
// two slow turns, so the letters pass e e n s e e n s ...
const turn = (): Anim => tween(spin, { rot: [0, 0, -720] }, { from: { rot: [0, 0, 0] }, duration: 20, ease: soft });
// the fold in reverse, then the colour drains from the letters and everything fades
const apart = (): Anim => par(
run(topHinge, { rot: [0, 0, 0] }, { rot: CLOSE }, 0, 1.4), ...[...folds].reverse().map((f, i) => run(f.hinge, { rot: [0, 0, 0] }, { rot: f.up }, 0.8 + i * 0.5, 1.4)),
run(stage, { pos: SLIDE }, { pos: [0, 0, 0] }, 0.8, 3.4), run(s.camera, { zoom: 1.25 }, { zoom: ZOOM }, 0.6, 3),
...each(pick('square'), 'line', 0, 1, 0, 0.8), ...each(pick('letter'), 'fill', 1, 0, 4.6, 0.9, 0.1), ...each(pick('letter'), 'line', 0, 1, 4.6, 0.9, 0.1),
...dressed.flatMap((d, i) => [...d.letter, ...d.square, ...d.paper].map((q) => run(q, { op: 0 }, { op: 1 }, 6 + i * 0.25, 0.7))),
);
const tl = new Timeline();
// starts finished, so the first frame is the logo: it turns, comes apart, then is built again
tl.play(setAll(done, true));
tl.wait(1.4);
tl.play(turn());
tl.wait(0.4);
tl.play(apart());
tl.wait(0.5);
tl.play(setAll(empty, false));
tl.play(built);
That is the cube from the home page, drawn from nothing: guide lines first, then six squares, a letter traced into each, and last the flat net folds shut. A construction is a flat drawing, so it lies on a plane of the scene like any SVG, and the rest of the scene (camera, timeline, theme) treats it as one node.
import { Scene, Timeline, Construct, circleD, segsD, arcSegs, tween, mount } from 'cseen';
const s = new Scene({ view: 'front', size: [360, 360], padding: 6 });const c = s.add(new Construct({ box: [0, 0, 200, 200], size: [3.4, 3.4] }));
const ring = c.add({ d: circleD(100, 100, 70), stroke: 'faint', width: 1.5, props: { draw: 0 } });const leaf = c.add({ d: segsD(arcSegs(160, 100, 80, 2.5, 3.78).concat(arcSegs(40, 100, 80, -0.64, 0.64)), true), paint: 'accent', width: 3, props: { draw: 0 } });
const tl = new Timeline();tl.play(tween(ring, { draw: 1 }, { duration: 1.2 }));tl.play(tween(leaf, { draw: 1, fill: 0.4 }, { duration: 1.2 }));tl.play(tween(leaf, { spin: 90 }, { duration: 1.2 }));box is the part of the drawing’s own coordinates to show: [x, y, width, height]. You draw in those units, not in scene units, with y pointing down as in SVG. size is how big the drawing is in the scene.
c.add({...}) returns the part. Its options:
| Option | Meaning |
|---|---|
d | The outline as an SVG path, or a function of the part’s properties that makes one |
stroke | Line colour, a role (ink, faint, accent, s1 to s8) or any CSS colour; 'none' hides the line |
paint | Fill colour; there is no fill unless you give one |
width | Line width in the drawing’s units |
about | The point that spin and scale turn and grow about; the middle of the box by default |
props | Starting values of the properties below |
text, at, size, anchor | Make the part a small text tag instead of an outline |
Parts paint in the order they were added, so add the guides first and the finished shape last.
What a part can animate
Section titled “What a part can animate”Everything that changes lives in the part’s properties, so a timeline can tween it:
| Property | Default | Effect |
|---|---|---|
draw | 1 | How much of the outline is drawn, 0 to 1. Start at 0 to make it trace on |
fill | 0 | Opacity of the fill |
line | 1 | Opacity of the outline, so a guide can fade once the shape is filled |
spin | 0 | Degrees about about |
scale | 1 | Growth about about |
dx, dy | 0 | Move, in the drawing’s units |
op | 1 | Opacity of the whole part |
Any property you add through props is animatable too, and when d is a function it receives them all. That is how one outline is reshaped:
const ring = c.add({ d: (p) => circleD(100, 100, p.r as number), props: { r: 30 },});tl.play(tween(ring, { r: 80 }, { duration: 1 }));A part with text is a label for the step: it is drawn at at, in the colour of stroke, and you fade it in with op.
Describing shapes
Section titled “Describing shapes”Outlines are paths made of cubic segments. The helpers build them so you do not write path strings by hand:
| Helper | Gives |
|---|---|
arcSegs(cx, cy, r, a0, a1) | A circular arc as segments. Angles in radians, a positive sweep turns clockwise on screen |
lineSegs(a, b) | A straight segment |
rotateSegs(segs, centre, angle) | The same segments turned about a point |
segsD(segs, close?) | Segments written as a path string. Pass true to close it |
circleD(cx, cy, r) | A full circle that starts at the top and goes clockwise, so it draws on like a compass |
lineD(a, b) | A straight line as a path |
polar(centre, r, degrees) | The point at a distance and an angle (0 to the right, 90 up) |
measureD(a, b, tick?) | A measuring bracket: a line with a tick across each end |
Join arcs and lines with segsD to make a petal, a crescent or a stem, and copy a shape around a centre with rotateSegs:
import { Scene, Construct, lineSegs, rotateSegs, segsD } from 'cseen';
const s = new Scene({ view: 'front', size: [320, 320], padding: 6 });const c = new Construct({ box: [0, 0, 200, 200], size: [3, 3] });const spoke = lineSegs([100, 100], [100, 20]);for (let k = 0; k < 6; k++) c.add({ d: segsD(rotateSegs(spoke, [100, 100], (k * Math.PI) / 3)), width: 4 });s.add(c);Taking it apart
Section titled “Taking it apart”reverse(anim) plays an animation backwards. Every tween in it needs a literal to and a from, so it knows where to go back to:
import { Scene, Timeline, box, tween, reverse } from 'cseen';
const s = new Scene();const b = s.add(box({ at: [0, 0, 0] }));
const tl = new Timeline();const there = tween(b, { pos: [3, 0, 0] }, { duration: 1.5, from: { pos: [-3, 0, 0] } });tl.play(there);tl.play(reverse(there));tl.lockFit(s);A tween with no from, or with a function as its to, throws an error that names what is missing. It also works on a combined animation such as par(...) or stagger(...), as long as every tween inside has a from.
Tying the scene to the drawing
Section titled “Tying the scene to the drawing”Two calls link a drawing to the world around it.
c.at(x, y) returns where a point of the drawing sits in the scene, so you can drop a line from a corner of a shape to where it lifted, or put a dot or a label on it:
const corner = c.at(160, 100);s.add(arrow(corner, [corner[0], corner[1], 2]));scene.screen([x, y, z]) goes the other way, to the picture. It returns where a point of the scene lands, in the scene’s drawing units, with the framing as it is now. Use it to line up something on the page with the figure, for example a grid drawn behind it in CSS. Call it after the framing is settled, for instance after tl.lockFit(s).
- Keep guides
faintand thin, and the thing being explained in theaccentand thick. The eye follows the thick line. - Start every part you want to trace at
draw: 0, and every part you want to fill later atfill: 0. - When a drawing repeats a kind of part, wrap
c.addin a helper with your defaults, as the demo above does withguide. - Use two constructions on the same plane when some parts should stay on the ground and others should rise. Give the second
layer: 1so it paints above the first, and lift it with the group it is in. The 404 page does this. - A construction can also be a face of a solid. The cube above gives each square its own construction, hung from a hinge group that turns, and
cull: truehides a face while its back is toward the camera. - The demo’s code is on its gallery card; open it in the playground to read the whole build.