Works out the roof over a building outline: the plan of every roof face, the ridge, hip and valley lines with their heights, the eave and fascia lines, and the roof's area and line lengths.
How the precision is kept
The roof is a weighted straight skeleton: the eave polygon shrinks as height grows, and between events every corner moves in a straight line, so each step is a closed-form solve in 64-bit with no time stepping. The solver's tolerance is 1e-7 of the outline's size and every merge and split test is a multiple of it. The roof plan built on top of it classifies lines and edges with fixed tolerances of 0.5 mm and 1 mm.
Calculations under this policy
23 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.
n·p − c is the distance of the point from the face's eave line. Both terms are of the size of the coordinates, so for a roof far from the origin their difference loses the leading digits they share.
The direction, inward normal and line offset of each eave edge.
u = (b − a) ÷ |b − a|; n = (−u_y, u_x); c = n·a
The direction is the edge vector divided by its Math.hypot length. Edges of length eps or less were dropped before this, so the division is safe.
addons/trusscraft/lib/bim/res-skeleton.js
lines 53 to 54
CadCraft 0.8.2
const a = pts[i], b = pts[(i + 1) % pts.length], L = hyp(a, b), u = [(b[0] - a[0]) / L, (b[1] - a[1]) / L], n = [-u[1], u[0]];
E.push({ u, n, c: dot(n, a), speed: sp[i].speed, start: sp[i].start, orig: ix[i] });
Corner velocity
64-bit
How a corner of the shrinking polygon moves per unit of height.
n_a·v = v_a; n_b·v = v_b; determinant = n_a × n_b
A corner travels where its two edges' lines meet: a 2 × 2 solve by Cramer's rule. When the determinant of the two unit normals is 1e-9 or less the edges are treated as parallel, and the corner moves with their mean speed along the shared normal, or stays still when the normals oppose.
Finds the height at which the polygon next changes: an edge starts to move, an edge shrinks to nothing, or a reflex corner reaches another edge.
collapse: t = T + length ÷ closing rate; split: t = T + distance ÷ approach rate
Closing and approach rates above −1e-12 are ignored, and an edge start counts only when later than T by 1e-9 × scale. A split is accepted when the hit lies within eps of the edge's two ends. Each time is one division; the earliest wins.
addons/trusscraft/lib/bim/res-skeleton.js
lines 105 to 130
CadCraft 0.8.2
let tn = Infinity;
for (const e of E) if (e.speed > 0 && e.start > T + 1e-9 * scale && e.start < tn) tn = e.start;
for (const cyc of cycles) {
const m = cyc.length;
for (let i = 0; i < m; i++) {
const a = cyc[i], b = cyc[(i + 1) % m], u = E[a.e].u;
const len = u[0] * (b.x - a.x) + u[1] * (b.y - a.y), rate = u[0] * (b.v[0] - a.v[0]) + u[1] * (b.v[1] - a.v[1]);
if (rate < -1e-12) { const t = T + Math.max(0, len) / -rate; if (t < tn) tn = t; }
}
for (let r = 0; r < m; r++) {
const R = cyc[r], A = E[prevE(cyc, r)], B = E[R.e];
if (cross(A.u, B.u) > -1e-9) continue; // only reflex corners reach other edges
for (let j = 0; j < m; j++) {
const q = cyc[j], q1 = cyc[(j + 1) % m];
if (j === r || (j + 1) % m === r) continue;
const e = E[q.e], ve = moving(q.e, T) ? e.speed : 0;
const d0 = e.n[0] * (R.x - q.x) + e.n[1] * (R.y - q.y), rate = e.n[0] * R.v[0] + e.n[1] * R.v[1] - ve;
if (!(d0 > eps && rate < -1e-12)) continue;
const th = T + d0 / -rate;
if (th >= tn) continue;
const dt = th - T, pr = [R.x + R.v[0] * dt, R.y + R.v[1] * dt], pq = [q.x + q.v[0] * dt, q.y + q.v[1] * dt], pq1 = [q1.x + q1.v[0] * dt, q1.y + q1.v[1] * dt];
const s = dot(e.u, sub(pr, pq)), L = dot(e.u, sub(pq1, pq));
if (s > -eps && s < L + eps) tn = th;
}
}
}
Advance to the event
64-bit
Moves every corner to the event height and records its path as a roof line and a face boundary.
p′ = p + v × (t_next − T)
Motion is linear between events, so the move is one multiplication and addition per coordinate with no stepping error. Paths of eps or less are not recorded.
addons/trusscraft/lib/bim/res-skeleton.js
lines 133 to 142
CadCraft 0.8.2
const dt = tn - T;
for (const cyc of cycles) for (let i = 0; i < cyc.length; i++) {
const q = cyc[i], a = [q.x, q.y], b = [q.x + q.v[0] * dt, q.y + q.v[1] * dt], pe = prevE(cyc, i);
if (dt > 0 && hyp(a, b) > eps) {
if (moving(pe, T)) piece(pe, a, T, b, tn);
if (moving(q.e, T)) piece(q.e, a, T, b, tn);
if (moving(pe, T) || moving(q.e, T)) lines.push({ a: [a[0], a[1], T], b: [b[0], b[1], tn], f: [pe, q.e] });
}
q.x = b[0]; q.y = b[1];
}
Clean-up after an event
64-bit
Merges corners that met, removes edges folded onto each other as ridges, splits a loop a corner has cut, and closes loops with no area left.
Corners closer than 4·eps merge at their midpoint. A spike is two edges whose directions' dot product is within 1e-9 of −1 and whose offset is within 4·eps. A corner within 4·eps of another edge splits the loop, and a loop whose area is at most eps × scale is closed.
addons/trusscraft/lib/bim/res-skeleton.js
lines 165 to 242
CadCraft 0.8.2
function clean(cs, T1) {
const out = [];
const work = cs.slice();
let n = 0;
while (work.length && n++ < 10000) {
const cyc = work.pop();
let again = true;
while (again) {
again = false;
if (cyc.length < 3) break;
// 1. corners closer than eps merge (the edge between them is gone)
for (let i = 0; i < cyc.length && cyc.length >= 3; i++) {
const a = cyc[i], b = cyc[(i + 1) % cyc.length];
if (hyp([a.x, a.y], [b.x, b.y]) <= eps * 4) {
const j = (i + 1) % cyc.length;
a.x = (a.x + b.x) / 2; a.y = (a.y + b.y) / 2; a.e = b.e;
cyc.splice(j, 1); again = true; break;
}
}
if (again || cyc.length < 3) continue;
// 2. a spike: two edges folded onto one line meet at a corner — the part they share is a ridge
for (let i = 0; i < cyc.length; i++) {
const m = cyc.length, pI = (i + m - 1) % m, nI = (i + 1) % m;
const P = cyc[pI], N = cyc[i], Q = cyc[nI], A = P.e, B = N.e;
if (dot(E[A].u, E[B].u) > -1 + 1e-9) continue;
const dQ = Math.abs(E[A].n[0] * (Q.x - P.x) + E[A].n[1] * (Q.y - P.y));
if (dQ > eps * 4) continue;
const lPN = hyp([P.x, P.y], [N.x, N.y]), lNQ = hyp([N.x, N.y], [Q.x, Q.y]);
const X = lPN <= lNQ ? [P.x, P.y] : [Q.x, Q.y];
piece(A, X, T1, [N.x, N.y], T1); piece(B, [N.x, N.y], T1, X, T1);
if (hyp(X, [N.x, N.y]) > eps) lines.push({ a: [X[0], X[1], T1], b: [N.x, N.y, T1], f: [A, B] });
if (lPN < lNQ - eps * 4) { P.e = B; cyc.splice(i, 1); }
else if (lNQ < lPN - eps * 4) { cyc.splice(i, 1); }
else {
P.x = (P.x + Q.x) / 2; P.y = (P.y + Q.y) / 2; P.e = Q.e;
const qi = cyc.indexOf(Q); cyc.splice(Math.max(i, qi), 1); cyc.splice(Math.min(i, qi), 1);
}
again = true; break;
}
if (again || cyc.length < 3) continue;
// 3. a corner on another edge (or on another corner): the loop splits in two
outer: for (let r = 0; r < cyc.length; r++) {
const R = cyc[r], m = cyc.length;
for (let j = 0; j < m; j++) {
if (j === r || (j + 1) % m === r) continue;
const q = cyc[j], q1 = cyc[(j + 1) % m], e = E[q.e];
const d = e.n[0] * (R.x - q.x) + e.n[1] * (R.y - q.y);
if (Math.abs(d) > eps * 4) continue;
const s = e.u[0] * (R.x - q.x) + e.u[1] * (R.y - q.y), L = e.u[0] * (q1.x - q.x) + e.u[1] * (q1.y - q.y);
if (s < -eps * 4 || s > L + eps * 4) continue;
let c1, c2;
if (s <= eps * 4 || s >= L - eps * 4) {
// two corners meet (a pinch)
const k = s <= eps * 4 ? j : (j + 1) % m;
if (k === r || (k + 1) % m === r || (r + 1) % m === k) continue;
const K = cyc[k];
c1 = []; for (let x = r; x !== k; x = (x + 1) % m) c1.push(cyc[x]);
c2 = []; for (let x = k; x !== r; x = (x + 1) % m) c2.push(cyc[x]);
const R1 = { ...R }, K2 = { ...K, x: R.x, y: R.y };
c1[0] = R1; c2[0] = K2;
} else {
const R1 = { ...R }, R2 = { ...R, e: q.e };
c1 = [R1]; for (let x = (r + 1) % m; x !== (j + 1) % m; x = (x + 1) % m) c1.push(cyc[x]);
c2 = [R2]; for (let x = (j + 1) % m; x !== r; x = (x + 1) % m) c2.push(cyc[x]);
}
work.push(c1, c2);
cyc.length = 0;
break outer;
}
}
if (!cyc.length) break;
}
if (!cyc.length) continue;
if (cyc.length < 3 || Math.abs(polyArea(cyc.map((q) => [q.x, q.y]))) <= eps * scale) { if (cyc.length >= 2) closeLoop(cyc, T1); continue; }
out.push(cyc);
}
return out;
}
Shared part of two folded edges
64-bit
The stretch two opposite edges on one line have in common, which becomes a ridge.
The second segment must lie within 4·eps of the first one's line. The shared stretch runs between the clamped projections of its ends; a stretch of eps or less counts as none.
Joins the boundary pieces of a face into closed loops.
Piece ends within the tolerance (20·eps from the caller) in both x and y are one point, and the first point found keeps its coordinates. Where several pieces continue, one is chosen by its turn angle from atan2 of the cross and dot products.
addons/trusscraft/lib/bim/res-skeleton.js
lines 270 to 312
CadCraft 0.8.2
/** Segments [ax, ay, az, bx, by, bz] → closed loops of plan points (ends within tol are one point). */
function chain(segs, tol) {
const P = [];
const idOf = (x, y) => {
for (let i = 0; i < P.length; i++) if (Math.abs(P[i][0] - x) <= tol && Math.abs(P[i][1] - y) <= tol) return i;
P.push([x, y]); return P.length - 1;
};
const adj = new Map(), used = new Set();
const E = [];
for (const s of segs) {
const a = idOf(s[0], s[1]), b = idOf(s[3], s[4]);
if (a === b) continue;
const k = a < b ? a + ',' + b : b + ',' + a;
if (E.some((x) => x.k === k)) continue; // the same piece twice
const id = E.length;
E.push({ a, b, k });
for (const v of [a, b]) { if (!adj.has(v)) adj.set(v, []); adj.get(v).push(id); }
}
const loops = [];
for (let s = 0; s < E.length; s++) {
if (used.has(s)) continue;
used.add(s);
const start = E[s].a, loop = [start];
let cur = E[s].b, prev = start;
for (let n = 0; n < E.length + 2 && cur !== start; n++) {
loop.push(cur);
const opts = (adj.get(cur) || []).filter((id) => !used.has(id));
if (!opts.length) break;
// where several go on, take the one turning least away from straight on (keeps loops from crossing)
let best = opts[0];
if (opts.length > 1) {
const din = sub(P[cur], P[prev]);
let bestAng = Infinity;
for (const id of opts) { const nx = E[id].a === cur ? E[id].b : E[id].a, dout = sub(P[nx], P[cur]); const ang = Math.atan2(cross(din, dout), dot(din, dout)); if (-ang < bestAng) { bestAng = -ang; best = id; } }
}
used.add(best);
const nx = E[best].a === cur ? E[best].b : E[best].a;
prev = cur; cur = nx;
}
if (cur === start && loop.length >= 3) loops.push(loop.map((i) => P[i].slice()));
}
return loops;
}
Collinear points removed from a face
64-bit
Drops points of a face polygon that lie on the line between their neighbors.
removed when |u × v| ÷ max(|u|, |v|) ≤ tolerance
A point is removed when a neighbor is within the tolerance or when its offset from the line through its neighbors is within it. The caller passes 4·eps.
addons/trusscraft/lib/bim/res-skeleton.js
lines 313 to 325
CadCraft 0.8.2
/** Collinear points and back-and-forth spikes out. */
function tidy(poly, eps) {
let p = poly.slice();
for (let changed = true; changed && p.length >= 3;) {
changed = false;
for (let i = 0; i < p.length; i++) {
const a = p[(i + p.length - 1) % p.length], b = p[i], c = p[(i + 1) % p.length];
const u = sub(b, a), v = sub(c, b), lu = Math.hypot(...u), lv = Math.hypot(...v);
if (lu <= eps || lv <= eps || Math.abs(cross(u, v)) / Math.max(lu, lv) <= eps) { p.splice(i, 1); changed = true; break; }
}
}
return p;
}
Roof lines joined end to end
64-bit
Joins the pieces of one ridge, hip or valley into a single line.
joined when ends meet within tolerance and cos θ ≥ 1 − 1e-9
The ends must meet within the tolerance in 3D (20·eps from the caller). The direction test is a cosine of at least 1 − 1e-9, which allows directions up to about 4.5e-5 radian apart.
addons/trusscraft/lib/bim/res-skeleton.js
lines 326 to 352
CadCraft 0.8.2
/** Collinear, end-to-end lines between the same two faces joined into one. */
function mergeLines(L, tol) {
const key = (l) => (l.f[0] < l.f[1] ? l.f[0] + ',' + l.f[1] : l.f[1] + ',' + l.f[0]);
const groups = new Map();
for (const l of L) { if (hyp(l.a, l.b) <= tol && Math.abs(l.a[2] - l.b[2]) <= tol) continue; const k = key(l); if (!groups.has(k)) groups.set(k, []); groups.get(k).push({ ...l }); }
const out = [];
for (const list of groups.values()) {
for (let changed = true; changed;) {
changed = false;
for (let i = 0; i < list.length && !changed; i++) for (let j = i + 1; j < list.length && !changed; j++) {
const A = list[i], B = list[j];
for (const [p, q, r, s] of [[A.a, A.b, B.a, B.b], [A.a, A.b, B.b, B.a], [A.b, A.a, B.a, B.b], [A.b, A.a, B.b, B.a]]) {
// A from p to q, B from r to s: q meets r and all four are on one 3D line
if (Math.hypot(q[0] - r[0], q[1] - r[1], q[2] - r[2]) > tol) continue;
const d1 = [q[0] - p[0], q[1] - p[1], q[2] - p[2]], d2 = [s[0] - r[0], s[1] - r[1], s[2] - r[2]];
const l1 = Math.hypot(...d1), l2 = Math.hypot(...d2);
if (!l1 || !l2) continue;
const c = (d1[0] * d2[0] + d1[1] * d2[1] + d1[2] * d2[2]) / (l1 * l2);
if (c < 1 - 1e-9) continue;
list[i] = { ...A, a: p, b: s }; list.splice(j, 1); changed = true; break;
}
}
}
out.push(...list);
}
return out;
}
Faces kept
64-bit
Builds each face's plan polygon and drops slivers.
A loop is dropped when its area is at most 10 × eps × scale, and a clockwise loop is reversed. Each face keeps its edge's speed, start height, normal and offset for the height formula.
addons/trusscraft/lib/bim/res-skeleton.js
lines 246 to 252
CadCraft 0.8.2
for (const [e, list] of pieces) {
for (const loop of chain(list, eps * 20)) {
const poly2 = tidy(loop, eps * 4);
if (poly2.length < 3) continue;
const ar = polyArea(poly2);
if (Math.abs(ar) <= eps * scale * 10) continue;
faces.push({ edge: E[e].orig, poly: ar < 0 ? poly2.reverse() : poly2, speed: E[e].speed, start: E[e].start, n: E[e].n, c: E[e].c, u: E[e].u });
Polygon area
64-bit
The signed plan area of a polygon.
A = ½ Σ (xᵢ yᵢ₊₁ − xᵢ₊₁ yᵢ)
The shoelace sum over absolute coordinates, added in point order. For a polygon far from the origin the products are large and partly cancel.
addons/trusscraft/lib/bim/res-skeleton.js
line 353
CadCraft 0.8.2
export function polyArea(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; }
The shortest distance from a plan point to a segment.
t = clamp((p − a)·d ÷ |d|², 0, 1); distance = |p − a − d t|
The parameter is clamped to [0, 1]. A zero-length segment uses 1 in place of its squared length, so nothing divides by zero.
addons/trusscraft/lib/bim/res-roofplan.js
lines 19 to 22
CadCraft 0.8.2
export function segDist(p, a, b) {
const d = sub(b, a), L2 = dot(d, d) || 1, t = Math.max(0, Math.min(1, dot(sub(p, a), d) / L2));
return Math.hypot(p[0] - a[0] - d[0] * t, p[1] - a[1] - d[1] * t);
}
Eave line and edge speeds
64-bit
Moves each wall line out to the eave and sets how fast and from what height each roof edge rises.
out = overhang − fascia thickness; speed = 1 ÷ tan(pitch); start = plate − out × tan(pitch)
tan(pitch) is taken once per edge from the pitch in degrees. The pitch must be above 1° and below 80°, checked earlier in the function, so the tangent is neither 0 nor very large.
The pitch goes through tan, two reciprocals and atan on its way from the typed value, so the stored pitch can differ from the typed one in the last digits.
Decides what each roof line is and measures its length.
len = √(Δx² + Δy² + Δz²)
A line is a ridge when its two ends differ in height by less than 0.5 mm (absolute). Otherwise the two faces' heights are compared at a point up to 50 mm to one side of the line's middle to tell a hip from a valley.
Finds the face edges that lie on the eave line, tells level eaves from sloped rakes, and measures them.
top height = face height + thickness ÷ cos(pitch)
An edge is on the eave when both ends are within 0.5 mm of the eave line and it is at least 1 mm long. It is an eave when its ends differ in height by less than 0.5 mm, otherwise a rake. Its length is 3D.
z_eave = plate − out × tan(pitch); z = min(z_eave, z_eave + thickness ÷ cos(pitch) − fascia)
The soffit is level with the fascia's bottom but never above the roof's underside at the eave. Strips with an overhang under 5 mm or a plan area under 1e4 mm² are left out.
addons/trusscraft/lib/bim/res-roofplan.js
lines 92 to 97
CadCraft 0.8.2
const s = Math.tan(k.pitch * D2R), tv = thickness / Math.cos(k.pitch * D2R);
const zEave = plate - out[i] * s;
const z = Math.min(zEave, fascia > 0 ? zEave + tv - fascia : zEave);
const poly = [E[i].a, E[i].b, eave[(i + 1) % n], eave[i]];
if (Math.abs(polyArea(poly)) < 1e4) return;
plan.soffit.push({ edge: i, poly, z });
Roof area and line totals
64-bit
The roof's sloped area, its plan area, and the total length of ridges, hips, valleys, eaves and rakes.
area = Σ |plan area of face| ÷ cos(pitch)
Each face's plan area is divided by the cosine of its pitch and the faces are added in order. Line totals are sums of the 3D lengths computed above.
Each corner is where the two neighboring offset lines meet, from one ratio of cross products. When the neighbors are parallel (|cross| of the unit directions below 1e-9) the corner is the offset start of the second edge.
addons/trusscraft/lib/bim/res-roofplan.js
lines 109 to 122
CadCraft 0.8.2
/** The polygon with each edge moved out by its own distance (consecutive lines meet), one point per corner. */
function offsetPoly(E, d) {
const n = E.length, out = [];
for (let i = 0; i < n; i++) {
const f = E[(i + n - 1) % n], e = E[i];
const p0 = [f.a[0] - f.n[0] * d[(i + n - 1) % n], f.a[1] - f.n[1] * d[(i + n - 1) % n]], p1 = [e.a[0] - e.n[0] * d[i], e.a[1] - e.n[1] * d[i]];
const c = cross(f.u, e.u);
// collinear neighbours: one point (with different overhangs the eave between them leans a little)
if (Math.abs(c) < 1e-9) { out.push(p1); continue; }
const tt = cross(sub(p1, p0), e.u) / c;
out.push([p0[0] + f.u[0] * tt, p0[1] + f.u[1] * tt]);
}
return out;
}
Eave line crosses itself
64-bit
Checks that the eave polygon does not cross itself and runs anticlockwise.
Two edges cross when the four orientation values have opposite signs beyond ±1e-6. The threshold is absolute on cross products of coordinate differences (mm²), not scaled to the edge lengths.
addons/trusscraft/lib/bim/res-roofplan.js
lines 123 to 132
CadCraft 0.8.2
function simple(P) {
const n = P.length;
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
if (Math.abs(i - j) <= 1 || (i === 0 && j === n - 1)) continue;
const a = P[i], b = P[(i + 1) % n], c = P[j], d = P[(j + 1) % n];
const d1 = cross(sub(b, a), sub(c, a)), d2 = cross(sub(b, a), sub(d, a)), d3 = cross(sub(d, c), sub(a, c)), d4 = cross(sub(d, c), sub(b, c));
if (((d1 > 1e-6 && d2 < -1e-6) || (d1 < -1e-6 && d2 > 1e-6)) && ((d3 > 1e-6 && d4 < -1e-6) || (d3 < -1e-6 && d4 > 1e-6))) return false;
}
return polyArea(P) > 0;
}
Roof height over a point
64-bit
The height of the roof's underside above a plan point.
Among the faces whose plan polygon contains the point (even-odd crossing test) the highest plane height is returned. Outside every face the result is null.
addons/trusscraft/lib/bim/res-roofplan.js
lines 134 to 139
CadCraft 0.8.2
/** Height of the roof's underside over p (mm above the walls' level), null outside it. */
export function heightAt(plan, p) {
let best = null;
for (const f of plan.faces) if (inPoly(p[0], p[1], f.poly)) { const z = faceZ(f, p); if (best === null || z > best) best = z; }
return best;
}
In detail
Method
Event-driven weighted straight skeleton with edges that may start later (per the file header); corner velocities from a 2 × 2 solve of the two edge lines; faces assembled by chaining the corner paths; face height from the plane start + (n·p − c) ÷ speed; roof area as plan area ÷ cos(pitch).
Number types
64-bit numbers; plan millimeters, height as the skeleton's time.
Tolerances
eps = 1e-7 × outline size (at least 1e-7). Merge and split: 4·eps. Chaining and line merge: 20·eps. Parallel edges: |cross| below 1e-9 on unit vectors. Collinear line merge: cosine at least 1 − 1e-9. Roof plan: ridge when end heights differ by less than 0.5 mm; fascia edge within 0.5 mm of the eave line and longer than 1 mm; self-crossing test ±1e-6.
Limits
Face heights subtract two numbers of the size of the coordinates (n·p − c), so a roof far from the origin loses the digits they share. The face pitch is recovered by atan from the reciprocal of a tangent. The event loop stops after 20000 passes.
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.