Resources Numerical accuracy Calculation policy

Roof planes over an outline

Works out the sloping faces, ridges, eave and area of a roof from the building's outline, a pitch and an overhang, and the roof's height over any plan point.

How the precision is kept

The slope is tan(pitch) and every face is a plane rising from an eave line, so heights and face outlines are closed-form 64-bit results. Faces of a convex outline are found by clipping the eave polygon with half-planes; outlines with square corners are covered by the largest rectangles that fit. The sloped area is the eave polygon's plan area times √(1 + slope²).

Calculations under this policy

8 calculations in 1 source file. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.

model-roof.js BimCraft 0.8.1 64-bit 8 calculations Reviewed

Tests tools/bimcraft-test/model-test.mjs

Slope and outline kind

64-bit

Turns the pitch into a slope and chooses the method for the outline.

s = tan(pitch)

The slope is tan(pitch × π ÷ 180). The default pitch 26.565° stands for a 6 in 12 slope, whose angle is 26.56505…°, so the default slope is 0.499999 and not 0.5. The default overhang 304.8 mm is exactly one foot.

  • model-roof.js
  • lines 28 to 38
  • BimCraft 0.8.1
export function roofPlan(outline, { pitch = 26.565, overhang = 304.8, gables = [] } = {}) {
  const P = outline.map((q) => [q[0], q[1]]);
  if (P.length < 3) return { error: 'model.err.roofshape' };
  if (!(pitch > 1 && pitch < 80)) return { error: 'model.err.pitch' };
  const s = Math.tan(pitch * D2R);
  const convex = isConvex(P);
  if (convex) return convexRoof(P, s, overhang, new Set(gables));
  const frame = squareFrame(P);
  if (!frame) return { error: 'model.err.roofshape' };
  return squareRoof(P, s, overhang, frame);
}

Faces of a roof over a convex outline

64-bit

Each pitched edge's face, the eave, the ridges and the area.

keep p where (nᵢ − nⱼ) · p ≤ nᵢ · aᵢ − nⱼ · aⱼ; area = plan area × √(1 + s²)

Each face is the eave polygon clipped by one half-plane per other pitched edge, where its own plane is the lower. The sloped area multiplies the eave's plan area by √(1 + s²), which treats every part of the plan as pitched at the one slope.

  • model-roof.js
  • lines 61 to 86
  • BimCraft 0.8.1
function convexRoof(P, s, ov, gables) {
  const E = P.map((_, i) => edgeInfo(P, i));
  const pitched = E.map((e, i) => i).filter((i) => !gables.has(i));
  if (pitched.length < 2) return { error: 'model.err.gables' };
  const eave = offsetConvex(P, ov);
  // d_i measured from the wall line; the plane of edge i: z = s·d_i − 0 at the wall line, −ov·s at the eave
  const d = (i, p) => dot(sub(p, E[i].a), E[i].n);
  const faces = [];
  for (const i of pitched) {
    let poly = eave.slice();
    for (const j of pitched) {
      if (j === i) continue;
      // keep where s·d_i ≤ s·d_j  ⇔  (n_i − n_j)·p ≤ n_i·a_i − n_j·a_j
      const nx = E[i].n[0] - E[j].n[0], ny = E[i].n[1] - E[j].n[1], c = dot(E[i].n, E[i].a) - dot(E[j].n, E[j].a);
      poly = clipHalf(poly, nx, ny, c);
      if (poly.length < 3) break;
    }
    if (poly.length >= 3 && Math.abs(area(poly)) > 1) faces.push({ edge: i, poly, o: E[i].a, u: E[i].u, n: E[i].n, slope: s });
  }
  const plan = { kind: 'convex', faces, eave, s, ov };
  plan.gables = [...gables].filter((i) => i < E.length).map((i) => ({ edge: i, a: E[i].a, b: E[i].b }));
  plan.ridge = ridgeLines(faces);
  plan.projected = Math.abs(area(eave));
  plan.area = plan.projected * Math.sqrt(1 + s * s);
  return plan;
}

Clipping a polygon by a half-plane

64-bit

Keeps the part of a convex polygon on one side of a line.

t = f_a ÷ (f_a − f_b); point = a + t (b − a)

A corner is kept when its signed value is at most 1e-9 and an edge is cut only when its ends are more than 1e-9 on opposite sides, absolute values in millimeters times the size of the normal difference. The cut point divides by the difference of the two values, which then cannot be zero.

  • model-roof.js
  • lines 88 to 97
  • BimCraft 0.8.1
