diff --git a/explainers/wind-farm/chapters.html b/explainers/wind-farm/chapters.html
new file mode 100644
index 00000000..691ae883
--- /dev/null
+++ b/explainers/wind-farm/chapters.html
@@ -0,0 +1,546 @@
+
+
01
+
Twenty-seven turbines and one windy day
+
+ This is a wind farm: 27 turbines spread over rolling fields. Each one is a
+ Vestas V150. At full power it makes 4.2 megawatts (MW). A megawatt is a million
+ watts. All together, the farm can make about 113 MW. Målarberget in Sweden is a real farm just
+ like it: 27 of these turbines, running since 2021.
+
+
+ The turbines stand in rows across the prevailing wind, the
+ direction the wind blows from most often. Here, that is west. Engineers measure the gaps in
+ rotor widths, called D. One D is the width of a rotor from tip to tip, 150 m here. The rows are
+ 7 D apart, or 1,050 m. In each row, the turbines are 4 D apart, or 600 m. The middle row is
+ shifted sideways, so no turbine stands right behind another.
+
+
+ From above, the farm looks crowded, but most of the land is still fields. For each megawatt,
+ only about 0.3 hectares is built on. A hectare is a square 100 m on each side. Most of that
+ built land is access roads, not turbine bases.
+ Collector cables gather the power from every turbine. They carry it to a
+ substation. There it joins the grid, the web of power lines that feeds homes
+ and towns.
+
+
+ Målarberget's real layout is not published, so this one is the model's own. The windy day is
+ made up too, and it plays on a loop. It is not an average day. It packs a calm night, a breezy
+ morning, a full-power afternoon and an evening storm into 24 hours. The clock runs many times
+ faster than real time, yet every rotor turns at its real speed. Roads, cables and wind arrows
+ are drawn wider than they really are, so you can see them from far away. The light stays the
+ same all day and night.
+
+
+
+
Turbines
+
27
+
+
+
Full power of the farm
+
113.4 MW
+
+
+
Between rows, along the wind
+
1,050 m, 7 D
+
+
+
Between turbines in a row
+
600 m, 4 D
+
+
+
Land built on
+
34 ha
+
+
+
Typical project area for this size
+
39 km²
+
+
+
+
+
+
02
+
Blades that fly in circles
+
+ Up close, one turbine is huge. The rotor is three blades fixed to a round hub,
+ and it is 150 m across. The steel tower lifts the hub 105 m above the ground.
+ At the top of its turn, a blade tip reaches about 180 m, almost twice as tall as Big Ben. Up
+ there, the wind is faster than near the ground, as chapter 5 shows.
+
+
+ Each blade is about 74 m long, almost the wingspan of an Airbus A380, the
+ biggest airliner. It is made of carbon and glass fibre set in epoxy, a hard plastic glue. The
+ wind does not push a blade like a sail. It flows over the blade the way it flows over a plane's
+ wing. The curved shape creates lift, the same force that holds a plane up. Lift
+ pulls each blade round, and the rotor turns.
+
+
+ At full speed, the rotor turns only about 10 times a minute, or 10 rpm (revolutions per minute).
+ That is one turn every 6 seconds, which looks lazy. But the tips travel round a huge circle, so
+ they move at about 80 metres per second (m/s), close to 300 km/h. In this model, the tips move 8
+ times as fast as the wind until the rotor reaches its top speed. Drag the wind slider and watch
+ the rotor speed up with the wind. Pick Follow the day to hand the wind back to the clock.
+
+
+ The circle the blades sweep is the swept area: about 17,700 m², roughly 2.5
+ football pitches. Only wind that passes through this circle can turn the rotor. The airflow
+ lines round the rotor are only a drawing, since real air is invisible.
+
+
+ The turbine's transformer gets its power ready for the cables, as chapter 6
+ explains. Where the V150 keeps it is not published, so this model puts it at the foot of the
+ tower. The real rotor also tilts up slightly, but that angle is not published either, so the
+ model keeps it level.
+
+
+
+
+
+
+
+
+
Rotor speed
+
10.4 rpm
+
+
+
One turn takes
+
5.8 s
+
+
+
+
+
+
+
+
+
+
Blade tip speed
+
82 m/s, 290 km/h
+
+
+
Tip height
+
180 m
+
+
+
Blade length
+
74 m
+
+
+
Swept area
+
17,671 m², about 2.5 football pitches
+
+
+
+
+
+
03
+
Inside the nacelle
+
+ The box behind the hub is the nacelle, 12.8 m long. It holds the machines that
+ turn the rotor's slow, strong spin into electricity. Pick Cover off to look inside. The machines
+ all stand on the bedplate, a heavy cast iron frame. A
+ cooler on the roof carries their heat away.
+
+
+ The angle of a blade to the wind is its pitch. It sets how much of the wind the
+ blade can grab. Each blade sits in the hub on a bearing, a ring that lets it
+ twist. Three pitch cylinders, one per blade, twist the blades with oil
+ pressure. Behind the hub, the main shaft turns in its
+ main bearing at the rotor's slow speed, about 10 rpm.
+
+
+ The generator needs a fast spin, so a gearbox sits between it and the main
+ shaft. The gearbox works in three steps. In the first two, small gears circle round a central
+ gear, like planets round the sun. The last step uses gears with slanted teeth. Together they
+ make the output spin about 140 times faster, up to about 1,485 rpm. The
+ generator makes electricity with copper coils, without permanent magnets. Its
+ power comes out at 800 volts. All of it then passes through the converter,
+ which reshapes it to keep in step with the grid.
+
+
+ A disc brake sits on the shaft. It is not what stops the turbine in a storm.
+ The blades do that, by turning edge-on to the wind. The brake only holds the rotor still once it
+ has slowed down, to park it. On top of the tower, yaw drives turn the whole
+ nacelle to face the wind. This turning is called yaw. In this model, the nacelle turns with the
+ wind at once, because the V150's real turning speed is not published.
+
+
+
+
+
+
+
+
Main shaft
+
10.4 rpm
+
+
+
Generator
+
1,485 rpm
+
+
+
Gear ratio
+
about 1 to 140
+
+
+
Blade pitch
+
4°
+
+
+
Disc brake
+
Released
+
+
+
Generator voltage
+
800 V
+
+
+
+
+
+
04
+
How much wind a turbine can catch
+
+ A turbine starts making power in a light breeze of 3 m/s, the cut-in speed.
+ From there, the power climbs fast. The power in the wind grows with the speed cubed: speed times
+ speed times speed. Double the wind, and it carries eight times the power. So the turbine makes
+ about 0.3 MW at 4 m/s, about 2.5 MW at 8 m/s and its full 4.2 MW from about 12 m/s. Drag the
+ wind slider and watch the dot move along the curve.
+
+
+ No turbine can catch all of that power. To take energy, the rotor must slow the air down. But
+ the slowed air still has to flow away behind it. If the rotor stopped the air completely, no new
+ air could get through. The physicist Albert Betz showed that even a perfect rotor can take at
+ most 59.3 % of the wind's power. That is the Betz limit. This turbine's best is
+ just under half the wind's power, at about 7 to 8 m/s. The chart shows how close it comes.
+
+
+ Above about 12 m/s, the wind brings more power than the generator can take. So the pitch
+ cylinders turn the blades a little out of the wind, and more of it slips past. At 15 m/s, the
+ turbine keeps only about 12 % of the power in the wind. Above 20 m/s, it starts to ramp down,
+ lowering its power step by step. The pitch angles the model shows in strong wind are its own
+ guess, since the V150's are not published.
+
+
+ At the cut-out speed, about 22 to 25 m/s, the wind is too strong and the
+ turbine shuts down. The blades turn edge-on to the wind. This is called
+ feathering. The rotor slows down on its own, and then the disc brake parks it.
+ The turbine starts again only once the wind has dropped by about 2 m/s. In this model, all 27
+ turbines stop together at 24.5 m/s and restart at 22.5 m/s. Stopping and restarting each take 20
+ minutes on the model's clock, longer than in real life, so you have time to watch.
+
+
+
+
+
+
+
+
+
Turbine state
+
Partial load
+
+
+
Turbine output
+
2.55 MW
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Power in the wind, the Betz limit and this turbine's output, for winds from 0 to 30 m/s. The
+ dot marks the wind now.
+
+
+
+
+
Power in the wind
+
5.54 MW
+
+
+
Share of the wind's power it catches
+
46 %
+
+
+
Share of the Betz limit
+
78 %
+
+
+
Blade pitch
+
0°
+
+
+
+
+
+
05
+
Why turbines stand so far apart
+
+ A turbine takes energy out of the wind, so the air behind it moves slower. This trail of slow,
+ stirred-up air is called a wake, like the trail behind a boat. Wakes (K) draws
+ a plume behind every turning rotor. Switch it off to compare. Wakes are strongest in medium
+ winds, when the rotor holds back the most air. In stronger wind, the blades let more air slip
+ past, and the wakes fade.
+
+
+ The wakes here follow the Jensen model, a simple recipe engineers use a lot. It
+ treats each wake as a cone that widens and slowly fills back up with faster air. One number sets
+ how fast the cone widens: 0.075 here, the usual value on land. At 8 m/s, a turbine 7 D downwind
+ still meets air about 13 % slower. Since power follows the speed cubed, that costs it about a
+ third of its power.
+
+
+ Over a year, wakes cost a farm about 5 to 15 % of its energy. In a Vestas example farm of 24 of
+ these turbines, the loss is about 6 %. From one unlucky direction, where turbines line up one
+ behind another, the loss can pass 30 %. That is why rows usually stand 5 to 9 D apart along the
+ wind, and turbines 3 to 5 D apart across it. Try 5, 7 and 9 D: wider gaps lose less to wakes but
+ need more land. In this model, the wind also swings a few degrees either side of west through
+ the day, so the nacelles turn a little.
+
+
+ Wind also gets stronger with height, because the ground slows down the air near it. Airflow (F)
+ shows this: the top of the rotor meets faster wind than the bottom. The plumes and arrows are
+ only drawings. The wake model also cuts corners. It treats the ground as flat, leaves no wake
+ behind a stopped rotor and only roughly adds up wakes that overlap.
+
+
+
+
+
+
+
+
+
Gap between rows
+
1,050 m, 7 D
+
+
+
Slowdown in the wake, one row behind
+
13 %
+
+
+
Farm power lost to wakes now
+
4.9 %
+
+
+
Wind at the top of the rotor
+
8.3 m/s
+
+
+
Wind at the bottom of the rotor
+
6.1 m/s
+
+
+
+
+
+
06
+
The road to the grid
+
+ Each generator makes power at 800 volts (V). Voltage is the push that drives electricity along a
+ wire. After the converter, the turbine's own transformer raises the voltage.
+ With a stronger push, the same power needs less current, the flow of electricity. Less current
+ means the cables waste less power as heat. Cables (C) shows the power's path from the turbines
+ to the grid.
+
+
+ Collector cables at 36,000 volts, or 36 kilovolts (kV), link the turbines to
+ the substation. There, another transformer raises the voltage again, to 110 kV,
+ for the grid line. These voltages come from a Vestas example farm in Germany.
+
+
+ A megawatt hour (MWh) is the energy of one megawatt running for one hour. At full power, one
+ turbine makes 4.2 MWh every hour. But the wind is not always strong. Over a year, a turbine
+ averages about 40 % of its full power. That share is its capacity factor. In a
+ year, one turbine makes about 14,700 MWh, enough for about 1,400 US homes. On the typical and
+ windy sites, the model's made-up day is windier than a real average day, so the capacity factor
+ it shows comes out higher.
+
+
+ Building a turbine also uses energy and releases carbon. Over its whole life, it causes about 7
+ g of CO2e for each kilowatt hour (kWh) it makes. CO2e counts every greenhouse gas as if it were
+ carbon dioxide. A turbine pays back the energy used to build it in about 8 months. It is
+ designed to last 20 years and often runs longer. Its blades, though, are still hard to recycle.
+ Pick a calm, typical or windy site to see how much the everyday wind matters. Only the everyday
+ wind changes. The evening storm is the same at every site.
+
+
+
+
+
+
+
+
+
Farm output now
+
113.4 MW
+
+
+
Energy made today
+
1,250 MWh
+
+
+
US homes this output could run
+
92,200
+
+
+
This day's capacity factor
+
43 %
+
+
+
Voltage at each step
+
800 V, 36 kV, 110 kV
+
+
+
One turbine in a year
+
14,692 MWh
+
+
+
US homes one turbine supplies
+
1,400
+
+
+
Carbon footprint
+
7.3 g CO2e per kWh
+
+
+
Energy payback
+
7.6 months
+
+
+
diff --git a/explainers/wind-farm/explainer.json b/explainers/wind-farm/explainer.json
new file mode 100644
index 00000000..4c989605
--- /dev/null
+++ b/explainers/wind-farm/explainer.json
@@ -0,0 +1,12 @@
+{
+ "slug": "wind-farm",
+ "tags": ["energy", "mechanics", "weather"],
+ "cover": "cover.webp",
+ "entry": "src/index.ts",
+ "chapters": "chapters.html",
+ "locales": ["en", "zh", "es", "uk", "pt", "fr", "de", "ja"],
+ "social": {
+ "image": "social/og-image.png",
+ "alt": "A wind farm on rolling land with one turbine close up, beside the title How a wind farm works"
+ }
+}
diff --git a/explainers/wind-farm/locales/de.json b/explainers/wind-farm/locales/de.json
new file mode 100644
index 00000000..26b5330a
--- /dev/null
+++ b/explainers/wind-farm/locales/de.json
@@ -0,0 +1,270 @@
+{
+ "meta": {
+ "title": "So funktioniert ein Windpark",
+ "eyebrow": "Erneuerbare Energie",
+ "tagline": "Siebenundzwanzig Windräder, ein windiger Tag: So macht ein Blatt in Flügelform aus bewegter Luft Strom fürs Netz.",
+ "description": "Ein 3D-Windpark, ein windiger Tag: wie Blätter den Wind einfangen, was sich im Windrad dreht, warum Windräder Abstand halten und wie Strom ins Netz kommt.",
+ "summary": "Die Anordnung des Parks, Turm und Rotorblätter, Getriebe und Generator, Leistungskurve und Betz-Grenze, Nachlauf und der Weg ins Netz.",
+ "socialAlt": "Ein Windpark in hügeliger Landschaft, vorn ein Windrad ganz nah, daneben der Titel „So funktioniert ein Windpark“"
+ },
+ "stage": {
+ "label": "Interaktives Modell eines Windparks",
+ "gauge": "Wind und Leistung live"
+ },
+ "timeline": {
+ "label": "Tageszeit",
+ "phases": "Zu einem Abschnitt des Tages springen",
+ "speed": "Wiedergabetempo",
+ "value": "{{time}}",
+ "during": "{{time}}, {{phase}}",
+ "speedValue": "{{value}} h/min",
+ "speedFormat": "{{value}} Stunden des Tages pro Minute",
+ "phase": {
+ "night": "Ruhige Nacht",
+ "morning": "Morgenbrise",
+ "afternoon": "Volllast",
+ "storm": "Sturm",
+ "evening": "Abend"
+ },
+ "jump": {
+ "night": "Zur ruhigen Nacht springen",
+ "morning": "Zur Morgenbrise springen",
+ "afternoon": "Zur Volllast springen",
+ "storm": "Zum Sturm springen",
+ "evening": "Zum Abend springen"
+ }
+ },
+ "controls": {
+ "label": "Steuerung des Modells",
+ "siteWind": "Wind am Standort",
+ "siteWindOptions": {
+ "calm": "Windarmer Standort",
+ "typical": "Typischer Standort",
+ "windy": "Windreicher Standort"
+ },
+ "spacingOptions": {
+ "5": "5 D",
+ "7": "7 D",
+ "9": "9 D"
+ },
+ "nacelleOptions": {
+ "closed": "Mit Haube",
+ "open": "Ohne Haube"
+ },
+ "windAtOptions": {
+ "day": "Tagesverlauf",
+ "cutIn": "Einschalten, 3 m/s",
+ "peak": "Beste Ausbeute, 8 m/s",
+ "rated": "Volllast, 12 m/s",
+ "rampDown": "Abregeln, 23 m/s",
+ "cutOut": "Abgeschaltet, 25 m/s"
+ },
+ "groups": {
+ "siteWind": "Wind am Standort",
+ "spacing": "Abstand der Reihen",
+ "nacelle": "Gondelhaube",
+ "windAt": "Windgeschwindigkeit"
+ },
+ "views": {
+ "streamlines": {
+ "name": "Luftströmung",
+ "short": "Luft",
+ "hint": "Luftströmung (F)"
+ },
+ "wakes": {
+ "name": "Nachlauf",
+ "short": "Nachlauf",
+ "hint": "Nachlauf (K)"
+ },
+ "cables": {
+ "name": "Kabel",
+ "short": "Kabel",
+ "hint": "Kabel (C)"
+ },
+ "labels": {
+ "name": "Beschriftungen",
+ "short": "Namen",
+ "hint": "Beschriftungen (L)"
+ }
+ }
+ },
+ "readouts": {
+ "wind": "Wind",
+ "power": "Ein Windrad",
+ "farm": "Ganzer Park",
+ "state": {
+ "idle": "Wartet auf Wind",
+ "partial": "Teillast",
+ "full": "Volllast",
+ "rampDown": "Regelt ab",
+ "stopping": "Fährt in Fahnenstellung",
+ "parked": "Geparkt",
+ "starting": "Läuft an"
+ }
+ },
+ "units": {
+ "metresPerSecond": "{{value}} m/s",
+ "speedPair": "{{ms}} m/s, {{kmh}} km/h",
+ "rpm": "{{value}} U/min",
+ "degrees": "{{value}}°",
+ "mw": "{{value}} MW",
+ "mwh": "{{value}} MWh",
+ "percent": "{{value}} %",
+ "metres": "{{value}} m",
+ "seconds": "{{value}} s",
+ "count": "{{value}}",
+ "hectares": "{{value}} ha",
+ "squareKm": "{{value}} km²",
+ "volts": "{{value}} V",
+ "gramsPerKwh": "{{value}} g CO2e pro kWh",
+ "months": "{{value}} Monate"
+ },
+ "chapters": {
+ "farm": {
+ "count": "Windräder",
+ "rated": "Volle Leistung des Parks",
+ "rows": "Zwischen den Reihen, in Windrichtung",
+ "across": "Zwischen den Windrädern einer Reihe",
+ "spacingValue": "{{metres}} m, {{diameters}} D",
+ "built": "Bebaute Fläche",
+ "project": "Typische Projektfläche bei dieser Größe"
+ },
+ "tower": {
+ "wind": "Wind auf Nabenhöhe",
+ "rpm": "Rotordrehzahl",
+ "turn": "Eine Umdrehung dauert",
+ "stopped": "Rotor steht",
+ "tip": "Tempo der Blattspitze",
+ "height": "Höhe der Blattspitze",
+ "blade": "Blattlänge",
+ "swept": "Rotorfläche",
+ "sweptValue": "{{area}} m², etwa {{pitches}} Fußballfelder"
+ },
+ "nacelle": {
+ "shaft": "Hauptwelle",
+ "generator": "Generator",
+ "gear": "Übersetzung",
+ "gearValue": "etwa 1 zu {{ratio}}",
+ "pitch": "Blattwinkel",
+ "brake": "Scheibenbremse",
+ "brakeValue": {
+ "released": "Gelöst",
+ "holding": "Hält den Rotor"
+ },
+ "volts": "Generatorspannung"
+ },
+ "curve": {
+ "wind": "Windgeschwindigkeit",
+ "state": "Zustand des Windrads",
+ "power": "Leistung des Windrads",
+ "caption": "Leistung im Wind, Betz-Grenze und Leistung dieses Windrads bei Wind von 0 bis 30 m/s. Der Punkt zeigt den aktuellen Wind.",
+ "axisWind": "Windgeschwindigkeit (m/s)",
+ "axisPower": "Leistung (MW)",
+ "lineWind": "Leistung im Wind",
+ "lineBetz": "Betz-Grenze",
+ "lineTurbine": "Dieses Windrad",
+ "windPower": "Leistung im Wind",
+ "cp": "Genutzter Anteil der Leistung im Wind",
+ "betz": "Anteil an der Betz-Grenze",
+ "pitch": "Blattwinkel"
+ },
+ "wakes": {
+ "spacing": "Abstand der Reihen",
+ "deficit": "Windabschwächung im Nachlauf, eine Reihe dahinter",
+ "loss": "Aktueller Verlust durch Nachlauf",
+ "top": "Wind oben am Rotor",
+ "bottom": "Wind unten am Rotor"
+ },
+ "grid": {
+ "output": "Aktuelle Leistung des Parks",
+ "energy": "Heute erzeugte Energie",
+ "homes": "So viele US-Haushalte könnte diese Leistung versorgen",
+ "capacity": "Kapazitätsfaktor dieses Tages",
+ "voltages": "Spannung auf jeder Stufe",
+ "voltagesValue": "{{generator}} V, {{collector}} kV, {{grid}} kV",
+ "annual": "Ein Windrad in einem Jahr",
+ "annualHomes": "So viele US-Haushalte versorgt ein Windrad",
+ "carbon": "CO2-Fußabdruck",
+ "payback": "Energetische Amortisation"
+ }
+ },
+ "parts": {
+ "land": "Boden",
+ "foundation": "Fundament",
+ "tower": "Turm",
+ "transformer": "Transformator",
+ "towerCable": "Stromkabel",
+ "nacelle": "Gondel",
+ "cooler": "Kühler",
+ "bedplate": "Maschinenträger",
+ "hub": "Nabe",
+ "blades": "Rotorblätter",
+ "pitchCylinders": "Pitchzylinder",
+ "mainBearing": "Hauptlager",
+ "mainShaft": "Hauptwelle",
+ "gearbox": "Getriebe",
+ "brakeDisc": "Scheibenbremse",
+ "generator": "Generator",
+ "converter": "Umrichter",
+ "yawDrives": "Azimutantriebe",
+ "sweptArea": "Rotorfläche",
+ "streamlinesGroup": "Luftströmung",
+ "heroWake": "Nachlauf",
+ "farmLand": "Ackerland",
+ "farmTurbines": "27 Windräder",
+ "accessRoads": "Zufahrtswege",
+ "spacingMarker": "Reihenabstand",
+ "prevailingWind": "Hauptwindrichtung",
+ "windArrows": "Wind",
+ "shearProfile": "Wind je nach Höhe",
+ "wakePlumes": "Nachläufe",
+ "collectorCables": "Sammelkabel",
+ "substation": "Umspannwerk",
+ "gridLine": "Netzleitung"
+ },
+ "sections": {
+ "farm": {
+ "title": "Siebenundzwanzig Windräder und ein windiger Tag",
+ "p1": "Das ist ein Windpark: 27 Windräder, verteilt über sanft hügelige Felder. Alle sind vom Typ Vestas V150. Unter Volllast, also mit voller Kraft, liefert jedes 4,2 Megawatt (MW). Ein Megawatt sind eine Million Watt. Zusammen schafft der Park etwa 113 MW. Målarberget in Schweden ist ein echter Park genau wie dieser: 27 solche Windräder, in Betrieb seit 2021.",
+ "p2": "Die Windräder stehen in Reihen quer zur Hauptwindrichtung. Das ist die Richtung, aus der der Wind am häufigsten weht. Hier kommt er aus Westen. Fachleute messen die Abstände in Rotordurchmessern, kurz D. Ein D ist die Breite des Rotors von Blattspitze zu Blattspitze, hier 150 m. Die Reihen stehen 7 D auseinander, also 1.050 m. Innerhalb einer Reihe liegen 4 D zwischen den Windrädern, also 600 m. Die mittlere Reihe ist seitlich versetzt. So steht kein Windrad direkt hinter einem anderen.",
+ "p3": "Von oben wirkt der Park dicht gedrängt, doch der größte Teil der Fläche bleibt Acker. Pro Megawatt sind nur etwa 0,3 Hektar wirklich bebaut. Ein Hektar ist ein Quadrat mit 100 m Seitenlänge. Bebaut sind vor allem Zufahrtswege, nicht die Sockel der Windräder. Sammelkabel holen den Strom bei jedem Windrad ab. Sie bringen ihn zu einem Umspannwerk. Dort fließt er ins Stromnetz, das Geflecht aus Leitungen, das Häuser und Städte mit Strom versorgt.",
+ "p4": "Der echte Lageplan von Målarberget ist nicht veröffentlicht. Die Anordnung hier ist deshalb frei gewählt. Auch der windige Tag ist erfunden und läuft in einer Schleife. Er ist kein Durchschnittstag. In 24 Stunden stecken eine ruhige Nacht, eine Morgenbrise, ein Nachmittag unter Volllast und ein Sturm am Abend. Die Uhr läuft um ein Vielfaches schneller als in Wirklichkeit. Trotzdem dreht sich jeder Rotor in seinem echten Tempo. Wege, Kabel und Windpfeile sind breiter gezeichnet, als sie wirklich sind. So sehen Sie sie auch aus großer Entfernung. Das Licht bleibt Tag und Nacht gleich."
+ },
+ "tower": {
+ "title": "Blätter, die im Kreis fliegen",
+ "p1": "Aus der Nähe ist ein einzelnes Windrad riesig. Der Rotor besteht aus drei Blättern an einer runden Nabe. Er misst 150 m im Durchmesser. Der Turm aus Stahl hebt die Nabe 105 m über den Boden. Steht ein Blatt ganz oben, reicht seine Spitze etwa 180 m hoch, fast doppelt so hoch wie Big Ben. Dort oben weht der Wind schneller als am Boden, wie Kapitel 5 zeigt.",
+ "p2": "Jedes Rotorblatt ist etwa 74 m lang. Das ist fast die Spannweite eines Airbus A380, des größten Passagierflugzeugs. Das Blatt besteht aus Kohle- und Glasfasern in Epoxidharz, einem harten Kunststoffkleber. Der Wind drückt nicht dagegen wie gegen ein Segel. Er strömt darüber wie über die Tragfläche eines Flugzeugs. Die gewölbte Form erzeugt dabei Auftrieb, dieselbe Kraft, die ein Flugzeug in der Luft hält. Der Auftrieb zieht jedes Blatt im Kreis herum, und der Rotor dreht sich.",
+ "p3": "Bei voller Drehzahl schafft der Rotor nur etwa 10 Umdrehungen pro Minute, kurz 10 U/min. Das ist eine Umdrehung alle 6 Sekunden und wirkt gemächlich. Doch die Spitzen laufen auf einem riesigen Kreis. Deshalb erreichen sie etwa 80 Meter pro Sekunde (m/s), knapp 300 km/h. In diesem Modell sind die Spitzen 8-mal so schnell wie der Wind, bis der Rotor seine Höchstdrehzahl erreicht. Schieben Sie den Windregler und sehen Sie zu, wie der Rotor mit dem Wind schneller wird. Wählen Sie „Tagesverlauf“, dann bestimmt wieder die Uhr den Wind.",
+ "p4": "Beim Drehen überstreichen die Blätter einen Kreis, die Rotorfläche. Sie misst etwa 17.700 m², rund 2,5 Fußballfelder. Nur Wind, der durch diesen Kreis strömt, kann den Rotor antreiben. Die Strömungslinien um den Rotor sind nur gezeichnet, denn echte Luft ist unsichtbar.",
+ "p5": "Der Transformator des Windrads bereitet den Strom für die Kabel vor, wie Kapitel 6 erklärt. Wo die V150 ihn unterbringt, ist nicht veröffentlicht. Darum steht er in diesem Modell am Fuß des Turms. Der echte Rotor ist außerdem leicht nach oben geneigt. Auch dieser Winkel ist nicht veröffentlicht, deshalb steht der Rotor im Modell gerade."
+ },
+ "nacelle": {
+ "title": "Ein Blick in die Gondel",
+ "p1": "Der Kasten hinter der Nabe ist die Gondel, 12,8 m lang. Darin stecken die Maschinen, die aus der langsamen, kräftigen Drehung des Rotors Strom machen. Wählen Sie „Ohne Haube“, um hineinzusehen. Alle Maschinen stehen auf dem Maschinenträger, einem schweren Rahmen aus Gusseisen. Ein Kühler auf dem Dach führt ihre Wärme ab.",
+ "p2": "Der Winkel eines Blatts zum Wind heißt Pitch oder Blattwinkel. Davon hängt ab, wie viel Wind das Blatt zu fassen bekommt. Jedes Blatt sitzt in der Nabe auf einem Lager. Das ist ein Ring, in dem es sich drehen kann. Drei Pitchzylinder, einer pro Blatt, verstellen die Blätter mit Öldruck. Hinter der Nabe dreht sich die Hauptwelle in ihrem Hauptlager. Sie läuft so langsam wie der Rotor, mit etwa 10 U/min.",
+ "p3": "Der Generator braucht eine schnelle Drehung. Deshalb sitzt zwischen ihm und der Hauptwelle ein Getriebe. Es arbeitet in drei Stufen. In den ersten beiden kreisen kleine Zahnräder um ein mittleres, wie Planeten um die Sonne. Die letzte Stufe hat Zahnräder mit schrägen Zähnen. Zusammen machen sie die Drehung etwa 140-mal schneller, auf bis zu etwa 1.485 U/min. Der Generator erzeugt den Strom mit Kupferspulen, ganz ohne Dauermagnete. Er liefert ihn mit 800 Volt. Danach fließt der ganze Strom durch den Umrichter. Der formt ihn so um, dass er im Takt des Netzes bleibt.",
+ "p4": "Auf der Welle sitzt eine Scheibenbremse. Doch sie ist es nicht, die das Windrad im Sturm stoppt. Das erledigen die Blätter: Sie drehen ihre Kante in den Wind. Erst wenn der Rotor langsam geworden ist, hält die Bremse ihn fest. So wird er geparkt. Oben auf dem Turm drehen Azimutantriebe die ganze Gondel in den Wind. Diese Drehung heißt Windnachführung. In diesem Modell folgt die Gondel dem Wind sofort. Denn wie schnell sich die Gondel der echten V150 dreht, ist nicht veröffentlicht."
+ },
+ "curve": {
+ "title": "Wie viel Wind ein Windrad einfangen kann",
+ "p1": "Ab einer leichten Brise von 3 m/s fängt ein Windrad an, Strom zu erzeugen. Das ist die Einschaltgeschwindigkeit. Danach steigt die Leistung schnell an. Die Leistung im Wind wächst nämlich mit der dritten Potenz der Geschwindigkeit: Geschwindigkeit mal Geschwindigkeit mal Geschwindigkeit. Weht der Wind doppelt so schnell, trägt er achtmal so viel Leistung. Deshalb liefert das Windrad bei 4 m/s etwa 0,3 MW, bei 8 m/s etwa 2,5 MW und ab etwa 12 m/s seine vollen 4,2 MW. Schieben Sie den Windregler und sehen Sie zu, wie der Punkt über die Kurve wandert.",
+ "p2": "Kein Windrad kann diese ganze Leistung einfangen. Um Energie zu entnehmen, muss der Rotor die Luft bremsen. Doch die gebremste Luft muss hinter ihm noch abfließen können. Würde der Rotor die Luft ganz anhalten, käme keine neue mehr durch. Der Physiker Albert Betz hat gezeigt: Selbst ein perfekter Rotor kann höchstens 59,3 % der Leistung im Wind nutzen. Das ist die Betz-Grenze. Dieses Windrad schafft im besten Fall knapp die Hälfte der Leistung im Wind, bei etwa 7 bis 8 m/s. Das Diagramm zeigt, wie nah es der Grenze kommt.",
+ "p3": "Weht der Wind stärker als etwa 12 m/s, bringt er mehr Leistung, als der Generator aufnehmen kann. Deshalb drehen die Pitchzylinder die Blätter ein Stück aus dem Wind. So strömt mehr Wind ungenutzt vorbei. Bei 15 m/s nutzt das Windrad nur noch etwa 12 % der Leistung im Wind. Über 20 m/s beginnt es abzuregeln: Es senkt seine Leistung Schritt für Schritt. Die Blattwinkel, die das Modell bei starkem Wind zeigt, sind nur geschätzt. Die echten Werte der V150 sind nämlich nicht veröffentlicht.",
+ "p4": "Bei der Abschaltgeschwindigkeit, etwa 22 bis 25 m/s, ist der Wind zu stark, und das Windrad schaltet ab. Die Blätter drehen ihre Kante in den Wind. Das heißt Fahnenstellung, denn das Blatt steht nun wie eine Fahne im Wind. Der Rotor wird dabei von selbst langsamer, dann parkt ihn die Scheibenbremse. Das Windrad läuft erst wieder an, wenn der Wind um etwa 2 m/s nachgelassen hat. In diesem Modell halten alle 27 Windräder gemeinsam bei 24,5 m/s an und starten bei 22,5 m/s neu. Anhalten und Neustart dauern auf der Modelluhr jeweils 20 Minuten. Das ist länger als in Wirklichkeit, damit Sie in Ruhe zusehen können."
+ },
+ "wakes": {
+ "title": "Warum Windräder so viel Abstand halten",
+ "p1": "Ein Windrad nimmt dem Wind Energie. Deshalb strömt die Luft dahinter langsamer. Diese Spur aus langsamer, verwirbelter Luft heißt Nachlauf, ähnlich wie das Kielwasser hinter einem Boot. Die Ansicht „Nachlauf (K)“ zeichnet hinter jeden drehenden Rotor einen Schweif. Schalten Sie sie zum Vergleich aus. Am stärksten ist der Nachlauf bei mittlerem Wind. Dann bremst der Rotor die Luft am meisten. Bei stärkerem Wind lassen die Blätter mehr Luft vorbei, und der Nachlauf wird schwächer.",
+ "p2": "Die Nachläufe hier folgen dem Jensen-Modell, einer einfachen Rechenmethode, die Fachleute oft verwenden. Darin ist jeder Nachlauf ein Kegel. Er wird nach hinten breiter und füllt sich langsam wieder mit schnellerer Luft. Wie schnell der Kegel breiter wird, legt eine einzige Zahl fest: hier 0,075, der übliche Wert an Land. Bei 8 m/s trifft ein Windrad 7 D weiter hinten noch auf Luft, die etwa 13 % langsamer ist. Weil die Leistung der dritten Potenz der Geschwindigkeit folgt, verliert das Windrad dadurch etwa ein Drittel seiner Leistung.",
+ "p3": "Übers Jahr kostet der Nachlauf einen Windpark etwa 5 bis 15 % seiner Energie. In einem Beispielpark von Vestas mit 24 dieser Windräder sind es etwa 6 %. Bei einer besonders ungünstigen Windrichtung stehen die Windräder genau hintereinander im Wind. Dann kann der Verlust über 30 % steigen. Deshalb liegen zwischen den Reihen in Windrichtung meist 5 bis 9 D, zwischen den Windrädern quer dazu 3 bis 5 D. Probieren Sie 5, 7 und 9 D aus: Mit größeren Abständen geht weniger an den Nachlauf verloren, aber der Park braucht mehr Fläche. In diesem Modell pendelt der Wind außerdem im Lauf des Tages ein paar Grad um die Westrichtung. Darum drehen sich die Gondeln ein wenig mit.",
+ "p4": "Außerdem wird der Wind mit der Höhe stärker, weil der Boden die Luft in seiner Nähe bremst. Das zeigt die Ansicht „Luftströmung (F)“: Oben trifft der Rotor auf schnelleren Wind als unten. Die Schweife und Pfeile sind nur gezeichnet. Auch das Nachlaufmodell vereinfacht einiges. Es nimmt den Boden als flach an. Ein stehender Rotor hinterlässt darin keinen Nachlauf. Und wo sich Nachläufe überlagern, rechnet es sie nur grob zusammen."
+ },
+ "grid": {
+ "title": "Der Weg ins Netz",
+ "p1": "Jeder Generator erzeugt Strom mit 800 Volt (V). Die Spannung ist der Druck, der den Strom durch eine Leitung schiebt. Hinter dem Umrichter erhöht der eigene Transformator des Windrads die Spannung. Bei mehr Druck muss für dieselbe Leistung weniger Strom fließen. Und je weniger Strom fließt, desto weniger Energie geht in den Kabeln als Wärme verloren. Die Ansicht „Kabel (C)“ zeigt, welchen Weg der Strom von den Windrädern ins Netz nimmt.",
+ "p2": "Sammelkabel mit 36.000 Volt, also 36 Kilovolt (kV), verbinden die Windräder mit dem Umspannwerk. Dort erhöht ein weiterer Transformator die Spannung noch einmal, auf 110 kV für die Netzleitung. Diese Spannungen stammen aus einem Beispielpark von Vestas in Deutschland.",
+ "p3": "Eine Megawattstunde (MWh) ist die Energie, die ein Megawatt Leistung in einer Stunde liefert. Unter Volllast erzeugt ein Windrad jede Stunde 4,2 MWh. Doch der Wind weht nicht immer kräftig. Übers Jahr kommt ein Windrad im Schnitt auf etwa 40 % seiner vollen Leistung. Diesen Anteil nennt man Kapazitätsfaktor. In einem Jahr erzeugt ein Windrad so etwa 14.700 MWh. Das reicht für etwa 1.400 Haushalte in den USA. Am typischen und am windreichen Standort ist der erfundene Tag des Modells windiger als ein echter Durchschnittstag. Darum fällt der Kapazitätsfaktor im Modell höher aus.",
+ "p4": "Auch der Bau eines Windrads verbraucht Energie und setzt CO2 frei. Über seine ganze Lebensdauer verursacht es etwa 7 g CO2e für jede Kilowattstunde (kWh), die es liefert. CO2e rechnet alle Treibhausgase so, als wären sie Kohlendioxid. Die Energie für seinen Bau hat ein Windrad nach etwa 8 Monaten wieder erzeugt. Fachleute nennen das energetische Amortisation. Gebaut ist es für 20 Jahre, oft läuft es aber länger. Seine Blätter lassen sich allerdings noch schwer recyceln. Wählen Sie einen windarmen, typischen oder windreichen Standort und sehen Sie, wie viel der alltägliche Wind ausmacht. Dabei ändert sich nur der alltägliche Wind. Der Sturm am Abend ist an allen Standorten gleich."
+ }
+ }
+}
diff --git a/explainers/wind-farm/locales/en.json b/explainers/wind-farm/locales/en.json
new file mode 100644
index 00000000..cda6233c
--- /dev/null
+++ b/explainers/wind-farm/locales/en.json
@@ -0,0 +1,270 @@
+{
+ "meta": {
+ "title": "How a wind farm works",
+ "eyebrow": "Renewable energy",
+ "tagline": "Twenty-seven turbines and one windy day: how a blade shaped like a wing turns moving air into power for the grid.",
+ "description": "A 3D wind farm through one windy day: how blades catch the wind, what turns inside a turbine, why turbines stand far apart and how power reaches the grid.",
+ "summary": "The farm layout, the tower and blades, the gearbox and generator, the power curve and the Betz limit, wakes, and the road to the grid.",
+ "socialAlt": "A wind farm on rolling land with one turbine close up, beside the title How a wind farm works"
+ },
+ "stage": {
+ "label": "Interactive wind farm model",
+ "gauge": "Live wind and power"
+ },
+ "timeline": {
+ "label": "Time of day",
+ "phases": "Jump to a part of the day",
+ "speed": "Playback speed",
+ "value": "{{time}}",
+ "during": "{{time}}, {{phase}}",
+ "speedValue": "{{value}} h/min",
+ "speedFormat": "{{value}} hours of the day per minute",
+ "phase": {
+ "night": "Calm night",
+ "morning": "Morning breeze",
+ "afternoon": "Full power",
+ "storm": "Storm",
+ "evening": "Evening"
+ },
+ "jump": {
+ "night": "Jump to the calm night",
+ "morning": "Jump to the morning breeze",
+ "afternoon": "Jump to full power",
+ "storm": "Jump to the storm",
+ "evening": "Jump to the evening"
+ }
+ },
+ "controls": {
+ "label": "Model controls",
+ "siteWind": "Site wind",
+ "siteWindOptions": {
+ "calm": "Calm site",
+ "typical": "Typical site",
+ "windy": "Windy site"
+ },
+ "spacingOptions": {
+ "5": "5 D",
+ "7": "7 D",
+ "9": "9 D"
+ },
+ "nacelleOptions": {
+ "closed": "Cover on",
+ "open": "Cover off"
+ },
+ "windAtOptions": {
+ "day": "Follow the day",
+ "cutIn": "Cut-in, 3 m/s",
+ "peak": "Best share, 8 m/s",
+ "rated": "Full power, 12 m/s",
+ "rampDown": "Ramping down, 23 m/s",
+ "cutOut": "Past cut-out, 25 m/s"
+ },
+ "groups": {
+ "siteWind": "Site wind",
+ "spacing": "Gap between rows",
+ "nacelle": "Nacelle cover",
+ "windAt": "Wind speed"
+ },
+ "views": {
+ "streamlines": {
+ "name": "Airflow",
+ "short": "Air",
+ "hint": "Airflow (F)"
+ },
+ "wakes": {
+ "name": "Wakes",
+ "short": "Wakes",
+ "hint": "Wakes (K)"
+ },
+ "cables": {
+ "name": "Cables",
+ "short": "Cables",
+ "hint": "Cables (C)"
+ },
+ "labels": {
+ "name": "Labels",
+ "short": "Labels",
+ "hint": "Labels (L)"
+ }
+ }
+ },
+ "readouts": {
+ "wind": "Wind",
+ "power": "One turbine",
+ "farm": "Whole farm",
+ "state": {
+ "idle": "Waiting for wind",
+ "partial": "Below full power",
+ "full": "Full power",
+ "rampDown": "Ramping down",
+ "stopping": "Feathering to stop",
+ "parked": "Parked",
+ "starting": "Starting up"
+ }
+ },
+ "units": {
+ "metresPerSecond": "{{value}} m/s",
+ "speedPair": "{{ms}} m/s, {{kmh}} km/h",
+ "rpm": "{{value}} rpm",
+ "degrees": "{{value}}°",
+ "mw": "{{value}} MW",
+ "mwh": "{{value}} MWh",
+ "percent": "{{value}} %",
+ "metres": "{{value}} m",
+ "seconds": "{{value}} s",
+ "count": "{{value}}",
+ "hectares": "{{value}} ha",
+ "squareKm": "{{value}} km²",
+ "volts": "{{value}} V",
+ "gramsPerKwh": "{{value}} g CO2e per kWh",
+ "months": "{{value}} months"
+ },
+ "chapters": {
+ "farm": {
+ "count": "Turbines",
+ "rated": "Full power of the farm",
+ "rows": "Between rows, along the wind",
+ "across": "Between turbines in a row",
+ "spacingValue": "{{metres}} m, {{diameters}} D",
+ "built": "Land built on",
+ "project": "Typical project area for this size"
+ },
+ "tower": {
+ "wind": "Wind at hub height",
+ "rpm": "Rotor speed",
+ "turn": "One turn takes",
+ "stopped": "Rotor stopped",
+ "tip": "Blade tip speed",
+ "height": "Tip height",
+ "blade": "Blade length",
+ "swept": "Swept area",
+ "sweptValue": "{{area}} m², about {{pitches}} football pitches"
+ },
+ "nacelle": {
+ "shaft": "Main shaft",
+ "generator": "Generator",
+ "gear": "Gear ratio",
+ "gearValue": "about 1 to {{ratio}}",
+ "pitch": "Blade pitch",
+ "brake": "Disc brake",
+ "brakeValue": {
+ "released": "Released",
+ "holding": "Holding the rotor"
+ },
+ "volts": "Generator voltage"
+ },
+ "curve": {
+ "wind": "Wind speed",
+ "state": "Turbine state",
+ "power": "Turbine output",
+ "caption": "Power in the wind, the Betz limit and this turbine's output, for winds from 0 to 30 m/s. The dot marks the wind now.",
+ "axisWind": "Wind speed (m/s)",
+ "axisPower": "Power (MW)",
+ "lineWind": "Power in the wind",
+ "lineBetz": "Betz limit",
+ "lineTurbine": "This turbine",
+ "windPower": "Power in the wind",
+ "cp": "Share of the wind's power it catches",
+ "betz": "Share of the Betz limit",
+ "pitch": "Blade pitch"
+ },
+ "wakes": {
+ "spacing": "Gap between rows",
+ "deficit": "Slowdown in the wake, one row behind",
+ "loss": "Farm power lost to wakes now",
+ "top": "Wind at the top of the rotor",
+ "bottom": "Wind at the bottom of the rotor"
+ },
+ "grid": {
+ "output": "Farm output now",
+ "energy": "Energy made today",
+ "homes": "US homes this output could run",
+ "capacity": "This day's capacity factor",
+ "voltages": "Voltage at each step",
+ "voltagesValue": "{{generator}} V, {{collector}} kV, {{grid}} kV",
+ "annual": "One turbine in a year",
+ "annualHomes": "US homes one turbine supplies",
+ "carbon": "Carbon footprint",
+ "payback": "Energy payback"
+ }
+ },
+ "parts": {
+ "land": "Ground",
+ "foundation": "Foundation",
+ "tower": "Tower",
+ "transformer": "Transformer",
+ "towerCable": "Power cable",
+ "nacelle": "Nacelle",
+ "cooler": "Cooler",
+ "bedplate": "Bedplate",
+ "hub": "Hub",
+ "blades": "Blades",
+ "pitchCylinders": "Pitch cylinders",
+ "mainBearing": "Main bearing",
+ "mainShaft": "Main shaft",
+ "gearbox": "Gearbox",
+ "brakeDisc": "Disc brake",
+ "generator": "Generator",
+ "converter": "Converter",
+ "yawDrives": "Yaw drives",
+ "sweptArea": "Swept area",
+ "streamlinesGroup": "Airflow",
+ "heroWake": "Wake",
+ "farmLand": "Farmland",
+ "farmTurbines": "27 turbines",
+ "accessRoads": "Access roads",
+ "spacingMarker": "Row spacing",
+ "prevailingWind": "Prevailing wind",
+ "windArrows": "Wind",
+ "shearProfile": "Wind by height",
+ "wakePlumes": "Wakes",
+ "collectorCables": "Collector cables",
+ "substation": "Substation",
+ "gridLine": "Grid line"
+ },
+ "sections": {
+ "farm": {
+ "title": "Twenty-seven turbines and one windy day",
+ "p1": "This is a wind farm: 27 turbines spread over rolling fields. Each one is a Vestas V150. At full power it makes 4.2 megawatts (MW). A megawatt is a million watts. All together, the farm can make about 113 MW. Målarberget in Sweden is a real farm just like it: 27 of these turbines, running since 2021.",
+ "p2": "The turbines stand in rows across the prevailing wind, the direction the wind blows from most often. Here, that is west. Engineers measure the gaps in rotor widths, called D. One D is the width of a rotor from tip to tip, 150 m here. The rows are 7 D apart, or 1,050 m. In each row, the turbines are 4 D apart, or 600 m. The middle row is shifted sideways, so no turbine stands right behind another.",
+ "p3": "From above, the farm looks crowded, but most of the land is still fields. For each megawatt, only about 0.3 hectares is built on. A hectare is a square 100 m on each side. Most of that built land is access roads, not turbine bases. Collector cables gather the power from every turbine. They carry it to a substation. There it joins the grid, the web of power lines that feeds homes and towns.",
+ "p4": "Målarberget's real layout is not published, so this one is the model's own. The windy day is made up too, and it plays on a loop. It is not an average day. It packs a calm night, a breezy morning, a full-power afternoon and an evening storm into 24 hours. The clock runs many times faster than real time, yet every rotor turns at its real speed. Roads, cables and wind arrows are drawn wider than they really are, so you can see them from far away. The light stays the same all day and night."
+ },
+ "tower": {
+ "title": "Blades that fly in circles",
+ "p1": "Up close, one turbine is huge. The rotor is three blades fixed to a round hub, and it is 150 m across. The steel tower lifts the hub 105 m above the ground. At the top of its turn, a blade tip reaches about 180 m, almost twice as tall as Big Ben. Up there, the wind is faster than near the ground, as chapter 5 shows.",
+ "p2": "Each blade is about 74 m long, almost the wingspan of an Airbus A380, the biggest airliner. It is made of carbon and glass fibre set in epoxy, a hard plastic glue. The wind does not push a blade like a sail. It flows over the blade the way it flows over a plane's wing. The curved shape creates lift, the same force that holds a plane up. Lift pulls each blade round, and the rotor turns.",
+ "p3": "At full speed, the rotor turns only about 10 times a minute, or 10 rpm (revolutions per minute). That is one turn every 6 seconds, which looks lazy. But the tips travel round a huge circle, so they move at about 80 metres per second (m/s), close to 300 km/h. In this model, the tips move 8 times as fast as the wind until the rotor reaches its top speed. Drag the wind slider and watch the rotor speed up with the wind. Pick Follow the day to hand the wind back to the clock.",
+ "p4": "The circle the blades sweep is the swept area: about 17,700 m², roughly 2.5 football pitches. Only wind that passes through this circle can turn the rotor. The airflow lines round the rotor are only a drawing, since real air is invisible.",
+ "p5": "The turbine's transformer gets its power ready for the cables, as chapter 6 explains. Where the V150 keeps it is not published, so this model puts it at the foot of the tower. The real rotor also tilts up slightly, but that angle is not published either, so the model keeps it level."
+ },
+ "nacelle": {
+ "title": "Inside the nacelle",
+ "p1": "The box behind the hub is the nacelle, 12.8 m long. It holds the machines that turn the rotor's slow, strong spin into electricity. Pick Cover off to look inside. The machines all stand on the bedplate, a heavy cast iron frame. A cooler on the roof carries their heat away.",
+ "p2": "The angle of a blade to the wind is its pitch. It sets how much of the wind the blade can grab. Each blade sits in the hub on a bearing, a ring that lets it twist. Three pitch cylinders, one per blade, twist the blades with oil pressure. Behind the hub, the main shaft turns in its main bearing at the rotor's slow speed, about 10 rpm.",
+ "p3": "The generator needs a fast spin, so a gearbox sits between it and the main shaft. The gearbox works in three steps. In the first two, small gears circle round a central gear, like planets round the sun. The last step uses gears with slanted teeth. Together they make the output spin about 140 times faster, up to about 1,485 rpm. The generator makes electricity with copper coils, without permanent magnets. Its power comes out at 800 volts. All of it then passes through the converter, which reshapes it to keep in step with the grid.",
+ "p4": "A disc brake sits on the shaft. It is not what stops the turbine in a storm. The blades do that, by turning edge-on to the wind. The brake only holds the rotor still once it has slowed down, to park it. On top of the tower, yaw drives turn the whole nacelle to face the wind. This turning is called yaw. In this model, the nacelle turns with the wind at once, because the V150's real turning speed is not published."
+ },
+ "curve": {
+ "title": "How much wind a turbine can catch",
+ "p1": "A turbine starts making power in a light breeze of 3 m/s, the cut-in speed. From there, the power climbs fast. The power in the wind grows with the speed cubed: speed times speed times speed. Double the wind, and it carries eight times the power. So the turbine makes about 0.3 MW at 4 m/s, about 2.5 MW at 8 m/s and its full 4.2 MW from about 12 m/s. Drag the wind slider and watch the dot move along the curve.",
+ "p2": "No turbine can catch all of that power. To take energy, the rotor must slow the air down. But the slowed air still has to flow away behind it. If the rotor stopped the air completely, no new air could get through. The physicist Albert Betz showed that even a perfect rotor can take at most 59.3 % of the wind's power. That is the Betz limit. This turbine's best is just under half the wind's power, at about 7 to 8 m/s. The chart shows how close it comes.",
+ "p3": "Above about 12 m/s, the wind brings more power than the generator can take. So the pitch cylinders turn the blades a little out of the wind, and more of it slips past. At 15 m/s, the turbine keeps only about 12 % of the power in the wind. Above 20 m/s, it starts to ramp down, lowering its power step by step. The pitch angles the model shows in strong wind are its own guess, since the V150's are not published.",
+ "p4": "At the cut-out speed, about 22 to 25 m/s, the wind is too strong and the turbine shuts down. The blades turn edge-on to the wind. This is called feathering. The rotor slows down on its own, and then the disc brake parks it. The turbine starts again only once the wind has dropped by about 2 m/s. In this model, all 27 turbines stop together at 24.5 m/s and restart at 22.5 m/s. Stopping and restarting each take 20 minutes on the model's clock, longer than in real life, so you have time to watch."
+ },
+ "wakes": {
+ "title": "Why turbines stand so far apart",
+ "p1": "A turbine takes energy out of the wind, so the air behind it moves slower. This trail of slow, stirred-up air is called a wake, like the trail behind a boat. Wakes (K) draws a plume behind every turning rotor. Switch it off to compare. Wakes are strongest in medium winds, when the rotor holds back the most air. In stronger wind, the blades let more air slip past, and the wakes fade.",
+ "p2": "The wakes here follow the Jensen model, a simple recipe engineers use a lot. It treats each wake as a cone that widens and slowly fills back up with faster air. One number sets how fast the cone widens: 0.075 here, the usual value on land. At 8 m/s, a turbine 7 D downwind still meets air about 13 % slower. Since power follows the speed cubed, that costs it about a third of its power.",
+ "p3": "Over a year, wakes cost a farm about 5 to 15 % of its energy. In a Vestas example farm of 24 of these turbines, the loss is about 6 %. From one unlucky direction, where turbines line up one behind another, the loss can pass 30 %. That is why rows usually stand 5 to 9 D apart along the wind, and turbines 3 to 5 D apart across it. Try 5, 7 and 9 D: wider gaps lose less to wakes but need more land. In this model, the wind also swings a few degrees either side of west through the day, so the nacelles turn a little.",
+ "p4": "Wind also gets stronger with height, because the ground slows down the air near it. Airflow (F) shows this: the top of the rotor meets faster wind than the bottom. The plumes and arrows are only drawings. The wake model also cuts corners. It treats the ground as flat, leaves no wake behind a stopped rotor and only roughly adds up wakes that overlap."
+ },
+ "grid": {
+ "title": "The road to the grid",
+ "p1": "Each generator makes power at 800 volts (V). Voltage is the push that drives electricity along a wire. After the converter, the turbine's own transformer raises the voltage. With a stronger push, the same power needs less current, the flow of electricity. Less current means the cables waste less power as heat. Cables (C) shows the power's path from the turbines to the grid.",
+ "p2": "Collector cables at 36,000 volts, or 36 kilovolts (kV), link the turbines to the substation. There, another transformer raises the voltage again, to 110 kV, for the grid line. These voltages come from a Vestas example farm in Germany.",
+ "p3": "A megawatt hour (MWh) is the energy of one megawatt running for one hour. At full power, one turbine makes 4.2 MWh every hour. But the wind is not always strong. Over a year, a turbine averages about 40 % of its full power. That share is its capacity factor. In a year, one turbine makes about 14,700 MWh, enough for about 1,400 US homes. On the typical and windy sites, the model's made-up day is windier than a real average day, so the capacity factor it shows comes out higher.",
+ "p4": "Building a turbine also uses energy and releases carbon. Over its whole life, it causes about 7 g of CO2e for each kilowatt hour (kWh) it makes. CO2e counts every greenhouse gas as if it were carbon dioxide. A turbine pays back the energy used to build it in about 8 months. It is designed to last 20 years and often runs longer. Its blades, though, are still hard to recycle. Pick a calm, typical or windy site to see how much the everyday wind matters. Only the everyday wind changes. The evening storm is the same at every site."
+ }
+ }
+}
diff --git a/explainers/wind-farm/locales/es.json b/explainers/wind-farm/locales/es.json
new file mode 100644
index 00000000..b0e64f22
--- /dev/null
+++ b/explainers/wind-farm/locales/es.json
@@ -0,0 +1,270 @@
+{
+ "meta": {
+ "title": "Cómo funciona un parque eólico",
+ "eyebrow": "Energía renovable",
+ "tagline": "Veintisiete aerogeneradores y un día de viento: así es como una pala con forma de ala convierte el viento en electricidad para la red.",
+ "description": "Un día ventoso en un parque eólico 3D: cómo las palas captan el viento, qué gira dentro de un aerogenerador, por qué se separan tanto y el camino a la red.",
+ "summary": "La distribución del parque, la torre y las palas, la multiplicadora y el generador, la curva de potencia y el límite de Betz, las estelas y el camino hasta la red.",
+ "socialAlt": "Un parque eólico sobre un terreno ondulado, con un aerogenerador en primer plano, junto al título «Cómo funciona un parque eólico»"
+ },
+ "stage": {
+ "label": "Modelo interactivo de un parque eólico",
+ "gauge": "Viento y potencia en directo"
+ },
+ "timeline": {
+ "label": "Hora del día",
+ "phases": "Saltar a un momento del día",
+ "speed": "Velocidad de reproducción",
+ "value": "{{time}}",
+ "during": "{{time}}, {{phase}}",
+ "speedValue": "{{value}} h/min",
+ "speedFormat": "{{value}} horas del día por minuto",
+ "phase": {
+ "night": "Noche en calma",
+ "morning": "Brisa de la mañana",
+ "afternoon": "Plena potencia",
+ "storm": "Tormenta",
+ "evening": "Anochecer"
+ },
+ "jump": {
+ "night": "Saltar a la noche en calma",
+ "morning": "Saltar a la brisa de la mañana",
+ "afternoon": "Saltar a plena potencia",
+ "storm": "Saltar a la tormenta",
+ "evening": "Saltar al anochecer"
+ }
+ },
+ "controls": {
+ "label": "Controles del modelo",
+ "siteWind": "Viento de la zona",
+ "siteWindOptions": {
+ "calm": "Zona tranquila",
+ "typical": "Zona normal",
+ "windy": "Zona ventosa"
+ },
+ "spacingOptions": {
+ "5": "5 D",
+ "7": "7 D",
+ "9": "9 D"
+ },
+ "nacelleOptions": {
+ "closed": "Con cubierta",
+ "open": "Sin cubierta"
+ },
+ "windAtOptions": {
+ "day": "Seguir el día",
+ "cutIn": "Arranque, 3 m/s",
+ "peak": "Mejor aprovechamiento, 8 m/s",
+ "rated": "Plena potencia, 12 m/s",
+ "rampDown": "Bajando potencia, 23 m/s",
+ "cutOut": "Pasado el corte, 25 m/s"
+ },
+ "groups": {
+ "siteWind": "Viento de la zona",
+ "spacing": "Distancia entre filas",
+ "nacelle": "Cubierta de la góndola",
+ "windAt": "Velocidad del viento"
+ },
+ "views": {
+ "streamlines": {
+ "name": "Flujo de aire",
+ "short": "Aire",
+ "hint": "Flujo de aire (F)"
+ },
+ "wakes": {
+ "name": "Estelas",
+ "short": "Estelas",
+ "hint": "Estelas (K)"
+ },
+ "cables": {
+ "name": "Cables",
+ "short": "Cables",
+ "hint": "Cables (C)"
+ },
+ "labels": {
+ "name": "Etiquetas",
+ "short": "Etiquetas",
+ "hint": "Etiquetas (L)"
+ }
+ }
+ },
+ "readouts": {
+ "wind": "Viento",
+ "power": "Un aerogenerador",
+ "farm": "Todo el parque",
+ "state": {
+ "idle": "Esperando viento",
+ "partial": "Por debajo de plena potencia",
+ "full": "Plena potencia",
+ "rampDown": "Bajando potencia",
+ "stopping": "Palas en bandera, parando",
+ "parked": "Parado",
+ "starting": "Arrancando"
+ }
+ },
+ "units": {
+ "metresPerSecond": "{{value}} m/s",
+ "speedPair": "{{ms}} m/s, {{kmh}} km/h",
+ "rpm": "{{value}} rpm",
+ "degrees": "{{value}}°",
+ "mw": "{{value}} MW",
+ "mwh": "{{value}} MWh",
+ "percent": "{{value}} %",
+ "metres": "{{value}} m",
+ "seconds": "{{value}} s",
+ "count": "{{value}}",
+ "hectares": "{{value}} ha",
+ "squareKm": "{{value}} km²",
+ "volts": "{{value}} V",
+ "gramsPerKwh": "{{value}} g de CO2e por kWh",
+ "months": "{{value}} meses"
+ },
+ "chapters": {
+ "farm": {
+ "count": "Aerogeneradores",
+ "rated": "Potencia máxima del parque",
+ "rows": "Entre filas, en la dirección del viento",
+ "across": "Entre aerogeneradores de una fila",
+ "spacingValue": "{{metres}} m, {{diameters}} D",
+ "built": "Superficie construida",
+ "project": "Superficie típica de un proyecto de este tamaño"
+ },
+ "tower": {
+ "wind": "Viento a la altura del buje",
+ "rpm": "Velocidad del rotor",
+ "turn": "Una vuelta dura",
+ "stopped": "Rotor parado",
+ "tip": "Velocidad en la punta de la pala",
+ "height": "Altura de la punta",
+ "blade": "Longitud de la pala",
+ "swept": "Área barrida",
+ "sweptValue": "{{area}} m², unos {{pitches}} campos de fútbol"
+ },
+ "nacelle": {
+ "shaft": "Eje principal",
+ "generator": "Generador",
+ "gear": "Relación de la multiplicadora",
+ "gearValue": "cerca de 1 a {{ratio}}",
+ "pitch": "Ángulo de paso",
+ "brake": "Freno de disco",
+ "brakeValue": {
+ "released": "Liberado",
+ "holding": "Sujetando el rotor"
+ },
+ "volts": "Voltaje del generador"
+ },
+ "curve": {
+ "wind": "Velocidad del viento",
+ "state": "Estado del aerogenerador",
+ "power": "Producción del aerogenerador",
+ "caption": "La potencia del viento, el límite de Betz y la producción de este aerogenerador, con vientos de 0 a 30 m/s. El punto marca el viento actual.",
+ "axisWind": "Velocidad del viento (m/s)",
+ "axisPower": "Potencia (MW)",
+ "lineWind": "Potencia del viento",
+ "lineBetz": "Límite de Betz",
+ "lineTurbine": "Este aerogenerador",
+ "windPower": "Potencia del viento",
+ "cp": "Parte de la potencia del viento que aprovecha",
+ "betz": "Cuánto se acerca al límite de Betz",
+ "pitch": "Ángulo de paso"
+ },
+ "wakes": {
+ "spacing": "Distancia entre filas",
+ "deficit": "Frenado en la estela, una fila más atrás",
+ "loss": "Potencia que pierde ahora el parque por las estelas",
+ "top": "Viento en lo alto del rotor",
+ "bottom": "Viento en la parte baja del rotor"
+ },
+ "grid": {
+ "output": "Producción actual del parque",
+ "energy": "Energía producida hoy",
+ "homes": "Hogares de EE. UU. que abastecería esta producción",
+ "capacity": "Factor de capacidad de este día",
+ "voltages": "Voltaje en cada etapa",
+ "voltagesValue": "{{generator}} V, {{collector}} kV, {{grid}} kV",
+ "annual": "Un aerogenerador en un año",
+ "annualHomes": "Hogares de EE. UU. que abastece un aerogenerador",
+ "carbon": "Huella de carbono",
+ "payback": "Amortización energética"
+ }
+ },
+ "parts": {
+ "land": "Suelo",
+ "foundation": "Cimentación",
+ "tower": "Torre",
+ "transformer": "Transformador",
+ "towerCable": "Cable eléctrico",
+ "nacelle": "Góndola",
+ "cooler": "Enfriador",
+ "bedplate": "Bancada",
+ "hub": "Buje",
+ "blades": "Palas",
+ "pitchCylinders": "Cilindros de paso",
+ "mainBearing": "Rodamiento principal",
+ "mainShaft": "Eje principal",
+ "gearbox": "Multiplicadora",
+ "brakeDisc": "Freno de disco",
+ "generator": "Generador",
+ "converter": "Convertidor",
+ "yawDrives": "Motores de orientación",
+ "sweptArea": "Área barrida",
+ "streamlinesGroup": "Flujo de aire",
+ "heroWake": "Estela",
+ "farmLand": "Tierras de cultivo",
+ "farmTurbines": "27 aerogeneradores",
+ "accessRoads": "Caminos de acceso",
+ "spacingMarker": "Distancia entre filas",
+ "prevailingWind": "Viento dominante",
+ "windArrows": "Viento",
+ "shearProfile": "Viento según la altura",
+ "wakePlumes": "Estelas",
+ "collectorCables": "Cables colectores",
+ "substation": "Subestación",
+ "gridLine": "Línea de la red"
+ },
+ "sections": {
+ "farm": {
+ "title": "Veintisiete aerogeneradores y un día de viento",
+ "p1": "Esto es un parque eólico: 27 aerogeneradores repartidos por campos ondulados. Todos son del modelo Vestas V150. A plena potencia, cada uno produce 4,2 megavatios (MW). Un megavatio es un millón de vatios. En total, el parque puede producir unos 113 MW. Målarberget, en Suecia, es un parque real igual que este: 27 de estos aerogeneradores, en marcha desde 2021.",
+ "p2": "Los aerogeneradores están en filas, de cara al viento dominante, que es la dirección desde la que sopla el viento más a menudo. Aquí sopla del oeste. Los ingenieros miden estas distancias en anchos de rotor, que llaman D. Un D es lo que mide el rotor de punta a punta: aquí, 150 m. Las filas están a 7 D unas de otras, es decir, a 1050 m. Dentro de cada fila, los aerogeneradores están a 4 D, o sea, a 600 m. La fila del medio está desplazada hacia un lado, así que ningún aerogenerador queda justo detrás de otro.",
+ "p3": "Desde arriba, el parque parece abarrotado, pero casi todo el terreno sigue siendo campo. Por cada megavatio, solo se construye sobre unas 0,3 hectáreas. Una hectárea es un cuadrado de 100 m de lado. La mayor parte de esa superficie son caminos de acceso, no las bases de los aerogeneradores. Los cables colectores recogen la electricidad de cada aerogenerador. La llevan hasta una subestación. Allí entra en la red, el entramado de líneas eléctricas que abastece a casas y pueblos.",
+ "p4": "La distribución real de Målarberget no se ha publicado, así que la que ves es propia de este modelo. El día de viento también es inventado, y se repite en bucle. No es un día medio. Reúne en 24 horas una noche en calma, una mañana de brisa, una tarde a plena potencia y una tormenta al anochecer. El reloj corre muchas veces más rápido que en la realidad, pero cada rotor gira a su velocidad real. Los caminos, los cables y las flechas del viento se dibujan más anchos de lo que son, para que puedas verlos desde lejos. La luz es la misma de día y de noche."
+ },
+ "tower": {
+ "title": "Palas que vuelan en círculo",
+ "p1": "De cerca, un solo aerogenerador es enorme. El rotor está formado por tres palas unidas a una pieza central redonda, el buje, y mide 150 m de punta a punta. La torre de acero levanta el buje a 105 m del suelo. En lo más alto de su giro, la punta de una pala llega a unos 180 m, casi el doble de alto que el Big Ben. Allí arriba el viento sopla más fuerte que cerca del suelo, como verás en el capítulo 5.",
+ "p2": "Cada pala mide unos 74 m, casi la envergadura de un Airbus A380, el avión de pasajeros más grande del mundo. Está hecha de fibra de carbono y fibra de vidrio unidas con resina epoxi, un pegamento plástico muy duro. El viento no empuja la pala como a una vela. Se desliza sobre ella igual que sobre el ala de un avión. Su forma curva crea sustentación, la misma fuerza que sostiene a un avión en el aire. La sustentación tira de cada pala, y el rotor gira.",
+ "p3": "A toda velocidad, el rotor da solo unas 10 vueltas por minuto, o 10 rpm (revoluciones por minuto). Es una vuelta cada 6 segundos, y parece que va sin prisa. Pero las puntas recorren un círculo enorme, así que van a unos 80 metros por segundo (m/s), cerca de 300 km/h. En este modelo, las puntas van 8 veces más rápido que el viento hasta que el rotor llega a su velocidad máxima. Arrastra el control del viento y mira cómo el rotor se acelera con él. Elige Seguir el día para que el viento vuelva a cambiar con la hora.",
+ "p4": "El círculo que recorren las palas es el área barrida: unos 17 700 m², más o menos 2,5 campos de fútbol. Solo el viento que pasa por ese círculo puede mover el rotor. Las líneas de aire alrededor del rotor son solo un dibujo, porque el aire de verdad no se ve.",
+ "p5": "El transformador del aerogenerador prepara su electricidad para los cables, como explica el capítulo 6. No se ha publicado dónde lo lleva el V150, así que este modelo lo pone al pie de la torre. Además, el rotor real mira un poco hacia arriba. Ese ángulo tampoco se ha publicado, así que en el modelo va recto."
+ },
+ "nacelle": {
+ "title": "Dentro de la góndola",
+ "p1": "La caja que hay detrás del buje es la góndola, de 12,8 m de largo. Dentro van las máquinas que convierten el giro lento y potente del rotor en electricidad. Elige Sin cubierta para verlas. Todas se apoyan en la bancada, un pesado armazón de hierro fundido. Un enfriador en el techo se lleva su calor.",
+ "p2": "La inclinación de una pala frente al viento se llama ángulo de paso. De él depende cuánto viento puede atrapar la pala. Cada pala va montada en el buje sobre un rodamiento, un anillo que le permite girar sobre sí misma. Tres cilindros de paso, uno por pala, las giran con presión de aceite. Detrás del buje, el eje principal gira dentro de su rodamiento principal a la misma velocidad lenta que el rotor, unas 10 rpm.",
+ "p3": "El generador necesita girar deprisa, así que entre él y el eje principal hay una caja de engranajes, la multiplicadora. Trabaja en tres etapas. En las dos primeras, unos engranajes pequeños giran alrededor de uno central, como los planetas alrededor del Sol. La última usa engranajes con dientes inclinados. Entre las tres hacen que el eje de salida gire unas 140 veces más rápido, hasta unas 1485 rpm. El generador produce electricidad con bobinas de cobre, sin imanes permanentes. La electricidad sale a 800 voltios. Después pasa entera por el convertidor, que la ajusta para que vaya al compás de la red.",
+ "p4": "En el eje hay un freno de disco. No es lo que detiene el aerogenerador en una tormenta. Eso lo hacen las palas, al ponerse de canto al viento. El freno solo sujeta el rotor cuando ya va despacio, para dejarlo parado. En lo alto de la torre, los motores de orientación giran toda la góndola para ponerla de cara al viento. A este giro se le llama orientación. En este modelo, la góndola sigue al viento al instante, porque no se ha publicado a qué velocidad gira la del V150 real."
+ },
+ "curve": {
+ "title": "Cuánto viento puede captar un aerogenerador",
+ "p1": "Un aerogenerador empieza a producir electricidad con una brisa suave de 3 m/s, la velocidad de arranque. A partir de ahí, la potencia sube deprisa. La potencia del viento crece con el cubo de la velocidad: velocidad por velocidad por velocidad. Si el viento sopla el doble de rápido, lleva ocho veces más potencia. Por eso el aerogenerador da unos 0,3 MW a 4 m/s, unos 2,5 MW a 8 m/s y sus 4,2 MW completos a partir de unos 12 m/s. Arrastra el control del viento y mira cómo se mueve el punto por la curva.",
+ "p2": "Ningún aerogenerador puede quedarse con toda esa potencia. Para sacar energía, el rotor tiene que frenar el aire. Pero ese aire frenado aún tiene que salir por detrás. Si el rotor lo parara del todo, no podría entrar aire nuevo. El físico Albert Betz demostró que ni siquiera un rotor perfecto puede sacar más del 59,3 % de la potencia del viento. Es el límite de Betz. Lo mejor que logra este aerogenerador es quedarse con algo menos de la mitad de la potencia del viento, con vientos de unos 7 a 8 m/s. La gráfica muestra lo cerca que se queda del límite.",
+ "p3": "Por encima de unos 12 m/s, el viento trae más potencia de la que el generador puede aguantar. Por eso los cilindros de paso giran las palas para sacarlas un poco del viento, y más aire pasa de largo. A 15 m/s, el aerogenerador solo se queda con alrededor del 12 % de la potencia del viento. Por encima de 20 m/s, empieza a reducir su potencia poco a poco. Los ángulos de paso que muestra el modelo con viento fuerte son una estimación propia, porque los del V150 no se han publicado.",
+ "p4": "A la velocidad de corte, de unos 22 a 25 m/s, el viento es demasiado fuerte y el aerogenerador se detiene. Las palas se ponen de canto al viento. Esto se llama puesta en bandera. El rotor se frena solo, y luego el freno de disco lo deja parado. El aerogenerador no vuelve a arrancar hasta que el viento baja unos 2 m/s. En este modelo, los 27 aerogeneradores se paran a la vez a 24,5 m/s y vuelven a arrancar a 22,5 m/s. La parada y el arranque duran 20 minutos cada uno en el reloj del modelo, más que en la realidad, para que te dé tiempo a verlos."
+ },
+ "wakes": {
+ "title": "Por qué los aerogeneradores están tan separados",
+ "p1": "Un aerogenerador le quita energía al viento, así que el aire que deja detrás va más lento. Esa cola de aire lento y revuelto se llama estela, como la que deja un barco al pasar. La opción Estelas (K) dibuja un penacho detrás de cada rotor que gira. Desactívala para comparar. Las estelas son más fuertes con vientos medios, que es cuando el rotor frena más aire. Con viento más fuerte, las palas dejan pasar más aire y las estelas se desvanecen.",
+ "p2": "Aquí las estelas siguen el modelo de Jensen, una receta sencilla que los ingenieros usan mucho. Trata cada estela como un cono que se va ensanchando y que poco a poco se vuelve a llenar de aire más rápido. Un solo número marca lo deprisa que se ensancha el cono: aquí 0,075, el valor habitual en tierra. A 8 m/s, un aerogenerador que está 7 D por detrás, en la dirección del viento, sigue recibiendo aire alrededor de un 13 % más lento. Como la potencia depende del cubo de la velocidad, eso le cuesta cerca de un tercio de su potencia.",
+ "p3": "A lo largo de un año, las estelas le cuestan a un parque entre un 5 y un 15 % de su energía. En un parque de ejemplo de Vestas con 24 de estos aerogeneradores, la pérdida ronda el 6 %. Si el viento sopla desde una dirección desafortunada, con los aerogeneradores en fila uno detrás de otro, la pérdida puede pasar del 30 %. Por eso las filas suelen separarse de 5 a 9 D en la dirección del viento, y los aerogeneradores, de 3 a 5 D a lo ancho. Prueba con 5, 7 y 9 D: cuanto más separados, menos se pierde por las estelas, pero más terreno hace falta. En este modelo, además, el viento oscila unos grados a cada lado del oeste a lo largo del día, así que las góndolas giran un poco.",
+ "p4": "El viento también es más fuerte cuanto más alto, porque el suelo frena el aire que tiene cerca. La opción Flujo de aire (F) lo muestra: la parte alta del rotor recibe un viento más rápido que la baja. Los penachos y las flechas son solo dibujos. El modelo de estelas también simplifica las cosas. Trata el suelo como si fuera llano, no pone estela detrás de un rotor parado y suma solo a grandes rasgos las estelas que se solapan."
+ },
+ "grid": {
+ "title": "El camino hasta la red",
+ "p1": "Cada generador produce electricidad a 800 voltios (V). El voltaje es el empuje que mueve la electricidad por un cable. Después del convertidor, el propio transformador del aerogenerador sube el voltaje. Con un empuje más fuerte, la misma potencia necesita menos corriente, que es el flujo de electricidad. Con menos corriente, los cables pierden menos energía en forma de calor. La opción Cables (C) muestra el camino de la electricidad desde los aerogeneradores hasta la red.",
+ "p2": "Los cables colectores, a 36 000 voltios, o 36 kilovoltios (kV), unen los aerogeneradores con la subestación. Allí, otro transformador vuelve a subir el voltaje, hasta 110 kV, para la línea de la red. Estos voltajes son los de un parque de ejemplo de Vestas en Alemania.",
+ "p3": "Un megavatio hora (MWh) es la energía que da un megavatio durante una hora. A plena potencia, un aerogenerador produce 4,2 MWh cada hora. Pero el viento no siempre sopla fuerte. A lo largo del año, un aerogenerador da de media cerca del 40 % de su plena potencia. Esa proporción es su factor de capacidad. En un año, un aerogenerador produce unos 14 700 MWh, suficiente para unos 1400 hogares de Estados Unidos. En las zonas normal y ventosa, el día inventado del modelo tiene más viento que un día medio de verdad, así que el factor de capacidad que muestra sale más alto.",
+ "p4": "Fabricar un aerogenerador también gasta energía y emite carbono. Contando toda su vida, son unos 7 g de CO2e por cada kilovatio hora (kWh) que produce. El CO2e cuenta todos los gases de efecto invernadero como si fueran dióxido de carbono. Un aerogenerador recupera en unos 8 meses la energía que costó construirlo. Está diseñado para durar 20 años, y a menudo funciona más tiempo. Eso sí, sus palas todavía son difíciles de reciclar. Prueba una zona tranquila, normal o ventosa para ver cuánto importa el viento de todos los días. Solo cambia ese viento. La tormenta del anochecer es la misma en todas las zonas."
+ }
+ }
+}
diff --git a/explainers/wind-farm/locales/fr.json b/explainers/wind-farm/locales/fr.json
new file mode 100644
index 00000000..2b56005e
--- /dev/null
+++ b/explainers/wind-farm/locales/fr.json
@@ -0,0 +1,270 @@
+{
+ "meta": {
+ "title": "Comment fonctionne un parc éolien",
+ "eyebrow": "Énergies renouvelables",
+ "tagline": "Vingt-sept éoliennes et une journée de vent : comment une pale en forme d’aile transforme le vent en électricité pour le réseau.",
+ "description": "Un parc éolien en 3D sur une journée de vent : les pales, ce qui tourne dans une éolienne, pourquoi les espacer et le chemin jusqu’au réseau.",
+ "summary": "Le plan du parc, le mât et les pales, le multiplicateur et la génératrice, la courbe de puissance et la limite de Betz, les sillages et le chemin jusqu’au réseau.",
+ "socialAlt": "Un parc éolien dans un paysage vallonné, avec une éolienne en gros plan, à côté du titre « Comment fonctionne un parc éolien »"
+ },
+ "stage": {
+ "label": "Modèle interactif de parc éolien",
+ "gauge": "Vent et puissance en direct"
+ },
+ "timeline": {
+ "label": "Heure de la journée",
+ "phases": "Aller à un moment de la journée",
+ "speed": "Vitesse de lecture",
+ "value": "{{time}}",
+ "during": "{{time}}, {{phase}}",
+ "speedValue": "{{value}} h/min",
+ "speedFormat": "{{value}} heures de la journée par minute",
+ "phase": {
+ "night": "Nuit calme",
+ "morning": "Brise du matin",
+ "afternoon": "Pleine puissance",
+ "storm": "Tempête",
+ "evening": "Soirée"
+ },
+ "jump": {
+ "night": "Aller à la nuit calme",
+ "morning": "Aller à la brise du matin",
+ "afternoon": "Aller à la pleine puissance",
+ "storm": "Aller à la tempête",
+ "evening": "Aller à la soirée"
+ }
+ },
+ "controls": {
+ "label": "Commandes du modèle",
+ "siteWind": "Vent du site",
+ "siteWindOptions": {
+ "calm": "Site calme",
+ "typical": "Site moyen",
+ "windy": "Site venté"
+ },
+ "spacingOptions": {
+ "5": "5 D",
+ "7": "7 D",
+ "9": "9 D"
+ },
+ "nacelleOptions": {
+ "closed": "Avec capot",
+ "open": "Sans capot"
+ },
+ "windAtOptions": {
+ "day": "Suivre la journée",
+ "cutIn": "Démarrage, 3 m/s",
+ "peak": "Meilleur rendement, 8 m/s",
+ "rated": "Pleine puissance, 12 m/s",
+ "rampDown": "Baisse progressive, 23 m/s",
+ "cutOut": "Arrêt pour vent fort, 25 m/s"
+ },
+ "groups": {
+ "siteWind": "Vent du site",
+ "spacing": "Écart entre les rangées",
+ "nacelle": "Capot de la nacelle",
+ "windAt": "Vitesse du vent"
+ },
+ "views": {
+ "streamlines": {
+ "name": "Flux d’air",
+ "short": "Air",
+ "hint": "Flux d’air (F)"
+ },
+ "wakes": {
+ "name": "Sillages",
+ "short": "Sillages",
+ "hint": "Sillages (K)"
+ },
+ "cables": {
+ "name": "Câbles",
+ "short": "Câbles",
+ "hint": "Câbles (C)"
+ },
+ "labels": {
+ "name": "Étiquettes",
+ "short": "Étiquettes",
+ "hint": "Étiquettes (L)"
+ }
+ }
+ },
+ "readouts": {
+ "wind": "Vent",
+ "power": "Une éolienne",
+ "farm": "Tout le parc",
+ "state": {
+ "idle": "En attente de vent",
+ "partial": "Charge partielle",
+ "full": "Pleine puissance",
+ "rampDown": "Baisse progressive",
+ "stopping": "Mise en drapeau",
+ "parked": "À l’arrêt",
+ "starting": "Mise en route"
+ }
+ },
+ "units": {
+ "metresPerSecond": "{{value}} m/s",
+ "speedPair": "{{ms}} m/s, {{kmh}} km/h",
+ "rpm": "{{value}} tr/min",
+ "degrees": "{{value}}°",
+ "mw": "{{value}} MW",
+ "mwh": "{{value}} MWh",
+ "percent": "{{value}} %",
+ "metres": "{{value}} m",
+ "seconds": "{{value}} s",
+ "count": "{{value}}",
+ "hectares": "{{value}} ha",
+ "squareKm": "{{value}} km²",
+ "volts": "{{value}} V",
+ "gramsPerKwh": "{{value}} g de CO2e par kWh",
+ "months": "{{value}} mois"
+ },
+ "chapters": {
+ "farm": {
+ "count": "Éoliennes",
+ "rated": "Pleine puissance du parc",
+ "rows": "Entre les rangées, dans l’axe du vent",
+ "across": "Entre les éoliennes d’une rangée",
+ "spacingValue": "{{metres}} m, {{diameters}} D",
+ "built": "Surface construite",
+ "project": "Surface type d’un projet de cette taille"
+ },
+ "tower": {
+ "wind": "Vent à hauteur du moyeu",
+ "rpm": "Vitesse du rotor",
+ "turn": "Durée d’un tour",
+ "stopped": "Rotor à l’arrêt",
+ "tip": "Vitesse en bout de pale",
+ "height": "Hauteur en bout de pale",
+ "blade": "Longueur d’une pale",
+ "swept": "Surface balayée",
+ "sweptValue": "{{area}} m², environ {{pitches}} terrains de football"
+ },
+ "nacelle": {
+ "shaft": "Arbre principal",
+ "generator": "Génératrice",
+ "gear": "Rapport de multiplication",
+ "gearValue": "environ 1 à {{ratio}}",
+ "pitch": "Calage des pales",
+ "brake": "Frein à disque",
+ "brakeValue": {
+ "released": "Desserré",
+ "holding": "Immobilise le rotor"
+ },
+ "volts": "Tension de la génératrice"
+ },
+ "curve": {
+ "wind": "Vitesse du vent",
+ "state": "État de l’éolienne",
+ "power": "Production de l’éolienne",
+ "caption": "La puissance du vent, la limite de Betz et la production de cette éolienne, pour des vents de 0 à 30 m/s. Le point indique le vent actuel.",
+ "axisWind": "Vitesse du vent (m/s)",
+ "axisPower": "Puissance (MW)",
+ "lineWind": "Puissance du vent",
+ "lineBetz": "Limite de Betz",
+ "lineTurbine": "Cette éolienne",
+ "windPower": "Puissance du vent",
+ "cp": "Part de la puissance du vent captée",
+ "betz": "Part de la limite de Betz",
+ "pitch": "Calage des pales"
+ },
+ "wakes": {
+ "spacing": "Écart entre les rangées",
+ "deficit": "Ralentissement dans le sillage, une rangée plus loin",
+ "loss": "Puissance du parc perdue dans les sillages",
+ "top": "Vent en haut du rotor",
+ "bottom": "Vent en bas du rotor"
+ },
+ "grid": {
+ "output": "Production du parc en ce moment",
+ "energy": "Énergie produite aujourd’hui",
+ "homes": "Foyers américains que cette puissance pourrait alimenter",
+ "capacity": "Facteur de charge de cette journée",
+ "voltages": "Tension à chaque étape",
+ "voltagesValue": "{{generator}} V, {{collector}} kV, {{grid}} kV",
+ "annual": "Une éolienne en un an",
+ "annualHomes": "Foyers américains alimentés par une éolienne",
+ "carbon": "Empreinte carbone",
+ "payback": "Temps de retour énergétique"
+ }
+ },
+ "parts": {
+ "land": "Sol",
+ "foundation": "Fondation",
+ "tower": "Mât",
+ "transformer": "Transformateur",
+ "towerCable": "Câble électrique",
+ "nacelle": "Nacelle",
+ "cooler": "Refroidisseur",
+ "bedplate": "Châssis",
+ "hub": "Moyeu",
+ "blades": "Pales",
+ "pitchCylinders": "Vérins de calage",
+ "mainBearing": "Roulement principal",
+ "mainShaft": "Arbre principal",
+ "gearbox": "Multiplicateur",
+ "brakeDisc": "Frein à disque",
+ "generator": "Génératrice",
+ "converter": "Convertisseur",
+ "yawDrives": "Moteurs d’orientation",
+ "sweptArea": "Surface balayée",
+ "streamlinesGroup": "Flux d’air",
+ "heroWake": "Sillage",
+ "farmLand": "Terres agricoles",
+ "farmTurbines": "27 éoliennes",
+ "accessRoads": "Chemins d’accès",
+ "spacingMarker": "Écart entre les rangées",
+ "prevailingWind": "Vent dominant",
+ "windArrows": "Vent",
+ "shearProfile": "Vent selon la hauteur",
+ "wakePlumes": "Sillages",
+ "collectorCables": "Câbles de collecte",
+ "substation": "Poste électrique",
+ "gridLine": "Ligne du réseau"
+ },
+ "sections": {
+ "farm": {
+ "title": "Vingt-sept éoliennes et une journée de vent",
+ "p1": "Voici un parc éolien : 27 éoliennes réparties sur des champs vallonnés. Ce sont toutes des Vestas V150. À pleine puissance, chacune produit 4,2 mégawatts (MW). Un mégawatt, c’est un million de watts. Au total, le parc peut donc produire environ 113 MW. Målarberget, en Suède, est un vrai parc tout à fait semblable : 27 éoliennes de ce modèle, en service depuis 2021.",
+ "p2": "Les éoliennes sont alignées en rangées, en travers du vent dominant, c’est-à-dire la direction d’où le vent souffle le plus souvent. Ici, il vient de l’ouest. Les ingénieurs mesurent les écarts en largeurs de rotor, notées D. Un D, c’est la largeur du rotor d’un bout de pale à l’autre : 150 m ici. Les rangées sont espacées de 7 D, soit 1 050 m. Dans chaque rangée, les éoliennes sont espacées de 4 D, soit 600 m. La rangée du milieu est décalée sur le côté : ainsi, aucune éolienne ne se trouve juste derrière une autre.",
+ "p3": "Vu d’en haut, le parc semble encombré. Pourtant, la plupart des terres restent des champs. Pour chaque mégawatt, on ne construit que sur 0,3 hectare environ. Un hectare, c’est un carré de 100 m de côté. Et cette surface construite, ce sont surtout des chemins d’accès, pas des socles d’éoliennes. Des câbles de collecte rassemblent l’électricité de chaque éolienne. Ils l’amènent jusqu’à un poste électrique. Là, elle rejoint le réseau, c’est-à-dire toutes les lignes électriques qui alimentent les maisons et les villes.",
+ "p4": "Le vrai plan de Målarberget n’est pas publié : celui-ci a donc été imaginé pour le modèle. La journée de vent est inventée, elle aussi, et elle passe en boucle. Ce n’est pas une journée moyenne. Elle réunit en 24 heures une nuit calme, une matinée de brise, un après-midi à pleine puissance et une tempête en soirée. L’horloge avance bien plus vite que le temps réel, mais chaque rotor tourne à sa vraie vitesse. Les chemins, les câbles et les flèches de vent sont dessinés plus larges qu’en réalité, pour que vous puissiez les voir de loin. La lumière reste la même, de jour comme de nuit."
+ },
+ "tower": {
+ "title": "Des pales qui volent en cercle",
+ "p1": "De près, une éolienne est gigantesque. Le rotor, ce sont trois pales fixées sur une pièce ronde au centre, le moyeu. Il mesure 150 m de diamètre. Le mât en acier porte ce moyeu à 105 m du sol. En haut de sa course, le bout d’une pale monte à environ 180 m, presque deux fois la hauteur de Big Ben. Là-haut, le vent souffle plus vite qu’au ras du sol, comme le montre le chapitre 5.",
+ "p2": "Chaque pale mesure environ 74 m, presque l’envergure d’un Airbus A380, le plus gros avion de ligne. Elle est faite de fibres de carbone et de verre prises dans de l’époxy, une colle plastique très dure. Le vent ne pousse pas la pale comme une voile. Il glisse dessus, comme sur l’aile d’un avion. La forme bombée de la pale crée une portance, la même force qui fait tenir un avion en l’air. La portance entraîne chaque pale, et le rotor se met à tourner.",
+ "p3": "À pleine vitesse, le rotor ne fait qu’environ 10 tours par minute, ce qu’on note 10 tr/min. Un tour toutes les 6 secondes : on dirait qu’il paresse. Mais le bout des pales parcourt un cercle immense. Il file donc à environ 80 mètres par seconde (m/s), près de 300 km/h. Dans ce modèle, le bout des pales va 8 fois plus vite que le vent, jusqu’à ce que le rotor atteigne sa vitesse maximale. Faites glisser le curseur du vent et regardez le rotor accélérer avec lui. Choisissez « Suivre la journée » pour que l’horloge reprenne la main sur le vent.",
+ "p4": "Le cercle que balaient les pales, c’est la surface balayée : environ 17 700 m², à peu près 2,5 terrains de football. Seul le vent qui traverse ce cercle peut faire tourner le rotor. Les lignes d’air autour du rotor ne sont qu’un dessin, car en réalité l’air est invisible.",
+ "p5": "Le transformateur de l’éolienne prépare son électricité pour les câbles, comme l’explique le chapitre 6. Son emplacement sur la V150 n’est pas publié : ce modèle le place donc au pied du mât. Le vrai rotor est aussi un peu incliné vers le haut. Cet angle n’est pas publié non plus, alors le modèle laisse le rotor bien droit."
+ },
+ "nacelle": {
+ "title": "Dans la nacelle",
+ "p1": "Le caisson derrière le moyeu, c’est la nacelle, longue de 12,8 m. Elle abrite les machines qui transforment la rotation lente et puissante du rotor en électricité. Choisissez « Sans capot » pour regarder dedans. Toutes ces machines reposent sur le châssis, un lourd cadre en fonte. Sur le toit, un refroidisseur évacue leur chaleur.",
+ "p2": "L’angle que fait une pale avec le vent s’appelle son calage. Il détermine la part du vent que la pale peut saisir. Chaque pale est montée dans le moyeu sur un roulement, un anneau qui lui permet de pivoter sur elle-même. Trois vérins de calage, un par pale, les font pivoter grâce à de l’huile sous pression. Derrière le moyeu, l’arbre principal est une grosse barre entraînée par le rotor. Il repose sur son roulement principal et tourne aussi lentement que le rotor, environ 10 tr/min.",
+ "p3": "La génératrice a besoin de tourner vite. Un multiplicateur, une sorte de boîte de vitesses, se place donc entre elle et l’arbre principal. Il travaille en trois étapes. Dans les deux premières, de petits engrenages tournent autour d’un engrenage central, comme des planètes autour du Soleil. La dernière utilise des engrenages aux dents inclinées. Ensemble, ces étapes font tourner la sortie environ 140 fois plus vite, jusqu’à environ 1 485 tr/min. La génératrice produit l’électricité avec des bobines de cuivre, sans aimants permanents. Son électricité sort sous 800 volts, puis passe tout entière par le convertisseur. Celui-ci la remet en forme pour qu’elle suive le rythme du réseau.",
+ "p4": "Un frein à disque est monté sur l’arbre. Ce n’est pas lui qui arrête l’éolienne pendant une tempête. Ce sont les pales, en présentant leur tranche au vent. Le frein sert seulement à immobiliser le rotor une fois qu’il a ralenti, pour le maintenir à l’arrêt. En haut du mât, des moteurs d’orientation font pivoter toute la nacelle face au vent. Ce mouvement s’appelle l’orientation. Dans ce modèle, la nacelle pivote aussitôt avec le vent, car la vitesse à laquelle la V150 pivote vraiment n’est pas publiée."
+ },
+ "curve": {
+ "title": "Quelle part du vent une éolienne peut capter",
+ "p1": "Une éolienne commence à produire dès une petite brise de 3 m/s : c’est la vitesse de démarrage. À partir de là, la puissance grimpe vite. La puissance du vent dépend du cube de sa vitesse, c’est-à-dire vitesse fois vitesse fois vitesse. Si le vent souffle deux fois plus vite, il transporte huit fois plus de puissance. L’éolienne produit donc environ 0,3 MW à 4 m/s, environ 2,5 MW à 8 m/s, et toute sa puissance, 4,2 MW, à partir de 12 m/s environ. Faites glisser le curseur du vent et regardez le point avancer sur la courbe.",
+ "p2": "Aucune éolienne ne peut capter toute cette puissance. Pour prendre de l’énergie, le rotor doit freiner l’air. Mais cet air ralenti doit quand même pouvoir s’écouler derrière lui. Si le rotor l’arrêtait complètement, plus aucun air neuf ne pourrait passer. Le physicien Albert Betz a montré que même un rotor parfait ne peut pas prendre plus de 59,3 % de la puissance du vent. C’est la limite de Betz. Au mieux, cette éolienne capte un peu moins de la moitié de la puissance du vent, vers 7 à 8 m/s. Le graphique montre à quel point elle s’approche de cette limite.",
+ "p3": "Au-delà de 12 m/s environ, le vent apporte plus de puissance que la génératrice ne peut en absorber. Les vérins de calage tournent donc un peu les pales hors du vent, et davantage d’air passe sans être capté. À 15 m/s, l’éolienne ne garde qu’environ 12 % de la puissance du vent. Passé 20 m/s, elle commence à baisser sa puissance, petit à petit. Les angles de calage affichés par vent fort sont une estimation du modèle, car ceux de la V150 ne sont pas publiés.",
+ "p4": "À la vitesse de coupure, entre 22 et 25 m/s environ, le vent est trop fort et l’éolienne s’arrête. Les pales présentent alors leur tranche au vent. C’est ce qu’on appelle la mise en drapeau. Le rotor ralentit tout seul, puis le frein à disque l’immobilise. L’éolienne ne redémarre qu’une fois que le vent a faibli d’environ 2 m/s. Dans ce modèle, les 27 éoliennes s’arrêtent ensemble à 24,5 m/s et repartent à 22,5 m/s. L’arrêt et le redémarrage prennent chacun 20 minutes sur l’horloge du modèle, plus longtemps qu’en réalité, pour que vous ayez le temps de regarder."
+ },
+ "wakes": {
+ "title": "Pourquoi les éoliennes sont si espacées",
+ "p1": "Une éolienne retire de l’énergie au vent : derrière elle, l’air avance donc plus lentement. Cette traînée d’air lent et brassé s’appelle un sillage, comme celui que laisse un bateau. L’option « Sillages (K) » dessine un panache derrière chaque rotor qui tourne. Désactivez-la pour comparer. Les sillages sont les plus marqués par vent moyen, quand le rotor freine le plus d’air. Par vent plus fort, les pales laissent filer davantage d’air, et les sillages s’estompent.",
+ "p2": "Les sillages suivent ici le modèle de Jensen, une recette simple que les ingénieurs utilisent beaucoup. Il voit chaque sillage comme un cône qui s’élargit et se remplit peu à peu d’air plus rapide. Un seul nombre règle la vitesse à laquelle le cône s’élargit : ici 0,075, la valeur habituelle pour les parcs sur terre. À 8 m/s, une éolienne placée 7 D plus loin dans le sens du vent reçoit encore un air environ 13 % plus lent. Comme la puissance suit le cube de la vitesse, cela lui coûte environ un tiers de sa puissance.",
+ "p3": "Sur une année, les sillages font perdre à un parc environ 5 à 15 % de son énergie. Dans un parc type décrit par Vestas, avec 24 de ces éoliennes, la perte est d’environ 6 %. Quand le vent vient d’une direction défavorable, où les éoliennes se retrouvent alignées les unes derrière les autres, la perte peut dépasser 30 %. C’est pourquoi les rangées sont en général espacées de 5 à 9 D dans le sens du vent, et les éoliennes de 3 à 5 D en travers. Essayez 5 D, 7 D et 9 D : plus l’écart est grand, moins les sillages coûtent d’énergie, mais plus il faut de terrain. Dans ce modèle, le vent varie aussi de quelques degrés de part et d’autre de l’ouest au fil de la journée : les nacelles pivotent donc un peu.",
+ "p4": "Le vent souffle aussi plus fort en hauteur, car le sol freine l’air juste au-dessus de lui. L’option « Flux d’air (F) » le montre : le haut du rotor reçoit un vent plus rapide que le bas. Les panaches et les flèches ne sont que des dessins. Le modèle de sillage simplifie aussi les choses. Il considère le sol comme plat, ne laisse aucun sillage derrière un rotor arrêté et additionne de façon approximative les sillages qui se chevauchent."
+ },
+ "grid": {
+ "title": "Le chemin jusqu’au réseau",
+ "p1": "Chaque génératrice produit de l’électricité sous 800 volts (V). Les volts mesurent la tension, la poussée qui fait avancer l’électricité dans un fil. Après le convertisseur, le transformateur de l’éolienne élève la tension. Avec une poussée plus forte, la même puissance demande moins de courant. Le courant, c’est le débit de l’électricité. Moins de courant, c’est moins d’énergie perdue en chaleur dans les câbles. L’option « Câbles (C) » montre le chemin de l’électricité, des éoliennes jusqu’au réseau.",
+ "p2": "Des câbles de collecte à 36 000 volts, soit 36 kilovolts (kV), relient les éoliennes au poste électrique. Là, un autre transformateur élève encore la tension, jusqu’à 110 kV, pour la ligne du réseau. Ces tensions viennent d’un parc type décrit par Vestas, en Allemagne.",
+ "p3": "Un mégawattheure (MWh), c’est l’énergie d’un mégawatt fourni pendant une heure. À pleine puissance, une éolienne produit donc 4,2 MWh chaque heure. Mais le vent n’est pas toujours fort. Sur un an, une éolienne fournit en moyenne environ 40 % de sa pleine puissance. Cette part s’appelle son facteur de charge. Au total, une éolienne produit environ 14 700 MWh par an, de quoi alimenter environ 1 400 foyers américains. Sur le site moyen et le site venté, la journée inventée du modèle est plus venteuse qu’une vraie journée moyenne. Le facteur de charge affiché sort donc plus élevé.",
+ "p4": "Construire une éolienne consomme aussi de l’énergie et rejette du carbone. Sur toute sa durée de vie, elle émet environ 7 g de CO2e pour chaque kilowattheure (kWh) qu’elle produit. Le CO2e compte tous les gaz à effet de serre comme s’ils étaient du dioxyde de carbone. En 8 mois environ, une éolienne rend l’énergie qu’il a fallu pour la construire. Elle est conçue pour durer 20 ans et tourne souvent plus longtemps. Ses pales, en revanche, restent difficiles à recycler. Choisissez un site calme, moyen ou venté pour voir à quel point le vent de tous les jours compte. Seul ce vent de tous les jours change. La tempête du soir est la même sur tous les sites."
+ }
+ }
+}
diff --git a/explainers/wind-farm/locales/ja.json b/explainers/wind-farm/locales/ja.json
new file mode 100644
index 00000000..54e18424
--- /dev/null
+++ b/explainers/wind-farm/locales/ja.json
@@ -0,0 +1,270 @@
+{
+ "meta": {
+ "title": "風力発電所のしくみ",
+ "eyebrow": "再生可能エネルギー",
+ "tagline": "27基の風車と、風の強い一日。翼の形をしたブレードは、どうやって風を電気に変え、送電網へ送るのでしょう。",
+ "description": "3Dの風力発電所で、風の強い一日をたどります。ブレードが風をとらえるしくみ、風車の中で回るもの、風車どうしが離れて立つ理由、電気が送電網に届くまで。",
+ "summary": "発電所の配置、タワーとブレード、増速機と発電機、パワーカーブとベッツの限界、後流、そして送電網までの道のり。",
+ "socialAlt": "なだらかな丘に広がる風力発電所と、手前に大きく写る風車1基、その横に「風力発電所のしくみ」というタイトル"
+ },
+ "stage": {
+ "label": "風力発電所のインタラクティブモデル",
+ "gauge": "いまの風と出力"
+ },
+ "timeline": {
+ "label": "時刻",
+ "phases": "一日の時間帯へ移動",
+ "speed": "再生速度",
+ "value": "{{time}}",
+ "during": "{{time}}、{{phase}}",
+ "speedValue": "{{value}}時間/分",
+ "speedFormat": "1分で{{value}}時間進む速さ",
+ "phase": {
+ "night": "風の弱い夜",
+ "morning": "朝のそよ風",
+ "afternoon": "フル出力",
+ "storm": "嵐",
+ "evening": "夕方"
+ },
+ "jump": {
+ "night": "風の弱い夜へ移動",
+ "morning": "朝のそよ風へ移動",
+ "afternoon": "フル出力の時間へ移動",
+ "storm": "嵐へ移動",
+ "evening": "夕方へ移動"
+ }
+ },
+ "controls": {
+ "label": "モデルの操作",
+ "siteWind": "設置場所の風",
+ "siteWindOptions": {
+ "calm": "風の弱い場所",
+ "typical": "ふつうの場所",
+ "windy": "風の強い場所"
+ },
+ "spacingOptions": {
+ "5": "5D",
+ "7": "7D",
+ "9": "9D"
+ },
+ "nacelleOptions": {
+ "closed": "カバーあり",
+ "open": "カバーなし"
+ },
+ "windAtOptions": {
+ "day": "時刻に合わせる",
+ "cutIn": "カットイン(3m/s)",
+ "peak": "取り出す割合が最大(8m/s)",
+ "rated": "フル出力(12m/s)",
+ "rampDown": "出力低減(23m/s)",
+ "cutOut": "カットアウト超え(25m/s)"
+ },
+ "groups": {
+ "siteWind": "設置場所の風",
+ "spacing": "列の間隔",
+ "nacelle": "ナセルのカバー",
+ "windAt": "風速"
+ },
+ "views": {
+ "streamlines": {
+ "name": "気流",
+ "short": "気流",
+ "hint": "気流(F)"
+ },
+ "wakes": {
+ "name": "後流",
+ "short": "後流",
+ "hint": "後流(K)"
+ },
+ "cables": {
+ "name": "ケーブル",
+ "short": "ケーブル",
+ "hint": "ケーブル(C)"
+ },
+ "labels": {
+ "name": "ラベル",
+ "short": "ラベル",
+ "hint": "ラベル(L)"
+ }
+ }
+ },
+ "readouts": {
+ "wind": "風",
+ "power": "風車1基",
+ "farm": "発電所全体",
+ "state": {
+ "idle": "風待ち",
+ "partial": "フル出力未満",
+ "full": "フル出力",
+ "rampDown": "出力低減中",
+ "stopping": "フェザリングで減速中",
+ "parked": "停止中",
+ "starting": "起動中"
+ }
+ },
+ "units": {
+ "metresPerSecond": "{{value}}m/s",
+ "speedPair": "{{ms}}m/s、時速{{kmh}}km",
+ "rpm": "{{value}}rpm",
+ "degrees": "{{value}}°",
+ "mw": "{{value}}MW",
+ "mwh": "{{value}}MWh",
+ "percent": "{{value}}%",
+ "metres": "{{value}}m",
+ "seconds": "{{value}}秒",
+ "count": "{{value}}",
+ "hectares": "{{value}}ha",
+ "squareKm": "{{value}}km²",
+ "volts": "{{value}}V",
+ "gramsPerKwh": "1kWhあたり{{value}}g(CO2e)",
+ "months": "{{value}}か月"
+ },
+ "chapters": {
+ "farm": {
+ "count": "風車の数",
+ "rated": "発電所のフル出力",
+ "rows": "列の間隔(風下方向)",
+ "across": "列内の風車の間隔",
+ "spacingValue": "{{metres}}m、{{diameters}}D",
+ "built": "建設に使う土地",
+ "project": "この規模で一般的な事業区域"
+ },
+ "tower": {
+ "wind": "ハブの高さの風",
+ "rpm": "ローターの回転数",
+ "turn": "1回転にかかる時間",
+ "stopped": "ローター停止中",
+ "tip": "ブレード先端の速さ",
+ "height": "ブレード先端の届く高さ",
+ "blade": "ブレードの長さ",
+ "swept": "受風面積",
+ "sweptValue": "{{area}}m²、サッカー場およそ{{pitches}}面分"
+ },
+ "nacelle": {
+ "shaft": "主軸",
+ "generator": "発電機",
+ "gear": "増速比",
+ "gearValue": "約1対{{ratio}}",
+ "pitch": "ブレードのピッチ角",
+ "brake": "ディスクブレーキ",
+ "brakeValue": {
+ "released": "解除",
+ "holding": "ローターを固定中"
+ },
+ "volts": "発電機の電圧"
+ },
+ "curve": {
+ "wind": "風速",
+ "state": "風車の状態",
+ "power": "風車の出力",
+ "caption": "風速0から30m/sまでの、風のもつパワー、ベッツの限界、この風車の出力。点は、いまの風速を示します。",
+ "axisWind": "風速(m/s)",
+ "axisPower": "出力(MW)",
+ "lineWind": "風のもつパワー",
+ "lineBetz": "ベッツの限界",
+ "lineTurbine": "この風車",
+ "windPower": "風のもつパワー",
+ "cp": "風のパワーのうち取り出している割合",
+ "betz": "ベッツの限界に対する割合",
+ "pitch": "ブレードのピッチ角"
+ },
+ "wakes": {
+ "spacing": "列の間隔",
+ "deficit": "後流での減速(1列分後ろ)",
+ "loss": "いま後流で失っている出力",
+ "top": "ローター上端の風",
+ "bottom": "ローター下端の風"
+ },
+ "grid": {
+ "output": "いまの発電所の出力",
+ "energy": "今日の発電量",
+ "homes": "この出力でまかなえる米国の世帯数",
+ "capacity": "この日の設備利用率",
+ "voltages": "各段階の電圧",
+ "voltagesValue": "{{generator}}V、{{collector}}kV、{{grid}}kV",
+ "annual": "風車1基の年間発電量",
+ "annualHomes": "風車1基でまかなえる米国の世帯数",
+ "carbon": "カーボンフットプリント",
+ "payback": "エネルギー回収期間"
+ }
+ },
+ "parts": {
+ "land": "地面",
+ "foundation": "基礎",
+ "tower": "タワー",
+ "transformer": "変圧器",
+ "towerCable": "電力ケーブル",
+ "nacelle": "ナセル",
+ "cooler": "冷却器",
+ "bedplate": "ベッドプレート",
+ "hub": "ハブ",
+ "blades": "ブレード",
+ "pitchCylinders": "ピッチシリンダー",
+ "mainBearing": "主軸受",
+ "mainShaft": "主軸",
+ "gearbox": "増速機",
+ "brakeDisc": "ディスクブレーキ",
+ "generator": "発電機",
+ "converter": "コンバーター",
+ "yawDrives": "ヨー駆動装置",
+ "sweptArea": "受風面積",
+ "streamlinesGroup": "気流",
+ "heroWake": "後流",
+ "farmLand": "農地",
+ "farmTurbines": "27基の風車",
+ "accessRoads": "管理用道路",
+ "spacingMarker": "列の間隔",
+ "prevailingWind": "卓越風",
+ "windArrows": "風",
+ "shearProfile": "高さごとの風",
+ "wakePlumes": "後流",
+ "collectorCables": "集電ケーブル",
+ "substation": "変電所",
+ "gridLine": "送電線"
+ },
+ "sections": {
+ "farm": {
+ "title": "27基の風車と、風の強い一日",
+ "p1": "これは風力発電所です。なだらかな丘の畑に、27基の風車が立ち並んでいます。どれもヴェスタス社のV150です。フル出力のとき、1基で4.2メガワット(MW)を発電します。メガワットは100万ワットのことです。発電所全体では、およそ113MWを発電できます。スウェーデンのモーラルベリエットは、これとそっくりの実在の発電所です。同じ風車が27基あり、2021年から動いています。",
+ "p2": "風車は列になって、卓越風を横切るように並んでいます。卓越風とは、いちばんよく吹く向きの風のことです。ここでは西風です。技術者は、風車どうしの間隔を「ローターの直径いくつ分か」で表し、直径1つ分をDと呼びます。1Dは、ブレードの先端から反対側の先端までの幅で、ここでは150mです。列どうしは7D、つまり1,050m離れています。同じ列の中では、風車どうしは4D、つまり600mの間隔です。真ん中の列は横にずらしてあるので、ほかの風車の真後ろに立つ風車はありません。",
+ "p3": "上から見ると、風車がぎっしり並んでいるように見えます。でも、土地のほとんどは今も畑のままです。建設に使う土地は、1メガワットあたり約0.3ヘクタールだけです。1ヘクタールは、1辺100mの正方形の広さです。しかもその大半は、風車の土台ではなく管理用道路です。集電ケーブルが、すべての風車から電気を集めます。その電気を変電所へ運びます。電気はそこで送電網に合流します。送電網とは、家や町に電気を届ける送電線の網のことです。",
+ "p4": "モーラルベリエットの実際の配置は公開されていません。そのため、ここに見える配置はこのモデル独自のものです。風の強い一日も架空のもので、くり返し再生されます。平均的な一日ではありません。風の弱い夜、そよ風の朝、フル出力の午後、夕方の嵐を、24時間にぎゅっと詰めこんでいます。時計は実際の何倍もの速さで進みますが、ローターはどれも本当の速さで回ります。道路やケーブル、風の矢印は、遠くからでも見えるように、実物より太く描いています。明るさは、昼も夜も変わりません。"
+ },
+ "tower": {
+ "title": "円を描いて飛ぶブレード",
+ "p1": "近くで見ると、風車1基でも見上げるほどの大きさです。ローターは、丸いハブに3枚のブレードを取りつけたもので、直径は150mあります。鋼鉄のタワーが、ハブを地上105mの高さまで持ち上げています。回転のいちばん上では、ブレードの先端が約180mに届きます。ビッグ・ベンのほぼ2倍の高さです。その高さでは、地面の近くより風が速く吹いています。これは第5章で見ていきます。",
+ "p2": "ブレードは1枚が約74mあります。世界最大の旅客機、エアバスA380の翼幅とほぼ同じ長さです。材料は炭素繊維とガラス繊維で、それをエポキシ樹脂で固めています。エポキシ樹脂は、プラスチックでできた硬い接着剤です。風は、帆を押すようにブレードを押すわけではありません。飛行機の翼と同じように、ブレードの表面を流れていきます。ブレードのふくらんだ形が、揚力を生みます。飛行機を空に浮かせているのと同じ力です。この揚力が1枚1枚のブレードを引っぱり、ローターが回ります。",
+ "p3": "最高速度で回っても、ローターは1分間に約10回転しかしません。これを10rpm(毎分回転数)と書きます。6秒で1回転なので、のんびり回っているように見えます。でも先端は大きな円を描くので、秒速約80メートル(m/s)、時速300km近くで動いています。このモデルでは、ローターが最高速度に達するまで、先端は風の8倍の速さで動きます。風速スライダーを動かして、風に合わせてローターが速くなるのを見てみましょう。「時刻に合わせる」を選ぶと、風はまた時計に合わせて変わります。",
+ "p4": "ブレードが回って描く円の広さを受風面積といいます。約17,700m²で、サッカー場およそ2.5面分です。ローターを回せるのは、この円を通り抜ける風だけです。ローターのまわりの気流の線は、図として描いたものにすぎません。本物の空気は目に見えないからです。",
+ "p5": "風車の変圧器は、電気をケーブルで送れる形に整えます。くわしくは第6章で説明します。V150のどこに変圧器があるかは公開されていません。そのため、このモデルではタワーの足もとに置いています。本物のローターは少し上向きに傾いていますが、その角度も公開されていません。そこで、このモデルではローターを傾けずに描いています。"
+ },
+ "nacelle": {
+ "title": "ナセルの中",
+ "p1": "ハブの後ろにある箱がナセルで、長さは12.8mあります。中には、ローターのゆっくりで力強い回転を電気に変える機械が詰まっています。「カバーなし」を選ぶと、中をのぞけます。機械はどれも、ベッドプレートという重い鋳鉄の台に載っています。屋根の冷却器が、機械の熱を外へ逃がします。",
+ "p2": "風に対するブレードの角度をピッチ角といいます。ブレードがどれだけ風をつかめるかは、この角度で決まります。ブレードは1枚ずつ、軸受でハブに取りつけられています。軸受は、ブレードがその場でひねれるようにするリングです。ブレード1枚に1本ずつ、計3本のピッチシリンダーが、油圧でブレードをひねります。ハブの後ろでは、主軸が主軸受に支えられて回っています。回転はローターと同じく、約10rpmとゆっくりです。",
+ "p3": "発電機には速い回転が必要です。そこで、主軸と発電機のあいだに増速機(ギアボックス)が入っています。増速機は、3段階で回転を速めます。最初の2段階では、太陽のまわりを回る惑星のように、小さな歯車が中心の歯車のまわりを回ります。最後の段階では、歯がななめについた歯車を使います。これらを合わせると、回転は約140倍に速まり、最大で約1,485rpmになります。発電機は、銅線のコイルで電気をつくり、永久磁石は使いません。電気は800ボルトで出てきます。そのすべてが次にコンバーターを通ります。コンバーターは、送電網の電気と足並みがそろうように、電気の形を整え直します。",
+ "p4": "軸にはディスクブレーキがついています。でも、嵐のときに風車を止めるのは、このブレーキではありません。止めるのはブレードのほうです。ブレードが薄い縁を風に向けることで、回転が弱まります。ブレーキの役目は、十分ゆっくりになったローターを、その場に止めておくことだけです。タワーのてっぺんでは、ヨー駆動装置がナセルごと回して、風の来る方へ向けます。この向きを変える動きを「ヨー」と呼びます。V150が実際にどれくらいの速さで向きを変えるかは公開されていません。そのため、このモデルでは、ナセルが風向きに合わせてすぐに向きを変えます。"
+ },
+ "curve": {
+ "title": "風車はどれだけ風をつかめるか",
+ "p1": "風車は、風速3m/sのそよ風で発電を始めます。この風速をカットイン風速といいます。そこから、出力は一気に伸びます。風のもつパワーは、風速の3乗、つまり風速×風速×風速に比例して増えるからです。風速が2倍になれば、風が運ぶパワーは8倍になります。そのため風車の出力は、4m/sで約0.3MW、8m/sで約2.5MWです。約12m/sからは、フル出力の4.2MWになります。風速スライダーを動かして、点がカーブの上を動くのを見てみましょう。",
+ "p2": "ただし、そのパワーをすべてとらえられる風車はありません。エネルギーを取り出すには、ローターが空気の流れを遅くする必要があります。でも、遅くなった空気も、後ろへ流れ去らなければなりません。もしローターが空気を完全に止めてしまったら、新しい空気が入ってこられなくなります。物理学者のアルベルト・ベッツは、完璧なローターでも、取り出せるのは風のパワーの59.3%までだと示しました。これがベッツの限界です。この風車が取り出す割合がいちばん高くなるのは、約7から8m/sのときです。その割合は、風のパワーの半分弱です。限界にどこまで近づくかは、グラフで確かめられます。",
+ "p3": "風速が約12m/sを超えると、風は発電機が受けとめきれないほどのパワーを運んできます。そこでピッチシリンダーがブレードを少しだけ風からそらし、より多くの風を素通りさせます。15m/sでは、風車が取り出すのは風のパワーの約12%だけです。20m/sを超えると出力低減が始まり、風車は出力を少しずつ下げていきます。強い風のときにこのモデルが見せるピッチ角は、モデルが推定したものです。V150の実際の値は公開されていないからです。",
+ "p4": "風が強すぎると、風車は運転を止めます。その風速をカットアウト風速といい、約22から25m/sです。このとき、ブレードは縁を風に向けます。これをフェザリングといいます。ローターは自然に減速し、そのあとディスクブレーキで止めておきます。風車が再び動き出すのは、風が約2m/s弱まってからです。このモデルでは、27基すべてが24.5m/sでいっせいに止まり、22.5m/sで動き出します。止まるのにも動き出すのにも、モデルの時計でそれぞれ20分かかります。実際より長くしてあるので、その様子をゆっくり見られます。"
+ },
+ "wakes": {
+ "title": "風車どうしがこれほど離れている理由",
+ "p1": "風車は風からエネルギーを取り出すので、その後ろの空気は遅くなります。この遅くて乱れた空気の流れを後流(ウェイク)と呼びます。船が通ったあとに残る航跡のようなものです。「後流(K)」は、回っているローターすべての後ろに、尾を引くような帯を描きます。オフにして見比べてみましょう。後流がいちばん強くなるのは、中くらいの風のときです。このとき、ローターがいちばん多くの空気を押しとどめるからです。それより風が強くなると、ブレードが空気を多く素通りさせるので、後流は弱まります。",
+ "p2": "ここで描く後流は、ジェンセンモデルにもとづいています。技術者がよく使う、シンプルな計算方法です。後流を円すいの形とみなし、広がりながら、まわりの速い空気で少しずつ埋め戻されていくと考えます。円すいが広がる速さは、1つの数字で決まります。ここでは、陸上でふつう使われる0.075です。風速8m/sのとき、7D風下にある風車には、まだ約13%遅い風が届きます。出力は風速の3乗に比例するので、それだけでこの風車は出力の約3分の1を失います。",
+ "p3": "1年を通すと、後流によって発電所は発電量の約5から15%を失います。ヴェスタスが例に挙げる、この風車を24基並べた発電所では、損失は約6%です。風車が前後一直線に並んでしまう、運の悪い向きから風が吹くと、損失が30%を超えることもあります。そのため、列どうしは風の向きに沿って5から9D、同じ列の風車どうしは風を横切る向きに3から5D離すのがふつうです。5D、7D、9Dを試してみましょう。間隔を広げるほど後流で失う分は減りますが、そのぶん広い土地が必要になります。このモデルでは、風向きも一日のうちに、西を中心に左右へ数度振れます。そのため、ナセルも少しずつ向きを変えます。",
+ "p4": "風は、高いところほど強くなります。地面が、そばの空気の流れを遅くするからです。「気流(F)」で、その様子がわかります。ローターの上端には、下端より速い風が当たっています。帯や矢印は、図として描いたものにすぎません。後流の計算も、ところどころ簡単にしています。地面は平らとみなし、止まったローターの後ろには後流をつくりません。重なった後流も、大まかに足し合わせているだけです。"
+ },
+ "grid": {
+ "title": "送電網までの道のり",
+ "p1": "どの発電機も、800ボルト(V)の電圧で電気をつくります。電圧とは、電気を電線の中へ押し進める力のことです。コンバーターを通ったあと、風車ごとの変圧器がこの電圧を上げます。押す力が強いほど、同じ電力を少ない電流で送れます。電流とは、電気の流れの量のことです。電流が少なければ、ケーブルで熱として失われる電気も減ります。「ケーブル(C)」では、風車から送電網までの電気の通り道が表示されます。",
+ "p2": "36,000ボルト、つまり36キロボルト(kV)の集電ケーブルが、風車と変電所を結んでいます。変電所では、別の変圧器が電圧をもう一度上げて110kVにし、送電線へ送り出します。これらの電圧は、ヴェスタスが例に挙げるドイツの発電所の値です。",
+ "p3": "メガワット時(MWh)は、1メガワットで1時間発電したときのエネルギーの量です。フル出力なら、風車1基は1時間に4.2MWhの電気をつくります。でも、風はいつも強いわけではありません。1年を平均すると、風車の出力はフル出力の約40%です。この割合を設備利用率といいます。風車1基の発電量は1年で約14,700MWhで、米国の家庭約1,400世帯分をまかなえます。「ふつうの場所」と「風の強い場所」では、このモデルの架空の一日は、実際の平均的な一日より風が強めです。そのため、モデルが示す設備利用率は高めに出ます。",
+ "p4": "風車をつくるときにも、エネルギーを使い、CO2を出します。寿命全体で見ると、発電した電気1キロワット時(kWh)あたり約7gのCO2eを出します。CO2eとは、あらゆる温室効果ガスを二酸化炭素に換算した量です。風車は、建設に使ったエネルギーを約8か月で取り戻します。20年もつように設計されていて、それより長く動くこともよくあります。ただ、ブレードのリサイクルはまだ難しいのが現状です。「風の弱い場所」「ふつうの場所」「風の強い場所」を選んで、ふだんの風がどれほど効いてくるか確かめてみましょう。変わるのは、ふだんの風だけです。夕方の嵐は、どの場所でも同じです。"
+ }
+ }
+}
diff --git a/explainers/wind-farm/locales/pt.json b/explainers/wind-farm/locales/pt.json
new file mode 100644
index 00000000..3f64b1e0
--- /dev/null
+++ b/explainers/wind-farm/locales/pt.json
@@ -0,0 +1,270 @@
+{
+ "meta": {
+ "title": "Como funciona um parque eólico",
+ "eyebrow": "Energia renovável",
+ "tagline": "Vinte e sete turbinas e um dia de muito vento: como uma pá com formato de asa transforma o movimento do ar em energia para a rede elétrica.",
+ "description": "Parque eólico 3D num dia de vento: como as pás captam o vento, o que gira dentro das turbinas, por que elas ficam afastadas e como a energia chega à rede.",
+ "summary": "A disposição do parque, a torre e as pás, a caixa multiplicadora e o gerador, a curva de potência e o limite de Betz, as esteiras e o caminho até a rede.",
+ "socialAlt": "Um parque eólico em terreno ondulado, com uma turbina em primeiro plano, ao lado do título “Como funciona um parque eólico”"
+ },
+ "stage": {
+ "label": "Modelo interativo de um parque eólico",
+ "gauge": "Vento e potência em tempo real"
+ },
+ "timeline": {
+ "label": "Hora do dia",
+ "phases": "Ir para um momento do dia",
+ "speed": "Velocidade de reprodução",
+ "value": "{{time}}",
+ "during": "{{time}}, {{phase}}",
+ "speedValue": "{{value}} h/min",
+ "speedFormat": "{{value}} horas do dia por minuto",
+ "phase": {
+ "night": "Noite calma",
+ "morning": "Brisa da manhã",
+ "afternoon": "Potência máxima",
+ "storm": "Tempestade",
+ "evening": "Anoitecer"
+ },
+ "jump": {
+ "night": "Ir para a noite calma",
+ "morning": "Ir para a brisa da manhã",
+ "afternoon": "Ir para a potência máxima",
+ "storm": "Ir para a tempestade",
+ "evening": "Ir para o anoitecer"
+ }
+ },
+ "controls": {
+ "label": "Controles do modelo",
+ "siteWind": "Vento no local",
+ "siteWindOptions": {
+ "calm": "Local calmo",
+ "typical": "Local típico",
+ "windy": "Local ventoso"
+ },
+ "spacingOptions": {
+ "5": "5 D",
+ "7": "7 D",
+ "9": "9 D"
+ },
+ "nacelleOptions": {
+ "closed": "Com cobertura",
+ "open": "Sem cobertura"
+ },
+ "windAtOptions": {
+ "day": "Seguir o dia",
+ "cutIn": "Partida, 3 m/s",
+ "peak": "Melhor aproveitamento, 8 m/s",
+ "rated": "Potência máxima, 12 m/s",
+ "rampDown": "Reduzindo, 23 m/s",
+ "cutOut": "Acima do corte, 25 m/s"
+ },
+ "groups": {
+ "siteWind": "Vento no local",
+ "spacing": "Distância entre fileiras",
+ "nacelle": "Cobertura da nacele",
+ "windAt": "Velocidade do vento"
+ },
+ "views": {
+ "streamlines": {
+ "name": "Fluxo de ar",
+ "short": "Ar",
+ "hint": "Fluxo de ar (F)"
+ },
+ "wakes": {
+ "name": "Esteiras",
+ "short": "Esteiras",
+ "hint": "Esteiras (K)"
+ },
+ "cables": {
+ "name": "Cabos",
+ "short": "Cabos",
+ "hint": "Cabos (C)"
+ },
+ "labels": {
+ "name": "Rótulos",
+ "short": "Rótulos",
+ "hint": "Rótulos (L)"
+ }
+ }
+ },
+ "readouts": {
+ "wind": "Vento",
+ "power": "Uma turbina",
+ "farm": "Parque inteiro",
+ "state": {
+ "idle": "Esperando o vento",
+ "partial": "Abaixo da potência máxima",
+ "full": "Potência máxima",
+ "rampDown": "Reduzindo a potência",
+ "stopping": "Embandeirando para parar",
+ "parked": "Travada",
+ "starting": "Dando a partida"
+ }
+ },
+ "units": {
+ "metresPerSecond": "{{value}} m/s",
+ "speedPair": "{{ms}} m/s, {{kmh}} km/h",
+ "rpm": "{{value}} rpm",
+ "degrees": "{{value}}°",
+ "mw": "{{value}} MW",
+ "mwh": "{{value}} MWh",
+ "percent": "{{value}}%",
+ "metres": "{{value}} m",
+ "seconds": "{{value}} s",
+ "count": "{{value}}",
+ "hectares": "{{value}} ha",
+ "squareKm": "{{value}} km²",
+ "volts": "{{value}} V",
+ "gramsPerKwh": "{{value}} g de CO2e por kWh",
+ "months": "{{value}} meses"
+ },
+ "chapters": {
+ "farm": {
+ "count": "Turbinas",
+ "rated": "Potência máxima do parque",
+ "rows": "Entre fileiras, no sentido do vento",
+ "across": "Entre turbinas da mesma fileira",
+ "spacingValue": "{{metres}} m, {{diameters}} D",
+ "built": "Área construída",
+ "project": "Área típica de projeto deste porte"
+ },
+ "tower": {
+ "wind": "Vento na altura do cubo",
+ "rpm": "Velocidade do rotor",
+ "turn": "Uma volta leva",
+ "stopped": "Rotor parado",
+ "tip": "Velocidade na ponta da pá",
+ "height": "Altura da ponta",
+ "blade": "Comprimento da pá",
+ "swept": "Área varrida",
+ "sweptValue": "{{area}} m², cerca de {{pitches}} campos de futebol"
+ },
+ "nacelle": {
+ "shaft": "Eixo principal",
+ "generator": "Gerador",
+ "gear": "Relação de transmissão",
+ "gearValue": "cerca de 1 para {{ratio}}",
+ "pitch": "Passo das pás",
+ "brake": "Freio a disco",
+ "brakeValue": {
+ "released": "Liberado",
+ "holding": "Segurando o rotor"
+ },
+ "volts": "Tensão do gerador"
+ },
+ "curve": {
+ "wind": "Velocidade do vento",
+ "state": "Estado da turbina",
+ "power": "Produção da turbina",
+ "caption": "Potência do vento, limite de Betz e produção desta turbina, para ventos de 0 a 30 m/s. O ponto mostra o vento atual.",
+ "axisWind": "Velocidade do vento (m/s)",
+ "axisPower": "Potência (MW)",
+ "lineWind": "Potência do vento",
+ "lineBetz": "Limite de Betz",
+ "lineTurbine": "Esta turbina",
+ "windPower": "Potência do vento",
+ "cp": "Parcela da potência do vento captada",
+ "betz": "Parcela do limite de Betz",
+ "pitch": "Passo das pás"
+ },
+ "wakes": {
+ "spacing": "Distância entre fileiras",
+ "deficit": "Perda de velocidade na esteira, uma fileira atrás",
+ "loss": "Potência do parque perdida nas esteiras agora",
+ "top": "Vento na parte de cima do rotor",
+ "bottom": "Vento na parte de baixo do rotor"
+ },
+ "grid": {
+ "output": "Produção do parque agora",
+ "energy": "Energia gerada hoje",
+ "homes": "Casas nos EUA que essa produção abasteceria",
+ "capacity": "Fator de capacidade do dia",
+ "voltages": "Tensão em cada etapa",
+ "voltagesValue": "{{generator}} V, {{collector}} kV, {{grid}} kV",
+ "annual": "Uma turbina em um ano",
+ "annualHomes": "Casas nos EUA que uma turbina abastece",
+ "carbon": "Pegada de carbono",
+ "payback": "Tempo de retorno energético"
+ }
+ },
+ "parts": {
+ "land": "Terreno",
+ "foundation": "Fundação",
+ "tower": "Torre",
+ "transformer": "Transformador",
+ "towerCable": "Cabo de energia",
+ "nacelle": "Nacele",
+ "cooler": "Resfriador",
+ "bedplate": "Chassi",
+ "hub": "Cubo",
+ "blades": "Pás",
+ "pitchCylinders": "Cilindros de passo",
+ "mainBearing": "Rolamento principal",
+ "mainShaft": "Eixo principal",
+ "gearbox": "Caixa multiplicadora",
+ "brakeDisc": "Freio a disco",
+ "generator": "Gerador",
+ "converter": "Conversor",
+ "yawDrives": "Motores de guinada",
+ "sweptArea": "Área varrida",
+ "streamlinesGroup": "Fluxo de ar",
+ "heroWake": "Esteira",
+ "farmLand": "Lavouras",
+ "farmTurbines": "27 turbinas",
+ "accessRoads": "Estradas de acesso",
+ "spacingMarker": "Distância entre fileiras",
+ "prevailingWind": "Vento predominante",
+ "windArrows": "Vento",
+ "shearProfile": "Vento conforme a altura",
+ "wakePlumes": "Esteiras",
+ "collectorCables": "Cabos coletores",
+ "substation": "Subestação",
+ "gridLine": "Linha de transmissão"
+ },
+ "sections": {
+ "farm": {
+ "title": "Vinte e sete turbinas e um dia de vento",
+ "p1": "Este é um parque eólico: 27 turbinas espalhadas por campos ondulados. Cada uma é uma Vestas V150. Na potência máxima, ela gera 4,2 megawatts (MW). Um megawatt é um milhão de watts. Somando todas, o parque pode gerar cerca de 113 MW. Målarberget, na Suécia, é um parque de verdade igual a este: 27 dessas turbinas, funcionando desde 2021.",
+ "p2": "As turbinas ficam lado a lado em fileiras, de frente para o vento predominante, o vento que sopra com mais frequência no local. Aqui, ele vem do oeste. Os engenheiros medem os espaços entre elas em larguras de rotor, chamadas de D. Um D é a largura do rotor de uma ponta de pá à outra: aqui, 150 m. As fileiras ficam a 7 D umas das outras, ou 1.050 m. Em cada fileira, as turbinas ficam a 4 D umas das outras, ou 600 m. A fileira do meio é deslocada para o lado, assim nenhuma turbina fica bem atrás de outra.",
+ "p3": "Visto de cima, o parque parece lotado, mas quase todo o terreno continua sendo lavoura. Para cada megawatt, só cerca de 0,3 hectare tem alguma construção. Um hectare é um quadrado de 100 m de lado. E a maior parte dessa área construída são estradas de acesso, não as bases das turbinas. Os cabos coletores recolhem a energia de todas as turbinas. Eles a levam até uma subestação. Lá, ela entra na rede elétrica, a grande malha de fios que leva energia às casas e às cidades.",
+ "p4": "A disposição real de Målarberget não foi publicada, então a que você vê aqui foi criada para o modelo. O dia de vento também é inventado e se repete sem parar. Não é um dia médio. Em 24 horas, ele reúne uma noite calma, uma manhã de brisa, uma tarde de potência máxima e uma tempestade no começo da noite. O relógio corre muitas vezes mais rápido que o tempo real, mas cada rotor gira na sua velocidade real. Estradas, cabos e setas de vento aparecem mais largos do que são de verdade, para que você os enxergue de bem longe. A luz não muda, nem de dia nem de noite."
+ },
+ "tower": {
+ "title": "Pás que voam em círculos",
+ "p1": "De perto, uma turbina é enorme. O rotor é formado por três pás presas a uma peça central redonda, o cubo, e mede 150 m de ponta a ponta. A torre de aço ergue o cubo a 105 m do chão. No ponto mais alto do giro, a ponta de uma pá chega a cerca de 180 m, quase o dobro da altura do Big Ben. Lá em cima, o vento é mais rápido do que perto do chão, como mostra o capítulo 5.",
+ "p2": "Cada pá tem cerca de 74 m de comprimento, quase a envergadura de um Airbus A380, o maior avião de passageiros. Ela é feita de fibras de carbono e de vidro unidas por epóxi, uma cola plástica que endurece. O vento não empurra a pá como faria com uma vela. O ar passa por ela do mesmo jeito que passa pela asa de um avião. O formato curvo gera sustentação, a mesma força que mantém um avião no ar. É a sustentação que puxa cada pá ao longo do círculo, e assim o rotor gira.",
+ "p3": "Na velocidade máxima, o rotor dá só cerca de 10 voltas por minuto, ou 10 rpm (rotações por minuto). Ou seja, uma volta a cada 6 segundos, um ritmo que parece preguiçoso. Só que as pontas percorrem um círculo enorme, por isso andam a cerca de 80 metros por segundo (m/s), perto de 300 km/h. Neste modelo, as pontas andam 8 vezes mais rápido que o vento, até o rotor chegar à sua velocidade máxima. Arraste o controle deslizante do vento e veja o rotor acelerar junto com o vento. Escolha Seguir o dia para que o vento volte a acompanhar o relógio.",
+ "p4": "O círculo que as pás percorrem é a área varrida: cerca de 17.700 m², mais ou menos 2,5 campos de futebol. Só o vento que atravessa esse círculo consegue girar o rotor. As linhas de ar em volta do rotor são só um desenho, porque o ar de verdade é invisível.",
+ "p5": "O transformador da turbina prepara a energia para seguir pelos cabos, como explica o capítulo 6. O lugar dele na V150 não foi publicado, então este modelo o coloca no pé da torre. O rotor de verdade fica um pouco inclinado para cima, mas esse ângulo também não foi publicado, por isso o modelo deixa o rotor reto."
+ },
+ "nacelle": {
+ "title": "Dentro da nacele",
+ "p1": "A caixa atrás do cubo é a nacele, com 12,8 m de comprimento. Ali ficam as máquinas que transformam o giro lento e forte do rotor em eletricidade. Escolha Sem cobertura para espiar lá dentro. Todas essas máquinas se apoiam no chassi, uma estrutura pesada de ferro fundido. Um resfriador no teto leva embora o calor delas.",
+ "p2": "O ângulo da pá em relação ao vento se chama passo. É ele que define quanto vento a pá consegue aproveitar. Cada pá se encaixa no cubo sobre um rolamento, um anel que deixa a pá girar em torno do próprio eixo. Três cilindros de passo, um por pá, fazem esse giro com pressão de óleo. Atrás do cubo, o eixo principal gira apoiado no rolamento principal, na mesma velocidade lenta do rotor: cerca de 10 rpm.",
+ "p3": "O gerador precisa de um giro rápido, por isso há uma caixa multiplicadora entre ele e o eixo principal. É uma caixa de engrenagens que trabalha em três etapas. Nas duas primeiras, engrenagens pequenas giram em volta de uma engrenagem central, como planetas em volta do Sol. A última etapa usa engrenagens de dentes inclinados. Juntas, as etapas deixam o giro cerca de 140 vezes mais rápido, até uns 1.485 rpm. O gerador produz eletricidade com bobinas de cobre, sem ímãs permanentes. Ele entrega a energia a 800 volts. Depois, toda essa energia passa pelo conversor, que a ajusta para ficar no mesmo ritmo da rede.",
+ "p4": "No eixo fica um freio a disco. Não é ele que faz a turbina parar numa tempestade. Quem faz isso são as pás, ao virar a borda para o vento. O freio só segura o rotor quando ele já perdeu velocidade, para deixá-lo travado. No alto da torre, os motores de guinada giram a nacele inteira para que ela fique de frente para o vento. Esse giro se chama guinada. Neste modelo, a nacele gira junto com o vento na mesma hora, porque a velocidade real desse giro na V150 não foi publicada."
+ },
+ "curve": {
+ "title": "Quanto vento uma turbina consegue captar",
+ "p1": "A turbina começa a gerar energia com uma brisa leve de 3 m/s, a velocidade de partida. Daí em diante, a potência sobe rápido. A potência do vento cresce com o cubo da velocidade: velocidade vezes velocidade vezes velocidade. Se o vento dobra, ele carrega oito vezes mais potência. Por isso, a turbina gera cerca de 0,3 MW a 4 m/s, cerca de 2,5 MW a 8 m/s e seus 4,2 MW de potência máxima a partir de uns 12 m/s. Arraste o controle deslizante do vento e veja o ponto andar pela curva.",
+ "p2": "Nenhuma turbina consegue captar toda essa potência. Para tirar energia, o rotor precisa frear o ar. Mas esse ar mais lento ainda tem que sair por trás dele. Se o rotor parasse o ar por completo, nenhum ar novo conseguiria passar. O físico Albert Betz mostrou que nem um rotor perfeito consegue tirar mais de 59,3% da potência do vento. Esse é o limite de Betz. No melhor momento, com ventos de uns 7 a 8 m/s, esta turbina capta um pouco menos da metade da potência do vento. O gráfico mostra o quanto ela chega perto do limite.",
+ "p3": "Acima de uns 12 m/s, o vento traz mais potência do que o gerador aguenta. Então os cilindros de passo giram as pás um pouco para fora do vento, e mais vento passa direto. A 15 m/s, a turbina aproveita só cerca de 12% da potência do vento. Acima de 20 m/s, ela começa a reduzir a potência, baixando um pouco de cada vez. Os ângulos de passo que o modelo mostra com vento forte são uma estimativa dele mesmo, porque os da V150 não foram publicados.",
+ "p4": "Na velocidade de corte, por volta de 22 a 25 m/s, o vento fica forte demais e a turbina desliga. As pás giram até ficar com a borda virada para o vento. Isso se chama embandeiramento. O rotor desacelera sozinho, e depois o freio a disco o trava. A turbina só volta a funcionar quando o vento cai uns 2 m/s. Neste modelo, as 27 turbinas param juntas a 24,5 m/s e voltam a girar a 22,5 m/s. A parada e a retomada levam 20 minutos cada no relógio do modelo, mais do que na vida real, para você ter tempo de acompanhar."
+ },
+ "wakes": {
+ "title": "Por que as turbinas ficam tão afastadas",
+ "p1": "Uma turbina tira energia do vento, então o ar atrás dela fica mais lento. Esse rastro de ar lento e agitado se chama esteira, como o rastro que um barco deixa na água. A opção Esteiras (K) desenha uma pluma atrás de cada rotor que está girando. Desligue para comparar. As esteiras são mais fortes com ventos médios, quando o rotor segura mais ar. Com vento mais forte, as pás deixam passar mais ar, e as esteiras vão sumindo.",
+ "p2": "Aqui, as esteiras seguem o modelo de Jensen, uma receita simples que os engenheiros usam muito. Ele trata cada esteira como um cone que vai se abrindo e, aos poucos, volta a se encher de ar mais rápido. Um único número define a rapidez com que o cone se abre: aqui, 0,075, o valor usual em terra firme. A 8 m/s, uma turbina 7 D mais adiante, no sentido do vento, ainda recebe ar cerca de 13% mais lento. Como a potência segue o cubo da velocidade, essa turbina perde cerca de um terço da potência.",
+ "p3": "Ao longo de um ano, as esteiras tiram de um parque cerca de 5% a 15% da energia. Em um parque que a Vestas usa como exemplo, com 24 dessas turbinas, a perda fica em cerca de 6%. Quando o vento vem de uma direção azarada, em que as turbinas ficam alinhadas uma atrás da outra, a perda pode passar de 30%. Por isso, as fileiras costumam ficar entre 5 e 9 D umas das outras no sentido do vento, e as turbinas de uma mesma fileira, entre 3 e 5 D. Experimente 5 D, 7 D e 9 D: com mais espaço, as esteiras roubam menos energia, mas o parque precisa de mais terra. Neste modelo, a direção do vento também oscila alguns graus em torno do oeste ao longo do dia, por isso as naceles giram um pouco.",
+ "p4": "O vento também fica mais forte com a altura, porque o chão freia o ar que passa perto dele. A opção Fluxo de ar (F) mostra isso: a parte de cima do rotor recebe vento mais rápido que a parte de baixo. As plumas e as setas são só desenhos. O modelo de esteiras também simplifica as coisas. Ele trata o terreno como plano, não deixa esteira atrás de um rotor parado e soma só de forma aproximada as esteiras que se sobrepõem."
+ },
+ "grid": {
+ "title": "O caminho até a rede",
+ "p1": "Cada gerador produz energia a 800 volts (V). A tensão, ou voltagem, é o empurrão que faz a eletricidade andar pelo fio. Depois do conversor, o transformador da própria turbina eleva a tensão. Com um empurrão mais forte, a mesma potência precisa de menos corrente, que é o fluxo de eletricidade. Menos corrente quer dizer que os cabos desperdiçam menos energia em forma de calor. A opção Cabos (C) mostra o caminho da energia das turbinas até a rede.",
+ "p2": "Os cabos coletores, a 36.000 volts, ou 36 quilovolts (kV), ligam as turbinas à subestação. Lá, outro transformador eleva a tensão mais uma vez, para 110 kV, e a energia segue pela linha de transmissão. Esses valores de tensão vêm de um parque na Alemanha que a Vestas usa como exemplo.",
+ "p3": "Um megawatt-hora (MWh) é a energia gerada com um megawatt de potência durante uma hora. Na potência máxima, uma turbina gera 4,2 MWh a cada hora. Mas o vento nem sempre é forte. Ao longo de um ano, uma turbina produz em média cerca de 40% da sua potência máxima. Essa porcentagem é o fator de capacidade. Em um ano, uma turbina gera cerca de 14.700 MWh, o bastante para umas 1.400 casas nos Estados Unidos. No local típico e no local ventoso, o dia inventado do modelo tem mais vento que um dia médio de verdade, por isso o fator de capacidade que ele mostra sai mais alto.",
+ "p4": "Construir uma turbina também gasta energia e emite carbono. Ao longo de toda a vida útil, ela é responsável por cerca de 7 g de CO2e para cada quilowatt-hora (kWh) que gera. O CO2e conta todos os gases de efeito estufa como se fossem dióxido de carbono. Em cerca de 8 meses, a turbina devolve a energia gasta para construí-la. Ela é projetada para durar 20 anos e muitas vezes vai além. As pás, porém, ainda são difíceis de reciclar. Escolha Local calmo, Local típico ou Local ventoso para ver o quanto o vento de todo dia faz diferença. Só muda o vento de todo dia. A tempestade do começo da noite é a mesma nos três locais."
+ }
+ }
+}
diff --git a/explainers/wind-farm/locales/uk.json b/explainers/wind-farm/locales/uk.json
new file mode 100644
index 00000000..6132889a
--- /dev/null
+++ b/explainers/wind-farm/locales/uk.json
@@ -0,0 +1,270 @@
+{
+ "meta": {
+ "title": "Як працює вітрова електростанція",
+ "eyebrow": "Відновлювана енергетика",
+ "tagline": "Двадцять сім турбін і один вітряний день: як лопать, схожа на крило, перетворює рух повітря на електрику для мережі.",
+ "description": "Вітрова електростанція в 3D за один вітряний день: як лопаті ловлять вітер, що крутиться в турбіні, чому турбіни стоять далеко і як струм іде в мережу.",
+ "summary": "Розташування станції, башта й лопаті, редуктор і генератор, крива потужності й межа Беца, сліди за турбінами та шлях до мережі.",
+ "socialAlt": "Вітрова електростанція серед пологих пагорбів, одна турбіна великим планом, поруч заголовок «Як працює вітрова електростанція»"
+ },
+ "stage": {
+ "label": "Інтерактивна модель вітрової електростанції",
+ "gauge": "Вітер і потужність зараз"
+ },
+ "timeline": {
+ "label": "Час доби",
+ "phases": "Перейти до частини доби",
+ "speed": "Швидкість відтворення",
+ "value": "{{time}}",
+ "during": "{{time}}, {{phase}}",
+ "speedValue": "{{value}} год/хв",
+ "speedFormat": "Годин доби за хвилину: {{value}}",
+ "phase": {
+ "night": "Тиха ніч",
+ "morning": "Ранковий вітерець",
+ "afternoon": "Повна потужність",
+ "storm": "Буря",
+ "evening": "Вечір"
+ },
+ "jump": {
+ "night": "Перейти до тихої ночі",
+ "morning": "Перейти до ранкового вітерця",
+ "afternoon": "Перейти до повної потужності",
+ "storm": "Перейти до бурі",
+ "evening": "Перейти до вечора"
+ }
+ },
+ "controls": {
+ "label": "Керування моделлю",
+ "siteWind": "Вітер у місцевості",
+ "siteWindOptions": {
+ "calm": "Маловітряна місцевість",
+ "typical": "Звичайна місцевість",
+ "windy": "Вітряна місцевість"
+ },
+ "spacingOptions": {
+ "5": "5 D",
+ "7": "7 D",
+ "9": "9 D"
+ },
+ "nacelleOptions": {
+ "closed": "З кожухом",
+ "open": "Без кожуха"
+ },
+ "windAtOptions": {
+ "day": "За ходом дня",
+ "cutIn": "Увімкнення, 3 м/с",
+ "peak": "Найбільша частка, 8 м/с",
+ "rated": "Повна потужність, 12 м/с",
+ "rampDown": "Спад потужності, 23 м/с",
+ "cutOut": "За межею вимкнення, 25 м/с"
+ },
+ "groups": {
+ "siteWind": "Вітер у місцевості",
+ "spacing": "Відстань між рядами",
+ "nacelle": "Кожух гондоли",
+ "windAt": "Швидкість вітру"
+ },
+ "views": {
+ "streamlines": {
+ "name": "Потік повітря",
+ "short": "Повітря",
+ "hint": "Потік повітря (F)"
+ },
+ "wakes": {
+ "name": "Сліди",
+ "short": "Сліди",
+ "hint": "Сліди (K)"
+ },
+ "cables": {
+ "name": "Кабелі",
+ "short": "Кабелі",
+ "hint": "Кабелі (C)"
+ },
+ "labels": {
+ "name": "Підписи",
+ "short": "Підписи",
+ "hint": "Підписи (L)"
+ }
+ }
+ },
+ "readouts": {
+ "wind": "Вітер",
+ "power": "Одна турбіна",
+ "farm": "Уся станція",
+ "state": {
+ "idle": "Чекає на вітер",
+ "partial": "Неповна потужність",
+ "full": "Повна потужність",
+ "rampDown": "Спад потужності",
+ "stopping": "Зупиняється флюгуванням",
+ "parked": "Загальмована",
+ "starting": "Запускається"
+ }
+ },
+ "units": {
+ "metresPerSecond": "{{value}} м/с",
+ "speedPair": "{{ms}} м/с, {{kmh}} км/год",
+ "rpm": "{{value}} об/хв",
+ "degrees": "{{value}}°",
+ "mw": "{{value}} МВт",
+ "mwh": "{{value}} МВт·год",
+ "percent": "{{value}} %",
+ "metres": "{{value}} м",
+ "seconds": "{{value}} с",
+ "count": "{{value}}",
+ "hectares": "{{value}} га",
+ "squareKm": "{{value}} км²",
+ "volts": "{{value}} В",
+ "gramsPerKwh": "{{value}} г CO2-екв. на кВт·год",
+ "months": "{{value}} місяця"
+ },
+ "chapters": {
+ "farm": {
+ "count": "Турбіни",
+ "rated": "Повна потужність станції",
+ "rows": "Між рядами, уздовж вітру",
+ "across": "Між турбінами в ряду",
+ "spacingValue": "{{metres}} м, {{diameters}} D",
+ "built": "Забудована земля",
+ "project": "Типова площа проєкту такого розміру"
+ },
+ "tower": {
+ "wind": "Вітер на висоті маточини",
+ "rpm": "Швидкість ротора",
+ "turn": "Один оберт триває",
+ "stopped": "Ротор зупинено",
+ "tip": "Швидкість кінчика лопаті",
+ "height": "Найвища точка лопаті",
+ "blade": "Довжина лопаті",
+ "swept": "Площа ротора",
+ "sweptValue": "{{area}} м², приблизно {{pitches}} футбольного поля"
+ },
+ "nacelle": {
+ "shaft": "Головний вал",
+ "generator": "Генератор",
+ "gear": "Передавальне число",
+ "gearValue": "приблизно 1 : {{ratio}}",
+ "pitch": "Кут повороту лопатей",
+ "brake": "Дискове гальмо",
+ "brakeValue": {
+ "released": "Відпущене",
+ "holding": "Тримає ротор"
+ },
+ "volts": "Напруга генератора"
+ },
+ "curve": {
+ "wind": "Швидкість вітру",
+ "state": "Стан турбіни",
+ "power": "Потужність турбіни",
+ "caption": "Потужність вітру, межа Беца й потужність цієї турбіни за вітру від 0 до 30 м/с. Точка позначає поточну швидкість вітру.",
+ "axisWind": "Швидкість вітру (м/с)",
+ "axisPower": "Потужність (МВт)",
+ "lineWind": "Потужність вітру",
+ "lineBetz": "Межа Беца",
+ "lineTurbine": "Ця турбіна",
+ "windPower": "Потужність вітру",
+ "cp": "Частка потужності вітру, яку вловлює турбіна",
+ "betz": "Частка від межі Беца",
+ "pitch": "Кут повороту лопатей"
+ },
+ "wakes": {
+ "spacing": "Відстань між рядами",
+ "deficit": "Сповільнення вітру в сліді, на ряд далі",
+ "loss": "Втрати станції через сліди зараз",
+ "top": "Вітер угорі ротора",
+ "bottom": "Вітер унизу ротора"
+ },
+ "grid": {
+ "output": "Потужність станції зараз",
+ "energy": "Вироблено енергії сьогодні",
+ "homes": "Скільки домівок у США це могло б живити",
+ "capacity": "Коефіцієнт використання потужності за цей день",
+ "voltages": "Напруга на кожному етапі",
+ "voltagesValue": "{{generator}} В, {{collector}} кВ, {{grid}} кВ",
+ "annual": "Одна турбіна за рік",
+ "annualHomes": "Скільки домівок у США живить одна турбіна",
+ "carbon": "Вуглецевий слід",
+ "payback": "Енергетична окупність"
+ }
+ },
+ "parts": {
+ "land": "Земля",
+ "foundation": "Фундамент",
+ "tower": "Башта",
+ "transformer": "Трансформатор",
+ "towerCable": "Силовий кабель",
+ "nacelle": "Гондола",
+ "cooler": "Охолоджувач",
+ "bedplate": "Опорна рама",
+ "hub": "Маточина",
+ "blades": "Лопаті",
+ "pitchCylinders": "Циліндри повороту лопатей",
+ "mainBearing": "Головний підшипник",
+ "mainShaft": "Головний вал",
+ "gearbox": "Редуктор",
+ "brakeDisc": "Дискове гальмо",
+ "generator": "Генератор",
+ "converter": "Перетворювач",
+ "yawDrives": "Приводи повороту гондоли",
+ "sweptArea": "Площа ротора",
+ "streamlinesGroup": "Потік повітря",
+ "heroWake": "Слід",
+ "farmLand": "Поля",
+ "farmTurbines": "27 турбін",
+ "accessRoads": "Під’їзні дороги",
+ "spacingMarker": "Відстань між рядами",
+ "prevailingWind": "Переважний вітер",
+ "windArrows": "Вітер",
+ "shearProfile": "Вітер на різній висоті",
+ "wakePlumes": "Сліди",
+ "collectorCables": "Збірні кабелі",
+ "substation": "Підстанція",
+ "gridLine": "Лінія електропередачі"
+ },
+ "sections": {
+ "farm": {
+ "title": "Двадцять сім турбін і один вітряний день",
+ "p1": "Перед вами вітрова електростанція: 27 турбін серед полів на пологих пагорбах. Це турбіни Vestas V150. На повній потужності кожна дає 4,2 мегавата (МВт). Мегават дорівнює мільйону ватів. Разом уся станція може давати близько 113 МВт. Справжня станція, точно як ця, є у Швеції: Моларбер’єт. Там стоять 27 таких самих турбін, і працюють вони з 2021 року.",
+ "p2": "Турбіни стоять рядами впоперек переважного вітру, тобто напрямку, звідки вітер дме найчастіше. Тут це захід. Відстані між турбінами інженери міряють у ширинах ротора й позначають літерою D. Один D дорівнює ширині ротора від кінчика до кінчика лопатей, тут це 150 м. Ряди стоять за 7 D один від одного, тобто за 1050 м. У кожному ряду турбіни стоять за 4 D одна від одної, тобто за 600 м. Середній ряд зсунуто вбік, тож жодна турбіна не стоїть просто за іншою.",
+ "p3": "Згори станція здається тісною, але більша частина землі так і лишається полями. На кожен мегават забудовано лише близько 0,3 гектара. Гектар дорівнює площі квадрата зі стороною 100 м. Більшу частину цієї забудованої землі займають під’їзні дороги, а не основи турбін. Збірні кабелі забирають електрику від кожної турбіни. Вони несуть її до підстанції. Там електрика потрапляє в мережу: павутину ліній електропередачі, яка живить будинки й міста.",
+ "p4": "Справжню розстановку турбін у Моларбер’єті не оприлюднено, тож тут вона своя, придумана для моделі. Вітряний день теж вигаданий і повторюється по колу. Це не середній день. За 24 години в ньому вміщаються тиха ніч, ранковий вітерець, пообідні години на повній потужності й вечірня буря. Годинник іде в багато разів швидше, ніж насправді, але кожен ротор обертається зі своєю справжньою швидкістю. Дороги, кабелі й стрілки вітру намальовано ширшими, ніж вони є насправді, щоб їх було видно здалеку. Освітлення не змінюється ні вдень, ні вночі."
+ },
+ "tower": {
+ "title": "Лопаті, що літають по колу",
+ "p1": "Зблизька видно, яка величезна навіть одна турбіна. Ротор складається з трьох лопатей, закріплених на круглій маточині в центрі, і має 150 м завширшки. Сталева башта піднімає маточину на 105 м над землею. У найвищій точці оберту кінчик лопаті сягає приблизно 180 м. Це майже вдвічі вище за Біг-Бен. Там угорі вітер дме швидше, ніж біля землі, як ви побачите в розділі 5.",
+ "p2": "Кожна лопать завдовжки близько 74 м, майже як розмах крил Airbus A380, найбільшого пасажирського літака. Її роблять із вуглеволокна й скловолокна в епоксидній смолі, тобто твердому пластиковому клеї. Вітер не штовхає лопать, як вітрило. Він обтікає її так само, як крило літака. Завдяки вигнутій формі виникає підйомна сила, та сама, що тримає літак у повітрі. Ця сила тягне кожну лопать по колу, і ротор обертається.",
+ "p3": "На повній швидкості ротор робить лише близько 10 обертів за хвилину, скорочено 10 об/хв. Це один оберт за 6 секунд, тож ротор здається ледачим. Але кінчики лопатей пробігають величезне коло, тому мчать зі швидкістю близько 80 метрів за секунду (м/с), майже 300 км/год. У цій моделі кінчики лопатей рухаються у 8 разів швидше за вітер, доки ротор не вийде на найвищу швидкість. Потягніть повзунок вітру й подивіться, як ротор розганяється разом із вітром. Виберіть «За ходом дня», і вітром знову керуватиме годинник.",
+ "p4": "Площу кола, яке описують лопаті, називають площею ротора. Це близько 17 700 м², приблизно 2,5 футбольного поля. Обертати ротор може лише той вітер, що проходить крізь це коло. Лінії потоку довкола ротора лише намальовані, бо справжнього повітря не видно.",
+ "p5": "Трансформатор турбіни готує електрику до шляху кабелями, як пояснює розділ 6. Де він стоїть у V150, не оприлюднено, тому модель ставить його біля підніжжя башти. Справжній ротор ще й трохи нахилений угору, але цей кут теж не оприлюднено, тож у моделі ротор стоїть рівно."
+ },
+ "nacelle": {
+ "title": "Усередині гондоли",
+ "p1": "Коробка за маточиною називається гондолою, вона завдовжки 12,8 м. У ній стоять механізми, які перетворюють повільне й потужне обертання ротора на електрику. Виберіть «Без кожуха», щоб зазирнути всередину. Усі механізми закріплені на опорній рамі, важкій чавунній основі. Охолоджувач на даху відводить від них тепло.",
+ "p2": "Кут, під яким лопать стоїть до вітру, називають кутом повороту лопаті. Від нього залежить, скільки вітру лопать може вхопити. Кожна лопать сидить у маточині на підшипнику, тобто кільці, яке дає їй провертатися. Три циліндри повороту лопатей, по одному на кожну, провертають їх тиском оливи. За маточиною головний вал обертається в головному підшипнику так само повільно, як ротор, близько 10 об/хв.",
+ "p3": "Генераторові потрібне швидке обертання, тому між ним і головним валом стоїть редуктор із шестернями. Він працює у три кроки. На перших двох малі шестерні кружляють довкола центральної, як планети довкола Сонця. На останньому працюють шестерні з похилими зубцями. Разом вони розкручують вал на виході приблизно в 140 разів швидше, десь до 1485 об/хв. Генератор виробляє електрику за допомогою мідних котушок, без постійних магнітів. Електрика виходить із нього під напругою 800 вольт. Уся вона потім проходить через перетворювач, який підлаштовує її, щоб вона йшла в такт із мережею.",
+ "p4": "На валу стоїть дискове гальмо. Але в бурю турбіну зупиняє не воно. Це роблять лопаті: вони повертаються до вітру ребром. Гальмо вмикається, лише коли ротор уже сповільнився, і просто тримає його на місці. Угорі башти приводи повороту гондоли розвертають усю гондолу назустріч вітру. У цій моделі гондола повертається за вітром одразу, бо справжня швидкість повороту V150 не оприлюднена."
+ },
+ "curve": {
+ "title": "Скільки вітру може вловити турбіна",
+ "p1": "Турбіна починає виробляти електрику вже за легкого вітерця 3 м/с. Це швидкість увімкнення. Далі потужність швидко росте. Потужність вітру залежить від куба швидкості: швидкість, помножена на швидкість і ще раз на швидкість. Удвічі сильніший вітер несе у вісім разів більше потужності. Тож за вітру 4 м/с турбіна дає близько 0,3 МВт, за 8 м/с близько 2,5 МВт, а всі свої 4,2 МВт приблизно від 12 м/с. Потягніть повзунок вітру й подивіться, як точка рухається кривою.",
+ "p2": "Жодна турбіна не вловить усю цю потужність. Щоб забрати енергію, ротор мусить сповільнити повітря. Але сповільнене повітря все одно має відтікати за ротор. Якби ротор зупинив повітря зовсім, нове повітря не змогло б крізь нього пройти. Фізик Альберт Бец показав, що навіть ідеальний ротор може забрати щонайбільше 59,3 % потужності вітру. Це і є межа Беца. Ця турбіна в найкращому разі бере трохи менше половини потужності вітру, коли він дме приблизно від 7 до 8 м/с. Графік показує, наскільки близько вона підходить до межі.",
+ "p3": "Коли вітер дме швидше приблизно за 12 м/с, він приносить більше потужності, ніж може прийняти генератор. Тому циліндри повороту трохи відвертають лопаті від вітру, і більше повітря проходить повз них. За 15 м/с турбіна бере лише близько 12 % потужності вітру. Понад 20 м/с починається спад потужності: турбіна крок за кроком знижує її. Кути повороту лопатей за сильного вітру в моделі лише приблизні, бо справжніх кутів V150 не оприлюднено.",
+ "p4": "За швидкості вимкнення, приблизно від 22 до 25 м/с, вітер уже надто сильний, і турбіна вимикається. Лопаті повертаються до вітру ребром. Це називають флюгуванням. Ротор сповільнюється сам, а тоді дискове гальмо фіксує його на місці. Турбіна знову запускається лише тоді, коли вітер ослабне приблизно на 2 м/с. У цій моделі всі 27 турбін зупиняються разом за 24,5 м/с і знову запускаються за 22,5 м/с. Зупинка й запуск тривають по 20 хвилин за годинником моделі. Це довше, ніж насправді, зате ви встигнете все роздивитися."
+ },
+ "wakes": {
+ "title": "Чому турбіни стоять так далеко одна від одної",
+ "p1": "Турбіна забирає з вітру енергію, тож повітря позаду неї рухається повільніше. Такий шлейф повільного, завихреного повітря називають слідом, як слід за човном на воді. Режим «Сліди (K)» малює шлейф за кожним ротором, що обертається. Вимкніть його, щоб порівняти. Сліди найсильніші за середнього вітру, коли ротор стримує найбільше повітря. За сильнішого вітру лопаті пропускають більше повітря повз себе, і сліди слабшають.",
+ "p2": "Сліди тут побудовано за моделлю Єнсена. Це простий спосіб, яким інженери користуються дуже часто. Він уявляє кожен слід конусом, що розширюється й поволі знову наповнюється швидшим повітрям. Як швидко розширюється конус, задає одне число: тут 0,075, звичне значення для суходолу. За вітру 8 м/с до турбіни, що стоїть на 7 D далі за вітром, повітря все ще доходить приблизно на 13 % повільнішим. Оскільки потужність залежить від куба швидкості, через це турбіна втрачає приблизно третину потужності.",
+ "p3": "За рік сліди забирають у станції приблизно від 5 до 15 % енергії. У прикладі Vestas, де станція має 24 такі турбіни, втрати становлять близько 6 %. Коли вітер дме з одного невдалого напрямку, де турбіни вишикувалися одна за одною, втрати можуть перевищити 30 %. Тому ряди зазвичай ставлять на відстані від 5 до 9 D уздовж вітру, а турбіни в ряду від 3 до 5 D упоперек нього. Спробуйте 5 D, 7 D і 9 D: що ширші проміжки, то менше втрат через сліди, але й землі треба більше. У цій моделі вітер ще й протягом дня відхиляється на кілька градусів то в один, то в інший бік від заходу, тож гондоли трохи повертаються.",
+ "p4": "А ще вітер посилюється з висотою, бо земля гальмує повітря біля себе. Режим «Потік повітря (F)» це показує: угорі ротора вітер швидший, ніж унизу. Шлейфи й стрілки лише намальовані. Модель слідів теж дещо спрощує. Вона вважає землю рівною, за зупиненим ротором сліду не лишає, а сліди, що перекриваються, додає лише приблизно."
+ },
+ "grid": {
+ "title": "Шлях до мережі",
+ "p1": "Кожен генератор виробляє електрику напругою 800 вольт (В). Напруга штовхає електрику вздовж дроту. Після перетворювача власний трансформатор турбіни підвищує напругу. Коли поштовх сильніший, для тієї самої потужності потрібен менший струм, тобто потік електрики. А що менший струм, то менше енергії кабелі втрачають на нагрівання. Режим «Кабелі (C)» показує шлях електрики від турбін до мережі.",
+ "p2": "Збірні кабелі напругою 36 000 вольт, або 36 кіловольт (кВ), з’єднують турбіни з підстанцією. Там інший трансформатор ще раз підвищує напругу, до 110 кВ, для лінії електропередачі. Ці напруги взято з прикладу Vestas для станції в Німеччині.",
+ "p3": "Одна мегават-година (МВт·год) дорівнює енергії, яку дає один мегават за годину. На повній потужності одна турбіна щогодини виробляє 4,2 МВт·год. Але вітер не завжди сильний. У середньому за рік турбіна дає близько 40 % своєї повної потужності. Цю частку називають коефіцієнтом використання потужності. За рік одна турбіна виробляє близько 14 700 МВт·год, і цього вистачає приблизно на 1400 домівок у США. У звичайній і вітряній місцевості вигаданий день моделі вітряніший за справжній середній день, тому й коефіцієнт, який вона показує, виходить вищим.",
+ "p4": "Виготовлення турбіни теж забирає енергію й дає викиди вуглецю. За весь строк служби на кожну вироблену кіловат-годину (кВт·год) припадає близько 7 г CO2-еквівалента (CO2-екв.). У CO2-еквіваленті кожен парниковий газ рахують так, ніби це вуглекислий газ. Енергію, витрачену на виготовлення, турбіна повертає приблизно за 8 місяців. Її розраховано на 20 років, і часто вона працює довше. Але лопаті досі важко переробляти. Виберіть маловітряну, звичайну чи вітряну місцевість, щоб побачити, як багато важить щоденний вітер. Змінюється лише щоденний вітер. Вечірня буря однакова в усіх місцевостях."
+ }
+ }
+}
diff --git a/explainers/wind-farm/locales/zh.json b/explainers/wind-farm/locales/zh.json
new file mode 100644
index 00000000..e01d7e3b
--- /dev/null
+++ b/explainers/wind-farm/locales/zh.json
@@ -0,0 +1,270 @@
+{
+ "meta": {
+ "title": "风电场是怎样工作的",
+ "eyebrow": "可再生能源",
+ "tagline": "27座风机,一个刮风天:像机翼一样的叶片,怎样把流动的空气变成送进电网的电。",
+ "description": "跟着3D风电场过完刮风的一天:叶片怎样借风转起来,风机里面什么在转,风机为何隔得很远,电又怎样送进电网。",
+ "summary": "风电场布局、塔筒与叶片、齿轮箱与发电机、功率曲线与贝茨极限、尾流,以及通往电网的路。",
+ "socialAlt": "起伏的田野上有一座风电场,近处是一台风机的特写,旁边是标题“风电场是怎样工作的”"
+ },
+ "stage": {
+ "label": "风电场互动模型",
+ "gauge": "实时风速与功率"
+ },
+ "timeline": {
+ "label": "一天中的时间",
+ "phases": "跳到一天中的某个时段",
+ "speed": "播放速度",
+ "value": "{{time}}",
+ "during": "{{time}},{{phase}}",
+ "speedValue": "{{value}}小时/分钟",
+ "speedFormat": "每分钟播放一天中的{{value}}小时",
+ "phase": {
+ "night": "无风的夜晚",
+ "morning": "早晨的微风",
+ "afternoon": "满功率",
+ "storm": "风暴",
+ "evening": "傍晚"
+ },
+ "jump": {
+ "night": "跳到无风的夜晚",
+ "morning": "跳到早晨的微风",
+ "afternoon": "跳到满功率的时段",
+ "storm": "跳到风暴",
+ "evening": "跳到傍晚"
+ }
+ },
+ "controls": {
+ "label": "模型控制",
+ "siteWind": "当地风况",
+ "siteWindOptions": {
+ "calm": "风小的地方",
+ "typical": "风况一般的地方",
+ "windy": "风大的地方"
+ },
+ "spacingOptions": {
+ "5": "5 D",
+ "7": "7 D",
+ "9": "9 D"
+ },
+ "nacelleOptions": {
+ "closed": "合上外罩",
+ "open": "打开外罩"
+ },
+ "windAtOptions": {
+ "day": "按这一天的风",
+ "cutIn": "切入风速,3米/秒",
+ "peak": "效率最高,8米/秒",
+ "rated": "满功率,12米/秒",
+ "rampDown": "降功率,23米/秒",
+ "cutOut": "超过切出风速,25米/秒"
+ },
+ "groups": {
+ "siteWind": "当地风况",
+ "spacing": "排间距",
+ "nacelle": "机舱外罩",
+ "windAt": "风速"
+ },
+ "views": {
+ "streamlines": {
+ "name": "气流",
+ "short": "气流",
+ "hint": "气流(F)"
+ },
+ "wakes": {
+ "name": "尾流",
+ "short": "尾流",
+ "hint": "尾流(K)"
+ },
+ "cables": {
+ "name": "电缆",
+ "short": "电缆",
+ "hint": "电缆(C)"
+ },
+ "labels": {
+ "name": "标签",
+ "short": "标签",
+ "hint": "标签(L)"
+ }
+ }
+ },
+ "readouts": {
+ "wind": "风速",
+ "power": "单台风机",
+ "farm": "整座风电场",
+ "state": {
+ "idle": "等风中",
+ "partial": "未到满功率",
+ "full": "满功率",
+ "rampDown": "降功率",
+ "stopping": "顺桨停机中",
+ "parked": "已停机",
+ "starting": "启动中"
+ }
+ },
+ "units": {
+ "metresPerSecond": "{{value}}米/秒",
+ "speedPair": "{{ms}}米/秒,{{kmh}}公里/小时",
+ "rpm": "{{value}}转/分",
+ "degrees": "{{value}}°",
+ "mw": "{{value}}兆瓦",
+ "mwh": "{{value}}兆瓦时",
+ "percent": "{{value}}%",
+ "metres": "{{value}}米",
+ "seconds": "{{value}}秒",
+ "count": "{{value}}",
+ "hectares": "{{value}}公顷",
+ "squareKm": "{{value}}平方公里",
+ "volts": "{{value}}伏",
+ "gramsPerKwh": "每千瓦时{{value}}克CO2e",
+ "months": "{{value}}个月"
+ },
+ "chapters": {
+ "farm": {
+ "count": "风机台数",
+ "rated": "全场满功率",
+ "rows": "排与排之间(顺风向)",
+ "across": "同一排风机之间",
+ "spacingValue": "{{metres}}米,{{diameters}} D",
+ "built": "实际占地",
+ "project": "同等规模项目的典型面积"
+ },
+ "tower": {
+ "wind": "轮毂高度的风速",
+ "rpm": "风轮转速",
+ "turn": "转一圈用时",
+ "stopped": "风轮已停转",
+ "tip": "叶尖速度",
+ "height": "叶尖最高点",
+ "blade": "叶片长度",
+ "swept": "扫掠面积",
+ "sweptValue": "{{area}}平方米,约{{pitches}}个足球场"
+ },
+ "nacelle": {
+ "shaft": "主轴",
+ "generator": "发电机",
+ "gear": "增速比",
+ "gearValue": "约1比{{ratio}}",
+ "pitch": "桨距角",
+ "brake": "盘式制动器",
+ "brakeValue": {
+ "released": "已松开",
+ "holding": "锁住风轮"
+ },
+ "volts": "发电机电压"
+ },
+ "curve": {
+ "wind": "风速",
+ "state": "风机状态",
+ "power": "风机输出",
+ "caption": "风的功率、贝茨极限和这台风机的输出,风速从0到30米/秒。图上的点表示此刻的风速。",
+ "axisWind": "风速(米/秒)",
+ "axisPower": "功率(兆瓦)",
+ "lineWind": "风的功率",
+ "lineBetz": "贝茨极限",
+ "lineTurbine": "这台风机",
+ "windPower": "风的功率",
+ "cp": "从风中抓住的功率比例",
+ "betz": "占贝茨极限的比例",
+ "pitch": "桨距角"
+ },
+ "wakes": {
+ "spacing": "排间距",
+ "deficit": "尾流里的风慢了多少(往后一排处)",
+ "loss": "此刻全场因尾流损失的功率",
+ "top": "风轮顶部的风速",
+ "bottom": "风轮底部的风速"
+ },
+ "grid": {
+ "output": "全场此刻的输出",
+ "energy": "今天的发电量",
+ "homes": "此刻的电够多少户美国家庭用",
+ "capacity": "这一天的容量系数",
+ "voltages": "每一级的电压",
+ "voltagesValue": "{{generator}}伏,{{collector}}千伏,{{grid}}千伏",
+ "annual": "单台风机一年的发电量",
+ "annualHomes": "单台风机够多少户美国家庭用",
+ "carbon": "碳足迹",
+ "payback": "能量回收期"
+ }
+ },
+ "parts": {
+ "land": "地面",
+ "foundation": "基础",
+ "tower": "塔筒",
+ "transformer": "变压器",
+ "towerCable": "电力电缆",
+ "nacelle": "机舱",
+ "cooler": "冷却器",
+ "bedplate": "底座",
+ "hub": "轮毂",
+ "blades": "叶片",
+ "pitchCylinders": "变桨油缸",
+ "mainBearing": "主轴承",
+ "mainShaft": "主轴",
+ "gearbox": "齿轮箱",
+ "brakeDisc": "盘式制动器",
+ "generator": "发电机",
+ "converter": "变流器",
+ "yawDrives": "偏航驱动",
+ "sweptArea": "扫掠面积",
+ "streamlinesGroup": "气流",
+ "heroWake": "尾流",
+ "farmLand": "农田",
+ "farmTurbines": "27座风机",
+ "accessRoads": "检修道路",
+ "spacingMarker": "排间距",
+ "prevailingWind": "主导风向",
+ "windArrows": "风",
+ "shearProfile": "不同高度的风速",
+ "wakePlumes": "尾流",
+ "collectorCables": "集电电缆",
+ "substation": "变电站",
+ "gridLine": "输电线路"
+ },
+ "sections": {
+ "farm": {
+ "title": "27座风机,一个刮风天",
+ "p1": "这是一座风电场:27台风力发电机(下面简称风机)散布在起伏的田野上。每台都是维斯塔斯V150,满功率时能发4.2兆瓦(MW)的电。兆瓦就是100万瓦。全场加起来,最多能发约113兆瓦。瑞典的Målarberget风电场就和它一模一样:同样是27台这种风机,2021年起开始运行。",
+ "p2": "风机排成几排,横对着主导风向,也就是风最常吹来的方向。这里吹的多是西风。工程师习惯用风轮的直径来量间距,记作D。1 D就是风轮从一头叶尖到另一头叶尖的距离,这里是150米。排与排相隔7 D,也就是1050米。同一排里,风机相隔4 D,也就是600米。中间那一排往旁边错开了一段,所以不会有哪台风机正好站在另一台的正后方。",
+ "p3": "从空中看,风电场好像挤得满满的,其实大部分土地还是农田。每兆瓦实际占用的土地,只有约0.3公顷。一公顷就是边长100米的正方形。这些地大多修成了检修道路,而不是风机底座。集电电缆把每台风机发的电收集起来,送到变电站。电在那里并入电网。电网就是由输电线路连成的一张大网,把电送到城镇里的家家户户。",
+ "p4": "Målarberget的真实布局没有公开,所以这里的布局是模型自己排的。这一天的风也是编出来的,会循环播放。这并不是平常的一天。它把无风的夜晚、微风习习的早晨、满功率的下午和傍晚的风暴,全都塞进了24小时。时钟走得比现实快很多倍,但每个风轮都按真实的转速在转。道路、电缆和风向箭头都画得比实际宽,这样你离得很远也能看清。光照从早到晚都不变。"
+ },
+ "tower": {
+ "title": "绕着圈飞的叶片",
+ "p1": "走近一看,一台风机大得惊人。三片叶片装在一个圆圆的轮毂上,合起来叫风轮,直径有150米。钢制的塔筒把轮毂托到离地105米的高处。叶片转到最高点时,叶尖离地约180米,差不多是大本钟的两倍高。那么高的地方,风比贴近地面时更快,第5章会讲到。",
+ "p2": "每片叶片长约74米,差不多赶上最大的客机空客A380的翼展。叶片用碳纤维和玻璃纤维加环氧树脂做成。环氧树脂是一种很硬的塑料胶,把纤维牢牢粘在一起。风不是像推船帆那样推叶片。它流过叶片,就像流过飞机的机翼。叶片弯弯的外形会产生升力,托起飞机的也正是这种力。升力拉着每片叶片往前转,风轮就转起来了。",
+ "p3": "全速时,风轮每分钟也只转约10圈,写作10转/分。算下来6秒才转一圈,看上去懒洋洋的。可叶尖绕的圆实在太大,所以叶尖每秒能跑约80米(80米/秒),时速接近300公里。在这个模型里,风轮达到最高转速之前,叶尖的速度一直是风速的8倍。拖动风速滑块,看风轮怎样随着风变快。选“按这一天的风”,风速就重新交给时钟来定。",
+ "p4": "叶片转动时扫过一个圆,这个圆的面积叫扫掠面积:约1.77万平方米,差不多有2.5个足球场大。只有穿过这个圆的风,才能推动风轮。风轮周围那些气流线只是示意,真实的空气是看不见的。",
+ "p5": "每台风机都有自己的变压器,负责把电变成适合电缆输送的样子,第6章会细讲。V150把变压器装在哪里没有公开,所以模型把它放在塔筒脚下。真实的风轮还会微微往上仰,可这个角度也没有公开,所以模型里的风轮没有上仰。"
+ },
+ "nacelle": {
+ "title": "机舱里有什么",
+ "p1": "轮毂后面那个大箱子叫机舱,长12.8米。风轮转得慢,但力气很大,机舱里的机器负责把这种转动变成电。选“打开外罩”,就能看看里面。这些机器都装在底座上,底座是一个沉重的铸铁框架。机舱顶上有个冷却器,负责把机器发出的热带走。",
+ "p2": "叶片对着风的角度叫桨距角,它决定叶片能吃到多少风。每片叶片都通过一个轴承装在轮毂上。轴承是一个圆环,有了它,叶片就能转动,改变角度。三个变桨油缸靠油压来转动叶片,每片叶片配一个。轮毂后面,主轴在主轴承里转动,转速和风轮一样慢,约10转/分。",
+ "p3": "发电机得转得很快才行,所以在它和主轴之间加了一个齿轮箱。齿轮箱分三步提速。前两步是几个小齿轮围着中间的齿轮转,就像行星绕着太阳转。最后一步用的是斜着切齿的齿轮。三步合起来,让输出的转速提高到原来的约140倍,最高约1485转/分。发电机靠铜线圈发电,不用永久磁铁。它发出的电是800伏,接着全部经过变流器。变流器把电重新调整一遍,让它和电网保持同步。",
+ "p4": "主轴上装着一个盘式制动器,也就是刹车。不过风暴来时,让风机停下的并不是它,而是叶片:叶片会侧过身来,用边缘对着风。刹车只在风轮慢下来以后才出手,把它锁住停稳。塔筒顶上的偏航驱动会转动整个机舱,让它正对来风。这种转动就叫偏航。在这个模型里,风向一变,机舱马上跟着转,因为V150实际转得多快没有公开。"
+ },
+ "curve": {
+ "title": "一台风机能抓住多少风",
+ "p1": "只要有3米/秒的轻风,风机就开始发电,这个风速叫切入风速。从这里开始,功率迅速往上爬。风的功率和风速的三次方成正比,也就是速度乘速度再乘速度。风速翻一倍,风带来的功率就变成8倍。所以这台风机在4米/秒时约发0.3兆瓦,8米/秒时约发2.5兆瓦,从约12米/秒起就能发满4.2兆瓦。拖动风速滑块,看那个点沿着曲线移动。",
+ "p2": "不过,没有哪台风机能把这些功率全部抓住。要从风里取走能量,风轮就得让空气慢下来。可慢下来的空气还得从风轮后面流走。要是风轮把空气完全挡住,新的空气就进不来了。物理学家阿尔伯特·贝茨证明,就算是完美的风轮,最多也只能取走风中59.3%的功率。这就是贝茨极限。这台风机在约7到8米/秒时表现最好,能抓住风中将近一半的功率。从图上能看出它离这个极限有多近。",
+ "p3": "风速超过约12米/秒后,风带来的功率比发电机能接住的还多。于是变桨油缸把叶片稍稍转离风向,让更多的风从旁边溜走。在15米/秒时,风机只留下风中约12%的功率。风速超过20米/秒后,风机开始一步步调低功率。模型在大风时显示的桨距角是自己估的,因为V150的实际数值没有公开。",
+ "p4": "到了切出风速,也就是约22到25米/秒,风太猛了,风机就会停机。叶片侧过身来,用边缘对着风,这叫顺桨。风轮自己慢下来,然后盘式制动器把它锁住停稳。风速要回落约2米/秒,风机才会重新启动。在这个模型里,27座风机在24.5米/秒时一起停机,到22.5米/秒时再重新启动。停机和重启在模型的时钟上各要20分钟,比现实中慢,让你有时间看清楚。"
+ },
+ "wakes": {
+ "title": "风机为什么隔得这么远",
+ "p1": "风机从风里取走了能量,所以它身后的空气就慢了下来。这道又慢又乱的气流叫尾流,就像船尾拖出的那道水痕。“尾流(K)”会在每个转动的风轮后面画出一道尾流。把它关掉,就能对比一下。风不大不小的时候,尾流最强,因为这时风轮挡住的空气最多。风再大一些,叶片会放更多的空气溜过去,尾流也就淡了。",
+ "p2": "这里的尾流按Jensen模型来画,这是工程师常用的一个简单办法。它把每道尾流看成一个圆锥,越往后越宽,周围较快的空气也慢慢补进来。圆锥张开得多快,由一个数决定:这里取0.075,是陆上常用的值。风速8米/秒时,顺着风往后7 D处的风机,迎面的风仍要慢约13%。功率和风速的三次方成正比,所以这台风机要少发约三分之一的电。",
+ "p3": "按一整年算,尾流会让一座风电场少发约5%到15%的电。维斯塔斯举过一个例子:一座有24台这种风机的风电场,损失约6%。要是风正好从某个倒霉的方向吹来,风机一台挡着一台排成一线,损失可能超过30%。所以顺着风向,排与排通常相隔5到9 D;横着风向,同一排里的风机相隔3到5 D。试试“5 D”“7 D”和“9 D”:间距越大,被尾流吃掉的电越少,但要占的地也越多。在这个模型里,风向一天当中还会在正西两边摆动几度,所以机舱也会跟着转一点。",
+ "p4": "风还会越往高处越强,因为地面会拖慢贴近它的空气。“气流(F)”显示的就是这一点:风轮顶部迎来的风比底部更快。画出来的尾流和箭头都只是示意。这个尾流模型也做了些简化:它把地面当作平地,认为停转的风轮不留尾流,几道尾流重叠时也只是粗略地相加。"
+ },
+ "grid": {
+ "title": "通往电网的路",
+ "p1": "每台发电机发出的电是800伏(V)。电压就是推着电沿电线走的那股劲儿。电经过变流器之后,风机自带的变压器会把电压升高。推力越大,输送同样的功率,需要的电流就越小。电流就是电线里流动的电有多少。电流小了,电缆里白白变成热的电也就少了。“电缆(C)”显示的是电从风机一路走到电网的路线。",
+ "p2": "集电电缆把各台风机连到变电站,电压是3.6万伏,也就是36千伏(kV)。到了变电站,另一台变压器再把电压升到110千伏,送上输电线路。这些电压取自维斯塔斯举例用的一座德国风电场。",
+ "p3": "兆瓦时(MWh)是电量的单位:1兆瓦的功率持续1小时,发出的电就是1兆瓦时。满功率时,一台风机每小时能发4.2兆瓦时的电。可风不会一直那么大。一年平均下来,一台风机的输出约为满功率的40%。这个比例就是它的容量系数。一台风机一年约发1.47万兆瓦时的电,够约1400户美国家庭用。在“风况一般的地方”和“风大的地方”,模型编出来的这一天比真实的平常日子风更大,所以显示的容量系数也偏高。",
+ "p4": "造一台风机,本身也要耗能、排碳。算上整个寿命,它每发一千瓦时(kWh)电,也就是一度电,约排放7克CO2e。CO2e叫二氧化碳当量,就是把各种温室气体都折算成二氧化碳来算。大约8个月,风机就能把建造时用掉的能量发回来。风机按能用20年来设计,实际常常用得更久。不过,它的叶片至今仍很难回收。选“风小的地方”“风况一般的地方”或“风大的地方”,看看一个地方平时的风有多重要。变的只是平时的风,傍晚的风暴在每个地方都一样。"
+ }
+ }
+}
diff --git a/explainers/wind-farm/public/cover.webp b/explainers/wind-farm/public/cover.webp
new file mode 100644
index 00000000..e28a93da
Binary files /dev/null and b/explainers/wind-farm/public/cover.webp differ
diff --git a/explainers/wind-farm/public/social/og-image.png b/explainers/wind-farm/public/social/og-image.png
new file mode 100644
index 00000000..117bd03f
Binary files /dev/null and b/explainers/wind-farm/public/social/og-image.png differ
diff --git a/explainers/wind-farm/src/ids.test.ts b/explainers/wind-farm/src/ids.test.ts
new file mode 100644
index 00000000..ab14786b
--- /dev/null
+++ b/explainers/wind-farm/src/ids.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import {
+ FARM_PART_IDS,
+ FOLLOW_DAY,
+ PART_IDS,
+ PRESET_IDS,
+ PRESET_SCENES,
+ SCENE_IDS,
+ SCENE_PARTS,
+ SPACING_CHOICE_IDS,
+ SPACING_OPTIONS,
+ TURBINE_PART_IDS,
+ WIND_AT_IDS,
+ WIND_PRESET_IDS,
+} from './ids';
+
+describe('ids', () => {
+ it('names every part once', () => {
+ expect(PART_IDS).toHaveLength(TURBINE_PART_IDS.length + FARM_PART_IDS.length);
+ expect(new Set(PART_IDS).size).toBe(PART_IDS.length);
+ });
+
+ it('splits the parts between the two scenes with no overlap', () => {
+ expect(Object.keys(SCENE_PARTS).sort()).toEqual([...SCENE_IDS].sort());
+ const scenePartIds = SCENE_IDS.flatMap((scene) => [...SCENE_PARTS[scene]]);
+ expect(scenePartIds).toHaveLength(PART_IDS.length);
+ expect(new Set(scenePartIds)).toEqual(new Set(PART_IDS));
+ });
+
+ it('places every preset in a scene', () => {
+ expect(Object.keys(PRESET_SCENES)).toEqual([...PRESET_IDS]);
+ PRESET_IDS.forEach((preset) => {
+ expect(SCENE_IDS).toContain(PRESET_SCENES[preset]);
+ });
+ });
+
+ it('offers following the day before the wind presets', () => {
+ expect(WIND_AT_IDS[0]).toBe('day');
+ expect(WIND_AT_IDS).toEqual([FOLLOW_DAY, ...WIND_PRESET_IDS]);
+ });
+
+ it('labels each spacing choice with its rotor diameters', () => {
+ expect(SPACING_CHOICE_IDS).toEqual(SPACING_OPTIONS.map(String));
+ });
+});
diff --git a/explainers/wind-farm/src/ids.ts b/explainers/wind-farm/src/ids.ts
new file mode 100644
index 00000000..c2535d81
--- /dev/null
+++ b/explainers/wind-farm/src/ids.ts
@@ -0,0 +1,176 @@
+export const SCENE_IDS = ['farm', 'turbine'] as const;
+
+export type SceneId = (typeof SCENE_IDS)[number];
+
+export const TURBINE_PART_IDS = [
+ 'land',
+ 'foundation',
+ 'tower',
+ 'transformer',
+ 'towerCable',
+ 'nacelle',
+ 'cooler',
+ 'bedplate',
+ 'hub',
+ 'blades',
+ 'pitchCylinders',
+ 'mainBearing',
+ 'mainShaft',
+ 'gearbox',
+ 'brakeDisc',
+ 'generator',
+ 'converter',
+ 'yawDrives',
+ 'sweptArea',
+ 'streamlinesGroup',
+ 'heroWake',
+] as const;
+
+export const FARM_PART_IDS = [
+ 'farmLand',
+ 'farmTurbines',
+ 'accessRoads',
+ 'spacingMarker',
+ 'prevailingWind',
+ 'windArrows',
+ 'shearProfile',
+ 'wakePlumes',
+ 'collectorCables',
+ 'substation',
+ 'gridLine',
+] as const;
+
+export const PART_IDS = [...TURBINE_PART_IDS, ...FARM_PART_IDS] as const;
+
+export type PartId = (typeof PART_IDS)[number];
+
+export const SCENE_PARTS: Readonly> = {
+ turbine: TURBINE_PART_IDS,
+ farm: FARM_PART_IDS,
+};
+
+export const PHASE_IDS = ['night', 'morning', 'afternoon', 'storm', 'evening'] as const;
+
+export type PhaseId = (typeof PHASE_IDS)[number];
+
+export const PRESET_IDS = ['farm', 'tower', 'nacelle', 'curve', 'wakes', 'grid'] as const;
+
+export type PresetId = (typeof PRESET_IDS)[number];
+
+export const PRESET_SCENES: Readonly> = {
+ farm: 'farm',
+ tower: 'turbine',
+ nacelle: 'turbine',
+ curve: 'turbine',
+ wakes: 'farm',
+ grid: 'farm',
+};
+
+export const SITE_WIND_IDS = ['calm', 'typical', 'windy'] as const;
+
+export type SiteWindId = (typeof SITE_WIND_IDS)[number];
+
+export const SPACING_OPTIONS = [5, 7, 9] as const;
+
+export type SpacingD = (typeof SPACING_OPTIONS)[number];
+
+export const SPACING_CHOICE_IDS = ['5', '7', '9'] as const;
+
+export type SpacingChoiceId = (typeof SPACING_CHOICE_IDS)[number];
+
+export const OPERATING_STATE_IDS = [
+ 'idle',
+ 'partial',
+ 'full',
+ 'rampDown',
+ 'stopping',
+ 'parked',
+ 'starting',
+] as const;
+
+export type OperatingStateId = (typeof OPERATING_STATE_IDS)[number];
+
+export const NACELLE_CHOICE_IDS = ['closed', 'open'] as const;
+
+export type NacelleChoiceId = (typeof NACELLE_CHOICE_IDS)[number];
+
+export const WIND_PRESET_IDS = ['cutIn', 'peak', 'rated', 'rampDown', 'cutOut'] as const;
+
+export type WindPresetId = (typeof WIND_PRESET_IDS)[number];
+
+export const FOLLOW_DAY = 'day';
+
+export const WIND_AT_IDS = [FOLLOW_DAY, ...WIND_PRESET_IDS] as const;
+
+export type WindAtId = (typeof WIND_AT_IDS)[number];
+
+export const CHOICE_IDS = ['siteWind'] as const;
+
+export type ChoiceId = (typeof CHOICE_IDS)[number];
+
+export const CHAPTER_ACTION_IDS = ['siteWind', 'spacing', 'nacelle', 'windAt'] as const;
+
+export type ChapterActionId = (typeof CHAPTER_ACTION_IDS)[number];
+
+export const VIEW_IDS = ['streamlines', 'wakes', 'cables', 'labels', 'cutaway'] as const;
+
+export type ViewId = (typeof VIEW_IDS)[number];
+
+export type ViewOptions = Record;
+
+export type CameraView =
+ | 'farmAerial'
+ | 'turbineTall'
+ | 'nacelleCutaway'
+ | 'rotorQuarter'
+ | 'wakeStreaks'
+ | 'gridSubstation';
+
+export type RegionId = 'farm' | 'wakes' | 'grid' | 'turbine' | 'nacelle' | 'rotor';
+
+export type AnchorId = 'hub' | 'yawPivot' | 'towerBase' | 'farmCentre' | 'heroSite' | 'substation';
+
+export type Point = readonly [x: number, y: number, z: number];
+
+export type RowIndex = 0 | 1 | 2;
+
+export interface FarmSite {
+ row: RowIndex;
+ column: number;
+ x: number;
+ z: number;
+}
+
+export interface WindReading {
+ speed: number;
+ fromDeg: number;
+ induction: number;
+}
+
+export interface RotorReading {
+ rpm: number;
+ pitchDeg: number;
+ yawDeg: number;
+ state: OperatingStateId;
+ braked: boolean;
+ powerShare: number;
+}
+
+export interface FarmReading {
+ spacing: SpacingD;
+ sites: readonly FarmSite[];
+ deficits: readonly number[];
+ plumeLengthD: number;
+ plumeStrength: number;
+ outputShare: number;
+}
+
+export interface AssemblyState {
+ scene: SceneId;
+ phase: number;
+ playing: boolean;
+ wind: WindReading;
+ rotor: RotorReading;
+ farm: FarmReading;
+ view: ViewOptions;
+}
diff --git a/explainers/wind-farm/src/index.ts b/explainers/wind-farm/src/index.ts
new file mode 100644
index 00000000..97cdc8f5
--- /dev/null
+++ b/explainers/wind-farm/src/index.ts
@@ -0,0 +1,28 @@
+import './style.css';
+import { defineExplainer } from '@core/explainer';
+import { SCENE_OPTIONS, mountWindFarmScene } from './scene';
+import { PART_INFO } from './scene/partInfo';
+import { PRESETS, createWindFarmStore } from './state';
+import type { WindFarmStoreState } from './state';
+import { WIND_FARM_TIMELINE } from './timeline';
+import {
+ CHAPTER_ACTIONS,
+ VIEW_TOGGLES,
+ WIND_FARM_CHOICES,
+ WIND_FARM_READOUTS,
+ mountWindFarmUi,
+} from './ui';
+
+export default defineExplainer({
+ id: 'wind-farm',
+ timeline: WIND_FARM_TIMELINE,
+ presets: PRESETS,
+ parts: PART_INFO,
+ createStore: () => createWindFarmStore(),
+ dock: { choices: WIND_FARM_CHOICES, toggles: VIEW_TOGGLES },
+ readouts: WIND_FARM_READOUTS,
+ actions: CHAPTER_ACTIONS,
+ scene: SCENE_OPTIONS,
+ mountScene: mountWindFarmScene,
+ mountUi: mountWindFarmUi,
+});
diff --git a/explainers/wind-farm/src/model/constants.test.ts b/explainers/wind-farm/src/model/constants.test.ts
new file mode 100644
index 00000000..3a4e4d7d
--- /dev/null
+++ b/explainers/wind-farm/src/model/constants.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import {
+ BETZ_LIMIT,
+ CUT_IN_MS,
+ CUT_OUT_MS,
+ FARM_RATED_KW,
+ RAMP_START_MS,
+ RATED_WIND_MS,
+ RESTART_MS,
+ ROTOR_RADIUS_M,
+ TIP_HEIGHT_M,
+ TURBINE_COUNT,
+} from './constants';
+
+describe('constants', () => {
+ it('puts the blade tip 180 m above the ground', () => {
+ expect(ROTOR_RADIUS_M).toBe(75);
+ expect(TIP_HEIGHT_M).toBe(180);
+ });
+
+ it('rates the 27 turbine farm at 113,400 kW', () => {
+ expect(TURBINE_COUNT).toBe(27);
+ expect(FARM_RATED_KW).toBe(113_400);
+ });
+
+ it('caps the share of wind power at the Betz limit of 0.593', () => {
+ expect(BETZ_LIMIT).toBeCloseTo(0.593, 3);
+ });
+
+ it('orders the wind thresholds from cut-in to cut-out', () => {
+ expect(CUT_IN_MS).toBeLessThan(RATED_WIND_MS);
+ expect(RATED_WIND_MS).toBeLessThan(RAMP_START_MS);
+ expect(RAMP_START_MS).toBeLessThan(RESTART_MS);
+ expect(RESTART_MS).toBeLessThan(CUT_OUT_MS);
+ });
+});
diff --git a/explainers/wind-farm/src/model/constants.ts b/explainers/wind-farm/src/model/constants.ts
new file mode 100644
index 00000000..355bfeca
--- /dev/null
+++ b/explainers/wind-farm/src/model/constants.ts
@@ -0,0 +1,48 @@
+export const ROTOR_DIAMETER_M = 150;
+export const ROTOR_RADIUS_M = ROTOR_DIAMETER_M / 2;
+export const HUB_HEIGHT_M = 105;
+export const TIP_HEIGHT_M = HUB_HEIGHT_M + ROTOR_RADIUS_M;
+export const BLADE_LENGTH_M = 73.7;
+export const MAX_CHORD_M = 4.2;
+export const SWEPT_AREA_M2 = 17_671;
+export const AIR_DENSITY = 1.225;
+
+export const RATED_KW = 4200;
+export const CUT_IN_MS = 3;
+export const RATED_WIND_MS = 12;
+export const RAMP_START_MS = 20;
+export const CUT_OUT_MS = 24.5;
+export const RESTART_MS = 22.5;
+export const MAX_RPM = 10.4;
+export const GEAR_RATIO = 143;
+export const MODEL_TIP_SPEED_RATIO = 8;
+export const FEATHER_DEG = 90;
+export const STOP_MINUTES = 20;
+export const START_MINUTES = 20;
+export const BETZ_LIMIT = 16 / 27;
+
+export const TURBINE_COUNT = 27;
+export const FARM_RATED_KW = TURBINE_COUNT * RATED_KW;
+export const ACROSS_SPACING_D = 4;
+export const DEFAULT_SPACING_D = 7;
+export const WAKE_DECAY = 0.075;
+export const PLUME_VISIBLE_DEFICIT = 0.03;
+export const MAX_PLUME_D = 20;
+export const SHEAR_EXPONENT = 1 / 7;
+
+export const GENERATOR_VOLTS = 800;
+export const COLLECTOR_KV = 36;
+export const GRID_KV = 110;
+
+export const HOME_KWH_PER_YEAR = 10_791;
+export const HOURS_PER_YEAR = 8760;
+export const ANNUAL_MWH_PER_TURBINE = 14_692;
+export const CARBON_G_PER_KWH = 7.3;
+export const PAYBACK_MONTHS = 7.6;
+export const DESIGN_LIFE_YEARS = 20;
+export const BUILT_HA_PER_MW = 0.3;
+export const PROJECT_HA_PER_MW = 34;
+export const FOOTBALL_PITCH_M2 = 7140;
+
+export const WIND_FROM_DEG = 270;
+export const VEER_DEG = 3;
diff --git a/explainers/wind-farm/src/model/control.test.ts b/explainers/wind-farm/src/model/control.test.ts
new file mode 100644
index 00000000..1da3a7e2
--- /dev/null
+++ b/explainers/wind-farm/src/model/control.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from 'vitest';
+import {
+ advanceAzimuth,
+ generatorRpm,
+ operatingAt,
+ operatingAtWind,
+ parkedWindow,
+ runningPitchDeg,
+ runningRpm,
+ runningState,
+ tipSpeed,
+} from './control';
+import { dayWind } from './day';
+
+const FULL_TURN_RAD = 2 * Math.PI;
+
+describe('running turbine', () => {
+ it('follows the wind with the rotor speed up to 10.4 rpm', () => {
+ expect(runningRpm(8)).toBeCloseTo(8.15, 2);
+ expect(runningRpm(15)).toBe(10.4);
+ expect(runningRpm(-1)).toBe(0);
+ });
+
+ it('moves the blade tip at about 82 m/s at full speed', () => {
+ expect(tipSpeed(10.4)).toBeCloseTo(81.68, 2);
+ });
+
+ it('gears the rotor up to about 1487 rpm at the generator', () => {
+ expect(generatorRpm(10.4)).toBeCloseTo(1487.2, 9);
+ });
+
+ it('pitches the blades only above 11 m/s', () => {
+ expect(runningPitchDeg(10)).toBe(0);
+ expect(runningPitchDeg(15)).toBe(12);
+ });
+
+ it('names the state by the wind speed', () => {
+ expect(runningState(2.9)).toBe('idle');
+ expect(runningState(3)).toBe('partial');
+ expect(runningState(12)).toBe('full');
+ expect(runningState(20)).toBe('full');
+ expect(runningState(20.5)).toBe('rampDown');
+ expect(runningState(24.5)).toBe('parked');
+ });
+});
+
+describe('parkedWindow', () => {
+ it('stops in the storm and restarts as it eases at every site', () => {
+ expect(parkedWindow('typical')).toEqual({ stop: 993, restart: 1122 });
+ expect(parkedWindow('calm')).toEqual({ stop: 995, restart: 1115 });
+ expect(parkedWindow('windy')).toEqual({ stop: 989, restart: 1132 });
+ });
+});
+
+describe('operatingAt', () => {
+ const { stop, restart } = parkedWindow('typical') ?? { stop: 0, restart: 0 };
+
+ it('runs at full power at noon', () => {
+ const noon = operatingAt(720, 'typical');
+ expect(noon.state).toBe('full');
+ expect(noon.producing).toBe(true);
+ expect(noon.braked).toBe(false);
+ });
+
+ it('feathers the blades while stopping', () => {
+ const halfway = operatingAt(stop + 10, 'typical');
+ const runningPitch = runningPitchDeg(dayWind(stop, 'typical'));
+ expect(halfway.state).toBe('stopping');
+ expect(halfway.producing).toBe(false);
+ expect(halfway.braked).toBe(false);
+ expect(halfway.pitchDeg).toBeGreaterThan(runningPitch);
+ expect(halfway.pitchDeg).toBeLessThan(90);
+ expect(operatingAt(stop + 19, 'typical').braked).toBe(true);
+ });
+
+ it('parks the rotor feathered and braked in the storm core', () => {
+ expect(operatingAt(1050, 'typical')).toEqual({
+ state: 'parked',
+ rpm: 0,
+ pitchDeg: 90,
+ braked: true,
+ producing: false,
+ });
+ });
+
+ it('releases the brake while starting and runs again afterwards', () => {
+ const starting = operatingAt(restart + 10, 'typical');
+ expect(starting.state).toBe('starting');
+ expect(starting.braked).toBe(false);
+ expect(starting.producing).toBe(false);
+ const running = operatingAt(restart + 20, 'typical');
+ expect(running.producing).toBe(true);
+ expect(running.braked).toBe(false);
+ });
+});
+
+describe('operatingAtWind', () => {
+ it('parks at cut-out and ramps down just below it', () => {
+ expect(operatingAtWind(25).state).toBe('parked');
+ expect(operatingAtWind(23).state).toBe('rampDown');
+ });
+});
+
+describe('advanceAzimuth', () => {
+ it('turns one full revolution in 6 s at 10 rpm', () => {
+ const azimuth = advanceAzimuth(0, 10, 6);
+ expect(Math.min(azimuth, FULL_TURN_RAD - azimuth)).toBeCloseTo(0, 9);
+ });
+
+ it('keeps the angle within one turn', () => {
+ [-10, -1, -FULL_TURN_RAD].forEach((start) => {
+ const azimuth = advanceAzimuth(start, 0, 1);
+ expect(azimuth).toBeGreaterThanOrEqual(0);
+ expect(azimuth).toBeLessThan(FULL_TURN_RAD);
+ });
+ expect(advanceAzimuth(-1, 0, 1)).toBeCloseTo(FULL_TURN_RAD - 1, 9);
+ });
+});
diff --git a/explainers/wind-farm/src/model/control.ts b/explainers/wind-farm/src/model/control.ts
new file mode 100644
index 00000000..253292dc
--- /dev/null
+++ b/explainers/wind-farm/src/model/control.ts
@@ -0,0 +1,141 @@
+import { lerp, smoothstep } from '@core/math';
+import type { OperatingStateId, SiteWindId } from '../ids';
+import {
+ CUT_IN_MS,
+ CUT_OUT_MS,
+ FEATHER_DEG,
+ GEAR_RATIO,
+ MAX_RPM,
+ MODEL_TIP_SPEED_RATIO,
+ RAMP_START_MS,
+ RATED_WIND_MS,
+ RESTART_MS,
+ ROTOR_DIAMETER_M,
+ START_MINUTES,
+ STOP_MINUTES,
+} from './constants';
+import { PITCH_KNOTS, interpolateKnots } from './curves';
+import { CYCLE_MINUTES, dayWind, wrapMinute } from './day';
+
+export interface OperatingReading {
+ state: OperatingStateId;
+ rpm: number;
+ pitchDeg: number;
+ braked: boolean;
+ producing: boolean;
+}
+
+export interface ParkedWindow {
+ stop: number;
+ restart: number;
+}
+
+const SECONDS_PER_MINUTE = 60;
+const FULL_TURN_RAD = 2 * Math.PI;
+const FEATHER_SHARE = 0.6;
+const SPIN_DOWN_SHARE = 0.8;
+const SPIN_UP_START_SHARE = 0.2;
+const PRODUCING_STATES: readonly OperatingStateId[] = ['partial', 'full', 'rampDown'];
+
+export function runningRpm(wind: number): number {
+ const followRpm =
+ (MODEL_TIP_SPEED_RATIO * Math.max(0, wind) * SECONDS_PER_MINUTE) / (Math.PI * ROTOR_DIAMETER_M);
+ return Math.min(MAX_RPM, followRpm);
+}
+
+export function tipSpeed(rpm: number): number {
+ return (Math.PI * ROTOR_DIAMETER_M * rpm) / SECONDS_PER_MINUTE;
+}
+
+export function generatorRpm(rpm: number): number {
+ return rpm * GEAR_RATIO;
+}
+
+export function runningPitchDeg(wind: number): number {
+ return interpolateKnots(PITCH_KNOTS, wind);
+}
+
+export function runningState(wind: number): OperatingStateId {
+ if (wind < CUT_IN_MS) return 'idle';
+ if (wind < RATED_WIND_MS) return 'partial';
+ if (wind <= RAMP_START_MS) return 'full';
+ if (wind < CUT_OUT_MS) return 'rampDown';
+ return 'parked';
+}
+
+function reading(
+ state: OperatingStateId,
+ rpm: number,
+ pitchDeg: number,
+ braked: boolean,
+): OperatingReading {
+ return { state, rpm, pitchDeg, braked, producing: PRODUCING_STATES.includes(state) };
+}
+
+function runningAt(wind: number): OperatingReading {
+ const state = runningState(wind);
+ if (state === 'parked') return parkedReading();
+ return reading(state, runningRpm(wind), runningPitchDeg(wind), false);
+}
+
+function parkedReading(): OperatingReading {
+ return reading('parked', 0, FEATHER_DEG, true);
+}
+
+export function operatingAtWind(wind: number): OperatingReading {
+ return runningAt(wind);
+}
+
+function findParkedWindow(site: SiteWindId): ParkedWindow | null {
+ let stop: number | null = null;
+ for (let minute = 0; minute < CYCLE_MINUTES; minute += 1) {
+ const wind = dayWind(minute, site);
+ if (stop === null && wind >= CUT_OUT_MS) stop = minute;
+ else if (stop !== null && wind < RESTART_MS) return { stop, restart: minute };
+ }
+ return stop === null ? null : { stop, restart: CYCLE_MINUTES };
+}
+
+const parkedWindows = new Map();
+
+export function parkedWindow(site: SiteWindId): ParkedWindow | null {
+ if (!parkedWindows.has(site)) parkedWindows.set(site, findParkedWindow(site));
+ return parkedWindows.get(site) ?? null;
+}
+
+function stoppingAt(progress: number, windAtStop: number): OperatingReading {
+ const pitchDeg = lerp(
+ runningPitchDeg(windAtStop),
+ FEATHER_DEG,
+ smoothstep(progress, 0, FEATHER_SHARE),
+ );
+ const rpm = runningRpm(windAtStop) * (1 - smoothstep(progress, 0, SPIN_DOWN_SHARE));
+ return reading('stopping', rpm, pitchDeg, progress >= SPIN_DOWN_SHARE);
+}
+
+function startingAt(progress: number, wind: number): OperatingReading {
+ const pitchDeg = lerp(FEATHER_DEG, runningPitchDeg(wind), smoothstep(progress, 0, FEATHER_SHARE));
+ const rpm = runningRpm(wind) * smoothstep(progress, SPIN_UP_START_SHARE, 1);
+ return reading('starting', rpm, pitchDeg, false);
+}
+
+export function operatingAt(minute: number, site: SiteWindId): OperatingReading {
+ const clock = wrapMinute(minute);
+ const wind = dayWind(clock, site);
+ const window = parkedWindow(site);
+ if (window === null || clock < window.stop) return runningAt(wind);
+ const parkedFrom = window.stop + STOP_MINUTES;
+ if (clock < parkedFrom) {
+ return stoppingAt((clock - window.stop) / STOP_MINUTES, dayWind(window.stop, site));
+ }
+ if (clock < window.restart) return parkedReading();
+ if (clock < window.restart + START_MINUTES) {
+ return startingAt((clock - window.restart) / START_MINUTES, wind);
+ }
+ return runningAt(wind);
+}
+
+export function advanceAzimuth(azimuth: number, rpm: number, deltaSeconds: number): number {
+ const next = azimuth + (rpm / SECONDS_PER_MINUTE) * FULL_TURN_RAD * deltaSeconds;
+ return ((next % FULL_TURN_RAD) + FULL_TURN_RAD) % FULL_TURN_RAD;
+}
diff --git a/explainers/wind-farm/src/model/curves.test.ts b/explainers/wind-farm/src/model/curves.test.ts
new file mode 100644
index 00000000..522af702
--- /dev/null
+++ b/explainers/wind-farm/src/model/curves.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+import { PITCH_KNOTS, POWER_CURVE, THRUST_CURVE, interpolateKnots } from './curves';
+import type { Knot } from './curves';
+
+const LINE: readonly Knot[] = [
+ [0, 10],
+ [10, 30],
+ [20, 0],
+];
+
+describe('interpolateKnots', () => {
+ it('holds the first and last values beyond the ends', () => {
+ expect(interpolateKnots(LINE, -5)).toBe(10);
+ expect(interpolateKnots(LINE, 0)).toBe(10);
+ expect(interpolateKnots(LINE, 20)).toBe(0);
+ expect(interpolateKnots(LINE, 99)).toBe(0);
+ });
+
+ it('draws a straight line between neighbouring knots', () => {
+ expect(interpolateKnots(LINE, 5)).toBeCloseTo(20, 9);
+ expect(interpolateKnots(LINE, 10)).toBeCloseTo(30, 9);
+ expect(interpolateKnots(LINE, 15)).toBeCloseTo(15, 9);
+ });
+
+ it('reads 2127 kW from the power curve at 7.5 m/s', () => {
+ expect(interpolateKnots(POWER_CURVE, 7.5)).toBeCloseTo(2127, 9);
+ });
+});
+
+describe('curves', () => {
+ it('lists every curve with rising wind speeds', () => {
+ [POWER_CURVE, THRUST_CURVE, PITCH_KNOTS].forEach((curve) => {
+ curve.slice(1).forEach(([x], index) => {
+ expect(x).toBeGreaterThan(curve[index][0]);
+ });
+ });
+ });
+
+ it('holds 4200 kW between 12 and 20 m/s', () => {
+ expect(interpolateKnots(POWER_CURVE, 12)).toBe(4200);
+ expect(interpolateKnots(POWER_CURVE, 16)).toBe(4200);
+ expect(interpolateKnots(POWER_CURVE, 20)).toBe(4200);
+ });
+});
diff --git a/explainers/wind-farm/src/model/curves.ts b/explainers/wind-farm/src/model/curves.ts
new file mode 100644
index 00000000..0314ee9c
--- /dev/null
+++ b/explainers/wind-farm/src/model/curves.ts
@@ -0,0 +1,54 @@
+export type Knot = readonly [x: number, y: number];
+
+export function interpolateKnots(knots: readonly Knot[], x: number): number {
+ const first = knots[0];
+ const last = knots[knots.length - 1];
+ if (x <= first[0]) return first[1];
+ if (x >= last[0]) return last[1];
+ const upper = knots.findIndex(([knotX]) => knotX >= x);
+ const [x0, y0] = knots[upper - 1];
+ const [x1, y1] = knots[upper];
+ return y0 + ((y1 - y0) * (x - x0)) / (x1 - x0);
+}
+
+export const POWER_CURVE: readonly Knot[] = [
+ [3, 81],
+ [4, 285],
+ [5, 597],
+ [6, 1062],
+ [7, 1709],
+ [8, 2545],
+ [9, 3458],
+ [10, 4038],
+ [11, 4191],
+ [12, 4200],
+ [20, 4200],
+ [20.5, 4186],
+ [21, 3870],
+ [22, 2745],
+ [23, 1805],
+ [24, 1283],
+ [24.5, 1116],
+];
+
+export const THRUST_CURVE: readonly Knot[] = [
+ [4, 0.83],
+ [5, 0.82],
+ [6, 0.82],
+ [7, 0.8],
+ [8, 0.79],
+ [9, 0.7],
+ [10, 0.54],
+ [11, 0.39],
+ [12, 0.29],
+ [15, 0.15],
+ [20, 0.06],
+];
+
+export const PITCH_KNOTS: readonly Knot[] = [
+ [11, 0],
+ [12, 4],
+ [15, 12],
+ [20, 20],
+ [24.5, 27],
+];
diff --git a/explainers/wind-farm/src/model/day.test.ts b/explainers/wind-farm/src/model/day.test.ts
new file mode 100644
index 00000000..d27555ec
--- /dev/null
+++ b/explainers/wind-farm/src/model/day.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest';
+import { PHASE_IDS, SITE_WIND_IDS } from '../ids';
+import {
+ CYCLE_MINUTES,
+ PHASE_RANGES,
+ WIND_PRESETS,
+ clockOf,
+ dayWind,
+ phaseOf,
+ rate,
+ windFromDeg,
+ wrapMinute,
+} from './day';
+
+describe('day phases', () => {
+ it('covers the whole day with ranges that follow each other', () => {
+ expect(CYCLE_MINUTES).toBe(1440);
+ expect(PHASE_RANGES[PHASE_IDS[0]][0]).toBe(0);
+ expect(PHASE_RANGES[PHASE_IDS[PHASE_IDS.length - 1]][1]).toBe(CYCLE_MINUTES);
+ PHASE_IDS.slice(1).forEach((id, index) => {
+ expect(PHASE_RANGES[id][0]).toBe(PHASE_RANGES[PHASE_IDS[index]][1]);
+ });
+ });
+
+ it('switches phase exactly at each boundary', () => {
+ expect(phaseOf(0)).toBe('night');
+ expect(phaseOf(359)).toBe('night');
+ expect(phaseOf(360)).toBe('morning');
+ expect(phaseOf(1147)).toBe('storm');
+ expect(phaseOf(1148)).toBe('evening');
+ expect(phaseOf(1440)).toBe('night');
+ });
+
+ it('wraps minutes into one day', () => {
+ expect(wrapMinute(1500)).toBe(60);
+ expect(wrapMinute(-60)).toBe(1380);
+ });
+});
+
+describe('clockOf', () => {
+ it('prints the minute as a 24 hour clock', () => {
+ expect(clockOf(0)).toBe('00:00');
+ expect(clockOf(1035)).toBe('17:15');
+ expect(clockOf(1440)).toBe('00:00');
+ expect(clockOf(-5)).toBe('23:55');
+ });
+});
+
+describe('dayWind', () => {
+ it('scales the base wind by the site', () => {
+ expect(dayWind(600, 'typical')).toBeCloseTo(9, 9);
+ expect(dayWind(600, 'calm')).toBeCloseTo(7.2, 9);
+ expect(dayWind(600, 'windy')).toBeCloseTo(10.35, 9);
+ expect(dayWind(960, 'calm')).toBeCloseTo(14.4, 9);
+ });
+
+ it('blows 26 m/s at every site in the storm core', () => {
+ SITE_WIND_IDS.forEach((site) => {
+ expect(dayWind(1050, site)).toBeCloseTo(26, 9);
+ });
+ });
+
+ it('joins the end of the day to its start', () => {
+ SITE_WIND_IDS.forEach((site) => {
+ expect(dayWind(CYCLE_MINUTES, site)).toBeCloseTo(dayWind(0, site), 9);
+ });
+ });
+});
+
+describe('windFromDeg', () => {
+ it('veers 3 degrees either side of west through the day', () => {
+ expect(windFromDeg(0)).toBeCloseTo(270, 9);
+ expect(windFromDeg(360)).toBeCloseTo(273, 9);
+ expect(windFromDeg(1080)).toBeCloseTo(267, 9);
+ });
+});
+
+describe('rate', () => {
+ it('turns the speed multiplier into clock minutes per second', () => {
+ expect(rate(12)).toBe(12);
+ });
+});
+
+describe('WIND_PRESETS', () => {
+ it('pins the wind speeds of the five presets', () => {
+ expect(WIND_PRESETS).toEqual({ cutIn: 3, peak: 8, rated: 12, rampDown: 23, cutOut: 25 });
+ });
+});
diff --git a/explainers/wind-farm/src/model/day.ts b/explainers/wind-farm/src/model/day.ts
new file mode 100644
index 00000000..2c78e255
--- /dev/null
+++ b/explainers/wind-farm/src/model/day.ts
@@ -0,0 +1,104 @@
+import { PHASE_IDS, SITE_WIND_IDS } from '../ids';
+import type { PhaseId, SiteWindId, WindPresetId } from '../ids';
+import { CUT_IN_MS, RATED_WIND_MS, VEER_DEG, WIND_FROM_DEG } from './constants';
+import { interpolateKnots } from './curves';
+import type { Knot } from './curves';
+
+export const MINUTES_PER_HOUR = 60;
+export const CYCLE_MINUTES = 24 * MINUTES_PER_HOUR;
+
+export const PHASE_RANGES: Readonly> = {
+ night: [0, 360],
+ morning: [360, 690],
+ afternoon: [690, 960],
+ storm: [960, 1148],
+ evening: [1148, CYCLE_MINUTES],
+};
+
+export const BASE_WIND_KNOTS: readonly Knot[] = [
+ [0, 2.5],
+ [300, 2.5],
+ [360, 3],
+ [600, 9],
+ [690, 12],
+ [900, 16],
+ [960, 18],
+ [1000, 26],
+ [1100, 26],
+ [1148, 18],
+ [1200, 13],
+ [1320, 7],
+ [CYCLE_MINUTES, 2.5],
+];
+
+export const STORM_CORE = [1000, 1100] as const;
+
+export const SITE_WIND_SCALE: Readonly> = {
+ calm: 0.8,
+ typical: 1,
+ windy: 1.15,
+};
+
+const PEAK_EFFICIENCY_MS = 8;
+const PARKED_DEMO_MS = 25;
+const RAMP_DEMO_MS = 23;
+
+export const WIND_PRESETS: Readonly> = {
+ cutIn: CUT_IN_MS,
+ peak: PEAK_EFFICIENCY_MS,
+ rated: RATED_WIND_MS,
+ rampDown: RAMP_DEMO_MS,
+ cutOut: PARKED_DEMO_MS,
+};
+
+export const DEFAULT_SPEED = 12;
+export const SPEED_RANGE = { min: 1, max: 60, step: 1, default: DEFAULT_SPEED } as const;
+
+const SECONDS_PER_MINUTE = 60;
+
+export function rate(speed: number): number {
+ return (speed * MINUTES_PER_HOUR) / SECONDS_PER_MINUTE;
+}
+
+export function wrapMinute(minute: number): number {
+ return ((minute % CYCLE_MINUTES) + CYCLE_MINUTES) % CYCLE_MINUTES;
+}
+
+export function phaseOf(minute: number): PhaseId {
+ const wrapped = wrapMinute(minute);
+ return PHASE_IDS.find((id) => wrapped < PHASE_RANGES[id][1]) ?? PHASE_IDS[PHASE_IDS.length - 1];
+}
+
+function twoDigits(value: number): string {
+ return String(value).padStart(2, '0');
+}
+
+export function clockOf(minute: number): string {
+ const whole = Math.floor(wrapMinute(minute));
+ return `${twoDigits(Math.floor(whole / MINUTES_PER_HOUR))}:${twoDigits(whole % MINUTES_PER_HOUR)}`;
+}
+
+function inStormCore(minute: number): boolean {
+ return minute >= STORM_CORE[0] && minute <= STORM_CORE[1];
+}
+
+function siteKnots(scale: number): readonly Knot[] {
+ return BASE_WIND_KNOTS.map(([minute, wind]) => [
+ minute,
+ inStormCore(minute) ? wind : wind * scale,
+ ]);
+}
+
+const SITE_KNOTS: Readonly> = Object.fromEntries(
+ SITE_WIND_IDS.map((site) => [site, siteKnots(SITE_WIND_SCALE[site])]),
+) as Record;
+
+export function dayWind(minute: number, site: SiteWindId): number {
+ return interpolateKnots(SITE_KNOTS[site], wrapMinute(minute));
+}
+
+const FULL_TURN_RAD = 2 * Math.PI;
+
+export function windFromDeg(minute: number): number {
+ return WIND_FROM_DEG + VEER_DEG * Math.sin((FULL_TURN_RAD * wrapMinute(minute)) / CYCLE_MINUTES);
+}
diff --git a/explainers/wind-farm/src/model/energy.test.ts b/explainers/wind-farm/src/model/energy.test.ts
new file mode 100644
index 00000000..6b2c7abc
--- /dev/null
+++ b/explainers/wind-farm/src/model/energy.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from 'vitest';
+import { FARM_RATED_KW } from './constants';
+import { operatingAt, parkedWindow } from './control';
+import { CYCLE_MINUTES, dayWind, windFromDeg } from './day';
+import {
+ annualHomesPerTurbine,
+ dayCapacityFactor,
+ dayEnergyMwh,
+ energyTable,
+ energyTodayMwh,
+ homesNow,
+} from './energy';
+import { farmPowerKw } from './wakes';
+
+const SAMPLE_COUNT = 289;
+const NOON = 720;
+
+describe('energyTable', () => {
+ it('samples the farm every 5 minutes through the whole day', () => {
+ const table = energyTable('typical', 7);
+ expect(table.stepMinutes).toBe(5);
+ expect(table.farmKw).toHaveLength(SAMPLE_COUNT);
+ expect(table.cumulativeMwh).toHaveLength(SAMPLE_COUNT);
+ expect(table.cumulativeMwh[0]).toBe(0);
+ });
+
+ it('reuses the table for the same site and spacing', () => {
+ expect(energyTable('typical', 7)).toBe(energyTable('typical', 7));
+ expect(energyTable('typical', 7)).not.toBe(energyTable('typical', 9));
+ });
+
+ it('follows the waked farm while producing and drops to zero while parked', () => {
+ const { farmKw } = energyTable('typical', 7);
+ const noonSample = NOON / 5;
+ expect(farmKw[noonSample]).toBeCloseTo(
+ farmPowerKw(dayWind(NOON, 'typical'), windFromDeg(NOON), 7),
+ 9,
+ );
+ const { stop, restart } = parkedWindow('typical') ?? { stop: 0, restart: 0 };
+ const parkedSample = Math.ceil((stop + restart) / 2 / 5) * 5;
+ expect(operatingAt(parkedSample, 'typical').producing).toBe(false);
+ expect(farmKw[parkedSample / 5]).toBe(0);
+ expect(farmKw[0]).toBe(0);
+ });
+
+ it('never runs above the rated farm', () => {
+ expect(Math.max(...energyTable('windy', 9).farmKw)).toBeLessThanOrEqual(FARM_RATED_KW);
+ });
+});
+
+describe('energy today', () => {
+ it('climbs through the day and ends on the day total', () => {
+ const samples = [0, 300, 600, 900, 1200, 1435].map((minute) =>
+ energyTodayMwh(minute, 'typical', 7),
+ );
+ samples
+ .slice(1)
+ .forEach((total, index) => expect(total).toBeGreaterThanOrEqual(samples[index]));
+ expect(energyTodayMwh(0, 'typical', 7)).toBe(0);
+ expect(dayEnergyMwh('typical', 7)).toBeCloseTo(1183.3, 1);
+ });
+
+ it('reads linearly between the samples', () => {
+ const before = energyTodayMwh(NOON, 'typical', 7);
+ const after = energyTodayMwh(NOON + 5, 'typical', 7);
+ expect(energyTodayMwh(NOON + 2, 'typical', 7)).toBeCloseTo(
+ before + ((after - before) * 2) / 5,
+ 9,
+ );
+ });
+
+ it('starts over at midnight', () => {
+ expect(energyTodayMwh(CYCLE_MINUTES, 'typical', 7)).toBe(0);
+ });
+});
+
+describe('day capacity factor', () => {
+ it('lands near 0.37, 0.43 and 0.46 for the calm, typical and windy sites', () => {
+ expect(dayCapacityFactor('calm', 7)).toBeCloseTo(0.37, 2);
+ expect(dayCapacityFactor('typical', 7)).toBeCloseTo(0.43, 2);
+ expect(dayCapacityFactor('windy', 7)).toBeCloseTo(0.46, 2);
+ });
+
+ it('rises from the calm site to the windy one', () => {
+ const calm = dayCapacityFactor('calm', 7);
+ const typical = dayCapacityFactor('typical', 7);
+ expect(typical).toBeGreaterThan(calm);
+ expect(dayCapacityFactor('windy', 7)).toBeGreaterThan(typical);
+ });
+});
+
+describe('homes', () => {
+ it('counts one home for each 1.23 kW of output', () => {
+ expect(homesNow(1.2318)).toBeCloseTo(1, 3);
+ expect(homesNow(4200)).toBeCloseTo(3409.5, 1);
+ expect(homesNow(0)).toBe(0);
+ });
+
+ it('supplies 1,361 full homes a year from one turbine', () => {
+ expect(annualHomesPerTurbine()).toBeCloseTo(1361.5, 1);
+ expect(Math.floor(annualHomesPerTurbine())).toBe(1361);
+ });
+});
diff --git a/explainers/wind-farm/src/model/energy.ts b/explainers/wind-farm/src/model/energy.ts
new file mode 100644
index 00000000..6270a7da
--- /dev/null
+++ b/explainers/wind-farm/src/model/energy.ts
@@ -0,0 +1,79 @@
+import { lerp } from '@core/math';
+import type { SiteWindId, SpacingD } from '../ids';
+import {
+ ANNUAL_MWH_PER_TURBINE,
+ FARM_RATED_KW,
+ HOME_KWH_PER_YEAR,
+ HOURS_PER_YEAR,
+} from './constants';
+import { operatingAt } from './control';
+import { CYCLE_MINUTES, MINUTES_PER_HOUR, dayWind, windFromDeg, wrapMinute } from './day';
+import { farmPowerKw } from './wakes';
+
+export interface EnergyTable {
+ stepMinutes: number;
+ farmKw: readonly number[];
+ cumulativeMwh: readonly number[];
+}
+
+const SAMPLE_STEP_MINUTES = 5;
+const SAMPLE_COUNT = CYCLE_MINUTES / SAMPLE_STEP_MINUTES + 1;
+const KW_PER_MW = 1000;
+const HOURS_PER_DAY = CYCLE_MINUTES / MINUTES_PER_HOUR;
+const HOME_AVERAGE_KW = HOME_KWH_PER_YEAR / HOURS_PER_YEAR;
+
+function farmKwAt(minute: number, site: SiteWindId, spacing: SpacingD): number {
+ if (!operatingAt(minute, site).producing) return 0;
+ return farmPowerKw(dayWind(minute, site), windFromDeg(minute), spacing);
+}
+
+function cumulativeMwh(farmKw: readonly number[]): number[] {
+ const stepHours = SAMPLE_STEP_MINUTES / MINUTES_PER_HOUR;
+ let total = 0;
+ return farmKw.map((kw, index) => {
+ if (index > 0) total += (((farmKw[index - 1] + kw) / 2) * stepHours) / KW_PER_MW;
+ return total;
+ });
+}
+
+function buildEnergyTable(site: SiteWindId, spacing: SpacingD): EnergyTable {
+ const farmKw = Array.from({ length: SAMPLE_COUNT }, (_, index) =>
+ farmKwAt(index * SAMPLE_STEP_MINUTES, site, spacing),
+ );
+ return { stepMinutes: SAMPLE_STEP_MINUTES, farmKw, cumulativeMwh: cumulativeMwh(farmKw) };
+}
+
+const energyTables = new Map();
+
+export function energyTable(site: SiteWindId, spacing: SpacingD): EnergyTable {
+ const key = `${site}:${spacing}`;
+ const cached = energyTables.get(key);
+ if (cached) return cached;
+ const table = buildEnergyTable(site, spacing);
+ energyTables.set(key, table);
+ return table;
+}
+
+export function energyTodayMwh(minute: number, site: SiteWindId, spacing: SpacingD): number {
+ const { cumulativeMwh: totals } = energyTable(site, spacing);
+ const position = wrapMinute(minute) / SAMPLE_STEP_MINUTES;
+ const index = Math.floor(position);
+ return lerp(totals[index], totals[index + 1], position - index);
+}
+
+export function dayEnergyMwh(site: SiteWindId, spacing: SpacingD): number {
+ const { cumulativeMwh: totals } = energyTable(site, spacing);
+ return totals[totals.length - 1];
+}
+
+export function dayCapacityFactor(site: SiteWindId, spacing: SpacingD): number {
+ return dayEnergyMwh(site, spacing) / ((FARM_RATED_KW * HOURS_PER_DAY) / KW_PER_MW);
+}
+
+export function homesNow(farmKw: number): number {
+ return farmKw / HOME_AVERAGE_KW;
+}
+
+export function annualHomesPerTurbine(): number {
+ return (ANNUAL_MWH_PER_TURBINE * KW_PER_MW) / HOME_KWH_PER_YEAR;
+}
diff --git a/explainers/wind-farm/src/model/index.ts b/explainers/wind-farm/src/model/index.ts
new file mode 100644
index 00000000..537a3e82
--- /dev/null
+++ b/explainers/wind-farm/src/model/index.ts
@@ -0,0 +1,10 @@
+export * from './constants';
+export * from './curves';
+export * from './day';
+export * from './layout';
+export * from './control';
+export * from './power';
+export * from './wakes';
+export * from './energy';
+export * from './shear';
+export * from './scale';
diff --git a/explainers/wind-farm/src/model/layout.test.ts b/explainers/wind-farm/src/model/layout.test.ts
new file mode 100644
index 00000000..bed6470b
--- /dev/null
+++ b/explainers/wind-farm/src/model/layout.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest';
+import {
+ COLUMN_COUNT,
+ HERO_SITE,
+ ROW_COUNT,
+ SUBSTATION,
+ collectorRoutes,
+ farmLayout,
+ terrainHeight,
+} from './layout';
+
+const MAX_TERRAIN_M = 27;
+
+describe('farmLayout', () => {
+ it('places 27 turbines in three rows along the wind', () => {
+ const sites = farmLayout(7);
+ expect(sites).toHaveLength(27);
+ expect(ROW_COUNT * COLUMN_COUNT).toBe(27);
+ sites.forEach((site) => {
+ expect(site.x).toBe([-1050, 0, 1050][site.row]);
+ });
+ });
+
+ it('staggers the middle row against the outer rows', () => {
+ farmLayout(7).forEach((site) => {
+ const stagger = site.row === 1 ? 150 : -150;
+ expect(site.z).toBe((site.column - 4) * 600 + stagger);
+ });
+ });
+
+ it('puts the hero turbine in the middle of the front row', () => {
+ expect(HERO_SITE).toBe(4);
+ expect(farmLayout(7)[HERO_SITE]).toEqual({ row: 0, column: 4, x: -1050, z: -150 });
+ });
+
+ it('moves the outer rows with the spacing', () => {
+ const outerX = (spacing: 5 | 9) =>
+ farmLayout(spacing)
+ .filter((site) => site.row !== 1)
+ .map((site) => site.x);
+ expect(new Set(outerX(5))).toEqual(new Set([-750, 750]));
+ expect(new Set(outerX(9))).toEqual(new Set([-1350, 1350]));
+ });
+});
+
+describe('collectorRoutes', () => {
+ it('runs one cable per row along the turbines to the substation', () => {
+ const routes = collectorRoutes(7);
+ expect(routes).toHaveLength(3);
+ routes.forEach((route) => {
+ expect(route).toHaveLength(10);
+ const turbines = route.slice(0, -1);
+ turbines.slice(1).forEach(([, z], index) => {
+ expect(z).toBeGreaterThan(turbines[index][1]);
+ });
+ expect(route[route.length - 1]).toEqual([SUBSTATION.x, SUBSTATION.z]);
+ expect(route[route.length - 1]).toEqual([2200, 3000]);
+ });
+ });
+});
+
+describe('terrainHeight', () => {
+ it('keeps the hills within 27 m of the base level', () => {
+ for (let x = -4000; x <= 6000; x += 250) {
+ for (let z = -5000; z <= 5000; z += 250) {
+ expect(Math.abs(terrainHeight(x, z))).toBeLessThanOrEqual(MAX_TERRAIN_M);
+ }
+ }
+ });
+});
diff --git a/explainers/wind-farm/src/model/layout.ts b/explainers/wind-farm/src/model/layout.ts
new file mode 100644
index 00000000..41afb58b
--- /dev/null
+++ b/explainers/wind-farm/src/model/layout.ts
@@ -0,0 +1,94 @@
+import type { FarmSite, Point, RowIndex, SpacingD } from '../ids';
+import { ACROSS_SPACING_D, HUB_HEIGHT_M, ROTOR_DIAMETER_M, TIP_HEIGHT_M } from './constants';
+
+export type GroundPoint = readonly [x: number, z: number];
+
+const HILL_HEIGHT_M = 18;
+const HILL_WAVE_X_M = 900;
+const HILL_PHASE = 0.6;
+const HILL_WAVE_Z_M = 1300;
+const RIPPLE_HEIGHT_M = 9;
+const RIPPLE_WAVE_M = 520;
+
+export function terrainHeight(x: number, z: number): number {
+ return (
+ HILL_HEIGHT_M * Math.sin(x / HILL_WAVE_X_M + HILL_PHASE) * Math.cos(z / HILL_WAVE_Z_M) +
+ RIPPLE_HEIGHT_M * Math.sin((x + z) / RIPPLE_WAVE_M)
+ );
+}
+
+export const ROW_COUNT = 3;
+export const COLUMN_COUNT = 9;
+const MIDDLE_ROW = 1;
+const MIDDLE_COLUMN = 4;
+const STAGGER_M = 150;
+
+export const HERO_SITE = MIDDLE_COLUMN;
+
+export function farmLayout(spacing: SpacingD): readonly FarmSite[] {
+ const rowStep = spacing * ROTOR_DIAMETER_M;
+ const columnStep = ACROSS_SPACING_D * ROTOR_DIAMETER_M;
+ return Array.from({ length: ROW_COUNT * COLUMN_COUNT }, (_, index) => {
+ const row = Math.floor(index / COLUMN_COUNT) as RowIndex;
+ const column = index % COLUMN_COUNT;
+ const stagger = row === MIDDLE_ROW ? STAGGER_M : -STAGGER_M;
+ return {
+ row,
+ column,
+ x: (row - MIDDLE_ROW) * rowStep,
+ z: (column - MIDDLE_COLUMN) * columnStep + stagger,
+ };
+ });
+}
+
+export const SUBSTATION = { x: 2200, z: 3000, width: 120, depth: 80 } as const;
+export const GRID_LINE_END = { x: 6000, z: 3000 } as const;
+export const PYLON_SPACING_M = 400;
+export const FARM_TERRAIN = { minX: -4000, maxX: 6000, minZ: -5000, maxZ: 5000 } as const;
+
+export function collectorRoutes(spacing: SpacingD): readonly (readonly GroundPoint[])[] {
+ const sites = farmLayout(spacing);
+ return Array.from({ length: ROW_COUNT }, (_, row) => {
+ const rowSites = sites.filter((site) => site.row === row).sort((a, b) => a.z - b.z);
+ const route: GroundPoint[] = rowSites.map((site) => [site.x, site.z]);
+ route.push([SUBSTATION.x, SUBSTATION.z]);
+ return route;
+ });
+}
+
+const WIND_ARROW_OFFSET_M = 600;
+const SHEAR_OFFSET_M = 400;
+
+export function windArrowsX(spacing: SpacingD): number {
+ return -spacing * ROTOR_DIAMETER_M - WIND_ARROW_OFFSET_M;
+}
+
+export function shearProfileAt(spacing: SpacingD): GroundPoint {
+ return [-spacing * ROTOR_DIAMETER_M - SHEAR_OFFSET_M, -STAGGER_M];
+}
+
+export const SHEAR_HEIGHTS_M = [30, 55, 80, HUB_HEIGHT_M, 130, 155, TIP_HEIGHT_M] as const;
+
+export const TURBINE_LAND = { radius: 3000, flatRadius: 60, blendRadius: 200 } as const;
+
+export const TURBINE_GEOMETRY = {
+ towerBaseDiameter: 4.5,
+ towerTopDiameter: 3.0,
+ towerTopY: 103.3,
+ nacelle: { minX: -4.4, maxX: 8.4, minY: 103.3, maxY: 106.7, halfWidth: 2.1 },
+ cooler: { minX: 4.0, maxX: 8.4, topY: 110.2 },
+ shaftY: HUB_HEIGHT_M,
+ hub: [-7.0, HUB_HEIGHT_M, 0] as Point,
+ spinnerRadius: 1.9,
+ bladeRootRadius: 1.3,
+ mainBearing: { x: -3.6, radius: 1.2 },
+ mainShaft: { minX: -4.4, maxX: -1.0, radius: 0.6 },
+ gearbox: { minX: -1.0, maxX: 2.4, radius: 1.4 },
+ brakeDisc: { x: 2.6, radius: 0.9 },
+ generator: { minX: 3.2, maxX: 5.6, radius: 0.9 },
+ converter: { minX: 6.0, maxX: 8.0, wallZ: -2.1 },
+ bedplate: { minX: -4.4, maxX: 2.4, y: 103.5 },
+ yawDrives: { y: 103.3, radius: 1.9 },
+ transformer: { centre: [8, 0, 8] as Point, size: [3, 2.5, 3] as Point },
+ foundationRadius: 5,
+} as const;
diff --git a/explainers/wind-farm/src/model/power.test.ts b/explainers/wind-farm/src/model/power.test.ts
new file mode 100644
index 00000000..18ab5f86
--- /dev/null
+++ b/explainers/wind-farm/src/model/power.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest';
+import { BETZ_LIMIT, RATED_KW } from './constants';
+import {
+ betzShare,
+ isGenerating,
+ powerCoefficient,
+ thrustCoefficient,
+ turbinePowerKw,
+ windPowerKw,
+} from './power';
+
+describe('turbine power', () => {
+ it('makes nothing below cut-in and from cut-out up', () => {
+ expect(turbinePowerKw(2.9)).toBe(0);
+ expect(turbinePowerKw(24.5)).toBe(0);
+ expect(turbinePowerKw(30)).toBe(0);
+ expect(isGenerating(3)).toBe(true);
+ expect(isGenerating(24.5)).toBe(false);
+ });
+
+ it('follows the published power curve between the knots', () => {
+ expect(turbinePowerKw(3)).toBe(81);
+ expect(turbinePowerKw(8)).toBe(2545);
+ expect(turbinePowerKw(7.5)).toBeCloseTo(2127, 9);
+ expect(turbinePowerKw(15)).toBe(RATED_KW);
+ expect(turbinePowerKw(24)).toBe(1283);
+ });
+});
+
+describe('thrust coefficient', () => {
+ it('reads the thrust curve and holds it flat past its ends', () => {
+ expect(thrustCoefficient(8)).toBe(0.79);
+ expect(thrustCoefficient(3.5)).toBe(0.83);
+ expect(thrustCoefficient(22)).toBe(0.06);
+ });
+
+ it('drops to zero when the rotor is not running', () => {
+ expect(thrustCoefficient(2)).toBe(0);
+ expect(thrustCoefficient(25)).toBe(0);
+ });
+});
+
+describe('power in the wind', () => {
+ it('grows with the cube of the wind at about 10.82 kW per (m/s)³', () => {
+ expect(windPowerKw(1)).toBeCloseTo(10.82, 2);
+ expect(windPowerKw(10)).toBeCloseTo(10_823.5, 0);
+ expect(windPowerKw(0)).toBe(0);
+ });
+
+ it('peaks the power coefficient near 0.46 from 6.5 to 8 m/s', () => {
+ expect(powerCoefficient(7)).toBeCloseTo(0.46, 2);
+ expect(powerCoefficient(8)).toBeCloseTo(0.46, 2);
+ const winds = Array.from({ length: 41 }, (_, index) => 3 + index * 0.5);
+ const peak = Math.max(...winds.map(powerCoefficient));
+ expect(peak).toBeCloseTo(0.466, 3);
+ });
+
+ it('gives no power coefficient when the turbine makes nothing', () => {
+ expect(powerCoefficient(2)).toBe(0);
+ expect(powerCoefficient(25)).toBe(0);
+ });
+
+ it('stays below the Betz limit', () => {
+ expect(betzShare(8)).toBeCloseTo(powerCoefficient(8) / BETZ_LIMIT, 9);
+ expect(betzShare(8)).toBeCloseTo(0.775, 3);
+ expect(betzShare(8)).toBeLessThan(1);
+ });
+});
diff --git a/explainers/wind-farm/src/model/power.ts b/explainers/wind-farm/src/model/power.ts
new file mode 100644
index 00000000..386a3958
--- /dev/null
+++ b/explainers/wind-farm/src/model/power.ts
@@ -0,0 +1,29 @@
+import { AIR_DENSITY, BETZ_LIMIT, CUT_IN_MS, CUT_OUT_MS, SWEPT_AREA_M2 } from './constants';
+import { POWER_CURVE, THRUST_CURVE, interpolateKnots } from './curves';
+
+const WATTS_PER_KW = 1000;
+
+export function isGenerating(wind: number): boolean {
+ return wind >= CUT_IN_MS && wind < CUT_OUT_MS;
+}
+
+export function turbinePowerKw(wind: number): number {
+ return isGenerating(wind) ? interpolateKnots(POWER_CURVE, wind) : 0;
+}
+
+export function thrustCoefficient(wind: number): number {
+ return isGenerating(wind) ? interpolateKnots(THRUST_CURVE, wind) : 0;
+}
+
+export function windPowerKw(wind: number): number {
+ return (0.5 * AIR_DENSITY * SWEPT_AREA_M2 * wind ** 3) / WATTS_PER_KW;
+}
+
+export function powerCoefficient(wind: number): number {
+ const power = turbinePowerKw(wind);
+ return power > 0 ? power / windPowerKw(wind) : 0;
+}
+
+export function betzShare(wind: number): number {
+ return powerCoefficient(wind) / BETZ_LIMIT;
+}
diff --git a/explainers/wind-farm/src/model/scale.test.ts b/explainers/wind-farm/src/model/scale.test.ts
new file mode 100644
index 00000000..0d9e76fe
--- /dev/null
+++ b/explainers/wind-farm/src/model/scale.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+import { MAX_RPM } from './constants';
+import { builtHectares, farmRatedMw, projectKm2, sweptPitches, turnSeconds } from './scale';
+
+describe('scale', () => {
+ it('sweeps about 2.47 football pitches with one rotor', () => {
+ expect(sweptPitches()).toBeCloseTo(2.47, 2);
+ });
+
+ it('rates the 27 turbines at 113.4 MW', () => {
+ expect(farmRatedMw()).toBeCloseTo(113.4, 9);
+ });
+
+ it('builds on about 34 ha inside a 38.6 km² project area', () => {
+ expect(builtHectares()).toBeCloseTo(34, 0);
+ expect(projectKm2()).toBeCloseTo(38.6, 1);
+ });
+
+ it('turns the rotor once every 5.8 s at full speed', () => {
+ expect(turnSeconds(MAX_RPM)).toBeCloseTo(5.8, 1);
+ expect(turnSeconds(6)).toBe(10);
+ });
+
+ it('never completes a turn while the rotor stands still', () => {
+ expect(turnSeconds(0)).toBe(Infinity);
+ expect(turnSeconds(-1)).toBe(Infinity);
+ });
+});
diff --git a/explainers/wind-farm/src/model/scale.ts b/explainers/wind-farm/src/model/scale.ts
new file mode 100644
index 00000000..a18ac307
--- /dev/null
+++ b/explainers/wind-farm/src/model/scale.ts
@@ -0,0 +1,31 @@
+import {
+ BUILT_HA_PER_MW,
+ FARM_RATED_KW,
+ FOOTBALL_PITCH_M2,
+ PROJECT_HA_PER_MW,
+ SWEPT_AREA_M2,
+} from './constants';
+
+const KW_PER_MW = 1000;
+const HECTARES_PER_KM2 = 100;
+const SECONDS_PER_MINUTE = 60;
+
+export function sweptPitches(): number {
+ return SWEPT_AREA_M2 / FOOTBALL_PITCH_M2;
+}
+
+export function farmRatedMw(): number {
+ return FARM_RATED_KW / KW_PER_MW;
+}
+
+export function builtHectares(): number {
+ return BUILT_HA_PER_MW * farmRatedMw();
+}
+
+export function projectKm2(): number {
+ return (PROJECT_HA_PER_MW * farmRatedMw()) / HECTARES_PER_KM2;
+}
+
+export function turnSeconds(rpm: number): number {
+ return rpm > 0 ? SECONDS_PER_MINUTE / rpm : Infinity;
+}
diff --git a/explainers/wind-farm/src/model/shear.test.ts b/explainers/wind-farm/src/model/shear.test.ts
new file mode 100644
index 00000000..7621f84f
--- /dev/null
+++ b/explainers/wind-farm/src/model/shear.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+import { HUB_HEIGHT_M, TIP_HEIGHT_M } from './constants';
+import { SHEAR_HEIGHTS_M } from './layout';
+import { windAtHeight } from './shear';
+
+describe('windAtHeight', () => {
+ it('keeps the hub wind at hub height', () => {
+ expect(windAtHeight(10, HUB_HEIGHT_M)).toBe(10);
+ });
+
+ it('blows about 10.8 m/s at the tip and 8.4 m/s at 30 m for 10 m/s at the hub', () => {
+ expect(windAtHeight(10, TIP_HEIGHT_M)).toBeCloseTo(10.8, 1);
+ expect(windAtHeight(10, 30)).toBeCloseTo(8.4, 1);
+ });
+
+ it('grows with height up the shear profile', () => {
+ const winds = SHEAR_HEIGHTS_M.map((height) => windAtHeight(10, height));
+ winds.slice(1).forEach((wind, index) => expect(wind).toBeGreaterThan(winds[index]));
+ });
+
+ it('stays calm when the hub is calm', () => {
+ expect(windAtHeight(0, TIP_HEIGHT_M)).toBe(0);
+ });
+});
diff --git a/explainers/wind-farm/src/model/shear.ts b/explainers/wind-farm/src/model/shear.ts
new file mode 100644
index 00000000..3589b917
--- /dev/null
+++ b/explainers/wind-farm/src/model/shear.ts
@@ -0,0 +1,5 @@
+import { HUB_HEIGHT_M, SHEAR_EXPONENT } from './constants';
+
+export function windAtHeight(hubWind: number, heightM: number): number {
+ return hubWind * (heightM / HUB_HEIGHT_M) ** SHEAR_EXPONENT;
+}
diff --git a/explainers/wind-farm/src/model/wakes.test.ts b/explainers/wind-farm/src/model/wakes.test.ts
new file mode 100644
index 00000000..e71fc790
--- /dev/null
+++ b/explainers/wind-farm/src/model/wakes.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from 'vitest';
+import type { FarmSite } from '../ids';
+import { MAX_PLUME_D, ROTOR_DIAMETER_M, ROTOR_RADIUS_M, TURBINE_COUNT } from './constants';
+import { farmLayout, HERO_SITE } from './layout';
+import { thrustCoefficient, turbinePowerKw } from './power';
+import {
+ axialInduction,
+ centrelineDeficit,
+ farmPowerKw,
+ initialDeficit,
+ overlapShare,
+ plumeLengthD,
+ wakeDeficits,
+ wakeLoss,
+} from './wakes';
+
+const THRUST_AT_8 = 0.79;
+const LEAD: FarmSite = { row: 0, column: 0, x: 0, z: 0 };
+
+function behind(xOverD: number, lateralM = 0): FarmSite {
+ return { row: 1, column: 0, x: xOverD * ROTOR_DIAMETER_M, z: lateralM };
+}
+
+describe('wake strength', () => {
+ it('starts the deficit at twice the axial induction', () => {
+ expect(initialDeficit(THRUST_AT_8)).toBeCloseTo(0.5417, 4);
+ expect(axialInduction(THRUST_AT_8)).toBeCloseTo(0.2709, 4);
+ expect(initialDeficit(0)).toBe(0);
+ });
+
+ it('leaves about 13 % less wind on the centreline 7 D behind at 8 m/s', () => {
+ expect(centrelineDeficit(thrustCoefficient(8), 7)).toBeCloseTo(0.129, 3);
+ expect(centrelineDeficit(THRUST_AT_8, 0)).toBe(initialDeficit(THRUST_AT_8));
+ });
+});
+
+describe('overlapShare', () => {
+ it('covers the whole rotor when the wake is wider and centred', () => {
+ expect(overlapShare(0, 100, ROTOR_RADIUS_M)).toBe(1);
+ expect(overlapShare(20, 100, ROTOR_RADIUS_M)).toBe(1);
+ });
+
+ it('misses the rotor once the circles part', () => {
+ expect(overlapShare(175, 100, ROTOR_RADIUS_M)).toBe(0);
+ expect(overlapShare(-300, 100, ROTOR_RADIUS_M)).toBe(0);
+ });
+
+ it('shares half the rotor when two equal circles meet at the centre line', () => {
+ expect(overlapShare(0, ROTOR_RADIUS_M, ROTOR_RADIUS_M)).toBe(1);
+ const halfOffset = ROTOR_RADIUS_M * 0.8079;
+ expect(overlapShare(halfOffset, ROTOR_RADIUS_M, ROTOR_RADIUS_M)).toBeCloseTo(0.5, 3);
+ });
+
+ it('shrinks smoothly as the offset grows', () => {
+ const shares = [0, 40, 80, 120, 160].map((offset) => overlapShare(offset, 120, ROTOR_RADIUS_M));
+ shares.slice(1).forEach((share, index) => expect(share).toBeLessThanOrEqual(shares[index]));
+ });
+});
+
+describe('wakeDeficits', () => {
+ it('slows only the turbine downwind of the other', () => {
+ const [lead, trailing] = wakeDeficits([LEAD, behind(7)], THRUST_AT_8, 270);
+ expect(lead).toBe(0);
+ expect(trailing).toBeCloseTo(centrelineDeficit(THRUST_AT_8, 7), 9);
+ });
+
+ it('blows toward +x from 270 and toward −x from 90', () => {
+ expect(wakeDeficits([LEAD, behind(7)], THRUST_AT_8, 90)[0]).toBeCloseTo(
+ centrelineDeficit(THRUST_AT_8, 7),
+ 9,
+ );
+ });
+
+ it('turns a few degrees of veer into a small sideways shift', () => {
+ const straight = wakeDeficits([LEAD, behind(7)], THRUST_AT_8, 270)[1];
+ const veered = wakeDeficits([LEAD, behind(7)], THRUST_AT_8, 276)[1];
+ expect(veered).toBeGreaterThan(0);
+ expect(veered).toBeLessThan(straight);
+ expect(wakeDeficits([LEAD, behind(7, 110)], THRUST_AT_8, 276)[1]).toBeGreaterThan(veered);
+ });
+
+ it('adds wakes from two turbines as the root of the sum of squares', () => {
+ const [, , last] = wakeDeficits([LEAD, behind(7), behind(14)], THRUST_AT_8, 270);
+ const fromLead = centrelineDeficit(THRUST_AT_8, 14);
+ const fromMiddle = centrelineDeficit(THRUST_AT_8, 7);
+ expect(last).toBeCloseTo(Math.hypot(fromLead, fromMiddle), 9);
+ });
+
+ it('keeps the upwind row free in the farm', () => {
+ const deficits = wakeDeficits(farmLayout(7), THRUST_AT_8, 270);
+ expect(deficits).toHaveLength(TURBINE_COUNT);
+ expect(deficits[HERO_SITE]).toBe(0);
+ expect(Math.max(...deficits)).toBeCloseTo(0.056, 3);
+ });
+});
+
+describe('farm power', () => {
+ it('loses about 7.6, 4.9 and 3.5 % to wakes at 8 m/s from 270', () => {
+ expect(wakeLoss(8, 270, 5)).toBeCloseTo(0.076, 3);
+ expect(wakeLoss(8, 270, 7)).toBeCloseTo(0.049, 3);
+ expect(wakeLoss(8, 270, 9)).toBeCloseTo(0.035, 3);
+ });
+
+ it('sums the waked turbines below 27 free ones', () => {
+ const farm = farmPowerKw(8, 270, 7);
+ expect(farm).toBeLessThan(TURBINE_COUNT * turbinePowerKw(8));
+ expect(farm).toBeCloseTo(TURBINE_COUNT * turbinePowerKw(8) * (1 - wakeLoss(8, 270, 7)), 6);
+ });
+
+ it('makes nothing below cut-in or from cut-out up', () => {
+ expect(farmPowerKw(2, 270, 7)).toBe(0);
+ expect(farmPowerKw(25, 270, 7)).toBe(0);
+ expect(wakeLoss(2, 270, 7)).toBe(0);
+ expect(wakeLoss(25, 270, 7)).toBe(0);
+ });
+
+ it('barely loses anything at full power', () => {
+ expect(wakeLoss(15, 270, 7)).toBeLessThan(0.001);
+ });
+});
+
+describe('plumeLengthD', () => {
+ it('draws the visible plume about 8.6 D at 12 m/s and 4.1 D at 15 m/s', () => {
+ expect(plumeLengthD(thrustCoefficient(12))).toBeCloseTo(8.6, 1);
+ expect(plumeLengthD(thrustCoefficient(15))).toBeCloseTo(4.09, 2);
+ });
+
+ it('caps the 21.7 D plume at 8 m/s to the drawing limit', () => {
+ expect(plumeLengthD(thrustCoefficient(8))).toBe(MAX_PLUME_D);
+ });
+
+ it('draws no plume without thrust', () => {
+ expect(plumeLengthD(0)).toBe(0);
+ });
+});
diff --git a/explainers/wind-farm/src/model/wakes.ts b/explainers/wind-farm/src/model/wakes.ts
new file mode 100644
index 00000000..1dff33e8
--- /dev/null
+++ b/explainers/wind-farm/src/model/wakes.ts
@@ -0,0 +1,108 @@
+import { clamp, toRadians } from '@core/math';
+import type { FarmSite, SpacingD } from '../ids';
+import {
+ MAX_PLUME_D,
+ PLUME_VISIBLE_DEFICIT,
+ ROTOR_RADIUS_M,
+ TURBINE_COUNT,
+ WAKE_DECAY,
+} from './constants';
+import { farmLayout } from './layout';
+import { isGenerating, thrustCoefficient, turbinePowerKw } from './power';
+
+interface DownwindOffset {
+ along: number;
+ across: number;
+}
+
+export function initialDeficit(ct: number): number {
+ return 1 - Math.sqrt(1 - ct);
+}
+
+export function axialInduction(ct: number): number {
+ return initialDeficit(ct) / 2;
+}
+
+function expansion(xOverD: number): number {
+ return 1 + 2 * WAKE_DECAY * xOverD;
+}
+
+export function centrelineDeficit(ct: number, xOverD: number): number {
+ return initialDeficit(ct) / expansion(xOverD) ** 2;
+}
+
+function lensAngle(offset: number, near: number, far: number): number {
+ return Math.acos(clamp((offset ** 2 + near ** 2 - far ** 2) / (2 * offset * near), -1, 1));
+}
+
+function lensArea(offset: number, radiusA: number, radiusB: number): number {
+ const kite = Math.sqrt(
+ Math.max(
+ 0,
+ (-offset + radiusA + radiusB) *
+ (offset + radiusA - radiusB) *
+ (offset - radiusA + radiusB) *
+ (offset + radiusA + radiusB),
+ ),
+ );
+ return (
+ radiusA ** 2 * lensAngle(offset, radiusA, radiusB) +
+ radiusB ** 2 * lensAngle(offset, radiusB, radiusA) -
+ kite / 2
+ );
+}
+
+export function overlapShare(offset: number, wakeRadius: number, rotorRadius: number): number {
+ const distance = Math.abs(offset);
+ if (distance >= wakeRadius + rotorRadius) return 0;
+ if (distance <= Math.abs(wakeRadius - rotorRadius)) {
+ return Math.min(1, (wakeRadius / rotorRadius) ** 2);
+ }
+ return lensArea(distance, wakeRadius, rotorRadius) / (Math.PI * rotorRadius ** 2);
+}
+
+function downwindOffset(from: FarmSite, to: FarmSite, fromDeg: number): DownwindOffset {
+ const bearing = toRadians(fromDeg);
+ const towardX = -Math.sin(bearing);
+ const towardZ = Math.cos(bearing);
+ const dx = to.x - from.x;
+ const dz = to.z - from.z;
+ return { along: dx * towardX + dz * towardZ, across: dx * towardZ - dz * towardX };
+}
+
+function wakeDeficitAt(offset: DownwindOffset, strength: number): number {
+ const wakeRadius = ROTOR_RADIUS_M + WAKE_DECAY * offset.along;
+ return (
+ strength *
+ (ROTOR_RADIUS_M / wakeRadius) ** 2 *
+ overlapShare(offset.across, wakeRadius, ROTOR_RADIUS_M)
+ );
+}
+
+export function wakeDeficits(sites: readonly FarmSite[], ct: number, fromDeg: number): number[] {
+ const strength = initialDeficit(ct);
+ return sites.map((site) => {
+ const squares = sites.reduce((sum, upwind) => {
+ const offset = downwindOffset(upwind, site, fromDeg);
+ return offset.along > 0 ? sum + wakeDeficitAt(offset, strength) ** 2 : sum;
+ }, 0);
+ return Math.sqrt(squares);
+ });
+}
+
+export function farmPowerKw(wind: number, fromDeg: number, spacing: SpacingD): number {
+ if (!isGenerating(wind)) return 0;
+ const deficits = wakeDeficits(farmLayout(spacing), thrustCoefficient(wind), fromDeg);
+ return deficits.reduce((sum, deficit) => sum + turbinePowerKw(wind * (1 - deficit)), 0);
+}
+
+export function wakeLoss(wind: number, fromDeg: number, spacing: SpacingD): number {
+ const freeKw = turbinePowerKw(wind);
+ if (freeKw === 0) return 0;
+ return Math.max(0, 1 - farmPowerKw(wind, fromDeg, spacing) / (TURBINE_COUNT * freeKw));
+}
+
+export function plumeLengthD(ct: number): number {
+ const visibleExpansion = Math.sqrt(initialDeficit(ct) / PLUME_VISIBLE_DEFICIT);
+ return clamp((visibleExpansion - 1) / (2 * WAKE_DECAY), 0, MAX_PLUME_D);
+}
diff --git a/explainers/wind-farm/src/scene/assembly.ts b/explainers/wind-farm/src/scene/assembly.ts
new file mode 100644
index 00000000..f704004e
--- /dev/null
+++ b/explainers/wind-farm/src/scene/assembly.ts
@@ -0,0 +1,25 @@
+import type { Box3, Group, Object3D } from 'three';
+import type { MaterialLibrary } from '@core/scene/materials';
+import type { SceneTextures } from '@core/scene/textures';
+import type { AnchorId, AssemblyState, PartId, RegionId } from '../ids';
+import { createWindFarmAssembly } from './windFarmAssembly';
+
+export interface AssemblyResources {
+ materials: MaterialLibrary;
+ textures: SceneTextures;
+}
+
+export interface Assembly {
+ readonly root: Group;
+ setState(state: AssemblyState): void;
+ update(deltaSeconds: number, cameraDistance: number): boolean;
+ labelAnchors(): ReadonlyMap;
+ anchor(id: AnchorId): Object3D;
+ region(id: RegionId): Box3;
+ warmUp?(compile: (object: Object3D) => void): void;
+ dispose(): void;
+}
+
+export function createAssembly(resources: AssemblyResources, state: AssemblyState): Assembly {
+ return createWindFarmAssembly(resources, state);
+}
diff --git a/explainers/wind-farm/src/scene/bindings.test.ts b/explainers/wind-farm/src/scene/bindings.test.ts
new file mode 100644
index 00000000..9cbde8c8
--- /dev/null
+++ b/explainers/wind-farm/src/scene/bindings.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it, vi } from 'vitest';
+import type { AssemblyState } from '../ids';
+import { createWindFarmStore } from '../state';
+import { bindStore } from './bindings';
+import type { SceneTargets } from './bindings';
+
+const LATER_PHASE = 0.4;
+const STRONG_WIND_MS = 15;
+
+function fakeTargets() {
+ const received: AssemblyState[] = [];
+ const snapshots: AssemblyState[] = [];
+ const record = (state: AssemblyState) => {
+ received.push(state);
+ snapshots.push(structuredClone(state));
+ };
+ const frame = vi.fn();
+ const setWanted = vi.fn();
+ const targets: SceneTargets = {
+ windFarm: { build: record, setState: record, views: { frame } },
+ labelVisibility: { setWanted },
+ highlighter: { setHighlight: vi.fn() },
+ labels: { show: vi.fn() },
+ };
+ return { received, snapshots, frame, setWanted, targets };
+}
+
+describe('wind farm scene bindings', () => {
+ it('builds the scene from the store and frames the first chapter', () => {
+ const { received, frame, targets } = fakeTargets();
+ const store = createWindFarmStore();
+ bindStore(store, targets);
+ const { phase, spacing, view } = store.getState();
+ expect(received[0]).toMatchObject({ scene: 'farm', phase, view, farm: { spacing } });
+ expect(frame).toHaveBeenCalledWith('farmAerial', false, undefined);
+ });
+
+ it('swaps the scene and hands over the view flags before framing the next camera', () => {
+ const { received, frame, targets } = fakeTargets();
+ const store = createWindFarmStore();
+ bindStore(store, targets);
+ let handedBeforeFrame: AssemblyState | undefined;
+ frame.mockImplementationOnce(() => (handedBeforeFrame = received.at(-1)));
+ store.getState().applyPreset('nacelle');
+ expect(frame).toHaveBeenLastCalledWith('nacelleCutaway', true, undefined);
+ expect(handedBeforeFrame).toMatchObject({ scene: 'turbine', view: { cutaway: true } });
+ });
+
+ it('applies a view toggle at once and refreshes the labels', () => {
+ const { received, setWanted, targets } = fakeTargets();
+ const store = createWindFarmStore();
+ bindStore(store, targets);
+ const before = setWanted.mock.calls.length;
+ const shown = store.getState().view.cables;
+ store.getState().toggleView('cables');
+ expect(received.at(-1)?.view.cables).toBe(!shown);
+ expect(setWanted.mock.calls.length).toBeGreaterThan(before);
+ });
+
+ it('alternates two buffers so the held state is never rewritten', () => {
+ const { received, snapshots, targets } = fakeTargets();
+ const store = createWindFarmStore();
+ bindStore(store, targets);
+ store.getState().pause();
+ store.getState().setPhase(LATER_PHASE);
+ store.getState().setWindOverride(STRONG_WIND_MS);
+ const [first, second, third] = received.slice(-3);
+ expect(first).not.toBe(second);
+ expect(third).toBe(first);
+ expect(received.at(-2)).toEqual(snapshots.at(-2));
+ expect(third?.wind.speed).toBe(STRONG_WIND_MS);
+ });
+
+ it('stops handing over states once unbound', () => {
+ const { received, targets } = fakeTargets();
+ const store = createWindFarmStore();
+ const unbind = bindStore(store, targets);
+ unbind();
+ const count = received.length;
+ store.getState().setPhase(LATER_PHASE);
+ expect(received).toHaveLength(count);
+ });
+});
diff --git a/explainers/wind-farm/src/scene/bindings.ts b/explainers/wind-farm/src/scene/bindings.ts
new file mode 100644
index 00000000..ca73c68e
--- /dev/null
+++ b/explainers/wind-farm/src/scene/bindings.ts
@@ -0,0 +1,46 @@
+import { bindPresets } from '@core/scene/presetBinder';
+import type { LabelPolicy, PresetTargets } from '@core/scene/presetBinder';
+import type { AssemblyState } from '../ids';
+import { PRESETS, createAssemblyState, writeAssemblyState } from '../state';
+import type { AssemblySource, Preset, WindFarmStore, WindFarmStoreState } from '../state';
+import type { WindFarmController } from './controller';
+
+export interface SceneTargets extends PresetTargets {
+ windFarm: Pick;
+ labelVisibility: LabelPolicy;
+}
+
+class DoubleBufferedAssemblyState {
+ private heldByAssembly: AssemblyState;
+ private free: AssemblyState;
+
+ constructor(source: AssemblySource) {
+ this.heldByAssembly = createAssemblyState(source);
+ this.free = createAssemblyState(source);
+ }
+
+ handOver(source: AssemblySource): AssemblyState {
+ const next = writeAssemblyState(this.free, source);
+ this.free = this.heldByAssembly;
+ this.heldByAssembly = next;
+ return next;
+ }
+}
+
+export function bindStore(store: WindFarmStore, targets: SceneTargets): () => void {
+ const { windFarm, labelVisibility } = targets;
+ const assemblyState = new DoubleBufferedAssemblyState(store.getState());
+ const push = (state: AssemblySource) => windFarm.setState(assemblyState.handOver(state));
+ windFarm.build(assemblyState.handOver(store.getState()));
+ const unsubscribers = [
+ store.subscribe(push),
+ bindPresets(targets, store, {
+ presets: PRESETS,
+ views: windFarm.views,
+ labels: labelVisibility,
+ prepare: push,
+ onView: (_view, state) => push(state),
+ }),
+ ];
+ return () => unsubscribers.forEach((unsubscribe) => unsubscribe());
+}
diff --git a/explainers/wind-farm/src/scene/cameraViews.test.ts b/explainers/wind-farm/src/scene/cameraViews.test.ts
new file mode 100644
index 00000000..c10ad11c
--- /dev/null
+++ b/explainers/wind-farm/src/scene/cameraViews.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from 'vitest';
+import type { CameraView } from '../ids';
+import { CAMERA_VIEWS, FOLLOWED_ANCHOR, STAGE_VARIANTS, stageVariant } from './cameraViews';
+import type { StageVariant } from './cameraViews';
+import { SCENE_OPTIONS } from '.';
+
+const VIEWS = Object.keys(CAMERA_VIEWS) as CameraView[];
+const FOLLOWING_VIEWS: readonly CameraView[] = ['nacelleCutaway', 'rotorQuarter'];
+const DEGREES_PER_RADIAN = 180 / Math.PI;
+const RIGHT_ANGLE_DEG = 90;
+const WITHIN_FIVE_DEGREES = -1;
+const AERIAL_DEG = 27;
+const WAKE_DEG = 25;
+const GRID_DEG = 55;
+const LOW_VIEW_MAX_DEG = 8;
+const ROTOR_DEG = { min: 8, max: 18 } as const;
+const OPENING_MIN_DEG = 20;
+const DISTANCES: Readonly> = {
+ farmAerial: { min: 2000, max: 20000 },
+ turbineTall: { min: 150, max: 2500 },
+ nacelleCutaway: { min: 8, max: 150 },
+ rotorQuarter: { min: 80, max: 1200 },
+ wakeStreaks: { min: 1500, max: 18000 },
+ gridSubstation: { min: 600, max: 15000 },
+};
+
+function elevation(view: CameraView, variant: StageVariant): number {
+ const [x, y, z] = CAMERA_VIEWS[view].direction[variant];
+ return Math.atan2(y, Math.hypot(x, z)) * DEGREES_PER_RADIAN;
+}
+
+function eachVariant(check: (variant: StageVariant) => void): void {
+ STAGE_VARIANTS.forEach(check);
+}
+
+describe('camera views', () => {
+ it('offers the six views, the nacelle and rotor ones turning with the yaw', () => {
+ expect(VIEWS).toEqual([
+ 'farmAerial',
+ 'turbineTall',
+ 'nacelleCutaway',
+ 'rotorQuarter',
+ 'wakeStreaks',
+ 'gridSubstation',
+ ]);
+ expect(FOLLOWED_ANCHOR).toBe('yawPivot');
+ VIEWS.forEach((view) =>
+ expect(CAMERA_VIEWS[view].follow, view).toBe(
+ FOLLOWING_VIEWS.includes(view) ? 'heading' : undefined,
+ ),
+ );
+ expect(CAMERA_VIEWS.nacelleCutaway.region).toBe('nacelle');
+ expect(CAMERA_VIEWS.rotorQuarter.region).toBe('rotor');
+ });
+
+ it('limits the zoom of each view', () => {
+ VIEWS.forEach((view) => expect(CAMERA_VIEWS[view].distance, view).toEqual(DISTANCES[view]));
+ });
+
+ it('picks the phone framing below 600 pixels of stage width', () => {
+ expect(stageVariant(599)).toBe('phone');
+ expect(stageVariant(600)).toBe('desktop');
+ });
+
+ it('keeps every camera above the horizon and within the orbit limit', () => {
+ const lowest =
+ RIGHT_ANGLE_DEG - (SCENE_OPTIONS.camera?.maxPolarAngle ?? 0) * DEGREES_PER_RADIAN;
+ VIEWS.forEach((view) =>
+ eachVariant((variant) => expect(elevation(view, variant), view).toBeGreaterThan(lowest)),
+ );
+ });
+
+ it('looks down on the farm from the south-southwest at a low oblique', () => {
+ eachVariant((variant) => {
+ const [x, , z] = CAMERA_VIEWS.farmAerial.direction[variant];
+ expect(x).toBeLessThan(0);
+ expect(z).toBeGreaterThan(Math.abs(x));
+ expect(elevation('farmAerial', variant)).toBeCloseTo(AERIAL_DEG, WITHIN_FIVE_DEGREES);
+ });
+ });
+
+ it('looks at the hero turbine from upwind and to the south', () => {
+ (['turbineTall', 'rotorQuarter'] as const).forEach((view) =>
+ eachVariant((variant) => {
+ const [x, , z] = CAMERA_VIEWS[view].direction[variant];
+ expect(x, view).toBeLessThan(0);
+ expect(z, view).toBeGreaterThan(0);
+ }),
+ );
+ });
+
+ it('keeps the tall view level and lifts the rotor view a little above the hub', () => {
+ eachVariant((variant) => {
+ expect(Math.abs(elevation('turbineTall', variant))).toBeLessThan(LOW_VIEW_MAX_DEG);
+ expect(elevation('rotorQuarter', variant)).toBeGreaterThan(ROTOR_DEG.min);
+ expect(elevation('rotorQuarter', variant)).toBeLessThan(ROTOR_DEG.max);
+ });
+ });
+
+ it('looks into the nacelle opening from the +z side and above', () => {
+ eachVariant((variant) => {
+ const [x, , z] = CAMERA_VIEWS.nacelleCutaway.direction[variant];
+ expect(z).toBeGreaterThan(Math.abs(x));
+ expect(elevation('nacelleCutaway', variant)).toBeGreaterThan(OPENING_MIN_DEG);
+ });
+ });
+
+ it('looks along the rows from the south at about 25 degrees for the wakes', () => {
+ eachVariant((variant) => {
+ const [x, , z] = CAMERA_VIEWS.wakeStreaks.direction[variant];
+ expect(z).toBeGreaterThan(Math.abs(x));
+ expect(elevation('wakeStreaks', variant)).toBeCloseTo(WAKE_DEG, WITHIN_FIVE_DEGREES);
+ });
+ });
+
+ it('looks down on the substation from the south-east at about 55 degrees', () => {
+ eachVariant((variant) => {
+ const [x, , z] = CAMERA_VIEWS.gridSubstation.direction[variant];
+ expect(x).toBeGreaterThan(0);
+ expect(z).toBeGreaterThan(0);
+ expect(elevation('gridSubstation', variant)).toBeCloseTo(GRID_DEG, WITHIN_FIVE_DEGREES);
+ });
+ });
+});
diff --git a/explainers/wind-farm/src/scene/cameraViews.ts b/explainers/wind-farm/src/scene/cameraViews.ts
new file mode 100644
index 00000000..8089acfd
--- /dev/null
+++ b/explainers/wind-farm/src/scene/cameraViews.ts
@@ -0,0 +1,59 @@
+import type { Direction, FramedView } from '@core/scene/cameraViews';
+import type { AnchorId, CameraView, RegionId } from '../ids';
+
+export const STAGE_VARIANTS = ['phone', 'desktop'] as const;
+
+export type StageVariant = (typeof STAGE_VARIANTS)[number];
+
+export interface StageView extends FramedView {
+ direction: Readonly>;
+}
+
+const PHONE_STAGE_WIDTH_PX = 600;
+
+export const FOLLOWED_ANCHOR: AnchorId = 'yawPivot';
+
+export function stageVariant(width: number): StageVariant {
+ return width < PHONE_STAGE_WIDTH_PX ? 'phone' : 'desktop';
+}
+
+export const CAMERA_VIEWS: Readonly> = {
+ farmAerial: {
+ region: 'farm',
+ direction: { phone: [-0.47, 0.54, 1], desktop: [-0.18, 0.5, 1] },
+ margin: 1.1,
+ distance: { min: 2000, max: 20000 },
+ },
+ turbineTall: {
+ region: 'turbine',
+ direction: { phone: [-1, 0.12, 0.45], desktop: [-1, 0.12, 0.6] },
+ margin: 1.1,
+ distance: { min: 150, max: 2500 },
+ },
+ nacelleCutaway: {
+ region: 'nacelle',
+ direction: { phone: [0.1, 0.5, 1], desktop: [0.05, 0.47, 1] },
+ margin: 1,
+ follow: 'heading',
+ distance: { min: 8, max: 150 },
+ },
+ rotorQuarter: {
+ region: 'rotor',
+ direction: { phone: [-1, 0.2, 0.58], desktop: [-1, 0.2, 0.7] },
+ margin: 1.6,
+ follow: 'heading',
+ distance: { min: 80, max: 1200 },
+ },
+ wakeStreaks: {
+ region: 'wakes',
+ direction: { phone: [0.18, 0.45, 1], desktop: [0.09, 0.45, 1] },
+ margin: 1.05,
+ distance: { min: 1500, max: 18000 },
+ },
+ gridSubstation: {
+ region: 'grid',
+ direction: { phone: [0.14, 1.55, 1], desktop: [0.58, 1.5, 1] },
+ margin: 1.1,
+ distance: { min: 600, max: 15000 },
+ },
+};
diff --git a/explainers/wind-farm/src/scene/constants.ts b/explainers/wind-farm/src/scene/constants.ts
new file mode 100644
index 00000000..2856bf32
--- /dev/null
+++ b/explainers/wind-farm/src/scene/constants.ts
@@ -0,0 +1,41 @@
+import { TURBINE_COUNT } from '../model';
+import { THEME } from '../theme';
+
+type Triple = readonly [number, number, number];
+
+export const HAZE = {
+ colour: THEME.haze,
+ near: 9000,
+ far: 28_000,
+ nearPerDistance: 1.3,
+ farPerDistance: 3,
+ maxNearShare: 0.8,
+} as const;
+
+export const SKY = { top: THEME.skyTop, horizon: THEME.haze } as const;
+
+export const HIGHLIGHT_DIM = { saturation: 0.5, brightness: 0.6, emissive: 0.35 } as const;
+
+export const SCENE_LIMITS = {
+ cameraNear: 2,
+ cameraFar: 30_000,
+ maxPolarAngle: Math.PI * 0.47,
+ cameraMinDistance: 6,
+ cameraMaxDistance: 25_000,
+} as const;
+
+export const LIGHT_RIG = {
+ distance: 400,
+ key: { color: '#fff1dc', intensity: 2.6, direction: [-0.58, 0.57, 0.58] as Triple },
+ fill: { color: '#b9d2f0', intensity: 0.7, direction: [0.5, 0.75, -0.45] as Triple },
+ rim: { color: '#ffe2bd', intensity: 1.1, direction: [0.8, 0.35, 0.5] as Triple },
+} as const;
+
+export const SUN_DIRECTION: Triple = LIGHT_RIG.key.direction;
+
+export const EASE_SECONDS = 1.5;
+
+export const SUBSTATION_HEIGHT_M = 12;
+export const LABELLED_TURBINE = TURBINE_COUNT - 1;
+
+export const GROUND_LIFT_M = 2;
diff --git a/explainers/wind-farm/src/scene/controller.dom.test.ts b/explainers/wind-farm/src/scene/controller.dom.test.ts
new file mode 100644
index 00000000..0e9aca4b
--- /dev/null
+++ b/explainers/wind-farm/src/scene/controller.dom.test.ts
@@ -0,0 +1,177 @@
+import { Vector3 } from 'three';
+import type { Box3, Object3D } from 'three';
+import { describe, expect, it, vi } from 'vitest';
+import { frameBox } from '@core/scene/frameBox';
+import type { CameraPose } from '@core/scene/frameBox';
+import type { FramingSlopes } from '@core/scene/lens';
+import { MaterialLibrary } from '@core/scene/materials';
+import { createSceneTextures } from '@core/scene/textures';
+import type { AssemblyState, CameraView, RegionId } from '../ids';
+import { createAssemblyState, createWindFarmStore } from '../state';
+import { createAssembly } from './assembly';
+import type { Assembly } from './assembly';
+import { CAMERA_VIEWS, STAGE_VARIANTS } from './cameraViews';
+import type { StageVariant } from './cameraViews';
+import { WindFarmController } from './controller';
+import type { WindFarmControllerDependencies } from './controller';
+
+const FRAMING = { vertical: 0.2, horizontal: 0.27 };
+const STAGE_SLOPES: Readonly> = {
+ phone: { vertical: 0.75, horizontal: 0.38 },
+ desktop: { vertical: 0.42, horizontal: 0.75 },
+};
+const STAGE_WIDTH_PX: Readonly> = { phone: 390, desktop: 835 };
+const DIRECTION_DIGITS = 6;
+const VIEWS = Object.keys(CAMERA_VIEWS) as CameraView[];
+
+function assemblyState(changes: Partial = {}): AssemblyState {
+ return { ...createAssemblyState(createWindFarmStore().getState()), ...changes };
+}
+
+function referenceAssembly(): Assembly {
+ const resources = { materials: new MaterialLibrary(), textures: createSceneTextures() };
+ return createAssembly(resources, assemblyState());
+}
+
+function regionBox(region: RegionId): Box3 {
+ return referenceAssembly().region(region);
+}
+
+function fakeShell(stageWidth: number = STAGE_WIDTH_PX.desktop) {
+ const rig = {
+ framing: () => FRAMING,
+ follow: vi.fn<(anchor: Object3D | null, mode?: string) => void>(),
+ setDistanceLimits: vi.fn(),
+ jumpTo: vi.fn<(pose: CameraPose) => void>(),
+ tweenTo: vi.fn<(pose: CameraPose) => void>(),
+ setBounds: vi.fn<(bounds: Box3, floor: number) => void>(),
+ camera: { position: new Vector3(0, 100, 600) },
+ controls: { target: new Vector3(0, 100, 0) },
+ };
+ const scene = { add: vi.fn<(object: Object3D) => void>() };
+ const shell = {
+ scene,
+ materials: new MaterialLibrary(),
+ textures: createSceneTextures(),
+ labels: { attach: vi.fn(), show: vi.fn() },
+ rig,
+ viewport: {
+ renderer: { compileAsync: vi.fn(() => Promise.resolve()) },
+ element: { clientWidth: stageWidth },
+ },
+ };
+ return { rig, scene, shell: shell as unknown as WindFarmControllerDependencies };
+}
+
+function viewDirection(pose: CameraPose): number[] {
+ return roundAll(pose.position.clone().sub(pose.target).normalize().toArray());
+}
+
+function roundAll(values: readonly number[]): number[] {
+ return values.map((value) => Number(value.toFixed(DIRECTION_DIGITS)));
+}
+
+describe('wind farm controller', () => {
+ it('adds the assembly, attaches its labels and bounds the camera by the shown scene', () => {
+ const { rig, scene, shell } = fakeShell();
+ new WindFarmController(shell).build(assemblyState());
+ expect(scene.add).toHaveBeenCalledTimes(1);
+ expect(shell.labels.attach).toHaveBeenCalledTimes(1);
+ const [bounds, floor] = rig.setBounds.mock.lastCall ?? [];
+ expect(bounds).toEqual(regionBox('farm'));
+ expect(floor).toBe(bounds?.min.y);
+ });
+
+ it('rebounds the camera only when the scene swaps', () => {
+ const { rig, shell } = fakeShell();
+ const controller = new WindFarmController(shell);
+ controller.build(assemblyState());
+ controller.setState(assemblyState());
+ expect(rig.setBounds).toHaveBeenCalledTimes(1);
+ controller.setState(assemblyState({ scene: 'turbine' }));
+ expect(rig.setBounds).toHaveBeenCalledTimes(2);
+ expect(rig.setBounds.mock.lastCall?.[0]).toEqual(regionBox('turbine'));
+ });
+
+ it('frames nothing before the assembly exists', () => {
+ const { rig, shell } = fakeShell();
+ new WindFarmController(shell).views.frame('farmAerial', false);
+ expect(rig.jumpTo).not.toHaveBeenCalled();
+ });
+
+ it.each(STAGE_VARIANTS)('frames every view on its region for a %s stage', (variant) => {
+ const { rig, shell } = fakeShell(STAGE_WIDTH_PX[variant]);
+ const controller = new WindFarmController(shell);
+ controller.build(assemblyState());
+ for (const view of VIEWS) {
+ const spec = CAMERA_VIEWS[view];
+ controller.views.frame(view, false);
+ const pose = rig.jumpTo.mock.lastCall?.[0];
+ expect(pose?.target).toEqual(regionBox(spec.region).getCenter(new Vector3()));
+ const direction = new Vector3(...spec.direction[variant]).normalize();
+ expect(pose && viewDirection(pose)).toEqual(roundAll(direction.toArray()));
+ expect(rig.setDistanceLimits).toHaveBeenLastCalledWith(spec.distance);
+ }
+ });
+
+ it('turns the close-up views with the yaw pivot and keeps the rest still', () => {
+ const { rig, scene, shell } = fakeShell();
+ const controller = new WindFarmController(shell);
+ controller.build(assemblyState());
+ const root = scene.add.mock.lastCall?.[0];
+ for (const view of VIEWS) {
+ controller.views.frame(view, false);
+ const [anchor, mode] = rig.follow.mock.lastCall ?? [];
+ if (CAMERA_VIEWS[view].follow) {
+ expect(anchor && root?.getObjectById(anchor.id), view).toBe(anchor);
+ expect(mode, view).toBe('heading');
+ } else expect(anchor, view).toBeNull();
+ }
+ });
+
+ it.each(STAGE_VARIANTS)('starts every view inside its zoom limits on a %s', (variant) => {
+ const direction = (view: CameraView) =>
+ new Vector3(...CAMERA_VIEWS[view].direction[variant]).normalize();
+ for (const view of VIEWS) {
+ const { region, margin, distance } = CAMERA_VIEWS[view];
+ const pose = frameBox(regionBox(region), direction(view), STAGE_SLOPES[variant], margin);
+ const reach = pose.position.distanceTo(pose.target);
+ expect(reach, view).toBeGreaterThanOrEqual(distance?.min ?? 0);
+ expect(reach, view).toBeLessThanOrEqual(distance?.max ?? Infinity);
+ }
+ });
+
+ it('tweens when animated and honours an explicit variant', () => {
+ const { rig, shell } = fakeShell(STAGE_WIDTH_PX.phone);
+ const controller = new WindFarmController(shell);
+ controller.build(assemblyState());
+ controller.views.frame('gridSubstation', true, 'desktop');
+ const pose = rig.tweenTo.mock.lastCall?.[0];
+ const desktop = new Vector3(...CAMERA_VIEWS.gridSubstation.direction.desktop).normalize();
+ expect(pose && viewDirection(pose)).toEqual(roundAll(desktop.toArray()));
+ expect(rig.jumpTo).not.toHaveBeenCalled();
+ });
+
+ it('reports no motion without an assembly and removes it on dispose', () => {
+ const { scene, shell } = fakeShell();
+ const controller = new WindFarmController(shell);
+ expect(controller.update(1)).toBe(false);
+ controller.build(assemblyState());
+ const removed = vi.spyOn(scene.add.mock.lastCall?.[0] as Object3D, 'removeFromParent');
+ controller.dispose();
+ expect(removed).toHaveBeenCalledTimes(1);
+ expect(controller.update(1)).toBe(false);
+ });
+
+ it('rebuilds without leaving the previous assembly behind', () => {
+ const { rig, scene, shell } = fakeShell();
+ const controller = new WindFarmController(shell);
+ controller.build(assemblyState());
+ const first = scene.add.mock.lastCall?.[0];
+ const removed = vi.spyOn(first as Object3D, 'removeFromParent');
+ controller.build(assemblyState());
+ expect(removed).toHaveBeenCalledTimes(1);
+ expect(scene.add).toHaveBeenCalledTimes(2);
+ expect(rig.setBounds).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/explainers/wind-farm/src/scene/controller.ts b/explainers/wind-farm/src/scene/controller.ts
new file mode 100644
index 00000000..fdfefc68
--- /dev/null
+++ b/explainers/wind-farm/src/scene/controller.ts
@@ -0,0 +1,91 @@
+import type { Object3D } from 'three';
+import { CameraViews } from '@core/scene/cameraViews';
+import type { SceneShell } from '@core/scene/shell';
+import { whenIdle } from '@core/scene/warmUp';
+import type { AssemblyState, CameraView, RegionId, SceneId } from '../ids';
+import { createAssembly } from './assembly';
+import type { Assembly } from './assembly';
+import { CAMERA_VIEWS, FOLLOWED_ANCHOR, stageVariant } from './cameraViews';
+import type { StageVariant } from './cameraViews';
+import { applyHaze } from './haze';
+
+export type WindFarmControllerDependencies = Pick<
+ SceneShell,
+ 'scene' | 'materials' | 'textures' | 'labels' | 'rig' | 'viewport'
+>;
+
+export type ViewFramer = Pick, 'frame'>;
+
+const SCENE_BOUNDS: Readonly> = { farm: 'farm', turbine: 'turbine' };
+
+export class WindFarmController {
+ readonly views: ViewFramer;
+ private readonly dependencies: WindFarmControllerDependencies;
+ private readonly cameraViews: CameraViews;
+ private assembly: Assembly | null = null;
+ private boundScene: SceneId | null = null;
+ private cancelWarmUp: () => void = () => {};
+
+ constructor(dependencies: WindFarmControllerDependencies) {
+ this.dependencies = dependencies;
+ this.cameraViews = new CameraViews(dependencies.rig, {
+ views: CAMERA_VIEWS,
+ region: (id) => this.assembly?.region(id) ?? null,
+ anchor: () => this.assembly?.anchor(FOLLOWED_ANCHOR) ?? null,
+ });
+ this.views = {
+ frame: (view, animate, variant) =>
+ this.cameraViews.frame(view, animate, variant ?? this.stageVariant()),
+ };
+ }
+
+ build(state: AssemblyState): void {
+ const { scene, materials, textures, labels } = this.dependencies;
+ this.dispose();
+ const assembly = createAssembly({ materials, textures }, state);
+ this.assembly = assembly;
+ scene.add(assembly.root);
+ labels.attach(assembly.labelAnchors());
+ this.boundTo(state.scene);
+ this.cancelWarmUp = whenIdle(() => assembly.warmUp?.((object) => this.compileHidden(object)));
+ }
+
+ setState(state: AssemblyState): void {
+ this.assembly?.setState(state);
+ this.boundTo(state.scene);
+ }
+
+ update(deltaSeconds: number): boolean {
+ const { rig, scene } = this.dependencies;
+ const cameraDistance = rig.camera.position.distanceTo(rig.controls.target);
+ applyHaze(scene, cameraDistance);
+ return this.assembly?.update(deltaSeconds, cameraDistance) ?? false;
+ }
+
+ dispose(): void {
+ this.cancelWarmUp();
+ this.assembly?.root.removeFromParent();
+ this.assembly?.dispose();
+ this.assembly = null;
+ this.boundScene = null;
+ }
+
+ private boundTo(scene: SceneId): void {
+ if (!this.assembly || scene === this.boundScene) return;
+ const bounds = this.assembly.region(SCENE_BOUNDS[scene]);
+ this.dependencies.rig.setBounds(bounds, bounds.min.y);
+ this.boundScene = scene;
+ }
+
+ private stageVariant(): StageVariant {
+ return stageVariant(this.dependencies.viewport.element.clientWidth);
+ }
+
+ private compileHidden(object: Object3D): void {
+ const { viewport, rig, scene } = this.dependencies;
+ const shown = object.visible;
+ object.visible = true;
+ void viewport.renderer.compileAsync(object, rig.camera, scene);
+ object.visible = shown;
+ }
+}
diff --git a/explainers/wind-farm/src/scene/finishes.ts b/explainers/wind-farm/src/scene/finishes.ts
new file mode 100644
index 00000000..fe1c42fe
--- /dev/null
+++ b/explainers/wind-farm/src/scene/finishes.ts
@@ -0,0 +1,104 @@
+import { DoubleSide } from 'three';
+import type { MaterialFinish } from '@core/scene/materials';
+import type { PartId } from '../ids';
+import { THEME } from '../theme';
+
+const MATTE = 0.9;
+const SATIN = 0.48;
+const POLISHED = 0.28;
+const METAL = 0.75;
+const GLOW = 0.8;
+const VEIL_OPACITY = 0.28;
+const RING_OPACITY = 0.35;
+const GENERATOR_BLUE = '#3f6c8c';
+const AIR_WHITE = '#eaf3fa';
+const PLUME_STREAK = '#9cc3ea';
+
+function matte(color: string): MaterialFinish {
+ return { color, roughness: MATTE, metalness: 0 };
+}
+
+function satin(color: string): MaterialFinish {
+ return { color, roughness: SATIN, metalness: 0.05 };
+}
+
+function metal(color: string, roughness = SATIN): MaterialFinish {
+ return { color, roughness, metalness: METAL };
+}
+
+function glowing(color: string): MaterialFinish {
+ return { color, emissive: color, emissiveIntensity: GLOW, roughness: MATTE };
+}
+
+function veil(color: string, opacity: number): MaterialFinish {
+ return { color, transparent: true, opacity, depthWrite: false, side: DoubleSide };
+}
+
+export const FINISHES = {
+ paint: { color: THEME.turbineWhite, roughness: 0.32, metalness: 0.04 },
+ towerPaint: matte(THEME.turbineWhite),
+ paintShade: satin(THEME.turbineGrey),
+ gelcoat: { color: THEME.turbineWhite, roughness: 0.26, metalness: 0.03 },
+ concrete: matte(THEME.concrete),
+ castIron: metal(THEME.castIron),
+ steel: metal(THEME.steel, POLISHED),
+ gearbox: metal(THEME.gearOil),
+ copper: metal(THEME.copper),
+ generatorPaint: metal(GENERATOR_BLUE),
+ hydraulic: metal(THEME.hydraulic),
+ brake: metal(THEME.steel, POLISHED),
+ brakeGlow: glowing(THEME.brakeGlow),
+ cabinet: satin(THEME.converterCabinet),
+ cooler: metal(THEME.cooler),
+ grille: metal(THEME.castIron, MATTE),
+ substation: satin(THEME.substation),
+ gridSteel: metal(THEME.gridLine),
+ cable: glowing(THEME.cable),
+ cableDark: matte(THEME.gridLine),
+ gravel: matte(THEME.gravel),
+ grass: { color: '#ffffff', roughness: 1, metalness: 0, vertexColors: true },
+ trees: matte(THEME.trees),
+ wind: glowing(THEME.wind),
+ spacing: glowing(THEME.spacing),
+ wake: veil(AIR_WHITE, VEIL_OPACITY),
+ plume: { ...veil(THEME.wake, VEIL_OPACITY), emissive: PLUME_STREAK },
+ sweep: veil(THEME.wind, RING_OPACITY),
+ shadow: { color: '#000000', transparent: true, opacity: 0.22, depthWrite: false },
+} as const satisfies Record;
+
+export type Finish = keyof typeof FINISHES;
+
+export const PART_FINISHES: Readonly> = {
+ land: 'grass',
+ foundation: 'concrete',
+ tower: 'towerPaint',
+ transformer: 'substation',
+ towerCable: 'cable',
+ nacelle: 'paint',
+ cooler: 'cooler',
+ bedplate: 'castIron',
+ hub: 'paint',
+ blades: 'gelcoat',
+ pitchCylinders: 'hydraulic',
+ mainBearing: 'steel',
+ mainShaft: 'steel',
+ gearbox: 'gearbox',
+ brakeDisc: 'brake',
+ generator: 'generatorPaint',
+ converter: 'cabinet',
+ yawDrives: 'castIron',
+ sweptArea: 'sweep',
+ streamlinesGroup: 'wind',
+ heroWake: 'wake',
+ farmLand: 'grass',
+ farmTurbines: 'paint',
+ accessRoads: 'gravel',
+ spacingMarker: 'spacing',
+ prevailingWind: 'wind',
+ windArrows: 'wind',
+ shearProfile: 'wind',
+ wakePlumes: 'plume',
+ collectorCables: 'cable',
+ substation: 'substation',
+ gridLine: 'gridSteel',
+};
diff --git a/explainers/wind-farm/src/scene/geometry/band.ts b/explainers/wind-farm/src/scene/geometry/band.ts
new file mode 100644
index 00000000..169d4364
--- /dev/null
+++ b/explainers/wind-farm/src/scene/geometry/band.ts
@@ -0,0 +1,29 @@
+import { LatheGeometry, Vector2 } from 'three';
+import type { BufferGeometry } from 'three';
+
+export interface Band {
+ inner: number;
+ outer: number;
+ bottom: number;
+ top: number;
+}
+
+export function bandGeometry(band: Band, segments: number): BufferGeometry {
+ const { inner, outer, bottom, top } = band;
+ const corners = [
+ new Vector2(inner, bottom),
+ new Vector2(outer, bottom),
+ new Vector2(outer, top),
+ new Vector2(inner, top),
+ ];
+ const crisp = corners.flatMap((corner) => [corner, corner.clone()]);
+ return new LatheGeometry([...crisp, corners[0].clone()], segments);
+}
+
+export function crispProfile(points: readonly (readonly [radius: number, y: number])[]): Vector2[] {
+ return points.flatMap(([radius, y], index) => {
+ const point = new Vector2(radius, y);
+ const isEnd = index === 0 || index === points.length - 1;
+ return isEnd ? [point] : [point, point.clone()];
+ });
+}
diff --git a/explainers/wind-farm/src/scene/geometry/blade.test.ts b/explainers/wind-farm/src/scene/geometry/blade.test.ts
new file mode 100644
index 00000000..9f091d46
--- /dev/null
+++ b/explainers/wind-farm/src/scene/geometry/blade.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { Vector3 } from 'three';
+import { BLADE, bladeChord, bladeGeometry, bladeTwist } from './blade';
+
+function sectionNear(radius: number): Vector3[] {
+ const position = bladeGeometry().getAttribute('position');
+ const points = Array.from({ length: position.count }, (_, index) =>
+ new Vector3().fromBufferAttribute(position, index),
+ );
+ const closest = points.reduce((best, point) =>
+ Math.abs(point.y - radius) < Math.abs(best.y - radius) ? point : best,
+ );
+ return points.filter((point) => Math.abs(point.y - closest.y) < 1e-4);
+}
+
+describe('bladeGeometry', () => {
+ it('spans from the root to the tip radius', () => {
+ const geometry = bladeGeometry();
+ geometry.computeBoundingBox();
+ expect(geometry.boundingBox?.min.y).toBeCloseTo(BLADE.rootRadius, 3);
+ expect(geometry.boundingBox?.max.y).toBeCloseTo(BLADE.tipRadius, 3);
+ });
+
+ it('peaks at the maximum chord near a fifth of the span', () => {
+ expect(bladeChord(BLADE.maxChordShare)).toBeCloseTo(BLADE.maxChord, 5);
+ expect(bladeChord(0.5)).toBeLessThan(BLADE.maxChord);
+ expect(bladeChord(1)).toBeLessThan(0.2);
+ });
+
+ it('twists from the root value to zero at the tip', () => {
+ expect(bladeTwist(0.1)).toBeCloseTo((BLADE.rootTwistDeg * Math.PI) / 180, 5);
+ expect(bladeTwist(1)).toBeCloseTo(0, 5);
+ });
+
+ it('turns the leading edge into the wind with twist at pitch zero', () => {
+ const section = sectionNear(20);
+ const leading = section.reduce((best, point) => (point.z > best.z ? point : best));
+ const trailing = section.reduce((best, point) => (point.z < best.z ? point : best));
+ expect(leading.z).toBeGreaterThan(0);
+ expect(leading.x).toBeLessThan(trailing.x);
+ });
+
+ it('faces its surface outward', () => {
+ const geometry = bladeGeometry();
+ const position = geometry.getAttribute('position');
+ const normal = geometry.getAttribute('normal');
+ const outward = Array.from({ length: BLADE.aroundSections }, (_, step) => {
+ const index = 20 * BLADE.aroundSections + step;
+ const point = new Vector3().fromBufferAttribute(position, index).setY(0);
+ return new Vector3().fromBufferAttribute(normal, index).setY(0).dot(point) > 0;
+ });
+ expect(outward.filter(Boolean).length).toBeGreaterThan(BLADE.aroundSections * 0.8);
+ });
+});
diff --git a/explainers/wind-farm/src/scene/geometry/blade.ts b/explainers/wind-farm/src/scene/geometry/blade.ts
new file mode 100644
index 00000000..f809a5ca
--- /dev/null
+++ b/explainers/wind-farm/src/scene/geometry/blade.ts
@@ -0,0 +1,158 @@
+import { BufferGeometry, Float32BufferAttribute } from 'three';
+import { clamp, lerp, smoothstep } from '@core/math';
+import { TURBINE_GEOMETRY } from '../../model/layout';
+import { ROTOR_RADIUS_M } from '../../model/constants';
+
+const NACA = { spread: 5, coefficients: [0.2969, -0.126, -0.3516, 0.2843, -0.1036] } as const;
+
+export const BLADE = {
+ rootRadius: TURBINE_GEOMETRY.bladeRootRadius,
+ tipRadius: ROTOR_RADIUS_M,
+ rootDiameter: 2.2,
+ maxChord: 4.2,
+ maxChordShare: 0.2,
+ tipChord: 0.6,
+ cylinderShare: 0.03,
+ airfoilShare: 0.16,
+ rootTwistDeg: 13,
+ twistStartShare: 0.1,
+ pitchAxisShare: 0.33,
+ tipRoundShare: 0.965,
+ tipFloor: 0.2,
+ spanSections: 40,
+ aroundSections: 24,
+ band: { from: 0.86, to: 0.9 },
+} as const;
+
+const THICKNESS = { root: 0.6, maxChord: 0.4, tip: 0.18, falloff: 1.8 } as const;
+const CHORD_FALLOFF = 1.25;
+
+function halfThickness(fraction: number, ratio: number): number {
+ const [root, linear, square, cube, quartic] = NACA.coefficients;
+ const polynomial =
+ root * Math.sqrt(fraction) +
+ linear * fraction +
+ square * fraction ** 2 +
+ cube * fraction ** 3 +
+ quartic * fraction ** 4;
+ return NACA.spread * ratio * polynomial;
+}
+
+function outboardShare(share: number): number {
+ return clamp((1 - share) / (1 - BLADE.maxChordShare), 0, 1);
+}
+
+export function bladeChord(share: number): number {
+ if (share <= BLADE.maxChordShare) {
+ const grow = smoothstep(share, BLADE.cylinderShare, BLADE.maxChordShare);
+ return lerp(BLADE.rootDiameter, BLADE.maxChord, grow);
+ }
+ const taper = outboardShare(share) ** CHORD_FALLOFF;
+ const chord = BLADE.tipChord + (BLADE.maxChord - BLADE.tipChord) * taper;
+ const tip = clamp((share - BLADE.tipRoundShare) / (1 - BLADE.tipRoundShare), 0, 1);
+ return chord * Math.max(BLADE.tipFloor, Math.sqrt(1 - tip * tip));
+}
+
+export function bladeThickness(share: number): number {
+ if (share <= BLADE.maxChordShare) {
+ return lerp(THICKNESS.root, THICKNESS.maxChord, share / BLADE.maxChordShare);
+ }
+ const fall = outboardShare(share) ** THICKNESS.falloff;
+ return THICKNESS.tip + (THICKNESS.maxChord - THICKNESS.tip) * fall;
+}
+
+export function bladeTwist(share: number): number {
+ const along = clamp((share - BLADE.twistStartShare) / (1 - BLADE.twistStartShare), 0, 1);
+ return ((BLADE.rootTwistDeg * Math.PI) / 180) * (1 - along) ** 1.5;
+}
+
+function spanShare(section: number): number {
+ return (1 - Math.cos((Math.PI * section) / BLADE.spanSections)) / 2;
+}
+
+function sectionRing(share: number): number[] {
+ const radius = lerp(BLADE.rootRadius, BLADE.tipRadius, share);
+ const chord = bladeChord(share);
+ const ratio = bladeThickness(share);
+ const twist = bladeTwist(share);
+ const airfoil = smoothstep(share, BLADE.cylinderShare, BLADE.airfoilShare);
+ const circle = BLADE.rootDiameter / 2;
+ return Array.from({ length: BLADE.aroundSections }, (_, step) => {
+ const angle = (2 * Math.PI * step) / BLADE.aroundSections;
+ const fraction = (1 + Math.cos(angle)) / 2;
+ const side = Math.sin(angle) >= 0 ? 1 : -1;
+ const chordwise = lerp(
+ -circle * Math.cos(angle),
+ (BLADE.pitchAxisShare - fraction) * chord,
+ airfoil,
+ );
+ const thickness = lerp(
+ circle * Math.sin(angle),
+ side * halfThickness(fraction, ratio) * chord,
+ airfoil,
+ );
+ const x = thickness * Math.cos(twist) - chordwise * Math.sin(twist);
+ const z = thickness * Math.sin(twist) + chordwise * Math.cos(twist);
+ return [x, radius, z];
+ }).flat();
+}
+
+function inBand(share: number): boolean {
+ return share >= BLADE.band.from && share < BLADE.band.to;
+}
+
+function capCentre(ring: readonly number[]): number[] {
+ const count = ring.length / 3;
+ return [0, 1, 2].map((axis) => {
+ let sum = 0;
+ for (let index = axis; index < ring.length; index += 3) sum += ring[index];
+ return sum / count;
+ });
+}
+
+export interface BladeGeometries {
+ readonly body: BufferGeometry;
+ readonly band: BufferGeometry;
+}
+
+export function bladeGeometries(): BladeGeometries {
+ const around = BLADE.aroundSections;
+ const shares = Array.from({ length: BLADE.spanSections + 1 }, (_, index) => spanShare(index));
+ const rings = shares.map(sectionRing);
+ const positions = rings.flat();
+ const indices: number[] = [];
+ const band: number[] = [];
+ shares.slice(0, -1).forEach((share, section) => {
+ for (let step = 0; step < around; step += 1) {
+ const a = section * around + step;
+ const b = section * around + ((step + 1) % around);
+ const c = b + around;
+ const d = a + around;
+ indices.push(a, c, b, a, d, c);
+ if (inBand(share)) band.push(a, c, b, a, d, c);
+ }
+ });
+ [0, rings.length - 1].forEach((ringIndex, capIndex) => {
+ const base = positions.length / 3;
+ const centre = base + around;
+ positions.push(...rings[ringIndex], ...capCentre(rings[ringIndex]));
+ for (let step = 0; step < around; step += 1) {
+ const a = base + step;
+ const b = base + ((step + 1) % around);
+ indices.push(...(capIndex === 0 ? [centre, a, b] : [centre, b, a]));
+ }
+ });
+ const body = new BufferGeometry();
+ body.setAttribute('position', new Float32BufferAttribute(positions, 3));
+ body.setIndex(indices);
+ body.computeVertexNormals();
+ const stripe = new BufferGeometry();
+ stripe.setAttribute('position', body.getAttribute('position'));
+ stripe.setAttribute('normal', body.getAttribute('normal'));
+ stripe.setIndex(band);
+ return { body, band: stripe };
+}
+
+export function bladeGeometry(): BufferGeometry {
+ return bladeGeometries().body;
+}
diff --git a/explainers/wind-farm/src/scene/geometry/cylinder.ts b/explainers/wind-farm/src/scene/geometry/cylinder.ts
new file mode 100644
index 00000000..6ccf7d6f
--- /dev/null
+++ b/explainers/wind-farm/src/scene/geometry/cylinder.ts
@@ -0,0 +1,18 @@
+import { CylinderGeometry, Quaternion, Vector3 } from 'three';
+import type { BufferGeometry } from 'three';
+
+const UP = new Vector3(0, 1, 0);
+
+export function cylinderBetween(
+ from: Vector3,
+ to: Vector3,
+ radius: number,
+ segments: number,
+): BufferGeometry {
+ const direction = to.clone().sub(from);
+ const geometry = new CylinderGeometry(radius, radius, direction.length(), segments);
+ geometry.applyQuaternion(new Quaternion().setFromUnitVectors(UP, direction.normalize()));
+ const middle = from.clone().add(to).multiplyScalar(0.5);
+ geometry.translate(middle.x, middle.y, middle.z);
+ return geometry;
+}
diff --git a/explainers/wind-farm/src/scene/geometry/merge.ts b/explainers/wind-farm/src/scene/geometry/merge.ts
new file mode 100644
index 00000000..435110c9
--- /dev/null
+++ b/explainers/wind-farm/src/scene/geometry/merge.ts
@@ -0,0 +1,22 @@
+import type { BufferGeometry } from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+
+function sharedAttributes(parts: readonly BufferGeometry[]): Set {
+ const [first, ...rest] = parts.map((part) => Object.keys(part.attributes));
+ return new Set(first.filter((name) => rest.every((names) => names.includes(name))));
+}
+
+export function mergeParts(parts: readonly BufferGeometry[]): BufferGeometry {
+ const shared = sharedAttributes(parts);
+ const flat = parts.map((part) => {
+ const plain = part.index ? part.toNonIndexed() : part;
+ Object.keys(plain.attributes)
+ .filter((name) => !shared.has(name))
+ .forEach((name) => plain.deleteAttribute(name));
+ return plain;
+ });
+ const merged = mergeGeometries(flat);
+ new Set([...parts, ...flat]).forEach((part) => part.dispose());
+ if (!merged) throw new Error('Merged parts need matching attributes');
+ return merged;
+}
diff --git a/explainers/wind-farm/src/scene/geometry/subdivide.ts b/explainers/wind-farm/src/scene/geometry/subdivide.ts
new file mode 100644
index 00000000..4b39484d
--- /dev/null
+++ b/explainers/wind-farm/src/scene/geometry/subdivide.ts
@@ -0,0 +1,52 @@
+import { BufferGeometry, Float32BufferAttribute, Vector3 } from 'three';
+
+type Triangle = readonly [Vector3, Vector3, Vector3];
+
+const CORNERS = 3;
+const XYZ = 3;
+
+function triangles(geometry: BufferGeometry): Triangle[] {
+ const flat = geometry.index ? geometry.toNonIndexed() : geometry;
+ const position = flat.getAttribute('position');
+ const corner = (index: number) => new Vector3().fromBufferAttribute(position, index);
+ const result = Array.from({ length: position.count / CORNERS }, (_, face): Triangle => [
+ corner(face * CORNERS),
+ corner(face * CORNERS + 1),
+ corner(face * CORNERS + 2),
+ ]);
+ if (flat !== geometry) flat.dispose();
+ return result;
+}
+
+function longestEdge(faces: readonly Triangle[]): number {
+ return faces.reduce(
+ (longest, [a, b, c]) => Math.max(longest, a.distanceTo(b), b.distanceTo(c), c.distanceTo(a)),
+ 0,
+ );
+}
+
+function split([a, b, c]: Triangle): Triangle[] {
+ const ab = a.clone().lerp(b, 0.5);
+ const bc = b.clone().lerp(c, 0.5);
+ const ca = c.clone().lerp(a, 0.5);
+ return [
+ [a, ab, ca],
+ [ab, b, bc],
+ [ca, bc, c],
+ [ab, bc, ca],
+ ];
+}
+
+export function subdivide(geometry: BufferGeometry, maxEdge: number): BufferGeometry {
+ let faces = triangles(geometry);
+ const levels = Math.max(0, Math.ceil(Math.log2(longestEdge(faces) / maxEdge)));
+ for (let level = 0; level < levels; level += 1) faces = faces.flatMap(split);
+ const positions = new Float32Array(faces.length * CORNERS * XYZ);
+ faces.forEach((face, index) =>
+ face.forEach((point, corner) => point.toArray(positions, (index * CORNERS + corner) * XYZ)),
+ );
+ const result = new BufferGeometry();
+ result.setAttribute('position', new Float32BufferAttribute(positions, XYZ));
+ result.computeVertexNormals();
+ return result;
+}
diff --git a/explainers/wind-farm/src/scene/geometry/sweep.ts b/explainers/wind-farm/src/scene/geometry/sweep.ts
new file mode 100644
index 00000000..68e8ec42
--- /dev/null
+++ b/explainers/wind-farm/src/scene/geometry/sweep.ts
@@ -0,0 +1,83 @@
+import { BufferGeometry, Float32BufferAttribute } from 'three';
+import type { Vector2 } from 'three';
+
+export interface SweepStation {
+ x: number;
+ scale: number;
+}
+
+export interface SweepCentre {
+ y: number;
+ z: number;
+}
+
+function stationPoint(point: Vector2, station: SweepStation, centre: SweepCentre): number[] {
+ return [
+ station.x,
+ centre.y + (point.y - centre.y) * station.scale,
+ centre.z + (point.x - centre.z) * station.scale,
+ ];
+}
+
+function gridGeometry(positions: number[], rows: number, columns: number): BufferGeometry {
+ const indices: number[] = [];
+ for (let row = 0; row < rows - 1; row += 1) {
+ for (let column = 0; column < columns - 1; column += 1) {
+ const a = row * columns + column;
+ const b = a + 1;
+ const c = a + columns + 1;
+ const d = a + columns;
+ indices.push(a, b, c, a, c, d);
+ }
+ }
+ const geometry = new BufferGeometry();
+ geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
+ geometry.setIndex(indices);
+ geometry.computeVertexNormals();
+ return geometry;
+}
+
+export function sweepProfile(
+ profile: readonly Vector2[],
+ stations: readonly SweepStation[],
+ centre: SweepCentre,
+): BufferGeometry {
+ const positions = stations.flatMap((station) =>
+ profile.flatMap((point) => stationPoint(point, station, centre)),
+ );
+ return gridGeometry(positions, stations.length, profile.length);
+}
+
+export function rimProfile(
+ profile: readonly Vector2[],
+ station: SweepStation,
+ innerScale: number,
+ centre: SweepCentre,
+): BufferGeometry {
+ const inner = { x: station.x, scale: station.scale * innerScale };
+ const positions = [station, inner].flatMap((ring) =>
+ profile.flatMap((point) => stationPoint(point, ring, centre)),
+ );
+ return gridGeometry(positions, 2, profile.length);
+}
+
+export function scaleAt(stations: readonly SweepStation[], x: number): number {
+ const next = stations.findIndex((station) => station.x >= x);
+ if (next < 0) return stations[stations.length - 1].scale;
+ if (next === 0) return stations[0].scale;
+ const [a, b] = [stations[next - 1], stations[next]];
+ return a.scale + ((b.scale - a.scale) * (x - a.x)) / (b.x - a.x);
+}
+
+export function stationsBetween(
+ stations: readonly SweepStation[],
+ from: number,
+ to: number,
+): SweepStation[] {
+ const inside = stations.filter((station) => station.x > from && station.x < to);
+ return [
+ { x: from, scale: scaleAt(stations, from) },
+ ...inside,
+ { x: to, scale: scaleAt(stations, to) },
+ ];
+}
diff --git a/explainers/wind-farm/src/scene/haze.test.ts b/explainers/wind-farm/src/scene/haze.test.ts
new file mode 100644
index 00000000..88a43938
--- /dev/null
+++ b/explainers/wind-farm/src/scene/haze.test.ts
@@ -0,0 +1,46 @@
+import { Fog } from 'three';
+import { describe, expect, it } from 'vitest';
+import { HAZE, SCENE_LIMITS } from './constants';
+import { applyHaze, hazeRange } from './haze';
+
+const TURBINE_DISTANCE_M = 600;
+const FARM_DISTANCE_M = 12_000;
+const FARTHEST_DISTANCE_M = SCENE_LIMITS.cameraMaxDistance;
+
+describe('hazeRange', () => {
+ it('keeps the fixed haze for close turbine views', () => {
+ expect(hazeRange(TURBINE_DISTANCE_M)).toEqual({ near: HAZE.near, far: HAZE.far });
+ });
+
+ it('starts the fog beyond the framed target for distant farm views', () => {
+ const { near, far } = hazeRange(FARM_DISTANCE_M);
+ expect(near).toBe(FARM_DISTANCE_M * HAZE.nearPerDistance);
+ expect(near).toBeGreaterThan(FARM_DISTANCE_M);
+ expect(far).toBeLessThanOrEqual(SCENE_LIMITS.cameraFar);
+ });
+
+ it('keeps the fog inside the camera range when zoomed out fully', () => {
+ const { near, far } = hazeRange(FARTHEST_DISTANCE_M);
+ expect(far).toBe(SCENE_LIMITS.cameraFar);
+ expect(near).toBeLessThan(far);
+ });
+
+ it('grows with the camera distance', () => {
+ const close = hazeRange(FARM_DISTANCE_M / 2);
+ const far = hazeRange(FARM_DISTANCE_M);
+ expect(far.near).toBeGreaterThanOrEqual(close.near);
+ expect(far.far).toBeGreaterThanOrEqual(close.far);
+ });
+});
+
+describe('applyHaze', () => {
+ it('moves the scene fog to the range for the distance', () => {
+ const scene = { fog: new Fog(HAZE.colour, HAZE.near, HAZE.far) };
+ applyHaze(scene, FARM_DISTANCE_M);
+ expect({ near: scene.fog.near, far: scene.fog.far }).toEqual(hazeRange(FARM_DISTANCE_M));
+ });
+
+ it('ignores a scene without linear fog', () => {
+ expect(() => applyHaze({ fog: null }, FARM_DISTANCE_M)).not.toThrow();
+ });
+});
diff --git a/explainers/wind-farm/src/scene/haze.ts b/explainers/wind-farm/src/scene/haze.ts
new file mode 100644
index 00000000..79fe5a9a
--- /dev/null
+++ b/explainers/wind-farm/src/scene/haze.ts
@@ -0,0 +1,23 @@
+import { Fog } from 'three';
+import type { Scene } from 'three';
+import { clamp } from '@core/math';
+import { HAZE, SCENE_LIMITS } from './constants';
+
+export interface HazeRange {
+ near: number;
+ far: number;
+}
+
+export function hazeRange(cameraDistance: number): HazeRange {
+ const far = clamp(cameraDistance * HAZE.farPerDistance, HAZE.far, SCENE_LIMITS.cameraFar);
+ const near = clamp(cameraDistance * HAZE.nearPerDistance, HAZE.near, far * HAZE.maxNearShare);
+ return { near, far };
+}
+
+export function applyHaze(scene: Pick, cameraDistance: number): void {
+ const { fog } = scene;
+ if (!(fog instanceof Fog)) return;
+ const { near, far } = hazeRange(cameraDistance);
+ fog.near = near;
+ fog.far = far;
+}
diff --git a/explainers/wind-farm/src/scene/index.ts b/explainers/wind-farm/src/scene/index.ts
new file mode 100644
index 00000000..7b25c144
--- /dev/null
+++ b/explainers/wind-farm/src/scene/index.ts
@@ -0,0 +1,29 @@
+import { createLabelVisibility } from '@core/scene/presetBinder';
+import type { SceneShell } from '@core/scene/shell';
+import type { WindFarmStore } from '../state';
+import { bindStore } from './bindings';
+import { WindFarmController } from './controller';
+import { windFarmLight } from './lighting';
+import { LABEL_PRIORITY } from './partInfo';
+
+export { bindStore } from './bindings';
+export type { SceneTargets } from './bindings';
+export { WindFarmController } from './controller';
+export type { ViewFramer, WindFarmControllerDependencies } from './controller';
+
+export { SCENE_OPTIONS } from './sceneOptions';
+
+export function mountWindFarmScene(shell: SceneShell, store: WindFarmStore): () => void {
+ const restoreLight = windFarmLight(shell.lighting);
+ const windFarm = new WindFarmController(shell);
+ const removeFrame = shell.onFrame((deltaSeconds) => windFarm.update(deltaSeconds));
+ const labelVisibility = createLabelVisibility(shell, LABEL_PRIORITY);
+ const unbind = bindStore(store, { windFarm, labelVisibility, ...shell });
+ return () => {
+ removeFrame();
+ labelVisibility.dispose();
+ unbind();
+ windFarm.dispose();
+ restoreLight();
+ };
+}
diff --git a/explainers/wind-farm/src/scene/lighting.ts b/explainers/wind-farm/src/scene/lighting.ts
new file mode 100644
index 00000000..9a249696
--- /dev/null
+++ b/explainers/wind-farm/src/scene/lighting.ts
@@ -0,0 +1,57 @@
+import type { DirectionalLight } from 'three';
+import type { Lighting } from '@core/scene/lighting';
+import { LIGHT_RIG } from './constants';
+
+type Triple = readonly [number, number, number];
+
+interface LightSetting {
+ readonly color: string;
+ readonly intensity: number;
+ readonly position: Triple;
+}
+
+type LightName = 'key' | 'fill' | 'rim';
+
+function scaled(direction: Triple): Triple {
+ const length = Math.hypot(...direction);
+ return direction.map((value) => (value / length) * LIGHT_RIG.distance) as unknown as Triple;
+}
+
+export const WIND_FARM_LIGHT: Readonly> = {
+ key: {
+ color: LIGHT_RIG.key.color,
+ intensity: LIGHT_RIG.key.intensity,
+ position: scaled(LIGHT_RIG.key.direction),
+ },
+ fill: {
+ color: LIGHT_RIG.fill.color,
+ intensity: LIGHT_RIG.fill.intensity,
+ position: scaled(LIGHT_RIG.fill.direction),
+ },
+ rim: {
+ color: LIGHT_RIG.rim.color,
+ intensity: LIGHT_RIG.rim.intensity,
+ position: scaled(LIGHT_RIG.rim.direction),
+ },
+};
+
+function apply(light: DirectionalLight, setting: LightSetting): () => void {
+ const color = light.color.clone();
+ const intensity = light.intensity;
+ const position = light.position.clone();
+ light.color.set(setting.color);
+ light.intensity = setting.intensity;
+ light.position.set(...setting.position);
+ return () => {
+ light.color.copy(color);
+ light.intensity = intensity;
+ light.position.copy(position);
+ };
+}
+
+export function windFarmLight(lighting: Pick): () => void {
+ const restorers = (Object.keys(WIND_FARM_LIGHT) as LightName[]).map((name) =>
+ apply(lighting[name], WIND_FARM_LIGHT[name]),
+ );
+ return () => restorers.forEach((restore) => restore());
+}
diff --git a/explainers/wind-farm/src/scene/partInfo.test.ts b/explainers/wind-farm/src/scene/partInfo.test.ts
new file mode 100644
index 00000000..ba6594e5
--- /dev/null
+++ b/explainers/wind-farm/src/scene/partInfo.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import { PART_IDS } from '../ids';
+import type { PartId } from '../ids';
+import { LABEL_PRIORITY, PART_INFO } from './partInfo';
+
+const LEFT_PARTS: readonly PartId[] = [
+ 'land',
+ 'foundation',
+ 'tower',
+ 'hub',
+ 'blades',
+ 'pitchCylinders',
+ 'mainBearing',
+ 'mainShaft',
+ 'yawDrives',
+ 'sweptArea',
+ 'streamlinesGroup',
+ 'farmLand',
+ 'prevailingWind',
+ 'windArrows',
+ 'shearProfile',
+ 'spacingMarker',
+ 'accessRoads',
+];
+const RANKED_COUNT = 16;
+const NACELLE_FIRST: readonly PartId[] = [
+ 'gearbox',
+ 'generator',
+ 'brakeDisc',
+ 'mainShaft',
+ 'mainBearing',
+ 'converter',
+];
+const NACELLE_LATER: readonly PartId[] = ['pitchCylinders', 'yawDrives', 'cooler'];
+
+describe('part labels', () => {
+ it('places the prevailing wind label before the farm labels around it', () => {
+ const wind = LABEL_PRIORITY.indexOf('prevailingWind');
+ expect(wind).toBeLessThan(LABEL_PRIORITY.indexOf('spacingMarker'));
+ expect(wind).toBeLessThan(LABEL_PRIORITY.indexOf('accessRoads'));
+ });
+
+ it('names every part from its locale key', () => {
+ PART_IDS.forEach((id) => expect(PART_INFO[id].labelKey).toBe(`parts.${id}`));
+ expect(Object.keys(PART_INFO)).toHaveLength(PART_IDS.length);
+ });
+
+ it('ranks every part exactly once, the farm turbines first and the substation second', () => {
+ expect([...LABEL_PRIORITY].sort()).toEqual([...PART_IDS].sort());
+ expect(LABEL_PRIORITY.slice(0, 3)).toEqual(['farmTurbines', 'substation', 'nacelle']);
+ expect(LABEL_PRIORITY.indexOf('pitchCylinders')).toBe(RANKED_COUNT - 1);
+ });
+
+ it('ranks the drivetrain parts the nacelle chapter names first ahead of the other nacelle labels', () => {
+ const ranks = NACELLE_FIRST.map((id) => LABEL_PRIORITY.indexOf(id));
+ expect(ranks).toEqual([...ranks].sort((a, b) => a - b));
+ const lastFirst = Math.max(...ranks);
+ NACELLE_LATER.forEach((id) =>
+ expect(LABEL_PRIORITY.indexOf(id), id).toBeGreaterThan(lastFirst),
+ );
+ });
+
+ it('keeps the unranked parts in their part order after the ranked ones', () => {
+ const rest = LABEL_PRIORITY.slice(RANKED_COUNT);
+ expect(rest).toEqual(PART_IDS.filter((id) => rest.includes(id)));
+ expect(rest[0]).toBe('land');
+ });
+
+ it('labels the ground, the tower, the rotor and the wind on the left and the rest on the right', () => {
+ PART_IDS.forEach((id) =>
+ expect(PART_INFO[id].side, id).toBe(LEFT_PARTS.includes(id) ? 'left' : 'right'),
+ );
+ (['nacelle', 'gearbox', 'generator', 'substation', 'wakePlumes'] as const).forEach((id) =>
+ expect(PART_INFO[id].side, id).toBe('right'),
+ );
+ });
+});
diff --git a/explainers/wind-farm/src/scene/partInfo.ts b/explainers/wind-farm/src/scene/partInfo.ts
new file mode 100644
index 00000000..83b1b87b
--- /dev/null
+++ b/explainers/wind-farm/src/scene/partInfo.ts
@@ -0,0 +1,56 @@
+import type { PartInfo } from '@core/explainer';
+import type { LabelSide } from '@core/scene/labelLayout';
+import { PART_IDS } from '../ids';
+import type { PartId } from '../ids';
+
+const LEFT_PARTS: ReadonlySet = new Set([
+ 'land',
+ 'foundation',
+ 'tower',
+ 'hub',
+ 'blades',
+ 'pitchCylinders',
+ 'mainBearing',
+ 'mainShaft',
+ 'yawDrives',
+ 'sweptArea',
+ 'streamlinesGroup',
+ 'farmLand',
+ 'prevailingWind',
+ 'windArrows',
+ 'shearProfile',
+ 'spacingMarker',
+ 'accessRoads',
+]);
+
+const RANKED_FIRST: readonly PartId[] = [
+ 'farmTurbines',
+ 'substation',
+ 'nacelle',
+ 'hub',
+ 'blades',
+ 'gearbox',
+ 'generator',
+ 'wakePlumes',
+ 'collectorCables',
+ 'prevailingWind',
+ 'tower',
+ 'brakeDisc',
+ 'mainShaft',
+ 'mainBearing',
+ 'converter',
+ 'pitchCylinders',
+];
+
+function labelSide(id: PartId): LabelSide {
+ return LEFT_PARTS.has(id) ? 'left' : 'right';
+}
+
+export const PART_INFO: Record = Object.fromEntries(
+ PART_IDS.map((id) => [id, { labelKey: `parts.${id}`, side: labelSide(id) }]),
+) as Record;
+
+export const LABEL_PRIORITY: readonly PartId[] = [
+ ...RANKED_FIRST,
+ ...PART_IDS.filter((id) => !RANKED_FIRST.includes(id)),
+];
diff --git a/explainers/wind-farm/src/scene/parts/air/arrowGeometry.ts b/explainers/wind-farm/src/scene/parts/air/arrowGeometry.ts
new file mode 100644
index 00000000..d524a1a4
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/arrowGeometry.ts
@@ -0,0 +1,52 @@
+import { BufferAttribute, BufferGeometry } from 'three';
+import type { Point } from '../../../ids';
+import type { ArrowShape } from './arrowMaterial';
+
+export type ArrowPlane = 'ground' | 'upright';
+
+export interface ArrowSpec {
+ readonly tail: Point;
+ readonly length: number;
+ readonly rate: number;
+}
+
+const XYZ = 3;
+const CORNERS = [
+ [0, -1],
+ [1, -1],
+ [0, 1],
+ [1, 1],
+] as const;
+
+export function arrowGeometry(
+ arrows: readonly ArrowSpec[],
+ shape: ArrowShape,
+ plane: ArrowPlane,
+): BufferGeometry {
+ const reach = Math.max(shape.headHalf, shape.chevronHalf) + shape.margin;
+ const positions: number[] = [];
+ const along: number[] = [];
+ const lengths: number[] = [];
+ const rates: number[] = [];
+ const indices: number[] = [];
+ arrows.forEach(({ tail, length, rate }, index) => {
+ const first = index * CORNERS.length;
+ CORNERS.forEach(([end, side]) => {
+ const u = end === 0 ? -shape.margin : length + shape.margin;
+ const w = side * reach;
+ const [x, y, z] = tail;
+ positions.push(x + u, plane === 'upright' ? y + w : y, plane === 'ground' ? z + w : z);
+ along.push(u, w);
+ lengths.push(length);
+ rates.push(rate);
+ });
+ indices.push(first, first + 1, first + 2, first + 2, first + 1, first + 3);
+ });
+ const geometry = new BufferGeometry();
+ geometry.setAttribute('position', new BufferAttribute(new Float32Array(positions), XYZ));
+ geometry.setAttribute('aArrow', new BufferAttribute(new Float32Array(along), 2));
+ geometry.setAttribute('aLength', new BufferAttribute(new Float32Array(lengths), 1));
+ geometry.setAttribute('aRate', new BufferAttribute(new Float32Array(rates), 1));
+ geometry.setIndex(indices);
+ return geometry;
+}
diff --git a/explainers/wind-farm/src/scene/parts/air/arrowMaterial.ts b/explainers/wind-farm/src/scene/parts/air/arrowMaterial.ts
new file mode 100644
index 00000000..c65a595d
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/arrowMaterial.ts
@@ -0,0 +1,112 @@
+import { Color, DoubleSide, ShaderMaterial, UniformsLib, UniformsUtils } from 'three';
+import { ARROW_LOOK, FLOW_LOOK } from './constants';
+
+export interface ArrowShape {
+ readonly headLength: number;
+ readonly shaftHalf: number;
+ readonly headHalf: number;
+ readonly chevronHalf: number;
+ readonly chevronPeriod: number;
+ readonly margin: number;
+}
+
+const VERTEX = /* glsl */ `
+attribute vec2 aArrow;
+attribute float aLength;
+attribute float aRate;
+varying vec2 vArrow;
+varying float vLength;
+varying float vRate;
+#include
+void main() {
+ vArrow = aArrow;
+ vLength = aLength;
+ vRate = aRate;
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
+ gl_Position = projectionMatrix * mvPosition;
+ #include
+}
+`;
+
+const FRAGMENT = /* glsl */ `
+uniform vec3 uColour;
+uniform vec3 uChevron;
+uniform float uCycle;
+uniform float uPeriod;
+uniform float uHead;
+uniform float uShaftHalf;
+uniform float uHeadHalf;
+uniform float uChevronHalf;
+uniform float uEdge;
+uniform float uHalo;
+uniform float uBody;
+uniform float uHaloOpacity;
+uniform float uSlope;
+uniform float uChevronShare;
+uniform float uTailFade;
+varying vec2 vArrow;
+varying float vLength;
+varying float vRate;
+#include
+void main() {
+ float along = vArrow.x;
+ float across = abs(vArrow.y);
+ float neck = vLength - uHead;
+ float headShare = clamp((along - neck) / uHead, 0.0, 1.0);
+ float halfWidth = along < neck ? uShaftHalf : uHeadHalf * (1.0 - headShare);
+ float outside = max(across - halfWidth, max(-along, along - vLength));
+ float edge = uEdge * uShaftHalf;
+ float body = 1.0 - smoothstep(-edge, edge, outside);
+ float halo = exp(-max(outside, 0.0) / (uHalo * uShaftHalf));
+ float tail = smoothstep(0.0, uTailFade * vLength, along);
+ float phase = fract((along + across * uSlope) / (uPeriod * vRate) - uCycle);
+ float stripe = smoothstep(0.0, 0.1, phase) * (1.0 - smoothstep(uChevronShare, uChevronShare + 0.1, phase));
+ float span = 1.0 - smoothstep(0.75 * uChevronHalf, uChevronHalf, across);
+ float before = 1.0 - smoothstep(neck - 0.6 * uPeriod, neck, along);
+ float chevron = stripe * span * before * tail;
+ float glow = tail * max(body * uBody, (1.0 - body) * halo * uHaloOpacity);
+ gl_FragColor = vec4(mix(uColour, uChevron, chevron), clamp(glow + chevron, 0.0, 1.0));
+ #include
+ #include
+}
+`;
+
+export type ArrowMaterial = ShaderMaterial & { readonly color: Color };
+
+export function arrowMaterial(shape: ArrowShape): ArrowMaterial {
+ const material = new ShaderMaterial({
+ uniforms: UniformsUtils.merge([
+ UniformsLib.fog,
+ {
+ uChevron: { value: new Color(FLOW_LOOK.chevron) },
+ uCycle: { value: 0 },
+ uPeriod: { value: shape.chevronPeriod },
+ uHead: { value: shape.headLength },
+ uShaftHalf: { value: shape.shaftHalf },
+ uHeadHalf: { value: shape.headHalf },
+ uChevronHalf: { value: shape.chevronHalf },
+ uEdge: { value: ARROW_LOOK.edge },
+ uHalo: { value: ARROW_LOOK.halo },
+ uBody: { value: ARROW_LOOK.bodyOpacity },
+ uHaloOpacity: { value: ARROW_LOOK.haloOpacity },
+ uSlope: { value: ARROW_LOOK.chevronSlope },
+ uChevronShare: { value: ARROW_LOOK.chevronShare },
+ uTailFade: { value: ARROW_LOOK.tailFade },
+ },
+ ]),
+ vertexShader: VERTEX,
+ fragmentShader: FRAGMENT,
+ transparent: true,
+ depthWrite: false,
+ side: DoubleSide,
+ fog: true,
+ });
+ const color = new Color(FLOW_LOOK.line);
+ material.uniforms.uColour = { value: color };
+ return Object.assign(material, { color });
+}
+
+export function advanceArrows(material: ShaderMaterial, metres: number): void {
+ const { uCycle, uPeriod } = material.uniforms;
+ uCycle.value = ((uCycle.value as number) + metres / (uPeriod.value as number)) % 1;
+}
diff --git a/explainers/wind-farm/src/scene/parts/air/bearing.ts b/explainers/wind-farm/src/scene/parts/air/bearing.ts
new file mode 100644
index 00000000..81b50271
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/bearing.ts
@@ -0,0 +1,11 @@
+import type { GroundPoint } from '../../../model/layout';
+
+export function turnedGround(x: number, z: number, turn: number): GroundPoint {
+ const cos = Math.cos(turn);
+ const sin = Math.sin(turn);
+ return [x * cos + z * sin, -x * sin + z * cos];
+}
+
+export function quantise(value: number, step: number): number {
+ return Math.round(value / step) * step;
+}
diff --git a/explainers/wind-farm/src/scene/parts/air/constants.ts b/explainers/wind-farm/src/scene/parts/air/constants.ts
new file mode 100644
index 00000000..ee495d39
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/constants.ts
@@ -0,0 +1,90 @@
+import { THEME } from '../../../theme';
+
+export const FLOW_LOOK = {
+ line: THEME.wind,
+ dash: '#eaf6ff',
+ chevron: '#f4fbff',
+} as const;
+
+export const STREAMLINES = {
+ startX: -450,
+ endX: 900,
+ samples: 140,
+ rows: [-0.75, -0.25, 0.25, 0.75],
+ columns: [-1, -0.6, -0.2, 0.2, 0.6, 1],
+ gridReach: 0.64,
+ maxInduction: 1 / 3,
+ inductionStep: 0.01,
+ dashPeriod: 110,
+ dashShare: 0.3,
+ dashOpacity: 0.7,
+ lineOpacity: 0.32,
+ fade: [0.25, 2 / 3],
+ facing: [0.12, 0.4],
+ near: [0.35, 0.7],
+ minWidth: 0.8,
+ widthPerMetre: 0.004,
+ playback: 4,
+ labelAt: [-120, 140, 0],
+} as const;
+
+export const ARROW_LOOK = {
+ edge: 0.18,
+ halo: 0.6,
+ bodyOpacity: 0.5,
+ haloOpacity: 0.3,
+ chevronSlope: 0.9,
+ chevronShare: 0.3,
+ tailFade: 0.18,
+} as const;
+
+export const WIND_ARROWS = {
+ count: 9,
+ spanZ: 5400,
+ length: 480,
+ headLength: 130,
+ shaftHalf: 16,
+ headHalf: 52,
+ chevronHalf: 40,
+ chevronPeriod: 110,
+ margin: 30,
+ playback: 12,
+ labelLift: 60,
+} as const;
+
+export const SHEAR_ARROWS = {
+ longest: 240,
+ headLength: 34,
+ shaftHalf: 3.5,
+ headHalf: 10,
+ chevronHalf: 7,
+ chevronPeriod: 45,
+ margin: 8,
+ playback: 6,
+ mastTop: 195,
+ mastRadius: 1.6,
+ curveRadius: 1.8,
+ curveSegments: 64,
+ curveSides: 6,
+ labelAt: [0, 215, 0],
+} as const;
+
+export const GROUND_ARROW = {
+ offsetX: 700,
+ length: 620,
+ headLength: 230,
+ shaftHalf: 55,
+ headHalf: 140,
+ chevrons: 2,
+ chevronLead: 60,
+ chevronPitch: 80,
+ chevronDepth: 90,
+ chevronThickness: 38,
+ chevronSpan: 100,
+ lift: 2,
+ maxEdge: 45,
+ opacity: 0.85,
+ labelLift: 40,
+} as const;
+
+export const BEARING_STEP_DEG = 0.25;
diff --git a/explainers/wind-farm/src/scene/parts/air/farmAir.ts b/explainers/wind-farm/src/scene/parts/air/farmAir.ts
new file mode 100644
index 00000000..d14b2df2
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/farmAir.ts
@@ -0,0 +1,42 @@
+import type { AssemblyState, SpacingD } from '../../../ids';
+import { bearingTurn, namedGroup } from '../context';
+import type { Motion, PartContext, Section } from '../context';
+import { BEARING_STEP_DEG, SHEAR_ARROWS, WIND_ARROWS } from './constants';
+import { GroundArrowPart } from './groundArrow';
+import { ShearProfilePart } from './shearProfile';
+import { WindArrowsPart } from './windArrows';
+import { quantise } from './bearing';
+
+export function farmAirSection(context: PartContext): Section {
+ const root = namedGroup('farmAir');
+ const ground = new GroundArrowPart(context);
+ const arrows = new WindArrowsPart(context);
+ const shear = new ShearProfilePart(context);
+ root.add(ground.group, arrows.group, shear.group);
+ let placed: { spacing: SpacingD; bearing: number } | undefined;
+
+ const place = (spacing: SpacingD, bearing: number) => {
+ if (placed?.spacing === spacing && placed.bearing === bearing) return;
+ placed = { spacing, bearing };
+ const turn = bearingTurn(bearing);
+ ground.place(spacing, turn);
+ arrows.place(spacing, turn);
+ shear.place(spacing, turn);
+ };
+
+ return {
+ root,
+ setState(state: AssemblyState) {
+ arrows.group.visible = state.view.streamlines;
+ shear.group.visible = state.view.streamlines;
+ place(state.farm.spacing, quantise(state.wind.fromDeg, BEARING_STEP_DEG));
+ },
+ animate(motion: Motion, state: AssemblyState) {
+ if (!state.view.streamlines) return false;
+ const metres = motion.delta * state.wind.speed;
+ arrows.advance(metres * WIND_ARROWS.playback);
+ shear.advance(metres * SHEAR_ARROWS.playback);
+ return true;
+ },
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/air/flowMaterial.ts b/explainers/wind-farm/src/scene/parts/air/flowMaterial.ts
new file mode 100644
index 00000000..2bdcd01c
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/flowMaterial.ts
@@ -0,0 +1,91 @@
+import { Color, DoubleSide, ShaderMaterial, Vector2, Vector3 } from 'three';
+import { TURBINE_GEOMETRY } from '../../../model/layout';
+import { FLOW_LOOK, STREAMLINES } from './constants';
+
+const VERTEX = /* glsl */ `
+attribute vec3 aTangent;
+attribute float aSide;
+attribute float aTravel;
+attribute float aShare;
+uniform float uMinWidth;
+uniform float uWidthPerMetre;
+uniform vec3 uHub;
+uniform vec2 uFacing;
+uniform vec2 uNear;
+varying float vSide;
+varying float vTravel;
+varying float vShare;
+varying float vSight;
+void main() {
+ vec4 world = modelMatrix * vec4(position, 1.0);
+ vec3 tangent = normalize(mat3(modelMatrix) * aTangent);
+ vec3 toEye = cameraPosition - world.xyz;
+ vec3 across = cross(tangent, toEye);
+ float span = length(across);
+ vec3 side = span > 1e-4 ? across / span : vec3(0.0, 1.0, 0.0);
+ float eye = length(toEye);
+ float focus = length(cameraPosition - (modelMatrix * vec4(uHub, 1.0)).xyz);
+ float facing = smoothstep(uFacing.x, uFacing.y, span / max(eye, 1e-4));
+ vSight = facing * smoothstep(uNear.x * focus, uNear.y * focus, eye);
+ float width = max(uMinWidth, eye * uWidthPerMetre);
+ world.xyz += side * aSide * width * 0.5;
+ vSide = aSide;
+ vTravel = aTravel;
+ vShare = aShare;
+ gl_Position = projectionMatrix * viewMatrix * world;
+}
+`;
+
+const FRAGMENT = /* glsl */ `
+uniform vec3 uLine;
+uniform vec3 uDash;
+uniform float uClock;
+uniform float uPeriod;
+uniform float uDashShare;
+uniform float uDashOpacity;
+uniform float uLineOpacity;
+uniform vec2 uFade;
+varying float vSide;
+varying float vTravel;
+varying float vShare;
+varying float vSight;
+void main() {
+ float core = 1.0 - vSide * vSide;
+ float phase = fract((vTravel - uClock) / uPeriod);
+ float rise = smoothstep(1.0 - uDashShare, 1.0, phase);
+ float dash = rise * rise * (1.0 - smoothstep(0.97, 1.0, phase));
+ float tail = 1.0 - smoothstep(uFade.y, 1.0, vShare);
+ float ends = smoothstep(0.0, uFade.x, vShare) * tail;
+ float line = uLineOpacity * core * core * core;
+ float alpha = ends * vSight * clamp(line + dash * tail * core * uDashOpacity, 0.0, 1.0);
+ gl_FragColor = vec4(mix(uLine, uDash, dash), alpha);
+ #include
+}
+`;
+
+export function flowMaterial(): ShaderMaterial {
+ const { dashPeriod, dashShare, dashOpacity, lineOpacity, fade, facing, near } = STREAMLINES;
+ const { minWidth, widthPerMetre } = STREAMLINES;
+ return new ShaderMaterial({
+ uniforms: {
+ uLine: { value: new Color(FLOW_LOOK.line) },
+ uDash: { value: new Color(FLOW_LOOK.dash) },
+ uClock: { value: 0 },
+ uPeriod: { value: dashPeriod },
+ uDashShare: { value: dashShare },
+ uDashOpacity: { value: dashOpacity },
+ uLineOpacity: { value: lineOpacity },
+ uFade: { value: new Vector2(...fade) },
+ uFacing: { value: new Vector2(...facing) },
+ uNear: { value: new Vector2(...near) },
+ uHub: { value: new Vector3(...TURBINE_GEOMETRY.hub) },
+ uMinWidth: { value: minWidth },
+ uWidthPerMetre: { value: widthPerMetre },
+ },
+ vertexShader: VERTEX,
+ fragmentShader: FRAGMENT,
+ transparent: true,
+ depthWrite: false,
+ side: DoubleSide,
+ });
+}
diff --git a/explainers/wind-farm/src/scene/parts/air/groundArrow.ts b/explainers/wind-farm/src/scene/parts/air/groundArrow.ts
new file mode 100644
index 00000000..0716ac1d
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/groundArrow.ts
@@ -0,0 +1,103 @@
+import { Shape, ShapeGeometry } from 'three';
+import type { BufferAttribute, BufferGeometry } from 'three';
+import type { SpacingD } from '../../../ids';
+import { terrainHeight, windArrowsX } from '../../../model/layout';
+import { FINISHES } from '../../finishes';
+import { subdivide } from '../../geometry/subdivide';
+import { finishMesh, label, namedGroup } from '../context';
+import type { PartContext } from '../context';
+import { GROUND_ARROW } from './constants';
+import { turnedGround } from './bearing';
+
+const XYZ = 3;
+const LIE_FLAT = -Math.PI / 2;
+const OVERLAY_OFFSET = -2;
+
+const GROUND_ARROW_FINISH = {
+ ...FINISHES.wind,
+ transparent: true,
+ opacity: GROUND_ARROW.opacity,
+ depthWrite: false,
+ polygonOffset: true,
+ polygonOffsetFactor: OVERLAY_OFFSET,
+ polygonOffsetUnits: OVERLAY_OFFSET,
+};
+
+function outline(points: readonly (readonly [number, number])[]): Shape {
+ const shape = new Shape();
+ points.forEach(([x, y], index) => (index === 0 ? shape.moveTo(x, y) : shape.lineTo(x, y)));
+ shape.closePath();
+ return shape;
+}
+
+function arrowShape(): Shape {
+ const { length, headLength, shaftHalf, headHalf } = GROUND_ARROW;
+ const [tail, tip] = [-length / 2, length / 2];
+ const neck = tip - headLength;
+ return outline([
+ [tail, -shaftHalf],
+ [neck, -shaftHalf],
+ [neck, -headHalf],
+ [tip, 0],
+ [neck, headHalf],
+ [neck, shaftHalf],
+ [tail, shaftHalf],
+ ]);
+}
+
+function chevronShape(index: number): Shape {
+ const { length, chevronLead, chevronPitch, chevronDepth, chevronThickness, chevronSpan } =
+ GROUND_ARROW;
+ const apex = -length / 2 - chevronLead - index * chevronPitch;
+ const back = apex - chevronDepth;
+ return outline([
+ [apex, 0],
+ [back, chevronSpan],
+ [back - chevronThickness, chevronSpan],
+ [apex - chevronThickness, 0],
+ [back - chevronThickness, -chevronSpan],
+ [back, -chevronSpan],
+ ]);
+}
+
+function flatArrow(): BufferGeometry {
+ const shapes = [
+ arrowShape(),
+ ...Array.from({ length: GROUND_ARROW.chevrons }, (_, index) => chevronShape(index)),
+ ];
+ const flat = new ShapeGeometry(shapes).rotateX(LIE_FLAT);
+ const geometry = subdivide(flat, GROUND_ARROW.maxEdge);
+ flat.dispose();
+ return geometry;
+}
+
+export class GroundArrowPart {
+ readonly group = namedGroup('prevailingWind');
+ private readonly geometry: BufferGeometry;
+ private readonly flat: Float32Array;
+
+ constructor(context: PartContext) {
+ const mesh = finishMesh(context, flatArrow(), 'prevailingWind', GROUND_ARROW_FINISH);
+ mesh.name = 'prevailingWind';
+ this.geometry = mesh.geometry;
+ this.flat = Float32Array.from(this.geometry.getAttribute('position').array);
+ this.group.add(mesh);
+ label(context, 'prevailingWind', this.group, [0, GROUND_ARROW.labelLift, 0]);
+ }
+
+ place(spacing: SpacingD, turn: number): void {
+ const centreX = windArrowsX(spacing) - GROUND_ARROW.offsetX;
+ const ground = terrainHeight(centreX, 0);
+ this.group.position.set(centreX, ground, 0);
+ this.group.rotation.y = turn;
+ const position = this.geometry.getAttribute('position') as BufferAttribute;
+ for (let vertex = 0; vertex < position.count; vertex += 1) {
+ const [x, z] = [this.flat[vertex * XYZ], this.flat[vertex * XYZ + 2]];
+ const [dx, dz] = turnedGround(x, z, turn);
+ const height = terrainHeight(centreX + dx, dz);
+ position.setY(vertex, height - ground + GROUND_ARROW.lift);
+ }
+ position.needsUpdate = true;
+ this.geometry.computeBoundingSphere();
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/air/index.ts b/explainers/wind-farm/src/scene/parts/air/index.ts
new file mode 100644
index 00000000..8acf71e1
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/index.ts
@@ -0,0 +1,11 @@
+import type { PartContext, Section } from '../context';
+import { farmAirSection } from './farmAir';
+import { streamlinesSection } from './streamlines';
+
+export function buildStreamlines(context: PartContext): Section {
+ return streamlinesSection(context);
+}
+
+export function buildFarmAir(context: PartContext): Section {
+ return farmAirSection(context);
+}
diff --git a/explainers/wind-farm/src/scene/parts/air/shear.test.ts b/explainers/wind-farm/src/scene/parts/air/shear.test.ts
new file mode 100644
index 00000000..3dbb3312
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/shear.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest';
+import { HUB_HEIGHT_M, TIP_HEIGHT_M } from '../../../model/constants';
+import { shearArrowLength, shearArrows, shearSpeed } from './shear';
+
+const LONGEST = 240;
+
+describe('shear profile', () => {
+ it('follows the one seventh power law about hub height', () => {
+ expect(shearSpeed(HUB_HEIGHT_M)).toBeCloseTo(1, 9);
+ expect(shearSpeed(30)).toBeCloseTo((30 / 105) ** (1 / 7), 9);
+ });
+
+ it('draws the arrow at the tip height longest', () => {
+ const lengths = shearArrows(LONGEST).map(({ length }) => length);
+ expect(Math.max(...lengths)).toBeCloseTo(LONGEST, 9);
+ expect(shearArrowLength(TIP_HEIGHT_M, LONGEST)).toBeCloseTo(LONGEST, 9);
+ lengths.slice(1).forEach((length, index) => expect(length).toBeGreaterThan(lengths[index]));
+ });
+});
diff --git a/explainers/wind-farm/src/scene/parts/air/shear.ts b/explainers/wind-farm/src/scene/parts/air/shear.ts
new file mode 100644
index 00000000..53d02a02
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/shear.ts
@@ -0,0 +1,18 @@
+import { HUB_HEIGHT_M, SHEAR_EXPONENT, TIP_HEIGHT_M } from '../../../model/constants';
+import { SHEAR_HEIGHTS_M } from '../../../model/layout';
+
+export function shearSpeed(height: number): number {
+ return (height / HUB_HEIGHT_M) ** SHEAR_EXPONENT;
+}
+
+export function shearArrowLength(height: number, longest: number): number {
+ return (longest * shearSpeed(height)) / shearSpeed(TIP_HEIGHT_M);
+}
+
+export function shearArrows(longest: number) {
+ return SHEAR_HEIGHTS_M.map((height) => ({
+ height,
+ length: shearArrowLength(height, longest),
+ rate: shearSpeed(height),
+ }));
+}
diff --git a/explainers/wind-farm/src/scene/parts/air/shearProfile.ts b/explainers/wind-farm/src/scene/parts/air/shearProfile.ts
new file mode 100644
index 00000000..fe45030c
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/shearProfile.ts
@@ -0,0 +1,63 @@
+import { CatmullRomCurve3, CylinderGeometry, Mesh, TubeGeometry, Vector3 } from 'three';
+import type { ShaderMaterial } from 'three';
+import type { SpacingD } from '../../../ids';
+import { shearProfileAt, terrainHeight } from '../../../model/layout';
+import { FINISHES } from '../../finishes';
+import { finishMesh, label, namedGroup, partMesh, registeredMaterial } from '../context';
+import type { PartContext } from '../context';
+import { arrowGeometry } from './arrowGeometry';
+import { advanceArrows, arrowMaterial } from './arrowMaterial';
+import { SHEAR_ARROWS } from './constants';
+import { shearArrows } from './shear';
+
+const MAST_SIDES = 8;
+
+function mast(context: PartContext): Mesh {
+ const { mastTop, mastRadius } = SHEAR_ARROWS;
+ const geometry = new CylinderGeometry(mastRadius, mastRadius, mastTop, MAST_SIDES);
+ geometry.translate(0, mastTop / 2, 0);
+ return finishMesh(context, geometry, 'shearProfile', FINISHES.steel);
+}
+
+function tipCurve(context: PartContext, tips: readonly Vector3[]): Mesh {
+ const { curveSegments, curveRadius, curveSides } = SHEAR_ARROWS;
+ const curve = new CatmullRomCurve3([...tips]);
+ return partMesh(
+ context,
+ new TubeGeometry(curve, curveSegments, curveRadius, curveSides),
+ 'shearProfile',
+ );
+}
+
+export class ShearProfilePart {
+ readonly group = namedGroup('shearProfile');
+ private readonly material: ShaderMaterial;
+
+ constructor(context: PartContext) {
+ const arrows = shearArrows(SHEAR_ARROWS.longest);
+ this.material = registeredMaterial(context, 'shearProfile', arrowMaterial(SHEAR_ARROWS));
+ const specs = arrows.map(({ height, length, rate }) => ({
+ tail: [0, height, 0] as const,
+ length,
+ rate,
+ }));
+ const geometry = arrowGeometry(specs, SHEAR_ARROWS, 'upright');
+ const tips = arrows.map(({ height, length }) => new Vector3(length, height, 0));
+ this.group.add(
+ new Mesh(context.tracker.track(geometry), this.material),
+ mast(context),
+ tipCurve(context, tips),
+ );
+ label(context, 'shearProfile', this.group, SHEAR_ARROWS.labelAt);
+ }
+
+ place(spacing: SpacingD, turn: number): void {
+ const [x, z] = shearProfileAt(spacing);
+ this.group.position.set(x, terrainHeight(x, z), z);
+ this.group.rotation.y = turn;
+ }
+
+ advance(metres: number): void {
+ advanceArrows(this.material, metres);
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/air/streamTube.test.ts b/explainers/wind-farm/src/scene/parts/air/streamTube.test.ts
new file mode 100644
index 00000000..d2f2ba8e
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/streamTube.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from 'vitest';
+import { ROTOR_RADIUS_M } from '../../../model/constants';
+import { TURBINE_GEOMETRY } from '../../../model/layout';
+import { STREAMLINES } from './constants';
+import { StreamlineGeometry } from './streamlineGeometry';
+import { speedRatio, tubeSpread } from './streamTube';
+
+const INDUCTION = 1 / 3;
+const FAR = 1e6;
+const DIGITS = 6;
+const INDUCTIONS = [0, 0.1, 0.25, INDUCTION, 0.9];
+const AXIS_BOUND_M = 1.15 * ROTOR_RADIUS_M;
+const LOWEST_M = 45;
+const LINE_COUNT = 24;
+const [HUB_X, HUB_Y, HUB_Z] = TURBINE_GEOMETRY.hub;
+
+function shapedPoints(induction: number): number[][] {
+ const lines = new StreamlineGeometry();
+ lines.shape(induction);
+ const position = lines.geometry.getAttribute('position');
+ return Array.from({ length: position.count }, (_, index) => [
+ position.getX(index),
+ position.getY(index),
+ position.getZ(index),
+ ]);
+}
+
+function axisDistance([, y, z]: number[]): number {
+ return Math.hypot(y - HUB_Y, z - HUB_Z);
+}
+
+describe('stream tube', () => {
+ it('slows from the free stream to one minus a at the disc and one minus two a behind', () => {
+ expect(speedRatio(INDUCTION, -FAR)).toBeCloseTo(1, DIGITS);
+ expect(speedRatio(INDUCTION, 0)).toBeCloseTo(1 - INDUCTION, DIGITS);
+ expect(speedRatio(INDUCTION, FAR)).toBeCloseTo(1 - 2 * INDUCTION, DIGITS);
+ });
+
+ it('keeps slowing all the way along the wind', () => {
+ const speeds = [-4, -1, 0, 1, 4].map((xi) => speedRatio(INDUCTION, xi));
+ speeds.slice(1).forEach((speed, index) => expect(speed).toBeLessThan(speeds[index]));
+ });
+
+ it('narrows upstream, passes the disc as aimed and widens behind it', () => {
+ expect(tubeSpread(INDUCTION, -FAR)).toBeCloseTo(Math.sqrt(1 - INDUCTION), DIGITS);
+ expect(tubeSpread(INDUCTION, 0)).toBeCloseTo(1, DIGITS);
+ expect(tubeSpread(INDUCTION, FAR)).toBeCloseTo(Math.sqrt(2), DIGITS);
+ });
+
+ it('carries the same air through every section of the tube', () => {
+ [-3, 0, 2, FAR].forEach((xi) =>
+ expect(speedRatio(INDUCTION, xi) * tubeSpread(INDUCTION, xi) ** 2).toBeCloseTo(
+ 1 - INDUCTION,
+ DIGITS,
+ ),
+ );
+ });
+
+ it('leaves the flow straight without induction', () => {
+ [-2, 0, 3].forEach((xi) => expect(tubeSpread(0, xi)).toBeCloseTo(1, DIGITS));
+ });
+});
+
+describe('streamlines', () => {
+ it('draws 24 lines from x -450 to +900', () => {
+ const lines = new StreamlineGeometry();
+ expect(lines.lineCount).toBe(LINE_COUNT);
+ const xs = shapedPoints(INDUCTION).map(([x]) => x);
+ expect(Math.min(...xs)).toBeCloseTo(STREAMLINES.startX, DIGITS);
+ expect(Math.max(...xs)).toBeCloseTo(STREAMLINES.endX, DIGITS);
+ });
+
+ it('keeps every point close to the hub axis and clear of the fields', () => {
+ INDUCTIONS.forEach((induction) =>
+ shapedPoints(induction).forEach((point) => {
+ expect(axisDistance(point)).toBeLessThanOrEqual(AXIS_BOUND_M);
+ expect(point[1]).toBeGreaterThan(LOWEST_M);
+ }),
+ );
+ });
+
+ it('keeps the tube centred at hub height', () => {
+ INDUCTIONS.forEach((induction) => {
+ const heights = shapedPoints(induction).map(([, y]) => y);
+ const mean = heights.reduce((sum, y) => sum + y, 0) / heights.length;
+ expect(mean).toBeCloseTo(HUB_Y, DIGITS);
+ });
+ });
+
+ it('enters upstream inside the rotor radius and crosses the disc inside it', () => {
+ const points = shapedPoints(INDUCTION);
+ const upstream = points.filter(([x]) => x === STREAMLINES.startX);
+ const atDisc = points.filter(([x]) => Math.abs(x - HUB_X) < ROTOR_RADIUS_M / 10);
+ [upstream, atDisc].forEach((section) => {
+ expect(section.length).toBeGreaterThan(0);
+ section.forEach((point) => expect(axisDistance(point)).toBeLessThan(ROTOR_RADIUS_M));
+ });
+ });
+
+ it('widens the tube behind the disc', () => {
+ const points = shapedPoints(INDUCTION);
+ const reach = (x: number) =>
+ Math.max(...points.filter(([px]) => px === x).map((point) => axisDistance(point)));
+ expect(reach(STREAMLINES.endX)).toBeGreaterThan(reach(STREAMLINES.startX));
+ });
+});
diff --git a/explainers/wind-farm/src/scene/parts/air/streamTube.ts b/explainers/wind-farm/src/scene/parts/air/streamTube.ts
new file mode 100644
index 00000000..b6cbd9c0
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/streamTube.ts
@@ -0,0 +1,7 @@
+export function speedRatio(induction: number, xi: number): number {
+ return 1 - induction * (1 + xi / Math.hypot(xi, 1));
+}
+
+export function tubeSpread(induction: number, xi: number): number {
+ return Math.sqrt((1 - induction) / speedRatio(induction, xi));
+}
diff --git a/explainers/wind-farm/src/scene/parts/air/streamlineGeometry.ts b/explainers/wind-farm/src/scene/parts/air/streamlineGeometry.ts
new file mode 100644
index 00000000..dace9026
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/streamlineGeometry.ts
@@ -0,0 +1,112 @@
+import { BufferAttribute, BufferGeometry, Vector3 } from 'three';
+import { clamp } from '@core/math';
+import { ROTOR_RADIUS_M } from '../../../model/constants';
+import { TURBINE_GEOMETRY } from '../../../model/layout';
+import { STREAMLINES } from './constants';
+import { speedRatio, tubeSpread } from './streamTube';
+
+interface Aim {
+ readonly up: number;
+ readonly across: number;
+}
+
+const XYZ = 3;
+const SIDES = [-1, 1] as const;
+const GOLDEN = 0.6180339887;
+const [HUB_X, HUB_Y, HUB_Z] = TURBINE_GEOMETRY.hub;
+const { samples, startX, endX } = STREAMLINES;
+
+function lineAims(): readonly Aim[] {
+ const reach = STREAMLINES.gridReach * ROTOR_RADIUS_M;
+ return STREAMLINES.rows.flatMap((row) =>
+ STREAMLINES.columns.map((column) => ({ up: row * reach, across: column * reach })),
+ );
+}
+
+function vertexIndex(line: number, sample: number, side: number): number {
+ return (line * samples + sample) * SIDES.length + side;
+}
+
+function ribbonIndices(lines: number): number[] {
+ const indices: number[] = [];
+ for (let line = 0; line < lines; line += 1) {
+ for (let sample = 0; sample < samples - 1; sample += 1) {
+ const [a, b] = [vertexIndex(line, sample, 0), vertexIndex(line, sample, 1)];
+ const [c, d] = [vertexIndex(line, sample + 1, 0), vertexIndex(line, sample + 1, 1)];
+ indices.push(a, c, b, b, c, d);
+ }
+ }
+ return indices;
+}
+
+export class StreamlineGeometry {
+ readonly geometry = new BufferGeometry();
+ readonly lineCount: number;
+ private readonly aims = lineAims();
+ private readonly centres: Float32Array;
+ private readonly tangents: Float32Array;
+ private readonly travel: Float32Array;
+ private readonly points = Array.from({ length: samples }, () => new Vector3());
+ private readonly speeds = new Float32Array(samples);
+ private readonly tangent = new Vector3();
+
+ constructor() {
+ this.lineCount = this.aims.length;
+ const vertices = this.lineCount * samples * SIDES.length;
+ this.centres = new Float32Array(vertices * XYZ);
+ this.tangents = new Float32Array(vertices * XYZ);
+ this.travel = new Float32Array(vertices);
+ const sides = new Float32Array(vertices);
+ const shares = new Float32Array(vertices);
+ for (let vertex = 0; vertex < vertices; vertex += 1) {
+ sides[vertex] = SIDES[vertex % SIDES.length];
+ shares[vertex] = (Math.floor(vertex / SIDES.length) % samples) / (samples - 1);
+ }
+ this.geometry.setAttribute('position', new BufferAttribute(this.centres, XYZ));
+ this.geometry.setAttribute('aTangent', new BufferAttribute(this.tangents, XYZ));
+ this.geometry.setAttribute('aTravel', new BufferAttribute(this.travel, 1));
+ this.geometry.setAttribute('aSide', new BufferAttribute(sides, 1));
+ this.geometry.setAttribute('aShare', new BufferAttribute(shares, 1));
+ this.geometry.setIndex(ribbonIndices(this.lineCount));
+ }
+
+ shape(induction: number): void {
+ const share = clamp(induction, 0, STREAMLINES.maxInduction);
+ this.aims.forEach((aim, line) => this.shapeLine(aim, line, share));
+ ['position', 'aTangent', 'aTravel'].forEach((name) => {
+ this.geometry.getAttribute(name).needsUpdate = true;
+ });
+ this.geometry.computeBoundingSphere();
+ }
+
+ private shapeLine(aim: Aim, line: number, induction: number): void {
+ this.points.forEach((point, sample) => {
+ const x = startX + ((endX - startX) * sample) / (samples - 1);
+ const xi = (x - HUB_X) / ROTOR_RADIUS_M;
+ const spread = tubeSpread(induction, xi);
+ point.set(x, HUB_Y + aim.up * spread, HUB_Z + aim.across * spread);
+ this.speeds[sample] = speedRatio(induction, xi);
+ });
+ let travel = (line * GOLDEN * STREAMLINES.dashPeriod) % STREAMLINES.dashPeriod;
+ this.points.forEach((point, sample) => {
+ if (sample > 0) {
+ const previous = this.points[sample - 1];
+ const speed = (this.speeds[sample] + this.speeds[sample - 1]) / 2;
+ travel += point.distanceTo(previous) / speed;
+ }
+ this.writeSample(line, sample, point, travel);
+ });
+ }
+
+ private writeSample(line: number, sample: number, point: Vector3, travel: number): void {
+ const before = this.points[Math.max(sample - 1, 0)];
+ const after = this.points[Math.min(sample + 1, samples - 1)];
+ this.tangent.subVectors(after, before).normalize();
+ SIDES.forEach((_, side) => {
+ const vertex = vertexIndex(line, sample, side);
+ point.toArray(this.centres, vertex * XYZ);
+ this.tangent.toArray(this.tangents, vertex * XYZ);
+ this.travel[vertex] = travel;
+ });
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/air/streamlines.ts b/explainers/wind-farm/src/scene/parts/air/streamlines.ts
new file mode 100644
index 00000000..2446c1af
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/streamlines.ts
@@ -0,0 +1,38 @@
+import { Mesh } from 'three';
+import type { AssemblyState } from '../../../ids';
+import { bearingTurn, label, namedGroup, registeredMaterial } from '../context';
+import type { Motion, PartContext, Section } from '../context';
+import { STREAMLINES } from './constants';
+import { flowMaterial } from './flowMaterial';
+import { StreamlineGeometry } from './streamlineGeometry';
+import { quantise } from './bearing';
+
+export function streamlinesSection(context: PartContext): Section {
+ const root = namedGroup('streamlinesGroup');
+ const lines = new StreamlineGeometry();
+ const material = registeredMaterial(context, 'streamlinesGroup', flowMaterial());
+ const mesh = new Mesh(context.tracker.track(lines.geometry), material);
+ mesh.name = 'streamlines';
+ root.add(mesh);
+ label(context, 'streamlinesGroup', root, STREAMLINES.labelAt);
+ let induction = Number.NaN;
+ const clock = material.uniforms.uClock;
+
+ return {
+ root,
+ setState(state: AssemblyState) {
+ root.visible = state.view.streamlines;
+ root.rotation.y = bearingTurn(state.wind.fromDeg);
+ const target = quantise(state.wind.induction, STREAMLINES.inductionStep);
+ if (target === induction) return;
+ induction = target;
+ lines.shape(induction);
+ },
+ animate(motion: Motion, state: AssemblyState) {
+ if (!state.view.streamlines) return false;
+ const step = motion.delta * state.wind.speed * STREAMLINES.playback;
+ clock.value = ((clock.value as number) + step) % STREAMLINES.dashPeriod;
+ return true;
+ },
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/air/windArrows.ts b/explainers/wind-farm/src/scene/parts/air/windArrows.ts
new file mode 100644
index 00000000..c56390ff
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/air/windArrows.ts
@@ -0,0 +1,50 @@
+import { Mesh } from 'three';
+import type { ShaderMaterial } from 'three';
+import type { SpacingD } from '../../../ids';
+import { HUB_HEIGHT_M } from '../../../model/constants';
+import { terrainHeight, windArrowsX } from '../../../model/layout';
+import { label, namedGroup, registeredMaterial } from '../context';
+import type { PartContext } from '../context';
+import { arrowGeometry } from './arrowGeometry';
+import { advanceArrows, arrowMaterial } from './arrowMaterial';
+import { WIND_ARROWS } from './constants';
+
+const { count, spanZ, length } = WIND_ARROWS;
+
+function arrowZ(index: number): number {
+ return -spanZ / 2 + (spanZ * index) / (count - 1);
+}
+
+export class WindArrowsPart {
+ readonly group = namedGroup('windArrows');
+ private readonly arrows: readonly Mesh[];
+ private readonly material: ShaderMaterial;
+
+ constructor(context: PartContext) {
+ this.material = registeredMaterial(context, 'windArrows', arrowMaterial(WIND_ARROWS));
+ const tail = [-length / 2, 0, 0] as const;
+ const geometry = context.tracker.track(
+ arrowGeometry([{ tail, length, rate: 1 }], WIND_ARROWS, 'ground'),
+ );
+ this.arrows = Array.from({ length: count }, (_, index) => {
+ const arrow = new Mesh(geometry, this.material);
+ arrow.position.z = arrowZ(index);
+ this.group.add(arrow);
+ return arrow;
+ });
+ label(context, 'windArrows', this.group, [0, HUB_HEIGHT_M + WIND_ARROWS.labelLift, 0]);
+ }
+
+ place(spacing: SpacingD, turn: number): void {
+ const x = windArrowsX(spacing);
+ this.group.position.x = x;
+ this.arrows.forEach((arrow) => {
+ arrow.position.y = terrainHeight(x, arrow.position.z) + HUB_HEIGHT_M;
+ arrow.rotation.y = turn;
+ });
+ }
+
+ advance(metres: number): void {
+ advanceArrows(this.material, metres);
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/context.ts b/explainers/wind-farm/src/scene/parts/context.ts
new file mode 100644
index 00000000..fd54aaba
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/context.ts
@@ -0,0 +1,129 @@
+import { Group, InstancedMesh, Mesh } from 'three';
+import type { BufferGeometry, Material, Object3D } from 'three';
+import type {
+ MaterialFinish,
+ MaterialLibrary,
+ STRUCTURE_GROUP,
+ UNDIMMED_GROUP,
+} from '@core/scene/materials';
+import { anchorAt } from '@core/scene/parts';
+import type { ResourceTracker } from '@core/scene/resources';
+import type { SceneTextures } from '@core/scene/textures';
+import type { AnchorId, AssemblyState, PartId, Point } from '../../ids';
+import { FINISHES, PART_FINISHES } from '../finishes';
+import type { Finish } from '../finishes';
+
+export type EmphasisGroup = PartId | typeof STRUCTURE_GROUP | typeof UNDIMMED_GROUP;
+
+export interface PartContext {
+ readonly materials: MaterialLibrary;
+ readonly tracker: ResourceTracker;
+ readonly textures: SceneTextures;
+ readonly labels: Map;
+ readonly anchors: Partial>;
+}
+
+export interface Motion {
+ readonly delta: number;
+ readonly azimuth: number;
+ readonly pitchDeg: number;
+ readonly rpm: number;
+ readonly cameraDistance: number;
+}
+
+export interface Section {
+ readonly root: Object3D;
+ setState(state: AssemblyState): void;
+ animate?(motion: Motion, state: AssemblyState): boolean;
+}
+
+const DEGREES_TO_RADIANS = Math.PI / 180;
+const FACING_WEST_DEG = 270;
+
+export function degrees(value: number): number {
+ return value * DEGREES_TO_RADIANS;
+}
+
+export function bearingTurn(bearingDeg: number): number {
+ return (FACING_WEST_DEG - bearingDeg) * DEGREES_TO_RADIANS;
+}
+
+export function namedGroup(name: string, parent?: Object3D): Group {
+ const group = new Group();
+ group.name = name;
+ parent?.add(group);
+ return group;
+}
+
+export function label(context: PartContext, part: PartId, parent: Object3D, point: Point): void {
+ context.labels.set(part, anchorAt(parent, ...point));
+}
+
+export function sceneAnchor(
+ context: PartContext,
+ id: AnchorId,
+ parent: Object3D,
+ point: Point,
+): Object3D {
+ const anchor = anchorAt(parent, ...point);
+ context.anchors[id] = anchor;
+ return anchor;
+}
+
+export function finishMesh(
+ context: PartContext,
+ geometry: BufferGeometry,
+ group: EmphasisGroup,
+ finish: MaterialFinish,
+): Mesh {
+ return new Mesh(context.tracker.track(geometry), context.materials.get(group, finish));
+}
+
+export function partMesh(
+ context: PartContext,
+ geometry: BufferGeometry,
+ part: PartId,
+ finish: Finish = PART_FINISHES[part],
+): Mesh {
+ const mesh = finishMesh(context, geometry, part, FINISHES[finish]);
+ mesh.name = part;
+ return mesh;
+}
+
+export function groupMesh(
+ context: PartContext,
+ geometry: BufferGeometry,
+ group: EmphasisGroup,
+ finish: Finish,
+): Mesh {
+ return finishMesh(context, geometry, group, FINISHES[finish]);
+}
+
+export function registeredMaterial(
+ context: PartContext,
+ group: EmphasisGroup,
+ material: M,
+): M {
+ context.materials.register(group, context.tracker.track(material));
+ return material;
+}
+
+export function registeredMesh(
+ context: PartContext,
+ geometry: BufferGeometry,
+ group: EmphasisGroup,
+ material: Material,
+): Mesh {
+ return new Mesh(context.tracker.track(geometry), registeredMaterial(context, group, material));
+}
+
+export function instancedMesh(
+ context: PartContext,
+ geometry: BufferGeometry,
+ group: EmphasisGroup,
+ finish: Finish,
+ count: number,
+): InstancedMesh {
+ const material = context.materials.get(group, FINISHES[finish]);
+ return context.tracker.track(new InstancedMesh(context.tracker.track(geometry), material, count));
+}
diff --git a/explainers/wind-farm/src/scene/parts/ease.ts b/explainers/wind-farm/src/scene/parts/ease.ts
new file mode 100644
index 00000000..0ce70f1f
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/ease.ts
@@ -0,0 +1,36 @@
+import { EASE_SECONDS } from '../constants';
+
+const SETTLE_TIME_CONSTANTS = 5;
+const TIME_CONSTANT = EASE_SECONDS / SETTLE_TIME_CONSTANTS;
+
+export class Ease {
+ private target: number;
+ private current: number;
+ private remaining = 0;
+
+ constructor(value: number) {
+ this.target = value;
+ this.current = value;
+ }
+
+ get value(): number {
+ return this.current;
+ }
+
+ get running(): boolean {
+ return this.remaining > 0;
+ }
+
+ aim(target: number): void {
+ if (target === this.target) return;
+ this.target = target;
+ this.remaining = EASE_SECONDS;
+ }
+
+ step(deltaSeconds: number): void {
+ if (!this.running) return;
+ this.remaining = Math.max(0, this.remaining - deltaSeconds);
+ const share = 1 - Math.exp(-deltaSeconds / TIME_CONSTANT);
+ this.current = this.running ? this.current + (this.target - this.current) * share : this.target;
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/blade.ts b/explainers/wind-farm/src/scene/parts/farm/blade.ts
new file mode 100644
index 00000000..d57c7eeb
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/blade.ts
@@ -0,0 +1,108 @@
+import { BufferAttribute, BufferGeometry, Matrix3, Matrix4, Sphere, Vector3 } from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { FULL_TURN, lerp } from '@core/math';
+import { BLADE_LENGTH_M, ROTOR_RADIUS_M, TURBINE_GEOMETRY } from '../../../model';
+import { degrees } from '../context';
+import { BLADE_WIDEN } from './turbineConstants';
+
+interface Station {
+ readonly radius: number;
+ readonly chord: number;
+ readonly thickness: number;
+ readonly twistDeg: number;
+}
+
+const ROOT = TURBINE_GEOMETRY.bladeRootRadius;
+const STATIONS: readonly Station[] = [
+ { radius: ROOT, chord: 2.6, thickness: 1, twistDeg: 13 },
+ { radius: ROOT + 0.06 * BLADE_LENGTH_M, chord: 3.6, thickness: 0.6, twistDeg: 13 },
+ { radius: ROOT + 0.17 * BLADE_LENGTH_M, chord: 4.2, thickness: 0.34, twistDeg: 10 },
+ { radius: ROOT + 0.36 * BLADE_LENGTH_M, chord: 3.4, thickness: 0.24, twistDeg: 6 },
+ { radius: ROOT + 0.59 * BLADE_LENGTH_M, chord: 2.5, thickness: 0.2, twistDeg: 3 },
+ { radius: ROOT + 0.82 * BLADE_LENGTH_M, chord: 1.7, thickness: 0.18, twistDeg: 1 },
+ { radius: ROOT + 0.96 * BLADE_LENGTH_M, chord: 1, thickness: 0.18, twistDeg: 0 },
+ { radius: ROTOR_RADIUS_M, chord: 0.35, thickness: 0.18, twistDeg: 0 },
+];
+const LEADING_SHARE = 0.25;
+const OUTLINE: readonly (readonly [along: number, half: number])[] = [
+ [0, 0],
+ [0.08, 0.36],
+ [0.3, 0.5],
+ [0.65, 0.3],
+ [1, 0.02],
+ [0.65, -0.3],
+ [0.3, -0.5],
+ [0.08, -0.36],
+];
+const BLADE_COUNT = 3;
+const XYZ = 3;
+const BOUNDS_MARGIN_M = 6;
+
+function ring({ radius, chord, thickness, twistDeg }: Station): number[] {
+ const twist = degrees(twistDeg);
+ const cos = Math.cos(twist);
+ const sin = Math.sin(twist);
+ return OUTLINE.flatMap(([along, half]) => {
+ const chordwise = (LEADING_SHARE - along) * chord;
+ const across = half * thickness * chord;
+ return [across * cos - chordwise * sin, radius, across * sin + chordwise * cos];
+ });
+}
+
+function lateralWeight(radius: number): number {
+ return lerp(BLADE_WIDEN.rootWeight, BLADE_WIDEN.tipWeight, (radius - ROOT) / BLADE_LENGTH_M);
+}
+
+function ringLaterals(station: Station): number[] {
+ const points = ring(station);
+ const weight = lateralWeight(station.radius);
+ const side = new Vector3();
+ return OUTLINE.flatMap((_, index) => {
+ side.set(points[index * XYZ], 0, points[index * XYZ + 2]).normalize();
+ return side.multiplyScalar(weight).toArray();
+ });
+}
+
+function turnedLaterals(turn: Matrix4): BufferAttribute {
+ const laterals = new BufferAttribute(new Float32Array(STATIONS.flatMap(ringLaterals)), XYZ);
+ return laterals.applyMatrix3(new Matrix3().setFromMatrix4(turn));
+}
+
+function bladeIndices(): number[] {
+ const sides = OUTLINE.length;
+ return STATIONS.slice(1).flatMap((_, span) =>
+ OUTLINE.flatMap((__, side) => {
+ const next = (side + 1) % sides;
+ const a = span * sides + side;
+ const b = span * sides + next;
+ const c = a + sides;
+ const d = b + sides;
+ return [a, b, c, b, d, c];
+ }),
+ );
+}
+
+function bladeGeometry(): BufferGeometry {
+ const geometry = new BufferGeometry();
+ geometry.setAttribute(
+ 'position',
+ new BufferAttribute(new Float32Array(STATIONS.flatMap(ring)), XYZ),
+ );
+ geometry.setIndex(bladeIndices());
+ geometry.computeVertexNormals();
+ return geometry;
+}
+
+export function rotorGeometry(): BufferGeometry {
+ const blade = bladeGeometry();
+ const rotor = mergeGeometries(
+ Array.from({ length: BLADE_COUNT }, (_, index) => {
+ const turn = new Matrix4().makeRotationX((index * FULL_TURN) / BLADE_COUNT);
+ return blade.clone().applyMatrix4(turn).setAttribute('lateral', turnedLaterals(turn));
+ }),
+ );
+ blade.dispose();
+ if (!rotor) throw new Error('Farm rotor blades do not share attributes');
+ rotor.boundingSphere = new Sphere(new Vector3(), ROTOR_RADIUS_M + BOUNDS_MARGIN_M);
+ return rotor;
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/cables.ts b/explainers/wind-farm/src/scene/parts/farm/cables.ts
new file mode 100644
index 00000000..f05c17c9
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/cables.ts
@@ -0,0 +1,54 @@
+import type { DataTexture } from 'three';
+import type { AssemblyState, SpacingD } from '../../../ids';
+import type { Motion, PartContext } from '../context';
+import { CABLE, CABLE_FINISH } from './constants';
+import { midpoint, onGround } from './ground';
+import { ribbons, SpacingLayer } from './layer';
+import type { LayerShape } from './layer';
+import { farmRoutes, feederRoutes } from './routes';
+import { dashTexture } from './textures';
+import { Widening } from './widening';
+
+const LABEL_ROW = 1;
+const PART = 'collectorCables';
+
+function cableShape(spacing: SpacingD): LayerShape {
+ const routes = farmRoutes(spacing);
+ const options = { width: CABLE.width, lift: CABLE.lift, period: CABLE.dashPeriod };
+ const geometry = ribbons([
+ { routes, options },
+ { routes: feederRoutes(spacing), options },
+ ]);
+ const row = routes[LABEL_ROW];
+ const leg = midpoint(row[row.length - 2], row[row.length - 1]);
+ return { geometry, label: onGround(leg, CABLE.lift) };
+}
+
+export class CollectorCables {
+ readonly layer: SpacingLayer;
+ private readonly dashes: DataTexture;
+
+ constructor(context: PartContext) {
+ this.dashes = context.tracker.track(dashTexture());
+ const widening = new Widening(
+ context,
+ PART,
+ { ...CABLE_FINISH, emissiveMap: this.dashes },
+ { halfWidth: CABLE.width / 2, perMetre: CABLE.widenPerMetre },
+ );
+ this.layer = new SpacingLayer(context, PART, widening, cableShape);
+ }
+
+ setState(state: AssemblyState): void {
+ this.layer.group.visible = state.view.cables;
+ }
+
+ animate(motion: Motion, state: AssemblyState): boolean {
+ if (!this.layer.group.visible) return false;
+ const share = state.farm.outputShare;
+ if (share <= 0) return false;
+ const shift = (motion.delta * CABLE.flowSpeed * share) / CABLE.dashPeriod;
+ this.dashes.offset.x = (this.dashes.offset.x - shift) % 1;
+ return true;
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/conductors.ts b/explainers/wind-farm/src/scene/parts/farm/conductors.ts
new file mode 100644
index 00000000..88a9c0e1
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/conductors.ts
@@ -0,0 +1,48 @@
+import { Curve, TubeGeometry, Vector3 } from 'three';
+import type { BufferGeometry } from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import type { Point } from '../../../ids';
+import { CONDUCTOR } from './gridConstants';
+
+export type Span = readonly [from: Point, to: Point];
+
+export function sagPoint(from: Point, to: Point, share: number): Point {
+ const sag = 4 * CONDUCTOR.sagM * share * (1 - share);
+ return [
+ from[0] + (to[0] - from[0]) * share,
+ from[1] + (to[1] - from[1]) * share - sag,
+ from[2] + (to[2] - from[2]) * share,
+ ];
+}
+
+class SagCurve extends Curve {
+ private readonly span: Span;
+
+ constructor(span: Span) {
+ super();
+ this.span = span;
+ }
+
+ override getPoint(share: number, target = new Vector3()): Vector3 {
+ return target.set(...sagPoint(...this.span, share));
+ }
+}
+
+function conductor(span: Span): BufferGeometry {
+ const tube = new TubeGeometry(
+ new SagCurve(span),
+ CONDUCTOR.samples,
+ CONDUCTOR.radius,
+ CONDUCTOR.sides,
+ );
+ tube.setAttribute('lateral', tube.getAttribute('normal').clone());
+ return tube;
+}
+
+export function conductorGeometry(spans: readonly Span[]): BufferGeometry {
+ const tubes = spans.map(conductor);
+ const merged = mergeGeometries(tubes);
+ tubes.forEach((tube) => tube.dispose());
+ if (!merged) throw new Error('Grid conductors do not share attributes');
+ return merged;
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/constants.ts b/explainers/wind-farm/src/scene/parts/farm/constants.ts
new file mode 100644
index 00000000..03e0ba48
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/constants.ts
@@ -0,0 +1,67 @@
+import type { MaterialFinish } from '@core/scene/materials';
+import { THEME } from '../../../theme';
+import { GROUND_LIFT_M } from '../../constants';
+import { FINISHES } from '../../finishes';
+
+const ROAD_OFFSET = { polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -4 };
+const CABLE_OFFSET = { polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -8 };
+const MARKER_OFFSET = { polygonOffset: true, polygonOffsetFactor: -3, polygonOffsetUnits: -12 };
+
+export const ROUTE = { offset: 30, towerClearance: 3 } as const;
+
+export const ROAD = {
+ width: 10,
+ lift: GROUND_LIFT_M,
+ padBehind: 16,
+ padWidth: 36,
+ period: 1,
+ widenPerMetre: 0.0006,
+} as const;
+
+export const ROAD_FINISH: MaterialFinish = { ...FINISHES.gravel, ...ROAD_OFFSET };
+
+export const CABLE = {
+ width: 4,
+ lift: GROUND_LIFT_M + 0.4,
+ dashPeriod: 70,
+ flowSpeed: 160,
+ widenPerMetre: 0.0011,
+ glow: 1.6,
+} as const;
+
+export const CABLE_FINISH: MaterialFinish = {
+ color: THEME.cable,
+ emissive: THEME.cable,
+ emissiveIntensity: CABLE.glow,
+ roughness: 0.9,
+ metalness: 0,
+ ...CABLE_OFFSET,
+};
+
+export const MARKER = {
+ width: 6,
+ lift: GROUND_LIFT_M + 0.8,
+ tickLength: 90,
+ labelLift: 30,
+ widenPerMetre: 0.0011,
+} as const;
+
+export const MARKER_FINISH: MaterialFinish = { ...FINISHES.spacing, ...MARKER_OFFSET };
+
+export const PLUME = {
+ radialSegments: 32,
+ lengthSegments: 14,
+ lengthStepD: 0.25,
+ fadeInD: 0.6,
+ tailPower: 2,
+ rimPower: 3,
+ groundFade: 70,
+ minOpacity: 0.5,
+ maxOpacity: 0.9,
+ fullStrength: 0.6,
+ opacityStep: 0.05,
+ streakPeriod: 900,
+ streakBands: 2,
+ streakTimeScale: 9,
+ glow: 0.45,
+} as const;
diff --git a/explainers/wind-farm/src/scene/parts/farm/discs.ts b/explainers/wind-farm/src/scene/parts/farm/discs.ts
new file mode 100644
index 00000000..d84e5b07
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/discs.ts
@@ -0,0 +1,74 @@
+import { CircleGeometry, InstancedMesh } from 'three';
+import type { DataTexture, Matrix4, MeshStandardMaterial } from 'three';
+import { clamp } from '@core/math';
+import { MAX_RPM, ROTOR_RADIUS_M, TURBINE_COUNT } from '../../../model';
+import type { PartContext } from '../context';
+import { tiltTowardEye } from './rotors';
+import { DISC, DISC_FINISH } from './turbineConstants';
+import { discTexture } from './textures';
+
+const PART = 'farmTurbines';
+const NAME = 'turbineDiscs';
+const QUARTER_TURN = Math.PI / 2;
+const CACHE_KEY = 'farmRotorDisc';
+const EDGE_FRAGMENT = `#include
+diffuseColor.a /= max(abs(dot(normal, normalize(vViewPosition))), ${(1 / DISC.edgeBoost).toFixed(3)});`;
+
+export function discOpacity(rpm: number): number {
+ if (rpm <= 0) return 0;
+ const opacity = DISC.maxOpacity * clamp(rpm / MAX_RPM, 0, 1);
+ return Math.max(DISC.opacityStep, Math.round(opacity / DISC.opacityStep) * DISC.opacityStep);
+}
+
+function discGeometry(): CircleGeometry {
+ const geometry = new CircleGeometry(ROTOR_RADIUS_M, DISC.segments);
+ return geometry.rotateY(QUARTER_TURN);
+}
+
+export class RotorDiscs {
+ readonly mesh: InstancedMesh;
+ private readonly context: PartContext;
+ private readonly blur: DataTexture;
+ private readonly materials = new Map();
+
+ constructor(context: PartContext) {
+ this.context = context;
+ this.blur = context.tracker.track(discTexture());
+ this.mesh = context.tracker.track(
+ new InstancedMesh(
+ context.tracker.track(discGeometry()),
+ this.materialFor(DISC.opacityStep),
+ TURBINE_COUNT,
+ ),
+ );
+ this.mesh.name = NAME;
+ this.mesh.visible = false;
+ }
+
+ setMatrixAt(index: number, rotor: Matrix4): void {
+ this.mesh.setMatrixAt(index, rotor);
+ }
+
+ spin(rpm: number): void {
+ const opacity = discOpacity(rpm);
+ this.mesh.visible = opacity > 0;
+ if (this.mesh.visible) this.mesh.material = this.materialFor(opacity);
+ }
+
+ private materialFor(opacity: number): MeshStandardMaterial {
+ const key = Math.round(opacity / DISC.opacityStep);
+ let material = this.materials.get(key);
+ if (!material) {
+ material = this.context.materials.get(PART, { ...DISC_FINISH, opacity, alphaMap: this.blur });
+ material.onBeforeCompile = (shader) => {
+ shader.fragmentShader = shader.fragmentShader.replace(
+ '#include ',
+ EDGE_FRAGMENT,
+ );
+ };
+ tiltTowardEye(material, CACHE_KEY);
+ this.materials.set(key, material);
+ }
+ return material;
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/farm.test.ts b/explainers/wind-farm/src/scene/parts/farm/farm.test.ts
new file mode 100644
index 00000000..cafdfd60
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/farm.test.ts
@@ -0,0 +1,220 @@
+import { InstancedMesh, LineSegments, Matrix4, Mesh, Vector3 } from 'three';
+import type { BufferGeometry, Material, Object3D } from 'three';
+import { describe, expect, it } from 'vitest';
+import { MaterialLibrary } from '@core/scene/materials';
+import { ResourceTracker } from '@core/scene/resources';
+import type { SceneTextures } from '@core/scene/textures';
+import type { AssemblyState, OperatingStateId, SpacingD } from '../../../ids';
+import {
+ MAX_RPM,
+ ROTOR_RADIUS_M,
+ SUBSTATION,
+ TURBINE_COUNT,
+ TURBINE_GEOMETRY,
+ WAKE_DECAY,
+ farmLayout,
+} from '../../../model';
+import type { PartContext } from '../context';
+import { PLUME } from './constants';
+import { discOpacity } from './discs';
+import { rotorMatrix } from './fleet';
+import { ribbonGeometry } from './ground';
+import { buildFarm } from './index';
+import { plumeAlpha, plumeRadius } from './plumeGeometry';
+import { plumeOpacity, plumesShown } from './plumes';
+import { farmRoutes } from './routes';
+import { SHADOW_HEADING } from './shadows';
+import { shadowLevel } from './textures';
+
+const SPACING: SpacingD = 7;
+const QUARTER_TURN = Math.PI / 2;
+const TRIANGLE_BUDGET = 150_000;
+const DRAW_CALL_BUDGET = 25;
+const FLEET_PIECES = ['tower', 'nacelle', 'hub', 'rotor'] as const;
+
+function withRotor(state: AssemblyState, rotorState: OperatingStateId): AssemblyState {
+ return { ...state, rotor: { ...state.rotor, state: rotorState } };
+}
+
+function withWakes(state: AssemblyState, wakes: boolean): AssemblyState {
+ return { ...state, view: { ...state.view, wakes } };
+}
+
+function farmState(spacing: SpacingD): AssemblyState {
+ return {
+ scene: 'farm',
+ phase: 0,
+ playing: false,
+ wind: { speed: 9, fromDeg: 270, induction: 0.25 },
+ rotor: { rpm: 8, pitchDeg: 0, yawDeg: 270, state: 'partial', braked: false, powerShare: 0.5 },
+ farm: {
+ spacing,
+ sites: farmLayout(spacing),
+ deficits: Array.from({ length: TURBINE_COUNT }, () => 0.2),
+ plumeLengthD: 8,
+ plumeStrength: 0.3,
+ outputShare: 0.5,
+ },
+ view: { streamlines: false, wakes: true, cables: true, labels: true, cutaway: false },
+ };
+}
+
+function farmContext(): PartContext {
+ return {
+ materials: new MaterialLibrary(),
+ tracker: new ResourceTracker(),
+ textures: {} as SceneTextures,
+ labels: new Map(),
+ anchors: {},
+ };
+}
+
+function triangles(geometry: BufferGeometry): number {
+ const count = geometry.index?.count ?? geometry.getAttribute('position').count;
+ return count / 3;
+}
+
+function drawCalls(root: Object3D): number {
+ let total = 0;
+ root.traverse((object) => {
+ if (object instanceof Mesh || object instanceof LineSegments) total += 1;
+ });
+ return total;
+}
+
+function drawnTriangles(root: Object3D): number {
+ let total = 0;
+ root.traverse((object) => {
+ if (!(object instanceof Mesh)) return;
+ const copies = object instanceof InstancedMesh ? object.count : 1;
+ total += triangles(object.geometry) * copies;
+ });
+ return total;
+}
+
+describe('farm diorama', () => {
+ it('turns a rotor clockwise seen from upwind, top tip toward +z', () => {
+ const tip = new Vector3(0, ROTOR_RADIUS_M, 0).applyMatrix4(
+ rotorMatrix(new Matrix4(), QUARTER_TURN),
+ );
+ const [hubX, hubY] = TURBINE_GEOMETRY.hub;
+ expect(tip.x).toBeCloseTo(hubX);
+ expect(tip.y).toBeCloseTo(hubY);
+ expect(tip.z).toBeCloseTo(ROTOR_RADIUS_M);
+ });
+
+ it('expands a plume by the wake decay and fades it at both ends', () => {
+ expect(plumeRadius(0)).toBe(ROTOR_RADIUS_M);
+ expect(plumeRadius(1000)).toBeCloseTo(ROTOR_RADIUS_M + WAKE_DECAY * 1000);
+ expect(plumeAlpha(0, 1200)).toBe(0);
+ expect(plumeAlpha(1200, 1200)).toBe(0);
+ expect(plumeAlpha(300, 1200)).toBeGreaterThan(0.3);
+ expect(plumeAlpha(900, 1200)).toBeLessThan(plumeAlpha(300, 1200));
+ });
+
+ it('thickens rotor blur discs with rpm and hides them at a standstill', () => {
+ expect(discOpacity(0)).toBe(0);
+ expect(discOpacity(MAX_RPM / 2)).toBeLessThan(discOpacity(MAX_RPM));
+ expect(discOpacity(MAX_RPM)).toBeGreaterThan(0.25);
+ });
+
+ it('turns the rotor blur discs with the blades', () => {
+ const context = farmContext();
+ const section = buildFarm(context);
+ const state = farmState(SPACING);
+ section.setState(state);
+ section.animate?.(
+ { delta: 0, azimuth: QUARTER_TURN, pitchDeg: 0, rpm: MAX_RPM, cameraDistance: 0 },
+ state,
+ );
+ const blades = section.root.getObjectByName('farmTurbines.rotor') as InstancedMesh;
+ const discs = section.root.getObjectByName('turbineDiscs') as InstancedMesh;
+ const blade = new Matrix4();
+ const disc = new Matrix4();
+ blades.getMatrixAt(0, blade);
+ discs.getMatrixAt(0, disc);
+ expect(disc.equals(blade)).toBe(true);
+ expect(discs.visible).toBe(true);
+ context.tracker.dispose();
+ });
+
+ it('sets the plume opacity from the plume strength', () => {
+ expect(plumeOpacity(PLUME.fullStrength)).toBeCloseTo(PLUME.maxOpacity);
+ expect(plumeOpacity(0.2)).toBeLessThan(plumeOpacity(PLUME.fullStrength));
+ const context = farmContext();
+ const section = buildFarm(context);
+ const state = farmState(SPACING);
+ section.setState(state);
+ const plumes = section.root.getObjectByName('wakePlumes')?.children[0] as InstancedMesh;
+ expect((plumes.material as Material).opacity).toBeCloseTo(
+ plumeOpacity(state.farm.plumeStrength),
+ );
+ context.tracker.dispose();
+ });
+
+ it('draws the wake plumes only with the wakes flag on and the rotors turning', () => {
+ const state = farmState(SPACING);
+ expect(plumesShown(state)).toBe(true);
+ expect(plumesShown(withWakes(state, false))).toBe(false);
+ expect(plumesShown(withRotor(state, 'parked'))).toBe(false);
+ expect(plumesShown(withRotor(state, 'idle'))).toBe(false);
+ const context = farmContext();
+ const section = buildFarm(context);
+ const plumes = section.root.getObjectByName('wakePlumes');
+ section.setState(withWakes(state, false));
+ expect(plumes?.visible).toBe(false);
+ section.setState(state);
+ expect(plumes?.visible).toBe(true);
+ context.tracker.dispose();
+ });
+
+ it('darkens the contact shadow beside the tower base and fades it toward the edges', () => {
+ expect(shadowLevel(0.2, 0.5)).toBeGreaterThan(shadowLevel(0.8, 0.5));
+ expect(shadowLevel(0.2, 0)).toBe(0);
+ expect(shadowLevel(0, 0.5)).toBe(0);
+ });
+
+ it('casts the turbine contact shadows toward the north-east', () => {
+ expect(SHADOW_HEADING.x).toBeGreaterThan(0);
+ expect(SHADOW_HEADING.y).toBeLessThan(0);
+ });
+
+ it('faces ground ribbons up whichever way they run', () => {
+ const geometry = ribbonGeometry(
+ [
+ [0, 0],
+ [0, 500],
+ [-400, 900],
+ ],
+ { width: 10, lift: 2, period: 50 },
+ );
+ const normals = geometry.getAttribute('normal');
+ for (let index = 0; index < normals.count; index += 1)
+ expect(normals.getY(index)).toBeGreaterThan(0.9);
+ });
+
+ it('runs each collector route to the substation fence', () => {
+ farmRoutes(SPACING).forEach((route) => {
+ const [x, z] = route[route.length - 1];
+ const onSide = Math.abs(Math.abs(x - SUBSTATION.x) - SUBSTATION.width / 2) < 1e-6;
+ const onEnd = Math.abs(Math.abs(z - SUBSTATION.z) - SUBSTATION.depth / 2) < 1e-6;
+ expect(onSide || onEnd).toBe(true);
+ });
+ });
+
+ it('draws 27 instanced turbines within the triangle and draw call budgets', () => {
+ const context = farmContext();
+ const section = buildFarm(context);
+ section.setState(farmState(SPACING));
+ FLEET_PIECES.forEach((piece) => {
+ const mesh = section.root.getObjectByName(`farmTurbines.${piece}`);
+ expect(mesh).toBeInstanceOf(InstancedMesh);
+ expect((mesh as InstancedMesh).count).toBe(TURBINE_COUNT);
+ });
+ const discs = section.root.getObjectByName('turbineDiscs');
+ expect((discs as InstancedMesh).count).toBe(TURBINE_COUNT);
+ expect(drawnTriangles(section.root)).toBeLessThan(TRIANGLE_BUDGET);
+ expect(drawCalls(section.root)).toBeLessThan(DRAW_CALL_BUDGET);
+ context.tracker.dispose();
+ });
+});
diff --git a/explainers/wind-farm/src/scene/parts/farm/fleet.ts b/explainers/wind-farm/src/scene/parts/farm/fleet.ts
new file mode 100644
index 00000000..e40657e9
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/fleet.ts
@@ -0,0 +1,149 @@
+import { Color, InstancedMesh, Matrix4, Quaternion, Vector3 } from 'three';
+import type { BufferGeometry, Group, Object3D } from 'three';
+import { anchorAt } from '@core/scene/parts';
+import { clamp } from '@core/math';
+import type { FarmSite } from '../../../ids';
+import { TIP_HEIGHT_M, TURBINE_COUNT, TURBINE_GEOMETRY, terrainHeight } from '../../../model';
+import { LABELLED_TURBINE } from '../../constants';
+import { namedGroup } from '../context';
+import type { Motion, PartContext } from '../context';
+import { rotorGeometry } from './blade';
+import {
+ BLADE_WIDEN,
+ DEFICIT_SHADE,
+ NACELLE_FINISH,
+ TURBINE_FINISH,
+ TURBINE_WIDEN,
+} from './turbineConstants';
+import { RotorDiscs } from './discs';
+import { tiltTowardEye } from './rotors';
+import { TurbineShadows } from './shadows';
+import { nacelleGeometry, spinnerGeometry, towerGeometry } from './turbine';
+import { Widening } from './widening';
+
+const PART = 'farmTurbines';
+const BLADES_CACHE_KEY = 'farmRotorBlades';
+const X_AXIS = new Vector3(1, 0, 0);
+const Y_AXIS = new Vector3(0, 1, 0);
+const UNIT = new Vector3(1, 1, 1);
+const HUB = new Vector3(...TURBINE_GEOMETRY.hub);
+
+function named(mesh: InstancedMesh, piece: string, parent: Group): InstancedMesh {
+ mesh.name = `${PART}.${piece}`;
+ parent.add(mesh);
+ return mesh;
+}
+
+export function sitePosition(site: FarmSite, target = new Vector3()): Vector3 {
+ return target.set(site.x, terrainHeight(site.x, site.z), site.z);
+}
+
+const spinTurn = new Quaternion();
+const hubFrame = new Matrix4();
+
+export function rotorMatrix(base: Matrix4, azimuth: number, target = new Matrix4()): Matrix4 {
+ spinTurn.setFromAxisAngle(X_AXIS, azimuth);
+ return target.multiplyMatrices(base, hubFrame.compose(HUB, spinTurn, UNIT));
+}
+
+function widenedInstances(
+ context: PartContext,
+ geometry: BufferGeometry,
+ widening: Widening,
+): InstancedMesh {
+ return context.tracker.track(
+ new InstancedMesh(context.tracker.track(geometry), widening.material, TURBINE_COUNT),
+ );
+}
+
+export class Fleet {
+ readonly group: Group;
+ private readonly towers: InstancedMesh;
+ private readonly nacelles: InstancedMesh;
+ private readonly hubs: InstancedMesh;
+ private readonly rotors: InstancedMesh;
+ private readonly discs: RotorDiscs;
+ private readonly shadows: TurbineShadows;
+ private readonly widenings: readonly Widening[];
+ private readonly positions = Array.from({ length: TURBINE_COUNT }, () => new Vector3());
+ private readonly base = new Matrix4();
+ private readonly spin = new Matrix4();
+ private readonly heading = new Quaternion();
+ private readonly shade = new Color();
+ private readonly plain = new Color(1, 1, 1);
+ private readonly cool = new Color(DEFICIT_SHADE.colour);
+ private readonly label: Object3D;
+ private deficits?: readonly number[];
+
+ constructor(context: PartContext) {
+ this.group = namedGroup(PART);
+ const tower = new Widening(context, PART, TURBINE_FINISH, TURBINE_WIDEN);
+ const nacelle = new Widening(context, PART, NACELLE_FINISH, TURBINE_WIDEN);
+ const hub = new Widening(context, PART, TURBINE_FINISH, TURBINE_WIDEN);
+ const blades = new Widening(context, PART, TURBINE_FINISH, BLADE_WIDEN);
+ tiltTowardEye(blades.material, BLADES_CACHE_KEY);
+ this.widenings = [tower, nacelle, hub, blades];
+ this.towers = named(widenedInstances(context, towerGeometry(), tower), 'tower', this.group);
+ this.nacelles = named(
+ widenedInstances(context, nacelleGeometry(), nacelle),
+ 'nacelle',
+ this.group,
+ );
+ this.hubs = named(widenedInstances(context, spinnerGeometry(), hub), 'hub', this.group);
+ this.rotors = named(widenedInstances(context, rotorGeometry(), blades), 'rotor', this.group);
+ this.discs = new RotorDiscs(context);
+ this.shadows = new TurbineShadows(context);
+ this.group.add(this.discs.mesh, this.shadows.mesh);
+ for (let index = 0; index < TURBINE_COUNT; index += 1)
+ this.nacelles.setColorAt(index, this.plain);
+ this.label = anchorAt(this.group, 0, 0, 0);
+ context.labels.set(PART, this.label);
+ }
+
+ place(sites: readonly FarmSite[], yaw: number, azimuth: number): void {
+ sites.forEach((site, index) => {
+ sitePosition(site, this.positions[index]);
+ this.towers.setMatrixAt(index, this.base.makeTranslation(this.positions[index]));
+ });
+ this.towers.instanceMatrix.needsUpdate = true;
+ this.shadows.place(sites);
+ const labelled = this.positions[LABELLED_TURBINE];
+ this.label.position.set(labelled.x, labelled.y + TIP_HEIGHT_M, labelled.z);
+ this.turn(yaw, azimuth);
+ [this.towers, this.nacelles, this.hubs, this.rotors, this.discs.mesh].forEach((mesh) =>
+ mesh.computeBoundingSphere(),
+ );
+ }
+
+ animate(motion: Motion, yaw: number): void {
+ this.turn(yaw, motion.azimuth);
+ this.discs.spin(motion.rpm);
+ this.shadows.widen(motion.cameraDistance);
+ this.widenings.forEach((widening) => widening.update(motion.cameraDistance));
+ }
+
+ turn(yaw: number, azimuth: number): void {
+ this.heading.setFromAxisAngle(Y_AXIS, yaw);
+ this.positions.forEach((position, index) => {
+ this.base.compose(position, this.heading, UNIT);
+ this.nacelles.setMatrixAt(index, this.base);
+ this.hubs.setMatrixAt(index, this.base);
+ const rotor = rotorMatrix(this.base, azimuth, this.spin);
+ this.rotors.setMatrixAt(index, rotor);
+ this.discs.setMatrixAt(index, rotor);
+ });
+ [this.nacelles, this.hubs, this.rotors, this.discs.mesh].forEach((mesh) => {
+ mesh.instanceMatrix.needsUpdate = true;
+ });
+ }
+
+ shadeBy(deficits: readonly number[]): void {
+ if (deficits === this.deficits) return;
+ this.deficits = deficits;
+ deficits.forEach((deficit, index) => {
+ const share = clamp(deficit / DEFICIT_SHADE.full, 0, 1);
+ this.nacelles.setColorAt(index, this.shade.copy(this.plain).lerp(this.cool, share));
+ });
+ if (this.nacelles.instanceColor) this.nacelles.instanceColor.needsUpdate = true;
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/geometry.ts b/explainers/wind-farm/src/scene/parts/farm/geometry.ts
new file mode 100644
index 00000000..1a5bdfbb
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/geometry.ts
@@ -0,0 +1,55 @@
+import { BufferAttribute, Color, Matrix4, Quaternion, Vector3 } from 'three';
+import type { BufferGeometry } from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { box } from '@core/scene/geometry/box';
+import type { Point } from '../../../ids';
+
+const KEPT_ATTRIBUTES = new Set(['position', 'normal', 'color', 'lateral']);
+const RGB = 3;
+const UP = new Vector3(0, 1, 0);
+
+function bare(geometry: BufferGeometry): BufferGeometry {
+ const flat = geometry.index ? geometry.toNonIndexed() : geometry;
+ Object.keys(flat.attributes).forEach((name) => {
+ if (!KEPT_ATTRIBUTES.has(name)) flat.deleteAttribute(name);
+ });
+ return flat;
+}
+
+export function mergeParts(parts: readonly BufferGeometry[]): BufferGeometry {
+ const merged = mergeGeometries(parts.map(bare));
+ if (!merged) throw new Error('Farm geometry parts do not share attributes');
+ return merged;
+}
+
+export function tinted(geometry: BufferGeometry, colour: string): BufferGeometry {
+ const flat = bare(geometry);
+ const { r, g, b } = new Color(colour);
+ const count = flat.getAttribute('position').count;
+ const colours = new Float32Array(count * RGB);
+ for (let index = 0; index < count; index += 1) colours.set([r, g, b], index * RGB);
+ flat.setAttribute('color', new BufferAttribute(colours, RGB));
+ return flat;
+}
+
+export function slab(min: Point, max: Point): BufferGeometry {
+ const [minX, minY, minZ] = min;
+ const [maxX, maxY, maxZ] = max;
+ return box({ minX, maxX, minY, maxY, minZ, maxZ });
+}
+
+export function strut(from: Point, to: Point, thickness: number): BufferGeometry {
+ const start = new Vector3(...from);
+ const span = new Vector3(...to).sub(start);
+ const length = span.length();
+ const geometry = box({
+ minX: -thickness / 2,
+ maxX: thickness / 2,
+ minY: 0,
+ maxY: length,
+ minZ: -thickness / 2,
+ maxZ: thickness / 2,
+ });
+ const turn = new Quaternion().setFromUnitVectors(UP, span.normalize());
+ return geometry.applyMatrix4(new Matrix4().compose(start, turn, new Vector3(1, 1, 1)));
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/glowSwitch.ts b/explainers/wind-farm/src/scene/parts/farm/glowSwitch.ts
new file mode 100644
index 00000000..25d210ed
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/glowSwitch.ts
@@ -0,0 +1,33 @@
+import type { Mesh } from 'three';
+import type { MaterialFinish } from '@core/scene/materials';
+import type { EmphasisGroup, PartContext } from '../context';
+import { Widening } from './widening';
+import type { WideningOptions } from './widening';
+
+export class GlowSwitch {
+ private readonly plain: Widening;
+ private readonly glowing: Widening;
+
+ constructor(
+ context: PartContext,
+ group: EmphasisGroup,
+ finishes: { readonly plain: MaterialFinish; readonly glowing: MaterialFinish },
+ options: WideningOptions,
+ ) {
+ this.plain = new Widening(context, group, finishes.plain, options);
+ this.glowing = new Widening(context, group, finishes.glowing, options);
+ }
+
+ get material() {
+ return this.plain.material;
+ }
+
+ apply(mesh: Mesh, glowing: boolean): void {
+ mesh.material = glowing ? this.glowing.material : this.plain.material;
+ }
+
+ widen(cameraDistance: number): void {
+ this.plain.update(cameraDistance);
+ this.glowing.update(cameraDistance);
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/gridConstants.ts b/explainers/wind-farm/src/scene/parts/farm/gridConstants.ts
new file mode 100644
index 00000000..69875c62
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/gridConstants.ts
@@ -0,0 +1,79 @@
+import type { MaterialFinish } from '@core/scene/materials';
+import { THEME } from '../../../theme';
+import { FINISHES } from '../../finishes';
+
+const GALVANISED = '#c3cad1';
+const TRANSFORMER_GREY = '#ccd4dc';
+const RADIATOR_GREY = '#b2bcc6';
+const YARD_STONE = '#dcd9d1';
+
+export const YARD_GRAVEL_FINISH: MaterialFinish = {
+ color: YARD_STONE,
+ roughness: 0.95,
+ metalness: 0,
+ emissive: YARD_STONE,
+ emissiveIntensity: 0.3,
+};
+
+export const YARD_FENCE = { halfWidth: 0.06, perMetre: 0.0006 } as const;
+
+export const YARD_FENCE_FINISH: MaterialFinish = {
+ color: THEME.gridLine,
+ roughness: 0.7,
+ metalness: 0.2,
+};
+
+export const YARD_STEEL_FINISH: MaterialFinish = {
+ color: GALVANISED,
+ roughness: 0.42,
+ metalness: 0.3,
+};
+
+export const TRANSFORMER_FINISH: MaterialFinish = {
+ color: TRANSFORMER_GREY,
+ roughness: 0.4,
+ metalness: 0.05,
+};
+
+export const RADIATOR_FINISH: MaterialFinish = {
+ color: RADIATOR_GREY,
+ roughness: 0.5,
+ metalness: 0.2,
+};
+
+export const BUSBAR = { radius: 0.25, perMetre: 0.0007, glow: 1.8 } as const;
+
+export const BUSBAR_GLOW_FINISH: MaterialFinish = {
+ ...YARD_STEEL_FINISH,
+ emissive: THEME.cable,
+ emissiveIntensity: BUSBAR.glow,
+};
+
+export const CONDUCTOR = {
+ radius: 0.2,
+ sides: 4,
+ samples: 12,
+ sagM: 7,
+ perMetre: 0.00036,
+ glow: 0.5,
+} as const;
+
+export const CONDUCTOR_FINISH: MaterialFinish = {
+ color: THEME.gridLine,
+ roughness: 0.6,
+ metalness: 0.3,
+};
+
+export const CONDUCTOR_GLOW_FINISH: MaterialFinish = {
+ ...CONDUCTOR_FINISH,
+ emissive: THEME.cable,
+ emissiveIntensity: CONDUCTOR.glow,
+};
+
+export const PYLON = { halfWidth: 0.25, perMetre: 0.00026, glow: 0.3 } as const;
+
+export const PYLON_GLOW_FINISH: MaterialFinish = {
+ ...FINISHES.gridSteel,
+ emissive: THEME.cable,
+ emissiveIntensity: PYLON.glow,
+};
diff --git a/explainers/wind-farm/src/scene/parts/farm/gridLine.ts b/explainers/wind-farm/src/scene/parts/farm/gridLine.ts
new file mode 100644
index 00000000..33fb2d6f
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/gridLine.ts
@@ -0,0 +1,107 @@
+import { InstancedMesh, Matrix4, Mesh } from 'three';
+import type { Group } from 'three';
+import type { AssemblyState, Point } from '../../../ids';
+import { GRID_LINE_END, PYLON_SPACING_M, SUBSTATION } from '../../../model';
+import { FINISHES } from '../../finishes';
+import { label, namedGroup } from '../context';
+import type { PartContext } from '../context';
+import { conductorGeometry } from './conductors';
+import type { Span } from './conductors';
+import {
+ CONDUCTOR,
+ CONDUCTOR_FINISH,
+ CONDUCTOR_GLOW_FINISH,
+ PYLON,
+ PYLON_GLOW_FINISH,
+} from './gridConstants';
+import { GlowSwitch } from './glowSwitch';
+import { groundRange } from './ground';
+import { EARTH_WIRE, phaseAttachments, pylonGeometry, PYLON_TOP_M } from './pylon';
+import { EXIT_HALF_SPAN_M, LINE_EXIT } from './substation';
+
+const PART = 'gridLine';
+const FOOT_HALF_M = 3.6;
+const EXIT_EDGE_M = 1.5;
+const LABEL_PYLON = 1;
+
+export function pylonSites(): Point[] {
+ const length = GRID_LINE_END.x - SUBSTATION.x;
+ const spans = Math.ceil(length / PYLON_SPACING_M);
+ return Array.from({ length: spans }, (_, index) => {
+ const x = SUBSTATION.x + ((index + 1) * length) / spans;
+ return [x, groundRange([x, GRID_LINE_END.z], FOOT_HALF_M).low, GRID_LINE_END.z];
+ });
+}
+
+function exitPoints(count: number): Point[] {
+ const [x, y, z] = LINE_EXIT;
+ const half = EXIT_HALF_SPAN_M - EXIT_EDGE_M;
+ return Array.from({ length: count }, (_, index): Point => [
+ x,
+ y,
+ z - half + (2 * half * index) / Math.max(1, count - 1),
+ ]);
+}
+
+function wireSpans(towers: readonly Point[]): Span[] {
+ const phases = phaseAttachments();
+ const starts = [...exitPoints(phases.length), LINE_EXIT];
+ return [...phases, EARTH_WIRE].flatMap((attachment, wire) => {
+ const hangs = towers.map(([x, y, z]): Point => [
+ x + attachment[0],
+ y + attachment[1],
+ z + attachment[2],
+ ]);
+ const route = [starts[wire], ...hangs];
+ return route.slice(1).map((to, span): Span => [route[span], to]);
+ });
+}
+
+export class GridLine {
+ readonly group: Group;
+ private readonly pylons: InstancedMesh;
+ private readonly steel: GlowSwitch;
+ private readonly wires: Mesh;
+ private readonly conductors: GlowSwitch;
+
+ constructor(context: PartContext) {
+ this.group = namedGroup(PART);
+ const towers = pylonSites();
+ this.steel = new GlowSwitch(
+ context,
+ PART,
+ { plain: FINISHES.gridSteel, glowing: PYLON_GLOW_FINISH },
+ PYLON,
+ );
+ this.pylons = context.tracker.track(
+ new InstancedMesh(context.tracker.track(pylonGeometry()), this.steel.material, towers.length),
+ );
+ this.pylons.name = PART;
+ towers.forEach((tower, index) =>
+ this.pylons.setMatrixAt(index, new Matrix4().makeTranslation(...tower)),
+ );
+ this.pylons.computeBoundingSphere();
+ this.conductors = new GlowSwitch(
+ context,
+ PART,
+ { plain: CONDUCTOR_FINISH, glowing: CONDUCTOR_GLOW_FINISH },
+ { halfWidth: CONDUCTOR.radius, perMetre: CONDUCTOR.perMetre },
+ );
+ const wires = context.tracker.track(conductorGeometry(wireSpans(towers)));
+ this.wires = new Mesh(wires, this.conductors.material);
+ this.wires.name = PART;
+ this.group.add(this.pylons, this.wires);
+ const [x, y, z] = towers[Math.min(LABEL_PYLON, towers.length - 1)];
+ label(context, PART, this.group, [x, y + PYLON_TOP_M, z]);
+ }
+
+ setState(state: AssemblyState): void {
+ this.steel.apply(this.pylons, state.view.cables);
+ this.conductors.apply(this.wires, state.view.cables);
+ }
+
+ widen(cameraDistance: number): void {
+ this.steel.widen(cameraDistance);
+ this.conductors.widen(cameraDistance);
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/ground.ts b/explainers/wind-farm/src/scene/parts/farm/ground.ts
new file mode 100644
index 00000000..a0b3d86f
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/ground.ts
@@ -0,0 +1,105 @@
+import { BufferAttribute, BufferGeometry } from 'three';
+import type { Point } from '../../../ids';
+import { terrainHeight } from '../../../model';
+import type { GroundPoint } from '../../../model';
+
+const SAMPLE_STEP_M = 20;
+const MIN_MITER = 0.5;
+const XYZ = 3;
+const UV = 2;
+
+export interface RibbonOptions {
+ readonly width: number;
+ readonly lift: number;
+ readonly period: number;
+}
+
+export function onGround([x, z]: GroundPoint, lift = 0): Point {
+ return [x, terrainHeight(x, z) + lift, z];
+}
+
+export interface GroundRange {
+ readonly low: number;
+ readonly high: number;
+}
+
+const RANGE_SAMPLES = 4;
+
+export function groundRange([x, z]: GroundPoint, halfX: number, halfZ = halfX): GroundRange {
+ const heights = Array.from({ length: (RANGE_SAMPLES + 1) ** 2 }, (_, index) => {
+ const column = index % (RANGE_SAMPLES + 1);
+ const row = Math.floor(index / (RANGE_SAMPLES + 1));
+ const sampleX = x - halfX + (2 * halfX * column) / RANGE_SAMPLES;
+ const sampleZ = z - halfZ + (2 * halfZ * row) / RANGE_SAMPLES;
+ return terrainHeight(sampleX, sampleZ);
+ });
+ return { low: Math.min(...heights), high: Math.max(...heights) };
+}
+
+export function midpoint(from: GroundPoint, to: GroundPoint): GroundPoint {
+ return [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2];
+}
+
+export function densify(route: readonly GroundPoint[], step = SAMPLE_STEP_M): GroundPoint[] {
+ return route.flatMap(([x, z], index): GroundPoint[] => {
+ if (index === 0) return [[x, z]];
+ const [fromX, fromZ] = route[index - 1];
+ const steps = Math.max(1, Math.ceil(Math.hypot(x - fromX, z - fromZ) / step));
+ return Array.from({ length: steps }, (_, part) => {
+ const share = (part + 1) / steps;
+ return [fromX + (x - fromX) * share, fromZ + (z - fromZ) * share];
+ });
+ });
+}
+
+function segmentSide(from: GroundPoint, to: GroundPoint): GroundPoint {
+ const length = Math.hypot(to[0] - from[0], to[1] - from[1]) || 1;
+ return [-(to[1] - from[1]) / length, (to[0] - from[0]) / length];
+}
+
+function miteredSide(points: readonly GroundPoint[], index: number): GroundPoint {
+ const before = segmentSide(points[Math.max(0, index - 1)], points[Math.max(1, index)]);
+ const last = points.length - 1;
+ const after = segmentSide(points[Math.min(index, last - 1)], points[Math.min(index + 1, last)]);
+ const sumX = before[0] + after[0];
+ const sumZ = before[1] + after[1];
+ const length = Math.hypot(sumX, sumZ) || 1;
+ const sideX = sumX / length;
+ const sideZ = sumZ / length;
+ const miter = 1 / Math.max(sideX * after[0] + sideZ * after[1], MIN_MITER);
+ return [sideX * miter, sideZ * miter];
+}
+
+export function ribbonGeometry(
+ route: readonly GroundPoint[],
+ { width, lift, period }: RibbonOptions,
+): BufferGeometry {
+ const points = densify(route);
+ const positions = new Float32Array(points.length * 2 * XYZ);
+ const laterals = new Float32Array(points.length * 2 * XYZ);
+ const uvs = new Float32Array(points.length * 2 * UV);
+ let distance = 0;
+ points.forEach(([x, z], index) => {
+ if (index > 0) distance += Math.hypot(x - points[index - 1][0], z - points[index - 1][1]);
+ const [sideX, sideZ] = miteredSide(points, index);
+ [1, -1].forEach((sign, edge) => {
+ const vertex = index * 2 + edge;
+ const edgeX = x + (sign * sideX * width) / 2;
+ const edgeZ = z + (sign * sideZ * width) / 2;
+ positions.set([edgeX, terrainHeight(edgeX, edgeZ) + lift, edgeZ], vertex * XYZ);
+ laterals.set([sign * sideX, 0, sign * sideZ], vertex * XYZ);
+ uvs.set([distance / period, edge], vertex * UV);
+ });
+ });
+ const indices = points.slice(1).flatMap((_, index) => {
+ const left = index * 2;
+ return [left, left + 2, left + 1, left + 2, left + 3, left + 1];
+ });
+ const geometry = new BufferGeometry();
+ geometry.setAttribute('position', new BufferAttribute(positions, XYZ));
+ geometry.setAttribute('lateral', new BufferAttribute(laterals, XYZ));
+ geometry.setAttribute('uv', new BufferAttribute(uvs, UV));
+ geometry.setIndex(indices);
+ geometry.computeVertexNormals();
+ return geometry;
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/index.ts b/explainers/wind-farm/src/scene/parts/farm/index.ts
new file mode 100644
index 00000000..21af581d
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/index.ts
@@ -0,0 +1,89 @@
+import type { Object3D } from 'three';
+import type { AssemblyState, FarmSite, SpacingD } from '../../../ids';
+import {
+ HERO_SITE,
+ HUB_HEIGHT_M,
+ SUBSTATION,
+ TURBINE_GEOMETRY,
+ terrainHeight,
+} from '../../../model';
+import { SUBSTATION_HEIGHT_M } from '../../constants';
+import { bearingTurn, namedGroup, sceneAnchor } from '../context';
+import type { Motion, PartContext, Section } from '../context';
+import { CollectorCables } from './cables';
+import { Fleet } from './fleet';
+import { GridLine } from './gridLine';
+import { spacingMarker } from './marker';
+import { WakePlumes } from './plumes';
+import { accessRoads } from './roads';
+import { Substation, YARD_LEVEL } from './substation';
+
+const [HUB_X] = TURBINE_GEOMETRY.hub;
+const ORIGIN = [0, 0, 0] as const;
+
+interface LayoutAnchors {
+ readonly farmCentre: Object3D;
+ readonly heroSite: Object3D;
+}
+
+function average(values: readonly number[]): number {
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
+}
+
+function placeAnchors(anchors: LayoutAnchors, sites: readonly FarmSite[]): void {
+ const x = average(sites.map((site) => site.x));
+ const z = average(sites.map((site) => site.z));
+ anchors.farmCentre.position.set(x, terrainHeight(x, z) + HUB_HEIGHT_M, z);
+ const hero = sites[HERO_SITE];
+ const heroHub = terrainHeight(hero.x, hero.z) + HUB_HEIGHT_M;
+ anchors.heroSite.position.set(hero.x + HUB_X, heroHub, hero.z);
+}
+
+export function buildFarm(context: PartContext): Section {
+ const root = namedGroup('farm');
+ const fleet = new Fleet(context);
+ const plumes = new WakePlumes(context);
+ const roads = accessRoads(context);
+ const cables = new CollectorCables(context);
+ const marker = spacingMarker(context);
+ const grid = new GridLine(context);
+ const substation = new Substation(context);
+ const layers = [roads, cables.layer, marker];
+ root.add(fleet.group, plumes.group, grid.group, substation.group);
+ layers.forEach((layer) => root.add(layer.group));
+ const anchors: LayoutAnchors = {
+ farmCentre: sceneAnchor(context, 'farmCentre', root, ORIGIN),
+ heroSite: sceneAnchor(context, 'heroSite', root, ORIGIN),
+ };
+ const substationMiddle = YARD_LEVEL + SUBSTATION_HEIGHT_M / 2;
+ sceneAnchor(context, 'substation', root, [SUBSTATION.x, substationMiddle, SUBSTATION.z]);
+ let spacing: SpacingD | undefined;
+ let azimuth = 0;
+ return {
+ root,
+ setState: (state: AssemblyState) => {
+ const yaw = bearingTurn(state.rotor.yawDeg);
+ if (state.farm.spacing !== spacing) {
+ spacing = state.farm.spacing;
+ layers.forEach((layer) => layer.show(state.farm.spacing));
+ fleet.place(state.farm.sites, yaw, azimuth);
+ placeAnchors(anchors, state.farm.sites);
+ }
+ fleet.shadeBy(state.farm.deficits);
+ plumes.setState(state, yaw);
+ cables.setState(state);
+ grid.setState(state);
+ substation.setState(state);
+ },
+ animate: (motion: Motion, state: AssemblyState) => {
+ azimuth = motion.azimuth;
+ layers.forEach((layer) => layer.widen(motion.cameraDistance));
+ grid.widen(motion.cameraDistance);
+ substation.widen(motion.cameraDistance);
+ fleet.animate(motion, bearingTurn(state.rotor.yawDeg));
+ const flowing = cables.animate(motion, state);
+ const drifting = plumes.animate(motion, state);
+ return flowing || drifting;
+ },
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/layer.ts b/explainers/wind-farm/src/scene/parts/farm/layer.ts
new file mode 100644
index 00000000..c1916cba
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/layer.ts
@@ -0,0 +1,75 @@
+import { BufferGeometry, Mesh } from 'three';
+import type { Group, Object3D } from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { anchorAt } from '@core/scene/parts';
+import type { PartId, Point, SpacingD } from '../../../ids';
+import type { GroundPoint } from '../../../model';
+import { namedGroup } from '../context';
+import type { PartContext } from '../context';
+import { ribbonGeometry } from './ground';
+import type { RibbonOptions } from './ground';
+import type { Widening } from './widening';
+
+export interface LayerShape {
+ readonly geometry: BufferGeometry;
+ readonly label: Point;
+}
+
+export type LayerBuilder = (spacing: SpacingD) => LayerShape;
+
+export interface RibbonSet {
+ readonly routes: readonly (readonly GroundPoint[])[];
+ readonly options: RibbonOptions;
+}
+
+export function ribbons(sets: readonly RibbonSet[]): BufferGeometry {
+ const parts = sets.flatMap(({ routes, options }) =>
+ routes.map((route) => ribbonGeometry(route, options)),
+ );
+ const merged = mergeGeometries(parts);
+ parts.forEach((part) => part.dispose());
+ if (!merged) throw new Error('Farm ribbons do not share attributes');
+ return merged;
+}
+
+export class SpacingLayer {
+ readonly group: Group;
+ readonly mesh: Mesh;
+ private readonly label: Object3D;
+ private readonly context: PartContext;
+ private readonly build: LayerBuilder;
+ private readonly widening: Widening;
+ private readonly shapes = new Map();
+
+ constructor(context: PartContext, part: PartId, widening: Widening, build: LayerBuilder) {
+ this.context = context;
+ this.build = build;
+ this.widening = widening;
+ this.group = namedGroup(part);
+ this.mesh = new Mesh(context.tracker.track(new BufferGeometry()), widening.material);
+ this.mesh.name = part;
+ this.group.add(this.mesh);
+ this.label = anchorAt(this.group, 0, 0, 0);
+ context.labels.set(part, this.label);
+ }
+
+ show(spacing: SpacingD): void {
+ const shape = this.shapeFor(spacing);
+ this.mesh.geometry = shape.geometry;
+ this.label.position.set(...shape.label);
+ }
+
+ widen(cameraDistance: number): void {
+ this.widening.update(cameraDistance);
+ }
+
+ private shapeFor(spacing: SpacingD): LayerShape {
+ let shape = this.shapes.get(spacing);
+ if (!shape) {
+ shape = this.build(spacing);
+ this.context.tracker.track(shape.geometry);
+ this.shapes.set(spacing, shape);
+ }
+ return shape;
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/marker.ts b/explainers/wind-farm/src/scene/parts/farm/marker.ts
new file mode 100644
index 00000000..f83ddd5a
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/marker.ts
@@ -0,0 +1,41 @@
+import type { SpacingD } from '../../../ids';
+import { COLUMN_COUNT, HERO_SITE, farmLayout } from '../../../model';
+import type { GroundPoint } from '../../../model';
+import type { PartContext } from '../context';
+import { MARKER, MARKER_FINISH } from './constants';
+import { midpoint, onGround } from './ground';
+import { ribbons, SpacingLayer } from './layer';
+import type { LayerShape } from './layer';
+import { Widening } from './widening';
+
+function tick([x, z]: GroundPoint): GroundPoint[] {
+ return [
+ [x, z - MARKER.tickLength / 2],
+ [x, z + MARKER.tickLength / 2],
+ ];
+}
+
+export function markerEnds(spacing: SpacingD): readonly [GroundPoint, GroundPoint] {
+ const sites = farmLayout(spacing);
+ const hero = sites[HERO_SITE];
+ const next = sites[HERO_SITE + COLUMN_COUNT];
+ return [
+ [hero.x, hero.z],
+ [next.x, hero.z],
+ ];
+}
+
+function markerShape(spacing: SpacingD): LayerShape {
+ const [start, end] = markerEnds(spacing);
+ const options = { width: MARKER.width, lift: MARKER.lift, period: MARKER.width };
+ const geometry = ribbons([{ routes: [[start, end], tick(start), tick(end)], options }]);
+ return { geometry, label: onGround(midpoint(start, end), MARKER.labelLift) };
+}
+
+export function spacingMarker(context: PartContext): SpacingLayer {
+ const widening = new Widening(context, 'spacingMarker', MARKER_FINISH, {
+ halfWidth: MARKER.width / 2,
+ perMetre: MARKER.widenPerMetre,
+ });
+ return new SpacingLayer(context, 'spacingMarker', widening, markerShape);
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/plumeGeometry.ts b/explainers/wind-farm/src/scene/parts/farm/plumeGeometry.ts
new file mode 100644
index 00000000..e9cd7e3a
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/plumeGeometry.ts
@@ -0,0 +1,44 @@
+import { BufferAttribute, CylinderGeometry } from 'three';
+import type { BufferGeometry } from 'three';
+import { smoothstep } from '@core/math';
+import { ROTOR_DIAMETER_M, ROTOR_RADIUS_M, WAKE_DECAY } from '../../../model';
+import { PLUME } from './constants';
+
+const QUARTER_TURN = Math.PI / 2;
+const RGBA = 4;
+const UV = 2;
+
+export function plumeRadius(distance: number): number {
+ return ROTOR_RADIUS_M + WAKE_DECAY * distance;
+}
+
+export function plumeAlpha(distance: number, length: number): number {
+ const fadeIn = smoothstep(distance, 0, PLUME.fadeInD * ROTOR_DIAMETER_M);
+ const remaining = Math.max(0, 1 - distance / length);
+ return fadeIn * remaining ** PLUME.tailPower;
+}
+
+export function plumeGeometry(length: number): BufferGeometry {
+ const geometry = new CylinderGeometry(
+ plumeRadius(length),
+ plumeRadius(0),
+ length,
+ PLUME.radialSegments,
+ PLUME.lengthSegments,
+ true,
+ )
+ .rotateZ(-QUARTER_TURN)
+ .translate(length / 2, 0, 0);
+ const position = geometry.getAttribute('position');
+ const around = geometry.getAttribute('uv');
+ const colours = new Float32Array(position.count * RGBA);
+ const uvs = new Float32Array(position.count * UV);
+ for (let index = 0; index < position.count; index += 1) {
+ const distance = position.getX(index);
+ colours.set([1, 1, 1, plumeAlpha(distance, length)], index * RGBA);
+ uvs.set([distance / PLUME.streakPeriod, around.getX(index) * PLUME.streakBands], index * UV);
+ }
+ geometry.setAttribute('color', new BufferAttribute(colours, RGBA));
+ geometry.setAttribute('uv', new BufferAttribute(uvs, UV));
+ return geometry;
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/plumes.ts b/explainers/wind-farm/src/scene/parts/farm/plumes.ts
new file mode 100644
index 00000000..38435aa3
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/plumes.ts
@@ -0,0 +1,163 @@
+import { InstancedMesh, Matrix4, Quaternion, Vector3 } from 'three';
+import type {
+ DataTexture,
+ Group,
+ MeshStandardMaterial,
+ Object3D,
+ WebGLProgramParametersWithUniforms,
+} from 'three';
+import { anchorAt } from '@core/scene/parts';
+import { clamp, lerp } from '@core/math';
+import { createMaterial } from '@core/scene/materials';
+import type { AssemblyState, FarmSite, OperatingStateId } from '../../../ids';
+import {
+ HERO_SITE,
+ HUB_HEIGHT_M,
+ PLUME_VISIBLE_DEFICIT,
+ ROTOR_DIAMETER_M,
+ TURBINE_COUNT,
+ TURBINE_GEOMETRY,
+} from '../../../model';
+import { FINISHES } from '../../finishes';
+import { namedGroup, registeredMaterial } from '../context';
+import type { Motion, PartContext } from '../context';
+import { PLUME } from './constants';
+import { sitePosition } from './fleet';
+import { plumeGeometry } from './plumeGeometry';
+import { streakTexture } from './textures';
+
+const PART = 'wakePlumes';
+const CACHE_KEY = 'farmWakePlume';
+const STILL_STATES: ReadonlySet = new Set(['parked', 'idle']);
+const LABEL_REACH_M = 3 * ROTOR_DIAMETER_M;
+const Y_AXIS = new Vector3(0, 1, 0);
+const UNIT = new Vector3(1, 1, 1);
+const HUB = new Vector3(...TURBINE_GEOMETRY.hub);
+const PLUME_FINISH = { ...FINISHES.plume, vertexColors: true, emissiveIntensity: PLUME.glow };
+const VERTEX_DECLARATIONS = 'varying float vPlumeHeight;\nvoid main() {';
+const VERTEX_HEIGHT = `#include \nvPlumeHeight = position.y + ${HUB_HEIGHT_M.toFixed(1)};`;
+const FRAGMENT_FADE = `#include
+diffuseColor.a *= pow(abs(dot(normal, normalize(vViewPosition))), ${PLUME.rimPower.toFixed(2)})
+ * smoothstep(0.0, ${PLUME.groundFade.toFixed(1)}, vPlumeHeight);`;
+
+function fadePlume(shader: WebGLProgramParametersWithUniforms): void {
+ shader.vertexShader = shader.vertexShader
+ .replace('void main() {', VERTEX_DECLARATIONS)
+ .replace('#include ', VERTEX_HEIGHT);
+ shader.fragmentShader = shader.fragmentShader
+ .replace('void main() {', VERTEX_DECLARATIONS)
+ .replace('#include ', FRAGMENT_FADE);
+}
+
+export function plumeOpacity(strength: number): number {
+ const share = clamp(strength / PLUME.fullStrength, 0, 1);
+ const opacity = lerp(PLUME.minOpacity, PLUME.maxOpacity, share);
+ return Math.max(PLUME.opacityStep, Math.round(opacity / PLUME.opacityStep) * PLUME.opacityStep);
+}
+
+function plumeLength(lengthD: number): number {
+ const steps = Math.max(1, Math.round(lengthD / PLUME.lengthStepD));
+ return steps * PLUME.lengthStepD * ROTOR_DIAMETER_M;
+}
+
+export function plumesShown(state: AssemblyState): boolean {
+ const { view, rotor, farm } = state;
+ return (
+ view.wakes &&
+ !STILL_STATES.has(rotor.state) &&
+ farm.plumeStrength >= PLUME_VISIBLE_DEFICIT &&
+ farm.plumeLengthD > 0
+ );
+}
+
+export class WakePlumes {
+ readonly group: Group;
+ private readonly mesh: InstancedMesh;
+ private readonly streaks: DataTexture;
+ private readonly label: Object3D;
+ private readonly materials = new Map();
+ private readonly matrix = new Matrix4();
+ private readonly offset = new Matrix4().makeTranslation(HUB);
+ private readonly heading = new Quaternion();
+ private readonly position = new Vector3();
+ private length = 0;
+ private sites?: readonly FarmSite[];
+ private yaw?: number;
+ private readonly context: PartContext;
+
+ constructor(context: PartContext) {
+ this.context = context;
+ this.group = namedGroup(PART);
+ this.streaks = context.tracker.track(streakTexture());
+ this.length = plumeLength(1);
+ this.mesh = context.tracker.track(
+ new InstancedMesh(
+ plumeGeometry(this.length),
+ this.materialFor(PLUME.opacityStep),
+ TURBINE_COUNT,
+ ),
+ );
+ context.tracker.track({ dispose: () => this.mesh.geometry.dispose() });
+ this.mesh.name = PART;
+ this.group.add(this.mesh);
+ this.label = anchorAt(this.group, 0, 0, 0);
+ context.labels.set(PART, this.label);
+ }
+
+ setState(state: AssemblyState, yaw: number): void {
+ this.group.visible = plumesShown(state);
+ this.place(state.farm.sites, yaw);
+ if (!this.group.visible) return;
+ this.mesh.material = this.materialFor(plumeOpacity(state.farm.plumeStrength));
+ this.stretch(plumeLength(state.farm.plumeLengthD));
+ }
+
+ animate(motion: Motion, state: AssemblyState): boolean {
+ if (!this.group.visible) return false;
+ const drift = state.wind.speed * PLUME.streakTimeScale * motion.delta;
+ this.streaks.offset.x = (this.streaks.offset.x - drift / PLUME.streakPeriod) % 1;
+ return true;
+ }
+
+ private place(sites: readonly FarmSite[], yaw: number): void {
+ if (sites === this.sites && yaw === this.yaw) return;
+ this.sites = sites;
+ this.yaw = yaw;
+ this.heading.setFromAxisAngle(Y_AXIS, yaw);
+ sites.forEach((site, index) => {
+ this.matrix.compose(sitePosition(site, this.position), this.heading, UNIT);
+ this.mesh.setMatrixAt(index, this.matrix.multiply(this.offset));
+ });
+ this.mesh.instanceMatrix.needsUpdate = true;
+ this.mesh.computeBoundingSphere();
+ const hero = sitePosition(sites[HERO_SITE], this.position);
+ this.label.position.set(hero.x + LABEL_REACH_M, hero.y + HUB_HEIGHT_M, hero.z);
+ }
+
+ private stretch(length: number): void {
+ if (length === this.length) return;
+ this.length = length;
+ const previous = this.mesh.geometry;
+ this.mesh.geometry = plumeGeometry(length);
+ previous.dispose();
+ this.mesh.computeBoundingSphere();
+ }
+
+ private materialFor(opacity: number): MeshStandardMaterial {
+ const key = Math.round(opacity / PLUME.opacityStep);
+ let material = this.materials.get(key);
+ if (!material) {
+ material = createMaterial({
+ ...PLUME_FINISH,
+ opacity,
+ alphaMap: this.streaks,
+ emissiveMap: this.streaks,
+ });
+ material.onBeforeCompile = fadePlume;
+ material.customProgramCacheKey = () => CACHE_KEY;
+ registeredMaterial(this.context, PART, material);
+ this.materials.set(key, material);
+ }
+ return material;
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/pylon.ts b/explainers/wind-farm/src/scene/parts/farm/pylon.ts
new file mode 100644
index 00000000..7f8cdce8
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/pylon.ts
@@ -0,0 +1,94 @@
+import { Vector3 } from 'three';
+import type { BufferGeometry } from 'three';
+import { lerp } from '@core/math';
+import type { Point } from '../../../ids';
+import { mergeParts, strut } from './geometry';
+import { withLateral } from './widening';
+
+const LEVELS = [0, 7, 13.5, 20, 25, 30, 35] as const;
+const WAIST_LEVEL = 3;
+const BASE_HALF_M = 3.6;
+const WAIST_HALF_M = 1.3;
+const TOP_HALF_M = 0.8;
+const SINK_M = 1;
+const LEG_M = 0.5;
+const BRACE_M = 0.22;
+const ARM_RISE_M = 2.4;
+const INSULATOR_M = 2.4;
+const INSULATOR_THICKNESS_M = 0.32;
+const PEAK_M = 2;
+const ARMS = [
+ { level: 3, reach: 6 },
+ { level: 4, reach: 7.5 },
+ { level: 5, reach: 5.5 },
+] as const;
+const CORNERS = [
+ [1, 1],
+ [1, -1],
+ [-1, -1],
+ [-1, 1],
+] as const;
+
+export const PYLON_TOP_M = LEVELS[LEVELS.length - 1] + PEAK_M;
+
+function beam(from: Point, to: Point, thickness: number): BufferGeometry {
+ const start = new Vector3(...from);
+ const axis = new Vector3(...to).sub(start).normalize();
+ const offset = new Vector3();
+ return withLateral(strut(from, to, thickness), (point, target) => {
+ offset.subVectors(point, start);
+ return target.copy(offset).addScaledVector(axis, -offset.dot(axis)).normalize();
+ });
+}
+
+function halfWidth(level: number): number {
+ if (level <= WAIST_LEVEL) return lerp(BASE_HALF_M, WAIST_HALF_M, level / WAIST_LEVEL);
+ return lerp(WAIST_HALF_M, TOP_HALF_M, (level - WAIST_LEVEL) / (LEVELS.length - 1 - WAIST_LEVEL));
+}
+
+function corner(level: number, [signX, signZ]: readonly [number, number]): Point {
+ const half = halfWidth(level);
+ const y = level === 0 ? -SINK_M : LEVELS[level];
+ return [signX * half, y, signZ * half];
+}
+
+function body(): BufferGeometry[] {
+ return LEVELS.slice(1).flatMap((_, below) => {
+ const above = below + 1;
+ return CORNERS.flatMap((signs, index) => {
+ const next = CORNERS[(index + 1) % CORNERS.length];
+ return [
+ beam(corner(below, signs), corner(above, signs), LEG_M),
+ beam(corner(above, signs), corner(above, next), BRACE_M),
+ beam(corner(below, signs), corner(above, next), BRACE_M),
+ beam(corner(below, next), corner(above, signs), BRACE_M),
+ ];
+ });
+ });
+}
+
+export const EARTH_WIRE: Point = [0, PYLON_TOP_M, 0];
+
+export function phaseAttachments(): Point[] {
+ return ARMS.flatMap(({ level, reach }) =>
+ [-1, 1].map((side): Point => [0, LEVELS[level] - INSULATOR_M, side * reach]),
+ ).sort((a, b) => a[2] - b[2]);
+}
+
+function arms(): BufferGeometry[] {
+ return ARMS.flatMap(({ level, reach }) => {
+ const y = LEVELS[level];
+ const half = halfWidth(level);
+ return [-1, 1].flatMap((side) => [
+ beam([0, y, side * half], [0, y, side * reach], LEG_M),
+ beam([0, y + ARM_RISE_M, side * half], [0, y, side * reach], BRACE_M),
+ beam([0, y, side * reach], [0, y - INSULATOR_M, side * reach], INSULATOR_THICKNESS_M),
+ ]);
+ });
+}
+
+export function pylonGeometry(): BufferGeometry {
+ const top = LEVELS.length - 1;
+ const peak = beam([0, LEVELS[top], 0], [0, PYLON_TOP_M, 0], LEG_M);
+ return mergeParts([...body(), ...arms(), peak]);
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/roads.ts b/explainers/wind-farm/src/scene/parts/farm/roads.ts
new file mode 100644
index 00000000..7f1a3714
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/roads.ts
@@ -0,0 +1,30 @@
+import type { SpacingD } from '../../../ids';
+import type { PartContext } from '../context';
+import { ROAD, ROAD_FINISH } from './constants';
+import { midpoint, onGround } from './ground';
+import { ribbons, SpacingLayer } from './layer';
+import type { LayerShape } from './layer';
+import { farmRoutes, padRoutes } from './routes';
+import { Widening } from './widening';
+
+const PART = 'accessRoads';
+const LABEL_ROW = 2;
+
+function roadShape(spacing: SpacingD): LayerShape {
+ const routes = farmRoutes(spacing);
+ const geometry = ribbons([
+ { routes, options: ROAD },
+ { routes: padRoutes(spacing), options: { ...ROAD, width: ROAD.padWidth } },
+ ]);
+ const row = routes[LABEL_ROW];
+ const yardApproach = midpoint(row[row.length - 2], row[row.length - 1]);
+ return { geometry, label: onGround(yardApproach, ROAD.lift) };
+}
+
+export function accessRoads(context: PartContext): SpacingLayer {
+ const widening = new Widening(context, PART, ROAD_FINISH, {
+ halfWidth: ROAD.width / 2,
+ perMetre: ROAD.widenPerMetre,
+ });
+ return new SpacingLayer(context, PART, widening, roadShape);
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/rotors.ts b/explainers/wind-farm/src/scene/parts/farm/rotors.ts
new file mode 100644
index 00000000..37a50a09
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/rotors.ts
@@ -0,0 +1,24 @@
+import type { Material } from 'three';
+import { ROTOR_TILT } from './turbineConstants';
+
+const MIN_FACING = ROTOR_TILT.minFacing.toFixed(3);
+const TILT_VERTEX = `mat4 rotorFrame = modelMatrix * instanceMatrix;
+vec3 rotorEye = normalize(transpose(mat3(rotorFrame)) * (cameraPosition - rotorFrame[3].xyz));
+float rotorFacing = abs(rotorEye.x);
+if (rotorFacing < ${MIN_FACING}) {
+ vec2 rotorAcross = normalize(rotorEye.yz);
+ float rotorTilt = acos(rotorFacing) - acos(${MIN_FACING});
+ float rotorSpan = dot(transformed.yz, rotorAcross);
+ transformed.yz += rotorAcross * rotorSpan * (cos(rotorTilt) - 1.0);
+ transformed.x -= sign(rotorEye.x) * rotorSpan * sin(rotorTilt);
+}
+#include `;
+
+export function tiltTowardEye(material: Material, cacheKey: string): void {
+ const compile = material.onBeforeCompile.bind(material);
+ material.onBeforeCompile = (shader, renderer) => {
+ compile(shader, renderer);
+ shader.vertexShader = shader.vertexShader.replace('#include ', TILT_VERTEX);
+ };
+ material.customProgramCacheKey = () => cacheKey;
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/routes.ts b/explainers/wind-farm/src/scene/parts/farm/routes.ts
new file mode 100644
index 00000000..19c3986c
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/routes.ts
@@ -0,0 +1,37 @@
+import type { SpacingD } from '../../../ids';
+import { SUBSTATION, TURBINE_GEOMETRY, collectorRoutes, farmLayout } from '../../../model';
+import type { GroundPoint } from '../../../model';
+import { ROAD, ROUTE } from './constants';
+
+const TOWER_RADIUS = TURBINE_GEOMETRY.towerBaseDiameter / 2;
+
+export function yardEntry([fromX, fromZ]: GroundPoint): GroundPoint {
+ const dx = fromX - SUBSTATION.x;
+ const dz = fromZ - SUBSTATION.z;
+ const reach = Math.min(
+ SUBSTATION.width / 2 / Math.max(Math.abs(dx), Number.EPSILON),
+ SUBSTATION.depth / 2 / Math.max(Math.abs(dz), Number.EPSILON),
+ );
+ return [SUBSTATION.x + dx * reach, SUBSTATION.z + dz * reach];
+}
+
+export function farmRoutes(spacing: SpacingD): GroundPoint[][] {
+ return collectorRoutes(spacing).map((route) => {
+ const beside = route.slice(0, -1).map(([x, z]): GroundPoint => [x + ROUTE.offset, z]);
+ return [...beside, yardEntry(beside[beside.length - 1])];
+ });
+}
+
+export function feederRoutes(spacing: SpacingD): GroundPoint[][] {
+ return farmLayout(spacing).map(({ x, z }) => [
+ [x + TOWER_RADIUS + ROUTE.towerClearance, z],
+ [x + ROUTE.offset, z],
+ ]);
+}
+
+export function padRoutes(spacing: SpacingD): GroundPoint[][] {
+ return farmLayout(spacing).map(({ x, z }) => [
+ [x - ROAD.padBehind, z],
+ [x + ROUTE.offset, z],
+ ]);
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/shadows.ts b/explainers/wind-farm/src/scene/parts/farm/shadows.ts
new file mode 100644
index 00000000..bc6d6abf
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/shadows.ts
@@ -0,0 +1,128 @@
+import { BufferAttribute, BufferGeometry, Mesh, MeshBasicMaterial, Vector2 } from 'three';
+import { clamp } from '@core/math';
+import { UNDIMMED_GROUP } from '@core/scene/materials';
+import type { FarmSite } from '../../../ids';
+import { terrainHeight } from '../../../model';
+import { SUN_DIRECTION } from '../../constants';
+import { FINISHES } from '../../finishes';
+import { registeredMaterial } from '../context';
+import type { PartContext } from '../context';
+import { CONTACT_SHADOW } from './turbineConstants';
+import { shadowTexture } from './textures';
+
+const NAME = 'turbineShadows';
+const XYZ = 3;
+const UV = 2;
+const SHADOW_OFFSET = { polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -4 };
+
+export const SHADOW_HEADING = new Vector2(-SUN_DIRECTION[0], -SUN_DIRECTION[2]).normalize();
+
+interface GridSize {
+ readonly along: number;
+ readonly across: number;
+}
+
+const GRID: GridSize = {
+ along: CONTACT_SHADOW.alongSteps + 1,
+ across: CONTACT_SHADOW.acrossSteps + 1,
+};
+
+export function shadowScale(cameraDistance: number): number {
+ const { fullSizeDistance, maxScale, scaleStep } = CONTACT_SHADOW;
+ const scale = clamp(cameraDistance / fullSizeDistance, 1, maxScale);
+ return Math.round(scale / scaleStep) * scaleStep;
+}
+
+function blobPoints(site: FarmSite, scale: number): { positions: number[]; uvs: number[] } {
+ const { baseShare, lift } = CONTACT_SHADOW;
+ const length = CONTACT_SHADOW.length * scale;
+ const width = CONTACT_SHADOW.width * scale;
+ const reach = length * (0.5 - baseShare);
+ const centreX = site.x + SHADOW_HEADING.x * reach;
+ const centreZ = site.z + SHADOW_HEADING.y * reach;
+ const positions: number[] = [];
+ const uvs: number[] = [];
+ for (let row = 0; row < GRID.across; row += 1) {
+ const v = row / (GRID.across - 1);
+ for (let column = 0; column < GRID.along; column += 1) {
+ const u = column / (GRID.along - 1);
+ const along = (u - 0.5) * length;
+ const across = (v - 0.5) * width;
+ const x = centreX + SHADOW_HEADING.x * along - SHADOW_HEADING.y * across;
+ const z = centreZ + SHADOW_HEADING.y * along + SHADOW_HEADING.x * across;
+ positions.push(x, terrainHeight(x, z) + lift, z);
+ uvs.push(u, v);
+ }
+ }
+ return { positions, uvs };
+}
+
+function blobIndices(first: number): number[] {
+ return Array.from({ length: GRID.across - 1 }, (_, row) =>
+ Array.from({ length: GRID.along - 1 }, (__, column) => {
+ const a = first + row * GRID.along + column;
+ const b = a + GRID.along;
+ return [a, b, a + 1, a + 1, b, b + 1];
+ }).flat(),
+ ).flat();
+}
+
+export function shadowGeometry(sites: readonly FarmSite[], scale = 1): BufferGeometry {
+ const blobs = sites.map((site) => blobPoints(site, scale));
+ const geometry = new BufferGeometry();
+ geometry.setAttribute(
+ 'position',
+ new BufferAttribute(new Float32Array(blobs.flatMap((blob) => blob.positions)), XYZ),
+ );
+ geometry.setAttribute(
+ 'uv',
+ new BufferAttribute(new Float32Array(blobs.flatMap((blob) => blob.uvs)), UV),
+ );
+ geometry.setIndex(sites.flatMap((_, index) => blobIndices(index * GRID.along * GRID.across)));
+ geometry.computeBoundingSphere();
+ return geometry;
+}
+
+export class TurbineShadows {
+ readonly mesh: Mesh;
+ private sites?: readonly FarmSite[];
+ private scale = 1;
+
+ constructor(context: PartContext) {
+ const material = registeredMaterial(
+ context,
+ UNDIMMED_GROUP,
+ new MeshBasicMaterial({
+ color: FINISHES.shadow.color,
+ alphaMap: context.tracker.track(shadowTexture()),
+ transparent: true,
+ opacity: CONTACT_SHADOW.opacity,
+ depthWrite: false,
+ fog: false,
+ ...SHADOW_OFFSET,
+ }),
+ );
+ this.mesh = new Mesh(new BufferGeometry(), material);
+ this.mesh.name = NAME;
+ context.tracker.track({ dispose: () => this.mesh.geometry.dispose() });
+ }
+
+ place(sites: readonly FarmSite[]): void {
+ this.sites = sites;
+ this.rebuild();
+ }
+
+ widen(cameraDistance: number): void {
+ const scale = shadowScale(cameraDistance);
+ if (scale === this.scale) return;
+ this.scale = scale;
+ this.rebuild();
+ }
+
+ private rebuild(): void {
+ if (!this.sites) return;
+ const previous = this.mesh.geometry;
+ this.mesh.geometry = shadowGeometry(this.sites, this.scale);
+ previous.dispose();
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/substation.test.ts b/explainers/wind-farm/src/scene/parts/farm/substation.test.ts
new file mode 100644
index 00000000..e1d9cffd
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/substation.test.ts
@@ -0,0 +1,25 @@
+import { Vector3 } from 'three';
+import { describe, expect, it } from 'vitest';
+import { GANTRY, inflatingSlab, TRANSFORMER } from './yard';
+
+describe('substation yard', () => {
+ it('inflates fence pieces outward and level', () => {
+ const geometry = inflatingSlab([2, 0, -1], [6, 3, 1]);
+ const position = geometry.getAttribute('position');
+ const lateral = geometry.getAttribute('lateral');
+ const centre = new Vector3(4, 0, 0);
+ for (let index = 0; index < position.count; index += 1) {
+ const out = new Vector3().fromBufferAttribute(position, index).sub(centre);
+ const push = new Vector3().fromBufferAttribute(lateral, index);
+ expect(push.y).toBe(0);
+ expect(push.x * out.x).toBeGreaterThan(0);
+ expect(push.z * out.z).toBeGreaterThan(0);
+ }
+ });
+
+ it('hangs the gantries above the transformer bushings', () => {
+ const [, plinth] = TRANSFORMER.plinth;
+ const [, body] = TRANSFORMER.body;
+ expect(GANTRY.height).toBeGreaterThan(plinth + body + TRANSFORMER.bushingHeight);
+ });
+});
diff --git a/explainers/wind-farm/src/scene/parts/farm/substation.ts b/explainers/wind-farm/src/scene/parts/farm/substation.ts
new file mode 100644
index 00000000..1a6b7e72
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/substation.ts
@@ -0,0 +1,182 @@
+import { Mesh } from 'three';
+import type { BufferGeometry, Group } from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import type { MaterialFinish } from '@core/scene/materials';
+import type { AssemblyState, Point } from '../../../ids';
+import { SUBSTATION } from '../../../model';
+import { FINISHES } from '../../finishes';
+import { finishMesh, label, namedGroup } from '../context';
+import type { PartContext } from '../context';
+import { mergeParts, slab } from './geometry';
+import { GlowSwitch } from './glowSwitch';
+import {
+ BUSBAR,
+ BUSBAR_GLOW_FINISH,
+ RADIATOR_FINISH,
+ TRANSFORMER_FINISH,
+ YARD_FENCE,
+ YARD_FENCE_FINISH,
+ YARD_GRAVEL_FINISH,
+ YARD_STEEL_FINISH,
+} from './gridConstants';
+import { groundRange } from './ground';
+import { Widening } from './widening';
+import { busbar, gantry, GANTRY, inflatingSlab, transformerPieces } from './yard';
+
+const PART = 'substation';
+const YARD_FINISHES = {
+ gravel: YARD_GRAVEL_FINISH,
+ concrete: FINISHES.concrete,
+ transformer: TRANSFORMER_FINISH,
+ radiator: RADIATOR_FINISH,
+ steel: YARD_STEEL_FINISH,
+ roof: FINISHES.paintShade,
+} as const satisfies Record;
+
+type YardPiece = keyof typeof YARD_FINISHES;
+const HALF_WIDTH = SUBSTATION.width / 2;
+const HALF_DEPTH = SUBSTATION.depth / 2;
+const PAD_RAISE_M = 0.3;
+const PAD_SINK_M = 1.5;
+const APRON_M = 8;
+const PAD_HALF_WIDTH = HALF_WIDTH + APRON_M;
+const PAD_HALF_DEPTH = HALF_DEPTH + APRON_M;
+const FENCE = { inset: 1, height: 2.4, thickness: 0.12, postPitch: 8, post: 0.22 } as const;
+const LABEL_POINT: Point = [0, FENCE.height, -HALF_DEPTH];
+const BUILDING = { minX: -54, maxX: -32, minZ: 14, maxZ: 30, height: 5.5, eave: 0.6, roof: 0.5 };
+const TRANSFORMER_SITES: readonly (readonly [number, number])[] = [
+ [30, -14],
+ [30, 14],
+];
+const GANTRY_XS = [-26, -10, 6, 22, 42] as const;
+const GANTRY_HALF_SPAN = 18;
+const BUSBAR_ZS = [-7, 0, 7] as const;
+const BUSBAR_DROP_M = 3;
+const EXIT_GANTRY_X = HALF_WIDTH - 6;
+const EXIT_HALF_SPAN = 9;
+
+export const SUBSTATION_RANGE = groundRange(
+ [SUBSTATION.x, SUBSTATION.z],
+ PAD_HALF_WIDTH,
+ PAD_HALF_DEPTH,
+);
+export const YARD_LEVEL = SUBSTATION_RANGE.high + PAD_RAISE_M;
+export const LINE_EXIT: Point = [
+ SUBSTATION.x + EXIT_GANTRY_X,
+ YARD_LEVEL + GANTRY.height,
+ SUBSTATION.z,
+];
+export const EXIT_HALF_SPAN_M = EXIT_HALF_SPAN;
+
+function fence(): BufferGeometry[] {
+ const x = HALF_WIDTH - FENCE.inset;
+ const z = HALF_DEPTH - FENCE.inset;
+ const half = FENCE.thickness / 2;
+ const panels = [
+ inflatingSlab([-x, 0, -z - half], [x, FENCE.height, -z + half]),
+ inflatingSlab([-x, 0, z - half], [x, FENCE.height, z + half]),
+ inflatingSlab([-x - half, 0, -z], [-x + half, FENCE.height, z]),
+ inflatingSlab([x - half, 0, -z], [x + half, FENCE.height, z]),
+ ];
+ const corners: Point[] = [
+ [-x, 0, -z],
+ [x, 0, -z],
+ [x, 0, z],
+ [-x, 0, z],
+ ];
+ const posts = corners.flatMap((corner, index) => {
+ const next = corners[(index + 1) % corners.length];
+ const length = Math.hypot(next[0] - corner[0], next[2] - corner[2]);
+ const count = Math.round(length / FENCE.postPitch);
+ return Array.from({ length: count }, (_, step) => {
+ const share = step / count;
+ const postX = corner[0] + (next[0] - corner[0]) * share;
+ const postZ = corner[2] + (next[2] - corner[2]) * share;
+ const p = FENCE.post / 2;
+ return inflatingSlab([postX - p, 0, postZ - p], [postX + p, FENCE.height + p, postZ + p]);
+ });
+ });
+ return [...panels, ...posts];
+}
+
+function building(): { walls: BufferGeometry; roof: BufferGeometry } {
+ const { minX, maxX, minZ, maxZ, height, eave, roof } = BUILDING;
+ return {
+ walls: slab([minX, 0, minZ], [maxX, height, maxZ]),
+ roof: slab([minX - eave, height, minZ - eave], [maxX + eave, height + roof, maxZ + eave]),
+ };
+}
+
+function steelwork(): BufferGeometry[] {
+ const portals = GANTRY_XS.flatMap((x) => gantry(x, GANTRY_HALF_SPAN));
+ const exit = gantry(EXIT_GANTRY_X, EXIT_HALF_SPAN);
+ return [...portals, ...exit];
+}
+
+function busbars(): BufferGeometry {
+ const busbarY = GANTRY.height - BUSBAR_DROP_M;
+ const bars = BUSBAR_ZS.map((z) => busbar(GANTRY_XS[0], EXIT_GANTRY_X, busbarY, z));
+ const merged = mergeGeometries(bars);
+ bars.forEach((bar) => bar.dispose());
+ if (!merged) throw new Error('Substation busbars do not share attributes');
+ return merged;
+}
+
+function pieces(): Record {
+ const transformers = TRANSFORMER_SITES.map(transformerPieces);
+ const { walls, roof } = building();
+ const pad = slab(
+ [-PAD_HALF_WIDTH, SUBSTATION_RANGE.low - PAD_SINK_M - YARD_LEVEL, -PAD_HALF_DEPTH],
+ [PAD_HALF_WIDTH, 0, PAD_HALF_DEPTH],
+ );
+ return {
+ gravel: [pad],
+ concrete: [walls, ...transformers.map((piece) => piece.plinth)],
+ transformer: transformers.flatMap((piece) => piece.body),
+ radiator: transformers.flatMap((piece) => piece.fins),
+ steel: steelwork(),
+ roof: [roof, ...transformers.flatMap((piece) => piece.bushings)],
+ };
+}
+
+export class Substation {
+ readonly group: Group;
+ private readonly busbars: Mesh;
+ private readonly glow: GlowSwitch;
+ private readonly fence: Widening;
+
+ constructor(context: PartContext) {
+ this.group = namedGroup(PART);
+ this.group.position.set(SUBSTATION.x, YARD_LEVEL, SUBSTATION.z);
+ const yard = pieces();
+ (Object.keys(YARD_FINISHES) as YardPiece[]).forEach((piece) => {
+ const geometry = mergeParts(yard[piece]);
+ const mesh = finishMesh(context, geometry, PART, YARD_FINISHES[piece]);
+ mesh.name = PART;
+ this.group.add(mesh);
+ });
+ this.fence = new Widening(context, PART, YARD_FENCE_FINISH, YARD_FENCE);
+ const fenceMesh = new Mesh(context.tracker.track(mergeParts(fence())), this.fence.material);
+ fenceMesh.name = PART;
+ this.group.add(fenceMesh);
+ this.glow = new GlowSwitch(
+ context,
+ PART,
+ { plain: YARD_STEEL_FINISH, glowing: BUSBAR_GLOW_FINISH },
+ { halfWidth: BUSBAR.radius, perMetre: BUSBAR.perMetre },
+ );
+ this.busbars = new Mesh(context.tracker.track(busbars()), this.glow.material);
+ this.busbars.name = PART;
+ this.group.add(this.busbars);
+ label(context, PART, this.group, LABEL_POINT);
+ }
+
+ setState(state: AssemblyState): void {
+ this.glow.apply(this.busbars, state.view.cables);
+ }
+
+ widen(cameraDistance: number): void {
+ this.glow.widen(cameraDistance);
+ this.fence.update(cameraDistance);
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/textures.ts b/explainers/wind-farm/src/scene/parts/farm/textures.ts
new file mode 100644
index 00000000..6c5160a0
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/textures.ts
@@ -0,0 +1,108 @@
+import { DataTexture, LinearMipmapLinearFilter, LinearFilter, RepeatWrapping } from 'three';
+import { lerp, smoothstep } from '@core/math';
+import { CONTACT_SHADOW } from './turbineConstants';
+
+const RGBA = 4;
+const FULL_BYTE = 255;
+const DASH_TEXELS = 64;
+const DASH_SHARE = 0.45;
+const DASH_EDGE = 0.08;
+const DASH_GAP_LEVEL = 0.28;
+const STREAK_TEXELS = 128;
+const STREAK = {
+ floor: 0.7,
+ lanes: 4,
+ laneWidth: 0.2,
+ dashes: 2,
+ dashShare: 0.6,
+ dashEdge: 0.22,
+} as const;
+const DISC_TEXELS = 128;
+const DISC_RIM = { hub: 0.05, hubEdge: 0.08, edge: 0.05 } as const;
+const DISC_BODY = { inner: 0.45, outer: 0.85 } as const;
+const DISC_RING = { at: 0.9, width: 0.06, boost: 0.35 } as const;
+const DISC_STREAKS = { count: 3, swirl: 1.1, depth: 0.35, sharpness: 3 } as const;
+const SHADOW_TEXELS = 64;
+const SHADOW_SHAPE = { tail: 0.4, baseWidth: 0.95, tipWidth: 0.35, softness: 0.6 } as const;
+const HASH_SCALE = 12.9898;
+const HASH_LIFT = 43758.5453;
+
+function hash(seed: number): number {
+ const value = Math.sin(seed * HASH_SCALE) * HASH_LIFT;
+ return value - Math.floor(value);
+}
+
+function greyTexture(width: number, height: number, level: (u: number, v: number) => number) {
+ const data = new Uint8Array(width * height * RGBA);
+ for (let row = 0; row < height; row += 1) {
+ for (let column = 0; column < width; column += 1) {
+ const byte = Math.round(FULL_BYTE * level(column / width, row / height));
+ data.set([byte, byte, byte, FULL_BYTE], (row * width + column) * RGBA);
+ }
+ }
+ const texture = new DataTexture(data, width, height);
+ texture.wrapS = RepeatWrapping;
+ texture.wrapT = RepeatWrapping;
+ texture.magFilter = LinearFilter;
+ texture.minFilter = LinearMipmapLinearFilter;
+ texture.generateMipmaps = true;
+ texture.needsUpdate = true;
+ return texture;
+}
+
+function dashLevel(u: number): number {
+ const rise = smoothstep(u, 0, DASH_EDGE);
+ const fall = 1 - smoothstep(u, DASH_SHARE - DASH_EDGE, DASH_SHARE);
+ return DASH_GAP_LEVEL + (1 - DASH_GAP_LEVEL) * Math.min(rise, fall);
+}
+
+export function streakLevel(u: number, v: number): number {
+ const lane = Math.floor(v * STREAK.lanes);
+ const across = v * STREAK.lanes - lane;
+ const band = Math.exp(-(((across - 0.5) / STREAK.laneWidth) ** 2));
+ const along = (u * STREAK.dashes + hash(lane + 1)) % 1;
+ const dash =
+ smoothstep(along, 0, STREAK.dashEdge) *
+ (1 - smoothstep(along, STREAK.dashShare - STREAK.dashEdge, STREAK.dashShare));
+ return STREAK.floor + (1 - STREAK.floor) * band * dash;
+}
+
+export function discLevel(u: number, v: number): number {
+ const across = 2 * u - 1;
+ const up = 2 * v - 1;
+ const radius = Math.hypot(across, up);
+ const rim =
+ smoothstep(radius, DISC_RIM.hub, DISC_RIM.hub + DISC_RIM.hubEdge) *
+ (1 - smoothstep(radius, 1 - DISC_RIM.edge, 1));
+ const body = lerp(DISC_BODY.inner, DISC_BODY.outer, radius);
+ const ring = DISC_RING.boost * Math.exp(-(((radius - DISC_RING.at) / DISC_RING.width) ** 2));
+ const angle = Math.atan2(up, across) + DISC_STREAKS.swirl * radius;
+ const streak = (0.5 + 0.5 * Math.cos(DISC_STREAKS.count * angle)) ** DISC_STREAKS.sharpness;
+ const streaks = 1 - DISC_STREAKS.depth + DISC_STREAKS.depth * streak;
+ return Math.min(1, rim * (body + ring) * streaks);
+}
+
+export function shadowLevel(u: number, v: number): number {
+ const across = Math.abs(2 * v - 1);
+ const width = lerp(SHADOW_SHAPE.baseWidth, SHADOW_SHAPE.tipWidth, u);
+ const side = 1 - smoothstep(across, width * (1 - SHADOW_SHAPE.softness), width);
+ const rise = smoothstep(u, 0, CONTACT_SHADOW.baseShare);
+ const fall = 1 - smoothstep(u, SHADOW_SHAPE.tail, 1);
+ return side * rise * fall;
+}
+
+export function dashTexture(): DataTexture {
+ return greyTexture(DASH_TEXELS, 1, dashLevel);
+}
+
+export function streakTexture(): DataTexture {
+ return greyTexture(STREAK_TEXELS, STREAK_TEXELS, streakLevel);
+}
+
+export function discTexture(): DataTexture {
+ return greyTexture(DISC_TEXELS, DISC_TEXELS, discLevel);
+}
+
+export function shadowTexture(): DataTexture {
+ return greyTexture(SHADOW_TEXELS, SHADOW_TEXELS, shadowLevel);
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/turbine.ts b/explainers/wind-farm/src/scene/parts/farm/turbine.ts
new file mode 100644
index 00000000..f3838df4
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/turbine.ts
@@ -0,0 +1,72 @@
+import { LatheGeometry, Vector2 } from 'three';
+import type { BufferGeometry } from 'three';
+import { extrudeProfileAlongX, roundedRectShape } from '@core/scene/geometry/extrude';
+import { latheAlongX, sampleProfile } from '@core/scene/geometry/lathe';
+import type { ProfilePoint } from '@core/scene/geometry/lathe';
+import { TURBINE_GEOMETRY } from '../../../model';
+import { THEME } from '../../../theme';
+import { mergeParts, slab, tinted } from './geometry';
+import { radialAroundX, radialAroundY, withLateral } from './widening';
+import type { LateralRule } from './widening';
+
+const TOWER_SEGMENTS = 16;
+const TOWER_SINK_M = 3;
+const TOWER_FLANGE_M = 0.15;
+const TOWER_FLANGE_HEIGHT_M = 0.8;
+const NACELLE_CORNER_M = 0.7;
+const COOLER_INSET_M = 0.2;
+const SPINNER_SAMPLES = 8;
+const SPINNER_SEGMENTS = 14;
+
+const NACELLE = TURBINE_GEOMETRY.nacelle;
+const COOLER = TURBINE_GEOMETRY.cooler;
+const [HUB_X] = TURBINE_GEOMETRY.hub;
+const SPINNER_RADIUS = TURBINE_GEOMETRY.spinnerRadius;
+const NACELLE_MIDDLE_Y = (NACELLE.minY + NACELLE.maxY) / 2;
+const NACELLE_HALF_HEIGHT = (NACELLE.maxY - NACELLE.minY) / 2;
+
+const SPINNER_PROFILE: readonly ProfilePoint[] = [
+ [HUB_X - 2.6, 0],
+ [HUB_X - 2.2, 0.95],
+ [HUB_X - 1.3, 1.6],
+ [HUB_X, SPINNER_RADIUS],
+ [HUB_X + 1.8, SPINNER_RADIUS * 0.95],
+ [NACELLE.minX, SPINNER_RADIUS * 0.85],
+];
+
+export function towerGeometry(): BufferGeometry {
+ const base = TURBINE_GEOMETRY.towerBaseDiameter / 2;
+ const top = TURBINE_GEOMETRY.towerTopDiameter / 2;
+ const profile = [
+ new Vector2(base + TOWER_FLANGE_M, -TOWER_SINK_M),
+ new Vector2(base + TOWER_FLANGE_M, TOWER_FLANGE_HEIGHT_M),
+ new Vector2(base, TOWER_FLANGE_HEIGHT_M),
+ new Vector2(top, TURBINE_GEOMETRY.towerTopY),
+ ];
+ return withLateral(new LatheGeometry(profile, TOWER_SEGMENTS), radialAroundY);
+}
+
+const nacelleLateral: LateralRule = (point, target) =>
+ target.set(0, (point.y - NACELLE_MIDDLE_Y) / NACELLE_HALF_HEIGHT, point.z / NACELLE.halfWidth);
+
+export function nacelleGeometry(): BufferGeometry {
+ const shell = roundedRectShape(
+ { minA: -NACELLE.halfWidth, maxA: NACELLE.halfWidth, minB: NACELLE.minY, maxB: NACELLE.maxY },
+ NACELLE_CORNER_M,
+ );
+ const body = tinted(extrudeProfileAlongX(shell, NACELLE.minX, NACELLE.maxX), THEME.turbineWhite);
+ const cooler = tinted(
+ slab(
+ [COOLER.minX, NACELLE.maxY - COOLER_INSET_M, -NACELLE.halfWidth + COOLER_INSET_M],
+ [COOLER.maxX - COOLER_INSET_M, COOLER.topY, NACELLE.halfWidth - COOLER_INSET_M],
+ ),
+ THEME.cooler,
+ );
+ return withLateral(mergeParts([body, cooler]), nacelleLateral);
+}
+
+export function spinnerGeometry(): BufferGeometry {
+ const spinner = latheAlongX(sampleProfile(SPINNER_PROFILE, SPINNER_SAMPLES), SPINNER_SEGMENTS);
+ spinner.translate(0, TURBINE_GEOMETRY.shaftY, 0);
+ return withLateral(spinner, radialAroundX(TURBINE_GEOMETRY.shaftY));
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/turbineConstants.ts b/explainers/wind-farm/src/scene/parts/farm/turbineConstants.ts
new file mode 100644
index 00000000..99f0e36a
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/turbineConstants.ts
@@ -0,0 +1,61 @@
+import { DoubleSide } from 'three';
+import type { MaterialFinish } from '@core/scene/materials';
+import { THEME } from '../../../theme';
+import { GROUND_LIFT_M } from '../../constants';
+
+const BRIGHT_WHITE = '#fbfcfd';
+
+export const TURBINE_FINISH: MaterialFinish = {
+ color: BRIGHT_WHITE,
+ emissive: THEME.turbineWhite,
+ emissiveIntensity: 0.42,
+ roughness: 0.42,
+ metalness: 0,
+};
+
+export const NACELLE_FINISH: MaterialFinish = { ...TURBINE_FINISH, vertexColors: true };
+
+export const DEFICIT_SHADE = { colour: '#8ea4bd', full: 0.45 } as const;
+
+export const TURBINE_WIDEN = { halfWidth: 1.9, perMetre: 0.0011 } as const;
+
+export const BLADE_WIDEN = {
+ halfWidth: 1.25,
+ perMetre: 0.0008,
+ rootWeight: 1.3,
+ tipWeight: 0.7,
+} as const;
+
+export const DISC = {
+ segments: 40,
+ maxOpacity: 0.7,
+ opacityStep: 0.05,
+ glow: 0.6,
+ edgeBoost: 2,
+} as const;
+
+export const ROTOR_TILT = { minFacing: 0.45 } as const;
+
+export const DISC_FINISH: MaterialFinish = {
+ color: THEME.turbineWhite,
+ emissive: THEME.turbineWhite,
+ emissiveIntensity: DISC.glow,
+ roughness: 0.9,
+ metalness: 0,
+ transparent: true,
+ depthWrite: false,
+ side: DoubleSide,
+};
+
+export const CONTACT_SHADOW = {
+ length: 130,
+ width: 40,
+ baseShare: 0.12,
+ opacity: 0.6,
+ lift: GROUND_LIFT_M + 0.3,
+ alongSteps: 8,
+ acrossSteps: 4,
+ fullSizeDistance: 4000,
+ maxScale: 3,
+ scaleStep: 0.25,
+} as const;
diff --git a/explainers/wind-farm/src/scene/parts/farm/widening.ts b/explainers/wind-farm/src/scene/parts/farm/widening.ts
new file mode 100644
index 00000000..65860e34
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/widening.ts
@@ -0,0 +1,69 @@
+import { BufferAttribute, Vector3 } from 'three';
+import type { BufferGeometry, MeshStandardMaterial } from 'three';
+import { createMaterial } from '@core/scene/materials';
+import type { MaterialFinish } from '@core/scene/materials';
+import { registeredMaterial } from '../context';
+import type { EmphasisGroup, PartContext } from '../context';
+
+const CACHE_KEY = 'farmWidening';
+const DECLARATIONS = 'attribute vec3 lateral;\nuniform float farmWiden;\nvoid main() {';
+const WIDEN_VERTEX = '#include \ntransformed += lateral * farmWiden;';
+const XYZ = 3;
+
+export interface WideningOptions {
+ readonly halfWidth: number;
+ readonly perMetre: number;
+}
+
+export type LateralRule = (point: Vector3, target: Vector3) => Vector3;
+
+export function wideningAt(cameraDistance: number, { halfWidth, perMetre }: WideningOptions) {
+ return Math.max(0, cameraDistance * perMetre - halfWidth);
+}
+
+export function withLateral(geometry: BufferGeometry, rule: LateralRule): BufferGeometry {
+ const position = geometry.getAttribute('position');
+ const laterals = new Float32Array(position.count * XYZ);
+ const point = new Vector3();
+ const side = new Vector3();
+ for (let index = 0; index < position.count; index += 1)
+ rule(point.fromBufferAttribute(position, index), side).toArray(laterals, index * XYZ);
+ geometry.setAttribute('lateral', new BufferAttribute(laterals, XYZ));
+ return geometry;
+}
+
+export function radialAroundY(point: Vector3, target: Vector3): Vector3 {
+ return target.set(point.x, 0, point.z).normalize();
+}
+
+export function radialAroundX(axisY: number): LateralRule {
+ return (point, target) => target.set(0, point.y - axisY, point.z).normalize();
+}
+
+export class Widening {
+ readonly material: MeshStandardMaterial;
+ private readonly widen = { value: 0 };
+ private readonly options: WideningOptions;
+
+ constructor(
+ context: PartContext,
+ group: EmphasisGroup,
+ finish: MaterialFinish,
+ options: WideningOptions,
+ ) {
+ this.options = options;
+ this.material = createMaterial(finish);
+ this.material.onBeforeCompile = (shader) => {
+ shader.uniforms.farmWiden = this.widen;
+ shader.vertexShader = shader.vertexShader
+ .replace('void main() {', DECLARATIONS)
+ .replace('#include ', WIDEN_VERTEX);
+ };
+ this.material.customProgramCacheKey = () => CACHE_KEY;
+ registeredMaterial(context, group, this.material);
+ }
+
+ update(cameraDistance: number): void {
+ this.widen.value = wideningAt(cameraDistance, this.options);
+ }
+}
diff --git a/explainers/wind-farm/src/scene/parts/farm/yard.ts b/explainers/wind-farm/src/scene/parts/farm/yard.ts
new file mode 100644
index 00000000..76f208a9
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/farm/yard.ts
@@ -0,0 +1,118 @@
+import { CylinderGeometry } from 'three';
+import type { BufferGeometry } from 'three';
+import type { Point } from '../../../ids';
+import { BUSBAR } from './gridConstants';
+import { strut, slab } from './geometry';
+import { radialAroundX, withLateral } from './widening';
+
+const ROUND_SEGMENTS = 10;
+const QUARTER_TURN = Math.PI / 2;
+const KNEE_M = 4.2;
+
+export const TRANSFORMER = {
+ plinth: [14, 0.7, 9.5] as Point,
+ body: [11.5, 7.6, 6.6] as Point,
+ fins: 8,
+ fin: [3, 5.8, 0.22] as Point,
+ finPitch: 0.82,
+ bushings: 3,
+ bushingRadius: 0.3,
+ bushingHeight: 3.3,
+ conservatorRadius: 0.8,
+ conservatorLength: 4.6,
+} as const;
+
+export const GANTRY = {
+ height: 20,
+ column: 0.8,
+ beam: 0.65,
+ brace: 0.3,
+} as const;
+
+export interface TransformerPieces {
+ readonly plinth: BufferGeometry;
+ readonly body: BufferGeometry[];
+ readonly fins: BufferGeometry[];
+ readonly bushings: BufferGeometry[];
+}
+
+function cylinder(radius: number, height: number, [x, y, z]: Point): BufferGeometry {
+ return new CylinderGeometry(radius, radius, height, ROUND_SEGMENTS).translate(
+ x,
+ y + height / 2,
+ z,
+ );
+}
+
+export function transformerPieces([x, z]: readonly [number, number]): TransformerPieces {
+ const [plinthX, plinthY, plinthZ] = TRANSFORMER.plinth;
+ const [bodyX, bodyY, bodyZ] = TRANSFORMER.body;
+ const [finX, finY, finZ] = TRANSFORMER.fin;
+ const top = plinthY + bodyY;
+ const east = x + bodyX / 2;
+ const fins = Array.from({ length: TRANSFORMER.fins }, (_, index) => {
+ const finCentreZ = z + (index - (TRANSFORMER.fins - 1) / 2) * TRANSFORMER.finPitch;
+ return slab(
+ [east, plinthY + (bodyY - finY) / 2, finCentreZ - finZ / 2],
+ [east + finX, plinthY + (bodyY + finY) / 2, finCentreZ + finZ / 2],
+ );
+ });
+ const conservator = new CylinderGeometry(
+ TRANSFORMER.conservatorRadius,
+ TRANSFORMER.conservatorRadius,
+ TRANSFORMER.conservatorLength,
+ ROUND_SEGMENTS,
+ )
+ .rotateX(QUARTER_TURN)
+ .translate(
+ x - bodyX / 2 + TRANSFORMER.conservatorRadius,
+ top + TRANSFORMER.conservatorRadius * 2,
+ z,
+ );
+ const bushings = Array.from({ length: TRANSFORMER.bushings }, (_, index) =>
+ cylinder(TRANSFORMER.bushingRadius, TRANSFORMER.bushingHeight, [
+ x,
+ top,
+ z + (index - (TRANSFORMER.bushings - 1) / 2) * (bodyZ / TRANSFORMER.bushings),
+ ]),
+ );
+ return {
+ plinth: slab(
+ [x - plinthX / 2, 0, z - plinthZ / 2],
+ [x + plinthX / 2, plinthY, z + plinthZ / 2],
+ ),
+ body: [
+ slab([x - bodyX / 2, plinthY, z - bodyZ / 2], [x + bodyX / 2, top, z + bodyZ / 2]),
+ conservator,
+ ],
+ fins,
+ bushings,
+ };
+}
+
+export function inflatingSlab(min: Point, max: Point): BufferGeometry {
+ const centreX = (min[0] + max[0]) / 2;
+ const centreZ = (min[2] + max[2]) / 2;
+ return withLateral(slab(min, max), (point, target) =>
+ target.set(Math.sign(point.x - centreX), 0, Math.sign(point.z - centreZ)),
+ );
+}
+
+export function gantry(x: number, halfSpan: number): BufferGeometry[] {
+ const { height, column, beam, brace } = GANTRY;
+ const legs = [-halfSpan, halfSpan].flatMap((z) => [
+ slab([x - column / 2, 0, z - column / 2], [x + column / 2, height, z + column / 2]),
+ strut([x, height - KNEE_M, z], [x, height - beam, z - Math.sign(z) * KNEE_M], brace),
+ ]);
+ const top = slab([x - beam / 2, height - beam, -halfSpan], [x + beam / 2, height, halfSpan]);
+ return [...legs, top];
+}
+
+export function busbar(fromX: number, toX: number, y: number, z: number): BufferGeometry {
+ const bar = new CylinderGeometry(BUSBAR.radius, BUSBAR.radius, toX - fromX, ROUND_SEGMENTS);
+ return withLateral(bar.rotateZ(QUARTER_TURN), radialAroundX(0)).translate(
+ (fromX + toX) / 2,
+ y,
+ z,
+ );
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/constants.ts b/explainers/wind-farm/src/scene/parts/hero/constants.ts
new file mode 100644
index 00000000..ca62a109
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/constants.ts
@@ -0,0 +1,33 @@
+import { TURBINE_GEOMETRY } from '../../../model/layout';
+
+export const TOWER = {
+ baseRadius: TURBINE_GEOMETRY.towerBaseDiameter / 2,
+ topRadius: TURBINE_GEOMETRY.towerTopDiameter / 2,
+ topY: TURBINE_GEOMETRY.towerTopY,
+ segments: 64,
+ rows: 12,
+ flangeSegments: 48,
+ flangeYs: [26, 52, 78],
+ flangeHeight: 0.22,
+ flangeProud: 0.045,
+ topFlange: { height: 0.32, proud: 0.14 },
+} as const;
+
+export const PLINTH = {
+ radius: TURBINE_GEOMETRY.foundationRadius,
+ chamferRadius: 4.65,
+ shoulderY: 0.32,
+ topY: 0.6,
+ buriedY: -0.4,
+ segments: 64,
+} as const;
+
+export const NACELLE = TURBINE_GEOMETRY.nacelle;
+export const COOLER = TURBINE_GEOMETRY.cooler;
+export const HUB = TURBINE_GEOMETRY.hub;
+export const SHAFT_Y = TURBINE_GEOMETRY.shaftY;
+
+export function towerRadiusAt(y: number): number {
+ const share = y / TOWER.topY;
+ return TOWER.baseRadius + (TOWER.topRadius - TOWER.baseRadius) * share;
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/cooler.ts b/explainers/wind-farm/src/scene/parts/hero/cooler.ts
new file mode 100644
index 00000000..8f9496bb
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/cooler.ts
@@ -0,0 +1,141 @@
+import { CylinderGeometry } from 'three';
+import type { BufferGeometry, Object3D } from 'three';
+import { box } from '@core/scene/geometry/box';
+import { mergeParts } from '../../geometry/merge';
+import { groupMesh, label, partMesh } from '../context';
+import type { PartContext } from '../context';
+import { COOLER, NACELLE } from './constants';
+
+const BASE = { bottomY: 106.0, topY: 107.0, halfWidth: 2.06 } as const;
+const FRAME = { post: 0.16, rim: 0.16, halfWidth: 2.0 } as const;
+const GRILLE = { inset: 0.12, thickness: 0.06 } as const;
+const FINS = { count: 26, thickness: 0.035, depth: 0.12, margin: 0.2 } as const;
+const MAST = { x: 7.9, radius: 0.05, height: 2.2, cup: 0.09, arm: 0.35, segments: 8 } as const;
+
+const TOP = COOLER.topY;
+
+function baseHalfGeometry(side: -1 | 1): BufferGeometry {
+ const edge = side * BASE.halfWidth;
+ return box({
+ minX: COOLER.minX,
+ maxX: COOLER.maxX,
+ minY: BASE.bottomY,
+ maxY: BASE.topY,
+ minZ: Math.min(0, edge),
+ maxZ: Math.max(0, edge),
+ });
+}
+
+function frameGeometry(): BufferGeometry {
+ const half = FRAME.post / 2;
+ const xs = [COOLER.minX + half, (COOLER.minX + COOLER.maxX) / 2, COOLER.maxX - half];
+ const posts = xs.flatMap((x) =>
+ [-1, 1].map((side) =>
+ box({
+ minX: x - half,
+ maxX: x + half,
+ minY: BASE.topY,
+ maxY: TOP,
+ minZ: side * FRAME.halfWidth - half,
+ maxZ: side * FRAME.halfWidth + half,
+ }),
+ ),
+ );
+ const rims = [-1, 1].flatMap((side) => [
+ box({
+ minX: COOLER.minX,
+ maxX: COOLER.maxX,
+ minY: TOP - FRAME.rim,
+ maxY: TOP,
+ minZ: side * FRAME.halfWidth - half,
+ maxZ: side * FRAME.halfWidth + half,
+ }),
+ box({
+ minX: side < 0 ? COOLER.minX : COOLER.maxX - FRAME.post,
+ maxX: side < 0 ? COOLER.minX + FRAME.post : COOLER.maxX,
+ minY: BASE.topY,
+ maxY: TOP,
+ minZ: -FRAME.halfWidth,
+ maxZ: FRAME.halfWidth,
+ }),
+ ]);
+ return mergeParts([...posts, ...rims, ...finGeometries(), mastGeometry()]);
+}
+
+function finGeometries(): BufferGeometry[] {
+ const from = COOLER.minX + FINS.margin;
+ const span = COOLER.maxX - FINS.margin - from;
+ return [-1, 1].flatMap((side) =>
+ Array.from({ length: FINS.count }, (_, index) => {
+ const x = from + (span * index) / (FINS.count - 1);
+ const z = side * (FRAME.halfWidth - GRILLE.inset);
+ return box({
+ minX: x - FINS.thickness / 2,
+ maxX: x + FINS.thickness / 2,
+ minY: BASE.topY,
+ maxY: TOP - FRAME.rim,
+ minZ: z - FINS.depth / 2,
+ maxZ: z + FINS.depth / 2,
+ });
+ }),
+ );
+}
+
+function grilleGeometry(): BufferGeometry {
+ const inner = FRAME.halfWidth - GRILLE.inset - FINS.depth / 2;
+ const sides = [-1, 1].map((side) =>
+ box({
+ minX: COOLER.minX + FRAME.post,
+ maxX: COOLER.maxX - FRAME.post,
+ minY: BASE.topY,
+ maxY: TOP - FRAME.rim,
+ minZ: side * inner - GRILLE.thickness / 2,
+ maxZ: side * inner + GRILLE.thickness / 2,
+ }),
+ );
+ const roof = box({
+ minX: COOLER.minX + FRAME.post,
+ maxX: COOLER.maxX - FRAME.post,
+ minY: TOP - FRAME.rim - GRILLE.thickness,
+ maxY: TOP - FRAME.rim,
+ minZ: -inner,
+ maxZ: inner,
+ });
+ return mergeParts([...sides, roof]);
+}
+
+function mastGeometry(): BufferGeometry {
+ const pole = new CylinderGeometry(MAST.radius, MAST.radius, MAST.height, MAST.segments);
+ pole.translate(MAST.x, TOP + MAST.height / 2, 0);
+ const arm = box({
+ minX: MAST.x - MAST.radius,
+ maxX: MAST.x + MAST.radius,
+ minY: TOP + MAST.height - MAST.radius,
+ maxY: TOP + MAST.height + MAST.radius,
+ minZ: -MAST.arm,
+ maxZ: MAST.arm,
+ });
+ const cups = [-1, 1].map((side) => {
+ const cup = new CylinderGeometry(MAST.cup, MAST.cup, MAST.cup * 1.6, MAST.segments);
+ cup.translate(MAST.x, TOP + MAST.height + MAST.cup, side * MAST.arm);
+ return cup;
+ });
+ return mergeParts([pole, arm, ...cups]);
+}
+
+export function buildCooler(
+ context: PartContext,
+ parent: Object3D,
+ openable: Object3D,
+): BufferGeometry {
+ const frame = frameGeometry();
+ const cooler = partMesh(context, frame, 'cooler');
+ cooler.add(
+ groupMesh(context, grilleGeometry(), 'cooler', 'grille'),
+ groupMesh(context, baseHalfGeometry(-1), 'cooler', 'paint'),
+ );
+ openable.add(groupMesh(context, baseHalfGeometry(1), 'cooler', 'paint'));
+ parent.add(cooler);
+ label(context, 'cooler', cooler, [(COOLER.minX + COOLER.maxX) / 2, TOP, NACELLE.halfWidth]);
+ return frame;
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/hero.test.ts b/explainers/wind-farm/src/scene/parts/hero/hero.test.ts
new file mode 100644
index 00000000..539cd7c0
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/hero.test.ts
@@ -0,0 +1,147 @@
+import { Mesh, Raycaster, Texture, Vector3 } from 'three';
+import type { Object3D } from 'three';
+import { describe, expect, it } from 'vitest';
+import { MaterialLibrary } from '@core/scene/materials';
+import { ResourceTracker } from '@core/scene/resources';
+import type { AssemblyState } from '../../../ids';
+import { TURBINE_COUNT, farmLayout } from '../../../model';
+import { CAMERA_VIEWS } from '../../cameraViews';
+import type { Motion, PartContext } from '../context';
+import { buildHero } from './index';
+
+const TRIANGLE_BUDGET = 60_000;
+const TIP_RADIUS = 75;
+const NACELLE_VIEW_TARGET = new Vector3(0, 105, 0);
+const NACELLE_VIEW_DISTANCES = [30, 60];
+const AZIMUTH_STEPS = 72;
+const PITCH_RANGE_DEG = [0, 90];
+const LABELS_BESIDE_BLADES = ['hub', 'pitchCylinders'] as const;
+
+function heroState(yawDeg: number): AssemblyState {
+ return {
+ scene: 'turbine',
+ phase: 0,
+ playing: false,
+ wind: { speed: 9, fromDeg: yawDeg, induction: 0.25 },
+ rotor: { rpm: 8, pitchDeg: 0, yawDeg, state: 'partial', braked: false, powerShare: 0.5 },
+ farm: {
+ spacing: 7,
+ sites: farmLayout(7),
+ deficits: Array.from({ length: TURBINE_COUNT }, () => 0.2),
+ plumeLengthD: 8,
+ plumeStrength: 0.6,
+ outputShare: 0.5,
+ },
+ view: { streamlines: false, wakes: true, cables: true, labels: true, cutaway: false },
+ };
+}
+
+function motion(azimuth: number, pitchDeg: number): Motion {
+ return { delta: 0, azimuth, pitchDeg, rpm: 8, cameraDistance: 300 };
+}
+
+function buildScene() {
+ const texture = new Texture();
+ const context: PartContext = {
+ materials: new MaterialLibrary(),
+ tracker: new ResourceTracker(),
+ textures: { dot: texture, glow: texture, shadow: texture, dispose: () => undefined },
+ labels: new Map(),
+ anchors: {},
+ };
+ return { context, hero: buildHero(context) };
+}
+
+function worldOf(object: Object3D | undefined): Vector3 {
+ object?.updateWorldMatrix(true, false);
+ return new Vector3().setFromMatrixPosition(object?.matrixWorld ?? new Mesh().matrixWorld);
+}
+
+function bladeTip(root: Object3D, index: number): Vector3 {
+ const pitch = root.getObjectByName(`bladePitch${index}`);
+ pitch?.updateWorldMatrix(true, false);
+ return new Vector3(0, TIP_RADIUS, 0).applyMatrix4(pitch?.matrixWorld ?? new Mesh().matrixWorld);
+}
+
+function bladeMeshes(context: PartContext, root: Object3D): Mesh[] {
+ const meshes: Mesh[] = [];
+ root.traverse((object) => {
+ if (!(object instanceof Mesh) || Array.isArray(object.material)) return;
+ if (context.materials.groupOf(object.material) === 'blades') meshes.push(object);
+ });
+ return meshes;
+}
+
+describe('buildHero', () => {
+ it('places the hub upwind of the tower at both yaw extremes', () => {
+ const { context, hero } = buildScene();
+ hero.setState(heroState(270));
+ expect(worldOf(context.anchors.hub).distanceTo(new Vector3(-7, 105, 0))).toBeCloseTo(0, 5);
+ hero.setState(heroState(0));
+ expect(worldOf(context.anchors.hub).distanceTo(new Vector3(0, 105, -7))).toBeCloseTo(0, 5);
+ });
+
+ it('turns the top tip toward +z as the azimuth grows', () => {
+ const { hero } = buildScene();
+ hero.setState(heroState(270));
+ hero.animate?.(motion(Math.PI / 2, 0), heroState(270));
+ const tip = bladeTip(hero.root, 0);
+ expect(tip.z).toBeCloseTo(TIP_RADIUS, 3);
+ expect(tip.y).toBeCloseTo(105, 3);
+ });
+
+ it('feathers the leading edge into the wind', () => {
+ const { hero } = buildScene();
+ hero.setState(heroState(270));
+ hero.animate?.(motion(0, 90), heroState(270));
+ const pitch = hero.root.getObjectByName('bladePitch0');
+ pitch?.updateWorldMatrix(true, false);
+ const leading = new Vector3(0, 30, 1).applyMatrix4(
+ pitch?.matrixWorld ?? new Mesh().matrixWorld,
+ );
+ expect(leading.x).toBeLessThan(-7.5);
+ });
+
+ it('stays within the triangle budget', () => {
+ const { hero } = buildScene();
+ let triangles = 0;
+ hero.root.traverse((object) => {
+ if (!(object instanceof Mesh)) return;
+ const geometry = object.geometry;
+ triangles += (geometry.index?.count ?? geometry.getAttribute('position').count) / 3;
+ });
+ expect(triangles).toBeLessThan(TRIANGLE_BUDGET);
+ });
+
+ it('keeps the hub and pitch cylinder labels clear of the turning blades in the nacelle view', () => {
+ const { context, hero } = buildScene();
+ const state = heroState(270);
+ hero.setState(state);
+ const blades = bladeMeshes(context, hero.root);
+ const raycaster = new Raycaster();
+ const crossings: string[] = [];
+ for (const direction of Object.values(CAMERA_VIEWS.nacelleCutaway.direction)) {
+ for (const distance of NACELLE_VIEW_DISTANCES) {
+ const camera = new Vector3(...direction)
+ .normalize()
+ .multiplyScalar(distance)
+ .add(NACELLE_VIEW_TARGET);
+ for (let step = 0; step < AZIMUTH_STEPS * PITCH_RANGE_DEG.length; step += 1) {
+ const pitchDeg = PITCH_RANGE_DEG[step % PITCH_RANGE_DEG.length];
+ hero.animate?.(motion((step * 2 * Math.PI) / AZIMUTH_STEPS, pitchDeg), state);
+ hero.root.updateMatrixWorld(true);
+ for (const part of LABELS_BESIDE_BLADES) {
+ const anchor = worldOf(context.labels.get(part));
+ const sight = camera.clone().sub(anchor);
+ raycaster.set(anchor, sight.clone().normalize());
+ raycaster.far = sight.length();
+ if (raycaster.intersectObjects(blades, false).length > 0) {
+ crossings.push(`${part} ${distance} m step ${step}`);
+ }
+ }
+ }
+ }
+ }
+ expect(crossings).toEqual([]);
+ });
+});
diff --git a/explainers/wind-farm/src/scene/parts/hero/index.ts b/explainers/wind-farm/src/scene/parts/hero/index.ts
new file mode 100644
index 00000000..94a8f534
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/index.ts
@@ -0,0 +1,65 @@
+import type { AssemblyState, Point } from '../../../ids';
+import { bearingTurn, label, namedGroup, sceneAnchor } from '../context';
+import type { Motion, PartContext, Section } from '../context';
+import { HUB, TOWER } from './constants';
+import { buildInterior } from './interior';
+import { buildNacelle } from './nacelle';
+import { buildRotor, turnRotor } from './rotor';
+import { buildShadow } from './shadow';
+import type { ShadowCaster } from './shadow';
+import { buildSpinner } from './spinner';
+import { buildSweptArea } from './sweptArea';
+import { buildTower } from './tower';
+import { buildTowerCable } from './towerCable';
+import { buildTransformer } from './transformer';
+import { buildHeroWake } from './wake';
+
+const PITCH_LABEL: Point = [HUB[0] + 1.4, HUB[1] - 0.3, 0.9];
+
+export function buildHero(context: PartContext): Section {
+ const root = namedGroup('hero');
+ const tower = buildTower(context, root);
+ buildTransformer(context, root);
+ buildTowerCable(context, root);
+ const yaw = namedGroup('yaw', root);
+ const nacelle = buildNacelle(context, yaw);
+ const spinner = buildSpinner(context, yaw);
+ const rotor = buildRotor(context, yaw);
+ buildSweptArea(context, yaw);
+ const wake = buildHeroWake(context, yaw);
+ const interior = buildInterior(context);
+ const nacelleInterior = namedGroup('nacelleInterior', yaw);
+ nacelleInterior.add(interior.root);
+ label(context, 'pitchCylinders', nacelleInterior, PITCH_LABEL);
+ sceneAnchor(context, 'hub', yaw, HUB);
+ sceneAnchor(context, 'yawPivot', yaw, [0, TOWER.topY, 0]);
+ const casters: ShadowCaster[] = [
+ { geometry: tower.shell, source: tower.mesh },
+ ...[...nacelle.casters, ...spinner.casters].map((geometry) => ({ geometry, source: yaw })),
+ ...rotor.pitchGroups.map((source) => ({ geometry: rotor.blade, source })),
+ ];
+ const shadow = buildShadow(context, root, casters);
+
+ return {
+ root,
+ setState(state: AssemblyState) {
+ yaw.rotation.y = bearingTurn(state.rotor.yawDeg);
+ yaw.updateMatrix();
+ const open = state.view.cutaway;
+ nacelle.openable.visible = !open;
+ nacelleInterior.visible = open;
+ spinner.openable.visible = !open;
+ rotor.pitchCylinders.forEach((cylinder) => (cylinder.visible = open));
+ wake.setState(state);
+ interior.setState(state);
+ shadow.update();
+ },
+ animate(motion: Motion) {
+ turnRotor(rotor, motion.azimuth, motion.pitchDeg);
+ wake.animate(motion.delta, motion.rpm);
+ interior.animate(motion);
+ shadow.update();
+ return false;
+ },
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/bedplate.ts b/explainers/wind-farm/src/scene/parts/hero/interior/bedplate.ts
new file mode 100644
index 00000000..2973deb0
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/bedplate.ts
@@ -0,0 +1,72 @@
+import { Path } from 'three';
+import type { BufferGeometry, Object3D } from 'three';
+import { extrudePlan, planShape } from '@core/scene/geometry/extrude';
+import type { PlanPoint } from '@core/scene/geometry/extrude';
+import { degrees, partMesh } from '../../context';
+import type { PartContext } from '../../context';
+import { DECK_Y, FLOOR_Y } from './constants';
+import { TORQUE_ARM_FOOT_Y, TORQUE_ARM_X } from './gearbox';
+import { arcPoints, circlePoints, merge, mirrored, SIDES, span } from './geometry';
+import { SECTOR_SPAN_DEG, SECTOR_TOP } from './yawDrives';
+
+const DECK_HALF_OUTLINE: readonly PlanPoint[] = [
+ { x: 2.4, z: 1.35 },
+ { x: -2.5, z: 1.35 },
+ { x: -3.0, z: 1.55 },
+ { x: -4.2, z: 1.55 },
+ { x: -4.4, z: 1.3 },
+];
+const ACCESS_HOLE = { radius: 0.8, segments: 32 } as const;
+const GIRDER = {
+ top: 103.88,
+ z: [1.15, 1.35],
+ runs: [
+ [-4.3, -1.6],
+ [1.6, 2.4],
+ ],
+} as const;
+const CROSS_RIBS = {
+ xs: [
+ [-2.75, -2.6],
+ [2.25, 2.4],
+ ],
+ top: 103.8,
+ z: 1.15,
+} as const;
+const SECTOR = { inner: 1.74, outer: 2.08, bottom: 103.46, segments: 14 } as const;
+const PEDESTAL = { grow: 0.07, z: [1.25, 1.95] } as const;
+
+function deck(): BufferGeometry {
+ const lower = [...DECK_HALF_OUTLINE].reverse().map(({ x, z }) => ({ x, z: -z }));
+ const shape = planShape([...DECK_HALF_OUTLINE, ...lower]);
+ shape.holes.push(new Path(circlePoints(ACCESS_HOLE.radius, ACCESS_HOLE.segments)));
+ return extrudePlan(shape, FLOOR_Y, DECK_Y);
+}
+
+function girders(): BufferGeometry[] {
+ const runs = SIDES.flatMap((side) =>
+ GIRDER.runs.map((run) => span(run, [DECK_Y, GIRDER.top], mirrored(GIRDER.z, side))),
+ );
+ const ribs = CROSS_RIBS.xs.map((x) =>
+ span(x, [DECK_Y, CROSS_RIBS.top], [-CROSS_RIBS.z, CROSS_RIBS.z]),
+ );
+ return [...runs, ...ribs];
+}
+
+function driveSector(side: number): BufferGeometry {
+ const [from, to] = SECTOR_SPAN_DEG.map((angle) => side * degrees(angle));
+ const outer = arcPoints(SECTOR.outer, from, to, SECTOR.segments);
+ const inner = arcPoints(SECTOR.inner, to, from, SECTOR.segments);
+ const shape = planShape([...outer, ...inner].map(({ x, y }) => ({ x, z: y })));
+ return extrudePlan(shape, SECTOR.bottom, SECTOR_TOP);
+}
+
+function pedestals(): BufferGeometry[] {
+ const x = [TORQUE_ARM_X[0] - PEDESTAL.grow, TORQUE_ARM_X[1] + PEDESTAL.grow] as const;
+ return SIDES.map((side) => span(x, [FLOOR_Y, TORQUE_ARM_FOOT_Y], mirrored(PEDESTAL.z, side)));
+}
+
+export function buildBedplate(context: PartContext, parent: Object3D): void {
+ const geometry = merge([deck(), ...girders(), ...SIDES.map(driveSector), ...pedestals()]);
+ parent.add(partMesh(context, geometry, 'bedplate'));
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/brake.ts b/explainers/wind-farm/src/scene/parts/hero/interior/brake.ts
new file mode 100644
index 00000000..1edc0ce4
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/brake.ts
@@ -0,0 +1,123 @@
+import { Path, Shape, Vector2 } from 'three';
+import type { BufferGeometry, Group, Object3D } from 'three';
+import { FULL_TURN } from '@core/math';
+import { STRUCTURE_GROUP } from '@core/scene/materials';
+import { extrudeProfileAlongX } from '@core/scene/geometry/extrude';
+import type { ProfilePoint } from '@core/scene/geometry/lathe';
+import { TURBINE_GEOMETRY } from '../../../../model/layout';
+import { FINISHES } from '../../../finishes';
+import { degrees, groupMesh, namedGroup, partMesh } from '../../context';
+import type { PartContext } from '../../context';
+import { AXIS_Y, SEGMENTS } from './constants';
+import { boltRing, circlePoints, merge, rodAlongX, SIDES, span, turned } from './geometry';
+
+export interface Brake {
+ readonly spin: Group;
+ setBraked(braked: boolean): void;
+}
+
+const DISC = {
+ x: TURBINE_GEOMETRY.brakeDisc.x,
+ radius: TURBINE_GEOMETRY.brakeDisc.radius,
+ halfThickness: 0.03,
+ holes: 8,
+ holeRadius: 0.06,
+ holeRing: 0.62,
+} as const;
+
+const DISC_HUB: readonly ProfilePoint[] = [
+ [2.5, 0],
+ [2.5, 0.26],
+ [2.7, 0.26],
+ [2.7, 0],
+];
+const HUB_BOLTS = { count: 6, radius: 0.19, x: 2.7, side: 1, head: 0.03, length: 0.04 } as const;
+
+const COUPLING_PROFILE: readonly ProfilePoint[] = [
+ [2.45, 0],
+ [2.45, 0.12],
+ [2.74, 0.12],
+ [2.74, 0.34],
+ [2.8, 0.34],
+ [2.8, 0.17],
+ [3.06, 0.17],
+ [3.06, 0.34],
+ [3.12, 0.34],
+ [3.12, 0.12],
+ [3.28, 0.12],
+ [3.28, 0],
+];
+const COUPLING_BOLTS = [
+ { count: 8, radius: 0.27, x: 2.8, side: 1, head: 0.028, length: 0.04 },
+ { count: 8, radius: 0.27, x: 3.06, side: -1, head: 0.028, length: 0.04, phase: FULL_TURN / 16 },
+] as const;
+
+const CALIPER_TURN = degrees(-55);
+const CALIPER = {
+ tangential: 0.19,
+ radial: [0.66, 0.98],
+ bridge: [0.92, 1.0],
+ inner: [2.47, 2.565],
+ outer: [2.635, 2.73],
+ bracket: { x: [2.4, 2.47], tangential: 0.12, radial: [0.6, 0.9] },
+ piston: { radius: 0.075, length: 0.04, offset: 0.09, radial: 0.8 },
+} as const;
+
+function discGeometry(): BufferGeometry {
+ const shape = new Shape(circlePoints(DISC.radius, SEGMENTS.disc));
+ for (let index = 0; index < DISC.holes; index++) {
+ const angle = (index / DISC.holes) * FULL_TURN;
+ const centre = new Vector2(Math.cos(angle), Math.sin(angle)).multiplyScalar(DISC.holeRing);
+ shape.holes.push(new Path(circlePoints(DISC.holeRadius, SEGMENTS.hole, centre)));
+ }
+ return extrudeProfileAlongX(shape, DISC.x - DISC.halfThickness, DISC.x + DISC.halfThickness);
+}
+
+function caliperGeometry(): BufferGeometry {
+ const { tangential, radial, bridge, inner, outer, bracket, piston } = CALIPER;
+ const across: readonly [number, number] = [-tangential, tangential];
+ const pistons = SIDES.flatMap((side) => [
+ rodAlongX([inner[0] - piston.length, inner[0]], piston.radius, SEGMENTS.small).translate(
+ 0,
+ side * piston.offset,
+ piston.radial,
+ ),
+ rodAlongX([outer[1], outer[1] + piston.length], piston.radius, SEGMENTS.small).translate(
+ 0,
+ side * piston.offset,
+ piston.radial,
+ ),
+ ]);
+ return merge([
+ span(inner, across, radial),
+ span(outer, across, radial),
+ span([inner[0], outer[1]], across, bridge),
+ span(bracket.x, [-bracket.tangential, bracket.tangential], bracket.radial),
+ ...pistons,
+ ])
+ .rotateX(CALIPER_TURN)
+ .translate(0, AXIS_Y, 0);
+}
+
+export function buildBrake(context: PartContext, parent: Object3D): Brake {
+ const spin = namedGroup('fastShaftSpin', parent);
+ spin.position.y = AXIS_Y;
+ const disc = merge([discGeometry(), turned(DISC_HUB, SEGMENTS.medium), ...boltRing(HUB_BOLTS)]);
+ spin.add(partMesh(context, disc, 'brakeDisc'));
+ const coupling = merge([
+ turned(COUPLING_PROFILE, SEGMENTS.medium),
+ ...COUPLING_BOLTS.flatMap(boltRing),
+ ]);
+ spin.add(groupMesh(context, coupling, STRUCTURE_GROUP, 'steel'));
+
+ const caliper = partMesh(context, caliperGeometry(), 'brakeDisc', 'hydraulic');
+ const resting = caliper.material;
+ const glowing = context.materials.get('brakeDisc', FINISHES.brakeGlow);
+ parent.add(caliper);
+ return {
+ spin,
+ setBraked: (braked) => {
+ caliper.material = braked ? glowing : resting;
+ },
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/constants.ts b/explainers/wind-farm/src/scene/parts/hero/interior/constants.ts
new file mode 100644
index 00000000..7127191f
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/constants.ts
@@ -0,0 +1,22 @@
+import { TURBINE_GEOMETRY } from '../../../../model/layout';
+
+export const AXIS_Y = TURBINE_GEOMETRY.shaftY;
+export const FLOOR_Y = TURBINE_GEOMETRY.nacelle.minY;
+export const DECK_Y = TURBINE_GEOMETRY.bedplate.y;
+export const ROOF_Y = TURBINE_GEOMETRY.nacelle.maxY;
+export const WALL_Z = TURBINE_GEOMETRY.nacelle.halfWidth;
+export const WALKWAY_Y = 103.61;
+export const FRAME_TOP_Y = 103.6;
+
+export const SEGMENTS = {
+ large: 40,
+ medium: 28,
+ small: 16,
+ drive: 12,
+ bolt: 6,
+ cable: 6,
+ disc: 64,
+ hole: 10,
+} as const;
+
+export const FAST_SHAFT_RATIO = 12;
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/converter.ts b/explainers/wind-farm/src/scene/parts/hero/interior/converter.ts
new file mode 100644
index 00000000..0a067ddc
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/converter.ts
@@ -0,0 +1,88 @@
+import type { BufferGeometry, Object3D } from 'three';
+import { TURBINE_GEOMETRY } from '../../../../model/layout';
+import { partMesh } from '../../context';
+import type { PartContext } from '../../context';
+import { FRAME_TOP_Y, SEGMENTS } from './constants';
+import { merge, span, turnedUp } from './geometry';
+import type { Range } from './geometry';
+
+const { minX, maxX } = TURBINE_GEOMETRY.converter;
+const BACK_Z = -2.05;
+const FRONT_Z = -1.28;
+const PLINTH_TOP = 103.72;
+const BODY_TOP = 106.1;
+const DOOR = { count: 3, inset: 0.04, gap: 0.03, y: [103.8, 106.02], depth: 0.015 } as const;
+const HANDLE = { fromEdge: 0.09, width: 0.03, y: [104.85, 105.15], depth: 0.03 } as const;
+const VENT = {
+ bands: [104.0, 105.55],
+ slats: 5,
+ pitch: 0.07,
+ height: 0.035,
+ margin: 0.1,
+ depth: 0.025,
+} as const;
+const FAN = { xs: [6.5, 7.5], radius: 0.2, height: 0.12 } as const;
+
+const FACE_Z = FRONT_Z + DOOR.depth;
+const DOOR_WIDTH = (maxX - minX - 2 * DOOR.inset - (DOOR.count - 1) * DOOR.gap) / DOOR.count;
+const CENTRE_Z = (BACK_Z + FRONT_Z) / 2;
+
+function doorSpans(): Range[] {
+ return Array.from({ length: DOOR.count }, (_, index) => {
+ const start = minX + DOOR.inset + index * (DOOR_WIDTH + DOOR.gap);
+ return [start, start + DOOR_WIDTH];
+ });
+}
+
+function cabinet(): BufferGeometry {
+ return merge([
+ span([minX, maxX], [PLINTH_TOP, BODY_TOP], [BACK_Z, FRONT_Z]),
+ ...doorSpans().map((door) => span(door, DOOR.y, [FRONT_Z, FACE_Z])),
+ ]);
+}
+
+function vents(): BufferGeometry[] {
+ const slats = doorSpans().flatMap(([start, end]) =>
+ VENT.bands.flatMap((band) =>
+ Array.from({ length: VENT.slats }, (_, index) => {
+ const bottom = band + index * VENT.pitch;
+ return span(
+ [start + VENT.margin, end - VENT.margin],
+ [bottom, bottom + VENT.height],
+ [FACE_Z, FACE_Z + VENT.depth],
+ );
+ }),
+ ),
+ );
+ const fans = FAN.xs.map((x) =>
+ turnedUp(
+ [
+ [BODY_TOP, 0],
+ [BODY_TOP, FAN.radius],
+ [BODY_TOP + FAN.height, FAN.radius],
+ [BODY_TOP + FAN.height, 0],
+ ],
+ SEGMENTS.small,
+ ).translate(x, 0, CENTRE_Z),
+ );
+ return [span([minX, maxX], [FRAME_TOP_Y, PLINTH_TOP], [BACK_Z, FRONT_Z]), ...slats, ...fans];
+}
+
+function handles(): BufferGeometry[] {
+ return doorSpans().map(([, end]) =>
+ span([end - HANDLE.fromEdge - HANDLE.width, end - HANDLE.fromEdge], HANDLE.y, [
+ FACE_Z,
+ FACE_Z + HANDLE.depth,
+ ]),
+ );
+}
+
+export function buildConverter(context: PartContext, parent: Object3D): void {
+ parent.add(
+ partMesh(context, cabinet(), 'converter'),
+ partMesh(context, merge(vents()), 'converter', 'castIron'),
+ partMesh(context, merge(handles()), 'converter', 'steel'),
+ );
+}
+
+export const CONVERTER_FACE_Z = FACE_Z;
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/frame.ts b/explainers/wind-farm/src/scene/parts/hero/interior/frame.ts
new file mode 100644
index 00000000..c62372cd
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/frame.ts
@@ -0,0 +1,63 @@
+import type { BufferGeometry, Object3D } from 'three';
+import { STRUCTURE_GROUP } from '@core/scene/materials';
+import { extrudeProfileAlongX } from '@core/scene/geometry/extrude';
+import { TURBINE_GEOMETRY } from '../../../../model/layout';
+import { groupMesh } from '../../context';
+import type { PartContext } from '../../context';
+import { FLOOR_Y, FRAME_TOP_Y, WALKWAY_Y } from './constants';
+import { iBeamShape, merge, span } from './geometry';
+import type { Range } from './geometry';
+
+const REAR_X: Range = [TURBINE_GEOMETRY.bedplate.maxX - 0.1, TURBINE_GEOMETRY.nacelle.maxX - 0.1];
+const BEAM = { width: 0.22, bottom: FLOOR_Y, top: FRAME_TOP_Y, web: 0.04, flange: 0.04 } as const;
+const BEAM_ZS = [-1.65, -0.9, 0.9, 1.75] as const;
+const CROSS_BEAMS = { xs: [2.5, 4.4, 6.2, 8.2], half: 0.06, drop: 0.04, z: [-1.8, 1.85] } as const;
+
+const GRATING = { bar: 0.012, height: 0.04, pitchX: 0.3, pitchZ: 0.25 } as const;
+const REAR_WALKWAY = { x: [2.55, 8.2], z: [-1.2, 1.95] } as const;
+const FRONT_WALKWAY = { x: [-4.2, -1.65], z: [1.6, 2.0] } as const;
+const FRONT_RAILS = [
+ [1.58, 1.65],
+ [1.93, 2.0],
+] as const;
+
+function bars(from: number, to: number, pitch: number): number[] {
+ const count = Math.floor((to - from) / pitch);
+ const step = (to - from) / count;
+ return Array.from({ length: count + 1 }, (_, index) => from + index * step);
+}
+
+function grating(x: Range, z: Range): BufferGeometry[] {
+ const y: Range = [WALKWAY_Y, WALKWAY_Y + GRATING.height];
+ const along = bars(z[0], z[1], GRATING.pitchZ).map((barZ) =>
+ span(x, y, [barZ - GRATING.bar, barZ + GRATING.bar]),
+ );
+ const across = bars(x[0], x[1], GRATING.pitchX).map((barX) =>
+ span([barX - GRATING.bar, barX + GRATING.bar], y, z),
+ );
+ return [...along, ...across];
+}
+
+function rearFrame(): BufferGeometry {
+ const beams = BEAM_ZS.map((z) => extrudeProfileAlongX(iBeamShape(z, BEAM), REAR_X[0], REAR_X[1]));
+ const cross = CROSS_BEAMS.xs.map((x) =>
+ span(
+ [x - CROSS_BEAMS.half, x + CROSS_BEAMS.half],
+ [FLOOR_Y, FRAME_TOP_Y - CROSS_BEAMS.drop],
+ CROSS_BEAMS.z,
+ ),
+ );
+ const rails = FRONT_RAILS.map((z) => span(FRONT_WALKWAY.x, [FLOOR_Y, WALKWAY_Y], z));
+ return merge([...beams, ...cross, ...rails]);
+}
+
+export function buildFrame(context: PartContext, parent: Object3D): void {
+ const walkways = merge([
+ ...grating(REAR_WALKWAY.x, REAR_WALKWAY.z),
+ ...grating(FRONT_WALKWAY.x, FRONT_WALKWAY.z),
+ ]);
+ parent.add(
+ groupMesh(context, rearFrame(), STRUCTURE_GROUP, 'paintShade'),
+ groupMesh(context, walkways, STRUCTURE_GROUP, 'gridSteel'),
+ );
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/gearbox.ts b/explainers/wind-farm/src/scene/parts/hero/interior/gearbox.ts
new file mode 100644
index 00000000..6b8a6fd2
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/gearbox.ts
@@ -0,0 +1,130 @@
+import type { BufferGeometry, Object3D } from 'three';
+import { FULL_TURN } from '@core/math';
+import { extrudeProfileAlongX, roundedRectShape } from '@core/scene/geometry/extrude';
+import type { ProfilePoint } from '@core/scene/geometry/lathe';
+import { partMesh } from '../../context';
+import type { PartContext } from '../../context';
+import { AXIS_Y, SEGMENTS } from './constants';
+import { merge, mirrored, rodAlongX, rodUp, SIDES, span, turned } from './geometry';
+
+const PLANETARY_PROFILE: readonly ProfilePoint[] = [
+ [-1.0, 0.56],
+ [-1.0, 0.92],
+ [-0.92, 1.02],
+ [-0.92, 1.3],
+ [-0.84, 1.4],
+ [-0.72, 1.4],
+ [-0.72, 1.32],
+ [-0.24, 1.32],
+ [-0.24, 1.4],
+ [-0.14, 1.4],
+ [-0.14, 1.32],
+ [0.36, 1.32],
+ [0.36, 1.4],
+ [0.46, 1.4],
+ [0.56, 1.16],
+ [0.62, 1.16],
+ [0.62, 1.1],
+ [0.96, 1.1],
+ [0.96, 1.16],
+ [1.04, 1.16],
+ [1.04, 1.1],
+ [1.3, 1.1],
+ [1.3, 1.16],
+ [1.4, 1.16],
+ [1.4, 0.7],
+ [1.45, 0.7],
+ [1.45, 0],
+];
+
+const GUSSETS = { count: 12, x: [-0.72, 0.36], inner: 1.3, outer: 1.37, half: 0.02 } as const;
+const HELICAL = { x: [1.4, 2.4], z: 0.95, bottom: -1.15, top: 1.05, corner: 0.22 } as const;
+const SPLIT_FLANGE = { x: [1.36, 2.44], y: [-0.03, 0.05], z: 1.02 } as const;
+const HELICAL_RIBS = { xs: [1.65, 1.9, 2.15], half: 0.02, y: [-1.0, 0.9], z: [0.93, 1.0] } as const;
+const OUTPUT_BOSS: readonly ProfilePoint[] = [
+ [2.38, 0],
+ [2.38, 0.4],
+ [2.47, 0.4],
+ [2.47, 0],
+];
+const LAYSHAFT_COVER: readonly ProfilePoint[] = [
+ [2.38, 0],
+ [2.38, 0.26],
+ [2.44, 0.26],
+ [2.44, 0],
+];
+const LAYSHAFT_Y = 0.62;
+const OIL_FILTER = {
+ x: 0.95,
+ z: 0.5,
+ y: [0.9, 1.32],
+ radius: 0.14,
+ cap: 0.16,
+ capTop: 1.37,
+} as const;
+const TORQUE_ARM = { x: [-0.38, -0.02], y: [-0.86, -0.44], z: [1.1, 1.7] } as const;
+const ARM_BUSH = { x: [-0.4, 0], y: -0.65, z: 1.7, radius: 0.21 } as const;
+
+function gussets(): BufferGeometry[] {
+ const { count, x, inner, outer, half } = GUSSETS;
+ const rib = span(x, [-half, half], [inner, outer]);
+ const ribs = Array.from({ length: count }, (_, index) =>
+ rib.clone().rotateX(((index + 0.5) / count) * FULL_TURN),
+ );
+ rib.dispose();
+ return ribs;
+}
+
+function helicalStage(): BufferGeometry[] {
+ const { x, z, bottom, top, corner } = HELICAL;
+ const casing = roundedRectShape({ minA: -z, maxA: z, minB: bottom, maxB: top }, corner);
+ const ribs = HELICAL_RIBS.xs.flatMap((ribX) =>
+ SIDES.map((side) =>
+ span(
+ [ribX - HELICAL_RIBS.half, ribX + HELICAL_RIBS.half],
+ HELICAL_RIBS.y,
+ mirrored(HELICAL_RIBS.z, side),
+ ),
+ ),
+ );
+ return [
+ extrudeProfileAlongX(casing, x[0], x[1]),
+ span(SPLIT_FLANGE.x, SPLIT_FLANGE.y, [-SPLIT_FLANGE.z, SPLIT_FLANGE.z]),
+ turned(OUTPUT_BOSS, SEGMENTS.medium),
+ turned(LAYSHAFT_COVER, SEGMENTS.medium).translate(0, LAYSHAFT_Y, 0),
+ ...ribs,
+ ];
+}
+
+function oilFilter(): BufferGeometry[] {
+ const { x, z, y, radius, cap, capTop } = OIL_FILTER;
+ return [
+ rodUp(y, radius, SEGMENTS.small).translate(x, 0, z),
+ rodUp([y[1], capTop], cap, SEGMENTS.small).translate(x, 0, z),
+ ];
+}
+
+function torqueArms(): BufferGeometry[] {
+ return SIDES.flatMap((side) => [
+ span(TORQUE_ARM.x, TORQUE_ARM.y, mirrored(TORQUE_ARM.z, side)),
+ rodAlongX(ARM_BUSH.x, ARM_BUSH.radius, SEGMENTS.small).translate(
+ 0,
+ ARM_BUSH.y,
+ side * ARM_BUSH.z,
+ ),
+ ]);
+}
+
+export const TORQUE_ARM_FOOT_Y = AXIS_Y + TORQUE_ARM.y[0];
+export const TORQUE_ARM_X = TORQUE_ARM.x;
+
+export function buildGearbox(context: PartContext, parent: Object3D): void {
+ const geometry = merge([
+ turned(PLANETARY_PROFILE, SEGMENTS.large),
+ ...gussets(),
+ ...helicalStage(),
+ ...oilFilter(),
+ ...torqueArms(),
+ ]).translate(0, AXIS_Y, 0);
+ parent.add(partMesh(context, geometry, 'gearbox'));
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/generator.ts b/explainers/wind-farm/src/scene/parts/hero/interior/generator.ts
new file mode 100644
index 00000000..396f1230
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/generator.ts
@@ -0,0 +1,102 @@
+import type { BufferGeometry, Object3D } from 'three';
+import { FULL_TURN } from '@core/math';
+import type { ProfilePoint } from '@core/scene/geometry/lathe';
+import { partMesh } from '../../context';
+import type { PartContext } from '../../context';
+import { AXIS_Y, FRAME_TOP_Y, SEGMENTS } from './constants';
+import { merge, span, turned } from './geometry';
+
+const SHELL_PROFILE: readonly ProfilePoint[] = [
+ [3.4, 0.74],
+ [3.4, 0.84],
+ [3.5, 0.84],
+ [3.5, 0.78],
+ [5.3, 0.78],
+ [5.3, 0.84],
+ [5.4, 0.84],
+ [5.4, 0.74],
+];
+
+const DRIVE_END_PROFILE: readonly ProfilePoint[] = [
+ [3.2, 0],
+ [3.2, 0.32],
+ [3.27, 0.32],
+ [3.27, 0.5],
+ [3.4, 0.76],
+];
+
+const FAR_END_PROFILE: readonly ProfilePoint[] = [
+ [5.4, 0.76],
+ [5.5, 0.62],
+ [5.5, 0.4],
+ [5.6, 0.4],
+ [5.6, 0],
+];
+
+const FINS = { count: 20, x: [3.56, 5.24], half: 0.0175, radial: [0.76, 0.9] } as const;
+const FOOT_BASE = FRAME_TOP_Y - AXIS_Y;
+const FEET = {
+ xs: [3.7, 5.1],
+ saddle: { half: 0.15, top: -0.55, z: 0.72 },
+ plate: { half: 0.25, thickness: 0.08, z: 1.05 },
+} as const;
+const TERMINAL_BOX = {
+ x: [4.15, 4.75],
+ y: [0.78, 1.18],
+ z: 0.35,
+ lid: { grow: 0.03, thickness: 0.04 },
+} as const;
+
+function fins(): BufferGeometry[] {
+ const { count, x, half, radial } = FINS;
+ const fin = span(x, [-half, half], radial);
+ const all = Array.from({ length: count }, (_, index) =>
+ fin.clone().rotateX(((index + 0.5) / count) * FULL_TURN),
+ );
+ fin.dispose();
+ return all;
+}
+
+function feet(): BufferGeometry[] {
+ const { saddle, plate } = FEET;
+ return FEET.xs.flatMap((x) => [
+ span([x - saddle.half, x + saddle.half], [FOOT_BASE, saddle.top], [-saddle.z, saddle.z]),
+ span(
+ [x - plate.half, x + plate.half],
+ [FOOT_BASE, FOOT_BASE + plate.thickness],
+ [-plate.z, plate.z],
+ ),
+ ]);
+}
+
+function terminalBox(): BufferGeometry[] {
+ const { x, y, z, lid } = TERMINAL_BOX;
+ return [
+ span(x, y, [-z, z]),
+ span(
+ [x[0] - lid.grow, x[1] + lid.grow],
+ [y[1], y[1] + lid.thickness],
+ [-z - lid.grow, z + lid.grow],
+ ),
+ ];
+}
+
+export function buildGenerator(context: PartContext, parent: Object3D): void {
+ const windings = merge([turned(SHELL_PROFILE, SEGMENTS.large), ...fins()]).translate(
+ 0,
+ AXIS_Y,
+ 0,
+ );
+ const shields = merge([
+ turned(DRIVE_END_PROFILE, SEGMENTS.large),
+ turned(FAR_END_PROFILE, SEGMENTS.large),
+ ]).translate(0, AXIS_Y, 0);
+ const fittings = merge([...feet(), ...terminalBox()]).translate(0, AXIS_Y, 0);
+ parent.add(
+ partMesh(context, windings, 'generator'),
+ partMesh(context, shields, 'generator', 'steel'),
+ partMesh(context, fittings, 'generator', 'castIron'),
+ );
+}
+
+export const TERMINAL_BOX_TOP = AXIS_Y + TERMINAL_BOX.y[1];
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/geometry.test.ts b/explainers/wind-farm/src/scene/parts/hero/interior/geometry.test.ts
new file mode 100644
index 00000000..b84fa50b
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/geometry.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+import { turned } from './geometry';
+
+const STEPPED: readonly (readonly [number, number])[] = [
+ [0, 0],
+ [0, 1],
+ [1, 1],
+ [1, 0.5],
+ [2, 0.5],
+ [2, 0],
+];
+const SEGMENTS = 8;
+
+describe('turned', () => {
+ it('keeps finite normals across stepped corners', () => {
+ const normals = turned(STEPPED, SEGMENTS).getAttribute('normal').array;
+ expect(Array.from(normals).every(Number.isFinite)).toBe(true);
+ });
+
+ it('gives the stepped face an axial normal', () => {
+ const geometry = turned(STEPPED, SEGMENTS);
+ const positions = geometry.getAttribute('position');
+ const normals = geometry.getAttribute('normal');
+ const shoulder = Array.from({ length: positions.count }, (_, index) => index).filter(
+ (index) =>
+ positions.getX(index) === 1 &&
+ Math.hypot(positions.getY(index), positions.getZ(index)) > 0.9,
+ );
+ expect(shoulder.some((index) => normals.getX(index) > 0.99)).toBe(true);
+ });
+});
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/geometry.ts b/explainers/wind-farm/src/scene/parts/hero/interior/geometry.ts
new file mode 100644
index 00000000..75878bdd
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/geometry.ts
@@ -0,0 +1,144 @@
+import { CylinderGeometry, Shape, Vector2 } from 'three';
+import type { BufferGeometry } from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { FULL_TURN } from '@core/math';
+import { box } from '@core/scene/geometry/box';
+import { latheAlongX } from '@core/scene/geometry/lathe';
+import type { ProfilePoint } from '@core/scene/geometry/lathe';
+import { SEGMENTS } from './constants';
+
+export type Range = readonly [min: number, max: number];
+
+export interface BoltRing {
+ count: number;
+ radius: number;
+ x: number;
+ side: 1 | -1;
+ head: number;
+ length: number;
+ phase?: number;
+}
+
+const QUARTER_TURN = FULL_TURN / 4;
+const KEPT_ATTRIBUTES = ['position', 'normal'];
+const TOOTH_CORNERS = [0, 0.18, 0.5, 0.68];
+
+function flattened(geometry: BufferGeometry): BufferGeometry {
+ const flat = geometry.index ? geometry.toNonIndexed() : geometry;
+ if (flat !== geometry) geometry.dispose();
+ Object.keys(flat.attributes)
+ .filter((name) => !KEPT_ATTRIBUTES.includes(name))
+ .forEach((name) => flat.deleteAttribute(name));
+ return flat;
+}
+
+export function merge(parts: readonly BufferGeometry[]): BufferGeometry {
+ const flats = parts.map(flattened);
+ const merged = mergeGeometries(flats);
+ flats.forEach((part) => part.dispose());
+ return merged;
+}
+
+export function span(x: Range, y: Range, z: Range): BufferGeometry {
+ return box({ minX: x[0], maxX: x[1], minY: y[0], maxY: y[1], minZ: z[0], maxZ: z[1] });
+}
+
+export function turned(profile: readonly ProfilePoint[], segments: number): BufferGeometry {
+ const last = profile.length - 1;
+ const points = profile.flatMap(([axial, radius], index) => {
+ const point = new Vector2(radius, axial);
+ return index > 0 && index < last ? [point, point.clone()] : [point];
+ });
+ return latheAlongX(points, segments);
+}
+
+export function turnedUp(profile: readonly ProfilePoint[], segments: number): BufferGeometry {
+ return turned(profile, segments).rotateZ(QUARTER_TURN);
+}
+
+export function rodAlongX(x: Range, radius: number, segments: number): BufferGeometry {
+ const geometry = new CylinderGeometry(radius, radius, x[1] - x[0], segments);
+ geometry.rotateZ(QUARTER_TURN);
+ return geometry.translate((x[0] + x[1]) / 2, 0, 0);
+}
+
+export function rodUp(y: Range, radius: number, segments: number): BufferGeometry {
+ const geometry = new CylinderGeometry(radius, radius, y[1] - y[0], segments);
+ return geometry.translate(0, (y[0] + y[1]) / 2, 0);
+}
+
+export function boltRing(ring: BoltRing): BufferGeometry[] {
+ const { count, radius, x, side, head, length, phase = 0 } = ring;
+ const ends: Range = side > 0 ? [x, x + length] : [x - length, x];
+ const bolt = rodAlongX(ends, head, SEGMENTS.bolt);
+ const bolts = Array.from({ length: count }, (_, index) => {
+ const angle = phase + (index / count) * FULL_TURN;
+ return bolt.clone().translate(0, radius * Math.sin(angle), radius * Math.cos(angle));
+ });
+ bolt.dispose();
+ return bolts;
+}
+
+export function circlePoints(radius: number, count: number, centre = new Vector2()): Vector2[] {
+ return Array.from({ length: count }, (_, index) => {
+ const angle = (index / count) * FULL_TURN;
+ return new Vector2(centre.x + radius * Math.cos(angle), centre.y + radius * Math.sin(angle));
+ });
+}
+
+export function gearShape(root: number, tip: number, teeth: number): Shape {
+ const pitch = FULL_TURN / teeth;
+ const corners = TOOTH_CORNERS.map((fraction) => fraction * pitch);
+ const radii = [root, tip, tip, root];
+ const points = Array.from({ length: teeth }, (_, tooth) =>
+ corners.map((corner, index) => {
+ const angle = tooth * pitch + corner;
+ return new Vector2(radii[index] * Math.cos(angle), radii[index] * Math.sin(angle));
+ }),
+ ).flat();
+ return new Shape(points);
+}
+
+export function arcPoints(radius: number, from: number, to: number, count: number): Vector2[] {
+ return Array.from({ length: count + 1 }, (_, index) => {
+ const angle = from + ((to - from) * index) / count;
+ return new Vector2(radius * Math.cos(angle), radius * Math.sin(angle));
+ });
+}
+
+export type Side = 1 | -1;
+export const SIDES: readonly Side[] = [1, -1];
+
+export function mirrored(range: Range, side: Side): Range {
+ return side > 0 ? range : [-range[1], -range[0]];
+}
+
+export interface IBeam {
+ width: number;
+ bottom: number;
+ top: number;
+ web: number;
+ flange: number;
+}
+
+export function iBeamShape(centre: number, beam: IBeam): Shape {
+ const half = beam.width / 2;
+ const web = beam.web / 2;
+ const low = beam.bottom + beam.flange;
+ const high = beam.top - beam.flange;
+ const outline: readonly (readonly [number, number])[] = [
+ [-half, beam.bottom],
+ [half, beam.bottom],
+ [half, low],
+ [web, low],
+ [web, high],
+ [half, high],
+ [half, beam.top],
+ [-half, beam.top],
+ [-half, high],
+ [-web, high],
+ [-web, low],
+ [-half, low],
+ ];
+ return new Shape(outline.map(([a, b]) => new Vector2(centre + a, b)));
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/index.ts b/explainers/wind-farm/src/scene/parts/hero/interior/index.ts
new file mode 100644
index 00000000..c12f608f
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/index.ts
@@ -0,0 +1,55 @@
+import type { Group } from 'three';
+import type { AssemblyState, PartId, Point } from '../../../../ids';
+import { label, namedGroup } from '../../context';
+import type { Motion, PartContext } from '../../context';
+import { buildBedplate } from './bedplate';
+import { buildBrake } from './brake';
+import { AXIS_Y, DECK_Y, FAST_SHAFT_RATIO } from './constants';
+import { buildConverter, CONVERTER_FACE_Z } from './converter';
+import { buildFrame } from './frame';
+import { buildGearbox } from './gearbox';
+import { buildGenerator } from './generator';
+import { buildMainBearing } from './mainBearing';
+import { buildMainShaft } from './mainShaft';
+import { buildService } from './service';
+import { buildYawDrives, DRIVE_TOP } from './yawDrives';
+
+export interface Interior {
+ readonly root: Group;
+ setState(state: AssemblyState): void;
+ animate(motion: Motion): void;
+}
+
+const INTERIOR_LABELS: readonly (readonly [PartId, Point])[] = [
+ ['bedplate', [-2.2, DECK_Y, 1.2]],
+ ['mainBearing', [-3.3, AXIS_Y, 1.17]],
+ ['mainShaft', [-1.8, 104.7, 0.52]],
+ ['gearbox', [-0.5, 104.34, 1.14]],
+ ['brakeDisc', [2.6, 105.9, 0]],
+ ['generator', [3.8, 105.84, 0]],
+ ['converter', [7.6, 104.0, CONVERTER_FACE_Z]],
+ ['yawDrives', [-0.8, DRIVE_TOP, 1.72]],
+];
+
+export function buildInterior(context: PartContext): Interior {
+ const root = namedGroup('interior');
+ buildBedplate(context, root);
+ buildFrame(context, root);
+ buildYawDrives(context, root);
+ buildMainBearing(context, root);
+ const mainShaft = buildMainShaft(context, root);
+ buildGearbox(context, root);
+ const brake = buildBrake(context, root);
+ buildGenerator(context, root);
+ buildConverter(context, root);
+ buildService(context, root);
+ INTERIOR_LABELS.forEach(([part, point]) => label(context, part, root, point));
+ return {
+ root,
+ setState: (state) => brake.setBraked(state.rotor.braked),
+ animate: (motion) => {
+ mainShaft.rotation.x = motion.azimuth;
+ brake.spin.rotation.x = FAST_SHAFT_RATIO * motion.azimuth;
+ },
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/interior.test.ts b/explainers/wind-farm/src/scene/parts/hero/interior/interior.test.ts
new file mode 100644
index 00000000..9e3a0e4d
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/interior.test.ts
@@ -0,0 +1,71 @@
+import { InstancedMesh, Mesh } from 'three';
+import type { Object3D } from 'three';
+import { describe, expect, it } from 'vitest';
+import { MaterialLibrary } from '@core/scene/materials';
+import { ResourceTracker } from '@core/scene/resources';
+import type { SceneTextures } from '@core/scene/textures';
+import type { AssemblyState } from '../../../../ids';
+import { FINISHES } from '../../../finishes';
+import type { Motion, PartContext } from '../../context';
+import { FAST_SHAFT_RATIO } from './constants';
+import { buildInterior } from './index';
+
+const TRIANGLE_BUDGET = 30_000;
+const AZIMUTH = 0.4;
+
+function partContext(): PartContext {
+ return {
+ materials: new MaterialLibrary(),
+ tracker: new ResourceTracker(),
+ textures: {} as SceneTextures,
+ labels: new Map(),
+ anchors: {},
+ };
+}
+
+function triangles(root: Object3D): number {
+ let total = 0;
+ root.traverse((object) => {
+ if (!(object instanceof Mesh)) return;
+ const { index, attributes } = object.geometry;
+ const copies = object instanceof InstancedMesh ? object.count : 1;
+ total += ((index ? index.count : attributes.position.count) / 3) * copies;
+ });
+ return total;
+}
+
+function braked(value: boolean): AssemblyState {
+ return { rotor: { braked: value } } as AssemblyState;
+}
+
+describe('buildInterior', () => {
+ it('stays within the triangle budget', () => {
+ expect(triangles(buildInterior(partContext()).root)).toBeLessThan(TRIANGLE_BUDGET);
+ });
+
+ it('lights the caliper only while the rotor is braked', () => {
+ const context = partContext();
+ const interior = buildInterior(context);
+ const glow = context.materials.get('brakeDisc', FINISHES.brakeGlow);
+ const glowing = () => {
+ let found = false;
+ interior.root.traverse((object) => {
+ if (object instanceof Mesh && object.material === glow) found = true;
+ });
+ return found;
+ };
+ interior.setState(braked(true));
+ expect(glowing()).toBe(true);
+ interior.setState(braked(false));
+ expect(glowing()).toBe(false);
+ });
+
+ it('turns the fast shaft faster than the main shaft, both positively about x', () => {
+ const interior = buildInterior(partContext());
+ interior.animate({ azimuth: AZIMUTH } as Motion);
+ const main = interior.root.getObjectByName('mainShaftSpin');
+ const fast = interior.root.getObjectByName('fastShaftSpin');
+ expect(main?.rotation.x).toBeCloseTo(AZIMUTH);
+ expect(fast?.rotation.x).toBeCloseTo(AZIMUTH * FAST_SHAFT_RATIO);
+ });
+});
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/mainBearing.ts b/explainers/wind-farm/src/scene/parts/hero/interior/mainBearing.ts
new file mode 100644
index 00000000..29c24f2a
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/mainBearing.ts
@@ -0,0 +1,86 @@
+import { Shape, Vector2 } from 'three';
+import type { BufferGeometry, Object3D } from 'three';
+import { extrudeProfileAlongX } from '@core/scene/geometry/extrude';
+import type { ProfilePoint } from '@core/scene/geometry/lathe';
+import { partMesh } from '../../context';
+import type { PartContext } from '../../context';
+import { AXIS_Y, DECK_Y, SEGMENTS } from './constants';
+import { boltRing, merge, rodUp, turned } from './geometry';
+
+const HOUSING_PROFILE: readonly ProfilePoint[] = [
+ [-4.08, 0.6],
+ [-4.08, 0.78],
+ [-3.98, 0.78],
+ [-3.98, 1.08],
+ [-3.94, 1.2],
+ [-3.86, 1.2],
+ [-3.86, 1.15],
+ [-3.34, 1.15],
+ [-3.34, 1.2],
+ [-3.26, 1.2],
+ [-3.22, 1.08],
+ [-3.22, 0.78],
+ [-3.12, 0.78],
+ [-3.12, 0.6],
+];
+
+const FRONT_BOLTS = {
+ count: 12,
+ radius: 0.93,
+ x: -3.98,
+ side: -1,
+ head: 0.03,
+ length: 0.04,
+} as const;
+const REAR_BOLTS = {
+ count: 12,
+ radius: 0.93,
+ x: -3.22,
+ side: 1,
+ head: 0.03,
+ length: 0.04,
+} as const;
+
+const FOOT = { minX: -3.95, maxX: -3.25, plateTop: 103.68, webTop: 104.4 } as const;
+const FOOT_SECTION: readonly (readonly [z: number, y: number])[] = [
+ [-1.45, DECK_Y],
+ [1.45, DECK_Y],
+ [1.45, FOOT.plateTop],
+ [1.1, FOOT.plateTop],
+ [0.75, FOOT.webTop],
+ [-0.75, FOOT.webTop],
+ [-1.1, FOOT.plateTop],
+ [-1.45, FOOT.plateTop],
+];
+
+const HOLD_DOWN = { xs: [-3.85, -3.6, -3.35], z: 1.3, radius: 0.035, height: 0.07 } as const;
+
+function holdDownBolts(): BufferGeometry[] {
+ const bolt = rodUp(
+ [FOOT.plateTop, FOOT.plateTop + HOLD_DOWN.height],
+ HOLD_DOWN.radius,
+ SEGMENTS.bolt,
+ );
+ const bolts = HOLD_DOWN.xs.flatMap((x) =>
+ [-HOLD_DOWN.z, HOLD_DOWN.z].map((z) => bolt.clone().translate(x, 0, z)),
+ );
+ bolt.dispose();
+ return bolts;
+}
+
+function housing(): BufferGeometry {
+ return merge([
+ turned(HOUSING_PROFILE, SEGMENTS.large),
+ ...boltRing(FRONT_BOLTS),
+ ...boltRing(REAR_BOLTS),
+ ]).translate(0, AXIS_Y, 0);
+}
+
+function foot(): BufferGeometry {
+ const shape = new Shape(FOOT_SECTION.map(([z, y]) => new Vector2(z, y)));
+ return extrudeProfileAlongX(shape, FOOT.minX, FOOT.maxX);
+}
+
+export function buildMainBearing(context: PartContext, parent: Object3D): void {
+ parent.add(partMesh(context, merge([housing(), foot(), ...holdDownBolts()]), 'mainBearing'));
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/mainShaft.ts b/explainers/wind-farm/src/scene/parts/hero/interior/mainShaft.ts
new file mode 100644
index 00000000..3b7d4136
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/mainShaft.ts
@@ -0,0 +1,61 @@
+import type { Group, Object3D } from 'three';
+import type { ProfilePoint } from '@core/scene/geometry/lathe';
+import { namedGroup, partMesh } from '../../context';
+import type { PartContext } from '../../context';
+import { AXIS_Y, SEGMENTS } from './constants';
+import { boltRing, merge, turned } from './geometry';
+
+const SHAFT_PROFILE: readonly ProfilePoint[] = [
+ [-4.4, 0],
+ [-4.4, 0.95],
+ [-4.26, 0.95],
+ [-4.26, 0.74],
+ [-4.12, 0.6],
+ [-3.0, 0.6],
+ [-1.6, 0.56],
+ [-1.0, 0.56],
+ [-1.0, 0],
+];
+
+const SHRINK_DISC_PROFILE: readonly ProfilePoint[] = [
+ [-1.4, 0.56],
+ [-1.4, 0.82],
+ [-1.36, 0.86],
+ [-1.24, 0.86],
+ [-1.24, 0.78],
+ [-1.2, 0.78],
+ [-1.2, 0.86],
+ [-1.1, 0.86],
+ [-1.06, 0.82],
+ [-1.06, 0.56],
+];
+
+const FLANGE_BOLTS = {
+ count: 20,
+ radius: 0.85,
+ x: -4.26,
+ side: 1,
+ head: 0.035,
+ length: 0.06,
+} as const;
+const DISC_BOLTS = {
+ count: 16,
+ radius: 0.72,
+ x: -1.4,
+ side: -1,
+ head: 0.03,
+ length: 0.05,
+} as const;
+
+export function buildMainShaft(context: PartContext, parent: Object3D): Group {
+ const spin = namedGroup('mainShaftSpin', parent);
+ spin.position.y = AXIS_Y;
+ const geometry = merge([
+ turned(SHAFT_PROFILE, SEGMENTS.large),
+ turned(SHRINK_DISC_PROFILE, SEGMENTS.large),
+ ...boltRing(FLANGE_BOLTS),
+ ...boltRing(DISC_BOLTS),
+ ]);
+ spin.add(partMesh(context, geometry, 'mainShaft'));
+ return spin;
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/service.ts b/explainers/wind-farm/src/scene/parts/hero/interior/service.ts
new file mode 100644
index 00000000..d2b810b5
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/service.ts
@@ -0,0 +1,109 @@
+import { CatmullRomCurve3, TubeGeometry, Vector3 } from 'three';
+import type { BufferGeometry, Object3D } from 'three';
+import { STRUCTURE_GROUP } from '@core/scene/materials';
+import { extrudeProfileAlongX } from '@core/scene/geometry/extrude';
+import type { Point } from '../../../../ids';
+import { groupMesh } from '../../context';
+import type { PartContext } from '../../context';
+import { ROOF_Y, SEGMENTS, WALL_Z } from './constants';
+import { iBeamShape, merge, rodAlongX, span } from './geometry';
+import type { Range } from './geometry';
+
+const RAIL = {
+ x: [-3.8, 8.2],
+ beam: { width: 0.18, bottom: 106.44, top: 106.62, web: 0.03, flange: 0.035 },
+} as const;
+const HANGERS = { xs: [-3.4, -0.4, 2.6, 5.6, 7.9], half: 0.05, depth: 0.04 } as const;
+const HOIST = {
+ body: { x: [6.6, 7.1], y: [106.14, RAIL.beam.bottom], z: 0.18 },
+ chain: { half: 0.025, y: [105.78, 106.14] },
+ block: { half: 0.08, y: [105.66, 105.78], z: 0.06 },
+} as const;
+
+const TRAY = {
+ x: [-3.5, 5.9],
+ y: [105.85, 105.95],
+ z: [-WALL_Z + 0.05, -1.75],
+ skin: 0.02,
+} as const;
+const TRAY_BRACKETS = { every: 1.6, half: 0.03, drop: 0.05 } as const;
+const TRAY_CABLES = { zs: [-1.96, -1.9, -1.84], radius: 0.03, y: 105.9 } as const;
+
+const GENERATOR_CABLE = { radius: 0.04, segments: 20 } as const;
+const GENERATOR_CABLE_ROUTES: readonly (readonly Point[])[] = [-0.15, 0, 0.15].map(
+ (offset, index) => [
+ [4.45 + offset, 105.98, -0.2],
+ [4.55 + offset, 106.02, -0.9],
+ [5.6, 105.95 - index * 0.06, -1.45 - index * 0.08],
+ [6.05, 105.85 - index * 0.08, -1.55 - index * 0.08],
+ ],
+);
+
+function craneRail(): BufferGeometry {
+ const hangerTop = ROOF_Y;
+ return merge([
+ extrudeProfileAlongX(iBeamShape(0, RAIL.beam), RAIL.x[0], RAIL.x[1]),
+ ...HANGERS.xs.map((x) =>
+ span(
+ [x - HANGERS.half, x + HANGERS.half],
+ [RAIL.beam.top, hangerTop],
+ [-HANGERS.depth, HANGERS.depth],
+ ),
+ ),
+ ]);
+}
+
+function hoist(): BufferGeometry {
+ const { body, chain, block } = HOIST;
+ const centre = (body.x[0] + body.x[1]) / 2;
+ return merge([
+ span(body.x, body.y, [-body.z, body.z]),
+ span([centre - chain.half, centre + chain.half], chain.y, [-chain.half, chain.half]),
+ span([centre - block.half, centre + block.half], block.y, [-block.z, block.z]),
+ ]);
+}
+
+function tray(): BufferGeometry {
+ const { x, y, z, skin } = TRAY;
+ const floor: Range = [y[0], y[0] + skin];
+ const count = Math.floor((x[1] - x[0]) / TRAY_BRACKETS.every);
+ const brackets = Array.from({ length: count + 1 }, (_, index) => {
+ const at = x[0] + index * TRAY_BRACKETS.every;
+ return span(
+ [at - TRAY_BRACKETS.half, at + TRAY_BRACKETS.half],
+ [y[0] - TRAY_BRACKETS.drop, y[0]],
+ [-WALL_Z, z[1]],
+ );
+ });
+ return merge([
+ span(x, floor, z),
+ span(x, y, [z[0], z[0] + skin]),
+ span(x, y, [z[1] - skin, z[1]]),
+ ...brackets,
+ ]);
+}
+
+function cables(): BufferGeometry {
+ const runs = TRAY_CABLES.zs.map((z) =>
+ rodAlongX(TRAY.x, TRAY_CABLES.radius, SEGMENTS.cable).translate(0, TRAY_CABLES.y, z),
+ );
+ const leads = GENERATOR_CABLE_ROUTES.map((route) => {
+ const curve = new CatmullRomCurve3(route.map((point) => new Vector3(...point)));
+ return new TubeGeometry(
+ curve,
+ GENERATOR_CABLE.segments,
+ GENERATOR_CABLE.radius,
+ SEGMENTS.cable,
+ );
+ });
+ return merge([...runs, ...leads]);
+}
+
+export function buildService(context: PartContext, parent: Object3D): void {
+ parent.add(
+ groupMesh(context, craneRail(), STRUCTURE_GROUP, 'steel'),
+ groupMesh(context, hoist(), STRUCTURE_GROUP, 'castIron'),
+ groupMesh(context, tray(), STRUCTURE_GROUP, 'gridSteel'),
+ groupMesh(context, cables(), STRUCTURE_GROUP, 'cableDark'),
+ );
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/interior/yawDrives.ts b/explainers/wind-farm/src/scene/parts/hero/interior/yawDrives.ts
new file mode 100644
index 00000000..1967a06c
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/interior/yawDrives.ts
@@ -0,0 +1,74 @@
+import { Matrix4, Path } from 'three';
+import type { BufferGeometry, Object3D } from 'three';
+import { extrudePlan } from '@core/scene/geometry/extrude';
+import type { ProfilePoint } from '@core/scene/geometry/lathe';
+import { TURBINE_GEOMETRY } from '../../../../model/layout';
+import { degrees, instancedMesh, partMesh } from '../../context';
+import type { PartContext } from '../../context';
+import { FLOOR_Y, SEGMENTS } from './constants';
+import { circlePoints, gearShape, SIDES, turnedUp } from './geometry';
+
+const { radius: DRIVE_RADIUS } = TURBINE_GEOMETRY.yawDrives;
+const RING_GEAR = {
+ root: 1.62,
+ tip: 1.7,
+ teeth: 72,
+ bore: 1.4,
+ top: 103.46,
+ boreSegments: 48,
+} as const;
+const DRIVE_ANGLES_DEG = [50, 65, 115, 130] as const;
+
+export const SECTOR_TOP = 103.56;
+export const SECTOR_SPAN_DEG = [40, 140] as const;
+
+const DRIVE_PROFILE: readonly ProfilePoint[] = [
+ [FLOOR_Y, 0],
+ [FLOOR_Y, 0.19],
+ [SECTOR_TOP, 0.19],
+ [SECTOR_TOP, 0.27],
+ [103.62, 0.27],
+ [103.62, 0.2],
+ [103.95, 0.2],
+ [103.95, 0.23],
+ [104.0, 0.23],
+ [104.0, 0.17],
+ [104.24, 0.17],
+ [104.3, 0.12],
+ [104.3, 0],
+];
+
+export const DRIVE_TOP = 104.3;
+
+export function driveAngles(): number[] {
+ return SIDES.flatMap((side) => DRIVE_ANGLES_DEG.map((angle) => side * degrees(angle)));
+}
+
+export function drivePosition(angle: number): readonly [x: number, z: number] {
+ return [DRIVE_RADIUS * Math.cos(angle), DRIVE_RADIUS * Math.sin(angle)];
+}
+
+function ringGear(): BufferGeometry {
+ const shape = gearShape(RING_GEAR.root, RING_GEAR.tip, RING_GEAR.teeth);
+ shape.holes.push(new Path(circlePoints(RING_GEAR.bore, RING_GEAR.boreSegments)));
+ return extrudePlan(shape, FLOOR_Y, RING_GEAR.top);
+}
+
+export function buildYawDrives(context: PartContext, parent: Object3D): void {
+ parent.add(partMesh(context, ringGear(), 'yawDrives'));
+ const angles = driveAngles();
+ const drives = instancedMesh(
+ context,
+ turnedUp(DRIVE_PROFILE, SEGMENTS.drive),
+ 'yawDrives',
+ 'castIron',
+ angles.length,
+ );
+ drives.name = 'yawDrives';
+ const placement = new Matrix4();
+ angles.forEach((angle, index) => {
+ const [x, z] = drivePosition(angle);
+ drives.setMatrixAt(index, placement.makeTranslation(x, 0, z));
+ });
+ parent.add(drives);
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/nacelle.ts b/explainers/wind-farm/src/scene/parts/hero/nacelle.ts
new file mode 100644
index 00000000..a3be7dab
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/nacelle.ts
@@ -0,0 +1,36 @@
+import type { BufferGeometry, Group, Object3D } from 'three';
+import { finishMesh, label, namedGroup } from '../context';
+import type { PartContext } from '../context';
+import { FINISHES } from '../../finishes';
+import { NACELLE } from './constants';
+import { buildCooler } from './cooler';
+import { hatchGeometry, railGeometry } from './nacelleRoof';
+import { SEAM_FINISH, SHELL_FINISH, shellGeometries } from './nacelleShell';
+
+const LABEL_POINT = [1.0, NACELLE.maxY, -1.0] as const;
+
+export interface Nacelle {
+ readonly openable: Group;
+ readonly casters: readonly BufferGeometry[];
+}
+
+export function buildNacelle(context: PartContext, yaw: Object3D): Nacelle {
+ const shell = shellGeometries();
+ const nacelle = finishMesh(context, shell.closed, 'nacelle', SHELL_FINISH);
+ nacelle.name = 'nacelle';
+ const openable = namedGroup('nacelleOpenable', nacelle);
+ openable.add(
+ finishMesh(context, shell.open, 'nacelle', SHELL_FINISH),
+ finishMesh(context, shell.openSeams, 'nacelle', SEAM_FINISH),
+ finishMesh(context, railGeometry(1), 'nacelle', FINISHES.steel),
+ );
+ nacelle.add(
+ finishMesh(context, shell.closedSeams, 'nacelle', SEAM_FINISH),
+ finishMesh(context, railGeometry(-1), 'nacelle', FINISHES.steel),
+ finishMesh(context, hatchGeometry(), 'nacelle', FINISHES.paintShade),
+ );
+ yaw.add(nacelle);
+ label(context, 'nacelle', nacelle, [...LABEL_POINT]);
+ const cooler = buildCooler(context, yaw, openable);
+ return { openable, casters: [shell.closed, shell.open, cooler] };
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/nacelleProfile.ts b/explainers/wind-farm/src/scene/parts/hero/nacelleProfile.ts
new file mode 100644
index 00000000..b802886c
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/nacelleProfile.ts
@@ -0,0 +1,64 @@
+import { Vector2 } from 'three';
+import type { SweepCentre, SweepStation } from '../../geometry/sweep';
+import { NACELLE, SHAFT_Y } from './constants';
+
+const ARC_STEPS = 6;
+const QUARTER = Math.PI / 2;
+
+export const SHELL = {
+ roofCorner: 0.62,
+ floorCorner: 0.32,
+ innerScale: 0.955,
+} as const;
+
+export const SHELL_CENTRE: SweepCentre = { y: SHAFT_Y, z: 0 };
+
+export const SHELL_STATIONS: readonly SweepStation[] = [
+ { x: NACELLE.minX, scale: 0.84 },
+ { x: NACELLE.minX + 0.22, scale: 0.92 },
+ { x: NACELLE.minX + 0.6, scale: 0.975 },
+ { x: NACELLE.minX + 1.2, scale: 1 },
+ { x: NACELLE.maxX - 0.3, scale: 1 },
+ { x: NACELLE.maxX, scale: 0.965 },
+];
+
+function arc(
+ centreZ: number,
+ centreY: number,
+ radius: number,
+ from: number,
+ to: number,
+): Vector2[] {
+ return Array.from({ length: ARC_STEPS + 1 }, (_, step) => {
+ const angle = from + ((to - from) * step) / ARC_STEPS;
+ return new Vector2(centreZ + radius * Math.cos(angle), centreY + radius * Math.sin(angle));
+ });
+}
+
+const W = NACELLE.halfWidth;
+const TOP = NACELLE.maxY;
+const BOTTOM = NACELLE.minY;
+const R = SHELL.roofCorner;
+const F = SHELL.floorCorner;
+
+export const PANEL_PROFILES = {
+ roofPlus: [new Vector2(0, TOP), ...arc(W - R, TOP - R, R, QUARTER, 0)],
+ wallPlus: [new Vector2(W, TOP - R), new Vector2(W, BOTTOM + F)],
+ floor: [
+ ...arc(W - F, BOTTOM + F, F, 0, -QUARTER),
+ ...arc(-W + F, BOTTOM + F, F, -QUARTER, -2 * QUARTER),
+ ],
+ wallMinus: [new Vector2(-W, BOTTOM + F), new Vector2(-W, TOP - R)],
+ roofMinus: [...arc(-W + R, TOP - R, R, 2 * QUARTER, QUARTER), new Vector2(0, TOP)],
+} as const satisfies Record;
+
+export type PanelId = keyof typeof PANEL_PROFILES;
+
+const MERGE_DISTANCE = 1e-6;
+
+export function perimeter(): Vector2[] {
+ const points = Object.values(PANEL_PROFILES).flatMap((profile) => profile);
+ return points.filter(
+ (point, index) => point.distanceTo(points[(index + 1) % points.length]) > MERGE_DISTANCE,
+ );
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/nacelleRoof.ts b/explainers/wind-farm/src/scene/parts/hero/nacelleRoof.ts
new file mode 100644
index 00000000..a53ac886
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/nacelleRoof.ts
@@ -0,0 +1,73 @@
+import type { BufferGeometry } from 'three';
+import { box } from '@core/scene/geometry/box';
+import { mergeParts } from '../../geometry/merge';
+import { NACELLE } from './constants';
+
+const TOP = NACELLE.maxY;
+const HATCH = {
+ minX: 0.2,
+ maxX: 1.8,
+ minZ: -1.45,
+ maxZ: -0.35,
+ frame: 0.12,
+ lid: 0.06,
+ rim: 0.08,
+} as const;
+const RAIL = {
+ z: 1.62,
+ fromX: -3.0,
+ toX: 3.6,
+ posts: 7,
+ post: 0.06,
+ height: 1.05,
+ bars: [0.5, 1.05],
+ bar: 0.045,
+ sink: 0.06,
+} as const;
+
+export function hatchGeometry(): BufferGeometry {
+ const frame = box({
+ minX: HATCH.minX,
+ maxX: HATCH.maxX,
+ minY: TOP - HATCH.rim,
+ maxY: TOP + HATCH.frame,
+ minZ: HATCH.minZ,
+ maxZ: HATCH.maxZ,
+ });
+ const lid = box({
+ minX: HATCH.minX + HATCH.rim,
+ maxX: HATCH.maxX - HATCH.rim,
+ minY: TOP + HATCH.frame,
+ maxY: TOP + HATCH.frame + HATCH.lid,
+ minZ: HATCH.minZ + HATCH.rim,
+ maxZ: HATCH.maxZ - HATCH.rim,
+ });
+ return mergeParts([frame, lid]);
+}
+
+export function railGeometry(side: number): BufferGeometry {
+ const z = side * RAIL.z;
+ const span = RAIL.toX - RAIL.fromX;
+ const posts = Array.from({ length: RAIL.posts }, (_, index) => {
+ const x = RAIL.fromX + (span * index) / (RAIL.posts - 1);
+ return box({
+ minX: x - RAIL.post / 2,
+ maxX: x + RAIL.post / 2,
+ minY: TOP - RAIL.sink,
+ maxY: TOP + RAIL.height,
+ minZ: z - RAIL.post / 2,
+ maxZ: z + RAIL.post / 2,
+ });
+ });
+ const bars = RAIL.bars.map((height) =>
+ box({
+ minX: RAIL.fromX - RAIL.post / 2,
+ maxX: RAIL.toX + RAIL.post / 2,
+ minY: TOP + height - RAIL.bar,
+ maxY: TOP + height,
+ minZ: z - RAIL.bar / 2,
+ maxZ: z + RAIL.bar / 2,
+ }),
+ );
+ return mergeParts([...posts, ...bars]);
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/nacelleShell.ts b/explainers/wind-farm/src/scene/parts/hero/nacelleShell.ts
new file mode 100644
index 00000000..e0c58562
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/nacelleShell.ts
@@ -0,0 +1,106 @@
+import { DoubleSide, Path, Shape, ShapeGeometry, Vector2 } from 'three';
+import type { BufferGeometry } from 'three';
+import { mergeParts } from '../../geometry/merge';
+import { rimProfile, scaleAt, stationsBetween, sweepProfile } from '../../geometry/sweep';
+import type { SweepStation } from '../../geometry/sweep';
+import { FINISHES } from '../../finishes';
+import { NACELLE } from './constants';
+import { PANEL_PROFILES, SHELL, SHELL_CENTRE, SHELL_STATIONS, perimeter } from './nacelleProfile';
+import type { PanelId } from './nacelleProfile';
+
+export const SHELL_FINISH = { ...FINISHES.paint, side: DoubleSide };
+export const SEAM_FINISH = { ...FINISHES.paintShade, side: DoubleSide };
+
+const SHAFT_HOLE = { radius: 0.82, segments: 32 } as const;
+const SEAM = { xs: [-2.3, 0.9, 4.0, 6.3], halfWidth: 0.035, lift: 1.008 } as const;
+
+interface Piece {
+ readonly panel: PanelId;
+ readonly from: number;
+ readonly to: number;
+ readonly openable: boolean;
+}
+
+const PIECES: readonly Piece[] = [
+ { panel: 'floor', from: NACELLE.minX, to: NACELLE.maxX, openable: false },
+ { panel: 'wallMinus', from: NACELLE.minX, to: NACELLE.maxX, openable: false },
+ { panel: 'roofMinus', from: NACELLE.minX, to: NACELLE.maxX, openable: false },
+ { panel: 'wallPlus', from: NACELLE.minX, to: NACELLE.maxX, openable: true },
+ { panel: 'roofPlus', from: NACELLE.minX, to: NACELLE.maxX, openable: true },
+];
+
+function inward(point: Vector2): Vector2 {
+ const { y, z } = SHELL_CENTRE;
+ return new Vector2(z + (point.x - z) * SHELL.innerScale, y + (point.y - y) * SHELL.innerScale);
+}
+
+function pieceGeometry(piece: Piece): BufferGeometry {
+ const profile = PANEL_PROFILES[piece.panel];
+ const stations = stationsBetween(SHELL_STATIONS, piece.from, piece.to);
+ const inner = stations.map((station) => ({
+ ...station,
+ scale: station.scale * SHELL.innerScale,
+ }));
+ const edges = [profile[0], profile[profile.length - 1]].map((point) =>
+ sweepProfile([point, inward(point)], stations, SHELL_CENTRE),
+ );
+ const rims = [stations[0], stations[stations.length - 1]].map((station) =>
+ rimProfile(profile, station, SHELL.innerScale, SHELL_CENTRE),
+ );
+ return mergeParts([
+ sweepProfile(profile, stations, SHELL_CENTRE),
+ sweepProfile(profile, inner, SHELL_CENTRE),
+ ...edges,
+ ...rims,
+ ]);
+}
+
+function seamGeometry(piece: Piece): BufferGeometry[] {
+ const profile = PANEL_PROFILES[piece.panel];
+ return SEAM.xs
+ .filter((x) => x - SEAM.halfWidth > piece.from && x + SEAM.halfWidth < piece.to)
+ .map((x) => {
+ const scale = scaleAt(SHELL_STATIONS, x) * SEAM.lift;
+ const lifted = (offset: number): SweepStation => ({ x: x + offset, scale });
+ return sweepProfile(profile, [lifted(-SEAM.halfWidth), lifted(SEAM.halfWidth)], SHELL_CENTRE);
+ });
+}
+
+function capGeometry(station: SweepStation, hole: boolean): BufferGeometry {
+ const { y, z } = SHELL_CENTRE;
+ const outline = perimeter().map(
+ (point) => new Vector2(z + (point.x - z) * station.scale, y + (point.y - y) * station.scale),
+ );
+ const shape = new Shape(outline);
+ if (hole) {
+ const circle = new Path();
+ circle.absarc(z, y, SHAFT_HOLE.radius, 0, Math.PI * 2, true);
+ shape.holes.push(circle);
+ }
+ const geometry = new ShapeGeometry(shape, SHAFT_HOLE.segments);
+ geometry.rotateY(-Math.PI / 2);
+ geometry.translate(station.x, 0, 0);
+ return geometry;
+}
+
+export interface ShellGeometries {
+ readonly closed: BufferGeometry;
+ readonly open: BufferGeometry;
+ readonly closedSeams: BufferGeometry;
+ readonly openSeams: BufferGeometry;
+}
+
+export function shellGeometries(): ShellGeometries {
+ const closedPieces = PIECES.filter((piece) => !piece.openable);
+ const openPieces = PIECES.filter((piece) => piece.openable);
+ const caps = [
+ capGeometry(SHELL_STATIONS[0], true),
+ capGeometry(SHELL_STATIONS[SHELL_STATIONS.length - 1], false),
+ ];
+ return {
+ closed: mergeParts([...closedPieces.map(pieceGeometry), ...caps]),
+ open: mergeParts(openPieces.map(pieceGeometry)),
+ closedSeams: mergeParts(closedPieces.flatMap(seamGeometry)),
+ openSeams: mergeParts(openPieces.flatMap(seamGeometry)),
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/rotor.ts b/explainers/wind-farm/src/scene/parts/hero/rotor.ts
new file mode 100644
index 00000000..24fa5796
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/rotor.ts
@@ -0,0 +1,114 @@
+import { Vector3 } from 'three';
+import type { BufferGeometry, Group, Mesh, Object3D } from 'three';
+import { latheAlongX, sampleProfile } from '@core/scene/geometry/lathe';
+import type { ProfilePoint } from '@core/scene/geometry/lathe';
+import { bandGeometry } from '../../geometry/band';
+import { bladeGeometries } from '../../geometry/blade';
+import { cylinderBetween } from '../../geometry/cylinder';
+import { mergeParts } from '../../geometry/merge';
+import { FINISHES } from '../../finishes';
+import { degrees, finishMesh, groupMesh, label, namedGroup, partMesh } from '../context';
+import type { PartContext } from '../context';
+import { HUB } from './constants';
+
+const BLADE_COUNT = 3;
+const BAND_FINISH = {
+ ...FINISHES.paintShade,
+ polygonOffset: true,
+ polygonOffsetFactor: -1,
+ polygonOffsetUnits: -1,
+};
+const BLADE_LABEL_RADIUS = 52;
+const HUB_BODY: readonly ProfilePoint[] = [
+ [-1.05, 0],
+ [-0.95, 0.55],
+ [-0.6, 0.92],
+ [0.6, 0.95],
+ [1.1, 0.8],
+ [1.3, 0.62],
+];
+const SHAFT = { from: 1.2, to: 2.75, radius: 0.6, segments: 24 } as const;
+const BEARING = { inner: 1.02, outer: 1.24, bottom: 1.15, top: 1.5, segments: 32 } as const;
+const NECK = { bottom: 0.6, top: 1.2, radius: 0.75, segments: 24 } as const;
+const COLLAR = { inner: 0.7, outer: 1.12, bottom: 1.08, top: 1.2 } as const;
+const PITCH_CYLINDER = {
+ start: new Vector3(-0.78, 0.5, -0.62),
+ end: new Vector3(-0.98, 1.28, 0.7),
+ bodyShare: 0.62,
+ body: 0.11,
+ rod: 0.05,
+ segments: 10,
+} as const;
+
+export interface Rotor {
+ readonly root: Group;
+ readonly pitchGroups: readonly Group[];
+ readonly pitchCylinders: readonly Mesh[];
+ readonly blade: BufferGeometry;
+}
+
+function hubGeometry(): BufferGeometry {
+ const body = latheAlongX(sampleProfile(HUB_BODY, 12), 32);
+ const shaft = cylinderBetween(
+ new Vector3(SHAFT.from, 0, 0),
+ new Vector3(SHAFT.to, 0, 0),
+ SHAFT.radius,
+ SHAFT.segments,
+ );
+ const necks = Array.from({ length: BLADE_COUNT }, (_, index) => {
+ const neck = bandGeometry(
+ { inner: 0, outer: NECK.radius, bottom: NECK.bottom, top: NECK.top },
+ NECK.segments,
+ );
+ const collar = bandGeometry(COLLAR, NECK.segments);
+ const turned = mergeParts([neck, collar]);
+ turned.rotateX((index * 2 * Math.PI) / BLADE_COUNT);
+ return turned;
+ });
+ return mergeParts([body, shaft, ...necks]);
+}
+
+function pitchCylinderGeometry(): BufferGeometry {
+ const { start, end, bodyShare } = PITCH_CYLINDER;
+ const split = start.clone().lerp(end, bodyShare);
+ return mergeParts([
+ cylinderBetween(start, split, PITCH_CYLINDER.body, PITCH_CYLINDER.segments),
+ cylinderBetween(split, end, PITCH_CYLINDER.rod, PITCH_CYLINDER.segments),
+ ]);
+}
+
+export function buildRotor(context: PartContext, yaw: Object3D): Rotor {
+ const root = namedGroup('rotor', yaw);
+ root.position.set(...HUB);
+ root.add(groupMesh(context, hubGeometry(), 'hub', 'castIron'));
+ const bearing = bandGeometry(BEARING, BEARING.segments);
+ const cylinder = pitchCylinderGeometry();
+ const { body: blade, band } = bladeGeometries();
+ const pitchGroups: Group[] = [];
+ const pitchCylinders: Mesh[] = [];
+ for (let index = 0; index < BLADE_COUNT; index += 1) {
+ const arm = namedGroup(`bladeArm${index}`, root);
+ arm.rotation.x = (index * 2 * Math.PI) / BLADE_COUNT;
+ arm.updateMatrix();
+ arm.add(groupMesh(context, bearing, 'hub', 'steel'));
+ const actuator = partMesh(context, cylinder, 'pitchCylinders');
+ arm.add(actuator);
+ pitchCylinders.push(actuator);
+ const pitch = namedGroup(`bladePitch${index}`, arm);
+ const mesh = partMesh(context, blade, 'blades');
+ mesh.add(finishMesh(context, band, 'blades', BAND_FINISH));
+ pitch.add(mesh);
+ pitchGroups.push(pitch);
+ }
+ label(context, 'blades', yaw, [HUB[0], HUB[1] + BLADE_LABEL_RADIUS, HUB[2]]);
+ return { root, pitchGroups, pitchCylinders, blade };
+}
+
+export function turnRotor(rotor: Rotor, azimuth: number, pitchDeg: number): void {
+ rotor.root.rotation.x = azimuth;
+ rotor.root.updateMatrix();
+ rotor.pitchGroups.forEach((pitch) => {
+ pitch.rotation.y = -degrees(pitchDeg);
+ pitch.updateMatrix();
+ });
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/shadow.ts b/explainers/wind-farm/src/scene/parts/hero/shadow.ts
new file mode 100644
index 00000000..33c09af9
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/shadow.ts
@@ -0,0 +1,131 @@
+import { DoubleSide, LessDepth, Matrix4, Mesh, MeshBasicMaterial, PlaneGeometry } from 'three';
+import type { BufferGeometry, Object3D } from 'three';
+import { UNDIMMED_GROUP } from '@core/scene/materials';
+import { TURBINE_GEOMETRY } from '../../../model/layout';
+import { SUN_DIRECTION } from '../../constants';
+import { FINISHES } from '../../finishes';
+import { namedGroup, registeredMaterial, registeredMesh } from '../context';
+import type { PartContext } from '../context';
+import { followGround, groundHeightTexture } from './shadowGround';
+
+const LIFT = 0.05;
+const BLOBS = [
+ { x: 0, z: 0, size: 18, opacity: 0.5 },
+ {
+ x: TURBINE_GEOMETRY.transformer.centre[0],
+ z: TURBINE_GEOMETRY.transformer.centre[2],
+ size: 7,
+ opacity: 0.4,
+ },
+] as const;
+const BLOB_LIFT = 0.08;
+
+export interface ShadowCaster {
+ readonly geometry: BufferGeometry;
+ readonly source: Object3D;
+}
+
+export interface HeroShadow {
+ update(): void;
+}
+
+function projection(): Matrix4 {
+ const [x, y, z] = SUN_DIRECTION;
+ const run = -x / y;
+ const drift = -z / y;
+ return new Matrix4().set(
+ 1,
+ run,
+ 0,
+ -LIFT * run,
+ 0,
+ 0,
+ 0,
+ LIFT,
+ 0,
+ drift,
+ 1,
+ -LIFT * drift,
+ 0,
+ 0,
+ 0,
+ 1,
+ );
+}
+
+function relativeMatrix(source: Object3D, root: Object3D, target: Matrix4): Matrix4 {
+ target.copy(source.matrix);
+ for (let parent = source.parent; parent && parent !== root; parent = parent.parent) {
+ target.premultiply(parent.matrix);
+ }
+ return target;
+}
+
+function silhouetteMaterial(context: PartContext): MeshBasicMaterial {
+ const ground = followGround(context.tracker.track(groundHeightTexture()));
+ const material = registeredMaterial(
+ context,
+ UNDIMMED_GROUP,
+ new MeshBasicMaterial({
+ color: FINISHES.shadow.color,
+ transparent: true,
+ opacity: FINISHES.shadow.opacity,
+ depthWrite: true,
+ depthFunc: LessDepth,
+ polygonOffset: true,
+ polygonOffsetFactor: -1,
+ polygonOffsetUnits: -2,
+ side: DoubleSide,
+ }),
+ );
+ material.onBeforeCompile = ground.compile;
+ material.customProgramCacheKey = ground.key;
+ return material;
+}
+
+function buildBlobs(context: PartContext, parent: Object3D): void {
+ const plane = new PlaneGeometry(1, 1);
+ plane.rotateX(-Math.PI / 2);
+ BLOBS.forEach((blob) => {
+ const material = new MeshBasicMaterial({
+ map: context.textures.shadow,
+ transparent: true,
+ opacity: blob.opacity,
+ depthWrite: false,
+ polygonOffset: true,
+ polygonOffsetFactor: -1,
+ polygonOffsetUnits: -4,
+ });
+ const mesh = registeredMesh(context, plane, UNDIMMED_GROUP, material);
+ mesh.position.set(blob.x, BLOB_LIFT, blob.z);
+ mesh.scale.setScalar(blob.size);
+ parent.add(mesh);
+ });
+}
+
+export function buildShadow(
+ context: PartContext,
+ root: Object3D,
+ casters: readonly ShadowCaster[],
+): HeroShadow {
+ const group = namedGroup('heroShadow', root);
+ const material = silhouetteMaterial(context);
+ const project = projection();
+ const scratch = new Matrix4();
+ const meshes = casters.map((caster) => {
+ const mesh = new Mesh(caster.geometry, material);
+ mesh.matrixAutoUpdate = false;
+ group.add(mesh);
+ return mesh;
+ });
+ buildBlobs(context, group);
+ return {
+ update() {
+ meshes.forEach((mesh, index) => {
+ relativeMatrix(casters[index].source, root, scratch);
+ mesh.matrix.multiplyMatrices(project, scratch);
+ mesh.matrixWorldNeedsUpdate = true;
+ });
+ },
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/shadowGround.ts b/explainers/wind-farm/src/scene/parts/hero/shadowGround.ts
new file mode 100644
index 00000000..f280a762
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/shadowGround.ts
@@ -0,0 +1,56 @@
+import { DataTexture, DataUtils, HalfFloatType, LinearFilter, RedFormat, Vector2 } from 'three';
+import type { WebGLProgramParametersWithUniforms } from 'three';
+import { turbineLandHeight } from '../land/turbineGround';
+
+const GROUND = { originX: -120, originZ: -300, span: 420, texels: 96 } as const;
+const PROGRAM_KEY = 'heroShadowGround';
+
+const VERTEX_HEAD = `
+uniform sampler2D groundHeights;
+uniform vec2 groundOrigin;
+uniform float groundSpan;
+`;
+
+const VERTEX_PROJECT = `
+vec4 shadowWorld = modelMatrix * vec4(transformed, 1.0);
+vec2 groundUv = (shadowWorld.xz - groundOrigin) / groundSpan;
+shadowWorld.y += texture2D(groundHeights, groundUv).r;
+vec4 mvPosition = viewMatrix * shadowWorld;
+gl_Position = projectionMatrix * mvPosition;
+`;
+
+export function groundHeightTexture(): DataTexture {
+ const { originX, originZ, span, texels } = GROUND;
+ const data = new Uint16Array(texels * texels);
+ for (let row = 0; row < texels; row += 1) {
+ for (let column = 0; column < texels; column += 1) {
+ const x = originX + ((column + 0.5) / texels) * span;
+ const z = originZ + ((row + 0.5) / texels) * span;
+ data[row * texels + column] = DataUtils.toHalfFloat(turbineLandHeight(x, z));
+ }
+ }
+ const texture = new DataTexture(data, texels, texels, RedFormat, HalfFloatType);
+ texture.magFilter = LinearFilter;
+ texture.minFilter = LinearFilter;
+ texture.generateMipmaps = false;
+ texture.needsUpdate = true;
+ return texture;
+}
+
+export function followGround(texture: DataTexture) {
+ const uniforms = {
+ groundHeights: { value: texture },
+ groundOrigin: { value: new Vector2(GROUND.originX, GROUND.originZ) },
+ groundSpan: { value: GROUND.span },
+ };
+ return {
+ key: () => PROGRAM_KEY,
+ compile(shader: WebGLProgramParametersWithUniforms): void {
+ Object.assign(shader.uniforms, uniforms);
+ shader.vertexShader = (VERTEX_HEAD + shader.vertexShader).replace(
+ '#include ',
+ VERTEX_PROJECT,
+ );
+ },
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/spinner.ts b/explainers/wind-farm/src/scene/parts/hero/spinner.ts
new file mode 100644
index 00000000..c19f6786
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/spinner.ts
@@ -0,0 +1,47 @@
+import type { BufferGeometry, Object3D } from 'three';
+import { latheAlongX, sampleProfile } from '@core/scene/geometry/lathe';
+import type { ProfilePoint } from '@core/scene/geometry/lathe';
+import { finishMesh, label } from '../context';
+import type { PartContext } from '../context';
+import { HUB } from './constants';
+import { SHELL_FINISH } from './nacelleShell';
+
+const PROFILE: readonly ProfilePoint[] = [
+ [-3.3, 0],
+ [-3.12, 0.55],
+ [-2.6, 1.12],
+ [-1.7, 1.58],
+ [-0.6, 1.86],
+ [0.6, 1.9],
+ [1.7, 1.86],
+ [2.3, 1.78],
+];
+const SAMPLES = 20;
+const HALF_SEGMENTS = 24;
+const LABEL_X = 1.7;
+const LABEL_RADIUS = 1.85;
+
+export interface Spinner {
+ readonly openable: Object3D;
+ readonly casters: readonly BufferGeometry[];
+}
+
+function half(start: number): BufferGeometry {
+ const geometry = latheAlongX(sampleProfile(PROFILE, SAMPLES), HALF_SEGMENTS, {
+ start,
+ length: Math.PI,
+ });
+ geometry.translate(...HUB);
+ return geometry;
+}
+
+export function buildSpinner(context: PartContext, yaw: Object3D): Spinner {
+ const back = half(Math.PI / 2);
+ const front = half(-Math.PI / 2);
+ const kept = finishMesh(context, back, 'hub', SHELL_FINISH);
+ const openable = finishMesh(context, front, 'hub', SHELL_FINISH);
+ kept.name = 'hub';
+ yaw.add(kept, openable);
+ label(context, 'hub', kept, [HUB[0] + LABEL_X, HUB[1] + LABEL_RADIUS, HUB[2]]);
+ return { openable, casters: [back, front] };
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/sweptArea.ts b/explainers/wind-farm/src/scene/parts/hero/sweptArea.ts
new file mode 100644
index 00000000..d5d52298
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/sweptArea.ts
@@ -0,0 +1,37 @@
+import { CircleGeometry, RingGeometry } from 'three';
+import type { BufferGeometry, Object3D } from 'three';
+import { ROTOR_RADIUS_M } from '../../../model/constants';
+import { FINISHES } from '../../finishes';
+import { finishMesh, label, partMesh } from '../context';
+import type { PartContext } from '../context';
+import { HUB } from './constants';
+
+const DISC = { segments: 96, opacity: 0.06 } as const;
+const RING = { width: 0.7, segments: 160 } as const;
+const LABEL_HEIGHT = -40;
+const DISC_FINISH = { ...FINISHES.sweep, opacity: DISC.opacity };
+
+function facingWind(geometry: BufferGeometry): BufferGeometry {
+ geometry.rotateY(Math.PI / 2);
+ geometry.translate(...HUB);
+ return geometry;
+}
+
+export function buildSweptArea(context: PartContext, yaw: Object3D): void {
+ const ring = partMesh(
+ context,
+ facingWind(new RingGeometry(ROTOR_RADIUS_M - RING.width, ROTOR_RADIUS_M, RING.segments)),
+ 'sweptArea',
+ );
+ ring.add(
+ finishMesh(
+ context,
+ facingWind(new CircleGeometry(ROTOR_RADIUS_M, DISC.segments)),
+ 'sweptArea',
+ DISC_FINISH,
+ ),
+ );
+ yaw.add(ring);
+ const across = Math.sqrt(ROTOR_RADIUS_M ** 2 - LABEL_HEIGHT ** 2);
+ label(context, 'sweptArea', ring, [HUB[0], HUB[1] + LABEL_HEIGHT, HUB[2] + across]);
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/tower.ts b/explainers/wind-farm/src/scene/parts/hero/tower.ts
new file mode 100644
index 00000000..9be56e44
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/tower.ts
@@ -0,0 +1,70 @@
+import { LatheGeometry, Vector2 } from 'three';
+import type { BufferGeometry, Object3D } from 'three';
+import { bandGeometry, crispProfile } from '../../geometry/band';
+import { mergeParts } from '../../geometry/merge';
+import { groupMesh, label, partMesh, sceneAnchor } from '../context';
+import type { PartContext } from '../context';
+import { PLINTH, TOWER, towerRadiusAt } from './constants';
+import { buildTowerDoor } from './towerDoor';
+
+function plinthGeometry(): BufferGeometry {
+ const profile = crispProfile([
+ [0, PLINTH.buriedY],
+ [PLINTH.radius, PLINTH.buriedY],
+ [PLINTH.radius, PLINTH.shoulderY],
+ [PLINTH.chamferRadius, PLINTH.topY],
+ [0, PLINTH.topY],
+ ]);
+ return new LatheGeometry(profile, PLINTH.segments);
+}
+
+function shellGeometry(): BufferGeometry {
+ const profile = Array.from({ length: TOWER.rows + 1 }, (_, row) => {
+ const y = (TOWER.topY * row) / TOWER.rows;
+ return new Vector2(towerRadiusAt(y), y);
+ });
+ return new LatheGeometry(profile, TOWER.segments);
+}
+
+function flangeGeometry(): BufferGeometry {
+ const rings = TOWER.flangeYs.map((y) => {
+ const radius = towerRadiusAt(y);
+ return bandGeometry(
+ {
+ inner: radius - TOWER.flangeProud,
+ outer: radius + TOWER.flangeProud,
+ bottom: y - TOWER.flangeHeight / 2,
+ top: y + TOWER.flangeHeight / 2,
+ },
+ TOWER.flangeSegments,
+ );
+ });
+ const top = bandGeometry(
+ {
+ inner: TOWER.topRadius - TOWER.topFlange.proud,
+ outer: TOWER.topRadius + TOWER.topFlange.proud,
+ bottom: TOWER.topY - TOWER.topFlange.height,
+ top: TOWER.topY,
+ },
+ TOWER.flangeSegments,
+ );
+ return mergeParts([...rings, top]);
+}
+
+export interface TowerParts {
+ readonly shell: BufferGeometry;
+ readonly mesh: Object3D;
+}
+
+export function buildTower(context: PartContext, parent: Object3D): TowerParts {
+ const foundation = partMesh(context, plinthGeometry(), 'foundation');
+ const shell = shellGeometry();
+ const tower = partMesh(context, shell, 'tower');
+ tower.add(groupMesh(context, flangeGeometry(), 'tower', 'paintShade'));
+ buildTowerDoor(context, tower);
+ parent.add(foundation, tower);
+ label(context, 'foundation', foundation, [0, PLINTH.topY, PLINTH.chamferRadius]);
+ label(context, 'tower', tower, [0, TOWER.topY / 2, towerRadiusAt(TOWER.topY / 2)]);
+ sceneAnchor(context, 'towerBase', parent, [0, 0, 0]);
+ return { shell, mesh: tower };
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/towerCable.ts b/explainers/wind-farm/src/scene/parts/hero/towerCable.ts
new file mode 100644
index 00000000..85da0e8e
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/towerCable.ts
@@ -0,0 +1,95 @@
+import {
+ BufferGeometry,
+ CatmullRomCurve3,
+ Float32BufferAttribute,
+ TubeGeometry,
+ Vector3,
+} from 'three';
+import type { Object3D } from 'three';
+import { smoothstep } from '@core/math';
+import { TURBINE_GEOMETRY, TURBINE_LAND } from '../../../model/layout';
+import { FINISHES } from '../../finishes';
+import { finishMesh, label, partMesh } from '../context';
+import type { PartContext } from '../context';
+import { turbineLandHeight } from '../land/turbineGround';
+
+const [TRANSFORMER_X, , TRANSFORMER_Z] = TURBINE_GEOMETRY.transformer.centre;
+const [TRANSFORMER_WIDTH] = TURBINE_GEOMETRY.transformer.size;
+
+const RUN = {
+ radius: 0.13,
+ y: 0.16,
+ tubular: 28,
+ radial: 8,
+ points: [
+ [TRANSFORMER_X - TRANSFORMER_WIDTH / 2 + 0.2, 0.16, TRANSFORMER_Z - 0.4],
+ [5.6, 0.16, 6.1],
+ [3.4, 0.16, 3.4],
+ ],
+} as const;
+
+const TRENCH_FINISH = {
+ ...FINISHES.cable,
+ polygonOffset: true,
+ polygonOffsetFactor: -2,
+ polygonOffsetUnits: -2,
+};
+
+const TRENCH = {
+ startX: TRANSFORMER_X + TRANSFORMER_WIDTH / 2,
+ endX: TURBINE_LAND.radius,
+ halfWidth: 0.55,
+ nearStep: 6,
+ farStep: 40,
+ nearReach: TURBINE_LAND.blendRadius,
+ lift: 0.1,
+ hillLift: 0.6,
+ distanceLift: 0.0005,
+} as const;
+
+function runGeometry(): BufferGeometry {
+ const curve = new CatmullRomCurve3(RUN.points.map((point) => new Vector3(...point)));
+ return new TubeGeometry(curve, RUN.tubular, RUN.radius, RUN.radial);
+}
+
+function trenchStations(): number[] {
+ const stations: number[] = [];
+ for (let x = TRENCH.startX; x < TRENCH.endX;) {
+ stations.push(x);
+ x += x < TRENCH.nearReach ? TRENCH.nearStep : TRENCH.farStep;
+ }
+ return [...stations, TRENCH.endX];
+}
+
+function trenchLift(x: number): number {
+ const hill = smoothstep(x, TURBINE_LAND.flatRadius, TRENCH.nearReach);
+ return TRENCH.lift + hill * TRENCH.hillLift + x * TRENCH.distanceLift;
+}
+
+function trenchGeometry(): BufferGeometry {
+ const positions: number[] = [];
+ const indices: number[] = [];
+ trenchStations().forEach((x, index) => {
+ [-1, 1].forEach((side) => {
+ const z = TRANSFORMER_Z + side * TRENCH.halfWidth;
+ positions.push(x, turbineLandHeight(x, z) + trenchLift(x), z);
+ });
+ if (index === 0) return;
+ const base = index * 2;
+ indices.push(base - 2, base - 1, base, base - 1, base + 1, base);
+ });
+ const geometry = new BufferGeometry();
+ geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
+ geometry.setIndex(indices);
+ geometry.computeVertexNormals();
+ return geometry;
+}
+
+export function buildTowerCable(context: PartContext, parent: Object3D): void {
+ const run = partMesh(context, runGeometry(), 'towerCable');
+ const trench = finishMesh(context, trenchGeometry(), 'towerCable', TRENCH_FINISH);
+ run.add(trench);
+ parent.add(run);
+ const [, middle] = RUN.points;
+ label(context, 'towerCable', run, [middle[0], RUN.y + RUN.radius, middle[2]]);
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/towerDoor.ts b/explainers/wind-farm/src/scene/parts/hero/towerDoor.ts
new file mode 100644
index 00000000..b013e675
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/towerDoor.ts
@@ -0,0 +1,128 @@
+import type { BufferGeometry, Object3D } from 'three';
+import { box } from '@core/scene/geometry/box';
+import { degrees, groupMesh } from '../context';
+import type { PartContext } from '../context';
+import { PLINTH, towerRadiusAt } from './constants';
+import { mergeParts } from '../../geometry/merge';
+
+const DOOR = {
+ facingDeg: 25,
+ sillY: 1.6,
+ width: 0.9,
+ height: 2.2,
+ proud: 0.03,
+ depth: 0.12,
+ frame: 0.1,
+} as const;
+
+const PLATFORM = { halfWidth: 1.05, depth: 1.55, thickness: 0.14, tuck: 0.3 } as const;
+const RAIL = { post: 0.06, heights: [0.55, 1.05], bar: 0.05 } as const;
+const STEPS = { count: 4, run: 0.3, inset: 0.15 } as const;
+
+function doorFace(): BufferGeometry {
+ const front = towerRadiusAt(DOOR.sillY) + DOOR.proud;
+ const half = DOOR.width / 2;
+ const frameHalf = half + DOOR.frame;
+ const panel = box({
+ minX: -half,
+ maxX: half,
+ minY: DOOR.sillY,
+ maxY: DOOR.sillY + DOOR.height,
+ minZ: front - DOOR.depth,
+ maxZ: front,
+ });
+ const lintel = box({
+ minX: -frameHalf,
+ maxX: frameHalf,
+ minY: DOOR.sillY + DOOR.height,
+ maxY: DOOR.sillY + DOOR.height + DOOR.frame,
+ minZ: front - DOOR.depth,
+ maxZ: front + DOOR.proud,
+ });
+ const jambs = [-1, 1].map((side) =>
+ box({
+ minX: side < 0 ? -frameHalf : half,
+ maxX: side < 0 ? -half : frameHalf,
+ minY: DOOR.sillY,
+ maxY: DOOR.sillY + DOOR.height,
+ minZ: front - DOOR.depth,
+ maxZ: front + DOOR.proud,
+ }),
+ );
+ return mergeParts([panel, lintel, ...jambs]);
+}
+
+function platformGeometry(): BufferGeometry {
+ const back = towerRadiusAt(DOOR.sillY) - PLATFORM.tuck;
+ const front = back + PLATFORM.depth;
+ const deck = box({
+ minX: -PLATFORM.halfWidth,
+ maxX: PLATFORM.halfWidth,
+ minY: DOOR.sillY - PLATFORM.thickness,
+ maxY: DOOR.sillY,
+ minZ: back,
+ maxZ: front,
+ });
+ const rise = (DOOR.sillY - PLINTH.topY) / STEPS.count;
+ const steps = Array.from({ length: STEPS.count }, (_, index) =>
+ box({
+ minX: PLATFORM.halfWidth + index * STEPS.run,
+ maxX: PLATFORM.halfWidth + (index + 1) * STEPS.run,
+ minY: PLINTH.topY - STEPS.inset,
+ maxY: DOOR.sillY - (index + 1) * rise,
+ minZ: back + STEPS.inset * 2,
+ maxZ: front - STEPS.inset,
+ }),
+ );
+ return mergeParts([deck, ...steps, ...railGeometry(front)]);
+}
+
+function railGeometry(front: number): BufferGeometry[] {
+ const edge = front - RAIL.post;
+ const top = DOOR.sillY + RAIL.heights[RAIL.heights.length - 1];
+ const postAt = (x: number, z: number) =>
+ box({
+ minX: x - RAIL.post / 2,
+ maxX: x + RAIL.post / 2,
+ minY: DOOR.sillY,
+ maxY: top,
+ minZ: z - RAIL.post / 2,
+ maxZ: z + RAIL.post / 2,
+ });
+ const side = -PLATFORM.halfWidth + RAIL.post;
+ const posts = [postAt(side, edge), postAt(-side, edge), postAt(side, edge - PLATFORM.depth / 2)];
+ const bars = RAIL.heights.flatMap((height) => {
+ const y = DOOR.sillY + height;
+ return [
+ box({
+ minX: side,
+ maxX: -side,
+ minY: y - RAIL.bar,
+ maxY: y,
+ minZ: edge - RAIL.bar / 2,
+ maxZ: edge + RAIL.bar / 2,
+ }),
+ box({
+ minX: side - RAIL.bar / 2,
+ maxX: side + RAIL.bar / 2,
+ minY: y - RAIL.bar,
+ maxY: y,
+ minZ: edge - PLATFORM.depth + RAIL.post * 2,
+ maxZ: edge,
+ }),
+ ];
+ });
+ return [...posts, ...bars];
+}
+
+export function buildTowerDoor(context: PartContext, tower: Object3D): void {
+ const turn = degrees(DOOR.facingDeg);
+ const door = doorFace();
+ const platform = platformGeometry();
+ door.rotateY(turn);
+ platform.rotateY(turn);
+ tower.add(
+ groupMesh(context, door, 'tower', 'paintShade'),
+ groupMesh(context, platform, 'tower', 'steel'),
+ );
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/transformer.ts b/explainers/wind-farm/src/scene/parts/hero/transformer.ts
new file mode 100644
index 00000000..22d92185
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/transformer.ts
@@ -0,0 +1,95 @@
+import { CylinderGeometry } from 'three';
+import type { BufferGeometry, Object3D } from 'three';
+import { box } from '@core/scene/geometry/box';
+import { TURBINE_GEOMETRY } from '../../../model/layout';
+import { mergeParts } from '../../geometry/merge';
+import { groupMesh, label, partMesh } from '../context';
+import type { PartContext } from '../context';
+
+const [CENTRE_X, , CENTRE_Z] = TURBINE_GEOMETRY.transformer.centre;
+const [WIDTH, HEIGHT, DEPTH] = TURBINE_GEOMETRY.transformer.size;
+const PAD = { margin: 0.6, top: 0.15, bottom: -0.3 } as const;
+const LID = { thickness: 0.14, overhang: 0.06 } as const;
+const FINS = {
+ count: 9,
+ depth: 0.42,
+ thickness: 0.05,
+ bottom: 0.45,
+ top: 2.0,
+ inset: 0.3,
+} as const;
+const BUSHING = { radius: 0.09, height: 0.55, spacing: 0.7, segments: 10, inset: 0.6 } as const;
+
+function padGeometry(): BufferGeometry {
+ return box({
+ minX: CENTRE_X - WIDTH / 2 - PAD.margin,
+ maxX: CENTRE_X + WIDTH / 2 + PAD.margin,
+ minY: PAD.bottom,
+ maxY: PAD.top,
+ minZ: CENTRE_Z - DEPTH / 2 - PAD.margin,
+ maxZ: CENTRE_Z + DEPTH / 2 + PAD.margin,
+ });
+}
+
+function finGeometries(): BufferGeometry[] {
+ const span = DEPTH - FINS.inset * 2;
+ return [-1, 1].flatMap((side) =>
+ Array.from({ length: FINS.count }, (_, index) => {
+ const z = CENTRE_Z - span / 2 + (span * index) / (FINS.count - 1);
+ const wall = CENTRE_X + (side * WIDTH) / 2;
+ const outer = wall + side * FINS.depth;
+ return box({
+ minX: Math.min(wall, outer) - FINS.thickness,
+ maxX: Math.max(wall, outer) + FINS.thickness,
+ minY: FINS.bottom,
+ maxY: FINS.top,
+ minZ: z - FINS.thickness / 2,
+ maxZ: z + FINS.thickness / 2,
+ });
+ }),
+ );
+}
+
+function bushingGeometries(): BufferGeometry[] {
+ return [-1, 0, 1].map((step) => {
+ const bushing = new CylinderGeometry(
+ BUSHING.radius * 0.7,
+ BUSHING.radius,
+ BUSHING.height,
+ BUSHING.segments,
+ );
+ bushing.translate(
+ CENTRE_X + step * BUSHING.spacing,
+ HEIGHT + BUSHING.height / 2,
+ CENTRE_Z - DEPTH / 2 + BUSHING.inset,
+ );
+ return bushing;
+ });
+}
+
+function bodyGeometry(): BufferGeometry {
+ const body = box({
+ minX: CENTRE_X - WIDTH / 2,
+ maxX: CENTRE_X + WIDTH / 2,
+ minY: PAD.top - LID.thickness,
+ maxY: HEIGHT - LID.thickness,
+ minZ: CENTRE_Z - DEPTH / 2,
+ maxZ: CENTRE_Z + DEPTH / 2,
+ });
+ const lid = box({
+ minX: CENTRE_X - WIDTH / 2 - LID.overhang,
+ maxX: CENTRE_X + WIDTH / 2 + LID.overhang,
+ minY: HEIGHT - LID.thickness,
+ maxY: HEIGHT,
+ minZ: CENTRE_Z - DEPTH / 2 - LID.overhang,
+ maxZ: CENTRE_Z + DEPTH / 2 + LID.overhang,
+ });
+ return mergeParts([body, lid, ...finGeometries(), ...bushingGeometries()]);
+}
+
+export function buildTransformer(context: PartContext, parent: Object3D): void {
+ const transformer = partMesh(context, bodyGeometry(), 'transformer');
+ transformer.add(groupMesh(context, padGeometry(), 'transformer', 'concrete'));
+ parent.add(transformer);
+ label(context, 'transformer', transformer, [CENTRE_X, HEIGHT, CENTRE_Z]);
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/wake.ts b/explainers/wind-farm/src/scene/parts/hero/wake.ts
new file mode 100644
index 00000000..5e029d13
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/wake.ts
@@ -0,0 +1,76 @@
+import { CylinderGeometry, MeshBasicMaterial } from 'three';
+import type { Mesh, Object3D } from 'three';
+import { clamp } from '@core/math';
+import type { AssemblyState, OperatingStateId } from '../../../ids';
+import { ROTOR_DIAMETER_M, ROTOR_RADIUS_M, WAKE_DECAY } from '../../../model/constants';
+import { FINISHES } from '../../finishes';
+import { label, namedGroup, registeredMaterial, registeredMesh } from '../context';
+import type { PartContext } from '../context';
+import { HUB } from './constants';
+import { wakeShader } from './wakeShader';
+import type { WakeUniforms } from './wakeShader';
+
+const SEGMENTS = { around: 72, along: 20 } as const;
+const MAX_OPACITY = 0.2;
+const OPACITY_STEP = 0.05;
+const FLOW_PER_RPM = 0.012;
+const LABEL_SHARE = 0.3;
+const LABEL_REACH_M = ROTOR_DIAMETER_M;
+const CALM_STATES: readonly OperatingStateId[] = ['parked', 'idle'];
+const PROGRAM_KEY = 'heroWake';
+
+export interface HeroWake {
+ setState(state: AssemblyState): void;
+ animate(delta: number, rpm: number): void;
+}
+
+function unitFrustum(): CylinderGeometry {
+ const geometry = new CylinderGeometry(1, 1, 1, SEGMENTS.around, SEGMENTS.along, true);
+ geometry.translate(0, 0.5, 0);
+ geometry.rotateZ(-Math.PI / 2);
+ return geometry;
+}
+
+function quantised(strength: number): number {
+ const opacity = clamp(strength, 0, 1) * MAX_OPACITY;
+ return Math.round(opacity / OPACITY_STEP) * OPACITY_STEP;
+}
+
+export function buildHeroWake(context: PartContext, yaw: Object3D): HeroWake {
+ const group = namedGroup('heroWake', yaw);
+ const uniforms: WakeUniforms = { wakeLength: { value: 1 }, wakeFlow: { value: 0 } };
+ const compile = wakeShader(uniforms);
+ const materials = new Map();
+ const materialFor = (opacity: number): MeshBasicMaterial => {
+ const cached = materials.get(opacity);
+ if (cached) return cached;
+ const material = new MeshBasicMaterial({ ...FINISHES.wake, opacity });
+ material.onBeforeCompile = compile;
+ material.customProgramCacheKey = () => PROGRAM_KEY;
+ materials.set(opacity, material);
+ return registeredMaterial(context, 'heroWake', material);
+ };
+ const mesh: Mesh = registeredMesh(context, unitFrustum(), 'heroWake', materialFor(MAX_OPACITY));
+ mesh.frustumCulled = false;
+ mesh.name = 'heroWake';
+ group.add(mesh);
+ label(context, 'heroWake', group, [...HUB]);
+ const anchor = context.labels.get('heroWake');
+
+ return {
+ setState(state) {
+ const opacity = quantised(state.farm.plumeStrength);
+ const length = state.farm.plumeLengthD * ROTOR_DIAMETER_M;
+ group.visible =
+ state.view.wakes && !CALM_STATES.includes(state.rotor.state) && opacity > 0 && length > 0;
+ if (!group.visible) return;
+ mesh.material = materialFor(opacity);
+ uniforms.wakeLength.value = length;
+ const along = Math.min(length * LABEL_SHARE, LABEL_REACH_M);
+ anchor?.position.set(HUB[0] + along, HUB[1] + ROTOR_RADIUS_M + WAKE_DECAY * along, HUB[2]);
+ },
+ animate(delta, rpm) {
+ uniforms.wakeFlow.value += delta * rpm * FLOW_PER_RPM;
+ },
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/hero/wakeShader.ts b/explainers/wind-farm/src/scene/parts/hero/wakeShader.ts
new file mode 100644
index 00000000..d60f65b9
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/hero/wakeShader.ts
@@ -0,0 +1,62 @@
+import type { WebGLProgramParametersWithUniforms } from 'three';
+import { ROTOR_RADIUS_M, WAKE_DECAY } from '../../../model/constants';
+import { HUB } from './constants';
+
+export interface WakeUniforms {
+ readonly wakeLength: { value: number };
+ readonly wakeFlow: { value: number };
+}
+
+const glsl = (value: number) => value.toFixed(4);
+
+const VERTEX_HEAD = `
+uniform float wakeLength;
+varying float vWakeAlong;
+varying float vWakeAngle;
+varying float vWakeFacing;
+`;
+
+const VERTEX_BEGIN = `
+float wakeRadius = ${glsl(ROTOR_RADIUS_M)} + ${glsl(WAKE_DECAY)} * position.x * wakeLength;
+vec3 transformed = vec3(
+ ${glsl(HUB[0])} + position.x * wakeLength,
+ ${glsl(HUB[1])} + wakeRadius * position.y,
+ ${glsl(HUB[2])} + wakeRadius * position.z
+);
+vec3 wakeNormal = normalize(vec3(-${glsl(WAKE_DECAY)}, position.y, position.z));
+vWakeAlong = position.x;
+vWakeAngle = atan(position.z, position.y);
+`;
+
+const VERTEX_FACING = `
+vWakeFacing = abs(dot(normalize(normalMatrix * wakeNormal), normalize(-mvPosition.xyz)));
+`;
+
+const FRAGMENT_HEAD = `
+uniform float wakeLength;
+uniform float wakeFlow;
+varying float vWakeAlong;
+varying float vWakeAngle;
+varying float vWakeFacing;
+`;
+
+const FRAGMENT_FADE = `
+float wakeFade = smoothstep(0.0, 0.05, vWakeAlong) * pow(1.0 - vWakeAlong, 1.5);
+float wakeEdge = smoothstep(0.02, 0.8, vWakeFacing);
+float wakeBands = 0.65 + 0.35 * sin(6.2832 * (vWakeAlong * wakeLength / 70.0 - wakeFlow));
+float wakeSwirl = 0.8 + 0.2 * sin(vWakeAngle * 6.0 + vWakeAlong * 9.0 - wakeFlow * 2.0);
+diffuseColor.a *= wakeFade * wakeEdge * wakeBands * wakeSwirl;
+`;
+
+export function wakeShader(uniforms: WakeUniforms) {
+ return (shader: WebGLProgramParametersWithUniforms): void => {
+ Object.assign(shader.uniforms, uniforms);
+ shader.vertexShader = (VERTEX_HEAD + shader.vertexShader)
+ .replace('#include ', VERTEX_BEGIN)
+ .replace('#include ', `#include \n${VERTEX_FACING}`);
+ shader.fragmentShader = (FRAGMENT_HEAD + shader.fragmentShader).replace(
+ '#include ',
+ `${FRAGMENT_FADE}\n#include `,
+ );
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/canopyPattern.ts b/explainers/wind-farm/src/scene/parts/land/canopyPattern.ts
new file mode 100644
index 00000000..12489e0a
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/canopyPattern.ts
@@ -0,0 +1,35 @@
+import { CANOPY_TILE } from './fieldConstants';
+import { between, seededRandom } from './random';
+
+const FULL_TURN = Math.PI * 2;
+const WRAPS = [-1, 0, 1];
+
+function crown(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
+ const { size, shadow, light, lift } = CANOPY_TILE;
+ WRAPS.forEach((dx) =>
+ WRAPS.forEach((dy) => {
+ const [cx, cy] = [x + dx * size, y + dy * size];
+ context.fillStyle = shadow;
+ context.beginPath();
+ context.arc(cx + lift, cy + lift, radius, 0, FULL_TURN);
+ context.fill();
+ context.fillStyle = light;
+ context.beginPath();
+ context.arc(cx - lift, cy - lift, radius * CANOPY_TILE.highlight, 0, FULL_TURN);
+ context.fill();
+ }),
+ );
+}
+
+export function canopyPattern(context: CanvasRenderingContext2D): CanvasPattern | null {
+ const { size, crowns, radius, seed } = CANOPY_TILE;
+ const tile = document.createElement('canvas');
+ tile.width = size;
+ tile.height = size;
+ const drawing = tile.getContext('2d');
+ if (!drawing) return null;
+ const random = seededRandom(seed);
+ for (let index = 0; index < crowns; index += 1)
+ crown(drawing, random() * size, random() * size, between(random, radius));
+ return context.createPattern(tile, 'repeat');
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/constants.ts b/explainers/wind-farm/src/scene/parts/land/constants.ts
new file mode 100644
index 00000000..3252e7ba
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/constants.ts
@@ -0,0 +1,182 @@
+import type { GroundPoint } from '../../../model/layout';
+
+type Range = readonly [number, number];
+type Triple = readonly [number, number, number];
+
+export const GROUND_TEXTURE = {
+ size: 2048,
+ anisotropy: 8,
+ minLinePx: 1.1,
+ minStripePx: 3,
+ strokePiece: 60,
+ fadeStops: 8,
+ farm: {
+ softness: 2600,
+ step: 150,
+ soften: { reduction: 4, alpha: 0.8 },
+ mottle: [
+ { cells: 40, amount: 0.18, seed: 3 },
+ { cells: 150, amount: 0.16, seed: 7 },
+ { cells: 520, amount: 0.12, seed: 5 },
+ ],
+ },
+ turbine: { softness: 300, step: 20 },
+ mottle: [
+ { cells: 40, amount: 0.2, seed: 3 },
+ { cells: 320, amount: 0.12, seed: 5 },
+ ],
+} as const;
+
+export interface MottleLayer {
+ readonly cells: number;
+ readonly amount: number;
+ readonly seed: number;
+}
+
+export const MOWING = { width: 7, reach: 150, fade: 0.6, shade: 1.12, alpha: 0.5 } as const;
+
+export const TURBINE_GROUND = {
+ firstRingStep: 2,
+ segments: 240,
+ meadow: { colour: '#97b565', inner: 90, outer: 170 },
+ hedgeClear: 120,
+ woodClear: 260,
+ hazeFrom: 0.85,
+ alphaFrom: 0.9,
+} as const;
+
+export const FARM_GROUND = { segments: 180, hazeFade: 700, alphaFade: 450 } as const;
+
+export const LAND_RENDER_ORDER = -1;
+
+export const HARDSTAND = {
+ pad: { minX: -20, maxX: 20, minZ: 8, maxZ: 33 },
+ apronRadius: 10,
+ corner: 3,
+ lift: 0.05,
+ arcSegments: 32,
+} as const;
+
+export const TRACK = {
+ points: [
+ [0, 30],
+ [0, 120],
+ [60, 320],
+ [210, 700],
+ [260, 1100],
+ [520, 1700],
+ [700, 2400],
+ [880, 3100],
+ ] satisfies GroundPoint[],
+} as const;
+
+export interface TreeLayout {
+ readonly hedgeCount: number;
+ readonly woodCount: number;
+ readonly hedgeBand: number;
+ readonly hedgeSpacing: number;
+ readonly cluster: { readonly wavelength: number; readonly threshold: number };
+ readonly scale: Range;
+ readonly woodScale: Range;
+ readonly attempts: number;
+ readonly sink: number;
+ readonly seed: number;
+}
+
+export const TURBINE_TREES: TreeLayout = {
+ hedgeCount: 280,
+ woodCount: 190,
+ hedgeBand: 1.5,
+ hedgeSpacing: 4.5,
+ cluster: { wavelength: 140, threshold: 0.45 },
+ scale: [0.8, 1.35],
+ woodScale: [1, 1.5],
+ attempts: 200_000,
+ sink: 0,
+ seed: 41,
+};
+
+export const TURBINE_TREE_LIMITS = { reach: 2100, heroClear: 150, trackClear: 18 } as const;
+
+export const FARM_TREES: TreeLayout = {
+ hedgeCount: 1300,
+ woodCount: 650,
+ hedgeBand: 2.5,
+ hedgeSpacing: 7,
+ cluster: { wavelength: 420, threshold: 0.45 },
+ scale: [1.2, 2],
+ woodScale: [1.5, 2.4],
+ attempts: 400_000,
+ sink: 1.2,
+ seed: 43,
+};
+
+export const FARM_TREE_LIMITS = {
+ edgeMargin: 1300,
+ siteClear: 160,
+ routeClear: 45,
+ substationClear: 140,
+ gridClear: 45,
+} as const;
+
+export interface Lobe {
+ readonly centre: Triple;
+ readonly radius: number;
+ readonly detail: number;
+}
+
+export const TREE_SHAPE = {
+ trunk: { top: 0.22, bottom: 0.34, height: 5.4, sink: 1.2, sides: 5 },
+ crown: [
+ { centre: [0, 6.8, 0], radius: 3.3, detail: 1 },
+ { centre: [1.9, 5.7, 0.8], radius: 2.4, detail: 1 },
+ { centre: [-1.4, 5.5, -1.3], radius: 2.2, detail: 0 },
+ ] satisfies Lobe[],
+ clump: [
+ { centre: [0, 3.4, 0], radius: 4.2, detail: 0 },
+ { centre: [3.6, 2.6, 1.4], radius: 3.2, detail: 0 },
+ { centre: [-2.8, 2.4, -2.6], radius: 3, detail: 0 },
+ ] satisfies Lobe[],
+ squash: 0.86,
+} as const;
+
+export const TREE_TINT = {
+ lightness: [0.8, 1.16] as Range,
+ warmth: 0.08,
+ autumnShare: 0.05,
+ autumn: [1.3, 1.08, 0.7] as Triple,
+ trunk: [1.25, 0.78, 0.92] as Triple,
+ stretch: [0.85, 1.2] as Range,
+} as const;
+
+export const SKY_DOME = {
+ widthSegments: 48,
+ heightSegments: 24,
+ renderOrder: -10,
+ rise: 3.6,
+ band: 0.01,
+ ground: {
+ colour: '#a8bb93',
+ warm: '#bcb88b',
+ wood: '#8b9d73',
+ minHeight: 2,
+ band: 0.01,
+ fields: { size: 650, tone: 0.08, warmShare: 0.35, angle: 0.4 },
+ woods: { cell: 700, threshold: 0.72, edge: 0.08, octave: 2.7, octaveShare: 0.4 },
+ detail: { from: 0.15, to: 0.6 },
+ },
+ glow: {
+ colour: '#f7dbb1',
+ wide: 0.3,
+ wideTightness: 5,
+ core: 0.5,
+ coreTightness: 260,
+ horizon: 0.25,
+ horizonTightness: 3,
+ },
+} as const;
+
+export const LAND_LABELS = {
+ land: [-70, 70],
+ farmLand: [2800, -2200],
+} as const satisfies Record;
diff --git a/explainers/wind-farm/src/scene/parts/land/distantLand.ts b/explainers/wind-farm/src/scene/parts/land/distantLand.ts
new file mode 100644
index 00000000..726429fd
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/distantLand.ts
@@ -0,0 +1,55 @@
+import { Color, Vector3, Vector4 } from 'three';
+import { SKY_DOME } from './constants';
+
+export const DISTANT_LAND = /* glsl */ `
+uniform vec3 uGround;
+uniform vec3 uWarm;
+uniform vec3 uWood;
+uniform vec4 uFields;
+uniform vec4 uWoods;
+uniform vec3 uDetail;
+
+const vec2 HASH_STEP = vec2(127.1, 311.7);
+const float HASH_SCALE = 43758.5453;
+const float TONE_SEED = 0.5;
+
+float landHash(vec2 cell) {
+ return fract(sin(dot(cell, HASH_STEP)) * HASH_SCALE);
+}
+
+float landNoise(vec2 point) {
+ vec2 cell = floor(point);
+ vec2 share = smoothstep(0.0, 1.0, fract(point));
+ return mix(
+ mix(landHash(cell), landHash(cell + vec2(1.0, 0.0)), share.x),
+ mix(landHash(cell + vec2(0.0, 1.0)), landHash(cell + vec2(1.0, 1.0)), share.x),
+ share.y
+ );
+}
+
+vec3 distantLand(vec2 point) {
+ vec2 axis = vec2(cos(uFields.w), sin(uFields.w));
+ vec2 cells = vec2(dot(point, axis), dot(point, vec2(-axis.y, axis.x))) / uFields.x;
+ float detail = 1.0 - smoothstep(uDetail.x, uDetail.y, length(fwidth(cells)));
+ vec2 cell = floor(cells);
+ vec3 field = mix(uGround, uWarm, step(1.0 - uFields.z, landHash(cell)));
+ field *= 1.0 + (landHash(cell + TONE_SEED) - TONE_SEED) * uFields.y;
+ vec2 woods = point / uWoods.x;
+ float cover = mix(landNoise(woods), landNoise(woods * uWoods.z), uWoods.w);
+ float wood = smoothstep(uWoods.y, uWoods.y + uDetail.z, cover);
+ vec3 average = mix(uGround, uWarm, uFields.z);
+ return mix(average, mix(field, uWood, wood), detail);
+}
+`;
+
+export function distantLandUniforms() {
+ const { colour, warm, wood, fields, woods, detail } = SKY_DOME.ground;
+ return {
+ uGround: { value: new Color(colour) },
+ uWarm: { value: new Color(warm) },
+ uWood: { value: new Color(wood) },
+ uFields: { value: new Vector4(fields.size, fields.tone, fields.warmShare, fields.angle) },
+ uWoods: { value: new Vector4(woods.cell, woods.threshold, woods.octave, woods.octaveShare) },
+ uDetail: { value: new Vector3(detail.from, detail.to, woods.edge) },
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/farmKeepOut.ts b/explainers/wind-farm/src/scene/parts/land/farmKeepOut.ts
new file mode 100644
index 00000000..ee4a027b
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/farmKeepOut.ts
@@ -0,0 +1,23 @@
+import { SPACING_OPTIONS } from '../../../ids';
+import { GRID_LINE_END, SUBSTATION, collectorRoutes, farmLayout } from '../../../model/layout';
+import type { GroundPoint } from '../../../model/layout';
+import { FARM_TREE_LIMITS } from './constants';
+import type { KeepOut } from './keepOut';
+
+const SUBSTATION_POINT: GroundPoint = [SUBSTATION.x, SUBSTATION.z];
+
+export function farmKeepOut(): KeepOut {
+ const { siteClear, routeClear, substationClear, gridClear } = FARM_TREE_LIMITS;
+ const sites = SPACING_OPTIONS.flatMap((spacing) => farmLayout(spacing));
+ const routes = SPACING_OPTIONS.flatMap((spacing) => collectorRoutes(spacing));
+ return {
+ discs: [
+ ...sites.map(({ x, z }) => ({ centre: [x, z] as GroundPoint, radius: siteClear })),
+ { centre: SUBSTATION_POINT, radius: substationClear },
+ ],
+ corridors: [
+ ...routes.map((points) => ({ points, radius: routeClear })),
+ { points: [SUBSTATION_POINT, [GRID_LINE_END.x, GRID_LINE_END.z]], radius: gridClear },
+ ],
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/farmLand.ts b/explainers/wind-farm/src/scene/parts/land/farmLand.ts
new file mode 100644
index 00000000..6c3d7e7d
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/farmLand.ts
@@ -0,0 +1,48 @@
+import { terrainHeight } from '../../../model/layout';
+import { label, namedGroup } from '../context';
+import type { PartContext, Section } from '../context';
+import { FARM_TREE_LIMITS, FARM_TREES, LAND_LABELS, TREE_SHAPE } from './constants';
+import { FARM_FIELDS } from './fieldConstants';
+import { fieldLayout } from './fieldLayout';
+import type { FieldLayout } from './fieldPlan';
+import { farmKeepOut } from './farmKeepOut';
+import { farmEdgeDistance, farmRules, farmTerrainGeometry } from './farmTerrain';
+import { farmProjection, paintFarmGround } from './farmTexture';
+import { groundTexture } from './groundTexture';
+import { isClear } from './keepOut';
+import type { KeepOut } from './keepOut';
+import { skyDome } from './sky';
+import { terrainMesh } from './terrainMesh';
+import { crownGeometry } from './treeGeometry';
+import { placeTrees } from './treePlacement';
+import type { TreeArea } from './treePlacement';
+import { plantTrees } from './trees';
+
+export function farmFieldLayout(keepOut: KeepOut): FieldLayout {
+ return fieldLayout(FARM_FIELDS, farmRules(keepOut));
+}
+
+export function farmTreeArea(layout: FieldLayout, keepOut: KeepOut): TreeArea {
+ return {
+ layout,
+ trees: FARM_TREES,
+ allowed: (x, z) =>
+ farmEdgeDistance(x, z) > FARM_TREE_LIMITS.edgeMargin && isClear(keepOut, x, z),
+ height: terrainHeight,
+ };
+}
+
+export function buildFarmLand(context: PartContext): Section {
+ const root = namedGroup('farmLand');
+ const keepOut = farmKeepOut();
+ const layout = farmFieldLayout(keepOut);
+ const projection = farmProjection();
+ const texture = groundTexture(context, projection, (painter) => paintFarmGround(painter, layout));
+ const terrain = terrainMesh(context, farmTerrainGeometry(projection), texture, 'farmLand');
+ const spots = placeTrees(farmTreeArea(layout, keepOut));
+ const { crown } = plantTrees(context, spots, 'farmTreeClump', crownGeometry(TREE_SHAPE.clump));
+ root.add(skyDome(context, 'farmSky'), terrain, crown);
+ const [x, z] = LAND_LABELS.farmLand;
+ label(context, 'farmLand', terrain, [x, terrainHeight(x, z), z]);
+ return { root, setState: () => undefined };
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/farmTerrain.ts b/explainers/wind-farm/src/scene/parts/land/farmTerrain.ts
new file mode 100644
index 00000000..60adcb18
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/farmTerrain.ts
@@ -0,0 +1,49 @@
+import { Color } from 'three';
+import type { BufferGeometry } from 'three';
+import { smoothstep } from '@core/math';
+import { FARM_TERRAIN, terrainHeight } from '../../../model/layout';
+import { FARM_GROUND } from './constants';
+import type { GroundRules } from './fieldPlan';
+import { isClear } from './keepOut';
+import type { KeepOut } from './keepOut';
+import type { Projection } from './projection';
+import { terrainAttributes } from './terrainAttributes';
+
+const WHITE = new Color(1, 1, 1);
+
+export function farmEdgeDistance(x: number, z: number): number {
+ const { minX, maxX, minZ, maxZ } = FARM_TERRAIN;
+ return Math.min(x - minX, maxX - x, z - minZ, maxZ - z);
+}
+
+function gridIndices(segments: number): number[] {
+ const indices: number[] = [];
+ const row = segments + 1;
+ for (let j = 0; j < segments; j += 1) {
+ for (let i = 0; i < segments; i += 1) {
+ const a = j * row + i;
+ indices.push(a, a + row, a + 1, a + 1, a + row, a + row + 1);
+ }
+ }
+ return indices;
+}
+
+export function farmRules(keepOut: KeepOut): GroundRules {
+ return { wooded: (x, z) => isClear(keepOut, x, z), hedged: () => true };
+}
+
+export function farmTerrainGeometry(projection: Projection): BufferGeometry {
+ const { segments, alphaFade } = FARM_GROUND;
+ const { minX, maxX, minZ, maxZ } = FARM_TERRAIN;
+ const stepX = (maxX - minX) / segments;
+ const stepZ = (maxZ - minZ) / segments;
+ const count = (segments + 1) ** 2;
+ const attributes = terrainAttributes(count, projection);
+ for (let index = 0; index < count; index += 1) {
+ const x = minX + (index % (segments + 1)) * stepX;
+ const z = minZ + Math.floor(index / (segments + 1)) * stepZ;
+ const alpha = smoothstep(farmEdgeDistance(x, z), 0, alphaFade);
+ attributes.write(index, [x, terrainHeight(x, z), z], WHITE, alpha);
+ }
+ return attributes.geometry(gridIndices(segments));
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/farmTexture.ts b/explainers/wind-farm/src/scene/parts/land/farmTexture.ts
new file mode 100644
index 00000000..d3671928
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/farmTexture.ts
@@ -0,0 +1,26 @@
+import { FARM_TERRAIN } from '../../../model/layout';
+import { FARM_GROUND, GROUND_TEXTURE } from './constants';
+import { GROUND_PAINT } from './fieldConstants';
+import type { FieldLayout } from './fieldPlan';
+import { paintFeatures } from './featurePainter';
+import { paintFieldDetails, paintFieldFills } from './fieldPainter';
+import type { Painter } from './groundCanvas';
+import { fadeBoxEdges, paintMottle } from './groundOverlays';
+import { boxProjection } from './projection';
+import type { Projection } from './projection';
+import { softenCanvas } from './softenCanvas';
+
+export function farmProjection(): Projection {
+ const { size, farm } = GROUND_TEXTURE;
+ return boxProjection(FARM_TERRAIN, size, farm.softness, farm.step);
+}
+
+export function paintFarmGround(painter: Painter, layout: FieldLayout): void {
+ const { soften, mottle } = GROUND_TEXTURE.farm;
+ paintFieldFills(painter, layout);
+ softenCanvas(painter, soften);
+ paintFieldDetails(painter, layout);
+ paintFeatures(painter, layout);
+ paintMottle(painter, mottle);
+ fadeBoxEdges(painter, FARM_TERRAIN, FARM_GROUND.hazeFade, GROUND_PAINT.distance);
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/featurePainter.ts b/explainers/wind-farm/src/scene/parts/land/featurePainter.ts
new file mode 100644
index 00000000..e0de936f
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/featurePainter.ts
@@ -0,0 +1,50 @@
+import { Color } from 'three';
+import type { GroundPoint } from '../../../model/layout';
+import { FARMSTEAD_ROOFS, GROUND_PAINT } from './fieldConstants';
+import type { Farmstead, FieldLayout, Run } from './fieldPlan';
+import { cssColour, rotatedRectangle, shaded, strokePath, tracePolygon } from './groundCanvas';
+import type { Painter } from './groundCanvas';
+
+const ROOF_SLOPES = [
+ { shade: 1.12, across: -0.25 },
+ { shade: 0.84, across: 0.25 },
+] as const;
+const HALF = 0.5;
+
+function strokeRuns(painter: Painter, runs: readonly Run[], metres: number, style: string): void {
+ runs.forEach(({ from, to }) => strokePath(painter, [from, to], metres, style));
+}
+
+function fillShape(painter: Painter, corners: readonly GroundPoint[], style: string): void {
+ tracePolygon(painter, corners);
+ painter.context.fillStyle = style;
+ painter.context.fill();
+}
+
+function paintFarmstead(painter: Painter, { centre, angle, yard, buildings }: Farmstead): void {
+ fillShape(painter, rotatedRectangle(centre, angle, yard), GROUND_PAINT.yard);
+ buildings.forEach(({ centre: offset, length, width, roof }) =>
+ ROOF_SLOPES.forEach(({ shade, across }) =>
+ fillShape(
+ painter,
+ rotatedRectangle(
+ centre,
+ angle,
+ [length, width * HALF],
+ [offset[0], offset[1] + across * width],
+ ),
+ cssColour(shaded(FARMSTEAD_ROOFS[roof], shade)),
+ ),
+ ),
+ );
+}
+
+const HEDGE = new Color(GROUND_PAINT.hedge);
+const MEAN = new Color(GROUND_PAINT.mean);
+
+export function paintFeatures(painter: Painter, layout: FieldLayout): void {
+ const hedge = cssColour(HEDGE.clone().lerp(MEAN, layout.look.hedgeMuting));
+ strokeRuns(painter, layout.hedges, GROUND_PAINT.hedgeWidth, hedge);
+ strokeRuns(painter, layout.tracks, GROUND_PAINT.trackWidth, GROUND_PAINT.track);
+ layout.farmsteads.forEach((farmstead) => paintFarmstead(painter, farmstead));
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/fieldConstants.ts b/explainers/wind-farm/src/scene/parts/land/fieldConstants.ts
new file mode 100644
index 00000000..9e2686df
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/fieldConstants.ts
@@ -0,0 +1,127 @@
+import { FARM_TERRAIN, TURBINE_LAND } from '../../../model/layout';
+import { THEME } from '../../../theme';
+import type { FieldPlan } from './fieldPlan';
+
+export interface Stripes {
+ readonly spacing: number;
+ readonly width: number;
+ readonly shade: number;
+ readonly alpha: number;
+}
+
+const FURROWS: Stripes = { spacing: 6, width: 2.4, shade: 0.7, alpha: 0.4 };
+const TRAMLINES: Stripes = { spacing: 24, width: 1.6, shade: 0.84, alpha: 0.45 };
+const SWATHS: Stripes = { spacing: 10, width: 3.5, shade: 1.15, alpha: 0.3 };
+
+export interface FieldKind {
+ readonly colour: string;
+ readonly warmth: number;
+ readonly stripes?: Stripes;
+}
+
+export const FIELD_KINDS: readonly FieldKind[] = [
+ { colour: '#8aa65d', warmth: 0 },
+ { colour: '#748f4c', warmth: 0.1 },
+ { colour: '#9db26a', warmth: 0.3, stripes: TRAMLINES },
+ { colour: '#bdb27a', warmth: 0.8, stripes: TRAMLINES },
+ { colour: '#c6bb8c', warmth: 1, stripes: SWATHS },
+ { colour: '#977f5d', warmth: 0.9, stripes: FURROWS },
+ { colour: '#a5a16d', warmth: 0.6 },
+];
+
+export const CANOPY_TILE = {
+ size: 96,
+ crowns: 90,
+ radius: [2.2, 4],
+ lift: 0.9,
+ highlight: 0.6,
+ shadow: 'rgba(44,62,32,0.55)',
+ light: 'rgba(118,146,84,0.4)',
+ seed: 17,
+} as const;
+
+export const FARMSTEAD_ROOFS = ['#6b6a6c', '#9c5b43', '#85827b', '#b9b2a4', '#7a4c3c'] as const;
+
+export const GROUND_PAINT = {
+ base: THEME.grass,
+ canopy: THEME.trees,
+ hedge: '#45602f',
+ track: '#c7b994',
+ yard: '#b7ad93',
+ gravel: THEME.gravel,
+ distance: '#a4b298',
+ mean: '#9eaa6e',
+ acrossShade: 0.08,
+ hedgeWidth: 6,
+ trackWidth: 4,
+ accessWidth: 5,
+ canopyFringe: { width: 14, alpha: 0.45 },
+} as const;
+
+export const WOOD_OUTLINE = {
+ rounding: 3,
+ smoothing: 2,
+ inset: [0, 0.12],
+ wavelength: 0.3,
+ maxPull: 0.4,
+ seed: 23,
+} as const;
+
+const FARMSTEAD = {
+ share: 0.85,
+ offset: [28, 50],
+ yard: [45, 75],
+ buildings: [2, 4],
+ length: [14, 40],
+ width: [8, 15],
+ spread: 26,
+} as const;
+
+const HEDGE = { share: 0.74, run: [40, 220], gapChance: 0.3, gap: [8, 26] } as const;
+
+export const FARM_FIELDS: FieldPlan = {
+ bounds: FARM_TERRAIN,
+ size: [560, 1300],
+ block: 3000,
+ angles: [0.22, 0.52, -0.14, 0.9],
+ cutShare: [0.32, 0.68],
+ cutJitter: 0.1,
+ woodShare: 0.05,
+ hedge: HEDGE,
+ track: { count: 8, minLength: 900 },
+ farmstead: FARMSTEAD,
+ look: {
+ weights: [0.26, 0.14, 0.2, 0.16, 0.09, 0.04, 0.11],
+ patches: { wavelength: 2600, pull: 0.8 },
+ muting: 0.32,
+ woodMuting: 0.22,
+ toneSpread: 0.06,
+ hedgeMuting: 0.25,
+ },
+ seed: 29,
+};
+
+const TURBINE_REACH = TURBINE_LAND.radius;
+
+export const TURBINE_FIELDS: FieldPlan = {
+ bounds: { minX: -TURBINE_REACH, maxX: TURBINE_REACH, minZ: -TURBINE_REACH, maxZ: TURBINE_REACH },
+ size: [230, 560],
+ near: { scale: 0.42, from: 250, to: 1800 },
+ block: 1100,
+ angles: [0.38, 0.1, 0.7, -0.25],
+ cutShare: [0.32, 0.68],
+ cutJitter: 0.09,
+ woodShare: 0.045,
+ hedge: HEDGE,
+ track: { count: 4, minLength: 700 },
+ farmstead: FARMSTEAD,
+ look: {
+ weights: [0.24, 0.17, 0.13, 0.16, 0.07, 0.13, 0.1],
+ patches: { wavelength: 1400, pull: 0.4 },
+ muting: 0.08,
+ woodMuting: 0,
+ toneSpread: 0.14,
+ hedgeMuting: 0.1,
+ },
+ seed: 11,
+};
diff --git a/explainers/wind-farm/src/scene/parts/land/fieldLayout.ts b/explainers/wind-farm/src/scene/parts/land/fieldLayout.ts
new file mode 100644
index 00000000..d8df8a77
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/fieldLayout.ts
@@ -0,0 +1,100 @@
+import type { GroundPoint } from '../../../model/layout';
+import type {
+ Building,
+ Farmstead,
+ Field,
+ FieldLayout,
+ FieldPlan,
+ GroundRules,
+ Run,
+} from './fieldPlan';
+import { splitParcels } from './fieldSplit';
+import type { Parcel } from './fieldSplit';
+import { pointAlong } from './polygon';
+import { between, seededRandom } from './random';
+import type { Random } from './random';
+import { FARMSTEAD_ROOFS } from './fieldConstants';
+import { woodOutline } from './woodOutline';
+
+const HALF = 0.5;
+const MIDDLE_SHARES = [0, HALF, 1];
+const FARM_ALONG: readonly [number, number] = [0.25, 0.75];
+
+function runLength({ from, to }: Run): number {
+ return Math.hypot(to[0] - from[0], to[1] - from[1]);
+}
+
+function toField(parcel: Parcel, plan: FieldPlan, rules: GroundRules, random: Random): Field {
+ const wooded = parcel.corners.every(([x, z]) => rules.wooded(x, z)) && random() < plan.woodShare;
+ return { ...parcel, wood: wooded ? woodOutline(parcel.corners) : undefined };
+}
+
+function hedgeRuns(boundary: Run, plan: FieldPlan, rules: GroundRules, random: Random): Run[] {
+ const { run, gapChance, gap } = plan.hedge;
+ const length = runLength(boundary);
+ const runs: Run[] = [];
+ let start = 0;
+ while (start < length) {
+ const end = Math.min(length, start + between(random, run));
+ const piece = {
+ from: pointAlong(boundary.from, boundary.to, start / length),
+ to: pointAlong(boundary.from, boundary.to, end / length),
+ };
+ const hedged = MIDDLE_SHARES.every((share) =>
+ rules.hedged(...pointAlong(piece.from, piece.to, share)),
+ );
+ if (hedged) runs.push(piece);
+ start = end + (random() < gapChance ? between(random, gap) : 0);
+ }
+ return runs;
+}
+
+function pickTracks(boundaries: readonly Run[], plan: FieldPlan, random: Random): Set {
+ const long = boundaries.filter((boundary) => runLength(boundary) >= plan.track.minLength);
+ const stride = long.length / plan.track.count;
+ const picks = Array.from(
+ { length: Math.min(plan.track.count, long.length) },
+ (_, index) => long[Math.floor((index + random()) * stride)],
+ );
+ return new Set(picks);
+}
+
+function building(random: Random, plan: FieldPlan): Building {
+ const { length, width, spread } = plan.farmstead;
+ return {
+ centre: [(random() - HALF) * spread, (random() - HALF) * spread],
+ length: between(random, length),
+ width: between(random, width),
+ roof: Math.floor(random() * FARMSTEAD_ROOFS.length),
+ };
+}
+
+function farmstead(track: Run, plan: FieldPlan, random: Random): Farmstead {
+ const { offset, yard, buildings } = plan.farmstead;
+ const angle = Math.atan2(track.to[1] - track.from[1], track.to[0] - track.from[0]);
+ const [x, z] = pointAlong(track.from, track.to, between(random, FARM_ALONG));
+ const side = (random() < HALF ? -1 : 1) * between(random, offset);
+ const centre: GroundPoint = [x - Math.sin(angle) * side, z + Math.cos(angle) * side];
+ const count = Math.round(between(random, buildings));
+ return {
+ centre,
+ angle,
+ yard: [between(random, yard), between(random, yard)],
+ buildings: Array.from({ length: count }, () => building(random, plan)),
+ };
+}
+
+export function fieldLayout(plan: FieldPlan, rules: GroundRules): FieldLayout {
+ const random = seededRandom(plan.seed);
+ const { parcels, boundaries } = splitParcels(plan, random);
+ const fields = parcels.map((parcel) => toField(parcel, plan, rules, random));
+ const tracks = pickTracks(boundaries, plan, random);
+ const hedges = boundaries
+ .filter((boundary) => !tracks.has(boundary) && random() < plan.hedge.share)
+ .flatMap((boundary) => hedgeRuns(boundary, plan, rules, random));
+ const farmsteads = [...tracks]
+ .filter(() => random() < plan.farmstead.share)
+ .map((track) => farmstead(track, plan, random))
+ .filter(({ centre }) => rules.wooded(...centre));
+ return { fields, hedges, tracks: [...tracks], farmsteads, look: plan.look };
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/fieldPainter.ts b/explainers/wind-farm/src/scene/parts/land/fieldPainter.ts
new file mode 100644
index 00000000..f5bd21b3
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/fieldPainter.ts
@@ -0,0 +1,125 @@
+import { Color } from 'three';
+import type { GroundPoint } from '../../../model/layout';
+import { canopyPattern } from './canopyPattern';
+import { GROUND_TEXTURE } from './constants';
+import { FIELD_KINDS, GROUND_PAINT } from './fieldConstants';
+import type { Stripes } from './fieldConstants';
+import type { Field, FieldLayout, FieldLook } from './fieldPlan';
+import { cssColour, shaded, traceLine, tracePolygon } from './groundCanvas';
+import type { Painter } from './groundCanvas';
+import { centroid, extents } from './polygon';
+import type { Polygon } from './polygon';
+
+const HALF = 0.5;
+
+function alongPoint(angle: number, along: number, across: number): GroundPoint {
+ const [cos, sin] = [Math.cos(angle), Math.sin(angle)];
+ return [along * cos - across * sin, along * sin + across * cos];
+}
+
+function fieldFill(painter: Painter, field: Field, base: Color): CanvasGradient {
+ const { along, across } = extents(field.corners, field.angle);
+ const middle = (along[0] + along[1]) / 2;
+ const [from, to] = [across[0], across[1]].map((offset) =>
+ painter.projection.pixel(...alongPoint(field.angle, middle, offset)),
+ );
+ const gradient = painter.context.createLinearGradient(...from, ...to);
+ const spread = GROUND_PAINT.acrossShade * HALF;
+ gradient.addColorStop(0, cssColour(shaded(base, 1 + spread)));
+ gradient.addColorStop(1, cssColour(shaded(base, 1 - spread)));
+ return gradient;
+}
+
+function paintStripes(painter: Painter, field: Field, base: Color, stripes: Stripes): void {
+ const { context, projection } = painter;
+ const [cx, cz] = centroid(field.corners);
+ const scale = projection.scale(cx, cz);
+ if (stripes.spacing * scale < GROUND_TEXTURE.minStripePx) return;
+ const { along, across } = extents(field.corners, field.angle);
+ context.save();
+ tracePolygon(painter, field.corners);
+ context.clip();
+ context.beginPath();
+ for (
+ let offset = across[0] + stripes.spacing * HALF;
+ offset < across[1];
+ offset += stripes.spacing
+ )
+ traceLine(painter, [
+ alongPoint(field.angle, along[0], offset),
+ alongPoint(field.angle, along[1], offset),
+ ]);
+ context.globalAlpha = stripes.alpha;
+ context.lineWidth = stripes.width * scale;
+ context.strokeStyle = cssColour(shaded(base, stripes.shade));
+ context.stroke();
+ context.restore();
+}
+
+const MEAN = new Color(GROUND_PAINT.mean);
+
+function tinted(colour: string, muting: number, field: Field, look: FieldLook): Color {
+ const tone = 1 + (field.tone - HALF) * look.toneSpread;
+ return new Color(colour).lerp(MEAN, muting).multiplyScalar(tone);
+}
+
+function fieldBase(field: Field, look: FieldLook): Color {
+ const colour = field.wood ? GROUND_PAINT.base : FIELD_KINDS[field.kind].colour;
+ return tinted(colour, look.muting, field, look);
+}
+
+function featherFill(
+ painter: Painter,
+ outline: Polygon,
+ style: CanvasGradient | CanvasPattern,
+): void {
+ const { context, projection } = painter;
+ const { width, alpha } = GROUND_PAINT.canopyFringe;
+ tracePolygon(painter, outline);
+ context.fillStyle = style;
+ context.fill();
+ context.save();
+ context.globalAlpha = alpha;
+ context.lineJoin = 'round';
+ context.lineWidth = width * projection.scale(...centroid(outline));
+ context.strokeStyle = style;
+ context.stroke();
+ context.restore();
+}
+
+function fillField(painter: Painter, field: Field, look: FieldLook): void {
+ tracePolygon(painter, field.corners);
+ painter.context.fillStyle = fieldFill(painter, field, fieldBase(field, look));
+ painter.context.fill();
+ if (!field.wood) return;
+ const canopy = tinted(GROUND_PAINT.canopy, look.woodMuting, field, look);
+ featherFill(painter, field.wood, fieldFill(painter, field, canopy));
+}
+
+function detailField(
+ painter: Painter,
+ field: Field,
+ look: FieldLook,
+ canopy: CanvasPattern | null,
+): void {
+ const { stripes } = FIELD_KINDS[field.kind];
+ if (field.wood && canopy) featherFill(painter, field.wood, canopy);
+ if (!field.wood && stripes) paintStripes(painter, field, fieldBase(field, look), stripes);
+}
+
+export function paintFieldFills(painter: Painter, { fields, look }: FieldLayout): void {
+ const { context, projection } = painter;
+ context.fillStyle = GROUND_PAINT.base;
+ context.fillRect(0, 0, projection.size, projection.size);
+ fields.forEach((field) => fillField(painter, field, look));
+}
+
+export function paintFieldDetails(painter: Painter, { fields, look }: FieldLayout): void {
+ const canopy = canopyPattern(painter.context);
+ fields.forEach((field) => detailField(painter, field, look, canopy));
+}
+
+export function paintFields(painter: Painter, layout: FieldLayout): void {
+ paintFieldFills(painter, layout);
+ paintFieldDetails(painter, layout);
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/fieldPlan.ts b/explainers/wind-farm/src/scene/parts/land/fieldPlan.ts
new file mode 100644
index 00000000..aab24e71
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/fieldPlan.ts
@@ -0,0 +1,94 @@
+import type { GroundPoint } from '../../../model/layout';
+import type { Polygon } from './polygon';
+
+type Range = readonly [number, number];
+
+export interface Bounds {
+ readonly minX: number;
+ readonly maxX: number;
+ readonly minZ: number;
+ readonly maxZ: number;
+}
+
+export interface FieldPatches {
+ readonly wavelength: number;
+ readonly pull: number;
+}
+
+export interface FieldLook {
+ readonly weights: readonly number[];
+ readonly patches: FieldPatches;
+ readonly muting: number;
+ readonly woodMuting: number;
+ readonly toneSpread: number;
+ readonly hedgeMuting: number;
+}
+
+export interface FieldPlan {
+ readonly bounds: Bounds;
+ readonly size: Range;
+ readonly near?: { readonly scale: number; readonly from: number; readonly to: number };
+ readonly block: number;
+ readonly angles: readonly number[];
+ readonly cutShare: Range;
+ readonly cutJitter: number;
+ readonly woodShare: number;
+ readonly hedge: {
+ readonly share: number;
+ readonly run: Range;
+ readonly gapChance: number;
+ readonly gap: Range;
+ };
+ readonly track: { readonly count: number; readonly minLength: number };
+ readonly farmstead: {
+ readonly share: number;
+ readonly offset: Range;
+ readonly yard: Range;
+ readonly buildings: Range;
+ readonly length: Range;
+ readonly width: Range;
+ readonly spread: number;
+ };
+ readonly look: FieldLook;
+ readonly seed: number;
+}
+
+export interface GroundRules {
+ wooded(x: number, z: number): boolean;
+ hedged(x: number, z: number): boolean;
+}
+
+export interface Field {
+ readonly corners: readonly GroundPoint[];
+ readonly angle: number;
+ readonly kind: number;
+ readonly tone: number;
+ readonly wood?: Polygon;
+}
+
+export interface Run {
+ readonly from: GroundPoint;
+ readonly to: GroundPoint;
+}
+
+export interface Building {
+ readonly centre: GroundPoint;
+ readonly length: number;
+ readonly width: number;
+ readonly roof: number;
+}
+
+export interface Farmstead {
+ readonly centre: GroundPoint;
+ readonly angle: number;
+ readonly yard: readonly [number, number];
+ readonly buildings: readonly Building[];
+}
+
+export interface FieldLayout {
+ readonly fields: readonly Field[];
+ readonly hedges: readonly Run[];
+ readonly tracks: readonly Run[];
+ readonly farmsteads: readonly Farmstead[];
+ readonly look: FieldLook;
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/fieldSplit.ts b/explainers/wind-farm/src/scene/parts/land/fieldSplit.ts
new file mode 100644
index 00000000..fa7dbcca
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/fieldSplit.ts
@@ -0,0 +1,118 @@
+import { smoothstep } from '@core/math';
+import { FIELD_KINDS } from './fieldConstants';
+import { wavelengthNoise } from './noise';
+import type { FieldPlan, Run } from './fieldPlan';
+import { centroid, cutPolygon, extents } from './polygon';
+import type { Cut, Polygon } from './polygon';
+import { between } from './random';
+import type { Random } from './random';
+
+export interface Parcel {
+ readonly corners: Polygon;
+ readonly angle: number;
+ readonly kind: number;
+ readonly tone: number;
+}
+
+export interface Parcels {
+ readonly parcels: readonly Parcel[];
+ readonly boundaries: readonly Run[];
+}
+
+const QUARTER_TURN = Math.PI / 2;
+const WARMTH_RANGE = [0.3, 0.7] as const;
+
+function patchWeights(plan: FieldPlan, [x, z]: readonly [number, number]): number[] {
+ const { weights, patches } = plan.look;
+ const noise = wavelengthNoise(x, z, patches.wavelength, plan.seed);
+ const warmth = smoothstep(noise, ...WARMTH_RANGE);
+ return weights.map(
+ (weight, index) =>
+ weight * (1 + patches.pull * (1 - 2 * Math.abs(FIELD_KINDS[index].warmth - warmth))),
+ );
+}
+
+function pickKind(random: Random, weights: readonly number[]): number {
+ let remaining = random() * weights.reduce((sum, weight) => sum + weight, 0);
+ const index = weights.findIndex((weight) => (remaining -= weight) < 0);
+ return index < 0 ? FIELD_KINDS.length - 1 : index;
+}
+
+function sizeScale(plan: FieldPlan, x: number, z: number): number {
+ if (!plan.near) return 1;
+ const { scale, from, to } = plan.near;
+ return scale + (1 - scale) * smoothstep(Math.hypot(x, z), from, to);
+}
+
+function boundsPolygon({ minX, maxX, minZ, maxZ }: FieldPlan['bounds']): Polygon {
+ return [
+ [minX, minZ],
+ [maxX, minZ],
+ [maxX, maxZ],
+ [minX, maxZ],
+ ];
+}
+
+function longestEdgeAngle(corners: Polygon): number {
+ let [best, angle] = [0, 0];
+ corners.forEach(([x, z], index) => {
+ const [nx, nz] = corners[(index + 1) % corners.length];
+ const length = Math.hypot(nx - x, nz - z);
+ if (length > best) [best, angle] = [length, Math.atan2(nz - z, nx - x)];
+ });
+ return angle;
+}
+
+function cutAcross(
+ plan: FieldPlan,
+ corners: Polygon,
+ angle: number,
+ random: Random,
+): Cut | undefined {
+ const [cx, cz] = centroid(corners);
+ const { along, across } = extents(corners, angle);
+ const wide = along[1] - along[0] >= across[1] - across[0];
+ const axis = wide ? angle : angle + QUARTER_TURN;
+ const [from, to] = wide ? along : across;
+ const shift =
+ from +
+ (to - from) * between(random, plan.cutShare) -
+ (cx * Math.cos(axis) + cz * Math.sin(axis));
+ const tilt = axis + (random() * 2 - 1) * plan.cutJitter;
+ return cutPolygon(
+ corners,
+ [cx + Math.cos(axis) * shift, cz + Math.sin(axis) * shift],
+ [Math.cos(tilt), Math.sin(tilt)],
+ );
+}
+
+export function splitParcels(plan: FieldPlan, random: Random): Parcels {
+ const parcels: Parcel[] = [];
+ const boundaries: Run[] = [];
+ const pickAngle = () => plan.angles[Math.floor(random() * plan.angles.length)];
+ const split = (corners: Polygon) => {
+ const [cx, cz] = centroid(corners);
+ const blockAngle = pickAngle();
+ const fieldAngle = longestEdgeAngle(corners);
+ const { along, across } = extents(corners, fieldAngle);
+ const longest = Math.max(along[1] - along[0], across[1] - across[0]);
+ const block = longest > plan.block;
+ const angle = block ? blockAngle : fieldAngle;
+ const cut =
+ longest > between(random, plan.size) * sizeScale(plan, cx, cz) &&
+ cutAcross(plan, corners, angle, random);
+ if (!cut) {
+ parcels.push({
+ corners,
+ angle: fieldAngle,
+ kind: pickKind(random, patchWeights(plan, [cx, cz])),
+ tone: random(),
+ });
+ return;
+ }
+ boundaries.push({ from: cut.chord[0], to: cut.chord[1] });
+ cut.sides.forEach(split);
+ };
+ split(boundsPolygon(plan.bounds));
+ return { parcels, boundaries };
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/groundCanvas.ts b/explainers/wind-farm/src/scene/parts/land/groundCanvas.ts
new file mode 100644
index 00000000..36c9f629
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/groundCanvas.ts
@@ -0,0 +1,91 @@
+import { Color, SRGBColorSpace } from 'three';
+import type { GroundPoint } from '../../../model/layout';
+import { GROUND_TEXTURE } from './constants';
+import { pointAlong } from './polygon';
+import type { Projection } from './projection';
+
+export interface Painter {
+ readonly context: CanvasRenderingContext2D;
+ readonly projection: Projection;
+}
+
+const CHANNEL = 255;
+const HALF = 0.5;
+const rgb = { r: 0, g: 0, b: 0 };
+
+export function cssColour(colour: Color, alpha = 1): string {
+ const { r, g, b } = colour.getRGB(rgb, SRGBColorSpace);
+ const [red, green, blue] = [r, g, b].map((value) => Math.round(Math.min(1, value) * CHANNEL));
+ return `rgba(${red},${green},${blue},${alpha})`;
+}
+
+export function shaded(colour: string | Color, factor: number): Color {
+ return new Color(colour).multiplyScalar(factor);
+}
+
+export function traceLine(painter: Painter, points: readonly GroundPoint[], closed = false): void {
+ const { context, projection } = painter;
+ const ring = closed ? [...points, points[0]] : points;
+ context.moveTo(...projection.pixel(...ring[0]));
+ for (let index = 1; index < ring.length; index += 1) {
+ const [fromX, fromZ] = ring[index - 1];
+ const [toX, toZ] = ring[index];
+ const steps = Math.max(1, Math.ceil(Math.hypot(toX - fromX, toZ - fromZ) / projection.step));
+ for (let step = 1; step <= steps; step += 1) {
+ const share = step / steps;
+ context.lineTo(
+ ...projection.pixel(fromX + (toX - fromX) * share, fromZ + (toZ - fromZ) * share),
+ );
+ }
+ }
+}
+
+export function tracePolygon(painter: Painter, points: readonly GroundPoint[]): void {
+ painter.context.beginPath();
+ traceLine(painter, points, true);
+ painter.context.closePath();
+}
+
+export function lineWidth(painter: Painter, [x, z]: GroundPoint, metres: number): number {
+ return Math.max(GROUND_TEXTURE.minLinePx, metres * painter.projection.scale(x, z));
+}
+
+export function strokePath(
+ painter: Painter,
+ points: readonly GroundPoint[],
+ metres: number,
+ style: string,
+): void {
+ const { context } = painter;
+ context.strokeStyle = style;
+ context.lineCap = 'round';
+ for (let index = 1; index < points.length; index += 1) {
+ const [from, to] = [points[index - 1], points[index]];
+ const length = Math.hypot(to[0] - from[0], to[1] - from[1]);
+ const pieces = Math.max(1, Math.ceil(length / GROUND_TEXTURE.strokePiece));
+ for (let piece = 0; piece < pieces; piece += 1) {
+ const [start, end] = [piece / pieces, (piece + 1) / pieces].map((share) =>
+ pointAlong(from, to, share),
+ );
+ context.beginPath();
+ traceLine(painter, [start, end]);
+ context.lineWidth = lineWidth(painter, pointAlong(start, end, HALF), metres);
+ context.stroke();
+ }
+ }
+}
+
+export function rotatedRectangle(
+ centre: GroundPoint,
+ angle: number,
+ [length, width]: readonly [number, number],
+ [offsetAlong, offsetAcross]: readonly [number, number] = [0, 0],
+): GroundPoint[] {
+ const [cos, sin] = [Math.cos(angle), Math.sin(angle)];
+ const corner = (along: number, across: number): GroundPoint => [
+ centre[0] + (offsetAlong + along) * cos - (offsetAcross + across) * sin,
+ centre[1] + (offsetAlong + along) * sin + (offsetAcross + across) * cos,
+ ];
+ const [a, b] = [length / 2, width / 2];
+ return [corner(-a, -b), corner(a, -b), corner(a, b), corner(-a, b)];
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/groundOverlays.ts b/explainers/wind-farm/src/scene/parts/land/groundOverlays.ts
new file mode 100644
index 00000000..210d839c
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/groundOverlays.ts
@@ -0,0 +1,125 @@
+import { Color } from 'three';
+import { smoothstep } from '@core/math';
+import type { GroundPoint } from '../../../model/layout';
+import { GROUND_TEXTURE } from './constants';
+import type { MottleLayer } from './constants';
+import type { Bounds } from './fieldPlan';
+import { cssColour } from './groundCanvas';
+import type { Painter } from './groundCanvas';
+import { hash2 } from './random';
+
+const RGBA = 4;
+const GREY = 128;
+const OPAQUE = 255;
+const HALF = 0.5;
+
+function noiseCanvas(cells: number, amount: number, seed: number): HTMLCanvasElement {
+ const canvas = document.createElement('canvas');
+ canvas.width = cells;
+ canvas.height = cells;
+ const context = canvas.getContext('2d');
+ if (!context) return canvas;
+ const image = context.createImageData(cells, cells);
+ for (let index = 0; index < cells * cells; index += 1) {
+ const value =
+ GREY + (hash2(index % cells, Math.floor(index / cells), seed) - HALF) * amount * OPAQUE;
+ image.data.fill(value, index * RGBA, index * RGBA + RGBA - 1);
+ image.data[index * RGBA + RGBA - 1] = OPAQUE;
+ }
+ context.putImageData(image, 0, 0);
+ return canvas;
+}
+
+export function paintMottle(
+ painter: Painter,
+ layers: readonly MottleLayer[] = GROUND_TEXTURE.mottle,
+): void {
+ const { context, projection } = painter;
+ context.save();
+ context.globalCompositeOperation = 'soft-light';
+ context.imageSmoothingEnabled = true;
+ context.imageSmoothingQuality = 'high';
+ layers.forEach(({ cells, amount, seed }) =>
+ context.drawImage(noiseCanvas(cells, amount, seed), 0, 0, projection.size, projection.size),
+ );
+ context.restore();
+}
+
+function fadeStops(
+ gradient: CanvasGradient,
+ colour: Color,
+ offsets: (share: number) => number,
+): void {
+ const { fadeStops: stops } = GROUND_TEXTURE;
+ for (let stop = 0; stop <= stops; stop += 1) {
+ const share = stop / stops;
+ gradient.addColorStop(offsets(share), cssColour(colour, 1 - smoothstep(share, 0, 1)));
+ }
+}
+
+export function fadeBoxEdges(painter: Painter, bounds: Bounds, fade: number, tone: string): void {
+ const { context, projection } = painter;
+ const colour = new Color(tone);
+ const [middleX, middleZ] = [(bounds.minX + bounds.maxX) / 2, (bounds.minZ + bounds.maxZ) / 2];
+ const sides: readonly [GroundPoint, GroundPoint][] = [
+ [
+ [bounds.minX, middleZ],
+ [bounds.minX + fade, middleZ],
+ ],
+ [
+ [bounds.maxX, middleZ],
+ [bounds.maxX - fade, middleZ],
+ ],
+ [
+ [middleX, bounds.minZ],
+ [middleX, bounds.minZ + fade],
+ ],
+ [
+ [middleX, bounds.maxZ],
+ [middleX, bounds.maxZ - fade],
+ ],
+ ];
+ sides.forEach(([edge, inner]) => {
+ const [from, to] = [projection.pixel(...edge), projection.pixel(...inner)];
+ const length = Math.hypot(to[0] - from[0], to[1] - from[1]);
+ const gradient = context.createLinearGradient(...from, ...to);
+ fadeStops(gradient, colour, (share) => {
+ const [x, y] = projection.pixel(
+ edge[0] + (inner[0] - edge[0]) * share,
+ edge[1] + (inner[1] - edge[1]) * share,
+ );
+ return Math.hypot(x - from[0], y - from[1]) / length;
+ });
+ context.fillStyle = gradient;
+ context.fillRect(0, 0, projection.size, projection.size);
+ });
+}
+
+export function radialGradient(
+ painter: Painter,
+ [inner, outer]: readonly [number, number],
+ stop: (share: number) => string,
+): CanvasGradient {
+ const { context, projection } = painter;
+ const centre = projection.pixel(0, 0);
+ const radius = (distance: number) => projection.pixel(distance, 0)[0] - centre[0];
+ const gradient = context.createRadialGradient(...centre, radius(inner), ...centre, radius(outer));
+ const { fadeStops: stops } = GROUND_TEXTURE;
+ for (let index = 0; index <= stops; index += 1) {
+ const share = index / stops;
+ const distance = inner + (outer - inner) * share;
+ const offset = (radius(distance) - radius(inner)) / (radius(outer) - radius(inner));
+ gradient.addColorStop(offset, stop(smoothstep(share, 0, 1)));
+ }
+ return gradient;
+}
+
+export function radialPaint(
+ painter: Painter,
+ range: readonly [number, number],
+ stop: (share: number) => string,
+): void {
+ const { context, projection } = painter;
+ context.fillStyle = radialGradient(painter, range, stop);
+ context.fillRect(0, 0, projection.size, projection.size);
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/groundTexture.ts b/explainers/wind-farm/src/scene/parts/land/groundTexture.ts
new file mode 100644
index 00000000..b0359721
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/groundTexture.ts
@@ -0,0 +1,21 @@
+import { CanvasTexture, SRGBColorSpace } from 'three';
+import type { PartContext } from '../context';
+import { GROUND_TEXTURE } from './constants';
+import type { Painter } from './groundCanvas';
+import type { Projection } from './projection';
+
+export function groundTexture(
+ context: PartContext,
+ projection: Projection,
+ paint: (painter: Painter) => void,
+): CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = projection.size;
+ canvas.height = projection.size;
+ const drawing = canvas.getContext('2d');
+ if (drawing) paint({ context: drawing, projection });
+ const texture = context.tracker.track(new CanvasTexture(canvas));
+ texture.colorSpace = SRGBColorSpace;
+ texture.anisotropy = GROUND_TEXTURE.anisotropy;
+ return texture;
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/hardstand.ts b/explainers/wind-farm/src/scene/parts/land/hardstand.ts
new file mode 100644
index 00000000..b3ea560b
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/hardstand.ts
@@ -0,0 +1,42 @@
+import { CircleGeometry, ShapeGeometry } from 'three';
+import type { BufferGeometry, Mesh } from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { roundedRectShape } from '@core/scene/geometry/extrude';
+import { FINISHES } from '../../finishes';
+import { finishMesh } from '../context';
+import type { PartContext } from '../context';
+import { HARDSTAND } from './constants';
+
+const FLAT_TURN = -Math.PI / 2;
+const POLYGON_OFFSET = { factor: -1, units: -4 } as const;
+
+const HARDSTAND_FINISH = {
+ ...FINISHES.gravel,
+ polygonOffset: true,
+ polygonOffsetFactor: POLYGON_OFFSET.factor,
+ polygonOffsetUnits: POLYGON_OFFSET.units,
+};
+
+function hardstandGeometry(): BufferGeometry {
+ const { pad, apronRadius, corner, arcSegments, lift } = HARDSTAND;
+ const padShape = roundedRectShape(
+ { minA: pad.minX, maxA: pad.maxX, minB: -pad.maxZ, maxB: -pad.minZ },
+ corner,
+ );
+ const parts = [
+ new ShapeGeometry(padShape, arcSegments),
+ new CircleGeometry(apronRadius, arcSegments),
+ ];
+ const merged = mergeGeometries(parts);
+ parts.forEach((part) => part.dispose());
+ merged.rotateX(FLAT_TURN);
+ merged.translate(0, lift, 0);
+ return merged;
+}
+
+export function hardstandMesh(context: PartContext): Mesh {
+ const mesh = finishMesh(context, hardstandGeometry(), 'land', HARDSTAND_FINISH);
+ mesh.name = 'land';
+ mesh.receiveShadow = true;
+ return mesh;
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/index.ts b/explainers/wind-farm/src/scene/parts/land/index.ts
new file mode 100644
index 00000000..2e48fbff
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/index.ts
@@ -0,0 +1,2 @@
+export { buildFarmLand } from './farmLand';
+export { buildTurbineLand } from './turbineLand';
diff --git a/explainers/wind-farm/src/scene/parts/land/keepOut.ts b/explainers/wind-farm/src/scene/parts/land/keepOut.ts
new file mode 100644
index 00000000..9d9aeaf0
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/keepOut.ts
@@ -0,0 +1,44 @@
+import type { GroundPoint } from '../../../model/layout';
+
+export interface KeepOutDisc {
+ readonly centre: GroundPoint;
+ readonly radius: number;
+}
+
+export interface KeepOutCorridor {
+ readonly points: readonly GroundPoint[];
+ readonly radius: number;
+}
+
+export interface KeepOut {
+ readonly discs: readonly KeepOutDisc[];
+ readonly corridors: readonly KeepOutCorridor[];
+}
+
+function distanceToSegment(x: number, z: number, from: GroundPoint, to: GroundPoint): number {
+ const dx = to[0] - from[0];
+ const dz = to[1] - from[1];
+ const lengthSquared = dx * dx + dz * dz;
+ const share =
+ lengthSquared > 0
+ ? Math.min(1, Math.max(0, ((x - from[0]) * dx + (z - from[1]) * dz) / lengthSquared))
+ : 0;
+ return Math.hypot(x - from[0] - dx * share, z - from[1] - dz * share);
+}
+
+export function distanceToPolyline(points: readonly GroundPoint[], x: number, z: number): number {
+ let nearest = Infinity;
+ for (let index = 1; index < points.length; index += 1)
+ nearest = Math.min(nearest, distanceToSegment(x, z, points[index - 1], points[index]));
+ return nearest;
+}
+
+export function isClear(keepOut: KeepOut, x: number, z: number): boolean {
+ const discHit = keepOut.discs.some(
+ ({ centre, radius }) => Math.hypot(x - centre[0], z - centre[1]) < radius,
+ );
+ if (discHit) return false;
+ return keepOut.corridors.every(
+ ({ points, radius }) => distanceToPolyline(points, x, z) >= radius,
+ );
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/land.test.ts b/explainers/wind-farm/src/scene/parts/land/land.test.ts
new file mode 100644
index 00000000..ab1998ea
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/land.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+import { SPACING_OPTIONS } from '../../../ids';
+import { TURBINE_LAND, farmLayout, terrainHeight } from '../../../model/layout';
+import { FARM_TREE_LIMITS, TURBINE_TREE_LIMITS } from './constants';
+import { farmKeepOut } from './farmKeepOut';
+import { farmFieldLayout, farmTreeArea } from './farmLand';
+import { placeTrees } from './treePlacement';
+import { turbineLandHeight } from './turbineGround';
+import { turbineFieldLayout, turbineTreeArea } from './turbineLand';
+
+describe('turbine land', () => {
+ it('is flat around the tower and meets the terrain past the blend', () => {
+ expect(turbineLandHeight(TURBINE_LAND.flatRadius, 0)).toBe(0);
+ const far = TURBINE_LAND.blendRadius + 1;
+ expect(turbineLandHeight(0, far)).toBeCloseTo(terrainHeight(0, far));
+ });
+
+ it('keeps trees away from the hero', () => {
+ const spots = placeTrees(turbineTreeArea(turbineFieldLayout()));
+ expect(spots.length).toBeGreaterThan(300);
+ spots.forEach(({ x, z }) =>
+ expect(Math.hypot(x, z)).toBeGreaterThan(TURBINE_TREE_LIMITS.heroClear),
+ );
+ });
+});
+
+describe('farm land', () => {
+ it('keeps tree clumps clear of every turbine site', () => {
+ const keepOut = farmKeepOut();
+ const spots = placeTrees(farmTreeArea(farmFieldLayout(keepOut), keepOut));
+ expect(spots.length).toBeGreaterThan(1000);
+ const sites = SPACING_OPTIONS.flatMap((spacing) => farmLayout(spacing));
+ spots.forEach(({ x, z }) =>
+ sites.forEach((site) =>
+ expect(Math.hypot(x - site.x, z - site.z)).toBeGreaterThan(FARM_TREE_LIMITS.siteClear),
+ ),
+ );
+ });
+});
diff --git a/explainers/wind-farm/src/scene/parts/land/noise.ts b/explainers/wind-farm/src/scene/parts/land/noise.ts
new file mode 100644
index 00000000..a354d3b6
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/noise.ts
@@ -0,0 +1,23 @@
+import { hash2 } from './random';
+
+function fade(share: number): number {
+ return share * share * (3 - 2 * share);
+}
+
+export function valueNoise(x: number, z: number, seed: number): number {
+ const cellX = Math.floor(x);
+ const cellZ = Math.floor(z);
+ const shareX = fade(x - cellX);
+ const shareZ = fade(z - cellZ);
+ const a = hash2(cellX, cellZ, seed);
+ const b = hash2(cellX + 1, cellZ, seed);
+ const c = hash2(cellX, cellZ + 1, seed);
+ const d = hash2(cellX + 1, cellZ + 1, seed);
+ const near = a + (b - a) * shareX;
+ const far = c + (d - c) * shareX;
+ return near + (far - near) * shareZ;
+}
+
+export function wavelengthNoise(x: number, z: number, wavelength: number, seed: number): number {
+ return valueNoise(x / wavelength, z / wavelength, seed);
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/polygon.ts b/explainers/wind-farm/src/scene/parts/land/polygon.ts
new file mode 100644
index 00000000..3c81a9e2
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/polygon.ts
@@ -0,0 +1,93 @@
+import type { GroundPoint } from '../../../model/layout';
+import type { Random } from './random';
+
+export type Polygon = readonly GroundPoint[];
+
+export interface Extents {
+ readonly along: readonly [number, number];
+ readonly across: readonly [number, number];
+}
+
+export interface Cut {
+ readonly sides: readonly [Polygon, Polygon];
+ readonly chord: readonly [GroundPoint, GroundPoint];
+}
+
+const MIN_CHORD_POINTS = 2;
+
+export function centroid(polygon: Polygon): GroundPoint {
+ const [x, z] = polygon.reduce(([sumX, sumZ], [px, pz]) => [sumX + px, sumZ + pz], [0, 0]);
+ return [x / polygon.length, z / polygon.length];
+}
+
+function triangleArea(a: GroundPoint, b: GroundPoint, c: GroundPoint): number {
+ return Math.abs((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])) / 2;
+}
+
+export function polygonArea(polygon: Polygon): number {
+ let area = 0;
+ for (let index = 2; index < polygon.length; index += 1)
+ area += triangleArea(polygon[0], polygon[index - 1], polygon[index]);
+ return area;
+}
+
+export function extents(polygon: Polygon, angle: number): Extents {
+ const cos = Math.cos(angle);
+ const sin = Math.sin(angle);
+ const along = polygon.map(([x, z]) => x * cos + z * sin);
+ const across = polygon.map(([x, z]) => z * cos - x * sin);
+ return {
+ along: [Math.min(...along), Math.max(...along)],
+ across: [Math.min(...across), Math.max(...across)],
+ };
+}
+
+function crossing(a: GroundPoint, b: GroundPoint, da: number, db: number): GroundPoint {
+ const share = da / (da - db);
+ return [a[0] + (b[0] - a[0]) * share, a[1] + (b[1] - a[1]) * share];
+}
+
+export function cutPolygon(
+ polygon: Polygon,
+ origin: GroundPoint,
+ normal: GroundPoint,
+): Cut | undefined {
+ const front: GroundPoint[] = [];
+ const back: GroundPoint[] = [];
+ const chord: GroundPoint[] = [];
+ const side = ([x, z]: GroundPoint) => (x - origin[0]) * normal[0] + (z - origin[1]) * normal[1];
+ polygon.forEach((point, index) => {
+ const next = polygon[(index + 1) % polygon.length];
+ const [here, there] = [side(point), side(next)];
+ (here >= 0 ? front : back).push(point);
+ if (here >= 0 === there >= 0) return;
+ const hit = crossing(point, next, here, there);
+ front.push(hit);
+ back.push(hit);
+ chord.push(hit);
+ });
+ if (chord.length < MIN_CHORD_POINTS) return undefined;
+ return { sides: [front, back], chord: [chord[0], chord[1]] };
+}
+
+export function pointInPolygon(polygon: Polygon, random: Random): GroundPoint {
+ const areas = polygon
+ .slice(2)
+ .map((point, index) => triangleArea(polygon[0], polygon[index + 1], point));
+ let pick = random() * areas.reduce((sum, area) => sum + area, 0);
+ const triangle = Math.max(
+ 0,
+ areas.findIndex((area) => (pick -= area) <= 0),
+ );
+ const [a, b, c] = [polygon[0], polygon[triangle + 1], polygon[triangle + 2]];
+ let [s, t] = [random(), random()];
+ if (s + t > 1) [s, t] = [1 - s, 1 - t];
+ return [
+ a[0] + (b[0] - a[0]) * s + (c[0] - a[0]) * t,
+ a[1] + (b[1] - a[1]) * s + (c[1] - a[1]) * t,
+ ];
+}
+
+export function pointAlong(from: GroundPoint, to: GroundPoint, share: number): GroundPoint {
+ return [from[0] + (to[0] - from[0]) * share, from[1] + (to[1] - from[1]) * share];
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/projection.ts b/explainers/wind-farm/src/scene/parts/land/projection.ts
new file mode 100644
index 00000000..06e2c0eb
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/projection.ts
@@ -0,0 +1,71 @@
+import type { Bounds } from './fieldPlan';
+
+export interface Projection {
+ readonly size: number;
+ readonly step: number;
+ pixel(x: number, z: number): [number, number];
+ scale(x: number, z: number): number;
+}
+
+interface AxisWarp {
+ share(value: number): number;
+ slope(value: number): number;
+}
+
+function axisWarp(min: number, max: number, softness: number): AxisWarp {
+ const centre = (min + max) / 2;
+ const warp = (value: number) => Math.asinh((value - centre) / softness);
+ const from = warp(min);
+ const span = warp(max) - from;
+ return {
+ share: (value) => (warp(value) - from) / span,
+ slope: (value) => 1 / (softness * Math.hypot(1, (value - centre) / softness) * span),
+ };
+}
+
+export function boxProjection(
+ bounds: Bounds,
+ size: number,
+ softness: number,
+ step: number,
+): Projection {
+ const across = axisWarp(bounds.minX, bounds.maxX, softness);
+ const down = axisWarp(bounds.minZ, bounds.maxZ, softness);
+ return {
+ size,
+ step,
+ pixel: (x, z) => [across.share(x) * size, down.share(z) * size],
+ scale: (x, z) => size * Math.sqrt(across.slope(x) * down.slope(z)),
+ };
+}
+
+export function discProjection(
+ radius: number,
+ size: number,
+ softness: number,
+ step: number,
+): Projection {
+ const half = size / 2;
+ const span = Math.log1p(radius / softness);
+ const reach = (distance: number) => (half * Math.log1p(distance / softness)) / span;
+ const radial = (distance: number) => half / ((softness + distance) * span);
+ return {
+ size,
+ step,
+ pixel: (x, z) => {
+ const distance = Math.hypot(x, z);
+ const ratio = distance > 0 ? reach(distance) / distance : radial(0);
+ return [half + x * ratio, half + z * ratio];
+ },
+ scale: (x, z) => {
+ const distance = Math.hypot(x, z);
+ const around = distance > 0 ? reach(distance) / distance : radial(0);
+ return Math.sqrt(radial(distance) * around);
+ },
+ };
+}
+
+export function projectedUv(projection: Projection, x: number, z: number): [number, number] {
+ const [px, py] = projection.pixel(x, z);
+ return [px / projection.size, 1 - py / projection.size];
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/random.ts b/explainers/wind-farm/src/scene/parts/land/random.ts
new file mode 100644
index 00000000..1b45d5f9
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/random.ts
@@ -0,0 +1,32 @@
+const UINT32 = 2 ** 32;
+const MIX_STEP = 0x6d2b79f5;
+const SHIFT_A = 15;
+const SHIFT_B = 7;
+const SHIFT_C = 14;
+const ODD_A = 1;
+const ODD_B = 61;
+const HASH_X = 127.1;
+const HASH_Y = 311.7;
+const HASH_SEED = 74.7;
+const HASH_SCALE = 43_758.5453;
+
+export type Random = () => number;
+
+export function seededRandom(seed: number): Random {
+ let state = seed >>> 0;
+ return () => {
+ state = (state + MIX_STEP) >>> 0;
+ let mixed = Math.imul(state ^ (state >>> SHIFT_A), ODD_A | state);
+ mixed ^= mixed + Math.imul(mixed ^ (mixed >>> SHIFT_B), ODD_B | mixed);
+ return ((mixed ^ (mixed >>> SHIFT_C)) >>> 0) / UINT32;
+ };
+}
+
+export function hash2(x: number, y: number, seed = 0): number {
+ const value = Math.sin(x * HASH_X + y * HASH_Y + seed * HASH_SEED) * HASH_SCALE;
+ return value - Math.floor(value);
+}
+
+export function between(random: Random, [from, to]: readonly [number, number]): number {
+ return from + (to - from) * random();
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/sky.ts b/explainers/wind-farm/src/scene/parts/land/sky.ts
new file mode 100644
index 00000000..11868364
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/sky.ts
@@ -0,0 +1,111 @@
+import {
+ BackSide,
+ Color,
+ Fog,
+ Mesh,
+ ShaderMaterial,
+ SphereGeometry,
+ Vector2,
+ Vector3,
+} from 'three';
+import type { Object3D } from 'three';
+import { UNDIMMED_GROUP } from '@core/scene/materials';
+import { HAZE, SKY, SUN_DIRECTION } from '../../constants';
+import { registeredMaterial } from '../context';
+import type { PartContext } from '../context';
+import { SKY_DOME } from './constants';
+import { DISTANT_LAND, distantLandUniforms } from './distantLand';
+
+const VERTEX = /* glsl */ `
+varying vec3 vDirection;
+void main() {
+ vDirection = position;
+ vec4 clip = projectionMatrix * vec4(mat3(viewMatrix) * position, 1.0);
+ gl_Position = clip.xyww;
+}
+`;
+
+const FRAGMENT = /* glsl */ `
+${DISTANT_LAND}
+uniform vec3 uTop;
+uniform vec3 uHorizon;
+uniform vec3 uGlow;
+uniform vec3 uSun;
+uniform vec2 uRise;
+uniform vec3 uWide;
+uniform vec3 uCore;
+uniform vec2 uFloor;
+uniform vec2 uFog;
+varying vec3 vDirection;
+
+vec3 groundBelow(vec3 direction) {
+ float down = max(-direction.y, 1e-5);
+ float reach = max(cameraPosition.y, uFloor.x) / down;
+ float depth = reach * max(-(viewMatrix * vec4(direction, 0.0)).z, 0.0);
+ float haze = max(smoothstep(uFog.x, uFog.y, depth), 1.0 - smoothstep(0.0, uFloor.y, down));
+ vec2 point = cameraPosition.xz + direction.xz * reach;
+ return mix(distantLand(point), uHorizon, haze);
+}
+
+void main() {
+ vec3 direction = normalize(vDirection);
+ float up = max(direction.y, 0.0);
+ float lift = smoothstep(0.0, uRise.y, up);
+ float rise = (1.0 - exp(-up * uRise.x)) * lift;
+ vec3 ground = groundBelow(direction);
+ vec3 sky = direction.y >= 0.0 ? mix(uHorizon, uTop, rise) : ground;
+ float towardSun = max(dot(direction, uSun), 0.0);
+ vec2 bearing = normalize(direction.xz + vec2(1e-5));
+ float sunward = max(dot(bearing, normalize(uSun.xz)), 0.0);
+ float glow = pow(towardSun, uWide.y) * uWide.x
+ + pow(sunward, uWide.z) * uCore.z * lift * (1.0 - rise)
+ + pow(towardSun, uCore.y) * uCore.x;
+ float above = smoothstep(-uRise.y, uRise.y, direction.y);
+ sky = mix(sky, uGlow, clamp(glow, 0.0, 1.0) * above);
+ gl_FragColor = vec4(sky, 1.0);
+ #include
+}
+`;
+
+function skyMaterial(): ShaderMaterial {
+ const { rise, band, glow, ground } = SKY_DOME;
+ return new ShaderMaterial({
+ uniforms: {
+ uTop: { value: new Color(SKY.top) },
+ uHorizon: { value: new Color(HAZE.colour) },
+ uGlow: { value: new Color(glow.colour) },
+ uSun: { value: new Vector3(...SUN_DIRECTION).normalize() },
+ uRise: { value: new Vector2(rise, band) },
+ uWide: { value: new Vector3(glow.wide, glow.wideTightness, glow.horizonTightness) },
+ uCore: { value: new Vector3(glow.core, glow.coreTightness, glow.horizon) },
+ ...distantLandUniforms(),
+ uFloor: { value: new Vector2(ground.minHeight, ground.band) },
+ uFog: { value: new Vector2(HAZE.near, HAZE.far) },
+ },
+ vertexShader: VERTEX,
+ fragmentShader: FRAGMENT,
+ side: BackSide,
+ depthTest: false,
+ depthWrite: false,
+ fog: false,
+ toneMapped: false,
+ });
+}
+
+const followFog: Object3D['onBeforeRender'] = (_renderer, scene, _camera, _geometry, material) => {
+ if (!(scene.fog instanceof Fog) || !(material instanceof ShaderMaterial)) return;
+ material.uniforms.uFog.value.set(scene.fog.near, scene.fog.far);
+ material.uniforms.uHorizon.value.copy(scene.fog.color);
+};
+
+export function skyDome(context: PartContext, name: string): Mesh {
+ const geometry = context.tracker.track(
+ new SphereGeometry(1, SKY_DOME.widthSegments, SKY_DOME.heightSegments),
+ );
+ const dome = new Mesh(geometry, registeredMaterial(context, UNDIMMED_GROUP, skyMaterial()));
+ dome.onBeforeRender = followFog;
+ dome.name = name;
+ dome.frustumCulled = false;
+ dome.renderOrder = SKY_DOME.renderOrder;
+ return dome;
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/softenCanvas.ts b/explainers/wind-farm/src/scene/parts/land/softenCanvas.ts
new file mode 100644
index 00000000..bf7a860e
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/softenCanvas.ts
@@ -0,0 +1,25 @@
+import type { Painter } from './groundCanvas';
+
+export interface Softening {
+ readonly reduction: number;
+ readonly alpha: number;
+}
+
+export function softenCanvas(
+ { context, projection }: Painter,
+ { reduction, alpha }: Softening,
+): void {
+ const small = document.createElement('canvas');
+ small.width = Math.round(projection.size / reduction);
+ small.height = small.width;
+ const drawing = small.getContext('2d');
+ if (!drawing) return;
+ drawing.imageSmoothingQuality = 'high';
+ drawing.drawImage(context.canvas, 0, 0, small.width, small.height);
+ context.save();
+ context.globalAlpha = alpha;
+ context.imageSmoothingEnabled = true;
+ context.imageSmoothingQuality = 'high';
+ context.drawImage(small, 0, 0, projection.size, projection.size);
+ context.restore();
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/terrainAttributes.ts b/explainers/wind-farm/src/scene/parts/land/terrainAttributes.ts
new file mode 100644
index 00000000..5639ada4
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/terrainAttributes.ts
@@ -0,0 +1,41 @@
+import { BufferAttribute, BufferGeometry } from 'three';
+import type { Color } from 'three';
+import type { Projection } from './projection';
+import { projectedUv } from './projection';
+
+const XYZ = 3;
+const UV = 2;
+const RGBA = 4;
+
+export interface TerrainAttributes {
+ write(
+ index: number,
+ point: readonly [number, number, number],
+ colour: Color,
+ alpha: number,
+ ): void;
+ geometry(indices: number[]): BufferGeometry;
+}
+
+export function terrainAttributes(count: number, projection: Projection): TerrainAttributes {
+ const positions = new Float32Array(count * XYZ);
+ const uvs = new Float32Array(count * UV);
+ const colours = new Float32Array(count * RGBA);
+ return {
+ write: (index, [x, y, z], colour, alpha) => {
+ positions.set([x, y, z], index * XYZ);
+ uvs.set(projectedUv(projection, x, z), index * UV);
+ colour.toArray(colours, index * RGBA);
+ colours[index * RGBA + RGBA - 1] = alpha;
+ },
+ geometry: (indices) => {
+ const geometry = new BufferGeometry();
+ geometry.setAttribute('position', new BufferAttribute(positions, XYZ));
+ geometry.setAttribute('uv', new BufferAttribute(uvs, UV));
+ geometry.setAttribute('color', new BufferAttribute(colours, RGBA));
+ geometry.setIndex(indices);
+ geometry.computeVertexNormals();
+ return geometry;
+ },
+ };
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/terrainMesh.ts b/explainers/wind-farm/src/scene/parts/land/terrainMesh.ts
new file mode 100644
index 00000000..2d921ab7
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/terrainMesh.ts
@@ -0,0 +1,19 @@
+import type { BufferGeometry, Mesh, Texture } from 'three';
+import type { PartId } from '../../../ids';
+import { FINISHES } from '../../finishes';
+import { finishMesh } from '../context';
+import type { PartContext } from '../context';
+import { LAND_RENDER_ORDER } from './constants';
+
+export function terrainMesh(
+ context: PartContext,
+ geometry: BufferGeometry,
+ map: Texture,
+ part: PartId,
+): Mesh {
+ const mesh = finishMesh(context, geometry, part, { ...FINISHES.grass, transparent: true, map });
+ mesh.name = part;
+ mesh.renderOrder = LAND_RENDER_ORDER;
+ mesh.receiveShadow = true;
+ return mesh;
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/treeGeometry.ts b/explainers/wind-farm/src/scene/parts/land/treeGeometry.ts
new file mode 100644
index 00000000..a9ccfb85
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/treeGeometry.ts
@@ -0,0 +1,28 @@
+import { CylinderGeometry, IcosahedronGeometry } from 'three';
+import type { BufferGeometry } from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { TREE_SHAPE } from './constants';
+import type { Lobe } from './constants';
+
+const HALF = 0.5;
+
+function lobeGeometry({ centre, radius, detail }: Lobe): BufferGeometry {
+ const geometry = new IcosahedronGeometry(radius, detail);
+ geometry.scale(1, TREE_SHAPE.squash, 1);
+ geometry.translate(...centre);
+ return geometry;
+}
+
+export function crownGeometry(lobes: readonly Lobe[]): BufferGeometry {
+ const parts = lobes.map(lobeGeometry);
+ const merged = mergeGeometries(parts);
+ parts.forEach((part) => part.dispose());
+ return merged;
+}
+
+export function trunkGeometry(): BufferGeometry {
+ const { top, bottom, height, sink, sides } = TREE_SHAPE.trunk;
+ const geometry = new CylinderGeometry(top, bottom, height, sides, 1, true);
+ geometry.translate(0, height * HALF - sink, 0);
+ return geometry;
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/treePlacement.ts b/explainers/wind-farm/src/scene/parts/land/treePlacement.ts
new file mode 100644
index 00000000..ca1eff30
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/treePlacement.ts
@@ -0,0 +1,111 @@
+import type { TreeLayout } from './constants';
+import type { FieldLayout, Run } from './fieldPlan';
+import { wavelengthNoise } from './noise';
+import { centroid, pointAlong, pointInPolygon, polygonArea } from './polygon';
+import type { Polygon } from './polygon';
+import { between, seededRandom } from './random';
+import type { Random } from './random';
+
+export interface TreeSpot {
+ readonly x: number;
+ readonly y: number;
+ readonly z: number;
+ readonly scale: number;
+ readonly seed: number;
+}
+
+export interface TreeArea {
+ readonly layout: FieldLayout;
+ readonly trees: TreeLayout;
+ allowed(x: number, z: number): boolean;
+ height(x: number, z: number): number;
+}
+
+type Grove = 'hedge' | 'wood';
+type Picker = (random: Random) => readonly [number, number];
+
+const HALF = 0.5;
+const HEDGE_STEP: readonly [number, number] = [0.8, 1.2];
+
+function firstAtLeast(totals: readonly number[], target: number): number {
+ let [low, high] = [0, totals.length - 1];
+ while (low < high) {
+ const middle = (low + high) >> 1;
+ if (totals[middle] < target) low = middle + 1;
+ else high = middle;
+ }
+ return low;
+}
+
+function weightedPick(items: readonly T[], weight: (item: T) => number): (random: Random) => T {
+ let sum = 0;
+ const totals = items.map((item) => (sum += weight(item)));
+ return (random) => items[firstAtLeast(totals, random() * sum)];
+}
+
+function runLength({ from, to }: Run): number {
+ return Math.hypot(to[0] - from[0], to[1] - from[1]);
+}
+
+interface Row {
+ readonly run: Run;
+ readonly length: number;
+ at: number;
+}
+
+function hedgePicker({ hedges }: FieldLayout, trees: TreeLayout): Picker {
+ const pick = weightedPick(hedges, runLength);
+ let row: Row | undefined;
+ return (random) => {
+ if (!row || row.at > row.length) {
+ const run = pick(random);
+ row = { run, length: runLength(run) || 1, at: random() * trees.hedgeSpacing };
+ }
+ const { from, to } = row.run;
+ const [x, z] = pointAlong(from, to, row.at / row.length);
+ const offset = (random() - HALF) * trees.hedgeBand;
+ row.at += trees.hedgeSpacing * between(random, HEDGE_STEP);
+ return [
+ x - ((to[1] - from[1]) / row.length) * offset,
+ z + ((to[0] - from[0]) / row.length) * offset,
+ ];
+ };
+}
+
+function woodPicker({ fields }: FieldLayout): Picker {
+ const woods: Polygon[] = fields.flatMap(({ wood }) =>
+ wood ? [[centroid(wood), ...wood, wood[0]]] : [],
+ );
+ const pick = weightedPick(woods, polygonArea);
+ return (random) => pointInPolygon(pick(random), random);
+}
+
+function clustered(trees: TreeLayout, x: number, z: number): boolean {
+ return wavelengthNoise(x, z, trees.cluster.wavelength, trees.seed) > trees.cluster.threshold;
+}
+
+export function placeTrees(area: TreeArea): TreeSpot[] {
+ const { trees, layout } = area;
+ const random = seededRandom(trees.seed);
+ const budget: Record = {
+ hedge: layout.hedges.length > 0 ? trees.hedgeCount : 0,
+ wood: layout.fields.some(({ wood }) => wood) ? trees.woodCount : 0,
+ };
+ const pickers: Record = {
+ hedge: hedgePicker(layout, trees),
+ wood: woodPicker(layout),
+ };
+ const spots: TreeSpot[] = [];
+ for (let attempt = 0; attempt < trees.attempts; attempt += 1) {
+ if (budget.hedge + budget.wood === 0) break;
+ const grove: Grove =
+ budget.hedge > 0 && (budget.wood === 0 || attempt % 2 === 0) ? 'hedge' : 'wood';
+ const [x, z] = pickers[grove](random);
+ if (grove === 'hedge' && !clustered(trees, x, z)) continue;
+ if (!area.allowed(x, z)) continue;
+ budget[grove] -= 1;
+ const scale = between(random, grove === 'wood' ? trees.woodScale : trees.scale);
+ spots.push({ x, y: area.height(x, z) - trees.sink, z, scale, seed: random() });
+ }
+ return spots;
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/trees.ts b/explainers/wind-farm/src/scene/parts/land/trees.ts
new file mode 100644
index 00000000..202fae96
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/trees.ts
@@ -0,0 +1,64 @@
+import { Color, Euler, Matrix4, Quaternion, Vector3 } from 'three';
+import type { BufferGeometry, InstancedMesh } from 'three';
+import { STRUCTURE_GROUP } from '@core/scene/materials';
+import { instancedMesh } from '../context';
+import type { PartContext } from '../context';
+import { TREE_TINT } from './constants';
+import { between, seededRandom } from './random';
+import type { Random } from './random';
+import type { TreeSpot } from './treePlacement';
+
+const FULL_TURN = Math.PI * 2;
+const SEED_SCALE = 2 ** 31;
+const AUTUMN = new Color(...TREE_TINT.autumn);
+const BARK = new Color(...TREE_TINT.trunk);
+
+export interface Planting {
+ readonly crown: InstancedMesh;
+ readonly trunk?: InstancedMesh;
+}
+
+function crownTint(random: Random, out: Color): Color {
+ const lightness = between(random, TREE_TINT.lightness);
+ const warmth = (random() * 2 - 1) * TREE_TINT.warmth;
+ out.setRGB(lightness * (1 + warmth), lightness, lightness * (1 - warmth));
+ if (random() < TREE_TINT.autumnShare) out.multiply(AUTUMN);
+ return out;
+}
+
+function plantMatrix(spot: TreeSpot, random: Random, out: Matrix4): Matrix4 {
+ const turn = new Quaternion().setFromEuler(new Euler(0, random() * FULL_TURN, 0));
+ const height = spot.scale * between(random, TREE_TINT.stretch);
+ return out.compose(
+ new Vector3(spot.x, spot.y, spot.z),
+ turn,
+ new Vector3(spot.scale, height, spot.scale),
+ );
+}
+
+export function plantTrees(
+ context: PartContext,
+ spots: readonly TreeSpot[],
+ name: string,
+ crownShape: BufferGeometry,
+ trunkShape?: BufferGeometry,
+): Planting {
+ const crown = instancedMesh(context, crownShape, STRUCTURE_GROUP, 'trees', spots.length);
+ const trunk = trunkShape
+ ? instancedMesh(context, trunkShape, STRUCTURE_GROUP, 'trees', spots.length)
+ : undefined;
+ crown.name = `${name}Crowns`;
+ if (trunk) trunk.name = `${name}Trunks`;
+ const matrix = new Matrix4();
+ const tint = new Color();
+ spots.forEach((spot, index) => {
+ const random = seededRandom(Math.floor(spot.seed * SEED_SCALE));
+ plantMatrix(spot, random, matrix);
+ crown.setMatrixAt(index, matrix);
+ crown.setColorAt(index, crownTint(random, tint));
+ trunk?.setMatrixAt(index, matrix);
+ trunk?.setColorAt(index, BARK);
+ });
+ [crown, trunk].forEach((mesh) => mesh?.computeBoundingSphere());
+ return { crown, trunk };
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/turbineGround.ts b/explainers/wind-farm/src/scene/parts/land/turbineGround.ts
new file mode 100644
index 00000000..5e950b73
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/turbineGround.ts
@@ -0,0 +1,41 @@
+import { Color } from 'three';
+import { smoothstep } from '@core/math';
+import { TURBINE_LAND, terrainHeight } from '../../../model/layout';
+import { HARDSTAND, TRACK, TURBINE_GROUND } from './constants';
+import { GROUND_PAINT } from './fieldConstants';
+import type { GroundRules } from './fieldPlan';
+import { distanceToPolyline } from './keepOut';
+
+const WHITE = new Color(1, 1, 1);
+const GRAVEL = new Color(GROUND_PAINT.gravel);
+const MEADOW = new Color(TURBINE_GROUND.meadow.colour);
+const GRAVEL_OVER_MEADOW = new Color(GRAVEL.r / MEADOW.r, GRAVEL.g / MEADOW.g, GRAVEL.b / MEADOW.b);
+
+export function turbineLandHeight(x: number, z: number): number {
+ const { flatRadius, blendRadius } = TURBINE_LAND;
+ return terrainHeight(x, z) * smoothstep(Math.hypot(x, z), flatRadius, blendRadius);
+}
+
+export function trackDistance(x: number, z: number): number {
+ return distanceToPolyline(TRACK.points, x, z);
+}
+
+function onHardstand(x: number, z: number): boolean {
+ const { pad, apronRadius } = HARDSTAND;
+ const onPad = x >= pad.minX && x <= pad.maxX && z >= pad.minZ && z <= pad.maxZ;
+ return onPad || Math.hypot(x, z) <= apronRadius;
+}
+
+export const TURBINE_RULES: GroundRules = {
+ wooded: (x, z) => Math.hypot(x, z) > TURBINE_GROUND.woodClear,
+ hedged: (x, z) => Math.hypot(x, z) > TURBINE_GROUND.hedgeClear,
+};
+
+export function turbineGroundTint(x: number, z: number): Color {
+ return onHardstand(x, z) ? GRAVEL_OVER_MEADOW : WHITE;
+}
+
+export function turbineGroundAlpha(x: number, z: number): number {
+ const { radius } = TURBINE_LAND;
+ return 1 - smoothstep(Math.hypot(x, z), radius * TURBINE_GROUND.alphaFrom, radius);
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/turbineLand.ts b/explainers/wind-farm/src/scene/parts/land/turbineLand.ts
new file mode 100644
index 00000000..7582cb8f
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/turbineLand.ts
@@ -0,0 +1,57 @@
+import { label, namedGroup } from '../context';
+import type { PartContext, Section } from '../context';
+import { LAND_LABELS, TREE_SHAPE, TURBINE_TREE_LIMITS, TURBINE_TREES } from './constants';
+import { TURBINE_FIELDS } from './fieldConstants';
+import { fieldLayout } from './fieldLayout';
+import type { FieldLayout } from './fieldPlan';
+import { groundTexture } from './groundTexture';
+import { hardstandMesh } from './hardstand';
+import { skyDome } from './sky';
+import { terrainMesh } from './terrainMesh';
+import { crownGeometry, trunkGeometry } from './treeGeometry';
+import { placeTrees } from './treePlacement';
+import type { TreeArea } from './treePlacement';
+import { plantTrees } from './trees';
+import { TURBINE_RULES, trackDistance, turbineLandHeight } from './turbineGround';
+import { turbineTerrainGeometry } from './turbineTerrain';
+import { paintTurbineGround, turbineProjection } from './turbineTexture';
+
+export function turbineFieldLayout(): FieldLayout {
+ return fieldLayout(TURBINE_FIELDS, TURBINE_RULES);
+}
+
+export function turbineTreeArea(layout: FieldLayout): TreeArea {
+ const { reach, heroClear, trackClear } = TURBINE_TREE_LIMITS;
+ return {
+ layout,
+ trees: TURBINE_TREES,
+ allowed: (x, z) => {
+ const distance = Math.hypot(x, z);
+ return distance > heroClear && distance < reach && trackDistance(x, z) > trackClear;
+ },
+ height: turbineLandHeight,
+ };
+}
+
+export function buildTurbineLand(context: PartContext): Section {
+ const root = namedGroup('turbineLand');
+ const layout = turbineFieldLayout();
+ const projection = turbineProjection();
+ const texture = groundTexture(context, projection, (painter) =>
+ paintTurbineGround(painter, layout),
+ );
+ const terrain = terrainMesh(context, turbineTerrainGeometry(projection), texture, 'land');
+ const spots = placeTrees(turbineTreeArea(layout));
+ const { crown, trunk } = plantTrees(
+ context,
+ spots,
+ 'turbineTree',
+ crownGeometry(TREE_SHAPE.crown),
+ trunkGeometry(),
+ );
+ root.add(skyDome(context, 'turbineSky'), terrain, hardstandMesh(context), crown);
+ if (trunk) root.add(trunk);
+ const [x, z] = LAND_LABELS.land;
+ label(context, 'land', terrain, [x, turbineLandHeight(x, z), z]);
+ return { root, setState: () => undefined };
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/turbineTerrain.ts b/explainers/wind-farm/src/scene/parts/land/turbineTerrain.ts
new file mode 100644
index 00000000..03082977
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/turbineTerrain.ts
@@ -0,0 +1,61 @@
+import type { BufferGeometry } from 'three';
+import { TURBINE_LAND } from '../../../model/layout';
+import { TURBINE_GROUND } from './constants';
+import type { Projection } from './projection';
+import { terrainAttributes } from './terrainAttributes';
+import { turbineGroundAlpha, turbineGroundTint, turbineLandHeight } from './turbineGround';
+
+const FULL_TURN = Math.PI * 2;
+
+export function ringRadii(): number[] {
+ const { firstRingStep, segments } = TURBINE_GROUND;
+ const growth = FULL_TURN / segments;
+ const radii = [0];
+ while (radii[radii.length - 1] < TURBINE_LAND.radius) {
+ const last = radii[radii.length - 1];
+ radii.push(last + firstRingStep + growth * last);
+ }
+ const scale = TURBINE_LAND.radius / radii[radii.length - 1];
+ return radii.map((radius) => radius * scale);
+}
+
+function ringIndices(rings: number, segments: number): number[] {
+ const indices: number[] = [];
+ const at = (ring: number, step: number) => 1 + (ring - 1) * segments + (step % segments);
+ for (let step = 0; step < segments; step += 1) {
+ indices.push(0, at(1, step + 1), at(1, step));
+ for (let ring = 1; ring < rings; ring += 1) {
+ const [a, b, c, d] = [
+ at(ring, step),
+ at(ring, step + 1),
+ at(ring + 1, step),
+ at(ring + 1, step + 1),
+ ];
+ indices.push(a, b, c, b, d, c);
+ }
+ }
+ return indices;
+}
+
+export function turbineTerrainGeometry(projection: Projection): BufferGeometry {
+ const { segments } = TURBINE_GROUND;
+ const radii = ringRadii();
+ const rings = radii.length - 1;
+ const attributes = terrainAttributes(1 + rings * segments, projection);
+ const write = (index: number, x: number, z: number) =>
+ attributes.write(
+ index,
+ [x, turbineLandHeight(x, z), z],
+ turbineGroundTint(x, z),
+ turbineGroundAlpha(x, z),
+ );
+ write(0, 0, 0);
+ for (let ring = 1; ring <= rings; ring += 1) {
+ for (let step = 0; step < segments; step += 1) {
+ const angle = (step / segments) * FULL_TURN;
+ const index = 1 + (ring - 1) * segments + step;
+ write(index, radii[ring] * Math.cos(angle), radii[ring] * Math.sin(angle));
+ }
+ }
+ return attributes.geometry(ringIndices(rings, segments));
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/turbineTexture.ts b/explainers/wind-farm/src/scene/parts/land/turbineTexture.ts
new file mode 100644
index 00000000..3a702206
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/turbineTexture.ts
@@ -0,0 +1,51 @@
+import { Color } from 'three';
+import { TURBINE_LAND } from '../../../model/layout';
+import { GROUND_TEXTURE, MOWING, TRACK, TURBINE_GROUND } from './constants';
+import { GROUND_PAINT } from './fieldConstants';
+import type { FieldLayout } from './fieldPlan';
+import { paintFeatures } from './featurePainter';
+import { paintFields } from './fieldPainter';
+import { cssColour, rotatedRectangle, strokePath, tracePolygon } from './groundCanvas';
+import type { Painter } from './groundCanvas';
+import { paintMottle, radialGradient, radialPaint } from './groundOverlays';
+import { discProjection } from './projection';
+import type { Projection } from './projection';
+
+const MEADOW = new Color(TURBINE_GROUND.meadow.colour);
+const QUARTER_TURN = Math.PI / 2;
+
+export function turbineProjection(): Projection {
+ const { size, turbine } = GROUND_TEXTURE;
+ return discProjection(TURBINE_LAND.radius, size, turbine.softness, turbine.step);
+}
+
+function paintMeadow(painter: Painter): void {
+ const { inner, outer } = TURBINE_GROUND.meadow;
+ radialPaint(painter, [inner, outer], (share) => cssColour(MEADOW, 1 - share));
+ const { width, reach, fade, shade, alpha } = MOWING;
+ const mown = MEADOW.clone().multiplyScalar(shade);
+ const { context } = painter;
+ context.save();
+ context.globalAlpha = alpha;
+ context.fillStyle = radialGradient(painter, [reach * fade, reach], (share) =>
+ cssColour(mown, 1 - share),
+ );
+ for (let offset = -reach; offset < reach; offset += width * 2) {
+ tracePolygon(painter, rotatedRectangle([0, 0], QUARTER_TURN, [reach * 2, width], [0, offset]));
+ context.fill();
+ }
+ context.restore();
+}
+
+export function paintTurbineGround(painter: Painter, layout: FieldLayout): void {
+ paintFields(painter, layout);
+ paintFeatures(painter, layout);
+ paintMeadow(painter);
+ strokePath(painter, TRACK.points, GROUND_PAINT.accessWidth, GROUND_PAINT.gravel);
+ paintMottle(painter);
+ const { radius } = TURBINE_LAND;
+ const distance = new Color(GROUND_PAINT.distance);
+ radialPaint(painter, [radius * TURBINE_GROUND.hazeFrom, radius], (share) =>
+ cssColour(distance, share),
+ );
+}
diff --git a/explainers/wind-farm/src/scene/parts/land/woodOutline.ts b/explainers/wind-farm/src/scene/parts/land/woodOutline.ts
new file mode 100644
index 00000000..777a412e
--- /dev/null
+++ b/explainers/wind-farm/src/scene/parts/land/woodOutline.ts
@@ -0,0 +1,31 @@
+import { WOOD_OUTLINE } from './fieldConstants';
+import { wavelengthNoise } from './noise';
+import { centroid, pointAlong, polygonArea } from './polygon';
+import type { Polygon } from './polygon';
+
+const CUTS = [0.25, 0.75] as const;
+
+function smoothed(ring: Polygon, passes: number): Polygon {
+ let result = ring;
+ for (let pass = 0; pass < passes; pass += 1)
+ result = result.flatMap((point, index) =>
+ CUTS.map((share) => pointAlong(point, result[(index + 1) % result.length], share)),
+ );
+ return result;
+}
+
+function wobbled(ring: Polygon, size: number): Polygon {
+ const [cx, cz] = centroid(ring);
+ const { inset, wavelength, maxPull, seed } = WOOD_OUTLINE;
+ return ring.map(([x, z]) => {
+ const reach = Math.hypot(cx - x, cz - z) || 1;
+ const share = inset[0] + (inset[1] - inset[0]) * wavelengthNoise(x, z, wavelength * size, seed);
+ const pull = Math.min((share * size) / reach, maxPull);
+ return [x + (cx - x) * pull, z + (cz - z) * pull];
+ });
+}
+
+export function woodOutline(corners: Polygon): Polygon {
+ const { rounding, smoothing } = WOOD_OUTLINE;
+ return smoothed(wobbled(smoothed(corners, rounding), Math.sqrt(polygonArea(corners))), smoothing);
+}
diff --git a/explainers/wind-farm/src/scene/regions.test.ts b/explainers/wind-farm/src/scene/regions.test.ts
new file mode 100644
index 00000000..80ff5e8d
--- /dev/null
+++ b/explainers/wind-farm/src/scene/regions.test.ts
@@ -0,0 +1,66 @@
+import { Vector3 } from 'three';
+import { describe, expect, it } from 'vitest';
+import { SPACING_OPTIONS } from '../ids';
+import {
+ ROTOR_DIAMETER_M,
+ TIP_HEIGHT_M,
+ SUBSTATION,
+ collectorRoutes,
+ farmLayout,
+ terrainHeight,
+ windArrowsX,
+} from '../model';
+import { LABELLED_TURBINE } from './constants';
+import { farmRegions } from './regions';
+
+const SPACING = 7;
+
+describe('farm regions', () => {
+ const regions = farmRegions(SPACING);
+
+ it('centres the grid region on the substation where the cables converge', () => {
+ const centre = regions.grid.getCenter(new Vector3());
+ expect(centre.x).toBeCloseTo(SUBSTATION.x);
+ expect(centre.z).toBeCloseTo(SUBSTATION.z);
+ collectorRoutes(SPACING).forEach((route) => {
+ const [x, z] = route[route.length - 1];
+ expect(regions.grid.containsPoint(new Vector3(x, terrainHeight(x, z), z))).toBe(true);
+ });
+ });
+
+ it('reaches out to the turbine nearest the substation', () => {
+ const [nearest] = [...farmLayout(SPACING)].sort(
+ (a, b) =>
+ Math.hypot(a.x - SUBSTATION.x, a.z - SUBSTATION.z) -
+ Math.hypot(b.x - SUBSTATION.x, b.z - SUBSTATION.z),
+ );
+ const base = new Vector3(nearest.x, terrainHeight(nearest.x, nearest.z), nearest.z);
+ expect(regions.grid.distanceToPoint(base)).toBeLessThan(ROTOR_DIAMETER_M);
+ });
+
+ it.each(SPACING_OPTIONS)(
+ 'frames the labelled turbine tip with room in the grid at %sD',
+ (spacing) => {
+ const grid = farmRegions(spacing).grid;
+ const { x, z } = farmLayout(spacing)[LABELLED_TURBINE];
+ const tip = new Vector3(x, terrainHeight(x, z) + TIP_HEIGHT_M, z);
+ expect(grid.containsPoint(tip)).toBe(true);
+ expect(Math.min(tip.x - grid.min.x, tip.z - grid.min.z)).toBeGreaterThanOrEqual(
+ ROTOR_DIAMETER_M,
+ );
+ },
+ );
+
+ it('keeps the grid region much tighter than the farm', () => {
+ const grid = regions.grid.getSize(new Vector3());
+ const farm = regions.farm.getSize(new Vector3());
+ expect(grid.x).toBeLessThan(farm.x);
+ expect(grid.z).toBeLessThan(farm.z / 2);
+ });
+
+ it('holds the substation in the farm and the upwind arrows in the wakes', () => {
+ expect(regions.farm.max.x).toBeGreaterThanOrEqual(SUBSTATION.x);
+ expect(regions.farm.max.z).toBeGreaterThanOrEqual(SUBSTATION.z);
+ expect(regions.wakes.min.x).toBeLessThanOrEqual(windArrowsX(SPACING));
+ });
+});
diff --git a/explainers/wind-farm/src/scene/regions.ts b/explainers/wind-farm/src/scene/regions.ts
new file mode 100644
index 00000000..057f0c4c
--- /dev/null
+++ b/explainers/wind-farm/src/scene/regions.ts
@@ -0,0 +1,133 @@
+import { Box3, Vector3 } from 'three';
+import type { RegionId, SpacingD } from '../ids';
+import type { GroundPoint } from '../model';
+import { LABELLED_TURBINE, SUBSTATION_HEIGHT_M } from './constants';
+import {
+ collectorRoutes,
+ FARM_TERRAIN,
+ MAX_PLUME_D,
+ ROTOR_DIAMETER_M,
+ ROTOR_RADIUS_M,
+ SUBSTATION,
+ TIP_HEIGHT_M,
+ TURBINE_GEOMETRY,
+ farmLayout,
+ terrainHeight,
+ windArrowsX,
+} from '../model';
+
+type FarmRegionId = Extract;
+
+const [HUB_X, HUB_Y] = TURBINE_GEOMETRY.hub;
+const ROTOR_REACH_M = Math.abs(HUB_X) + ROTOR_RADIUS_M;
+const ROTOR_DEPTH_M = TURBINE_GEOMETRY.spinnerRadius * 2;
+const CABLE_END_REACH_M = 1200;
+const PLUME_VIEW_SHARE = 0.5;
+const LABELLED_TURBINE_ROOM_M = 2 * ROTOR_DIAMETER_M;
+
+function turbineRegion(): Box3 {
+ return new Box3(
+ new Vector3(-ROTOR_REACH_M, 0, -ROTOR_REACH_M),
+ new Vector3(ROTOR_REACH_M, TIP_HEIGHT_M, ROTOR_REACH_M),
+ );
+}
+
+function nacelleRegion(): Box3 {
+ const { nacelle, cooler, spinnerRadius } = TURBINE_GEOMETRY;
+ return new Box3(
+ new Vector3(HUB_X - spinnerRadius, nacelle.minY, -nacelle.halfWidth),
+ new Vector3(nacelle.maxX, cooler.topY, nacelle.halfWidth),
+ );
+}
+
+function rotorRegion(): Box3 {
+ return new Box3(
+ new Vector3(HUB_X - ROTOR_DEPTH_M, HUB_Y - ROTOR_RADIUS_M, -ROTOR_RADIUS_M),
+ new Vector3(HUB_X + ROTOR_DEPTH_M, HUB_Y + ROTOR_RADIUS_M, ROTOR_RADIUS_M),
+ );
+}
+
+function turbinesRegion(spacing: SpacingD): Box3 {
+ const box = new Box3();
+ farmLayout(spacing).forEach(({ x, z }) => {
+ const ground = terrainHeight(x, z);
+ box.expandByPoint(new Vector3(x - ROTOR_RADIUS_M, ground, z - ROTOR_RADIUS_M));
+ box.expandByPoint(new Vector3(x + ROTOR_RADIUS_M, ground + TIP_HEIGHT_M, z + ROTOR_RADIUS_M));
+ });
+ return box;
+}
+
+function withSubstation(turbines: Box3): Box3 {
+ const ground = terrainHeight(SUBSTATION.x, SUBSTATION.z);
+ const halfWidth = SUBSTATION.width / 2;
+ const halfDepth = SUBSTATION.depth / 2;
+ return turbines
+ .clone()
+ .expandByPoint(new Vector3(SUBSTATION.x - halfWidth, ground, SUBSTATION.z - halfDepth))
+ .expandByPoint(new Vector3(SUBSTATION.x + halfWidth, ground, SUBSTATION.z + halfDepth));
+}
+
+function wakesRegion(turbines: Box3, spacing: SpacingD): Box3 {
+ const box = turbines.clone();
+ box.min.x = Math.min(box.min.x, windArrowsX(spacing));
+ box.max.x = Math.min(
+ FARM_TERRAIN.maxX,
+ box.max.x + MAX_PLUME_D * PLUME_VIEW_SHARE * ROTOR_DIAMETER_M,
+ );
+ return box;
+}
+
+function cableEnd(route: readonly GroundPoint[]): GroundPoint {
+ const [fromX, fromZ] = route[route.length - 2];
+ const dx = fromX - SUBSTATION.x;
+ const dz = fromZ - SUBSTATION.z;
+ const share = Math.min(1, CABLE_END_REACH_M / Math.hypot(dx, dz));
+ return [SUBSTATION.x + dx * share, SUBSTATION.z + dz * share];
+}
+
+function labelledTurbineReach(spacing: SpacingD): GroundPoint[] {
+ const { x, z } = farmLayout(spacing)[LABELLED_TURBINE];
+ return [
+ [x - LABELLED_TURBINE_ROOM_M, z - LABELLED_TURBINE_ROOM_M],
+ [x + LABELLED_TURBINE_ROOM_M, z + LABELLED_TURBINE_ROOM_M],
+ ];
+}
+
+function gridRegion(spacing: SpacingD): Box3 {
+ const labelled = farmLayout(spacing)[LABELLED_TURBINE];
+ const ends = collectorRoutes(spacing).map(cableEnd);
+ const reach = [...ends, ...labelledTurbineReach(spacing)];
+ const halfX = Math.max(...reach.map(([x]) => Math.abs(x - SUBSTATION.x)));
+ const halfZ = Math.max(...reach.map(([, z]) => Math.abs(z - SUBSTATION.z)));
+ const yard = terrainHeight(SUBSTATION.x, SUBSTATION.z);
+ const ground = Math.min(yard, ...reach.map(([x, z]) => terrainHeight(x, z)));
+ const labelledTip = terrainHeight(labelled.x, labelled.z) + TIP_HEIGHT_M;
+ const top = Math.max(yard + SUBSTATION_HEIGHT_M, labelledTip);
+ return new Box3(
+ new Vector3(SUBSTATION.x - halfX, ground, SUBSTATION.z - halfZ),
+ new Vector3(SUBSTATION.x + halfX, top, SUBSTATION.z + halfZ),
+ );
+}
+
+export function farmRegions(spacing: SpacingD): Readonly> {
+ const turbines = turbinesRegion(spacing);
+ return {
+ farm: withSubstation(turbines),
+ wakes: wakesRegion(turbines, spacing),
+ grid: gridRegion(spacing),
+ };
+}
+
+export function buildRegions(spacing: SpacingD): Record {
+ return {
+ turbine: turbineRegion(),
+ nacelle: nacelleRegion(),
+ rotor: rotorRegion(),
+ ...farmRegions(spacing),
+ };
+}
+
+export function refreshFarmRegions(regions: Record, spacing: SpacingD): void {
+ const next = farmRegions(spacing);
+ (Object.keys(next) as FarmRegionId[]).forEach((id) => regions[id].copy(next[id]));
+}
diff --git a/explainers/wind-farm/src/scene/sceneOptions.ts b/explainers/wind-farm/src/scene/sceneOptions.ts
new file mode 100644
index 00000000..1a3b8217
--- /dev/null
+++ b/explainers/wind-farm/src/scene/sceneOptions.ts
@@ -0,0 +1,24 @@
+import type { SceneOptions } from '@core/scene/shell';
+import type { PartId } from '../ids';
+import { HAZE, HIGHLIGHT_DIM, SCENE_LIMITS } from './constants';
+
+export const UNDIMMED_PARTS: readonly PartId[] = [
+ 'land',
+ 'farmLand',
+ 'streamlinesGroup',
+ 'windArrows',
+];
+
+export const SCENE_OPTIONS: SceneOptions = {
+ background: HAZE.colour,
+ fog: { color: HAZE.colour, near: HAZE.near, far: HAZE.far },
+ stage: false,
+ camera: {
+ near: SCENE_LIMITS.cameraNear,
+ far: SCENE_LIMITS.cameraFar,
+ maxPolarAngle: SCENE_LIMITS.maxPolarAngle,
+ distance: { min: SCENE_LIMITS.cameraMinDistance, max: SCENE_LIMITS.cameraMaxDistance },
+ },
+ gaugeSide: 'top',
+ highlight: { dim: HIGHLIGHT_DIM, undimmed: UNDIMMED_PARTS },
+};
diff --git a/explainers/wind-farm/src/scene/windFarmAssembly.dom.test.ts b/explainers/wind-farm/src/scene/windFarmAssembly.dom.test.ts
new file mode 100644
index 00000000..0dd6ea73
--- /dev/null
+++ b/explainers/wind-farm/src/scene/windFarmAssembly.dom.test.ts
@@ -0,0 +1,202 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { InstancedMesh, Vector3 } from 'three';
+import type { Box3, Object3D } from 'three';
+import { MaterialLibrary } from '@core/scene/materials';
+import { isShown } from '@core/scene/parts';
+import { createSceneTextures } from '@core/scene/textures';
+import { FARM_PART_IDS, PART_IDS, TURBINE_PART_IDS } from '../ids';
+import type {
+ AnchorId,
+ AssemblyState,
+ PartId,
+ RegionId,
+ SceneId,
+ SpacingD,
+ ViewOptions,
+} from '../ids';
+import { SUBSTATION, TURBINE_COUNT, farmLayout } from '../model';
+import type { Assembly } from './assembly';
+import { EASE_SECONDS } from './constants';
+import { createWindFarmAssembly } from './windFarmAssembly';
+
+const ANCHOR_IDS: readonly AnchorId[] = [
+ 'hub',
+ 'yawPivot',
+ 'towerBase',
+ 'farmCentre',
+ 'heroSite',
+ 'substation',
+];
+const REGION_IDS: readonly RegionId[] = ['farm', 'wakes', 'grid', 'turbine', 'nacelle', 'rotor'];
+const INTERIOR_PARTS: readonly PartId[] = [
+ 'bedplate',
+ 'pitchCylinders',
+ 'mainBearing',
+ 'mainShaft',
+ 'gearbox',
+ 'brakeDisc',
+ 'generator',
+ 'converter',
+ 'yawDrives',
+];
+const ALL_VIEWS: ViewOptions = {
+ streamlines: true,
+ wakes: true,
+ cables: true,
+ labels: true,
+ cutaway: true,
+};
+const QUIET_VIEWS: ViewOptions = {
+ streamlines: false,
+ wakes: false,
+ cables: false,
+ labels: true,
+ cutaway: false,
+};
+const DEFAULT_SPACING: SpacingD = 7;
+const FRAME_SECONDS = 1 / 60;
+const FARM_TURBINE_MESHES = 4;
+const TURNING_RPM = 10;
+
+function stateWith(
+ scene: SceneId,
+ view: ViewOptions,
+ rpm = TURNING_RPM,
+ spacing = DEFAULT_SPACING,
+): AssemblyState {
+ return {
+ scene,
+ phase: 0,
+ playing: false,
+ wind: { speed: 9, fromDeg: 270, induction: 0.25 },
+ rotor: { rpm, pitchDeg: 0, yawDeg: 270, state: 'partial', braked: false, powerShare: 0.5 },
+ farm: {
+ spacing,
+ sites: farmLayout(spacing),
+ deficits: Array.from({ length: TURBINE_COUNT }, () => 0),
+ plumeLengthD: 8,
+ plumeStrength: 0.3,
+ outputShare: 0.5,
+ },
+ view,
+ };
+}
+
+function isFilled(box: Box3): boolean {
+ return !box.isEmpty() && box.max.x > box.min.x && box.max.y > box.min.y && box.max.z > box.min.z;
+}
+
+function worldPoint(object: Object3D): number[] {
+ object.updateWorldMatrix(true, false);
+ return object
+ .getWorldPosition(new Vector3())
+ .toArray()
+ .map((value) => Math.round(value * 10) / 10);
+}
+
+function shownParts(parts: readonly PartId[]): boolean[] {
+ return parts.map((id) => isShown(assembly.labelAnchors().get(id)!));
+}
+
+let textures: ReturnType;
+let assembly: Assembly;
+
+beforeAll(() => {
+ textures = createSceneTextures();
+ assembly = createWindFarmAssembly(
+ { materials: new MaterialLibrary(), textures },
+ stateWith('turbine', ALL_VIEWS),
+ );
+});
+
+afterAll(() => {
+ assembly.dispose();
+ textures.dispose();
+});
+
+describe('wind farm assembly', () => {
+ it('has a label anchor for every part', () => {
+ const anchors = assembly.labelAnchors();
+ expect(PART_IDS.filter((id) => !anchors.has(id))).toEqual([]);
+ });
+
+ it('resolves every anchor and region', () => {
+ ANCHOR_IDS.forEach((id) => expect(assembly.anchor(id)).toBeDefined());
+ REGION_IDS.forEach((id) => expect(isFilled(assembly.region(id)), id).toBe(true));
+ });
+
+ it('shows only the labels of the visible scene', () => {
+ assembly.setState(stateWith('farm', ALL_VIEWS));
+ expect(shownParts(TURBINE_PART_IDS).every((shown) => !shown)).toBe(true);
+ expect(shownParts(FARM_PART_IDS).every(Boolean)).toBe(true);
+ assembly.setState(stateWith('turbine', ALL_VIEWS));
+ expect(shownParts(TURBINE_PART_IDS).every(Boolean)).toBe(true);
+ expect(shownParts(FARM_PART_IDS).every((shown) => !shown)).toBe(true);
+ });
+
+ it('hides the interior labels while the nacelle is closed', () => {
+ assembly.setState(stateWith('turbine', { ...ALL_VIEWS, cutaway: false }));
+ expect(shownParts(INTERIOR_PARTS).every((shown) => !shown)).toBe(true);
+ expect(isShown(assembly.labelAnchors().get('nacelle')!)).toBe(true);
+ });
+
+ it('draws one instance per farm turbine', () => {
+ const meshes: InstancedMesh[] = [];
+ assembly.root.traverse((object) => {
+ if (object instanceof InstancedMesh && object.name.startsWith('farmTurbines'))
+ meshes.push(object);
+ });
+ expect(meshes.length).toBe(FARM_TURBINE_MESHES);
+ meshes.forEach((mesh) => expect(mesh.count).toBe(TURBINE_COUNT));
+ });
+
+ it('keeps animating only while something moves', () => {
+ assembly.setState(stateWith('turbine', QUIET_VIEWS));
+ expect(assembly.update(FRAME_SECONDS, 0)).toBe(true);
+ assembly.setState(stateWith('turbine', QUIET_VIEWS, 0));
+ expect(assembly.update(FRAME_SECONDS, 0)).toBe(true);
+ assembly.update(EASE_SECONDS, 0);
+ expect(assembly.update(FRAME_SECONDS, 0)).toBe(false);
+ });
+
+ it('eases a pitch jump over the ease time', () => {
+ const still = stateWith('turbine', QUIET_VIEWS, 0);
+ assembly.setState(still);
+ assembly.update(EASE_SECONDS, 0);
+ assembly.setState({ ...still, rotor: { ...still.rotor, pitchDeg: 90 } });
+ expect(assembly.update(EASE_SECONDS / 2, 0)).toBe(true);
+ expect(assembly.update(EASE_SECONDS / 2, 0)).toBe(false);
+ });
+
+ it('keeps the farm moving while the cables carry power', () => {
+ const farm = stateWith('farm', QUIET_VIEWS, 0);
+ assembly.setState({ ...farm, view: { ...QUIET_VIEWS, cables: true } });
+ assembly.update(EASE_SECONDS, 0);
+ expect(assembly.update(FRAME_SECONDS, 0)).toBe(true);
+ assembly.setState({ ...farm, farm: { ...farm.farm, outputShare: 0 } });
+ expect(assembly.update(FRAME_SECONDS, 0)).toBe(false);
+ });
+
+ it('turns the hub toward the bearing the wind comes from', () => {
+ const west = stateWith('turbine', ALL_VIEWS);
+ assembly.setState(west);
+ expect(worldPoint(assembly.anchor('hub'))).toEqual([-7, 105, 0]);
+ assembly.setState({ ...west, rotor: { ...west.rotor, yawDeg: 0 } });
+ expect(worldPoint(assembly.anchor('hub'))).toEqual([0, 105, -7]);
+ });
+
+ it('moves the farm region with the spacing', () => {
+ assembly.setState(stateWith('farm', ALL_VIEWS, TURNING_RPM, 5));
+ const tight = assembly.region('farm').clone();
+ assembly.setState(stateWith('farm', ALL_VIEWS, TURNING_RPM, 9));
+ expect(assembly.region('farm').equals(tight)).toBe(false);
+ expect(assembly.region('farm').min.x).toBeLessThan(tight.min.x);
+ });
+
+ it('anchors the substation label on the yard edge', () => {
+ assembly.setState(stateWith('farm', ALL_VIEWS));
+ const [x, , z] = worldPoint(assembly.labelAnchors().get('substation')!);
+ expect(x).toBeCloseTo(SUBSTATION.x, 0);
+ expect(z).toBeCloseTo(SUBSTATION.z - SUBSTATION.depth / 2, 0);
+ });
+});
diff --git a/explainers/wind-farm/src/scene/windFarmAssembly.ts b/explainers/wind-farm/src/scene/windFarmAssembly.ts
new file mode 100644
index 00000000..0d5c0ca6
--- /dev/null
+++ b/explainers/wind-farm/src/scene/windFarmAssembly.ts
@@ -0,0 +1,172 @@
+import { Group, Line, Mesh, Points, Sprite } from 'three';
+import type { Box3, Material, Object3D } from 'three';
+import type { MaterialLibrary } from '@core/scene/materials';
+import { isShown } from '@core/scene/parts';
+import { ResourceTracker } from '@core/scene/resources';
+import { PART_IDS } from '../ids';
+import type { AnchorId, AssemblyState, PartId, RegionId, SceneId } from '../ids';
+import { advanceAzimuth } from '../model';
+import type { Assembly, AssemblyResources } from './assembly';
+import { buildFarmAir, buildStreamlines } from './parts/air';
+import { namedGroup } from './parts/context';
+import type { Motion, PartContext, Section } from './parts/context';
+import { Ease } from './parts/ease';
+import { buildFarm } from './parts/farm';
+import { buildHero } from './parts/hero';
+import { buildFarmLand, buildTurbineLand } from './parts/land';
+import { buildRegions, refreshFarmRegions } from './regions';
+
+const ROOT_NAME = 'windFarm';
+const ANCHOR_IDS: readonly AnchorId[] = [
+ 'hub',
+ 'yawPivot',
+ 'towerBase',
+ 'farmCentre',
+ 'heroSite',
+ 'substation',
+];
+
+type Drawable = Mesh | Line | Points | Sprite;
+
+function isDrawable(object: Object3D): object is Drawable {
+ return (
+ object instanceof Mesh ||
+ object instanceof Line ||
+ object instanceof Points ||
+ object instanceof Sprite
+ );
+}
+
+function completeLabels(found: ReadonlyMap): ReadonlyMap {
+ return new Map(
+ PART_IDS.map((id) => {
+ const anchor = found.get(id);
+ if (!anchor) throw new Error(`No label anchor for part ${id}`);
+ return [id, anchor];
+ }),
+ );
+}
+
+function completeAnchors(
+ found: Partial>,
+): Readonly> {
+ return Object.fromEntries(
+ ANCHOR_IDS.map((id) => {
+ const anchor = found[id];
+ if (!anchor) throw new Error(`No scene anchor ${id}`);
+ return [id, anchor];
+ }),
+ ) as Record;
+}
+
+class WindFarmAssembly implements Assembly {
+ readonly root = new Group();
+ private readonly materials: MaterialLibrary;
+ private readonly tracker = new ResourceTracker();
+ private readonly scenes: Record;
+ private readonly sections: Record;
+ private readonly labels: ReadonlyMap;
+ private readonly anchors: Readonly>;
+ private readonly regions: Record;
+ private readonly pitch: Ease;
+ private readonly rpm: Ease;
+ private state: AssemblyState;
+ private azimuth = 0;
+
+ constructor(resources: AssemblyResources, state: AssemblyState) {
+ this.materials = resources.materials;
+ this.root.name = ROOT_NAME;
+ const context: PartContext = {
+ materials: resources.materials,
+ textures: resources.textures,
+ tracker: this.tracker,
+ labels: new Map(),
+ anchors: {},
+ };
+ this.sections = {
+ turbine: [buildTurbineLand(context), buildHero(context), buildStreamlines(context)],
+ farm: [buildFarmLand(context), buildFarm(context), buildFarmAir(context)],
+ };
+ this.scenes = {
+ turbine: namedGroup('turbineScene', this.root),
+ farm: namedGroup('farmScene', this.root),
+ };
+ (Object.keys(this.sections) as SceneId[]).forEach((id) =>
+ this.sections[id].forEach((section) => this.scenes[id].add(section.root)),
+ );
+ this.labels = completeLabels(context.labels);
+ this.anchors = completeAnchors(context.anchors);
+ this.regions = buildRegions(state.farm.spacing);
+ this.pitch = new Ease(state.rotor.pitchDeg);
+ this.rpm = new Ease(state.rotor.rpm);
+ this.state = state;
+ this.setState(state);
+ this.animate(0, 0);
+ }
+
+ setState(state: AssemblyState): void {
+ if (state.farm.spacing !== this.state.farm.spacing)
+ refreshFarmRegions(this.regions, state.farm.spacing);
+ this.state = state;
+ this.pitch.aim(state.rotor.pitchDeg);
+ this.rpm.aim(state.rotor.rpm);
+ (Object.keys(this.scenes) as SceneId[]).forEach((id) => {
+ this.scenes[id].visible = state.scene === id;
+ this.sections[id].forEach((section) => section.setState(state));
+ });
+ }
+
+ update(deltaSeconds: number, cameraDistance: number): boolean {
+ this.pitch.step(deltaSeconds);
+ this.rpm.step(deltaSeconds);
+ this.azimuth = advanceAzimuth(this.azimuth, this.rpm.value, deltaSeconds);
+ const sectionsMoving = this.animate(deltaSeconds, cameraDistance);
+ return sectionsMoving || this.rpm.value > 0 || this.pitch.running || this.rpm.running;
+ }
+
+ labelAnchors(): ReadonlyMap {
+ return this.labels;
+ }
+
+ anchor(id: AnchorId): Object3D {
+ return this.anchors[id];
+ }
+
+ region(id: RegionId): Box3 {
+ return this.regions[id];
+ }
+
+ warmUp(compile: (object: Object3D) => void): void {
+ const seen = new Set();
+ this.root.traverse((object) => {
+ if (!isDrawable(object) || isShown(object) || seen.has(object.material)) return;
+ seen.add(object.material);
+ compile(object);
+ });
+ }
+
+ dispose(): void {
+ this.materials.clearRegistered();
+ this.tracker.dispose();
+ }
+
+ private animate(delta: number, cameraDistance: number): boolean {
+ const motion: Motion = {
+ delta,
+ azimuth: this.azimuth,
+ pitchDeg: this.pitch.value,
+ rpm: this.rpm.value,
+ cameraDistance,
+ };
+ return this.sections[this.state.scene]
+ .map((section) => section.animate?.(motion, this.state) ?? false)
+ .some(Boolean);
+ }
+}
+
+export function createWindFarmAssembly(
+ resources: AssemblyResources,
+ state: AssemblyState,
+): Assembly {
+ return new WindFarmAssembly(resources, state);
+}
diff --git a/explainers/wind-farm/src/state/derived.test.ts b/explainers/wind-farm/src/state/derived.test.ts
new file mode 100644
index 00000000..c99576b6
--- /dev/null
+++ b/explainers/wind-farm/src/state/derived.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from 'vitest';
+import { WIND_PRESETS, dayWind, farmLayout, windFromDeg } from '../model';
+import {
+ createAssemblyState,
+ farmSitesOf,
+ liveReading,
+ liveWind,
+ sceneOf,
+ writeAssemblyState,
+} from './derived';
+import type { AssemblySource } from './derived';
+import { DEFAULT_VIEW } from './store';
+
+const NIGHT = 120;
+const NOON = 720;
+const STORM_CORE_MINUTE = 1050;
+
+function source(overrides: Partial = {}): AssemblySource {
+ return {
+ phase: NOON,
+ playing: true,
+ preset: 'farm',
+ siteWind: 'typical',
+ windOverride: null,
+ spacing: 7,
+ view: { ...DEFAULT_VIEW },
+ ...overrides,
+ };
+}
+
+describe('live wind', () => {
+ it('follows the day wind of the site until a wind is set', () => {
+ expect(liveWind(source())).toBeCloseTo(dayWind(NOON, 'typical'));
+ expect(liveWind(source({ siteWind: 'windy' }))).toBeCloseTo(dayWind(NOON, 'windy'));
+ expect(liveWind(source({ windOverride: 8 }))).toBe(8);
+ });
+
+ it('reuses the reading while its inputs stay the same', () => {
+ expect(liveReading(source())).toBe(liveReading(source({ playing: false })));
+ expect(liveReading(source())).not.toBe(liveReading(source({ spacing: 5 })));
+ });
+
+ it('parks the turbines above cut-out whatever the clock says', () => {
+ const reading = liveReading(source({ windOverride: WIND_PRESETS.cutOut }));
+ expect(reading.operating.state).toBe('parked');
+ expect(reading.heroKw).toBe(0);
+ expect(reading.farmKw).toBe(0);
+ expect(reading.thrust).toBe(0);
+ });
+});
+
+describe('assembly state', () => {
+ it('shows the scene of the chapter', () => {
+ expect(sceneOf('farm')).toBe('farm');
+ expect(sceneOf('nacelle')).toBe('turbine');
+ expect(createAssemblyState(source({ preset: 'curve' })).scene).toBe('turbine');
+ });
+
+ it('runs at full power at noon with the rotors facing the wind', () => {
+ const state = createAssemblyState(source());
+ expect(state.rotor.state).toBe('full');
+ expect(state.rotor.powerShare).toBeCloseTo(1);
+ expect(state.rotor.yawDeg).toBeCloseTo(windFromDeg(NOON));
+ expect(state.wind.induction).toBeGreaterThan(0);
+ expect(state.farm.outputShare).toBeGreaterThan(0.9);
+ expect(state.farm.outputShare).toBeLessThanOrEqual(1);
+ expect(state.farm.sites).toEqual(farmLayout(7));
+ expect(state.farm.sites).toBe(farmSitesOf(7));
+ expect(state.farm.deficits).toHaveLength(state.farm.sites.length);
+ expect(state.farm.plumeLengthD).toBeGreaterThan(0);
+ expect(state.farm.plumeStrength).toBeGreaterThan(0);
+ });
+
+ it('waits below cut-in at night with no plumes and no output', () => {
+ const state = createAssemblyState(source({ phase: NIGHT }));
+ expect(state.rotor.state).toBe('idle');
+ expect(state.rotor.powerShare).toBe(0);
+ expect(state.farm.outputShare).toBe(0);
+ expect(state.farm.plumeLengthD).toBe(0);
+ expect(state.farm.plumeStrength).toBe(0);
+ });
+
+ it('parks and brakes the rotor in the storm', () => {
+ const state = createAssemblyState(source({ phase: STORM_CORE_MINUTE }));
+ expect(state.rotor.state).toBe('parked');
+ expect(state.rotor.braked).toBe(true);
+ expect(state.rotor.rpm).toBe(0);
+ expect(state.wind.induction).toBe(0);
+ expect(state.farm.outputShare).toBe(0);
+ });
+
+ it('rewrites the same objects so the bindings can double buffer', () => {
+ const target = createAssemblyState(source());
+ const { wind, rotor, farm, view } = target;
+ const next = writeAssemblyState(
+ target,
+ source({ preset: 'tower', spacing: 9, view: { ...DEFAULT_VIEW, cutaway: true } }),
+ );
+ expect(next).toBe(target);
+ expect([next.wind, next.rotor, next.farm, next.view]).toEqual([wind, rotor, farm, view]);
+ expect(next.wind).toBe(wind);
+ expect(next.scene).toBe('turbine');
+ expect(next.farm.spacing).toBe(9);
+ expect(next.view.cutaway).toBe(true);
+ });
+});
diff --git a/explainers/wind-farm/src/state/derived.ts b/explainers/wind-farm/src/state/derived.ts
new file mode 100644
index 00000000..d8bc64d9
--- /dev/null
+++ b/explainers/wind-farm/src/state/derived.ts
@@ -0,0 +1,137 @@
+import type { AssemblyState, FarmSite, PresetId, SceneId, SpacingD } from '../ids';
+import { PRESET_SCENES, SPACING_OPTIONS } from '../ids';
+import {
+ FARM_RATED_KW,
+ RATED_KW,
+ axialInduction,
+ dayWind,
+ farmLayout,
+ farmPowerKw,
+ initialDeficit,
+ operatingAt,
+ operatingAtWind,
+ plumeLengthD,
+ thrustCoefficient,
+ turbinePowerKw,
+ wakeDeficits,
+ windFromDeg,
+} from '../model';
+import type { OperatingReading } from '../model';
+import type { WindFarmState } from './store';
+
+export type LiveSource = Pick;
+
+export type AssemblySource = LiveSource & Pick;
+
+export interface LiveReading {
+ wind: number;
+ fromDeg: number;
+ operating: OperatingReading;
+ thrust: number;
+ heroKw: number;
+ farmKw: number;
+ deficits: readonly number[];
+}
+
+const FARM_SITES = new Map(
+ SPACING_OPTIONS.map((spacing) => [spacing, farmLayout(spacing)]),
+);
+
+export function farmSitesOf(spacing: SpacingD): readonly FarmSite[] {
+ return FARM_SITES.get(spacing) ?? farmLayout(spacing);
+}
+
+export function sceneOf(preset: PresetId): SceneId {
+ return PRESET_SCENES[preset];
+}
+
+export function liveWind({ phase, siteWind, windOverride }: LiveSource): number {
+ return windOverride ?? dayWind(phase, siteWind);
+}
+
+function operatingOf(source: LiveSource, wind: number): OperatingReading {
+ return source.windOverride === null
+ ? operatingAt(source.phase, source.siteWind)
+ : operatingAtWind(wind);
+}
+
+function readingOf(source: LiveSource): LiveReading {
+ const wind = liveWind(source);
+ const fromDeg = windFromDeg(source.phase);
+ const operating = operatingOf(source, wind);
+ const thrust = operating.producing ? thrustCoefficient(wind) : 0;
+ return {
+ wind,
+ fromDeg,
+ operating,
+ thrust,
+ heroKw: operating.producing ? turbinePowerKw(wind) : 0,
+ farmKw: operating.producing ? farmPowerKw(wind, fromDeg, source.spacing) : 0,
+ deficits: wakeDeficits(farmSitesOf(source.spacing), thrust, fromDeg),
+ };
+}
+
+let lastSource: LiveSource | null = null;
+let lastReading: LiveReading | null = null;
+
+function sameSource(a: LiveSource, b: LiveSource): boolean {
+ return (
+ a.phase === b.phase &&
+ a.siteWind === b.siteWind &&
+ a.windOverride === b.windOverride &&
+ a.spacing === b.spacing
+ );
+}
+
+export function liveReading(source: LiveSource): LiveReading {
+ if (lastReading && lastSource && sameSource(lastSource, source)) return lastReading;
+ const { phase, siteWind, windOverride, spacing } = source;
+ lastSource = { phase, siteWind, windOverride, spacing };
+ lastReading = readingOf(source);
+ return lastReading;
+}
+
+export function writeAssemblyState(target: AssemblyState, source: AssemblySource): AssemblyState {
+ const reading = liveReading(source);
+ const { operating } = reading;
+ target.scene = sceneOf(source.preset);
+ target.phase = source.phase;
+ target.playing = source.playing;
+ target.wind.speed = reading.wind;
+ target.wind.fromDeg = reading.fromDeg;
+ target.wind.induction = axialInduction(reading.thrust);
+ target.rotor.rpm = operating.rpm;
+ target.rotor.pitchDeg = operating.pitchDeg;
+ target.rotor.yawDeg = reading.fromDeg;
+ target.rotor.state = operating.state;
+ target.rotor.braked = operating.braked;
+ target.rotor.powerShare = reading.heroKw / RATED_KW;
+ target.farm.spacing = source.spacing;
+ target.farm.sites = farmSitesOf(source.spacing);
+ target.farm.deficits = reading.deficits;
+ target.farm.plumeLengthD = plumeLengthD(reading.thrust);
+ target.farm.plumeStrength = initialDeficit(reading.thrust);
+ target.farm.outputShare = reading.farmKw / FARM_RATED_KW;
+ Object.assign(target.view, source.view);
+ return target;
+}
+
+export function createAssemblyState(source: AssemblySource): AssemblyState {
+ const target: AssemblyState = {
+ scene: sceneOf(source.preset),
+ phase: source.phase,
+ playing: source.playing,
+ wind: { speed: 0, fromDeg: 0, induction: 0 },
+ rotor: { rpm: 0, pitchDeg: 0, yawDeg: 0, state: 'idle', braked: false, powerShare: 0 },
+ farm: {
+ spacing: source.spacing,
+ sites: farmSitesOf(source.spacing),
+ deficits: [],
+ plumeLengthD: 0,
+ plumeStrength: 0,
+ outputShare: 0,
+ },
+ view: { ...source.view },
+ };
+ return writeAssemblyState(target, source);
+}
diff --git a/explainers/wind-farm/src/state/index.ts b/explainers/wind-farm/src/state/index.ts
new file mode 100644
index 00000000..029bc932
--- /dev/null
+++ b/explainers/wind-farm/src/state/index.ts
@@ -0,0 +1,3 @@
+export * from './derived';
+export * from './presets';
+export * from './store';
diff --git a/explainers/wind-farm/src/state/presets.test.ts b/explainers/wind-farm/src/state/presets.test.ts
new file mode 100644
index 00000000..589af3b2
--- /dev/null
+++ b/explainers/wind-farm/src/state/presets.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+import { PRESET_IDS, SCENE_PARTS } from '../ids';
+import { CYCLE_MINUTES, DEFAULT_SPEED, PHASE_RANGES, clockOf } from '../model';
+import { sceneOf } from './derived';
+import { PRESETS } from './presets';
+
+describe('wind farm presets', () => {
+ it('defines one preset per chapter at the default speed', () => {
+ expect(Object.keys(PRESETS)).toEqual([...PRESET_IDS]);
+ PRESET_IDS.forEach((id) => expect(PRESETS[id].speed).toBe(DEFAULT_SPEED));
+ });
+
+ it('labels and highlights only parts of the scene the chapter shows', () => {
+ PRESET_IDS.forEach((id) => {
+ const parts = SCENE_PARTS[sceneOf(id)];
+ [...PRESETS[id].labels, ...PRESETS[id].highlight].forEach((part) =>
+ expect(parts, `${id} ${part}`).toContain(part),
+ );
+ });
+ });
+
+ it('shows the farm in chapters 1, 5 and 6 and the turbine in chapters 2, 3 and 4', () => {
+ expect(PRESET_IDS.map(sceneOf)).toEqual([
+ 'farm',
+ 'turbine',
+ 'turbine',
+ 'turbine',
+ 'farm',
+ 'farm',
+ ]);
+ });
+
+ it('opens the nacelle only in its own chapter', () => {
+ PRESET_IDS.forEach((id) => expect(PRESETS[id].view.cutaway).toBe(id === 'nacelle'));
+ });
+
+ it('starts every chapter inside the day and follows the day wind', () => {
+ PRESET_IDS.forEach((id) => {
+ const seek = PRESETS[id].startAt ?? PRESETS[id].pauseAt;
+ expect(seek).toBeGreaterThanOrEqual(0);
+ expect(seek).toBeLessThan(CYCLE_MINUTES);
+ expect(PRESETS[id].start?.windOverride).toBeNull();
+ });
+ expect(PRESETS.wakes.pauseAt).toBeGreaterThanOrEqual(PHASE_RANGES.morning[0]);
+ });
+
+ it('opens each chapter on the hour', () => {
+ const opening = PRESET_IDS.map((id) =>
+ clockOf(PRESETS[id].startAt ?? PRESETS[id].pauseAt ?? 0),
+ );
+ expect(opening).toEqual(['12:00', '12:00', '13:00', '06:00', '09:00', '15:00']);
+ });
+});
diff --git a/explainers/wind-farm/src/state/presets.ts b/explainers/wind-farm/src/state/presets.ts
new file mode 100644
index 00000000..adb665b6
--- /dev/null
+++ b/explainers/wind-farm/src/state/presets.ts
@@ -0,0 +1,92 @@
+import type { ScenePreset } from '@core/scene/presetBinder';
+import type { CameraView, PartId, PresetId, SpacingD, ViewOptions } from '../ids';
+import { DEFAULT_SPACING_D, DEFAULT_SPEED, MINUTES_PER_HOUR } from '../model';
+
+export interface ChapterControls {
+ spacing: SpacingD;
+ windOverride: number | null;
+}
+
+export interface Preset extends ScenePreset {
+ view: Partial;
+ start?: Partial;
+}
+
+const NOON = 12 * MINUTES_PER_HOUR;
+const EARLY_AFTERNOON = 13 * MINUTES_PER_HOUR;
+const MID_AFTERNOON = 15 * MINUTES_PER_HOUR;
+const DAWN = 6 * MINUTES_PER_HOUR;
+const MID_MORNING = 9 * MINUTES_PER_HOUR;
+
+const FOLLOW_THE_DAY: Partial = { windOverride: null };
+const DEFAULT_FARM: Partial = { spacing: DEFAULT_SPACING_D, windOverride: null };
+
+const NACELLE_INTERIOR: readonly PartId[] = [
+ 'hub',
+ 'pitchCylinders',
+ 'mainBearing',
+ 'mainShaft',
+ 'gearbox',
+ 'brakeDisc',
+ 'generator',
+ 'converter',
+ 'yawDrives',
+ 'cooler',
+];
+
+export const PRESETS: Record = {
+ farm: {
+ camera: 'farmAerial',
+ speed: DEFAULT_SPEED,
+ view: { cutaway: false, streamlines: false, wakes: false, cables: false },
+ startAt: NOON,
+ start: DEFAULT_FARM,
+ labels: ['farmTurbines', 'prevailingWind', 'spacingMarker', 'accessRoads', 'substation'],
+ highlight: [],
+ },
+ tower: {
+ camera: 'turbineTall',
+ speed: DEFAULT_SPEED,
+ view: { cutaway: false, streamlines: true, wakes: false, cables: false },
+ startAt: NOON,
+ start: FOLLOW_THE_DAY,
+ labels: ['blades', 'hub', 'nacelle', 'tower', 'foundation', 'sweptArea', 'transformer'],
+ highlight: ['blades', 'hub', 'sweptArea'],
+ },
+ nacelle: {
+ camera: 'nacelleCutaway',
+ speed: DEFAULT_SPEED,
+ view: { cutaway: true, streamlines: false, wakes: false, cables: false },
+ startAt: EARLY_AFTERNOON,
+ start: FOLLOW_THE_DAY,
+ labels: NACELLE_INTERIOR,
+ highlight: [...NACELLE_INTERIOR, 'bedplate'],
+ },
+ curve: {
+ camera: 'rotorQuarter',
+ speed: DEFAULT_SPEED,
+ view: { cutaway: false, streamlines: true, wakes: true, cables: false },
+ startAt: DAWN,
+ start: FOLLOW_THE_DAY,
+ labels: ['blades', 'hub', 'nacelle', 'streamlinesGroup', 'heroWake'],
+ highlight: ['blades', 'hub', 'streamlinesGroup', 'heroWake'],
+ },
+ wakes: {
+ camera: 'wakeStreaks',
+ speed: DEFAULT_SPEED,
+ view: { cutaway: false, streamlines: true, wakes: true, cables: false },
+ pauseAt: MID_MORNING,
+ start: DEFAULT_FARM,
+ labels: ['wakePlumes', 'farmTurbines', 'spacingMarker', 'prevailingWind', 'shearProfile'],
+ highlight: ['wakePlumes', 'spacingMarker', 'shearProfile'],
+ },
+ grid: {
+ camera: 'gridSubstation',
+ speed: DEFAULT_SPEED,
+ view: { cutaway: false, streamlines: false, wakes: false, cables: true },
+ startAt: MID_AFTERNOON,
+ start: DEFAULT_FARM,
+ labels: ['collectorCables', 'substation', 'gridLine', 'farmTurbines', 'accessRoads'],
+ highlight: ['collectorCables', 'substation', 'gridLine'],
+ },
+};
diff --git a/explainers/wind-farm/src/state/store.test.ts b/explainers/wind-farm/src/state/store.test.ts
new file mode 100644
index 00000000..56b4724b
--- /dev/null
+++ b/explainers/wind-farm/src/state/store.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_SPACING_D, PHASE_RANGES } from '../model';
+import {
+ CHAPTER_CONTROL_DEFAULTS,
+ DEFAULT_SITE_WIND,
+ DEFAULT_VIEW,
+ START_PHASE,
+ START_VIEW,
+ WIND_OVERRIDE_RANGE,
+ createWindFarmStore,
+ snapToRange,
+} from './store';
+import { PRESETS } from './presets';
+
+describe('wind farm store', () => {
+ it('starts on the farm chapter at noon on a typical site with the farm view', () => {
+ const state = createWindFarmStore().getState();
+ expect(state.preset).toBe('farm');
+ expect(state.phase).toBe(START_PHASE);
+ expect(START_PHASE).toBe(PRESETS.farm.startAt);
+ expect(state.siteWind).toBe(DEFAULT_SITE_WIND);
+ expect(state.spacing).toBe(DEFAULT_SPACING_D);
+ expect(state.windOverride).toBeNull();
+ expect(state.view).toEqual(START_VIEW);
+ expect(START_VIEW).toEqual({ ...DEFAULT_VIEW, ...PRESETS.farm.view });
+ expect(state.view).toEqual({
+ streamlines: false,
+ wakes: false,
+ cables: false,
+ labels: true,
+ cutaway: false,
+ });
+ });
+
+ it('shows the same farm view on coming back to the farm chapter', () => {
+ const store = createWindFarmStore();
+ const firstView = store.getState().view;
+ store.getState().applyPreset('wakes');
+ store.getState().applyPreset('farm');
+ expect(store.getState().view).toEqual(firstView);
+ });
+
+ it('snaps the wind override to half metres per second inside its range', () => {
+ const store = createWindFarmStore();
+ store.getState().setWindOverride(7.3);
+ expect(store.getState().windOverride).toBe(7.5);
+ store.getState().setWindOverride(40);
+ expect(store.getState().windOverride).toBe(WIND_OVERRIDE_RANGE.max);
+ store.getState().setWindOverride(-2);
+ expect(store.getState().windOverride).toBe(WIND_OVERRIDE_RANGE.min);
+ store.getState().setWindOverride(null);
+ expect(store.getState().windOverride).toBeNull();
+ expect(snapToRange(12.26, WIND_OVERRIDE_RANGE)).toBe(12.5);
+ });
+
+ it('resets the chapter controls on a chapter change and keeps them inside a chapter', () => {
+ const store = createWindFarmStore();
+ store.getState().applyPreset('wakes');
+ store.getState().setSpacing(9);
+ store.getState().setWindOverride(20);
+ store.getState().applyPreset('wakes');
+ expect(store.getState().spacing).toBe(9);
+ expect(store.getState().windOverride).toBe(20);
+ store.getState().applyPreset('grid');
+ expect(store.getState().spacing).toBe(CHAPTER_CONTROL_DEFAULTS.spacing);
+ expect(store.getState().windOverride).toBeNull();
+ });
+
+ it('keeps the site wind and the labels toggle across chapters', () => {
+ const store = createWindFarmStore();
+ store.getState().setSiteWind('windy');
+ store.getState().setView({ labels: false });
+ store.getState().applyPreset('curve');
+ expect(store.getState().siteWind).toBe('windy');
+ expect(store.getState().view.labels).toBe(false);
+ });
+
+ it('opens the nacelle in its chapter and closes it on leaving', () => {
+ const store = createWindFarmStore();
+ store.getState().applyPreset('nacelle');
+ expect(store.getState().view.cutaway).toBe(true);
+ store.getState().applyPreset('tower');
+ expect(store.getState().view.cutaway).toBe(false);
+ });
+
+ it('pauses the wakes chapter in the morning breeze', () => {
+ const store = createWindFarmStore();
+ store.getState().applyPreset('wakes');
+ const { phase, playing } = store.getState();
+ expect(playing).toBe(false);
+ expect(phase).toBeGreaterThanOrEqual(PHASE_RANGES.morning[0]);
+ expect(phase).toBeLessThan(PHASE_RANGES.morning[1]);
+ });
+});
diff --git a/explainers/wind-farm/src/state/store.ts b/explainers/wind-farm/src/state/store.ts
new file mode 100644
index 00000000..b71ac8c1
--- /dev/null
+++ b/explainers/wind-farm/src/state/store.ts
@@ -0,0 +1,86 @@
+import type { ExplainerStore, Playback, PlaybackState } from '@core/explainer';
+import { clamp } from '@core/math';
+import { createExplainerStore } from '@core/store';
+import type { PresetId, SiteWindId, SpacingD, ViewOptions } from '../ids';
+import { DEFAULT_SPACING_D, SPEED_RANGE } from '../model';
+import { WIND_FARM_TIMELINE } from '../timeline';
+import { PRESETS } from './presets';
+import type { ChapterControls, Preset } from './presets';
+
+export type ViewState = { [Key in keyof ViewOptions]: ViewOptions[Key] };
+
+export interface WindFarmFields extends ChapterControls {
+ siteWind: SiteWindId;
+ view: ViewState;
+ preset: PresetId;
+}
+
+export interface WindFarmOwnActions {
+ setSiteWind(siteWind: SiteWindId): void;
+ setSpacing(spacing: SpacingD): void;
+ setWindOverride(windOverride: number | null): void;
+}
+
+export type WindFarmState = PlaybackState & WindFarmFields;
+export type WindFarmStoreState = Playback & WindFarmFields & WindFarmOwnActions;
+export type WindFarmStore = ExplainerStore;
+
+export interface SteppedRange {
+ min: number;
+ max: number;
+ step: number;
+}
+
+export const WIND_OVERRIDE_RANGE = { min: 0, max: 30, step: 0.5 } as const;
+
+export const DEFAULT_VIEW: ViewState = {
+ streamlines: true,
+ wakes: true,
+ cables: true,
+ labels: true,
+ cutaway: false,
+};
+export const DEFAULT_SITE_WIND: SiteWindId = 'typical';
+
+export const CHAPTER_CONTROL_DEFAULTS: ChapterControls = {
+ spacing: DEFAULT_SPACING_D,
+ windOverride: null,
+};
+
+const START_PRESET: PresetId = 'farm';
+const START_SPEED = PRESETS[START_PRESET].speed ?? SPEED_RANGE.default;
+export const START_PHASE = PRESETS[START_PRESET].startAt ?? 0;
+export const START_VIEW: ViewState = { ...DEFAULT_VIEW, ...PRESETS[START_PRESET].view };
+
+export function snapToRange(value: number, { min, max, step }: SteppedRange): number {
+ return clamp(min + Math.round((value - min) / step) * step, min, max);
+}
+
+function snapOverride(windOverride: number | null): number | null {
+ return windOverride === null ? null : snapToRange(windOverride, WIND_OVERRIDE_RANGE);
+}
+
+function presetFields(preset: Preset, state: WindFarmFields): Partial {
+ if (PRESETS[state.preset] === preset) return {};
+ return { ...CHAPTER_CONTROL_DEFAULTS, ...preset.start };
+}
+
+export function createWindFarmStore(overrides: Partial = {}): WindFarmStore {
+ return createExplainerStore(
+ {
+ timeline: WIND_FARM_TIMELINE,
+ presets: PRESETS,
+ defaults: { preset: START_PRESET, speed: START_SPEED, view: START_VIEW },
+ extend: (set) => ({
+ ...CHAPTER_CONTROL_DEFAULTS,
+ ...PRESETS[START_PRESET].start,
+ siteWind: DEFAULT_SITE_WIND,
+ setSiteWind: (siteWind) => set({ siteWind }),
+ setSpacing: (spacing) => set({ spacing }),
+ setWindOverride: (windOverride) => set({ windOverride: snapOverride(windOverride) }),
+ }),
+ presetState: presetFields,
+ },
+ { phase: START_PHASE, ...overrides },
+ );
+}
diff --git a/explainers/wind-farm/src/style.css b/explainers/wind-farm/src/style.css
new file mode 100644
index 00000000..145e468a
--- /dev/null
+++ b/explainers/wind-farm/src/style.css
@@ -0,0 +1,95 @@
+:root {
+ --wind: #5fa8ff;
+ --power: #f2b134;
+ --cable: #ffb347;
+ --wake: #7f9fc0;
+ --cutaway: #c58b3a;
+ --screen: #1b1d20;
+}
+
+.chapter .readouts {
+ --readout-min-width: 8rem;
+ margin-block: 16px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px 16px;
+}
+
+.chapter .readouts > div,
+.chapter .readouts :is(dt, dd) {
+ min-width: 0;
+}
+
+.chapter .readouts :is(dt, dd) {
+ margin: 0;
+ overflow-wrap: break-word;
+ hyphens: auto;
+}
+
+.chapter .readouts dt {
+ color: var(--muted);
+ font-size: 0.8125rem;
+}
+
+.chapter .readouts > div:last-child:nth-child(odd) {
+ grid-column: 1 / -1;
+}
+
+.range-widget__header {
+ flex-wrap: wrap;
+}
+
+.range-widget__header label {
+ min-width: 0;
+ overflow-wrap: break-word;
+}
+
+.curve-widget {
+ margin: 4px 0 16px;
+}
+
+.curve-widget canvas {
+ display: block;
+ width: 100%;
+ height: auto;
+ aspect-ratio: 2;
+ border-radius: var(--radius-small);
+ background: var(--screen);
+}
+
+.curve-widget figcaption {
+ margin-top: 6px;
+ color: var(--muted);
+ font-size: 0.8125rem;
+}
+
+.chip:is([data-action='windAt'], [data-action='siteWind']) {
+ --tone: var(--wind);
+}
+
+.chip[data-action='windAt'][data-value='rated'] {
+ --tone: var(--power);
+}
+
+.chip[data-action='spacing'] {
+ --tone: var(--wake);
+}
+
+.chip[data-action='nacelle'] {
+ --tone: var(--cutaway);
+}
+
+@media (pointer: coarse) {
+ .chip[data-action] {
+ height: 44px;
+ }
+}
+
+@media (max-width: 480px) {
+ .chapter .readouts {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .chapter .readouts > div:last-child:nth-child(odd) {
+ grid-column: auto;
+ }
+}
diff --git a/explainers/wind-farm/src/theme.test.ts b/explainers/wind-farm/src/theme.test.ts
new file mode 100644
index 00000000..442d2cf0
--- /dev/null
+++ b/explainers/wind-farm/src/theme.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+import { THEME as CORE_THEME } from '@core/theme';
+import { OPERATING_STATE_IDS, PHASE_IDS } from './ids';
+import { PHASE_TONES, STATE_TONES, THEME } from './theme';
+
+const HEX_COLOUR = /^#[0-9a-f]{6}$/i;
+
+describe('theme', () => {
+ it('keeps the core tokens', () => {
+ expect(THEME).toMatchObject(CORE_THEME);
+ });
+
+ it('writes every colour as hex', () => {
+ Object.values(THEME).forEach((colour) => {
+ expect(colour).toMatch(HEX_COLOUR);
+ });
+ });
+
+ it('gives each phase of the day its own tone', () => {
+ expect(Object.keys(PHASE_TONES)).toEqual([...PHASE_IDS]);
+ expect(new Set(Object.values(PHASE_TONES)).size).toBe(PHASE_IDS.length);
+ Object.values(PHASE_TONES).forEach((tone) => {
+ expect(tone).toMatch(HEX_COLOUR);
+ });
+ });
+
+ it('gives each operating state a tone', () => {
+ expect(Object.keys(STATE_TONES)).toEqual([...OPERATING_STATE_IDS]);
+ Object.values(STATE_TONES).forEach((tone) => {
+ expect(tone).toMatch(HEX_COLOUR);
+ });
+ });
+});
diff --git a/explainers/wind-farm/src/theme.ts b/explainers/wind-farm/src/theme.ts
new file mode 100644
index 00000000..c3679adf
--- /dev/null
+++ b/explainers/wind-farm/src/theme.ts
@@ -0,0 +1,53 @@
+import { THEME as CORE_THEME } from '@core/theme';
+import type { OperatingStateId, PhaseId } from './ids';
+
+export const THEME = {
+ ...CORE_THEME,
+ night: '#3d5a99',
+ morning: '#6fbf73',
+ afternoon: '#f2b134',
+ storm: '#e5534b',
+ evening: '#9b7fd1',
+ haze: '#cfdbe6',
+ skyTop: '#7fa9d6',
+ grass: '#8fae5d',
+ grassDark: '#6f8f45',
+ field: '#c9b876',
+ trees: '#4f6b3a',
+ gravel: '#bdb5a6',
+ turbineWhite: '#eef0f2',
+ turbineGrey: '#c9ced3',
+ concrete: '#b9b6ae',
+ castIron: '#5d6168',
+ steel: '#8c939b',
+ gearOil: '#c58b3a',
+ copper: '#b87333',
+ converterCabinet: '#d7dbe0',
+ hydraulic: '#d9c23f',
+ brakeGlow: '#ff6a3d',
+ cooler: '#aeb6bd',
+ wind: '#5fa8ff',
+ wake: '#2f4f6f',
+ cable: '#ffb347',
+ gridLine: '#3a3f46',
+ substation: '#9aa3ad',
+ spacing: '#ffffff',
+} as const;
+
+export const PHASE_TONES: Readonly> = {
+ night: THEME.night,
+ morning: THEME.morning,
+ afternoon: THEME.afternoon,
+ storm: THEME.storm,
+ evening: THEME.evening,
+};
+
+export const STATE_TONES: Readonly> = {
+ idle: THEME.night,
+ partial: THEME.morning,
+ full: THEME.afternoon,
+ rampDown: THEME.storm,
+ stopping: THEME.storm,
+ parked: THEME.castIron,
+ starting: THEME.morning,
+};
diff --git a/explainers/wind-farm/src/timeline.dom.test.ts b/explainers/wind-farm/src/timeline.dom.test.ts
new file mode 100644
index 00000000..31952a7c
--- /dev/null
+++ b/explainers/wind-farm/src/timeline.dom.test.ts
@@ -0,0 +1,56 @@
+import { beforeAll, describe, expect, it } from 'vitest';
+import { initI18n } from '@core/i18n';
+import en from '../locales/en.json';
+import { PHASE_IDS } from './ids';
+import type { PhaseId } from './ids';
+import { CYCLE_MINUTES, DEFAULT_SPEED, PHASE_RANGES, SPEED_RANGE, rate } from './model';
+import { PHASE_TONES } from './theme';
+import {
+ WIND_FARM_TIMELINE,
+ describePhase,
+ describeSpeed,
+ formatPhase,
+ formatSpeed,
+} from './timeline';
+
+describe('wind farm timeline', () => {
+ beforeAll(() => initI18n({ en: () => Promise.resolve(en) }));
+
+ it('loops one day of minutes through the five parts of the day', () => {
+ const { phases, cycle, loop } = WIND_FARM_TIMELINE;
+ expect(cycle).toBe(CYCLE_MINUTES);
+ expect(loop).toBe(true);
+ expect(phases.map((phase) => phase.id)).toEqual([...PHASE_IDS]);
+ phases.forEach((phase) => {
+ const [start, end] = PHASE_RANGES[phase.id as PhaseId];
+ expect(phase).toMatchObject({ start, end, tone: PHASE_TONES[phase.id as PhaseId] });
+ expect(phase.labelKey).toBe(`timeline.phase.${phase.id}`);
+ expect(phase.jumpLabelKey).toBe(`timeline.jump.${phase.id}`);
+ });
+ expect(phases[0].start).toBe(0);
+ expect(phases.at(-1)?.end).toBe(cycle);
+ phases.slice(1).forEach((phase, index) => expect(phase.start).toBe(phases[index].end));
+ });
+
+ it('steps by five minutes and nudges by a quarter hour or an hour', () => {
+ expect(WIND_FARM_TIMELINE.step).toBe(5);
+ expect(WIND_FARM_TIMELINE.nudge).toEqual({ fine: 15, coarse: 60 });
+ });
+
+ it('shows the clock time and names the part of the day', () => {
+ expect(formatPhase(0)).toContain('00:00');
+ expect(formatPhase(1148)).toContain('19:08');
+ expect(describePhase(720)).toContain('12:00');
+ expect(describePhase(720)).toContain(en.timeline.phase.afternoon);
+ expect(describePhase(1000)).toContain(en.timeline.phase.storm);
+ });
+
+ it('plays the day at hours per reader minute', () => {
+ const { speed } = WIND_FARM_TIMELINE;
+ expect(speed).toMatchObject({ min: 1, max: 60, step: 1 });
+ expect(SPEED_RANGE.default).toBe(DEFAULT_SPEED);
+ expect(WIND_FARM_TIMELINE.rate(DEFAULT_SPEED)).toBe(rate(DEFAULT_SPEED));
+ expect(formatSpeed(DEFAULT_SPEED)).toContain('12');
+ expect(describeSpeed(DEFAULT_SPEED)).toContain('12');
+ });
+});
diff --git a/explainers/wind-farm/src/timeline.ts b/explainers/wind-farm/src/timeline.ts
new file mode 100644
index 00000000..a52b41af
--- /dev/null
+++ b/explainers/wind-farm/src/timeline.ts
@@ -0,0 +1,65 @@
+import type { Phase, Timeline } from '@core/explainer';
+import { formatNumber } from '@core/format';
+import { t } from '@core/i18n';
+import { PHASE_IDS } from './ids';
+import type { PhaseId } from './ids';
+import { CYCLE_MINUTES, PHASE_RANGES, SPEED_RANGE, clockOf, phaseOf, rate } from './model';
+import { PHASE_TONES } from './theme';
+
+const SCRUBBER_STEP_MINUTES = 5;
+const FINE_STEP_MINUTES = 15;
+const COARSE_STEP_MINUTES = 60;
+
+function phaseKey(id: PhaseId): string {
+ return `timeline.phase.${id}`;
+}
+
+function jumpKey(id: PhaseId): string {
+ return `timeline.jump.${id}`;
+}
+
+export function formatPhase(minute: number): string {
+ return t('timeline.value', { time: clockOf(minute) });
+}
+
+export function describePhase(minute: number): string {
+ return t('timeline.during', { time: clockOf(minute), phase: t(phaseKey(phaseOf(minute))) });
+}
+
+export function formatSpeed(speed: number): string {
+ return t('timeline.speedValue', { value: formatNumber(speed) });
+}
+
+export function describeSpeed(speed: number): string {
+ return t('timeline.speedFormat', { value: formatNumber(speed) });
+}
+
+const DAY_PHASES: readonly Phase[] = PHASE_IDS.map((id) => ({
+ id,
+ start: PHASE_RANGES[id][0],
+ end: PHASE_RANGES[id][1],
+ labelKey: phaseKey(id),
+ jumpLabelKey: jumpKey(id),
+ tone: PHASE_TONES[id],
+}));
+
+export const WIND_FARM_TIMELINE: Timeline = {
+ cycle: CYCLE_MINUTES,
+ loop: true,
+ step: SCRUBBER_STEP_MINUTES,
+ nudge: { fine: FINE_STEP_MINUTES, coarse: COARSE_STEP_MINUTES },
+ labelKey: 'timeline.label',
+ phasesLabelKey: 'timeline.phases',
+ formatPhase,
+ describePhase,
+ rate,
+ phases: DAY_PHASES,
+ speed: {
+ min: SPEED_RANGE.min,
+ max: SPEED_RANGE.max,
+ step: SPEED_RANGE.step,
+ labelKey: 'timeline.speed',
+ format: formatSpeed,
+ describe: describeSpeed,
+ },
+};
diff --git a/explainers/wind-farm/src/ui/actions.test.ts b/explainers/wind-farm/src/ui/actions.test.ts
new file mode 100644
index 00000000..ee737881
--- /dev/null
+++ b/explainers/wind-farm/src/ui/actions.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest';
+import { CHAPTER_ACTION_IDS, SITE_WIND_IDS, SPACING_CHOICE_IDS, WIND_PRESET_IDS } from '../ids';
+import { WIND_PRESETS } from '../model';
+import { createWindFarmStore } from '../state';
+import { CHAPTER_ACTIONS } from './actions';
+
+const OFF_PRESET_WIND = 9.5;
+
+describe('chapter actions', () => {
+ it('covers every chapter action', () => {
+ expect(Object.keys(CHAPTER_ACTIONS)).toEqual([...CHAPTER_ACTION_IDS]);
+ });
+
+ it('switches the site wind', () => {
+ const store = createWindFarmStore();
+ SITE_WIND_IDS.forEach((site) => {
+ CHAPTER_ACTIONS.siteWind.run(store.getState(), site);
+ expect(store.getState().siteWind).toBe(site);
+ expect(CHAPTER_ACTIONS.siteWind.current?.(store.getState())).toBe(site);
+ });
+ });
+
+ it('switches the row spacing', () => {
+ const store = createWindFarmStore();
+ SPACING_CHOICE_IDS.forEach((choice) => {
+ CHAPTER_ACTIONS.spacing.run(store.getState(), choice);
+ expect(store.getState().spacing).toBe(Number(choice));
+ expect(CHAPTER_ACTIONS.spacing.current?.(store.getState())).toBe(choice);
+ });
+ expect(() => CHAPTER_ACTIONS.spacing.run(store.getState(), '6')).toThrow();
+ });
+
+ it('opens and closes the nacelle', () => {
+ const store = createWindFarmStore();
+ CHAPTER_ACTIONS.nacelle.run(store.getState(), 'open');
+ expect(store.getState().view.cutaway).toBe(true);
+ expect(CHAPTER_ACTIONS.nacelle.current?.(store.getState())).toBe('open');
+ CHAPTER_ACTIONS.nacelle.run(store.getState(), 'closed');
+ expect(store.getState().view.cutaway).toBe(false);
+ expect(CHAPTER_ACTIONS.nacelle.current?.(store.getState())).toBe('closed');
+ });
+
+ it('pins the wind to a preset or hands it back to the day', () => {
+ const store = createWindFarmStore();
+ WIND_PRESET_IDS.forEach((preset) => {
+ CHAPTER_ACTIONS.windAt.run(store.getState(), preset);
+ expect(store.getState().windOverride).toBe(WIND_PRESETS[preset]);
+ expect(CHAPTER_ACTIONS.windAt.current?.(store.getState())).toBe(preset);
+ });
+ CHAPTER_ACTIONS.windAt.run(store.getState(), 'day');
+ expect(store.getState().windOverride).toBeNull();
+ expect(CHAPTER_ACTIONS.windAt.current?.(store.getState())).toBe('day');
+ });
+
+ it('marks no wind chip for a wind that matches no preset', () => {
+ const store = createWindFarmStore();
+ store.getState().setWindOverride(OFF_PRESET_WIND);
+ expect(CHAPTER_ACTIONS.windAt.current?.(store.getState())).toBe('');
+ });
+});
diff --git a/explainers/wind-farm/src/ui/actions.ts b/explainers/wind-farm/src/ui/actions.ts
new file mode 100644
index 00000000..d373dc7a
--- /dev/null
+++ b/explainers/wind-farm/src/ui/actions.ts
@@ -0,0 +1,50 @@
+import type { ChapterAction } from '@core/explainer';
+import { parseOption } from '@core/ui/parse';
+import {
+ FOLLOW_DAY,
+ NACELLE_CHOICE_IDS,
+ SITE_WIND_IDS,
+ SPACING_CHOICE_IDS,
+ WIND_AT_IDS,
+ WIND_PRESET_IDS,
+} from '../ids';
+import type { ChapterActionId, NacelleChoiceId, SpacingD, WindAtId } from '../ids';
+import { WIND_PRESETS } from '../model';
+import type { WindFarmStoreState } from '../state';
+
+const NOTHING_CURRENT = '';
+const OPEN: NacelleChoiceId = 'open';
+const CLOSED: NacelleChoiceId = 'closed';
+
+function spacingOf(value: string): SpacingD {
+ return Number(parseOption(value, SPACING_CHOICE_IDS)) as SpacingD;
+}
+
+function overrideOf(choice: WindAtId): number | null {
+ return choice === FOLLOW_DAY ? null : WIND_PRESETS[choice];
+}
+
+function windAtOf(override: number | null): WindAtId | typeof NOTHING_CURRENT {
+ if (override === null) return FOLLOW_DAY;
+ return WIND_PRESET_IDS.find((preset) => WIND_PRESETS[preset] === override) ?? NOTHING_CURRENT;
+}
+
+export const CHAPTER_ACTIONS: Record> = {
+ siteWind: {
+ run: (state, value) => state.setSiteWind(parseOption(value, SITE_WIND_IDS)),
+ current: (state) => state.siteWind,
+ },
+ spacing: {
+ run: (state, value) => state.setSpacing(spacingOf(value)),
+ current: (state) => String(state.spacing),
+ },
+ nacelle: {
+ run: (state, value) =>
+ state.setView({ cutaway: parseOption(value, NACELLE_CHOICE_IDS) === OPEN }),
+ current: (state) => (state.view.cutaway ? OPEN : CLOSED),
+ },
+ windAt: {
+ run: (state, value) => state.setWindOverride(overrideOf(parseOption(value, WIND_AT_IDS))),
+ current: (state) => windAtOf(state.windOverride),
+ },
+};
diff --git a/explainers/wind-farm/src/ui/dock.ts b/explainers/wind-farm/src/ui/dock.ts
new file mode 100644
index 00000000..9f690b6b
--- /dev/null
+++ b/explainers/wind-farm/src/ui/dock.ts
@@ -0,0 +1,45 @@
+import type { Choice, ViewToggle } from '@core/explainer';
+import { parseOption } from '@core/ui/parse';
+import { SITE_WIND_IDS } from '../ids';
+import type { WindFarmStoreState } from '../state';
+
+const STREAMLINES_ICON =
+ '';
+const WAKES_ICON =
+ '';
+const CABLES_ICON =
+ '';
+const LABELS_ICON =
+ '';
+
+export const WIND_FARM_CHOICES: readonly Choice[] = [
+ {
+ id: 'siteWind',
+ labelKey: 'controls.siteWind',
+ shortcut: 'W',
+ options: SITE_WIND_IDS.map((site) => ({
+ value: site,
+ labelKey: `controls.siteWindOptions.${site}`,
+ })),
+ select: (state) => state.siteWind,
+ apply: (state, value) => state.setSiteWind(parseOption(value, SITE_WIND_IDS)),
+ },
+];
+
+function viewToggle(view: string, shortcut: string, icon: string): ViewToggle {
+ return {
+ view,
+ shortcut,
+ nameKey: `controls.views.${view}.name`,
+ shortKey: `controls.views.${view}.short`,
+ hintKey: `controls.views.${view}.hint`,
+ icon,
+ };
+}
+
+export const VIEW_TOGGLES: readonly ViewToggle[] = [
+ viewToggle('streamlines', 'F', STREAMLINES_ICON),
+ viewToggle('wakes', 'K', WAKES_ICON),
+ viewToggle('cables', 'C', CABLES_ICON),
+ viewToggle('labels', 'L', LABELS_ICON),
+];
diff --git a/explainers/wind-farm/src/ui/format.dom.test.ts b/explainers/wind-farm/src/ui/format.dom.test.ts
new file mode 100644
index 00000000..ee16f428
--- /dev/null
+++ b/explainers/wind-farm/src/ui/format.dom.test.ts
@@ -0,0 +1,84 @@
+import { beforeAll, describe, expect, it } from 'vitest';
+import { GEAR_RATIO, MAX_RPM, generatorRpm } from '../model';
+import {
+ formatBrake,
+ formatDegrees,
+ formatFarmMw,
+ formatGearRatio,
+ formatGeneratorRpm,
+ formatHomes,
+ formatLossPercent,
+ formatMetres,
+ formatMwh,
+ formatOperatingState,
+ formatPercent,
+ formatRatedMw,
+ formatRpm,
+ formatSpacing,
+ formatSquareKm,
+ formatTipSpeed,
+ formatTurbineMw,
+ formatTurnTime,
+ formatVoltages,
+ formatWind,
+} from './format';
+import { TEST_LOCALE, fill, initTestLocale } from './testing';
+
+const { units, chapters } = TEST_LOCALE;
+
+beforeAll(() => initTestLocale());
+
+describe('wind farm formats', () => {
+ it('shows wind to one decimal and rpm to one decimal', () => {
+ expect(formatWind(7.5)).toBe(fill(units.metresPerSecond, { value: '7.5' }));
+ expect(formatWind(12)).toBe(fill(units.metresPerSecond, { value: '12.0' }));
+ expect(formatRpm(MAX_RPM)).toBe(fill(units.rpm, { value: '10.4' }));
+ });
+
+ it('rounds the generator speed to the nearest 5 and the gear ratio to the nearest 10', () => {
+ expect(formatGeneratorRpm(generatorRpm(MAX_RPM))).toBe(fill(units.rpm, { value: '1,485' }));
+ expect(formatGearRatio(GEAR_RATIO)).toBe(fill(chapters.nacelle.gearValue, { ratio: '140' }));
+ expect(formatGearRatio(GEAR_RATIO)).toBe('about 1 to 140');
+ });
+
+ it('shows the time for one turn, or that the rotor stands still', () => {
+ expect(formatTurnTime(MAX_RPM)).toBe(fill(units.seconds, { value: '5.8' }));
+ expect(formatTurnTime(0)).toBe(chapters.tower.stopped);
+ });
+
+ it('shows the tip speed in whole m/s and km/h to the nearest 10', () => {
+ expect(formatTipSpeed(MAX_RPM)).toBe(fill(units.speedPair, { ms: '82', kmh: '290' }));
+ });
+
+ it('shows one turbine in hundredths of a megawatt and the farm in tenths', () => {
+ expect(formatTurbineMw(4200)).toBe(fill(units.mw, { value: '4.20' }));
+ expect(formatTurbineMw(2545)).toBe(fill(units.mw, { value: '2.55' }));
+ expect(formatFarmMw(113_400)).toBe(fill(units.mw, { value: '113.4' }));
+ expect(formatRatedMw(113.4)).toBe(fill(units.mw, { value: '113.4' }));
+ });
+
+ it('shows whole percents except the wake loss', () => {
+ expect(formatPercent(0.456)).toBe(fill(units.percent, { value: '46' }));
+ expect(formatLossPercent(0.0492)).toBe(fill(units.percent, { value: '4.9' }));
+ });
+
+ it('rounds energy, homes, land and pitch', () => {
+ expect(formatMwh(1234.6)).toBe(fill(units.mwh, { value: '1,235' }));
+ expect(formatHomes(1361)).toBe(fill(units.count, { value: '1,400' }));
+ expect(formatSquareKm(38.6)).toBe(fill(units.squareKm, { value: '39' }));
+ expect(formatDegrees(12.4)).toBe(fill(units.degrees, { value: '12' }));
+ expect(formatMetres(73.7, 1)).toBe(fill(units.metres, { value: '73.7' }));
+ });
+
+ it('spells out the spacing, the brake, the state and the voltages', () => {
+ expect(formatSpacing(7)).toBe(
+ fill(chapters.farm.spacingValue, { metres: '1,050', diameters: '7' }),
+ );
+ expect(formatBrake(true)).toBe(chapters.nacelle.brakeValue.holding);
+ expect(formatBrake(false)).toBe(chapters.nacelle.brakeValue.released);
+ expect(formatOperatingState('parked')).toBe(TEST_LOCALE.readouts.state.parked);
+ expect(formatVoltages()).toBe(
+ fill(chapters.grid.voltagesValue, { generator: '800', collector: '36', grid: '110' }),
+ );
+ });
+});
diff --git a/explainers/wind-farm/src/ui/format.ts b/explainers/wind-farm/src/ui/format.ts
new file mode 100644
index 00000000..01a2814a
--- /dev/null
+++ b/explainers/wind-farm/src/ui/format.ts
@@ -0,0 +1,155 @@
+import { formatFixed, formatNumber } from '@core/format';
+import { t } from '@core/i18n';
+import type { OperatingStateId } from '../ids';
+import {
+ COLLECTOR_KV,
+ GENERATOR_VOLTS,
+ GRID_KV,
+ ROTOR_DIAMETER_M,
+ SWEPT_AREA_M2,
+ sweptPitches,
+ tipSpeed,
+ turnSeconds,
+} from '../model';
+
+const WHOLE = 0;
+const TENTHS = 1;
+const HUNDREDTHS = 2;
+const PERCENT = 100;
+const KW_PER_MW = 1000;
+const KMH_PER_MS = 3.6;
+const KMH_STEP = 10;
+const GENERATOR_RPM_STEP = 5;
+const GEAR_RATIO_STEP = 10;
+const HOMES_STEP = 100;
+
+function unit(key: string, value: string): string {
+ return t(`units.${key}`, { value });
+}
+
+function roundTo(value: number, step: number): number {
+ return Math.round(value / step) * step;
+}
+
+function whole(value: number): string {
+ return formatNumber(Math.round(value));
+}
+
+export function formatWind(metresPerSecond: number): string {
+ return unit('metresPerSecond', formatFixed(metresPerSecond, TENTHS));
+}
+
+export function formatRpm(rpm: number): string {
+ return unit('rpm', formatFixed(rpm, TENTHS));
+}
+
+export function formatGeneratorRpm(rpm: number): string {
+ return unit('rpm', formatNumber(roundTo(rpm, GENERATOR_RPM_STEP)));
+}
+
+export function formatTurnTime(rpm: number): string {
+ if (rpm <= 0) return t('chapters.tower.stopped');
+ return unit('seconds', formatFixed(turnSeconds(rpm), TENTHS));
+}
+
+export function formatTipSpeed(rpm: number): string {
+ const metresPerSecond = tipSpeed(rpm);
+ return t('units.speedPair', {
+ ms: whole(metresPerSecond),
+ kmh: formatNumber(roundTo(metresPerSecond * KMH_PER_MS, KMH_STEP)),
+ });
+}
+
+export function formatTurbineMw(kw: number): string {
+ return unit('mw', formatFixed(kw / KW_PER_MW, HUNDREDTHS));
+}
+
+export function formatFarmMw(kw: number): string {
+ return unit('mw', formatFixed(kw / KW_PER_MW, TENTHS));
+}
+
+export function formatRatedMw(mw: number): string {
+ return unit('mw', formatFixed(mw, TENTHS));
+}
+
+export function formatMwh(mwh: number): string {
+ return unit('mwh', whole(mwh));
+}
+
+export function formatPercent(fraction: number): string {
+ return unit('percent', whole(fraction * PERCENT));
+}
+
+export function formatLossPercent(fraction: number): string {
+ return unit('percent', formatFixed(fraction * PERCENT, TENTHS));
+}
+
+export function formatDegrees(degrees: number): string {
+ return unit('degrees', whole(degrees));
+}
+
+export function formatMetres(metres: number, fractionDigits = WHOLE): string {
+ return unit('metres', formatFixed(metres, fractionDigits));
+}
+
+export function formatCount(count: number): string {
+ return unit('count', whole(count));
+}
+
+export function formatHomes(homes: number): string {
+ return unit('count', formatNumber(roundTo(homes, HOMES_STEP)));
+}
+
+export function formatHectares(hectares: number): string {
+ return unit('hectares', whole(hectares));
+}
+
+export function formatSquareKm(squareKm: number): string {
+ return unit('squareKm', whole(squareKm));
+}
+
+export function formatVolts(volts: number): string {
+ return unit('volts', whole(volts));
+}
+
+export function formatGramsPerKwh(grams: number): string {
+ return unit('gramsPerKwh', formatFixed(grams, TENTHS));
+}
+
+export function formatMonths(months: number): string {
+ return unit('months', formatFixed(months, TENTHS));
+}
+
+export function formatSpacing(diameters: number): string {
+ return t('chapters.farm.spacingValue', {
+ metres: whole(diameters * ROTOR_DIAMETER_M),
+ diameters: formatNumber(diameters),
+ });
+}
+
+export function formatSweptArea(): string {
+ return t('chapters.tower.sweptValue', {
+ area: whole(SWEPT_AREA_M2),
+ pitches: formatFixed(sweptPitches(), TENTHS),
+ });
+}
+
+export function formatGearRatio(ratio: number): string {
+ return t('chapters.nacelle.gearValue', { ratio: formatNumber(roundTo(ratio, GEAR_RATIO_STEP)) });
+}
+
+export function formatBrake(braked: boolean): string {
+ return t(`chapters.nacelle.brakeValue.${braked ? 'holding' : 'released'}`);
+}
+
+export function formatOperatingState(state: OperatingStateId): string {
+ return t(`readouts.state.${state}`);
+}
+
+export function formatVoltages(): string {
+ return t('chapters.grid.voltagesValue', {
+ generator: whole(GENERATOR_VOLTS),
+ collector: whole(COLLECTOR_KV),
+ grid: whole(GRID_KV),
+ });
+}
diff --git a/explainers/wind-farm/src/ui/index.ts b/explainers/wind-farm/src/ui/index.ts
new file mode 100644
index 00000000..c88360ae
--- /dev/null
+++ b/explainers/wind-farm/src/ui/index.ts
@@ -0,0 +1,24 @@
+import { disposeAll } from '@core/ui/disposers';
+import type { Disposer } from '@core/ui/disposers';
+import type { WindFarmStore } from '../state';
+import { mountCurveReadouts } from './mountCurveReadouts';
+import { mountFarmReadouts } from './mountFarmReadouts';
+import { mountGridReadouts } from './mountGridReadouts';
+import { mountNacelleReadouts } from './mountNacelleReadouts';
+import { mountTowerReadouts } from './mountTowerReadouts';
+import { mountWakesReadouts } from './mountWakesReadouts';
+
+export { CHAPTER_ACTIONS } from './actions';
+export { VIEW_TOGGLES, WIND_FARM_CHOICES } from './dock';
+export { WIND_FARM_READOUTS } from './readouts';
+
+export function mountWindFarmUi(root: Document, store: WindFarmStore): Disposer {
+ return disposeAll([
+ mountFarmReadouts(root, store),
+ mountTowerReadouts(root, store),
+ mountNacelleReadouts(root, store),
+ mountCurveReadouts(root, store),
+ mountWakesReadouts(root, store),
+ mountGridReadouts(root, store),
+ ]);
+}
diff --git a/explainers/wind-farm/src/ui/mountCurveReadouts.ts b/explainers/wind-farm/src/ui/mountCurveReadouts.ts
new file mode 100644
index 00000000..6a878fe4
--- /dev/null
+++ b/explainers/wind-farm/src/ui/mountCurveReadouts.ts
@@ -0,0 +1,33 @@
+import { disposeAll } from '@core/ui/disposers';
+import type { Disposer } from '@core/ui/disposers';
+import { mountLiveReadouts } from '@core/ui/liveReadouts';
+import { betzShare, powerCoefficient, windPowerKw } from '../model';
+import { liveReading } from '../state';
+import type { LiveReading, WindFarmStore } from '../state';
+import { formatDegrees, formatOperatingState, formatPercent, formatTurbineMw } from './format';
+import { mountPowerCurve } from './powerCurveView';
+import { mountWindOverride } from './mountWindOverride';
+
+const CURVE_WIND_INPUT = 'curve-wind';
+
+function whenProducing(share: (wind: number) => number) {
+ return ({ wind, operating }: LiveReading): number => (operating.producing ? share(wind) : 0);
+}
+
+const coefficientOf = whenProducing(powerCoefficient);
+const betzShareOf = whenProducing(betzShare);
+
+export function mountCurveReadouts(root: Document, store: WindFarmStore): Disposer {
+ return disposeAll([
+ mountWindOverride(root, store, CURVE_WIND_INPUT),
+ mountLiveReadouts(root, store, {
+ operatingState: (state) => formatOperatingState(liveReading(state).operating.state),
+ curvePower: (state) => formatTurbineMw(liveReading(state).heroKw),
+ windPower: (state) => formatTurbineMw(windPowerKw(liveReading(state).wind)),
+ powerCoefficient: (state) => formatPercent(coefficientOf(liveReading(state))),
+ betzShare: (state) => formatPercent(betzShareOf(liveReading(state))),
+ curvePitch: (state) => formatDegrees(liveReading(state).operating.pitchDeg),
+ }),
+ mountPowerCurve(root, store),
+ ]);
+}
diff --git a/explainers/wind-farm/src/ui/mountFarmReadouts.ts b/explainers/wind-farm/src/ui/mountFarmReadouts.ts
new file mode 100644
index 00000000..872c9cc7
--- /dev/null
+++ b/explainers/wind-farm/src/ui/mountFarmReadouts.ts
@@ -0,0 +1,22 @@
+import type { Disposer } from '@core/ui/disposers';
+import { mountLiveReadouts } from '@core/ui/liveReadouts';
+import { ACROSS_SPACING_D, TURBINE_COUNT, builtHectares, farmRatedMw, projectKm2 } from '../model';
+import type { WindFarmStore } from '../state';
+import {
+ formatCount,
+ formatHectares,
+ formatRatedMw,
+ formatSpacing,
+ formatSquareKm,
+} from './format';
+
+export function mountFarmReadouts(root: Document, store: WindFarmStore): Disposer {
+ return mountLiveReadouts(root, store, {
+ turbineCount: () => formatCount(TURBINE_COUNT),
+ farmRated: () => formatRatedMw(farmRatedMw()),
+ rowSpacing: (state) => formatSpacing(state.spacing),
+ acrossSpacing: () => formatSpacing(ACROSS_SPACING_D),
+ builtLand: () => formatHectares(builtHectares()),
+ projectArea: () => formatSquareKm(projectKm2()),
+ });
+}
diff --git a/explainers/wind-farm/src/ui/mountGridReadouts.ts b/explainers/wind-farm/src/ui/mountGridReadouts.ts
new file mode 100644
index 00000000..a9ede3b6
--- /dev/null
+++ b/explainers/wind-farm/src/ui/mountGridReadouts.ts
@@ -0,0 +1,36 @@
+import type { Disposer } from '@core/ui/disposers';
+import { mountLiveReadouts } from '@core/ui/liveReadouts';
+import {
+ ANNUAL_MWH_PER_TURBINE,
+ CARBON_G_PER_KWH,
+ PAYBACK_MONTHS,
+ annualHomesPerTurbine,
+ dayCapacityFactor,
+ energyTodayMwh,
+ homesNow,
+} from '../model';
+import { liveReading } from '../state';
+import type { WindFarmStore } from '../state';
+import {
+ formatFarmMw,
+ formatGramsPerKwh,
+ formatHomes,
+ formatMonths,
+ formatMwh,
+ formatPercent,
+ formatVoltages,
+} from './format';
+
+export function mountGridReadouts(root: Document, store: WindFarmStore): Disposer {
+ return mountLiveReadouts(root, store, {
+ farmOutput: (state) => formatFarmMw(liveReading(state).farmKw),
+ energyToday: (state) => formatMwh(energyTodayMwh(state.phase, state.siteWind, state.spacing)),
+ homes: (state) => formatHomes(homesNow(liveReading(state).farmKw)),
+ capacityFactor: (state) => formatPercent(dayCapacityFactor(state.siteWind, state.spacing)),
+ voltages: () => formatVoltages(),
+ annualEnergy: () => formatMwh(ANNUAL_MWH_PER_TURBINE),
+ annualHomes: () => formatHomes(annualHomesPerTurbine()),
+ carbon: () => formatGramsPerKwh(CARBON_G_PER_KWH),
+ payback: () => formatMonths(PAYBACK_MONTHS),
+ });
+}
diff --git a/explainers/wind-farm/src/ui/mountNacelleReadouts.ts b/explainers/wind-farm/src/ui/mountNacelleReadouts.ts
new file mode 100644
index 00000000..53400807
--- /dev/null
+++ b/explainers/wind-farm/src/ui/mountNacelleReadouts.ts
@@ -0,0 +1,24 @@
+import type { Disposer } from '@core/ui/disposers';
+import { mountLiveReadouts } from '@core/ui/liveReadouts';
+import { GEAR_RATIO, GENERATOR_VOLTS, generatorRpm } from '../model';
+import { liveReading } from '../state';
+import type { WindFarmStore } from '../state';
+import {
+ formatBrake,
+ formatDegrees,
+ formatGearRatio,
+ formatGeneratorRpm,
+ formatRpm,
+ formatVolts,
+} from './format';
+
+export function mountNacelleReadouts(root: Document, store: WindFarmStore): Disposer {
+ return mountLiveReadouts(root, store, {
+ shaftRpm: (state) => formatRpm(liveReading(state).operating.rpm),
+ generatorRpm: (state) => formatGeneratorRpm(generatorRpm(liveReading(state).operating.rpm)),
+ gearRatio: () => formatGearRatio(GEAR_RATIO),
+ pitchAngle: (state) => formatDegrees(liveReading(state).operating.pitchDeg),
+ brake: (state) => formatBrake(liveReading(state).operating.braked),
+ generatorVolts: () => formatVolts(GENERATOR_VOLTS),
+ });
+}
diff --git a/explainers/wind-farm/src/ui/mountTowerReadouts.ts b/explainers/wind-farm/src/ui/mountTowerReadouts.ts
new file mode 100644
index 00000000..dfffeafe
--- /dev/null
+++ b/explainers/wind-farm/src/ui/mountTowerReadouts.ts
@@ -0,0 +1,29 @@
+import { disposeAll } from '@core/ui/disposers';
+import type { Disposer } from '@core/ui/disposers';
+import { mountLiveReadouts } from '@core/ui/liveReadouts';
+import { BLADE_LENGTH_M, TIP_HEIGHT_M } from '../model';
+import { liveReading } from '../state';
+import type { WindFarmStore, WindFarmStoreState } from '../state';
+import { formatMetres, formatRpm, formatSweptArea, formatTipSpeed, formatTurnTime } from './format';
+import { mountWindOverride } from './mountWindOverride';
+
+const TOWER_WIND_INPUT = 'tower-wind';
+const BLADE_DIGITS = 1;
+
+function rpmOf(state: WindFarmStoreState): number {
+ return liveReading(state).operating.rpm;
+}
+
+export function mountTowerReadouts(root: Document, store: WindFarmStore): Disposer {
+ return disposeAll([
+ mountWindOverride(root, store, TOWER_WIND_INPUT),
+ mountLiveReadouts(root, store, {
+ rotorRpm: (state) => formatRpm(rpmOf(state)),
+ turnTime: (state) => formatTurnTime(rpmOf(state)),
+ tipSpeed: (state) => formatTipSpeed(rpmOf(state)),
+ tipHeight: () => formatMetres(TIP_HEIGHT_M),
+ bladeLength: () => formatMetres(BLADE_LENGTH_M, BLADE_DIGITS),
+ sweptArea: () => formatSweptArea(),
+ }),
+ ]);
+}
diff --git a/explainers/wind-farm/src/ui/mountUi.dom.test.ts b/explainers/wind-farm/src/ui/mountUi.dom.test.ts
new file mode 100644
index 00000000..90507987
--- /dev/null
+++ b/explainers/wind-farm/src/ui/mountUi.dom.test.ts
@@ -0,0 +1,108 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { mountActions } from '@core/ui/actions';
+import { MAX_RPM, RATED_WIND_MS, WIND_PRESETS } from '../model';
+import { createWindFarmStore } from '../state';
+import type { WindFarmStore } from '../state';
+import { CHAPTER_ACTIONS } from './actions';
+import { CHAPTER_FIXTURE, TEST_LOCALE, fill, initTestLocale, isFilled } from './testing';
+import { mountWindFarmUi } from '.';
+
+const { units, chapters, readouts } = TEST_LOCALE;
+const NOON = 720;
+
+function readout(id: string): string | null | undefined {
+ return document.querySelector(`[data-readout="${id}"]`)?.textContent;
+}
+
+function click(action: string, value: string): void {
+ document.querySelector(`[data-action="${action}"][data-value="${value}"]`)?.click();
+}
+
+function windInput(id: string): HTMLInputElement {
+ const found = document.querySelector(`#${id}`);
+ if (!found) throw new Error(`No ${id} control`);
+ return found;
+}
+
+function slide(id: string, value: number): void {
+ const input = windInput(id);
+ input.value = String(value);
+ input.dispatchEvent(new Event('input'));
+}
+
+function output(id: string): string | null | undefined {
+ return document.querySelector(`output[for="${id}"]`)?.textContent;
+}
+
+describe('chapter widgets', () => {
+ let store: WindFarmStore;
+ let dispose: () => void;
+
+ beforeAll(() => initTestLocale());
+
+ beforeEach(() => {
+ document.body.innerHTML = CHAPTER_FIXTURE;
+ store = createWindFarmStore({ playing: false, phase: NOON });
+ dispose = mountWindFarmUi(document, store);
+ mountActions(document, store, CHAPTER_ACTIONS);
+ });
+
+ afterEach(() => dispose());
+
+ it('fills every readout with a finished text', () => {
+ const texts = [...document.querySelectorAll('[data-readout]')].map((node) => node.textContent);
+ expect(texts.length).toBeGreaterThan(0);
+ texts.forEach((text) => expect(isFilled(text), text ?? '').toBe(true));
+ });
+
+ it('pins the wind from either slider and shows it in both', () => {
+ slide('tower-wind', RATED_WIND_MS);
+ expect(store.getState().windOverride).toBe(RATED_WIND_MS);
+ expect(output('tower-wind')).toBe(fill(units.metresPerSecond, { value: '12.0' }));
+ expect(output('curve-wind')).toBe(fill(units.metresPerSecond, { value: '12.0' }));
+ expect(windInput('curve-wind').value).toBe(String(RATED_WIND_MS));
+ expect(readout('rotorRpm')).toBe(fill(units.rpm, { value: '10.4' }));
+ expect(readout('generatorRpm')).toBe(fill(units.rpm, { value: '1,485' }));
+ expect(readout('curvePower')).toBe(fill(units.mw, { value: '4.20' }));
+ expect(readout('operatingState')).toBe(readouts.state.full);
+ });
+
+ it('pins the wind from the power curve slider too', () => {
+ slide('curve-wind', WIND_PRESETS.cutIn);
+ expect(store.getState().windOverride).toBe(WIND_PRESETS.cutIn);
+ expect(output('curve-wind')).toBe(fill(units.metresPerSecond, { value: '3.0' }));
+ expect(output('tower-wind')).toBe(output('curve-wind'));
+ expect(readout('operatingState')).toBe(readouts.state.partial);
+ });
+
+ it('parks the rotor on the cut-out chip and hands the wind back to the day', () => {
+ click('windAt', 'cutOut');
+ expect(store.getState().windOverride).toBe(WIND_PRESETS.cutOut);
+ expect(readout('operatingState')).toBe(readouts.state.parked);
+ expect(readout('turnTime')).toBe(chapters.tower.stopped);
+ expect(readout('curvePower')).toBe(fill(units.mw, { value: '0.00' }));
+ click('windAt', 'day');
+ expect(store.getState().windOverride).toBeNull();
+ expect(output('tower-wind')).not.toBe(fill(units.metresPerSecond, { value: '25.0' }));
+ });
+
+ it('follows the spacing, the site and the nacelle chips', () => {
+ click('spacing', '5');
+ expect(readout('wakeSpacing')).toBe(
+ fill(chapters.farm.spacingValue, { metres: '750', diameters: '5' }),
+ );
+ expect(readout('rowSpacing')).toBe(readout('wakeSpacing'));
+ const typicalCapacity = readout('capacityFactor');
+ click('siteWind', 'windy');
+ expect(store.getState().siteWind).toBe('windy');
+ expect(readout('capacityFactor')).not.toBe(typicalCapacity);
+ click('nacelle', 'open');
+ expect(store.getState().view.cutaway).toBe(true);
+ });
+
+ it('shows the turning speed at rated wind across the tower and the nacelle', () => {
+ click('windAt', 'rated');
+ expect(readout('shaftRpm')).toBe(readout('rotorRpm'));
+ expect(readout('shaftRpm')).toBe(fill(units.rpm, { value: String(MAX_RPM) }));
+ });
+});
diff --git a/explainers/wind-farm/src/ui/mountWakesReadouts.ts b/explainers/wind-farm/src/ui/mountWakesReadouts.ts
new file mode 100644
index 00000000..31ac21b8
--- /dev/null
+++ b/explainers/wind-farm/src/ui/mountWakesReadouts.ts
@@ -0,0 +1,31 @@
+import type { Disposer } from '@core/ui/disposers';
+import { mountLiveReadouts } from '@core/ui/liveReadouts';
+import {
+ HUB_HEIGHT_M,
+ ROTOR_RADIUS_M,
+ TIP_HEIGHT_M,
+ centrelineDeficit,
+ wakeLoss,
+ windAtHeight,
+} from '../model';
+import { liveReading } from '../state';
+import type { WindFarmStore, WindFarmStoreState } from '../state';
+import { formatLossPercent, formatPercent, formatSpacing, formatWind } from './format';
+
+const BOTTOM_TIP_HEIGHT_M = HUB_HEIGHT_M - ROTOR_RADIUS_M;
+
+function farmWakeLoss(state: WindFarmStoreState): number {
+ const { wind, fromDeg, operating } = liveReading(state);
+ return operating.producing ? wakeLoss(wind, fromDeg, state.spacing) : 0;
+}
+
+export function mountWakesReadouts(root: Document, store: WindFarmStore): Disposer {
+ return mountLiveReadouts(root, store, {
+ wakeSpacing: (state) => formatSpacing(state.spacing),
+ wakeDeficit: (state) =>
+ formatPercent(centrelineDeficit(liveReading(state).thrust, state.spacing)),
+ wakeLoss: (state) => formatLossPercent(farmWakeLoss(state)),
+ windTop: (state) => formatWind(windAtHeight(liveReading(state).wind, TIP_HEIGHT_M)),
+ windBottom: (state) => formatWind(windAtHeight(liveReading(state).wind, BOTTOM_TIP_HEIGHT_M)),
+ });
+}
diff --git a/explainers/wind-farm/src/ui/mountWindOverride.dom.test.ts b/explainers/wind-farm/src/ui/mountWindOverride.dom.test.ts
new file mode 100644
index 00000000..21e1b24e
--- /dev/null
+++ b/explainers/wind-farm/src/ui/mountWindOverride.dom.test.ts
@@ -0,0 +1,42 @@
+import { beforeAll, describe, expect, it } from 'vitest';
+import { RATED_WIND_MS } from '../model';
+import { createWindFarmStore } from '../state';
+import { mountWindOverride } from './mountWindOverride';
+import { initTestLocale } from './testing';
+
+const FIRST_ID = 'tower-wind';
+const SECOND_ID = 'curve-wind';
+
+function slider(id: string): string {
+ return ``;
+}
+
+function widget(id: string, inner: string): string {
+ return `
${inner}
`;
+}
+
+describe('wind override slider', () => {
+ beforeAll(() => initTestLocale());
+
+ it('drives its own widget when the markup wraps the slider', () => {
+ document.body.innerHTML =
+ widget(FIRST_ID, slider(FIRST_ID)) + widget(SECOND_ID, `