@altpsyche/maths 0.7.0 → 0.9.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 +84 -6
- package/dist/figure/animation.d.ts +11 -0
- package/dist/figure/animation.js +13 -0
- package/dist/figure/annotate.d.ts +47 -0
- package/dist/figure/annotate.js +75 -1
- package/dist/figure/area.d.ts +22 -0
- package/dist/figure/area.js +45 -0
- package/dist/figure/boolean.d.ts +13 -0
- package/dist/figure/boolean.js +173 -0
- package/dist/figure/cut.d.ts +36 -0
- package/dist/figure/cut.js +83 -0
- package/dist/figure/inside.d.ts +39 -0
- package/dist/figure/inside.js +106 -0
- package/dist/figure/intersect.d.ts +36 -0
- package/dist/figure/intersect.js +309 -0
- package/dist/figure/morph.js +2 -16
- package/dist/figure/path.d.ts +8 -0
- package/dist/figure/path.js +19 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +8 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,9 +24,10 @@ svgMarkup(at(figure, 0.5), viewMatrix(figure.extent, 'contain', 640, 360), 640,
|
|
|
24
24
|
|
|
25
25
|
The picture arrives rather than appearing. The grid fades, the axes draw on, their labels come in one
|
|
26
26
|
after another, the curve draws, the dot grows out of the origin, and the dot is pointed at where the
|
|
27
|
-
slope is nothing. Then it walks the curve at one speed and flashes at the top
|
|
28
|
-
|
|
29
|
-
the
|
|
27
|
+
slope is nothing. Then it walks the curve at one speed and flashes at the top, and a brace measures
|
|
28
|
+
how far it climbed with its number counting up to the rise. The number in the corner is a value of
|
|
29
|
+
the typeset rule under it, and that rule reads no slope while the dot is held at the stationary point
|
|
30
|
+
and walks into the one that depends on x as the dot leaves.
|
|
30
31
|
|
|
31
32
|
```ts
|
|
32
33
|
import { axes, coordsOf, group, interval, numberPlane, plot, scaleOf, shape } from '@altpsyche/maths';
|
|
@@ -59,7 +60,7 @@ region is the limit of at the left edge, the right edge or the middle of each on
|
|
|
59
60
|
lays the tangent along the curve, cut where it leaves the graph. `slopeOf` reads the slope itself,
|
|
60
61
|
which is what the number in the corner is.
|
|
61
62
|
|
|
62
|
-
<img src="docs/tangent-strip.svg" width="960" alt="Four frames of the same figure side by side, the point walking up the curve, the shaded region growing behind it,
|
|
63
|
+
<img src="docs/tangent-strip.svg" width="960" alt="Four frames of the same figure side by side, the point walking up the curve, the shaded region growing behind it, the typeset rule in the corner changing from a slope of nothing to one that depends on x, and a brace measuring the rise in the last frame.">
|
|
63
64
|
|
|
64
65
|
Four times of one figure, side by side: the picture arrived, the beat at the stationary point, half
|
|
65
66
|
way up, and the top. A moving picture in a README needs a GIF and this package has no encoder, so the
|
|
@@ -126,10 +127,87 @@ which would draw with whatever font a browser had and draw nothing at all in a r
|
|
|
126
127
|
undefined macro is not an error at all, because MathJax draws the macro's own name in red, so a typo
|
|
127
128
|
would otherwise ship as a red word inside the picture.
|
|
128
129
|
|
|
130
|
+
## Braces and a number that counts
|
|
131
|
+
|
|
132
|
+
`bracePath` draws a curly brace from one point to another with its tip standing off the line between
|
|
133
|
+
them, and `brace` is that path with a word beyond the tip. It is one open subpath of six pieces: a
|
|
134
|
+
curl out of each end, a run along at the curl's own height, and two curls meeting at the tip. The tip
|
|
135
|
+
is a corner rather than a smooth turn, which is what a brace has and what says which point of it is
|
|
136
|
+
doing the pointing. The tip stands at the depth asked for whatever the span, and only the curl
|
|
137
|
+
narrows when the span is short, so two close points get a shallower brace rather than one whose
|
|
138
|
+
halves cross.
|
|
139
|
+
|
|
140
|
+
The label is anchored and never measured, because nothing about a figure's layout may depend on how
|
|
141
|
+
wide some text is.
|
|
142
|
+
|
|
143
|
+
`countTo` writes the value a count has reached into a text mark. How the value is written is the
|
|
144
|
+
caller's, so a count of a length and a count of a population can round differently and this holds no
|
|
145
|
+
opinion about either.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { brace, countTo, labelFor, pointOf, vec2 } from '@altpsyche/maths';
|
|
149
|
+
|
|
150
|
+
brace('rise', pointOf(coords, 3, 9), pointOf(coords, 3, 0), labelFor(9, 0.01), {
|
|
151
|
+
depth: 0.3,
|
|
152
|
+
padding: 0.28,
|
|
153
|
+
stroke: pen,
|
|
154
|
+
fill: ink,
|
|
155
|
+
size: 0.3,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
countTo('rise/word', 0, 9, (value) => labelFor(value, 0.01));
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
A number driven by the clock is not the same as a reading driven by a track, and the difference
|
|
162
|
+
matters. The slope in the demo is a value of the walk, so it is worked out by the scene from the
|
|
163
|
+
track and cannot drift from the dot. The rise is counted by the clock, which is honest only because
|
|
164
|
+
the dot has stopped by the time it counts: two clocks running at once would be free to disagree.
|
|
165
|
+
|
|
166
|
+
## Two shapes combined
|
|
167
|
+
|
|
168
|
+
<img src="docs/boolean.svg" width="720" alt="Two discs drawn three times side by side: everything either one covers, only what both cover, and the first with the second taken out of it.">
|
|
169
|
+
|
|
170
|
+
`unionOf` is everything either path covers, `intersectionOf` is only what both cover, and
|
|
171
|
+
`differenceOf` is the first with the second taken out of it. Each input is closed loops that do not
|
|
172
|
+
cross themselves, and a loop left open is closed by a straight run back to where it started before
|
|
173
|
+
anything else happens.
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { circle, differenceOf, intersectionOf, unionOf, vec2 } from '@altpsyche/maths';
|
|
177
|
+
|
|
178
|
+
const first = circle(vec2(0, 0), 0.9);
|
|
179
|
+
const second = circle(vec2(-0.6, 0), 0.36);
|
|
180
|
+
|
|
181
|
+
unionOf(first, second);
|
|
182
|
+
intersectionOf(first, second);
|
|
183
|
+
differenceOf(first, second);
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
A result may have a hole even though an input may not. A disc with a smaller disc taken out of it is
|
|
187
|
+
a ring, which is an outer loop and an inner loop wound the opposite way, and the nonzero rule the
|
|
188
|
+
mark already carries leaves the middle empty.
|
|
189
|
+
|
|
190
|
+
The work happens in four steps, and each of them is a call of its own. `curveCrossings` says where
|
|
191
|
+
two cubics cross, by halving both curves and following only the halves whose boxes still overlap,
|
|
192
|
+
then sharpening what it finds by Newton's method. `cutPath` puts a cut wherever something crosses,
|
|
193
|
+
so that afterwards every piece is wholly inside the other path or wholly outside it. `containsPoint`
|
|
194
|
+
decides which of those a piece is, by counting how many times the other path winds round its middle.
|
|
195
|
+
`areaOf` says how much a path encloses, in closed form rather than by sampling, which is what every
|
|
196
|
+
claim above is checked against.
|
|
197
|
+
|
|
198
|
+
<img src="docs/boolean-strip.svg" width="960" alt="Four frames side by side, each showing the three panels, as the small disc walks from clear of the large one, through touching it at one point, through overlapping it, to sitting wholly inside it.">
|
|
199
|
+
|
|
200
|
+
Four times of one figure. A small disc walks across a larger one: clear of it, touching it at one
|
|
201
|
+
point, crossing it at two, and wholly inside it. Those are the four cases this kind of code gets
|
|
202
|
+
silently wrong, which is why the demo walks through all of them rather than drawing one.
|
|
203
|
+
|
|
204
|
+
Two edges that lie on top of each other for a stretch have no one answer, and what comes back for
|
|
205
|
+
them is decided by the tolerance rather than by the geometry.
|
|
206
|
+
|
|
129
207
|
## The animations
|
|
130
208
|
|
|
131
|
-
`fadeIn`, `fadeOut`, `fadeTo`, `draw`, `morph`, `morphEquation`, `
|
|
132
|
-
`growFrom`, `moveAlong`, `indicate`, `flash` and `circumscribe`. A `Timeline` plays them in order, plays several `together`, or
|
|
209
|
+
`fadeIn`, `fadeOut`, `fadeTo`, `draw`, `morph`, `morphEquation`, `countTo`, `moveBy`, `rotate`,
|
|
210
|
+
`scale`, `growFrom`, `moveAlong`, `indicate`, `flash` and `circumscribe`. A `Timeline` plays them in order, plays several `together`, or
|
|
133
211
|
`stagger`s a row so its parts arrive one after another.
|
|
134
212
|
|
|
135
213
|
A turn and a growth happen about a point the marks decide for themselves, which is the middle of the
|
|
@@ -43,6 +43,17 @@ export declare function morph(target: string, into: Path): Animation;
|
|
|
43
43
|
* expression still fading in when a morph starts does not jump to solid.
|
|
44
44
|
*/
|
|
45
45
|
export declare function morphEquation(from: string, to: string): Animation;
|
|
46
|
+
/**
|
|
47
|
+
* A number ticking from one value to another, written into a text mark.
|
|
48
|
+
*
|
|
49
|
+
* How the value is written is the caller's, so this holds no opinion about
|
|
50
|
+
* decimal places: a count of a length and a count of a population want different
|
|
51
|
+
* rounding and neither is this function's to choose.
|
|
52
|
+
*
|
|
53
|
+
* The scene writes the value the count ends at, which is what this writes at the
|
|
54
|
+
* end of its span, so the two never disagree about what the number settles on.
|
|
55
|
+
*/
|
|
56
|
+
export declare function countTo(target: string, from: number, to: number, write: (value: number) => string): Animation;
|
|
46
57
|
/** A mark's own opacity walked to a value, for a figure that wants a thing dimmed
|
|
47
58
|
* rather than gone. */
|
|
48
59
|
export declare function fadeTo(target: string, opacity: number): Animation;
|
package/dist/figure/animation.js
CHANGED
|
@@ -119,6 +119,19 @@ export function morphEquation(from, to) {
|
|
|
119
119
|
return marks.map((mark) => changed.get(mark.id) ?? mark);
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* A number ticking from one value to another, written into a text mark.
|
|
124
|
+
*
|
|
125
|
+
* How the value is written is the caller's, so this holds no opinion about
|
|
126
|
+
* decimal places: a count of a length and a count of a population want different
|
|
127
|
+
* rounding and neither is this function's to choose.
|
|
128
|
+
*
|
|
129
|
+
* The scene writes the value the count ends at, which is what this writes at the
|
|
130
|
+
* end of its span, so the two never disagree about what the number settles on.
|
|
131
|
+
*/
|
|
132
|
+
export function countTo(target, from, to, write) {
|
|
133
|
+
return over(target, (mark, along) => (mark.kind === 'text' ? { ...mark, text: write(lerp(from, to, along)) } : mark));
|
|
134
|
+
}
|
|
122
135
|
/** A mark's own opacity walked to a value, for a figure that wants a thing dimmed
|
|
123
136
|
* rather than gone. */
|
|
124
137
|
export function fadeTo(target, opacity) {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* filled at its head, and no mark can be both.
|
|
9
9
|
*/
|
|
10
10
|
import { type Vec2 } from '../values/vec2.js';
|
|
11
|
+
import { type Path } from './path.js';
|
|
11
12
|
import { type GroupNode, type TextOptions } from './node.js';
|
|
12
13
|
import type { Fill, Stroke } from './mark.js';
|
|
13
14
|
export interface ArrowOptions {
|
|
@@ -32,6 +33,52 @@ export interface ArrowOptions {
|
|
|
32
33
|
export declare function arrow(name: string, from: Vec2, to: Vec2, options: ArrowOptions): GroupNode;
|
|
33
34
|
/** A filled disc, which is what marks a place a line is pointing at. */
|
|
34
35
|
export declare function dot(name: string, at: Vec2, radius: number, fill: Fill): GroupNode;
|
|
36
|
+
export interface BraceOptions {
|
|
37
|
+
/** How far the tip stands off the line between the two points, in figure
|
|
38
|
+
* units. A negative depth puts the brace on the other side of that line. */
|
|
39
|
+
depth: number;
|
|
40
|
+
/**
|
|
41
|
+
* How wide the curl at each end and at the tip is, in figure units. Half the
|
|
42
|
+
* depth unless named, which is the curl a quarter circle gives, and never more
|
|
43
|
+
* than a quarter of the span, since two curls wider than that would cross.
|
|
44
|
+
*/
|
|
45
|
+
curl?: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A curly brace from one point to the other, with its tip standing off the line
|
|
49
|
+
* between them.
|
|
50
|
+
*
|
|
51
|
+
* It is one open subpath of six pieces: a curl out of each end, a run along at
|
|
52
|
+
* the curl's own height, and two curls meeting at the tip. The tip is a corner
|
|
53
|
+
* rather than a smooth turn, which is what a brace has and what says which point
|
|
54
|
+
* of it is being pointed at.
|
|
55
|
+
*
|
|
56
|
+
* The tip stands at the depth asked for whatever the span, and only the curl
|
|
57
|
+
* narrows when the span is short, so a brace between two close points is a
|
|
58
|
+
* shallower shape rather than one whose halves cross.
|
|
59
|
+
*/
|
|
60
|
+
export declare function bracePath(from: Vec2, to: Vec2, options: BraceOptions): Path;
|
|
61
|
+
export interface BracedOptions extends BraceOptions {
|
|
62
|
+
stroke: Stroke;
|
|
63
|
+
fill: Fill;
|
|
64
|
+
size: number;
|
|
65
|
+
/** How far beyond the tip the label's anchor sits, in figure units. */
|
|
66
|
+
padding?: number;
|
|
67
|
+
align?: TextOptions['align'];
|
|
68
|
+
baseline?: TextOptions['baseline'];
|
|
69
|
+
family?: string;
|
|
70
|
+
weight?: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A brace with a word on it, placed beyond the tip on the far side from the two
|
|
74
|
+
* points.
|
|
75
|
+
*
|
|
76
|
+
* The label is anchored and never measured. Nothing about a figure's layout may
|
|
77
|
+
* depend on how wide some text is, because the width depends on which fonts the
|
|
78
|
+
* machine has and a box sized to fit a label would be a different box on two
|
|
79
|
+
* machines.
|
|
80
|
+
*/
|
|
81
|
+
export declare function brace(name: string, from: Vec2, to: Vec2, content: string, options: BracedOptions): GroupNode;
|
|
35
82
|
export interface CalloutOptions {
|
|
36
83
|
stroke: Stroke;
|
|
37
84
|
fill: Fill;
|
package/dist/figure/annotate.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* filled at its head, and no mark can be both.
|
|
9
9
|
*/
|
|
10
10
|
import { vec2 } from '../values/vec2.js';
|
|
11
|
-
import { circle, line, polygon } from './path.js';
|
|
11
|
+
import { circle, line, polygon, straight } from './path.js';
|
|
12
12
|
import { group, shape, text } from './node.js';
|
|
13
13
|
/**
|
|
14
14
|
* A line with a head at the far end.
|
|
@@ -32,6 +32,80 @@ export function arrow(name, from, to, options) {
|
|
|
32
32
|
export function dot(name, at, radius, fill) {
|
|
33
33
|
return group(name, [shape('disc', circle(at, radius), { fill })]);
|
|
34
34
|
}
|
|
35
|
+
/** The control distance a quarter circle wants, which is what makes each curl of
|
|
36
|
+
* a brace an arc rather than a corner rounded by eye. */
|
|
37
|
+
const QUARTER = (4 / 3) * (Math.SQRT2 - 1);
|
|
38
|
+
/**
|
|
39
|
+
* A curly brace from one point to the other, with its tip standing off the line
|
|
40
|
+
* between them.
|
|
41
|
+
*
|
|
42
|
+
* It is one open subpath of six pieces: a curl out of each end, a run along at
|
|
43
|
+
* the curl's own height, and two curls meeting at the tip. The tip is a corner
|
|
44
|
+
* rather than a smooth turn, which is what a brace has and what says which point
|
|
45
|
+
* of it is being pointed at.
|
|
46
|
+
*
|
|
47
|
+
* The tip stands at the depth asked for whatever the span, and only the curl
|
|
48
|
+
* narrows when the span is short, so a brace between two close points is a
|
|
49
|
+
* shallower shape rather than one whose halves cross.
|
|
50
|
+
*/
|
|
51
|
+
export function bracePath(from, to, options) {
|
|
52
|
+
const span = vec2.distance(from, to);
|
|
53
|
+
if (span === 0)
|
|
54
|
+
return [];
|
|
55
|
+
const along = vec2.normalize(vec2.sub(to, from));
|
|
56
|
+
const out = vec2.perpendicular(along);
|
|
57
|
+
const depth = options.depth;
|
|
58
|
+
const curl = Math.min(Math.abs(options.curl ?? depth / 2), span / 4);
|
|
59
|
+
const rise = Math.sign(depth || 1) * curl;
|
|
60
|
+
const at = (forward, off) => vec2.add(from, vec2.add(vec2.scale(along, forward), vec2.scale(out, off)));
|
|
61
|
+
const shoulder = at(curl, rise);
|
|
62
|
+
const beforeTip = at(span / 2 - curl, rise);
|
|
63
|
+
const tip = at(span / 2, depth);
|
|
64
|
+
const afterTip = at(span / 2 + curl, rise);
|
|
65
|
+
const beyond = at(span - curl, rise);
|
|
66
|
+
const reach = QUARTER * curl;
|
|
67
|
+
const lift = QUARTER * (depth - rise);
|
|
68
|
+
const piece = (control1, control2, end) => ({ control1, control2, to: end });
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
start: from,
|
|
72
|
+
curves: [
|
|
73
|
+
piece(vec2.add(from, vec2.scale(out, rise * QUARTER)), vec2.sub(shoulder, vec2.scale(along, reach)), shoulder),
|
|
74
|
+
straight(shoulder, beforeTip),
|
|
75
|
+
piece(vec2.add(beforeTip, vec2.scale(along, reach)), vec2.sub(tip, vec2.scale(out, lift)), tip),
|
|
76
|
+
piece(vec2.sub(tip, vec2.scale(out, lift)), vec2.sub(afterTip, vec2.scale(along, reach)), afterTip),
|
|
77
|
+
straight(afterTip, beyond),
|
|
78
|
+
piece(vec2.add(beyond, vec2.scale(along, reach)), vec2.add(to, vec2.scale(out, rise * QUARTER)), to),
|
|
79
|
+
],
|
|
80
|
+
closed: false,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A brace with a word on it, placed beyond the tip on the far side from the two
|
|
86
|
+
* points.
|
|
87
|
+
*
|
|
88
|
+
* The label is anchored and never measured. Nothing about a figure's layout may
|
|
89
|
+
* depend on how wide some text is, because the width depends on which fonts the
|
|
90
|
+
* machine has and a box sized to fit a label would be a different box on two
|
|
91
|
+
* machines.
|
|
92
|
+
*/
|
|
93
|
+
export function brace(name, from, to, content, options) {
|
|
94
|
+
const path = bracePath(from, to, options);
|
|
95
|
+
const padding = options.padding ?? Math.abs(options.depth) / 2;
|
|
96
|
+
const out = vec2.perpendicular(vec2.normalize(vec2.sub(to, from)));
|
|
97
|
+
const middle = vec2.lerp(from, to, 0.5);
|
|
98
|
+
const at = vec2.add(middle, vec2.scale(out, options.depth + Math.sign(options.depth || 1) * padding));
|
|
99
|
+
const style = { fill: options.fill, family: options.family, weight: options.weight };
|
|
100
|
+
return group(name, [
|
|
101
|
+
shape('brace', path, { stroke: options.stroke }),
|
|
102
|
+
text('word', at, content, options.size, {
|
|
103
|
+
...style,
|
|
104
|
+
align: options.align ?? 'middle',
|
|
105
|
+
baseline: options.baseline ?? 'middle',
|
|
106
|
+
}),
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
35
109
|
/**
|
|
36
110
|
* A word attached to a place: a disc on the place, a line out to where there is
|
|
37
111
|
* room, and the word at the end of it.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much a path encloses.
|
|
3
|
+
*
|
|
4
|
+
* This is Green's theorem, which turns the area inside a closed loop into an
|
|
5
|
+
* integral round its edge, and for a cubic that integral has a closed form. So
|
|
6
|
+
* the answer is worked out rather than sampled, and it is exact for the shape
|
|
7
|
+
* the cubics actually draw.
|
|
8
|
+
*
|
|
9
|
+
* The sign is the direction the loop is wound in, positive anticlockwise. That
|
|
10
|
+
* is what makes a hole subtract: a loop wound the other way inside another one
|
|
11
|
+
* encloses a negative amount, and the two added together are the ring.
|
|
12
|
+
*/
|
|
13
|
+
import type { Path } from './path.js';
|
|
14
|
+
/**
|
|
15
|
+
* How much a path encloses, positive where it is wound anticlockwise.
|
|
16
|
+
*
|
|
17
|
+
* A subpath left open is closed by the straight run back to where it started,
|
|
18
|
+
* since an open loop encloses nothing on its own. Every subpath is added, so a
|
|
19
|
+
* ring written as an outer loop and an inner loop wound the other way comes
|
|
20
|
+
* back as the difference between the two discs.
|
|
21
|
+
*/
|
|
22
|
+
export declare function areaOf(path: Path): number;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Twice the area of the triangle two points make with the origin, positive
|
|
2
|
+
* anticlockwise. */
|
|
3
|
+
function wedge(a, b) {
|
|
4
|
+
return a.x * b.y - b.x * a.y;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* What one piece contributes to the integral round the loop.
|
|
8
|
+
*
|
|
9
|
+
* Each pair of the piece's four points contributes the triangle it makes with
|
|
10
|
+
* the origin, and the weights are what integrating a cubic against its own
|
|
11
|
+
* derivative leaves: six, three, one, three, three and six twentieths, in the
|
|
12
|
+
* order the pairs are taken.
|
|
13
|
+
*/
|
|
14
|
+
function pieceArea(from, curve) {
|
|
15
|
+
const { control1, control2, to } = curve;
|
|
16
|
+
return ((6 * wedge(from, control1) +
|
|
17
|
+
3 * wedge(from, control2) +
|
|
18
|
+
wedge(from, to) +
|
|
19
|
+
3 * wedge(control1, control2) +
|
|
20
|
+
3 * wedge(control1, to) +
|
|
21
|
+
6 * wedge(control2, to)) /
|
|
22
|
+
20);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* How much a path encloses, positive where it is wound anticlockwise.
|
|
26
|
+
*
|
|
27
|
+
* A subpath left open is closed by the straight run back to where it started,
|
|
28
|
+
* since an open loop encloses nothing on its own. Every subpath is added, so a
|
|
29
|
+
* ring written as an outer loop and an inner loop wound the other way comes
|
|
30
|
+
* back as the difference between the two discs.
|
|
31
|
+
*/
|
|
32
|
+
export function areaOf(path) {
|
|
33
|
+
let total = 0;
|
|
34
|
+
for (const subpath of path) {
|
|
35
|
+
if (subpath.curves.length === 0)
|
|
36
|
+
continue;
|
|
37
|
+
let from = subpath.start;
|
|
38
|
+
for (const curve of subpath.curves) {
|
|
39
|
+
total += pieceArea(from, curve);
|
|
40
|
+
from = curve.to;
|
|
41
|
+
}
|
|
42
|
+
total += wedge(from, subpath.start) / 2;
|
|
43
|
+
}
|
|
44
|
+
return total;
|
|
45
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Path } from './path.js';
|
|
2
|
+
export interface BooleanOptions {
|
|
3
|
+
/** How close two things come before they count as the same place, in the
|
|
4
|
+
* picture's own units. It decides where two paths are read as crossing and
|
|
5
|
+
* which ends are read as meeting. */
|
|
6
|
+
readonly tolerance?: number;
|
|
7
|
+
}
|
|
8
|
+
/** Everything either path covers. */
|
|
9
|
+
export declare function unionOf(first: Path, second: Path, options?: BooleanOptions): Path;
|
|
10
|
+
/** Only what both paths cover. */
|
|
11
|
+
export declare function intersectionOf(first: Path, second: Path, options?: BooleanOptions): Path;
|
|
12
|
+
/** The first path with the second taken out of it. */
|
|
13
|
+
export declare function differenceOf(first: Path, second: Path, options?: BooleanOptions): Path;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Union, intersection and difference on paths.
|
|
3
|
+
*
|
|
4
|
+
* Both paths are cut at every place they cross, so that after the cutting every
|
|
5
|
+
* piece is wholly inside the other path or wholly outside it. Each piece is
|
|
6
|
+
* then decided one way or the other, the operation says which pieces it wants,
|
|
7
|
+
* and what is kept is stitched back into loops by joining ends that meet.
|
|
8
|
+
*
|
|
9
|
+
* The difference takes the second path's kept pieces the other way round, which
|
|
10
|
+
* is what turns a disc taken out of the middle of another disc into a ring: the
|
|
11
|
+
* inner loop is wound against the outer one and the nonzero rule the mark
|
|
12
|
+
* carries leaves it empty.
|
|
13
|
+
*/
|
|
14
|
+
import { vec2 } from '../values/vec2.js';
|
|
15
|
+
import { pointOn } from './path.js';
|
|
16
|
+
import { cutPath } from './cut.js';
|
|
17
|
+
import { curveCrossings } from './intersect.js';
|
|
18
|
+
import { flattenPath, windingAt } from './inside.js';
|
|
19
|
+
const TOLERANCE = 1e-6;
|
|
20
|
+
/** A subpath left open closed by the straight run back to where it started,
|
|
21
|
+
* since a path with an open loop has no inside for any of this to ask about. */
|
|
22
|
+
function closedLoops(path) {
|
|
23
|
+
return path.map((subpath) => {
|
|
24
|
+
if (subpath.curves.length === 0)
|
|
25
|
+
return subpath;
|
|
26
|
+
const end = subpath.curves[subpath.curves.length - 1].to;
|
|
27
|
+
if (subpath.closed && end.x === subpath.start.x && end.y === subpath.start.y) {
|
|
28
|
+
return subpath;
|
|
29
|
+
}
|
|
30
|
+
const curves = end.x === subpath.start.x && end.y === subpath.start.y
|
|
31
|
+
? subpath.curves
|
|
32
|
+
: [
|
|
33
|
+
...subpath.curves,
|
|
34
|
+
{
|
|
35
|
+
control1: vec2.lerp(end, subpath.start, 1 / 3),
|
|
36
|
+
control2: vec2.lerp(end, subpath.start, 2 / 3),
|
|
37
|
+
to: subpath.start,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
return { start: subpath.start, curves, closed: true };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function piecesOf(path) {
|
|
44
|
+
const pieces = [];
|
|
45
|
+
for (const subpath of path) {
|
|
46
|
+
let from = subpath.start;
|
|
47
|
+
for (const curve of subpath.curves) {
|
|
48
|
+
pieces.push({ from, curve });
|
|
49
|
+
from = curve.to;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return pieces;
|
|
53
|
+
}
|
|
54
|
+
/** A piece walked the other way, which is what puts a hole the opposite way
|
|
55
|
+
* round from the loop it sits in. */
|
|
56
|
+
function reversed(piece) {
|
|
57
|
+
return {
|
|
58
|
+
from: piece.curve.to,
|
|
59
|
+
curve: { control1: piece.curve.control2, control2: piece.curve.control1, to: piece.from },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Where the two paths cross, as the cuts each of them needs. */
|
|
63
|
+
function cutsBetween(first, second, tolerance) {
|
|
64
|
+
const forFirst = [];
|
|
65
|
+
const forSecond = [];
|
|
66
|
+
for (let left = 0; left < first.length; left++) {
|
|
67
|
+
let leftFrom = first[left].start;
|
|
68
|
+
for (let leftPiece = 0; leftPiece < first[left].curves.length; leftPiece++) {
|
|
69
|
+
const leftCurve = first[left].curves[leftPiece];
|
|
70
|
+
for (let right = 0; right < second.length; right++) {
|
|
71
|
+
let rightFrom = second[right].start;
|
|
72
|
+
for (let rightPiece = 0; rightPiece < second[right].curves.length; rightPiece++) {
|
|
73
|
+
const rightCurve = second[right].curves[rightPiece];
|
|
74
|
+
for (const crossing of curveCrossings(leftFrom, leftCurve, rightFrom, rightCurve, { tolerance })) {
|
|
75
|
+
forFirst.push({ subpath: left, curve: leftPiece, along: crossing.alongFirst });
|
|
76
|
+
forSecond.push({ subpath: right, curve: rightPiece, along: crossing.alongSecond });
|
|
77
|
+
}
|
|
78
|
+
rightFrom = rightCurve.to;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
leftFrom = leftCurve.to;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return [forFirst, forSecond];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The kept pieces joined into loops, by taking each end to the piece that
|
|
88
|
+
* starts where it finishes.
|
|
89
|
+
*
|
|
90
|
+
* A piece carries its two controls and where it ends, so the loop takes where
|
|
91
|
+
* each piece begins from where the piece before it ended. The two paths put
|
|
92
|
+
* their cut at a place they each worked out on their own, so the two ends of a
|
|
93
|
+
* join differ by whatever the crossing was out by, and that difference is
|
|
94
|
+
* absorbed here rather than left as a gap.
|
|
95
|
+
*/
|
|
96
|
+
function stitch(pieces, tolerance) {
|
|
97
|
+
const used = pieces.map(() => false);
|
|
98
|
+
const loops = [];
|
|
99
|
+
for (let seed = 0; seed < pieces.length; seed++) {
|
|
100
|
+
if (used[seed])
|
|
101
|
+
continue;
|
|
102
|
+
used[seed] = true;
|
|
103
|
+
const start = pieces[seed].from;
|
|
104
|
+
const curves = [pieces[seed].curve];
|
|
105
|
+
let end = pieces[seed].curve.to;
|
|
106
|
+
while (vec2.distance(end, start) > tolerance) {
|
|
107
|
+
let next = -1;
|
|
108
|
+
let nearest = tolerance;
|
|
109
|
+
for (let at = 0; at < pieces.length; at++) {
|
|
110
|
+
if (used[at])
|
|
111
|
+
continue;
|
|
112
|
+
const gap = vec2.distance(pieces[at].from, end);
|
|
113
|
+
if (gap <= nearest) {
|
|
114
|
+
nearest = gap;
|
|
115
|
+
next = at;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (next < 0)
|
|
119
|
+
break;
|
|
120
|
+
used[next] = true;
|
|
121
|
+
curves.push(pieces[next].curve);
|
|
122
|
+
end = pieces[next].curve.to;
|
|
123
|
+
}
|
|
124
|
+
loops.push({ start, curves, closed: true });
|
|
125
|
+
}
|
|
126
|
+
return loops;
|
|
127
|
+
}
|
|
128
|
+
/** Which side of the other path each piece falls on, taken at its middle, which
|
|
129
|
+
* after the cutting stands for the whole piece. */
|
|
130
|
+
function insideOther(pieces, other) {
|
|
131
|
+
return pieces.map((piece) => windingAt(other, pointOn(piece.from, piece.curve, 0.5)) !== 0);
|
|
132
|
+
}
|
|
133
|
+
function combine(first, second, keep, options) {
|
|
134
|
+
const tolerance = options.tolerance ?? TOLERANCE;
|
|
135
|
+
const left = closedLoops(first);
|
|
136
|
+
const right = closedLoops(second);
|
|
137
|
+
if (left.length === 0)
|
|
138
|
+
return keep === 'union' ? right : [];
|
|
139
|
+
if (right.length === 0)
|
|
140
|
+
return keep === 'intersection' ? [] : left;
|
|
141
|
+
const [leftCuts, rightCuts] = cutsBetween(left, right, tolerance);
|
|
142
|
+
const leftPieces = piecesOf(cutPath(left, leftCuts, { tolerance }));
|
|
143
|
+
const rightPieces = piecesOf(cutPath(right, rightCuts, { tolerance }));
|
|
144
|
+
const leftInside = insideOther(leftPieces, flattenPath(right, { tolerance }));
|
|
145
|
+
const rightInside = insideOther(rightPieces, flattenPath(left, { tolerance }));
|
|
146
|
+
const kept = [];
|
|
147
|
+
for (let at = 0; at < leftPieces.length; at++) {
|
|
148
|
+
if (leftInside[at] === (keep === 'intersection'))
|
|
149
|
+
kept.push(leftPieces[at]);
|
|
150
|
+
}
|
|
151
|
+
for (let at = 0; at < rightPieces.length; at++) {
|
|
152
|
+
if (keep === 'difference') {
|
|
153
|
+
if (rightInside[at])
|
|
154
|
+
kept.push(reversed(rightPieces[at]));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (rightInside[at] === (keep === 'intersection'))
|
|
158
|
+
kept.push(rightPieces[at]);
|
|
159
|
+
}
|
|
160
|
+
return stitch(kept, tolerance);
|
|
161
|
+
}
|
|
162
|
+
/** Everything either path covers. */
|
|
163
|
+
export function unionOf(first, second, options = {}) {
|
|
164
|
+
return combine(first, second, 'union', options);
|
|
165
|
+
}
|
|
166
|
+
/** Only what both paths cover. */
|
|
167
|
+
export function intersectionOf(first, second, options = {}) {
|
|
168
|
+
return combine(first, second, 'intersection', options);
|
|
169
|
+
}
|
|
170
|
+
/** The first path with the second taken out of it. */
|
|
171
|
+
export function differenceOf(first, second, options = {}) {
|
|
172
|
+
return combine(first, second, 'difference', options);
|
|
173
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A path cut where something crosses it.
|
|
3
|
+
*
|
|
4
|
+
* Each cut names a piece and how far along it the cut falls, and the pieces
|
|
5
|
+
* that come back draw exactly what the pieces they replace drew, because de
|
|
6
|
+
* Casteljau's construction gives both halves of a cubic as cubics. Several cuts
|
|
7
|
+
* in one piece are taken in order, and each one after the first is measured
|
|
8
|
+
* against what is left rather than against the piece it started as.
|
|
9
|
+
*/
|
|
10
|
+
import { type Path } from './path.js';
|
|
11
|
+
/** Where one cut falls: which subpath, which piece of it, and how far along
|
|
12
|
+
* that piece. */
|
|
13
|
+
export interface Cut {
|
|
14
|
+
readonly subpath: number;
|
|
15
|
+
readonly curve: number;
|
|
16
|
+
readonly along: number;
|
|
17
|
+
}
|
|
18
|
+
export interface CutOptions {
|
|
19
|
+
/** How close two cuts, or a cut and the end of a piece, are before they count
|
|
20
|
+
* as the same place, in the picture's own units. */
|
|
21
|
+
readonly tolerance?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A path with every cut put in, drawing what it drew and holding one more piece
|
|
25
|
+
* for each cut.
|
|
26
|
+
*
|
|
27
|
+
* The tolerance is a distance rather than a fraction, so it is read against
|
|
28
|
+
* each piece's own length: a cut is worth making only where the piece it would
|
|
29
|
+
* leave behind is long enough to see, and a piece of nothing is one the stitch
|
|
30
|
+
* that follows would have to know to skip.
|
|
31
|
+
*
|
|
32
|
+
* A cut naming a piece the path does not have is ignored, since a caller that
|
|
33
|
+
* has already thrown one subpath away should not have to renumber the cuts it
|
|
34
|
+
* gathered before it did.
|
|
35
|
+
*/
|
|
36
|
+
export declare function cutPath(path: Path, cuts: readonly Cut[], options?: CutOptions): Path;
|