/** Keep the part of a convex polygon where nx·x + ny·y ≤ c. */
function clipHalf(poly, nx, ny, c) {
  const out = [], f = (p) => nx * p[0] + ny * p[1] - c;
  for (let i = 0; i < poly.length; i++) {
    const a = poly[i], b = poly[(i + 1) % poly.length], fa = f(a), fb = f(b);
    if (fa <= 1e-9) out.push(a);
    if ((fa < -1e-9 && fb > 1e-9) || (fa > 1e-9 && fb < -1e-9)) { const t = fa / (fa - fb); out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); }
  }
  return out;
}

Where two offset edges meet

64-bit

The corner of the eave where two neighboring edges, pushed out by the overhang, cross.

t = ((p₂ − p₁) × d₂) ÷ (d₁ × d₂)

The two-line solve divides by the cross product of the unit directions; below 1e-12 the lines count as parallel and the offset end point is used instead.

  • model-roof.js
  • lines 54 to 59
  • BimCraft 0.8.1
function meet(p1, d1, p2, d2) {
  const c = cross(d1, d2);
  if (Math.abs(c) < 1e-12) return null;
  const t = cross(sub(p2, p1), d2) / c;
  return [p1[0] + d1[0] * t, p1[1] + d1[1] * t];
}

Roof over an outline with square corners

64-bit

A hip roof for every largest rectangle that fits in the outline with its overhang.

m = min(w, h) ÷ 2; ridge from (x₀ + m, c_y) to (x₁ − m, c_y)

The outline is turned into its own two axes and its coordinates are rounded to 1e-6 mm to make a grid; whole-number prefix sums decide which blocks of cells are inside. Each rectangle's ridge is inset by half its shorter side.

  • model-roof.js
  • lines 108 to 151
  • BimCraft 0.8.1
function squareRoof(P, s, ov, { u, v }) {
  // into the outline's own axes
  const to = (p) => [dot(p, u), dot(p, v)], from = (q) => [u[0] * q[0] + v[0] * q[1], u[1] * q[0] + v[1] * q[1]];
  const Q = P.map(to);
  const eaveQ = offsetSquare(Q, ov);
  const xs = [...new Set(eaveQ.map((q) => r6(q[0])))].sort((a, b) => a - b), ys = [...new Set(eaveQ.map((q) => r6(q[1])))].sort((a, b) => a - b);
  const nx = xs.length - 1, ny = ys.length - 1;
  if (nx < 1 || ny < 1 || nx * ny > 2500) return { error: 'model.err.roofshape' };
  const inside = [];
  for (let i = 0; i < nx; i++) { inside.push([]); for (let j = 0; j < ny; j++) inside[i].push(inPoly((xs[i] + xs[i + 1]) / 2, (ys[j] + ys[j + 1]) / 2, eaveQ)); }
  // prefix sums: is a block of cells all inside?
  const S = Array.from({ length: nx + 1 }, () => new Int32Array(ny + 1));
  for (let i = 0; i < nx; i++) for (let j = 0; j < ny; j++) S[i + 1][j + 1] = S[i][j + 1] + S[i + 1][j] - S[i][j] + (inside[i][j] ? 0 : 1);
  const full = (i0, i1, j0, j1) => i0 >= 0 && j0 >= 0 && i1 <= nx && j1 <= ny && S[i1][j1] - S[i0][j1] - S[i1][j0] + S[i0][j0] === 0;
  const rects = [];
  for (let i0 = 0; i0 < nx; i0++) for (let i1 = i0 + 1; i1 <= nx; i1++) for (let j0 = 0; j0 < ny; j0++) for (let j1 = j0 + 1; j1 <= ny; j1++) {
    if (!full(i0, i1, j0, j1)) continue;
    if (full(i0 - 1, i1, j0, j1) || full(i0, i1 + 1, j0, j1) || full(i0, i1, j0 - 1, j1) || full(i0, i1, j0, j1 + 1)) continue;
    rects.push([xs[i0], ys[j0], xs[i1], ys[j1]]);
  }
  const faces = [];
  for (const [x0, y0, x1, y1] of rects) {
    // a hip roof over the rectangle: four faces meeting at its ridge (or a point for a square)
    const w = x1 - x0, h = y1 - y0, m = Math.min(w, h) / 2;
    const c = [(x0 + x1) / 2, (y0 + y1) / 2];
    const r = w >= h ? [[x0 + m, c[1]], [x1 - m, c[1]]] : [[c[0], y0 + m], [c[0], y1 - m]];
    const corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
    const quads = w >= h
      ? [[corners[0], corners[1], r[1], r[0]], [corners[1], corners[2], r[1]], [corners[2], corners[3], r[0], r[1]], [corners[3], corners[0], r[0]]]
      : [[corners[0], corners[1], r[0]], [corners[1], corners[2], r[1], r[0]], [corners[2], corners[3], r[1]], [corners[3], corners[0], r[0], r[1]]];
    quads.forEach((q, k) => {
      const a = corners[k], b = corners[(k + 1) % 4], L = Math.hypot(b[0] - a[0], b[1] - a[1]);
      const uu = [(b[0] - a[0]) / L, (b[1] - a[1]) / L];
      const poly = dedupe(q).map(from);
      if (poly.length >= 3) faces.push({ edge: -1, poly, o: from(a), u: from(uu), n: from([-uu[1], uu[0]]), slope: s, eaveAt: -ov * s, rect: true });
    });
  }
  const eave = eaveQ.map(from);
  const plan = { kind: 'square', faces, eave, s, ov, gables: [], rects: rects.length };
  plan.ridge = ridgeLines(faces);
  plan.projected = Math.abs(area(eave));
  plan.area = plan.projected * Math.sqrt(1 + s * s);
  return plan;
}

