@altpsyche/maths 0.8.0 → 0.9.1

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 CHANGED
@@ -163,6 +163,47 @@ matters. The slope in the demo is a value of the walk, so it is worked out by th
163
163
  track and cannot drift from the dot. The rise is counted by the clock, which is honest only because
164
164
  the dot has stopped by the time it counts: two clocks running at once would be free to disagree.
165
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
+
166
207
  ## The animations
167
208
 
168
209
  `fadeIn`, `fadeOut`, `fadeTo`, `draw`, `morph`, `morphEquation`, `countTo`, `moveBy`, `rotate`,
@@ -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,223 @@
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
+ * A piece lying along the other path's own edge is decided by which way the two
15
+ * run rather than by which side it is on, because a point on an edge is the one
16
+ * place the winding count has no answer for. Two paths walking a shared stretch
17
+ * the same way have their solid on the same side of it, so the stretch is on the
18
+ * edge of a union and of an overlap and is kept once. Walking it opposite ways
19
+ * puts their solids on opposite sides, so the stretch is inside a union and
20
+ * outside an overlap, and a difference keeps the first path's copy of it.
21
+ */
22
+ import { vec2 } from '../values/vec2.js';
23
+ import { pointOn, slopeOn } from './path.js';
24
+ import { cutPath } from './cut.js';
25
+ import { curveCrossings } from './intersect.js';
26
+ import { flattenPath, nearestEdge, windingAt } from './inside.js';
27
+ const TOLERANCE = 1e-6;
28
+ /** A subpath left open closed by the straight run back to where it started,
29
+ * since a path with an open loop has no inside for any of this to ask about. */
30
+ function closedLoops(path) {
31
+ return path.map((subpath) => {
32
+ if (subpath.curves.length === 0)
33
+ return subpath;
34
+ const end = subpath.curves[subpath.curves.length - 1].to;
35
+ if (subpath.closed && end.x === subpath.start.x && end.y === subpath.start.y) {
36
+ return subpath;
37
+ }
38
+ const curves = end.x === subpath.start.x && end.y === subpath.start.y
39
+ ? subpath.curves
40
+ : [
41
+ ...subpath.curves,
42
+ {
43
+ control1: vec2.lerp(end, subpath.start, 1 / 3),
44
+ control2: vec2.lerp(end, subpath.start, 2 / 3),
45
+ to: subpath.start,
46
+ },
47
+ ];
48
+ return { start: subpath.start, curves, closed: true };
49
+ });
50
+ }
51
+ function piecesOf(path) {
52
+ const pieces = [];
53
+ for (const subpath of path) {
54
+ let from = subpath.start;
55
+ for (const curve of subpath.curves) {
56
+ pieces.push({ from, curve });
57
+ from = curve.to;
58
+ }
59
+ }
60
+ return pieces;
61
+ }
62
+ /** A piece walked the other way, which is what puts a hole the opposite way
63
+ * round from the loop it sits in. */
64
+ function reversed(piece) {
65
+ return {
66
+ from: piece.curve.to,
67
+ curve: { control1: piece.curve.control2, control2: piece.curve.control1, to: piece.from },
68
+ };
69
+ }
70
+ /** Where the two paths cross, as the cuts each of them needs. */
71
+ function cutsBetween(first, second, tolerance) {
72
+ const forFirst = [];
73
+ const forSecond = [];
74
+ for (let left = 0; left < first.length; left++) {
75
+ let leftFrom = first[left].start;
76
+ for (let leftPiece = 0; leftPiece < first[left].curves.length; leftPiece++) {
77
+ const leftCurve = first[left].curves[leftPiece];
78
+ for (let right = 0; right < second.length; right++) {
79
+ let rightFrom = second[right].start;
80
+ for (let rightPiece = 0; rightPiece < second[right].curves.length; rightPiece++) {
81
+ const rightCurve = second[right].curves[rightPiece];
82
+ for (const crossing of curveCrossings(leftFrom, leftCurve, rightFrom, rightCurve, { tolerance })) {
83
+ forFirst.push({ subpath: left, curve: leftPiece, along: crossing.alongFirst });
84
+ forSecond.push({ subpath: right, curve: rightPiece, along: crossing.alongSecond });
85
+ }
86
+ rightFrom = rightCurve.to;
87
+ }
88
+ }
89
+ leftFrom = leftCurve.to;
90
+ }
91
+ }
92
+ return [forFirst, forSecond];
93
+ }
94
+ function place(point) {
95
+ return `(${point.x.toFixed(6)}, ${point.y.toFixed(6)})`;
96
+ }
97
+ /**
98
+ * What a run of pieces that will not close stops with.
99
+ *
100
+ * Handing it back as a loop anyway is the one failure a caller cannot see: the
101
+ * shape drawn is wrong and nothing about it says so. Every input is closed
102
+ * loops, so a run that will not close is this code being wrong rather than the
103
+ * caller, and stopping is what makes that visible on the frame it happens.
104
+ */
105
+ function refuse(pieces, start, end, tolerance) {
106
+ throw new Error(`the pieces kept do not close into a loop: ${pieces} of them run from ${place(start)} ` +
107
+ `to ${place(end)}, which is ${vec2.distance(end, start).toFixed(6)} apart against a ` +
108
+ `tolerance of ${tolerance}`);
109
+ }
110
+ /**
111
+ * The kept pieces joined into loops, by taking each end to the piece that
112
+ * starts where it finishes.
113
+ *
114
+ * A piece carries its two controls and where it ends, so the loop takes where
115
+ * each piece begins from where the piece before it ended. The two paths put
116
+ * their cut at a place they each worked out on their own, so the two ends of a
117
+ * join differ by whatever the crossing was out by, and that difference is
118
+ * absorbed here rather than left as a gap.
119
+ */
120
+ function stitch(pieces, tolerance) {
121
+ const used = pieces.map(() => false);
122
+ const loops = [];
123
+ for (let seed = 0; seed < pieces.length; seed++) {
124
+ if (used[seed])
125
+ continue;
126
+ used[seed] = true;
127
+ const start = pieces[seed].from;
128
+ const curves = [pieces[seed].curve];
129
+ let end = pieces[seed].curve.to;
130
+ while (vec2.distance(end, start) > tolerance) {
131
+ let next = -1;
132
+ let nearest = tolerance;
133
+ for (let at = 0; at < pieces.length; at++) {
134
+ if (used[at])
135
+ continue;
136
+ const gap = vec2.distance(pieces[at].from, end);
137
+ if (gap <= nearest) {
138
+ nearest = gap;
139
+ next = at;
140
+ }
141
+ }
142
+ if (next < 0)
143
+ refuse(curves.length, start, end, tolerance);
144
+ used[next] = true;
145
+ curves.push(pieces[next].curve);
146
+ end = pieces[next].curve.to;
147
+ }
148
+ loops.push({ start, curves, closed: true });
149
+ }
150
+ return loops;
151
+ }
152
+ /**
153
+ * Where each piece stands against the other path, read at its middle.
154
+ *
155
+ * The middle stands for the whole piece because the cutting has already put a
156
+ * break wherever the two paths meet, so a piece after it is wholly one thing.
157
+ */
158
+ function sidesAgainst(pieces, other, tolerance) {
159
+ return pieces.map((piece) => {
160
+ const middle = pointOn(piece.from, piece.curve, 0.5);
161
+ const edge = nearestEdge(other, middle);
162
+ if (edge && edge.gap <= tolerance) {
163
+ return vec2.dot(slopeOn(piece.from, piece.curve, 0.5), edge.heading) >= 0 ? 'along' : 'against';
164
+ }
165
+ return windingAt(other, middle) !== 0 ? 'inside' : 'outside';
166
+ });
167
+ }
168
+ /** Whether the first path's piece belongs to the answer. */
169
+ function keepsFirst(keep, side) {
170
+ if (side === 'along')
171
+ return keep !== 'difference';
172
+ if (side === 'against')
173
+ return keep === 'difference';
174
+ if (keep === 'intersection')
175
+ return side === 'inside';
176
+ return side === 'outside';
177
+ }
178
+ /** Whether the second path's piece belongs to the answer. A shared stretch is
179
+ * never taken from here, since the first path's copy of it is already in. */
180
+ function keepsSecond(keep, side) {
181
+ if (side === 'along' || side === 'against')
182
+ return false;
183
+ if (keep === 'union')
184
+ return side === 'outside';
185
+ return side === 'inside';
186
+ }
187
+ function combine(first, second, keep, options) {
188
+ const tolerance = options.tolerance ?? TOLERANCE;
189
+ const left = closedLoops(first);
190
+ const right = closedLoops(second);
191
+ if (left.length === 0)
192
+ return keep === 'union' ? right : [];
193
+ if (right.length === 0)
194
+ return keep === 'intersection' ? [] : left;
195
+ const [leftCuts, rightCuts] = cutsBetween(left, right, tolerance);
196
+ const leftPieces = piecesOf(cutPath(left, leftCuts, { tolerance }));
197
+ const rightPieces = piecesOf(cutPath(right, rightCuts, { tolerance }));
198
+ const leftSide = sidesAgainst(leftPieces, flattenPath(right, { tolerance }), tolerance);
199
+ const rightSide = sidesAgainst(rightPieces, flattenPath(left, { tolerance }), tolerance);
200
+ const kept = [];
201
+ for (let at = 0; at < leftPieces.length; at++) {
202
+ if (keepsFirst(keep, leftSide[at]))
203
+ kept.push(leftPieces[at]);
204
+ }
205
+ for (let at = 0; at < rightPieces.length; at++) {
206
+ if (!keepsSecond(keep, rightSide[at]))
207
+ continue;
208
+ kept.push(keep === 'difference' ? reversed(rightPieces[at]) : rightPieces[at]);
209
+ }
210
+ return stitch(kept, tolerance);
211
+ }
212
+ /** Everything either path covers. */
213
+ export function unionOf(first, second, options = {}) {
214
+ return combine(first, second, 'union', options);
215
+ }
216
+ /** Only what both paths cover. */
217
+ export function intersectionOf(first, second, options = {}) {
218
+ return combine(first, second, 'intersection', options);
219
+ }
220
+ /** The first path with the second taken out of it. */
221
+ export function differenceOf(first, second, options = {}) {
222
+ return combine(first, second, 'difference', options);
223
+ }
@@ -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;
@@ -0,0 +1,83 @@
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 { splitCurve } from './path.js';
11
+ import { measurePath } from './length.js';
12
+ const TOLERANCE = 1e-9;
13
+ /** The fractions for one piece, in order, with the ones that would leave a
14
+ * piece of nothing dropped. */
15
+ function wanted(fractions, tolerance) {
16
+ const kept = [];
17
+ for (const fraction of [...fractions].sort((a, b) => a - b)) {
18
+ if (fraction <= tolerance || fraction >= 1 - tolerance)
19
+ continue;
20
+ if (kept.length > 0 && fraction - kept[kept.length - 1] <= tolerance)
21
+ continue;
22
+ kept.push(fraction);
23
+ }
24
+ return kept;
25
+ }
26
+ /** One piece as the run of pieces the cuts leave, each cut measured against
27
+ * what is left of the piece rather than against the whole of it. */
28
+ function cutCurve(from, curve, fractions) {
29
+ const pieces = [];
30
+ let rest = curve;
31
+ let restFrom = from;
32
+ let taken = 0;
33
+ for (const fraction of fractions) {
34
+ const [head, tail] = splitCurve(restFrom, rest, (fraction - taken) / (1 - taken));
35
+ pieces.push(head);
36
+ restFrom = head.to;
37
+ rest = tail;
38
+ taken = fraction;
39
+ }
40
+ pieces.push(rest);
41
+ return pieces;
42
+ }
43
+ /**
44
+ * A path with every cut put in, drawing what it drew and holding one more piece
45
+ * for each cut.
46
+ *
47
+ * The tolerance is a distance rather than a fraction, so it is read against
48
+ * each piece's own length: a cut is worth making only where the piece it would
49
+ * leave behind is long enough to see, and a piece of nothing is one the stitch
50
+ * that follows would have to know to skip.
51
+ *
52
+ * A cut naming a piece the path does not have is ignored, since a caller that
53
+ * has already thrown one subpath away should not have to renumber the cuts it
54
+ * gathered before it did.
55
+ */
56
+ export function cutPath(path, cuts, options = {}) {
57
+ const tolerance = options.tolerance ?? TOLERANCE;
58
+ if (cuts.length === 0)
59
+ return path;
60
+ const gathered = new Map();
61
+ for (const cut of cuts) {
62
+ const key = `${cut.subpath}:${cut.curve}`;
63
+ const already = gathered.get(key);
64
+ if (already)
65
+ already.push(cut.along);
66
+ else
67
+ gathered.set(key, [cut.along]);
68
+ }
69
+ const measured = measurePath(path);
70
+ return path.map((subpath, at) => {
71
+ let from = subpath.start;
72
+ const curves = [];
73
+ for (let piece = 0; piece < subpath.curves.length; piece++) {
74
+ const curve = subpath.curves[piece];
75
+ const span = measured.per[at][piece].total;
76
+ const asFraction = span > 0 ? Math.min(tolerance / span, 0.5) : 1;
77
+ const fractions = wanted(gathered.get(`${at}:${piece}`) ?? [], asFraction);
78
+ curves.push(...cutCurve(from, curve, fractions));
79
+ from = curve.to;
80
+ }
81
+ return { start: subpath.start, curves, closed: subpath.closed };
82
+ });
83
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Whether a point is inside a path.
3
+ *
4
+ * The count is taken on a flattening rather than on the cubics, because a ray
5
+ * against a cubic is a cubic to solve and what is wanted here is a yes or a no
6
+ * rather than a place. The geometry a boolean operation keeps stays the exact
7
+ * cubics; only this decision is taken on the flattening.
8
+ *
9
+ * The rule is the nonzero winding rule, which is the rule a mark is already
10
+ * drawn under, so a loop wound the other way inside another loop is a hole.
11
+ * Every edge crossing the ray is counted with the sign of the direction it
12
+ * crosses in, and an edge is counted at its lower end and not at its upper one,
13
+ * which is what makes a ray leaving through a corner answer what every other
14
+ * ray answers.
15
+ */
16
+ import { type Vec2 } from '../values/vec2.js';
17
+ import type { Path } from './path.js';
18
+ export interface FlattenOptions {
19
+ /** How far a straight run may sit from the curve it stands for, in the
20
+ * picture's own units. */
21
+ readonly tolerance?: number;
22
+ }
23
+ /**
24
+ * Every subpath as a run of points, each loop closed.
25
+ *
26
+ * A subpath that was left open is closed by the straight run back to where it
27
+ * started, since a path with an open loop has no inside until it has one.
28
+ */
29
+ export declare function flattenPath(path: Path, options?: FlattenOptions): Vec2[][];
30
+ /** How many times the loops wind round a point, counted along the ray heading
31
+ * in the positive x direction. */
32
+ export declare function windingAt(loops: readonly (readonly Vec2[])[], point: Vec2): number;
33
+ /** The nearest straight run of a flattening to a point: how far off it is, and
34
+ * which way that run goes. */
35
+ export interface Edge {
36
+ readonly gap: number;
37
+ readonly heading: Vec2;
38
+ }
39
+ /**
40
+ * Which edge of a flattening a point sits nearest, and which way it runs.
41
+ *
42
+ * This is what tells a piece lying along another path's edge from one merely
43
+ * near it, which the winding count cannot answer because a point on the edge
44
+ * itself is the one place the count has no answer for.
45
+ */
46
+ export declare function nearestEdge(loops: readonly (readonly Vec2[])[], point: Vec2): Edge | null;
47
+ /**
48
+ * Whether a path holds a point, under the nonzero rule.
49
+ *
50
+ * A point sitting on the edge itself has no answer this can be right about, and
51
+ * what comes back for one is whichever side the tolerance put it on.
52
+ */
53
+ export declare function containsPoint(path: Path, point: Vec2, options?: FlattenOptions): boolean;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Whether a point is inside a path.
3
+ *
4
+ * The count is taken on a flattening rather than on the cubics, because a ray
5
+ * against a cubic is a cubic to solve and what is wanted here is a yes or a no
6
+ * rather than a place. The geometry a boolean operation keeps stays the exact
7
+ * cubics; only this decision is taken on the flattening.
8
+ *
9
+ * The rule is the nonzero winding rule, which is the rule a mark is already
10
+ * drawn under, so a loop wound the other way inside another loop is a hole.
11
+ * Every edge crossing the ray is counted with the sign of the direction it
12
+ * crosses in, and an edge is counted at its lower end and not at its upper one,
13
+ * which is what makes a ray leaving through a corner answer what every other
14
+ * ray answers.
15
+ */
16
+ import { vec2 } from '../values/vec2.js';
17
+ const TOLERANCE = 1e-6;
18
+ /** How many times a piece may be halved, which a tolerance of zero would
19
+ * otherwise leave unbounded. */
20
+ const DEPTH = 24;
21
+ /**
22
+ * How many points a whole flattening may hold before it is refused.
23
+ *
24
+ * Halving is the only bound the depth gives, and sixteen million points for one
25
+ * piece is a machine out of memory rather than a fine flattening. A circle of
26
+ * radius 1 wants 4096 of these at a tolerance of a millionth, so the room here
27
+ * is a thousandfold.
28
+ */
29
+ const POINTS = 1_000_000;
30
+ /** How far the two controls sit from the straight run between the ends.
31
+ *
32
+ * The curve itself stays within three quarters of this, so measuring the
33
+ * controls asks for a little more than the tolerance rather than a little
34
+ * less. */
35
+ function offChord(from, curve) {
36
+ const run = vec2.sub(curve.to, from);
37
+ const span = Math.hypot(run.x, run.y);
38
+ if (span === 0) {
39
+ return Math.max(vec2.distance(curve.control1, from), vec2.distance(curve.control2, from));
40
+ }
41
+ const away = (point) => Math.abs(run.x * (point.y - from.y) - run.y * (point.x - from.x)) / span;
42
+ return Math.max(away(curve.control1), away(curve.control2));
43
+ }
44
+ function walk(from, curve, tolerance, depth, into) {
45
+ if (depth >= DEPTH || offChord(from, curve) <= tolerance) {
46
+ into.push(curve.to);
47
+ return;
48
+ }
49
+ if (into.length >= POINTS) {
50
+ throw new Error(`a tolerance of ${tolerance} asks for more than ${POINTS} straight runs to stand for one ` +
51
+ `curve, which is finer than this flattens`);
52
+ }
53
+ const a = vec2.lerp(from, curve.control1, 0.5);
54
+ const b = vec2.lerp(curve.control1, curve.control2, 0.5);
55
+ const c = vec2.lerp(curve.control2, curve.to, 0.5);
56
+ const d = vec2.lerp(a, b, 0.5);
57
+ const e = vec2.lerp(b, c, 0.5);
58
+ const middle = vec2.lerp(d, e, 0.5);
59
+ walk(from, { control1: a, control2: d, to: middle }, tolerance, depth + 1, into);
60
+ walk(middle, { control1: e, control2: c, to: curve.to }, tolerance, depth + 1, into);
61
+ }
62
+ /**
63
+ * Every subpath as a run of points, each loop closed.
64
+ *
65
+ * A subpath that was left open is closed by the straight run back to where it
66
+ * started, since a path with an open loop has no inside until it has one.
67
+ */
68
+ export function flattenPath(path, options = {}) {
69
+ const tolerance = options.tolerance ?? TOLERANCE;
70
+ const loops = [];
71
+ for (const subpath of path) {
72
+ if (subpath.curves.length === 0)
73
+ continue;
74
+ const points = [subpath.start];
75
+ let from = subpath.start;
76
+ for (const curve of subpath.curves) {
77
+ walk(from, curve, tolerance, 0, points);
78
+ from = curve.to;
79
+ }
80
+ const last = points[points.length - 1];
81
+ if (last.x !== points[0].x || last.y !== points[0].y)
82
+ points.push(points[0]);
83
+ loops.push(points);
84
+ }
85
+ return loops;
86
+ }
87
+ /** Which side of the run from one point to another a third point falls on,
88
+ * positive to the left of it. */
89
+ function leftOf(from, to, point) {
90
+ return (to.x - from.x) * (point.y - from.y) - (point.x - from.x) * (to.y - from.y);
91
+ }
92
+ /** How many times the loops wind round a point, counted along the ray heading
93
+ * in the positive x direction. */
94
+ export function windingAt(loops, point) {
95
+ let winding = 0;
96
+ for (const loop of loops) {
97
+ for (let at = 1; at < loop.length; at++) {
98
+ const from = loop[at - 1];
99
+ const to = loop[at];
100
+ if (from.y <= point.y) {
101
+ if (to.y > point.y && leftOf(from, to, point) > 0)
102
+ winding += 1;
103
+ }
104
+ else if (to.y <= point.y && leftOf(from, to, point) < 0) {
105
+ winding -= 1;
106
+ }
107
+ }
108
+ }
109
+ return winding;
110
+ }
111
+ /**
112
+ * Which edge of a flattening a point sits nearest, and which way it runs.
113
+ *
114
+ * This is what tells a piece lying along another path's edge from one merely
115
+ * near it, which the winding count cannot answer because a point on the edge
116
+ * itself is the one place the count has no answer for.
117
+ */
118
+ export function nearestEdge(loops, point) {
119
+ let nearest = null;
120
+ for (const loop of loops) {
121
+ for (let at = 1; at < loop.length; at++) {
122
+ const from = loop[at - 1];
123
+ const to = loop[at];
124
+ const run = vec2.sub(to, from);
125
+ const square = vec2.dot(run, run);
126
+ const along = square === 0 ? 0 : Math.min(1, Math.max(0, vec2.dot(vec2.sub(point, from), run) / square));
127
+ const gap = vec2.distance(point, vec2.add(from, vec2.scale(run, along)));
128
+ if (!nearest || gap < nearest.gap)
129
+ nearest = { gap, heading: run };
130
+ }
131
+ }
132
+ return nearest;
133
+ }
134
+ /**
135
+ * Whether a path holds a point, under the nonzero rule.
136
+ *
137
+ * A point sitting on the edge itself has no answer this can be right about, and
138
+ * what comes back for one is whichever side the tolerance put it on.
139
+ */
140
+ export function containsPoint(path, point, options = {}) {
141
+ return windingAt(flattenPath(path, options), point) !== 0;
142
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Where two cubics cross.
3
+ *
4
+ * Two curves whose boxes miss each other cannot cross, so a pair is halved by
5
+ * de Casteljau's construction and only the halves whose boxes still overlap are
6
+ * followed down, until both are smaller than the tolerance. A box is bigger
7
+ * than the curve inside it, so a pair that small is kept only when the straight
8
+ * runs across the two pieces come within the tolerance of each other. What is
9
+ * left is a run of touching hits per meeting rather than one hit, so the run is
10
+ * joined up and answered once, and then sharpened by Newton's method on the
11
+ * pair, which is what takes a crossing of two straight pieces from the
12
+ * tolerance to the last few bits.
13
+ */
14
+ import { type Vec2 } from '../values/vec2.js';
15
+ import type { Cubic } from './path.js';
16
+ /** One place two curves meet, as a fraction along each of them and the point
17
+ * itself. */
18
+ export interface Crossing {
19
+ readonly alongFirst: number;
20
+ readonly alongSecond: number;
21
+ readonly point: Vec2;
22
+ }
23
+ export interface CrossingOptions {
24
+ /** How close two pieces come before they count as meeting, in the picture's
25
+ * own units. */
26
+ readonly tolerance?: number;
27
+ }
28
+ /**
29
+ * Every place two cubics cross, as a fraction along each and the point.
30
+ *
31
+ * Each segment is given the point it starts from, since a segment carries where
32
+ * it ends and not where it began. Two curves covering the same stretch answer
33
+ * with the two ends of that stretch, so a caller reading the answer as places to
34
+ * cut at gets the stretch marked off rather than chopped into slivers.
35
+ */
36
+ export declare function curveCrossings(fromFirst: Vec2, first: Cubic, fromSecond: Vec2, second: Cubic, options?: CrossingOptions): Crossing[];
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Where two cubics cross.
3
+ *
4
+ * Two curves whose boxes miss each other cannot cross, so a pair is halved by
5
+ * de Casteljau's construction and only the halves whose boxes still overlap are
6
+ * followed down, until both are smaller than the tolerance. A box is bigger
7
+ * than the curve inside it, so a pair that small is kept only when the straight
8
+ * runs across the two pieces come within the tolerance of each other. What is
9
+ * left is a run of touching hits per meeting rather than one hit, so the run is
10
+ * joined up and answered once, and then sharpened by Newton's method on the
11
+ * pair, which is what takes a crossing of two straight pieces from the
12
+ * tolerance to the last few bits.
13
+ */
14
+ import { vec2 } from '../values/vec2.js';
15
+ /** How close two pieces come before they count as meeting. It decides which
16
+ * meetings are told apart rather than how sharp one is, since Newton's method
17
+ * supplies the sharpness afterwards. */
18
+ const TOLERANCE = 1e-6;
19
+ /**
20
+ * How many pairs the search may make before it answers with where it had got to.
21
+ *
22
+ * It counts pairs made rather than pairs looked at, because each one looked at
23
+ * makes up to four more: counting the ones looked at leaves the pile of pairs
24
+ * waiting growing four times faster than it drains, and a tolerance small enough
25
+ * ran the machine out of memory before the count was ever reached.
26
+ */
27
+ const BUDGET = 200_000;
28
+ /** How deep the halving goes, which a tolerance of zero would otherwise leave
29
+ * unbounded. */
30
+ const DEPTH = 60;
31
+ /** Below this the two tangents are parallel, Newton's step divides by nothing,
32
+ * and the answer stays the one the halving gave. */
33
+ const PARALLEL = 1e-12;
34
+ /** How far Newton is allowed to move a cluster before its step is read as a
35
+ * jump to a different crossing and thrown away. */
36
+ const REACH = 1e-3;
37
+ /** A segment written as its four points, since a segment carries where it ends
38
+ * and not where it began. */
39
+ function hullOf(from, curve) {
40
+ return [from, curve.control1, curve.control2, curve.to];
41
+ }
42
+ /** The box round the four points, which holds the curve because a cubic never
43
+ * leaves the hull of the points it is written from. */
44
+ function boxOf(hull) {
45
+ let lowX = hull[0].x;
46
+ let highX = hull[0].x;
47
+ let lowY = hull[0].y;
48
+ let highY = hull[0].y;
49
+ for (let at = 1; at < 4; at++) {
50
+ const point = hull[at];
51
+ if (point.x < lowX)
52
+ lowX = point.x;
53
+ if (point.x > highX)
54
+ highX = point.x;
55
+ if (point.y < lowY)
56
+ lowY = point.y;
57
+ if (point.y > highY)
58
+ highY = point.y;
59
+ }
60
+ return { lowX, highX, lowY, highY };
61
+ }
62
+ function spread(box) {
63
+ return Math.max(box.highX - box.lowX, box.highY - box.lowY);
64
+ }
65
+ /** Whether two boxes miss, with the tolerance added on both sides so two curves
66
+ * touching at exactly one point are not lost to rounding. */
67
+ function apart(a, b, slack) {
68
+ return (a.lowX - slack > b.highX ||
69
+ b.lowX - slack > a.highX ||
70
+ a.lowY - slack > b.highY ||
71
+ b.lowY - slack > a.highY);
72
+ }
73
+ /** How far a point sits from a straight run between two points. */
74
+ function offSegment(point, from, to) {
75
+ const run = vec2.sub(to, from);
76
+ const square = vec2.dot(run, run);
77
+ const along = square === 0 ? 0 : held(vec2.dot(vec2.sub(point, from), run) / square);
78
+ return vec2.distance(point, vec2.add(from, vec2.scale(run, along)));
79
+ }
80
+ /**
81
+ * How far apart two pieces are, taken between the straight runs across them.
82
+ *
83
+ * A piece no bigger than the tolerance leaves its own chord by a fraction of
84
+ * that, so the chords answer for the curves here, and this is what tells a
85
+ * meeting from two boxes that merely overlap without their curves coming close.
86
+ */
87
+ function chordGap(first, second) {
88
+ const a = first[0];
89
+ const b = first[3];
90
+ const c = second[0];
91
+ const d = second[3];
92
+ const ab = vec2.sub(b, a);
93
+ const cd = vec2.sub(d, c);
94
+ const under = ab.x * cd.y - ab.y * cd.x;
95
+ if (under !== 0) {
96
+ const ac = vec2.sub(c, a);
97
+ const alongFirst = (ac.x * cd.y - ac.y * cd.x) / under;
98
+ const alongSecond = (ac.x * ab.y - ac.y * ab.x) / under;
99
+ if (alongFirst >= 0 && alongFirst <= 1 && alongSecond >= 0 && alongSecond <= 1)
100
+ return 0;
101
+ }
102
+ return Math.min(offSegment(a, c, d), offSegment(b, c, d), offSegment(c, a, b), offSegment(d, a, b));
103
+ }
104
+ /** A segment cut in half, both pieces drawing what the whole drew. */
105
+ function halves(hull) {
106
+ const a = vec2.lerp(hull[0], hull[1], 0.5);
107
+ const b = vec2.lerp(hull[1], hull[2], 0.5);
108
+ const c = vec2.lerp(hull[2], hull[3], 0.5);
109
+ const d = vec2.lerp(a, b, 0.5);
110
+ const e = vec2.lerp(b, c, 0.5);
111
+ const middle = vec2.lerp(d, e, 0.5);
112
+ return [
113
+ [hull[0], a, d, middle],
114
+ [middle, e, c, hull[3]],
115
+ ];
116
+ }
117
+ function pointAt(hull, along) {
118
+ const u = 1 - along;
119
+ const a = u * u * u;
120
+ const b = 3 * u * u * along;
121
+ const c = 3 * u * along * along;
122
+ const d = along * along * along;
123
+ return vec2(a * hull[0].x + b * hull[1].x + c * hull[2].x + d * hull[3].x, a * hull[0].y + b * hull[1].y + c * hull[2].y + d * hull[3].y);
124
+ }
125
+ /** Which way the curve is heading, read off the four points the search already
126
+ * holds rather than off a piece, so following a pair down allocates nothing. */
127
+ function slopeAt(hull, along) {
128
+ const u = 1 - along;
129
+ const a = 3 * u * u;
130
+ const b = 6 * u * along;
131
+ const c = 3 * along * along;
132
+ return vec2(a * (hull[1].x - hull[0].x) + b * (hull[2].x - hull[1].x) + c * (hull[3].x - hull[2].x), a * (hull[1].y - hull[0].y) + b * (hull[2].y - hull[1].y) + c * (hull[3].y - hull[2].y));
133
+ }
134
+ /** A fraction pulled back onto the segment, so a crossing at an end lands on
135
+ * the end exactly rather than a hair outside it. */
136
+ function held(along) {
137
+ return along < 0 ? 0 : along > 1 ? 1 : along;
138
+ }
139
+ function gapBetween(first, second, alongFirst, alongSecond) {
140
+ return vec2.distance(pointAt(first, alongFirst), pointAt(second, alongSecond));
141
+ }
142
+ /**
143
+ * One cluster moved onto the crossing itself, by Newton's method on the pair of
144
+ * curves: the step that takes the difference between the two points to zero is
145
+ * the two tangents solved as a two by two system.
146
+ *
147
+ * A tangency has no such step, since the tangents are parallel there and the
148
+ * system has no answer, so the halving's own reading is what stands.
149
+ */
150
+ function sharpened(first, second, start) {
151
+ let alongFirst = start.alongFirst;
152
+ let alongSecond = start.alongSecond;
153
+ let gap = gapBetween(first, second, alongFirst, alongSecond);
154
+ for (let step = 0; step < 12; step++) {
155
+ const apartBy = vec2.sub(pointAt(first, alongFirst), pointAt(second, alongSecond));
156
+ const heading = slopeAt(first, alongFirst);
157
+ const other = slopeAt(second, alongSecond);
158
+ const under = other.x * heading.y - heading.x * other.y;
159
+ if (Math.abs(under) < PARALLEL)
160
+ break;
161
+ const moveFirst = (apartBy.x * other.y - other.x * apartBy.y) / under;
162
+ const moveSecond = (apartBy.x * heading.y - heading.x * apartBy.y) / under;
163
+ if (Math.abs(moveFirst) > REACH || Math.abs(moveSecond) > REACH)
164
+ break;
165
+ const nextFirst = held(alongFirst + moveFirst);
166
+ const nextSecond = held(alongSecond + moveSecond);
167
+ const nextGap = gapBetween(first, second, nextFirst, nextSecond);
168
+ if (nextGap > gap)
169
+ break;
170
+ alongFirst = nextFirst;
171
+ alongSecond = nextSecond;
172
+ gap = nextGap;
173
+ if (gap === 0)
174
+ break;
175
+ }
176
+ return { alongFirst, alongSecond, point: pointAt(first, alongFirst) };
177
+ }
178
+ /**
179
+ * A run of hits reported as the places they gather at.
180
+ *
181
+ * Every hit carries the stretch of each curve it was found in, and two hits
182
+ * whose stretches touch on both curves are the same meeting: the stretch
183
+ * between them was never thrown away, so the curves stayed within the tolerance
184
+ * across it. A gap in the run is the curves moving apart by more than the
185
+ * tolerance, which is two meetings rather than one.
186
+ */
187
+ function clustered(hits) {
188
+ const inOrder = [...hits].sort((a, b) => a.firstLow - b.firstLow);
189
+ const groups = [];
190
+ for (const hit of inOrder) {
191
+ const joined = groups.find((group) => hit.firstLow <= group.firstHigh &&
192
+ hit.firstHigh >= group.firstLow &&
193
+ hit.secondLow <= group.secondHigh &&
194
+ hit.secondHigh >= group.secondLow);
195
+ if (joined) {
196
+ joined.firstHigh = Math.max(joined.firstHigh, hit.firstHigh);
197
+ joined.firstLow = Math.min(joined.firstLow, hit.firstLow);
198
+ joined.secondHigh = Math.max(joined.secondHigh, hit.secondHigh);
199
+ joined.secondLow = Math.min(joined.secondLow, hit.secondLow);
200
+ joined.sumFirst += (hit.firstLow + hit.firstHigh) / 2;
201
+ joined.sumSecond += (hit.secondLow + hit.secondHigh) / 2;
202
+ joined.sum = vec2.add(joined.sum, hit.point);
203
+ joined.count += 1;
204
+ continue;
205
+ }
206
+ groups.push({
207
+ firstLow: hit.firstLow,
208
+ firstHigh: hit.firstHigh,
209
+ secondLow: hit.secondLow,
210
+ secondHigh: hit.secondHigh,
211
+ sumFirst: (hit.firstLow + hit.firstHigh) / 2,
212
+ sumSecond: (hit.secondLow + hit.secondHigh) / 2,
213
+ sum: hit.point,
214
+ count: 1,
215
+ });
216
+ }
217
+ return groups.map((group) => ({
218
+ alongFirst: group.sumFirst / group.count,
219
+ alongSecond: group.sumSecond / group.count,
220
+ point: vec2.scale(group.sum, 1 / group.count),
221
+ }));
222
+ }
223
+ /** How many places the coarse sweep looks at before it decides which part of a
224
+ * curve a point is nearest. */
225
+ const SWEEP = 32;
226
+ /** How hard the curve is turning at a place, which Newton needs because the
227
+ * nearest point moves as the curve bends away from it. */
228
+ function bendAt(hull, along) {
229
+ const u = 1 - along;
230
+ return vec2(6 * (u * (hull[2].x - 2 * hull[1].x + hull[0].x) + along * (hull[3].x - 2 * hull[2].x + hull[1].x)), 6 * (u * (hull[2].y - 2 * hull[1].y + hull[0].y) + along * (hull[3].y - 2 * hull[2].y + hull[1].y)));
231
+ }
232
+ /**
233
+ * Where on a curve a point sits nearest, as a fraction along it, and how far
234
+ * away it is there.
235
+ *
236
+ * A coarse sweep picks which part of the curve to believe, and Newton's method
237
+ * on the distance finishes it, since the nearest point on a cubic is a fifth
238
+ * degree root and solving one is more than this needs. The sweep gives up before
239
+ * Newton when its best is further off than the tolerance plus the most one step
240
+ * of the sweep can be hiding, since no refining brings it under from there.
241
+ */
242
+ function nearestPlace(hull, point, tolerance) {
243
+ let along = 0;
244
+ let gap = Infinity;
245
+ for (let step = 0; step <= SWEEP; step++) {
246
+ const at = step / SWEEP;
247
+ const away = vec2.distance(pointAt(hull, at), point);
248
+ if (away < gap) {
249
+ gap = away;
250
+ along = at;
251
+ }
252
+ }
253
+ const reach = spread(boxOf(hull)) * 2;
254
+ if (gap > tolerance + reach / SWEEP)
255
+ return { along, gap };
256
+ for (let step = 0; step < 12; step++) {
257
+ const away = vec2.sub(pointAt(hull, along), point);
258
+ const heading = slopeAt(hull, along);
259
+ const turning = vec2.dot(heading, heading) + vec2.dot(away, bendAt(hull, along));
260
+ if (Math.abs(turning) < PARALLEL)
261
+ break;
262
+ const next = held(along - vec2.dot(away, heading) / turning);
263
+ const closer = vec2.distance(pointAt(hull, next), point);
264
+ if (!(closer < gap))
265
+ break;
266
+ along = next;
267
+ gap = closer;
268
+ }
269
+ return { along, gap };
270
+ }
271
+ /** How many places along a shared stretch are checked to still be on the other
272
+ * curve before the stretch is believed. */
273
+ const ALONG_SHARED = 12;
274
+ /**
275
+ * The two ends of the stretch two curves cover together, when they cover one.
276
+ *
277
+ * Halving into a stretch like that answers it as a spray of meetings, because
278
+ * every pair of small pieces along it overlaps and the budget runs out before
279
+ * the run is walked. So the stretch is found first, from the ends of each curve
280
+ * that lie on the other, and answered by where it starts and where it ends.
281
+ *
282
+ * Two curves meeting at a single point are not a stretch and are left to the
283
+ * halving, which places one meeting more sharply than a sweep can.
284
+ */
285
+ function sharedStretch(first, second, tolerance) {
286
+ const ends = [];
287
+ for (const along of [0, 1]) {
288
+ const place = nearestPlace(second, pointAt(first, along), tolerance);
289
+ if (place.gap <= tolerance)
290
+ ends.push({ first: along, second: place.along });
291
+ }
292
+ for (const along of [0, 1]) {
293
+ const place = nearestPlace(first, pointAt(second, along), tolerance);
294
+ if (place.gap <= tolerance)
295
+ ends.push({ first: place.along, second: along });
296
+ }
297
+ if (ends.length < 2)
298
+ return null;
299
+ let low = ends[0];
300
+ let high = ends[0];
301
+ for (const end of ends) {
302
+ if (end.first < low.first)
303
+ low = end;
304
+ if (end.first > high.first)
305
+ high = end;
306
+ }
307
+ const from = pointAt(first, low.first);
308
+ const to = pointAt(first, high.first);
309
+ if (vec2.distance(from, to) <= tolerance)
310
+ return null;
311
+ for (let step = 1; step < ALONG_SHARED; step++) {
312
+ const along = low.first + ((high.first - low.first) * step) / ALONG_SHARED;
313
+ if (nearestPlace(second, pointAt(first, along), tolerance).gap > tolerance)
314
+ return null;
315
+ }
316
+ return [
317
+ { alongFirst: low.first, alongSecond: low.second, point: from },
318
+ { alongFirst: high.first, alongSecond: high.second, point: to },
319
+ ];
320
+ }
321
+ /**
322
+ * Every place two cubics cross, as a fraction along each and the point.
323
+ *
324
+ * Each segment is given the point it starts from, since a segment carries where
325
+ * it ends and not where it began. Two curves covering the same stretch answer
326
+ * with the two ends of that stretch, so a caller reading the answer as places to
327
+ * cut at gets the stretch marked off rather than chopped into slivers.
328
+ */
329
+ export function curveCrossings(fromFirst, first, fromSecond, second, options = {}) {
330
+ const tolerance = options.tolerance ?? TOLERANCE;
331
+ const firstHull = hullOf(fromFirst, first);
332
+ const secondHull = hullOf(fromSecond, second);
333
+ if (apart(boxOf(firstHull), boxOf(secondHull), tolerance))
334
+ return [];
335
+ const shared = sharedStretch(firstHull, secondHull, tolerance);
336
+ if (shared)
337
+ return shared;
338
+ const stack = [
339
+ {
340
+ first: firstHull,
341
+ firstLow: 0,
342
+ firstHigh: 1,
343
+ second: secondHull,
344
+ secondLow: 0,
345
+ secondHigh: 1,
346
+ depth: 0,
347
+ },
348
+ ];
349
+ const hits = [];
350
+ let made = 1;
351
+ const record = (pair) => {
352
+ const alongFirst = (pair.firstLow + pair.firstHigh) / 2;
353
+ const alongSecond = (pair.secondLow + pair.secondHigh) / 2;
354
+ hits.push({
355
+ alongFirst,
356
+ alongSecond,
357
+ point: pointAt(firstHull, alongFirst),
358
+ firstLow: pair.firstLow,
359
+ firstHigh: pair.firstHigh,
360
+ secondLow: pair.secondLow,
361
+ secondHigh: pair.secondHigh,
362
+ });
363
+ };
364
+ while (stack.length > 0) {
365
+ const pair = stack.pop();
366
+ const firstBox = boxOf(pair.first);
367
+ const secondBox = boxOf(pair.second);
368
+ if (apart(firstBox, secondBox, tolerance))
369
+ continue;
370
+ if (made >= BUDGET) {
371
+ record(pair);
372
+ break;
373
+ }
374
+ const cutFirst = spread(firstBox) > tolerance;
375
+ const cutSecond = spread(secondBox) > tolerance;
376
+ if (!cutFirst && !cutSecond) {
377
+ if (chordGap(pair.first, pair.second) <= tolerance)
378
+ record(pair);
379
+ continue;
380
+ }
381
+ if (pair.depth >= DEPTH) {
382
+ record(pair);
383
+ continue;
384
+ }
385
+ const firstMid = (pair.firstLow + pair.firstHigh) / 2;
386
+ const secondMid = (pair.secondLow + pair.secondHigh) / 2;
387
+ const firstParts = cutFirst
388
+ ? halves(pair.first).map((hull, side) => ({
389
+ hull,
390
+ low: side === 0 ? pair.firstLow : firstMid,
391
+ high: side === 0 ? firstMid : pair.firstHigh,
392
+ }))
393
+ : [{ hull: pair.first, low: pair.firstLow, high: pair.firstHigh }];
394
+ const secondParts = cutSecond
395
+ ? halves(pair.second).map((hull, side) => ({
396
+ hull,
397
+ low: side === 0 ? pair.secondLow : secondMid,
398
+ high: side === 0 ? secondMid : pair.secondHigh,
399
+ }))
400
+ : [{ hull: pair.second, low: pair.secondLow, high: pair.secondHigh }];
401
+ made += firstParts.length * secondParts.length;
402
+ for (const left of firstParts) {
403
+ for (const right of secondParts) {
404
+ stack.push({
405
+ first: left.hull,
406
+ firstLow: left.low,
407
+ firstHigh: left.high,
408
+ second: right.hull,
409
+ secondLow: right.low,
410
+ secondHigh: right.high,
411
+ depth: pair.depth + 1,
412
+ });
413
+ }
414
+ }
415
+ }
416
+ return clustered(hits).map((crossing) => sharpened(firstHull, secondHull, crossing));
417
+ }
@@ -7,21 +7,7 @@
7
7
  * line has to become an arc, because a line is an arc whose controls sit on it.
8
8
  */
9
9
  import { vec2 } from '../values/vec2.js';
10
- import { pointOn } from './path.js';
11
- /** One segment cut into two at a fraction, both pieces kept, which is how a
12
- * subpath gains a point without changing shape. */
13
- function halves(from, curve, along) {
14
- const a = vec2.lerp(from, curve.control1, along);
15
- const b = vec2.lerp(curve.control1, curve.control2, along);
16
- const c = vec2.lerp(curve.control2, curve.to, along);
17
- const d = vec2.lerp(a, b, along);
18
- const e = vec2.lerp(b, c, along);
19
- const middle = vec2.lerp(d, e, along);
20
- return [
21
- { control1: a, control2: d, to: middle },
22
- { control1: e, control2: c, to: curve.to },
23
- ];
24
- }
10
+ import { pointOn, splitCurve } from './path.js';
25
11
  /**
26
12
  * A subpath rewritten to hold exactly this many segments, drawing the same shape.
27
13
  *
@@ -49,7 +35,7 @@ function withCurves(subpath, wanted) {
49
35
  let start = subpath.start;
50
36
  for (let at = 0; at < cutAt; at++)
51
37
  start = curves[at].to;
52
- const [first, second] = halves(start, curves[cutAt], 0.5);
38
+ const [first, second] = splitCurve(start, curves[cutAt], 0.5);
53
39
  curves = [...curves.slice(0, cutAt), first, second, ...curves.slice(cutAt + 1)];
54
40
  }
55
41
  return { start: subpath.start, curves, closed: subpath.closed };
@@ -48,6 +48,17 @@ export declare function arc(centre: Vec2, radius: number, fromAngle: number, toA
48
48
  /** A point on a cubic, with the segment's own start passed in because a segment
49
49
  * carries where it ends and not where it began. */
50
50
  export declare function pointOn(from: Vec2, curve: Cubic, along: number): Vec2;
51
+ /** Which way a piece is heading at a fraction along it, which is the derivative
52
+ * of a cubic and so a quadratic over the gaps between neighbouring points. */
53
+ export declare function slopeOn(from: Vec2, curve: Cubic, along: number): Vec2;
54
+ /**
55
+ * One piece cut into two at a fraction, both pieces drawing what the whole
56
+ * drew, by de Casteljau's construction.
57
+ *
58
+ * The piece's own start is passed in because a piece carries where it ends and
59
+ * not where it began.
60
+ */
61
+ export declare function splitCurve(from: Vec2, curve: Cubic, along: number): [Cubic, Cubic];
51
62
  /** Every point of a path moved by a transform, which is how a group's transform
52
63
  * reaches the geometry rather than being carried alongside it. */
53
64
  export declare function transformPath(path: Path, m: Mat3): Path;
@@ -121,6 +121,34 @@ export function pointOn(from, curve, along) {
121
121
  const d = along * along * along;
122
122
  return vec2(a * from.x + b * curve.control1.x + c * curve.control2.x + d * curve.to.x, a * from.y + b * curve.control1.y + c * curve.control2.y + d * curve.to.y);
123
123
  }
124
+ /** Which way a piece is heading at a fraction along it, which is the derivative
125
+ * of a cubic and so a quadratic over the gaps between neighbouring points. */
126
+ export function slopeOn(from, curve, along) {
127
+ const u = 1 - along;
128
+ const a = 3 * u * u;
129
+ const b = 6 * u * along;
130
+ const c = 3 * along * along;
131
+ return vec2(a * (curve.control1.x - from.x) + b * (curve.control2.x - curve.control1.x) + c * (curve.to.x - curve.control2.x), a * (curve.control1.y - from.y) + b * (curve.control2.y - curve.control1.y) + c * (curve.to.y - curve.control2.y));
132
+ }
133
+ /**
134
+ * One piece cut into two at a fraction, both pieces drawing what the whole
135
+ * drew, by de Casteljau's construction.
136
+ *
137
+ * The piece's own start is passed in because a piece carries where it ends and
138
+ * not where it began.
139
+ */
140
+ export function splitCurve(from, curve, along) {
141
+ const a = vec2.lerp(from, curve.control1, along);
142
+ const b = vec2.lerp(curve.control1, curve.control2, along);
143
+ const c = vec2.lerp(curve.control2, curve.to, along);
144
+ const d = vec2.lerp(a, b, along);
145
+ const e = vec2.lerp(b, c, along);
146
+ const middle = vec2.lerp(d, e, along);
147
+ return [
148
+ { control1: a, control2: d, to: middle },
149
+ { control1: e, control2: c, to: curve.to },
150
+ ];
151
+ }
124
152
  /** Every point of a path moved by a transform, which is how a group's transform
125
153
  * reaches the geometry rather than being carried alongside it. */
126
154
  export function transformPath(path, m) {
package/dist/index.d.ts CHANGED
@@ -20,9 +20,18 @@ export { mat3 } from './values/mat3.js';
20
20
  export type { Mat3 } from './values/mat3.js';
21
21
  export { SAME_TIME, keyAt, sampleTrack, sampleTracks, withKey, withoutKey } from './timing/track.js';
22
22
  export type { Key, Track, TrackValue, Tracks } from './timing/track.js';
23
- export { arc, circle, line, polygon, polyline, pointCount, pointOn, rect, straight, transformPath } from './figure/path.js';
23
+ export { arc, circle, line, polygon, polyline, pointCount, pointOn, rect, slopeOn, splitCurve, straight, transformPath } from './figure/path.js';
24
24
  export type { Cubic, Path, Subpath } from './figure/path.js';
25
25
  export { pathFromData } from './figure/path-data.js';
26
+ export { areaOf } from './figure/area.js';
27
+ export { differenceOf, intersectionOf, unionOf } from './figure/boolean.js';
28
+ export type { BooleanOptions } from './figure/boolean.js';
29
+ export { containsPoint, flattenPath, nearestEdge, windingAt } from './figure/inside.js';
30
+ export type { Edge, FlattenOptions } from './figure/inside.js';
31
+ export { cutPath } from './figure/cut.js';
32
+ export type { Cut, CutOptions } from './figure/cut.js';
33
+ export { curveCrossings } from './figure/intersect.js';
34
+ export type { Crossing, CrossingOptions } from './figure/intersect.js';
26
35
  export type { Colour, Fill, Mark, PathMark, Stroke, TextMark } from './figure/mark.js';
27
36
  export { byAspect, fractionOf, matchingAspect, resolveExtent, viewMatrix } from './figure/extent.js';
28
37
  export type { Extent, ExtentChoice, Fit } from './figure/extent.js';
package/dist/index.js CHANGED
@@ -14,8 +14,13 @@ export { vec3 } from './values/vec3.js';
14
14
  export { interval } from './values/interval.js';
15
15
  export { mat3 } from './values/mat3.js';
16
16
  export { SAME_TIME, keyAt, sampleTrack, sampleTracks, withKey, withoutKey } from './timing/track.js';
17
- export { arc, circle, line, polygon, polyline, pointCount, pointOn, rect, straight, transformPath } from './figure/path.js';
17
+ export { arc, circle, line, polygon, polyline, pointCount, pointOn, rect, slopeOn, splitCurve, straight, transformPath } from './figure/path.js';
18
18
  export { pathFromData } from './figure/path-data.js';
19
+ export { areaOf } from './figure/area.js';
20
+ export { differenceOf, intersectionOf, unionOf } from './figure/boolean.js';
21
+ export { containsPoint, flattenPath, nearestEdge, windingAt } from './figure/inside.js';
22
+ export { cutPath } from './figure/cut.js';
23
+ export { curveCrossings } from './figure/intersect.js';
19
24
  export { byAspect, fractionOf, matchingAspect, resolveExtent, viewMatrix } from './figure/extent.js';
20
25
  export { boundsOf, boundsOfMarks, centreOf } from './figure/bounds.js';
21
26
  export { areaUnder, plot, riemannBars, slopeOf, tangentAt } from './figure/plot.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@altpsyche/maths",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "The mathematics AltPsyche's figures are drawn from: vectors, matrices, curves, and a value walked over time.",
5
5
  "license": "MIT",
6
6
  "author": "Siva",