<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. BIM Math
  3. Vectors
All lessons in this course

Lesson 11 of 44 15 minBeginner

Building a Vec3 struct in C#

Put the whole module into one small, fast, immutable type: operators, length, normalising, dot and cross products, and tolerant comparison. It is the base for every later module.

So far every sample has used tuples and loose helper functions. That is fine for a few lines, but real geometry code wants one well-designed type that reads like the maths: a + b * 2, Vec3.Cross(e1, e2).Normalize(). This lesson builds it, and the rest of the course uses it.

Interactive

Vec3 calculator

Type two vectors, or pick a preset. Every row is one member of the Vec3 struct below.

a =
b =
a + b(4, 3, 1)
a − b(4, 3, −1)
a.Length, b.Length5, 1
a.Normalize()(0.8, 0.6, 0)
Dot(a, b)0
AngleTo(a, b)90°
Cross(a, b)(3, −4, 0)
Cross(b, a)(−3, 4, 0)
|a × b| (area)5
Dot(a × b, a), Dot(a × b, b)0, 0
a × b = (3, −4, 0) is perpendicular to both (both dot products are 0), and its length 5 is the area of the parallelogram they span. Swapping the order flips its sign.

Design decisions

ChoiceWhy
struct, not classThree doubles (24 bytes) copied by value: no heap allocation, no garbage collection, no null. Millions of vectors cost nothing extra.
readonlyA vector never changes after it is made, like an int. No surprises when one is shared, and the compiler can skip defensive copies.
record structFree equality, hashing, deconstruction and with expressions.
double, not floatModel coordinates need the precision (see Units, precision and tolerance).
Static Dot and CrossThey take two equal partners, so Vec3.Dot(a, b) reads better than a.Dot(b). Avoid overloading * for them: “a * b” would be ambiguous.
A tolerance-based AlmostEqualsThe record’s == compares bit for bit, which is rarely what geometry wants.

The struct

Vec3.cs
using System.Globalization; var a = new Vec3(4, 3, 0);var up = Vec3.UnitZ; Console.WriteLine($"a          = {a}");Console.WriteLine($"a.Length   = {a.Length}");Console.WriteLine($"a + up * 2 = {a + up * 2}");Console.WriteLine($"unit a     = {a.Normalize()}");Console.WriteLine($"a . up     = {Vec3.Dot(a, up)}");Console.WriteLine($"a x up     = {Vec3.Cross(a, up)}");Console.WriteLine($"==         : {new Vec3(0.1 + 0.2, 0, 0) == new Vec3(0.3, 0, 0)}");Console.WriteLine($"AlmostEquals: {new Vec3(0.1 + 0.2, 0, 0).AlmostEquals(new Vec3(0.3, 0, 0))}"); public readonly record struct Vec3(double X, double Y, double Z){    public const double Tolerance = 1e-9;     public static readonly Vec3 Zero = new(0, 0, 0);    public static readonly Vec3 UnitX = new(1, 0, 0);    public static readonly Vec3 UnitY = new(0, 1, 0);    public static readonly Vec3 UnitZ = new(0, 0, 1);     public static Vec3 operator +(Vec3 a, Vec3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z);    public static Vec3 operator -(Vec3 a, Vec3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z);    public static Vec3 operator -(Vec3 v) => new(-v.X, -v.Y, -v.Z);    public static Vec3 operator *(Vec3 v, double k) => new(v.X * k, v.Y * k, v.Z * k);    public static Vec3 operator *(double k, Vec3 v) => v * k;    public static Vec3 operator /(Vec3 v, double k) => new(v.X / k, v.Y / k, v.Z / k);     public double LengthSquared => X * X + Y * Y + Z * Z;    public double Length => Math.Sqrt(LengthSquared);     public static double Dot(Vec3 a, Vec3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z;     public static Vec3 Cross(Vec3 a, Vec3 b) =>        new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X);     public Vec3 Normalize()    {        double len = Length;        if (len < Tolerance)            throw new InvalidOperationException("Cannot normalise a zero-length vector.");        return this / len;    }     public bool AlmostEquals(Vec3 o, double tol = Tolerance) =>        Math.Abs(X - o.X) <= tol && Math.Abs(Y - o.Y) <= tol && Math.Abs(Z - o.Z) <= tol;     public override string ToString() =>        string.Create(CultureInfo.InvariantCulture, $"({X}, {Y}, {Z})");}
Output
a          = (4, 3, 0)
a.Length   = 5
a + up * 2 = (4, 3, 2)
unit a     = (0.8, 0.6, 0)
a . up     = 0
a x up     = (3, -4, 0)
==         : False
AlmostEquals: True

