Geometry Library in C# — Transformations

Saturday, April 4, 2026

In the previous articles, we built points, vectors, operators, and unit vectors. Our types can describe themselves and interact with each other, but they're static — we can't yet move, rotate, or resize them.

That's the role of geometric transformations.

Three fundamental transformations

Before discussing implementation, let's identify the three basic operations:

  • Translation moves an object without changing its orientation or size. It's defined by a displacement vector.
  • Rotation turns an object around a point (in 2D) or an axis (in 3D). It's defined by an angle and a center or axis.
  • Scaling enlarges or shrinks an object. It's defined by a multiplicative factor.

Each of these operations can be represented individually in a simple way. But the real power comes from their composition: applying a rotation, then a translation, then a scale as a single operation. That's where matrices come in.

Why homogeneous matrices?

The translation problem

Rotation and scaling naturally express as matrix multiplication. For example, a 2D rotation by angle \(\theta\) around the origin:

\(\begin{pmatrix} x' \\ y' \end{pmatrix} = \begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix}\)

But translation is an addition, not a multiplication:

\(\begin{pmatrix} x' \\ y' \end{pmatrix} = \begin{pmatrix} x \\ y \end{pmatrix} + \begin{pmatrix} t_x \\ t_y \end{pmatrix}\)

Translation cannot be represented with a 2×2 matrix. This is a problem, because we want to combine all transformations in the same way.

The solution: homogeneous coordinates

The trick is to add an extra dimension. Instead of working with coordinates \((x, y)\), we use coordinates \((x, y, w)\) where \(w\) is always 1 for a point. This allows representing translation as a matrix multiplication:

\(\begin{pmatrix} x' \\ y' \\ 1 \end{pmatrix} = \begin{pmatrix} 1 & 0 & t_x \\ 0 & 1 & t_y \\ 0 & 0 & 1 \end{pmatrix} \begin{pmatrix} x \\ y \\ 1 \end{pmatrix}\)

Expanding, we get \(x' = x + t_x\) and \(y' = y + t_y\) as expected.

This is why we use 3×3 matrices in 2D and 4×4 matrices in 3D: the extra dimension encodes translation.

Points and vectors: a natural distinction

This system has an elegant advantage. A point in homogeneous coordinates is written \((x, y, 1)\)\(w\) equals 1. A vector is written \((x, y, 0)\)\(w\) equals 0.

The consequence is remarkable: when applying a translation matrix to a vector, the translation has no effect, because \(w = 0\) cancels the translation terms. This is exactly the expected behavior: translating a direction makes no sense.

The 3×3 matrix for 2D

Memory representation

A 3×3 matrix contains 9 elements. We store them in a one-dimensional array, in row-major order:

\(M = \begin{pmatrix} m_0 & m_1 & m_2 \\ m_3 & m_4 & m_5 \\ m_6 & m_7 & m_8 \end{pmatrix}\)

public readonly struct Matrix3x3
{
    readonly double[] _elements;

    Matrix3x3(double[] elements)
    {
        ArgumentNullException.ThrowIfNull(elements);

        if (elements.Length != 9)
            throw new ArgumentException("The array must contain exactly 9 elements.", nameof(elements));

        _elements = elements;
    }
}

The constructor validates its input, even though it's private. This protects against mistakes in our own factory methods during development. It will be the only way to create a matrix: we provide factory methods for each transformation type instead of exposing the constructor.

Identity

The identity matrix is the starting point: it transforms nothing.

