@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.
- package/README.md +81 -3
- package/dist/figure/annotate.d.ts +3 -0
- package/dist/figure/annotate.js +4 -1
- package/dist/figure/axis3.d.ts +45 -0
- package/dist/figure/axis3.js +75 -0
- package/dist/figure/camera.d.ts +100 -0
- package/dist/figure/camera.js +74 -0
- package/dist/figure/extent.d.ts +7 -3
- package/dist/figure/extent.js +8 -5
- package/dist/figure/field.d.ts +64 -0
- package/dist/figure/field.js +69 -0
- package/dist/figure/figure.d.ts +11 -1
- package/dist/figure/figure.js +13 -0
- package/dist/figure/section.d.ts +37 -0
- package/dist/figure/section.js +184 -0
- package/dist/figure/space.d.ts +153 -0
- package/dist/figure/space.js +242 -0
- package/dist/figure/streamline.d.ts +49 -0
- package/dist/figure/streamline.js +95 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +8 -1
- package/dist/values/mat4.d.ts +118 -0
- package/dist/values/mat4.js +193 -0
- package/package.json +1 -1
package/dist/figure/figure.js
CHANGED
|
@@ -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
|
+
}
|