Finds the roof planes that can carry modules, fits modules inside a plane with a setback, and lays out ground rows with a spacing that avoids shade at the winter solstice.
How the precision is kept
Planes, module corners and row spacing are computed in 64-bit millimeters and degrees. Triangles are grouped into one roof face when their normals are within 0.5° and their planes within 30 mm, and a module fits when nine sample points of its setback rectangle lie inside the face. Reported tilt, azimuth, area, pitch and power are rounded to 2 decimals.
Calculations under this policy
8 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.
pv.jsBimCraft 0.8.164-bit8 calculationsReviewed
Tests tools/bimcraft-test/eg-pv-test.mjs
Normal of a tilted plane
64-bit
The unit normal of a plane from its tilt and compass bearing.
n = (h_x·sin β, h_y·sin β, cos β), h = north·cos az + east·sin az
Angles in degrees are multiplied by π ÷ 180. The result is a unit vector to 64-bit rounding as long as the site's north vector is one; frameOf normalizes it.
pv.js
lines 106 to 107
BimCraft 0.8.1
/** The unit normal (out of the plane) of a plane tilted `tilt` degrees from horizontal and facing compass bearing `az`. */
export function planeNormal(tilt, az, site) { const h = bearing(az, site), s = Math.sin(tilt * RAD); return [h[0] * s, h[1] * s, Math.cos(tilt * RAD)]; }
Tilt and azimuth of a normal
64-bit
The tilt from horizontal and the compass bearing a plane faces.
n_z is clamped to [−1, 1] before Math.acos. For a nearly flat plane the arccosine of a value near 1 loses digits; planes under 0.5° are reported flat with no azimuth.
pv.js
lines 108 to 114
BimCraft 0.8.1
/** → { tilt, azimuth (0–360, null for a flat plane), flat } of a unit normal. */
export function tiltAzimuthOf(n, site) {
const F = frameOf(site), tilt = Math.acos(Math.max(-1, Math.min(1, n[2]))) / RAD;
if (tilt < 0.5) return { tilt, azimuth: null, flat: true };
const east = n[0] * F.east[0] + n[1] * F.east[1], north = n[0] * F.north[0] + n[1] * F.north[1];
return { tilt, azimuth: (Math.atan2(east, north) / RAD + 360) % 360, flat: false };
}
Axes of a plane from its normal
64-bit
The along-eave and up-slope directions of a roof face.
u = (−n_y, nₓ, 0) ÷ √(nₓ² + n_y²); v = n × u
The horizontal part of the normal is normalized with Math.hypot; below 0.0087 (about 0.5°) the plane is treated as flat with east and north as its axes.
pv.js
lines 123 to 129
BimCraft 0.8.1
/** basisOf from an exact unit normal (n × u gives the slope direction, v). */
export function basisOfNormal(n, site) {
const F = frameOf(site), h = Math.hypot(n[0], n[1]);
if (h < 0.0087) return { n: [0, 0, 1], u: [F.east[0], F.east[1], 0], v: [F.north[0], F.north[1], 0] };
const u = [-n[1] / h, n[0] / h, 0];
return { n, u, v: cross(n, u) };
}
Roof faces from triangles
64-bit
Groups upward triangles into planes and gives each plane's normal, tilt, azimuth, area and centroid.
n = (B − A) × (C − A) ÷ |…|; area = |…| ÷ 2; face normal = unit(Σ area·n); centroid = Σ area·mid ÷ Σ area
A triangle joins the first group whose first triangle's normal is within 0.5° and whose plane is within 30 mm, so grouping depends on triangle order. Areas are summed in order. Tilt, azimuth and area are rounded to 0.01 in the returned face.
pv.js
lines 132 to 153
BimCraft 0.8.1
/** Upward planes of placed triangles (flat [x0,y0,z0, x1,y1,z1, x2,y2,z2, …] in mm) → faces, biggest first. */
export function roofFaces(tris, site, { minAreaM2 = 0.5, maxTilt = 80, angleTol = 0.5, offsetTol = 30 } = {}) {
const cosMax = Math.cos(maxTilt * RAD), cosTol = Math.cos(angleTol * RAD), clusters = [];
for (let i = 0; i + 8 < tris.length; i += 9) {
const A = [tris[i], tris[i + 1], tris[i + 2]], B = [tris[i + 3], tris[i + 4], tris[i + 5]], C = [tris[i + 6], tris[i + 7], tris[i + 8]];
const c = cross(sub(B, A), sub(C, A)), l = len(c);
if (l < 1e-6) continue;
const n = [c[0] / l, c[1] / l, c[2] / l];
if (n[2] < cosMax) continue;
const mid = [(A[0] + B[0] + C[0]) / 3, (A[1] + B[1] + C[1]) / 3, (A[2] + B[2] + C[2]) / 3], d = dot(n, mid), area = l / 2;
let k = null;
for (const cl of clusters) if (dot(cl.n0, n) >= cosTol && Math.abs(dot(cl.n0, mid) - dot(cl.n0, cl.m0)) <= offsetTol) { k = cl; break; }
if (!k) { k = { n0: n, m0: mid, nsum: [0, 0, 0], msum: [0, 0, 0], area: 0, tris: [] }; clusters.push(k); }
k.nsum = add(k.nsum, n, area); k.msum = add(k.msum, mid, area); k.area += area; k.tris.push(...A, ...B, ...C);
void d;
}
const faces = clusters.filter((k) => k.area / 1e6 >= minAreaM2).sort((a, b) => b.area - a.area).map((k, i) => {
const normal = unit(k.nsum), ta = tiltAzimuthOf(normal, site);
return { id: 'F' + (i + 1), normal, basis: basisOfNormal(normal, site), tilt: r2(ta.tilt), azimuth: ta.azimuth === null ? null : r2(ta.azimuth), flat: ta.flat, areaM2: r2(k.area / 1e6), centroid: [k.msum[0] / k.area, k.msum[1] / k.area, k.msum[2] / k.area], tris: k.tris };
});
return faces;
}
Point inside the face's triangles
64-bit
Tests whether a point in the face's own axes lies in any of its triangles.
barycentric l₁, l₂, l₃ ≥ −eps ÷ √|d|
Barycentric coordinates by division by the triangle's doubled area d; triangles with |d| < 1e-9 mm² are skipped. The slack of 1 mm is scaled by √|d|, so it is approximate rather than a true 1 mm distance.
pv.js
lines 155 to 166
BimCraft 0.8.1
/** Is (s, t) inside any of the 2-D triangles (flat [s0,t0,s1,t1,s2,t2, …])? `eps` mm of slack so a seam between two triangles is not a gap. */
function inUnion(T2, s, t, eps = 1) {
for (let i = 0; i + 5 < T2.length; i += 6) {
const ax = T2[i], ay = T2[i + 1], bx = T2[i + 2], by = T2[i + 3], cx = T2[i + 4], cy = T2[i + 5];
const d = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
if (Math.abs(d) < 1e-9) continue;
const l1 = ((s - ax) * (cy - ay) - (cx - ax) * (t - ay)) / d, l2 = ((bx - ax) * (t - ay) - (s - ax) * (by - ay)) / d, l3 = 1 - l1 - l2;
const e = eps / Math.sqrt(Math.abs(d));
if (l1 >= -e && l2 >= -e && l3 >= -e) return true;
}
return false;
}
Modules fitted in a roof face
64-bit
The positions and corners of the modules that fit inside a face with a setback.
Triangle corners are projected on the face's axes after the centroid is subtracted. Positions are index × step, not accumulated sums. A module is kept when nine sample points of its rectangle grown by the setback are inside the face; of 16 grid offsets the one with most modules wins.
pv.js
lines 168 to 198
BimCraft 0.8.1
/**
* Modules inside a roof face. opts: { orient 'portrait' | 'landscape', setback mm (300), gap mm (20), max (count cap), exclude: [[s0,t0,s1,t1]…] none yet }.
* → { modules: [{ c: [x,y,z] centre, row, col, corners: 4 × [x,y,z] }], count, kwp, u, v, n, setbackMm, across, along }.
*/
export function fitFace(face, module, { orient = 'portrait', setback = 300, gap = 20, max = 100000 } = {}) {
const basis = face.basis;
const p0 = face.centroid, T = face.tris, T2 = new Float64Array(T.length / 3 * 2);
let smin = Infinity, smax = -Infinity, tmin = Infinity, tmax = -Infinity;
for (let i = 0, j = 0; i < T.length; i += 3, j += 2) {
const q = [T[i] - p0[0], T[i + 1] - p0[1], T[i + 2] - p0[2]], s = dot(q, basis.u), t = dot(q, basis.v);
T2[j] = s; T2[j + 1] = t; smin = Math.min(smin, s); smax = Math.max(smax, s); tmin = Math.min(tmin, t); tmax = Math.max(tmax, t);
}
const across = orient === 'landscape' ? module.lengthMm : module.widthMm, along = orient === 'landscape' ? module.widthMm : module.lengthMm;
const du = across + gap, dv = along + gap;
let best = null;
for (let a = 0; a < 4; a++) for (let b = 0; b < 4; b++) {
const s0 = smin + (a * du) / 4, t0 = tmin + (b * dv) / 4, list = [];
for (let row = 0; t0 + row * dv + along <= tmax + 1; row++) for (let col = 0; s0 + col * du + across <= smax + 1; col++) {
const sa = s0 + col * du, ta = t0 + row * dv;
let ok = true;
for (const fx of SAMPLE) { for (const fy of SAMPLE) { if (!inUnion(T2, sa - setback + fx * (across + 2 * setback), ta - setback + fy * (along + 2 * setback))) { ok = false; break; } } if (!ok) break; }
if (ok) list.push([row, col, sa, ta]);
}
if (!best || list.length > best.length) best = list;
}
const modules = best.slice(0, max).map(([row, col, sa, ta]) => {
const corner = (s, t) => add(add(p0, basis.u, s), basis.v, t);
return { row, col, c: corner(sa + across / 2, ta + along / 2), corners: [corner(sa, ta), corner(sa + across, ta), corner(sa + across, ta + along), corner(sa, ta + along)] };
});
return { modules, count: modules.length, kwp: r2(modules.length * module.wp / 1000), u: basis.u, v: basis.v, n: basis.n, setbackMm: setback, across, along };
}
Row spacing without winter shade
64-bit
The distance between ground rows that leaves no shade at the winter solstice.
h = slant × sin β; shadow = h ÷ tan α × max(0, cos Δaz); pitch = slant × cos β + shadow
The sun is taken on December 21 in the northern hemisphere and June 21 in the southern, at solar noon or a given number of hours either side. A sun altitude of 1° or less returns an error, which keeps tan α away from 0.
pv.js
lines 201 to 214
BimCraft 0.8.1
/** The row spacing that leaves no shade at the winter-solstice sun `hours` from solar noon. → { pitchMm, shadowMm, heightMm, elevation, sunAzimuth, error? } */
export function winterPitch({ slantMm, tilt, azimuth, site, hours = 0 }) {
const lat = site.lat, month = lat >= 0 ? 12 : 6, day = 21, tz = Number.isFinite(site.tz) ? site.tz : Math.round(site.lon / 15);
const sunAt = (dt) => sunPosition({ month, day, hour: solarToClock(site, month, day, 12 + dt, tz), lat, lon: site.lon, tz });
let worst = null;
for (const dt of hours ? [-Math.abs(hours), Math.abs(hours)] : [0]) {
const s = sunAt(dt);
if (s.altitude <= 1) return { error: 'pv.err.polar' };
const dAz = (s.azimuth - azimuth) * RAD, h = slantMm * Math.sin(tilt * RAD);
const shadow = h / Math.tan(s.altitude * RAD) * Math.max(0, Math.cos(dAz));
if (!worst || shadow > worst.shadowMm) worst = { shadowMm: shadow, heightMm: h, elevation: s.altitude, sunAzimuth: s.azimuth };
}
return { ...worst, pitchMm: slantMm * Math.cos(tilt * RAD) + worst.shadowMm };
}
Ground rows of tables
64-bit
The tables and modules of a ground array with their corners, the pitch and the ground cover ratio.
Row origins are origin − direction × pitch × r, a product rather than a running sum. The counts per row, stack and rows are truncated with a bitwise OR 0, which is used on whole-number counts only. Pitch, shadow and GCR are rounded to 0.01 in the result; the corners use the unrounded pitch.
pv.js
lines 215 to 242
BimCraft 0.8.1
/**
* Rows of tables on the ground (or a flat roof). opts: { site, module, orient, perRow (modules across a table), stack (modules up the slope, 1),
* rows, tilt, azimuth, origin [x,y,z] (mm: the middle of the first table's lower edge, on the ground), clearance (500 mm), gap (20),
* pitchMm (null = from the winter rule), hours (0), contains(x, y) (a table is kept only when its four lower corners are inside) }.
* → { modules, tables: [{ row, corners }], pitchMm, shadowMm, gcr (slant ÷ pitch), count, kwp, rule }.
*/
export function groundLayout(o) {
const { site, module } = o, tilt = o.tilt ?? 30, azimuth = o.azimuth ?? (site.lat >= 0 ? 180 : 0), orient = o.orient || 'portrait';
const perRow = Math.max(1, o.perRow | 0), stack = Math.max(1, o.stack | 0 || 1), rows = Math.max(1, o.rows | 0), gap = o.gap ?? 20, clearance = o.clearance ?? 500;
const across = orient === 'landscape' ? module.lengthMm : module.widthMm, along = orient === 'landscape' ? module.widthMm : module.lengthMm;
const tableAcross = perRow * across + (perRow - 1) * gap, slant = stack * along + (stack - 1) * gap;
const B = basisOf(tilt, azimuth, site), nh = bearing(azimuth, site);
const rule = o.pitchMm > 0 ? { pitchMm: o.pitchMm, shadowMm: o.pitchMm - slant * Math.cos(tilt * RAD), manual: true } : winterPitch({ slantMm: slant, tilt, azimuth, site, hours: o.hours || 0 });
if (rule.error) return { error: rule.error };
const pitch = rule.pitchMm, org = o.origin || [0, 0, 0], modules = [], tables = [];
for (let r = 0; r < rows; r++) {
const o0 = [org[0] - nh[0] * pitch * r, org[1] - nh[1] * pitch * r, org[2] + clearance];
const P = (s, t) => add(add(o0, B.u, s), B.v, t);
const corners = [P(-tableAcross / 2, 0), P(tableAcross / 2, 0), P(tableAcross / 2, slant), P(-tableAcross / 2, slant)];
if (o.contains && !corners.slice(0, 2).concat([[o0[0], o0[1], 0]]).every((q) => o.contains(q[0], q[1]))) continue;
tables.push({ row: r, corners });
for (let k = 0; k < stack; k++) for (let c = 0; c < perRow; c++) {
const s0 = -tableAcross / 2 + c * (across + gap), t0 = k * (along + gap);
modules.push({ row: r * stack + k, col: c, table: r, c: P(s0 + across / 2, t0 + along / 2), corners: [P(s0, t0), P(s0 + across, t0), P(s0 + across, t0 + along), P(s0, t0 + along)] });
}
}
return { modules, tables, pitchMm: r2(pitch), shadowMm: r2(rule.shadowMm), gcr: r2(slant * 1 / pitch), count: modules.length, kwp: r2(modules.length * module.wp / 1000), rule, u: B.u, v: B.v, n: B.n, across, along, tilt, azimuth };
}
In detail
Method
Triangle normals by cross product; area-weighted mean normal per face; barycentric point-in-triangle tests; grid of module positions tried at 4 × 4 offsets; row pitch = slant × cos(tilt) + height ÷ tan(sun altitude) × cos(azimuth difference). The file's header cites Duffie and Beckman for the spacing rule.
Number types
64-bit numbers; Float64Array for the face's triangles in its own axes.
Tolerances
Face grouping 0.5° and 30 mm, compared with the first triangle of the group. Triangles with a cross product shorter than 1e-6 mm² are skipped. A plane under 0.5° of tilt is flat. Inside test slack 1 mm. Minimum face 0.5 m². Sun altitude must be above 1°.
Limits
The nine-point test can accept a module that crosses a narrow notch in the face. The tilt and azimuth of a face are rounded to 0.01° before they are passed on.
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.