\(I = \begin{pmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{pmatrix}\)

public static Matrix3x3 Identity { get; } = new(new double[]
{
    1, 0, 0,
    0, 1, 0,
    0, 0, 1
});

Translation

public static Matrix3x3 CreateTranslation(Vector2d displacement)
{
    return new(new double[]
    {
        1, 0, displacement.X,
        0, 1, displacement.Y,
        0, 0, 1
    });
}

Rotation

Rotation around the origin, by an angle \(\theta\) in radians, counterclockwise:

public static Matrix3x3 CreateRotation(double angle)
{
    double cos = Math.Cos(angle);
    double sin = Math.Sin(angle);

    return new(new double[]
    {
        cos, -sin, 0,
        sin,  cos, 0,
        0,    0,   1
    });
}

For rotation around an arbitrary point \(C\), we compose three transformations: translate \(C\) to the origin, rotate, then translate back:

public static Matrix3x3 CreateRotation(double angle, Point2d center)
{
    Matrix3x3 toOrigin = CreateTranslation(Point2d.Origin - center);
    Matrix3x3 rotation = CreateRotation(angle);
    Matrix3x3 fromOrigin = CreateTranslation(center - Point2d.Origin);

    return fromOrigin * rotation * toOrigin;
}

This is where matrix composition shows its full power: three operations become a single matrix.

Scaling

public static Matrix3x3 CreateScale(double factor)
{
    if (factor == 0)
        throw new ArgumentException("Scale factor must not be zero.", nameof(factor));

    return new(new double[]
    {
        factor, 0,      0,
        0,      factor, 0,
        0,      0,      1
    });
}

You can also define non-uniform scaling (different factors for X and Y):

public static Matrix3x3 CreateScale(double factorX, double factorY)
{
    if (factorX == 0)
        throw new ArgumentException("Scale factor must not be zero.", nameof(factorX));
    if (factorY == 0)
        throw new ArgumentException("Scale factor must not be zero.", nameof(factorY));

    return new(new double[]
    {
        factorX, 0,       0,
        0,       factorY, 0,
        0,       0,       1
    });
}

Multiplication

Composing two transformations corresponds to multiplying their matrices. Element \((i, j)\) of the result is the dot product of row \(i\) of the first matrix with column \(j\) of the second:

public static Matrix3x3 operator *(Matrix3x3 left, Matrix3x3 right)
{
    double[] a = left._elements;
    double[] b = right._elements;
    var result = new double[9];

    for (int row = 0; row < 3; row++)
    {
        for (int column = 0; column < 3; column++)
        {
            double sum = 0;

            for (int k = 0; k < 3; k++)
                sum += a[row * 3 + k] * b[k * 3 + column];

            result[row * 3 + column] = sum;
        }
    }

    return new Matrix3x3(result);
}

Watch the order: matrix multiplication is not commutative. A * B is not the same as B * A. By convention, transformations apply right to left: in C * B * A, transformation A is applied first, then B, then C.

Transforming a point and a vector

To apply the matrix to a point (with \(w = 1\)):

public Point2d Transform(Point2d point)
{
    double[] m = _elements;
    return new Point2d(
        m[0] * point.X + m[1] * point.Y + m[2],
        m[3] * point.X + m[4] * point.Y + m[5]);
}

For a vector (with \(w = 0\)) — translation is ignored:

public Vector2d Transform(Vector2d vector)
{
    double[] m = _elements;
    return new Vector2d(
        m[0] * vector.X + m[1] * vector.Y,
        m[3] * vector.X + m[4] * vector.Y);
}

The difference is visible: for the point, we add m[2] and m[5] (the translation terms). For the vector, we don't.

Full example

// 90° rotation around point (2, 0), followed by a translation of (1, 1)
double angle = Math.PI / 2;
var center = new Point2d(2.0, 0.0);

Matrix3x3 transformation = Matrix3x3.CreateTranslation(new Vector2d(1.0, 1.0))
                          * Matrix3x3.CreateRotation(angle, center);

var point = new Point2d(3.0, 0.0);
Point2d result = transformation.Transform(point); // (3, 2)

In 3D: the 4×4 matrix

The principle is identical with one more dimension. A 4×4 matrix stores 16 elements and the rotation formulas become more complex (a 3D rotation requires an axis and an angle).

The .NET framework already provides System.Numerics.Matrix4x4 with all the necessary factory methods. If your library targets 3D scenarios, it's perfectly reasonable to rely on this implementation rather than reinventing the wheel — it's optimized, tested, and well-documented.

Our Matrix4x4 struct would follow the same pattern as Matrix3x3:

public readonly struct Matrix4x4
{
    readonly double[] _elements;

    Matrix4x4(double[] elements)
    {
        ArgumentNullException.ThrowIfNull(elements);

        if (elements.Length != 16)
            throw new ArgumentException("The array must contain exactly 16 elements.", nameof(elements));

        _elements = elements;
    }

    public static Matrix4x4 Identity { get; } = new(new double[]
    {
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, 1
    });

    public static Matrix4x4 CreateTranslation(Vector3d displacement)
    {
        return new(new double[]
        {
            1, 0, 0, displacement.X,
            0, 1, 0, displacement.Y,
            0, 0, 1, displacement.Z,
            0, 0, 0, 1
        });
    }

    public Point3d Transform(Point3d point)
    {
        double[] m = _elements;
        return new Point3d(
            m[0] * point.X + m[1] * point.Y + m[2]  * point.Z + m[3],
            m[4] * point.X + m[5] * point.Y + m[6]  * point.Z + m[7],
            m[8] * point.X + m[9] * point.Y + m[10] * point.Z + m[11]);
    }

    public Vector3d Transform(Vector3d vector)
    {
        double[] m = _elements;
        return new Vector3d(
            m[0] * vector.X + m[1] * vector.Y + m[2]  * vector.Z,
            m[4] * vector.X + m[5] * vector.Y + m[6]  * vector.Z,
            m[8] * vector.X + m[9] * vector.Y + m[10] * vector.Z);
    }

    // Rotation, scale, multiplication... same principle as in 2D
}

An alternative to matrices: the TRS decomposition

Matrices are powerful and general, but they have drawbacks:

  • Numerical drift: after many multiplications, rounding errors accumulate. A matrix that should represent a pure rotation can gradually lose its orthogonality.
  • Difficult interpolation: interpolating between two matrices (for animation, for example) isn't trivial and can produce visually incorrect results (distortions, parasitic scaling).
  • Opacity: looking at a 4×4 matrix, it's hard to tell what transformation it encodes.

The alternative is to store the three components separately:

  • T (Translation): a Vector3d for displacement.
  • R (Rotation): a quaternion for orientation.
  • S (Scale): a double for uniform scale.

What is a quaternion?

A quaternion is a four-component number \((w, x, y, z)\) that can represent a 3D rotation. Without diving into the full mathematical theory, here are the essential properties:

  • A unit quaternion (of length 1) represents a rotation.
  • Composing two rotations corresponds to multiplying their quaternions.
  • Interpolation between two rotations is done naturally with the SLERP algorithm (Spherical Linear Interpolation), which produces a smooth, even rotation.
  • A quaternion doesn't suffer from gimbal lock, a classic problem with Euler angles.
public readonly struct Quaternion
{
    public double W { get; }
    public double X { get; }
    public double Y { get; }
    public double Z { get; }

    public Quaternion(double w, double x, double y, double z)
    {
        W = w;
        X = x;
        Y = y;
        Z = z;
    }

    public static Quaternion Identity { get; } = new(1.0, 0.0, 0.0, 0.0);

    public static Quaternion CreateFromAxisAngle(UnitVector3d axis, double angle)
    {
        double halfAngle = angle / 2.0;
        double sin = Math.Sin(halfAngle);

        return new Quaternion(
            Math.Cos(halfAngle),
            axis.X * sin,
            axis.Y * sin,
            axis.Z * sin);
    }

    public static Quaternion operator *(Quaternion left, Quaternion right)
    {
        return new Quaternion(
            left.W * right.W - left.X * right.X - left.Y * right.Y - left.Z * right.Z,
            left.W * right.X + left.X * right.W + left.Y * right.Z - left.Z * right.Y,
            left.W * right.Y - left.X * right.Z + left.Y * right.W + left.Z * right.X,
            left.W * right.Z + left.X * right.Y - left.Y * right.X + left.Z * right.W);
    }
}

The Transform3d struct

public readonly struct Transform3d
{
    public Vector3d Translation { get; }
    public Quaternion Rotation { get; }
    public double Scale { get; }

    public Transform3d(Vector3d translation, Quaternion rotation, double scale)
    {
        if (scale == 0)
            throw new ArgumentException("Scale factor must not be zero.", nameof(scale));

        Translation = translation;
        Rotation = rotation;
        Scale = scale;
    }

    public static Transform3d Identity { get; } = new(
        new Vector3d(0.0, 0.0, 0.0),
        Quaternion.Identity,
        1.0);
}

The transformation is applied in S → R → T order: scale, then rotate, then translate.

When to use which?

Criterion Matrices TRS (Translation + Quaternion + Scale)
Memory footprint (3D) 16 double (128 bytes) 8 double (64 bytes)
Cost to transform a point 12 multiplications + 9 additions A few quaternion operations + addition
Non-uniform scaling ✗ (single scalar)
Shear
Smooth interpolation ✓ (SLERP on quaternions)
Numerical stability Degrades Stable (unit quaternion)
Readability Opaque matrix Explicit components
Many rotation compositions Drifts No drift

In practice, 3D engines (Unity, Unreal, Godot) use TRS decomposition for object transforms, and matrices for final rendering (projection, camera view). CAD libraries (AutoCAD, Inventor, OpenCascade) primarily use matrices, as it's the de facto standard format — a compact, general representation that interfaces easily with geometric kernels.

What's next

With transformations, our library covers the fundamental operations of computational geometry. In the next article, we'll define the curve interfaces — the common contract for lines, segments, arcs, and circles.

On the same topic