Changelog
3.0.0 (2026-09-20)
The public API grows from 43 methods to 115. Vectors are now genuinely
immutable and safe to share between ractors, they can be pattern matched,
and they convert to and from the standard library Matrix and Vector.
Nothing was removed. The seven deprecated methods still work, and stay silent unless deprecation warnings are enabled.
Most of the breaking changes correct behavior that was undefined or plainly wrong in 2.x: input that used to produce an unusable vector now raises where the bad input is, rather than somewhere further along. Four changes can affect code that works today, and they come first.
Breaking changes
Ruby 3.4 is now the minimum, up from 3.2
MatrixInterop#to_matrix, #to_vector and #transform are not
ractor-safe before 3.4. Ruby 3.2 raises Ractor::IsolationError from the
require, and 3.3 raises out of ::Vector[] even with matrix already
loaded. The floor moves so the matrix paths are safe across the whole
supported range, and the gemspec stops claiming versions CI never ran.
Vectors are frozen
Instances are frozen on construction, which is what makes them shareable
between ractors when their coordinates are. #dup and clone(freeze: false)
return frozen objects, matching Data, Rational and Complex.
A subclass that adds instance variables must assign them before calling
super, as the object is frozen by the time super returns.
Strings keep their coordinate type
Vector2d.parse("50x70") returns Vector2d(50,70) with Integer
coordinates. It previously returned floats, which made strings the one
input type that did not preserve what it was given.
Parsing is stricter about malformed input and more permissive about valid
input. "1.2.3x4" was accepted and silently truncated, and now raises.
Negative coordinates, an uppercase X separator and comma-separated pairs
are now accepted.
Coordinates must be Numeric
Coordinates were never validated, so any unrecognized argument fell through
to the constructor. Vector2d.parse(nil) returned Vector2d(nil,nil) and
raised NoMethodError later at an unrelated call site.
Every parse path now checks both coordinates. Integer, Float, Rational,
BigDecimal and custom Numeric subclasses are unaffected. Objects that
are numeric by duck typing alone are no longer accepted.
Tightened validation
These raise where 2.x returned something unusable. Code that was working is unlikely to notice.
Vector2d(2, 0).aspect_ratioreturnedInfinityand the zero vector returnedNaN. Both now raiseArgumentError.Complexpassed the coordinate check, and the resulting vector raisedRangeErrorout ofMathfrom#aspect_ratioand.from_angle. It is now rejected:ArgumentErrorfrom.parse,.newand.from_angle,TypeErrorfrom the operators.Vector2d.parse(5, nil)read the explicitnilas an omitted argument and returnedVector2d(5,5). An explicitnilsecond argument now raisesArgumentError.Vector2d(2, 3).clamp_length(-1.0)returned a reversed unit vector rather than anything shorter. A negative maximum now raisesArgumentError, followingComparable#clamp. This applies to#truncateas well. Passmax.clamp(0..)to saturate instead.#resizeis unchanged, where a negative length is a signed magnitude that reverses the vector.#fitand#fit_eitherreturned early on the zero vector, before the argument was coerced, soVector2d(0, 0).fit("garbage")returned the zero vector. Both now raiseArgumentError, as every other receiver already did.
Subclasses
Two places where a subclass could be dropped on the way through:
#coercebuilt the left-hand operand with.parse, so2 * subvectorreturned aVector2dwheresubvector * 2returned the subclass. It now builds through#build, so coercion is symmetric.#coerce_vectoris unchanged, so methods that only read an operand's coordinates still parse into the base class..parsereturned anyVector2dargument untouched, soSub.parse(vector)handed back the plain vector. An argument is returned as it is only when it is already an instance of the class parsing it; anything else is rebuilt through.build.
Deprecations
All of these still work. They warn under Warning[:deprecated] = true or
ruby -w, and each one names its replacement.
| Deprecated | Replacement |
|---|---|
#truncate |
#limit_length |
#squared_length |
#length_squared |
#squared_distance |
#distance_squared |
#fit_either |
#cover |
#constrain_both |
#fit |
#constrain_one |
#cover |
#contain |
other.fit(self, upscale: false) |
#truncate is the one worth looking at. It took a maximum length, where
Numeric#truncate takes a digit count, and the name went to the wrong
method. #limit_length replaces it.
Features
Construction
.build and #build are the hooks every returning method routes through,
so a subclass overrides construction in one place.
Vector2d.from_angle(angle, length = 1.0)and#to_polarVector2d.from_degrees(angle, length = 1.0)Vector2d.random(length = 1.0, random: Random)Vector2d.zero,.one,.up,.down,.leftand.right
Angles and rotation
#angle_toreturns a signed angle in-PI..PI, positive when the other vector is counterclockwise.#angle_betweenstays unsigned in0..PI.Vector2d.degreesand.radiansconvert between the two#angle_in_degreesand#rotate_degrees#rotate_around(center, angle)#perpendicular_cw, alongside the existing counterclockwise#perpendicular
Length and distance
#length_squaredand#distance_squared, which skip the square root#manhattan_distanceand#chebyshev_distance#limit_length(max)scales down only if longer#clamp_length(min, max = nil)takes a range or a pair, mirroringComparable#clamp#direction_to#magnitudeand#normas aliases of#length
Projection and reflection
#project,#rejectand#scalar_projection#reflect(normal)and#refract(normal, refractive_index)#dotand#inner_productas aliases of#dot_product
Interpolation
#lerp,#inverse_lerpand#slerp#midpoint#move_toward(target, distance)
Comparison
#eql?and#hash, so vectors work as hash keys#approx_equal?(other, tolerance = nil)and#approx_zero?#zero?,#finite?and#nan?#parallel?,#perpendicular?,#opposite?and#independent?
Component-wise operations
- Unary
+@and-@ #abs,#signand#snap(step)#with_xand#with_y#minand#max#floorand#ceiltake a digit count
Dimensions and fitting
#cover(other, upscale: true)scales to the smallest size that fills the box, the counterpart to#fit#fit(other, upscale: false)never scales up#fits?and#covers?#area,#square?,#portrait?and#landscape?
Standard library compatibility
#to_matrix,#to_vectorand#transform(matrix)#deconstructand#deconstruct_keysfor pattern matching
Bug fixes
#angle_betweenis computed withatan2rather thanMath.acos(dot_product), which raisedMath::DomainErrorwhenever float normalization pushed the dot product past ±1.0. That happened for roughly 15% of parallel and antiparallel pairs in a random probe. The zero vector now gives0.0instead ofNaN. The range is unchanged.- The angle converters always return floats, so a
BigDecimalangle no longer leaks through. #perpendicularand#rotatepreserve the receiver's class.#inspectrenders the receiver's class.#normalizeand#resizereturn the zero vector unchanged, where they previously returnedNaNcoordinates.#reflectguards against a zero normal, returning the vector unchanged.#projectreturns a float zero when projecting onto the zero vector.#lerprequires a real number as the amount.#fitand#fit_eitherhandle negative vectors and zero coordinates by magnitude.#==returns false for non-vectors instead of raising.- Scalar arguments are validated in the transformations.
- Internal files are loaded with
require_relative. - The gemspec has a
changelog_uri.
Performance improvements
Roughly 1.6x faster than the standard library Vector on the common
operations.
- Operands that are already usable skip the parser
- Coercion skips the parser for vectors
- The common coordinate types are checked first
2.3.0 (2026-08-30)
Features
- Add Vector2d#clamp (de77e58)