A double-precision 3D maths library for C#, written to be read and understood before it is used. It is a tutorial in code: a guided tour of the vectors, angles, rotations and geometry that underpin computer graphics, aimed at undergraduates (and anyone) meeting this material for the first time. It assumes nothing beyond high-school maths — basic trigonometry and algebra — and explains every operation with the maths behind it. Every type is a small, immutable value object built on Cartesian coordinates and Euclidean geometry, and the code favours clarity over raw speed so the why stays easy to follow.
This library grew out of A Vector Type for C#,
originally published by Richard Potter on CodeProject under the
Code Project Open License (CPOL). That article walked
through a single Vector3 type from first principles; this project keeps that teaching spirit and
extends it to a whole family of related types — lines, rays, planes, angles, rotations, quaternions and
more.
- What this project is for
- Conventions used across the library
- The types at a glance
- Converting between types
Vector— the foundation- The line family:
Line,Ray,LineSegment,Chord PlaneAngle- Orientation:
Rotation,Attitude,Quaternion PoseMatrix- The camera: view and projection matrices
OrthogonalAxes— coordinate conventions- Curves:
Bezier,Hermite,CatmullRom - Shapes: conceptual and placed
- Bounding volumes:
BoundingBoxandBoundingSphere - Supporting numeric helpers
- Points of interest
- Future considerations
- Status and history
For years I have seen people struggle with the mathematics that underpins computer graphics — vectors, angles, rotations, the geometry of lines and planes. This library is, first and foremost, a learning resource, written for a reader with a high-school maths background and no more: an undergraduate first course, a self-teaching programmer, anyone who wants the why and not just an API. The maths is the point; the code is the explanation. The resulting types are not designed to be the fastest possible (matrix mathematics and SIMD are more efficient but much harder to follow) — they are designed to be as simple and understandable as possible.
A few principles run through everything:
- Maths first, not rendering. This teaches the maths behind computer graphics — magnitude, projection, central angle, signed distance, gimbal lock — not how to draw pixels. The same maths drives games, CAD, robotics and simulation; a graphics/game engine is explicitly not what this is.
- No unexplained jargon. Wherever a term, symbol or acronym appears, it is introduced in plain language first. You will need only a basic grasp of trigonometry and algebra; the formal names (Cartesian, Euclidean, and so on) are just the school-level maths dressed up.
- Verbose on purpose. The documentation goes into far more detail than a working programmer strictly needs. If any part seems patronising, please do not be offended — it is written for a wide audience, including people meeting these ideas for the first time.
A quick glossary you will see used throughout:
- Operator — the symbol that defines an operation, such as plus (
+) ina + b. - Operand — the values an operator works on. In
a + b,ais the left-hand-side (LHS) operand andbis the right-hand-side (RHS) operand. - Scalar — an ordinary single number (a
double), as opposed to a vector or other structured value.
These hold for (almost) every type, so they are stated once here rather than repeated everywhere.
Three dimensions, Cartesian coordinates. Everything lives in 3D space described by three perpendicular axes — x, y and z. Orientation of the axes does not affect the maths, but the illustrations assume the common graphics convention (as in OpenGL), where z is negative as you look down the axis:
Immutable value objects. Types such as Vector, Angle, Plane, Quaternion, Rotation,
Pose and Matrix are immutable: an operation never changes the object it is called on, it returns a
new value. This makes them behave like primitive numbers — safe to share, easy to reason about.
Static and instance forms of each operation. Most operations exist twice, so you can write whichever reads better:
double d1 = Vector.Distance(a, b); // static form
double d2 = a.Distance(b); // instance form — calls the static oneThe instance form almost always just calls the static form, so the two can never disagree.
Angles are radians, wrapped in Angle. Angles are stored as radians but carry a dedicated
Angle type so you never have to remember which unit a double is in. Angle converts
to and from degrees, radians and gradians, and a double converts implicitly to an Angle (treated
as radians).
Tolerance-aware comparisons. Floating-point numbers rarely land exactly on a computed value, so
many comparisons accept a tolerance (Contains(point, tolerance), Equals(other, tolerance),
IsParallelTo(other, tolerance)). Where it makes sense there is also a no-tolerance overload that uses
a sensible default.
Safe variants: …OrDefault. Normalizing a zero-length vector or quaternion is undefined. The
strict methods (Normalize) throw; the safe ones (NormalizeOrDefault) return the zero value instead.
Constructors that need a direction use the safe form, so a degenerate input produces a degenerate (but
not exploding) result.
Everything is built on Vector. The families below — the line family, the orientation types, planes,
angles, poses and shapes — each lean on it, and several convert freely into one another:
graph TD
V["Vector<br/>(x, y, z) — point or direction"]
subgraph lines["Line family — differ only in where they may stop"]
L["Line<br/>no ends"] --- R["Ray<br/>one end"] --- S["LineSegment<br/>two ends"] --- C["Chord<br/>ends on a circle"]
end
subgraph orient["Orientation — same turn, different encodings"]
Q["Quaternion"] --- AA["AxisAngle"] --- Rot["Rotation<br/>(Euler X/Y/Z)"] --- M["Matrix"]
Att["Attitude<br/>(yaw/pitch/roll)"]
end
subgraph shapes["Shapes"]
Concept["Conceptual shape<br/>size only"] -->|"+ Pose"| Placed["Placed shape<br/>in the world"]
end
P["Plane"]
Ang["Angle"]
Pose["Pose<br/>position + orientation"]
OA["OrthogonalAxes<br/>coordinate convention"]
V --> lines
V --> P
V --> orient
V --> shapes
V --> Pose
Q --- Pose
OA -.->|"gives meaning to"| Att
Ang -.-> orient
classDef found fill:#ffe9a8,stroke:#b8860b,color:#222;
class V found;
| Type | What it models | How it differs from its neighbours |
|---|---|---|
Vector |
A point or direction in 3D space (x, y, z). | The foundation; almost everything else is built from it. |
Line |
An infinite straight line. | Infinite in both directions — no ends, no length. |
Ray |
A half-line: a start point and one direction. | Infinite in one direction — has a start but no end. |
LineSegment |
A finite straight line between two points. | Finite — two ends, so it has a length and a midpoint. |
Chord |
A line segment whose ends lie on a circle. | A LineSegment that also knows its circle's radius, unlocking arc/angle maths. |
Plane |
An infinite flat surface (Ax + By + Cz + D = 0). |
A 2D surface in 3D space, with a front and a back. |
Angle |
An amount of turn. | A 1D quantity with unit conversion and classification (acute, reflex, …). |
Rotation |
An orientation as Euler angles about X, Y, Z. | Human-friendly to read/write, but prone to gimbal lock. |
Attitude |
An orientation as yaw / pitch / roll. | The same idea as Rotation in aviation naming. |
Quaternion |
An orientation as a 4-component number. | The robust form: no gimbal lock, smooth interpolation. |
Pose |
A position and an orientation together. | A rigid placement ("where" + "which way") in friendly form. |
Matrix |
A 4×4 transformation matrix. | The general linear-algebra workhorse for transforms. |
OrthogonalAxes |
A coordinate-system convention (which way is up, right, far, and its handedness). | A constant label chosen per project — not a position or an orientation. |
Bezier, Hermite, CatmullRom |
Smooth curves through space. | Extend the straight-line/arc interpolation of Vector to genuine curves. |
BoundingBox, BoundingSphere |
Axis-aligned bounding volumes. | Cheap quick-rejection hulls — "could these possibly touch?" — distinct from the exact shapes. |
Circle, Triangle, Rectangle, Ellipse, Annulus, Sector |
Conceptual flat shapes — size and proportion only, no position. | Pure intrinsic geometry: area, perimeter, angles; comparable by size. |
Sphere, Cuboid, Cylinder, Cone, Capsule, Ellipsoid, Torus |
Conceptual solids — size and proportion only, no position. | Pure intrinsic geometry: volume, surface area; comparable by size. |
Placed… (one per conceptual shape) |
A conceptual shape placed in space by a Pose. |
Add world-space maths: containment, closest point, line/ray hits. |
PlacedPolygon, PlacedTetrahedron |
Vertex-defined shapes (a planar polygon; a solid with four corners). | Always placed — defined by their corners, with no separate conceptual form. |
The shape types come in two layers — a conceptual shape and its placed partner — so that size-only maths never has to see a position.
The four members of the line family are best understood together — they differ only in where they are allowed to stop:
Line <──────────●──────────> infinite both ways · no ends · no length
Ray ●──────────> starts here · infinite one way · one end
LineSegment ●──────────● finite · two ends · has a length & midpoint
Chord ●─────────╴●╶ a segment whose two ends sit on a circle
( ) ╲___ the circle it belongs to
That single difference drives their "closest point" behaviour: a line never clamps (any point on it is allowed), a ray clamps behind its start, and a segment clamps at both ends.
The three orientation types (Rotation, Attitude, Quaternion) all describe the same thing —
which way something is facing — at different trade-offs of readability versus robustness. They convert
freely between one another, and Pose bundles an orientation together with a position.
The library converts between types two ways, and the choice of which is deliberate:
- Cast operators (
(Target)x, or implicit) are used only for value-representation changes — the same value in a different container (double↔Angle, a tuple ↔ aVector), a re-encoding of the same rotation, or a lossy widening within a family. They never hide a coordinate-convention choice. - Named
To*/From*methods are used for everything else: conversions that do real computation, or that need a parameter the language can't supply through a cast — above all anything involving yaw/pitch/roll, which always takes anOrthogonalAxesso no convention is ever assumed silently.
The rule of thumb for the casts themselves: implicit when the conversion is total and loses nothing the type cares about; explicit when it narrows precision, decomposes, asserts a precondition, or discards information (so you write the cast and take responsibility for it).
| From | To | Implicit / explicit | Why |
|---|---|---|---|
double, float, int |
Angle |
implicit | a bare number is treated as radians |
Angle |
double |
implicit | exact (radians) |
Angle |
float, int |
explicit | narrows precision |
(double, double, double) |
Vector |
implicit | repack |
Vector |
(double, double, double) |
implicit | repack |
(double, double, double, double) |
Quaternion |
implicit | repack |
Quaternion |
(double, double, double, double) |
implicit | repack |
(Vector, Angle) |
AxisAngle |
explicit | normalises the axis (not a pure repack) |
AxisAngle |
(Vector, Angle) |
implicit | repack (reads the stored unit axis) |
(Vector, Quaternion) |
Pose |
explicit | normalises the orientation (not a pure repack) |
Pose |
(Vector, Quaternion) |
implicit | repack |
double |
ExpandedDouble |
explicit | decomposes into sign/exponent/mantissa |
Quaternion ⇄ AxisAngle ⇄ Rotation ⇄ Matrix |
(see mesh below) | explicit | re-encodes the same rotation |
LineSegment |
Line, Ray |
explicit | widens — drops the segment's ends |
Line |
Ray |
explicit | narrows — keeps only the forward half |
Ray |
Line |
explicit | widens — forgets where the ray starts/stops |
Quaternion, AxisAngle, Rotation (Euler X/Y/Z) and the rotation Matrix are four encodings of the
same turn, so every pair interconverts explicitly and losslessly (each cast is a thin alias over
the existing To*/From* method, routing through the quaternion where needed). Casting from
Matrix additionally asserts its 3×3 block is a pure rotation (scale/skew is ignored).
graph TD
Q["Quaternion"]
AA["AxisAngle"]
Rot["Rotation<br/>(Euler X/Y/Z)"]
M["Matrix"]
Att["Attitude<br/>(yaw / pitch / roll)"]
OA["OrthogonalAxes"]
Q <--> AA
Q <--> Rot
Q <--> M
AA <--> Rot
AA <--> M
Rot <--> M
Att -->|"ToQuaternion(axes)"| Q
Q -->|"FromQuaternion(q, axes)"| Att
OA -.->|"required convention"| Att
classDef mesh fill:#dfe9ff,stroke:#4472c4,color:#222;
classDef conv fill:#ffe0f0,stroke:#c0508f,color:#222;
class Q,AA,Rot,M mesh;
class Att,OA conv;
Every solid edge above is a two-way explicit cast (e.g. var aa = (AxisAngle)q; var m = (Matrix)aa;).
Attitude (yaw/pitch/roll) is not in the mesh: it only has meaning relative to an OrthogonalAxes,
so it converts through Attitude.ToQuaternion(axes) / Attitude.FromQuaternion(q, axes) — methods, not
casts. Pose is also method/constructor-only for its rotation, because it carries a position too; its
tuple cast above is separate — reading a pose out is a lossless repack, but building one in is explicit
because the orientation is normalised.
These remain methods on purpose — either because they take a convention, or read more clearly named:
| Conversion | API |
|---|---|
Attitude ⇄ Quaternion (convention-aware) |
Attitude.ToQuaternion(axes) / Attitude.FromQuaternion(q, axes) |
AxisAngle ⇄ Attitude (convention-aware) |
AxisAngle.FromAttitude(attitude, axes) |
Quaternion → axis & angle |
Quaternion.ToAxisAngle(out axis, out angle) |
Quaternion from an axis & angle |
Quaternion.FromAxisAngle(axis, angle) |
Pose from a position + any orientation type |
new Pose(position, rotation | attitude+axes | axisAngle) |
Plane from geometry |
Plane.FromPointNormal(...), Plane.FromThreePoints(...) |
| line-family widenings (named form of the casts) | Line.ToRay(), Ray.ToLine(), LineSegment.ToLine() / ToRay() |
Every cast above has a corresponding
To*/From*method behind it, so nothing is only reachable through an operator — the cast is sugar, the method is the contract.
A vector represents values along a number of axes. For this library that is three: three numbers and
three axes. Vector is the type everything else leans on, so it gets the fullest treatment.
public struct Vector
{
private double x, y, z;
}Why a struct rather than a class? A struct is a value type: it is copied by value, created and disposed of cheaply, and behaves like a primitive — which is exactly how a vector should feel. (There is no reason it could not be a class; this is a design choice.)
Unless stated otherwise a vector is treated as positional, originating at (0, 0, 0). The
alternatives are unit vectors (length 1, interpreted as pure direction) and vector pairs (where one
vector is the origin and another is the displacement from it).
Throughout, the worked equations assume two vectors broken into components:
$A = \begin{pmatrix} a \ b \ c \end{pmatrix}\qquad B = \begin{pmatrix} d \ e \ f \end{pmatrix}$
The components are exposed as X, Y, Z properties, as an Array of three doubles, and through an
indexer (v[0], v[1], v[2]). Constructors accept three doubles, an array, or another vector:
var v = new Vector(1, 2, 3);
var w = new Vector(new double[] { 1, 2, 3 });
var u = new Vector(v); // copy
double y = v[1]; // 2Addition (v3 = v1 + v2) — add the components pairwise (x+x, y+y, z+z).
public static Vector operator +(Vector v1, Vector v2)
{
return new Vector(v1.X + v2.X, v1.Y + v2.Y, v1.Z + v2.Z);
}Subtraction (v3 = v1 - v2) — subtract the components pairwise.
Negation (v2 = -v1) — invert direction by negating each component. Reinforcement (+v1)
returns the vector unchanged (it exists only to mirror negation).
Equality (==, !=) — two vectors are equal when all three component pairs are equal. (Equals
also offers a tolerance-aware overload, which is what you usually want with floating point.)
Multiplication and division by a scalar — scale each component by the number.
public static Vector operator *(Vector v1, double s2)
{
return new Vector(v1.X * s2, v1.Y * s2, v1.Z * s2);
}The order of operands can be reversed (s1 * v2 does the same thing). Division by a scalar divides
each component.
Comparison (<, >, <=, >=) — vectors are compared by magnitude (length), irrespective of
direction.
The length, or magnitude, of a vector is
:
public static double Magnitude(Vector v1)
{
return Math.Sqrt(v1.X * v1.X + v1.Y * v1.Y + v1.Z * v1.Z);
}In the current library this is exposed as a read-only property, v.Magnitude. To resize a vector
to a new length while keeping its direction, use Scale:
double len = v.Magnitude; // length of v
Vector resized = v.Scale(2.5); // same direction, length 2.5When you only need to compare lengths, skip the square root with MagnitudeSquared (and
DistanceSquared for distances) — squaring preserves ordering and is cheaper.
Multiplying vectors is trickier than scaling. There are two products, and neither is overloaded onto an operator (to avoid confusion) — you call them by name.
The cross product of two vectors produces a vector that is normal (perpendicular) to the plane the two vectors span:
The formula (with v1 = A, v2 = B) expands from
— the sine of the angle accounts for direction, θ being the smallest
angle between A and B (
):
In matrix-style notation:
Cross product is non-commutative: v1 × v2 is not the same as v2 × v1 (they point opposite ways).
public static Vector CrossProduct(Vector v1, Vector v2)
{
return new Vector(
v1.Y * v2.Z - v1.Z * v2.Y,
v1.Z * v2.X - v1.X * v2.Z,
v1.X * v2.Y - v1.Y * v2.X);
}A handy template for working a cross product by hand:
The dot product of two vectors is a single scalar, defined by
and computed as
:
public static double DotProduct(Vector v1, Vector v2)
{
return v1.X * v2.X + v1.Y * v2.Y + v1.Z * v2.Z;
}Cosine of the angle is what makes the dot product so useful: it is zero exactly when the vectors are
perpendicular, positive when they point broadly the same way, and negative when they oppose. (The
mixed, or scalar triple, product MixedProduct(v1, v2, v3) combines both — it is v1 · (v2 × v3)
and gives the signed volume of the parallelepiped the three vectors span.)
A unit vector has a magnitude of 1 — a pure direction with the size removed. Normalisation converts any (non-zero) vector to a unit vector by dividing by its magnitude:
Vector dir = v.Normalize(); // throws if v has zero length
Vector safe = v.NormalizeOrDefault(); // returns (0,0,0) instead of throwingIsUnitVector() tests for length 1 (with a tolerance overload, since exact equality is unreliable for
doubles).
Interpolate takes a point a fraction of the way between two vectors. A control of 0 returns the
first vector, 1 returns the second, and 0.5 the midpoint:
Vector mid = a.Interpolate(b, 0.5);By default control is restricted to
; an overload allows
extrapolation beyond the endpoints.
The distance between two positional vectors is Pythagoras' theorem in 3D:
double d = a.Distance(b);double radians = a.Angle(b);The angle θ between two vectors is always reported in [0, π]. Its textbook definition is the
arccosine of the normalised dot product:
That definition is correct, but computing it literally as arccos(a·b) is numerically fragile. So the
library returns the same angle by a different — and far steadier — route: θ = atan2(a×b length, a·b).
It feeds atan2 two quantities the vector pair already provides — the dot product, which is large when
the vectors are aligned, and the length of the cross product, which is large when they are
perpendicular:
input to atan2 |
proportional to | large when the vectors are |
|---|---|---|
a · b (dot product) |
cos θ |
aligned (θ near 0 or π) |
a × b length (cross product) |
sin θ |
perpendicular (θ near 90°) |
atan2(y, x) returns the angle of the point (x, y) measured from the positive x-axis. Feed it the
dot product as x and the cross-product length as y, and that point is |a||b|·(cos θ, sin θ) — the
same direction as (cos θ, sin θ), just scaled by the positive number |a||b|. Scaling by a positive
number does not change a point's angle, so atan2 hands back exactly θ. And because sin θ ≥ 0 for
every θ in [0, π], that point always sits in the upper half-plane, so the answer lands in [0, π]
automatically — the very range we want.
Geometrically — extending the cosine picture above — place a along the x-axis; the unit vector along
b then sits on the unit circle at angle θ, with coordinates (cos θ, sin θ) = (a·b, a×b length).
arccos is given only the x-coordinate, cos θ, and must infer the angle from that single number;
atan2 is given both coordinates and reads the angle directly off the point.
arccos must recover the angle from its cosine alone, and the cosine is a poor messenger near the ends
of the range. The slope of arccos is −1 / √(1 − (a·b)²), which grows without bound as a·b → ±1 —
exactly where the vectors are nearly parallel or nearly anti-parallel. So a microscopic rounding error
in a·b is magnified into a large error in θ, and if rounding pushes a·b just past −1, arccos
returns NaN outright. The table traces that blow-up (for two near-anti-parallel unit vectors the
rounded dot product is only about 1 ULP from −1):
a · b (cos θ) |
θ | sensitivity dθ/d(a·b) = −1/√(1−(a·b)²) |
|---|---|---|
0 (perpendicular) |
90° |
−1.0 — gentle, harmless |
−0.9 |
154° |
−2.3 |
−0.99 |
172° |
−7.1 |
−0.9999 |
179° |
−70.7 |
−1 + 1e-16 (anti-parallel) |
≈180° |
≈ −7×10⁷ — catastrophic |
−1.0000000000000002 (rounded past −1) |
— | NaN |
This is the exact mechanism behind the old Vector.Angle bug: arccos(a·b) returned NaN for a vector
and its own negation, because the rounded dot landed a hair below −1.
atan2 avoids this because near the poles it leans on the coordinate that is still responsive. Write
θ = π − φ for a small φ: the cosine cos θ = −cos φ ≈ −1 + φ²/2 flattens out — it changes only with
φ², so it loses precision — while the sine sin θ = sin φ ≈ φ keeps changing linearly and carries the
information. Exactly where the dot product goes numb, the cross length is at its most informative, and
atan2, seeing both, is governed by the responsive one. The same holds at θ = 0. The result is
well-conditioned across the whole range, needs no clamping, and returns exactly π for a vector and
its negation (atan2(0, −1) = π).
graph LR
D["a · b = cos θ<br/>'how aligned'"]
X["|a × b| = sin θ<br/>'how perpendicular'"]
AC["arccos(a · b)<br/>reads ONE coordinate"]
AT["atan2(|a × b|, a · b)<br/>reads BOTH coordinates"]
D --> AC
D --> AT
X --> AT
AC -.->|"blind to sin θ — vertical slope at θ = 0, π"| Bad["NaN / ~1e-8 error<br/>near (anti-)parallel"]
AT -.->|"sin θ stays responsive at the poles"| Good["exact, well-conditioned<br/>across all of 0..π"]
(The implementation normalises both vectors first only so its existing special-case handling for zero,
infinite and NaN components still governs the result; the atan2 identity itself needs no
normalisation, since it depends only on the ratio of the cross length to the dot.)
Yaw, Pitch and Roll rotate a vector about a coordinate convention's Up, Right and
Forward axes — not a hard-wired X/Y/Z. "Yaw" only means something once you have decided which way
is up, so each overload takes an OrthogonalAxes convention, and the sense of a
positive angle follows that convention's handedness:
Vector yawed = v.Yaw(angle, OrthogonalAxes.DirectX); // about axes.Up
Vector pitched = v.Pitch(angle, OrthogonalAxes.DirectX); // about axes.Right
Vector rolled = v.Roll(angle, OrthogonalAxes.DirectX); // about axes.ForwardThe pictures below show the DirectX convention, where Up / Right / Forward are +Y / +X / +Z.
Yaw — about the convention's Up (here +Y):
Pitch — about the convention's Right (here +X):
Roll — about the convention's Forward (here +Z):
Each delegates to the library's single right-hand-rule primitive (Quaternion.FromAxisAngle), so no
axis assumption is baked in — switch the convention and the rotations follow it (a Roll banks the
opposite way in a left- vs right-handed frame). When you want to name the literal axis instead, use
RotateX / RotateY / RotateZ (with optional offset pivots and Angle-typed overloads). For
robust, composable orientation prefer Quaternion.
This is exactly where the convention-aware design earns its keep. The literal RotateZ always banks
about +Z; Roll banks about the frame's own Forward. So the two agree precisely when a
frame's forward is +Z (DirectX, Unity) and correctly turn opposite ways when it is −Z (OpenGL,
Maya, Godot, where +Z carries near):
| Frame | axes.Forward |
Roll(θ) equals |
|---|---|---|
| DirectX / Unity (z = far) | (0, 0, +1) |
RotateZ(θ) |
| OpenGL / Maya / Godot / MathsYUp (z = near) | (0, 0, −1) |
RotateZ(−θ) |
Roll is the honest one: it surfaces an assumption the fixed-axis primitive silently bakes in
(banking about +Z even in frames whose forward is −Z). The relationship is pinned as a positive test,
Roll_HonoursEachFramesForward_RelatingToLiteralRotateZ.
Projection(direction)— the "shadow" of a vector cast along another vector's line: the part of it that lies alongdirection.Rejection(direction)— what is left over: the part perpendicular todirection(so that projection + rejection reconstructs the original).Reflection(reflector)— mirror the vector about the line of another vector.Reflect(normal)— mirror the vector about the surface described by a normal: the classic "bounce", where the angle of incidence equals the angle of reflection. (Note the distinction:Reflectionmirrors about a line,Reflectabout a surface.)
var incoming = new Vector(1, -1, 0);
var surfaceNormal = new Vector(0, 1, 0);
var bounced = incoming.Reflect(surfaceNormal); // (1, 1, 0)Back-face test. Interpreting a vector as a face normal, IsBackFace(lineOfSight) reports whether
the face points away from the viewer (a negative dot product with the line of sight):
Perpendicularity. IsPerpendicular(other) is true when the dot product is zero (with a tolerance
overload).
Component utilities. SumComponents, SumComponentSqrs, PowComponents, SqrtComponents,
SqrComponents, AbsComponents, and Round (with digit/MidpointRounding overloads) apply arithmetic
to each component independently. (Note that Abs is not one of these: it returns the vector's
magnitude |v| — the scalar norm, the way Math.Abs gives the size of a number — so it is an alias
for Magnitude. Use AbsComponents for the per-axis (|x|, |y|, |z|).)
Beyond the original article, Vector has grown a handful of operations that round it out. Each is also
available to explore in the interactive visualizer — drag the vectors and watch
the result update live.
Slerp — spherical interpolation. Where Interpolate (linear) walks the straight chord between
two vectors, Slerp walks the arc at constant angular speed, keeping interpolated directions on
the sphere. It falls back to linear interpolation when the vectors are (anti)parallel.
var halfway = Vector.XAxis.Slerp(Vector.YAxis, 0.5); // ~ (0.707, 0.707, 0), still length 1ClampMagnitude. Caps a vector's length at a maximum while keeping its direction (a no-op when it is already short enough) — handy for limiting speeds and forces.
var limited = new Vector(3, 4, 0).ClampMagnitude(2.5); // (1.5, 2, 0), length 2.5MoveTowards. Steps from one point towards a target by at most a given distance, never overshooting — the staple of frame-by-frame animation and AI movement.
var next = Vector.Origin.MoveTowards(new Vector(10, 0, 0), 3); // (3, 0, 0)Component-wise ComponentMin, ComponentMax and Clamp. These work on each axis
independently — the X of the result depends only on the Xs of the inputs, and so on. Together
ComponentMin/ComponentMax give the two opposite corners of the axis-aligned box that just contains
the inputs:
var lo = new Vector(1, 5, 3).ComponentMin(new Vector(4, 2, 6)); // (1, 2, 3)
var hi = new Vector(1, 5, 3).ComponentMax(new Vector(4, 2, 6)); // (4, 5, 6)Not the same as
Min/Max. Those compare whole vectors by magnitude and return the shorter or longer one unchanged — they never mix components. Use theComponent…versions for a per-axis result.
Clamp applies the same idea to a range, pushing each component into the [min, max] interval for its
axis:
var inBox = new Vector(5, -5, 2).Clamp(Vector.Origin, new Vector(3, 3, 3)); // (3, 0, 2)Zero test, deconstruction and tuple conversion.
bool isZero = v.IsZero(); // exactly (0, 0, 0)
bool nearZero = v.IsZero(1e-6); // within a tolerance of zero
var (x, y, z) = v; // deconstruction
Vector p = (1.0, 2.0, 3.0); // implicit from a tupleVector.Origin // (0, 0, 0) also Vector.Zero
Vector.XAxis // (1, 0, 0)
Vector.YAxis // (0, 1, 0)
Vector.ZAxis // (0, 0, 1)
Vector.MinValue, Vector.MaxValue, Vector.Epsilon, Vector.NaNVector also implements IComparable (by magnitude), Equals/GetHashCode (with tolerance-aware
overloads), IsNaN, and ToString / ToVerbString for textual output.
All four describe a straight path through space. As the overview showed, they
differ only in where they are allowed to stop — and that single fact explains every difference in
their behaviour. They are immutable and built on Vector.
A line is the "no ends" member: it stretches forever both ways, so it has no length and no
midpoint. It is stored as a Point it passes through plus a unit Direction it runs along. Every
point on it is
P(t) = Point + t · Direction for any t, positive or negative
Because Direction is unit length, t is a true distance. Construct one from a point and a direction,
or through two points:
var line = new Line(new Vector(0, 0, 0), new Vector(1, 1, 0));
var same = Line.ThroughPoints(new Vector(0, 0, 0), new Vector(2, 2, 0));Closest point. To find the point on the line nearest some point, project onto the direction. The
signed distance along the line is t = (point − Point) · Direction (no division, since Direction is
unit length), and the nearest point is Point + t · Direction. Crucially, t is never clamped —
the whole endless line is available, so the perpendicular foot is always reachable.
Vector foot = line.ClosestPointTo(p);
double dist = line.DistanceTo(p); // length of the perpendicular
bool on = line.Contains(p, 1e-9);Parallelism. Two lines are parallel when their directions point the same or exactly opposite ways.
Since |a × b| = |a||b|sinθ and our directions are unit length, the cross-product magnitude is just
sinθ, which is zero precisely at 0° or 180°:
bool parallel = line.IsParallelTo(other, 1e-9); // |Direction × other.Direction| ≤ toleranceA ray is the "one end" member — think of a beam of light or a line of sight. It has an Origin and a
unit Direction, and every point on it is
P(s) = Origin + s · Direction for s ≥ 0
Negative s would be behind the start, which is not part of the ray. So PointAt clamps a negative
distance to the origin, and ClosestPointTo returns the origin whenever the projection falls behind
it. That is the only behavioural difference from Line:
var ray = new Ray(new Vector(0, 0, 0), new Vector(1, 0, 0));
Vector hit = ray.ClosestPointTo(new Vector(-5, 2, 0)); // the origin — the point is behind the start
double d = ray.DistanceTo(new Vector(3, 4, 0));A segment runs between a fixed Tail (start) and Head (end). Having two ends gives it things the
others lack: a Length, a Midpoint, and a Direction. Every point on it is written with a parameter
that runs from 0 to 1:
P(t) = Tail + t · (Head − Tail) with t clamped to 0..1
var seg = new LineSegment(new Vector(0, 0, 0), new Vector(3, 4, 0));
double len = seg.Length; // 5 = |Head − Tail|
Vector mid = seg.Midpoint; // (1.5, 2, 0) = (Tail + Head) / 2
Vector q = seg.PointAt(0.25); // a quarter of the way alongClosest point. Project onto the segment's direction d = Head − Tail. Minimising the squared
distance |Tail + t·d − point|² gives
t = ((point − Tail) · d) / (d · d)
then t is clamped to 0..1 so the answer can never fall beyond an end, and P(t) is evaluated. (A
zero-length segment returns its tail.) This clamping at both ends is what distinguishes a segment
from a ray (one end) and a line (no ends).
Other operations. A segment can hand you the infinite Line
it lies on (ToLine(), useful when you want to ignore the ends), produce a Reversed() copy,
Translate(offset) to slide it, rotate its head about its tail (RotateX/Y/Z(angle)), and
Interpolate(control) along its length. It can be built from two points, six numbers, or an array of
six.
A chord is a LineSegment whose two ends both lie on a circle — and which also knows that circle's
Radius. On its own a chord is just a segment; the radius is the whole reason the type exists,
because a surprising amount of circle geometry follows from the chord's length c and the radius r
alone. All of it comes from one right-angled triangle: drop a perpendicular from the circle's centre
to the chord; it meets the chord at its midpoint, with the radius r as the hypotenuse and the
half-chord c/2 as one leg.
var chord = new Chord(new Vector(-3, 0, 0), new Vector(3, 0, 0), radius: 5);| Property | Meaning | Formula |
|---|---|---|
CentralAngle |
The angle the chord subtends at the centre | θ = 2·asin((c/2)/r) |
ArcLength |
The curved distance along the edge between the ends | r · θ |
DistanceFromCentre |
Straight distance from centre to chord (the apothem) | √(r² − (c/2)²) |
Sagitta |
How far the arc bulges past the chord (its height) | r − DistanceFromCentre |
Diameter |
The longest possible chord | 2r |
Each derivation is spelled out in the source. For example sin(θ/2) = (c/2)/r gives the central angle;
Pythagoras on the same triangle gives the centre-to-chord distance; and the sagitta is simply what is
left of the radius once you subtract that distance. (Ratios are clamped and roots guarded so a
full-diameter chord resolves to a clean 180° rather than a non-number. "Sagitta" is Latin for arrow —
the chord is the bow, the height is the drawn arrow.)
A plane is an infinite flat surface, stored in the standard algebraic form
A·x + B·y + C·z + D = 0
where (A, B, C) is the plane's Normal (the direction it faces) and D is a signed offset. It is an
immutable value type following the Vector design.
Building a plane. The raw constructor stores whatever coefficients you give it; the geometry factories produce a unit normal for you:
var p1 = Plane.FromPointNormal(point, normal); // a point on it + which way it faces
var p2 = Plane.FromThreePoints(a, b, c); // through three points (throws if collinear)
var xy = Plane.XY; // z = 0; also Plane.XZ, Plane.YZFromThreePoints builds the normal with the cross product (b − a) × (c − a) following the right-hand
rule for the order a → b → c, and throws if the points are collinear (they do not define a unique
plane).
Signed distance and sides. The key operation is the signed distance from a point to the plane: positive on the side the normal points to, negative on the far side, zero on the plane itself. From it everything else follows:
double s = plane.SignedDistanceTo(point); // sign tells you which side
double d = plane.DistanceTo(point); // unsigned
int side = plane.SideOf(point, 1e-9); // +1, -1 or 0 (on the plane)
bool on = plane.Contains(point, 1e-9);
Vector foot = plane.ClosestPoint(point); // orthogonal projection onto the plane
Vector mir = plane.Reflect(point); // mirror the point through the planeLine intersection. TryIntersectLine returns where a line crosses the plane, reporting false
when the line is parallel (no single crossing). IntersectLineParameter returns just the parameter
t along the line, or NaN if parallel:
if (plane.TryIntersectLine(linePoint, lineDirection, out Vector where)) { /* … */ }Plane intersection. Two planes that are not parallel always meet in an infinite Line.
TryIntersect(other, out Line line) returns it, reporting false when the planes are parallel (whether
disjoint or coincident — neither gives a single line). The line's direction is the cross product of the
two normals (n₁ × n₂); a point on it is found by solving the small 2×2 system that forces a blend of
the two normals to satisfy both plane equations:
if (Plane.XY.TryIntersect(Plane.XZ, out Line axis)) { /* axis is the X axis */ }Comparing planes. == is an exact component-wise comparison of the four coefficients, but two
planes can describe the same surface with differently scaled (or flipped) coefficients —
Equals(other, tolerance) checks for that by comparing both in normalized form (and negated). Other
helpers: Normalize() (unit normal, same surface), unary - (flip orientation), IsDegenerate()
(zero normal), IsParallelTo(other, tolerance), and deconstruction into (A, B, C, D).
Angle is an immutable value type for an amount of turn. It stores radians internally but lets you
read and write degrees, radians or gradians, so a bare double never leaves you guessing about units.
A positive value is taken clockwise, a negative value counter-clockwise, and angles beyond a full
circle are automatically reduced.
var a = new Angle(System.Math.PI / 2); // radians
var b = new Angle(90, AngleUnits.DEG); // degrees
double deg = a.Deg; // 90
double rad = a.Rad; // π/2
Angle implicitlyRadians = 1.5; // double → Angle (radians)Arithmetic and comparison. The usual operators are overloaded (+, -, *, /, unary -, and
the comparison operators). Unary - is a true negation — -a flips the sign of the value, so
a + (-a) == 0. To re-express the same physical turn measured the other way round the circle, use
the clearly-named CounterClockwise()/Clockwise() methods (or the ! operator, which toggles between
the two windings). Equality is tolerance-aware via a shared static Tolerance.
Classification. Angle can tell you what kind of angle it is — IsAcute, IsRightAngle,
IsObtuse, IsStraitAngle, IsReflex, IsFullOrZeroAngle, IsOblique, IsClockwise — and how two
angles relate: IsComplementOf (sum to 90°), IsSupplementOf (180°), IsExplementOf (360°). It also
offers Complement, Supplement, SmallAngle, Reflex, Abs, and clockwise/counter-clockwise
conversions.
Trigonometry. Static and instance Sin, Cos, Tan (and their hyperbolic forms), plus the
inverse builders Asin, Acos, Atan that return an Angle.
Constants. Angle.Right_Angle, Angle.Strait_Angle, Angle.Full_Angle,
Angle.Three_Quater_Circle, Angle.Zero_Angle, and the matching …_Rad radian literals.
The companion AngleUnits enum names the three unit systems (DEG/DEGREE, RAD/RADIAN,
GRAD/GRADIENT). The ToString formats support d (degrees), r (radians), g (gradians) and v
(a verbose description that names the kind of angle).
These three types all answer the same question — which way is something facing? — but trade readability against robustness. They convert freely between one another, so you can author in the friendly form and store in the robust one.
The human-friendly "front door": three Angles applied about the X, then Y, then Z world axes
(equivalently the matrix product Rz · Ry · Rx).
var r = new Rotation(pitch, yaw, roll); // angles about X, Y, Z
var rx = Rotation.AboutX(angle); // single-axis factories: AboutX/Y/Z
Vector turned = r.Rotate(v);Component arithmetic (+, -) acts component-wise on the angles — useful for nudging a rotation,
but not the same as composing two rotations. For true composition, convert to a Quaternion and
multiply. ToQuaternion / FromQuaternion bridge the two exactly (folding X into Z at the
gimbal-lock poles), and Inverse() and ToMatrix() round it out. Rotation names the literal X/Y/Z
axes, so it needs no convention.
Aviation naming for an orientation: yaw (heading), pitch (elevation) and roll (bank). Those words only
become concrete once a convention says which way is up, right and forward, so an Attitude turns into
a rotation only when you hand it an OrthogonalAxes — there is deliberately no
axis-free conversion. Yaw is about axes.Up, pitch about axes.Right, roll about axes.Forward, and
the result follows that convention's handedness.
var att = new Attitude(yaw, pitch, roll);
var y = Attitude.FromYaw(angle); // FromYaw / FromPitch / FromRoll
Quaternion q = att.ToQuaternion(OrthogonalAxes.OpenGL); // yaw/pitch/roll → rotation, in a convention
Vector v2 = att.Rotate(v, OrthogonalAxes.OpenGL);
Attitude back = Attitude.FromQuaternion(q, OrthogonalAxes.OpenGL); // and the convention-aware read-backA quaternion (x, y, z, w) is a four-component number used to represent rotations without the
gimbal-lock and interpolation problems of Euler angles. The scalar (real) part is W; the vector
(imaginary) part is (X, Y, Z). Multiplication uses the Hamilton convention. It follows the Vector
design — static and instance forms, strict and …OrDefault normalisation, tolerance-aware equality.
var q = Quaternion.FromAxisAngle(axis, angle); // a turn about an axis
Vector rotated = q.Rotate(v);
q.ToAxisAngle(out Vector ax, out Angle ang);Why it is the dependable representation:
- No gimbal lock — it never loses a degree of freedom the way three stacked Euler angles can.
- Composes cleanly — multiplying two quaternions composes their rotations (
q1 * q2). - Interpolates smoothly —
Slerpwalks the shortest arc at constant angular speed (withLerpas the cheaper straight-line approximation).
It also offers Conjugate/Inverse, DotProduct, AngleBetween/AngleTo, FromYawPitchRoll(…, OrthogonalAxes),
ToMatrix, the identity/zero/NaN constants, and predicates IsUnit, IsIdentity, IsZero, IsNaN.
Building an orientation that points somewhere. Two factories construct a rotation from directions rather than from angles:
FromToRotation(from, to)— the shortest-arc turn that swings the directionfromonto the directionto. The axis is their cross product, the angle comes from their dot product; the aligned and exactly-opposite cases are handled. (This is the primitivePlacedTriangle.FromVerticesnow leans on.)LookRotation(forward, up, axes)— the orientation that "looks" alongforwardwithupas the upward reference. LikeFromYawPitchRoll, it takes anOrthogonalAxesso it never assumes a fixed frame: it maps the convention'sForwardontoforwardand itsUpas near toupas the look direction allows (a single-argument overload uses the convention's ownUp). The third axis then follows with the convention's handedness, so there is no handedness branching to get wrong.
Quaternion turn = Quaternion.FromToRotation(Vector.XAxis, Vector.YAxis); // a quarter turn about +Z
Quaternion look = Quaternion.LookRotation(target - eye, OrthogonalAxes.OpenGL);(Both are static factories on Quaternion; for the Euler form just cast the result, (Rotation)look.)
A Pose is a rigid placement in space — a position together with an orientation ("where" and
"which way"). It is the human-meaningful form of a rigid transform, as opposed to a 4×4
Matrix. Internally the orientation is kept as a unit Quaternion (the robust
representation), but you can construct it from a friendlier Rotation (or an Attitude plus a
convention) and read it back as Euler angles.
var pose = new Pose(position, rotation); // also a Rotation, or an Attitude + an OrthogonalAxes
var at = Pose.At(position); // no rotation
var fa = Pose.FromAxisAngle(position, axis, angle);Applying a pose transforms between the pose's local space and world space:
Vector world = pose.Apply(localPoint); // rotate, then translate
Vector dirW = pose.ApplyDirection(localDir); // rotate only (directions ignore position)
Vector local = pose.ApplyInverse(worldPoint); // the inverse transform
Matrix m = pose.ToMatrix(); // the equivalent 4×4 transformComposition. Compose (and the * operator) chains two poses — outer * inner applies inner
first, then outer — and Inverse() undoes a pose. Modifier methods return a new pose:
Translate(offset), RotateBy(delta), WithPosition(p), WithRotation(q). The RotationAsEuler
helper reads the orientation back as Euler X/Y/Z angles (for yaw/pitch/roll, use
Attitude.FromQuaternion(pose.Rotation, axes) with your convention), and Pose.Identity is the pose
at the origin with no rotation.
Matrix is the general-purpose 4×4 transformation matrix — the linear-algebra workhorse behind
translation, scaling and rotation, and the form that composes all of them by multiplication. Whereas
Pose is the readable form of a rigid transform, Matrix is the general form (it can also scale
and shear).
It can be built from a 3×3 or 4×4 array (smaller inputs are promoted to homogeneous 4×4 form), from
sixteen explicit values, from a Vector, or copied. It supports the arithmetic operators (+, -,
scalar * and /, matrix *, and matrix–vector * to transform a vector), Transpose(),
IsIdentity / IsZero, tolerance-aware equality and formatting, and the Identity / Zero
constants.
Factory builders create the standard transforms:
var t = Matrix.TranslationMatrix(new Vector(1, 2, 3));
var s = Matrix.ScalingMatrix(2, 2, 2);
var rx = Matrix.RotationMatrixAboutXAxis(angle); // …AboutYAxis, …AboutZAxis
Vector moved = t * v; // apply the transform to a vectorDeterminant and inverse. The Determinant measures how the transform scales volume (and its sign
records whether the transform flips orientation); it is zero exactly when the transform collapses space
onto a plane, line or point. Inverse() returns the transform that undoes this one — so
m * m.Inverse() is the Identity — computed the textbook way, as the adjugate (the transpose of the
matrix of cofactors) divided by the determinant. That division is why a collapsing transform has no
inverse: information flattened away cannot be recovered. Inverse() throws on a singular matrix;
IsInvertible lets you check first.
Matrix world = Matrix.TranslationMatrix(pos) * Matrix.ScalingMatrix(2, 2, 2);
Vector local = world.Inverse() * worldPoint; // map a world point back into local space
bool ok = world.IsInvertible; // false only when the determinant is zeroView and projection. Matrix also builds the camera matrices — LookAt, PerspectiveFieldOfView,
Orthographic — that turn a 3D scene into a flat picture, together with Transform (the perspective
divide). These have their own illustrated walk-through in
The camera: view and projection matrices below.
Everything so far has lived in world space — a fixed 3D stage where points, shapes and cameras all have coordinates. But a screen is flat. To draw the world you must answer one question: if I stand here, look that way, and hold a rectangular frame up to the scene, where does each 3D point land on that 2D frame? Answering it is the one place this library brushes against rendering — and because the maths is such a clean, classic piece of geometry, it is worth walking through slowly. (Turning the final coordinates into coloured pixels is your graphics API's job, not this library's.)
The journey from an object to the screen is a short assembly line, and most steps are one matrix multiply:
graph LR
M["Model space<br/>(an object's own coordinates)"] -->|"model matrix"| W["World space<br/>(placed in the scene)"]
W -->|"view (LookAt)"| V["View space<br/>(camera at the origin, looking down −Z)"]
V -->|"projection"| C["Clip space<br/>(homogeneous — carries a w)"]
C -->|"÷ w (Transform)"| N["Device coordinates<br/>(inside the −1…+1 clip cube)"]
N -->|"viewport — your graphics API"| S["Screen pixels"]
classDef lib fill:#dfe9ff,stroke:#4472c4,color:#222;
classDef ext fill:#eeeeee,stroke:#999999,color:#555555;
class V,C,N lib;
class M,W,S ext;
The blue steps — the view matrix, the projection, and the divide that follows — are what Matrix builds for
you. The grey ends (placing each object in the world, and stretching the final cube across your window's
pixels) belong to your application.
View and projection are the corner of graphics where two perfectly sensible textbooks disagree — which way the camera looks, whether the depth runs 0→1 or −1→+1, whether the frame is left- or right-handed. There is no neutral answer, so rather than hide a choice, this library makes one and says it plainly. It is the same right-handed, OpenGL convention the rest of the documentation's pictures assume:
The camera sits at the origin of view space and looks down its own −Z axis, with +X to the right and +Y up. Each projection squashes the visible region into a clip cube running from −1 to +1 on every axis — the near plane landing at z = −1 and the far plane at z = +1.
(This is deliberately not expressed through OrthogonalAxes: that type names the roles
of the world axes, whereas these conventions are about clip space — a different thing. If you target
a left-handed API, or one whose clip depth runs 0→1, these builders are the wrong tool, and the maths below
shows exactly what would change.)
Here is the trick that makes cameras simple: you never move the camera. Instead you move the entire
world so that the camera ends up at the origin, looking in a fixed direction. A photograph of a mountain,
and a photograph taken by swinging the whole mountain in front of a bolted-down lens, are identical — so we
choose the version that puts the camera somewhere predictable. The matrix that does this rearranging is the
view matrix, and LookAt builds it from three friendly inputs:
eye— where the camera is, in world space.target— a point it is aimed at.up— roughly which way is "up" for the camera. It need not be exactly perpendicular to the gaze; it is only a hint, used to decide how the camera is rolled.
From those it constructs an orthonormal basis — three mutually perpendicular unit axes — for the camera:
graph TD
E["your inputs: eye, target, up"] --> F["forward = normalize(target − eye)"]
F --> R["right = normalize(forward × up)"]
R --> U["trueUp = right × forward<br/>(rebuilt square, so a tilted up hint still works)"]
U --> VM["View matrix<br/>rows: right, trueUp, −forward<br/>last column: −(each axis · eye)"]
classDef hi fill:#ffe9a8,stroke:#b8860b,color:#222;
class VM hi;
The forward × up cross product gives a vector perpendicular to both (the camera's right); a second cross
product rebuilds a clean up exactly square to the other two — which is why a hastily-chosen up hint still
yields a valid frame. Those three axes become the rows of the rotation part (they re-express any world
direction in camera axes), and the last column shifts the eye to the origin. Because a right-handed camera
looks down −Z, the forward axis goes in negated.
Matrix view = Matrix.LookAt(eye: new Vector(0, 0, 5), target: Vector.Origin, up: Vector.YAxis);
view.Transform(new Vector(0, 0, 5)); // the eye -> (0, 0, 0): the camera is now at the origin
view.Transform(Vector.Origin); // the target -> (0, 0, -5): dead ahead, down -ZLookAt refuses two impossible requests — an eye equal to the target (no direction to look in), and an
up hint parallel to the gaze (no way to tell which way is up) — throwing rather than returning a broken
matrix.
The view matrix leaves the camera at the origin looking down −Z, but the world is still fully 3D and unbounded. A projection matrix does two jobs at once: it picks the region of space that is actually visible (the view volume) and squashes that region onto the standard clip cube. There are two shapes of view volume, and they give the two familiar looks.
Perspective — the view volume is a frustum: a pyramid with its tip at the eye and its top sliced off. Something further away fills a smaller slice of your vision, so it looks smaller — the everyday look of eyes and cameras, where parallel railway lines appear to meet at the horizon.
PerspectiveFieldOfView(verticalFov, aspect, near, far) is the everyday builder: give it how much it sees
top-to-bottom (the vertical field of view angle), the aspect ratio width ÷ height of your window, and
the near/far distances that cap the frustum at each end. (The general
PerspectiveOffCenter(left, right, bottom, top, near, far) — the glFrustum form — allows a lopsided
frustum, for stereo or tiled rendering; the field-of-view version is just its symmetric case.)
Orthographic — the view volume is a plain box. With no tip, depth no longer changes size: a metre-wide object draws the same whether it is near or far. This is the look of engineering drawings, maps, and most 2D and isometric games.
Orthographic(width, height, near, far) builds a box centred on the gaze; OrthographicOffCenter(left, right, bottom, top, near, far) (the glOrtho form) places it freely.
How can a single matrix multiply make distant things smaller? Plain matrix multiplication is linear — it can rotate, scale, shear, and (with the 4×4 trick) translate, but it cannot divide, and shrinking-with-depth is a division: an object at twice the distance should appear half as big. The way out is the fourth coordinate, w — the "homogeneous" coordinate that has quietly ridden along in every 4-vector.
An ordinary point carries w = 1. A perspective matrix is built so that, instead of leaving w alone, it
copies the point's depth into w. After the multiply you therefore hold (x′, y′, z′, w′) where w′ is
(near enough) how far away the point was. One final step — dividing all of them by w′, the perspective
divide — turns that stored depth into real shrinkage:
That last line is just similar triangles — the same ratio you met at school — and it is the whole secret
of perspective: divide the across-screen position x by the depth d, and distant points (large d) are
pulled in toward the centre. The matrix does the multiply; the divide finishes the job.
This is why the projection matrices need their own apply method. The * operator refuses any result
whose w is not 1 — it is meant for affine transforms, the moves that keep w = 1 (translation, rotation,
scaling, and the view matrix) — so it would throw on a perspective result. Use Transform(v) instead: it
does the multiply and the perspective divide. For an affine matrix w′ is already 1, so Transform and
* agree — meaning you can reach for Transform whenever a projection might be involved and never be caught
out.
After the divide, every visible point lands inside the clip cube: x, y and z each between −1 and +1.
The x and y say where on the screen the point belongs; the z carries depth, so a renderer can tell which surface is in front when two land on the same pixel. Stretching this −1…+1 cube across your actual window in pixels — the viewport transform — is the final step, and the point at which this library hands over to your graphics API.
The matrices compose by multiplication, right-to-left in the order a point travels — model, then
view, then projection — and a single Transform carries a world point all the way to the clip cube:
// One-time setup, per camera:
Matrix view = Matrix.LookAt(eye, target, Vector.YAxis);
Matrix proj = Matrix.PerspectiveFieldOfView(new Angle(60, AngleUnits.DEG), width / height, near: 0.1, far: 100);
Matrix viewProjection = proj * view; // compose once…
// …then, for each point in the world:
Vector clip = viewProjection.Transform(worldPoint); // Transform does the perspective divide
bool onScreen = System.Math.Abs(clip.X) <= 1
&& System.Math.Abs(clip.Y) <= 1
&& System.Math.Abs(clip.Z) <= 1; // inside the clip cube ⇒ visibleBecause the perspective divide makes the same world step shrink with depth, a unit-wide object at the far plane covers far fewer screen units than one at the near plane — perspective foreshortening, falling straight out of the maths rather than bolted on.
An OrthogonalAxes is a coordinate-system convention — a constant that records, for a given project or
ecosystem, which way is which: which Cartesian axis means right, which means up, which means
forward, and which direction along each is positive. It answers the question "when this project says
y, does it mean up or into the screen?"
This is what lets the library stop assuming a fixed world frame. Yaw, pitch and roll have no
meaning until you say which way is up, right and forward — so instead of hard-wiring X/Y/Z, the
convention-aware rotations take an OrthogonalAxes and turn about its directions. Swap the
convention and the rotations follow it:
graph LR
OA["OrthogonalAxes<br/>chosen once per project<br/>(OpenGL, DirectX, Blender, Unreal, ...)"]
OA -->|"Right"| RGT["+X? -X?"]
OA -->|"Up"| UP["+Y? -Y?"]
OA -->|"Forward (far)"| FWD["+Z? -Z?"]
RGT --> pitch["Pitch — about Right"]
UP --> yaw["Yaw — about Up"]
FWD --> roll["Roll — about Forward"]
yaw --> turn["rotated Vector / Quaternion"]
pitch --> turn
roll --> turn
note["no hard-wired world axis:<br/>swap the convention and<br/>the rotations follow it"]
note -.-> OA
Different worlds disagree, and that is the whole reason the type exists. OpenGL is y-up and
right-handed; Direct3D is y-up and left-handed; Blender and most CAD/engineering maths are z-up;
Unreal is z-up and left-handed. An OrthogonalAxes pins that choice down in one immutable value so the rest of
your maths never has to guess.
It is a label, not a motion. An OrthogonalAxes is meant to be chosen once, at the start, and handed to any
function that needs to know your frame. It never gets rotated — arbitrary orientations are the job of
Quaternion and Pose. (This single type
replaces two earlier ones: a label-only Axis and a free-vector OrthogonalAxis that — by storing
three arbitrary vectors — let you build skewed, non-unit, meaningless "axes". Merging them and keeping
only genuine conventions is what makes a bad frame impossible to construct. The name is the old
OrthogonalAxis corrected to the plural it always should have been: it describes a set of axes, not
one — and so does not collide with the single direction vector that an axis–angle rotation
(Quaternion.FromAxisAngle) calls an "axis".)
Each of the three Cartesian axes is given a role from one of the three opposed pairs:
UP/DOWNLEFT/RIGHTNEAR/FAR
var openGl = new OrthogonalAxes(AxisAlignment.RIGHT, AxisAlignment.UP, AxisAlignment.NEAR);The sign is carried by which member of the pair you choose: Y = UP means +y is up; Y = DOWN
means +y is down (so up is −y). Z = FAR means +z points into the scene; Z = NEAR means +z points
back toward the viewer.
A convention must use each pair exactly once — so two axes can never both mean "up", and no pair
can be left out. With three axes and three pairs, simply forbidding duplicates is enough to guarantee a
complete, non-contradictory frame, which is why a meaningless OrthogonalAxes cannot be constructed at all.
Why
NEAR/FARand notFORWARD/BACKWARD?nearandfarare positional labels — they say where a thing is, just likeup/downandleft/right.forward/backwardcarry a sense of motion, which belongs to a velocity or a rotation, not to a fixed frame. Naming all three pairs as positional opposites keeps the type consistent in kind. (far= into the scene, away from the viewer;near= toward the viewer.)
From the labels alone, an OrthogonalAxes derives the signed unit vectors Right, Up and Forward (forward
being the far direction — into the scene), and its Handedness:
OrthogonalAxes.OpenGL.Forward; // (0, 0, -1) — far is -z, because z is NEAR
OrthogonalAxes.OpenGL.Handedness; // Handedness.RightThese are derived, so they can never drift out of step with the labels.
Handedness, in one gesture. Point your right hand's fingers along Right and curl them toward
Up; your thumb gives the right-handed third direction. In numbers:
X × Y = (1, 0, 0) × (0, 1, 0) = (0, 0, 1) = +Z
With right = +x and up = +y, the right-hand rule puts the third axis at +z. So whether the far direction is +z or −z is exactly what decides handedness:
| Far direction (with right = +x, up = +y) | Handedness |
|---|---|
| far = −z | right-handed |
| far = +z | left-handed |
That single fact is the entire difference between OpenGL and Direct3D: same right, same up, opposite
depth. (It is also why "OpenGL is right-handed, yet you look down −z" is not a contradiction — the
axes obey the right-hand rule with +z pointing toward you; the camera simply faces the other way.
Where the camera looks is a rendering concern, not part of the convention.) The type computes
handedness as the sign of (Right × Up) · Forward — negative is right-handed — so it is read off the
geometry, never typed in by hand.
There is deliberately no default. A project must name the convention it wants, because the right answer depends on the ecosystem the maths is feeding. The common systems are provided as constants; several share a frame, so the constants are intentional aliases:
| Constant(s) | x | y | z | Up axis | Handed |
|---|---|---|---|---|---|
OrthogonalAxes.OpenGL, OrthogonalAxes.Maya, OrthogonalAxes.Godot, OrthogonalAxes.MathsYUp |
right | up | near | y | right |
OrthogonalAxes.DirectX, OrthogonalAxes.Direct3D, OrthogonalAxes.Unity |
right | up | far | y | left |
OrthogonalAxes.Blender, OrthogonalAxes.Max3ds, OrthogonalAxes.MathsZUp |
right | far | up | z | right |
OrthogonalAxes.Unreal |
far | right | up | z | left |
OrthogonalAxes.OpenGL.Z; // NEAR — +z toward the viewer
OrthogonalAxes.DirectX.Z; // FAR — +z into the screenThe point of holding a convention as a value is to feed it into maths that must respect it.
OrthogonalAxes.Map converts coordinates between a stored convention and a canonical right/up/far frame, so a
function can take your OrthogonalAxes, map its inputs into the easy canonical frame, do the work once there,
and map the result back — instead of branching on the convention in every method.
Vector can already walk between two points in a straight line
(Interpolate) or along an arc (Slerp). The curve types are the next step: smooth paths that bend
through space, the staple of camera moves, motion paths and easing. All three are immutable and built on
Vector, and all are parameterised by a t running 0→1 from start to end, with a PointAt(t) and a
Tangent(t) (the velocity — direction-and-speed — at that point).
graph LR
V["Vector<br/>lerp · slerp"] --> B["Bezier<br/>pulled by control points"]
V --> H["Hermite<br/>endpoints + tangents"]
H -->|"tangents chosen<br/>automatically"| CR["CatmullRom<br/>passes through waypoints"]
A Bézier curve is defined by an ordered list of control points. It starts at the first and ends at the last; the interior points pull the curve toward them without being touched. Two control points give a straight line, three a quadratic, four a cubic (the everyday case), and higher degrees are allowed.
It is evaluated by de Casteljau's algorithm: to find the point at t, repeatedly replace each
adjacent pair of points with the point a fraction t along the segment between them; each pass yields
one fewer point, and the last point left is the answer. It is just repeated straight-line interpolation
all the way down — which is exactly why it reads well as a teaching example. The Tangent reuses the
same evaluation on the curve's derivative, itself a lower-degree Bézier.
var curve = Bezier.Cubic(p0, p1, p2, p3);
Vector here = curve.PointAt(0.25);
Vector vel = curve.Tangent(0.25); // heading + speed at that point
double len = curve.Length(64); // arc length, approximated by samplingA cubic Hermite segment is fixed not by off-curve handles but by the two endpoints and the tangent
(velocity) at each — exactly the information needed to join segments together smoothly. The point is a
blend of the two endpoints and two tangents weighted by the four cubic Hermite basis functions; you can
check by hand that it passes through the endpoints and leaves/arrives along the given tangents.
var seg = new Hermite(startPoint, startTangent, endPoint, endTangent);
Vector mid = seg.PointAt(0.5);A Catmull–Rom spline passes through every one of a sequence of waypoints (unlike a Bézier, whose
interior points it misses) — so it is the natural choice for routing something through positions you have
placed by hand. The trick is that it is just a chain of Hermite segments with the tangents filled in
for you: at each waypoint the curve heads along the line through its two neighbours, at half their
separation. That single rule makes the joins smooth without the caller supplying any tangents. The whole
spline takes one t from 0 (first waypoint) to 1 (last); Segment(i) exposes the underlying Hermite.
var path = new CatmullRom(a, b, c, d);
Vector p = path.PointAt(0.5); // halfway along the whole spline, on the curve through the pointsThe shape types are built around one deliberate split: what a shape is is kept separate from where it is.
- A conceptual shape —
Triangle,Rectangle,Cuboid,Cylinder— describes only the intrinsic geometry: size and proportion, in no particular place. It has no centre, no orientation, no position of any kind. Everything it computes (area, volume, angles, diagonals, classification) depends solely on its own dimensions. - A placed shape —
PlacedTriangle,PlacedRectangle,PlacedCuboid,PlacedCylinder— is a conceptual shape together with aPosethat puts it somewhere in the world. Only the placed form answers world-space questions: does it contain this point, what is the closest point on it, where does this line or ray strike it.
graph LR
subgraph concept["Conceptual shape — size and proportion only"]
C1["Circle, Rectangle, Ellipse,<br/>Annulus, Sector, Triangle"]
C2["Sphere, Cuboid, Cylinder, Cone,<br/>Capsule, Ellipsoid, Torus"]
end
subgraph placed["Placed shape — in the world"]
P1["PlacedCircle, PlacedRectangle, ..."]
P2["PlacedSphere, PlacedCuboid, ..."]
end
pose["Pose<br/>(where + which way)"]
concept -->|"+ Pose"| placed
pose -.-> placed
q1["area / volume<br/>compare by size"] -.-> concept
q2["Contains? closest point?<br/>line / ray hit?"] -.-> placed
vtx["PlacedPolygon · PlacedTetrahedron<br/>defined by corners — always placed"] --> placed
Because position confuses size-only questions. If you want to know which of two circles is bigger, the honest comparison is between their areas — and a position has no business being part of that answer; it can only muddy what "bigger" means. Keeping the conceptual shape position-free makes those comparisons exact and unambiguous:
var a = new Cylinder(radius: 2, height: 5);
var b = new Cylinder(radius: 3, height: 1);
bool aIsBigger = a.Volume > b.Volume; // pure size — no placement anywhere in sight
bool same = a == b; // equality means "same shape", not "same place"Each conceptual shape implements IComparable<T> and the comparison operators (<, >, <=, >=)
by size — area for the flat shapes, volume for the solids — exactly as Vector
compares by magnitude. As with Vector, == still means equal dimensions, not equal size.
Each conceptual shape has one hand-written placed partner, named Placed + the shape name (no
cleverer name than that). A placed shape stores the conceptual Shape and a Pose, and delegates:
var disc = new Rectangle(4, 6);
var board = new PlacedRectangle(disc, Pose.At(new Vector(0, 0, 10)));
board.Area; // 24 — delegated straight to the conceptual shape
board.Contains(somePoint, 1e-9); // a world-space question only the placed form can answerThe placed form does its world-space maths by working in the shape's own local frame. It maps the
incoming world point back into that frame with Pose.ApplyInverse, runs the simple canonical maths
there (where a rectangle is axis-aligned at the origin, a cylinder runs along local +Z, and so on), and
maps any returned point forward again with Pose.Apply. Writing the geometry once, in the easy frame,
is the whole reason the two layers are kept apart.
How much of an orientation actually matters depends on the shape's symmetry — which is why the placed types differ in what their pose meaningfully controls:
| Symmetry | What the placement pins down | Shapes |
|---|---|---|
| Fully symmetric (a point) | only a centre; orientation is irrelevant | PlacedSphere |
| Rotationally symmetric about an axis | a centre and one axis direction; spin about it is irrelevant | PlacedCircle, PlacedCylinder, PlacedCone, PlacedCapsule, PlacedAnnulus, PlacedTorus |
| No symmetry to spare | a full orientation | PlacedRectangle, PlacedCuboid, PlacedEllipse, PlacedEllipsoid, PlacedSector |
| Defined by its corners | placement is implied by the corner positions | PlacedTriangle, PlacedPolygon, PlacedTetrahedron |
A PlacedSphere therefore ignores its pose's orientation entirely, and a PlacedCircle cares only about
its normal (any spin about that normal leaves the disc unchanged) — the symmetry simply makes the surplus
parts of the Pose inert.
Every shape is treated as a filled region, not just an outline or surface. Contains means "on or
within"; a Rectangle is the solid rectangle, a Cylinder is the solid cylinder. This matches the
existing Sphere, whose Contains has always meant "on or within".
Flat shapes (a Placed… partner lays each on a plane in space):
| Conceptual shape | Defined by | Placement notes |
|---|---|---|
Circle |
radius | a flat disc; axis symmetry, so the placed form needs only a centre and a normal |
Triangle |
three side lengths (A, B, C) | Heron area, angles by the law of cosines, classification; placed form adds corners, containment, closest point, line/ray hits (see special case below) |
Rectangle |
width, height | full orientation; placed form lies on a plane with width along local +X, height along +Y |
Ellipse |
two semi-axes | full orientation; eccentricity and foci; placed closest-point uses Eberly's robust method |
Annulus |
inner & outer radius | axis symmetry; the filled ring between two concentric circles |
Sector |
radius, sweep angle | a pie slice; the placed form is anchored at its apex (not its centroid) |
Solids (a Placed… partner positions each in space):
| Conceptual shape | Defined by | Placement notes |
|---|---|---|
Sphere |
radius | fully symmetric, so the placed form needs only a centre (its pose's orientation is inert) |
Cuboid |
width, height, depth | full orientation; oriented box (the slab test runs in its local, axis-aligned frame) |
Cylinder |
radius, height | axis symmetry; runs along local +Z, curved side + two caps |
Cone |
base radius, height | axis symmetry; placed form stands on its base, closest-point solved in the axial–radial half-plane |
Capsule |
radius, cylinder height | axis symmetry; a swept segment (cylinder with two hemispherical caps) |
Ellipsoid |
three semi-axes | full orientation; intersection by scaling to a unit sphere, closest-point by a Lagrange-multiplier bisection |
Torus |
major & minor radius | axis symmetry; line/ray intersection is an exact quartic (see below) |
Vertex-defined shapes (positioned in their own right — no conceptual partner):
| Shape | Defined by | Notes |
|---|---|---|
PlacedPolygon |
ordered coplanar corners | any simple polygon (convex or concave); robust area/normal/centroid, crossing-number containment |
PlacedTetrahedron |
four corners | the simplest solid; barycentric containment, per-face intersection |
"Cuboid", not "Box". The solid box is named
Cuboidto keep the plain word box for the axis-aligned bounding box — a different idea: the box that just contains something, used for quick rejection rather than as a shape in its own right. That type isBoundingBox, the partner ofBoundingSphere.
PlacedPolygon and PlacedTetrahedron are defined directly by their corner points, so — like the
corners of PlacedTriangle — they are always placed: their position is implicit in the corners and
there is no separate conceptual form (hence they carry the Placed prefix but have no plain-named
partner). PlacedPolygon handles any simple planar polygon, convex or concave (area, normal and centroid
come from summed edge cross-products; containment is a crossing-number test done in the polygon's own
plane). PlacedTetrahedron is the simplest solid, with barycentric containment and intersection tested
face by face.
Line/ray intersection is exact for every placed solid. Most reduce to a quadratic in the line parameter
(sphere, cylinder, cone, capsule, and the ellipsoid after scaling space to a unit sphere). The Torus
is the exception: substituting the line into its implicit equation gives a quartic, so the library
includes a small real-root solver, PolynomialRoots (quadratic by formula,
cubic by Cardano, quartic by Ferrari). A line can pierce a torus up to four times; the solver returns all
real crossings and the placed torus reports the nearest and farthest.
A triangle is the one shape whose natural placed form is its three corners, not a tidy centre and
size. Its conceptual form is therefore its three side lengths — the position-free description that
still fixes area, angles and classification. PlacedTriangle stores that Triangle plus a Pose whose
position is the triangle's centroid; in the local frame the centroid sits at the origin, the A→B edge
runs along local +X and corner C lies on the local +Y side (a layout recovered from the side lengths via
the law of cosines).
The everyday way to build one is from three world corners:
var t = PlacedTriangle.FromVertices(a, b, c); // recovers both the side lengths and the placing pose
t.A; t.B; t.C; // round-trips back to the original cornersFromVertices has to turn three points into a Pose, which means recovering an orientation from a
coordinate frame. Rather than assemble a rotation matrix and read it back (Matrix.ToQuaternion could
do that), the rotation is built directly by composing the library's own quaternion turns — first
Quaternion.FromToRotation to swing local +Z onto the face normal, then a Quaternion.FromAxisAngle
spin about that normal to line local +X up with the A→B edge. Building it from the orientation
primitives keeps it consistent with the quaternion conventions used everywhere else, and avoids assuming
the recovered frame is perfectly orthonormal (and the round-trip is covered by tests).
Circle and Sphere follow the same split as everything else: the conceptual Circle is just a radius
(area, circumference) and the conceptual Sphere is just a radius (volume, surface area), each comparable
by size, while PlacedCircle and PlacedSphere add the placement and the world-space maths. They were
originally written as positioned types and have been converted to match — PlacedCircle.InXYPlane(circle, centre) and PlacedSphere.At(sphere, centre) are the everyday factories.
- Conceptual and placed are separate types. Size-only maths lives on the conceptual shape and never sees a position; world-space maths lives on the placed shape.
- Paired concrete types, not a generic wrapper. Each shape has its own hand-written placed partner
rather than a single
Placed<T>over an interface (an earlierIShapeabstraction was removed). - Named
Placed+ the shape name.PlacedTriangle,PlacedRectangle, and so on — no invented names. - A placed shape is a conceptual shape plus a
Pose. World queries are done in the shape's local frame viaApplyInverse/Apply. - Shapes are filled regions.
Containsmeans "on or within". - Conceptual shapes are comparable by size (
IComparable, comparison operators), mirroringVector. - Vertex-defined shapes are always placed.
PlacedPolygonandPlacedTetrahedronare defined by general corner points (not regularised), so they have no conceptual partner; they carry thePlacedprefix because they only ever exist positioned — matching howPlacedTriangle's corners already work. CircleandSpherefollow the split too. Originally positioned, they are now a conceptual radius plusPlacedCircle/PlacedSphere, consistent with every other shape.- Intersections are exact, including the torus. Rather than approximate the torus's line/ray hits, a
real quartic solver (
PolynomialRoots) was added so its crossings are exact like the other solids'.
The exact shapes above answer questions precisely — and sometimes expensively. A bounding volume is
the opposite trade: a crude hull that is cheap to test, used for quick rejection — the fast "could
these two things possibly touch?" you run before any exact geometry. Both types are immutable, built on
Vector, and treat themselves as filled regions (Contains means "on or within").
A BoundingBox is described by two opposite corners, a Min and a Max, with faces parallel to the
coordinate planes. Because the faces never tilt, containment and overlap are just per-axis number
comparisons — which is the whole point. (This is the bounding box the shapes note keeps the
plain word box free for; it is a different idea from the oriented solid
Cuboid, which can sit at any angle.)
BoundingBox b = BoundingBox.FromPoints(p0, p1, p2); // smallest AABB enclosing some points
BoundingBox ce = BoundingBox.FromCenterExtents(centre, half); // …or from a centre and half-size
bool in = b.Contains(point);
bool hit = b.Intersects(other); // touching faces count
Vector near = b.ClosestPoint(point); // clamps the point into the box
BoundingBox grown = b.Merge(other).Expand(0.1); // union, then a safety marginLine and ray hits use the slab method: an AABB is the overlap of three pairs of parallel planes ("slabs"), one per axis; intersect the per-axis parameter spans and a non-empty result is the hit. It is spelled out in the source.
if (b.TryIntersect(ray, out Vector entry)) { /* … */ }
if (b.TryIntersect(line, out Vector nearP, out Vector farP)) { /* … */ }A BoundingSphere is the rotation-independent counterpart: a centre and radius, so it needs no re-fitting
when the thing it wraps turns, and the overlap test between two spheres is a single distance comparison. It
is distinct from PlacedSphere — that is a geometric sphere with full surface/volume
maths and exact line/ray hits; this is a lightweight hull focused on containment and overlap.
FromPoints fits one with Ritter's algorithm: span the two points found farthest apart in a quick
scan, then sweep once more, growing the sphere just enough whenever a stray point falls outside. The result
is small (though not provably the smallest) and cheap.
BoundingSphere s = BoundingSphere.FromPoints(points);
bool hitsBox = s.Intersects(box);
bool hitsBall = s.Intersects(other);
BoundingSphere grown = s.Merge(extraPoint);A small amount of plumbing supports the maths above and is not usually called directly:
DoubleExtension— floating-point comparison helpers (e.g.AlmostEqualsWithAbsTolerance) used throughout for tolerance-aware equality. Compares doubles sensibly rather than with the unreliable==.ExpandedDouble,UlongExtension— bit-level inspection of doubles, used by the ULP ("units in the last place") comparison strategy.Tolerance— interfaces and strategies (UlpsToleranceand others) describing how close is close enough when comparing values.Exceptions/NormalizeVectorException,Exceptions/NormalizeQuaternionException— the errors thrown by the strictNormalizemethods when asked to normalise a zero-length value (the…OrDefaultvariants avoid these).
The
Shapenamespace is documented above under Shapes: conceptual and placed.
A number of resources informed the original vector article and the maths in this library:
- CSOpenGL — Lucas Viñas Livschitz
- Exocortex — Ben Houston
- Essential Mathematics for Computer Graphics — John Vince (ISBN 1-85233-380-4)
A running list of design questions that have been thought through but deliberately not yet built — recorded here so the reasoning is not lost, and so the eventual implementation starts from a conclusion rather than from a blank page. The guiding decision in every case below is the same: wait until real usage makes the right shape obvious rather than committing to an abstraction early.
The line family is presented as "one family", but that
family is currently a documentation grouping, not something the type system enforces: Line, Ray
and LineSegment are unrelated classes (only Chord inherits — from LineSegment). A natural next
step is a shared interface so that code can hold "some straight path" without caring which kind it is —
useful for operations like snap to the nearest path under the cursor or is this click within
tolerance of any path, where the per-type clamping should be the path's business, not the caller's.
Two findings from thinking it through are worth preserving.
1. The anchor point is named differently on purpose, and should stay that way. The stored point is
Line.Point, Ray.Origin, and LineSegment.Tail / Head. This looks inconsistent, but the
inconsistency is honest: a Line is infinite both ways and has no start, so its point is merely an
arbitrary representative; a Ray genuinely begins at its origin; a LineSegment genuinely has
two ends. No single shared name is true for all three — Origin / Start / Tail would falsely
imply a line begins somewhere, and a bland Point everywhere would hide the fact that a ray's anchor is
its whole defining feature. The conclusion: do not rename to unify. A shared interface should expose
the behaviour the family shares (direction and parametric evaluation, below), not the anchor data
they differ on. (If a single neutral name were ever forced, only Anchor — "the point at parameter
zero", which claims nothing about being a start — would be acceptable; but per-type clarity is the
better trade.)
2. The obvious interface is too narrow — it actually spans the shapes too. A first sketch put five
members on an IStraightPath: Direction, PointAt, closest-point, DistanceTo and Contains. But
checking the real surface shows three of those are not path-specific at all — every Placed… shape
and Plane already implement closest-point and Contains. So the honest design is layered, not a
single interface:
- a broad capability — "a piece of geometry you can ask point-queries of" — carrying
Contains, closest-point andDistanceTo, implemented by the line family, the placed shapes andPlane; - a narrow straight-path specialization on top, adding the genuinely one-dimensional members
DirectionandPointAt, implemented by the line family alone.
Whatever shape the interfaces eventually take, they cannot be written until a few naming and coverage gaps — surfaced while exploring the above — are settled. Each is worth fixing in its own right, independently of any interface:
- Closest-point has two names for one operation:
ClosestPointTo(point)on the line family versusClosestPoint(point)on every shape and onPlane. One name must win before either can share a contract. Containscoverage is uneven: it exists onLinebut not onRayorLineSegment, while every shape carries both aContains(point)and aContains(point, tolerance)overload.DistanceTo✓ resolved. It was missing on the placed shapes (the line family andPlanealready had it). Every placed shape now carriesDistanceTo(point)— the one-liner(point − ClosestPoint(point)).Magnitude, which is zero on or within the shape.PlacedSphere, whose closest-point method returns a surface point, instead defines it asMax(0, SignedDistanceTo(point))so "inside ⇒ 0" holds uniformly.- An open question of meaning:
Containson a filled shape means "on or within"; on a zero-thickness line / ray / segment it can only mean "lies on". The signature and intent match, but whether that is genuinely the same concept — and therefore safe to place on one shared interface — is exactly the kind of false-implication risk this library tries hard to avoid, and should be settled deliberately rather than by accident. - Intersection out-parameters ✓ resolved. The
Lineoverload used to disagree —out Vector pointon the flat shapes (one crossing),out Vector near, out Vector faron the solids (two). They are now unified onout Vector near, out Vector fareverywhere: a flat shape meets an infinite line at most once, so it reports that single crossing asnear == far— the same convention solids already use when a line grazes. (TheRayoverload was already uniform, returning the first hit asout Vector point.)
Today every shape has a hand-written Placed… partner, and each placed shape stores a full
Pose regardless of how much of that pose the shape's symmetry actually uses. But the
placement-and-symmetry table already shows that the amount of placement a
shape needs falls into only a few distinct kinds — and that, in turn, decides whether a shape really
needs a whole pose or merely a vector:
- A point only — a bare
Vector. A fully symmetric shape (Sphere) needs just a centre; orientation is meaningless, so aPlacedSpherehanded an orientation simply ignores it. - A point and an axis — a
Vectorcentre plus one direction. An axially-symmetric shape (Circle,Cylinder,Cone,Capsule,Annulus,Torus) needs a centre and a single axis; spin about that axis is irrelevant, so the roll part of a pose is wasted. - A full
Pose. Only an asymmetric shape (Rectangle,Cuboid,Ellipse,Ellipsoid,Sector) genuinely needs a complete orientation. - Corners. Vertex-defined shapes (
PlacedTriangle,PlacedPolygon,PlacedTetrahedron) carry their placement implicitly in their points and need no separate positioning value at all.
That clustering is a strong hint that the placement layer could be expressed with a small number of generics keyed on the placement kind, rather than one bespoke class per shape — so that each placed type carries exactly the placement data its symmetry justifies. A couple of sketches, to be refined later:
// Fully symmetric: positioned by a single point.
public readonly struct PointPlaced<TShape> { TShape Shape; Vector Centre; /* … */ }
// Axially symmetric: positioned by a centre and one axis direction (spin about it is irrelevant).
public readonly struct AxialPlaced<TShape> { TShape Shape; Vector Centre; Vector Axis; /* … */ }
// No symmetry to spare: positioned by a full pose.
public readonly struct PosePlaced<TShape> { TShape Shape; Pose Pose; /* … */ }Done well, this would make a meaningless placement impossible to construct — a Sphere could no longer
be given an orientation it ignores — in the same spirit that OrthogonalAxes made a
meaningless coordinate frame impossible to build.
Two cautions, which is exactly why this is filed under future and not now:
- It reopens a decision already taken. The shapes section deliberately chose
paired concrete types over a generic wrapper, and an earlier blanket
IShape/Placed<T>was removed because the world-space maths differs too much shape to shape to hide behind one interface. The proposal here is narrower — generics keyed on placement kind, not one wrapper over everything — but it still revisits that ground and must earn its place. - It only pays off once the pairs are right. The conceptual/placed pairs are still settling, and abstracting over them now risks freezing an interface before the per-shape maths has proven its shape. Get the hand-written pairs correct first, let the genuinely-common positioning operations reveal themselves, and only then lift the repeated ones into a generic. The rule is that the generics should be extracted from working pairs, never imposed on them.
Angle's unary - operator used to not return the opposite angle — it returned a coterminal
re-expression of the same turn with a negative winding label. For a positive (clockwise) 0.9 it yielded
0.9 − 2π (≈ −5.383), which lands on the same orientation as +0.9 (they differ by a full circle),
not on the inverse −0.9. That broke two expectations every other unary minus in the library upholds:
it was not involutive (-(-x) ≠ x), and it disagreed with Vector and Quaternion, where unary -
means "the opposite" — so building an inverse rotation as -angle produced a silently wrong axis-angle.
Unary - now negates the raw radians (new Angle(-a.Rad)): -a is the true opposite, and
a + (-a) == 0. The original "same physical turn, measured the other way round the circle" behaviour was
not a defect of concept, only of being bound to the - symbol — it now lives in the clearly-named
CounterClockwise()/Clockwise() methods, and the ! operator (toggle the winding) is built on those.
Guarded by Angle_UnaryMinus_NumericallyNegates_Test in MathematicalBugTests.
(The convention-aware Roll vs literal RotateZ sign relationship — once listed here — is not a
defect: it is the principled API being correct. It is documented as a strength under
Rotation: yaw, pitch and roll and guarded by a positive test,
Roll_HonoursEachFramesForward_RelatingToLiteralRotateZ.)
Angle.ToAngleValue used to normalise an out-of-range radian with
rad > 2π ? IEEERemainder(rad, 2π) : rad. IEEERemainder returns a value in [−π, π], not the
[0, 2π) the type otherwise works in, so reducing 540° (3π) gave IEEERemainder(3π, 2π) = −π = −180°
— even though the method's own doc comment promises "3·PI (540 degree) ==> PI (180 degree)". The guard
also only fired for rad > 2π, leaving large negative angles unreduced entirely.
It now reduces with the ordinary remainder, rad % 2π, which keeps the sign of the input (so the
clockwise/counter-clockwise winding is preserved) and maps 540° → 180° exactly as the doc comment
promises, while reducing large negative angles symmetrically. Guarded by
Angle_ReduceFiveFortyDegrees_GivesOneEighty_PerOwnDocstring_Test.
The library began as the single Vector3 type from the CodeProject article (since renamed Vector)
and has grown to cover the line family, planes, angles, the orientation types, poses and matrices —
all in the spirit of the original: small immutable values, rich and well-explained mathematical
functionality, clarity ahead of speed.
It is an early-state, actively-developing project: the core numeric types are well covered by tests.
The former Axis and OrthogonalAxis types are now merged into a single OrthogonalAxes convention,
which carries the coordinate-frame logic they left unfinished and exposes the major systems (OpenGL,
Direct3D, Unity, Unreal, Blender, …) as named constants. The yaw/pitch/roll rotation API now consumes
an OrthogonalAxes in both directions (Attitude.ToQuaternion(axes) / FromQuaternion(q, axes),
Vector.Yaw/Pitch/Roll(angle, axes)): there is no longer any rotation entry point that silently
assumes a fixed up/right/forward. The Shape
types follow a settled conceptual/placed split — Circle, Sphere, Triangle, Rectangle, Ellipse,
Annulus, Sector, Cuboid, Cylinder, Cone, Capsule, Ellipsoid and Torus, each with a
Placed… partner, plus the always-placed PlacedPolygon and PlacedTetrahedron. More recently the
Matrix gained a true Inverse and right-handed view/projection builders (LookAt,
PerspectiveFieldOfView, Orthographic) with a perspective-divide Transform; Quaternion gained
direction-based construction (FromToRotation, LookRotation); Plane gained plane-plane intersection;
every placed shape gained DistanceTo; and two new families joined: the curves (Bezier, Hermite,
CatmullRom) and the axis-aligned bounding volumes (BoundingBox, BoundingSphere).























