@js-draw/math 1.17.0 → 1.18.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. package/dist/cjs/Mat33.js +6 -1
  2. package/dist/cjs/Vec3.d.ts +2 -1
  3. package/dist/cjs/Vec3.js +5 -7
  4. package/dist/cjs/lib.d.ts +2 -1
  5. package/dist/cjs/lib.js +5 -1
  6. package/dist/cjs/shapes/BezierJSWrapper.d.ts +4 -0
  7. package/dist/cjs/shapes/BezierJSWrapper.js +35 -0
  8. package/dist/cjs/shapes/LineSegment2.d.ts +11 -0
  9. package/dist/cjs/shapes/LineSegment2.js +26 -1
  10. package/dist/cjs/shapes/Parameterized2DShape.d.ts +6 -1
  11. package/dist/cjs/shapes/Parameterized2DShape.js +6 -1
  12. package/dist/cjs/shapes/Path.d.ts +96 -12
  13. package/dist/cjs/shapes/Path.js +338 -15
  14. package/dist/cjs/shapes/QuadraticBezier.d.ts +2 -3
  15. package/dist/cjs/shapes/QuadraticBezier.js +2 -3
  16. package/dist/cjs/shapes/Rect2.d.ts +6 -1
  17. package/dist/cjs/shapes/Rect2.js +5 -1
  18. package/dist/cjs/utils/convexHull2Of.d.ts +9 -0
  19. package/dist/cjs/utils/convexHull2Of.js +61 -0
  20. package/dist/cjs/utils/convexHull2Of.test.d.ts +1 -0
  21. package/dist/mjs/Mat33.mjs +6 -1
  22. package/dist/mjs/Vec3.d.ts +2 -1
  23. package/dist/mjs/Vec3.mjs +5 -7
  24. package/dist/mjs/lib.d.ts +2 -1
  25. package/dist/mjs/lib.mjs +2 -1
  26. package/dist/mjs/shapes/BezierJSWrapper.d.ts +4 -0
  27. package/dist/mjs/shapes/BezierJSWrapper.mjs +35 -0
  28. package/dist/mjs/shapes/LineSegment2.d.ts +11 -0
  29. package/dist/mjs/shapes/LineSegment2.mjs +26 -1
  30. package/dist/mjs/shapes/Parameterized2DShape.d.ts +6 -1
  31. package/dist/mjs/shapes/Parameterized2DShape.mjs +6 -1
  32. package/dist/mjs/shapes/Path.d.ts +96 -12
  33. package/dist/mjs/shapes/Path.mjs +335 -14
  34. package/dist/mjs/shapes/QuadraticBezier.d.ts +2 -3
  35. package/dist/mjs/shapes/QuadraticBezier.mjs +2 -3
  36. package/dist/mjs/shapes/Rect2.d.ts +6 -1
  37. package/dist/mjs/shapes/Rect2.mjs +5 -1
  38. package/dist/mjs/utils/convexHull2Of.d.ts +9 -0
  39. package/dist/mjs/utils/convexHull2Of.mjs +59 -0
  40. package/dist/mjs/utils/convexHull2Of.test.d.ts +1 -0
  41. package/package.json +2 -2
  42. package/src/Mat33.ts +8 -2
  43. package/src/Vec3.test.ts +16 -0
  44. package/src/Vec3.ts +7 -8
  45. package/src/lib.ts +3 -0
  46. package/src/shapes/BezierJSWrapper.ts +41 -0
  47. package/src/shapes/LineSegment2.test.ts +26 -0
  48. package/src/shapes/LineSegment2.ts +31 -1
  49. package/src/shapes/Parameterized2DShape.ts +6 -1
  50. package/src/shapes/Path.test.ts +173 -5
  51. package/src/shapes/Path.ts +390 -18
  52. package/src/shapes/QuadraticBezier.test.ts +21 -0
  53. package/src/shapes/QuadraticBezier.ts +2 -3
  54. package/src/shapes/Rect2.ts +6 -2
  55. package/src/utils/convexHull2Of.test.ts +43 -0
  56. package/src/utils/convexHull2Of.ts +71 -0
