@js-draw/math 1.17.0 → 1.18.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. package/dist/cjs/Mat33.js +6 -1
  2. package/dist/cjs/Vec3.d.ts +2 -1
  3. package/dist/cjs/Vec3.js +5 -7
  4. package/dist/cjs/lib.d.ts +2 -1
  5. package/dist/cjs/lib.js +5 -1
  6. package/dist/cjs/shapes/BezierJSWrapper.d.ts +4 -0
  7. package/dist/cjs/shapes/BezierJSWrapper.js +35 -0
  8. package/dist/cjs/shapes/LineSegment2.d.ts +11 -0
  9. package/dist/cjs/shapes/LineSegment2.js +26 -1
  10. package/dist/cjs/shapes/Parameterized2DShape.d.ts +6 -1
  11. package/dist/cjs/shapes/Parameterized2DShape.js +6 -1
  12. package/dist/cjs/shapes/Path.d.ts +96 -12
  13. package/dist/cjs/shapes/Path.js +338 -15
  14. package/dist/cjs/shapes/QuadraticBezier.d.ts +2 -3
  15. package/dist/cjs/shapes/QuadraticBezier.js +2 -3
  16. package/dist/cjs/shapes/Rect2.d.ts +6 -1
  17. package/dist/cjs/shapes/Rect2.js +5 -1
  18. package/dist/cjs/utils/convexHull2Of.d.ts +9 -0
  19. package/dist/cjs/utils/convexHull2Of.js +61 -0
  20. package/dist/cjs/utils/convexHull2Of.test.d.ts +1 -0
  21. package/dist/mjs/Mat33.mjs +6 -1
  22. package/dist/mjs/Vec3.d.ts +2 -1
  23. package/dist/mjs/Vec3.mjs +5 -7
  24. package/dist/mjs/lib.d.ts +2 -1
  25. package/dist/mjs/lib.mjs +2 -1
  26. package/dist/mjs/shapes/BezierJSWrapper.d.ts +4 -0
  27. package/dist/mjs/shapes/BezierJSWrapper.mjs +35 -0
  28. package/dist/mjs/shapes/LineSegment2.d.ts +11 -0
  29. package/dist/mjs/shapes/LineSegment2.mjs +26 -1
  30. package/dist/mjs/shapes/Parameterized2DShape.d.ts +6 -1
  31. package/dist/mjs/shapes/Parameterized2DShape.mjs +6 -1
  32. package/dist/mjs/shapes/Path.d.ts +96 -12
  33. package/dist/mjs/shapes/Path.mjs +335 -14
  34. package/dist/mjs/shapes/QuadraticBezier.d.ts +2 -3
  35. package/dist/mjs/shapes/QuadraticBezier.mjs +2 -3
  36. package/dist/mjs/shapes/Rect2.d.ts +6 -1
  37. package/dist/mjs/shapes/Rect2.mjs +5 -1
  38. package/dist/mjs/utils/convexHull2Of.d.ts +9 -0
  39. package/dist/mjs/utils/convexHull2Of.mjs +59 -0
  40. package/dist/mjs/utils/convexHull2Of.test.d.ts +1 -0
  41. package/package.json +2 -2
  42. package/src/Mat33.ts +8 -2
  43. package/src/Vec3.test.ts +16 -0
  44. package/src/Vec3.ts +7 -8
  45. package/src/lib.ts +3 -0
  46. package/src/shapes/BezierJSWrapper.ts +41 -0
  47. package/src/shapes/LineSegment2.test.ts +26 -0
  48. package/src/shapes/LineSegment2.ts +31 -1
  49. package/src/shapes/Parameterized2DShape.ts +6 -1
  50. package/src/shapes/Path.test.ts +173 -5
  51. package/src/shapes/Path.ts +390 -18
  52. package/src/shapes/QuadraticBezier.test.ts +21 -0
  53. package/src/shapes/QuadraticBezier.ts +2 -3
  54. package/src/shapes/Rect2.ts +6 -2
  55. package/src/utils/convexHull2Of.test.ts +43 -0
  56. package/src/utils/convexHull2Of.ts +71 -0
@@ -8,6 +8,8 @@ import PointShape2D from './PointShape2D';
8
8
  import toRoundedString from '../rounding/toRoundedString';
9
9
  import toStringOfSamePrecision from '../rounding/toStringOfSamePrecision';
