vector2d 2.3.0 → 3.0.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/build.yml +1 -1
  3. data/.gitignore +3 -1
  4. data/.release-please-manifest.json +1 -1
  5. data/.rubocop.yml +1 -1
  6. data/.yardopts +11 -0
  7. data/CHANGELOG.md +216 -0
  8. data/Gemfile +5 -1
  9. data/Gemfile.lock +35 -5
  10. data/README.md +325 -12
  11. data/Rakefile +18 -0
  12. data/benchmark/vector_comparison.rb +129 -0
  13. data/lib/vector2d/angles.rb +315 -0
  14. data/lib/vector2d/arithmetic.rb +97 -0
  15. data/lib/vector2d/comparison.rb +188 -0
  16. data/lib/vector2d/componentwise.rb +239 -0
  17. data/lib/vector2d/constructors.rb +137 -0
  18. data/lib/vector2d/conversions.rb +139 -0
  19. data/lib/vector2d/coordinates.rb +22 -0
  20. data/lib/vector2d/deprecation.rb +16 -0
  21. data/lib/vector2d/dimensions.rb +297 -0
  22. data/lib/vector2d/interpolation.rb +191 -0
  23. data/lib/vector2d/lengths.rb +284 -0
  24. data/lib/vector2d/matrix_interop.rb +93 -0
  25. data/lib/vector2d/parsing.rb +142 -0
  26. data/lib/vector2d/projection.rb +208 -0
  27. data/lib/vector2d/version.rb +2 -1
  28. data/lib/vector2d.rb +172 -60
  29. data/spec/lib/vector2d/angles_spec.rb +441 -0
  30. data/spec/lib/vector2d/{calculations_spec.rb → arithmetic_spec.rb} +48 -58
  31. data/spec/lib/vector2d/comparison_spec.rb +358 -0
  32. data/spec/lib/vector2d/componentwise_spec.rb +300 -0
  33. data/spec/lib/vector2d/constructors_spec.rb +299 -0
  34. data/spec/lib/vector2d/conversions_spec.rb +234 -0
  35. data/spec/lib/vector2d/dimensions_spec.rb +820 -0
  36. data/spec/lib/vector2d/interpolation_spec.rb +322 -0
  37. data/spec/lib/vector2d/lengths_spec.rb +457 -0
  38. data/spec/lib/vector2d/matrix_interop_spec.rb +92 -0
  39. data/spec/lib/vector2d/parsing_spec.rb +329 -0
  40. data/spec/lib/vector2d/projection_spec.rb +306 -0
  41. data/spec/lib/vector2d_documentation_spec.rb +38 -0
  42. data/spec/lib/vector2d_immutability_spec.rb +263 -0
  43. data/spec/lib/vector2d_spec.rb +90 -50
  44. data/spec/lib/vector2d_subclassing_spec.rb +210 -0
  45. data/spec/lib/vector2d_tags_spec.rb +43 -0
  46. data/spec/spec_helper.rb +2 -0
  47. data/spec/support/doc_examples/comments.rb +65 -0
  48. data/spec/support/doc_examples/markdown.rb +50 -0
  49. data/spec/support/doc_examples.rb +94 -0
  50. data/spec/support/doc_tags.rb +151 -0
  51. data/spec/support/shared_examples/class_preserving_method.rb +10 -0
  52. data/spec/support/shared_examples/deprecated_method.rb +25 -0
  53. data/spec/support/shared_examples/parsed_vector.rb +11 -0
  54. data/vector2d.gemspec +2 -1
  55. metadata +42 -13
  56. data/.travis.yml +0 -10
  57. data/lib/vector2d/calculations.rb +0 -141
  58. data/lib/vector2d/coercions.rb +0 -64
  59. data/lib/vector2d/fitting.rb +0 -60
  60. data/lib/vector2d/properties.rb +0 -46
  61. data/lib/vector2d/transformations.rb +0 -98
  62. data/spec/lib/vector2d/coercions_spec.rb +0 -59
  63. data/spec/lib/vector2d/fitting_spec.rb +0 -70
  64. data/spec/lib/vector2d/properties_spec.rb +0 -47
  65. data/spec/lib/vector2d/transformations_spec.rb +0 -139
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Vector2d
4
+ # The vector as a rectangle. Vector2d grew out of handling image
5
+ # dimensions, and these methods describe a size and fit one inside
6
+ # another. All of them compare coordinates by magnitude, so a
7
+ # negative vector describes the same rectangle as its positive twin.
8
+ module Dimensions
9
+ # Area covered by the vector, the product of its coordinates.
10
+ # Coordinates are taken by magnitude, as in #aspect_ratio, so the
11
+ # area is never negative.
12
+ #
13
+ # Vector2d(2, 3).area # => 6
14
+ # Vector2d(-2, 3).area # => 6
15
+ #
16
+ # A vector without width or height covers nothing.
17
+ #
18
+ # Vector2d(2, 0).area # => 0
19
+ #
20
+ # @return [Integer, Float, Rational, BigDecimal]
21
+ def area
22
+ (x * y).abs
23
+ end
24
+
25
+ # Aspect ratio of vector.
26
+ #
27
+ # Vector2d(2, 3).aspect_ratio # => 0.6666..
28
+ #
29
+ # A vector without height has no aspect ratio, so ArgumentError is
30
+ # raised.
31
+ #
32
+ # Vector2d(2, 0).aspect_ratio # => ArgumentError
33
+ # Vector2d(0, 0).aspect_ratio # => ArgumentError
34
+ #
35
+ # @return [Float]
36
+ def aspect_ratio
37
+ raise ArgumentError, "the zero vector has no aspect ratio" if zero?
38
+ raise ArgumentError, "#{inspect} has no aspect ratio, y is zero" if y.zero?
39
+
40
+ (x.to_f / y).abs
41
+ end
42
+
43
+ # Is the vector wider than it is tall?
44
+ #
45
+ # Vector2d(3, 2).landscape? # => true
46
+ # Vector2d(2, 3).landscape? # => false
47
+ # Vector2d(2, 2).landscape? # => false
48
+ #
49
+ # Coordinates are compared by magnitude, as in #aspect_ratio.
50
+ #
51
+ # Vector2d(-3, 2).landscape? # => true
52
+ #
53
+ # @return [Boolean]
54
+ def landscape?
55
+ x.abs > y.abs
56
+ end
57
+
58
+ # Is the vector taller than it is wide?
59
+ #
60
+ # Vector2d(2, 3).portrait? # => true
61
+ # Vector2d(3, 2).portrait? # => false
62
+ # Vector2d(2, 2).portrait? # => false
63
+ #
64
+ # Coordinates are compared by magnitude, as in #aspect_ratio.
65
+ #
66
+ # Vector2d(2, -3).portrait? # => true
67
+ #
68
+ # @return [Boolean]
69
+ def portrait?
70
+ x.abs < y.abs
71
+ end
72
+
73
+ # Is the vector as wide as it is tall?
74
+ #
75
+ # Vector2d(2, 2).square? # => true
76
+ # Vector2d(2, 3).square? # => false
77
+ #
78
+ # Coordinates are compared by magnitude, as in #aspect_ratio.
79
+ # Exactly one of #square?, #landscape? and #portrait? holds for any
80
+ # vector.
81
+ #
82
+ # Vector2d(-2, 2).square? # => true
83
+ #
84
+ # Unlike #aspect_ratio, these three don't single out the zero
85
+ # vector. It is square.
86
+ #
87
+ # Vector2d(0, 0).square? # => true
88
+ #
89
+ # @return [Boolean]
90
+ def square?
91
+ x.abs == y.abs
92
+ end
93
+
94
+ # Scales the vector to fit inside another vector, retaining the
95
+ # aspect ratio.
96
+ #
97
+ # vector = Vector2d(20, 10)
98
+ # vector.fit(Vector2d(10, 10)) # => Vector2d(10.0,5.0)
99
+ # vector.fit(Vector2d(20, 20)) # => Vector2d(20.0,10.0)
100
+ # vector.fit(Vector2d(40, 40)) # => Vector2d(40.0,20.0)
101
+ #
102
+ # Pass <tt>upscale: false</tt> to scale down only, leaving a vector
103
+ # that already fits unchanged.
104
+ #
105
+ # vector.fit(Vector2d(40, 40), upscale: false) # => Vector2d(20,10)
106
+ # vector.fit(Vector2d(10, 10), upscale: false) # => Vector2d(10.0,5.0)
107
+ #
108
+ # The constraint applies to the magnitude of each coordinate, and
109
+ # the vector keeps its direction.
110
+ #
111
+ # Vector2d(-20, 10).fit(Vector2d(5, 5)) # => Vector2d(-5.0,2.5)
112
+ #
113
+ # Note: Either axis will be disregarded if zero or nil. This is a
114
+ # feature, not a bug. A constraint that is zero on both axes leaves
115
+ # the vector unchanged.
116
+ #
117
+ # Vector2d(20, 10).fit(Vector2d(0, 0)) # => Vector2d(20,10)
118
+ #
119
+ # The zero vector has no direction, and is returned unchanged.
120
+ #
121
+ # Vector2d(0, 0).fit(Vector2d(10, 10)) # => Vector2d(0,0)
122
+ #
123
+ # @!macro coercible
124
+ # @param upscale [Boolean] whether to scale a vector that already
125
+ # fits up to the constraint
126
+ # @return [self]
127
+ def fit(other, upscale: true)
128
+ scale_by(fit_factors(coerce_vector(other)).min, upscale: upscale)
129
+ end
130
+
131
+ # Does the vector already fit inside another vector? True whenever
132
+ # <tt>fit(other, upscale: false)</tt> would leave it unchanged.
133
+ #
134
+ # constraint = Vector2d(20, 20)
135
+ # Vector2d(20, 10).fits?(constraint) # => true
136
+ # Vector2d(40, 10).fits?(constraint) # => false
137
+ #
138
+ # A vector that exactly matches the constraint fits.
139
+ #
140
+ # Vector2d(20, 20).fits?(constraint) # => true
141
+ #
142
+ # Coordinates are compared by magnitude, as in #fit.
143
+ #
144
+ # Vector2d(-20, 10).fits?(constraint) # => true
145
+ #
146
+ # Note: Either axis will be disregarded if zero or nil, as in #fit.
147
+ # An axis that doesn't constrain can't be overflowed either.
148
+ #
149
+ # Vector2d(40, 10).fits?(Vector2d(0, 20)) # => true
150
+ #
151
+ # The zero vector has no size, and fits inside anything.
152
+ #
153
+ # Vector2d(0, 0).fits?(constraint) # => true
154
+ #
155
+ # @!macro coercible
156
+ # @return [Boolean]
157
+ def fits?(other)
158
+ factor = fit_factors(coerce_vector(other)).min
159
+ factor.nil? || factor >= 1
160
+ end
161
+
162
+ # Scales the vector to cover another vector, retaining the aspect
163
+ # ratio. Where #fit scales until the vector is contained by the
164
+ # constraint, #cover scales until it contains the constraint.
165
+ #
166
+ # constraint = Vector2d(5, 5)
167
+ # Vector2d(20, 10).cover(constraint) # => Vector2d(10.0,5.0)
168
+ # Vector2d(10, 20).cover(constraint) # => Vector2d(5.0,10.0)
169
+ #
170
+ # Pass <tt>upscale: false</tt> to scale down only, leaving a vector
171
+ # that already covers the constraint unchanged.
172
+ #
173
+ # Vector2d(20, 10).cover(Vector2d(40, 40), upscale: false)
174
+ # # => Vector2d(20,10)
175
+ #
176
+ # As in #fit, coordinates are constrained by magnitude and the
177
+ # vector keeps its direction.
178
+ #
179
+ # Vector2d(-20, 10).cover(constraint) # => Vector2d(-10.0,5.0)
180
+ #
181
+ # Note: Either axis will be disregarded if zero or nil, as in #fit.
182
+ # This is a feature, not a bug.
183
+ #
184
+ # Vector2d(0, 10).cover(constraint) # => Vector2d(0.0,5.0)
185
+ # Vector2d(20, 10).cover(Vector2d(0, 0)) # => Vector2d(20,10)
186
+ #
187
+ # The zero vector has no direction, and is returned unchanged.
188
+ #
189
+ # Vector2d(0, 0).cover(Vector2d(5, 5)) # => Vector2d(0,0)
190
+ #
191
+ # @!macro coercible
192
+ # @param upscale [Boolean] whether to scale a vector that already
193
+ # covers up to the constraint
194
+ # @return [self]
195
+ def cover(other, upscale: true)
196
+ scale_by(fit_factors(coerce_vector(other)).max, upscale: upscale)
197
+ end
198
+
199
+ # Does the vector already cover another vector? True whenever
200
+ # #cover would shrink the vector or leave it alone, rather than
201
+ # scaling it up. This is the predicate to #cover that #fits? is to
202
+ # #fit.
203
+ #
204
+ # constraint = Vector2d(5, 5)
205
+ # Vector2d(20, 10).covers?(constraint) # => true
206
+ # Vector2d(20, 1).covers?(constraint) # => false
207
+ #
208
+ # Coordinates are compared by magnitude, as in #cover.
209
+ #
210
+ # Vector2d(-20, 10).covers?(constraint) # => true
211
+ #
212
+ # Note: Either axis will be disregarded if zero or nil, as in
213
+ # #cover, so a vector flat on one axis still counts as covering.
214
+ #
215
+ # Vector2d(0, 10).covers?(constraint) # => true
216
+ # Vector2d(0, 0).covers?(constraint) # => true
217
+ #
218
+ # @!macro coercible
219
+ # @return [Boolean]
220
+ def covers?(other)
221
+ factor = fit_factors(coerce_vector(other)).max
222
+ factor.nil? || factor <= 1
223
+ end
224
+
225
+ # @deprecated Use #cover instead.
226
+ #
227
+ # @!macro coercible
228
+ # @return [self]
229
+ def fit_either(other)
230
+ warn_deprecated("Vector2d#fit_either is deprecated. Use #cover instead.")
231
+ cover(other)
232
+ end
233
+
234
+ # @deprecated Use <tt>other.fit(self, upscale: false)</tt> instead.
235
+ #
236
+ # Scales down the given vector unless it fits inside.
237
+ #
238
+ # vector = Vector2d(20, 20)
239
+ # vector.contain(Vector2d(10, 10)) # => Vector2d(10,10)
240
+ # vector.contain(Vector2d(40, 20)) # => Vector2d(20.0,10.0)
241
+ # vector.contain(Vector2d(20, 40)) # => Vector2d(10.0,20.0)
242
+ #
243
+ # Coordinates are compared by magnitude, so negative vectors are
244
+ # scaled the same way and keep their direction.
245
+ #
246
+ # vector.contain(Vector2d(-40, 20)) # => Vector2d(-20.0,10.0)
247
+ #
248
+ # An axis that is zero is unconstrained, as in #fit, so the zero
249
+ # vector contains anything.
250
+ #
251
+ # Vector2d(0, 0).contain(Vector2d(40, 20)) # => Vector2d(40,20)
252
+ #
253
+ # @!macro coercible
254
+ # @return [Vector2d] a vector of the argument's class, not of this one
255
+ def contain(other)
256
+ warn_deprecated("Vector2d#contain is deprecated. " \
257
+ "Use `other.fit(self, upscale: false)` instead.")
258
+ coerce_vector(other).fit(self, upscale: false)
259
+ end
260
+
261
+ # @deprecated Use #fit instead.
262
+ #
263
+ # @!macro coercible
264
+ # @return [self]
265
+ def constrain_both(other)
266
+ warn_deprecated("Vector2d#constrain_both is deprecated. Use #fit instead.")
267
+ fit(other)
268
+ end
269
+
270
+ # @deprecated Use #cover instead.
271
+ #
272
+ # @!macro coercible
273
+ # @return [self]
274
+ def constrain_one(other)
275
+ warn_deprecated("Vector2d#constrain_one is deprecated. Use #cover instead.")
276
+ cover(other)
277
+ end
278
+
279
+ protected
280
+
281
+ # Magnitudes of the scale factor for each axis, disregarding axes
282
+ # that don't constrain the vector.
283
+ def fit_factors(other)
284
+ scale = other.to_f_vector / self
285
+ scale.to_a.select { |s| s.finite? && !s.zero? }.map(&:abs)
286
+ end
287
+
288
+ # Scales the vector by the given factor. An unconstrained vector,
289
+ # which has no factor, is returned unchanged, as is one that would
290
+ # grow when +upscale+ is false.
291
+ def scale_by(factor, upscale:)
292
+ return self if factor.nil? || (!upscale && factor >= 1)
293
+
294
+ self * factor
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Vector2d
4
+ # Interpolation between two vectors, and stepping from one toward
5
+ # another.
6
+ module Interpolation
7
+ # Linearly interpolates between this vector and another vector.
8
+ #
9
+ # v1 = Vector2d(0, 0)
10
+ # v2 = Vector2d(10, 20)
11
+ # v1.lerp(v2, 0.0) # => Vector2d(0.0,0.0)
12
+ # v1.lerp(v2, 0.25) # => Vector2d(2.5,5.0)
13
+ # v1.lerp(v2, 1.0) # => Vector2d(10.0,20.0)
14
+ #
15
+ # The amount is not clamped to 0..1. Values outside that range
16
+ # extrapolate past the end points.
17
+ #
18
+ # v1.lerp(v2, 2.0) # => Vector2d(20.0,40.0)
19
+ # v1.lerp(v2, -0.5) # => Vector2d(-5.0,-10.0)
20
+ #
21
+ # Raises ArgumentError unless the amount is a real number. One
22
+ # amount applies to both axes.
23
+ #
24
+ # v1.lerp(v2, Vector2d(0.25, 0.5)) # => ArgumentError
25
+ #
26
+ # @!macro coercible
27
+ # @param amount [Integer, Float, Rational, BigDecimal]
28
+ # the position along the segment
29
+ # @return [self]
30
+ def lerp(other, amount)
31
+ v = coerce_vector(other)
32
+ amount = coordinate(amount)
33
+ build(interpolate(x, v.x, amount), interpolate(y, v.y, amount))
34
+ end
35
+
36
+ # Returns the amount #lerp would need to land on a value, the
37
+ # position of that value along the segment from this vector to
38
+ # another one. This is the inverse of #lerp.
39
+ #
40
+ # v1 = Vector2d(0, 0)
41
+ # v2 = Vector2d(10, 20)
42
+ # v1.inverse_lerp(v2, Vector2d(2.5, 5.0)) # => 0.25
43
+ # v1.inverse_lerp(v2, v1) # => 0.0
44
+ # v1.inverse_lerp(v2, v2) # => 1.0
45
+ #
46
+ # The result is a scalar, one amount for both axes, matching the
47
+ # single amount #lerp takes.
48
+ #
49
+ # v1.lerp(v2, v1.inverse_lerp(v2, Vector2d(2.5, 5.0))) # => Vector2d(2.5,5.0)
50
+ #
51
+ # The value does not have to lie on the segment. Anything off it is
52
+ # projected onto the line through the end points first, so the
53
+ # result is the position of the nearest point on that line.
54
+ #
55
+ # v1.inverse_lerp(v2, Vector2d(5, 0)) # => 0.1
56
+ #
57
+ # The result is not clamped to 0..1, the same way the amount #lerp
58
+ # takes is not. Values beyond the end points fall outside it.
59
+ #
60
+ # v1.inverse_lerp(v2, Vector2d(20, 40)) # => 2.0
61
+ # v1.inverse_lerp(v2, Vector2d(-5, -10)) # => -0.5
62
+ #
63
+ # A segment between two identical vectors has no length to measure
64
+ # along, and no amount reaches anything but its own end point. Zero
65
+ # is returned.
66
+ #
67
+ # v1.inverse_lerp(v1, Vector2d(2.5, 5.0)) # => 0.0
68
+ #
69
+ # @!macro coercible
70
+ # @param value [Vector2d, Array, String, Hash, Integer, Float, Rational,
71
+ # BigDecimal, ::Vector, ::Matrix] the value to locate, anything
72
+ # Vector2d.parse accepts
73
+ # @return [Float] the amount #lerp would take to reach the value
74
+ def inverse_lerp(other, value)
75
+ segment = coerce_vector(other) - self
76
+ return 0.0 if segment.zero?
77
+
78
+ (coerce_vector(value) - self).dot_product(segment).to_f / segment.length_squared
79
+ end
80
+
81
+ # Spherically interpolates between this vector and another vector.
82
+ # The vector turns through the angle between the two, while the
83
+ # length is interpolated linearly. The other vector is coerced, so
84
+ # scalars work too.
85
+ #
86
+ # v1 = Vector2d(2, 0)
87
+ # v2 = Vector2d(0, 4)
88
+ # v1.slerp(v2, 0.5) # => Vector2d(2.1213..,2.1213..)
89
+ #
90
+ # Where #lerp moves along the straight line between the two
91
+ # vectors, #slerp moves along the arc between them, so the length
92
+ # follows the end points instead of cutting the corner.
93
+ #
94
+ # v1.lerp(v2, 0.5).length # => 2.2360..
95
+ # v1.slerp(v2, 0.5).length # => 3.0
96
+ #
97
+ # Vectors pointing in opposite directions are half a turn apart
98
+ # either way around. The turn is counterclockwise, the direction
99
+ # #rotate takes a positive angle in.
100
+ #
101
+ # half = Vector2d(2, 0).slerp(Vector2d(-2, 0), 0.5)
102
+ # half.angle # => 1.5707..
103
+ # half.length # => 2.0
104
+ #
105
+ # The zero vector has no direction to turn from or to, and there is
106
+ # no arc to follow. #lerp is used instead.
107
+ #
108
+ # Vector2d(0, 0).slerp(v2, 0.5) # => Vector2d(0.0,2.0)
109
+ #
110
+ # The amount is not clamped to 0..1, and behaves as it does in
111
+ # #lerp. Values outside that range keep turning past the end
112
+ # points.
113
+ #
114
+ # v1.slerp(v2, 2.0).angle # => 3.1415..
115
+ #
116
+ # @!macro coercible
117
+ # @param amount [Integer, Float, Rational, BigDecimal]
118
+ # the position along the segment
119
+ # @return [self]
120
+ def slerp(other, amount)
121
+ v = coerce_vector(other)
122
+ amount = coordinate(amount)
123
+ return lerp(v, amount) if zero? || v.zero?
124
+
125
+ rotate(slerp_angle(v) * amount) *
126
+ (interpolate(length, v.length, amount) / length)
127
+ end
128
+
129
+ # Returns the point halfway between this vector and another vector.
130
+ #
131
+ # v1 = Vector2d(0, 0)
132
+ # v2 = Vector2d(10, 20)
133
+ # v1.midpoint(v2) # => Vector2d(5.0,10.0)
134
+ #
135
+ # @!macro coercible
136
+ # @return [self]
137
+ def midpoint(other)
138
+ lerp(other, 0.5)
139
+ end
140
+
141
+ # Moves this vector toward another vector by a fixed distance,
142
+ # stopping at the target instead of overshooting it. The target is
143
+ # coerced, so scalars work too.
144
+ #
145
+ # v1 = Vector2d(0, 0)
146
+ # v2 = Vector2d(10, 0)
147
+ # v1.move_toward(v2, 2) # => Vector2d(2.0,0.0)
148
+ # v1.move_toward(v2, 20) # => Vector2d(10,0)
149
+ #
150
+ # Where #lerp takes a fraction of the way there, this takes a
151
+ # distance, so the step is the same size however far off the target
152
+ # is.
153
+ #
154
+ # A negative distance moves away from the target, and has nothing
155
+ # to overshoot.
156
+ #
157
+ # v1.move_toward(v2, -2) # => Vector2d(-2.0,0.0)
158
+ #
159
+ # There is nowhere to move when the vector is already at the
160
+ # target, whatever the distance. The target is returned.
161
+ #
162
+ # v2.move_toward(v2, 2) # => Vector2d(10,0)
163
+ #
164
+ # @!macro coercible
165
+ # @param distance [Integer, Float, Rational, BigDecimal] how far to move
166
+ # @return [self]
167
+ def move_toward(target, distance)
168
+ v = coerce_vector(target)
169
+ distance = coordinate(distance)
170
+ delta = build(v.x - x, v.y - y)
171
+ return build(v.x, v.y) if delta.zero? || distance >= delta.length
172
+
173
+ self + delta.resize(distance)
174
+ end
175
+
176
+ private
177
+
178
+ # The angle #slerp turns through. Vectors pointing in opposite
179
+ # directions are half a turn apart either way around, and the
180
+ # counterclockwise one is taken.
181
+ def slerp_angle(other)
182
+ return Math::PI if parallel?(other) && dot_product(other).negative?
183
+
184
+ angle_to(other)
185
+ end
186
+
187
+ def interpolate(start, finish, amount)
188
+ start + ((finish - start) * amount)
189
+ end
190
+ end
191
+ end