Reads the roof planes from the model, plans an array on a roof or the ground, stores it in the file, and combines shading over the modules for the yield.
How the precision is kept
Roof and obstruction triangles are read from the IFC library's mesh, whose vertex buffer is a Float32Array, so the roof planes, their areas and the module positions fitted to them start from 32-bit coordinates. The array is then stored as text with module centers rounded to 0.1 mm and its plane's axes to 6 decimals, and read back from that text.
Open item. Read roof and obstruction triangles from 64-bit vertex positions instead of the mesh's 32-bit buffer.
Calculations under this policy
7 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-calc.jsBimCraft 0.8.1 32-bit in part7 calculationsReviewed
Tests tools/bimcraft-test/eg-pv-test.mjs
Site position and time zone
64-bit
The latitude, longitude, north direction and time zone used for the sun.
time zone = round(longitude ÷ 15) unless given
Latitude and longitude are taken as typed or as found in the model and must lie within ±90 and ±180. The default time zone is the longitude divided by 15 and rounded to a whole hour.
Every placed triangle of the model that can shade or carry modules.
mm = mesh position × unit in mm
The positions come from productTriangles in ifc-util.js, which reads the IFC library's vertex buffer; that buffer is a Float32Array (ifc/geo-mesh.js interleaved). Roof faces, their area, tilt and azimuth, and the modules fitted to them are computed from these 32-bit coordinates.
pv-calc.js
lines 41 to 63
BimCraft 0.8.1
/** Placed triangles (mm) of every product that can shade or carry modules, except the PV devices themselves. */
export function modelTriangles(w) {
const api = w.api, mid = w.mid, mm = lengthUnitMm(api, mid), out = [];
const roofParent = new Set(tryIds(api, mid, 'IFCROOF', true));
const parent = new Map();
for (const rid of tryIds(api, mid, 'IFCRELAGGREGATES')) { const r = api.GetLine(mid, rid, false); for (const h of r?.RelatedObjects || []) parent.set(h.value, r.RelatingObject?.value); }
const pv = new Set(readArrays(w).map((a) => a.expressId));
for (const id of tryIds(api, mid, 'IFCPRODUCT', true)) {
if (pv.has(id)) continue;
const l = api.GetLine(mid, id, false);
const cls = String(l?.constructor?.name || '').toUpperCase();
if (!l || !l.Representation || SKIP.test(cls)) continue;
let t;
try { t = productTriangles(api, mid, id, mm); } catch { continue; }
if (!t.indices.length) continue;
const tris = new Array(t.indices.length * 3);
for (let i = 0; i < t.indices.length; i++) { const v = t.indices[i] * 3; tris[i * 3] = t.positions[v] * mm; tris[i * 3 + 1] = t.positions[v + 1] * mm; tris[i * 3 + 2] = t.positions[v + 2] * mm; }
const pre = String(val(l.PredefinedType) ?? '');
const roof = cls === 'IFCROOF' || roofParent.has(parent.get(id)) || (cls === 'IFCSLAB' && /ROOF/.test(pre));
out.push({ id, cls, guid: val(l.GlobalId), roof, tris });
}
return out;
}
Array tilt and power
64-bit
The tilt, azimuth and peak power recorded for a planned array.
n_z is clamped before Math.acos. Tilt, azimuth and kWp are rounded to 0.01. For a roof array the tilt and azimuth are then replaced by the roof face's values, which were already rounded to 0.01°.
The text that records an array's plane, module size and module centers.
normal, u, v: round to 1e-6; module centers and table corners: round to 0.1 mm
Module centers and table corners are rounded to 0.1 mm and the three direction vectors to 6 decimals before they are written as JSON. This text is what readArrays later rebuilds the array from.
Rebuilds each array's modules and corners from its stored definition.
corner = c ± u × across ÷ 2 ± v × along ÷ 2
Corners are recomputed from the 0.1 mm centers and the 6-decimal axis vectors, which are used as stored without normalizing. Peak power is recomputed and rounded to 0.01 kWp.
pv-calc.js
lines 122 to 138
BimCraft 0.8.1
export function readArrays(w) {
const out = [];
for (const cls of ['IFCSOLARDEVICE', 'IFCBUILDINGELEMENTPROXY']) {
for (const id of tryIds(w.api, w.mid, cls)) {
let ps = null;
try { ps = w.readPset(id, PV_PSET); } catch { ps = null; }
if (!ps || !ps.Definition) continue;
let d;
try { d = JSON.parse(String(ps.Definition)); } catch { continue; }
if (!d || d.ver !== 1 || !Array.isArray(d.modules)) continue;
const u = d.u, v = d.v, A = d.across, B = d.along;
const mods = d.modules.map(([x, y, z, row, col, table]) => { const c = [x, y, z]; return { c, row, col, table, corners: corners(c, u, v, A, B) }; });
out.push({ ...d, expressId: id, guid: w.guidOf(id), modules: mods, kwp: r2(mods.length * d.module.wp / 1000) });
}
}
return out;
}
Module frames in the level's axes
64-bit
Turns module centers and the array's directions from model axes into the level's own axes for the stored solids.
point′ = S⁻¹ · p; direction′ = S⁻¹ · d − S⁻¹ · 0
A direction is transformed as a point and the transformed origin is subtracted, which removes the translation. For a level far from the origin that subtraction is of two large nearly equal numbers. The frame is then written with 12-decimal directions and 6-decimal positions.
Each table's shade grid is weighted by its sample count and the sums are divided by the total, which is guarded against 0. Hours when the sun is down stay empty.
pv-calc.js
lines 185 to 202
BimCraft 0.8.1
/** The shade grid of each array: { [id]: frac[12][24] } and a summary. */
export function shadeArrays(arrays, months, items) {
const obs = obstructions([...items, ...tablesAsItems(arrays)]), grid = sunGridOf(months), out = {}, info = {};
for (const a of arrays) {
const groups = new Map();
for (const m of a.modules) { const t = m.table ?? 0; if (!groups.has(t)) groups.set(t, []); groups.get(t).push(m); }
let acc = null, total = 0;
for (const [t, mods] of groups) {
const r = shadeGrid(mods, a.normal, grid, obs, { owner: a.tables ? a.id + ':' + t : null });
if (!acc) acc = r.frac.map((row) => row.map((f) => (f === null ? null : 0)));
for (let m = 0; m < 12; m++) for (let h = 0; h < 24; h++) if (r.frac[m][h] !== null) acc[m][h] += r.frac[m][h] * r.samples;
total += r.samples;
}
out[a.id] = acc.map((row) => row.map((f) => (f === null ? null : f / (total || 1))));
info[a.id] = { samples: total };
}
return { frac: out, info };
}
In detail
Method
Mesh triangles transformed to file units and millimeters; tilt = acos(n_z); module corners rebuilt as center ± half sizes along the stored axes; shade fractions averaged over tables weighted by sample count.
Number types
Float32Array vertex positions from the mesh (ifc/geo-mesh.js interleaved); 64-bit numbers after that; the stored definition is JSON text.
Tolerances
Stored definition: module centers and table corners 0.1 mm, normal and axes 1e-6, tilt and power 0.01. A normal with horizontal part under 0.0087 has no azimuth.
Limits
A 32-bit coordinate carries about 7 digits. Arrays read back from the file are rebuilt from the rounded definition, and its axis vectors are not normalized again.
Approach: 32-bit step, flagged
One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.