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

Lesson 34 of 44 15 minAdvanced

Meshes: vertices, faces and normals

Store a solid as a list of corners and a list of triangles, learn why the order of a triangle’s corners matters, and see how face and vertex normals are worked out.

Whatever a BIM program uses to model a solid internally, the moment it has to draw it, export it to IFC or glTF, or send it to a clash checker, it turns it into a mesh: a surface made of flat triangles. A wall becomes 12 triangles, a curved duct elbow a few hundred, a detailed family thousands.

A triangle is the simplest flat face there is: any three points that are not in a line lie in exactly one plane. Larger polygons can bend out of their plane, and graphics hardware only draws triangles anyway, so everything else is split into them.

Interactive

Vertices, triangles and normals

vertices V8
edges E18
triangles F12
V − E + F2
vertices with flat normals24
surface area28.00 m²
volume from the triangles9.600 m³
every edge pairedyes

Triangle 1: (4, 5, 6)

8 shared corners and 12 triangles, each a triple of vertex numbers listed counter-clockwise seen from outside. The normal (b − a) × (c − a) then points out of the solid. Dashed faces point away from you.

Vertices and indices

A mesh is stored as two lists. The vertex list holds every corner once. The index list holds the triangles, each as three numbers that point into the vertex list. A box has 8 corners but 12 triangles, and each corner is shared by up to six of them, so storing each corner once saves memory and, more importantly, guarantees that neighbouring triangles really meet: they use the same point, not two copies that might differ in the last digit.

BoxMesh.cs
// The 8 corners of a 4 x 3 x 2.5 m box, each stored oncevar v = new V3[]{    new(0, 0, 0), new(4, 0, 0), new(4, 3, 0), new(0, 3, 0),    new(0, 0, 2.5), new(4, 0, 2.5), new(4, 3, 2.5), new(0, 3, 2.5)}; // 12 triangles, each listed counter-clockwise seen from outsidevar f = new (int A, int B, int C)[]{    (0, 2, 1), (0, 3, 2),   // floor (seen from below)    (4, 5, 6), (4, 6, 7),   // roof    (0, 1, 5), (0, 5, 4),   // front, y = 0    (1, 2, 6), (1, 6, 5),   // right, x = 4    (2, 3, 7), (2, 7, 6),   // back, y = 3    (3, 0, 4), (3, 4, 7)    // left, x = 0}; var edges = new HashSet<(int, int)>();double area = 0;foreach (var (a, b, c) in f){    area += V3.Cross(v[b] - v[a], v[c] - v[a]).Length / 2;    foreach (var (p, q) in new[] { (a, b), (b, c), (c, a) }) edges.Add((Math.Min(p, q), Math.Max(p, q)));} Console.WriteLine($"V = {v.Length}, E = {edges.Count}, F = {f.Length}, V - E + F = {v.Length - edges.Count + f.Length}");Console.WriteLine($"surface area = {area} m2");Console.WriteLine($"memory: {v.Length * 24 + f.Length * 12} bytes indexed, {f.Length * 3 * 24} bytes as separate triangles");Console.WriteLine($"normals: floor {Normal(0)}, roof {Normal(2)}, front {Normal(4)}, right {Normal(6)}"); V3 Normal(int i) { var (a, b, c) = f[i]; return V3.Cross(v[b] - v[a], v[c] - v[a]).Normalized(); } readonly record struct V3(double X, double Y, double Z){    public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z);    public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z);    public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k);    public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z;    public static V3 Cross(V3 a, V3 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 double Length => Math.Sqrt(Dot(this, this));    public V3 Normalized() => this * (1 / Length);    static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###");    public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}
Output
V = 8, E = 18, F = 12, V - E + F = 2
surface area = 59 m2
memory: 336 bytes indexed, 864 bytes as separate triangles
normals: floor (0, 0, -1), roof (0, 0, 1), front (0, -1, 0), right (1, 0, 0)

