@js-draw/math 1.17.0 → 1.18.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.
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
  });