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

Lesson 42 of 44 17 minMaster

B-rep and NURBS: how BIM kernels store solids

What is inside a geometry kernel: boundary representation with faces, loops, edges and vertices, Euler–Poincaré checks, B-spline and NURBS curves, exact circles, and tessellation to a tolerance.

The meshes of the previous module are what a viewer draws. They are not what Revit, ArchiCAD, Tekla or an IFC file usually store. A curved wall stored as triangles is only an approximation; ask for its exact area, offset it by 100 mm or cut a door into it, and the triangles get in the way. Modelling kernels (Parasolid, ACIS, Open CASCADE, and the kernels inside the BIM tools) store solids exactly as a boundary representation (B-rep) with NURBS or analytic surfaces, and only turn them into triangles when something needs drawing.

B-rep: topology plus geometry

A B-rep separates what is connected to what (topology) from where it is (geometry):

TopologyCarries the geometryExample for a curved wall
Solid / shellA closed set of facesThe whole wall
FaceA surface (plane, cylinder, NURBS)The curved inner face: a piece of a cylinder
LoopA closed ring of edges bounding a faceOuter boundary, plus one loop per window hole
EdgeA curve (line, arc, NURBS)The arc where the face meets the floor
VertexA pointA corner of the wall

Faces are not limited to triangles: a whole wall face is one face, however large, and can have holes as extra loops. Each edge is shared by exactly two faces, and walking the loops of neighbouring faces passes along it in opposite directions, just like the edge pairing of a closed mesh. The Euler–Poincaré formula checks that the counts are consistent: V − E + F − (L − F) = 2(S − G), where L is the number of loops, S the number of shells and G the genus (the number of through-holes).

BRep.cs
// A box: 8 vertices, 6 faces with one loop each (vertex numbers counter-clockwise from outside)var box = new BRep(8, new[]{    new[] { new[] { 0, 3, 2, 1 } }, new[] { new[] { 4, 5, 6, 7 } },    new[] { new[] { 0, 1, 5, 4 } }, new[] { new[] { 1, 2, 6, 5 } },    new[] { new[] { 2, 3, 7, 6 } }, new[] { new[] { 3, 0, 4, 7 } },});box.Report("box"); // A square tube (a box with a square hole through it): the top and bottom faces have two loops each.// Outer corners 0-3 (bottom) and 4-7 (top), hole corners 8-11 (bottom) and 12-15 (top).var tube = new BRep(16, new[]{    new[] { new[] { 0, 3, 2, 1 }, new[] { 8, 9, 10, 11 } },          // bottom face with the hole    new[] { new[] { 4, 5, 6, 7 }, new[] { 12, 15, 14, 13 } },        // top face with the hole    new[] { new[] { 0, 1, 5, 4 } }, new[] { new[] { 1, 2, 6, 5 } },    new[] { new[] { 2, 3, 7, 6 } }, new[] { new[] { 3, 0, 4, 7 } },    new[] { new[] { 8, 12, 13, 9 } }, new[] { new[] { 9, 13, 14, 10 } },  // the four faces inside the hole    new[] { new[] { 10, 14, 15, 11 } }, new[] { new[] { 11, 15, 12, 8 } },});tube.Report("tube"); sealed class BRep{    readonly int vertices;    readonly int[][][] faces;                       // face -> loops -> vertex numbers    public BRep(int vertices, int[][][] faces) { this.vertices = vertices; this.faces = faces; }     public void Report(string name)    {        var used = new Dictionary<(int, int), int>();        // directed edge -> how often a loop walks it        int loops = 0;        foreach (var face in faces)            foreach (var loop in face)            {                loops++;                for (int i = 0; i < loop.Length; i++)                {                    var e = (loop[i], loop[(i + 1) % loop.Length]);                    used[e] = used.GetValueOrDefault(e) + 1;                }            }        int edges = used.Count / 2;        bool paired = used.All(kv => kv.Value == 1 && used.GetValueOrDefault((kv.Key.Item2, kv.Key.Item1)) == 1);        int v = vertices, f = faces.Length, lhs = v - edges + f - (loops - f);        int genus = 1 - lhs / 2;                             // one shell: lhs = 2(1 - G)        Console.WriteLine($"{name}: V {v}, E {edges}, F {f}, L {loops}; V - E + F - (L - F) = {lhs}; edges paired: {paired}; genus {genus}");    }}
Output
box: V 8, E 12, F 6, L 6; V - E + F - (L - F) = 2; edges paired: True; genus 0
tube: V 16, E 24, F 10, L 12; V - E + F - (L - F) = 0; edges paired: True; genus 1

The box gives 2 and genus 0. The tube’s top and bottom faces each have an extra loop for the hole, and the formula gives 0: genus 1, one hole through the solid. Kernels run checks like these after every operation; a B-rep that fails them is “invalid” and gets refused by booleans, exporters and IFC validators.

B-splines: smooth curves from control points

