@js-draw/math 1.16.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 (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
  }