Resources Numerical accuracy Calculation policy

Stair risers, treads and stairwell

Works out how many risers a stair needs and how high each is, lays out the treads, landings, winders or spiral wedges, checks them against the limits, and cuts the stairwell in the floor above.

How the precision is kept

The riser count is a ceiling of the height over the maximum riser, and each riser is the height divided by that count, in 64-bit millimeters. Tread corners are computed from the tread index by multiplication, not by adding treads one after another. The limits are inches times 25.4, which is exact, and the checks allow 1e-6 mm.

Calculations under this policy

12 calculations in 2 source files. 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.

res-stair.js BimCraft 0.8.1 64-bit 11 calculations Reviewed

Tests tools/bimcraft-test/res-test.mjs, tools/bimcraft-test/regs-test.mjs

Number of risers and riser height

64-bit

The number of risers a floor-to-floor height needs, each riser's height, and whether the riser, tread, width and handrail meet the limits.

n = max(2, ⌈H ÷ riser_max − 1e-9⌉); r = H ÷ n; treads = n − 1

The 1e-9 is subtracted from the ratio, not from a length, so a height that is a whole number of maximum risers does not gain a riser through rounding. The checks compare with a tolerance of 1e-6 mm. The limits are 7.75, 10, 36, 34 and 38 inches times 25.4.

  • res-stair.js
  • lines 60 to 68
  • BimCraft 0.8.1
const n = Math.max(2, Math.ceil(H / s.riserMax - 1e-9)), r = H / n, T = n - 1;
const a0 = (s.angle || 0) * Math.PI / 180, u = [Math.cos(a0), Math.sin(a0)], nl = [-u[1], u[0]];
const tSide = s.turn === 'right' ? -1 : 1;   // the turn's side, in the stair's own t (left = +t)
const P = (si, ti) => [s.start[0] + u[0] * si + nl[0] * ti, s.start[1] + u[1] * si + nl[1] * ti];
const rect = (s0, s1, t0, t1) => [P(s0, t0), P(s1, t0), P(s1, t1), P(s0, t1)];
const ccw = (poly) => (area(poly) < 0 ? poly.slice().reverse() : poly);
const pieces = [], sides = [], walk = [];
const flight = (treads, meta) => pieces.push({ kind: 'flight', treads, ...meta });
const checks = { riser: r <= s.riserMax + 1e-6 && (LIM.riserMax === null || r <= LIM.riserMax + 1e-6), tread: LIM.treadMin === null || d >= LIM.treadMin - 1e-6, width: LIM.widthMin === null || w >= LIM.widthMin - 1e-6, rail: s.railings === 'none' || ((LIM.railMin === null || s.railHeight >= LIM.railMin - 1e-6) && (LIM.railMax === null || s.railHeight <= LIM.railMax + 1e-6)) };

Treads of a straight flight

64-bit

The plan rectangle and the height of each tread of a straight stair.

tread i spans i × d to (i + 1) × d; z = (i + 1) × r

Each tread's edges and height are a product of its index with the tread depth or the riser, so rounding does not build up along the flight. The top tread's height is (n − 1) × r, one riser below the floor above.

  • res-stair.js
  • lines 74 to 80
  • BimCraft 0.8.1
