Geometry Library in C# — Creating a Point Type

Saturday, April 4, 2026

In any geometry library, the point is the foundational building block. Vectors, lines, curves, and all derived operations rest upon it. In this first article, we'll build a Point2d type in C#, taking the time to justify each design decision.

Why a struct and not a class?

In C#, you can define a custom type with the class or struct keyword. For a geometric point, a struct is the right choice — here's why.

Value semantics

A point represents a value, not an entity. Two points with the same coordinates are identical, just as two integers that equal 42 are identical. We don't care whether it's the "same" point in memory — only the coordinates matter.

This is exactly the distinction C# makes between structs (value types) and classes (reference types):

  • A struct is copied when assigned to a variable or passed as a parameter. Each variable holds its own independent copy.
  • A class is not copied: the reference pointing to the object in memory is copied instead. Multiple variables can point to the same object.

With a class, we'd expose ourselves to a classic problem: modifying a point through one variable would silently affect all other variables pointing to the same object. For a type as fundamental as a point, this is a source of subtle, hard-to-track bugs.

Stack allocation

Structs are generally allocated on the stack, not the heap. This means no dynamic memory allocation, no pressure on the garbage collector, and more direct memory access. When manipulating thousands of points in a geometric algorithm, this performance difference becomes significant.

Value-based comparison

By default, two struct instances are considered equal if all their fields are equal. This is exactly the expected behavior for a point: (3, 5) should be equal to another (3, 5). With a class, default equality compares references, forcing you to override Equals and GetHashCode manually.

What existing libraries do

This choice isn't arbitrary. All major .NET geometry libraries use structs for their point types:

Library Type Declaration
System.Drawing Point struct
System.Numerics Vector2 struct
WPF Point struct
AutoCAD .NET Point2d, Point3d struct

The rule is simple: if a type is small, immutable (or intended to be), and represents a value, it's a natural candidate for a struct.

There are exceptions to this rule in the COM world, which doesn't support structs. AutoCAD's COM API represents points as simple double arrays, and Inventor's COM API uses interfaces for its geometric types. These are historical constraints tied to COM technology, not design choices.

This leads to rather unnatural code — for example, to translate a point, you first have to copy it:

var p1 = p0.Copy()!;
p1.TranslateBy(v0);

TranslateBy() modifies the point it's called on, so if you don't want to modify p0, you have to copy it first.

The Point2d struct

Let's start with the simplest declaration:

public readonly struct Point2d
{
    public double X { get; }
    
    public double Y { get; }

    public Point2d(double x, double y)
    {
        X = x;
        Y = y;
    }

    public Point2d(double[] coordinates)
    {
        ArgumentNullException.ThrowIfNull(coordinates);

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

        X = coordinates[0];
        Y = coordinates[1];
    }
}

The second constructor accepts a double array, which is handy when retrieving coordinates from an external API — such as AutoCAD's COM API, which represents points in this form. It checks that the array is not null and contains exactly two elements.

The X and Y properties are read-only: once created, a point doesn't change. This is a deliberate choice of immutability. If you need a point with different coordinates, you create a new one. This principle eliminates an entire category of bugs related to unintended modifications.

The readonly keyword before struct guarantees to the compiler that no member of the struct modifies its state. This has a concrete performance impact: when a struct is passed by read-only reference (in or ref readonly parameter), the compiler normally has to make a defensive copy before each method call, just in case the method might modify internal state. With readonly struct, this copy is unnecessary and the compiler eliminates it. For types manipulated intensively like points, this optimization makes a difference.

You can then create a point very simply:

var point = new Point2d(3.0, 5.0);

The origin point

The origin — the point (0, 0) — comes up so often in geometric calculations that it deserves its own property:

public static Point2d Origin { get; } = new(0.0, 0.0);

You can write Point2d.Origin instead of new Point2d(0.0, 0.0), which makes the code more readable and clearly expresses intent.

Here's the complete struct at this stage:

public readonly struct Point2d
{
    public static Point2d Origin { get; } = new(0.0, 0.0);

    public double X { get; }
    
    public double Y { get; }

    public Point2d(double x, double y)
    {
        X = x;
        Y = y;
    }

    public Point2d(double[] coordinates)
    {
        ArgumentNullException.ThrowIfNull(coordinates);

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

        X = coordinates[0];
        Y = coordinates[1];
    }
}

Computing the distance between two points

The distance between two points is probably the most fundamental operation in geometry. It relies on the Pythagorean theorem: given two points \(A(x_1, y_1)\) and \(B(x_2, y_2)\), the distance between them is:

\(d = \sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2}\)

In C#, this translates to:

public double GetDistanceTo(Point2d other)
{
    double deltaX = other.X - X;
    double deltaY = other.Y - Y;
    return Math.Sqrt(deltaX * deltaX + deltaY * deltaY);
}

You could use Math.Pow(deltaX, 2) to square the value, but direct multiplication deltaX * deltaX is faster and just as readable.

We can also expose the squared distance, i.e. the result before calling Math.Sqrt:

public double GetSquaredDistanceTo(Point2d other)
{
    double deltaX = other.X - X;
    double deltaY = other.Y - Y;
    return deltaX * deltaX + deltaY * deltaY;
}

At first glance, this method seems redundant. But square root is an expensive operation, and in many situations, you don't need the exact distance — just whether it's below or above a threshold. Comparing \(d^2\) to \(\varepsilon^2\) yields the same result as comparing \(d\) to \(\varepsilon\) (both values being positive), without paying the cost of Math.Sqrt. We'll see below that IsAlmostEqualTo takes advantage of this trick.

Usage example:

