@js-draw/math 1.16.0 → 1.18.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. package/dist/cjs/Mat33.js +6 -1
  2. package/dist/cjs/Vec3.d.ts +23 -1
  3. package/dist/cjs/Vec3.js +33 -7
  4. package/dist/cjs/lib.d.ts +2 -1
  5. package/dist/cjs/lib.js +5 -1
  6. package/dist/cjs/shapes/Abstract2DShape.d.ts +3 -0
  7. package/dist/cjs/shapes/BezierJSWrapper.d.ts +19 -5
  8. package/dist/cjs/shapes/BezierJSWrapper.js +170 -18
  9. package/dist/cjs/shapes/LineSegment2.d.ts +45 -5
  10. package/dist/cjs/shapes/LineSegment2.js +89 -11
  11. package/dist/cjs/shapes/Parameterized2DShape.d.ts +36 -0
  12. package/dist/cjs/shapes/Parameterized2DShape.js +20 -0
  13. package/dist/cjs/shapes/Path.d.ts +131 -13
  14. package/dist/cjs/shapes/Path.js +507 -26
  15. package/dist/cjs/shapes/PointShape2D.d.ts +14 -3
  16. package/dist/cjs/shapes/PointShape2D.js +28 -5
  17. package/dist/cjs/shapes/QuadraticBezier.d.ts +6 -3
  18. package/dist/cjs/shapes/QuadraticBezier.js +21 -7
  19. package/dist/cjs/shapes/Rect2.d.ts +9 -1
  20. package/dist/cjs/shapes/Rect2.js +9 -2
  21. package/dist/cjs/utils/convexHull2Of.d.ts +9 -0
  22. package/dist/cjs/utils/convexHull2Of.js +61 -0
  23. package/dist/cjs/utils/convexHull2Of.test.d.ts +1 -0
  24. package/dist/mjs/Mat33.mjs +6 -1
  25. package/dist/mjs/Vec3.d.ts +23 -1
  26. package/dist/mjs/Vec3.mjs +33 -7
  27. package/dist/mjs/lib.d.ts +2 -1
  28. package/dist/mjs/lib.mjs +2 -1
  29. package/dist/mjs/shapes/Abstract2DShape.d.ts +3 -0
  30. package/dist/mjs/shapes/BezierJSWrapper.d.ts +19 -5
  31. package/dist/mjs/shapes/BezierJSWrapper.mjs +168 -18
  32. package/dist/mjs/shapes/LineSegment2.d.ts +45 -5
  33. package/dist/mjs/shapes/LineSegment2.mjs +89 -11
  34. package/dist/mjs/shapes/Parameterized2DShape.d.ts +36 -0
  35. package/dist/mjs/shapes/Parameterized2DShape.mjs +13 -0
  36. package/dist/mjs/shapes/Path.d.ts +131 -13
  37. package/dist/mjs/shapes/Path.mjs +504 -25
  38. package/dist/mjs/shapes/PointShape2D.d.ts +14 -3
  39. package/dist/mjs/shapes/PointShape2D.mjs +28 -5
  40. package/dist/mjs/shapes/QuadraticBezier.d.ts +6 -3
  41. package/dist/mjs/shapes/QuadraticBezier.mjs +21 -7
  42. package/dist/mjs/shapes/Rect2.d.ts +9 -1
  43. package/dist/mjs/shapes/Rect2.mjs +9 -2
  44. package/dist/mjs/utils/convexHull2Of.d.ts +9 -0
  45. package/dist/mjs/utils/convexHull2Of.mjs +59 -0
  46. package/dist/mjs/utils/convexHull2Of.test.d.ts +1 -0
  47. package/package.json +5 -5
  48. package/src/Mat33.ts +8 -2
  49. package/src/Vec3.test.ts +42 -7
  50. package/src/Vec3.ts +37 -8
  51. package/src/lib.ts +5 -0
  52. package/src/shapes/Abstract2DShape.ts +3 -0
  53. package/src/shapes/BezierJSWrapper.ts +195 -14
  54. package/src/shapes/LineSegment2.test.ts +61 -1
  55. package/src/shapes/LineSegment2.ts +110 -12
  56. package/src/shapes/Parameterized2DShape.ts +44 -0
  57. package/src/shapes/Path.test.ts +233 -5
  58. package/src/shapes/Path.ts +593 -37
  59. package/src/shapes/PointShape2D.ts +33 -6
  60. package/src/shapes/QuadraticBezier.test.ts +69 -12
  61. package/src/shapes/QuadraticBezier.ts +25 -8
  62. package/src/shapes/Rect2.ts +10 -3
  63. package/src/utils/convexHull2Of.test.ts +43 -0
  64. package/src/utils/convexHull2Of.ts +71 -0
