@altpsyche/maths 0.9.5 → 0.11.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.
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { sampleTracks } from '../timing/track.js';
13
13
  import { flatten } from './node.js';
14
+ import { resolveExtent, viewMatrix } from './extent.js';
14
15
  export function durationOf(figure) {
15
16
  return figure.duration ?? figure.timeline?.duration ?? 0;
16
17
  }
@@ -21,6 +22,18 @@ export function at(figure, seconds) {
21
22
  const marks = flatten(tree);
22
23
  return figure.timeline ? figure.timeline.at(marks, seconds) : marks;
23
24
  }
25
+ /**
26
+ * The matrix a painter needs at a time, in one call.
27
+ *
28
+ * A figure whose extent is a function of the clock has to be asked for its
29
+ * extent at the same time its marks were asked for, and a consumer writing that
30
+ * as two calls has two chances to pass different times. What the painter is
31
+ * handed is the matrix, so the extent and the centring stay in here.
32
+ */
33
+ export function viewAt(figure, seconds, width, height) {
34
+ const extent = resolveExtent(figure.extent, width / height, seconds);
35
+ return viewMatrix(extent, figure.fit ?? 'contain', width, height);
36
+ }
24
37
  /** Whether a figure declaring itself a loop actually is one, which is the gate
25
38
  * behind that flag. The comparison is by tolerance rather than exactly, because
26
39
  * the sine and cosine a figure is built from are not specified to the last bit
@@ -0,0 +1,37 @@
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 { type Interval } from '../values/interval.js';
14
+ import { type Vec3 } from '../values/vec3.js';
15
+ export interface Plane {
16
+ /** A point the plane passes through. */
17
+ point: Vec3;
18
+ /** Which way the plane faces. Its length does not matter. */
19
+ normal: Vec3;
20
+ }
21
+ export interface SectionOptions {
22
+ u?: Interval;
23
+ v?: Interval;
24
+ resolution?: number | {
25
+ u: number;
26
+ v: number;
27
+ };
28
+ /** How close two ends come before they are read as the same place. */
29
+ tolerance?: number;
30
+ }
31
+ /**
32
+ * The runs of points where a plane cuts a surface, in space.
33
+ *
34
+ * A run whose two ends meet comes back with its first point repeated at the end,
35
+ * so drawing the points as they are given draws the loop closed.
36
+ */
37
+ export declare function sectionOf(of: (u: number, v: number) => Vec3, plane: Plane, options?: SectionOptions): Vec3[][];
@@ -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,153 @@
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 { Colour, Fill, Stroke } from './mark.js';
15
+ import { type ArrowOptions } from './annotate.js';
16
+ import type { Camera3 } from './camera.js';
17
+ export type Polyline3Options = Style & {
18
+ /** Whether the last point joins back to the first. A run that the near plane
19
+ * cut comes back open however this is set, since closing it would draw an edge
20
+ * that is nowhere in the world. */
21
+ close?: boolean;
22
+ };
23
+ /** A run of straight segments through points in space. */
24
+ export declare function polyline3(name: string, points: readonly Vec3[], camera: Camera3, options?: Polyline3Options): GroupNode;
25
+ /** A disc marking a point in space. Its radius is in figure units and does not
26
+ * shrink with distance, because a dot marks where something is rather than how
27
+ * big it is. */
28
+ export declare function dot3(name: string, at: Vec3, radius: number, fill: Fill, camera: Camera3): GroupNode;
29
+ export type Text3Options = TextOptions & {
30
+ /** How far the label stands off the point it names, in figure units, applied
31
+ * after the point is placed. */
32
+ offset?: Vec2;
33
+ };
34
+ /** A label at a point in space. The letters stay upright and stay the size they
35
+ * are given, since a label is read rather than seen in perspective. */
36
+ export declare function text3(name: string, at: Vec3, content: string, size: number, camera: Camera3, options?: Text3Options): GroupNode;
37
+ /** A drawn piece and the points in space it was drawn from, which are what say
38
+ * how far off it is. */
39
+ export type SpaceItem = {
40
+ points: readonly Vec3[];
41
+ node: Node;
42
+ };
43
+ /**
44
+ * A group whose children are ordered back to front, so the near piece is painted
45
+ * over the far one.
46
+ *
47
+ * This is the painter's algorithm, and what it cannot do is worth knowing before
48
+ * it is used: two pieces that pass through each other, and three that overlap in
49
+ * a ring, have no one order at all, and no comparison of depths can find one. The
50
+ * answer for those is smaller pieces, which is why a surface is cut into cells.
51
+ *
52
+ * Two pieces at the same depth keep the order the author gave them, since the
53
+ * sort is stable, and a picture that changed which of two touching faces was on
54
+ * top between frames would flicker.
55
+ */
56
+ export declare function space(name: string, items: readonly SpaceItem[], camera: Camera3): GroupNode;
57
+ export type Arrow3Options = ArrowOptions;
58
+ /**
59
+ * A line between two points in space with a head at the far end.
60
+ *
61
+ * The head is a flat triangle at the projected tip rather than a shape in
62
+ * space, so it stays the size it was given however far off the arrow is and
63
+ * however steeply it points away. A head built in space turns edge on to the eye
64
+ * and disappears exactly where the arrow is hardest to read.
65
+ *
66
+ * An arrow whose far end is behind the eye is cut at the near plane and drawn
67
+ * with no head, since the place the head belongs is not on the page.
68
+ */
69
+ export declare function arrow3(name: string, from: Vec3, to: Vec3, camera: Camera3, options: Arrow3Options): GroupNode;
70
+ export type VectorField3Options = ArrowOptions & {
71
+ /** The box the samples are taken in, nothing to one each way unless named. */
72
+ over?: {
73
+ x?: Interval;
74
+ y?: Interval;
75
+ z?: Interval;
76
+ };
77
+ /** How many samples each way. One number is all three. */
78
+ resolution?: number | {
79
+ x: number;
80
+ y: number;
81
+ z: number;
82
+ };
83
+ /** How long an arrow is, in the world's own units, from the magnitude of the
84
+ * vector at its own sample. */
85
+ lengthOf: (magnitude: number) => number;
86
+ /** What colour an arrow is, from that same magnitude. */
87
+ colourFor: (magnitude: number) => Colour;
88
+ };
89
+ /**
90
+ * The arrows of a field sampled over a box in space, and the points each was
91
+ * drawn from, for a figure that sorts them among pieces of its own.
92
+ *
93
+ * An arrow is measured in the world's own units rather than the figure's, unlike
94
+ * the arrows of a flat field, because a length in space is what perspective is
95
+ * for: a far arrow drawing shorter than a near one of the same magnitude is what
96
+ * says which is far. Its head is still in figure units, since the head is drawn
97
+ * on the page.
98
+ *
99
+ * A sample sits at the middle of its cell and the count is fixed by the
100
+ * resolution, so a gate can hold it as the eye moves. A sample whose vector is
101
+ * nothing draws no arrow there.
102
+ */
103
+ export declare function fieldArrows3(name: string, of: (at: Vec3) => Vec3, camera: Camera3, options: VectorField3Options): SpaceItem[];
104
+ /** A field of vectors in space, drawn as arrows ordered back to front. */
105
+ export declare function vectorField3(name: string, of: (at: Vec3) => Vec3, camera: Camera3, options: VectorField3Options): GroupNode;
106
+ export type Surface3Options = {
107
+ /** The run of the first parameter, nothing to one unless named. */
108
+ u?: Interval;
109
+ /** The run of the second parameter, nothing to one unless named. */
110
+ v?: Interval;
111
+ /** How many cells each way. */
112
+ resolution?: number | {
113
+ u: number;
114
+ v: number;
115
+ };
116
+ /**
117
+ * The colour a cell is filled with, given how squarely it faces the light: one
118
+ * where it faces the light head on, a half where it is edge on, and nothing
119
+ * where it faces straight away.
120
+ *
121
+ * The author supplies this rather than naming two colours to mix, because
122
+ * mixing two colours means reading them, and a colour here is any CSS colour
123
+ * written as text with nothing that parses one.
124
+ */
125
+ shade: (amount: number) => Fill;
126
+ /** Which way the light comes from, over the shoulder of an eye on the positive
127
+ * z axis unless named. */
128
+ light?: Vec3;
129
+ /** Whether a cell facing away from the eye is left out. Off by default, because
130
+ * a count that changes as the camera turns is a count no gate can hold. */
131
+ cull?: boolean;
132
+ stroke?: Stroke;
133
+ };
134
+ /**
135
+ * The cells a surface is made of, before they are put in an order.
136
+ *
137
+ * Cells rather than one shape is what makes the depth sort work at all: a surface
138
+ * that folds over itself has no one place in a painting order, and pieces small
139
+ * enough to be flat do.
140
+ *
141
+ * A scene holding a surface and a plane that cuts through it has to sort all of
142
+ * their cells together, since two surfaces sorted apart are two groups and the
143
+ * second is painted over the first whichever way round they stand. Each cell
144
+ * carries the name it was given ahead of its own place in the grid, so an
145
+ * animation can still name a whole surface once its cells are mixed with
146
+ * another's.
147
+ */
148
+ export declare function surfaceCells(name: string, of: (u: number, v: number) => Vec3, camera: Camera3, options: Surface3Options): SpaceItem[];
149
+ /**
150
+ * A surface given by a function of two parameters, drawn as a grid of
151
+ * four-cornered cells ordered back to front.
152
+ */
153
+ export declare function surface3(name: string, of: (u: number, v: number) => Vec3, camera: Camera3, options: Surface3Options): GroupNode;
@@ -0,0 +1,242 @@
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, line, polygon, polyline } from './path.js';
12
+ import { group, shape, text } from './node.js';
13
+ import { interval } from '../values/interval.js';
14
+ import { arrow } from './annotate.js';
15
+ /**
16
+ * Where along a segment the near plane is crossed.
17
+ *
18
+ * Depth changes evenly along a segment, because lining the world up with the eye
19
+ * moves and turns a point and never divides it, so the crossing is one division
20
+ * rather than a search.
21
+ */
22
+ function crossingAt(from, to, near) {
23
+ return (near - from) / (to - from);
24
+ }
25
+ /**
26
+ * The pieces of a run of points in space that the eye can see, cut where they
27
+ * cross the near plane.
28
+ *
29
+ * A point nearer the eye than the near plane has no place on the page: under a
30
+ * perspective the divide flips its sign and puts it on the wrong side of the
31
+ * frame, so the segment is cut rather than drawn to it.
32
+ */
33
+ function visibleRuns(points, camera) {
34
+ const near = camera.projection.near;
35
+ const seen = points.map((point) => camera.project(point));
36
+ const uncut = seen.every((point) => point.inFront);
37
+ const runs = [];
38
+ let current = [];
39
+ for (let i = 0; i < points.length; i += 1) {
40
+ const here = seen[i];
41
+ if (here.inFront) {
42
+ if (i > 0 && !seen[i - 1].inFront) {
43
+ const along = crossingAt(seen[i - 1].depth, here.depth, near);
44
+ current.push(camera.project(vec3.lerp(points[i - 1], points[i], along)).at);
45
+ }
46
+ current.push(here.at);
47
+ continue;
48
+ }
49
+ if (i > 0 && seen[i - 1].inFront) {
50
+ const along = crossingAt(seen[i - 1].depth, here.depth, near);
51
+ current.push(camera.project(vec3.lerp(points[i - 1], points[i], along)).at);
52
+ }
53
+ if (current.length > 1)
54
+ runs.push({ points: current, whole: false });
55
+ current = [];
56
+ }
57
+ if (current.length > 1)
58
+ runs.push({ points: current, whole: uncut });
59
+ return runs;
60
+ }
61
+ /** A run of straight segments through points in space. */
62
+ export function polyline3(name, points, camera, options = {}) {
63
+ const { close = false, ...style } = options;
64
+ const runs = visibleRuns(points, camera);
65
+ return group(name, runs.map((run) => shape('run', close && run.whole ? polygon(run.points) : polyline(run.points), style)));
66
+ }
67
+ /** A disc marking a point in space. Its radius is in figure units and does not
68
+ * shrink with distance, because a dot marks where something is rather than how
69
+ * big it is. */
70
+ export function dot3(name, at, radius, fill, camera) {
71
+ const seen = camera.project(at);
72
+ return group(name, seen.inFront ? [shape('disc', circle(seen.at, radius), { fill })] : []);
73
+ }
74
+ /** A label at a point in space. The letters stay upright and stay the size they
75
+ * are given, since a label is read rather than seen in perspective. */
76
+ export function text3(name, at, content, size, camera, options = {}) {
77
+ const { offset, ...style } = options;
78
+ const seen = camera.project(at);
79
+ const anchor = offset ? { x: seen.at.x + offset.x, y: seen.at.y + offset.y } : seen.at;
80
+ return group(name, seen.inFront ? [text('label', anchor, content, size, style)] : []);
81
+ }
82
+ /**
83
+ * The mean of a piece's own depths, so a piece is ordered by where its middle is
84
+ * rather than by whichever corner happens to be nearest.
85
+ */
86
+ function middleDepth(points, camera) {
87
+ if (points.length === 0)
88
+ return 0;
89
+ let total = 0;
90
+ for (const point of points)
91
+ total += camera.project(point).depth;
92
+ return total / points.length;
93
+ }
94
+ /**
95
+ * A group whose children are ordered back to front, so the near piece is painted
96
+ * over the far one.
97
+ *
98
+ * This is the painter's algorithm, and what it cannot do is worth knowing before
99
+ * it is used: two pieces that pass through each other, and three that overlap in
100
+ * a ring, have no one order at all, and no comparison of depths can find one. The
101
+ * answer for those is smaller pieces, which is why a surface is cut into cells.
102
+ *
103
+ * Two pieces at the same depth keep the order the author gave them, since the
104
+ * sort is stable, and a picture that changed which of two touching faces was on
105
+ * top between frames would flicker.
106
+ */
107
+ export function space(name, items, camera) {
108
+ const measured = items.map((item) => ({ node: item.node, depth: middleDepth(item.points, camera) }));
109
+ measured.sort((a, b) => b.depth - a.depth);
110
+ return group(name, measured.map((item) => item.node));
111
+ }
112
+ /**
113
+ * A line between two points in space with a head at the far end.
114
+ *
115
+ * The head is a flat triangle at the projected tip rather than a shape in
116
+ * space, so it stays the size it was given however far off the arrow is and
117
+ * however steeply it points away. A head built in space turns edge on to the eye
118
+ * and disappears exactly where the arrow is hardest to read.
119
+ *
120
+ * An arrow whose far end is behind the eye is cut at the near plane and drawn
121
+ * with no head, since the place the head belongs is not on the page.
122
+ */
123
+ export function arrow3(name, from, to, camera, options) {
124
+ const start = camera.project(from);
125
+ const end = camera.project(to);
126
+ if (!start.inFront && !end.inFront)
127
+ return group(name, []);
128
+ const cut = () => {
129
+ const along = crossingAt(start.depth, end.depth, camera.projection.near);
130
+ return camera.project(vec3.lerp(from, to, along)).at;
131
+ };
132
+ if (!end.inFront)
133
+ return group(name, [shape('shaft', line(start.at, cut()), { stroke: options.stroke })]);
134
+ const tail = start.inFront ? start.at : cut();
135
+ if (tail.x === end.at.x && tail.y === end.at.y)
136
+ return group(name, []);
137
+ return arrow(name, tail, end.at, options);
138
+ }
139
+ function gridOf(resolution) {
140
+ return typeof resolution === 'number' ? { x: resolution, y: resolution, z: resolution } : resolution;
141
+ }
142
+ /**
143
+ * The arrows of a field sampled over a box in space, and the points each was
144
+ * drawn from, for a figure that sorts them among pieces of its own.
145
+ *
146
+ * An arrow is measured in the world's own units rather than the figure's, unlike
147
+ * the arrows of a flat field, because a length in space is what perspective is
148
+ * for: a far arrow drawing shorter than a near one of the same magnitude is what
149
+ * says which is far. Its head is still in figure units, since the head is drawn
150
+ * on the page.
151
+ *
152
+ * A sample sits at the middle of its cell and the count is fixed by the
153
+ * resolution, so a gate can hold it as the eye moves. A sample whose vector is
154
+ * nothing draws no arrow there.
155
+ */
156
+ export function fieldArrows3(name, of, camera, options) {
157
+ const { over = {}, resolution = 6, lengthOf, colourFor, ...rest } = options;
158
+ const box = {
159
+ x: interval.ordered(over.x ?? interval(0, 1)),
160
+ y: interval.ordered(over.y ?? interval(0, 1)),
161
+ z: interval.ordered(over.z ?? interval(0, 1)),
162
+ };
163
+ const steps = gridOf(resolution);
164
+ const items = [];
165
+ for (let i = 0; i < steps.x; i += 1) {
166
+ for (let j = 0; j < steps.y; j += 1) {
167
+ for (let k = 0; k < steps.z; k += 1) {
168
+ const from = vec3(interval.at(box.x, (i + 0.5) / steps.x), interval.at(box.y, (j + 0.5) / steps.y), interval.at(box.z, (k + 0.5) / steps.z));
169
+ const vector = of(from);
170
+ const magnitude = vec3.magnitude(vector);
171
+ const length = lengthOf(magnitude);
172
+ if (!(magnitude > 0) || !Number.isFinite(length) || !(length > 0))
173
+ continue;
174
+ const to = vec3.add(from, vec3.scale(vector, length / magnitude));
175
+ items.push({
176
+ points: [from, to],
177
+ node: arrow3(`${name}/${i}-${j}-${k}`, from, to, camera, {
178
+ ...rest,
179
+ stroke: { ...rest.stroke, colour: colourFor(magnitude) },
180
+ }),
181
+ });
182
+ }
183
+ }
184
+ }
185
+ return items;
186
+ }
187
+ /** A field of vectors in space, drawn as arrows ordered back to front. */
188
+ export function vectorField3(name, of, camera, options) {
189
+ return space(name, fieldArrows3('arrow', of, camera, options), camera);
190
+ }
191
+ function resolutionOf(resolution) {
192
+ return typeof resolution === 'number' ? { u: resolution, v: resolution } : resolution;
193
+ }
194
+ /**
195
+ * The cells a surface is made of, before they are put in an order.
196
+ *
197
+ * Cells rather than one shape is what makes the depth sort work at all: a surface
198
+ * that folds over itself has no one place in a painting order, and pieces small
199
+ * enough to be flat do.
200
+ *
201
+ * A scene holding a surface and a plane that cuts through it has to sort all of
202
+ * their cells together, since two surfaces sorted apart are two groups and the
203
+ * second is painted over the first whichever way round they stand. Each cell
204
+ * carries the name it was given ahead of its own place in the grid, so an
205
+ * animation can still name a whole surface once its cells are mixed with
206
+ * another's.
207
+ */
208
+ export function surfaceCells(name, of, camera, options) {
209
+ const { u = interval(0, 1), v = interval(0, 1), resolution = 24, shade, light = vec3(0, 0, 1), cull = false, stroke } = options;
210
+ const steps = resolutionOf(resolution);
211
+ const toLight = vec3.normalize(light);
212
+ const items = [];
213
+ for (let i = 0; i < steps.u; i += 1) {
214
+ for (let j = 0; j < steps.v; j += 1) {
215
+ const corners = [
216
+ of(interval.at(u, i / steps.u), interval.at(v, j / steps.v)),
217
+ of(interval.at(u, (i + 1) / steps.u), interval.at(v, j / steps.v)),
218
+ of(interval.at(u, (i + 1) / steps.u), interval.at(v, (j + 1) / steps.v)),
219
+ of(interval.at(u, i / steps.u), interval.at(v, (j + 1) / steps.v)),
220
+ ];
221
+ const normal = vec3.normalize(vec3.cross(vec3.sub(corners[1], corners[0]), vec3.sub(corners[3], corners[0])));
222
+ if (cull) {
223
+ const middle = corners.reduce((sum, corner) => vec3.add(sum, vec3.scale(corner, 1 / 4)), vec3.ZERO);
224
+ if (vec3.dot(normal, vec3.sub(camera.eye, middle)) <= 0)
225
+ continue;
226
+ }
227
+ const fill = shade((vec3.dot(normal, toLight) + 1) / 2);
228
+ items.push({
229
+ points: corners,
230
+ node: polyline3(`${name}/${i}-${j}`, corners, camera, { close: true, fill, stroke }),
231
+ });
232
+ }
233
+ }
234
+ return items;
235
+ }
236
+ /**
237
+ * A surface given by a function of two parameters, drawn as a grid of
238
+ * four-cornered cells ordered back to front.
239
+ */
240
+ export function surface3(name, of, camera, options) {
241
+ return space(name, surfaceCells('cell', of, camera, options), camera);
242
+ }