@js-draw/math 1.11.1 → 1.17.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 (96) hide show
  1. package/dist/cjs/Vec3.d.ts +21 -0
  2. package/dist/cjs/Vec3.js +28 -0
  3. package/dist/cjs/lib.d.ts +2 -2
  4. package/dist/cjs/lib.js +16 -3
  5. package/dist/cjs/rounding/cleanUpNumber.d.ts +3 -0
  6. package/dist/cjs/rounding/cleanUpNumber.js +35 -0
  7. package/dist/cjs/rounding/constants.d.ts +1 -0
  8. package/dist/cjs/rounding/constants.js +4 -0
  9. package/dist/cjs/rounding/getLenAfterDecimal.d.ts +10 -0
  10. package/dist/cjs/rounding/getLenAfterDecimal.js +30 -0
  11. package/dist/cjs/rounding/lib.d.ts +1 -0
  12. package/dist/cjs/rounding/lib.js +5 -0
  13. package/dist/cjs/{rounding.d.ts → rounding/toRoundedString.d.ts} +1 -3
  14. package/dist/cjs/rounding/toRoundedString.js +54 -0
  15. package/dist/cjs/rounding/toStringOfSamePrecision.d.ts +2 -0
  16. package/dist/cjs/rounding/toStringOfSamePrecision.js +58 -0
  17. package/dist/cjs/rounding/toStringOfSamePrecision.test.d.ts +1 -0
  18. package/dist/cjs/shapes/Abstract2DShape.d.ts +3 -0
  19. package/dist/cjs/shapes/BezierJSWrapper.d.ts +15 -5
  20. package/dist/cjs/shapes/BezierJSWrapper.js +135 -18
  21. package/dist/cjs/shapes/LineSegment2.d.ts +34 -5
  22. package/dist/cjs/shapes/LineSegment2.js +63 -10
  23. package/dist/cjs/shapes/Parameterized2DShape.d.ts +31 -0
  24. package/dist/cjs/shapes/Parameterized2DShape.js +15 -0
  25. package/dist/cjs/shapes/Path.d.ts +40 -6
  26. package/dist/cjs/shapes/Path.js +181 -22
  27. package/dist/cjs/shapes/PointShape2D.d.ts +14 -3
  28. package/dist/cjs/shapes/PointShape2D.js +28 -5
  29. package/dist/cjs/shapes/QuadraticBezier.d.ts +4 -0
  30. package/dist/cjs/shapes/QuadraticBezier.js +19 -4
  31. package/dist/cjs/shapes/Rect2.d.ts +3 -0
  32. package/dist/cjs/shapes/Rect2.js +4 -1
  33. package/dist/mjs/Vec3.d.ts +21 -0
  34. package/dist/mjs/Vec3.mjs +28 -0
  35. package/dist/mjs/lib.d.ts +2 -2
  36. package/dist/mjs/lib.mjs +1 -1
  37. package/dist/mjs/rounding/cleanUpNumber.d.ts +3 -0
  38. package/dist/mjs/rounding/cleanUpNumber.mjs +31 -0
  39. package/dist/mjs/rounding/cleanUpNumber.test.d.ts +1 -0
  40. package/dist/mjs/rounding/constants.d.ts +1 -0
  41. package/dist/mjs/rounding/constants.mjs +1 -0
  42. package/dist/mjs/rounding/getLenAfterDecimal.d.ts +10 -0
  43. package/dist/mjs/rounding/getLenAfterDecimal.mjs +26 -0
  44. package/dist/mjs/rounding/lib.d.ts +1 -0
  45. package/dist/mjs/rounding/lib.mjs +1 -0
  46. package/dist/mjs/{rounding.d.ts → rounding/toRoundedString.d.ts} +1 -3
  47. package/dist/mjs/rounding/toRoundedString.mjs +47 -0
  48. package/dist/mjs/rounding/toRoundedString.test.d.ts +1 -0
  49. package/dist/mjs/rounding/toStringOfSamePrecision.d.ts +2 -0
  50. package/dist/mjs/rounding/toStringOfSamePrecision.mjs +51 -0
  51. package/dist/mjs/rounding/toStringOfSamePrecision.test.d.ts +1 -0
  52. package/dist/mjs/shapes/Abstract2DShape.d.ts +3 -0
  53. package/dist/mjs/shapes/BezierJSWrapper.d.ts +15 -5
  54. package/dist/mjs/shapes/BezierJSWrapper.mjs +133 -18
  55. package/dist/mjs/shapes/LineSegment2.d.ts +34 -5
  56. package/dist/mjs/shapes/LineSegment2.mjs +63 -10
  57. package/dist/mjs/shapes/Parameterized2DShape.d.ts +31 -0
  58. package/dist/mjs/shapes/Parameterized2DShape.mjs +8 -0
  59. package/dist/mjs/shapes/Path.d.ts +40 -6
  60. package/dist/mjs/shapes/Path.mjs +175 -16
  61. package/dist/mjs/shapes/PointShape2D.d.ts +14 -3
  62. package/dist/mjs/shapes/PointShape2D.mjs +28 -5
  63. package/dist/mjs/shapes/QuadraticBezier.d.ts +4 -0
  64. package/dist/mjs/shapes/QuadraticBezier.mjs +19 -4
  65. package/dist/mjs/shapes/Rect2.d.ts +3 -0
  66. package/dist/mjs/shapes/Rect2.mjs +4 -1
  67. package/package.json +5 -5
  68. package/src/Vec3.test.ts +26 -7
  69. package/src/Vec3.ts +30 -0
  70. package/src/lib.ts +3 -1
  71. package/src/rounding/cleanUpNumber.test.ts +15 -0
  72. package/src/rounding/cleanUpNumber.ts +38 -0
  73. package/src/rounding/constants.ts +3 -0
  74. package/src/rounding/getLenAfterDecimal.ts +29 -0
  75. package/src/rounding/lib.ts +2 -0
  76. package/src/rounding/toRoundedString.test.ts +32 -0
  77. package/src/rounding/toRoundedString.ts +57 -0
  78. package/src/rounding/toStringOfSamePrecision.test.ts +21 -0
  79. package/src/rounding/toStringOfSamePrecision.ts +63 -0
  80. package/src/shapes/Abstract2DShape.ts +3 -0
  81. package/src/shapes/BezierJSWrapper.ts +154 -14
  82. package/src/shapes/LineSegment2.test.ts +35 -1
  83. package/src/shapes/LineSegment2.ts +79 -11
  84. package/src/shapes/Parameterized2DShape.ts +39 -0
  85. package/src/shapes/Path.test.ts +63 -3
  86. package/src/shapes/Path.ts +211 -26
  87. package/src/shapes/PointShape2D.ts +33 -6
  88. package/src/shapes/QuadraticBezier.test.ts +48 -12
  89. package/src/shapes/QuadraticBezier.ts +23 -5
  90. package/src/shapes/Rect2.ts +4 -1
  91. package/dist/cjs/rounding.js +0 -146
  92. package/dist/mjs/rounding.mjs +0 -139
  93. package/src/rounding.test.ts +0 -65
  94. package/src/rounding.ts +0 -168
  95. /package/dist/cjs/{rounding.test.d.ts → rounding/cleanUpNumber.test.d.ts} +0 -0
  96. /package/dist/{mjs/rounding.test.d.ts → cjs/rounding/toRoundedString.test.d.ts} +0 -0