V − E + F = 2 is Euler’s formula. It holds for every closed surface without holes, however it is triangulated, which makes it a cheap sanity check on imported geometry: a mesh with a gap or a stray triangle usually breaks it. (A solid with a hole through it, like a ring beam, gives 0 instead.)

Winding: which way a triangle faces

The three indices of a triangle are listed in a definite order, and that order is information. By convention the corners go counter-clockwise when you look at the triangle from outside the solid. Then the cross product (b − a) × (c − a) points outwards, by the right-hand rule from the cross product lesson, and every triangle knows which side is outside without storing anything extra.

Turn on “Flip one triangle” in the widget. The same three corners listed the other way round give a normal pointing into the solid, and three things break:

  • Drawing: renderers skip triangles that face away from the camera (back-face culling), so the flipped triangle vanishes and leaves a hole.
  • Lighting: the light is computed from the normal, so even without culling the face is shaded as if lit from inside.
  • Measuring: volumes, inside/outside tests and sections all rely on consistent winding, as the next lessons show.

Consistent winding has a neat test. In a closed, well-wound mesh, every edge is used by exactly two triangles, once in each direction: if one triangle goes 4 → 6, its neighbour goes 6 → 4. Count directed edges and the problems show up:

Winding.cs
var box = Mesh.Box(4, 3, 2.5);Console.WriteLine($"box: {Check(box)}"); var (a, b, c) = box.F[3];box.F[3] = (a, c, b);                         // one roof triangle listed clockwiseConsole.WriteLine($"one triangle flipped: {Check(box)}"); box.F.RemoveAt(3);                            // and now that triangle missingConsole.WriteLine($"one triangle missing: {Check(box)}"); static string Check(Mesh m){    var count = new Dictionary<(int, int), int>();    foreach (var (a, b, c) in m.F)        foreach (var e in new[] { (a, b), (b, c), (c, a) })            count[e] = count.GetValueOrDefault(e) + 1;     int same = 0, open = 0;    foreach (var ((p, q), n) in count)    {        if (n > 1) same++;                                  // used twice in one direction        else if (!count.ContainsKey((q, p))) open++;        // nothing on the other side    }    return same == 0 && open == 0 ? "closed and consistently wound"         : $"{same} edges used twice in the same direction, {open} edges with no partner";} sealed class Mesh{    public readonly List<V3> V = new();    public readonly List<(int A, int B, int C)> F = new();   // counter-clockwise seen from outside     public static Mesh Box(double lx, double ly, double lz) =>        Prism(new[] { (0.0, 0.0), (lx, 0.0), (lx, ly), (0.0, ly) }, lz);     // A counter-clockwise footprint extruded up by h (floor and roof fanned from corner 0)    public static Mesh Prism((double X, double Y)[] poly, double h)    {        var m = new Mesh();        int n = poly.Length;        foreach (var (x, y) in poly) m.V.Add(new V3(x, y, 0));        foreach (var (x, y) in poly) m.V.Add(new V3(x, y, h));        for (int k = 1; k < n - 1; k++)        {            m.F.Add((0, k + 1, k));                 // floor, facing down            m.F.Add((n, n + k, n + k + 1));         // roof, facing up        }        for (int i = 0; i < n; i++)        {            int j = (i + 1) % n;            m.F.Add((i, j, j + n));                 // each wall as two triangles            m.F.Add((i, j + n, i + n));        }        return m;    }     public static Mesh Pyramid(double b, double h)    {        var m = new Mesh();        m.V.AddRange(new V3[] { new(0, 0, 0), new(b, 0, 0), new(b, b, 0), new(0, b, 0), new(b / 2, b / 2, h) });        m.F.AddRange(new[] { (0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4), (0, 2, 1), (0, 3, 2) });        return m;    }     // (b - a) x (c - a): points out of the solid, and its length is twice the triangle's area    public V3 Normal((int A, int B, int C) t) => V3.Cross(V[t.B] - V[t.A], V[t.C] - V[t.A]);} readonly record struct V3(double X, double Y, double Z){    public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z);    public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z);    public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k);    public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z;    public static V3 Cross(V3 a, V3 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 double Length => Math.Sqrt(Dot(this, this));    public V3 Normalized() => this * (1 / Length);    static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###");    public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}
Output
box: closed and consistently wound
one triangle flipped: 3 edges used twice in the same direction, 0 edges with no partner
one triangle missing: 0 edges used twice in the same direction, 3 edges with no partner