The geometry on the edges and faces is very often NURBS: Non-Uniform Rational B-Splines. Start with the B-spline part. A curve of degree p is a weighted average of its control points P₀…Pₙ, and the weights at each parameter t are the basis functions N(i, p)(t), defined recursively by the Cox–de Boor formula over a knot vector. Two properties make them so useful:

  • Local control: each basis function is non-zero only over p + 1 knot spans, so moving one control point changes only a nearby piece of the curve.
  • Partition of unity: the basis functions always add up to 1, so the curve stays inside the convex hull of its control points and moves with them under any affine transform.

A clamped knot vector repeats the first and last knots p + 1 times, which makes the curve start exactly at P₀ and end exactly at Pₙ. In the widget, change the degree and watch how far each basis function reaches.

Interactive

Control points, weights and knots

P0P1P2P3P4

knots [0.00, 0.00, 0.00, 0.00, 0.50, 1.00, 1.00, 1.00, 1.00]

Degree 3 with 5 control points: each point is a blend of at most 4 control points, weighted by the basis functions below. At t = 0.50, P2 pulls hardest (50%). A larger weight on P2 draws the curve towards it without moving anything else.
CoxDeBoor.cs
var ctrl = new V2[] { new(0.4, 0.6), new(1.4, 4.2), new(3, 1), new(4.6, 4.6), new(5.8, 1.4) };int p = 3;double[] knots = { 0, 0, 0, 0, 0.5, 1, 1, 1, 1 };       // clamped: p + 1 zeros and ones foreach (double t in new[] { 0.0, 0.25, 0.5, 1.0 }){    var n = Basis(t, p, knots, ctrl.Length);    var point = new V2(0, 0);    for (int i = 0; i < ctrl.Length; i++) point += ctrl[i] * n[i];    Console.WriteLine($"t = {t,4}: N = [{string.Join(", ", n.Select(v => v.ToString("0.000")))}], sum {n.Sum():0.###}, point {point}");} // Cox-de Boor: start with step functions (degree 0) and blend neighbours up to degree pstatic double[] Basis(double t, int p, double[] k, int count){    var n = new double[k.Length - 1];    for (int i = 0; i < n.Length; i++)        n[i] = (t >= k[i] && t < k[i + 1]) || (t == k[^1] && k[i] < t && k[i + 1] == t) ? 1 : 0;    for (int d = 1; d <= p; d++)        for (int i = 0; i < k.Length - d - 1; i++)        {            double a = k[i + d] - k[i], b = k[i + d + 1] - k[i + 1];            n[i] = (a > 0 ? (t - k[i]) / a * n[i] : 0) + (b > 0 ? (k[i + d + 1] - t) / b * n[i + 1] : 0);        }    return n[..count];} readonly record struct V2(double X, double Y){    public static V2 operator +(V2 a, V2 b) => new(a.X + b.X, a.Y + b.Y);    public static V2 operator -(V2 a, V2 b) => new(a.X - b.X, a.Y - b.Y);    public static V2 operator *(V2 v, double k) => new(v.X * k, v.Y * k);    public double Length => Math.Sqrt(X * X + Y * Y);    static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###");    public override string ToString() => $"({F(X)}, {F(Y)})";}
Output
t =    0: N = [1.000, 0.000, 0.000, 0.000, 0.000], sum 1, point (0.4, 0.6)
t = 0.25: N = [0.125, 0.594, 0.250, 0.031, 0.000], sum 1, point (1.775, 2.963)
t =  0.5: N = [0.000, 0.250, 0.500, 0.250, 0.000], sum 1, point (3, 2.7)
t =    1: N = [0.000, 0.000, 0.000, 0.000, 1.000], sum 1, point (5.8, 1.4)

At t = 0 only N₀ is non-zero, so the curve starts exactly at P₀; at t = 1 it ends at P₄. In between, at most p + 1 = 4 functions are non-zero, and they always add up to 1. Updating n[i] in place works because each new value only uses n[i] and n[i + 1], which have not been overwritten yet.

The R in NURBS: weights and exact circles

A plain B-spline is a polynomial, and no polynomial curve can draw an exact circle. That matters in BIM: pipes, round columns, curved walls and arcs in plans must be exact. NURBS add a weight wᵢ to each control point and divide: C(t) = Σ wᵢ·Nᵢ(t)·Pᵢ / Σ wᵢ·Nᵢ(t). With three control points at the corners of a square, degree 2, and the middle weight set to cos 45° ≈ 0.7071, the curve is exactly a quarter circle.

