@altpsyche/maths 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -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,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;
@@ -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) {