Works out, for each hour of each month's representative day, the share of a solar array's sample points that the model shades from the direct sun, and the share of the beam light lost.
How the precision is kept
A ray is cast from each sample point toward the sun and tested against the model's triangles in 64-bit. The sampling sets the accuracy: up to four by four points per module (two by two by default), every k-th module beyond 400, one day per month and one sun position per hour. A point is either shaded or not; the shadow edge inside a module is not followed.
Calculations under this policy
4 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.
Whether the line from a point toward the sun meets a triangle of the model.
det = e₁ · (d × e₂); u = (t · (d × e₂)) ÷ det; v = (d · (t × e₁)) ÷ det; hit if 0 ≤ u, 0 ≤ v, u + v ≤ 1 and distance > tMin
64-bit cross and dot products. The parallel test |det| < 1e-12 is absolute, while det scales with the product of the two edge lengths in millimeters. The limits on u and v have no tolerance.
pv-shade.js
lines 33 to 62
BimCraft 0.8.1
/** Does the ray from `o` along unit `d` meet a triangle of `obs` (not owned by `skip`)? */
export function blocked(obs, o, d, skip = null, tMin = 1) {
const inv = [1 / (Math.abs(d[0]) < EPS ? EPS : d[0]), 1 / (Math.abs(d[1]) < EPS ? EPS : d[1]), 1 / (Math.abs(d[2]) < EPS ? EPS : d[2])];
const T = obs.tri, lo = obs.lo, hi = obs.hi;
for (let k = 0; k < obs.count; k++) {
if (skip !== null && obs.owner[k] === skip) continue;
const j = k * 3;
let t0 = 0, t1 = Infinity;
for (let a = 0; a < 3; a++) {
let ta = (lo[j + a] - o[a]) * inv[a], tb = (hi[j + a] - o[a]) * inv[a];
if (ta > tb) { const x = ta; ta = tb; tb = x; }
if (ta > t0) t0 = ta;
if (tb < t1) t1 = tb;
if (t0 > t1) break;
}
if (t0 > t1) continue;
const i = k * 9;
const e1x = T[i + 3] - T[i], e1y = T[i + 4] - T[i + 1], e1z = T[i + 5] - T[i + 2], e2x = T[i + 6] - T[i], e2y = T[i + 7] - T[i + 1], e2z = T[i + 8] - T[i + 2];
const px = d[1] * e2z - d[2] * e2y, py = d[2] * e2x - d[0] * e2z, pz = d[0] * e2y - d[1] * e2x, det = e1x * px + e1y * py + e1z * pz;
if (Math.abs(det) < 1e-12) continue;
const id = 1 / det, tx = o[0] - T[i], ty = o[1] - T[i + 1], tz = o[2] - T[i + 2];
const u = (tx * px + ty * py + tz * pz) * id;
if (u < 0 || u > 1) continue;
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
const v = (d[0] * qx + d[1] * qy + d[2] * qz) * id;
if (v < 0 || u + v > 1) continue;
if ((e2x * qx + e2y * qy + e2z * qz) * id > tMin) return true;
}
return false;
}
Sample points on the modules
64-bit
The points on each module from which rays are cast.
p = bilinear blend of the four corners at ((a + ½) ÷ k, (b + ½) ÷ k) + normal × lift
64-bit. The number of points per side is cut to a whole number and kept between 1 and 4; with more modules than maxModules only every k-th module is sampled.
pv-shade.js
lines 64 to 80
BimCraft 0.8.1
/**
* Sample points of an array's modules. opts: perModule (points along each side, 2), maxModules (every k-th module is sampled past this, 400), lift (mm above the plane, 20).
* → [[x, y, z]…] in mm.
*/
export function samplePoints(modules, normal, { perModule = 2, maxModules = 400, lift = 20 } = {}) {
const stride = Math.max(1, Math.ceil(modules.length / maxModules)), pts = [];
const k = Math.max(1, Math.min(4, perModule | 0));
for (let m = 0; m < modules.length; m += stride) {
const c = modules[m].corners;
for (let a = 0; a < k; a++) for (let b = 0; b < k; b++) {
const s = (a + 0.5) / k, t = (b + 0.5) / k;
const p = [0, 1, 2].map((q) => c[0][q] * (1 - s) * (1 - t) + c[1][q] * s * (1 - t) + c[2][q] * s * t + c[3][q] * (1 - s) * t);
pts.push([p[0] + normal[0] * lift, p[1] + normal[1] * lift, p[2] + normal[2] * lift]);
}
}
return { pts, stride };
}
Shade fraction by month and hour
64-bit
The share of the sample points in shadow at each of 12 × 24 sun positions.
frac = shaded points ÷ points
A count divided by a count; null when the sun is down or behind the module plane. Triangles that lie wholly more than 1 mm below the lowest sample point are dropped before the rays are cast.
pv-shade.js
lines 82 to 107
BimCraft 0.8.1
export function shadeGrid(modules, normal, sunGrid, obs, { owner = null, perModule = 2, maxModules = 400, lift = 20 } = {}) {
const { pts } = samplePoints(modules, normal, { perModule: modules.length * perModule * perModule > maxModules * 4 ? 1 : perModule, maxModules, lift });
// triangles that can never be hit: wholly below the lowest sample point (the ray only climbs)
let zmin = Infinity;
for (const p of pts) zmin = Math.min(zmin, p[2]);
const keep = { tri: [], owner: [], lo: [], hi: [], count: 0 };
for (let k = 0; k < obs.count; k++) {
if (obs.hi[k * 3 + 2] < zmin - 1) continue;
for (let q = 0; q < 9; q++) keep.tri.push(obs.tri[k * 9 + q]);
keep.owner.push(obs.owner[k]);
for (let q = 0; q < 3; q++) { keep.lo.push(obs.lo[k * 3 + q]); keep.hi.push(obs.hi[k * 3 + q]); }
keep.count++;
}
const frac = [], stat = { samples: pts.length, rays: 0, triangles: keep.count };
for (let m = 0; m < 12; m++) {
frac.push([]);
for (let h = 0; h < 24; h++) {
const s = sunGrid[m]?.[h];
if (!s || s.dir[0] * normal[0] + s.dir[1] * normal[1] + s.dir[2] * normal[2] <= 0) { frac[m].push(null); continue; }
let n = 0;
if (keep.count) for (const p of pts) { stat.rays++; if (blocked(keep, p, s.dir, owner)) n++; }
frac[m].push(pts.length ? n / pts.length : 0);
}
}
return { frac, ...stat };
}
Beam light lost to shadows
64-bit
The share of the direct sunlight the array loses to shading, over the year or chosen months.
loss = Σ beam × frac ÷ Σ beam
Two running 64-bit sums over the hours in order; 0 when there is no beam.
pv-shade.js
lines 109 to 117
BimCraft 0.8.1
/** Beam lost to shadows: Σ shade × beam / Σ beam over the hours given (beamGrid[m][h] = the beam an hour would have delivered; months: indexes, default all). */
export function beamLoss(frac, beamGrid, months = null) {
let lost = 0, all = 0;
for (let m = 0; m < 12; m++) {
if (months && !months.includes(m)) continue;
for (let h = 0; h < 24; h++) { const b = beamGrid[m]?.[h] || 0, f = frac[m]?.[h]; if (b > 0 && f !== null && f !== undefined) { lost += b * f; all += b; } }
}
return all > 0 ? lost / all : 0;
}
In detail
Method
Bounding-box slab test, then the ray-triangle intersection of Möller and Trumbore (1997), which the file cites. Shade fraction = shaded points ÷ points. Beam loss = Σ shade × beam ÷ Σ beam.
Number types
64-bit numbers in plain arrays, millimeters.
Tolerances
A direction component smaller than 1e-9 is replaced by 1e-9 in the box test; a determinant smaller than 1e-12 skips the triangle as parallel; a hit must be more than 1 mm along the ray; sample points are lifted 20 mm off the module plane. All absolute.
Limits
Diffuse light is not shaded. The file's header says a part-shaded module is counted as losing that share of its beam.
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.