Geometry Library in C# — The Unit Vector
Saturday, April 4, 2026In the previous articles, we built points, vectors, and operators. Our vectors can represent any displacement: a small step, a large leap, or even a zero displacement. But in many situations, you don't need a displacement — you need a direction.
Why a separate type?
The direction of a line, the normal of a plane, the axis of a rotation — all these concepts share one property: length is meaningless. Only the orientation matters. By convention, a direction is represented by a vector of length 1, called a unit vector.
We could settle for a normalized Vector2d, but nothing would guarantee it's actually unit-length. We'd have to check at every use, or trust the caller — both fragile approaches.
A dedicated UnitVector2d type solves the problem at the root:
- The constructor verifies that the vector is unit-length.
- The method signature expresses intent: when a method accepts a
UnitVector2d, it requires a direction, not an arbitrary displacement. - Operations that would break the invariant (scalar multiplication, addition) are either not defined or return a
Vector2d.
It's the same principle as separating Point and Vector: the type system prevents mistakes from compiling.
The UnitVector2d struct
public readonly struct UnitVector2d
{
public static UnitVector2d XAxis { get; } = new(1.0, 0.0);
public static UnitVector2d YAxis { get; } = new(0.0, 1.0);
public double X { get; }
public double Y { get; }
public UnitVector2d(double x, double y, double tolerance = 1e-10)
{
double squaredLength = x * x + y * y;
if (Math.Abs(squaredLength - 1.0) > tolerance)
throw new ArgumentException("The components do not form a unit vector.");
X = x;
Y = y;
}
}
No Length property: it would always be 1 by construction, so exposing it would add nothing.
The constructor verifies that \(x^2 + y^2 \approx 1\) within a tolerance. We compare the squared length to 1 rather than the length to 1 — once again, to avoid an unnecessary Math.Sqrt. If the provided vector isn't unit-length, an exception is thrown immediately, preventing any downstream contamination.
Creating a unit vector from a vector
The most common case is extracting the direction of an existing vector. Until now, we used GetNormal() which returned a Vector2d. We'll add a static factory method that returns a UnitVector2d directly:
public static UnitVector2d CreateFromVector(Vector2d vector, double tolerance = 1e-10)
{
double length = vector.Length;
if (length < tolerance)
throw new ArgumentException("Cannot create a unit vector from a zero-length vector.");
return new UnitVector2d(vector.X / length, vector.Y / length);
}
Example:
var velocity = new Vector2d(3.0, 4.0);
UnitVector2d direction = UnitVector2d.CreateFromVector(velocity); // (0.6, 0.8)
Implicit conversion to Vector2d
A unit vector is a special case of a vector. We should be able to use it anywhere a Vector2d is expected — to compute a dot product, pass it to an operator, etc. Implicit conversion makes this transparent:
public static implicit operator Vector2d(UnitVector2d unit)
{
return new Vector2d(unit.X, unit.Y);
}
This conversion is safe: no information is lost, we're simply widening the type. The reverse conversion (from Vector2d to UnitVector2d) is intentionally not defined — it requires normalization and validation, so it must be explicit via CreateFromVector.
UnitVector2d direction = UnitVector2d.XAxis;
Vector2d displacement = direction * 5.0; // Implicit conversion, then multiplication
double dot = new Vector2d(1.0, 1.0).DotProduct(direction); // Implicit conversion
Negation
Reversing a direction always yields a direction. This is the only arithmetic operation that preserves the unit-length invariant:
public static UnitVector2d operator -(UnitVector2d vector)
{
return new UnitVector2d(-vector.X, -vector.Y);
}
Other operations (+, *, /) are not defined on UnitVector2d: they would produce a vector with a length other than 1. Thanks to implicit conversion, you can still write direction * 5.0 — C# first converts to Vector2d, then applies the multiplication.
The perpendicular vector
In 2D, the perpendicular vector to a unit vector is also a unit vector (a 90° rotation doesn't change the length):
public UnitVector2d GetPerpendicularVector()
{
return new UnitVector2d(-Y, X);
}
The complete UnitVector2d code
public readonly struct UnitVector2d
{
public static UnitVector2d XAxis { get; } = new(1.0, 0.0);
public static UnitVector2d YAxis { get; } = new(0.0, 1.0);
public double X { get; }
public double Y { get; }
public UnitVector2d(double x, double y, double tolerance = 1e-10)
{
double squaredLength = x * x + y * y;
if (Math.Abs(squaredLength - 1.0) > tolerance)
throw new ArgumentException("The components do not form a unit vector.");
X = x;
Y = y;
}
public static UnitVector2d CreateFromVector(Vector2d vector, double tolerance = 1e-10)
{
double length = vector.Length;
if (length < tolerance)
throw new ArgumentException("Cannot create a unit vector from a zero-length vector.");
return new UnitVector2d(vector.X / length, vector.Y / length);
}
public UnitVector2d GetPerpendicularVector()
{
return new UnitVector2d(-Y, X);
}
public static UnitVector2d operator -(UnitVector2d vector)
{
return new UnitVector2d(-vector.X, -vector.Y);
}
public static implicit operator Vector2d(UnitVector2d unit)
{
return new Vector2d(unit.X, unit.Y);
}
}
Going 3D with UnitVector3d
The 3D version follows the same pattern, with the same adjustments as for Vector3d:
- Three static properties
XAxis,YAxis,ZAxis. GetPerpendicularVectoruses the Arbitrary Axis Algorithm from the DXF specification, and returns aUnitVector3d.- Implicit conversion to
Vector3d.
public readonly struct UnitVector3d
{
public static UnitVector3d XAxis { get; } = new(1.0, 0.0, 0.0);
public static UnitVector3d YAxis { get; } = new(0.0, 1.0, 0.0);
public static UnitVector3d ZAxis { get; } = new(0.0, 0.0, 1.0);
public double X { get; }
public double Y { get; }
public double Z { get; }
public UnitVector3d(double x, double y, double z, double tolerance = 1e-10)
{
double squaredLength = x * x + y * y + z * z;
if (Math.Abs(squaredLength - 1.0) > tolerance)
throw new ArgumentException("The components do not form a unit vector.");
X = x;
Y = y;
Z = z;
}
public static UnitVector3d CreateFromVector(Vector3d vector, double tolerance = 1e-10)
{
double length = vector.Length;
if (length < tolerance)
throw new ArgumentException("Cannot create a unit vector from a zero-length vector.");
return new UnitVector3d(vector.X / length, vector.Y / length, vector.Z / length);
}
public UnitVector3d GetPerpendicularVector()
{
Vector3d asVector = this;
Vector3d reference = Math.Abs(X) < 1.0 / 64.0 && Math.Abs(Y) < 1.0 / 64.0
? new Vector3d(0.0, 1.0, 0.0)
: new Vector3d(0.0, 0.0, 1.0);
Vector3d cross = reference.CrossProduct(asVector);
return CreateFromVector(cross);
}
public static UnitVector3d operator -(UnitVector3d vector)
{
return new UnitVector3d(-vector.X, -vector.Y, -vector.Z);
}
public static implicit operator Vector3d(UnitVector3d unit)
{
return new Vector3d(unit.X, unit.Y, unit.Z);
}
}
Note that GetPerpendicularVector doesn't need to check for zero length here: a UnitVector3d always has a length of 1 by construction.
Impact on existing types
Introducing UnitVector has ripple effects on our existing types. Some methods that returned a Vector should now return a UnitVector:
| Type | Method | Before | After |
|---|---|---|---|
Vector2d |
GetNormal() |
Vector2d |
UnitVector2d |
Vector3d |
GetNormal() |
Vector3d |
UnitVector3d |
Vector3d |
GetPerpendicularVector() |
Vector3d |
UnitVector3d |
Thanks to implicit conversion, this change is backward-compatible: existing code that stored the result in a Vector2d continues to compile without modification.
// Both lines work after the change:
UnitVector2d direction = new Vector2d(3.0, 4.0).GetNormal(); // Precise type
Vector2d alsoWorks = new Vector2d(3.0, 4.0).GetNormal(); // Implicit conversion
What's next
Our library now distinguishes three concepts: positions (points), displacements (vectors), and directions (unit vectors). In the next article, we'll tackle geometric transformations: translations, rotations, and scaling.