@altpsyche/maths 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The curve where a plane cuts a surface, found on the grid the surface is
3
+ * already drawn from.
4
+ *
5
+ * This is marching squares, with the plane's signed distance as the value at each
6
+ * grid point. A crossing point is found by walking along a cell edge to where
7
+ * that distance reaches nothing, and because signed distance to a plane changes
8
+ * evenly along a straight line, every point this finds lies on the plane exactly.
9
+ * It lies on the chord between two samples of the surface rather than on the
10
+ * surface itself, which is the whole of the error and is why halving the cell
11
+ * size quarters it.
12
+ */
13
+ import { interval } from '../values/interval.js';
14
+ import { vec3 } from '../values/vec3.js';
15
+ import { TOLERANCE } from './tolerance.js';
16
+ /** Which edge of a cell each pair of corners is, going round from the corner at
17
+ * the low end of both parameters. */
18
+ const EDGES = [
19
+ [0, 1],
20
+ [1, 2],
21
+ [2, 3],
22
+ [3, 0],
23
+ ];
24
+ function keyFor(i, j, edge) {
25
+ if (edge === 0)
26
+ return `h${i},${j}`;
27
+ if (edge === 1)
28
+ return `v${i + 1},${j}`;
29
+ if (edge === 2)
30
+ return `h${i},${j + 1}`;
31
+ return `v${i},${j}`;
32
+ }
33
+ /**
34
+ * The chains a set of segments makes, each walked from one loose end to the other
35
+ * and then round whatever loops are left.
36
+ */
37
+ function chainsOf(segments, count) {
38
+ const next = new Map();
39
+ for (const [from, to] of segments) {
40
+ if (!next.has(from))
41
+ next.set(from, []);
42
+ if (!next.has(to))
43
+ next.set(to, []);
44
+ next.get(from).push(to);
45
+ next.get(to).push(from);
46
+ }
47
+ const used = segments.map(() => false);
48
+ const at = new Map();
49
+ segments.forEach(([from, to], index) => {
50
+ if (!at.has(from))
51
+ at.set(from, []);
52
+ if (!at.has(to))
53
+ at.set(to, []);
54
+ at.get(from).push(index);
55
+ at.get(to).push(index);
56
+ });
57
+ const walkFrom = (start) => {
58
+ const chain = [start];
59
+ let here = start;
60
+ for (;;) {
61
+ const step = (at.get(here) ?? []).find((index) => !used[index]);
62
+ if (step === undefined)
63
+ return chain;
64
+ used[step] = true;
65
+ const [from, to] = segments[step];
66
+ here = from === here ? to : from;
67
+ chain.push(here);
68
+ }
69
+ };
70
+ const chains = [];
71
+ // Loose ends first, so a chain that runs off the edge of the grid is walked
72
+ // from its end rather than being started in the middle and coming out as two.
73
+ for (let point = 0; point < count; point += 1) {
74
+ if ((next.get(point)?.length ?? 0) === 1) {
75
+ const chain = walkFrom(point);
76
+ if (chain.length > 1)
77
+ chains.push(chain);
78
+ }
79
+ }
80
+ for (let index = 0; index < segments.length; index += 1) {
81
+ if (used[index])
82
+ continue;
83
+ const chain = walkFrom(segments[index][0]);
84
+ if (chain.length > 1)
85
+ chains.push(chain);
86
+ }
87
+ return chains;
88
+ }
89
+ /** Chains whose ends meet joined into one, which is what closes a curve that the
90
+ * grid split at the seam where a parameter wraps round. */
91
+ function joinEnds(runs, tolerance) {
92
+ const meets = (a, b) => vec3.magnitude(vec3.sub(a, b)) <= tolerance;
93
+ const open = runs.slice();
94
+ const done = [];
95
+ while (open.length > 0) {
96
+ let run = open.shift();
97
+ for (;;) {
98
+ if (run.length > 2 && meets(run[0], run[run.length - 1]))
99
+ break;
100
+ const found = open.findIndex((other) => meets(run[run.length - 1], other[0]) ||
101
+ meets(run[run.length - 1], other[other.length - 1]) ||
102
+ meets(run[0], other[0]) ||
103
+ meets(run[0], other[other.length - 1]));
104
+ if (found === -1)
105
+ break;
106
+ const other = open.splice(found, 1)[0];
107
+ if (meets(run[run.length - 1], other[0]))
108
+ run = [...run, ...other.slice(1)];
109
+ else if (meets(run[run.length - 1], other[other.length - 1]))
110
+ run = [...run, ...other.slice(0, -1).reverse()];
111
+ else if (meets(run[0], other[other.length - 1]))
112
+ run = [...other.slice(0, -1), ...run];
113
+ else
114
+ run = [...other.slice(1).reverse(), ...run];
115
+ }
116
+ // A run whose two ends meet is given its first point again, so a caller draws
117
+ // a loop by drawing the points it is handed and needs no flag.
118
+ if (run.length > 2 && meets(run[0], run[run.length - 1]))
119
+ run = [...run.slice(0, -1), run[0]];
120
+ done.push(run);
121
+ }
122
+ return done;
123
+ }
124
+ /**
125
+ * The runs of points where a plane cuts a surface, in space.
126
+ *
127
+ * A run whose two ends meet comes back with its first point repeated at the end,
128
+ * so drawing the points as they are given draws the loop closed.
129
+ */
130
+ export function sectionOf(of, plane, options = {}) {
131
+ const { u = interval(0, 1), v = interval(0, 1), resolution = 24, tolerance = TOLERANCE } = options;
132
+ const steps = typeof resolution === 'number' ? { u: resolution, v: resolution } : resolution;
133
+ const facing = vec3.normalize(plane.normal);
134
+ const sample = [];
135
+ const gap = [];
136
+ for (let i = 0; i <= steps.u; i += 1) {
137
+ sample.push([]);
138
+ gap.push([]);
139
+ for (let j = 0; j <= steps.v; j += 1) {
140
+ const point = of(interval.at(u, i / steps.u), interval.at(v, j / steps.v));
141
+ sample[i].push(point);
142
+ gap[i].push(vec3.dot(facing, vec3.sub(point, plane.point)));
143
+ }
144
+ }
145
+ const points = [];
146
+ const found = new Map();
147
+ const segments = [];
148
+ for (let i = 0; i < steps.u; i += 1) {
149
+ for (let j = 0; j < steps.v; j += 1) {
150
+ const corners = [sample[i][j], sample[i + 1][j], sample[i + 1][j + 1], sample[i][j + 1]];
151
+ const gaps = [gap[i][j], gap[i + 1][j], gap[i + 1][j + 1], gap[i][j + 1]];
152
+ const crossed = [];
153
+ for (let edge = 0; edge < 4; edge += 1) {
154
+ const [from, to] = EDGES[edge];
155
+ if (gaps[from] >= 0 === gaps[to] >= 0)
156
+ continue;
157
+ const key = keyFor(i, j, edge);
158
+ let index = found.get(key);
159
+ if (index === undefined) {
160
+ const along = gaps[from] / (gaps[from] - gaps[to]);
161
+ index = points.push(vec3.lerp(corners[from], corners[to], along)) - 1;
162
+ found.set(key, index);
163
+ }
164
+ crossed.push(edge);
165
+ }
166
+ if (crossed.length === 2) {
167
+ segments.push([found.get(keyFor(i, j, crossed[0])), found.get(keyFor(i, j, crossed[1]))]);
168
+ continue;
169
+ }
170
+ if (crossed.length !== 4)
171
+ continue;
172
+ // A cell whose corners alternate in sign has two ways to be joined and the
173
+ // grid cannot tell them apart. The middle of the cell decides: the pair of
174
+ // corners it agrees with is the pair the curve runs around.
175
+ const middle = (gaps[0] + gaps[1] + gaps[2] + gaps[3]) / 4;
176
+ const pairs = middle >= 0 === gaps[0] >= 0 ? [[0, 1], [2, 3]] : [[3, 0], [1, 2]];
177
+ for (const [first, second] of pairs) {
178
+ segments.push([found.get(keyFor(i, j, first)), found.get(keyFor(i, j, second))]);
179
+ }
180
+ }
181
+ }
182
+ const runs = chainsOf(segments, points.length).map((chain) => chain.map((index) => points[index]));
183
+ return joinEnds(runs, tolerance);
184
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Marks placed at points in space, which a camera turns into the flat nodes the
3
+ * rest of this package already draws.
4
+ *
5
+ * Every builder here hands back a group, because a shape in space is not always
6
+ * one shape on the page: a line running past the eye comes back as the pieces of
7
+ * it the eye can see, and a shape wholly behind the eye comes back as a group
8
+ * with no children, which flattens to no marks rather than to a mark of nothing.
9
+ */
10
+ import { type Vec3 } from '../values/vec3.js';
11
+ import type { Vec2 } from '../values/vec2.js';
12
+ import { type GroupNode, type Node, type Style, type TextOptions } from './node.js';
13
+ import { type Interval } from '../values/interval.js';
14
+ import type { Fill, Stroke } from './mark.js';
15
+ import type { Camera3 } from './camera.js';
16
+ export type Polyline3Options = Style & {
17
+ /** Whether the last point joins back to the first. A run that the near plane
18
+ * cut comes back open however this is set, since closing it would draw an edge
19
+ * that is nowhere in the world. */
20
+ close?: boolean;
21
+ };
22
+ /** A run of straight segments through points in space. */
23
+ export declare function polyline3(name: string, points: readonly Vec3[], camera: Camera3, options?: Polyline3Options): GroupNode;
24
+ /** A disc marking a point in space. Its radius is in figure units and does not
25
+ * shrink with distance, because a dot marks where something is rather than how
26
+ * big it is. */
27
+ export declare function dot3(name: string, at: Vec3, radius: number, fill: Fill, camera: Camera3): GroupNode;
28
+ export type Text3Options = TextOptions & {
29
+ /** How far the label stands off the point it names, in figure units, applied
30
+ * after the point is placed. */
31
+ offset?: Vec2;
32
+ };
33
+ /** A label at a point in space. The letters stay upright and stay the size they
34
+ * are given, since a label is read rather than seen in perspective. */
35
+ export declare function text3(name: string, at: Vec3, content: string, size: number, camera: Camera3, options?: Text3Options): GroupNode;
36
+ /** A drawn piece and the points in space it was drawn from, which are what say
37
+ * how far off it is. */
38
+ export type SpaceItem = {
39
+ points: readonly Vec3[];
40
+ node: Node;
41
+ };
42
+ /**
43
+ * A group whose children are ordered back to front, so the near piece is painted
44
+ * over the far one.
45
+ *
46
+ * This is the painter's algorithm, and what it cannot do is worth knowing before
47
+ * it is used: two pieces that pass through each other, and three that overlap in
48
+ * a ring, have no one order at all, and no comparison of depths can find one. The
49
+ * answer for those is smaller pieces, which is why a surface is cut into cells.
50
+ *
51
+ * Two pieces at the same depth keep the order the author gave them, since the
52
+ * sort is stable, and a picture that changed which of two touching faces was on
53
+ * top between frames would flicker.
54
+ */
55
+ export declare function space(name: string, items: readonly SpaceItem[], camera: Camera3): GroupNode;
56
+ export type Surface3Options = {
57
+ /** The run of the first parameter, nothing to one unless named. */
58
+ u?: Interval;
59
+ /** The run of the second parameter, nothing to one unless named. */
60
+ v?: Interval;
61
+ /** How many cells each way. */
62
+ resolution?: number | {
63
+ u: number;
64
+ v: number;
65
+ };
66
+ /**
67
+ * The colour a cell is filled with, given how squarely it faces the light: one
68
+ * where it faces the light head on, a half where it is edge on, and nothing
69
+ * where it faces straight away.
70
+ *
71
+ * The author supplies this rather than naming two colours to mix, because
72
+ * mixing two colours means reading them, and a colour here is any CSS colour
73
+ * written as text with nothing that parses one.
74
+ */
75
+ shade: (amount: number) => Fill;
76
+ /** Which way the light comes from, over the shoulder of an eye on the positive
77
+ * z axis unless named. */
78
+ light?: Vec3;
79
+ /** Whether a cell facing away from the eye is left out. Off by default, because
80
+ * a count that changes as the camera turns is a count no gate can hold. */
81
+ cull?: boolean;
82
+ stroke?: Stroke;
83
+ };
84
+ /**
85
+ * The cells a surface is made of, before they are put in an order.
86
+ *
87
+ * Cells rather than one shape is what makes the depth sort work at all: a surface
88
+ * that folds over itself has no one place in a painting order, and pieces small
89
+ * enough to be flat do.
90
+ *
91
+ * A scene holding a surface and a plane that cuts through it has to sort all of
92
+ * their cells together, since two surfaces sorted apart are two groups and the
93
+ * second is painted over the first whichever way round they stand. Each cell
94
+ * carries the name it was given ahead of its own place in the grid, so an
95
+ * animation can still name a whole surface once its cells are mixed with
96
+ * another's.
97
+ */
98
+ export declare function surfaceCells(name: string, of: (u: number, v: number) => Vec3, camera: Camera3, options: Surface3Options): SpaceItem[];
99
+ /**
100
+ * A surface given by a function of two parameters, drawn as a grid of
101
+ * four-cornered cells ordered back to front.
102
+ */
103
+ export declare function surface3(name: string, of: (u: number, v: number) => Vec3, camera: Camera3, options: Surface3Options): GroupNode;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Marks placed at points in space, which a camera turns into the flat nodes the
3
+ * rest of this package already draws.
4
+ *
5
+ * Every builder here hands back a group, because a shape in space is not always
6
+ * one shape on the page: a line running past the eye comes back as the pieces of
7
+ * it the eye can see, and a shape wholly behind the eye comes back as a group
8
+ * with no children, which flattens to no marks rather than to a mark of nothing.
9
+ */
10
+ import { vec3 } from '../values/vec3.js';
11
+ import { circle, polygon, polyline } from './path.js';
12
+ import { group, shape, text } from './node.js';
13
+ import { interval } from '../values/interval.js';
14
+ /**
15
+ * Where along a segment the near plane is crossed.
16
+ *
17
+ * Depth changes evenly along a segment, because lining the world up with the eye
18
+ * moves and turns a point and never divides it, so the crossing is one division
19
+ * rather than a search.
20
+ */
21
+ function crossingAt(from, to, near) {
22
+ return (near - from) / (to - from);
23
+ }
24
+ /**
25
+ * The pieces of a run of points in space that the eye can see, cut where they
26
+ * cross the near plane.
27
+ *
28
+ * A point nearer the eye than the near plane has no place on the page: under a
29
+ * perspective the divide flips its sign and puts it on the wrong side of the
30
+ * frame, so the segment is cut rather than drawn to it.
31
+ */
32
+ function visibleRuns(points, camera) {
33
+ const near = camera.projection.near;
34
+ const seen = points.map((point) => camera.project(point));
35
+ const uncut = seen.every((point) => point.inFront);
36
+ const runs = [];
37
+ let current = [];
38
+ for (let i = 0; i < points.length; i += 1) {
39
+ const here = seen[i];
40
+ if (here.inFront) {
41
+ if (i > 0 && !seen[i - 1].inFront) {
42
+ const along = crossingAt(seen[i - 1].depth, here.depth, near);
43
+ current.push(camera.project(vec3.lerp(points[i - 1], points[i], along)).at);
44
+ }
45
+ current.push(here.at);
46
+ continue;
47
+ }
48
+ if (i > 0 && seen[i - 1].inFront) {
49
+ const along = crossingAt(seen[i - 1].depth, here.depth, near);
50
+ current.push(camera.project(vec3.lerp(points[i - 1], points[i], along)).at);
51
+ }
52
+ if (current.length > 1)
53
+ runs.push({ points: current, whole: false });
54
+ current = [];
55
+ }
56
+ if (current.length > 1)
57
+ runs.push({ points: current, whole: uncut });
58
+ return runs;
59
+ }
60
+ /** A run of straight segments through points in space. */
61
+ export function polyline3(name, points, camera, options = {}) {
62
+ const { close = false, ...style } = options;
63
+ const runs = visibleRuns(points, camera);
64
+ return group(name, runs.map((run) => shape('run', close && run.whole ? polygon(run.points) : polyline(run.points), style)));
65
+ }
66
+ /** A disc marking a point in space. Its radius is in figure units and does not
67
+ * shrink with distance, because a dot marks where something is rather than how
68
+ * big it is. */
69
+ export function dot3(name, at, radius, fill, camera) {
70
+ const seen = camera.project(at);
71
+ return group(name, seen.inFront ? [shape('disc', circle(seen.at, radius), { fill })] : []);
72
+ }
73
+ /** A label at a point in space. The letters stay upright and stay the size they
74
+ * are given, since a label is read rather than seen in perspective. */
75
+ export function text3(name, at, content, size, camera, options = {}) {
76
+ const { offset, ...style } = options;
77
+ const seen = camera.project(at);
78
+ const anchor = offset ? { x: seen.at.x + offset.x, y: seen.at.y + offset.y } : seen.at;
79
+ return group(name, seen.inFront ? [text('label', anchor, content, size, style)] : []);
80
+ }
81
+ /**
82
+ * The mean of a piece's own depths, so a piece is ordered by where its middle is
83
+ * rather than by whichever corner happens to be nearest.
84
+ */
85
+ function middleDepth(points, camera) {
86
+ if (points.length === 0)
87
+ return 0;
88
+ let total = 0;
89
+ for (const point of points)
90
+ total += camera.project(point).depth;
91
+ return total / points.length;
92
+ }
93
+ /**
94
+ * A group whose children are ordered back to front, so the near piece is painted
95
+ * over the far one.
96
+ *
97
+ * This is the painter's algorithm, and what it cannot do is worth knowing before
98
+ * it is used: two pieces that pass through each other, and three that overlap in
99
+ * a ring, have no one order at all, and no comparison of depths can find one. The
100
+ * answer for those is smaller pieces, which is why a surface is cut into cells.
101
+ *
102
+ * Two pieces at the same depth keep the order the author gave them, since the
103
+ * sort is stable, and a picture that changed which of two touching faces was on
104
+ * top between frames would flicker.
105
+ */
106
+ export function space(name, items, camera) {
107
+ const measured = items.map((item) => ({ node: item.node, depth: middleDepth(item.points, camera) }));
108
+ measured.sort((a, b) => b.depth - a.depth);
109
+ return group(name, measured.map((item) => item.node));
110
+ }
111
+ function resolutionOf(resolution) {
112
+ return typeof resolution === 'number' ? { u: resolution, v: resolution } : resolution;
113
+ }
114
+ /**
115
+ * The cells a surface is made of, before they are put in an order.
116
+ *
117
+ * Cells rather than one shape is what makes the depth sort work at all: a surface
118
+ * that folds over itself has no one place in a painting order, and pieces small
119
+ * enough to be flat do.
120
+ *
121
+ * A scene holding a surface and a plane that cuts through it has to sort all of
122
+ * their cells together, since two surfaces sorted apart are two groups and the
123
+ * second is painted over the first whichever way round they stand. Each cell
124
+ * carries the name it was given ahead of its own place in the grid, so an
125
+ * animation can still name a whole surface once its cells are mixed with
126
+ * another's.
127
+ */
128
+ export function surfaceCells(name, of, camera, options) {
129
+ const { u = interval(0, 1), v = interval(0, 1), resolution = 24, shade, light = vec3(0, 0, 1), cull = false, stroke } = options;
130
+ const steps = resolutionOf(resolution);
131
+ const toLight = vec3.normalize(light);
132
+ const items = [];
133
+ for (let i = 0; i < steps.u; i += 1) {
134
+ for (let j = 0; j < steps.v; j += 1) {
135
+ const corners = [
136
+ of(interval.at(u, i / steps.u), interval.at(v, j / steps.v)),
137
+ of(interval.at(u, (i + 1) / steps.u), interval.at(v, j / steps.v)),
138
+ of(interval.at(u, (i + 1) / steps.u), interval.at(v, (j + 1) / steps.v)),
139
+ of(interval.at(u, i / steps.u), interval.at(v, (j + 1) / steps.v)),
140
+ ];
141
+ const normal = vec3.normalize(vec3.cross(vec3.sub(corners[1], corners[0]), vec3.sub(corners[3], corners[0])));
142
+ if (cull) {
143
+ const middle = corners.reduce((sum, corner) => vec3.add(sum, vec3.scale(corner, 1 / 4)), vec3.ZERO);
144
+ if (vec3.dot(normal, vec3.sub(camera.eye, middle)) <= 0)
145
+ continue;
146
+ }
147
+ const fill = shade((vec3.dot(normal, toLight) + 1) / 2);
148
+ items.push({
149
+ points: corners,
150
+ node: polyline3(`${name}/${i}-${j}`, corners, camera, { close: true, fill, stroke }),
151
+ });
152
+ }
153
+ }
154
+ return items;
155
+ }
156
+ /**
157
+ * A surface given by a function of two parameters, drawn as a grid of
158
+ * four-cornered cells ordered back to front.
159
+ */
160
+ export function surface3(name, of, camera, options) {
161
+ return space(name, surfaceCells('cell', of, camera, options), camera);
162
+ }
package/dist/index.d.ts CHANGED
@@ -18,6 +18,8 @@ export { interval } from './values/interval.js';
18
18
  export type { Interval } from './values/interval.js';
19
19
  export { mat3 } from './values/mat3.js';
20
20
  export type { Mat3 } from './values/mat3.js';
21
+ export { mat4 } from './values/mat4.js';
22
+ export type { Mat4, OrthographicOptions, PerspectiveOptions } from './values/mat4.js';
21
23
  export { SAME_TIME, keyAt, sampleTrack, sampleTracks, withKey, withoutKey } from './timing/track.js';
22
24
  export type { Key, Track, TrackValue, Tracks } from './timing/track.js';
23
25
  export { arc, circle, line, polygon, polyline, pointCount, pointOn, rect, slopeOn, splitCurve, straight, transformPath } from './figure/path.js';
@@ -42,6 +44,14 @@ export { areaUnder, plot, riemannBars, slopeOf, tangentAt } from './figure/plot.
42
44
  export type { AreaOptions, BarsOptions, PlotOptions, TangentOptions } from './figure/plot.js';
43
45
  export { axes, numberLine, numberPlane } from './figure/axis.js';
44
46
  export type { AxesOptions, NumberLineOptions, NumberPlaneOptions } from './figure/axis.js';
47
+ export { camera3, orthographic, perspective } from './figure/camera.js';
48
+ export type { Camera3, Camera3Choice, OrthographicChoice, PerspectiveChoice, Projected, Projection } from './figure/camera.js';
49
+ export { dot3, polyline3, space, surface3, surfaceCells, text3 } from './figure/space.js';
50
+ export type { Polyline3Options, SpaceItem, Surface3Options, Text3Options } from './figure/space.js';
51
+ export { axes3 } from './figure/axis3.js';
52
+ export type { Axes3Options } from './figure/axis3.js';
53
+ export { sectionOf } from './figure/section.js';
54
+ export type { Plane, SectionOptions } from './figure/section.js';
45
55
  export { coordsOf, pointOf, scaleOf, scaled, unscaled } from './figure/scale.js';
46
56
  export type { Coords, Scale } from './figure/scale.js';
47
57
  export { labelFor, tickStep, ticksOn } from './figure/ticks.js';
@@ -55,7 +65,7 @@ export type { PlayOptions, Span, StaggerOptions } from './figure/timeline.js';
55
65
  export { lengthOf, pointAlong } from './figure/length.js';
56
66
  export { trimPath } from './figure/trim.js';
57
67
  export { alignPaths, lerpPath } from './figure/morph.js';
58
- export { at, durationOf, loops, sameMarks } from './figure/figure.js';
68
+ export { at, durationOf, loops, sameMarks, viewAt } from './figure/figure.js';
59
69
  export type { Figure, Values } from './figure/figure.js';
60
70
  export { pathData, paintSvg, svgElements, svgMarkup } from './paint/svg.js';
61
71
  export type { ElementMaker, PaintNode, PaintTarget, SvgElement } from './paint/svg.js';
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export { vec2 } from './values/vec2.js';
13
13
  export { vec3 } from './values/vec3.js';
14
14
  export { interval } from './values/interval.js';
15
15
  export { mat3 } from './values/mat3.js';
16
+ export { mat4 } from './values/mat4.js';
16
17
  export { SAME_TIME, keyAt, sampleTrack, sampleTracks, withKey, withoutKey } from './timing/track.js';
17
18
  export { arc, circle, line, polygon, polyline, pointCount, pointOn, rect, slopeOn, splitCurve, straight, transformPath } from './figure/path.js';
18
19
  export { pathFromData } from './figure/path-data.js';
@@ -26,6 +27,10 @@ export { byAspect, fractionOf, matchingAspect, resolveExtent, viewMatrix } from
26
27
  export { boundsOf, boundsOfMarks, centreOf } from './figure/bounds.js';
27
28
  export { areaUnder, plot, riemannBars, slopeOf, tangentAt } from './figure/plot.js';
28
29
  export { axes, numberLine, numberPlane } from './figure/axis.js';
30
+ export { camera3, orthographic, perspective } from './figure/camera.js';
31
+ export { dot3, polyline3, space, surface3, surfaceCells, text3 } from './figure/space.js';
32
+ export { axes3 } from './figure/axis3.js';
33
+ export { sectionOf } from './figure/section.js';
29
34
  export { coordsOf, pointOf, scaleOf, scaled, unscaled } from './figure/scale.js';
30
35
  export { labelFor, tickStep, ticksOn } from './figure/ticks.js';
31
36
  export { flatten, group, shape, text } from './figure/node.js';
@@ -34,7 +39,7 @@ export { Timeline } from './figure/timeline.js';
34
39
  export { lengthOf, pointAlong } from './figure/length.js';
35
40
  export { trimPath } from './figure/trim.js';
36
41
  export { alignPaths, lerpPath } from './figure/morph.js';
37
- export { at, durationOf, loops, sameMarks } from './figure/figure.js';
42
+ export { at, durationOf, loops, sameMarks, viewAt } from './figure/figure.js';
38
43
  export { pathData, paintSvg, svgElements, svgMarkup } from './paint/svg.js';
39
44
  export { paintCanvas } from './paint/canvas.js';
40
45
  export { arrow, brace, bracePath, callout, dot } from './figure/annotate.js';
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The transform a point in space carries: how a figure's camera turns a place in
3
+ * the world into a place on the page.
4
+ *
5
+ * Sixteen numbers, column-major, matching the engine's layout the way `Mat3`
6
+ * does: the first four are the first column rather than the first row, and the
7
+ * entry at flat index `col * 4 + row` is the one in that column and row. The
8
+ * fourth row exists so that a translation is a multiplication like every other
9
+ * move, and the fourth coordinate a point picks up is what a perspective divide
10
+ * reads.
11
+ *
12
+ * There is no inverse here because nothing needs one: a camera builds its view
13
+ * and its projection forwards and never undoes either. The day something has to
14
+ * go from the page back into the world, that is when an inverse is written.
15
+ */
16
+ import { type Vec3 } from './vec3.js';
17
+ export type Mat4 = readonly [
18
+ number,
19
+ number,
20
+ number,
21
+ number,
22
+ number,
23
+ number,
24
+ number,
25
+ number,
26
+ number,
27
+ number,
28
+ number,
29
+ number,
30
+ number,
31
+ number,
32
+ number,
33
+ number
34
+ ];
35
+ /** Column-major product, so `multiply(a, b)` applies `b` to a point first and
36
+ * then `a`, which is the order a projection sits outside a view. */
37
+ declare function multiply(a: Mat4, b: Mat4): Mat4;
38
+ /** The last column carries the offset, so this moves a point and leaves a
39
+ * direction where it was. */
40
+ declare function translation(v: Vec3): Mat4;
41
+ declare function scaling(v: Vec3): Mat4;
42
+ /** Turns y towards z, so a positive angle is anticlockwise seen from the far
43
+ * end of the x axis looking back at the origin. */
44
+ declare function rotationX(radians: number): Mat4;
45
+ /** Turns z towards x, which is the odd one out: the pair of axes runs z then x
46
+ * rather than x then z, and writing it the other way flips every y rotation. */
47
+ declare function rotationY(radians: number): Mat4;
48
+ /** Turns x towards y, which is the flat rotation `Mat3` gives with a z left
49
+ * alone. */
50
+ declare function rotationZ(radians: number): Mat4;
51
+ /**
52
+ * The view matrix of an eye at `eye` looking at `target`, with `up` saying which
53
+ * way is up.
54
+ *
55
+ * View space looks down its own negative z, so the target comes out on the
56
+ * negative z axis at the distance between the eye and the target. An `up` lying
57
+ * along the line of sight has no sideways direction in it and gives a matrix of
58
+ * zeroes, which is the pose a caller has to avoid rather than one this can fix.
59
+ */
60
+ declare function lookAt(eye: Vec3, target: Vec3, up: Vec3): Mat4;
61
+ export type PerspectiveOptions = {
62
+ /** The angle the frame covers up and down, in radians. */
63
+ fov: number;
64
+ /** Width over height, so a wide frame shows more sideways rather than less. */
65
+ aspect: number;
66
+ near: number;
67
+ far: number;
68
+ };
69
+ /**
70
+ * The projection of an eye that sees things smaller the further off they are.
71
+ *
72
+ * The third column puts the negated view-space z into the fourth coordinate, so
73
+ * a point twice as far away comes back with twice the divisor and lands half as
74
+ * far from the middle of the frame.
75
+ */
76
+ declare function perspective({ fov, aspect, near, far }: PerspectiveOptions): Mat4;
77
+ export type OrthographicOptions = {
78
+ left: number;
79
+ right: number;
80
+ bottom: number;
81
+ top: number;
82
+ near: number;
83
+ far: number;
84
+ };
85
+ /** The projection of an eye that sees everything at the size it is, which maps
86
+ * the named box onto the frame and leaves the fourth coordinate at one. */
87
+ declare function orthographic({ left, right, bottom, top, near, far }: OrthographicOptions): Mat4;
88
+ /**
89
+ * Applies the matrix to a point, taking the translation with it and dividing by
90
+ * the fourth coordinate the matrix gives it.
91
+ *
92
+ * That divide is why a point through `multiply(a, b)` matches the same point
93
+ * through `b` and then through `a` for matrices that do not touch the fourth
94
+ * coordinate and not for a perspective matrix: dividing halfway throws away the
95
+ * fourth coordinate the outer matrix still needed. A fourth coordinate of zero is
96
+ * a point on the plane through the eye, which has no place on the page at all, so
97
+ * the divide is skipped and the caller is left to notice.
98
+ */
99
+ declare function transformPoint(m: Mat4, v: Vec3): Vec3;
100
+ /** Applies the rotation and scale and neither the translation nor the divide,
101
+ * which is what a direction wants: moving the world must not move where an arrow
102
+ * points, and a direction has no distance for a perspective to shrink. */
103
+ declare function transformDirection(m: Mat4, v: Vec3): Vec3;
104
+ export declare const mat4: {
105
+ IDENTITY: Mat4;
106
+ multiply: typeof multiply;
107
+ translation: typeof translation;
108
+ scaling: typeof scaling;
109
+ rotationX: typeof rotationX;
110
+ rotationY: typeof rotationY;
111
+ rotationZ: typeof rotationZ;
112
+ lookAt: typeof lookAt;
113
+ perspective: typeof perspective;
114
+ orthographic: typeof orthographic;
115
+ transformPoint: typeof transformPoint;
116
+ transformDirection: typeof transformDirection;
117
+ };
118
+ export {};