@@ -0,0 +1,39 @@
1
+ import { Point2, Vec2 } from '../Vec2';
2
+ import Abstract2DShape from './Abstract2DShape';
3
+ import LineSegment2 from './LineSegment2';
4
+
5
+ /** A 2-dimensional path with parameter interval $t \in [0, 1]$. */
6
+ export abstract class Parameterized2DShape extends Abstract2DShape {
7
+ /** Returns this at a given parameter. $t \in [0, 1]$ */
8
+ abstract at(t: number): Point2;
9
+
10
+ /** Computes the unit normal vector at $t$. */
11
+ abstract normalAt(t: number): Vec2;
12
+
13
+ abstract tangentAt(t: number): Vec2;
14
+
15
+ /**
16
+ * Divides this shape into two separate shapes at parameter value $t$.
17
+ */
18
+ abstract splitAt(t: number): [ Parameterized2DShape ] | [ Parameterized2DShape, Parameterized2DShape ];
19
+
20
+ /**
21
+ * Returns the nearest point on `this` to `point` and the `parameterValue` at which
22
+ * that point occurs.
23
+ */
24
+ abstract nearestPointTo(point: Point2): { point: Point2, parameterValue: number };
25
+
26
+ /**
27
+ * Returns the **parameter values** at which `lineSegment` intersects this shape.
28
+ *
29
+ * See also {@link intersectsLineSegment}
30
+ */
31
+ public abstract argIntersectsLineSegment(lineSegment: LineSegment2): number[];
32
+
33
+
34
+ public override intersectsLineSegment(line: LineSegment2): Point2[] {
35
+ return this.argIntersectsLineSegment(line).map(t => this.at(t));
36
+ }
37
+ }
38
+
39
+ export default Parameterized2DShape;
@@ -60,6 +60,24 @@ describe('Path', () => {
60
60
  );
61
61
  });
62
62
 
