Putting it together: a mini room-and-wall geometry engine
The capstone: wall centrelines and thicknesses in, mitred wall outlines, rooms, areas, volumes, openings and point-in-room queries out, using the maths of the whole course.
Step 1: offsetting a centreline loop
// The outer walls of an L-shaped house, as a counter-clockwise centreline loopvar centre = new List<V2> { new(0, 0), new(10, 0), new(10, 4), new(6, 4), new(6, 7), new(0, 7) };const double T = 0.3; var outside = Geo.Offset(centre, -T / 2);var inside = Geo.Offset(centre, T / 2);Console.WriteLine($"outside face: {string.Join(" ", outside)}");Console.WriteLine($"inside face: {string.Join(" ", inside)}"); double ring = Geo.Area(outside) - Geo.Area(inside);Console.WriteLine($"wall footprint {ring:0.000} m2; centreline length x thickness = {Geo.Perimeter(centre) * T:0.000} m2"); static class Geo{ // Move each edge d to its left, then meet each pair of neighbouring edges again public static List<V2> Offset(IList<V2> loop, double d) { int n = loop.Count; var result = new List<V2>(); for (int i = 0; i < n; i++) { V2 a0 = loop[(i + n - 1) % n], a1 = loop[i], b1 = loop[(i + 1) % n]; V2 u = a1 - a0, v = b1 - a1; // edge into the corner, edge out of it V2 pa = a0 + Left(u) * d, pb = a1 + Left(v) * d; // a point on each offset line double den = Cross(u, v); if (Math.Abs(den) < 1e-12) { result.Add(pb); continue; } // straight on: no corner result.Add(pa + u * (Cross(pb - pa, v) / den)); } return result; } public static V2 Left(V2 v) => new V2(-v.Y, v.X) * (1 / v.Length); public static double Cross(V2 a, V2 b) => a.X * b.Y - a.Y * b.X; public static double Area(IList<V2> p) { double a = 0; for (int i = 0; i < p.Count; i++) a += Cross(p[i], p[(i + 1) % p.Count]); return a / 2; } public static double Perimeter(IList<V2> p) => Enumerable.Range(0, p.Count).Sum(i => (p[(i + 1) % p.Count] - p[i]).Length);} 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)})";}outside face: (-0.15, -0.15) (10.15, -0.15) (10.15, 4.15) (6.15, 4.15) (6.15, 7.15) (-0.15, 7.15) inside face: (0.15, 0.15) (9.85, 0.15) (9.85, 3.85) (5.85, 3.85) (5.85, 6.85) (0.15, 6.85) wall footprint 10.200 m2; centreline length x thickness = 10.200 m2
Step 2: rooms, areas and the schedule
const double T = 0.3, Height = 3.0;var rooms = new (string Name, List<V2> Centreline)[]{ ("Living", new() { new(0, 0), new(4, 0), new(4, 7), new(0, 7) }), ("Kitchen", new() { new(4, 0), new(10, 0), new(10, 4), new(6, 4), new(6, 7), new(4, 7) }),}; Console.WriteLine($"{"room",-8} {"area",8} {"perimeter",10} {"volume",8} tag at");double total = 0;foreach (var (name, cl) in rooms){ var outline = Geo.Offset(cl, T / 2); double area = Geo.Area(outline); total += area; Console.WriteLine($"{name,-8} {area,8:0.000} {Geo.Perimeter(outline),10:0.000} {area * Height,8:0.000} {Geo.Centroid(outline)}");} var gross = Geo.Area(Geo.Offset(new List<V2> { new(0, 0), new(10, 0), new(10, 4), new(6, 4), new(6, 7), new(0, 7) }, -T / 2));Console.WriteLine($"rooms {total:0.000} m2 + walls {gross - total:0.000} m2 = gross {gross:0.000} m2"); static class Geo{ public static List<V2> Offset(IList<V2> loop, double d) { int n = loop.Count; var result = new List<V2>(); for (int i = 0; i < n; i++) { V2 a0 = loop[(i + n - 1) % n], a1 = loop[i], b1 = loop[(i + 1) % n]; V2 u = a1 - a0, v = b1 - a1; V2 pa = a0 + Left(u) * d, pb = a1 + Left(v) * d; double den = Cross(u, v); if (Math.Abs(den) < 1e-12) { result.Add(pb); continue; } result.Add(pa + u * (Cross(pb - pa, v) / den)); } return result; } public static V2 Left(V2 v) => new V2(-v.Y, v.X) * (1 / v.Length); public static double Cross(V2 a, V2 b) => a.X * b.Y - a.Y * b.X; public static double Area(IList<V2> p) { double a = 0; for (int i = 0; i < p.Count; i++) a += Cross(p[i], p[(i + 1) % p.Count]); return a / 2; } public static double Perimeter(IList<V2> p) => Enumerable.Range(0, p.Count).Sum(i => (p[(i + 1) % p.Count] - p[i]).Length); // Area-weighted centre of a polygon (the polygons module) public static V2 Centroid(IList<V2> p) { double a = Area(p), cx = 0, cy = 0; for (int i = 0; i < p.Count; i++) { V2 s = p[i], t = p[(i + 1) % p.Count]; double k = Cross(s, t); cx += (s.X + t.X) * k; cy += (s.Y + t.Y) * k; } return new V2(cx / (6 * a), cy / (6 * a)); }} 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)})";}room area perimeter volume tag at Living 24.790 20.800 74.370 (2, 3.5) Kitchen 26.190 24.800 78.570 (6.611, 2.652) rooms 50.980 m2 + walls 12.210 m2 = gross 63.190 m2
Step 3: extruding to a solid
var kitchen = new (double X, double Y)[] { (4.15, 0.15), (9.85, 0.15), (9.85, 3.85), (5.85, 3.85), (5.85, 6.85), (4.15, 6.85) };var mesh = Mesh.Prism(kitchen, 3.0); var edges = new Dictionary<(int, int), int>();foreach (var t in mesh.F) foreach (var e in new[] { (t.A, t.B), (t.B, t.C), (t.C, t.A) }) edges[e] = edges.GetValueOrDefault(e) + 1;bool closed = edges.Keys.All(e => edges.GetValueOrDefault((e.Item2, e.Item1)) == 1); double volume = mesh.F.Sum(t => V3.Dot(mesh.V[t.A], V3.Cross(mesh.V[t.B], mesh.V[t.C]))) / 6;double area = 0;for (int i = 0; i < kitchen.Length; i++){ var (x0, y0) = kitchen[i]; var (x1, y1) = kitchen[(i + 1) % kitchen.Length]; area += x0 * y1 - x1 * y0;}area /= 2; Console.WriteLine($"{mesh.V.Count} vertices, {mesh.F.Count} triangles, every edge paired: {closed}");Console.WriteLine($"mesh volume {volume:0.000} m3, area x height {area * 3:0.000} m3"); 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)})";}12 vertices, 20 triangles, every edge paired: True mesh volume 78.570 m3, area x height 78.570 m3
Step 4: the engine
var model = new Model(thickness: 0.3, height: 3.0);model.AddRoom("Living", new(0, 0), new(4, 0), new(4, 7), new(0, 7));model.AddRoom("Kitchen", new(4, 0), new(10, 0), new(10, 4), new(6, 4), new(6, 7), new(4, 7));model.AddOpening("door, internal", width: 0.9, height: 2.1, "Living", "Kitchen");model.AddOpening("front door", width: 1.0, height: 2.1, "Living");model.AddOpening("window", width: 1.5, height: 1.2, "Kitchen");model.AddOpening("window", width: 1.2, height: 1.2, "Living"); Console.WriteLine($"{"room",-8} {"floor m2",9} {"volume m3",10} {"wall finish m2",15}");foreach (var r in model.Rooms) Console.WriteLine($"{r.Name,-8} {r.Area,9:0.000} {r.Area * model.Height,10:0.000} {model.WallFinish(r),15:0.000}"); foreach (var (name, p) in new (string, V2)[] { ("sofa", new(2, 3)), ("fridge", new(8.5, 2)), ("table", new(5, 5.5)), ("bin", new(8, 6)) }) Console.WriteLine($"{name,-6} at {p} is in: {model.RoomAt(p)?.Name ?? "no room"}"); sealed record Room(string Name, List<V2> Outline, double Area, double Perimeter); sealed class Model{ readonly List<(double Area, string[] Rooms)> openings = new(); public List<Room> Rooms { get; } = new(); public double Thickness { get; } public double Height { get; } public Model(double thickness, double height) { Thickness = thickness; Height = height; } public void AddRoom(string name, params V2[] centreline) { var outline = Geo.Offset(centreline, Thickness / 2); Rooms.Add(new Room(name, outline, Geo.Area(outline), Geo.Perimeter(outline))); } // An opening takes area off the wall finish of every room it opens into public void AddOpening(string name, double width, double height, params string[] rooms) => openings.Add((width * height, rooms)); public double WallFinish(Room r) => r.Perimeter * Height - openings.Where(o => o.Rooms.Contains(r.Name)).Sum(o => o.Area); // Crossing test: count edges that a ray going right from p crosses public Room? RoomAt(V2 p) => Rooms.FirstOrDefault(r => { bool inside = false; var o = r.Outline; for (int i = 0, j = o.Count - 1; i < o.Count; j = i++) if ((o[i].Y > p.Y) != (o[j].Y > p.Y) && p.X < o[j].X + (p.Y - o[j].Y) * (o[i].X - o[j].X) / (o[i].Y - o[j].Y)) inside = !inside; return inside; });} static class Geo{ public static List<V2> Offset(IList<V2> loop, double d) { int n = loop.Count; var result = new List<V2>(); for (int i = 0; i < n; i++) { V2 a0 = loop[(i + n - 1) % n], a1 = loop[i], b1 = loop[(i + 1) % n]; V2 u = a1 - a0, v = b1 - a1; V2 pa = a0 + Left(u) * d, pb = a1 + Left(v) * d; double den = Cross(u, v); if (Math.Abs(den) < 1e-12) { result.Add(pb); continue; } result.Add(pa + u * (Cross(pb - pa, v) / den)); } return result; } public static V2 Left(V2 v) => new V2(-v.Y, v.X) * (1 / v.Length); public static double Cross(V2 a, V2 b) => a.X * b.Y - a.Y * b.X; public static double Area(IList<V2> p) { double a = 0; for (int i = 0; i < p.Count; i++) a += Cross(p[i], p[(i + 1) % p.Count]); return a / 2; } public static double Perimeter(IList<V2> p) => Enumerable.Range(0, p.Count).Sum(i => (p[(i + 1) % p.Count] - p[i]).Length);} 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)})";}room floor m2 volume m3 wall finish m2 Living 24.790 74.370 56.970 Kitchen 26.190 78.570 70.710 sofa at (2, 3) is in: Living fridge at (8.5, 2) is in: Kitchen table at (5, 5.5) is in: Kitchen bin at (8, 6) is in: no room
Where a real engine goes next
Room detection from walls. Here each room’s loop is given. Real tools find the loops themselves: split all wall centrelines at their intersections into a planar graph, then walk it always taking the next edge clockwise, which traces each enclosed face once (a half-edge face walk). Wall joins. T-junctions, walls of different thicknesses and walls meeting at shallow angles (where a mitre would shoot far out) need their own join rules; this is one of the most special-cased parts of every BIM tool. Robust booleans. Cutting openings into wall solids, and joining walls with floors, needs polygon and solid booleans built on exact predicates, as in the first lesson of this module. Spatial indexing. With thousands of rooms and elements, the point-in-room query goes through a grid or BVH before any polygon test. Levels and georeferencing. Each storey has its own elevation, and the whole model sits on the site through the shared-coordinates transform.
Show a hint
Show the solution
const double T = 0.3;var rooms = new (string Name, List<V2> Centreline)[]{ ("Living", new() { new(0, 0), new(3.5, 0), new(3.5, 4), new(0, 4) }), ("Bedroom", new() { new(0, 4), new(3.5, 4), new(3.5, 7), new(0, 7) }), ("Kitchen", new() { new(3.5, 0), new(10, 0), new(10, 4), new(6, 4), new(6, 7), new(3.5, 7) }),}; double total = 0;foreach (var (name, cl) in rooms){ var outline = Geo.Offset(cl, T / 2); double area = Geo.Area(outline); total += area; Console.WriteLine($"{name,-8} area {area,7:0.000} m2, perimeter {Geo.Perimeter(outline),6:0.000} m");} var outer = new List<V2> { new(0, 0), new(10, 0), new(10, 4), new(6, 4), new(6, 7), new(0, 7) };double gross = Geo.Area(Geo.Offset(outer, -T / 2));Console.WriteLine($"rooms {total:0.000} + walls {gross - total:0.000} = gross {gross:0.000} m2"); static class Geo{ public static List<V2> Offset(IList<V2> loop, double d) { int n = loop.Count; var result = new List<V2>(); for (int i = 0; i < n; i++) { V2 a0 = loop[(i + n - 1) % n], a1 = loop[i], b1 = loop[(i + 1) % n]; V2 u = a1 - a0, v = b1 - a1; V2 pa = a0 + Left(u) * d, pb = a1 + Left(v) * d; double den = Cross(u, v); if (Math.Abs(den) < 1e-12) { result.Add(pb); continue; } result.Add(pa + u * (Cross(pb - pa, v) / den)); } return result; } public static V2 Left(V2 v) => new V2(-v.Y, v.X) * (1 / v.Length); public static double Cross(V2 a, V2 b) => a.X * b.Y - a.Y * b.X; public static double Area(IList<V2> p) { double a = 0; for (int i = 0; i < p.Count; i++) a += Cross(p[i], p[(i + 1) % p.Count]); return a / 2; } public static double Perimeter(IList<V2> p) => Enumerable.Range(0, p.Count).Sum(i => (p[(i + 1) % p.Count] - p[i]).Length);} 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)})";}Living area 11.840 m2, perimeter 13.800 m Bedroom area 8.640 m2, perimeter 11.800 m Kitchen area 29.540 m2, perimeter 25.800 m rooms 50.020 + walls 13.170 = gross 63.190 m2
Remember
Wall faces are centrelines offset by half the thickness; neighbouring offset lines meet at mitred corners. For a simple counter-clockwise loop, outside area minus inside area equals centreline length × thickness: a cheap correctness check. Rooms are centreline loops pushed inwards; shoelace gives the area, the perimeter gives skirting, the centroid places the tag. Extruding to a closed mesh and comparing the tetrahedron volume with area × height catches winding errors. A small model of walls, rooms and openings answers schedules, finishes and point-in-room queries; real engines add room detection, join rules, robust booleans and spatial indexes.