@js-draw/math 1.11.1 → 1.17.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (96) hide show
  1. package/dist/cjs/Vec3.d.ts +21 -0
  2. package/dist/cjs/Vec3.js +28 -0
  3. package/dist/cjs/lib.d.ts +2 -2
  4. package/dist/cjs/lib.js +16 -3
  5. package/dist/cjs/rounding/cleanUpNumber.d.ts +3 -0
  6. package/dist/cjs/rounding/cleanUpNumber.js +35 -0
  7. package/dist/cjs/rounding/constants.d.ts +1 -0
  8. package/dist/cjs/rounding/constants.js +4 -0
  9. package/dist/cjs/rounding/getLenAfterDecimal.d.ts +10 -0
  10. package/dist/cjs/rounding/getLenAfterDecimal.js +30 -0
  11. package/dist/cjs/rounding/lib.d.ts +1 -0
  12. package/dist/cjs/rounding/lib.js +5 -0
  13. package/dist/cjs/{rounding.d.ts → rounding/toRoundedString.d.ts} +1 -3
  14. package/dist/cjs/rounding/toRoundedString.js +54 -0
  15. package/dist/cjs/rounding/toStringOfSamePrecision.d.ts +2 -0
  16. package/dist/cjs/rounding/toStringOfSamePrecision.js +58 -0
  17. package/dist/cjs/rounding/toStringOfSamePrecision.test.d.ts +1 -0
  18. package/dist/cjs/shapes/Abstract2DShape.d.ts +3 -0
  19. package/dist/cjs/shapes/BezierJSWrapper.d.ts +15 -5
  20. package/dist/cjs/shapes/BezierJSWrapper.js +135 -18
  21. package/dist/cjs/shapes/LineSegment2.d.ts +34 -5
  22. package/dist/cjs/shapes/LineSegment2.js +63 -10
  23. package/dist/cjs/shapes/Parameterized2DShape.d.ts +31 -0
  24. package/dist/cjs/shapes/Parameterized2DShape.js +15 -0
  25. package/dist/cjs/shapes/Path.d.ts +40 -6
  26. package/dist/cjs/shapes/Path.js +181 -22
  27. package/dist/cjs/shapes/PointShape2D.d.ts +14 -3
  28. package/dist/cjs/shapes/PointShape2D.js +28 -5
  29. package/dist/cjs/shapes/QuadraticBezier.d.ts +4 -0
  30. package/dist/cjs/shapes/QuadraticBezier.js +19 -4
  31. package/dist/cjs/shapes/Rect2.d.ts +3 -0
  32. package/dist/cjs/shapes/Rect2.js +4 -1
  33. package/dist/mjs/Vec3.d.ts +21 -0
  34. package/dist/mjs/Vec3.mjs +28 -0
  35. package/dist/mjs/lib.d.ts +2 -2
  36. package/dist/mjs/lib.mjs +1 -1
  37. package/dist/mjs/rounding/cleanUpNumber.d.ts +3 -0
  38. package/dist/mjs/rounding/cleanUpNumber.mjs +31 -0
  39. package/dist/mjs/rounding/cleanUpNumber.test.d.ts +1 -0
  40. package/dist/mjs/rounding/constants.d.ts +1 -0
  41. package/dist/mjs/rounding/constants.mjs +1 -0
  42. package/dist/mjs/rounding/getLenAfterDecimal.d.ts +10 -0
  43. package/dist/mjs/rounding/getLenAfterDecimal.mjs +26 -0
  44. package/dist/mjs/rounding/lib.d.ts +1 -0
  45. package/dist/mjs/rounding/lib.mjs +1 -0
  46. package/dist/mjs/{rounding.d.ts → rounding/toRoundedString.d.ts} +1 -3
  47. package/dist/mjs/rounding/toRoundedString.mjs +47 -0
  48. package/dist/mjs/rounding/toRoundedString.test.d.ts +1 -0
  49. package/dist/mjs/rounding/toStringOfSamePrecision.d.ts +2 -0
  50. package/dist/mjs/rounding/toStringOfSamePrecision.mjs +51 -0
  51. package/dist/mjs/rounding/toStringOfSamePrecision.test.d.ts +1 -0
  52. package/dist/mjs/shapes/Abstract2DShape.d.ts +3 -0
  53. package/dist/mjs/shapes/BezierJSWrapper.d.ts +15 -5
  54. package/dist/mjs/shapes/BezierJSWrapper.mjs +133 -18
  55. package/dist/mjs/shapes/LineSegment2.d.ts +34 -5
  56. package/dist/mjs/shapes/LineSegment2.mjs +63 -10
  57. package/dist/mjs/shapes/Parameterized2DShape.d.ts +31 -0
  58. package/dist/mjs/shapes/Parameterized2DShape.mjs +8 -0
  59. package/dist/mjs/shapes/Path.d.ts +40 -6
  60. package/dist/mjs/shapes/Path.mjs +175 -16
  61. package/dist/mjs/shapes/PointShape2D.d.ts +14 -3
  62. package/dist/mjs/shapes/PointShape2D.mjs +28 -5
  63. package/dist/mjs/shapes/QuadraticBezier.d.ts +4 -0
  64. package/dist/mjs/shapes/QuadraticBezier.mjs +19 -4
  65. package/dist/mjs/shapes/Rect2.d.ts +3 -0
  66. package/dist/mjs/shapes/Rect2.mjs +4 -1
  67. package/package.json +5 -5
  68. package/src/Vec3.test.ts +26 -7
  69. package/src/Vec3.ts +30 -0
  70. package/src/lib.ts +3 -1
  71. package/src/rounding/cleanUpNumber.test.ts +15 -0
  72. package/src/rounding/cleanUpNumber.ts +38 -0
  73. package/src/rounding/constants.ts +3 -0
  74. package/src/rounding/getLenAfterDecimal.ts +29 -0
  75. package/src/rounding/lib.ts +2 -0
  76. package/src/rounding/toRoundedString.test.ts +32 -0
  77. package/src/rounding/toRoundedString.ts +57 -0
  78. package/src/rounding/toStringOfSamePrecision.test.ts +21 -0
  79. package/src/rounding/toStringOfSamePrecision.ts +63 -0
  80. package/src/shapes/Abstract2DShape.ts +3 -0
  81. package/src/shapes/BezierJSWrapper.ts +154 -14
  82. package/src/shapes/LineSegment2.test.ts +35 -1
  83. package/src/shapes/LineSegment2.ts +79 -11
  84. package/src/shapes/Parameterized2DShape.ts +39 -0
  85. package/src/shapes/Path.test.ts +63 -3
  86. package/src/shapes/Path.ts +211 -26
  87. package/src/shapes/PointShape2D.ts +33 -6
  88. package/src/shapes/QuadraticBezier.test.ts +48 -12
  89. package/src/shapes/QuadraticBezier.ts +23 -5
  90. package/src/shapes/Rect2.ts +4 -1
  91. package/dist/cjs/rounding.js +0 -146
  92. package/dist/mjs/rounding.mjs +0 -139
  93. package/src/rounding.test.ts +0 -65
  94. package/src/rounding.ts +0 -168
  95. /package/dist/cjs/{rounding.test.d.ts → rounding/cleanUpNumber.test.d.ts} +0 -0
  96. /package/dist/{mjs/rounding.test.d.ts → cjs/rounding/toRoundedString.test.d.ts} +0 -0
