@js-draw/math 1.16.0 → 1.17.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) 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 +1 -1
  4. package/dist/cjs/shapes/Abstract2DShape.d.ts +3 -0
  5. package/dist/cjs/shapes/BezierJSWrapper.d.ts +15 -5
  6. package/dist/cjs/shapes/BezierJSWrapper.js +135 -18
  7. package/dist/cjs/shapes/LineSegment2.d.ts +34 -5
  8. package/dist/cjs/shapes/LineSegment2.js +63 -10
  9. package/dist/cjs/shapes/Parameterized2DShape.d.ts +31 -0
  10. package/dist/cjs/shapes/Parameterized2DShape.js +15 -0
  11. package/dist/cjs/shapes/Path.d.ts +40 -6
  12. package/dist/cjs/shapes/Path.js +173 -15
  13. package/dist/cjs/shapes/PointShape2D.d.ts +14 -3
  14. package/dist/cjs/shapes/PointShape2D.js +28 -5
  15. package/dist/cjs/shapes/QuadraticBezier.d.ts +4 -0
  16. package/dist/cjs/shapes/QuadraticBezier.js +19 -4
  17. package/dist/cjs/shapes/Rect2.d.ts +3 -0
  18. package/dist/cjs/shapes/Rect2.js +4 -1
  19. package/dist/mjs/Vec3.d.ts +21 -0
  20. package/dist/mjs/Vec3.mjs +28 -0
  21. package/dist/mjs/lib.d.ts +1 -1
  22. package/dist/mjs/shapes/Abstract2DShape.d.ts +3 -0
  23. package/dist/mjs/shapes/BezierJSWrapper.d.ts +15 -5
  24. package/dist/mjs/shapes/BezierJSWrapper.mjs +133 -18
  25. package/dist/mjs/shapes/LineSegment2.d.ts +34 -5
  26. package/dist/mjs/shapes/LineSegment2.mjs +63 -10
  27. package/dist/mjs/shapes/Parameterized2DShape.d.ts +31 -0
  28. package/dist/mjs/shapes/Parameterized2DShape.mjs +8 -0
  29. package/dist/mjs/shapes/Path.d.ts +40 -6
  30. package/dist/mjs/shapes/Path.mjs +173 -15
  31. package/dist/mjs/shapes/PointShape2D.d.ts +14 -3
  32. package/dist/mjs/shapes/PointShape2D.mjs +28 -5
  33. package/dist/mjs/shapes/QuadraticBezier.d.ts +4 -0
  34. package/dist/mjs/shapes/QuadraticBezier.mjs +19 -4
  35. package/dist/mjs/shapes/Rect2.d.ts +3 -0
  36. package/dist/mjs/shapes/Rect2.mjs +4 -1
  37. package/package.json +5 -5
  38. package/src/Vec3.test.ts +26 -7
  39. package/src/Vec3.ts +30 -0
  40. package/src/lib.ts +2 -0
  41. package/src/shapes/Abstract2DShape.ts +3 -0
  42. package/src/shapes/BezierJSWrapper.ts +154 -14
  43. package/src/shapes/LineSegment2.test.ts +35 -1
  44. package/src/shapes/LineSegment2.ts +79 -11
  45. package/src/shapes/Parameterized2DShape.ts +39 -0
  46. package/src/shapes/Path.test.ts +63 -3
  47. package/src/shapes/Path.ts +209 -25
  48. package/src/shapes/PointShape2D.ts +33 -6
  49. package/src/shapes/QuadraticBezier.test.ts +48 -12
  50. package/src/shapes/QuadraticBezier.ts +23 -5
  51. package/src/shapes/Rect2.ts +4 -1
@@ -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;
@@ -0,0 +1,39 @@
1
+ import { Point2, Vec2 } from '../Vec2';
2
+ import Abstract2DShape from './Abstract2DShape';
3
+ import LineSegment2 from './LineSegment2';
4
+
5
+ /** A 2-dimensional path with parameter interval $t \in [0, 1]$. */
6
+ export abstract class Parameterized2DShape extends Abstract2DShape {
7
+ /** Returns this at a given parameter. $t \in [0, 1]$ */
8
+ abstract at(t: number): Point2;
9
+
10
+ /** Computes the unit normal vector at $t$. */
11
+ abstract normalAt(t: number): Vec2;
12
+
13
+ abstract tangentAt(t: number): Vec2;
14
+
15
+ /**
16
+ * Divides this shape into two separate shapes at parameter value $t$.
17
+ */
18
+ abstract splitAt(t: number): [ Parameterized2DShape ] | [ Parameterized2DShape, Parameterized2DShape ];
19
+
20
+ /**
21
+ * Returns the nearest point on `this` to `point` and the `parameterValue` at which
22
+ * that point occurs.
23
+ */
24
+ abstract nearestPointTo(point: Point2): { point: Point2, parameterValue: number };
25
+
26
+ /**
27
+ * Returns the **parameter values** at which `lineSegment` intersects this shape.
28
+ *
29
+ * See also {@link intersectsLineSegment}
30
+ */
31
+ public abstract argIntersectsLineSegment(lineSegment: LineSegment2): number[];
32
+
33
+
34
+ public override intersectsLineSegment(line: LineSegment2): Point2[] {
35
+ return this.argIntersectsLineSegment(line).map(t => this.at(t));
36
+ }
37
+ }
38
+
39
+ export default Parameterized2DShape;
@@ -60,6 +60,24 @@ describe('Path', () => {
60
60
  );
61
61
  });
62
62
 