var a = new Point2d(1.0, 2.0);
var b = new Point2d(4.0, 6.0);
double distance = a.GetDistanceTo(b); // 5.0

Here we find the classic 3-4-5 right triangle: the difference in X is 3, the difference in Y is 4, and the distance is 5.

Comparing two points: the floating-point trap

Can we simply compare two points with ==? In theory yes, but in practice, floating-point numbers have a well-known problem: rounding errors.

Let's take an example. In mathematics, \(0.1 + 0.2 = 0.3\) exactly. But in C#:

double result = 0.1 + 0.2;
Console.WriteLine(result == 0.3); // False!
Console.WriteLine(result);        // 0.30000000000000004

The number 0.1 cannot be represented exactly in binary (just as \(\frac{1}{3} = 0.333...\) cannot be represented exactly in decimal). This results in tiny rounding errors that accumulate over calculations.

For geometric operations — which chain additions, multiplications, and square roots — these errors are unavoidable. Two points that should be identical after a series of transformations end up separated by an infinitesimal distance, on the order of \(10^{-15}\).

The solution is to compare points with a tolerance:

public bool IsAlmostEqualTo(Point2d other, double tolerance = 1e-10)
{
    return GetSquaredDistanceTo(other) <= tolerance * tolerance;
}

Rather than comparing coordinates one by one, we reuse our GetSquaredDistanceTo method. If the squared distance between the two points is less than or equal to the square of the tolerance, we consider them identical — without paying the cost of Math.Sqrt. The default value of 1e-10 (i.e. \(10^{-10}\)) is small enough to capture truly identical points while absorbing rounding errors.

Example:

var a = new Point2d(1.0, 2.0);
var b = new Point2d(1.0 + 1e-12, 2.0 - 1e-12);

Console.WriteLine(a.IsAlmostEqualTo(b)); // True

The transitivity trap

This approximate comparison has a flaw you should be aware of: it is not transitive. In mathematics, transitivity states that if \(A = B\) and \(B = C\), then \(A = C\). With a tolerance, this property no longer holds.

Imagine three aligned points, separated by distances slightly less than the tolerance \(\varepsilon\):

Three points A, B and C: A is close to B, B is close to C, but A is too far from C

Point B lies within A's tolerance zone, and C lies within B's. But A and C are separated by a distance greater than \(\varepsilon\). Result:

a.IsAlmostEqualTo(b); // True  — d(A,B) < ε
b.IsAlmostEqualTo(c); // True  — d(B,C) < ε
a.IsAlmostEqualTo(c); // False — d(A,C) > ε

This is an unavoidable consequence of tolerance-based comparison. There is no perfect solution: either you accept this trade-off, or you use exact comparison, which suffers from rounding errors. In practice, tolerance-based comparison remains the most pragmatic choice — you just need to be aware of this limitation.

The complete Point2d code

Here's the complete struct:

public readonly struct Point2d
{
    public static Point2d Origin { get; } = new(0.0, 0.0);

    public double X { get; }
    
    public double Y { get; }

    public Point2d(double x, double y)
    {
        X = x;
        Y = y;
    }

    public Point2d(double[] coordinates)
    {
        ArgumentNullException.ThrowIfNull(coordinates);

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

        X = coordinates[0];
        Y = coordinates[1];
    }

    public double GetDistanceTo(Point2d other)
    {
        double deltaX = other.X - X;
        double deltaY = other.Y - Y;
        return Math.Sqrt(deltaX * deltaX + deltaY * deltaY);
    }

    public double GetSquaredDistanceTo(Point2d other)
    {
        double deltaX = other.X - X;
        double deltaY = other.Y - Y;
        return deltaX * deltaX + deltaY * deltaY;
    }

    public bool IsAlmostEqualTo(Point2d other, double tolerance = 1e-10)
    {
        return GetSquaredDistanceTo(other) <= tolerance * tolerance;
    }
}

Going 3D with Point3d

The transition to three dimensions is natural: just add a Z coordinate. The distance formula extends in the same way:

\(d = \sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2 + (z_2 - z_1)^2}\)

public readonly struct Point3d
{
    public static Point3d Origin { get; } = new(0.0, 0.0, 0.0);

    public double X { get; }
    
    public double Y { get; }
    
    public double Z { get; }

    public Point3d(double x, double y, double z)
    {
        X = x;
        Y = y;
        Z = z;
    }

    public Point3d(double[] coordinates)
    {
        ArgumentNullException.ThrowIfNull(coordinates);

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

        X = coordinates[0];
        Y = coordinates[1];
        Z = coordinates[2];
    }

    public double GetDistanceTo(Point3d other)
    {
        double deltaX = other.X - X;
        double deltaY = other.Y - Y;
        double deltaZ = other.Z - Z;
        return Math.Sqrt(deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ);
    }

    public double GetSquaredDistanceTo(Point3d other)
    {
        double deltaX = other.X - X;
        double deltaY = other.Y - Y;
        double deltaZ = other.Z - Z;
        return deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ;
    }

    public bool IsAlmostEqualTo(Point3d other, double tolerance = 1e-10)
    {
        return GetSquaredDistanceTo(other) <= tolerance * tolerance;
    }
}

The struct is identical to Point2d, with an additional axis. The GetDistanceTo and IsAlmostEqualTo methods follow exactly the same logic.

What's next

In upcoming articles, we'll progressively add new building blocks to our library: vectors (which represent directions and displacements, unlike points which represent positions), geometric transformations, then more complex entities like lines, circles, and arcs. Each new type will build upon the previous ones, following the same approach: understand the mathematical concept, then translate it into clean, efficient C# code.

On the same topic