vector2d 2.2.4 → 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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +39 -0
  3. data/.github/workflows/build.yml +48 -10
  4. data/.gitignore +3 -1
  5. data/.release-please-manifest.json +3 -0
  6. data/.rubocop.yml +5 -2
  7. data/.tool-versions +1 -0
  8. data/.yardopts +11 -0
  9. data/CHANGELOG.md +224 -0
  10. data/CODE_OF_CONDUCT.md +92 -0
  11. data/CONTRIBUTING.md +30 -0
  12. data/Gemfile +6 -2
  13. data/Gemfile.lock +85 -51
  14. data/README.md +330 -13
  15. data/Rakefile +21 -0
  16. data/benchmark/vector_comparison.rb +129 -0
  17. data/lib/vector2d/angles.rb +315 -0
  18. data/lib/vector2d/arithmetic.rb +97 -0
  19. data/lib/vector2d/comparison.rb +188 -0
  20. data/lib/vector2d/componentwise.rb +239 -0
  21. data/lib/vector2d/constructors.rb +137 -0
  22. data/lib/vector2d/conversions.rb +139 -0
  23. data/lib/vector2d/coordinates.rb +22 -0
  24. data/lib/vector2d/deprecation.rb +16 -0
  25. data/lib/vector2d/dimensions.rb +297 -0
  26. data/lib/vector2d/interpolation.rb +191 -0
  27. data/lib/vector2d/lengths.rb +284 -0
  28. data/lib/vector2d/matrix_interop.rb +93 -0
  29. data/lib/vector2d/parsing.rb +142 -0
  30. data/lib/vector2d/projection.rb +208 -0
  31. data/lib/vector2d/version.rb +2 -1
  32. data/lib/vector2d.rb +174 -62
  33. data/release-please-config.json +10 -0
  34. data/spec/lib/vector2d/angles_spec.rb +441 -0
  35. data/spec/lib/vector2d/{calculations_spec.rb → arithmetic_spec.rb} +48 -58
  36. data/spec/lib/vector2d/comparison_spec.rb +358 -0
  37. data/spec/lib/vector2d/componentwise_spec.rb +300 -0
  38. data/spec/lib/vector2d/constructors_spec.rb +299 -0
  39. data/spec/lib/vector2d/conversions_spec.rb +234 -0
  40. data/spec/lib/vector2d/dimensions_spec.rb +820 -0
  41. data/spec/lib/vector2d/interpolation_spec.rb +322 -0
  42. data/spec/lib/vector2d/lengths_spec.rb +457 -0
  43. data/spec/lib/vector2d/matrix_interop_spec.rb +92 -0
  44. data/spec/lib/vector2d/parsing_spec.rb +329 -0
  45. data/spec/lib/vector2d/projection_spec.rb +306 -0
  46. data/spec/lib/vector2d_documentation_spec.rb +38 -0
  47. data/spec/lib/vector2d_immutability_spec.rb +263 -0
  48. data/spec/lib/vector2d_spec.rb +90 -50
  49. data/spec/lib/vector2d_subclassing_spec.rb +210 -0
  50. data/spec/lib/vector2d_tags_spec.rb +43 -0
  51. data/spec/spec_helper.rb +2 -0
  52. data/spec/support/doc_examples/comments.rb +65 -0
  53. data/spec/support/doc_examples/markdown.rb +50 -0
  54. data/spec/support/doc_examples.rb +94 -0
  55. data/spec/support/doc_tags.rb +151 -0
  56. data/spec/support/shared_examples/class_preserving_method.rb +10 -0
  57. data/spec/support/shared_examples/deprecated_method.rb +25 -0
  58. data/spec/support/shared_examples/parsed_vector.rb +11 -0
  59. data/vector2d.gemspec +9 -3
  60. metadata +55 -19
  61. data/.travis.yml +0 -10
  62. data/lib/vector2d/calculations.rb +0 -141
  63. data/lib/vector2d/coercions.rb +0 -64
  64. data/lib/vector2d/fitting.rb +0 -60
  65. data/lib/vector2d/properties.rb +0 -46
  66. data/lib/vector2d/transformations.rb +0 -85
  67. data/spec/lib/vector2d/coercions_spec.rb +0 -59
  68. data/spec/lib/vector2d/fitting_spec.rb +0 -70
  69. data/spec/lib/vector2d/properties_spec.rb +0 -47
  70. data/spec/lib/vector2d/transformations_spec.rb +0 -122
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "bigdecimal"
5
+
6
+ describe Vector2d::Constructors do
7
+ describe ".from_angle" do
8
+ context "with an angle of zero" do
9
+ subject(:vector) { Vector2d.from_angle(0) }
10
+
11
+ it_behaves_like "a parsed vector", [1.0, 0.0]
12
+ end
13
+
14
+ context "with a quarter turn" do
15
+ subject(:vector) { Vector2d.from_angle(Math::PI / 2) }
16
+
17
+ its(:x) { is_expected.to be_within(0.0001).of(0.0) }
18
+ its(:y) { is_expected.to be_within(0.0001).of(1.0) }
19
+ end
20
+
21
+ context "with a negative angle" do
22
+ subject(:vector) { Vector2d.from_angle(-Math::PI / 2) }
23
+
24
+ its(:x) { is_expected.to be_within(0.0001).of(0.0) }
25
+ its(:y) { is_expected.to be_within(0.0001).of(-1.0) }
26
+ end
27
+
28
+ context "without a length" do
29
+ subject(:vector) { Vector2d.from_angle(0.9827) }
30
+
31
+ its(:length) { is_expected.to be_within(0.0001).of(1.0) }
32
+ end
33
+
34
+ context "with a length" do
35
+ subject(:vector) { Vector2d.from_angle(0.9827, 3.6055) }
36
+
37
+ its(:length) { is_expected.to be_within(0.0001).of(3.6055) }
38
+ end
39
+
40
+ context "with an integer length" do
41
+ subject(:vector) { Vector2d.from_angle(0, 2) }
42
+
43
+ its(:x) { is_expected.to be_a(Float) }
44
+ its(:y) { is_expected.to be_a(Float) }
45
+ end
46
+
47
+ [2, Math::PI / 2, Rational(1, 2), BigDecimal("1.5707963267948966")].each do |angle|
48
+ context "with a #{angle.class} angle" do
49
+ subject(:vector) { Vector2d.from_angle(angle) }
50
+
51
+ it "returns float coordinates" do
52
+ expect(vector.to_a).to all(be_an_instance_of(Float))
53
+ end
54
+
55
+ it "returns the angle it was given" do
56
+ expect(vector.angle).to be_within(0.0001).of(angle.to_f)
57
+ end
58
+ end
59
+ end
60
+
61
+ [2, 2.0, Rational(2, 1), BigDecimal("2")].each do |length|
62
+ context "with a #{length.class} length" do
63
+ subject(:vector) { Vector2d.from_angle(0.9827, length) }
64
+
65
+ it "returns float coordinates" do
66
+ expect(vector.to_a).to all(be_an_instance_of(Float))
67
+ end
68
+
69
+ it "applies the length" do
70
+ expect(vector.length).to be_within(0.0001).of(2.0)
71
+ end
72
+ end
73
+ end
74
+
75
+ it "is the inverse of #to_polar" do
76
+ length, angle = Vector2d.new(2, 3).to_polar
77
+
78
+ expect(Vector2d.from_angle(angle, length))
79
+ .to eq(Vector2d.new(2.0, 3.0))
80
+ end
81
+
82
+ it "returns the angle it was given" do
83
+ expect(Vector2d.from_angle(0.9827).angle)
84
+ .to be_within(0.0001).of(0.9827)
85
+ end
86
+
87
+ context "with a non-numeric angle" do
88
+ it "raises an error" do
89
+ expect { Vector2d.from_angle("1") }.to(
90
+ raise_error(ArgumentError, 'not a valid coordinate: "1"')
91
+ )
92
+ end
93
+ end
94
+
95
+ context "with a non-numeric length" do
96
+ it "raises an error" do
97
+ expect { Vector2d.from_angle(0, nil) }.to(
98
+ raise_error(ArgumentError, "not a valid coordinate: nil")
99
+ )
100
+ end
101
+ end
102
+
103
+ context "with a complex angle" do
104
+ it "raises an error" do
105
+ expect { Vector2d.from_angle(Complex(1, 2)) }.to(
106
+ raise_error(ArgumentError, "not a valid coordinate: (1+2i)")
107
+ )
108
+ end
109
+ end
110
+
111
+ context "with a complex length" do
112
+ it "raises an error" do
113
+ expect { Vector2d.from_angle(0, Complex(1, 2)) }.to(
114
+ raise_error(ArgumentError, "not a valid coordinate: (1+2i)")
115
+ )
116
+ end
117
+ end
118
+ end
119
+
120
+ describe ".random" do
121
+ it "returns a unit vector" do
122
+ expect(Vector2d.random.length).to be_within(1e-12).of(1.0)
123
+ end
124
+
125
+ it "returns a vector of the given length" do
126
+ expect(Vector2d.random(2.5).length).to be_within(1e-12).of(2.5)
127
+ end
128
+
129
+ it "returns floats" do
130
+ expect(Vector2d.random.to_a).to all(be_a(Float))
131
+ end
132
+
133
+ it "returns a frozen vector" do
134
+ expect(Vector2d.random).to be_frozen
135
+ end
136
+
137
+ it "stays within the unit circle over many draws" do
138
+ lengths = 200.times.map { Vector2d.random.length }
139
+
140
+ expect(lengths).to all(be_within(1e-12).of(1.0))
141
+ end
142
+
143
+ context "with a seeded Random" do
144
+ it "repeats the same vector for the same seed" do
145
+ first = Vector2d.random(random: Random.new(42))
146
+
147
+ expect(Vector2d.random(random: Random.new(42))).to eq(first)
148
+ end
149
+
150
+ it "returns a different vector for a different seed" do
151
+ expect(Vector2d.random(random: Random.new(42)))
152
+ .not_to eq(Vector2d.random(random: Random.new(43)))
153
+ end
154
+
155
+ it "advances the sequence between draws" do
156
+ random = Random.new(42)
157
+
158
+ expect(Vector2d.random(random: random))
159
+ .not_to eq(Vector2d.random(random: random))
160
+ end
161
+
162
+ it "applies the length" do
163
+ expect(Vector2d.random(3.0, random: Random.new(42)).length)
164
+ .to be_within(1e-12).of(3.0)
165
+ end
166
+ end
167
+
168
+ context "with a stubbed Random" do
169
+ let(:random) { instance_double(Random, rand: 0.25) }
170
+
171
+ it "maps the draw onto a full turn" do
172
+ expect(Vector2d.random(random: random))
173
+ .to eq(Vector2d.from_angle(Math::PI / 2))
174
+ end
175
+
176
+ it "covers the whole circle" do
177
+ allow(random).to receive(:rand).and_return(0.75)
178
+
179
+ expect(Vector2d.random(random: random))
180
+ .to eq(Vector2d.from_angle(1.5 * Math::PI))
181
+ end
182
+ end
183
+
184
+ it "reaches every quadrant" do
185
+ random = Random.new(1234)
186
+ quadrants = 200.times.map do
187
+ Vector2d.random(random: random).to_a.map(&:negative?)
188
+ end
189
+
190
+ expect(quadrants.uniq.size).to eq(4)
191
+ end
192
+
193
+ [2, 2.0, Rational(2, 1), BigDecimal("2")].each do |length|
194
+ context "with a #{length.class} length" do
195
+ subject(:vector) { Vector2d.random(length, random: Random.new(42)) }
196
+
197
+ it "returns float coordinates" do
198
+ expect(vector.to_a).to all(be_an_instance_of(Float))
199
+ end
200
+
201
+ it "applies the length" do
202
+ expect(vector.length).to be_within(1e-12).of(2.0)
203
+ end
204
+ end
205
+ end
206
+
207
+ context "with a non-numeric length" do
208
+ it "raises an error" do
209
+ expect { Vector2d.random(nil) }.to(
210
+ raise_error(ArgumentError, "not a valid coordinate: nil")
211
+ )
212
+ end
213
+ end
214
+
215
+ context "with a complex length" do
216
+ it "raises an error" do
217
+ expect { Vector2d.random(Complex(1, 2)) }.to(
218
+ raise_error(ArgumentError, "not a valid coordinate: (1+2i)")
219
+ )
220
+ end
221
+ end
222
+ end
223
+
224
+ describe "the direction constants" do
225
+ {
226
+ zero: [0, 0],
227
+ one: [1, 1],
228
+ up: [0, 1],
229
+ down: [0, -1],
230
+ left: [-1, 0],
231
+ right: [1, 0]
232
+ }.each do |name, coordinates|
233
+ describe ".#{name}" do
234
+ subject(:vector) { Vector2d.public_send(name) }
235
+
236
+ it "has the expected coordinates" do
237
+ expect(vector.to_a).to eq(coordinates)
238
+ end
239
+
240
+ it "has integer coordinates" do
241
+ expect(vector.to_a).to all(be_an(Integer))
242
+ end
243
+
244
+ it { is_expected.to be_frozen }
245
+ end
246
+ end
247
+
248
+ it "points up along the positive y axis" do
249
+ expect(Vector2d.up.angle).to be_within(1e-12).of(Math::PI / 2)
250
+ end
251
+
252
+ it "points down along the negative y axis" do
253
+ expect(Vector2d.down.angle).to be_within(1e-12).of(-Math::PI / 2)
254
+ end
255
+
256
+ it "points right along the zero angle" do
257
+ expect(Vector2d.right.angle).to eq(0.0)
258
+ end
259
+
260
+ it "points left along the straight angle" do
261
+ expect(Vector2d.left.angle).to be_within(1e-12).of(Math::PI)
262
+ end
263
+
264
+ it "reverses up into down" do
265
+ expect(Vector2d.up.reverse).to eq(Vector2d.down)
266
+ end
267
+
268
+ it "reverses left into right" do
269
+ expect(Vector2d.left.reverse).to eq(Vector2d.right)
270
+ end
271
+
272
+ it "rotates right into up a quarter turn counterclockwise" do
273
+ expect(Vector2d.right.rotate(Math::PI / 2).round)
274
+ .to eq(Vector2d.up)
275
+ end
276
+
277
+ it "takes the perpendicular of right to up" do
278
+ expect(Vector2d.right.perpendicular).to eq(Vector2d.up)
279
+ end
280
+
281
+ it "keeps integer coordinates through integer arithmetic" do
282
+ expect((Vector2d.up * 2).to_a).to all(be_an(Integer))
283
+ end
284
+
285
+ it "widens to floats when a float is involved" do
286
+ expect((Vector2d.up * 0.5).to_a).to all(be_a(Float))
287
+ end
288
+
289
+ it "returns an equal vector on every call" do
290
+ first = Vector2d.zero
291
+
292
+ expect(Vector2d.zero).to eql(first)
293
+ end
294
+
295
+ it "is the zero vector" do
296
+ expect(Vector2d.zero).to be_zero
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ describe Vector2d::Conversions do
6
+ subject(:vector) { Vector2d.new(2, 3) }
7
+
8
+ describe "#coerce" do
9
+ it "returns the other operand as a vector, followed by itself" do
10
+ expect(vector.coerce(4)).to eq([Vector2d.new(4, 4), vector])
11
+ end
12
+
13
+ it "makes a vector the right hand operand of a scalar" do
14
+ expect(2 * vector).to eq(Vector2d.new(4, 6))
15
+ end
16
+
17
+ it "retains the operand order of the original expression" do
18
+ expect(10 - vector).to eq(Vector2d.new(8, 7))
19
+ end
20
+
21
+ context "when the other operand isn't parseable" do
22
+ it "raises a TypeError" do
23
+ expect { vector.coerce(Object.new) }.to(
24
+ raise_error(TypeError, "Object can't be coerced into Vector2d")
25
+ )
26
+ end
27
+ end
28
+
29
+ context "when the other operand is complex" do
30
+ it "raises a TypeError" do
31
+ expect { vector.coerce(Complex(1, 2)) }.to(
32
+ raise_error(TypeError, "Complex can't be coerced into Vector2d")
33
+ )
34
+ end
35
+ end
36
+
37
+ context "when a complex number is the left hand operand" do
38
+ it "raises a TypeError" do
39
+ expect { Complex(1, 2) * vector }.to(
40
+ raise_error(TypeError, "Complex can't be coerced into Vector2d")
41
+ )
42
+ end
43
+ end
44
+ end
45
+
46
+ describe "#deconstruct" do
47
+ it "returns an array" do
48
+ expect(vector.deconstruct).to eq([2, 3])
49
+ end
50
+ end
51
+
52
+ describe "#deconstruct_keys" do
53
+ it "returns a hash" do
54
+ expect(vector.deconstruct_keys(nil)).to eq(x: 2, y: 3)
55
+ end
56
+
57
+ context "when only one key is requested" do
58
+ it "returns both components" do
59
+ expect(vector.deconstruct_keys([:x])).to eq(x: 2, y: 3)
60
+ end
61
+ end
62
+ end
63
+
64
+ describe "pattern matching" do
65
+ def classify(vector)
66
+ case vector
67
+ in [0, 0] then :origin
68
+ in [Integer => a, Integer => b] then a + b
69
+ end
70
+ end
71
+
72
+ def axis_of(vector)
73
+ case vector
74
+ in { x: 0, y: 0 } then :origin
75
+ in { x: 0 } then :on_y_axis
76
+ in { y: 0 } then :on_x_axis
77
+ else :off_axis
78
+ end
79
+ end
80
+
81
+ def orientation_of(vector)
82
+ case vector
83
+ in [x, y] if x < y then :portrait
84
+ in [x, y] if x > y then :landscape
85
+ else :square
86
+ end
87
+ end
88
+
89
+ def contains?(vector, value)
90
+ case vector
91
+ in [*, ^value, *] then true
92
+ else false
93
+ end
94
+ end
95
+
96
+ it "destructures into an array" do
97
+ vector => [x, y]
98
+
99
+ expect([x, y]).to eq([2, 3])
100
+ end
101
+
102
+ it "destructures into keys" do
103
+ vector => { x:, y: }
104
+
105
+ expect([x, y]).to eq([2, 3])
106
+ end
107
+
108
+ it "binds the components of an array pattern" do
109
+ expect(classify(vector)).to eq(5)
110
+ end
111
+
112
+ it "matches a hash pattern" do
113
+ expect(axis_of(vector)).to eq(:off_axis)
114
+ end
115
+
116
+ it "matches a guarded pattern" do
117
+ expect(orientation_of(vector)).to eq(:portrait)
118
+ end
119
+
120
+ it "matches a find pattern" do
121
+ expect(contains?(vector, 3)).to be(true)
122
+ end
123
+
124
+ it "doesn't match a find pattern on a missing component" do
125
+ expect(contains?(vector, 4)).to be(false)
126
+ end
127
+
128
+ it "destructures a vector nested in an array" do
129
+ [Vector2d.new(0, 0), vector] => [[0, 0], [x, y]]
130
+
131
+ expect([x, y]).to eq([2, 3])
132
+ end
133
+
134
+ it "destructures a vector nested in a hash" do
135
+ { size: vector } => { size: { x:, y: } }
136
+
137
+ expect([x, y]).to eq([2, 3])
138
+ end
139
+
140
+ context "when the vector is the origin" do
141
+ subject(:vector) { Vector2d.new(0, 0) }
142
+
143
+ it "matches the leading array pattern" do
144
+ expect(classify(vector)).to eq(:origin)
145
+ end
146
+
147
+ it "matches the leading hash pattern" do
148
+ expect(axis_of(vector)).to eq(:origin)
149
+ end
150
+ end
151
+
152
+ context "when the vector is on an axis" do
153
+ subject(:vector) { Vector2d.new(0, 3) }
154
+
155
+ it "matches on the partial hash pattern" do
156
+ expect(axis_of(vector)).to eq(:on_y_axis)
157
+ end
158
+ end
159
+
160
+ context "when the components are floats" do
161
+ subject(:vector) { Vector2d.new(2.0, 3.0) }
162
+
163
+ it "raises NoMatchingPatternError" do
164
+ expect { classify(vector) }.to raise_error(NoMatchingPatternError)
165
+ end
166
+
167
+ it "still matches an untyped pattern" do
168
+ expect(orientation_of(vector)).to eq(:portrait)
169
+ end
170
+ end
171
+ end
172
+
173
+ describe "#inspect" do
174
+ it "renders a string representation" do
175
+ expect(vector.inspect).to eq("Vector2d(2,3)")
176
+ end
177
+
178
+ context "when the vector is a subclass" do
179
+ subject(:vector) { SubVector.new(2, 3) }
180
+
181
+ before { stub_const("SubVector", Class.new(Vector2d)) }
182
+
183
+ it "renders the name of the subclass" do
184
+ expect(vector.inspect).to eq("SubVector(2,3)")
185
+ end
186
+ end
187
+ end
188
+
189
+ describe "#to_a" do
190
+ it "returns an array" do
191
+ expect(vector.to_a).to eq([2, 3])
192
+ end
193
+ end
194
+
195
+ describe "#to_hash" do
196
+ it "returns a hash" do
197
+ expect(vector.to_hash).to eq(x: 2, y: 3)
198
+ end
199
+ end
200
+
201
+ describe "#to_i_vector" do
202
+ subject { vector.to_i_vector }
203
+
204
+ let(:vector) { Vector2d.new(2.0, 3.0) }
205
+
206
+ its(:x) { is_expected.to be_a(Integer) }
207
+ its(:y) { is_expected.to be_a(Integer) }
208
+ end
209
+
210
+ describe "#to_f_vector" do
211
+ subject { vector.to_f_vector }
212
+
213
+ its(:x) { is_expected.to be_a(Float) }
214
+ its(:y) { is_expected.to be_a(Float) }
215
+ end
216
+
217
+ describe "#to_s" do
218
+ context "when fixnum" do
219
+ subject(:vector) { Vector2d.new(2, 3) }
220
+
221
+ it "renders a string" do
222
+ expect(vector.to_s).to eq("2x3")
223
+ end
224
+ end
225
+
226
+ context "when float" do
227
+ subject(:vector) { Vector2d.new(2.0, 3.0) }
228
+
229
+ it "renders a string" do
230
+ expect(vector.to_s).to eq("2.0x3.0")
231
+ end
232
+ end
233
+ end
234
+ end