A few details are worth a second look:

  • a x up is (3, −4, 0): the horizontal direction to the right of a wall running along a. Swap the order, Cross(up, a), for the left side. Up to its sign, it is the perpendicular the very first widget of this course used to draw a wall’s outline.
  • Normalize throws rather than returning NaNs. Code that can legitimately meet a zero vector should check LengthSquared first or offer a TryNormalize, as in the length lesson.
  • ToString uses the invariant culture. Without it, a machine set to German or French prints “0,8”, and any file you write with it breaks.
  • LengthSquared is public on purpose: distance comparisons should not pay for a square root.

Why not just use System.Numerics.Vector3?

Vector3 is excellent for graphics: it is hardware-accelerated and has every operation above. But it stores float, which the precision lesson showed is not enough for coordinates a few hundred metres from the origin. Use it for rendering and for local, small-scale geometry; keep model coordinates in doubles. BIM APIs agree: the Revit API’s XYZ is a double-precision type with DotProduct, CrossProduct, Normalize and IsAlmostEqualTo, almost exactly the struct above.

Quick check

Why is Vec3 a struct rather than a class?

Quick check

new Vec3(0.1 + 0.2, 0, 0) == new Vec3(0.3, 0, 0) is false. What is the right fix?

Practice

Add two members to Vec3: AngleTo(Vec3 other), returning the angle in degrees (clamp before Acos), and ProjectOnto(Vec3 onto), returning the part of this vector along onto (which need not be a unit vector). Print the angle from X to (1, 1, 0), the angle from Z to the roof normal (0, −0.6, 0.8), and (2.5, 1.25, 7) projected onto (3, 4, 0).

Show a hint

Angle: acos(Dot ÷ (|a||b|)). Projection onto v: v × (Dot(this, v) ÷ v.LengthSquared). Dividing by the squared length saves normalising v.

Show the solution
Solution.cs
using System.Globalization; Console.WriteLine($"{Vec3.UnitX.AngleTo(new Vec3(1, 1, 0)):F1} deg");Console.WriteLine($"{Vec3.UnitZ.AngleTo(new Vec3(0, -0.6, 0.8)):F2} deg");Console.WriteLine(new Vec3(2.5, 1.25, 7).ProjectOnto(new Vec3(3, 4, 0))); public readonly record struct Vec3(double X, double Y, double Z){    public static readonly Vec3 UnitX = new(1, 0, 0);    public static readonly Vec3 UnitZ = new(0, 0, 1);     public static Vec3 operator *(Vec3 v, double k) => new(v.X * k, v.Y * k, v.Z * k);    public double LengthSquared => X * X + Y * Y + Z * Z;    public double Length => Math.Sqrt(LengthSquared);    public static double Dot(Vec3 a, Vec3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z;     public double AngleTo(Vec3 other)    {        double c = Dot(this, other) / (Length * other.Length);        return Math.Acos(Math.Clamp(c, -1, 1)) * 180 / Math.PI;    }     public Vec3 ProjectOnto(Vec3 onto) => onto * (Dot(this, onto) / onto.LengthSquared);     public override string ToString() =>        string.Create(CultureInfo.InvariantCulture, $"({X}, {Y}, {Z})");}
Output
45.0 deg
36.87 deg
(1.5, 2, 0)

Remember

  • Make Vec3 a readonly record struct of three doubles: cheap, immutable, never null.
  • Operators for +, −, unary −, × and ÷ by a number make code read like the maths; Dot and Cross are static.
  • Normalize must reject zero-length vectors; expose LengthSquared for cheap comparisons.
  • Exact == is rarely right for geometry: add a tolerance-based AlmostEquals, and snap before hashing.
  • Format with the invariant culture, and keep model coordinates in double, not Vector3’s float.
0 of 44 lessons completed