Vector2d is a library for handling two-dimensional vectors and coordinates. It's fully featured, but has a particular focus on image processing, including constrained scaling and geometry string parsing.
It is strictly immutable and safe for use with Ractors.
It has no runtime dependencies, and is about 1.6x faster than the stdlib Vector class.
Every method is documented with examples in the API documentation.
Vector2d requires Ruby 3.4 or later. Add the gem to your Gemfile and run bundle install.
gem "vector2d"require 'vector2d'
vector = Vector2d(50, 70)
vector.aspect_ratio # => 0.7142857142857143
vector.length # => 86.02325267042627
vector * 2 # => Vector2d(100,140)
vector + Vector2d(20, 30) # => Vector2d(70,100)
vector.fit(Vector2d(64, 64)) # => Vector2d(45.714285714285715,64.0)
Vector2d.parse([50, 70]) # => Vector2d(50,70)
Vector2d.parse("50x70") # => Vector2d(50,70)These are the three operations from web image sizing:
| Vector2d | Scales to | CSS object-fit |
ImageMagick geometry |
|---|---|---|---|
fit(other) |
the largest size that fits inside the box | contain |
WxH |
cover(other) |
the smallest size that fills the box | cover |
WxH^ |
fit(other, upscale: false) |
the largest size that fits, never scaling up | scale-down |
WxH> |
image = Vector2d(1600, 1200)
small = Vector2d(120, 90)
thumbnail = Vector2d(200, 200)
image.fit(thumbnail) # => Vector2d(200.0,150.0)
image.cover(thumbnail) # => Vector2d(266.66666666666663,200.0)
small.fit(thumbnail) # => Vector2d(200.0,150.0)
small.fit(thumbnail, upscale: false) # => Vector2d(120,90)The results are floats, use #round and #to_s to get a string representation to use with other tools.
image.fit(thumbnail).round.to_s # => "200x150"To constrain only one axis, leave the other blank or zero:
image.fit(Vector2d("800x")) # => Vector2d(800.0,600.0)
image.fit(Vector2d(800, 0)) # => Vector2d(800.0,600.0)#fits? and #covers? ask the question without doing the scaling.
image.fits?(thumbnail) # => false
small.fits?(thumbnail) # => true
image.covers?(thumbnail) # => trueA vector doubles as a rectangle, and a few properties describe one.
image.area # => 1920000
image.aspect_ratio # => 1.3333333333333333
image.landscape? # => true
image.portrait? # => false
image.square? # => falseVector2d.parse is quite liberal: it accepts numbers, arrays, hashes, strings, other vectors, and the Vector and Matrix classes from the standard library.
Strings are written as "50x70" or "50,70". The separator is case insensitive and whitespace is ignored.
Coordinates can be signed, and they retain their numeric type. It is also aliased as Vector2d() shorthand.
Vector2d.parse("50x70") # => Vector2d(50,70)
Vector2d.parse("50.0x70") # => Vector2d(50.0,70)
Vector2d.parse("-50X70") # => Vector2d(-50,70)
Vector2d.parse("50, 70") # => Vector2d(50,70)
Vector2d.parse([50, 70]) # => Vector2d(50,70)
Vector2d.parse({ x: 50, y: 70 }) # => Vector2d(50,70)
Vector2d.parse(50) # => Vector2d(50,50)Coordinates are real numbers: integers, floats, rationals and decimals.
This isn't limited to construction. Every method that takes a vector runs its argument through .parse, so all of the forms above work at any call site.
Vector2d(2, 3).distance("5x7") # => 5.0
Vector2d(2, 3).min([1, 5]) # => Vector2d(1,3)
Vector2d(23, 47).snap([10, 5]) # => Vector2d(20,45)
Vector2d(3, 4).approx_equal?([3.0, 4.0]) # => true#== is the exception. It compares coordinates exactly and doesn't coerce, so that vectors stay usable as hash keys.
Vector2d(3, 4) == [3, 4] # => falseVector2d.new takes exactly two coordinates and nothing else.
Arithmetic operations have the same type semantics as Ruby numbers, so integer division truncates:
vector = Vector2d(50, 70)
vector / 20 # => Vector2d(2,3)
vector / 20.0 # => Vector2d(2.5,3.5)A scalar argument applies to both axes, and vectors coerce, so it can come first:
vector - 10 # => Vector2d(40,60)
vector * 2 # => Vector2d(100,140)
2 * vector # => Vector2d(100,140)
vector.max(60) # => Vector2d(60,70)
vector.clamp(0, 60) # => Vector2d(50,60)A vector argument applies one axis at a time:
vector + Vector2d(20, 30) # => Vector2d(70,100)
vector.min(Vector2d(60, 60)) # => Vector2d(50,60)Unary - points a vector the other way, and #reverse is the same thing spelled out:
-vector # => Vector2d(-50,-70)
vector.reverse # => Vector2d(-50,-70)#with_x and #with_y replace a single axis:
vector.with_x(100) # => Vector2d(100,70)
vector.with_y(100) # => Vector2d(50,100)#round, #ceil and #floor work one axis at a time, the way their counterparts on Ruby's numerics do, and all three take a number of digits.
Vector2d(2.44, 3.66).round # => Vector2d(2,4)
Vector2d(2.44, 3.66).ceil # => Vector2d(3,4)
Vector2d(2.44, 3.66).floor # => Vector2d(2,3)
Vector2d(2.44, 3.66).round(1) # => Vector2d(2.4,3.7)#snap rounds each axis to the nearest multiple of a step.
Vector2d(23, 47).snap(10) # => Vector2d(20,50)#abs drops the signs and #sign reduces each axis to -1, 0 or 1.
Vector2d(-2.5, 3.5).abs # => Vector2d(2.5,3.5)
Vector2d(-2.5, 3.5).sign # => Vector2d(-1,1)#resize scales a vector to a given length and #normalize scales it to one.
#limit_length caps the length at a maximum and #clamp_length bounds it at both ends, scaling a short vector up to the minimum.
All of them keep the direction, and #clamp_length takes its bounds the way #clamp does, as two arguments or as a range.
vector = Vector2d(3, 4)
vector.length # => 5.0
vector.length_squared # => 25
vector.normalize # => Vector2d(0.6000000000000001,0.8)
vector.resize(10) # => Vector2d(6.0,8.0)
vector.limit_length(3) # => Vector2d(1.7999999999999998,2.4)
vector.limit_length(10) # => Vector2d(3.0,4.0)
vector.clamp_length(10, 20) # => Vector2d(6.0,8.0)
vector.clamp_length(1..3) # => Vector2d(1.7999999999999998,2.4)#normalized? asks whether a vector is already of length one.
Distances come in the usual flavours, and #direction_to is the unit vector pointing from one vector to another.
origin = Vector2d(2, 3)
origin.distance("5x7") # => 5.0
origin.distance_squared([5, 7]) # => 25
origin.manhattan_distance([5, 7]) # => 7
origin.chebyshev_distance([5, 7]) # => 4
origin.direction_to([5, 7]) # => Vector2d(0.6000000000000001,0.8)Angles are radians, measured from the positive x axis.
Positive angles turn counterclockwise, and so do #rotate and #perpendicular.
The y axis grows upwards, putting Vector2d.up at (0, 1).
Vector2d.right # => Vector2d(1,0)
Vector2d.up # => Vector2d(0,1)
Vector2d.left # => Vector2d(-1,0)
Vector2d.down # => Vector2d(0,-1)
Vector2d.up.angle # => 1.5707963267948966
Vector2d(2, 3).perpendicular # => Vector2d(-3,2)
Vector2d.up.angle_to(Vector2d.right) # => -1.5707963267948966
Vector2d.up.angle_between(Vector2d.right) # => 1.5707963267948966#rotate turns a vector about the origin and #rotate_around about another point.
#perpendicular is a quarter turn counterclockwise, #perpendicular_cw the other way.
Vector2d.from_angle builds a vector from an angle and a length, and #to_polar takes one apart again.
Vector2d(2, 3).perpendicular_cw # => Vector2d(3,-2)
Vector2d(2, 1).rotate_around([1, 1], Math::PI / 2) # => Vector2d(1.0,2.0)
Vector2d.from_angle(0, 5) # => Vector2d(5.0,0.0)
Vector2d(3, 4).to_polar # => [5.0, 0.9272952180016122]Where degrees are easier to read, there are convenience methods for them.
Vector2d.from_degrees(45) # => Vector2d(0.7071067811865476,0.7071067811865475)
Vector2d.up.angle_in_degrees # => 90.0
Vector2d(2, 3).rotate_degrees(90) # => Vector2d(-3.0,2.0)
Vector2d.radians(90) # => 1.5707963267948966
Vector2d.degrees(Math::PI) # => 180.0#== compares coordinates exactly, so use #approx_equal? on anything you've done arithmetic to.
Its default tolerance, the one #parallel? and #perpendicular? use, is a few ulps scaled by the magnitude of the vectors; a tolerance of your own is an absolute distance, and isn't scaled.
drifted = Vector2d(0.1, 0.2) * 3
drifted # => Vector2d(0.30000000000000004,0.6000000000000001)
drifted == Vector2d(0.3, 0.6) # => false
drifted.approx_equal?([0.3, 0.6]) # => true
Vector2d(1e-20, 0).zero? # => false
Vector2d(1e-20, 0).approx_zero? # => true#eql? is stricter still and tells coordinate types apart, and #hash follows it, so the two vectors below are different hash keys.
Vector2d(3, 4) == Vector2d(3.0, 4.0) # => true
Vector2d(3, 4).eql?(Vector2d(3.0, 4.0)) # => falseThe directional predicates compare directions and ignore magnitudes, and #finite? and #nan? ask about the coordinates.
Vector2d(2, 3).parallel?([-4, -6]) # => true
Vector2d(2, 3).opposite?([-4, -6]) # => true
Vector2d(2, 3).perpendicular?([-3, 2]) # => true
Vector2d(2, 3).independent?([3, 2]) # => trueVectors deconstruct to an array or a hash, so they can be matched against either kind of pattern.
case Vector2d(3, 4)
in [0, 0] then :origin
in [Integer => x, Integer => y] then x + y
end # => 7
case Vector2d(0, 4)
in {x: 0} then :on_y_axis
in {y: 0} then :on_x_axis
end # => :on_y_axisA vector converts to the plain Ruby representations, #to_s writes the geometry string back out, and #to_i_vector and #to_f_vector convert the coordinates while keeping the class.
Vector2d(2.5, 3.5).to_a # => [2.5, 3.5]
Vector2d(2.5, 3.5).to_hash # => {x: 2.5, y: 3.5}
Vector2d(2.5, 3.5).to_s # => "2.5x3.5"
Vector2d(2.5, 3.5).to_i_vector # => Vector2d(2,3)
Vector2d(2, 3).to_f_vector # => Vector2d(2.0,3.0)Vectors convert to and from the Matrix and Vector classes in the standard library, and the operators accept both.
Vector2d(2, 3).to_vector # => Vector[2, 3]
Vector2d(2, 3).to_matrix # => Matrix[[2], [3]]
Vector2d.parse(Vector[2, 3]) # => Vector2d(2,3)
Vector2d.parse(Matrix[[2], [3]]) # => Vector2d(2,3)
Vector[1, 2] + Vector2d(3, 4) # => Vector2d(4,6)#transform multiplies a 2x2 matrix by the vector.
Vector2d(3, 4).transform(Matrix[[0, -1], [1, 0]]) # => Vector2d(-4,3)The matrix library is a bundled gem, so applications using these methods need gem "matrix" in their Gemfile. It's only loaded when a conversion needs it.
Bug reports, feature requests, and pull requests are welcome. Read CONTRIBUTING.md first. Report security vulnerabilities privately as described in SECURITY.md. CONTRIBUTING.md also covers how to run the tests and how commits are formatted, and note that this project ships with a code of conduct.
Released under the MIT License.