Counts the hours of direct sun each ground cell and each wall point of the building masses gets on chosen days, with the masses and the terrain casting shadows.
How the precision is kept
The count is a sampling: the sun is taken at the middle of every 15 minute step by default and a point is lit or not for the whole step. The ground result is accumulated and stored in a Float32Array, and its maximum is read back from it. The terrain used for shadows is also a 32-bit raster read by bilinear interpolation. Wall points accumulate in 64-bit. Sun directions are a Float64Array from drive.js.
Open item. Count lit samples per ground cell in whole numbers (or a Float64Array) and convert to hours once at the end, so the ground hours and their maximum are not accumulated in 32-bit.
Calculations under this policy
9 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.
sunhours.jsCivilCraft 0.8.1 32-bit in part9 calculationsReviewed
Tests bin/test-civilcraft-massing.mjs
Sun directions through a day
64-bit
The direction to the sun at the middle of each time step of a local day, and the hours it is above the horizon.
minute = (k + 0.5) × step; day length = samples above the horizon × step ÷ 60
Sample times are (k + 0.5) × step, a single multiplication, so they do not drift. Directions are stored in a Float64Array. The day length is a count of samples, so it is resolved to the step (15 minutes by default).
sunhours.js
lines 55 to 66
CivilCraft 0.8.1
export function sunSamples({ date, utcOffset, lat, lon, stepMin = 15 }) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(date));
if (!m) throw new Error('bad_date');
const day0 = Date.UTC(+m[1], +m[2] - 1, +m[3]);
const n = Math.round(1440 / stepMin), dirs = [], alt = [], az = [];
for (let k = 0; k < n; k++) {
const minute = (k + 0.5) * stepMin; // the middle of each step
const p = sunPosition(new Date(day0 + (minute - utcOffset * 60) * 60000), lat, lon);
if (p.altitude > 0) { const v = sunVector(p); dirs.push(v[0], v[1], v[2]); alt.push(p.altitude); az.push(p.azimuth); }
}
return { dirs: Float64Array.from(dirs), alt, az, hours: stepMin / 60, day: (dirs.length / 3) * (stepMin / 60) };
}
Terrain raster for shadows
32-bit in part
The ground elevations on a square grid, used to test whether the ground blocks the sun.
Elevations are stored in a Float32Array. They decide whether a ray is blocked and set the height of each ground sample point, so they act on the hour counts through yes or no decisions; no elevation from this raster is reported as a number.
sunhours.js
lines 68 to 73
CivilCraft 0.8.1
// ------------------------------------------------------------------ terrain
export function terrainRaster(elevAt, { minX, minY, maxX, maxY }, cell) {
const nx = Math.max(2, Math.ceil((maxX - minX) / cell) + 1), ny = Math.max(2, Math.ceil((maxY - minY) / cell) + 1), z = new Float32Array(nx * ny);
for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) { const v = elevAt(minX + i * cell, minY + j * cell); z[j * nx + i] = fin(v) ? v : NaN; }
return { x0: minX, y0: minY, cell, nx, ny, z };
}
Ray against a building mass
64-bit
Whether the ray from a point toward the sun passes through a mass.
t range from the mass's bottom and top; slab test on the footprint box; crossings of the sun's line with each footprint edge, inside between every other pair
Plain 64-bit arithmetic on footprint coordinates held local to the site's middle. The edge crossing divides by da − db only when the two have different signs, so the divisor is a sum of magnitudes. A ray that grazes a mass for less than 1e-9 in ray parameter does not count as blocked.
sunhours.js
lines 100 to 132
CivilCraft 0.8.1
/** Does the ray from (px, py, pz) toward direction s pass through the prism? */
function hitsPrism(px, py, pz, sx, sy, sz, P) {
if (sz <= 1e-9) return false;
let lo = Math.max((P.zlo - pz) / sz, 1e-9), hi = (P.zhi - pz) / sz;
if (hi <= lo) return false;
const h2 = sx * sx + sy * sy;
if (h2 < 1e-18) { // the sun overhead: the ray is a vertical line
if (px < P.x0 || px > P.x1 || py < P.y0 || py > P.y1) return false;
return inPoly(px, py, P);
}
for (const [p, s, a, b] of [[px, sx, P.x0, P.x1], [py, sy, P.y0, P.y1]]) { // the box of the footprint, as a slab on each axis
if (Math.abs(s) < 1e-12) { if (p < a || p > b) return false; continue; }
let t1 = (a - p) / s, t2 = (b - p) / s;
if (t1 > t2) { const q = t1; t1 = t2; t2 = q; }
if (t1 > lo) lo = t1;
if (t2 < hi) hi = t2;
if (hi <= lo) return false;
}
// where the sun's line (the whole line) crosses the footprint's edges, as t along it; inside between every other pair
const L = Math.sqrt(h2), ux = sx / L, uy = sy / L, n = P.n;
const ts = [];
for (let i = 0; i < n; i++) {
const j = i + 1 === n ? 0 : i + 1;
const da = ux * (P.ys[i] - py) - uy * (P.xs[i] - px), db = ux * (P.ys[j] - py) - uy * (P.xs[j] - px);
if ((da > 0) === (db > 0)) continue;
const f = da / (da - db), qx = P.xs[i] + (P.xs[j] - P.xs[i]) * f - px, qy = P.ys[i] + (P.ys[j] - P.ys[i]) * f - py;
ts.push((qx * ux + qy * uy) / L); // t in units of the ray parameter (sx, sy have length L)
}
if (ts.length < 2) return false;
ts.sort((a, b) => a - b);
for (let k = 0; k + 1 < ts.length; k += 2) if (ts[k] < hi && ts[k + 1] > lo && Math.min(ts[k + 1], hi) - Math.max(ts[k], lo) > 1e-9) return true;
return false;
}
Ray against the ground
64-bit
Whether a point sees the sun: no mass in the way and no ground rising above the ray.
march one cell at a time: x += dx, y += dy, z += rise; blocked when ground > z + 0.001
The ray position is built by repeated addition over at most 600 cells and compared with a bilinear reading of the 32-bit terrain raster, with a fixed margin of 0.001 in project units. The outcome is a yes or no per sample.
sunhours.js
lines 141 to 158
CivilCraft 0.8.1
function litLocal(world, px, py, pz, sx, sy, sz) {
if (sz <= 1e-9) return false;
for (const P of world.prisms) if (hitsPrism(px, py, pz, sx, sy, sz, P)) return false;
const T = world.terrain;
if (T && pz < world.zmax) { // march over the ground, a cell at a time, until the ray is above the highest ground
const h = Math.hypot(sx, sy);
if (h > 1e-9) {
const ds = T.cell, rise = (sz / h) * ds, dx = (sx / h) * ds, dy = (sy / h) * ds, kmax = Math.min(600, Math.ceil((world.zmax - pz) / rise));
let x = px, y = py, zz = pz;
for (let k = 1; k <= kmax; k++) {
x += dx; y += dy; zz += rise;
const g = sampleRaster(T, x, y);
if (g > zz + 1e-3) return false;
}
}
}
return true;
}
Hours of sun per ground cell and wall point
32-bit in part
Adds one time step to every ground cell and wall point that is lit at each sun sample, then averages over the days.
hours += step for each lit sample; mean = hours ÷ days; max = largest ground mean
The ground hours are added up in a Float32Array, divided by the number of days in place, and the maximum is compared and taken from the stored 32-bit values. With the default 15 minute step each addition is exact in 32-bit; with a step that is not a binary fraction of an hour each addition rounds. Wall point hours are plain 64-bit numbers.
sunhours.js
lines 198 to 219
CivilCraft 0.8.1
let daylight = 0;
for (const day of days) {
const sun = sunSamples({ date: day.date, utcOffset: day.utcOffset, lat, lon, stepMin });
daylight += sun.day;
const D = sun.dirs, ns = D.length / 3;
for (let s = 0; s < ns; s++) {
const sx = D[3 * s], sy = D[3 * s + 1], sz = D[3 * s + 2];
for (let k = 0; k < hours.length; k++) {
if (masked[k]) continue;
const i = k % nx, j = (k - i) / nx;
if (litLocal(world, gx0 + (i + 0.5) * cell - origin.x, gy0 + (j + 0.5) * cell - origin.y, gz[k], sx, sy, sz)) hours[k] += sun.hours;
}
for (const F of facades) {
if (F.nx * sx + F.ny * sy <= 1e-9) continue; // the wall faces away from the sun
const eps = 0.02 * Math.max(1, F.length / 50);
for (const p of F.pts) if (litLocal(world, p.x + F.nx * eps, p.y + F.ny * eps, p.z, sx, sy, sz)) p.hours += sun.hours;
}
}
}
const nd = days.length;
let max = 0;
for (let k = 0; k < hours.length; k++) { if (masked[k]) hours[k] = -1; else { hours[k] /= nd; if (hours[k] > max) max = hours[k]; } }
Mean hours per floor and per wall
64-bit
The mean hours of sun on each floor of each wall and on the wall as a whole.
floor mean = Σ point hours ÷ points; wall mean = Σ floor means ÷ floors
64-bit sums and divisions over the wall's sample points, added in order.
Joins the results of several runs of the same site into one, weighting each by its number of days.
hours = Σ (hoursᵢ × daysᵢ) ÷ Σ daysᵢ
The weighted sum is formed in 64-bit from the 32-bit ground values of each part and stored in a new Float32Array; the maximum is read back from that array. Wall values are weighted in 64-bit.
sunhours.js
lines 230 to 246
CivilCraft 0.8.1
/** Results of the same site over different days (what several workers return) as one: means weighted by their number of days. */
export function mergeResults(parts) {
if (parts.length === 1) return parts[0];
const total = parts.reduce((s, p) => s + p.days, 0), first = parts[0];
const hours = new Float32Array(first.ground.hours.length);
let max = 0;
for (let k = 0; k < hours.length; k++) {
if (first.ground.masked[k]) { hours[k] = -1; continue; }
let v = 0; for (const p of parts) v += p.ground.hours[k] * p.days;
hours[k] = v / total; if (hours[k] > max) max = hours[k];
}
const facades = first.facades.map((f, i) => {
const floors = f.floors.map((_, k) => parts.reduce((s, p) => s + p.facades[i].floors[k] * p.days, 0) / total);
return { ...f, floors, mean: floors.reduce((s, v) => s + v, 0) / floors.length, points: f.points.map((q, n) => ({ ...q, hours: parts.reduce((s, p) => s + p.facades[i].points[n].hours * p.days, 0) / total })) };
});
return { origin: first.origin, days: total, daylight: parts.reduce((s, p) => s + p.daylight * p.days, 0) / total, stepMin: first.stepMin, ground: { ...first.ground, hours, max }, facades };
}
Ground hours as CSV
32-bit in part
Writes each ground cell's center and its hours of sun a day.
x, y to 3 decimals; hours to 2 decimals
The hours are read from the 32-bit result array and written with toFixed(2); cell centers are computed in 64-bit and written with toFixed(3).
sunhours.js
lines 260 to 264
CivilCraft 0.8.1
export function groundCsv(r) {
const g = r.ground, rows = [['x', 'y', 'sun_hours_per_day']];
for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) { const h = g.hours[j * g.nx + i]; if (h >= 0) rows.push([(g.x0 + (i + 0.5) * g.cell).toFixed(3), (g.y0 + (j + 0.5) * g.cell).toFixed(3), h.toFixed(2)]); }
return rows.map((row) => row.join(',')).join('\n') + '\n';
}
Wall hours as CSV
64-bit
Writes each wall's facing direction and the hours of sun a day on each floor.
facing = round((90 − normal·180 ÷ π) mod 360); hours to 2 decimals
64-bit values; the facing direction is rounded to a whole degree and the hours to 2 decimals for the file.
sunhours.js
lines 255 to 259
CivilCraft 0.8.1
export function facadeCsv(r) {
const rows = [['mass', 'wall', 'facing_deg', 'floor', 'sun_hours_per_day']];
for (const f of r.facades) f.floors.forEach((h, k) => rows.push([f.name, f.edge + 1, Math.round(((90 - (f.normal * 180) / Math.PI) % 360 + 360) % 360), k + 1, h.toFixed(2)]));
return rows.map((row) => row.map(csvCell).join(',')).join('\n') + '\n';
}
In detail
Method
Sun position from drive.js (the file names NOAA and Meeus). A ray toward the sun is tested against each mass as a prism (line against footprint edges, clipped to its height) and marched over the terrain raster one cell at a time. Hours = lit samples × step.
Number types
Float64Array for sun directions and footprints (local to the site's middle); Float32Array for the terrain raster, the ground heights and the ground hours; 64-bit numbers for wall hours.
Tolerances
Sun counted when altitude > 0; ray ignored when its upward part is 1e-9 or less; prism overlap must exceed 1e-9 in ray parameter; ground must be more than 0.001 above the ray to block it; ground sample points are lifted 0.01; wall points are moved out 0.02 × max(1, wall length ÷ 50); the terrain march stops after 600 cells.
Limits
Hours are resolved to the time step. Shadows from the ground are resolved to the raster cell. The terrain march is capped at 600 cells, so a hill farther away than that is not seen.
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.