@altpsyche/maths
v3.0.1
Published
The mathematics AltPsyche's figures are drawn from: vectors, matrices, curves, and a value walked over time.
Maintainers
Readme
@altpsyche/maths
The mathematics behind the figures on altpsyche.dev.
The model
A figure is a description of a picture over time. It carries an extent and a scene. The extent is a width and a height in the figure's own units. The scene is a tree of nodes holding paths, text, transforms and styles. A figure has no canvas, no clock and no state.
Evaluating a figure at time t yields a flat array of marks. A mark is one drawn item: an outline with a fill, a stroke, or both, or a piece of text. Its geometry is expressed in figure units with every transform already applied, and its style is resolved rather than inherited. A mark holds no reference to an output device.
marksAt(figure, t): readonly Mark[]marksAt is a pure function of t. Two evaluations at one time produce identical arrays. A page
playing forward, a reader dragging a scrub bar backward and a recorder stepping at a fixed rate
therefore read one figure.
A painter consumes marks. viewMatrix builds the single affine transform taking figure units to
a surface of a given size. It inverts the y axis, since a figure counts upward and both painters
count downward from the top. No conversion to device units occurs anywhere else.
The figure above is evaluated at t = 7.86 s of a 10.25 s duration. Its extent is 10.8 by 6 units and its marks number 186 there, counting the inset it draws.
A figure is also data. Every part of one has a written form: a node is a record of a kind and its
parameters, a parameter a track drives is an expression, and a timeline is its spans. writeFigure
hands the whole of it back as the text of a file and readFigure reads that text into a figure, so a
picture crosses a machine rather than a process. The four figures on this page are committed as
files beside their pictures, in demos/tangent.figure.json, demos/boolean.figure.json,
demos/rotate.figure.json and demos/surface.figure.json, and a gate reads each one back and holds
its marks against what the demo draws.
Install
npm install @altpsyche/mathsNode 20 or newer, ESM, sideEffects: false. MathJax is a runtime dependency reached by a dynamic
import inside typesetElement, so a figure containing no equations never loads its 41 MB.
@altpsyche/engine is a peer dependency, which a package manager installs alongside this one: the
vectors and matrices in space are imported from the door it declares for its arithmetic, which is one
file and no renderer, and the GPU painter reaches the rest of it by a dynamic import of its own.
A complete figure
import { Timeline, circle, colourFrom, draw, group, marksAt, shape, svgMarkup, vec2, viewAt } from '@altpsyche/maths';
const figure = {
extent: { width: 16, height: 9 },
still: 1,
scene: group('fig', [shape('ring', circle(vec2(0, 0), 3), { stroke: { colour: colourFrom('#fff'), width: 0.05 } })]),
timeline: Timeline.empty().play(draw('fig/ring'), 1),
};
svgMarkup(marksAt(figure, 0.5), viewAt(figure, 0.5, 640, 360), 640, 360);At t = 0.5 the timeline has applied draw at half its span, and the mark carries half the ring's
arc length. svgMarkup returns a complete SVG document as a string, with a view box and no width or
height of its own.
The frame
A frame expression is the width, the height, the aspect or the centre of the frame, read while
the figure is drawn. A mark whose place is built from one moves when the frame changes shape, and a
mark placed in the figure's own units does not. The frame is the extent the figure declares resolved
at the aspect being drawn, never the extent a view move has left, since a view that follows a mark
reads the marks and a mark reading that extent would ask for what is being built. matchingAspect
gives a figure a different extent at each shape it is drawn in.
Geometry
All geometry is cubic Bézier. A path is a sequence of subpaths; a subpath is a start point followed by cubic segments, closed or open. A segment stores its two control points and its endpoint and not its start, which is why the functions evaluating one take that start as an argument.
circle and arc emit segments spanning at most a quarter turn. For a segment of angle θ the
control distance is (4/3)·tan(θ/4). That bounds the radial error at 2.7 × 10⁻⁴ times the radius, and
the suite holds the drawn edge between 2.6 × 10⁻⁴ and 2.8 × 10⁻⁴. splitCurve subdivides by de
Casteljau's construction and pathFromData reads the elliptical arc form of an SVG d attribute by
the conversion the specification itself gives.
unionOf, intersectionOf and differenceOf operate on closed loops of cubics. Crossings are
located per segment pair and refined by Newton's method, the surviving pieces are stitched, and the
result may contain a hole neither operand had. When the kept pieces fail to close, the call throws
and the message states the piece count and the distance between the two open ends. Proximity is a
distance in figure units, TOLERANCE = 1e-6 by default, rather than a fraction of anything.
areaOf returns the signed area, positive for anticlockwise winding, summed over subpaths.
containsPoint flattens to polylines and applies the nonzero winding rule. lengthOf and
pointAlong measure by arc length, and lengthOf reads six parts in a hundred million short of the
truth.
Graphs
A scale is a pair of intervals: the numbers an axis counts through, and where those numbers land
in figure units. coordsOf pairs two scales, and the mapping is a value the caller holds rather
than state read back out of a drawn group.
plot samples a function and emits one Hermite cubic per interval. The control points sit a third
of the way along in x and carry the sample's own slope, so the curve passes through both samples at
both slopes. slopeOf reads the slope off that curve's own cubics, which carry a quadratic with
nothing left over, so a parabola reads exactly rather than to a difference's O(h²). tangentAt
clips the tangent line to the graph analytically rather than by sampling, since a line crosses each
edge once.
streamlineOf integrates a field by fourth-order Runge-Kutta with a step in arc length rather than
in time, which keeps the points evenly spaced. Halving the step divides the error along the curve by
15.1 and then 15.6, against the factor of 16 the order predicts.
Three forms draw what no function of x describes. parametric samples a function of one parameter
and joins the samples by the same Hermite construction, cut where the curve leaves the graph across
the width as well as the height; a unit circle at 96 samples reads within 4.3 × 10⁻⁷ of the true
radius, where the four cubic quarters circle writes leave it 2.7 × 10⁻⁴ out. polar is that call
under the map from a radius and an angle to a place. implicit finds the curve where a function of
two numbers reaches a level, by marching squares: crossings are bisected on the cell edge rather than
interpolated along it, an ambiguous cell is resolved by the value at its middle, and each place leaves
along the gradient turned a quarter turn, which holds a unit circle within 2.3 × 10⁻⁷ of the true
radius at 64 cells. An implicit curve is the one curve here whose count of places the figure does not
fix, so it cannot be a morph's source.
Space
sphere3, cube3, cylinder3 and torus3 are cells over a parametrisation run in the order that
faces every cell away from the solid, each also available as cells for a scene to sort among its own.
A flat cell falls inside a sphere's true radius by 417.5, 106.4 and 26.7 parts in ten thousand at 12,
24 and 48 steps. curveOf3 reads a curve in space from one parameter, which is the third point
producer beside sectionOf and streamlineOf. curvePieces3 cuts that curve into one entry per
step, so a helix round a cylinder is sorted against the cylinder's own cells: all 300 of them stand
between the first piece of the helix and the last, where a curve sorted whole is one mark at one depth
and is painted entirely in front of the solid or entirely behind it.
camera3 holds an eye, a target, an up vector, a view matrix and a projection. perspective and
orthographic supply the projection; the orthographic case is a scale rather than a divide and
therefore has no near plane. Every builder that works in space projects to figure units and returns
the same node types a graph returns, so one animation reaches both.
scene3 orders children back to front by the depth of their sample points, which is the painter's
algorithm. surface3 cuts a surface into four-cornered cells, so two surfaces sort against each
other rather than as two groups. The sort is stable, so pieces at equal depth hold the order the
author gave and a picture does not flicker between frames. sectionOf returns the runs of points
where a plane cuts a parametric surface, closing a run whose ends meet.
A sort alone leaves two cases with no right answer: pieces that pierce each other, and overlaps that
run in a ring. So a mark carries a depth as well. Mark.depth is three numbers giving how far the
mark stands from the eye as a function of where on the page it is drawn, and of two marks over one
point the smaller number is the nearer. Three numbers are exact for a flat piece of the world, which
is what a cell, a face and a segment each are. The builders in space fit them; a flat figure carries
none, and a mark carrying none clears the depths before it, so a caption drawn after a surface
covers it.
Time and motion
An animation maps marks and a fraction of a span to marks. Twenty-one of them are supplied, among
them draw, fadeIn, moveAlong, rotate, applyMatrix, morph, morphEquation, which pairs the
glyphs of two typeset expressions and moves only the difference, and morphGroup, which pairs two
groups of marks by name and walks each pair in geometry and in style. Every animation is the identity at the start
of its span. Every mark it introduces exists at every fraction, at zero opacity where it is not yet
visible, so a frame-to-frame comparison never reports an arrival.
Timeline sequences animations by play, together and stagger, each span carrying its own
easing curve. Timeline.at applies a finished span in full and an unstarted one at zero, making the
timeline a function of time rather than a record of what has played. Tracks are the second source of
values: sampleTrack reads a keyed value at a time, holding the nearest key outside the keyed
range.
framesOf walks a figure at a fixed rate or count and yields one frame at a time. A frame carries
its index, its time, its marks and the view matrix built at that same time. The walk stops strictly
before the duration, so a looping figure never emits its first frame twice.
Painters
svgMarkup returns a document as a string and svgElements returns the elements as data.
paintSvg replaces the children of an element already in a document, and paintCanvas draws into a
two-dimensional context. Coordinates are written to three decimal places. That is finer than any
screen or encoder resolves, and coarse enough that the last bits of a double never reach the output.
One test paints a single mark list both ways and holds the two to the same geometry within a
thousandth of a pixel, and to the same style exactly.
The two flat painters meet the depths by cutting. depthOrder cuts each mark where its depth crosses
another's, which is along a straight line on the page, and paints the pieces furthest first. The GPU
painter keeps a depth attachment and lets the card compare at every pixel, which is what two surfaces
passing through each other need.
A figure may name the painters that can draw it. Figure.painters is a list of names and
PAINTER_NAMES holds the three: svg, canvas and gpu. Left out, all three may. The refusal is
at marksAt, since a painter is handed marks and never sees the figure they came from.
Recording
A recording is a figure walked at a fixed rate and encoded into a video file. recordFigure walks
the figure, paints each frame onto the sink's own canvas and hands the frame over. videoSink is a
sink that encodes: it loads mediabunny inside the call, so a consumer who never records never loads
an encoder.
const canvas = document.createElement('canvas');
canvas.width = 1080;
canvas.height = 600;
const sink = await videoSink(canvas, { fps: 30, format: 'mp4', codec: 'avc' });
const { frames, seconds, output } = await recordFigure(figure, sink, {
fps: 30,
width: 1080,
height: 600,
background: colourFrom('#ffffff'),
});output is the finished file as bytes. The walk is frameTimesOf, the same one the strips are drawn
from, so a recording holds the frames the rest of the package counts. A frame lasts exactly one over
the rate, and the walk stops strictly before the end, since the frame at the duration of a figure
that loops is its own first frame.
A recording runs for the figure's own length unless seconds asks for another. Past the end, a
figure that declares itself a loop is read at the remainder and a figure that does not holds its last
picture, which is figureTime.
FrameSink is the parameter that makes the encoder replaceable: it owns the canvas each frame is
painted onto, takes each painted frame, and hands back whatever it collected. A sink that counts the
frames it is given is how the walk is checked without a device.
Encoding needs a WebCodecs VideoEncoder. A browser has one and Node does not, so the bytes are a
gate with a browser in it: npm run gate:record records every committed figure in Chromium, reads
each file back, and says how many pictures the file holds.
Restrictions
A mark may request only what every painter that may draw the figure implements: no filters and no
blend modes. Which painters those are is Figure.painters, and left out it is all three. A figure using
an SVG filter would render correctly on a page and lose the effect silently in a recording.
A clip is a rectangle and no other shape, and that exclusion is not the rule above. Both painters
clip, with clip-path and with clip(). An arbitrary path clip needs a winding number counted,
which is a stencil on a card, where a box is the scissor test every device already has.
A fill carries one colour and a gradient beside it. SVG names a gradient with an element carrying a document-unique identifier and a canvas with an object built from the context, and the one colour stays because a contrast reading and anything else needing a single colour has to have one.
A stroke's width is one number or a taper between two numbers along a named curve, drawn as the
filled outline of its own path, since neither painter strokes at two widths. A width per point is not
something a figure can name. A colour is four channels and, where its author gave it one, the name a
page themes it under, so the SVG painter writes var(--name, #rrggbb) and every other painter reads
the numbers: a renderer in another language cannot resolve a custom property and a shader takes
numbers. colourFrom builds one from '#1b1b1b' or 'rgb(27, 27, 27)' and throws on every other
form rather than guessing. Nothing reads the page, and getComputedStyle appears nowhere in the
tree.
No screenshot gates this package. Every assertion reads a mark list or a number, so the suite of
1,310 tests over 87 files runs in Node without a browser. Comparisons are by tolerance rather than by
hash, because Math.sin, Math.cos and Math.pow are not specified to the last bit and differ
between engines. The one claim a browser is needed for is that a recording plays, and that is
npm run gate:record rather than part of the suite.
Moving from 1.6.0
1.6.0 was the version before this one on npm, and 2.0.0 is the figure format: a figure is a JSON
document a program reads, readFigure and writeFigure are the two calls that carry it either way,
and docs/SPECIFICATION.md states the whole of it for a renderer written in
another language.
The door is additive. It went from 266 names to 375 and no name was removed or renamed.
A colour changes shape, and it is the change that reaches every caller. A Colour was any CSS
colour written as text and is now four channels with an optional name, so every Fill, Stroke and
Stop a caller builds moves with it.
// at 1.6.0
shape('disc', circle(vec2(0, 0), 1), { fill: { colour: 'var(--accent, #fb923c)' } });
// at 2.0.0
shape('disc', circle(vec2(0, 0), 1), { fill: { colour: colourFrom('#fb923c', 'accent') } });colourFrom reads a hex or an rgb() and takes the name a page themes the colour under, colourOf
reads the channels alone, and hexOf and colourText write one back out. A form neither reads is
refused rather than painted as nothing: a named colour or an hsl() read as black is a wrong picture
with nothing to say it went wrong. The SVG painter still writes var(--name, #rrggbb), from the
channels the colour holds, so a page themes a figure exactly as it did.
Three readers take the drawn path rather than the function behind it.
| call | at 1.6.0 | at 2.0.0 |
| --- | --- | --- |
| slopeOf | (of, x, step?) | (coords, curve, x) |
| areaUnder | (coords, of, over, options?) | (coords, curve, options?) |
| tangentAt | (coords, of, x, options?) | (coords, curve, x, options?) |
What that buys is a tangent that touches the curve a reader can see. A slope read by a central
difference on the function differs from the slope of the cubic the curve is drawn as, and the two
parted by 1.06e-11 at a parabola's stationary point. Reading the drawn path makes the tangent exact
there. AreaOptions no longer extends PlotOptions, since the sampling belongs to the plot that
made the curve, and TangentOptions loses step, since nothing is differenced any more.
plot, riemannBars, vectorField, surface3, sectionOf and streamlineOf still take a
function and always will. A function making fixed geometry never had to serialise, and a figure
that stores geometry stores what the function produced.
Nothing else a caller reads or implements changed shape. CanvasLike, PaintNode, Mark,
Span and Stroke are what 1.6.0 published.
Further reading
docs/GUIDE.md teaches the package in order. docs/REFERENCE.md carries one entry for each of the 375 names at the door. DESIGN.md states why the design is what it is and what it will not become.
index.ts is the entire public surface, and nothing outside the package reaches a file inside it by
path. A line divides the package: values and timing below it, figures and painters above, and
nothing below the line imports anything above it. A test holds each of those.
MIT.