63
+ it.each([
64
+ [ 'm0,0 L1,1', 'M0,0 L1,1', true ],
65
+ [ 'm0,0 L1,1', 'M1,1 L0,0', false ],
66
+ [ 'm0,0 L1,1 Q2,3 4,5', 'M1,1 L0,0', false ],
67
+ [ 'm0,0 L1,1 Q2,3 4,5', 'M1,1 L0,0 Q2,3 4,5', false ],
68
+ [ 'm0,0 L1,1 Q2,3 4,5', 'M0,0 L1,1 Q2,3 4,5', true ],
69
+ [ 'm0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', true ],
70
+ [ 'm0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9Z', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', false ],
71
+ [ 'm0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9Z', false ],
72
+ [ 'm0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9.01', false ],
73
+ [ 'm0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5 6,7.01 8,9', false ],
74
+ [ 'm0,0 L1,1 Q2,3 4,5 C4,5 6,7 8,9', 'M0,0 L1,1 Q2,3 4,5 C4,5.01 6,7 8,9', false ],
75
+ ])('.eq should check equality', (path1Str, path2Str, shouldEqual) => {
76
+ expect(Path.fromString(path1Str)).objEq(Path.fromString(path1Str));
77
+ expect(Path.fromString(path2Str)).objEq(Path.fromString(path2Str));
78
+ expect(Path.fromString(path1Str).eq(Path.fromString(path2Str))).toBe(shouldEqual);
79
+ });
80
+
63
81
  describe('intersection', () => {
64
82
  it('should give all intersections for a path made up of lines', () => {
65
83
  const lineStart = Vec2.of(100, 100);
@@ -179,7 +197,7 @@ describe('Path', () => {
179
197
  });
180
198
  });
181
199
 
182
- it('should give all intersections for a Bézier stroked path', () => {
200
+ it('should correctly report intersections for a simple Bézier curve path', () => {
183
201
  const lineStart = Vec2.zero;
184
202
  const path = new Path(lineStart, [
185
203
  {
@@ -196,13 +214,36 @@ describe('Path', () => {
196
214
  let intersections = path.intersection(
197
215
  new LineSegment2(Vec2.of(-1, 0.5), Vec2.of(2, 0.5)), strokeWidth,
198
216
  );
199
- expect(intersections.length).toBe(0);
217
+ expect(intersections).toHaveLength(0);
200
218
 
201
219
  // Should be an intersection when exiting/entering the edge of the stroke
202
220
  intersections = path.intersection(
203
221
  new LineSegment2(Vec2.of(0, 0.5), Vec2.of(8, 0.5)), strokeWidth,
204
222
  );
205
- expect(intersections.length).toBe(1);
223
+ expect(intersections).toHaveLength(1);
224
+ });
225
+
226
+ it('should correctly report intersections near the cap of a line-like Bézier', () => {
227
+ const path = Path.fromString('M0,0Q14,0 27,0');
228
+ expect(
229
+ path.intersection(
230
+ new LineSegment2(Vec2.of(0, -100), Vec2.of(0, 100)),
231
+ 10,
232
+ ),
233
+
234
+ // Should have intersections, despite being at the cap of the Bézier
235
+ // curve.
236
+ ).toHaveLength(2);
237
+ });
238
+
239
+ it.each([
240
+ [new LineSegment2(Vec2.of(43.5,-12.5), Vec2.of(40.5,24.5)), 0],
241
+ // TODO: The below case is failing. It seems to be a Bezier-js bug though...
242
+ // (The Bézier.js method returns an empty array).
243
+ //[new LineSegment2(Vec2.of(35.5,19.5), Vec2.of(38.5,-17.5)), 0],
244
+ ])('should correctly report positive intersections with a line-like Bézier', (line, strokeRadius) => {
245
+ const bezier = Path.fromString('M0,0 Q50,0 100,0');
246
+ expect(bezier.intersection(line, strokeRadius).length).toBeGreaterThan(0);
206
247
  });
207
248
  });
208
249
 
@@ -306,4 +347,23 @@ describe('Path', () => {
306
347
  expect(strokedRect.startPoint).objEq(lastSegment.point);
307
348
  });
308
349
  });
350
+
351
+ it.each([
352
+ [ 'm0,0 L1,1', 'M1,1 L0,0' ],
353
+ [ 'm0,0 L1,1', 'M1,1 L0,0' ],
354
+ [ 'M0,0 L1,1 Q2,2 3,3', 'M3,3 Q2,2 1,1 L0,0' ],
355
+ [ 'M0,0 L1,1 Q4,2 5,3 C12,13 10,9 8,7', 'M8,7 C 10,9 12,13 5,3 Q 4,2 1,1 L 0,0' ],
356
+ ])('.reversed should reverse paths', (original, expected) => {
357
+ expect(Path.fromString(original).reversed()).objEq(Path.fromString(expected));
358
+ expect(Path.fromString(expected).reversed()).objEq(Path.fromString(original));
359
+ expect(Path.fromString(original).reversed().reversed()).objEq(Path.fromString(original));
360
+ });
361
+
362
+ it.each([
363
+ [ 'm0,0 l1,0', Vec2.of(0, 0), Vec2.of(0, 0) ],
364
+ [ 'm0,0 l1,0', Vec2.of(0.5, 0), Vec2.of(0.5, 0) ],
365
+ [ 'm0,0 Q1,0 1,2', Vec2.of(1, 0), Vec2.of(0.6236, 0.299) ],
366
+ ])('.nearestPointTo should return the closest point on a path to the given parameter (case %#)', (path, point, expectedClosest) => {
367
+ expect(Path.fromString(path).nearestPointTo(point).point).objEq(expectedClosest, 0.002);
368
+ });
309
369
  });