Reads luminaire photometry files (IES LM-63 and EULUMDAT) and works out the direct illuminance on a grid of points, with its minimum, average, maximum, uniformity and power density.
How the precision is kept
All arithmetic is 64-bit. The accuracy is set by the method and the data: candela values are interpolated linearly between the measured angles of the file, only the direct component is computed (the file's header and RESULT_NOTE say so), and the statistics are taken over a grid of cell centers. The CSV reports round lux to 1 decimal and coordinates to whole millimeters.
Calculations under this policy
17 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.
Splits the numeric part of an IES file into numbers.
Tokens are split on spaces and commas and read with Number(), so they keep the decimals in the file. A number written with a decimal comma would be split in two.
Scales the candela table and converts the luminous opening to millimeters.
cd = value × multiplier × ballast factor; lumens = lamps × lumens per lamp; size mm = value × 304.8 (feet) or × 1000 (meters)
304.8 mm per foot is exact. A multiplier or ballast factor of 0 is treated as 1. The value between the ballast factor and the input watts is not used. Vertical and horizontal angles must increase strictly, which keeps the interpolation from dividing by zero.
addons/lighting/lib/mep/photometry.js
lines 59 to 66
CadCraft 0.8.2
const k = units === 1 ? 304.8 : 1000; // 1 = feet, 2 = metres
if (vA.some((a, j) => j && a <= vA[j - 1]) || hA.some((a, j) => j && a <= hA[j - 1])) return { ok: false, key: 'ies.angles' };
const cd = [];
for (let a = 0; a < nH; a++) cd.push(flat.slice(a * nV, (a + 1) * nV).map((x) => x * (mult || 1) * (ballast || 1)));
const full = expandIes(hA, cd);
if (!full) return { ok: false, key: 'ies.hangles' };
return finish({ format: 'ies', name: keywords.LUMINAIRE || keywords.LUMCAT || keywords.TEST || '', lumens: lumPerLamp > 0 ? nLamps * lumPerLamp : 0, watts: inputW, vAngles: vA, hAngles: full.h, cd: full.cd, sym: full.sym,
width: Math.abs(w) * k, length: Math.abs(l) * k, height: Math.abs(h) * k, keywords });
Full set of horizontal planes
64-bit
Mirrors the horizontal planes of a symmetric IES file to cover 0 to 360 degrees.
quadrant: a, 180 − a, 180 + a, 360 − a; bilateral: a, 360 − a
Mirrored angles are sums and differences of the file's angles, reduced modulo 360 and rounded to 6 decimals as map keys so that equal planes coincide. The first plane stored for an angle is kept. The symmetry is recognized by exact comparison of the first and last angle with 0, 90, 180, 270 and 360.
addons/lighting/lib/mep/photometry.js
lines 68 to 86
CadCraft 0.8.2
/** LM-63 horizontal angle sets → the full 0..360 set (mirrored by the symmetry the angles announce). */
function expandIes(h, cd) {
const last = h[h.length - 1], m = new Map();
const put = (a, row) => { const k = Math.round((((a % 360) + 360) % 360) * 1e6) / 1e6; if (!m.has(k)) m.set(k, row); };
let sym = 'none';
if (h.length === 1) { if (h[0] !== 0) return null; put(0, cd[0]); sym = 'axial'; }
else if (h[0] === 0 && last === 90) { h.forEach((a, i) => { put(a, cd[i]); put(180 - a, cd[i]); put(180 + a, cd[i]); put(360 - a, cd[i]); }); sym = 'quadrant'; }
else if (h[0] === 0 && last === 180) { h.forEach((a, i) => { put(a, cd[i]); put(360 - a, cd[i]); }); sym = 'bilateral'; }
else if (h[0] === 90 && last === 270) { h.forEach((a, i) => { put(a, cd[i]); put(540 - a, cd[i]); }); sym = 'bilateral'; }
else if (h[0] === 0 && last === 360) { h.forEach((a, i) => put(a, cd[i])); }
else if (h[0] === 0 && last < 360) { h.forEach((a, i) => put(a, cd[i])); }
else return null;
const keys = [...m.keys()].sort((x, y) => x - y);
const H = keys.slice(), C = keys.map((k) => m.get(k));
if (H[0] !== 0) return null;
if (H[H.length - 1] !== 360) { H.push(360); C.push(C[0]); }
if (H.length === 2) { /* axial: both ends equal */ }
return { h: H, cd: C, sym };
}
EULUMDAT lamp flux and watts
64-bit
Adds up the lamp flux and wattage of the lamp sets in an EULUMDAT file.
lumens = Σ |count| × flux; watts = Σ watts
Each lamp set is read as six consecutive lines; the flux is multiplied by the absolute lamp count. Numbers are read with a decimal comma turned into a point.
addons/lighting/lib/mep/photometry.js
lines 99 to 100
CadCraft 0.8.2
let k = 26, lumens = 0, watts = 0;
for (let s = 0; s < nSets; s++) { const cnt = n(k), flux = n(k + 2), w = n(k + 5); lumens += Math.abs(cnt) * flux; watts += w; k += 6; }
EULUMDAT candela scale
64-bit
Turns the file's cd per 1000 lm values into candela.
cd = value × (lumens ÷ 1000) × (LORL ÷ 100) × conversion factor
The table values are multiplied by the lamp flux over 1000, by the light output ratio when it is above 0, and by the conversion factor (1 when it is 0 or missing). With no lamp flux, 1000 lm is assumed.
addons/lighting/lib/mep/photometry.js
lines 110 to 114
CadCraft 0.8.2
const scale = (lumens || 1000) / 1000 * (lorl > 0 ? lorl / 100 : 1) * cfac; // cd/klm → cd: lamp flux × light output ratio
const full = (angle) => ({ angle, row: null });
// plane angles of the stored planes
const step = Dc > 0 ? Dc : (Mc ? 360 / Mc : 0);
const stored = raw.map((row, i) => ({ c: symm === 1 ? 0 : (cAng[i] ?? i * step), row: row.map((x) => x * scale) }));
Position in an angle table
64-bit
Finds the two table angles on either side of an angle and the fraction between them.
f = (x − a_lo) ÷ (a_hi − a_lo)
A binary search over the angle list; the shift in the search halves an index, not a measured value. Outside the table the nearest end is used with f = 0.
addons/lighting/lib/mep/photometry.js
lines 149 to 156
CadCraft 0.8.2
// ------------------------------------------------------------------------------------------------------------- lookup and maths
function bracket(a, x) {
if (x <= a[0]) return [0, 0, 0];
if (x >= a[a.length - 1]) return [a.length - 1, a.length - 1, 0];
let lo = 0, hi = a.length - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (a[m] <= x) lo = m; else hi = m; }
return [lo, hi, (x - a[lo]) / (a[hi] - a[lo])];
}
Candela in a direction
64-bit
The luminous intensity at a vertical and horizontal angle, interpolated from the table.
Bilinear interpolation between the four nearest measured values, so the result is as fine as the file's angle steps. The intensity is 0 beyond the last vertical angle when the table stops short of 179.99°, with a 1e-9 allowance.
addons/lighting/lib/mep/photometry.js
lines 157 to 165
CadCraft 0.8.2
/** Candela at vertical angle gamma (0 = nadir … 180) and horizontal angle c (0…360): bilinear; zero beyond the last measured vertical angle when it is short of 180. */
export function candela(photo, gamma, c) {
const cc = ((c % 360) + 360) % 360, g = Math.max(0, Math.min(180, gamma));
const V = photo.vAngles, last = V[V.length - 1];
if (g > last + 1e-9 && last < 179.99) return 0;
const [h0, h1, fh] = bracket(photo.hAngles, cc), [v0, v1, fv] = bracket(V, g);
const a = photo.cd[h0][v0] * (1 - fv) + photo.cd[h0][v1] * fv, b = photo.cd[h1][v0] * (1 - fv) + photo.cd[h1][v1] * fv;
return a * (1 - fh) + b * fh;
}
Luminaire axes
64-bit
The nadir, C0 and C90 directions of an aimed and rotated luminaire.
n = aim ÷ |aim|; r₀ = axis − (axis·n) n, normalized; r = r₀ cos t + (n × r₀) sin t
One Gram-Schmidt step against the x axis, or the y axis when the aim is within about 26° of x (|nₓ| ≥ 0.9), then a rotation and a re-normalization. A zero aim vector is divided by 1 instead of 0.
addons/lighting/lib/mep/photometry.js
lines 169 to 177
CadCraft 0.8.2
/** Luminaire axes: n = nadir (default straight down), r = the C0 direction (default +x, turned `rotDeg` about n), s = r turned 90° about n. */
export function frame(aim = [0, 0, -1], rotDeg = 0) {
const n = unit(aim);
let r0 = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
r0 = unit([r0[0] - dot(r0, n) * n[0], r0[1] - dot(r0, n) * n[1], r0[2] - dot(r0, n) * n[2]]);
const s0 = cross(n, r0), t = rad(rotDeg);
const r = unit([r0[0] * Math.cos(t) + s0[0] * Math.sin(t), r0[1] * Math.cos(t) + s0[1] * Math.sin(t), r0[2] * Math.cos(t) + s0[2] * Math.sin(t)]);
return { n, r, s: cross(n, r) };
}
Illuminance at a point
64-bit
The direct illuminance in lux at a point on a surface from all luminaires.
E = LLF × Σ I(γ, C) × scale × cos θ ÷ d², d in meters
Distances are millimeters divided by 1000. The acos for γ is clamped to [−1, 1]; near straight below, where its argument is close to 1, acos is ill-conditioned, but the table is flat there for most luminaires. Luminaires within 1 mm of the point and those behind the surface add nothing. The sum runs in luminaire order.
addons/lighting/lib/mep/photometry.js
lines 178 to 193
CadCraft 0.8.2
/** Luminaire { photo, p: [x, y, z] mm, aim?, rot?, scale? (flux factor, 1) , f? (cached frame) }. Illuminance in lux on a surface with the given unit normal. */
export function illuminanceAt(luminaires, p, normal = [0, 0, 1], llf = DEFAULT_LLF.value) {
let E = 0;
for (const L of luminaires) {
const f = L.f || (L.f = frame(L.aim, L.rot));
const v = [(p[0] - L.p[0]) / 1000, (p[1] - L.p[1]) / 1000, (p[2] - L.p[2]) / 1000], d2 = dot(v, v);
if (d2 < 1e-6) continue;
const d = Math.sqrt(d2), u = [v[0] / d, v[1] / d, v[2] / d];
const gamma = Math.acos(Math.max(-1, Math.min(1, dot(u, f.n)))) * 180 / Math.PI;
const c = Math.atan2(dot(u, f.s), dot(u, f.r)) * 180 / Math.PI;
const cosT = -dot(u, normal); // the light arrives against the surface normal
if (cosT <= 0) continue;
E += (candela(L.photo, gamma, c) * (L.scale ?? 1)) * cosT / d2;
}
return E * llf;
}
Calculation grid
64-bit
The points the illuminance is computed at: cell centers inside the area at the working height.
nₓ = max(1, round(width ÷ spacing)); x = x₀ + (width ÷ nₓ) × (i + 0.5)
The cell count is rounded to a whole number, so the cell size is the extent divided by that count, not exactly the typed spacing. Points are computed from the index each time, not by adding a step. A point is kept when the even-odd test puts it inside and it is at least the margin from every edge.
addons/lighting/lib/mep/photometry.js
lines 204 to 216
CadCraft 0.8.2
/** Cell-centre points inside the polygon [[x, y]] mm at `height` (workplane), spacing mm, none nearer than `margin` to the edge. At most 40 000 points. */
export function gridPoints(polygon, { spacing = 500, height = 800, margin = 0 } = {}) {
const xs = polygon.map((p) => p[0]), ys = polygon.map((p) => p[1]);
const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
const nx = Math.max(1, Math.round((x1 - x0) / spacing)), ny = Math.max(1, Math.round((y1 - y0) / spacing));
if (nx * ny > 40000) return { points: [], tooMany: true };
const dx = (x1 - x0) / nx, dy = (y1 - y0) / ny, pts = [];
for (let i = 0; i < nx; i++) for (let j = 0; j < ny; j++) {
const x = x0 + dx * (i + 0.5), y = y0 + dy * (j + 0.5);
if (inside(polygon, x, y) && (!margin || distToEdge(polygon, x, y) >= margin)) pts.push([x, y, height]);
}
return { points: pts, nx, ny, tooMany: false };
}
Minimum, average, maximum and uniformity
64-bit
The statistics of the grid values.
avg = Σ E ÷ n; uniformity = min ÷ avg; min ÷ max
One pass with a plain running sum in grid order and no compensation. The two ratios are guarded against a zero divisor.
addons/lighting/lib/mep/photometry.js
lines 217 to 223
CadCraft 0.8.2
export function stats(values) {
if (!values.length) return { n: 0, min: 0, max: 0, avg: 0, minAvg: 0, minMax: 0 };
let min = Infinity, max = -Infinity, sum = 0;
for (const v of values) { if (v < min) min = v; if (v > max) max = v; sum += v; }
const avg = sum / values.length;
return { n: values.length, min, max, avg, minAvg: avg > 0 ? min / avg : 0, minMax: max > 0 ? min / max : 0 };
}
Area and lighting power density
64-bit
The area in square meters, the total lumens and watts, and the watts per square meter.
area m² = |Σ (xᵢ yᵢ₊₁ − xᵢ₊₁ yᵢ)| ÷ 2e6; LPD = watts ÷ area
The shoelace sum is in millimeters squared with absolute coordinates, then divided by 2e6. Lumens and watts are plain sums of the file values times each luminaire's scale.
Coordinates are rounded to whole millimeters and lux to 1 decimal in the file.
addons/lighting/lib/mep/photometry.js
lines 243 to 244
CadCraft 0.8.2
/** CSV of the grid (x, y, z mm, lux) and a header with the totals — for the report. */
export function gridCsv(res) { return 'x_mm,y_mm,z_mm,lux\n' + res.points.map((p, i) => p.map((x) => Math.round(x)).join(',') + ',' + res.values[i].toFixed(1)).join('\n') + '\n'; }
Lighting report CSV
64-bit
Writes one row per area with its totals and statistics, then every grid point.
area and lux 1 decimal; lumens and watts 0; W/m² and min ÷ avg 2
Every figure is written with toFixed at the stated decimals; grid coordinates are rounded to whole millimeters.
addons/lighting/lib/mep/photometry.js
lines 261 to 270
CadCraft 0.8.2
/** The lighting report as CSV: one row per area (name, luminaires, area, lumens, watts, W/m², lowest, average, highest lux, uniformity, checks) and then every grid point. Formula-looking names are quoted. */
export function lightingReportCsv(areas) {
const f = (x, d = 1) => (Number.isFinite(x) ? x.toFixed(d) : '');
const rows = [['area', 'luminaires', 'area_m2', 'lumens', 'watts', 'w_per_m2', 'min_lux', 'avg_lux', 'max_lux', 'min_over_avg', 'targets']];
for (const a of areas) rows.push([a.name, a.luminaires, f(a.res.areaM2), f(a.res.lumens, 0), f(a.res.watts, 0), f(a.res.lpd, 2), f(a.res.stats.min), f(a.res.stats.avg), f(a.res.stats.max), f(a.res.stats.minAvg, 2),
(a.checks || []).map((c) => c.key + (c.ok ? ' ok' : ' FAILS')).join('; ')]);
rows.push([], ['area', 'x_mm', 'y_mm', 'z_mm', 'lux']);
for (const a of areas) a.res.points.forEach((p, i) => rows.push([a.name, ...p.map((x) => Math.round(x)), f(a.res.values[i])]));
return rows.map((r) => r.map(q).join(',')).join('\n') + '\n';
}
Inverse-square cosine law, E = I(γ, C) × cos θ ÷ d², summed over the luminaires and multiplied by the light loss factor; bilinear interpolation of the candela table; cell-center grid inside the polygon; shoelace area.
Number types
64-bit numbers; candela tables are plain arrays read with Number().
Tolerances
A point within 1 mm of a luminaire is skipped (d² < 1e-6 m²); candela is 0 beyond the last measured vertical angle when that is under 179.99°; horizontal angles are keyed to 6 decimals when mirrored; at most 40 000 grid points.
Checked by
tools/bimcraft-test/eg-mep-test.mjs runs BimCraft's mep/photometry.js, which was byte-identical to this file at the review. It compares the illuminance straight below a 1000 lm Lambertian source at 3 m with 1000 ÷ π ÷ 9 lux, an off-axis point with I cos θ ÷ d², the light loss factor scaling, and a surface facing away with 0.
Limits
No inter-reflection and no shadowing. Type B and type A IES files are refused. The grid spacing used is the extent divided by a whole number of cells, not exactly the typed spacing.
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.