@@ -2,12 +2,14 @@ import LineSegment2 from './LineSegment2';
2
2
  import Mat33 from '../Mat33';
3
3
  import Rect2 from './Rect2';
4
4
  import { Point2, Vec2 } from '../Vec2';
5
- import Abstract2DShape from './Abstract2DShape';
6
5
  import CubicBezier from './CubicBezier';
7
6
  import QuadraticBezier from './QuadraticBezier';
8
7
  import PointShape2D from './PointShape2D';
9
8
  import toRoundedString from '../rounding/toRoundedString';
10
9
  import toStringOfSamePrecision from '../rounding/toStringOfSamePrecision';
10
+ import Parameterized2DShape from './Parameterized2DShape';
11
+ import BezierJSWrapper from './BezierJSWrapper';
12
+ import convexHull2Of from '../utils/convexHull2Of';
11
13
 
12
14
  export enum PathCommandType {
13
15
  LineTo,
@@ -41,19 +43,96 @@ export interface MoveToPathCommand {
41
43
 
42
44
  export type PathCommand = CubicBezierPathCommand | QuadraticBezierPathCommand | MoveToPathCommand | LinePathCommand;
43
45
 
44
- interface IntersectionResult {
46
+ export interface IntersectionResult {
45
47
  // @internal
46
- curve: Abstract2DShape;
48
+ curve: Parameterized2DShape;
49
+ // @internal
50
+ curveIndex: number;
47
51
 
48
- /** @internal @deprecated */
49
- parameterValue?: number;
52
+ /** Parameter value for the closest point **on** the path to the intersection. @internal */
53
+ parameterValue: number;
50
54
 
51
- // Point at which the intersection occured.
55
+ /** Point at which the intersection occured. */
52
56
  point: Point2;
53
57
  }
54
58
 
59
+ /** Options for {@link Path.splitNear} and {@link Path.splitAt} */
60
+ export interface PathSplitOptions {
61
+ /**
62
+ * Allows mapping points on newly added segments. This is useful, for example,
63
+ * to round points to prevent long decimals when later saving.
64
+ */
65
+ mapNewPoint?: (point: Point2)=>Point2;
66
+ }
67
+
68
+ /**
69
+ * Allows indexing a particular part of a path.
70
+ *
71
+ * @see {@link Path.at} {@link Path.tangentAt}
72
+ */
73
+ export interface CurveIndexRecord {
74
+ curveIndex: number;
75
+ parameterValue: number;
76
+ }
77
+
78
+ /** Returns a positive number if `a` comes after `b`, 0 if equal, and negative otherwise. */
79
+ export const compareCurveIndices = (a: CurveIndexRecord, b: CurveIndexRecord) => {
80
+ const indexCompare = a.curveIndex - b.curveIndex;
81
+ if (indexCompare === 0) {
82
+ return a.parameterValue - b.parameterValue;
83
+ } else {
84
+ return indexCompare;
85
+ }
86
+ };
87
+
88
+ /**
89
+ * Returns a version of `index` with its parameter value incremented by `stepBy`
90
+ * (which can be either positive or negative).
91
+ */
92
+ export const stepCurveIndexBy = (index: CurveIndexRecord, stepBy: number): CurveIndexRecord => {
93
+ if (index.parameterValue + stepBy > 1) {
94
+ return { curveIndex: index.curveIndex + 1, parameterValue: index.parameterValue + stepBy - 1 };
95
+ }
96
+ if (index.parameterValue + stepBy < 0) {
97
+ if (index.curveIndex === 0) {
98
+ return { curveIndex: 0, parameterValue: 0 };
99
+ }
100
+ return { curveIndex: index.curveIndex - 1, parameterValue: index.parameterValue + stepBy + 1 };
101
+ }
102
+
103
+ return { curveIndex: index.curveIndex, parameterValue: index.parameterValue + stepBy };
104
+ };
105
+
55
106
  /**
56
107
  * Represents a union of lines and curves.
108
+ *
109
+ * To create a path from a string, see {@link fromString}.
110
+ *
111
+ * @example
112
+ * ```ts,runnable,console
113
+ * import {Path, Mat33, Vec2, LineSegment2} from '@js-draw/math';
114
+ *
115
+ * // Creates a path from an SVG path string.
116
+ * // In this case,
117
+ * // 1. Move to (0,0)
118
+ * // 2. Line to (100,0)
119
+ * const path = Path.fromString('M0,0 L100,0');
120
+ *
121
+ * // Logs the distance from (10,0) to the curve 1 unit
122
+ * // away from path. This curve forms a stroke with the path at
123
+ * // its center.
124
+ * const strokeRadius = 1;
125
+ * console.log(path.signedDistance(Vec2.of(10,0), strokeRadius));
126
+ *
127
+ * // Log a version of the path that's scaled by a factor of 4.
128
+ * console.log(path.transformedBy(Mat33.scaling2D(4)).toString());
129
+ *
130
+ * // Log all intersections of a stroked version of the path with
131
+ * // a vertical line segment.
132
+ * // (Try removing the `strokeRadius` parameter).
133
+ * const segment = new LineSegment2(Vec2.of(5, -100), Vec2.of(5, 100));
134
+ * console.log(path.intersection(segment, strokeRadius).map(i => i.point));
135
+ * ```
57
136
  */
58
137
  export class Path {
59
138
  /**
@@ -88,6 +167,12 @@ export class Path {
88
167
  }
89
168
  }
90
169
 
170
+ /**
171
+ * Computes and returns the full bounding box for this path.
172
+ *
173
+ * If a slight over-estimate of a path's bounding box is sufficient, use
174
+ * {@link bbox} instead.
175
+ */
91
176
  public getExactBBox(): Rect2 {
92
177
  const bboxes: Rect2[] = [];
93
178
  for (const part of this.geometry) {
@@ -97,16 +182,16 @@ export class Path {
97
182
  return Rect2.union(...bboxes);
98
183
  }
99
184
 
100
- private cachedGeometry: Abstract2DShape[]|null = null;
185
+ private cachedGeometry: Parameterized2DShape[]|null = null;
101
186
 
102
187
  // Lazy-loads and returns this path's geometry
103
- public get geometry(): Abstract2DShape[] {
188
+ public get geometry(): Parameterized2DShape[] {
104
189
  if (this.cachedGeometry) {
105
190
  return this.cachedGeometry;
106
191
  }
107
192
 
108
193
  let startPoint = this.startPoint;
109
- const geometry: Abstract2DShape[] = [];
194
+ const geometry: Parameterized2DShape[] = [];
110
195
 
111
196
  for (const part of this.parts) {
112
197
  let exhaustivenessCheck: never;
@@ -237,7 +322,20 @@ export class Path {
237
322
  return Rect2.bboxOf(points);
238
323
  }
239
324
 
240
- /** **Note**: `strokeRadius = strokeWidth / 2` */
325
+ /**
326
+ * Returns the signed distance between `point` and a curve `strokeRadius` units
327
+ * away from this path.
328
+ *
329
+ * This returns the **signed distance**, which means that points inside this shape
330
+ * have their distance negated. For example,
331
+ * ```ts,runnable,console
332
+ * import {Path, Vec2} from '@js-draw/math';
333
+ * console.log(Path.fromString('m0,0 L100,0').signedDistance(Vec2.zero, 1));
334
+ * ```
335
+ * would print `-1` because (0,0) is on `m0,0 L100,0` and thus one unit away from its boundary.
336
+ *
337
+ * **Note**: `strokeRadius = strokeWidth / 2`
338
+ */
241
339
  public signedDistance(point: Point2, strokeRadius: number) {
242
340
  let minDist = Infinity;
243
341
 
@@ -270,7 +368,7 @@ export class Path {
270
368
 
271
369
  type DistanceFunction = (point: Point2) => number;
272
370
  type DistanceFunctionRecord = {
273
- part: Abstract2DShape,
371
+ part: Parameterized2DShape,
274
372
  bbox: Rect2,
275
373
  distFn: DistanceFunction,
276
374
  };
@@ -309,9 +407,9 @@ export class Path {
309
407
 
310
408
  // Returns the minimum distance to a part in this stroke, where only parts that the given
311
409
  // line could intersect are considered.
312
- const sdf = (point: Point2): [Abstract2DShape|null, number] => {
410
+ const sdf = (point: Point2): [Parameterized2DShape|null, number] => {
313
411
  let minDist = Infinity;
314
- let minDistPart: Abstract2DShape|null = null;
412
+ let minDistPart: Parameterized2DShape|null = null;
315
413
 
316
414
  const uncheckedDistFunctions: DistanceFunctionRecord[] = [];
317
415
 
@@ -338,7 +436,7 @@ export class Path {
338
436
  for (const { part, distFn, bbox } of uncheckedDistFunctions) {
339
437
  // Skip if impossible for the distance to the target to be lesser than
340
438
  // the current minimum.
341
- if (!bbox.grownBy(minDist).containsPoint(point)) {
439
+ if (isFinite(minDist) && !bbox.grownBy(minDist).containsPoint(point)) {
342
440
  continue;
343
441
  }
344
442
 
@@ -355,7 +453,7 @@ export class Path {
355
453
 
356
454
 
357
455
  // Raymarch:
358
- const maxRaymarchSteps = 7;
456
+ const maxRaymarchSteps = 8;
359
457
 
360
458
  // Start raymarching from each of these points. This allows detection of multiple
361
459
  // intersections.
@@ -388,7 +486,7 @@ export class Path {
388
486
 
389
487
  const stoppingThreshold = strokeRadius / 1000;
390
488
 
391
- // Returns the maximum x value explored
489
+ // Returns the maximum parameter value explored
392
490
  const raymarchFrom = (
393
491
  startPoint: Point2,
394
492
 
@@ -446,9 +544,15 @@ export class Path {
446
544
  if (lastPart && isOnLineSegment && Math.abs(lastDist) < stoppingThreshold) {
447
545
  result.push({
448
546
  point: currentPoint,
449
- parameterValue: NaN,
547
+ parameterValue: lastPart.nearestPointTo(currentPoint).parameterValue,
450
548
  curve: lastPart,
549
+ curveIndex: this.geometry.indexOf(lastPart),
451
550
  });
551
+
552
+ // Slightly increase the parameter value to prevent the same point from being
553
+ // added to the results twice.
554
+ const parameterIncrease = strokeRadius / 20 / line.length;
555
+ lastParameter += isFinite(parameterIncrease) ? parameterIncrease : 0;
452
556
  }
453
557
 
454
558
  return lastParameter;
@@ -489,15 +593,24 @@ export class Path {
489
593
  return [];
490
594
  }
491
595
 
596
+ if (this.parts.length === 0) {
597
+ return new Path(this.startPoint, [{ kind: PathCommandType.MoveTo, point: this.startPoint }]).intersection(line, strokeRadius);
598
+ }
599
+
600
+ let index = 0;
492
601
  for (const part of this.geometry) {
493
- const intersection = part.intersectsLineSegment(line);
602
+ const intersections = part.argIntersectsLineSegment(line);
494
603
 
495
- if (intersection.length > 0) {
604
+ for (const intersection of intersections) {
496
605
  result.push({
497
606
  curve: part,
498
- point: intersection[0],
607
+ curveIndex: index,
608
+ point: part.at(intersection),
609
+ parameterValue: intersection,
499
610
  });
500
611
  }
612
+
613
+ index ++;
501
614
  }
502
615
 
503
616
  // If given a non-zero strokeWidth, attempt to raymarch.
@@ -513,6 +626,287 @@ export class Path {
513
626
  return result;
514
627
  }
515
628
 
629
+ /**
630
+ * @returns the nearest point on this path to the given `point`.
631
+ */
632
+ public nearestPointTo(point: Point2): IntersectionResult {
633
+ // Find the closest point on this
634
+ let closestSquareDist = Infinity;
635
+ let closestPartIndex = 0;
636
+ let closestParameterValue = 0;
637
+ let closestPoint: Point2 = this.startPoint;
638
+
639
+ for (let i = 0; i < this.geometry.length; i++) {
640
+ const current = this.geometry[i];
641
+ const nearestPoint = current.nearestPointTo(point);
642
+ const sqareDist = nearestPoint.point.squareDistanceTo(point);
643
+ if (i === 0 || sqareDist < closestSquareDist) {
644
+ closestPartIndex = i;
645
+ closestSquareDist = sqareDist;
646
+ closestParameterValue = nearestPoint.parameterValue;
647
+ closestPoint = nearestPoint.point;
648
+ }
649
+ }
650
+
651
+ return {
652
+ curve: this.geometry[closestPartIndex],
653
+ curveIndex: closestPartIndex,
654
+ parameterValue: closestParameterValue,
655
+ point: closestPoint,
656
+ };
657
+ }
658
+
659
+ public at(index: CurveIndexRecord) {
660
+ if (index.curveIndex === 0 && index.parameterValue === 0) {
661
+ return this.startPoint;
662
+ }
663
+ return this.geometry[index.curveIndex].at(index.parameterValue);
664
+ }
665
+
666
+ public tangentAt(index: CurveIndexRecord) {
667
+ return this.geometry[index.curveIndex].tangentAt(index.parameterValue);
668
+ }
669
+
670
+ /** Splits this path in two near the given `point`. */
671
+ public splitNear(point: Point2, options?: PathSplitOptions) {
672
+ const nearest = this.nearestPointTo(point);
673
+ return this.splitAt(nearest, options);
674
+ }
675
+
676
+ /**
677
+ * Returns a copy of this path with `deleteFrom` until `deleteUntil` replaced with `insert`.
678
+ *
679
+ * This method is analogous to {@link Array.toSpliced}.
680
+ */
681
+ public spliced(deleteFrom: CurveIndexRecord, deleteTo: CurveIndexRecord, insert: Path|undefined, options?: PathSplitOptions): Path {
682
+ const isBeforeOrEqual = (a: CurveIndexRecord, b: CurveIndexRecord) => {
683
+ return a.curveIndex < b.curveIndex || (a.curveIndex === b.curveIndex && a.parameterValue <= b.parameterValue);
684
+ };
685
+
686
+ if (isBeforeOrEqual(deleteFrom, deleteTo)) {
687
+ // deleteFrom deleteTo
688
+ // <---------| |-------------->
689
+ // x x
690
+ // startPoint endPoint
691
+ const firstSplit = this.splitAt(deleteFrom, options);
692
+ const secondSplit = this.splitAt(deleteTo, options);
693
+ const before = firstSplit[0];
694
+ const after = secondSplit[secondSplit.length - 1];
695
+ return insert ? before.union(insert).union(after) : before.union(after);
696
+ } else {
697
+ // In this case, we need to handle wrapping at the start/end.
698
+ // deleteTo deleteFrom
699
+ // <---------| keep |-------------->
700
+ // x x
701
+ // startPoint endPoint
702
+ const splitAtFrom = this.splitAt([deleteFrom], options);
703
+ const beforeFrom = splitAtFrom[0];
704
+
705
+ // We need splitNear, rather than splitAt, because beforeFrom does not have
706
+ // the same indexing as this.
707
+ const splitAtTo = beforeFrom.splitNear(this.at(deleteTo), options);
708
+
709
+ const betweenBoth = splitAtTo[splitAtTo.length - 1];
710
+ return insert ? betweenBoth.union(insert) : betweenBoth;
711
+ }
712
+ }
713
+
714
+ public splitAt(at: CurveIndexRecord, options?: PathSplitOptions): [Path]|[Path, Path];
715
+ public splitAt(at: CurveIndexRecord[], options?: PathSplitOptions): Path[];
716
+
717
+ // @internal
718
+ public splitAt(splitAt: CurveIndexRecord[]|CurveIndexRecord, options?: PathSplitOptions): Path[] {
719
+ if (!Array.isArray(splitAt)) {
720
+ splitAt = [splitAt];
721
+ }
722
+
723
+ splitAt = [...splitAt];
724
+ splitAt.sort(compareCurveIndices);
725
+
726
+ //
727
+ // Bounds checking & reversal.
728
+ //
729
+
730
+ while (
731
+ splitAt.length > 0
732
+ && splitAt[splitAt.length - 1].curveIndex >= this.parts.length - 1
733
+ && splitAt[splitAt.length - 1].parameterValue >= 1
734
+ ) {
735
+ splitAt.pop();
736
+ }
737
+
738
+ splitAt.reverse(); // .reverse() <-- We're `.pop`ing from the end
739
+
740
+ while (
741
+ splitAt.length > 0
742
+ && splitAt[splitAt.length - 1].curveIndex <= 0
743
+ && splitAt[splitAt.length - 1].parameterValue <= 0
744
+ ) {
745
+ splitAt.pop();
746
+ }
747
+
748
+ if (splitAt.length === 0 || this.parts.length === 0) {
749
+ return [this];
750
+ }
751
+
752
+ const expectedSplitCount = splitAt.length + 1;
753
+ const mapNewPoint = options?.mapNewPoint ?? ((p: Point2)=>p);
754
+
755
+ const result: Path[] = [];
756
+ let currentStartPoint = this.startPoint;
757
+ let currentPath: PathCommand[] = [];
758
+
759
+ //
760
+ // Splitting
761
+ //
762
+
763
+ let { curveIndex, parameterValue } = splitAt.pop()!;
764
+
765
+ for (let i = 0; i < this.parts.length; i ++) {
766
+ if (i !== curveIndex) {
767
+ currentPath.push(this.parts[i]);
768
+ } else {
769
+ let part = this.parts[i];
770
+ let geom = this.geometry[i];
771
+ while (i === curveIndex) {
772
+ let newPathStart: Point2;
773
+ const newPath: PathCommand[] = [];
774
+
775
+ switch (part.kind) {
776
+ case PathCommandType.MoveTo:
777
+ currentPath.push({
778
+ kind: part.kind,
779
+ point: part.point,
780
+ });
781
+ newPathStart = part.point;
782
+ break;
783
+ case PathCommandType.LineTo:
784
+ {
785
+ const split = (geom as LineSegment2).splitAt(parameterValue);
786
+ currentPath.push({
787
+ kind: part.kind,
788
+ point: mapNewPoint(split[0].p2),
789
+ });
790
+ newPathStart = split[0].p2;
791
+ if (split.length > 1) {
792
+ console.assert(split.length === 2);
793
+ newPath.push({
794
+ kind: part.kind,
795
+
796
+ // Don't map: For lines, the end point of the split is
797
+ // the same as the end point of the original:
798
+ point: split[1]!.p2,
799
+ });
800
+ geom = split[1]!;
801
+ }
802
+ }
803
+ break;
804
+ case PathCommandType.QuadraticBezierTo:
805
+ case PathCommandType.CubicBezierTo:
806
+ {
807
+ const split = (geom as BezierJSWrapper).splitAt(parameterValue);
808
+ let isFirstPart = split.length === 2;
809
+ for (const segment of split) {
810
+ geom = segment;
811
+ const targetArray = isFirstPart ? currentPath : newPath;
812
+ const controlPoints = segment.getPoints();
813
+ if (part.kind === PathCommandType.CubicBezierTo) {
814
+ targetArray.push({
815
+ kind: part.kind,
816
+ controlPoint1: mapNewPoint(controlPoints[1]),
817
+ controlPoint2: mapNewPoint(controlPoints[2]),
818
+ endPoint: mapNewPoint(controlPoints[3]),
819
+ });
820
+ } else {
821
+ targetArray.push({
822
+ kind: part.kind,
823
+ controlPoint: mapNewPoint(controlPoints[1]),
824
+ endPoint: mapNewPoint(controlPoints[2]),
825
+ });
826
+ }
827
+
828
+ // We want the start of the new path to match the start of the
829
+ // FIRST Bézier in the NEW path.
830
+ if (!isFirstPart) {
831
+ newPathStart = controlPoints[0];
832
+ }
833
+ isFirstPart = false;
834
+ }
835
+ }
836
+ break;
837
+ default: {
838
+ const exhaustivenessCheck: never = part;
839
+ return exhaustivenessCheck;
840
+ }
841
+ }
842
+
843
+ result.push(new Path(currentStartPoint, [...currentPath]));
844
+ currentStartPoint = mapNewPoint(newPathStart!);
845
+ console.assert(!!currentStartPoint, 'should have a start point');
846
+ currentPath = newPath;
847
+ part = newPath[newPath.length - 1] ?? part;
848
+
849
+ const nextSplit = splitAt.pop();
850
+ if (!nextSplit) {
851
+ break;
852
+ } else {
853
+ curveIndex = nextSplit.curveIndex;
854
+ if (i === curveIndex) {
855
+ const originalPoint = this.at(nextSplit);
856
+ parameterValue = geom.nearestPointTo(originalPoint).parameterValue;
857
+ currentPath = [];
858
+ } else {
859
+ parameterValue = nextSplit.parameterValue;
860
+ }
861
+ }
862
+ }
863
+ }
864
+ }
865
+
866
+ result.push(new Path(currentStartPoint, currentPath));
867
+
868
+ console.assert(
869
+ result.length === expectedSplitCount,
870
+ `should split into splitAt.length + 1 splits (was ${result.length}, expected ${expectedSplitCount})`
871
+ );
872
+ return result;
873
+ }
874
+
875
+ /**
876
+ * Replaces all `MoveTo` commands with `LineTo` commands and connects the end point of this
877
+ * path to the start point.
878
+ */
879
+ public asClosed() {
880
+ const newParts: PathCommand[] = [];
881
+ let hasChanges = false;
882
+ for (const part of this.parts) {
883
+ if (part.kind === PathCommandType.MoveTo) {
884
+ newParts.push({
885
+ kind: PathCommandType.LineTo,
886
+ point: part.point,
887
+ });
888
+ hasChanges = true;
889
+ } else {
890
+ newParts.push(part);
891
+ }
892
+ }
893
+ if (!this.getEndPoint().eq(this.startPoint)) {
894
+ newParts.push({
895
+ kind: PathCommandType.LineTo,
896
+ point: this.startPoint,
897
+ });
898
+ hasChanges = true;
899
+ }
900
+
901
+ if (!hasChanges) {
902
+ return this;
903
+ }
904
+
905
+ const result = new Path(this.startPoint, newParts);
906
+ console.assert(result.getEndPoint().eq(result.startPoint));
907
+ return result;
908
+ }
909
+
516
910
  private static mapPathCommand(part: PathCommand, mapping: (point: Point2)=> Point2): PathCommand {
517
911
  switch (part.kind) {
518
912
  case PathCommandType.MoveTo:
@@ -562,23 +956,112 @@ export class Path {
562
956
  return this.mapPoints(point => affineTransfm.transformVec2(point));
563
957
  }
564
958
 
959
+ /**
960
+ * @internal
961
+ */
962
+ public closedContainsPoint(point: Point2) {
963
+ const bbox = this.getExactBBox();
964
+ if (!bbox.containsPoint(point)) {
965
+ return false;
966
+ }
967
+
968
+ const pointOutside = point.plus(Vec2.of(bbox.width, 0));
969
+ const asClosed = this.asClosed();
970
+
971
+ const lineToOutside = new LineSegment2(point, pointOutside);
972
+ return asClosed.intersection(lineToOutside).length % 2 === 1;
973
+ }
974
+
565
975
  // Creates a new path by joining [other] to the end of this path
566
- public union(other: Path|null): Path {
976
+ public union(
977
+ other: Path|PathCommand[]|null,
978
+
979
+ // allowReverse: true iff reversing other or this is permitted if it means
980
+ // no moveTo command is necessary when unioning the paths.
981
+ options: { allowReverse?: boolean } = { allowReverse: true },
982
+ ): Path {
567
983
  if (!other) {
568
984
  return this;
569
985
  }
986
+ if (Array.isArray(other)) {
987
+ return new Path(this.startPoint, [...this.parts, ...other]);
988
+ }
989
+
990
+ const thisEnd = this.getEndPoint();
991
+
992
+ let newParts: Readonly<PathCommand>[] = [];
993
+ if (thisEnd.eq(other.startPoint)) {
994
+ newParts = this.parts.concat(other.parts);
995
+ } else if (options.allowReverse && this.startPoint.eq(other.getEndPoint())) {
996
+ return other.union(this, { allowReverse: false });
997
+ } else if (options.allowReverse && this.startPoint.eq(other.startPoint)) {
998
+ return this.union(other.reversed(), { allowReverse: false });
999
+ } else {
1000
+ newParts = [
1001
+ ...this.parts,
1002
+ {
1003
+ kind: PathCommandType.MoveTo,
1004
+ point: other.startPoint,
1005
+ },
1006
+ ...other.parts,
1007
+ ];
1008
+ }
1009
+ return new Path(this.startPoint, newParts);
1010
+ }
570
1011
 
571
- return new Path(this.startPoint, [
572
- ...this.parts,
1012
+ /**
1013
+ * @returns a version of this path with the direction reversed.
1014
+ *
1015
+ * Example:
1016
+ * ```ts,runnable,console
1017
+ * import {Path} from '@js-draw/math';
1018
+ * console.log(Path.fromString('m0,0l1,1').reversed()); // -> M1,1 L0,0
1019
+ * ```
1020
+ */
1021
+ public reversed() {
1022
+ const newStart = this.getEndPoint();
1023
+ const newParts: Readonly<PathCommand>[] = [];
1024
+ let lastPoint: Point2 = this.startPoint;
1025
+ for (const part of this.parts) {
1026
+ switch (part.kind) {
1027
+ case PathCommandType.LineTo:
1028
+ case PathCommandType.MoveTo:
1029
+ newParts.push({
1030
+ kind: part.kind,
1031
+ point: lastPoint,
1032
+ });
1033
+ lastPoint = part.point;
1034
+ break;
1035
+ case PathCommandType.CubicBezierTo:
1036
+ newParts.push({
1037
+ kind: part.kind,
1038
+ controlPoint1: part.controlPoint2,
1039
+ controlPoint2: part.controlPoint1,
1040
+ endPoint: lastPoint,
1041
+ });
1042
+ lastPoint = part.endPoint;
1043
+ break;
1044
+ case PathCommandType.QuadraticBezierTo:
1045
+ newParts.push({
1046
+ kind: part.kind,
1047
+ controlPoint: part.controlPoint,
1048
+ endPoint: lastPoint,
1049
+ });
1050
+ lastPoint = part.endPoint;
1051
+ break;
1052
+ default:
573
1053
  {
574
- kind: PathCommandType.MoveTo,
575
- point: other.startPoint,
576
- },
577
- ...other.parts,
578
- ]);
1054
+ const exhaustivenessCheck: never = part;
1055
+ return exhaustivenessCheck;
1056
+ }
1057
+ }
1058
+ }
1059
+ newParts.reverse();
1060
+ return new Path(newStart, newParts);
579
1061
  }
580
1062
 
581
- private getEndPoint() {
1063
+ /** Computes and returns the end point of this path */
1064
+ public getEndPoint() {
582
1065
  if (this.parts.length === 0) {
583
1066
  return this.startPoint;
584
1067
  }
@@ -633,10 +1116,12 @@ export class Path {
633
1116
  return false;
634
1117
  }
635
1118
 
636
- // Treats this as a closed path and returns true if part of `rect` is *roughly* within
637
- // this path's interior.
638
- //
639
- // Note: Assumes that this is a closed, non-self-intersecting path.
1119
+ /**
1120
+ * Treats this as a closed path and returns true if part of `rect` is *roughly* within
1121
+ * this path's interior.
1122
+ *
1123
+ * **Note**: Assumes that this is a closed, non-self-intersecting path.
1124
+ */
640
1125
  public closedRoughlyIntersects(rect: Rect2): boolean {
641
1126
  if (rect.containsRect(this.bbox)) {
642
1127
  return true;
@@ -682,6 +1167,57 @@ export class Path {
682
1167
  return false;
683
1168
  }
684
1169
 
1170
+ /** @returns true if all points on this are equivalent to the points on `other` */
1171
+ public eq(other: Path, tolerance?: number) {
1172
+ if (other.parts.length !== this.parts.length) {
1173
+ return false;
1174
+ }
1175
+
1176
+ for (let i = 0; i < this.parts.length; i++) {
1177
+ const part1 = this.parts[i];
1178
+ const part2 = other.parts[i];
1179
+
1180
+ switch (part1.kind) {
1181
+ case PathCommandType.LineTo:
1182
+ case PathCommandType.MoveTo:
1183
+ if (part1.kind !== part2.kind) {
1184
+ return false;
1185
+ } else if(!part1.point.eq(part2.point, tolerance)) {
1186
+ return false;
1187
+ }
1188
+ break;
1189
+ case PathCommandType.CubicBezierTo:
1190
+ if (part1.kind !== part2.kind) {
1191
+ return false;
1192
+ } else if (
1193
+ !part1.controlPoint1.eq(part2.controlPoint1, tolerance)
1194
+ || !part1.controlPoint2.eq(part2.controlPoint2, tolerance)
1195
+ || !part1.endPoint.eq(part2.endPoint, tolerance)
1196
+ ) {
1197
+ return false;
1198
+ }
1199
+ break;
1200
+ case PathCommandType.QuadraticBezierTo:
1201
+ if (part1.kind !== part2.kind) {
1202
+ return false;
1203
+ } else if (
1204
+ !part1.controlPoint.eq(part2.controlPoint, tolerance)
1205
+ || !part1.endPoint.eq(part2.endPoint, tolerance)
1206
+ ) {
1207
+ return false;
1208
+ }
1209
+ break;
1210
+ default:
1211
+ {
1212
+ const exhaustivenessCheck: never = part1;
1213
+ return exhaustivenessCheck;
1214
+ }
1215
+ }
1216
+ }
1217
+
1218
+ return true;
1219
+ }
1220
+
685
1221
  /**
686
1222
  * Returns a path that outlines `rect`.
687
1223
  *
@@ -851,10 +1387,8 @@ export class Path {
851
1387
  /**
852
1388
  * Create a `Path` from a subset of the SVG path specification.
853
1389
  *
854
- * ## To-do
855
- * - TODO: Support a larger subset of SVG paths
856
- * - Elliptical arcs are currently unsupported.
857
- * - TODO: Support `s`,`t` commands shorthands.
1390
+ * Currently, this does not support elliptical arcs or `s` and `t` command
1391
+ * shorthands. See https://github.com/personalizedrefrigerator/js-draw/pull/19.
858
1392
  *
859
1393
  * @example
860
1394
  * ```ts,runnable,console
@@ -865,6 +1399,8 @@ export class Path {
865
1399
  * ```
866
1400
  */
867
1401
  public static fromString(pathString: string): Path {
1402
+ // TODO: Support elliptical arcs, and the `s`, `t` command shorthands.
1403
+ //
868
1404
  // See the MDN reference:
869
1405
  // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d
870
1406
  // and
@@ -1052,6 +1588,26 @@ export class Path {
1052
1588
  return result;
1053
1589
  }
1054
1590
 
1591
+ public static fromConvexHullOf(points: Point2[]) {
1592
+ if (points.length === 0) {
1593
+ return Path.empty;
1594
+ }
1595
+
1596
+ const hull = convexHull2Of(points);
1597
+
1598
+ const commands = hull.slice(1).map((p): LinePathCommand => ({
1599
+ kind: PathCommandType.LineTo,
1600
+ point: p,
1601
+ }));
1602
+ // Close -- connect back to the start
1603
+ commands.push({
1604
+ kind: PathCommandType.LineTo,
1605
+ point: hull[0],
1606
+ });
1607
+
1608
+ return new Path(hull[0], commands);
1609
+ }
1610
+
1055
1611
  // @internal TODO: At present, this isn't really an empty path.
1056
1612
  public static empty: Path = new Path(Vec2.zero, []);
1057
1613
  }