@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
@@ -6,6 +6,7 @@ import QuadraticBezier from './QuadraticBezier.mjs';
6
6
  import PointShape2D from './PointShape2D.mjs';
7
7
  import toRoundedString from '../rounding/toRoundedString.mjs';
8
8
  import toStringOfSamePrecision from '../rounding/toStringOfSamePrecision.mjs';
9
+ import convexHull2Of from '../utils/convexHull2Of.mjs';
9
10
  export var PathCommandType;
10
11
  (function (PathCommandType) {
11
12
  PathCommandType[PathCommandType["LineTo"] = 0] = "LineTo";
@@ -13,8 +14,62 @@ export var PathCommandType;
13
14
  PathCommandType[PathCommandType["CubicBezierTo"] = 2] = "CubicBezierTo";
14
15
  PathCommandType[PathCommandType["QuadraticBezierTo"] = 3] = "QuadraticBezierTo";
15
16
  })(PathCommandType || (PathCommandType = {}));
17
+ /** Returns a positive number if `a` comes after `b`, 0 if equal, and negative otherwise. */
18
+ export const compareCurveIndices = (a, b) => {
19
+ const indexCompare = a.curveIndex - b.curveIndex;
20
+ if (indexCompare === 0) {
21
+ return a.parameterValue - b.parameterValue;
22
+ }
23
+ else {
24
+ return indexCompare;
25
+ }
26
+ };
27
+ /**
28
+ * Returns a version of `index` with its parameter value incremented by `stepBy`
29
+ * (which can be either positive or negative).
30
+ */
31
+ export const stepCurveIndexBy = (index, stepBy) => {
32
+ if (index.parameterValue + stepBy > 1) {
33
+ return { curveIndex: index.curveIndex + 1, parameterValue: index.parameterValue + stepBy - 1 };
34
+ }
35
+ if (index.parameterValue + stepBy < 0) {
36
+ if (index.curveIndex === 0) {
37
+ return { curveIndex: 0, parameterValue: 0 };
38
+ }
39
+ return { curveIndex: index.curveIndex - 1, parameterValue: index.parameterValue + stepBy + 1 };
40
+ }
41
+ return { curveIndex: index.curveIndex, parameterValue: index.parameterValue + stepBy };
42
+ };
16
43
  /**
17
44
  * Represents a union of lines and curves.
45
+ *
46
+ * To create a path from a string, see {@link fromString}.
47
+ *
48
+ * @example
49
+ * ```ts,runnable,console
50
+ * import {Path, Mat33, Vec2, LineSegment2} from '@js-draw/math';
51
+ *
52
+ * // Creates a path from an SVG path string.
53
+ * // In this case,
54
+ * // 1. Move to (0,0)
55
+ * // 2. Line to (100,0)
56
+ * const path = Path.fromString('M0,0 L100,0');
57
+ *
58
+ * // Logs the distance from (10,0) to the curve 1 unit
59
+ * // away from path. This curve forms a stroke with the path at
60
+ * // its center.
61
+ * const strokeRadius = 1;
62
+ * console.log(path.signedDistance(Vec2.of(10,0), strokeRadius));
63
+ *
64
+ * // Log a version of the path that's scaled by a factor of 4.
65
+ * console.log(path.transformedBy(Mat33.scaling2D(4)).toString());
66
+ *
67
+ * // Log all intersections of a stroked version of the path with
68
+ * // a vertical line segment.
69
+ * // (Try removing the `strokeRadius` parameter).
70
+ * const segment = new LineSegment2(Vec2.of(5, -100), Vec2.of(5, 100));
71
+ * console.log(path.intersection(segment, strokeRadius).map(i => i.point));
72
+ * ```
18
73
  */
