Garden Sun Simulation
Scrub the time of day, or aggregate a season into a sun-hours heatmap, to find the sunniest spots in a real garden before you plant.
Problem
Before you plant anything, you want to know where the sun actually falls. That's hard to eyeball — it shifts hour to hour, month to month, as buildings, fences, and trees throw shadows that move. So: lay out a real garden as a grid of tiles, drop in whatever blocks light, then see the sun. Scrub the time of day and watch shadows sweep across the plot, or aggregate a whole season into a sun-hours heatmap that shows the sunniest and shadiest spots at a glance.
The real engineering problem was underneath: keep a real-time 3D sim responsive while it churns through a genuinely heavy amount of solar math.

Approach
A pure simulation core behind ports, swappable adapters at the edges. The core
— garden model, tiles, shadow-and-sun-hours math — has no DOM or engine
dependency, testable headlessly in plain Vitest. Adapters plug into the ports:
a Three.js orthographic renderer for the isometric view, a SunCalc
provider for real solar positions at a given latitude and date, a Web
Worker for the expensive seasonal aggregation. Domain language and the
decisions behind the seams live in the repo's CONTEXT.md and docs/adr.
- Two views, one model. An instantaneous mode (scrub time, watch one moment) and an aggregated mode (sum a season into a heatmap) both read from the same core — the renderer just draws whatever the core computes.
- Heavy math, off the main thread. Aggregating a season across thousands of tiles is the bottleneck. It runs in a Web Worker and never blocks the interaction.
Result
It holds 60fps at the design ceiling — a ~100×100, 10,000-tile garden —
scrubbing time or computing a season's heatmap. Getting there meant clearing
two bottlenecks: moving the seasonal aggregation into the Web Worker, and
switching the tile grid from one mesh per tile to a single InstancedMesh.
The ports-and-adapters split paid off twice: the solar math verifies headlessly, no renderer needed, and the whole performance story — prior state, diagrams, the actual fixes — is documented, because the bottlenecks lived in identifiable, swappable pieces instead of smeared across the render loop.