10
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,
@@ -47,13 +49,22 @@ export interface IntersectionResult {
47
49
  // @internal
48
50
  curveIndex: number;
49
51
 
50
- /** Parameter value for the closest point **on** the path to the intersection. @internal @deprecated */
51
- parameterValue?: number;
52
+ /** Parameter value for the closest point **on** the path to the intersection. @internal */
53
+ parameterValue: number;
52
54
 
53
55
  /** Point at which the intersection occured. */
54
56
  point: Point2;
55
57
  }
56
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
+
57
68
  /**
58
69
  * Allows indexing a particular part of a path.
59
70
  *
@@ -64,8 +75,64 @@ export interface CurveIndexRecord {
64
75
  parameterValue: number;
65
76
  }
66
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
+
67
106
  /**
68
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
+ * ```
69
136
  */
70
137
  export class Path {
71
138
  /**
@@ -100,6 +167,12 @@ export class Path {
100
167
  }
101
168
  }
102
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
+ */
103
176
  public getExactBBox(): Rect2 {
104
177
  const bboxes: Rect2[] = [];
105
178
  for (const part of this.geometry) {
@@ -249,7 +322,20 @@ export class Path {
249
322
  return Rect2.bboxOf(points);
250
323
  }
251
324
 
252
- /** **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
+ */
253
339
  public signedDistance(point: Point2, strokeRadius: number) {
254
340
  let minDist = Infinity;
255
341
 
@@ -367,7 +453,7 @@ export class Path {
367
453
 
368
454
 
369
455
  // Raymarch:
370
- const maxRaymarchSteps = 7;
456
+ const maxRaymarchSteps = 8;
371
457
 
372
458
  // Start raymarching from each of these points. This allows detection of multiple
373
459
  // intersections.
@@ -458,7 +544,7 @@ export class Path {
458
544
  if (lastPart && isOnLineSegment && Math.abs(lastDist) < stoppingThreshold) {
459
545
  result.push({
460
546
  point: currentPoint,
461
- parameterValue: NaN,// lastPart.nearestPointTo(currentPoint).parameterValue,
547
+ parameterValue: lastPart.nearestPointTo(currentPoint).parameterValue,
462
548
  curve: lastPart,
463
549
  curveIndex: this.geometry.indexOf(lastPart),
464
550
  });
@@ -507,6 +593,10 @@ export class Path {
507
593
  return [];
508
594
  }
509
595
 
596
+ if (this.parts.length === 0) {
597
+ return new Path(this.startPoint, [{ kind: PathCommandType.MoveTo, point: this.startPoint }]).intersection(line, strokeRadius);
598
+ }
599
+
510
600
  let index = 0;
511
601
  for (const part of this.geometry) {
512
602
  const intersections = part.argIntersectsLineSegment(line);
@@ -538,9 +628,6 @@ export class Path {
538
628
 
539
629
  /**
540
630
  * @returns the nearest point on this path to the given `point`.
541
- *
542
- * @internal
543
- * @beta
544
631
  */
545
632
  public nearestPointTo(point: Point2): IntersectionResult {
546
633
  // Find the closest point on this
@@ -570,6 +657,9 @@ export class Path {
570
657
  }
571
658
 
572
659
  public at(index: CurveIndexRecord) {
660
+ if (index.curveIndex === 0 && index.parameterValue === 0) {
661
+ return this.startPoint;
662
+ }
573
663
  return this.geometry[index.curveIndex].at(index.parameterValue);
574
664
  }
575
665
 
@@ -577,6 +667,246 @@ export class Path {
577
667
  return this.geometry[index.curveIndex].tangentAt(index.parameterValue);
578
668
  }
579
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
+
580
910
  private static mapPathCommand(part: PathCommand, mapping: (point: Point2)=> Point2): PathCommand {
581
911
  switch (part.kind) {
582
912
  case PathCommandType.MoveTo:
@@ -626,9 +956,25 @@ export class Path {
626
956
  return this.mapPoints(point => affineTransfm.transformVec2(point));
627
957
  }
628
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
+
629
975
  // Creates a new path by joining [other] to the end of this path
630
976
  public union(
631
- other: Path|null,
977
+ other: Path|PathCommand[]|null,
632
978
 
633
979
  // allowReverse: true iff reversing other or this is permitted if it means
634
980
  // no moveTo command is necessary when unioning the paths.
@@ -637,6 +983,9 @@ export class Path {
637
983
  if (!other) {
638
984
  return this;
639
985
  }
986
+ if (Array.isArray(other)) {
987
+ return new Path(this.startPoint, [...this.parts, ...other]);
988
+ }
640
989
 
641
990
  const thisEnd = this.getEndPoint();
642
991
 
@@ -711,7 +1060,8 @@ export class Path {
711
1060
  return new Path(newStart, newParts);
712
1061
  }
713
1062
 
714
- private getEndPoint() {
1063
+ /** Computes and returns the end point of this path */
1064
+ public getEndPoint() {
715
1065
  if (this.parts.length === 0) {
716
1066
  return this.startPoint;
717
1067
  }
@@ -766,10 +1116,12 @@ export class Path {
766
1116
  return false;
767
1117
  }
768
1118
 
769
- // Treats this as a closed path and returns true if part of `rect` is *roughly* within
770
- // this path's interior.
771
- //
772
- // 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
+ */
773
1125
  public closedRoughlyIntersects(rect: Rect2): boolean {
774
1126
  if (rect.containsRect(this.bbox)) {
775
1127
  return true;
@@ -1035,10 +1387,8 @@ export class Path {
1035
1387
  /**
1036
1388
  * Create a `Path` from a subset of the SVG path specification.
1037
1389
  *
1038
- * ## To-do
1039
- * - TODO: Support a larger subset of SVG paths
1040
- * - Elliptical arcs are currently unsupported.
1041
- * - 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.
1042
1392
  *
1043
1393
  * @example
1044
1394
  * ```ts,runnable,console
@@ -1049,6 +1399,8 @@ export class Path {
1049
1399
  * ```
1050
1400
  */
1051
1401
  public static fromString(pathString: string): Path {
1402
+ // TODO: Support elliptical arcs, and the `s`, `t` command shorthands.
1403
+ //
1052
1404
  // See the MDN reference:
1053
1405
  // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d
1054
1406
  // and
@@ -1236,6 +1588,26 @@ export class Path {
1236
1588
  return result;
1237
1589
  }
1238
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
+
1239
1611
  // @internal TODO: At present, this isn't really an empty path.
1240
1612
  public static empty: Path = new Path(Vec2.zero, []);
1241
1613
  }
@@ -37,6 +37,9 @@ describe('QuadraticBezier', () => {
37
37
  // Should not return an out-of-range parameter
38
38
  [ new QuadraticBezier(Vec2.zero, Vec2.of(0, 0.5), Vec2.unitY), Vec2.of(0, -1000), 0 ],
39
39
  [ new QuadraticBezier(Vec2.zero, Vec2.of(0, 0.5), Vec2.unitY), Vec2.of(0, 1000), 1 ],
40
+
41
+ // Edge case -- just a point
42
+ [ new QuadraticBezier(Vec2.zero, Vec2.zero, Vec2.zero), Vec2.of(0, 1000), 0 ],
40
43
  ])('nearestPointTo should return the nearest point and parameter value on %s to %s', (bezier, point, expectedParameter) => {
41
44
  const nearest = bezier.nearestPointTo(point);
42
45
  expect(nearest.parameterValue).toBeCloseTo(expectedParameter, 0.0001);
@@ -64,4 +67,22 @@ describe('QuadraticBezier', () => {
64
67
  }
65
68
  }
66
69
  });
70
+
71
+ test.each([
72
+ new QuadraticBezier(Vec2.zero, Vec2.unitY, Vec2.unitY.times(2)),
73
+ new QuadraticBezier(Vec2.zero, Vec2.unitX, Vec2.unitY),
74
+ new QuadraticBezier(Vec2.zero, Vec2.unitY, Vec2.unitX),
75
+ ])('.derivativeAt should return a derivative vector with the correct direction (curve: %s)', (curve) => {
76
+ for (let t = 0; t < 1; t += 0.1) {
77
+ const derivative = curve.derivativeAt(t);
78
+ const derivativeApprox = curve.at(t + 0.001).minus(curve.at(t - 0.001));
79
+ expect(derivativeApprox.normalized()).objEq(derivative.normalized(), 0.01);
80
+ }
81
+ });
82
+
83
+ test('should support Bezier-Bezier intersections', () => {
84
+ const b1 = new QuadraticBezier(Vec2.zero, Vec2.unitX, Vec2.unitY);
85
+ const b2 = new QuadraticBezier(Vec2.of(-1, 0.5), Vec2.of(0, 0.6), Vec2.of(1, 0.4));
86
+ expect(b1.intersectsBezier(b2)).toHaveLength(1);
87
+ });
67
88
  });
@@ -4,10 +4,9 @@ import BezierJSWrapper from './BezierJSWrapper';
4
4
  import Rect2 from './Rect2';
5
5
 
6
6
  /**
7
- * A wrapper around `bezier-js`'s quadratic Bézier.
7
+ * Represents a 2D Bézier curve.
8
8
  *
9
- * This wrappper lazy-loads `bezier-js`'s Bézier and can perform some operations
10
- * without loading it at all (e.g. `normal`, `at`, and `approximateDistance`).
9
+ * **Note**: Many Bézier operations use `bezier-js`'s.
11
10
  */
12
11
  export class QuadraticBezier extends BezierJSWrapper {
13
12
  public constructor(
@@ -4,7 +4,7 @@ import { Point2, Vec2 } from '../Vec2';
4
4
  import Abstract2DShape from './Abstract2DShape';
5
5
  import Vec3 from '../Vec3';
6
6
 
7
- /** An object that can be converted to a Rect2. */
7
+ /** An object that can be converted to a {@link Rect2}. */
8
8
  export interface RectTemplate {
9
9
  x: number;
10
10
  y: number;
@@ -14,7 +14,11 @@ export interface RectTemplate {
14
14
  height?: number;
15
15
  }
16
16
 
17
- // invariant: w ≥ 0, h ≥ 0, immutable
17
+ /**
18
+ * Represents a rectangle in 2D space, parallel to the XY axes.
19
+ *
20
+ * `invariant: w ≥ 0, h ≥ 0, immutable`
21
+ */
18
22
  export class Rect2 extends Abstract2DShape {
19
23
  // Derived state:
20
24
 
@@ -0,0 +1,43 @@
1
+ import { Vec2 } from '../Vec2';
2
+ import { Rect2 } from '../shapes/Rect2';
3
+ import convexHull2Of from './convexHull2Of';
4
+
5
+ describe('convexHull2Of', () => {
6
+ it.each([
7
+ [ [ Vec2.of(1, 1) ] , [ Vec2.of(1, 1) ] ],
8
+
9
+ // Line
10
+ [ [ Vec2.of(1, 1), Vec2.of(2, 2) ] , [ Vec2.of(1, 1), Vec2.of(2, 2) ] ],
11
+
12
+ // Just a triangle
13
+ [ [ Vec2.of(1, 1), Vec2.of(4, 2), Vec2.of(3, 3) ] , [ Vec2.of(1, 1), Vec2.of(4, 2), Vec2.of(3, 3) ]],
14
+
15
+ // Triangle with an extra point
16
+ [ [ Vec2.of(1, 1), Vec2.of(2, 20), Vec2.of(3, 5), Vec2.of(4, 3) ] , [ Vec2.of(1, 1), Vec2.of(4, 3), Vec2.of(2, 20) ]],
17
+
18
+ // Points within a triangle
19
+ [
20
+ [ Vec2.of(28, 5), Vec2.of(4, 5), Vec2.of(-100, -100), Vec2.of(7, 120), Vec2.of(1, 8), Vec2.of(100, -100), Vec2.of(2, 4), Vec2.of(3, 4), Vec2.of(4, 5) ],
21
+ [ Vec2.of(-100, -100), Vec2.of(100, -100), Vec2.of(7, 120) ],
22
+ ],
23
+
24
+ // Points within a triangle (repeated vertex)
25
+ [
26
+ [ Vec2.of(28, 5), Vec2.of(4, 5), Vec2.of(-100, -100), Vec2.of(-100, -100), Vec2.of(7, 120), Vec2.of(1, 8), Vec2.of(100, -100), Vec2.of(2, 4), Vec2.of(3, 4), Vec2.of(4, 5) ],
27
+ [ Vec2.of(-100, -100), Vec2.of(100, -100), Vec2.of(7, 120) ],
28
+ ],
29
+
30
+ // Points within a square
31
+ [
32
+ [ Vec2.of(28, 5), Vec2.of(4, 5), Vec2.of(-100, -100), Vec2.of(100, 100), Vec2.of(7, 100), Vec2.of(1, 8), Vec2.of(-100, 100), Vec2.of(100, -100), Vec2.of(2, 4), Vec2.of(3, 4), Vec2.of(4, 5) ],
33
+ [ Vec2.of(-100, -100), Vec2.of(100, -100), Vec2.of(100, 100), Vec2.of(-100, 100) ],
34
+ ],
35
+
36
+ [
37
+ Rect2.unitSquare.corners,
38
+ [ Vec2.of(1, 0), Vec2.of(1, 1), Vec2.of(0, 1), Vec2.of(0, 0) ],
39
+ ]
40
+ ])('should compute the convex hull of a set of points (%j)', (points, expected) => {
41
+ expect(convexHull2Of(points)).toMatchObject(expected);
42
+ });
43
+ });