package/src/Vec3.ts CHANGED
@@ -244,7 +244,8 @@ export class Vec3 {
244
244
  * Returns a vector with each component acted on by `fn`.
245
245
  *
246
246
  * @example
247
- * ```
247
+ * ```ts,runnable,console
248
+ * import { Vec3 } from '@js-draw/math';
248
249
  * console.log(Vec3.of(1, 2, 3).map(val => val + 1)); // → Vec(2, 3, 4)
249
250
  * ```
250
251
  */
@@ -272,13 +273,11 @@ export class Vec3 {
272
273
  * ```
273
274
  */
274
275
  public eq(other: Vec3, fuzz: number = 1e-10): boolean {
275
- for (let i = 0; i < 3; i++) {
276
- if (Math.abs(other.at(i) - this.at(i)) > fuzz) {
277
- return false;
278
- }
279
- }
280
-
281
- return true;
276
+ return (
277
+ Math.abs(other.x - this.x) <= fuzz
278
+ && Math.abs(other.y - this.y) <= fuzz
279
+ && Math.abs(other.z - this.z) <= fuzz
280
+ );
282
281
  }
283
282
 
284
283
  public toString(): string {
package/src/lib.ts CHANGED
@@ -23,6 +23,8 @@ export {
23
23
 
24
24
  IntersectionResult as PathIntersectionResult,
25
25
  CurveIndexRecord as PathCurveIndex,
26
+ stepCurveIndexBy as stepPathIndexBy,
27
+ compareCurveIndices as comparePathIndices,
26
28
  PathCommandType,
27
29
  PathCommand,
28
30
  LinePathCommand,
@@ -31,6 +33,7 @@ export {
31
33
  CubicBezierPathCommand,
32
34
  } from './shapes/Path';
33
35
  export { Rect2 } from './shapes/Rect2';
36
+ export { Parameterized2DShape } from './shapes/Parameterized2DShape';
34
37
  export { QuadraticBezier } from './shapes/QuadraticBezier';
35
38
  export { Abstract2DShape } from './shapes/Abstract2DShape';
36
39
 
@@ -87,6 +87,18 @@ export abstract class BezierJSWrapper extends Parameterized2DShape {
87
87
  }
88
88
 
89
89
  public override argIntersectsLineSegment(line: LineSegment2): number[] {
90
+ // Bezier-js has a bug when all control points of a Bezier curve lie on
91
+ // a line. Our solution involves converting the Bezier into a line, then
92
+ // finding the parameter value that produced the intersection.
93
+ //
94
+ // TODO: This is unnecessarily slow. A better solution would be to fix
95
+ // the bug upstream.
96
+ const asLine = LineSegment2.ofSmallestContainingPoints(this.getPoints());
97
+ if (asLine) {
98
+ const intersection = asLine.intersectsLineSegment(line);
99
+ return intersection.map(p => this.nearestPointTo(p).parameterValue);
100
+ }
101
+
90
102
  const bezier = this.getBezier();
91
103
 
92
104
  return bezier.intersects(line).map(t => {
@@ -186,6 +198,8 @@ export abstract class BezierJSWrapper extends Parameterized2DShape {
186
198
 
187
199
  const iterate = () => {
188
200
  const slope = secondDerivativeAt(t);
201
+ if (slope === 0) return;
202
+
189
203
  // We intersect a line through the point on f'(t) at t with the x-axis:
190
204
  // y = m(x - x₀) + y₀
191
205
  // ⇒ x - x₀ = (y - y₀) / m
@@ -211,6 +225,33 @@ export abstract class BezierJSWrapper extends Parameterized2DShape {
211
225
  return { parameterValue: t, point: this.at(t) };
212
226
  }
213
227
 
228
+ public intersectsBezier(other: BezierJSWrapper) {
229
+ const intersections = this.getBezier().intersects(other.getBezier()) as (string[] | null | undefined);
230
+ if (!intersections || intersections.length === 0) {
231
+ return [];
232
+ }
233
+
234
+ const result = [];
235
+ for (const intersection of intersections) {
236
+ // From http://pomax.github.io/bezierjs/#intersect-curve,
237
+ // .intersects returns an array of 't1/t2' pairs, where curve1.at(t1) gives the point.
238
+ const match = /^([-0-9.eE]+)\/([-0-9.eE]+)$/.exec(intersection);
239
+
240
+ if (!match) {
241
+ throw new Error(
242
+ `Incorrect format returned by .intersects: ${intersections} should be array of "number/number"!`
243
+ );
244
+ }
245
+
246
+ const t = parseFloat(match[1]);
247
+ result.push({
248
+ parameterValue: t,
249
+ point: this.at(t),
250
+ });
251
+ }
252
+ return result;
253
+ }
254
+
214
255
  public override toString() {
215
256
  return `Bézier(${this.getPoints().map(point => point.toString()).join(', ')})`;
216
257
  }
@@ -74,6 +74,14 @@ describe('Line2', () => {
74
74
  expect(line2.intersection(line1)).toBeNull();
75
75
  });
76
76
 
77
+ it('(9.559000000000001, 11.687)->(9.559, 11.67673) should intersect (9.56069, 11.68077)->(9.55719, 11.68077)', () => {
78
+ // Points taken from an issue observed in the editor.
79
+ const l1 = new LineSegment2(Vec2.of(9.559000000000001, 11.687), Vec2.of(9.559, 11.67673));
80
+ const l2 = new LineSegment2(Vec2.of(9.56069, 11.68077), Vec2.of(9.55719, 11.68077));
81
+ expect(l2.intersects(l1)).toBe(true);
82
+ expect(l1.intersects(l2)).toBe(true);
83
+ });
84
+
77
85
  it('Closest point to (0,0) on the line x = 1 should be (1,0)', () => {
78
86
  const line = new LineSegment2(Vec2.of(1, 100), Vec2.of(1, -100));
79
87
  expect(line.closestPointTo(Vec2.zero)).objEq(Vec2.of(1, 0));
@@ -130,4 +138,22 @@ describe('Line2', () => {
130
138
  expect(new LineSegment2(Vec2.zero, Vec2.unitX)).objEq(new LineSegment2(Vec2.unitX, Vec2.zero));
131
139
  expect(new LineSegment2(Vec2.zero, Vec2.unitX)).not.objEq(new LineSegment2(Vec2.unitX, Vec2.zero), { ignoreDirection: false });
132
140
  });
141
+
142
+ it('should support creating from a collection of points', () => {
143
+ expect(LineSegment2.ofSmallestContainingPoints([])).toBeNull();
144
+ expect(LineSegment2.ofSmallestContainingPoints([Vec2.of(1, 1)])).toBeNull();
145
+ expect(LineSegment2.ofSmallestContainingPoints(
146
+ [Vec2.of(1, 1), Vec2.of(1, 2), Vec2.of(3, 3)]
147
+ )).toBeNull();
148
+
149
+ expect(LineSegment2.ofSmallestContainingPoints(
150
+ [Vec2.of(1, 1), Vec2.of(1, 2)]
151
+ )).objEq(new LineSegment2(Vec2.of(1, 1), Vec2.of(1, 2)));
152
+ expect(LineSegment2.ofSmallestContainingPoints(
153
+ [Vec2.of(1, 1), Vec2.of(2, 2), Vec2.of(3, 3)]
154
+ )).objEq(new LineSegment2(Vec2.of(1, 1), Vec2.of(3, 3)));
155
+ expect(LineSegment2.ofSmallestContainingPoints(
156
+ [Vec2.of(3, 3), Vec2.of(2, 2), Vec2.of(2.4, 2.4), Vec2.of(3, 3)]
157
+ )).objEq(new LineSegment2(Vec2.of(2, 2), Vec2.of(3, 3)));
158
+ });
133
159
  });
@@ -46,6 +46,31 @@ export class LineSegment2 extends Parameterized2DShape {
46
46
  }
47
47
  }
48
48
 
49
+ /**
50
+ * Returns the smallest line segment that contains all points in `points`, or `null`
51
+ * if no such line segment exists.
52
+ *
53
+ * @example
54
+ * ```ts,runnable
55
+ * import {LineSegment2, Vec2} from '@js-draw/math';
56
+ * console.log(LineSegment2.ofSmallestContainingPoints([Vec2.of(1, 0), Vec2.of(0, 1)]));
57
+ * ```
58
+ */
59
+ public static ofSmallestContainingPoints(points: readonly Point2[]) {
60
+ if (points.length <= 1) return null;
61
+
62
+ const sorted = [...points].sort((a, b) => a.x !== b.x ? a.x - b.x : a.y - b.y);
63
+ const line = new LineSegment2(sorted[0], sorted[sorted.length - 1]);
64
+
65
+ for (const point of sorted) {
66
+ if (!line.containsPoint(point)) {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ return line;
72
+ }
73
+
49
74
  // Accessors to make LineSegment2 compatible with bezier-js's
50
75
  // interface
51
76
 
@@ -139,7 +164,11 @@ export class LineSegment2 extends Parameterized2DShape {
139
164
  // = ((o₁ᵧ - o₂ᵧ)((d₁ₓd₂ₓ)) + (d₂ᵧd₁ₓ)(o₂ₓ) - (d₁ᵧd₂ₓ)(o₁ₓ))/(d₂ᵧd₁ₓ - d₁ᵧd₂ₓ)
140
165
  // ⇒ y = o₁ᵧ + d₁ᵧ · (x - o₁ₓ) / d₁ₓ = ...
141
166
  let resultPoint, resultT;
142
- if (this.direction.x === 0) {
167
+
168
+ // Consider very-near-vertical lines to be vertical --- not doing so can lead to
169
+ // precision error when dividing by this.direction.x.
170
+ const small = 4e-13;
171
+ if (Math.abs(this.direction.x) < small) {
143
172
  // Vertical line: Where does the other have x = this.point1.x?
144
173
  // x = o₁ₓ = o₂ₓ + d₂ₓ · (y - o₂ᵧ) / d₂ᵧ
145
174
  // ⇒ (o₁ₓ - o₂ₓ)(d₂ᵧ/d₂ₓ) + o₂ᵧ = y
@@ -184,6 +213,7 @@ export class LineSegment2 extends Parameterized2DShape {
184
213
  const resultToP2 = resultPoint.distanceTo(this.point2);
185
214
  const resultToP3 = resultPoint.distanceTo(other.point1);
186
215
  const resultToP4 = resultPoint.distanceTo(other.point2);
216
+
187
217
  if (resultToP1 > this.length
188
218
  || resultToP2 > this.length
189
219
  || resultToP3 > other.length
@@ -2,7 +2,12 @@ import { Point2, Vec2 } from '../Vec2';
2
2
  import Abstract2DShape from './Abstract2DShape';
3
3
  import LineSegment2 from './LineSegment2';
4
4
 
5
- /** A 2-dimensional path with parameter interval $t \in [0, 1]$. */
5
+ /**
6
+ * A 2-dimensional path with parameter interval $t \in [0, 1]$.
7
+ *
8
+ * **Note:** Avoid extending this class outside of `js-draw` --- new abstract methods
9
+ * may be added between minor versions.
10
+ */
6
11
  export abstract class Parameterized2DShape extends Abstract2DShape {
7
12
  /** Returns this at a given parameter. $t \in [0, 1]$ */
8
13
  abstract at(t: number): Point2;
@@ -1,7 +1,7 @@
1
1
  import LineSegment2 from './LineSegment2';
2
- import Path, { PathCommandType } from './Path';
2
+ import Path, { CurveIndexRecord, PathCommandType } from './Path';
3
3
  import Rect2 from './Rect2';
4
- import { Vec2 } from '../Vec2';
4
+ import { Point2, Vec2 } from '../Vec2';
5
5
  import CubicBezier from './CubicBezier';
6
6
  import QuadraticBezier from './QuadraticBezier';
7
7
 
@@ -238,13 +238,22 @@ describe('Path', () => {
238
238
 
239
239
  it.each([
240
240
  [new LineSegment2(Vec2.of(43.5,-12.5), Vec2.of(40.5,24.5)), 0],
241
- // TODO: The below case is failing. It seems to be a Bezier-js bug though...
242
- // (The Bézier.js method returns an empty array).
243
- //[new LineSegment2(Vec2.of(35.5,19.5), Vec2.of(38.5,-17.5)), 0],
241
+ [new LineSegment2(Vec2.of(35.5,19.5), Vec2.of(38.5,-17.5)), 0],
244
242
  ])('should correctly report positive intersections with a line-like Bézier', (line, strokeRadius) => {
245
243
  const bezier = Path.fromString('M0,0 Q50,0 100,0');
246
244
  expect(bezier.intersection(line, strokeRadius).length).toBeGreaterThan(0);
247
245
  });
246
+
247
+ it('should handle near-vertical lines', () => {
248
+ const intersections = Path.fromString('M0,0 Q50,0 100,0').intersection(new LineSegment2(Vec2.of(44, -12), Vec2.of(39, 25)));
249
+ expect(intersections).toHaveLength(1);
250
+ });
251
+
252
+ it('should handle single-point strokes', () => {
253
+ const stroke = new Path(Vec2.zero, []);
254
+ expect(stroke.intersection(new LineSegment2(Vec2.of(-2, -20), Vec2.of(-2, -1)), 1)).toHaveLength(0);
255
+ expect(stroke.intersection(new LineSegment2(Vec2.of(-2, -2), Vec2.of(2, 2)), 1)).toHaveLength(2);
256
+ });
248
257
  });
249
258
 
250
259
  describe('polylineApproximation', () => {
@@ -348,6 +357,146 @@ describe('Path', () => {
348
357
  });
349
358
  });
350
359
 
360
+ describe('splitAt', () => {
361
+ it.each([
362
+ 2, 3, 4, 5,
363
+ ])('should split a line into %d sections', (numSections) => {
364
+ const path = Path.fromString('m0,0 l1,0');
365
+
366
+ const splitIndices: CurveIndexRecord[] = [];
367
+ for (let i = 0; i < numSections; i++) {
368
+ splitIndices.push({ curveIndex: 0, parameterValue: (i + 1) / (numSections + 1) });
369
+ }
370
+ const split = path.splitAt(splitIndices);
371
+
372
+ expect(split).toHaveLength(numSections + 1);
373
+ expect(split[numSections].getEndPoint()).objEq(Vec2.unitX);
374
+ for (let i = 0; i < numSections; i ++) {
375
+ expect(split[i].geometry).toHaveLength(1);
376
+ const geom = split[i].geometry[0] as LineSegment2;
377
+ expect(geom.p1.y).toBeCloseTo(0);
378
+ expect(geom.p1.x).toBeCloseTo(i / (numSections + 1));
379
+ expect(geom.p2.y).toBeCloseTo(0);
380
+ expect(geom.p2.x).toBeCloseTo((i + 1) / (numSections + 1));
381
+ }
382
+ });
383
+
384
+ it('should handle the case where the first division is at the beginning of the path', () => {
385
+ const path = Path.fromString('m0,0 l1,0');
386
+ const beginningSplit = path.splitAt({ curveIndex: 0, parameterValue: 0 });
387
+ expect(beginningSplit).toHaveLength(1);
388
+
389
+ const endSplit = path.splitAt({ curveIndex: 0, parameterValue: 1 });
390
+ expect(endSplit).toHaveLength(1);
391
+
392
+ expect(beginningSplit[0]).objEq(path);
393
+ expect(beginningSplit[0]).objEq(endSplit[0]);
394
+ });
395
+ });
396
+
397
+ describe('splitNear', () => {
398
+ it('should divide a line in half', () => {
399
+ const path = Path.fromString('m0,0l8,0');
400
+ const split = path.splitNear(Vec2.of(4, 0));
401
+ expect(split).toHaveLength(2);
402
+ expect(split[0].toString()).toBe('M0,0L4,0');
403
+ expect(split[1]!.toString()).toBe('M4,0L8,0');
404
+ });
405
+
406
+ it('should divide a polyline into parts', () => {
407
+ const path = Path.fromString('m0,0L8,0L8,8');
408
+ const split = path.splitNear(Vec2.of(8, 4));
409
+ expect(split).toHaveLength(2);
410
+ expect(split[0].toString()).toBe('M0,0L8,0L8,4');
411
+ expect(split[1]!.toString()).toBe('M8,4L8,8');
412
+ });
413
+
414
+ it('should divide a quadratic Bézier in half', () => {
415
+ const path = Path.fromString('m0,0 Q4,0 8,0');
416
+ const split = path.splitNear(Vec2.of(4, 0));
417
+ expect(split).toHaveLength(2);
418
+ expect(split[0].toString()).toBe('M0,0Q2,0 4,0');
419
+ expect(split[1]!.toString()).toBe('M4,0Q6,0 8,0');
420
+ });
421
+
422
+ it('should divide two quadratic Béziers half', () => {
423
+ const path = Path.fromString('m0,0 Q4,0 8,0 Q8,4 8,8');
424
+ const split = path.splitNear(Vec2.of(8, 4));
425
+ expect(split).toHaveLength(2);
426
+ expect(split[0].toString()).toBe('M0,0Q4,0 8,0Q8,2 8,4');
427
+ expect(split[1]!.toString()).toBe('M8,4Q8,6 8,8');
428
+ });
429
+
430
+ it.each([
431
+ {
432
+ original: 'm0,0 Q4,0 8,0 Q8,4 8,8',
433
+ near: Vec2.of(8, 4),
434
+ map: (p: Point2) => p.plus(Vec2.of(1, 1)),
435
+ expected: [ 'M0,0Q4,0 8,0Q9,3 9,5', 'M9,5Q9,7 9,9' ],
436
+ },
437
+ {
438
+ original: 'm0,0 L0,10',
439
+ near: Vec2.of(0, 5),
440
+ map: (p: Point2) => p.plus(Vec2.of(100, 0)),
441
+ expected: [ 'M0,0L100,5', 'M100,5L0,10' ],
442
+ },
443
+ {
444
+ // Tested using SVG data similar to:
445
+ // <path d="m1,1 C1,2 2,10 4,4 C5,0 9,3 7,7" fill="none" stroke="#ff0000"/>
446
+ // <path d="M2,6C3,6 3,6 4,4C5,0 9,3 7,7" fill="none" stroke="#00ff0080"/>
447
+ // Because of the rounding, the fit path should be slightly off.
448
+ original: 'm1,1 C1,2 2,10 4,4 C5,0 9,3 7,7',
449
+ near: Vec2.of(3, 5),
450
+ map: (p: Point2) => Vec2.of(Math.round(p.x), Math.round(p.y)),
451
+ expected: [ 'M1,1C1,2 1,6 2,6', 'M2,6C3,6 3,6 4,4C5,0 9,3 7,7' ],
452
+ },
453
+ ])('should support mapping newly-added points while splitting (case %j)', ({ original, near, map, expected }) => {
454
+ const path = Path.fromString(original);
455
+ const split = path.splitNear(near, { mapNewPoint: map });
456
+ expect(split.map(p => p.toString(false))).toMatchObject(expected);
457
+ });
458
+ });
459
+
460
+ describe('spliced', () => {
461
+ it.each([
462
+ // should support insertion splicing
463
+ {
464
+ curve: 'm0,0 l2,0',
465
+ from: { i: 0, t: 0.5 },
466
+ to: { i: 0, t: 0.5 },
467
+ insert: 'm1,0 l0,10 z',
468
+ expected: 'M0,0 L1,0 L1,10 L1,0 L2,0',
469
+ },
470
+
471
+ // should support removing a segment when splicing
472
+ {
473
+ curve: 'm0,0 l4,0',
474
+ from: { i: 0, t: 0.25 },
475
+ to: { i: 0, t: 0.75 },
476
+ insert: 'M1,0 L1,1 L3,1 L3,0',
477
+ expected: 'M0,0 L1,0 L1,1 L3,1 L3,0 L4,0',
478
+ },
479
+
480
+ // should support reverse splicing and reverse `insert` as necessary
481
+ {
482
+ curve: 'M0,0 l4,0',
483
+ from: { i: 0, t: 0.75 },
484
+ to: { i: 0, t: 0.25 },
485
+ insert: 'M1,0 L1,1 L3,1 L3,0',
486
+ expected: 'M1,0 L3,0 L3,1 L1,1 L1,0',
487
+ },
488
+ ])('.spliced should support inserting paths inbetween other paths (case %#)', ({ curve, from, to, insert, expected }) => {
489
+ const originalCurve = Path.fromString(curve);
490
+ expect(
491
+ originalCurve.spliced(
492
+ { curveIndex: from.i, parameterValue: from.t },
493
+ { curveIndex: to.i, parameterValue: to.t },
494
+ Path.fromString(insert),
495
+ )
496
+ ).objEq(Path.fromString(expected));
497
+ });
498
+ });
499
+
351
500
  it.each([
352
501
  [ 'm0,0 L1,1', 'M1,1 L0,0' ],
353
502
  [ 'm0,0 L1,1', 'M1,1 L0,0' ],
@@ -366,4 +515,23 @@ describe('Path', () => {
366
515
  ])('.nearestPointTo should return the closest point on a path to the given parameter (case %#)', (path, point, expectedClosest) => {
367
516
  expect(Path.fromString(path).nearestPointTo(point).point).objEq(expectedClosest, 0.002);
368
517
  });
518
+
519
+ it.each([
520
+ // Polyline
521
+ [ 'm0,0 l1,0 l0,1', [ 0, 0.5 ], Vec2.of(1, 0) ],
522
+ [ 'm0,0 l1,0 l0,1', [ 0, 0.99 ], Vec2.of(1, 0) ],
523
+ [ 'm0,0 l1,0 l0,1', [ 1, 0 ], Vec2.of(0, 1) ],
524
+ [ 'm0,0 l1,0 l0,1', [ 1, 0.5 ], Vec2.of(0, 1) ],
525
+ [ 'm0,0 l1,0 l0,1', [ 1, 1 ], Vec2.of(0, 1) ],
526
+
527
+ // Shape with quadratic Bézier curves
528
+ [ 'M0,0 Q1,0 0,1', [ 0, 0 ], Vec2.of(1, 0) ],
529
+ [ 'M0,0 Q1,1 0,1', [ 0, 1 ], Vec2.of(-1, 0) ],
530
+ [ 'M0,0 Q1,0 1,1 Q0,1 0,2', [ 0, 1 ], Vec2.of(0, 1) ],
531
+ [ 'M0,0 Q1,0 1,1 Q0,1 0,2', [ 1, 1 ], Vec2.of(0, 1) ],
532
+ ])('.tangentAt should point in the direction of increasing parameter values, for curve %s at %j', (pathString, evalAt, expected) => {
533
+ const at: CurveIndexRecord = { curveIndex: evalAt[0], parameterValue: evalAt[1] };
534
+ const path = Path.fromString(pathString);
535
+ expect(path.tangentAt(at)).objEq(expected);
536
+ });
369
537
  });