Face normals and vertex normals

A face normal belongs to one triangle and is the normalised cross product. A vertex normal belongs to a corner and is used for smooth shading: the renderer blends the normals across each triangle, so a pipe made of flat strips looks round. It is usually the normalised sum of the normals of the triangles around the corner. Using the un-normalised cross products weights bigger triangles more.

VertexNormal.cs
var box = Mesh.Box(4, 3, 2.5);int corner = 6;                                    // the top corner at (4, 3, 2.5) var sum = new V3(0, 0, 0);var faces = new HashSet<V3>();int count = 0;foreach (var t in box.F){    if (t.A != corner && t.B != corner && t.C != corner) continue;    sum += box.Normal(t);                          // length 2 x area: big triangles weigh more    faces.Add(box.Normal(t).Normalized());    count++;} Console.WriteLine($"{count} triangles meet at {box.V[corner]}");Console.WriteLine($"smooth vertex normal: {sum.Normalized()}");Console.WriteLine($"flat shading needs {faces.Count} copies of the corner: {string.Join(", ", faces)}"); sealed class Mesh{    public readonly List<V3> V = new();    public readonly List<(int A, int B, int C)> F = new();   // counter-clockwise seen from outside     public static Mesh Box(double lx, double ly, double lz) =>        Prism(new[] { (0.0, 0.0), (lx, 0.0), (lx, ly), (0.0, ly) }, lz);     // A counter-clockwise footprint extruded up by h (floor and roof fanned from corner 0)    public static Mesh Prism((double X, double Y)[] poly, double h)    {        var m = new Mesh();        int n = poly.Length;        foreach (var (x, y) in poly) m.V.Add(new V3(x, y, 0));        foreach (var (x, y) in poly) m.V.Add(new V3(x, y, h));        for (int k = 1; k < n - 1; k++)        {            m.F.Add((0, k + 1, k));                 // floor, facing down            m.F.Add((n, n + k, n + k + 1));         // roof, facing up        }        for (int i = 0; i < n; i++)        {            int j = (i + 1) % n;            m.F.Add((i, j, j + n));                 // each wall as two triangles            m.F.Add((i, j + n, i + n));        }        return m;    }     public static Mesh Pyramid(double b, double h)    {        var m = new Mesh();        m.V.AddRange(new V3[] { new(0, 0, 0), new(b, 0, 0), new(b, b, 0), new(0, b, 0), new(b / 2, b / 2, h) });        m.F.AddRange(new[] { (0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4), (0, 2, 1), (0, 3, 2) });        return m;    }     // (b - a) x (c - a): points out of the solid, and its length is twice the triangle's area    public V3 Normal((int A, int B, int C) t) => V3.Cross(V[t.B] - V[t.A], V[t.C] - V[t.A]);} readonly record struct V3(double X, double Y, double Z){    public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z);    public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z);    public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k);    public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z;    public static V3 Cross(V3 a, V3 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 double Length => Math.Sqrt(Dot(this, this));    public V3 Normalized() => this * (1 / Length);    static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###");    public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}
Output
5 triangles meet at (4, 3, 2.5)
smooth vertex normal: (0.5, 0.333, 0.8)
flat shading needs 3 copies of the corner: (0, 0, 1), (1, 0, 0), (0, 1, 0)

