@js-draw/math 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/cjs/Mat33.js +6 -1
  2. package/dist/cjs/Vec3.d.ts +2 -1
  3. package/dist/cjs/Vec3.js +5 -7
  4. package/dist/cjs/lib.d.ts +2 -1
  5. package/dist/cjs/lib.js +5 -1
  6. package/dist/cjs/shapes/BezierJSWrapper.d.ts +4 -0
  7. package/dist/cjs/shapes/BezierJSWrapper.js +35 -0
  8. package/dist/cjs/shapes/LineSegment2.d.ts +11 -0
  9. package/dist/cjs/shapes/LineSegment2.js +26 -1
  10. package/dist/cjs/shapes/Parameterized2DShape.d.ts +6 -1
  11. package/dist/cjs/shapes/Parameterized2DShape.js +6 -1
  12. package/dist/cjs/shapes/Path.d.ts +96 -12
  13. package/dist/cjs/shapes/Path.js +338 -15
  14. package/dist/cjs/shapes/QuadraticBezier.d.ts +2 -3
  15. package/dist/cjs/shapes/QuadraticBezier.js +2 -3
  16. package/dist/cjs/shapes/Rect2.d.ts +6 -1
  17. package/dist/cjs/shapes/Rect2.js +5 -1
  18. package/dist/cjs/utils/convexHull2Of.d.ts +9 -0
  19. package/dist/cjs/utils/convexHull2Of.js +61 -0
  20. package/dist/cjs/utils/convexHull2Of.test.d.ts +1 -0
  21. package/dist/mjs/Mat33.mjs +6 -1
  22. package/dist/mjs/Vec3.d.ts +2 -1
  23. package/dist/mjs/Vec3.mjs +5 -7
  24. package/dist/mjs/lib.d.ts +2 -1
  25. package/dist/mjs/lib.mjs +2 -1
  26. package/dist/mjs/shapes/BezierJSWrapper.d.ts +4 -0
  27. package/dist/mjs/shapes/BezierJSWrapper.mjs +35 -0
  28. package/dist/mjs/shapes/LineSegment2.d.ts +11 -0
  29. package/dist/mjs/shapes/LineSegment2.mjs +26 -1
  30. package/dist/mjs/shapes/Parameterized2DShape.d.ts +6 -1
  31. package/dist/mjs/shapes/Parameterized2DShape.mjs +6 -1
  32. package/dist/mjs/shapes/Path.d.ts +96 -12
  33. package/dist/mjs/shapes/Path.mjs +335 -14
  34. package/dist/mjs/shapes/QuadraticBezier.d.ts +2 -3
  35. package/dist/mjs/shapes/QuadraticBezier.mjs +2 -3
  36. package/dist/mjs/shapes/Rect2.d.ts +6 -1
  37. package/dist/mjs/shapes/Rect2.mjs +5 -1
  38. package/dist/mjs/utils/convexHull2Of.d.ts +9 -0
  39. package/dist/mjs/utils/convexHull2Of.mjs +59 -0
  40. package/dist/mjs/utils/convexHull2Of.test.d.ts +1 -0
  41. package/package.json +2 -2
  42. package/src/Mat33.ts +8 -2
  43. package/src/Vec3.test.ts +16 -0
  44. package/src/Vec3.ts +7 -8
  45. package/src/lib.ts +3 -0
  46. package/src/shapes/BezierJSWrapper.ts +41 -0
  47. package/src/shapes/LineSegment2.test.ts +26 -0
  48. package/src/shapes/LineSegment2.ts +31 -1
  49. package/src/shapes/Parameterized2DShape.ts +6 -1
  50. package/src/shapes/Path.test.ts +173 -5
  51. package/src/shapes/Path.ts +390 -18
  52. package/src/shapes/QuadraticBezier.test.ts +21 -0
  53. package/src/shapes/QuadraticBezier.ts +2 -3
  54. package/src/shapes/Rect2.ts +6 -2
  55. package/src/utils/convexHull2Of.test.ts +43 -0
  56. package/src/utils/convexHull2Of.ts +71 -0
@@ -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
+ });