Works out each room's average daylight factor from its windows, its inside surfaces and the angle of sky each window sees.
How the precision is kept
Geometry is held as 64-bit triangles in meters and every step is ordinary 64-bit arithmetic. The sky angle is measured on the model by cutting triangles with a vertical plane, with one absolute tolerance of 1e-6 m. The method sets the accuracy: the window's facing direction is found by a search to 0.1°, the sky angle is the mean of five sections across the window, and the formula is an early-design estimate.
Calculations under this policy
13 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.
The total of the angle intervals that obstructions hide, with overlaps counted once.
total = Σ (hiᵢ − loᵢ) over the merged intervals, each clipped to 0°…90°
Intervals are clipped to 0° to 90°, sorted and merged. Two intervals join when the gap between them is 1e-9 degrees or less (absolute). The total is a sum of the merged lengths in order.
daylight-bim.js
lines 43 to 49
BimCraft 0.8.1
/** Merge intervals and return their total length (degrees), clipped to [lo, hi]. */
function covered(list, lo = 0, hi = 90) {
const a = list.map(([x, y]) => [Math.max(lo, Math.min(x, y)), Math.min(hi, Math.max(x, y))]).filter(([x, y]) => y > x).sort((p, q) => p[0] - q[0]);
const out = [];
for (const iv of a) { const last = out[out.length - 1]; if (last && iv[0] <= last[1] + 1e-9) last[1] = Math.max(last[1], iv[1]); else out.push([...iv]); }
return { total: out.reduce((s, [x, y]) => s + (y - x), 0), merged: out };
}
Sky angle in one section
64-bit
The angle of sky, from the horizon to straight up, that is still visible from a point on the window's outer face.
cut point = a + t · (b − a), t = d_a ÷ (d_a − d_b); angle = atan2(v, u); θ = 90° − blocked total
Each triangle is cut by the vertical plane through the window. A corner within 1e-6 m of the plane (absolute) counts as on it. A crossing is interpolated only when the two distances have opposite signs beyond that tolerance, so d_a − d_b adds two magnitudes and cannot cancel. A point within 1e-6 m of the window face is taken as straight up or straight down instead of going through atan2.
daylight-bim.js
lines 51 to 93
BimCraft 0.8.1
export function sectionTheta(origin, n, tris) {
const up = [0, 0, 1], s = [n[1], -n[0], 0]; // the section plane holds n and up; s is square to it
const [ox, oy, oz] = origin;
const blocked = [];
for (let i = 0; i + 8 < tris.length; i += 9) {
// signed distance of each corner from the plane, and where it lies in the section (u outward, v up)
const P = [[tris[i], tris[i + 1], tris[i + 2]], [tris[i + 3], tris[i + 4], tris[i + 5]], [tris[i + 6], tris[i + 7], tris[i + 8]]];
const d = P.map((p) => (p[0] - ox) * s[0] + (p[1] - oy) * s[1]);
if ((d[0] > EPS && d[1] > EPS && d[2] > EPS) || (d[0] < -EPS && d[1] < -EPS && d[2] < -EPS)) continue;
const uv = (p) => [(p[0] - ox) * n[0] + (p[1] - oy) * n[1], p[2] - oz];
const uvs = P.map(uv);
if (uvs[0][0] <= EPS && uvs[1][0] <= EPS && uvs[2][0] <= EPS) continue; // all behind the window
const pts = [];
for (let k = 0; k < 3; k++) {
const a = k, b = (k + 1) % 3;
if (Math.abs(d[a]) <= EPS) pts.push(uvs[a]);
if ((d[a] > EPS && d[b] < -EPS) || (d[a] < -EPS && d[b] > EPS)) { const t = d[a] / (d[a] - d[b]); pts.push([uvs[a][0] + t * (uvs[b][0] - uvs[a][0]), uvs[a][1] + t * (uvs[b][1] - uvs[a][1])]); }
}
if (pts.length < 2) continue;
// the cut piece (a segment; a triangle lying in the plane gives its extreme points), clipped to u > 0
let lo = pts[0], hi = pts[0];
// a point on the window's own face (u = 0, clipped to EPS) is seen straight up or straight down: the exact limit
const ang = (p) => (p[0] <= EPS * 1.000001 ? (p[1] >= 0 ? Math.PI / 2 : -Math.PI / 2) : Math.atan2(p[1], p[0]));
// clip each candidate pair to the front half (u ≥ EPS) and take the widest angular span
const front = [];
for (let a = 0; a < pts.length; a++) for (let b = a + 1; b < pts.length; b++) {
let p = pts[a], q = pts[b];
if (p[0] < EPS && q[0] < EPS) continue;
if (p[0] < EPS || q[0] < EPS) {
const t = (EPS - p[0]) / (q[0] - p[0]), m = [EPS, p[1] + t * (q[1] - p[1])];
if (p[0] < EPS) p = m; else q = m;
}
front.push(p, q);
}
if (!front.length) continue;
lo = front[0]; hi = front[0];
for (const p of front) { if (ang(p) < ang(lo)) lo = p; if (ang(p) > ang(hi)) hi = p; }
const a0 = ang(lo) * R2D, a1 = ang(hi) * R2D;
if (a1 > 0 && a0 < 90) blocked.push([a0, a1]);
}
const c = covered(blocked);
return { theta: Math.max(0, 90 - c.total), blocked: c.merged };
}
Mean reflectance of the room
64-bit
The area-weighted mean reflectance of floor, walls, ceiling and glass.
A weighted mean in one 64-bit expression of positive terms. The glass reflectance is the constant 0.1; the other three are inputs. A room with no area returns 0 instead of dividing by zero.
daylight-bim.js
lines 95 to 99
BimCraft 0.8.1
// ---------------------------------------------------------------- the formula
export function meanReflectance(areas, refl = DEFAULTS.refl) {
const A = areas.floor + areas.wall + areas.ceiling + areas.glass;
return A > 0 ? (areas.floor * refl.floor + areas.wall * refl.wall + areas.ceiling * refl.ceiling + areas.glass * GLASS_R) / A : 0;
}
Average daylight factor
64-bit
The room's average daylight factor in percent.
ADF = Σ (T × A_g × θ × M) ÷ (A × (1 − R²))
The window terms are added in order and divided once. The function returns 0 when A is not positive or R is 1 or more, so the divisor is never zero. θ enters in degrees, as the published formula takes it.
daylight-bim.js
lines 100 to 105
BimCraft 0.8.1
export function averageDF({ A, R, windows }, { T = DEFAULTS.T, M = DEFAULTS.M } = {}) {
if (!(A > 0) || !(R < 1)) return 0;
let s = 0;
for (const w of windows) s += T * w.glazed * w.theta * M;
return s / (A * (1 - R * R));
}
Triangle area and tilt
64-bit
The area of one triangle of a room's shell and how far its normal points up or down.
area = |AB × AC| ÷ 2; nz = (AB × AC)_z ÷ |AB × AC|
The edge vectors are differences from the first corner, so the size of the model coordinates drops out before the cross product. The length is Math.hypot of the three components. A triangle with no area gives nz = 0 instead of dividing by zero.
daylight-bim.js
lines 121 to 125
BimCraft 0.8.1
function triArea(tr, i) {
const ax = tr[i + 3] - tr[i], ay = tr[i + 4] - tr[i + 1], az = tr[i + 5] - tr[i + 2], bx = tr[i + 6] - tr[i], by = tr[i + 7] - tr[i + 1], bz = tr[i + 8] - tr[i + 2];
const cx = ay * bz - az * by, cy = az * bx - ax * bz, cz = ax * by - ay * bx, l = Math.hypot(cx, cy, cz);
return { area: l / 2, nz: l > 0 ? cz / l : 0 };
}
Closest point of a triangle
64-bit
The point of a triangle nearest to a given point, used to find which room a window belongs to.
Region tests on dot products of 64-bit vectors, compared against exactly zero with no tolerance (the code's comment cites Ericson, Real-Time Collision Detection 5.1.5). The interior case divides by va + vb + vc, which is not guarded for a triangle with no area.
The level direction across which a window's triangles are thinnest.
width(α) = max(x·cos α + y·sin α) − min(x·cos α + y·sin α); the α with the least width
The direction is searched in whole degrees from 0° to 179°, then in steps of 0.1° within one degree either side, so the facing direction is resolved to 0.1° and not finer. A candidate replaces the best only when it is narrower by more than 1e-12 m. The fine loop reaches its angles by adding 0.1 repeatedly.
daylight-bim.js
lines 156 to 165
BimCraft 0.8.1
/** The level direction across which a window is thinnest (its glass faces that way, one way or the other): a search by degree, then by a tenth. */
export function facing(tr) {
const width = (a) => { const x = Math.cos(a), y = Math.sin(a); let lo = Infinity, hi = -Infinity; for (let i = 0; i < tr.length; i += 3) { const u = tr[i] * x + tr[i + 1] * y; if (u < lo) lo = u; if (u > hi) hi = u; } return hi - lo; };
let best = 0, bw = Infinity;
for (let d = 0; d < 180; d++) { const w = width(d * D2R); if (w < bw - 1e-12) { bw = w; best = d; } }
let fine = best;
for (let d = best - 1; d <= best + 1; d += 0.1) { const w = width(d * D2R); if (w < bw - 1e-12) { bw = w; fine = d; } }
const a = fine * D2R;
return [Math.cos(a), Math.sin(a)];
}
Floor, wall and ceiling areas of a room
64-bit
The inside surface areas of a room from the triangles of its space.
A = A_floor + A_wall + A_ceiling
A triangle whose unit normal has |nz| above 0.7 is floor or ceiling, by whether its centroid lies below the middle height of the room's box; the rest is wall. Areas are added triangle by triangle in the order the geometry gives them.
The area of one window and the point on its outer face that the sky angle is measured from.
area = OverallWidth × k × OverallHeight × k, k = file length unit in mm ÷ 1000
The area is OverallWidth × OverallHeight, each converted to meters by the file's length unit. A window without those two values takes the width of its triangles across the facing direction times the height of their box.
daylight-bim.js
lines 237 to 239
BimCraft 0.8.1
const ow = Number(val(l.OverallWidth)), oh = Number(val(l.OverallHeight)), k = mm / 1000;
const area = ow > 0 && oh > 0 ? ow * k * oh * k : (sHi - sLo) * ext[2];
const origin = [c[0] + n[0] * uMax, c[1] + n[1] * uMax, c[2]];
Sky angle of a window
64-bit
The window's sky angle: the mean of five sections across its width.
θ = (θ₁ + θ₂ + θ₃ + θ₄ + θ₅) ÷ 5, at 10, 30, 50, 70 and 90 % of the width
The blocked intervals of every obstruction are gathered per section and merged once, so two obstructions that hide the same angles are not counted twice. The result is a mean of five samples: an obstruction narrower than the spacing between sections is counted by the sections it crosses, not by its true share of the window.
The window areas are summed in order, capped at the wall area and subtracted from it. The total inside surface A is not changed by this step.
daylight-bim.js
lines 268 to 270
BimCraft 0.8.1
const g = r.windows.reduce((t, x) => t + x.area, 0);
r.areas.glass = Math.min(g, r.areas.wall);
r.areas.wall -= r.areas.glass;
Result per room
64-bit
Each room's mean reflectance, glazed areas, daylight factor and guidance band for the inputs given.
A_g = window area × glazed share; band: below 2 % low, below 5 % mid, else high
Inputs that are not finite numbers fall back to the defaults (T 0.7, M 1, glazed share 0.75). The band limits 2 and 5 are compared on the unrounded daylight factor.
daylight-bim.js
lines 278 to 289
BimCraft 0.8.1
/** The result for these inputs (cheap: run again whenever an input changes). */
export function daylight(g, inputs = {}) {
const T = Number.isFinite(inputs.T) ? inputs.T : DEFAULTS.T, M = Number.isFinite(inputs.M) ? inputs.M : DEFAULTS.M;
const share = Number.isFinite(inputs.glazedShare) ? inputs.glazedShare : DEFAULTS.glazedShare;
const refl = { ...DEFAULTS.refl, ...(inputs.refl || {}) };
return g.rooms.map((r) => {
const R = meanReflectance(r.areas, refl);
const windows = r.windows.map((x) => ({ ...x, glazed: x.area * share }));
const adf = averageDF({ A: r.A, R, windows }, { T, M });
return { ...r, R, windows, adf, band: band(adf) };
});
}
Daylight table as CSV
64-bit
The rooms' results written as a CSV file.
The file holds rounded text: areas to 2 decimals, the mean reflectance to 3, each sky angle to 1 decimal of a degree and the daylight factor to 2. The glazed total of a room is summed in 64-bit before it is rounded.
daylight-bim.js
lines 292 to 299
BimCraft 0.8.1
export function daylightCsv(rooms, { head, storeyName = () => '', inputs = {}, bandName = (b) => b } = {}) {
const H = head || { room: 'Room', storey: 'Level', A: 'Inside surfaces (m²)', floor: 'Floor (m²)', R: 'Mean reflectance', windows: 'Windows', glazed: 'Glazed area (m²)', theta: 'Sky angle θ (°)', adf: 'Average daylight factor (%)', band: 'Guidance band', inputs: 'Inputs' }; // i18n-ignore: the page passes `head` in the person's language
const rows = [[H.room, H.storey, H.A, H.floor, H.R, H.windows, H.glazed, H.theta, H.adf, H.band]];
for (const r of rooms) rows.push([[r.name, r.longName].filter(Boolean).join(' '), storeyName(r.storey), r.A.toFixed(2), r.areas.floor.toFixed(2), r.R.toFixed(3), r.windows.length,
r.windows.reduce((s, x) => s + x.glazed, 0).toFixed(2), r.windows.map((x) => x.theta.toFixed(1)).join(' '), r.adf.toFixed(2), bandName(r.band)]);
rows.push([H.inputs, JSON.stringify(inputs)]);
return '' + rows.map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n';
}
In detail
Method
ADF (%) = T · Ag · θ · M ÷ (A · (1 − R²)), summed over the room's windows. The file's header cites Littlefair, BRE 209, BS 8206-2 and CIBSE LG10. θ is the angle of visible sky in a vertical section square to the window, measured by cutting every other element's triangles with that section.
Number types
Float64Array triangles (9 numbers each, meters, Z up) and ordinary 64-bit numbers.
Tolerances
1e-6 m (absolute) for a point on the section plane or on the window's own face. 1e-9 degrees when blocked angle intervals are merged. 1e-12 m when widths are compared in the facing search. A window belongs to a space within 0.75 m of its shell. A triangle is floor or ceiling when |nz| > 0.7.
Limits
An estimate by a published formula, not a simulation and not a compliance check (the file's header says so). Windows thinner in height than in plan are listed as roof windows and left out. The triangles come from ifc-util.js productTriangles; their precision is set there, not in this file.
Approach: 64-bit, stated limit
The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.