if (s.kind === 'straight') {
  const treads = [];
  for (let i = 0; i < T; i++) treads.push({ poly: ccw(rect(i * d, (i + 1) * d, -w / 2, w / 2)), z: (i + 1) * r, i });
  flight(treads, { risers: n, n: T });
  if (railL) sides.push(nosingSide(0, (T - 1) * d + d, w / 2 - ins, r, T * r + r));
  if (railR) sides.push(nosingSide(0, (T - 1) * d + d, -w / 2 + ins, r, T * r + r));
  walk.push(P(-0, 0), P(T * d, 0));

Treads before and after a turn

64-bit

Divides the treads of an L, U or winder stair between the flight before the turn and the one after.

before = max(1, min(T − turn − 1, round((T − turn) × split))); after = T − before − turn

Math.round of a product decides the count, so a split that puts the product on a half rounds up. A stair with no tread left after the turn is refused.

  • res-stair.js
  • lines 82 to 86
  • BimCraft 0.8.1
const turnTreads = s.kind === 'winder' ? 3 : 1;
const before = Math.max(1, Math.min(T - turnTreads - 1, Math.round((T - turnTreads) * s.split)));
const after = T - before - turnTreads;
if (after < 1) return { error: 'res.err.stairshort', n, riser: r, tread: d };
const S0 = before * d;

Landing and second flight of a U stair

64-bit

The half landing of a U stair and the treads of the flight that comes back beside the first.

landing width = 2 w + gap; flight 2 center at ±(w + gap); z = z_landing + (j + 1) × r

Positions are sums and products of the width, the gap, the tread depth and the tread index; no tolerance is used.

  • res-stair.js
  • lines 93 to 100
  • BimCraft 0.8.1
const gap = s.gap, wide = 2 * w + gap;
const t0 = Math.min(tOut, tOut + tSide * wide), t1 = Math.max(tOut, tOut + tSide * wide);
const zL = (before + 1) * r;
pieces.push({ kind: 'landing', poly: ccw(rect(S0, S0 + w, t0, t1)), z: zL });
// flight 2 back down the other side: centre at t = tSide·(w + gap)
const c2 = tSide * (w + gap), f2 = [];
for (let j = 0; j < after; j++) f2.push({ poly: ccw(rect(S0 - (j + 1) * d, S0 - j * d, c2 - w / 2, c2 + w / 2)), z: zL + (j + 1) * r, i: before + 1 + j });
flight(f2, { risers: after + 1, n: after });

Winder treads

64-bit

Fans three winder treads from the inside corner of a quarter turn.

outer path of length 2 w cut at 2 w k ÷ 3, k = 0 … 3

The three treads share the inside corner as one vertex, so each has no depth there. The cut points on the outer path are found by linear interpolation along its two legs.

  • res-stair.js
  • lines 118 to 126
  • BimCraft 0.8.1
const path = [P(S0, tOut), P(S0 + w, tOut), P(S0 + w, tIn)];
const at = (len) => (len <= w ? [path[0][0] + (path[1][0] - path[0][0]) * len / w, path[0][1] + (path[1][1] - path[0][1]) * len / w] : [path[1][0] + (path[2][0] - path[1][0]) * (len - w) / w, path[1][1] + (path[2][1] - path[1][1]) * (len - w) / w]);
const wt = [];
for (let k = 0; k < 3; k++) {
  const l0 = 2 * w * k / 3, l1 = 2 * w * (k + 1) / 3;
  const poly = [c, at(l0), ...(l0 < w && l1 > w ? [path[1]] : []), at(l1)];
  wt.push({ poly: ccw(poly), z: zT(k), i: before + k });
}
pieces.push({ kind: 'winders', treads: wt });

Spiral stair

64-bit

The wedge treads of a spiral stair round its post, the handrail, the walk line and the outline.

Δθ = tread ÷ (r_post + 12 in); tread i from θ₀ ± i Δθ; arcs chorded at ≤ 6°

The tread angle is the tread depth divided by the walk line radius, and each tread's start angle is a product with its index. Inner and outer edges are polygons with chords of at most 6 degrees, so a tread's polygon area is slightly less than the true wedge. The checks use the spiral limits with a 1e-6 mm tolerance.

  • res-stair.js
  • lines 157 to 183
  • BimCraft 0.8.1
/** A spiral stair's treads (wedges round the post), its post, the outside handrail, the walk line, the outline (0.6 round 2). */
function spiralLayout(s, { n, r, T, d, w, a0, ins, pieces, sides, walk, flight }) {
  const rp = (s.post > 0 ? s.post : SPIRAL_DEFAULTS.post) / 2, Ro = rp + w, walkR = rp + SPIRAL_LIMITS.walk;
  const dA = d / walkR, dir = s.turn === 'right' ? -1 : 1, c = s.start;
  const pt = (rad, a) => [c[0] + rad * Math.cos(a), c[1] + rad * Math.sin(a)];
  const arc = (rad, from, to) => { const k = Math.max(2, Math.ceil(Math.abs(to - from) / (6 * Math.PI / 180))); const out = []; for (let j = 0; j <= k; j++) out.push(pt(rad, from + (to - from) * j / k)); return out; };
  const ccw = (poly) => (area(poly) < 0 ? poly.slice().reverse() : poly);
  const treads = [];
  for (let i = 0; i < T; i++) {
    const b0 = a0 + dir * i * dA, b1 = b0 + dir * dA;
    treads.push({ poly: ccw([...arc(rp, b0, b1), ...arc(Ro, b1, b0)]), z: (i + 1) * r, i });
  }
  flight(treads, { risers: n, n: T, spiral: true });
  pieces.push({ kind: 'post', c, r: rp, z0: 0, z1: s.height + (s.railings === 'none' ? 0 : s.railHeight) });
  // the handrail on the outside, rising with the nosings (two points a tread, so it reads as a curve)
  if (s.railings !== 'none') {
    const side = [];
    for (let j = 0; j <= 2 * T; j++) side.push([...pt(Ro - ins, a0 + dir * j * dA / 2), r + j * r / 2]);
    sides.push(side);
  }
  for (let j = 0; j <= T; j++) walk.push(pt(walkR, a0 + dir * j * dA));
  const sweep = T * dA;
  const circle = (rad) => arc(rad, 0, 2 * Math.PI).slice(0, -1);
  const outline = [sweep >= 2 * Math.PI - 1e-6 ? ccw(circle(Ro)) : ccw([...arc(rp, a0, a0 + dir * sweep), ...arc(Ro, a0 + dir * sweep, a0)]), ccw(circle(rp))];
  const checks = { riser: r <= s.riserMax + 1e-6 && r <= SPIRAL_LIMITS.riserMax + 1e-6, tread: d >= SPIRAL_LIMITS.treadMin - 1e-6, width: w >= SPIRAL_LIMITS.widthMin - 1e-6, rail: s.railings === 'none' || (s.railHeight >= SPIRAL_LIMITS.railMin - 1e-6 && s.railHeight <= SPIRAL_LIMITS.railMax + 1e-6) };
  return { n, riser: r, tread: d, treads: T, run: null, pieces, outline, sides, walk, checks, spec: s, spiral: { post: rp * 2, outer: Ro, walk: walkR, angle: dA * 180 / Math.PI, sweep: sweep * 180 / Math.PI } };
}

Floor-to-floor height of a stair

64-bit

The height a stair climbs: from its level to the level above, else its own fixed height.

H = elevation_top − elevation_bottom when that is more than 100 mm

A difference of two level elevations. When there is no level above, the stair's stored height is used, else the level's height, else 2743 mm.

  • res-stair.js
  • lines 209 to 215
  • BimCraft 0.8.1
/** Floor to floor: the bottom level to the level above it (else the stair's own height). */
export function stairHeight(w, st, levels = readLevels(w)) {
  const i = levels.findIndex((l) => l.id === st.storey);
  const top = st.topLevel ? levels.find((l) => l.guid === st.topLevel) : levels[i + 1];
  if (i >= 0 && top && top.elevation > levels[i].elevation + 100) return { height: top.elevation - levels[i].elevation, top };
  return { height: st.fixedHeight > 0 ? st.fixedHeight : (levels[i]?.height || 2743), top: null };
}

Stairwell opening

64-bit

Finds the treads and landings that have less than the required headroom under the floor above; their outline is cut out of that floor.

cut where z_tread + headroom > H + offset − thickness

A strict comparison of 64-bit sums with no tolerance. The headroom is the active limit, else 80 inches times 25.4. The treads that fail are joined into loops with a 0.5 mm snap.

  • res-stair.js
  • lines 313 to 314
  • BimCraft 0.8.1
const under = height + f.offset - f.thickness;   // the floor's underside over the bottom level
for (const pc of L.pieces) for (const t of (pc.kind === 'landing' ? [{ poly: pc.poly, z: pc.z }] : pc.treads || [])) if (t.z + (LIM.headroom ?? STAIR_LIMITS.headroom) > under) polys.push(t.poly);   // a spiral's post: none

Center post of a spiral stair

64-bit

Builds the spiral stair's round post as a 24-sided column.

vertices at r × (cos 2πj ÷ 24, sin 2πj ÷ 24)

The post is a 24-sided polygon with its corners on the circle, so its cross-section area is less than the circle's.

  • res-stair.js
  • lines 267 to 268
  • BimCraft 0.8.1
const ring = []; for (let j = 0; j < 24; j++) { const a = j / 24 * 2 * Math.PI; ring.push([pc.c[0] + pc.r * Math.cos(a), pc.c[1] + pc.r * Math.sin(a)]); }
const it = w.extrude(w.profile(ring), pc.z0, pc.z1 - pc.z0);

Riser and tread written to the model

64-bit

Writes the number of risers and treads, the riser height, the tread length and the required headroom.

RiserHeight, TreadLength = round(value ÷ unit, 6)

Lengths are rounded to 6 decimals of the file's length unit. The stored RequiredHeadroom is always the built-in 80 inches, not the active regulation pack's value.

  • res-stair.js
  • line 329
  • BimCraft 0.8.1
w.upsertPset(st.id, 'Pset_StairCommon', [['NumberOfRiser', 'IFCCOUNTMEASURE', L.n], ['NumberOfTreads', 'IFCCOUNTMEASURE', L.treads], ['RiserHeight', 'IFCPOSITIVELENGTHMEASURE', round(w.u(L.riser))], ['TreadLength', 'IFCPOSITIVELENGTHMEASURE', round(w.u(L.tread))], ['RequiredHeadroom', 'IFCPOSITIVELENGTHMEASURE', round(w.u(STAIR_LIMITS.headroom))]]);

Stair settings written to the model

64-bit

Writes the stair's start point, angle, width, riser limit, tread, split and rail height.

lengths = round(value ÷ unit, 6); Angle = round(angle, 6); Split = round(split, 4)

The stair is rebuilt from these stored values. Lengths keep 6 decimals of the file's length unit, the angle 6 decimals of a degree and the split 4 decimals.

  • res-stair.js
  • lines 337 to 342
  • BimCraft 0.8.1
function rowsOf(w, s) {
  const L = (k, v) => [k, 'IFCLENGTHMEASURE', round(w.u(v || 0))];
  return [['Kind', 'IFCLABEL', s.kind], ['Turn', 'IFCLABEL', s.turn], L('StartX', s.start[0]), L('StartY', s.start[1]), ['Angle', 'IFCREAL', round(s.angle || 0, 6)], L('Width', s.width), L('Height', s.fixedHeight || 0),
    L('RiserMax', s.riserMax), L('Tread', s.tread), ['Split', 'IFCREAL', round(s.split, 4)], L('Gap', s.gap), ['Railings', 'IFCLABEL', s.railings], L('RailHeight', s.railHeight), ['Well', 'IFCBOOLEAN', !!s.well], ['TopLevel', 'IFCIDENTIFIER', s.topLevel || ''],
    ...(s.kind === 'spiral' ? [L('Post', s.post || SPIRAL_DEFAULTS.post)] : [])];
}
stair-sketch.js BimCraft 0.8.1 64-bit 1 calculation Reviewed

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

Stair from a sketched walking line

64-bit

Turns a walking line of one, two or three segments into a straight, L or U stair: its direction, width, gap, split and tread depth.

n = max(2, ⌈H ÷ riser_max − 1e-9⌉); tread = max(50, line length ÷ (n − 1)); split = first leg ÷ total

The direction is Math.atan2 in degrees and the lengths are Math.hypot. The landing's width and length are taken from its bounding box along the x and y axes, so a landing drawn turned from those axes reads larger than it is. Points closer than 1 mm to the one before are dropped.

  • stair-sketch.js
  • lines 22 to 60
  • BimCraft 0.8.1
export function stairFromSketch(points, { landing = null, height, width = null, riserMax = STAIR_DEFAULTS.riserMax } = {}) {
  const P = (points || []).filter((p, i, a) => i === 0 || dist(p, a[i - 1]) > 1);
  if (P.length < 2 || P.length > 4) fail('model.err.stairsketch.lines');
  if (!(height > 0)) fail('model.err.stairsketch.height');
  const notes = [];
  let w = width || STAIR_DEFAULTS.width, gap = STAIR_DEFAULTS.gap;
  if (landing && landing.length >= 3) {
    const xs = landing.map((p) => p[0]), ys = landing.map((p) => p[1]);
    const a = Math.max(...xs) - Math.min(...xs), b = Math.max(...ys) - Math.min(...ys), lo = Math.min(a, b), hi = Math.max(a, b);
    if (lo < 300) fail('model.err.stairsketch.landing');
    w = lo;
    if (P.length === 4) gap = Math.max(0, hi - 2 * lo);
    notes.push('landing');
  }
  const segs = [];
  for (let i = 0; i + 1 < P.length; i++) segs.push({ a: P[i], b: P[i + 1], len: dist(P[i], P[i + 1]), dir: ang(P[i], P[i + 1]) });
  const n = Math.max(2, Math.ceil(height / riserMax - 1e-9)), T = n - 1;
  const spec = { start: [P[0][0], P[0][1]], angle: segs[0].dir, width: w, height };
  let total = segs[0].len;
  if (P.length === 2) spec.kind = 'straight';
  else if (P.length === 3) {
    const d = Math.abs(between(segs[0].dir, segs[1].dir));
    if (d < 60 || d > 120) fail('model.err.stairsketch.angle');
    spec.kind = 'l'; spec.turn = turnOf(P[0], P[1], P[2]) > 0 ? 'left' : 'right';
    total = segs[0].len + segs[1].len; spec.split = segs[0].len / total;
  } else {
    const d1 = Math.abs(between(segs[0].dir, segs[2].dir + 180)), d2 = Math.abs(between(segs[0].dir, segs[1].dir));
    if (d1 > 25 || d2 < 60 || d2 > 120) fail('model.err.stairsketch.u');
    spec.kind = 'u'; spec.turn = turnOf(P[0], P[1], P[2]) > 0 ? 'left' : 'right';
    gap = landing ? gap : Math.max(0, segs[1].len - w);
    spec.gap = gap;
    total = segs[0].len + segs[2].len; spec.split = segs[0].len / total;
  }
  const tread = Math.max(50, total / T);
  spec.tread = tread;
  const L = stairLayout({ ...STAIR_DEFAULTS, ...spec });
  if (L.error) fail(L.error);
  return { spec, tread, notes, layout: L };
}

In detail

Method
n = max(2, ⌈H ÷ riser_max − 1e-9⌉), riser = H ÷ n, treads = n − 1. A landing counts as one tread, winders as three. A spiral tread turns by tread ÷ walk radius, with the walk line 12 inches out from the post.
Number types
64-bit numbers. Angles are converted with π ÷ 180.
Tolerances
1e-9 on the height to riser ratio before the ceiling. 1e-6 mm on each check against a limit. The stair outline joins treads with a 0.5 mm snap. A sketched L must turn between 60 and 120 degrees.
Limits
Spiral treads and the outline are polygons with chords of at most 6 degrees, and the center post is a 24-sided polygon. The change detection key rounds numbers to 0.01 before hashing.

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