19
74
  export class Path {
20
75
  /**
@@ -37,6 +92,12 @@ export class Path {
37
92
  this.bbox = this.bbox.union(Path.computeBBoxForSegment(startPoint, part));
38
93
  }
39
94
  }
95
+ /**
96
+ * Computes and returns the full bounding box for this path.
97
+ *
98
+ * If a slight over-estimate of a path's bounding box is sufficient, use
99
+ * {@link bbox} instead.
100
+ */
40
101
  getExactBBox() {
41
102
  const bboxes = [];
42
103
  for (const part of this.geometry) {
@@ -155,7 +216,20 @@ export class Path {
155
216
  }
156
217
  return Rect2.bboxOf(points);
157
218
  }
158
- /** **Note**: `strokeRadius = strokeWidth / 2` */
219
+ /**
220
+ * Returns the signed distance between `point` and a curve `strokeRadius` units
221
+ * away from this path.
222
+ *
223
+ * This returns the **signed distance**, which means that points inside this shape
224
+ * have their distance negated. For example,
225
+ * ```ts,runnable,console
226
+ * import {Path, Vec2} from '@js-draw/math';
227
+ * console.log(Path.fromString('m0,0 L100,0').signedDistance(Vec2.zero, 1));
228
+ * ```
229
+ * would print `-1` because (0,0) is on `m0,0 L100,0` and thus one unit away from its boundary.
230
+ *
231
+ * **Note**: `strokeRadius = strokeWidth / 2`
232
+ */
159
233
  signedDistance(point, strokeRadius) {
160
234
  let minDist = Infinity;
161
235
  for (const part of this.geometry) {
@@ -242,7 +316,7 @@ export class Path {
242
316
  return [minDistPart, minDist - strokeRadius];
243
317
  };
244
318
  // Raymarch:
245
- const maxRaymarchSteps = 7;
319
+ const maxRaymarchSteps = 8;
246
320
  // Start raymarching from each of these points. This allows detection of multiple
247
321
  // intersections.
248
322
  const startPoints = [
@@ -312,7 +386,7 @@ export class Path {
312
386
  if (lastPart && isOnLineSegment && Math.abs(lastDist) < stoppingThreshold) {
313
387
  result.push({
314
388
  point: currentPoint,
315
- parameterValue: NaN, // lastPart.nearestPointTo(currentPoint).parameterValue,
389
+ parameterValue: lastPart.nearestPointTo(currentPoint).parameterValue,
316
390
  curve: lastPart,
317
391
  curveIndex: this.geometry.indexOf(lastPart),
318
392
  });
@@ -352,6 +426,9 @@ export class Path {
352
426
  if (!line.bbox.intersects(this.bbox.grownBy(strokeRadius ?? 0))) {
353
427
  return [];
354
428
  }
429
+ if (this.parts.length === 0) {
430
+ return new Path(this.startPoint, [{ kind: PathCommandType.MoveTo, point: this.startPoint }]).intersection(line, strokeRadius);
431
+ }
355
432
  let index = 0;
356
433
  for (const part of this.geometry) {
357
434
  const intersections = part.argIntersectsLineSegment(line);
@@ -378,9 +455,6 @@ export class Path {
378
455
  }
379
456
  /**
380
457
  * @returns the nearest point on this path to the given `point`.
381
- *
382
- * @internal
383
- * @beta
384
458
  */
385
459
  nearestPointTo(point) {
386
460
  // Find the closest point on this
@@ -407,11 +481,223 @@ export class Path {
407
481
  };
408
482
  }
409
483
  at(index) {
484
+ if (index.curveIndex === 0 && index.parameterValue === 0) {
485
+ return this.startPoint;
486
+ }
410
487
  return this.geometry[index.curveIndex].at(index.parameterValue);
411
488
  }
412
489
  tangentAt(index) {
413
490
  return this.geometry[index.curveIndex].tangentAt(index.parameterValue);
414
491
  }
492
+ /** Splits this path in two near the given `point`. */
493
+ splitNear(point, options) {
494
+ const nearest = this.nearestPointTo(point);
495
+ return this.splitAt(nearest, options);
496
+ }
497
+ /**
498
+ * Returns a copy of this path with `deleteFrom` until `deleteUntil` replaced with `insert`.
499
+ *
500
+ * This method is analogous to {@link Array.toSpliced}.
501
+ */
502
+ spliced(deleteFrom, deleteTo, insert, options) {
503
+ const isBeforeOrEqual = (a, b) => {
504
+ return a.curveIndex < b.curveIndex || (a.curveIndex === b.curveIndex && a.parameterValue <= b.parameterValue);
505
+ };
506
+ if (isBeforeOrEqual(deleteFrom, deleteTo)) {
507
+ // deleteFrom deleteTo
508
+ // <---------| |-------------->
509
+ // x x
510
+ // startPoint endPoint
511
+ const firstSplit = this.splitAt(deleteFrom, options);
512
+ const secondSplit = this.splitAt(deleteTo, options);
513
+ const before = firstSplit[0];
514
+ const after = secondSplit[secondSplit.length - 1];
515
+ return insert ? before.union(insert).union(after) : before.union(after);
516
+ }
517
+ else {
518
+ // In this case, we need to handle wrapping at the start/end.
519
+ // deleteTo deleteFrom
520
+ // <---------| keep |-------------->
521
+ // x x
522
+ // startPoint endPoint
523
+ const splitAtFrom = this.splitAt([deleteFrom], options);
524
+ const beforeFrom = splitAtFrom[0];
525
+ // We need splitNear, rather than splitAt, because beforeFrom does not have
526
+ // the same indexing as this.
527
+ const splitAtTo = beforeFrom.splitNear(this.at(deleteTo), options);
528
+ const betweenBoth = splitAtTo[splitAtTo.length - 1];
529
+ return insert ? betweenBoth.union(insert) : betweenBoth;
530
+ }
531
+ }
532
+ // @internal
533
+ splitAt(splitAt, options) {
534
+ if (!Array.isArray(splitAt)) {
535
+ splitAt = [splitAt];
536
+ }
537
+ splitAt = [...splitAt];
538
+ splitAt.sort(compareCurveIndices);
539
+ //
540
+ // Bounds checking & reversal.
541
+ //
542
+ while (splitAt.length > 0
543
+ && splitAt[splitAt.length - 1].curveIndex >= this.parts.length - 1
544
+ && splitAt[splitAt.length - 1].parameterValue >= 1) {
545
+ splitAt.pop();
546
+ }
547
+ splitAt.reverse(); // .reverse() <-- We're `.pop`ing from the end
548
+ while (splitAt.length > 0
549
+ && splitAt[splitAt.length - 1].curveIndex <= 0
550
+ && splitAt[splitAt.length - 1].parameterValue <= 0) {
551
+ splitAt.pop();
552
+ }
553
+ if (splitAt.length === 0 || this.parts.length === 0) {
554
+ return [this];
555
+ }
556
+ const expectedSplitCount = splitAt.length + 1;
557
+ const mapNewPoint = options?.mapNewPoint ?? ((p) => p);
558
+ const result = [];
559
+ let currentStartPoint = this.startPoint;
560
+ let currentPath = [];
561
+ //
562
+ // Splitting
563
+ //
564
+ let { curveIndex, parameterValue } = splitAt.pop();
565
+ for (let i = 0; i < this.parts.length; i++) {
566
+ if (i !== curveIndex) {
567
+ currentPath.push(this.parts[i]);
568
+ }
569
+ else {
570
+ let part = this.parts[i];
571
+ let geom = this.geometry[i];
572
+ while (i === curveIndex) {
573
+ let newPathStart;
574
+ const newPath = [];
575
+ switch (part.kind) {
576
+ case PathCommandType.MoveTo:
577
+ currentPath.push({
578
+ kind: part.kind,
579
+ point: part.point,
580
+ });
581
+ newPathStart = part.point;
582
+ break;
583
+ case PathCommandType.LineTo:
584
+ {
585
+ const split = geom.splitAt(parameterValue);
586
+ currentPath.push({
587
+ kind: part.kind,
588
+ point: mapNewPoint(split[0].p2),
589
+ });
590
+ newPathStart = split[0].p2;
591
+ if (split.length > 1) {
592
+ console.assert(split.length === 2);
593
+ newPath.push({
594
+ kind: part.kind,
595
+ // Don't map: For lines, the end point of the split is
596
+ // the same as the end point of the original:
597
+ point: split[1].p2,
598
+ });
599
+ geom = split[1];
600
+ }
601
+ }
602
+ break;
603
+ case PathCommandType.QuadraticBezierTo:
604
+ case PathCommandType.CubicBezierTo:
605
+ {
606
+ const split = geom.splitAt(parameterValue);
607
+ let isFirstPart = split.length === 2;
608
+ for (const segment of split) {
609
+ geom = segment;
610
+ const targetArray = isFirstPart ? currentPath : newPath;
611
+ const controlPoints = segment.getPoints();
612
+ if (part.kind === PathCommandType.CubicBezierTo) {
613
+ targetArray.push({
614
+ kind: part.kind,
615
+ controlPoint1: mapNewPoint(controlPoints[1]),
616
+ controlPoint2: mapNewPoint(controlPoints[2]),
617
+ endPoint: mapNewPoint(controlPoints[3]),
618
+ });
619
+ }
620
+ else {
621
+ targetArray.push({
622
+ kind: part.kind,
623
+ controlPoint: mapNewPoint(controlPoints[1]),
624
+ endPoint: mapNewPoint(controlPoints[2]),
625
+ });
626
+ }
627
+ // We want the start of the new path to match the start of the
628
+ // FIRST Bézier in the NEW path.
629
+ if (!isFirstPart) {
630
+ newPathStart = controlPoints[0];
631
+ }
632
+ isFirstPart = false;
633
+ }
634
+ }
635
+ break;
636
+ default: {
637
+ const exhaustivenessCheck = part;
638
+ return exhaustivenessCheck;
639
+ }
640
+ }
641
+ result.push(new Path(currentStartPoint, [...currentPath]));
642
+ currentStartPoint = mapNewPoint(newPathStart);
643
+ console.assert(!!currentStartPoint, 'should have a start point');
644
+ currentPath = newPath;
645
+ part = newPath[newPath.length - 1] ?? part;
646
+ const nextSplit = splitAt.pop();
647
+ if (!nextSplit) {
648
+ break;
649
+ }
650
+ else {
651
+ curveIndex = nextSplit.curveIndex;
652
+ if (i === curveIndex) {
653
+ const originalPoint = this.at(nextSplit);
654
+ parameterValue = geom.nearestPointTo(originalPoint).parameterValue;
655
+ currentPath = [];
656
+ }
657
+ else {
658
+ parameterValue = nextSplit.parameterValue;
659
+ }
660
+ }
661
+ }
662
+ }
663
+ }
664
+ result.push(new Path(currentStartPoint, currentPath));
665
+ console.assert(result.length === expectedSplitCount, `should split into splitAt.length + 1 splits (was ${result.length}, expected ${expectedSplitCount})`);
666
+ return result;
667
+ }
668
+ /**
669
+ * Replaces all `MoveTo` commands with `LineTo` commands and connects the end point of this
670
+ * path to the start point.
671
+ */
672
+ asClosed() {
673
+ const newParts = [];
674
+ let hasChanges = false;
675
+ for (const part of this.parts) {
676
+ if (part.kind === PathCommandType.MoveTo) {
677
+ newParts.push({
678
+ kind: PathCommandType.LineTo,
679
+ point: part.point,
680
+ });
681
+ hasChanges = true;
682
+ }
683
+ else {
684
+ newParts.push(part);
685
+ }
686
+ }
687
+ if (!this.getEndPoint().eq(this.startPoint)) {
688
+ newParts.push({
689
+ kind: PathCommandType.LineTo,
690
+ point: this.startPoint,
691
+ });
692
+ hasChanges = true;
693
+ }
694
+ if (!hasChanges) {
695
+ return this;
696
+ }
697
+ const result = new Path(this.startPoint, newParts);
698
+ console.assert(result.getEndPoint().eq(result.startPoint));
699
+ return result;
700
+ }
415
701
  static mapPathCommand(part, mapping) {
416
702
  switch (part.kind) {
417
703
  case PathCommandType.MoveTo:
@@ -454,6 +740,19 @@ export class Path {
454
740
  }
455
741
  return this.mapPoints(point => affineTransfm.transformVec2(point));
456
742
  }
743
+ /**
744
+ * @internal
745
+ */
746
+ closedContainsPoint(point) {
747
+ const bbox = this.getExactBBox();
748
+ if (!bbox.containsPoint(point)) {
749
+ return false;
750
+ }
751
+ const pointOutside = point.plus(Vec2.of(bbox.width, 0));
752
+ const asClosed = this.asClosed();
753
+ const lineToOutside = new LineSegment2(point, pointOutside);
754
+ return asClosed.intersection(lineToOutside).length % 2 === 1;
755
+ }
457
756
  // Creates a new path by joining [other] to the end of this path
458
757
  union(other,
459
758
  // allowReverse: true iff reversing other or this is permitted if it means
@@ -462,6 +761,9 @@ export class Path {
462
761
  if (!other) {
463
762
  return this;
464
763
  }
764
+ if (Array.isArray(other)) {
765
+ return new Path(this.startPoint, [...this.parts, ...other]);
766
+ }
465
767
  const thisEnd = this.getEndPoint();
466
768
  let newParts = [];
467
769
  if (thisEnd.eq(other.startPoint)) {
@@ -535,6 +837,7 @@ export class Path {
535
837
  newParts.reverse();
536
838
  return new Path(newStart, newParts);
537
839
  }
840
+ /** Computes and returns the end point of this path */
538
841
  getEndPoint() {
539
842
  if (this.parts.length === 0) {
540
843
  return this.startPoint;
@@ -584,10 +887,12 @@ export class Path {
584
887
  }
585
888
  return false;
586
889
  }
587
- // Treats this as a closed path and returns true if part of `rect` is *roughly* within
588
- // this path's interior.
589
- //
590
- // Note: Assumes that this is a closed, non-self-intersecting path.
890
+ /**
891
+ * Treats this as a closed path and returns true if part of `rect` is *roughly* within
892
+ * this path's interior.
893
+ *
894
+ * **Note**: Assumes that this is a closed, non-self-intersecting path.
895
+ */
591
896
  closedRoughlyIntersects(rect) {
592
897
  if (rect.containsRect(this.bbox)) {
593
898
  return true;
@@ -813,10 +1118,8 @@ export class Path {
813
1118
  /**
814
1119
  * Create a `Path` from a subset of the SVG path specification.
815
1120
  *
816
- * ## To-do
817
- * - TODO: Support a larger subset of SVG paths
818
- * - Elliptical arcs are currently unsupported.
819
- * - TODO: Support `s`,`t` commands shorthands.
1121
+ * Currently, this does not support elliptical arcs or `s` and `t` command
1122
+ * shorthands. See https://github.com/personalizedrefrigerator/js-draw/pull/19.
820
1123
  *
821
1124
  * @example
822
1125
  * ```ts,runnable,console
@@ -827,6 +1130,8 @@ export class Path {
827
1130
  * ```
828
1131
  */
829
1132
  static fromString(pathString) {
1133
+ // TODO: Support elliptical arcs, and the `s`, `t` command shorthands.
1134
+ //
830
1135
  // See the MDN reference:
831
1136
  // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d
832
1137
  // and
@@ -994,6 +1299,22 @@ export class Path {
994
1299
  result.cachedStringVersion = pathString;
995
1300
  return result;
996
1301
  }
1302
+ static fromConvexHullOf(points) {
1303
+ if (points.length === 0) {
1304
+ return Path.empty;
1305
+ }
1306
+ const hull = convexHull2Of(points);
1307
+ const commands = hull.slice(1).map((p) => ({
1308
+ kind: PathCommandType.LineTo,
1309
+ point: p,
1310
+ }));
1311
+ // Close -- connect back to the start
1312
+ commands.push({
1313
+ kind: PathCommandType.LineTo,
1314
+ point: hull[0],
1315
+ });
1316
+ return new Path(hull[0], commands);
1317
+ }
997
1318
  }
998
1319
  // @internal TODO: At present, this isn't really an empty path.
999
1320
  Path.empty = new Path(Vec2.zero, []);
@@ -2,10 +2,9 @@ import { Point2, Vec2 } from '../Vec2';
2
2
  import BezierJSWrapper from './BezierJSWrapper';
3
3
  import Rect2 from './Rect2';
4
4
  /**
5
- * A wrapper around `bezier-js`'s quadratic Bézier.
5
+ * Represents a 2D Bézier curve.
6
6
  *
7
- * This wrappper lazy-loads `bezier-js`'s Bézier and can perform some operations
8
- * without loading it at all (e.g. `normal`, `at`, and `approximateDistance`).
7
+ * **Note**: Many Bézier operations use `bezier-js`'s.
9
8
  */
10
9
  export declare class QuadraticBezier extends BezierJSWrapper {
11
10
  readonly p0: Point2;
@@ -3,10 +3,9 @@ import solveQuadratic from '../polynomial/solveQuadratic.mjs';
3
3
  import BezierJSWrapper from './BezierJSWrapper.mjs';
4
4
  import Rect2 from './Rect2.mjs';
5
5
  /**
6
- * A wrapper around `bezier-js`'s quadratic Bézier.
6
+ * Represents a 2D Bézier curve.
7
7
  *
8
- * This wrappper lazy-loads `bezier-js`'s Bézier and can perform some operations
9
- * without loading it at all (e.g. `normal`, `at`, and `approximateDistance`).
8
+ * **Note**: Many Bézier operations use `bezier-js`'s.
10
9
  */
11
10
  export class QuadraticBezier extends BezierJSWrapper {
12
11
  constructor(p0, p1, p2) {
@@ -3,7 +3,7 @@ import Mat33 from '../Mat33';
3
3
  import { Point2, Vec2 } from '../Vec2';
4
4
  import Abstract2DShape from './Abstract2DShape';
5
5
  import Vec3 from '../Vec3';
6
- /** An object that can be converted to a Rect2. */
6
+ /** An object that can be converted to a {@link Rect2}. */
7
7
  export interface RectTemplate {
8
8
  x: number;
9
9
  y: number;
@@ -12,6 +12,11 @@ export interface RectTemplate {
12
12
  width?: number;
13
13
  height?: number;
14
14
  }
15
+ /**
16
+ * Represents a rectangle in 2D space, parallel to the XY axes.
17
+ *
18
+ * `invariant: w ≥ 0, h ≥ 0, immutable`
19
+ */
15
20
  export declare class Rect2 extends Abstract2DShape {
16
21
  readonly x: number;
17
22
  readonly y: number;
@@ -1,7 +1,11 @@
1
1
  import LineSegment2 from './LineSegment2.mjs';
2
2
  import { Vec2 } from '../Vec2.mjs';
3
3
  import Abstract2DShape from './Abstract2DShape.mjs';
4
- // invariant: w ≥ 0, h ≥ 0, immutable
4
+ /**
5
+ * Represents a rectangle in 2D space, parallel to the XY axes.
6
+ *
7
+ * `invariant: w ≥ 0, h ≥ 0, immutable`
8
+ */
5
9
  export class Rect2 extends Abstract2DShape {
6
10
  constructor(x, y, w, h) {
7
11
  super();
@@ -0,0 +1,9 @@
1
+ import { Point2 } from '../Vec2';
2
+ /**
3
+ * Implements Gift Wrapping, in $O(nh)$. This algorithm is not the most efficient in the worst case.
4
+ *
5
+ * See https://en.wikipedia.org/wiki/Gift_wrapping_algorithm
6
+ * and https://www.cs.jhu.edu/~misha/Spring16/06.pdf
7
+ */
8
+ declare const convexHull2Of: (points: Point2[]) => import("../Vec3").Vec3[];
9
+ export default convexHull2Of;
@@ -0,0 +1,59 @@
1
+ import { Vec2 } from '../Vec2.mjs';
2
+ /**
3
+ * Implements Gift Wrapping, in $O(nh)$. This algorithm is not the most efficient in the worst case.
4
+ *
5
+ * See https://en.wikipedia.org/wiki/Gift_wrapping_algorithm
6
+ * and https://www.cs.jhu.edu/~misha/Spring16/06.pdf
7
+ */
8
+ const convexHull2Of = (points) => {
9
+ if (points.length === 0) {
10
+ return [];
11
+ }
12
+ // 1. Start with a vertex on the hull
13
+ const lowestPoint = points.reduce((lowest, current) => current.y < lowest.y ? current : lowest, points[0]);
14
+ const vertices = [lowestPoint];
15
+ let toProcess = [...points.filter(p => !p.eq(lowestPoint))];
16
+ let lastBaseDirection = Vec2.of(-1, 0);
17
+ // 2. Find the point with greatest angle from the vertex:
18
+ //
19
+ // . . .
20
+ // . . / <- Notice that **all** other points are to the
21
+ // / **left** of the vector from the current
22
+ // ./ vertex to the new point.
23
+ while (toProcess.length > 0) {
24
+ const lastVertex = vertices[vertices.length - 1];
25
+ let smallestDotProductSoFar = lastBaseDirection.dot(lowestPoint.minus(lastVertex).normalizedOrZero());
26
+ let furthestPointSoFar = lowestPoint;
27
+ for (const point of toProcess) {
28
+ // Maximizing the angle is the same as minimizing the dot product:
29
+ // point.minus(lastVertex)
30
+ // ^
31
+ // /
32
+ // /
33
+ // ϑ /
34
+ // <-----. lastBaseDirection
35
+ const currentDotProduct = lastBaseDirection.dot(point.minus(lastVertex).normalizedOrZero());
36
+ if (currentDotProduct <= smallestDotProductSoFar) {
37
+ furthestPointSoFar = point;
38
+ smallestDotProductSoFar = currentDotProduct;
39
+ }
40
+ }
41
+ toProcess = toProcess.filter(p => !p.eq(furthestPointSoFar));
42
+ const newBaseDirection = furthestPointSoFar.minus(lastVertex).normalized();
43
+ // If the last vertex is on the same edge as the current, there's no need to include
44
+ // the previous one.
45
+ if (Math.abs(newBaseDirection.dot(lastBaseDirection)) === 1 && vertices.length > 1) {
46
+ vertices.pop();
47
+ }
48
+ // Stoping condition: We've gone in a full circle.
49
+ if (furthestPointSoFar.eq(lowestPoint)) {
50
+ break;
51
+ }
52
+ else {
53
+ vertices.push(furthestPointSoFar);
54
+ lastBaseDirection = lastVertex.minus(furthestPointSoFar).normalized();
55
+ }
56
+ }
57
+ return vertices;
58
+ };
59
+ export default convexHull2Of;
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@js-draw/math",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "A math library for js-draw. ",
5
5
  "types": "./dist/mjs/lib.d.ts",
6
6
  "main": "./dist/cjs/lib.js",
@@ -45,5 +45,5 @@
45
45
  "svg",
46
46
  "math"
47
47
  ],
48
- "gitHead": "d0eff585750ab5670af3acda8ddff090e8825bd3"
48
+ "gitHead": "73c0d802a8439b5d408ba1e60f91be029db7e402"
49
49
  }
package/src/Mat33.ts CHANGED
@@ -444,8 +444,13 @@ export class Mat33 {
444
444
  return Mat33.identity;
445
445
  }
446
446
 
447
- const parseArguments = (argumentString: string) => {
448
- return argumentString.split(/[, \t\n]+/g).map(argString => {
447
+ const parseArguments = (argumentString: string): number[] => {
448
+ const parsed = argumentString.split(/[, \t\n]+/g).map(argString => {
449
+ // Handle trailing spaces/commands
450
+ if (argString.trim() === '') {
451
+ return null;
452
+ }
453
+
449
454
  let isPercentage = false;
450
455
  if (argString.endsWith('%')) {
451
456
  isPercentage = true;
@@ -476,6 +481,7 @@ export class Mat33 {
476
481
 
477
482
  return argNumber;
478
483
  });
484
+ return parsed.filter(n => n !== null) as number[];
479
485
  };
480
486
 
481
487
 
package/src/Vec3.test.ts CHANGED
@@ -57,6 +57,7 @@ describe('Vec3', () => {
57
57
  { from: Vec3.of(1, 1, 1), to: Vec3.of(0, 1, 0), expected: 2 },
58
58
  { from: Vec3.of(1, 1, 1), to: Vec3.of(0, 0, 0), expected: 3 },
59
59
  { from: Vec3.of(-1, -10, 0), to: Vec3.of(1, 2, 0), expected: 148 },
60
+ { from: Vec3.of(-1, -10, 0), to: Vec3.of(1, 2, 0), expected: 148 },
60
61
  ])(
61
62
  '.squareDistanceTo and .distanceTo should return correct square and euclidean distances (%j)',
62
63
  ({ from , to, expected }) => {
@@ -67,4 +68,19 @@ describe('Vec3', () => {
67
68
  expect(from.minus(to).magnitudeSquared()).toBe(expected);
68
69
  },
69
70
  );
71
+
72
+ test.each([
73
+ { a: Vec3.of(1, 2, 3), b: Vec3.of(4, 5, 6), tolerance: 0.1, eq: false },
74
+ { a: Vec3.of(1, 2, 3), b: Vec3.of(4, 5, 6), tolerance: 10, eq: true },
75
+ { a: Vec3.of(1, 2, 3), b: Vec3.of(1, 2, 3), tolerance: 0, eq: true },
76
+ { a: Vec3.of(1, 2, 3), b: Vec3.of(1, 2, 4), tolerance: 0, eq: false },
77
+ { a: Vec3.of(1, 2, 3), b: Vec3.of(1, 4, 3), tolerance: 0, eq: false },
78
+ { a: Vec3.of(1, 2, 3), b: Vec3.of(4, 2, 3), tolerance: 0, eq: false },
79
+ { a: Vec3.of(1, 2, 3.0001), b: Vec3.of(1, 2, 3), tolerance: 1e-12, eq: false },
80
+ { a: Vec3.of(1, 2, 3.0001), b: Vec3.of(1, 2, 3), tolerance: 1e-3, eq: true },
81
+ { a: Vec3.of(1, 2.00001, 3.0001), b: Vec3.of(1.00001, 2, 3), tolerance: 1e-3, eq: true },
82
+ ])('.eq should support tolerance (case %#)', ({ a, b, tolerance, eq }) => {
83
+ expect(a.eq(b, tolerance)).toBe(eq);
84
+ expect(b.eq(a, tolerance)).toBe(eq);
85
+ });
70
86
  });