Geometry Library in C# — Creating a Vector Type
Saturday, April 4, 2026In the previous article, we built the Point2d and Point3d types. A point represents a position in space. But to describe a displacement, a direction, or a force, we need another concept: the vector.
Point vs. vector: what's the difference?
The confusion between points and vectors is common, because both are represented by coordinates. But their meanings are radically different:
- A point designates a location: "I am here."
- A vector designates a displacement: "I'm going that way, for this distance."
A point has no length. A vector has no position. You can add two vectors (two successive displacements), but adding two points has no geometric meaning. On the other hand, the difference between two points yields a vector — the one going from the first to the second.
This distinction is found in all serious geometry libraries. Separating the two types prevents nonsensical operations from compiling and makes the code more expressive.
The Vector2d struct
As with Point2d, we use a readonly struct:
public readonly struct Vector2d
{
public double X { get; }
public double Y { get; }
public Vector2d(double x, double y)
{
X = x;
Y = y;
}
public Vector2d(double[] components)
{
ArgumentNullException.ThrowIfNull(components);
if (components.Length != 2)
throw new ArgumentException("The array must contain exactly 2 elements.", nameof(components));
X = components[0];
Y = components[1];
}
}
The same reasons as for Point2d apply: value semantics, immutability, readonly to avoid defensive copies.
Building a vector from two points
One of the most common operations is building the vector going from point \(A\) to point \(B\):
\(\vec{AB} = B - A = (B_x - A_x,\; B_y - A_y)\)
public Vector2d(Point2d from, Point2d to)
{
X = to.X - from.X;
Y = to.Y - from.Y;
}
Example:
var a = new Point2d(1.0, 2.0);
var b = new Point2d(4.0, 6.0);
var displacement = new Vector2d(a, b); // (3, 4)
Vector length
The length (or magnitude) of a vector is computed like the distance between the origin and the corresponding point:
\(\|\vec{v}\| = \sqrt{x^2 + y^2}\)
public double Length => Math.Sqrt(X * X + Y * Y);
We also expose the squared length, for the same performance reasons as GetSquaredDistanceTo in Point2d — avoiding a Math.Sqrt when only a comparison is needed:
public double SquaredLength => X * X + Y * Y;
Normalizing a vector
Normalizing a vector means obtaining a vector with the same direction but a length of 1 — a unit vector. We divide each component by the length:
\(\hat{v} = \frac{\vec{v}}{\|\vec{v}\|} = \left(\frac{x}{\|\vec{v}\|},\; \frac{y}{\|\vec{v}\|}\right)\)
public Vector2d GetNormal(double tolerance = 1e-10)
{
double length = Length;
if (length < tolerance)
throw new InvalidOperationException("Cannot normalize a zero-length vector.");
return new Vector2d(X / length, Y / length);
}
We check that the length isn't (nearly) zero, because dividing by zero would produce a vector with infinite components — a silently wrong result that would contaminate all downstream calculations.
Example:
var v = new Vector2d(3.0, 4.0);
Vector2d unit = v.GetNormal(); // (0.6, 0.8)
// unit.Length equals 1.0
The dot product
The dot product is the most widely used operation in computational geometry. Its algebraic formula is simple:
\(\vec{a} \cdot \vec{b} = a_x \cdot b_x + a_y \cdot b_y\)
public double DotProduct(Vector2d other)
{
return X * other.X + Y * other.Y;
}
But its geometric interpretation is what makes it so powerful. The dot product is related to the angle \(\theta\) between the two vectors by:
\(\vec{a} \cdot \vec{b} = \|\vec{a}\| \cdot \|\vec{b}\| \cdot \cos\theta\)
From this formula, we can extract several immediate insights:
| Dot product sign | Angle between vectors | Interpretation |
|---|---|---|
| \(> 0\) | Acute (\(< 90°\)) | The vectors point roughly in the same direction |
| \(= 0\) | Right (\(= 90°\)) | The vectors are perpendicular |
| \(< 0\) | Obtuse (\(> 90°\)) | The vectors point roughly in opposite directions |
It's through the dot product that we'll be able to determine whether two vectors are parallel, perpendicular, or pointing in the same direction.
The perpendicular vector
In 2D, each vector has exactly two perpendicular vectors of the same length. For a vector \((x, y)\), they are \((-y, x)\) and \((y, -x)\). By convention, we return the one obtained by a 90° counterclockwise rotation:
public Vector2d GetPerpendicularVector()
{
return new Vector2d(-Y, X);
}
We can verify the result is correct using the dot product — two perpendicular vectors have a zero dot product:
var v = new Vector2d(3.0, 4.0);
Vector2d perpendicular = v.GetPerpendicularVector(); // (-4, 3)
double dot = v.DotProduct(perpendicular); // 0.0
In 2D, the choice is unambiguous: there's only one perpendicular vector in the counterclockwise direction. In 3D, the situation is different — there are infinitely many vectors perpendicular to a given vector (they form an entire plane). There's no canonical choice. But it's still possible to pick one deterministically, as we'll see in the 3D section.
Parallelism and codirectionality
Two vectors are parallel if they point in the same direction or in exactly opposite directions. In 2D, this can be checked using the cross product (also called the 2D determinant):
\(\vec{a} \times \vec{b} = a_x \cdot b_y - a_y \cdot b_x\)
If this product is zero (or nearly zero, tolerance-wise), the vectors are parallel:
public bool IsParallelTo(Vector2d other, double tolerance = 1e-10)
{
double crossProduct = X * other.Y - Y * other.X;
return Math.Abs(crossProduct) <= tolerance * Length * other.Length;
}
We multiply the tolerance by the lengths of both vectors to make the comparison relative: two large-magnitude vectors have a naturally larger cross product, even if they're nearly parallel. Without this normalization, the tolerance would need to be adjusted based on scale, which would be impractical.
Two vectors are codirectional if they are parallel and point in the same direction. We simply check additionally that the dot product is positive:
public bool IsCodirectionalTo(Vector2d other, double tolerance = 1e-10)
{
return IsParallelTo(other, tolerance) && DotProduct(other) > 0;
}
Example:
var a = new Vector2d(2.0, 3.0);
var b = new Vector2d(4.0, 6.0);
var c = new Vector2d(-2.0, -3.0);
a.IsParallelTo(b); // True — same direction
a.IsParallelTo(c); // True — opposite directions
a.IsCodirectionalTo(b); // True — same sense
a.IsCodirectionalTo(c); // False — opposite sense
The complete Vector2d code
public readonly struct Vector2d
{
public double X { get; }
public double Y { get; }
public double Length => Math.Sqrt(X * X + Y * Y);
public double SquaredLength => X * X + Y * Y;
public Vector2d(double x, double y)
{
X = x;
Y = y;
}
public Vector2d(double[] components)
{
ArgumentNullException.ThrowIfNull(components);
if (components.Length != 2)
throw new ArgumentException("The array must contain exactly 2 elements.", nameof(components));
X = components[0];
Y = components[1];
}
public Vector2d(Point2d from, Point2d to)
{
X = to.X - from.X;
Y = to.Y - from.Y;
}
public double DotProduct(Vector2d other)
{
return X * other.X + Y * other.Y;
}
public Vector2d GetNormal()
{
double length = Length;
if (length < 1e-10)
throw new InvalidOperationException("Cannot normalize a zero-length vector.");
return new Vector2d(X / length, Y / length);
}
public Vector2d GetPerpendicularVector()
{
return new Vector2d(-Y, X);
}
public bool IsParallelTo(Vector2d other, double tolerance = 1e-10)
{
double crossProduct = X * other.Y - Y * other.X;
return Math.Abs(crossProduct) <= tolerance * Length * other.Length;
}
public bool IsCodirectionalTo(Vector2d other, double tolerance = 1e-10)
{
return IsParallelTo(other, tolerance) && DotProduct(other) > 0;
}
}
Going 3D with Vector3d
As with points, going 3D adds a Z component. The formulas extend naturally — length, dot product, and normalization work the same way.
The big novelty in 3D is the cross product. Where the dot product yields a number, the cross product yields a vector, perpendicular to both input vectors:
\(\vec{a} \times \vec{b} = \begin{pmatrix} a_y \cdot b_z - a_z \cdot b_y \\ a_z \cdot b_x - a_x \cdot b_z \\ a_x \cdot b_y - a_y \cdot b_x \end{pmatrix}\)
The length of this resulting vector equals \(\|\vec{a}\| \cdot \|\vec{b}\| \cdot \sin\theta\), which allows checking parallelism: if the vectors are parallel, the cross product is the zero vector.
In 3D, IsParallelTo uses the cross product's length instead of the scalar cross product.
GetPerpendicularVector remains useful in 3D, even though the result is no longer unique. The trick is to compute the cross product with a reference axis. If the vector is (nearly) collinear with that reference, we pick a different one.
Rather than inventing our own heuristic, we adopt the Arbitrary Axis Algorithm defined in AutoCAD's DXF specification. This algorithm is used internally by AutoCAD to build an Object Coordinate System (OCS) from a normal vector. It solves exactly our problem: given an arbitrary Z vector, find a perpendicular X vector deterministically.
The algorithm examines the X and Y components of the normalized vector. If both are close to zero (meaning the vector is nearly parallel to the world Z axis), it uses the cross product with the world Y axis. Otherwise, it uses the world Z axis. The threshold is set to \(\frac{1}{64}\) — a value chosen because it's exactly representable in both decimal (0.015625) and binary, guaranteeing identical behavior across all machines:
public Vector3d GetPerpendicularVector(double tolerance = 1e-10)
{
double length = Length;
if (length < tolerance)
throw new InvalidOperationException("Cannot get a perpendicular vector from a zero-length vector.");
double normalizedX = X / length;
double normalizedY = Y / length;
Vector3d reference = Math.Abs(normalizedX) < 1.0 / 64.0 && Math.Abs(normalizedY) < 1.0 / 64.0
? new Vector3d(0.0, 1.0, 0.0)
: new Vector3d(0.0, 0.0, 1.0);
return reference.CrossProduct(this).GetNormal(tolerance);
}
Note the cross product order: reference.CrossProduct(this), not the reverse. This is the order defined by the DXF specification (\(W \times N\)) — swapping it would give a perpendicular vector pointing in the opposite direction.
public readonly struct Vector3d
{
public double X { get; }
public double Y { get; }
public double Z { get; }
public double Length => Math.Sqrt(X * X + Y * Y + Z * Z);
public double SquaredLength => X * X + Y * Y + Z * Z;
public Vector3d(double x, double y, double z)
{
X = x;
Y = y;
Z = z;
}
public Vector3d(double[] components)
{
ArgumentNullException.ThrowIfNull(components);
if (components.Length != 3)
throw new ArgumentException("The array must contain exactly 3 elements.", nameof(components));
X = components[0];
Y = components[1];
Z = components[2];
}
public Vector3d(Point3d from, Point3d to)
{
X = to.X - from.X;
Y = to.Y - from.Y;
Z = to.Z - from.Z;
}
public double DotProduct(Vector3d other)
{
return X * other.X + Y * other.Y + Z * other.Z;
}
public Vector3d CrossProduct(Vector3d other)
{
return new Vector3d(
Y * other.Z - Z * other.Y,
Z * other.X - X * other.Z,
X * other.Y - Y * other.X);
}
public Vector3d GetNormal(double tolerance = 1e-10)
{
double length = Length;
if (length < tolerance)
throw new InvalidOperationException("Cannot normalize a zero-length vector.");
return new Vector3d(X / length, Y / length, Z / length);
}
public Vector3d GetPerpendicularVector()
{
double length = Length;
if (length < 1e-10)
throw new InvalidOperationException("Cannot get a perpendicular vector from a zero-length vector.");
double normalizedX = X / length;
double normalizedY = Y / length;
Vector3d reference = Math.Abs(normalizedX) < 1.0 / 64.0 && Math.Abs(normalizedY) < 1.0 / 64.0
? new Vector3d(0.0, 1.0, 0.0)
: new Vector3d(0.0, 0.0, 1.0);
return reference.CrossProduct(this).GetNormal();
}
public bool IsParallelTo(Vector3d other, double tolerance = 1e-10)
{
Vector3d cross = CrossProduct(other);
return cross.SquaredLength <= tolerance * tolerance * SquaredLength * other.SquaredLength;
}
public bool IsCodirectionalTo(Vector3d other, double tolerance = 1e-10)
{
return IsParallelTo(other, tolerance) && DotProduct(other) > 0;
}
}
What's next
Our library now has points and vectors — the two foundational building blocks of geometry. In the next article, we'll see how to combine them with operators (+, -, *) to write geometric expressions as naturally as mathematical ones.