@js-draw/math 1.11.1 → 1.17.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 (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;