63
+ it.each([
64
+ [ 'm0,0 L1,1', 'M0,0 L1,1', true ],
65
+ [ 'm0,0 L1,1', 'M1,1 L0,0', false ],
66
+ [ 'm0,0 L1,1 Q2,3 4,5', 'M1,1 L0,0', false ],
67
+ [ 'm0,0 L1,1 Q2,3 4,5', 'M1,1 L0,0 Q2,3 4,5', false ],
68
+ [ 'm0,0 L1,1 Q2,3 4,5', 'M0,0 L1,1 Q2,3 4,5', true ],
69
+ [ 'm0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', true ],
70
+ [ 'm0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9Z', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', false ],
71
+ [ 'm0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9Z', false ],
72
+ [ 'm0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9.01', false ],
73
+ [ 'm0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7.01 8,9', false ],
74
+ [ 'm0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5.01 6,7 8,9', false ],
75
+ ])('.eq should check equality', (path1Str, path2Str, shouldEqual) => {
76
+ expect(Path.fromString(path1Str)).objEq(Path.fromString(path1Str));
77
+ expect(Path.fromString(path2Str)).objEq(Path.fromString(path2Str));
78
+ expect(Path.fromString(path1Str).eq(Path.fromString(path2Str))).toBe(shouldEqual);
79
+ });
80
+
63
81
  describe('intersection', () => {
64
82
  it('should give all intersections for a path made up of lines', () => {
65
83
  const lineStart = Vec2.of(100, 100);
@@ -179,7 +197,7 @@ describe('Path', () => {
179
197
  });
180
198
  });
181
199
 
182
- it('should give all intersections for a Bézier stroked path', () => {
200
+ it('should correctly report intersections for a simple Bézier curve path', () => {
183
201
  const lineStart = Vec2.zero;
184
202
  const path = new Path(lineStart, [
185
203
  {
@@ -196,13 +214,36 @@ describe('Path', () => {
196
214
  let intersections = path.intersection(
197
215
  new LineSegment2(Vec2.of(-1, 0.5), Vec2.of(2, 0.5)), strokeWidth,
198
216
  );
199
- expect(intersections.length).toBe(0);
217
+ expect(intersections).toHaveLength(0);
200
218
 
201
219
  // Should be an intersection when exiting/entering the edge of the stroke
202
220
  intersections = path.intersection(
203
221
  new LineSegment2(Vec2.of(0, 0.5), Vec2.of(8, 0.5)), strokeWidth,
204
222
  );
205
- expect(intersections.length).toBe(1);
223
+ expect(intersections).toHaveLength(1);
224
+ });
225
+
226
+ it('should correctly report intersections near the cap of a line-like Bézier', () => {
227
+ const path = Path.fromString('M0,0Q14,0 27,0');
228
+ expect(
229
+ path.intersection(
230
+ new LineSegment2(Vec2.of(0, -100), Vec2.of(0, 100)),
231
+ 10,
232
+ ),
233
+
234
+ // Should have intersections, despite being at the cap of the Bézier
235
+ // curve.
236
+ ).toHaveLength(2);
237
+ });
238
+
239
+ it.each([
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],
244
+ ])('should correctly report positive intersections with a line-like Bézier', (line, strokeRadius) => {
245
+ const bezier = Path.fromString('M0,0 Q50,0 100,0');
246
+ expect(bezier.intersection(line, strokeRadius).length).toBeGreaterThan(0);
206
247
  });
207
248
  });
208
249
 
@@ -306,4 +347,23 @@ describe('Path', () => {
306
347
  expect(strokedRect.startPoint).objEq(lastSegment.point);
307
348
  });
308
349
  });
350
+
351
+ it.each([
352
+ [ 'm0,0 L1,1', 'M1,1 L0,0' ],
353
+ [ 'm0,0 L1,1', 'M1,1 L0,0' ],
354
+ [ 'M0,0 L1,1 Q2,2 3,3', 'M3,3 Q2,2 1,1 L0,0' ],
355
+ [ 'M0,0 L1,1 Q4,2 5,3 C12,13 10,9 8,7', 'M8,7 C 10,9 12,13 5,3 Q 4,2 1,1 L 0,0' ],
356
+ ])('.reversed should reverse paths', (original, expected) => {
357
+ expect(Path.fromString(original).reversed()).objEq(Path.fromString(expected));
358
+ expect(Path.fromString(expected).reversed()).objEq(Path.fromString(original));
359
+ expect(Path.fromString(original).reversed().reversed()).objEq(Path.fromString(original));
360
+ });
361
+
362
+ it.each([
363
+ [ 'm0,0 l1,0', Vec2.of(0, 0), Vec2.of(0, 0) ],
364
+ [ 'm0,0 l1,0', Vec2.of(0.5, 0), Vec2.of(0.5, 0) ],
365
+ [ 'm0,0 Q1,0 1,2', Vec2.of(1, 0), Vec2.of(0.6236, 0.299) ],
366
+ ])('.nearestPointTo should return the closest point on a path to the given parameter (case %#)', (path, point, expectedClosest) => {
367
+ expect(Path.fromString(path).nearestPointTo(point).point).objEq(expectedClosest, 0.002);
368
+ });
309
369
  });
@@ -1,12 +1,13 @@
1
- import { toRoundedString, toStringOfSamePrecision } from '../rounding';
2
1
  import LineSegment2 from './LineSegment2';
3
2
  import Mat33 from '../Mat33';
4
3
  import Rect2 from './Rect2';
5
4
  import { Point2, Vec2 } from '../Vec2';
6
- import Abstract2DShape from './Abstract2DShape';
7
5
  import CubicBezier from './CubicBezier';
8
6
  import QuadraticBezier from './QuadraticBezier';
9
7
  import PointShape2D from './PointShape2D';
8
+ import toRoundedString from '../rounding/toRoundedString';
9
+ import toStringOfSamePrecision from '../rounding/toStringOfSamePrecision';
10
+ import Parameterized2DShape from './Parameterized2DShape';
10
11
 
11
12
  export enum PathCommandType {
12
13
  LineTo,
@@ -40,17 +41,29 @@ export interface MoveToPathCommand {
40
41
 
41
42
  export type PathCommand = CubicBezierPathCommand | QuadraticBezierPathCommand | MoveToPathCommand | LinePathCommand;
42
43
 
43
- interface IntersectionResult {
44
+ export interface IntersectionResult {
44
45
  // @internal
45
- curve: Abstract2DShape;
46
+ curve: Parameterized2DShape;
47
+ // @internal
48
+ curveIndex: number;
46
49
 
47
- /** @internal @deprecated */
50
+ /** Parameter value for the closest point **on** the path to the intersection. @internal @deprecated */
48
51
  parameterValue?: number;
49
52
 
50
- // Point at which the intersection occured.
53
+ /** Point at which the intersection occured. */
51
54
  point: Point2;
52
55
  }
53
56
 
57
+ /**
58
+ * Allows indexing a particular part of a path.
59
+ *
60
+ * @see {@link Path.at} {@link Path.tangentAt}
61
+ */
62
+ export interface CurveIndexRecord {
63
+ curveIndex: number;
64
+ parameterValue: number;
65
+ }
66
+
54
67
  /**
55
68
  * Represents a union of lines and curves.
56
69
  */
@@ -96,16 +109,16 @@ export class Path {
96
109
  return Rect2.union(...bboxes);
97
110
  }
98
111
 
99
- private cachedGeometry: Abstract2DShape[]|null = null;
112
+ private cachedGeometry: Parameterized2DShape[]|null = null;
100
113
 
101
114
  // Lazy-loads and returns this path's geometry
102
- public get geometry(): Abstract2DShape[] {
115
+ public get geometry(): Parameterized2DShape[] {
103
116
  if (this.cachedGeometry) {
104
117
  return this.cachedGeometry;
105
118
  }
106
119
 
107
120
  let startPoint = this.startPoint;
108
- const geometry: Abstract2DShape[] = [];
121
+ const geometry: Parameterized2DShape[] = [];
109
122
 
110
123
  for (const part of this.parts) {
111
124
  let exhaustivenessCheck: never;
@@ -269,7 +282,7 @@ export class Path {
269
282
 
270
283
  type DistanceFunction = (point: Point2) => number;
271
284
  type DistanceFunctionRecord = {
272
- part: Abstract2DShape,
285
+ part: Parameterized2DShape,
273
286
  bbox: Rect2,
274
287
  distFn: DistanceFunction,
275
288
  };
@@ -308,9 +321,9 @@ export class Path {
308
321
 
309
322
  // Returns the minimum distance to a part in this stroke, where only parts that the given
310
323
  // line could intersect are considered.
311
- const sdf = (point: Point2): [Abstract2DShape|null, number] => {
324
+ const sdf = (point: Point2): [Parameterized2DShape|null, number] => {
312
325
  let minDist = Infinity;
313
- let minDistPart: Abstract2DShape|null = null;
326
+ let minDistPart: Parameterized2DShape|null = null;
314
327
 
315
328
  const uncheckedDistFunctions: DistanceFunctionRecord[] = [];
316
329
 
@@ -337,7 +350,7 @@ export class Path {
337
350
  for (const { part, distFn, bbox } of uncheckedDistFunctions) {
338
351
  // Skip if impossible for the distance to the target to be lesser than
339
352
  // the current minimum.
340
- if (!bbox.grownBy(minDist).containsPoint(point)) {
353
+ if (isFinite(minDist) && !bbox.grownBy(minDist).containsPoint(point)) {
341
354
  continue;
342
355
  }
343
356
 
@@ -387,7 +400,7 @@ export class Path {
387
400
 
388
401
  const stoppingThreshold = strokeRadius / 1000;
389
402
 
390
- // Returns the maximum x value explored
403
+ // Returns the maximum parameter value explored
391
404
  const raymarchFrom = (
392
405
  startPoint: Point2,
393
406
 
@@ -445,9 +458,15 @@ export class Path {
445
458
  if (lastPart && isOnLineSegment && Math.abs(lastDist) < stoppingThreshold) {
446
459
  result.push({
447
460
  point: currentPoint,
448
- parameterValue: NaN,
461
+ parameterValue: NaN,// lastPart.nearestPointTo(currentPoint).parameterValue,
449
462
  curve: lastPart,
463
+ curveIndex: this.geometry.indexOf(lastPart),
450
464
  });
465
+
466
+ // Slightly increase the parameter value to prevent the same point from being
467
+ // added to the results twice.
468
+ const parameterIncrease = strokeRadius / 20 / line.length;
469
+ lastParameter += isFinite(parameterIncrease) ? parameterIncrease : 0;
451
470
  }
452
471
 
453
472
  return lastParameter;
@@ -488,15 +507,20 @@ export class Path {
488
507
  return [];
489
508
  }
490
509
 
510
+ let index = 0;
491
511
  for (const part of this.geometry) {
492
- const intersection = part.intersectsLineSegment(line);
512
+ const intersections = part.argIntersectsLineSegment(line);
493
513
 
494
- if (intersection.length > 0) {
514
+ for (const intersection of intersections) {
495
515
  result.push({
496
516
  curve: part,
497
- point: intersection[0],
517
+ curveIndex: index,
518
+ point: part.at(intersection),
519
+ parameterValue: intersection,
498
520
  });
499
521
  }
522
+
523
+ index ++;
500
524
  }
501
525
 
502
526
  // If given a non-zero strokeWidth, attempt to raymarch.
@@ -512,6 +536,47 @@ export class Path {
512
536
  return result;
513
537
  }
514
538
 
539
+ /**
540
+ * @returns the nearest point on this path to the given `point`.
541
+ *
542
+ * @internal
543
+ * @beta
544
+ */
545
+ public nearestPointTo(point: Point2): IntersectionResult {
546
+ // Find the closest point on this
547
+ let closestSquareDist = Infinity;
548
+ let closestPartIndex = 0;
549
+ let closestParameterValue = 0;
550
+ let closestPoint: Point2 = this.startPoint;
551
+
552
+ for (let i = 0; i < this.geometry.length; i++) {
553
+ const current = this.geometry[i];
554
+ const nearestPoint = current.nearestPointTo(point);
555
+ const sqareDist = nearestPoint.point.squareDistanceTo(point);
556
+ if (i === 0 || sqareDist < closestSquareDist) {
557
+ closestPartIndex = i;
558
+ closestSquareDist = sqareDist;
559
+ closestParameterValue = nearestPoint.parameterValue;
560
+ closestPoint = nearestPoint.point;
561
+ }
562
+ }
563
+
564
+ return {
565
+ curve: this.geometry[closestPartIndex],
566
+ curveIndex: closestPartIndex,
567
+ parameterValue: closestParameterValue,
568
+ point: closestPoint,
569
+ };
570
+ }
571
+
572
+ public at(index: CurveIndexRecord) {
573
+ return this.geometry[index.curveIndex].at(index.parameterValue);
574
+ }
575
+
576
+ public tangentAt(index: CurveIndexRecord) {
577
+ return this.geometry[index.curveIndex].tangentAt(index.parameterValue);
578
+ }
579
+
515
580
  private static mapPathCommand(part: PathCommand, mapping: (point: Point2)=> Point2): PathCommand {
516
581
  switch (part.kind) {
517
582
  case PathCommandType.MoveTo:
@@ -562,19 +627,88 @@ export class Path {
562
627
  }
563
628
 
564
629
  // Creates a new path by joining [other] to the end of this path
565
- public union(other: Path|null): Path {
630
+ public union(
631
+ other: Path|null,
632
+
633
+ // allowReverse: true iff reversing other or this is permitted if it means
634
+ // no moveTo command is necessary when unioning the paths.
635
+ options: { allowReverse?: boolean } = { allowReverse: true },
636
+ ): Path {
566
637
  if (!other) {
567
638
  return this;
568
639
  }
569
640
 
570
- return new Path(this.startPoint, [
571
- ...this.parts,
641
+ const thisEnd = this.getEndPoint();
642
+
643
+ let newParts: Readonly<PathCommand>[] = [];
644
+ if (thisEnd.eq(other.startPoint)) {
645
+ newParts = this.parts.concat(other.parts);
646
+ } else if (options.allowReverse && this.startPoint.eq(other.getEndPoint())) {
647
+ return other.union(this, { allowReverse: false });
648
+ } else if (options.allowReverse && this.startPoint.eq(other.startPoint)) {
649
+ return this.union(other.reversed(), { allowReverse: false });
650
+ } else {
651
+ newParts = [
652
+ ...this.parts,
653
+ {
654
+ kind: PathCommandType.MoveTo,
655
+ point: other.startPoint,
656
+ },
657
+ ...other.parts,
658
+ ];
659
+ }
660
+ return new Path(this.startPoint, newParts);
661
+ }
662
+
663
+ /**
664
+ * @returns a version of this path with the direction reversed.
665
+ *
666
+ * Example:
667
+ * ```ts,runnable,console
668
+ * import {Path} from '@js-draw/math';
669
+ * console.log(Path.fromString('m0,0l1,1').reversed()); // -> M1,1 L0,0
670
+ * ```
671
+ */
672
+ public reversed() {
673
+ const newStart = this.getEndPoint();
674
+ const newParts: Readonly<PathCommand>[] = [];
675
+ let lastPoint: Point2 = this.startPoint;
676
+ for (const part of this.parts) {
677
+ switch (part.kind) {
678
+ case PathCommandType.LineTo:
679
+ case PathCommandType.MoveTo:
680
+ newParts.push({
681
+ kind: part.kind,
682
+ point: lastPoint,
683
+ });
684
+ lastPoint = part.point;
685
+ break;
686
+ case PathCommandType.CubicBezierTo:
687
+ newParts.push({
688
+ kind: part.kind,
689
+ controlPoint1: part.controlPoint2,
690
+ controlPoint2: part.controlPoint1,
691
+ endPoint: lastPoint,
692
+ });
693
+ lastPoint = part.endPoint;
694
+ break;
695
+ case PathCommandType.QuadraticBezierTo:
696
+ newParts.push({
697
+ kind: part.kind,
698
+ controlPoint: part.controlPoint,
699
+ endPoint: lastPoint,
700
+ });
701
+ lastPoint = part.endPoint;
702
+ break;
703
+ default:
572
704
  {
573
- kind: PathCommandType.MoveTo,
574
- point: other.startPoint,
575
- },
576
- ...other.parts,
577
- ]);
705
+ const exhaustivenessCheck: never = part;
706
+ return exhaustivenessCheck;
707
+ }
708
+ }
709
+ }
710
+ newParts.reverse();
711
+ return new Path(newStart, newParts);
578
712
  }
579
713
 
580
714
  private getEndPoint() {
@@ -681,6 +815,57 @@ export class Path {
681
815
  return false;
682
816
  }
683
817
 
818
+ /** @returns true if all points on this are equivalent to the points on `other` */
819
+ public eq(other: Path, tolerance?: number) {
820
+ if (other.parts.length !== this.parts.length) {
821
+ return false;
822
+ }
823
+
824
+ for (let i = 0; i < this.parts.length; i++) {
825
+ const part1 = this.parts[i];
826
+ const part2 = other.parts[i];
827
+
828
+ switch (part1.kind) {
829
+ case PathCommandType.LineTo:
830
+ case PathCommandType.MoveTo:
831
+ if (part1.kind !== part2.kind) {
832
+ return false;
833
+ } else if(!part1.point.eq(part2.point, tolerance)) {
834
+ return false;
835
+ }
836
+ break;
837
+ case PathCommandType.CubicBezierTo:
838
+ if (part1.kind !== part2.kind) {
839
+ return false;
840
+ } else if (
841
+ !part1.controlPoint1.eq(part2.controlPoint1, tolerance)
842
+ || !part1.controlPoint2.eq(part2.controlPoint2, tolerance)
843
+ || !part1.endPoint.eq(part2.endPoint, tolerance)
844
+ ) {
845
+ return false;
846
+ }
847
+ break;
848
+ case PathCommandType.QuadraticBezierTo:
849
+ if (part1.kind !== part2.kind) {
850
+ return false;
851
+ } else if (
852
+ !part1.controlPoint.eq(part2.controlPoint, tolerance)
853
+ || !part1.endPoint.eq(part2.endPoint, tolerance)
854
+ ) {
855
+ return false;
856
+ }
857
+ break;
858
+ default:
859
+ {
860
+ const exhaustivenessCheck: never = part1;
861
+ return exhaustivenessCheck;
862
+ }
863
+ }
864
+ }
865
+
866
+ return true;
867
+ }
868
+
684
869
  /**
685
870
  * Returns a path that outlines `rect`.
686
871
  *
@@ -1,7 +1,7 @@
1
- import { Point2 } from '../Vec2';
1
+ import { Point2, Vec2 } from '../Vec2';
2
2
  import Vec3 from '../Vec3';
3
- import Abstract2DShape from './Abstract2DShape';
4
3
  import LineSegment2 from './LineSegment2';
4
+ import Parameterized2DShape from './Parameterized2DShape';
5
5
  import Rect2 from './Rect2';
6
6
 
7
7
  /**
@@ -9,18 +9,18 @@ import Rect2 from './Rect2';
9
9
  *
10
10
  * Access the internal `Point2` using the `p` property.
11
11
  */
12
- class PointShape2D extends Abstract2DShape {
12
+ class PointShape2D extends Parameterized2DShape {
13
13
  public constructor(public readonly p: Point2) {
14
14
  super();
15
15
  }
16
16
 
17
17
  public override signedDistance(point: Vec3): number {
18
- return this.p.minus(point).magnitude();
18
+ return this.p.distanceTo(point);
19
19
  }
20
20
 
21
- public override intersectsLineSegment(lineSegment: LineSegment2, epsilon?: number): Vec3[] {
21
+ public override argIntersectsLineSegment(lineSegment: LineSegment2, epsilon?: number): number[] {
22
22
  if (lineSegment.containsPoint(this.p, epsilon)) {
23
- return [ this.p ];
23
+ return [ 0 ];
24
24
  }
25
25
  return [ ];
26
26
  }
@@ -28,6 +28,33 @@ class PointShape2D extends Abstract2DShape {
28
28
  public override getTightBoundingBox(): Rect2 {
29
29
  return new Rect2(this.p.x, this.p.y, 0, 0);
30
30
  }
31
+
32
+ public override at(_t: number) {
33
+ return this.p;
34
+ }
35
+
36
+ /**
37
+ * Returns an arbitrary unit-length vector.
38
+ */
39
+ public override normalAt(_t: number) {
40
+ // Return a vector that makes sense.
41
+ return Vec2.unitY;
42
+ }
43
+
44
+ public override tangentAt(_t: number): Vec3 {
45
+ return Vec2.unitX;
46
+ }
47
+
48
+ public override splitAt(_t: number): [PointShape2D] {
49
+ return [this];
50
+ }
51
+
52
+ public override nearestPointTo(_point: Point2) {
53
+ return {
54
+ point: this.p,
55
+ parameterValue: 0,
56
+ };
57
+ }
31
58
  }
32
59
 
33
60
  export default PointShape2D;
@@ -2,13 +2,12 @@ import { Vec2 } from '../Vec2';
2
2
  import QuadraticBezier from './QuadraticBezier';
3
3
 
4
4
  describe('QuadraticBezier', () => {
5
- it('approxmiateDistance should approximately return the distance to the curve', () => {
6
- const curves = [
7
- new QuadraticBezier(Vec2.zero, Vec2.of(10, 0), Vec2.of(20, 0)),
8
- new QuadraticBezier(Vec2.of(-10, 0), Vec2.of(2, 10), Vec2.of(20, 0)),
9
- new QuadraticBezier(Vec2.of(0, 0), Vec2.of(4, -10), Vec2.of(20, 60)),
10
- new QuadraticBezier(Vec2.of(0, 0), Vec2.of(4, -10), Vec2.of(-20, 60)),
11
- ];
5
+ test.each([
6
+ new QuadraticBezier(Vec2.zero, Vec2.of(10, 0), Vec2.of(20, 0)),
7
+ new QuadraticBezier(Vec2.of(-10, 0), Vec2.of(2, 10), Vec2.of(20, 0)),
8
+ new QuadraticBezier(Vec2.of(0, 0), Vec2.of(4, -10), Vec2.of(20, 60)),
9
+ new QuadraticBezier(Vec2.of(0, 0), Vec2.of(4, -10), Vec2.of(-20, 60)),
10
+ ])('approxmiateDistance should approximately return the distance to the curve (%s)', (curve) => {
12
11
  const testPoints = [
13
12
  Vec2.of(1, 1),
14
13
  Vec2.of(-1, 1),
@@ -18,13 +17,50 @@ describe('QuadraticBezier', () => {
18
17
  Vec2.of(5, 0),
19
18
  ];
20
19
 
20
+ for (const point of testPoints) {
21
+ const actualDist = curve.distance(point);
22
+ const approxDist = curve.approximateDistance(point);
23
+
24
+ expect(approxDist).toBeGreaterThan(actualDist * 0.6 - 0.25);
25
+ expect(approxDist).toBeLessThan(actualDist * 1.5 + 2.6);
26
+ }
27
+ });
28
+
29
+ test.each([
30
+ [ new QuadraticBezier(Vec2.zero, Vec2.unitX, Vec2.unitY), Vec2.zero, 0 ],
31
+ [ new QuadraticBezier(Vec2.zero, Vec2.unitX, Vec2.unitY), Vec2.unitY, 1 ],
32
+
33
+ [ new QuadraticBezier(Vec2.zero, Vec2.of(0.5, 0), Vec2.of(1, 0)), Vec2.of(0.4, 0), 0.4],
34
+ [ new QuadraticBezier(Vec2.zero, Vec2.of(0, 0.5), Vec2.of(0, 1)), Vec2.of(0, 0.4), 0.4],
35
+ [ new QuadraticBezier(Vec2.zero, Vec2.unitX, Vec2.unitY), Vec2.unitX, 0.42514 ],
36
+
37
+ // Should not return an out-of-range parameter
38
+ [ new QuadraticBezier(Vec2.zero, Vec2.of(0, 0.5), Vec2.unitY), Vec2.of(0, -1000), 0 ],
39
+ [ new QuadraticBezier(Vec2.zero, Vec2.of(0, 0.5), Vec2.unitY), Vec2.of(0, 1000), 1 ],
40
+ ])('nearestPointTo should return the nearest point and parameter value on %s to %s', (bezier, point, expectedParameter) => {
41
+ const nearest = bezier.nearestPointTo(point);
42
+ expect(nearest.parameterValue).toBeCloseTo(expectedParameter, 0.0001);
43
+ expect(nearest.point).objEq(bezier.at(nearest.parameterValue));
44
+ });
45
+
46
+ test('.normalAt should return a unit normal vector at the given parameter value', () => {
47
+ const curves = [
48
+ new QuadraticBezier(Vec2.zero, Vec2.unitY, Vec2.unitY.times(2)),
49
+ new QuadraticBezier(Vec2.zero, Vec2.unitX, Vec2.unitY),
50
+ new QuadraticBezier(Vec2.zero, Vec2.unitX, Vec2.unitY.times(-2)),
51
+ new QuadraticBezier(Vec2.of(2, 3), Vec2.of(4, 5.1), Vec2.of(6, 7)),
52
+ new QuadraticBezier(Vec2.of(2, 3), Vec2.of(100, 1000), Vec2.unitY.times(-2)),
53
+ ];
54
+
21
55
  for (const curve of curves) {
22
- for (const point of testPoints) {
23
- const actualDist = curve.distance(point);
24
- const approxDist = curve.approximateDistance(point);
56
+ for (let t = 0; t < 1; t += 0.1) {
57
+ const normal = curve.normalAt(t);
58
+ expect(normal.length()).toBe(1);
59
+
60
+ const tangentApprox = curve.at(t + 0.001).minus(curve.at(t - 0.001));
25
61
 
26
- expect(approxDist).toBeGreaterThan(actualDist * 0.6 - 0.25);
27
- expect(approxDist).toBeLessThan(actualDist * 1.5 + 2.6);
62
+ // The tangent vector should be perpindicular to the normal
63
+ expect(tangentApprox.dot(normal)).toBeCloseTo(0);
28
64
  }
29
65
  }
30
66
  });
@@ -30,10 +30,19 @@ export class QuadraticBezier extends BezierJSWrapper {
30
30
  return -2 * p0 + 2 * p1 + 2 * t * (p0 - 2 * p1 + p2);
31
31
  }
32
32
 
33
+ private static secondDerivativeComponentAt(t: number, p0: number, p1: number, p2: number) {
34
+ return 2 * (p0 - 2 * p1 + p2);
35
+ }
36
+
33
37
  /**
34
38
  * @returns the curve evaluated at `t`.
39
+ *
40
+ * `t` should be a number in `[0, 1]`.
35
41
  */
36
42
  public override at(t: number): Point2 {
43
+ if (t === 0) return this.p0;
44
+ if (t === 1) return this.p2;
45
+
37
46
  const p0 = this.p0;
38
47
  const p1 = this.p1;
39
48
  const p2 = this.p2;
@@ -53,6 +62,16 @@ export class QuadraticBezier extends BezierJSWrapper {
53
62
  );
54
63
  }
55
64
 
65
+ public override secondDerivativeAt(t: number): Point2 {
66
+ const p0 = this.p0;
67
+ const p1 = this.p1;
68
+ const p2 = this.p2;
69
+ return Vec2.of(
70
+ QuadraticBezier.secondDerivativeComponentAt(t, p0.x, p1.x, p2.x),
71
+ QuadraticBezier.secondDerivativeComponentAt(t, p0.y, p1.y, p2.y),
72
+ );
73
+ }
74
+
56
75
  public override normal(t: number): Vec2 {
57
76
  const tangent = this.derivativeAt(t);
58
77
  return tangent.orthog().normalized();
@@ -126,11 +145,10 @@ export class QuadraticBezier extends BezierJSWrapper {
126
145
 
127
146
  const at1 = this.at(min1);
128
147
  const at2 = this.at(min2);
129
- const sqrDist1 = at1.minus(point).magnitudeSquared();
130
- const sqrDist2 = at2.minus(point).magnitudeSquared();
131
- const sqrDist3 = this.at(0).minus(point).magnitudeSquared();
132
- const sqrDist4 = this.at(1).minus(point).magnitudeSquared();
133
-
148
+ const sqrDist1 = at1.squareDistanceTo(point);
149
+ const sqrDist2 = at2.squareDistanceTo(point);
150
+ const sqrDist3 = this.at(0).squareDistanceTo(point);
151
+ const sqrDist4 = this.at(1).squareDistanceTo(point);
134
152
 
135
153
  return Math.sqrt(Math.min(sqrDist1, sqrDist2, sqrDist3, sqrDist4));
136
154
  }