Height of the roof over a point

64-bit

The roof's underside height above the top of the walls at a plan point.

z = z₀ + s × (distance inside the face's eave edge)

The point is tested against each face's outline with an even-odd test that has no tolerance, and the highest plane among the faces that contain it is returned. On a face boundary the test can fall either way, where the two planes agree.

  • model-roof.js
  • lines 162 to 171
  • BimCraft 0.8.1
/** Height of the roof's underside over p (mm, from the top of the walls). */
export function heightAt(plan, p) {
  let best = -Infinity;
  for (const f of plan.faces) {
    if (!inPoly(p[0], p[1], f.poly)) continue;
    const z = (f.rect ? f.eaveAt : -plan.ov * plan.s) + f.slope * (dot(sub(p, f.o), f.n) + (f.rect ? 0 : plan.ov));
    if (z > best) best = z;
  }
  return best === -Infinity ? null : best;
}

Is the outline convex

64-bit

Chooses between the two roof methods.

convex when every corner turns the same way

The sign of each corner's cross product is compared; a corner whose cross product is under 1e-6 mm² in size, an absolute value, is skipped as straight.

  • model-roof.js
  • lines 192 to 201
  • BimCraft 0.8.1
function isConvex(P) {
  let sign = 0;
  for (let i = 0; i < P.length; i++) {
    const a = P[i], b = P[(i + 1) % P.length], c = P[(i + 2) % P.length];
    const z = cross(sub(b, a), sub(c, b));
    if (Math.abs(z) < 1e-6) continue;
    if (!sign) sign = Math.sign(z); else if (Math.sign(z) !== sign) return false;
  }
  return true;
}

Plan area of a polygon

64-bit

The shoelace area used for the eave and the faces.

A = ½ Σ (xᵢ yᵢ₊₁ − yᵢ xᵢ₊₁)

The sum uses the corners' own coordinates, so its terms grow with distance from the plan origin and cancel in the sum.

  • model-roof.js
  • line 202
  • BimCraft 0.8.1
export function area(p) { let s = 0; for (let i = 0; i < p.length; i++) { const a = p[i], b = p[(i + 1) % p.length]; s += a[0] * b[1] - a[1] * b[0]; } return s / 2; }

In detail

Method
z(p) = −overhang × s + min over pitched edges of s × dᵢ(p) for a convex outline; union of rectangle hip roofs for square-cornered outlines; other outlines are refused.
Number types
64-bit numbers in millimeters; an Int32Array of counts for the rectangle search.
Tolerances
Half-plane clipping classifies points with ±1e-9 mm. Offset lines are parallel when their cross product is under 1e-12. Corners with a cross product under 1e-6 mm² are skipped in the convexity test. Square-corner test: 1e-6 on unit directions. Coordinates rounded to 1e-6 mm to build the rectangle grid. Faces under 1 mm² are dropped. Pitch must be between 1° and 80°.
Checked by
tools/bimcraft-test/model-test.mjs calls roofPlan and heightAt in Node.
Limits
Only convex outlines and outlines whose edges all run along two directions at right angles. More than 2500 grid cells is refused.

Approach: 64-bit, direct

Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.

Every policy that takes this approach