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.
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.
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.
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.
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.