@altpsyche/maths 0.8.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 +41 -0
- 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 +10 -1
- package/dist/index.js +6 -1
- package/package.json +1 -1
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,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;
|
|
@@ -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,39 @@
|
|
|
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
|
+
/**
|
|
34
|
+
* Whether a path holds a point, under the nonzero rule.
|
|
35
|
+
*
|
|
36
|
+
* A point sitting on the edge itself has no answer this can be right about, and
|
|
37
|
+
* what comes back for one is whichever side the tolerance put it on.
|
|
38
|
+
*/
|
|
39
|
+
export declare function containsPoint(path: Path, point: Vec2, options?: FlattenOptions): boolean;
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
/** How far the two controls sit from the straight run between the ends.
|
|
22
|
+
*
|
|
23
|
+
* The curve itself stays within three quarters of this, so measuring the
|
|
24
|
+
* controls asks for a little more than the tolerance rather than a little
|
|
25
|
+
* less. */
|
|
26
|
+
function offChord(from, curve) {
|
|
27
|
+
const run = vec2.sub(curve.to, from);
|
|
28
|
+
const span = Math.hypot(run.x, run.y);
|
|
29
|
+
if (span === 0) {
|
|
30
|
+
return Math.max(vec2.distance(curve.control1, from), vec2.distance(curve.control2, from));
|
|
31
|
+
}
|
|
32
|
+
const away = (point) => Math.abs(run.x * (point.y - from.y) - run.y * (point.x - from.x)) / span;
|
|
33
|
+
return Math.max(away(curve.control1), away(curve.control2));
|
|
34
|
+
}
|
|
35
|
+
function walk(from, curve, tolerance, depth, into) {
|
|
36
|
+
if (depth >= DEPTH || offChord(from, curve) <= tolerance) {
|
|
37
|
+
into.push(curve.to);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const a = vec2.lerp(from, curve.control1, 0.5);
|
|
41
|
+
const b = vec2.lerp(curve.control1, curve.control2, 0.5);
|
|
42
|
+
const c = vec2.lerp(curve.control2, curve.to, 0.5);
|
|
43
|
+
const d = vec2.lerp(a, b, 0.5);
|
|
44
|
+
const e = vec2.lerp(b, c, 0.5);
|
|
45
|
+
const middle = vec2.lerp(d, e, 0.5);
|
|
46
|
+
walk(from, { control1: a, control2: d, to: middle }, tolerance, depth + 1, into);
|
|
47
|
+
walk(middle, { control1: e, control2: c, to: curve.to }, tolerance, depth + 1, into);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every subpath as a run of points, each loop closed.
|
|
51
|
+
*
|
|
52
|
+
* A subpath that was left open is closed by the straight run back to where it
|
|
53
|
+
* started, since a path with an open loop has no inside until it has one.
|
|
54
|
+
*/
|
|
55
|
+
export function flattenPath(path, options = {}) {
|
|
56
|
+
const tolerance = options.tolerance ?? TOLERANCE;
|
|
57
|
+
const loops = [];
|
|
58
|
+
for (const subpath of path) {
|
|
59
|
+
if (subpath.curves.length === 0)
|
|
60
|
+
continue;
|
|
61
|
+
const points = [subpath.start];
|
|
62
|
+
let from = subpath.start;
|
|
63
|
+
for (const curve of subpath.curves) {
|
|
64
|
+
walk(from, curve, tolerance, 0, points);
|
|
65
|
+
from = curve.to;
|
|
66
|
+
}
|
|
67
|
+
const last = points[points.length - 1];
|
|
68
|
+
if (last.x !== points[0].x || last.y !== points[0].y)
|
|
69
|
+
points.push(points[0]);
|
|
70
|
+
loops.push(points);
|
|
71
|
+
}
|
|
72
|
+
return loops;
|
|
73
|
+
}
|
|
74
|
+
/** Which side of the run from one point to another a third point falls on,
|
|
75
|
+
* positive to the left of it. */
|
|
76
|
+
function leftOf(from, to, point) {
|
|
77
|
+
return (to.x - from.x) * (point.y - from.y) - (point.x - from.x) * (to.y - from.y);
|
|
78
|
+
}
|
|
79
|
+
/** How many times the loops wind round a point, counted along the ray heading
|
|
80
|
+
* in the positive x direction. */
|
|
81
|
+
export function windingAt(loops, point) {
|
|
82
|
+
let winding = 0;
|
|
83
|
+
for (const loop of loops) {
|
|
84
|
+
for (let at = 1; at < loop.length; at++) {
|
|
85
|
+
const from = loop[at - 1];
|
|
86
|
+
const to = loop[at];
|
|
87
|
+
if (from.y <= point.y) {
|
|
88
|
+
if (to.y > point.y && leftOf(from, to, point) > 0)
|
|
89
|
+
winding += 1;
|
|
90
|
+
}
|
|
91
|
+
else if (to.y <= point.y && leftOf(from, to, point) < 0) {
|
|
92
|
+
winding -= 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return winding;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether a path holds a point, under the nonzero rule.
|
|
100
|
+
*
|
|
101
|
+
* A point sitting on the edge itself has no answer this can be right about, and
|
|
102
|
+
* what comes back for one is whichever side the tolerance put it on.
|
|
103
|
+
*/
|
|
104
|
+
export function containsPoint(path, point, options = {}) {
|
|
105
|
+
return windingAt(flattenPath(path, options), point) !== 0;
|
|
106
|
+
}
|
|
@@ -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 that lie on top of each other for
|
|
33
|
+
* a stretch have no one answer, and what comes back for them is decided by the
|
|
34
|
+
* tolerance rather than by the geometry.
|
|
35
|
+
*/
|
|
36
|
+
export declare function curveCrossings(fromFirst: Vec2, first: Cubic, fromSecond: Vec2, second: Cubic, options?: CrossingOptions): Crossing[];
|
|
@@ -0,0 +1,309 @@
|
|
|
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
|
+
/** How many pairs the search may open before it answers with where it had got
|
|
20
|
+
* to, which is what stops two curves lying on top of each other for a stretch
|
|
21
|
+
* from halving forever. */
|
|
22
|
+
const BUDGET = 500_000;
|
|
23
|
+
/** How deep the halving goes, which a tolerance of zero would otherwise leave
|
|
24
|
+
* unbounded. */
|
|
25
|
+
const DEPTH = 60;
|
|
26
|
+
/** Below this the two tangents are parallel, Newton's step divides by nothing,
|
|
27
|
+
* and the answer stays the one the halving gave. */
|
|
28
|
+
const PARALLEL = 1e-12;
|
|
29
|
+
/** How far Newton is allowed to move a cluster before its step is read as a
|
|
30
|
+
* jump to a different crossing and thrown away. */
|
|
31
|
+
const REACH = 1e-3;
|
|
32
|
+
/** A segment written as its four points, since a segment carries where it ends
|
|
33
|
+
* and not where it began. */
|
|
34
|
+
function hullOf(from, curve) {
|
|
35
|
+
return [from, curve.control1, curve.control2, curve.to];
|
|
36
|
+
}
|
|
37
|
+
/** The box round the four points, which holds the curve because a cubic never
|
|
38
|
+
* leaves the hull of the points it is written from. */
|
|
39
|
+
function boxOf(hull) {
|
|
40
|
+
let lowX = hull[0].x;
|
|
41
|
+
let highX = hull[0].x;
|
|
42
|
+
let lowY = hull[0].y;
|
|
43
|
+
let highY = hull[0].y;
|
|
44
|
+
for (let at = 1; at < 4; at++) {
|
|
45
|
+
const point = hull[at];
|
|
46
|
+
if (point.x < lowX)
|
|
47
|
+
lowX = point.x;
|
|
48
|
+
if (point.x > highX)
|
|
49
|
+
highX = point.x;
|
|
50
|
+
if (point.y < lowY)
|
|
51
|
+
lowY = point.y;
|
|
52
|
+
if (point.y > highY)
|
|
53
|
+
highY = point.y;
|
|
54
|
+
}
|
|
55
|
+
return { lowX, highX, lowY, highY };
|
|
56
|
+
}
|
|
57
|
+
function spread(box) {
|
|
58
|
+
return Math.max(box.highX - box.lowX, box.highY - box.lowY);
|
|
59
|
+
}
|
|
60
|
+
/** Whether two boxes miss, with the tolerance added on both sides so two curves
|
|
61
|
+
* touching at exactly one point are not lost to rounding. */
|
|
62
|
+
function apart(a, b, slack) {
|
|
63
|
+
return (a.lowX - slack > b.highX ||
|
|
64
|
+
b.lowX - slack > a.highX ||
|
|
65
|
+
a.lowY - slack > b.highY ||
|
|
66
|
+
b.lowY - slack > a.highY);
|
|
67
|
+
}
|
|
68
|
+
/** How far a point sits from a straight run between two points. */
|
|
69
|
+
function offSegment(point, from, to) {
|
|
70
|
+
const run = vec2.sub(to, from);
|
|
71
|
+
const square = vec2.dot(run, run);
|
|
72
|
+
const along = square === 0 ? 0 : held(vec2.dot(vec2.sub(point, from), run) / square);
|
|
73
|
+
return vec2.distance(point, vec2.add(from, vec2.scale(run, along)));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* How far apart two pieces are, taken between the straight runs across them.
|
|
77
|
+
*
|
|
78
|
+
* A piece no bigger than the tolerance leaves its own chord by a fraction of
|
|
79
|
+
* that, so the chords answer for the curves here, and this is what tells a
|
|
80
|
+
* meeting from two boxes that merely overlap without their curves coming close.
|
|
81
|
+
*/
|
|
82
|
+
function chordGap(first, second) {
|
|
83
|
+
const a = first[0];
|
|
84
|
+
const b = first[3];
|
|
85
|
+
const c = second[0];
|
|
86
|
+
const d = second[3];
|
|
87
|
+
const ab = vec2.sub(b, a);
|
|
88
|
+
const cd = vec2.sub(d, c);
|
|
89
|
+
const under = ab.x * cd.y - ab.y * cd.x;
|
|
90
|
+
if (under !== 0) {
|
|
91
|
+
const ac = vec2.sub(c, a);
|
|
92
|
+
const alongFirst = (ac.x * cd.y - ac.y * cd.x) / under;
|
|
93
|
+
const alongSecond = (ac.x * ab.y - ac.y * ab.x) / under;
|
|
94
|
+
if (alongFirst >= 0 && alongFirst <= 1 && alongSecond >= 0 && alongSecond <= 1)
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
return Math.min(offSegment(a, c, d), offSegment(b, c, d), offSegment(c, a, b), offSegment(d, a, b));
|
|
98
|
+
}
|
|
99
|
+
/** A segment cut in half, both pieces drawing what the whole drew. */
|
|
100
|
+
function halves(hull) {
|
|
101
|
+
const a = vec2.lerp(hull[0], hull[1], 0.5);
|
|
102
|
+
const b = vec2.lerp(hull[1], hull[2], 0.5);
|
|
103
|
+
const c = vec2.lerp(hull[2], hull[3], 0.5);
|
|
104
|
+
const d = vec2.lerp(a, b, 0.5);
|
|
105
|
+
const e = vec2.lerp(b, c, 0.5);
|
|
106
|
+
const middle = vec2.lerp(d, e, 0.5);
|
|
107
|
+
return [
|
|
108
|
+
[hull[0], a, d, middle],
|
|
109
|
+
[middle, e, c, hull[3]],
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
function pointAt(hull, along) {
|
|
113
|
+
const u = 1 - along;
|
|
114
|
+
const a = u * u * u;
|
|
115
|
+
const b = 3 * u * u * along;
|
|
116
|
+
const c = 3 * u * along * along;
|
|
117
|
+
const d = along * along * along;
|
|
118
|
+
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);
|
|
119
|
+
}
|
|
120
|
+
/** Which way the curve is heading, which is the derivative of a cubic and so a
|
|
121
|
+
* quadratic over the differences between neighbouring points. */
|
|
122
|
+
function slopeAt(hull, along) {
|
|
123
|
+
const u = 1 - along;
|
|
124
|
+
const a = 3 * u * u;
|
|
125
|
+
const b = 6 * u * along;
|
|
126
|
+
const c = 3 * along * along;
|
|
127
|
+
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));
|
|
128
|
+
}
|
|
129
|
+
/** A fraction pulled back onto the segment, so a crossing at an end lands on
|
|
130
|
+
* the end exactly rather than a hair outside it. */
|
|
131
|
+
function held(along) {
|
|
132
|
+
return along < 0 ? 0 : along > 1 ? 1 : along;
|
|
133
|
+
}
|
|
134
|
+
function gapBetween(first, second, alongFirst, alongSecond) {
|
|
135
|
+
return vec2.distance(pointAt(first, alongFirst), pointAt(second, alongSecond));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* One cluster moved onto the crossing itself, by Newton's method on the pair of
|
|
139
|
+
* curves: the step that takes the difference between the two points to zero is
|
|
140
|
+
* the two tangents solved as a two by two system.
|
|
141
|
+
*
|
|
142
|
+
* A tangency has no such step, since the tangents are parallel there and the
|
|
143
|
+
* system has no answer, so the halving's own reading is what stands.
|
|
144
|
+
*/
|
|
145
|
+
function sharpened(first, second, start) {
|
|
146
|
+
let alongFirst = start.alongFirst;
|
|
147
|
+
let alongSecond = start.alongSecond;
|
|
148
|
+
let gap = gapBetween(first, second, alongFirst, alongSecond);
|
|
149
|
+
for (let step = 0; step < 12; step++) {
|
|
150
|
+
const apartBy = vec2.sub(pointAt(first, alongFirst), pointAt(second, alongSecond));
|
|
151
|
+
const heading = slopeAt(first, alongFirst);
|
|
152
|
+
const other = slopeAt(second, alongSecond);
|
|
153
|
+
const under = other.x * heading.y - heading.x * other.y;
|
|
154
|
+
if (Math.abs(under) < PARALLEL)
|
|
155
|
+
break;
|
|
156
|
+
const moveFirst = (apartBy.x * other.y - other.x * apartBy.y) / under;
|
|
157
|
+
const moveSecond = (apartBy.x * heading.y - heading.x * apartBy.y) / under;
|
|
158
|
+
if (Math.abs(moveFirst) > REACH || Math.abs(moveSecond) > REACH)
|
|
159
|
+
break;
|
|
160
|
+
const nextFirst = held(alongFirst + moveFirst);
|
|
161
|
+
const nextSecond = held(alongSecond + moveSecond);
|
|
162
|
+
const nextGap = gapBetween(first, second, nextFirst, nextSecond);
|
|
163
|
+
if (nextGap > gap)
|
|
164
|
+
break;
|
|
165
|
+
alongFirst = nextFirst;
|
|
166
|
+
alongSecond = nextSecond;
|
|
167
|
+
gap = nextGap;
|
|
168
|
+
if (gap === 0)
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
return { alongFirst, alongSecond, point: pointAt(first, alongFirst) };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A run of hits reported as the places they gather at.
|
|
175
|
+
*
|
|
176
|
+
* Every hit carries the stretch of each curve it was found in, and two hits
|
|
177
|
+
* whose stretches touch on both curves are the same meeting: the stretch
|
|
178
|
+
* between them was never thrown away, so the curves stayed within the tolerance
|
|
179
|
+
* across it. A gap in the run is the curves moving apart by more than the
|
|
180
|
+
* tolerance, which is two meetings rather than one.
|
|
181
|
+
*/
|
|
182
|
+
function clustered(hits) {
|
|
183
|
+
const inOrder = [...hits].sort((a, b) => a.firstLow - b.firstLow);
|
|
184
|
+
const groups = [];
|
|
185
|
+
for (const hit of inOrder) {
|
|
186
|
+
const joined = groups.find((group) => hit.firstLow <= group.firstHigh &&
|
|
187
|
+
hit.firstHigh >= group.firstLow &&
|
|
188
|
+
hit.secondLow <= group.secondHigh &&
|
|
189
|
+
hit.secondHigh >= group.secondLow);
|
|
190
|
+
if (joined) {
|
|
191
|
+
joined.firstHigh = Math.max(joined.firstHigh, hit.firstHigh);
|
|
192
|
+
joined.firstLow = Math.min(joined.firstLow, hit.firstLow);
|
|
193
|
+
joined.secondHigh = Math.max(joined.secondHigh, hit.secondHigh);
|
|
194
|
+
joined.secondLow = Math.min(joined.secondLow, hit.secondLow);
|
|
195
|
+
joined.sumFirst += (hit.firstLow + hit.firstHigh) / 2;
|
|
196
|
+
joined.sumSecond += (hit.secondLow + hit.secondHigh) / 2;
|
|
197
|
+
joined.sum = vec2.add(joined.sum, hit.point);
|
|
198
|
+
joined.count += 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
groups.push({
|
|
202
|
+
firstLow: hit.firstLow,
|
|
203
|
+
firstHigh: hit.firstHigh,
|
|
204
|
+
secondLow: hit.secondLow,
|
|
205
|
+
secondHigh: hit.secondHigh,
|
|
206
|
+
sumFirst: (hit.firstLow + hit.firstHigh) / 2,
|
|
207
|
+
sumSecond: (hit.secondLow + hit.secondHigh) / 2,
|
|
208
|
+
sum: hit.point,
|
|
209
|
+
count: 1,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return groups.map((group) => ({
|
|
213
|
+
alongFirst: group.sumFirst / group.count,
|
|
214
|
+
alongSecond: group.sumSecond / group.count,
|
|
215
|
+
point: vec2.scale(group.sum, 1 / group.count),
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Every place two cubics cross, as a fraction along each and the point.
|
|
220
|
+
*
|
|
221
|
+
* Each segment is given the point it starts from, since a segment carries where
|
|
222
|
+
* it ends and not where it began. Two curves that lie on top of each other for
|
|
223
|
+
* a stretch have no one answer, and what comes back for them is decided by the
|
|
224
|
+
* tolerance rather than by the geometry.
|
|
225
|
+
*/
|
|
226
|
+
export function curveCrossings(fromFirst, first, fromSecond, second, options = {}) {
|
|
227
|
+
const tolerance = options.tolerance ?? TOLERANCE;
|
|
228
|
+
const firstHull = hullOf(fromFirst, first);
|
|
229
|
+
const secondHull = hullOf(fromSecond, second);
|
|
230
|
+
const stack = [
|
|
231
|
+
{
|
|
232
|
+
first: firstHull,
|
|
233
|
+
firstLow: 0,
|
|
234
|
+
firstHigh: 1,
|
|
235
|
+
second: secondHull,
|
|
236
|
+
secondLow: 0,
|
|
237
|
+
secondHigh: 1,
|
|
238
|
+
depth: 0,
|
|
239
|
+
},
|
|
240
|
+
];
|
|
241
|
+
const hits = [];
|
|
242
|
+
let opened = 0;
|
|
243
|
+
const record = (pair) => {
|
|
244
|
+
const alongFirst = (pair.firstLow + pair.firstHigh) / 2;
|
|
245
|
+
const alongSecond = (pair.secondLow + pair.secondHigh) / 2;
|
|
246
|
+
hits.push({
|
|
247
|
+
alongFirst,
|
|
248
|
+
alongSecond,
|
|
249
|
+
point: pointAt(firstHull, alongFirst),
|
|
250
|
+
firstLow: pair.firstLow,
|
|
251
|
+
firstHigh: pair.firstHigh,
|
|
252
|
+
secondLow: pair.secondLow,
|
|
253
|
+
secondHigh: pair.secondHigh,
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
while (stack.length > 0) {
|
|
257
|
+
const pair = stack.pop();
|
|
258
|
+
opened += 1;
|
|
259
|
+
const firstBox = boxOf(pair.first);
|
|
260
|
+
const secondBox = boxOf(pair.second);
|
|
261
|
+
if (apart(firstBox, secondBox, tolerance))
|
|
262
|
+
continue;
|
|
263
|
+
if (opened >= BUDGET) {
|
|
264
|
+
record(pair);
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
const cutFirst = spread(firstBox) > tolerance;
|
|
268
|
+
const cutSecond = spread(secondBox) > tolerance;
|
|
269
|
+
if (!cutFirst && !cutSecond) {
|
|
270
|
+
if (chordGap(pair.first, pair.second) <= tolerance)
|
|
271
|
+
record(pair);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (pair.depth >= DEPTH) {
|
|
275
|
+
record(pair);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const firstMid = (pair.firstLow + pair.firstHigh) / 2;
|
|
279
|
+
const secondMid = (pair.secondLow + pair.secondHigh) / 2;
|
|
280
|
+
const firstParts = cutFirst
|
|
281
|
+
? halves(pair.first).map((hull, side) => ({
|
|
282
|
+
hull,
|
|
283
|
+
low: side === 0 ? pair.firstLow : firstMid,
|
|
284
|
+
high: side === 0 ? firstMid : pair.firstHigh,
|
|
285
|
+
}))
|
|
286
|
+
: [{ hull: pair.first, low: pair.firstLow, high: pair.firstHigh }];
|
|
287
|
+
const secondParts = cutSecond
|
|
288
|
+
? halves(pair.second).map((hull, side) => ({
|
|
289
|
+
hull,
|
|
290
|
+
low: side === 0 ? pair.secondLow : secondMid,
|
|
291
|
+
high: side === 0 ? secondMid : pair.secondHigh,
|
|
292
|
+
}))
|
|
293
|
+
: [{ hull: pair.second, low: pair.secondLow, high: pair.secondHigh }];
|
|
294
|
+
for (const left of firstParts) {
|
|
295
|
+
for (const right of secondParts) {
|
|
296
|
+
stack.push({
|
|
297
|
+
first: left.hull,
|
|
298
|
+
firstLow: left.low,
|
|
299
|
+
firstHigh: left.high,
|
|
300
|
+
second: right.hull,
|
|
301
|
+
secondLow: right.low,
|
|
302
|
+
secondHigh: right.high,
|
|
303
|
+
depth: pair.depth + 1,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return clustered(hits).map((crossing) => sharpened(firstHull, secondHull, crossing));
|
|
309
|
+
}
|
package/dist/figure/morph.js
CHANGED
|
@@ -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] =
|
|
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 };
|
package/dist/figure/path.d.ts
CHANGED
|
@@ -48,6 +48,14 @@ 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
|
+
/**
|
|
52
|
+
* One piece cut into two at a fraction, both pieces drawing what the whole
|
|
53
|
+
* drew, by de Casteljau's construction.
|
|
54
|
+
*
|
|
55
|
+
* The piece's own start is passed in because a piece carries where it ends and
|
|
56
|
+
* not where it began.
|
|
57
|
+
*/
|
|
58
|
+
export declare function splitCurve(from: Vec2, curve: Cubic, along: number): [Cubic, Cubic];
|
|
51
59
|
/** Every point of a path moved by a transform, which is how a group's transform
|
|
52
60
|
* reaches the geometry rather than being carried alongside it. */
|
|
53
61
|
export declare function transformPath(path: Path, m: Mat3): Path;
|
package/dist/figure/path.js
CHANGED
|
@@ -121,6 +121,25 @@ 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
|
+
/**
|
|
125
|
+
* One piece cut into two at a fraction, both pieces drawing what the whole
|
|
126
|
+
* drew, by de Casteljau's construction.
|
|
127
|
+
*
|
|
128
|
+
* The piece's own start is passed in because a piece carries where it ends and
|
|
129
|
+
* not where it began.
|
|
130
|
+
*/
|
|
131
|
+
export function splitCurve(from, curve, along) {
|
|
132
|
+
const a = vec2.lerp(from, curve.control1, along);
|
|
133
|
+
const b = vec2.lerp(curve.control1, curve.control2, along);
|
|
134
|
+
const c = vec2.lerp(curve.control2, curve.to, along);
|
|
135
|
+
const d = vec2.lerp(a, b, along);
|
|
136
|
+
const e = vec2.lerp(b, c, along);
|
|
137
|
+
const middle = vec2.lerp(d, e, along);
|
|
138
|
+
return [
|
|
139
|
+
{ control1: a, control2: d, to: middle },
|
|
140
|
+
{ control1: e, control2: c, to: curve.to },
|
|
141
|
+
];
|
|
142
|
+
}
|
|
124
143
|
/** Every point of a path moved by a transform, which is how a group's transform
|
|
125
144
|
* reaches the geometry rather than being carried alongside it. */
|
|
126
145
|
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, 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, windingAt } from './figure/inside.js';
|
|
30
|
+
export type { 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, 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, 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