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.
a + b * 2, Vec3.Cross(e1, e2).Normalize(). This lesson builds it, and the rest of the course uses it.
Design decisions
| Choice | Why |
|---|---|
struct, not class | |
readonly | int. No surprises when one is shared, and the compiler can skip defensive copies. |
record struct | with expressions. |
double, not float | |
Dot and Cross | Vec3.Dot(a, b) reads better than a.Dot(b). Avoid overloading * for them: “a * b” would be ambiguous. |
AlmostEquals | == compares bit for bit, which is rarely what geometry wants. |
The struct
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})");}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 x upis (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.Normalizethrows rather than returning NaNs. Code that can legitimately meet a zero vector should checkLengthSquaredfirst or offer aTryNormalize, as in the length lesson.ToStringuses the invariant culture. Without it, a machine set to German or French prints “0,8”, and any file you write with it breaks.LengthSquaredis 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.
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
Show the solution
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})");}45.0 deg 36.87 deg (1.5, 2, 0)
Remember
Make Vec3areadonly record structof three doubles: cheap, immutable, never null.Operators for +, −, unary −, × and ÷ by a number make code read like the maths; DotandCrossare static.Normalizemust reject zero-length vectors; exposeLengthSquaredfor cheap comparisons.Exact ==is rarely right for geometry: add a tolerance-basedAlmostEquals, and snap before hashing.Format with the invariant culture, and keep model coordinates in double, not Vector3’s float.