Smooth shading would make the box’s corner look rounded, which is wrong for a box. So meshes with hard edges store a corner once per face plane, each copy with that face’s normal: the box needs 24 vertices instead of 8, which is the “vertices with flat normals” row in the widget. Notice also that the averaged normal leans towards the right wall rather than the back wall although the back wall is bigger: only one of its two triangles touches this corner. Weighting by the angle each triangle makes at the corner avoids that dependence on how the faces happen to be split.

FormatHow it stores a mesh
IFC (IfcTriangulatedFaceSet)A coordinate list and 1-based index triples, with optional normals
glTFBinary buffers of float positions, normals and indices, ready for the GPU
OBJText: v x y z lines, then f a b c lines with 1-based indices
STLEvery triangle separately with its normal; no shared vertices at all
Quick check

A triangle is stored as (2, 7, 5). Which of these describes the same triangle facing the opposite way?

Quick check

In a closed, consistently wound mesh, how is each edge used?

Practice

Build the mesh of a square pyramid: base 4 × 4 m on z = 0 and apex at (2, 2, 3). Use 5 vertices and 6 triangles, all counter-clockwise from outside. Print each triangle’s unit normal, then V − E + F and the surface area.

Show a hint

The base needs two triangles that face down, for example (0, 2, 1) and (0, 3, 2). Each side is (i, i + 1, apex), going round the base counter-clockwise seen from above.

Show the solution
Solution.cs
var v = new V3[] { new(0, 0, 0), new(4, 0, 0), new(4, 4, 0), new(0, 4, 0), new(2, 2, 3) };var f = new (int A, int B, int C)[] { (0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4), (0, 2, 1), (0, 3, 2) }; var edges = new HashSet<(int, int)>();double area = 0;foreach (var (a, b, c) in f){    var n = V3.Cross(v[b] - v[a], v[c] - v[a]);    area += n.Length / 2;    Console.WriteLine($"({a}, {b}, {c}): normal {n.Normalized()}");    foreach (var (p, q) in new[] { (a, b), (b, c), (c, a) }) edges.Add((Math.Min(p, q), Math.Max(p, q)));}Console.WriteLine($"V - E + F = {v.Length} - {edges.Count} + {f.Length} = {v.Length - edges.Count + f.Length}");Console.WriteLine($"surface area = {area:0.###} m2"); readonly record struct V3(double X, double Y, double Z){    public static V3 operator +(V3 a, V3 b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z);    public static V3 operator -(V3 a, V3 b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z);    public static V3 operator *(V3 v, double k) => new(v.X * k, v.Y * k, v.Z * k);    public static double Dot(V3 a, V3 b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z;    public static V3 Cross(V3 a, V3 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 double Length => Math.Sqrt(Dot(this, this));    public V3 Normalized() => this * (1 / Length);    static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###");    public override string ToString() => $"({F(X)}, {F(Y)}, {F(Z)})";}
Output
(0, 1, 4): normal (0, -0.832, 0.555)
(1, 2, 4): normal (0.832, 0, 0.555)
(2, 3, 4): normal (0, 0.832, 0.555)
(3, 0, 4): normal (-0.832, 0, 0.555)
(0, 2, 1): normal (0, 0, -1)
(0, 3, 2): normal (0, 0, -1)
V - E + F = 5 - 9 + 6 = 2
surface area = 44.844 m2

Remember

  • A mesh is a vertex list plus an index list of triangles; shared vertices save memory and make neighbours meet exactly.
  • V − E + F = 2 for any closed surface without holes: a cheap check on imported geometry.
  • Corners are listed counter-clockwise seen from outside, so (b − a) × (c − a) points out of the solid.
  • In a closed, well-wound mesh every edge is used exactly twice, once in each direction.
  • Vertex normals average the face normals for smooth shading; hard edges need one vertex copy per face plane.
0 of 44 lessons completed