@@ -0,0 +1,29 @@
1
+ import { numberRegex } from './constants';
2
+
3
+
4
+ /**
5
+ * Returns the length of `numberAsString` after a decimal point.
6
+ *
7
+ * For example,
8
+ * ```ts
9
+ * getLenAfterDecimal('1.001') // -> 3
10
+ * ```
11
+ */
12
+ export const getLenAfterDecimal = (numberAsString: string) => {
13
+ const numberMatch = numberRegex.exec(numberAsString);
14
+ if (!numberMatch) {
15
+ // If not a match, either the number is exponential notation (or is something
16
+ // like NaN or Infinity)
17
+ if (numberAsString.search(/[eE]/) !== -1 || /^[a-zA-Z]+$/.exec(numberAsString)) {
18
+ return -1;
19
+ // Or it has no decimal point
20
+ } else {
21
+ return 0;
22
+ }
23
+ }
24
+
25
+ const afterDecimalLen = numberMatch[3].length;
26
+ return afterDecimalLen;
27
+ };
28
+
29
+ export default getLenAfterDecimal;
@@ -0,0 +1,2 @@
1
+
2
+ export { toRoundedString } from './toRoundedString';
@@ -0,0 +1,32 @@
1
+ import { toRoundedString } from './toRoundedString';
2
+
3
+ describe('toRoundedString', () => {
4
+ it('should round up numbers endings similar to .999999999999999', () => {
5
+ expect(toRoundedString(0.999999999)).toBe('1');
6
+ expect(toRoundedString(0.899999999)).toBe('.9');
7
+ expect(toRoundedString(9.999999999)).toBe('10');
8
+ expect(toRoundedString(-10.999999999)).toBe('-11');
9
+ });
10
+
11
+ it('should round up numbers similar to 10.999999998', () => {
12
+ expect(toRoundedString(10.999999998)).toBe('11');
13
+ });
14
+
15
+ it('should round strings with multiple digits after the ending decimal points', () => {
16
+ expect(toRoundedString(292.2 - 292.8)).toBe('-.6');
17
+ expect(toRoundedString(4.06425600000023)).toBe('4.064256');
18
+ });
19
+
20
+ it('should round down strings ending endings similar to .00000001', () => {
21
+ expect(toRoundedString(10.00000001)).toBe('10');
22
+ expect(toRoundedString(-30.00000001)).toBe('-30');
23
+ expect(toRoundedString(-14.20000000000002)).toBe('-14.2');
24
+ });
25
+
26
+ it('should not round numbers insufficiently close to the next', () => {
27
+ expect(toRoundedString(-10.9999)).toBe('-10.9999');
28
+ expect(toRoundedString(-10.0001)).toBe('-10.0001');
29
+ expect(toRoundedString(-10.123499)).toBe('-10.123499');
30
+ expect(toRoundedString(0.00123499)).toBe('.00123499');
31
+ });
32
+ });
@@ -0,0 +1,57 @@
1
+ import cleanUpNumber from './cleanUpNumber';
2
+
3
+ /**
4
+ * Converts `num` to a string, removing trailing digits that were likely caused by
5
+ * precision errors.
6
+ *
7
+ * @example
8
+ * ```ts,runnable,console
9
+ * import { toRoundedString } from '@js-draw/math';
10
+ *
11
+ * console.log('Rounded: ', toRoundedString(1.000000011));
12
+ * ```
13
+ */
14
+ export const toRoundedString = (num: number): string => {
15
+ // Try to remove rounding errors. If the number ends in at least three/four zeroes
16
+ // (or nines) just one or two digits, it's probably a rounding error.
17
+ const fixRoundingUpExp = /^([-]?\d*\.\d{3,})0{4,}\d{1,4}$/;
18
+ const hasRoundingDownExp = /^([-]?)(\d*)\.(\d{3,}9{4,})\d{1,4}$/;
19
+
20
+ let text = num.toString(10);
21
+ if (text.indexOf('.') === -1) {
22
+ return text;
23
+ }
24
+
25
+ const roundingDownMatch = hasRoundingDownExp.exec(text);
26
+ if (roundingDownMatch) {
27
+ const negativeSign = roundingDownMatch[1];
28
+ const postDecimalString = roundingDownMatch[3];
29
+ const lastDigit = parseInt(postDecimalString.charAt(postDecimalString.length - 1), 10);
30
+ const postDecimal = parseInt(postDecimalString, 10);
31
+ const preDecimal = parseInt(roundingDownMatch[2], 10);
32
+
33
+ const origPostDecimalString = roundingDownMatch[3];
34
+
35
+ let newPostDecimal = (postDecimal + 10 - lastDigit).toString();
36
+ let carry = 0;
37
+ if (newPostDecimal.length > postDecimal.toString().length) {
38
+ // Left-shift
39
+ newPostDecimal = newPostDecimal.substring(1);
40
+ carry = 1;
41
+ }
42
+
43
+ // parseInt(...).toString() removes leading zeroes. Add them back.
44
+ while (newPostDecimal.length < origPostDecimalString.length) {
45
+ newPostDecimal = carry.toString(10) + newPostDecimal;
46
+ carry = 0;
47
+ }
48
+
49
+ text = `${negativeSign + (preDecimal + carry).toString()}.${newPostDecimal}`;
50
+ }
51
+
52
+ text = text.replace(fixRoundingUpExp, '$1');
53
+
54
+ return cleanUpNumber(text);
55
+ };
56
+
57
+ export default toRoundedString;
@@ -0,0 +1,21 @@
1
+ import { toStringOfSamePrecision } from './toStringOfSamePrecision';
2
+
3
+
4
+ it('toStringOfSamePrecision', () => {
5
+ expect(toStringOfSamePrecision(1.23456, '1.12')).toBe('1.23');
6
+ expect(toStringOfSamePrecision(1.23456, '1.120')).toBe('1.235');
7
+ expect(toStringOfSamePrecision(1.23456, '1.1')).toBe('1.2');
8
+ expect(toStringOfSamePrecision(1.23456, '1.1', '5.32')).toBe('1.23');
9
+ expect(toStringOfSamePrecision(-1.23456, '1.1', '5.32')).toBe('-1.23');
10
+ expect(toStringOfSamePrecision(-1.99999, '1.1', '5.32')).toBe('-2');
11
+ expect(toStringOfSamePrecision(1.99999, '1.1', '5.32')).toBe('2');
12
+ expect(toStringOfSamePrecision(1.89999, '1.1', '5.32')).toBe('1.9');
13
+ expect(toStringOfSamePrecision(9.99999999, '-1.1234')).toBe('10');
14
+ expect(toStringOfSamePrecision(9.999999998999996, '100')).toBe('10');
15
+ expect(toStringOfSamePrecision(0.000012345, '0.000012')).toBe('.000012');
16
+ expect(toStringOfSamePrecision(0.000012645, '.000012')).toBe('.000013');
17
+ expect(toStringOfSamePrecision(-0.09999999999999432, '291.3')).toBe('-.1');
18
+ expect(toStringOfSamePrecision(-0.9999999999999432, '291.3')).toBe('-1');
19
+ expect(toStringOfSamePrecision(9998.9, '.1', '-11')).toBe('9998.9');
20
+ expect(toStringOfSamePrecision(-14.20000000000002, '.000001', '-11')).toBe('-14.2');
21
+ });
@@ -0,0 +1,63 @@
1
+ import cleanUpNumber from './cleanUpNumber';
2
+ import { numberRegex } from './constants';
3
+ import getLenAfterDecimal from './getLenAfterDecimal';
4
+ import toRoundedString from './toRoundedString';
5
+
6
+ // [reference] should be a string representation of a base-10 number (no exponential (e.g. 10e10))
7
+ export const toStringOfSamePrecision = (num: number, ...references: string[]): string => {
8
+ const text = num.toString(10);
9
+ const textMatch = numberRegex.exec(text);
10
+ if (!textMatch) {
11
+ return text;
12
+ }
13
+
14
+ let decimalPlaces = -1;
15
+ for (const reference of references) {
16
+ decimalPlaces = Math.max(getLenAfterDecimal(reference), decimalPlaces);
17
+ }
18
+
19
+ if (decimalPlaces === -1) {
20
+ return toRoundedString(num);
21
+ }
22
+
23
+ // Make text's after decimal length match [afterDecimalLen].
24
+ let postDecimal = textMatch[3].substring(0, decimalPlaces);
25
+ let preDecimal = textMatch[2];
26
+ const nextDigit = textMatch[3].charAt(decimalPlaces);
27
+
28
+ if (nextDigit !== '') {
29
+ const asNumber = parseInt(nextDigit, 10);
30
+ if (asNumber >= 5) {
31
+ // Don't attempt to parseInt() an empty string.
32
+ if (postDecimal.length > 0) {
33
+ const leadingZeroMatch = /^(0+)(\d*)$/.exec(postDecimal);
34
+
35
+ let leadingZeroes = '';
36
+ let postLeading = postDecimal;
37
+ if (leadingZeroMatch) {
38
+ leadingZeroes = leadingZeroMatch[1];
39
+ postLeading = leadingZeroMatch[2];
40
+ }
41
+
42
+ postDecimal = (parseInt(postDecimal) + 1).toString();
43
+
44
+ // If postDecimal got longer, remove leading zeroes if possible
45
+ if (postDecimal.length > postLeading.length && leadingZeroes.length > 0) {
46
+ leadingZeroes = leadingZeroes.substring(1);
47
+ }
48
+
49
+ postDecimal = leadingZeroes + postDecimal;
50
+ }
51
+
52
+ if (postDecimal.length === 0 || postDecimal.length > decimalPlaces) {
53
+ preDecimal = (parseInt(preDecimal) + 1).toString();
54
+ postDecimal = postDecimal.substring(1);
55
+ }
56
+ }
57
+ }
58
+
59
+ const negativeSign = textMatch[1];
60
+ return cleanUpNumber(`${negativeSign}${preDecimal}.${postDecimal}`);
61
+ };
62
+
63
+ export default toStringOfSamePrecision;
@@ -49,6 +49,9 @@ export abstract class Abstract2DShape {
49
49
 
50
50
  /**
51
51
  * Returns a bounding box that precisely fits the content of this shape.
52
+ *
53
+ * **Note**: This bounding box should aligned with the x/y axes. (Thus, it may be
54
+ * possible to find a tighter bounding box not axes-aligned).
52
55
  */
53
56
  public abstract getTightBoundingBox(): Rect2;
54
57
 
@@ -1,8 +1,8 @@
1
1
  import { Bezier } from 'bezier-js';
2
2
  import { Point2, Vec2 } from '../Vec2';
3
- import Abstract2DShape from './Abstract2DShape';
4
3
  import LineSegment2 from './LineSegment2';
5
4
  import Rect2 from './Rect2';
5
+ import Parameterized2DShape from './Parameterized2DShape';
6
6
 
7
7
  /**
8
8
  * A lazy-initializing wrapper around Bezier-js.
@@ -10,14 +10,24 @@ import Rect2 from './Rect2';
10
10
  * Subclasses may override `at`, `derivativeAt`, and `normal` with functions
11
11
  * that do not initialize a `bezier-js` `Bezier`.
12
12
  *
13
- * Do not use this class directly. It may be removed/replaced in a future release.
13
+ * **Do not use this class directly.** It may be removed/replaced in a future release.
14
14
  * @internal
15
15
  */
16
- abstract class BezierJSWrapper extends Abstract2DShape {
16
+ export abstract class BezierJSWrapper extends Parameterized2DShape {
17
17
  #bezierJs: Bezier|null = null;
18
18
 
19
+ protected constructor(
20
+ bezierJsBezier?: Bezier
21
+ ) {
22
+ super();
23
+
24
+ if (bezierJsBezier) {
25
+ this.#bezierJs = bezierJsBezier;
26
+ }
27
+ }
28
+
19
29
  /** Returns the start, control points, and end point of this Bézier. */
20
- public abstract getPoints(): Point2[];
30
+ public abstract getPoints(): readonly Point2[];
21
31
 
22
32
  protected getBezier() {
23
33
  if (!this.#bezierJs) {
@@ -28,7 +38,7 @@ abstract class BezierJSWrapper extends Abstract2DShape {
28
38
 
29
39
  public override signedDistance(point: Point2): number {
30
40
  // .d: Distance
31
- return this.getBezier().project(point.xy).d!;
41
+ return this.nearestPointTo(point).point.distanceTo(point);
32
42
  }
33
43
 
34
44
  /**
@@ -44,7 +54,7 @@ abstract class BezierJSWrapper extends Abstract2DShape {
44
54
  /**
45
55
  * @returns the curve evaluated at `t`.
46
56
  */
47
- public at(t: number): Point2 {
57
+ public override at(t: number): Point2 {
48
58
  return Vec2.ofXY(this.getBezier().get(t));
49
59
  }
50
60
 
@@ -52,10 +62,22 @@ abstract class BezierJSWrapper extends Abstract2DShape {
52
62
  return Vec2.ofXY(this.getBezier().derivative(t));
53
63
  }
54
64
 
65
+ public secondDerivativeAt(t: number): Point2 {
66
+ return Vec2.ofXY((this.getBezier() as any).dderivative(t));
67
+ }
68
+
55
69
  public normal(t: number): Vec2 {
56
70
  return Vec2.ofXY(this.getBezier().normal(t));
57
71
  }
58
72
 
73
+ public override normalAt(t: number): Vec2 {
74
+ return this.normal(t);
75
+ }
76
+
77
+ public override tangentAt(t: number): Vec2 {
78
+ return this.derivativeAt(t).normalized();
79
+ }
80
+
59
81
  public override getTightBoundingBox(): Rect2 {
60
82
  const bbox = this.getBezier().bbox();
61
83
  const width = bbox.x.max - bbox.x.min;
@@ -64,10 +86,10 @@ abstract class BezierJSWrapper extends Abstract2DShape {
64
86
  return new Rect2(bbox.x.min, bbox.y.min, width, height);
65
87
  }
66
88
 
67
- public override intersectsLineSegment(line: LineSegment2): Point2[] {
89
+ public override argIntersectsLineSegment(line: LineSegment2): number[] {
68
90
  const bezier = this.getBezier();
69
91
 
70
- const intersectionPoints = bezier.intersects(line).map(t => {
92
+ return bezier.intersects(line).map(t => {
71
93
  // We're using the .intersects(line) function, which is documented
72
94
  // to always return numbers. However, to satisfy the type checker (and
73
95
  // possibly improperly-defined types),
@@ -75,18 +97,136 @@ abstract class BezierJSWrapper extends Abstract2DShape {
75
97
  t = parseFloat(t);
76
98
  }
77
99
 
78
- const point = Vec2.ofXY(bezier.get(t));
100
+ const point = Vec2.ofXY(this.at(t));
79
101
 
80
102
  // Ensure that the intersection is on the line segment
81
- if (point.minus(line.p1).magnitude() > line.length
82
- || point.minus(line.p2).magnitude() > line.length) {
103
+ if (point.distanceTo(line.p1) > line.length
104
+ || point.distanceTo(line.p2) > line.length) {
83
105
  return null;
84
106
  }
85
107
 
86
- return point;
87
- }).filter(entry => entry !== null) as Point2[];
108
+ return t;
109
+ }).filter(entry => entry !== null) as number[];
110
+ }
111
+
112
+ public override splitAt(t: number): [BezierJSWrapper] | [BezierJSWrapper, BezierJSWrapper] {
113
+ if (t <= 0 || t >= 1) {
114
+ return [ this ];
115
+ }
116
+
117
+ const bezier = this.getBezier();
118
+ const split = bezier.split(t);
119
+ return [
120
+ new BezierJSWrapperImpl(split.left.points.map(point => Vec2.ofXY(point)), split.left),
121
+ new BezierJSWrapperImpl(split.right.points.map(point => Vec2.ofXY(point)), split.right),
122
+ ];
123
+ }
124
+
125
+ public override nearestPointTo(point: Point2) {
126
+ // One implementation could be similar to this:
127
+ // const projection = this.getBezier().project(point);
128
+ // return {
129
+ // point: Vec2.ofXY(projection),
130
+ // parameterValue: projection.t!,
131
+ // };
132
+ // However, Bezier-js is rather impercise (and relies on a lookup table).
133
+ // Thus, we instead use Newton's Method:
134
+
135
+ // We want to find t such that f(t) = |B(t) - p|² is minimized.
136
+ // Expanding,
137
+ // f(t) = (Bₓ(t) - pₓ)² + (Bᵧ(t) - pᵧ)²
138
+ // ⇒ f'(t) = Dₜ(Bₓ(t) - pₓ)² + Dₜ(Bᵧ(t) - pᵧ)²
139
+ // ⇒ f'(t) = 2(Bₓ(t) - pₓ)(Bₓ'(t)) + 2(Bᵧ(t) - pᵧ)(Bᵧ'(t))
140
+ // = 2Bₓ(t)Bₓ'(t) - 2pₓBₓ'(t) + 2Bᵧ(t)Bᵧ'(t) - 2pᵧBᵧ'(t)
141
+ // ⇒ f''(t)= 2Bₓ'(t)Bₓ'(t) + 2Bₓ(t)Bₓ''(t) - 2pₓBₓ''(t) + 2Bᵧ'(t)Bᵧ'(t)
142
+ // + 2Bᵧ(t)Bᵧ''(t) - 2pᵧBᵧ''(t)
143
+ // Because f'(t) = 0 at relative extrema, we can use Newton's Method
144
+ // to improve on an initial guess.
145
+
146
+ const sqrDistAt = (t: number) => point.squareDistanceTo(this.at(t));
147
+ const yIntercept = sqrDistAt(0);
148
+ let t = 0;
149
+ let minSqrDist = yIntercept;
150
+
151
+ // Start by testing a few points:
152
+ const pointsToTest = 4;
153
+ for (let i = 0; i < pointsToTest; i ++) {
154
+ const testT = i / (pointsToTest - 1);
155
+ const testMinSqrDist = sqrDistAt(testT);
156
+
157
+ if (testMinSqrDist < minSqrDist) {
158
+ t = testT;
159
+ minSqrDist = testMinSqrDist;
160
+ }
161
+ }
162
+
163
+ // To use Newton's Method, we need to evaluate the second derivative of the distance
164
+ // function:
165
+ const secondDerivativeAt = (t: number) => {
166
+ // f''(t) = 2Bₓ'(t)Bₓ'(t) + 2Bₓ(t)Bₓ''(t) - 2pₓBₓ''(t)
167
+ // + 2Bᵧ'(t)Bᵧ'(t) + 2Bᵧ(t)Bᵧ''(t) - 2pᵧBᵧ''(t)
168
+ const b = this.at(t);
169
+ const bPrime = this.derivativeAt(t);
170
+ const bPrimePrime = this.secondDerivativeAt(t);
171
+ return (
172
+ 2 * bPrime.x * bPrime.x + 2 * b.x * bPrimePrime.x - 2 * point.x * bPrimePrime.x
173
+ + 2 * bPrime.y * bPrime.y + 2 * b.y * bPrimePrime.y - 2 * point.y * bPrimePrime.y
174
+ );
175
+ };
176
+ // Because we're zeroing f'(t), we also need to be able to compute it:
177
+ const derivativeAt = (t: number) => {
178
+ // f'(t) = 2Bₓ(t)Bₓ'(t) - 2pₓBₓ'(t) + 2Bᵧ(t)Bᵧ'(t) - 2pᵧBᵧ'(t)
179
+ const b = this.at(t);
180
+ const bPrime = this.derivativeAt(t);
181
+ return (
182
+ 2 * b.x * bPrime.x - 2 * point.x * bPrime.x
183
+ + 2 * b.y * bPrime.y - 2 * point.y * bPrime.y
184
+ );
185
+ };
186
+
187
+ const iterate = () => {
188
+ const slope = secondDerivativeAt(t);
189
+ // We intersect a line through the point on f'(t) at t with the x-axis:
190
+ // y = m(x - x₀) + y₀
191
+ // ⇒ x - x₀ = (y - y₀) / m
192
+ // ⇒ x = (y - y₀) / m + x₀
193
+ //
194
+ // Thus, when zeroed,
195
+ // tN = (0 - f'(t)) / m + t
196
+ const newT = (0 - derivativeAt(t)) / slope + t;
197
+ //const distDiff = sqrDistAt(newT) - sqrDistAt(t);
198
+ //console.assert(distDiff <= 0, `${-distDiff} >= 0`);
199
+ t = newT;
200
+ if (t > 1) {
201
+ t = 1;
202
+ } else if (t < 0) {
203
+ t = 0;
204
+ }
205
+ };
206
+
207
+ for (let i = 0; i < 12; i++) {
208
+ iterate();
209
+ }
210
+
211
+ return { parameterValue: t, point: this.at(t) };
212
+ }
213
+
214
+ public override toString() {
215
+ return `Bézier(${this.getPoints().map(point => point.toString()).join(', ')})`;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Private concrete implementation of `BezierJSWrapper`, used by methods above that need to return a wrapper
221
+ * around a `Bezier`.
222
+ */
223
+ class BezierJSWrapperImpl extends BezierJSWrapper {
224
+ public constructor(private controlPoints: readonly Point2[], curve?: Bezier) {
225
+ super(curve);
226
+ }
88
227
 
89
- return intersectionPoints;
228
+ public override getPoints() {
229
+ return this.controlPoints;
90
230
  }
91
231
  }
92
232
 
@@ -28,7 +28,7 @@ describe('Line2', () => {
28
28
 
29
29
  expect(line1.intersection(line2)?.point).objEq(Vec2.of(0, 10));
30
30
 
31
- // t=10 implies 10 units along he line from (10, 10) to (-10, 10)
31
+ // t=10 implies 10 units along the line from (10, 10) to (-10, 10)
32
32
  expect(line1.intersection(line2)?.t).toBe(10);
33
33
 
34
34
  // Similarly, t = 12 implies 12 units above (0, -2) in the direction of (0, 200)
@@ -96,4 +96,38 @@ describe('Line2', () => {
96
96
  p2: Vec2.of(3, 98),
97
97
  });
98
98
  });
99
+
100
+ it.each([
101
+ { from: Vec2.of(0, 0), to: Vec2.of(2, 2) },
102
+ { from: Vec2.of(100, 0), to: Vec2.of(2, 2) },
103
+ ])('should be able to split a line segment between %j', ({ from, to }) => {
104
+ const midpoint = from.lerp(to, 0.5);
105
+ const lineSegment = new LineSegment2(from, to);
106
+
107
+ // Halving
108
+ //
109
+ expect(lineSegment.at(0.5)).objEq(midpoint);
110
+ const [ firstHalf, secondHalf ] = lineSegment.splitAt(0.5);
111
+
112
+ if (!secondHalf) {
113
+ throw new Error('Splitting a line segment in half should yield two line segments.');
114
+ }
115
+
116
+ expect(firstHalf.p2).objEq(midpoint);
117
+ expect(firstHalf.p1).objEq(from);
118
+ expect(secondHalf.p2).objEq(to);
119
+ expect(secondHalf.p1).objEq(midpoint);
120
+
121
+ // Before start/end
122
+ expect(lineSegment.splitAt(0)[0]).objEq(lineSegment);
123
+ expect(lineSegment.splitAt(0)).toHaveLength(1);
124
+ expect(lineSegment.splitAt(1)).toHaveLength(1);
125
+ expect(lineSegment.splitAt(2)).toHaveLength(1);
126
+ });
127
+
128
+ it('equivalence check should allow ignoring direction', () => {
129
+ expect(new LineSegment2(Vec2.zero, Vec2.unitX)).objEq(new LineSegment2(Vec2.zero, Vec2.unitX));
130
+ expect(new LineSegment2(Vec2.zero, Vec2.unitX)).objEq(new LineSegment2(Vec2.unitX, Vec2.zero));
131
+ expect(new LineSegment2(Vec2.zero, Vec2.unitX)).not.objEq(new LineSegment2(Vec2.unitX, Vec2.zero), { ignoreDirection: false });
132
+ });
99
133
  });
@@ -1,7 +1,8 @@
1
1
  import Mat33 from '../Mat33';
2
2
  import Rect2 from './Rect2';
3
3
  import { Vec2, Point2 } from '../Vec2';
4
- import Abstract2DShape from './Abstract2DShape';
4
+ import Parameterized2DShape from './Parameterized2DShape';
5
+ import Vec3 from '../Vec3';
5
6
 
6
7
  interface IntersectionResult {
7
8
  point: Point2;
@@ -9,7 +10,7 @@ interface IntersectionResult {
9
10
  }
10
11
 
11
12
  /** Represents a line segment. A `LineSegment2` is immutable. */
12
- export class LineSegment2 extends Abstract2DShape {
13
+ export class LineSegment2 extends Parameterized2DShape {
13
14
  // invariant: ||direction|| = 1
14
15
 
15
16
  /**
@@ -58,8 +59,12 @@ export class LineSegment2 extends Abstract2DShape {
58
59
  return this.point2;
59
60
  }
60
61
 
62
+ public get center(): Point2 {
63
+ return this.point1.lerp(this.point2, 0.5);
64
+ }
65
+
61
66
  /**
62
- * Gets a point a distance `t` along this line.
67
+ * Gets a point a **distance** `t` along this line.
63
68
  *
64
69
  * @deprecated
65
70
  */
@@ -74,11 +79,40 @@ export class LineSegment2 extends Abstract2DShape {
74
79
  *
75
80
  * `t` should be in `[0, 1]`.
76
81
  */
77
- public at(t: number): Point2 {
82
+ public override at(t: number): Point2 {
78
83
  return this.get(t * this.length);
79
84
  }
80
85
 
86
+ public override normalAt(_t: number): Vec2 {
87
+ return this.direction.orthog();
88
+ }
89
+
90
+ public override tangentAt(_t: number): Vec3 {
91
+ return this.direction;
92
+ }
93
+
94
+ public splitAt(t: number): [LineSegment2]|[LineSegment2,LineSegment2] {
95
+ if (t <= 0 || t >= 1) {
96
+ return [this];
97
+ }
98
+
99
+ return [
100
+ new LineSegment2(this.point1, this.at(t)),
101
+ new LineSegment2(this.at(t), this.point2),
102
+ ];
103
+ }
104
+
105
+ /**
106
+ * Returns the intersection of this with another line segment.
107
+ *
108
+ * **WARNING**: The parameter value returned by this method does not range from 0 to 1 and
109
+ * is currently a length.
110
+ * This will change in a future release.
111
+ * @deprecated
112
+ */
81
113
  public intersection(other: LineSegment2): IntersectionResult|null {
114
+ // TODO(v2.0.0): Make this return a `t` value from `0` to `1`.
115
+
82
116
  // We want x₁(t) = x₂(t) and y₁(t) = y₂(t)
83
117
  // Observe that
84
118
  // x = this.point1.x + this.direction.x · t₁
@@ -146,10 +180,10 @@ export class LineSegment2 extends Abstract2DShape {
146
180
  }
147
181
 
148
182
  // Ensure the result is in this/the other segment.
149
- const resultToP1 = resultPoint.minus(this.point1).magnitude();
150
- const resultToP2 = resultPoint.minus(this.point2).magnitude();
151
- const resultToP3 = resultPoint.minus(other.point1).magnitude();
152
- const resultToP4 = resultPoint.minus(other.point2).magnitude();
183
+ const resultToP1 = resultPoint.distanceTo(this.point1);
184
+ const resultToP2 = resultPoint.distanceTo(this.point2);
185
+ const resultToP3 = resultPoint.distanceTo(other.point1);
186
+ const resultToP4 = resultPoint.distanceTo(other.point2);
153
187
  if (resultToP1 > this.length
154
188
  || resultToP2 > this.length
155
189
  || resultToP3 > other.length
@@ -167,6 +201,15 @@ export class LineSegment2 extends Abstract2DShape {
167
201
  return this.intersection(other) !== null;
168
202
  }
169
203
 
204
+ public override argIntersectsLineSegment(lineSegment: LineSegment2) {
205
+ const intersection = this.intersection(lineSegment);
206
+
207
+ if (intersection) {
208
+ return [ intersection.t / this.length ];
209
+ }
210
+ return [];
211
+ }
212
+
170
213
  /**
171
214
  * Returns the points at which this line segment intersects the
172
215
  * given line segment.
@@ -186,6 +229,10 @@ export class LineSegment2 extends Abstract2DShape {
186
229
 
187
230
  // Returns the closest point on this to [target]
188
231
  public closestPointTo(target: Point2) {
232
+ return this.nearestPointTo(target).point;
233
+ }
234
+
235
+ public override nearestPointTo(target: Vec3): { point: Vec3; parameterValue: number; } {
189
236
  // Distance from P1 along this' direction.
190
237
  const projectedDistFromP1 = target.minus(this.p1).dot(this.direction);
191
238
  const projectedDistFromP2 = this.length - projectedDistFromP1;
@@ -193,13 +240,13 @@ export class LineSegment2 extends Abstract2DShape {
193
240
  const projection = this.p1.plus(this.direction.times(projectedDistFromP1));
194
241
 
195
242
  if (projectedDistFromP1 > 0 && projectedDistFromP1 < this.length) {
196
- return projection;
243
+ return { point: projection, parameterValue: projectedDistFromP1 / this.length };
197
244
  }
198
245
 
199
246
  if (Math.abs(projectedDistFromP2) < Math.abs(projectedDistFromP1)) {
200
- return this.p2;
247
+ return { point: this.p2, parameterValue: 1 };
201
248
  } else {
202
- return this.p1;
249
+ return { point: this.p1, parameterValue: 0 };
203
250
  }
204
251
  }
205
252
 
@@ -228,5 +275,26 @@ export class LineSegment2 extends Abstract2DShape {
228
275
  public override toString() {
229
276
  return `LineSegment(${this.p1.toString()}, ${this.p2.toString()})`;
230
277
  }
278
+
279
+ /**
280
+ * Returns `true` iff this is equivalent to `other`.
281
+ *
282
+ * **Options**:
283
+ * - `tolerance`: The maximum difference between endpoints. (Default: 0)
284
+ * - `ignoreDirection`: Allow matching a version of `this` with opposite direction. (Default: `true`)
285
+ */
286
+ public eq(other: LineSegment2, options?: { tolerance?: number, ignoreDirection?: boolean }) {
287
+ if (!(other instanceof LineSegment2)) {
288
+ return false;
289
+ }
290
+
291
+ const tolerance = options?.tolerance;
292
+ const ignoreDirection = options?.ignoreDirection ?? true;
293
+
294
+ return (
295
+ (other.p1.eq(this.p1, tolerance) && other.p2.eq(this.p2, tolerance))
296
+ || (ignoreDirection && other.p1.eq(this.p2, tolerance) && other.p2.eq(this.p1, tolerance))
297
+ );
298
+ }
231
299
  }
232
300
  export default LineSegment2;