ExactCircle.cs
const double R = 4.4;var ctrl = new V2[] { new(R, 0), new(R, R), new(0, R) }; foreach (double w in new[] { 1.0, Math.Sqrt(0.5) }){    double worst = 0;    for (int s = 0; s <= 100; s++)    {        double t = s / 100.0;        // degree 2 on knots 0,0,0,1,1,1: the basis is (1-t)^2, 2t(1-t), t^2        double b0 = (1 - t) * (1 - t), b1 = 2 * t * (1 - t) * w, b2 = t * t;        var pt = (ctrl[0] * b0 + ctrl[1] * b1 + ctrl[2] * b2) * (1 / (b0 + b1 + b2));        worst = Math.Max(worst, Math.Abs(pt.Length - R));    }    Console.WriteLine($"middle weight {w:0.0000}: largest distance from the circle {worst * 1000:0.000000} mm");} readonly record struct V2(double X, double Y){    public static V2 operator +(V2 a, V2 b) => new(a.X + b.X, a.Y + b.Y);    public static V2 operator -(V2 a, V2 b) => new(a.X - b.X, a.Y - b.Y);    public static V2 operator *(V2 v, double k) => new(v.X * k, v.Y * k);    public double Length => Math.Sqrt(X * X + Y * Y);    static string F(double v) => Math.Abs(v) < 5e-4 ? "0" : v.ToString("0.###");    public override string ToString() => $"({F(X)}, {F(Y)})";}
Output
middle weight 1.0000: largest distance from the circle 266.904756 mm
middle weight 0.7071: largest distance from the circle 0.000000 mm

With every weight 1 (a plain quadratic) the curve misses the circle by up to about a quarter of a metre; with weight cos 45° the error is only rounding. Many kernels go one step further and store planes, cylinders, cones, spheres and tori as analytic surfaces with exact formulas, keeping NURBS for genuinely free-form shapes. IFC has both: IfcCircle and IfcCylindricalSurface next to IfcBSplineCurveWithKnots and IfcRationalBSplineSurfaceWithKnots.

Tessellation: exact shapes to triangles

To draw a B-rep, or to run mesh algorithms such as clash detection on it, the kernel tessellates each face to a tolerance. For an arc of radius r split into chords, the largest gap between chord and arc (the sagitta) is r·(1 − cos(θ / 2)) for a chord spanning angle θ. Fix the allowed gap and you get the number of segments.

Tessellate.cs
var shapes = new (string Name, double Radius, double AngleDeg)[]{    ("50 mm pipe", 0.025, 360), ("600 mm column", 0.3, 360), ("curved wall r = 12 m, 90 deg", 12, 90)};foreach (double tol in new[] { 0.001, 0.0001 })    foreach (var (name, r, angle) in shapes)    {        double maxStep = 2 * Math.Acos(1 - Math.Min(tol / r, 1));   // the chord angle that keeps the sagitta <= tol        int n = Math.Max(3, (int)Math.Ceiling(angle * Math.PI / 180 / maxStep));        Console.WriteLine($"tolerance {tol * 1000,3} mm: {name,-28} {n,4} segments");    }
Output
tolerance   1 mm: 50 mm pipe                     12 segments
tolerance   1 mm: 600 mm column                  39 segments
tolerance   1 mm: curved wall r = 12 m, 90 deg   61 segments
tolerance 0.1 mm: 50 mm pipe                     36 segments
tolerance 0.1 mm: 600 mm column                 122 segments
tolerance 0.1 mm: curved wall r = 12 m, 90 deg  193 segments

A thin pipe needs few segments; a big radius needs many, because a long chord drifts far from the arc. Tightening the tolerance tenfold multiplies the count by about √10 ≈ 3.2, and on surfaces the triangle count grows with the square of that. This is why BIM viewers offer a “level of detail” setting, and why the triangle count of an exported model depends so much on the export tolerance.

Quick check

Why do modelling kernels store a curved wall as a B-rep with a cylindrical or NURBS face rather than as a triangle mesh?

Quick check

A NURBS curve has degree 3. How many control points affect the curve at any one value of t?

Practice

Tessellate a round column of radius 0.3 m with a chord tolerance of 1 mm. Print the number of segments, the largest gap between chord and circle, the area of the polygon against the exact πr², and the relative area error.

Show a hint

n = ceil(2π / (2·acos(1 − tol / r))). The polygon of n equal chords has area ½·n·r²·sin(2π / n), and its largest gap is r·(1 − cos(π / n)).

Show the solution
Solution.cs
double r = 0.3, tol = 0.001;int n = (int)Math.Ceiling(2 * Math.PI / (2 * Math.Acos(1 - tol / r)));double gap = r * (1 - Math.Cos(Math.PI / n));double polygon = 0.5 * n * r * r * Math.Sin(2 * Math.PI / n);double exact = Math.PI * r * r; Console.WriteLine($"{n} segments, largest gap {gap * 1000:0.000} mm");Console.WriteLine($"polygon area {polygon:0.000000} m2, exact {exact:0.000000} m2");Console.WriteLine($"area error {(exact - polygon) / exact * 100:0.000}%");
Output
39 segments, largest gap 0.973 mm
polygon area 0.281522 m2, exact 0.282743 m2
area error 0.432%

Remember

  • Kernels store solids as B-reps: shells of faces bounded by loops of edges and vertices, each carrying exact geometry.
  • Euler–Poincaré, V − E + F − (L − F) = 2(S − G), and edge pairing check that a B-rep is valid.
  • B-spline basis functions (Cox–de Boor) give local control and always add up to 1; clamped knots make the curve start and end at the end points.
  • Weights make NURBS rational, so they can represent circles and conics exactly.
  • Tessellation turns exact faces into triangles to a chord tolerance; tighter tolerances cost many more triangles.
0 of 44 lessons completed