diff --git a/explainers/naval-drone/chapters.html b/explainers/naval-drone/chapters.html new file mode 100644 index 00000000..e0f54928 --- /dev/null +++ b/explainers/naval-drone/chapters.html @@ -0,0 +1,600 @@ +
+

01

+

A speedboat with no one aboard

+

+ This is a Magura V5, a Ukrainian naval drone: a fast boat with nobody on board. + The state firm SpetsTechnoExport builds it for GUR, Ukraine's defence intelligence; it was first + shown in July 2023. It is 5.5 m long, sits just 0.5 m above the water and weighs under one + tonne. A pilot far away steers it by satellite. +

+

+ Outside you see a low grey hull of composite, fibre cloth set in resin. On the + raised fairing at the back sit two Starlink antennas and a + backup satellite antenna; toward the bow, a low dome with a + thermal camera and a fixed bow camera. The waterjet at the + stern drives it. Inside are a payload bay for up to 320 kg, fuel tanks, and the engine in a + foam-bedded tub with the electronics above. Switch on the cutaway (C) to see them, or wait for + the jet chapter. +

+

+ The model follows one run. The boat leaves a slipway, rises onto the plane and + cruises at 22 knots, about 41 km/h; a knot is one nautical mile an hour. It then sprints at 42 + knots, about 78 km/h, with two more boats, until its bow reaches a grey ship at anchor. +

+

+ At normal speed the boat moves as fast as a real one, but the trip is cut to two minutes: a + cruise of about 20 hours over its full 450 nautical miles, about 800 km, takes 42 seconds here, + and the sprint is shorter too. The ship lies 1.5 km away, not hundreds of kilometres. The clock + counts model time; drag the timeline to jump. +

+

+ + + + + +

+
+
+
Hull
+
Floating
+
+
+
Distance run
+
0 m
+
+
+
Run done
+
0 %
+
+
+
+ +
+

02

+

It stops floating and starts skimming

+

+ At rest, the water the boat pushes aside holds it up: buoyancy. As it speeds + up, water striking the slanted bottom pushes it up harder and harder. Once that push carries + most of the weight, the hull rises and skims: planing. The back of the bottom + always stays in the water. +

+

+ Every boat makes a bow wave. At about 5.7 knots it is as long as this 5.5 m hull: the + hull speed, a rule of thumb for boats that only float, not a limit; at 42 knots + the Magura goes about 7 times faster. First it climbs the resistance hump, + where it makes big waves and the drag, the water holding it back, climbs + steeply. The sharp bottom edges, the chines, add drag here and pay off only + once it planes. +

+

+ Move the slider and watch the bow. It rises most near the hump, then settles: this angle is the + trim, with least drag at about 3 to 4 degrees. Planing starts at about 15 to 18 + knots; the model switches at 16. The wet bottom, 5.1 m at rest in the model, shrinks from about + 4.5 m at 15 knots to 1.5 to 2 m at 42. With no hump data published, the 6 degree peak and the + weight split are the model's own. +

+

+ The bottom is a deep V that softens the blows of waves; the model uses 20 + degrees at the stern, sharper forward, as the real angle is not published. Water breaks cleanly + off the square stern's sharp lower edge. A thin spray rail on each side, a + model guess, throws spray outward. Too much trim makes a planing boat bounce: + porpoising. +

+
+
+ + 11 kn, 20 km/h +
+ +
+
+
Hull
+
Climbing the hump
+
+
+
Bow angle (trim)
+
6.0°
+
+
+
Weight carried
+
36 % by speed, 64 % by floating
+
+
+
Wet bottom length
+
4.4 m, against 5.1 m at rest
+
+
+
Against hull speed
+
1.9 times the hull speed of 5.7 kn
+
+
+
+

+ + + + + +

+
+ +
+

03

+

A pump instead of a propeller

+

+ With the hull cut open, follow the power: the fuel tanks feed the + engine, whose drive shaft turns the pump at the stern, the + waterjet. Water enters through a grate in the bottom. A spinning + impeller, a fan-like rotor, speeds it up; fixed vanes, the + stator, take out the swirl; a nozzle shoots it out behind. +

+

+ The push comes from speeding up the water in the pump: thrust is the mass of + water per second times how much faster the jet leaves than the boat moves. Even a perfect pump + turns only 2 / (1 + jet speed / boat speed) of its work into motion: 80 % with the jet at 1.5 + times boat speed, far less at low speed. +

+

+ Above about 25 to 30 knots a jet matches or beats a propeller, and with nothing under the hull + the boat can run in shallow water. The grate mostly clears itself, but weed and rope can still + block it. The first boat of this family, in May 2022, had an outboard motor; later 2022 boats, + like one found near Sevastopol, used a Rotax jet-ski engine and a Sea-Doo pump. The Magura's + engine is not disclosed, so the model assumes a jet-ski-type petrol engine turning the pump + directly; the pump moves an estimated 210 litres a second at 42 knots. +

+

+ There is no rudder. The steering nozzle swings the stream up to 27 degrees each + way: swing it right and the bow turns right. To stop or back up, a + reverse bucket drops over the nozzle and sends the stream forward and down. The + slider sets the throttle, taken in the model as the pump's flow. Press play to rejoin the run. +

+
+
+ + 69 % +
+ +
+
+
Boat speed
+
22 kn, 41 km/h
+
+
+
Water through the pump
+
148 kg a second, about 144 litres
+
+
+
Jet speed
+
22 m/s, 81 km/h
+
+
+
Thrust
+
1.65 kN
+
+
+
Ideal efficiency
+
67 %
+
+
+
+

+ + + + +

+
+
+
Jet push
+
Straight back: holds course
+
+
+
+ +
+

04

+ +

+ A Magura captured in Crimea in November 2023 carried, by Russian accounts, two + Starlink terminals, a Kymeta backup satellite antenna, GPS, a Linux computer + and a two-SIM router for use near shore. The maker also claims mesh radio, autonomous control + and up to three video streams. +

+

+ The pilot sits at a console in a hard case. Long trips can be pre-programmed, but a pilot + watches throughout and often steers the last stretch by hand: the boat is not fully autonomous. + What a real boat does when the link drops is not published; the model keeps it on course. +

+

+ Starlink's typical delay is about 25 to 35 ms on land and can pass 100 ms at sea. The upload, 10 + to 30 megabits a second, about what three video streams need, sets how sharp the picture is. How + late it arrives is unpublished. At 42 knots a quarter second is 5.4 m, about a boat length; the + ghost boat shows where the pilot sees it. +

+

+ GPS jamming and faking over the Black Sea has been documented since mid-2024, likely why the + maker also lists inertial and visual navigation. In September 2022, by Elon Musk's account and a + corrected 2023 book about him, Ukraine expected Starlink coverage near Crimea that had never + been turned on; the book had first said it was switched off. The satellites here are drawn low, + close and far bigger than real. +

+

+ + + +

+
+
+ + 250 ms +
+ +
+
+
Where the pilot sees it
+
2.8 m behind the boat at 22 kn
+
+
+
At top speed
+
5.4 m at 42 kn, 1.0 boat lengths
+
+
+
+
+
+
Link
+
Starlink, two terminals
+
+
+
What the boat does
+
Pilot steers by the video
+
+
+

How a command reaches the boat

+
    +
  1. Pilot: moves a lever or joystick.
  2. +
  3. + Ground station: sends the command up by radio. +
  4. +
  5. + Satellite: passes it down to the boat. +
  6. +
  7. + Boat: the computer moves the throttle or nozzle. +
  8. +
  9. + Video: comes back the same way, a little late. +
  10. +
+
+ +
+

05

+

+ Low enough to hide behind the curve of the sea +

+

+ The sea curves away, so from h metres up the horizon lies about 3.86 × √h km off. The boat's + camera, about 0.7 m up, sees only about 3 km. The 3D sea is flat, as over 1.5 km the real curve + drops less than 0.2 m; the drawing below exaggerates it. +

+

+ A ship's radar meets the same curve. Radio bends slightly, so its + line of sight to a 0.5 m boat is about 4.12 × (√h1 + √h2) km, heights in + metres: about 21 km for a radar 20 m up. That is only the farthest it could see the boat, and + even then the ship would get at most about 16 minutes of warning at 42 knots. +

+

+ Real detection comes later. A small boat's radar cross-section, how big it + looks to radar, is about 0.5 to 5 m²; none is published for the Magura. A coastal radar standard + expects a 1 m² target 1 m up to be found at about 9 km, by an advanced radar, up to sea state 4 + (waves of 1.25 to 2.5 m): about 7 minutes of warning at 42 knots. Small boats may hide behind + waves up to half the time, and sea clutter, the waves' own echoes, can drown + theirs. +

+

+ Operators say the boats are hard to spot: a commander of GUR's Group 13 said radar struggles + with the plastic hull and the boat gives off little heat. These are claims; no measurement is + published. A Russian teardown reported that the foam-bedded engine tub reduces heating of the + outer skin. The boats are grey, and the best-known attacks came at night or dawn. Tap the chips + to change the sea state. +

+
+ +
+ Side view of the radar, the sea and the boat; curve and heights exaggerated. +
+
+
+
+ + 20 m +
+ +
+
+
Radar line of sight
+
21.3 km
+
+
+
Ship's warning at 42 kn
+
16.5 min
+
+
+
+

+ + + + +

+
+
+
Camera horizon
+
3.2 km from a lens 0.7 m up
+
+
+
Expected detection
+
About 9.3 km, 7.1 min of warning at 42 kn
+
+
+
Wave height
+
0.5 to 1.25 m, sea state 3
+
+
+
Hidden behind waves
+
Up to half the time: waves as tall as its deck
+
+
+
+ +
+

06

+

Cheap, small and changing fast

+

+ A Magura V5 costs about a quarter of a million dollars. Sea Baby, a different boat built by the + SBU, Ukraine's security service, costs about USD 221,000. GUR values the patrol ship Sergey + Kotov, sunk in March 2024 after a Magura attack, at about USD 65 million. +

+

+ The boats rarely work alone: many reported attacks used groups of about five to ten, each boat + with a role, the developers say. Ships reported sunk took several hits. The two companions in + the run, and their formation, are the model's choice. +

+

+ The design keeps changing. In December 2024 GUR fitted V5 boats with two R-73 air-to-air + missiles, renamed Sea Dragon; the chips show this fit, which changes only the deck, not the run. + In 2025 GUR showed the larger Magura V7, 7.2 m long, with up to 650 kg of payload. +

+

+ The record below keeps to each side's words, and many claims are disputed. The UK Ministry of + Defence counts at least 26 Russian navy ships damaged or destroyed from February 2022 to June + 2024 by all weapons, about a third of the Black Sea Fleet. +

+

+ + +

+
+
+
On board
+
A payload of up to 320 kg in the bow
+
+
+
Cost per boat
+
About USD 250,000 to 273,000
+
+
+
Patrol ship, GUR's value
+
About USD 65 million
+
+
+

Public record, as reported

+
    +
  1. + 29 Oct 2022, Sevastopol: sea and air drones hit the frigate Admiral Makarov + and a minesweeper; Russia admitted minor damage. +
  2. +
  3. + 31 Jan to 1 Feb 2024, Lake Donuzlav: Magura V5 boats sank the corvette + Ivanovets; GUR claims six hits, video shows about three. +
  4. +
  5. + 4 to 5 Mar 2024, Kerch Strait: Magura V5 boats sank the patrol ship Sergey + Kotov; Ukraine said five boats took part. +
  6. +
  7. + 31 Dec 2024, Cape Tarkhankut: GUR says Magura V5 boats downed Mi-8 + helicopters with R-73 missiles, a first for a sea drone; it first claimed one, later two. +
  8. +
  9. + 2 May 2025, near Novorossiysk: GUR says Magura V7 boats downed two Su-30SM + jets with Sidewinders; Russia has not confirmed it. +
  10. +
+
diff --git a/explainers/naval-drone/explainer.json b/explainers/naval-drone/explainer.json new file mode 100644 index 00000000..8d2355ed --- /dev/null +++ b/explainers/naval-drone/explainer.json @@ -0,0 +1,12 @@ +{ + "slug": "naval-drone", + "tags": ["vehicles", "physics", "weapons"], + "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 grey uncrewed boat skimming over the sea at dawn with spray flying from its chines, beside the title How a naval drone works" + } +} diff --git a/explainers/naval-drone/locales/de.json b/explainers/naval-drone/locales/de.json new file mode 100644 index 00000000..156866cc --- /dev/null +++ b/explainers/naval-drone/locales/de.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "So funktioniert eine Seedrohne", + "eyebrow": "Seedrohne", + "tagline": "Ein Boot ohne Besatzung: Es gleitet auf seinem Rumpf, fährt mit einer Wasserpumpe und wird per Satellit gesteuert.", + "description": "Seedrohne Magura V5 in 3D: wie ihr Rumpf gleitet, wie ein Wasserstrahl sie antreibt, ihr Satellitenfunk und warum ein niedriges Boot schwer zu sehen ist.", + "summary": "Der Gleitrumpf, der Wasserstrahl, die Satellitenverbindung und ihre Verzögerung, die Krümmung des Meeres und eine Bauweise im Wandel.", + "socialAlt": "Ein graues, unbemanntes Boot gleitet im Morgengrauen über das Meer, an den scharfen Kanten seines Rumpfs spritzt Gischt auf. Daneben steht der Titel „So funktioniert eine Seedrohne“." + }, + "stage": { + "label": "Interaktives Modell einer Seedrohne", + "gauge": "Live-Fahrtdaten" + }, + "controls": { + "label": "Steuerung des Modells", + "views": { + "cutaway": { + "name": "Schnittansicht", + "short": "Schnitt", + "hint": "Schnittansicht (C)" + }, + "flow": { + "name": "Wasserströmung", + "short": "Strömung", + "hint": "Wasserströmung (W)" + }, + "links": { + "name": "Funkverbindungen", + "short": "Funk", + "hint": "Funkverbindungen (K)" + }, + "labels": { + "name": "Beschriftungen", + "short": "Namen", + "hint": "Beschriftungen (L)" + } + }, + "fit": { + "label": "Deckausrüstung" + }, + "fitOptions": { + "standard": "Standard", + "missile": "Raketenschienen" + }, + "momentOptions": { + "hullSpeed": "Rumpfgeschwindigkeit", + "humpPeak": "Buckelspitze", + "onPlane": "In Gleitfahrt", + "cruiseSpeed": "Reisefahrt", + "throttleUp": "Sprint beginnt", + "topSpeed": "Höchstgeschwindigkeit", + "alongside": "Am Schiff" + }, + "speedMarkOptions": { + "hullSpeed": "Rumpfgeschwindigkeit", + "hump": "Buckel", + "planing": "Gleitfahrt", + "cruise": "Reisefahrt", + "top": "Höchstgeschwindigkeit" + }, + "helmOptions": { + "left": "Links", + "straight": "Geradeaus", + "right": "Rechts", + "reverse": "Rückwärts" + }, + "linkOptions": { + "satellite": "Starlink", + "backup": "Reservesatellit", + "lost": "Verbindung verloren" + }, + "seaStateOptions": { + "smooth": "Glatt", + "slight": "Leicht", + "moderate": "Mäßig", + "rough": "Rau" + } + }, + "timeline": { + "label": "Fahrtzeit", + "phases": "Zu einem Abschnitt der Fahrt springen", + "speed": "Wiedergabetempo", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "gegenüber normalem Tempo", + "phase": { + "launch": "Start", + "hump": "Buckel", + "cruise": "Reisefahrt", + "sprint": "Sprint", + "arrival": "Ankunft" + }, + "during": { + "launch": "das Boot tuckert von der Slipanlage los", + "hump": "das Boot beschleunigt und kommt ins Gleiten", + "cruise": "das Boot fährt stundenlang mit 22 Knoten, hier stark gekürzt", + "sprint": "das Boot sprintet mit 42 Knoten, zwei weitere kommen dazu", + "arrival": "das Boot fährt die letzten 170 m bis zum Schiff" + }, + "jump": { + "launch": "Zum Start springen", + "hump": "Zum Buckel springen", + "cruise": "Zur Reisefahrt springen", + "sprint": "Zum Sprint springen", + "arrival": "Zur Ankunft springen" + } + }, + "readouts": { + "clock": "Fahrtuhr", + "speed": "Geschwindigkeit", + "mode": "Rumpf", + "distance": "Bis zum Schiff" + }, + "mode": { + "floating": "Schwimmt", + "hump": "Klettert über den Buckel", + "planing": "Gleitet" + }, + "units": { + "speed": "{{kn}} kn, {{kmh}} km/h", + "m": "{{value}} m", + "km": "{{value}} km", + "percent": "{{value}} %", + "degrees": "{{value}}°", + "kilonewtons": "{{value}} kN", + "mps": "{{ms}} m/s, {{kmh}} km/h", + "ms": "{{value}} ms", + "minutes": "{{value}} min" + }, + "parts": { + "hull": "Rumpf aus Verbundwerkstoff", + "chines": "Kimmkanten", + "sprayRails": "Spritzleisten", + "payloadBay": "Nutzlastraum", + "fuelTanks": "Kraftstofftanks", + "engine": "Motor", + "electronicsBay": "Elektronik", + "starlinkPanels": "Starlink-Antennen", + "backupPanel": "Reserveantenne", + "cameraDome": "Kamerakuppel", + "bowCamera": "Bugkamera", + "intake": "Einlass mit Gitter", + "duct": "Einlasskanal", + "driveShaft": "Antriebswelle", + "impeller": "Impeller", + "waterjet": "Wasserstrahlantrieb", + "stator": "Statorschaufeln", + "nozzle": "Düse", + "steeringNozzle": "Steuerdüse", + "reverseBucket": "Umkehrklappe", + "missileRails": "Raketenschienen", + "jetStream": "Wasserstrahl", + "bowWave": "Bugwelle", + "spray": "Gischt", + "wake": "Kielwasser", + "wettedLength": "Nasse Bodenlänge", + "videoGhost": "Verzögerte Sicht des Piloten", + "satLink": "Starlink-Verbindung", + "backupLink": "Reserveverbindung", + "satellite": "Starlink-Satellit", + "backupSatellite": "Reservesatellit", + "groundStation": "Bodenstation", + "ship": "Schiff vor Anker", + "shipRadar": "Radar des Schiffs", + "companions": "Begleitboote" + }, + "hull": { + "lift": "{{dynamic}} % durch die Fahrt, {{buoyant}} % durch Auftrieb", + "wetted": "{{m}} m, gegenüber {{rest}} m im Stand", + "ratio": "Das {{times}}-Fache der Rumpfgeschwindigkeit von {{hullSpeed}} kn", + "ratioStill": "Stillstand, Rumpfgeschwindigkeit {{hullSpeed}} kn" + }, + "jet": { + "flow": "{{kg}} kg pro Sekunde, etwa {{litres}} Liter", + "backing": "Bremst, Klappe unten", + "astern": "Von der Klappe nach vorn gelenkt", + "noEfficiency": "Keiner ohne Vorwärtsfahrt", + "push": { + "left": "Düse {{angle}}° links: Bug dreht nach links", + "right": "Düse {{angle}}° rechts: Bug dreht nach rechts", + "straight": "Gerade nach hinten: hält den Kurs", + "reverse": "Klappe unten: Strahl nach vorn und unten" + } + }, + "link": { + "now": "{{m}} m hinter dem Boot bei {{kn}} kn", + "nowLost": "Kein Bild, solange die Verbindung weg ist", + "top": "{{m}} m bei 42 kn, {{lengths}} Bootslängen", + "carrier": { + "satellite": "Starlink, zwei Terminals", + "backup": "Reserve-Satellitenantenne", + "lost": "Keine" + }, + "boat": { + "satellite": "Pilot steuert nach dem Video", + "backup": "Pilot steuert über die Reserveverbindung", + "lost": "Hält im Modell den eingestellten Kurs, echtes Verhalten nicht veröffentlicht" + } + }, + "horizon": { + "camera": "{{km}} km aus {{m}} m Höhe", + "detect": "Etwa {{km}} km, {{min}} min Vorwarnzeit bei 42 kn", + "detectBeyond": "Nicht abgedeckt: Die Norm reicht nur bis Seegang 4", + "waves": { + "smooth": "{{from}} bis {{to}} m, Seegang 2", + "slight": "{{from}} bis {{to}} m, Seegang 3", + "moderate": "{{from}} bis {{to}} m, Seegang 4", + "rough": "{{from}} bis {{to}} m, Seegang 5" + }, + "hidden": { + "smooth": "Kein Wert für so glatte See: Die Wellen bleiben unter dem 0,5 m hohen Deck", + "slight": "Bis zur Hälfte der Zeit: Wellen so hoch wie das Deck", + "moderate": "Bis zur Hälfte der Zeit: Wellen über dem Deck", + "rough": "Bis zur Hälfte der Zeit: Wellen weit über dem Deck" + }, + "canvas": { + "radar": "Radar des Schiffs", + "boat": "Boot, 0,5 m hoch", + "lineOfSight": "Sichtlinie", + "detection": "Erwartete Entdeckung", + "km": "{{value}} km", + "note": "Krümmung des Meeres übertrieben" + } + }, + "fleet": { + "carries": { + "standard": "Bis zu {{kg}} kg Nutzlast im Bug", + "missile": "Zwei R-73-Raketen auf Schienen, im Modell bleibt der Bugraum" + }, + "cost": "Etwa {{from}} bis {{to}} USD", + "ship": "Etwa {{value}} Millionen USD" + }, + "sections": { + "overview": { + "title": "Ein Speedboot ohne Menschen an Bord", + "p1": "Das ist eine Magura V5, eine ukrainische Seedrohne: ein schnelles Boot ohne Menschen an Bord. Die staatliche Firma SpetsTechnoExport baut es für den GUR, den Militärgeheimdienst der Ukraine. Erstmals gezeigt wurde es im Juli 2023. Das Boot ist 5,5 m lang, ragt nur 0,5 m aus dem Wasser und wiegt unter einer Tonne. Ein Pilot steuert es aus der Ferne per Satellit.", + "p2": "Außen sehen Sie einen niedrigen grauen Rumpf aus Verbundwerkstoff, also Fasergewebe in Harz. Auf dem erhöhten Aufbau hinten sitzen zwei Starlink-Antennen und eine Reserve-Satellitenantenne. Weiter vorn gibt es eine niedrige Kuppel mit Wärmebildkamera und eine feste Bugkamera. Der Wasserstrahlantrieb am Heck treibt das Boot an. Innen liegen ein Nutzlastraum für bis zu 320 kg, die Tanks und der Motor in einer schaumgepolsterten Wanne, darüber die Elektronik. Schalten Sie die Schnittansicht (C) ein, um sie zu sehen, oder warten Sie auf das Kapitel zum Antrieb.", + "p3": "Das Modell begleitet eine Fahrt. Das Boot verlässt eine Slipanlage, kommt ins Gleiten und fährt mit 22 Knoten, etwa 41 km/h. Ein Knoten ist eine Seemeile pro Stunde. Dann sprintet es mit 42 Knoten, etwa 78 km/h. Zwei weitere Boote fahren mit, bis sein Bug ein graues Schiff vor Anker erreicht.", + "p4": "Bei normalem Tempo fährt das Boot so schnell wie ein echtes, doch die Fahrt ist auf zwei Minuten gekürzt. Eine Reisefahrt von etwa 20 Stunden über die vollen 450 Seemeilen, rund 800 km, dauert hier 42 Sekunden, und auch der Sprint ist kürzer. Das Schiff liegt 1,5 km entfernt, nicht Hunderte Kilometer. Die Uhr zählt Modellzeit. Ziehen Sie an der Zeitleiste, um zu springen.", + "mode": "Rumpf", + "covered": "Zurückgelegte Strecke", + "done": "Fortschritt der Fahrt" + }, + "hull": { + "title": "Vom Schwimmen zum Gleiten", + "p1": "Im Stand drückt das Boot Wasser zur Seite, und dieses Wasser hält es oben: der Auftrieb. Wird es schneller, drückt das Wasser immer stärker gegen den schrägen Boden und hebt das Boot an. Trägt dieser Druck den größten Teil des Gewichts, hebt sich der Rumpf und gleitet: die Gleitfahrt. Das hintere Ende des Bodens bleibt immer im Wasser.", + "p2": "Jedes Boot macht eine Bugwelle. Bei etwa 5,7 Knoten ist sie so lang wie dieser 5,5 m lange Rumpf. Das ist die Rumpfgeschwindigkeit, eine Faustregel für Boote, die nur schwimmen. Ein Tempolimit ist sie nicht: Mit 42 Knoten ist die Magura etwa 7-mal so schnell. Zuerst muss das Boot den Widerstandsbuckel überwinden. Dort macht es große Wellen, und der Widerstand, mit dem das Wasser es bremst, steigt steil an. Die scharfen Bodenkanten, die Kimmkanten, bremsen hier zusätzlich und nützen erst in Gleitfahrt.", + "p3": "Bewegen Sie den Regler und achten Sie auf den Bug. Am Buckel hebt er sich am stärksten, dann senkt er sich. Dieser Winkel heißt Trimm. Bei etwa 3 bis 4 Grad ist der Widerstand am kleinsten. Die Gleitfahrt beginnt bei etwa 15 bis 18 Knoten, das Modell schaltet bei 16 um. Die nasse Bodenlänge, im Modell 5,1 m im Stand, schrumpft von etwa 4,5 m bei 15 Knoten auf 1,5 bis 2 m bei 42. Da keine Daten zum Buckel veröffentlicht sind, sind die Spitze von 6 Grad und die Gewichtsaufteilung eigene Werte des Modells.", + "p4": "Der Boden ist ein tiefes V, das die Schläge der Wellen dämpft. Der echte Winkel ist nicht veröffentlicht: Das Modell nimmt am Heck 20 Grad an, nach vorn spitzer. An der scharfen Unterkante des geraden Hecks reißt das Wasser sauber ab. Eine schmale Spritzleiste an jeder Seite, eine Annahme des Modells, lenkt die Gischt nach außen. Zu viel Trimm lässt ein gleitendes Boot springen: das Delfinieren.", + "speedLabel": "Bootsgeschwindigkeit", + "mode": "Rumpf", + "trim": "Bugwinkel (Trimm)", + "lift": "Getragenes Gewicht", + "wetted": "Nasse Bodenlänge", + "ratio": "Gegenüber der Rumpfgeschwindigkeit" + }, + "jet": { + "title": "Eine Pumpe statt eines Propellers", + "p1": "Verfolgen Sie im offenen Rumpf den Weg der Kraft: Die Kraftstofftanks versorgen den Motor, und seine Antriebswelle dreht die Pumpe am Heck, den Wasserstrahlantrieb. Das Wasser kommt durch ein Gitter im Boden herein. Ein rotierender Impeller, ein Laufrad wie bei einem Ventilator, beschleunigt es. Feste Schaufeln, der Stator, nehmen den Drall heraus. Eine Düse schießt es nach hinten hinaus.", + "p2": "Die Kraft nach vorn entsteht, weil die Pumpe Wasser beschleunigt. Der Schub ist die Wassermasse pro Sekunde mal dem Tempo, um das der Strahl schneller ist als das Boot. Selbst eine perfekte Pumpe setzt nur 2 / (1 + Strahlgeschwindigkeit / Bootsgeschwindigkeit) ihrer Arbeit in Fahrt um: 80 %, wenn der Strahl 1,5-mal so schnell ist wie das Boot, bei langsamer Fahrt viel weniger.", + "p3": "Ab etwa 25 bis 30 Knoten ist ein Wasserstrahlantrieb einem Propeller ebenbürtig oder überlegen. Da unter dem Rumpf nichts hängt, kann das Boot auch in flachem Wasser fahren. Das Gitter hält sich meist selbst frei, Tang und Leinen können es aber trotzdem verstopfen. Das erste Boot dieser Reihe hatte im Mai 2022 einen Außenbordmotor. Spätere Boote von 2022, etwa eines, das bei Sewastopol gefunden wurde, nutzten einen Jetski-Motor von Rotax und eine Pumpe von Sea-Doo. Der Motor der Magura ist nicht offengelegt. Das Modell nimmt deshalb einen Benzinmotor wie im Jetski an, der die Pumpe direkt antreibt. Bei 42 Knoten bewegt die Pumpe geschätzt 210 Liter pro Sekunde.", + "p4": "Ein Ruder gibt es nicht. Die Steuerdüse schwenkt den Strahl bis zu 27 Grad zu jeder Seite. Schwenkt sie nach rechts, dreht der Bug nach rechts. Zum Bremsen oder Rückwärtsfahren senkt sich eine Umkehrklappe über die Düse und lenkt den Strahl nach vorn und unten. Mit dem Regler geben Sie Gas, im Modell ist das der Durchfluss der Pumpe. Drücken Sie auf Wiedergabe, um zur Fahrt zurückzukehren.", + "throttleLabel": "Gas", + "boat": "Bootsgeschwindigkeit", + "flow": "Wasser durch die Pumpe", + "velocity": "Strahlgeschwindigkeit", + "thrust": "Schub", + "efficiency": "Idealer Wirkungsgrad", + "push": "Strahlrichtung" + }, + "link": { + "title": "Gesteuert aus Hunderten Kilometern Entfernung", + "p1": "Im November 2023 wurde auf der Krim eine Magura erbeutet. Laut russischen Angaben hatte sie zwei Starlink-Terminals an Bord, eine Reserve-Satellitenantenne von Kymeta, GPS, einen Linux-Computer und einen Router mit zwei SIM-Karten für die Küstennähe. Laut eigenen Angaben bietet der Hersteller außerdem Mesh-Funk, autonome Steuerung und bis zu drei Videostreams.", + "p2": "Der Pilot sitzt an einem Steuerpult in einem robusten Koffer. Lange Fahrten lassen sich vorab programmieren, doch ein Pilot schaut die ganze Zeit zu und steuert das letzte Stück oft von Hand. Vollständig autonom ist das Boot also nicht. Was ein echtes Boot tut, wenn die Verbindung abreißt, ist nicht veröffentlicht. Das Modell hält es auf Kurs.", + "p3": "Die typische Verzögerung von Starlink liegt an Land bei etwa 25 bis 35 ms, auf See kann sie über 100 ms steigen. Wie scharf das Bild ist, bestimmt der Upload: 10 bis 30 Megabit pro Sekunde, etwa so viel, wie drei Videostreams brauchen. Wie spät das Bild ankommt, ist nicht veröffentlicht. Bei 42 Knoten ist eine Viertelsekunde 5,4 m, etwa eine Bootslänge. Das Geisterboot zeigt, wo der Pilot das Boot sieht.", + "p4": "Gestörte und gefälschte GPS-Signale über dem Schwarzen Meer sind seit Mitte 2024 belegt. Vermutlich nennt der Hersteller deshalb auch Trägheitsnavigation und visuelle Navigation. Laut Elon Musk und der korrigierten Fassung eines Buchs über ihn von 2023 erwartete die Ukraine im September 2022 Starlink-Abdeckung nahe der Krim, die nie eingeschaltet worden war. Im Buch hieß es zuerst, sie sei abgeschaltet worden. Die Satelliten sind hier niedrig, nah und viel größer als echte gezeichnet.", + "delayLabel": "Videoverzögerung", + "now": "Wo der Pilot das Boot sieht", + "top": "Bei Höchstgeschwindigkeit", + "carrier": "Verbindung", + "boat": "Was das Boot tut", + "path": "So erreicht ein Befehl das Boot", + "step1": "Pilot: bewegt einen Hebel oder Joystick.", + "step2": "Bodenstation: funkt den Befehl nach oben.", + "step3": "Satellit: gibt ihn nach unten ans Boot weiter.", + "step4": "Boot: Der Computer verstellt Gas oder Steuerdüse.", + "step5": "Video: kommt auf demselben Weg zurück, etwas verspätet." + }, + "horizon": { + "title": "Niedrig genug, um sich hinter der Krümmung des Meeres zu verstecken", + "p1": "Das Meer ist gekrümmt, deshalb liegt der Horizont aus h Metern Höhe etwa 3,86 × √h km entfernt. Die Bootskamera sitzt etwa 0,7 m über dem Wasser und sieht nur etwa 3 km weit. Das 3D-Meer ist flach, denn auf 1,5 km fällt die echte Krümmung um weniger als 0,2 m ab. Die Zeichnung unten übertreibt sie.", + "p2": "Das Radar eines Schiffs trifft auf dieselbe Krümmung. Funkwellen biegen sich leicht, darum reicht seine Sichtlinie zu einem 0,5 m hohen Boot etwa 4,12 × (√h1 + √h2) km weit, Höhen in Metern. Bei einem Radar in 20 m Höhe sind das etwa 21 km. Das ist nur die größte Entfernung, auf die es das Boot überhaupt sehen könnte. Selbst dann hätte das Schiff bei 42 Knoten höchstens etwa 16 Minuten Vorwarnzeit.", + "p3": "Wirklich entdeckt wird das Boot erst später. Der Radarquerschnitt eines kleinen Boots, also wie groß es für ein Radar wirkt, liegt bei etwa 0,5 bis 5 m². Für die Magura ist keiner veröffentlicht. Eine Norm für Küstenradar erwartet, dass ein fortschrittliches Radar ein Ziel von 1 m² in 1 m Höhe auf etwa 9 km findet, bis Seegang 4 (Wellen von 1,25 bis 2,5 m). Das sind bei 42 Knoten etwa 7 Minuten Vorwarnzeit. Kleine Boote können bis zur Hälfte der Zeit hinter Wellen verborgen sein, und die Seegangsechos, die Echos der Wellen selbst, können ihr Echo überdecken.", + "p4": "Wer die Boote einsetzt, sagt, sie seien schwer zu entdecken. Ein Kommandeur der Gruppe 13 des GUR sagte, Radar erfasse den Kunststoffrumpf schlecht, und das Boot gebe wenig Wärme ab. Das sind Behauptungen, Messungen sind nicht veröffentlicht. Laut einem russischen Bericht über ein zerlegtes Boot verringert die schaumgepolsterte Motorwanne die Erwärmung der Außenhaut. Die Boote sind grau, und die bekanntesten Angriffe kamen nachts oder im Morgengrauen. Tippen Sie auf die Schaltflächen, um den Seegang zu ändern.", + "caption": "Seitenansicht von Radar, Meer und Boot. Krümmung und Höhen sind übertrieben.", + "heightLabel": "Radarhöhe", + "radar": "Sichtlinie des Radars", + "minutes": "Vorwarnzeit des Schiffs bei 42 kn", + "camera": "Kamerahorizont", + "detect": "Erwartete Entdeckung", + "waves": "Wellenhöhe", + "hidden": "Hinter Wellen verborgen" + }, + "fleet": { + "title": "Günstig, klein und ständig im Wandel", + "p1": "Eine Magura V5 kostet etwa eine Viertelmillion Dollar. Sea Baby, ein anderes Boot des SBU, des ukrainischen Sicherheitsdienstes, kostet etwa 221.000 USD. Das Patrouillenschiff Sergej Kotow, das im März 2024 nach einem Magura-Angriff sank, ist laut GUR etwa 65 Millionen USD wert.", + "p2": "Die Boote sind selten allein unterwegs. Bei vielen gemeldeten Angriffen fuhren Gruppen von etwa fünf bis zehn Booten. Laut den Entwicklern hat jedes seine Aufgabe. Als versenkt gemeldete Schiffe bekamen mehrere Treffer ab. Die zwei Begleitboote in der Fahrt und ihre Formation hat das Modell gewählt.", + "p3": "Die Bauweise ändert sich ständig. Im Dezember 2024 rüstete der GUR V5-Boote mit zwei Luft-Luft-Raketen vom Typ R-73 aus, die nun Sea Dragon heißen. Die Schaltflächen zeigen diese Ausrüstung, die nur das Deck ändert, nicht die Fahrt. 2025 stellte der GUR die größere Magura V7 vor, 7,2 m lang, mit bis zu 650 kg Nutzlast.", + "p4": "Die Liste unten gibt nur wieder, was jede Seite sagt, und vieles ist umstritten. Das britische Verteidigungsministerium zählt von Februar 2022 bis Juni 2024 mindestens 26 russische Marineschiffe, die durch Waffen aller Art beschädigt oder zerstört wurden. Das ist etwa ein Drittel der Schwarzmeerflotte.", + "carries": "An Bord", + "cost": "Kosten pro Boot", + "ship": "Patrouillenschiff, Wert laut GUR", + "events": "Bekannte Ereignisse, laut Berichten", + "event1": "29. Okt. 2022, Sewastopol: See- und Luftdrohnen trafen die Fregatte Admiral Makarow und ein Minenräumboot. Russland räumte leichte Schäden ein.", + "event2": "31. Jan. bis 1. Feb. 2024, Donuslaw-See: Magura-V5-Boote versenkten die Korvette Iwanowez. Der GUR spricht von sechs Treffern, Videos zeigen etwa drei.", + "event3": "4. bis 5. März 2024, Straße von Kertsch: Magura-V5-Boote versenkten das Patrouillenschiff Sergej Kotow. Laut der Ukraine waren fünf Boote beteiligt.", + "event4": "31. Dez. 2024, Kap Tarchankut: Laut GUR schossen Magura-V5-Boote mit R-73-Raketen Mi-8-Hubschrauber ab, eine Premiere für Seedrohnen. Erst behauptete der GUR einen Abschuss, später zwei.", + "event5": "2. Mai 2025, nahe Noworossijsk: Laut GUR schossen Magura-V7-Boote mit Sidewinder-Raketen zwei Su-30SM-Jets ab. Russland hat das nicht bestätigt." + } + } +} diff --git a/explainers/naval-drone/locales/en.json b/explainers/naval-drone/locales/en.json new file mode 100644 index 00000000..98326b57 --- /dev/null +++ b/explainers/naval-drone/locales/en.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "How a naval drone works", + "eyebrow": "Naval drone", + "tagline": "A boat with nobody on board: it skims on its hull, drives with a water pump and is steered by satellite.", + "description": "A Magura V5 naval drone in 3D: how its hull planes, how a waterjet drives it, its satellite link, and why a low boat is hard to see.", + "summary": "The planing hull, the waterjet, the satellite link and its delay, the curve of the sea, and a changing design.", + "socialAlt": "A grey uncrewed boat skimming over the sea at dawn with spray flying from its chines, beside the title How a naval drone works" + }, + "stage": { + "label": "Interactive naval drone model", + "gauge": "Live run readings" + }, + "controls": { + "label": "Naval drone controls", + "views": { + "cutaway": { + "name": "Cutaway", + "short": "Cutaway", + "hint": "Cutaway (C)" + }, + "flow": { + "name": "Water flow", + "short": "Flow", + "hint": "Water flow (W)" + }, + "links": { + "name": "Radio links", + "short": "Links", + "hint": "Radio links (K)" + }, + "labels": { + "name": "Labels", + "short": "Labels", + "hint": "Labels (L)" + } + }, + "fit": { + "label": "Deck fit" + }, + "fitOptions": { + "standard": "Standard", + "missile": "Missile rails" + }, + "momentOptions": { + "hullSpeed": "Hull speed", + "humpPeak": "Hump peak", + "onPlane": "On the plane", + "cruiseSpeed": "Cruising", + "throttleUp": "Sprint starts", + "topSpeed": "Top speed", + "alongside": "At the ship" + }, + "speedMarkOptions": { + "hullSpeed": "Hull speed", + "hump": "Hump", + "planing": "Planing", + "cruise": "Cruise", + "top": "Top speed" + }, + "helmOptions": { + "left": "Left", + "straight": "Straight", + "right": "Right", + "reverse": "Reverse" + }, + "linkOptions": { + "satellite": "Starlink", + "backup": "Backup satellite", + "lost": "Link lost" + }, + "seaStateOptions": { + "smooth": "Smooth", + "slight": "Slight", + "moderate": "Moderate", + "rough": "Rough" + } + }, + "timeline": { + "label": "Run time", + "phases": "Jump to a part of the run", + "speed": "Playback speed", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "versus normal speed", + "phase": { + "launch": "Launch", + "hump": "Hump", + "cruise": "Cruise", + "sprint": "Sprint", + "arrival": "Arrival" + }, + "during": { + "launch": "the boat idles off the slipway", + "hump": "the boat speeds up onto the plane", + "cruise": "the boat cruises at 22 knots, hours cut short", + "sprint": "the boat sprints at 42 knots with two more", + "arrival": "the boat runs the last 170 m to the ship" + }, + "jump": { + "launch": "Jump to the launch", + "hump": "Jump to the hump", + "cruise": "Jump to the cruise", + "sprint": "Jump to the sprint", + "arrival": "Jump to the arrival" + } + }, + "readouts": { + "clock": "Run clock", + "speed": "Speed", + "mode": "Hull", + "distance": "To the ship" + }, + "mode": { + "floating": "Floating", + "hump": "Climbing the hump", + "planing": "Planing" + }, + "units": { + "speed": "{{kn}} kn, {{kmh}} km/h", + "m": "{{value}} m", + "km": "{{value}} km", + "percent": "{{value}} %", + "degrees": "{{value}}°", + "kilonewtons": "{{value}} kN", + "mps": "{{ms}} m/s, {{kmh}} km/h", + "ms": "{{value}} ms", + "minutes": "{{value}} min" + }, + "parts": { + "hull": "Composite hull", + "chines": "Chines", + "sprayRails": "Spray rails", + "payloadBay": "Payload bay", + "fuelTanks": "Fuel tanks", + "engine": "Engine", + "electronicsBay": "Electronics", + "starlinkPanels": "Starlink antennas", + "backupPanel": "Backup antenna", + "cameraDome": "Camera dome", + "bowCamera": "Bow camera", + "intake": "Intake and grate", + "duct": "Intake duct", + "driveShaft": "Drive shaft", + "impeller": "Impeller", + "waterjet": "Waterjet", + "stator": "Stator vanes", + "nozzle": "Nozzle", + "steeringNozzle": "Steering nozzle", + "reverseBucket": "Reverse bucket", + "missileRails": "Missile rails", + "jetStream": "Jet stream", + "bowWave": "Bow wave", + "spray": "Spray", + "wake": "Wake", + "wettedLength": "Wet bottom length", + "videoGhost": "Pilot's delayed view", + "satLink": "Starlink link", + "backupLink": "Backup link", + "satellite": "Starlink satellite", + "backupSatellite": "Backup satellite", + "groundStation": "Ground station", + "ship": "Ship at anchor", + "shipRadar": "Ship's radar", + "companions": "Companion boats" + }, + "hull": { + "lift": "{{dynamic}} % by speed, {{buoyant}} % by floating", + "wetted": "{{m}} m, against {{rest}} m at rest", + "ratio": "{{times}} times the hull speed of {{hullSpeed}} kn", + "ratioStill": "Stopped; hull speed is {{hullSpeed}} kn" + }, + "jet": { + "flow": "{{kg}} kg a second, about {{litres}} litres", + "backing": "Stopping, bucket down", + "astern": "Turned forward by the bucket", + "noEfficiency": "None while not moving ahead", + "push": { + "left": "Nozzle {{angle}}° left: bow turns left", + "right": "Nozzle {{angle}}° right: bow turns right", + "straight": "Straight back: holds course", + "reverse": "Bucket down: stream forward and down" + } + }, + "link": { + "now": "{{m}} m behind the boat at {{kn}} kn", + "nowLost": "No picture while the link is down", + "top": "{{m}} m at 42 kn, {{lengths}} boat lengths", + "carrier": { + "satellite": "Starlink, two terminals", + "backup": "Backup satellite antenna", + "lost": "None" + }, + "boat": { + "satellite": "Pilot steers by the video", + "backup": "Pilot steers over the backup link", + "lost": "Keeps its set course in the model; real behaviour not published" + } + }, + "horizon": { + "camera": "{{km}} km from a lens {{m}} m up", + "detect": "About {{km}} km, {{min}} min of warning at 42 kn", + "detectBeyond": "Not covered: the standard stops at sea state 4", + "waves": { + "smooth": "{{from}} to {{to}} m, sea state 2", + "slight": "{{from}} to {{to}} m, sea state 3", + "moderate": "{{from}} to {{to}} m, sea state 4", + "rough": "{{from}} to {{to}} m, sea state 5" + }, + "hidden": { + "smooth": "No figure this smooth; waves stay below its 0.5 m deck", + "slight": "Up to half the time: waves as tall as its deck", + "moderate": "Up to half the time: waves above its deck", + "rough": "Up to half the time: waves far above its deck" + }, + "canvas": { + "radar": "Ship's radar", + "boat": "Boat, 0.5 m high", + "lineOfSight": "Line of sight", + "detection": "Expected detection", + "km": "{{value}} km", + "note": "Curve of the sea exaggerated" + } + }, + "fleet": { + "carries": { + "standard": "A payload of up to {{kg}} kg in the bow", + "missile": "Two R-73 missiles on rails; bow bay kept in the model" + }, + "cost": "About USD {{from}} to {{to}}", + "ship": "About USD {{value}} million" + }, + "sections": { + "overview": { + "title": "A speedboat with no one aboard", + "p1": "This is a Magura V5, a Ukrainian naval drone: a fast boat with nobody on board. The state firm SpetsTechnoExport builds it for GUR, Ukraine's defence intelligence; it was first shown in July 2023. It is 5.5 m long, sits just 0.5 m above the water and weighs under one tonne. A pilot far away steers it by satellite.", + "p2": "Outside you see a low grey hull of composite, fibre cloth set in resin. On the raised fairing at the back sit two Starlink antennas and a backup satellite antenna; toward the bow, a low dome with a thermal camera and a fixed bow camera. The waterjet at the stern drives it. Inside are a payload bay for up to 320 kg, fuel tanks, and the engine in a foam-bedded tub with the electronics above. Switch on the cutaway (C) to see them, or wait for the jet chapter.", + "p3": "The model follows one run. The boat leaves a slipway, rises onto the plane and cruises at 22 knots, about 41 km/h; a knot is one nautical mile an hour. It then sprints at 42 knots, about 78 km/h, with two more boats, until its bow reaches a grey ship at anchor.", + "p4": "At normal speed the boat moves as fast as a real one, but the trip is cut to two minutes: a cruise of about 20 hours over its full 450 nautical miles, about 800 km, takes 42 seconds here, and the sprint is shorter too. The ship lies 1.5 km away, not hundreds of kilometres. The clock counts model time; drag the timeline to jump.", + "mode": "Hull", + "covered": "Distance run", + "done": "Run done" + }, + "hull": { + "title": "It stops floating and starts skimming", + "p1": "At rest, the water the boat pushes aside holds it up: buoyancy. As it speeds up, water striking the slanted bottom pushes it up harder and harder. Once that push carries most of the weight, the hull rises and skims: planing. The back of the bottom always stays in the water.", + "p2": "Every boat makes a bow wave. At about 5.7 knots it is as long as this 5.5 m hull: the hull speed, a rule of thumb for boats that only float, not a limit; at 42 knots the Magura goes about 7 times faster. First it climbs the resistance hump, where it makes big waves and the drag, the water holding it back, climbs steeply. The sharp bottom edges, the chines, add drag here and pay off only once it planes.", + "p3": "Move the slider and watch the bow. It rises most near the hump, then settles: this angle is the trim, with least drag at about 3 to 4 degrees. Planing starts at about 15 to 18 knots; the model switches at 16. The wet bottom, 5.1 m at rest in the model, shrinks from about 4.5 m at 15 knots to 1.5 to 2 m at 42. With no hump data published, the 6 degree peak and the weight split are the model's own.", + "p4": "The bottom is a deep V that softens the blows of waves; the model uses 20 degrees at the stern, sharper forward, as the real angle is not published. Water breaks cleanly off the square stern's sharp lower edge. A thin spray rail on each side, a model guess, throws spray outward. Too much trim makes a planing boat bounce: porpoising.", + "speedLabel": "Boat speed", + "mode": "Hull", + "trim": "Bow angle (trim)", + "lift": "Weight carried", + "wetted": "Wet bottom length", + "ratio": "Against hull speed" + }, + "jet": { + "title": "A pump instead of a propeller", + "p1": "With the hull cut open, follow the power: the fuel tanks feed the engine, whose drive shaft turns the pump at the stern, the waterjet. Water enters through a grate in the bottom. A spinning impeller, a fan-like rotor, speeds it up; fixed vanes, the stator, take out the swirl; a nozzle shoots it out behind.", + "p2": "The push comes from speeding up the water in the pump: thrust is the mass of water per second times how much faster the jet leaves than the boat moves. Even a perfect pump turns only 2 / (1 + jet speed / boat speed) of its work into motion: 80 % with the jet at 1.5 times boat speed, far less at low speed.", + "p3": "Above about 25 to 30 knots a jet matches or beats a propeller, and with nothing under the hull the boat can run in shallow water. The grate mostly clears itself, but weed and rope can still block it. The first boat of this family, in May 2022, had an outboard motor; later 2022 boats, like one found near Sevastopol, used a Rotax jet-ski engine and a Sea-Doo pump. The Magura's engine is not disclosed, so the model assumes a jet-ski-type petrol engine turning the pump directly; the pump moves an estimated 210 litres a second at 42 knots.", + "p4": "There is no rudder. The steering nozzle swings the stream up to 27 degrees each way: swing it right and the bow turns right. To stop or back up, a reverse bucket drops over the nozzle and sends the stream forward and down. The slider sets the throttle, taken in the model as the pump's flow. Press play to rejoin the run.", + "throttleLabel": "Throttle", + "boat": "Boat speed", + "flow": "Water through the pump", + "velocity": "Jet speed", + "thrust": "Thrust", + "efficiency": "Ideal efficiency", + "push": "Jet push" + }, + "link": { + "title": "Steered from hundreds of kilometres away", + "p1": "A Magura captured in Crimea in November 2023 carried, by Russian accounts, two Starlink terminals, a Kymeta backup satellite antenna, GPS, a Linux computer and a two-SIM router for use near shore. The maker also claims mesh radio, autonomous control and up to three video streams.", + "p2": "The pilot sits at a console in a hard case. Long trips can be pre-programmed, but a pilot watches throughout and often steers the last stretch by hand: the boat is not fully autonomous. What a real boat does when the link drops is not published; the model keeps it on course.", + "p3": "Starlink's typical delay is about 25 to 35 ms on land and can pass 100 ms at sea. The upload, 10 to 30 megabits a second, about what three video streams need, sets how sharp the picture is. How late it arrives is unpublished. At 42 knots a quarter second is 5.4 m, about a boat length; the ghost boat shows where the pilot sees it.", + "p4": "GPS jamming and faking over the Black Sea has been documented since mid-2024, likely why the maker also lists inertial and visual navigation. In September 2022, by Elon Musk's account and a corrected 2023 book about him, Ukraine expected Starlink coverage near Crimea that had never been turned on; the book had first said it was switched off. The satellites here are drawn low, close and far bigger than real.", + "delayLabel": "Video delay", + "now": "Where the pilot sees it", + "top": "At top speed", + "carrier": "Link", + "boat": "What the boat does", + "path": "How a command reaches the boat", + "step1": "Pilot: moves a lever or joystick.", + "step2": "Ground station: sends the command up by radio.", + "step3": "Satellite: passes it down to the boat.", + "step4": "Boat: the computer moves the throttle or nozzle.", + "step5": "Video: comes back the same way, a little late." + }, + "horizon": { + "title": "Low enough to hide behind the curve of the sea", + "p1": "The sea curves away, so from h metres up the horizon lies about 3.86 × √h km off. The boat's camera, about 0.7 m up, sees only about 3 km. The 3D sea is flat, as over 1.5 km the real curve drops less than 0.2 m; the drawing below exaggerates it.", + "p2": "A ship's radar meets the same curve. Radio bends slightly, so its line of sight to a 0.5 m boat is about 4.12 × (√h1 + √h2) km, heights in metres: about 21 km for a radar 20 m up. That is only the farthest it could see the boat, and even then the ship would get at most about 16 minutes of warning at 42 knots.", + "p3": "Real detection comes later. A small boat's radar cross-section, how big it looks to radar, is about 0.5 to 5 m²; none is published for the Magura. A coastal radar standard expects a 1 m² target 1 m up to be found at about 9 km, by an advanced radar, up to sea state 4 (waves of 1.25 to 2.5 m): about 7 minutes of warning at 42 knots. Small boats may hide behind waves up to half the time, and sea clutter, the waves' own echoes, can drown theirs.", + "p4": "Operators say the boats are hard to spot: a commander of GUR's Group 13 said radar struggles with the plastic hull and the boat gives off little heat. These are claims; no measurement is published. A Russian teardown reported that the foam-bedded engine tub reduces heating of the outer skin. The boats are grey, and the best-known attacks came at night or dawn. Tap the chips to change the sea state.", + "caption": "Side view of the radar, the sea and the boat; curve and heights exaggerated.", + "heightLabel": "Radar height", + "radar": "Radar line of sight", + "minutes": "Ship's warning at 42 kn", + "camera": "Camera horizon", + "detect": "Expected detection", + "waves": "Wave height", + "hidden": "Hidden behind waves" + }, + "fleet": { + "title": "Cheap, small and changing fast", + "p1": "A Magura V5 costs about a quarter of a million dollars. Sea Baby, a different boat built by the SBU, Ukraine's security service, costs about USD 221,000. GUR values the patrol ship Sergey Kotov, sunk in March 2024 after a Magura attack, at about USD 65 million.", + "p2": "The boats rarely work alone: many reported attacks used groups of about five to ten, each boat with a role, the developers say. Ships reported sunk took several hits. The two companions in the run, and their formation, are the model's choice.", + "p3": "The design keeps changing. In December 2024 GUR fitted V5 boats with two R-73 air-to-air missiles, renamed Sea Dragon; the chips show this fit, which changes only the deck, not the run. In 2025 GUR showed the larger Magura V7, 7.2 m long, with up to 650 kg of payload.", + "p4": "The record below keeps to each side's words, and many claims are disputed. The UK Ministry of Defence counts at least 26 Russian navy ships damaged or destroyed from February 2022 to June 2024 by all weapons, about a third of the Black Sea Fleet.", + "carries": "On board", + "cost": "Cost per boat", + "ship": "Patrol ship, GUR's value", + "events": "Public record, as reported", + "event1": "29 Oct 2022, Sevastopol: sea and air drones hit the frigate Admiral Makarov and a minesweeper; Russia admitted minor damage.", + "event2": "31 Jan to 1 Feb 2024, Lake Donuzlav: Magura V5 boats sank the corvette Ivanovets; GUR claims six hits, video shows about three.", + "event3": "4 to 5 Mar 2024, Kerch Strait: Magura V5 boats sank the patrol ship Sergey Kotov; Ukraine said five boats took part.", + "event4": "31 Dec 2024, Cape Tarkhankut: GUR says Magura V5 boats downed Mi-8 helicopters with R-73 missiles, a first for a sea drone; it first claimed one, later two.", + "event5": "2 May 2025, near Novorossiysk: GUR says Magura V7 boats downed two Su-30SM jets with Sidewinders; Russia has not confirmed it." + } + } +} diff --git a/explainers/naval-drone/locales/es.json b/explainers/naval-drone/locales/es.json new file mode 100644 index 00000000..377c321b --- /dev/null +++ b/explainers/naval-drone/locales/es.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "Cómo funciona un dron naval", + "eyebrow": "Dron naval", + "tagline": "Una lancha sin nadie a bordo: se desliza sobre su casco, avanza con una bomba de agua y se pilota por satélite.", + "description": "Un dron naval Magura V5 en 3D: cómo planea su casco, cómo lo mueve un chorro de agua, su enlace por satélite y por qué cuesta ver una lancha tan baja.", + "summary": "El casco que planea, el chorro de agua, el enlace por satélite y su retraso, la curvatura del mar y un diseño que no para de cambiar.", + "socialAlt": "Una lancha gris sin tripulación se desliza sobre el mar al amanecer, con la espuma saltando de las aristas de su casco, junto al título «Cómo funciona un dron naval»" + }, + "stage": { + "label": "Modelo interactivo del dron naval", + "gauge": "Datos del recorrido en directo" + }, + "controls": { + "label": "Controles del dron naval", + "views": { + "cutaway": { + "name": "Vista en corte", + "short": "Corte", + "hint": "Vista en corte (C)" + }, + "flow": { + "name": "Flujo de agua", + "short": "Flujo", + "hint": "Flujo de agua (W)" + }, + "links": { + "name": "Enlaces de radio", + "short": "Enlaces", + "hint": "Enlaces de radio (K)" + }, + "labels": { + "name": "Etiquetas", + "short": "Etiquetas", + "hint": "Etiquetas (L)" + } + }, + "fit": { + "label": "Equipo de cubierta" + }, + "fitOptions": { + "standard": "Estándar", + "missile": "Rieles para misiles" + }, + "momentOptions": { + "hullSpeed": "Velocidad de casco", + "humpPeak": "Cima de la joroba", + "onPlane": "Planeando", + "cruiseSpeed": "Crucero", + "throttleUp": "Empieza el esprint", + "topSpeed": "Velocidad máxima", + "alongside": "Junto al buque" + }, + "speedMarkOptions": { + "hullSpeed": "Velocidad de casco", + "hump": "Joroba", + "planing": "Planeo", + "cruise": "Crucero", + "top": "Máxima" + }, + "helmOptions": { + "left": "Izquierda", + "straight": "Recto", + "right": "Derecha", + "reverse": "Marcha atrás" + }, + "linkOptions": { + "satellite": "Starlink", + "backup": "Satélite de reserva", + "lost": "Sin enlace" + }, + "seaStateOptions": { + "smooth": "Tranquilo", + "slight": "Poco agitado", + "moderate": "Agitado", + "rough": "Muy agitado" + } + }, + "timeline": { + "label": "Tiempo de recorrido", + "phases": "Saltar a una parte del recorrido", + "speed": "Velocidad de reproducción", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "frente a la velocidad normal", + "phase": { + "launch": "Salida", + "hump": "Joroba", + "cruise": "Crucero", + "sprint": "Esprint", + "arrival": "Llegada" + }, + "during": { + "launch": "la lancha sale de la rampa al ralentí", + "hump": "la lancha acelera y se pone a planear", + "cruise": "la lancha navega a 22 nudos; aquí se recortan horas de viaje", + "sprint": "la lancha esprinta a 42 nudos con otras dos", + "arrival": "la lancha recorre los últimos 170 m hasta el buque" + }, + "jump": { + "launch": "Saltar a la salida", + "hump": "Saltar a la joroba", + "cruise": "Saltar al crucero", + "sprint": "Saltar al esprint", + "arrival": "Saltar a la llegada" + } + }, + "readouts": { + "clock": "Reloj del recorrido", + "speed": "Velocidad", + "mode": "Casco", + "distance": "Hasta el buque" + }, + "mode": { + "floating": "Flotando", + "hump": "Subiendo la joroba", + "planing": "Planeando" + }, + "units": { + "speed": "{{kn}} nudos, {{kmh}} km/h", + "m": "{{value}} m", + "km": "{{value}} km", + "percent": "{{value}} %", + "degrees": "{{value}}°", + "kilonewtons": "{{value}} kN", + "mps": "{{ms}} m/s, {{kmh}} km/h", + "ms": "{{value}} ms", + "minutes": "{{value}} min" + }, + "parts": { + "hull": "Casco de material compuesto", + "chines": "Codillos", + "sprayRails": "Deflectores de salpicaduras", + "payloadBay": "Compartimento de carga", + "fuelTanks": "Depósitos de combustible", + "engine": "Motor", + "electronicsBay": "Electrónica", + "starlinkPanels": "Antenas Starlink", + "backupPanel": "Antena de reserva", + "cameraDome": "Cúpula de la cámara", + "bowCamera": "Cámara de proa", + "intake": "Toma de agua y rejilla", + "duct": "Conducto de entrada", + "driveShaft": "Eje de transmisión", + "impeller": "Impulsor", + "waterjet": "Propulsor de chorro", + "stator": "Álabes del estátor", + "nozzle": "Tobera", + "steeringNozzle": "Tobera de dirección", + "reverseBucket": "Cuchara de marcha atrás", + "missileRails": "Rieles para misiles", + "jetStream": "Chorro de agua", + "bowWave": "Ola de proa", + "spray": "Salpicaduras", + "wake": "Estela", + "wettedLength": "Fondo mojado", + "videoGhost": "Vista retrasada del piloto", + "satLink": "Enlace Starlink", + "backupLink": "Enlace de reserva", + "satellite": "Satélite Starlink", + "backupSatellite": "Satélite de reserva", + "groundStation": "Estación terrestre", + "ship": "Buque fondeado", + "shipRadar": "Radar del buque", + "companions": "Lanchas acompañantes" + }, + "hull": { + "lift": "{{dynamic}} % por la velocidad, {{buoyant}} % por flotar", + "wetted": "{{m}} m, frente a {{rest}} m en reposo", + "ratio": "{{times}} veces la velocidad de casco, de {{hullSpeed}} nudos", + "ratioStill": "Parada; la velocidad de casco es de {{hullSpeed}} nudos" + }, + "jet": { + "flow": "{{kg}} kg por segundo, unos {{litres}} litros", + "backing": "Frenando, cuchara bajada", + "astern": "La cuchara lo desvía hacia delante", + "noEfficiency": "Ninguna si no avanza", + "push": { + "left": "Tobera {{angle}}° a la izquierda: proa a la izquierda", + "right": "Tobera {{angle}}° a la derecha: proa a la derecha", + "straight": "Recto hacia atrás: mantiene el rumbo", + "reverse": "Cuchara bajada: chorro hacia delante y abajo" + } + }, + "link": { + "now": "{{m}} m tras la lancha, a {{kn}} nudos", + "nowLost": "Sin imagen mientras no hay enlace", + "top": "{{m}} m a 42 nudos, {{lengths}} veces su largo", + "carrier": { + "satellite": "Starlink, dos terminales", + "backup": "Antena de satélite de reserva", + "lost": "Ninguno" + }, + "boat": { + "satellite": "El piloto la guía con el vídeo", + "backup": "El piloto la guía por el enlace de reserva", + "lost": "En el modelo mantiene el rumbo; lo que hace una real no se ha publicado" + } + }, + "horizon": { + "camera": "{{km}} km desde una lente a {{m}} m de altura", + "detect": "Unos {{km}} km, {{min}} min de aviso a 42 nudos", + "detectBeyond": "La norma solo llega al estado de la mar 4", + "waves": { + "smooth": "De {{from}} a {{to}} m, estado de la mar 2", + "slight": "De {{from}} a {{to}} m, estado de la mar 3", + "moderate": "De {{from}} a {{to}} m, estado de la mar 4", + "rough": "De {{from}} a {{to}} m, estado de la mar 5" + }, + "hidden": { + "smooth": "Sin cifras para un mar tan tranquilo; las olas no llegan a su cubierta de 0,5 m", + "slight": "Hasta la mitad del tiempo: olas tan altas como su cubierta", + "moderate": "Hasta la mitad del tiempo: olas por encima de su cubierta", + "rough": "Hasta la mitad del tiempo: olas muy por encima de su cubierta" + }, + "canvas": { + "radar": "Radar del buque", + "boat": "Lancha, 0,5 m de alto", + "lineOfSight": "Línea de vista", + "detection": "Detección esperada", + "km": "{{value}} km", + "note": "Curvatura del mar exagerada" + } + }, + "fleet": { + "carries": { + "standard": "Hasta {{kg}} kg de carga en la proa", + "missile": "Dos misiles R-73 sobre rieles; el modelo conserva el compartimento de proa" + }, + "cost": "Entre unos {{from}} y {{to}} USD", + "ship": "Unos {{value}} millones de USD" + }, + "sections": { + "overview": { + "title": "Una lancha rápida sin nadie a bordo", + "p1": "Esto es un Magura V5, un dron naval ucraniano: una lancha rápida sin nadie a bordo. Lo fabrica la empresa estatal SpetsTechnoExport para el GUR, la inteligencia militar de Ucrania, y se presentó en julio de 2023. Mide 5,5 m de largo, asoma solo 0,5 m sobre el agua y pesa menos de una tonelada. Un piloto lo guía por satélite desde muy lejos.", + "p2": "Por fuera ves un casco bajo y gris de material compuesto, tejido de fibra en resina. Sobre el carenado elevado de popa van dos antenas Starlink y una antena de satélite de reserva; hacia la proa, una cúpula baja con una cámara térmica y una cámara de proa fija. La mueve el propulsor de chorro de la popa. Dentro hay un compartimento de carga para hasta 320 kg, los depósitos de combustible y el motor en una cubeta sobre espuma, con la electrónica encima. Activa la vista en corte (C) para verlos, o espera al capítulo del propulsor.", + "p3": "El modelo sigue un recorrido. La lancha sale de una rampa, se pone a planear y navega a 22 nudos, unos 41 km/h; un nudo es una milla náutica por hora. Luego acelera a 42 nudos, unos 78 km/h, con otras dos lanchas, hasta que su proa llega a un buque gris fondeado.", + "p4": "A velocidad normal, la lancha va tan rápido como una real, pero el viaje se recorta a dos minutos: un crucero de unas 20 horas por toda su autonomía de 450 millas náuticas, unos 800 km, aquí dura 42 segundos, y el esprint también se acorta. El buque está a 1,5 km, no a cientos de kilómetros. El reloj cuenta el tiempo del modelo; arrastra la línea de tiempo para saltar.", + "mode": "Casco", + "covered": "Distancia recorrida", + "done": "Avance del recorrido" + }, + "hull": { + "title": "Deja de flotar y empieza a deslizarse", + "p1": "En reposo, el agua que aparta la lancha la sostiene: es la flotabilidad. Al acelerar, el agua que choca con el fondo inclinado la empuja hacia arriba cada vez con más fuerza. Cuando ese empuje carga con la mayor parte del peso, el casco se eleva y se desliza: es planear. La parte trasera del fondo siempre sigue en el agua.", + "p2": "Todo barco levanta una ola de proa. A unos 5,7 nudos es tan larga como este casco de 5,5 m: es la velocidad de casco, una regla práctica para barcos que solo flotan, no un límite; a 42 nudos, el Magura va unas 7 veces más rápido. Primero supera la joroba de resistencia, donde levanta olas grandes y la resistencia al avance, el agua que la frena, crece muy deprisa. Las aristas afiladas del fondo, los codillos, añaden resistencia aquí y solo compensan cuando ya planea.", + "p3": "Mueve el control deslizante y fíjate en la proa. Se levanta sobre todo cerca de la joroba y luego baja: ese ángulo es el trimado, con la mínima resistencia a unos 3 o 4 grados. El planeo empieza entre unos 15 y 18 nudos; el modelo cambia a los 16. El fondo mojado, de 5,1 m en reposo en el modelo, pasa de unos 4,5 m a 15 nudos a entre 1,5 y 2 m a 42. Como no hay datos publicados de la joroba, el pico de 6 grados y el reparto del peso son cifras propias del modelo.", + "p4": "El fondo es una V profunda que suaviza los golpes de las olas; el modelo usa 20 grados en la popa, más afilada hacia proa, porque el ángulo real no se ha publicado. El agua se separa limpia del borde inferior afilado de la popa cuadrada. Un fino deflector de salpicaduras a cada lado, una suposición del modelo, lanza el agua hacia fuera. Con demasiado trimado, una lancha que planea rebota: es el marsopeo.", + "speedLabel": "Velocidad de la lancha", + "mode": "Casco", + "trim": "Ángulo de proa (trimado)", + "lift": "Reparto del peso", + "wetted": "Fondo mojado", + "ratio": "Frente a la velocidad de casco" + }, + "jet": { + "title": "Una bomba en lugar de una hélice", + "p1": "Con el casco abierto, sigue la energía: los depósitos de combustible alimentan el motor, y su eje de transmisión mueve la bomba de popa, el propulsor de chorro. El agua entra por una rejilla en el fondo. Un impulsor, un rotor que gira como un ventilador, la acelera; unos álabes fijos, el estátor, le quitan el remolino, y una tobera la lanza hacia atrás.", + "p2": "La lancha avanza porque la bomba acelera el agua: el empuje es la masa de agua por segundo multiplicada por cuánto más rápido sale el chorro de lo que avanza la lancha. Incluso una bomba perfecta solo convierte en avance 2 / (1 + velocidad del chorro / velocidad de la lancha) de su trabajo: un 80 % con el chorro a 1,5 veces la velocidad de la lancha, mucho menos a poca velocidad.", + "p3": "Por encima de unos 25 a 30 nudos, un chorro iguala o supera a una hélice, y como bajo el casco no cuelga nada, la lancha puede ir por aguas poco profundas. La rejilla suele limpiarse sola, pero las algas y los cabos aún pueden atascarla. La primera lancha de esta familia, en mayo de 2022, llevaba un fueraborda; más tarde ese año, otras como la hallada cerca de Sebastopol usaban un motor Rotax de moto de agua y una bomba Sea-Doo. No se ha revelado el motor del Magura, así que el modelo supone uno de gasolina, tipo moto de agua, que hace girar la bomba directamente; se estima que esta mueve unos 210 litros por segundo a 42 nudos.", + "p4": "No hay timón. La tobera de dirección desvía el chorro hasta 27 grados a cada lado: si la giras a la derecha, la proa gira a la derecha. Para frenar o ir marcha atrás, una cuchara de marcha atrás baja sobre la tobera y manda el chorro hacia delante y hacia abajo. El control deslizante marca el acelerador, que en el modelo es el caudal de la bomba. Pulsa reproducir para volver al recorrido.", + "throttleLabel": "Acelerador", + "boat": "Velocidad de la lancha", + "flow": "Caudal de la bomba", + "velocity": "Velocidad del chorro", + "thrust": "Empuje", + "efficiency": "Eficiencia ideal", + "push": "Empuje del chorro" + }, + "link": { + "title": "Se pilota desde cientos de kilómetros", + "p1": "Un Magura capturado en Crimea en noviembre de 2023 llevaba, según fuentes rusas, dos terminales Starlink, una antena de satélite de reserva de Kymeta, GPS, un ordenador con Linux y un router con dos SIM para usar cerca de la costa. El fabricante afirma además que tiene radio en malla, control autónomo y hasta tres señales de vídeo.", + "p2": "El piloto se sienta ante una consola que va en una maleta rígida. Los trayectos largos se pueden programar, pero un piloto vigila todo el tiempo y a menudo lleva a mano el último tramo: la lancha no es del todo autónoma. No se ha publicado qué hace una lancha real si pierde el enlace; el modelo la mantiene en su rumbo.", + "p3": "El retraso típico de Starlink es de unos 25 a 35 ms en tierra y puede pasar de 100 ms en el mar. La subida, de 10 a 30 megabits por segundo, más o menos lo que piden tres señales de vídeo, marca la nitidez de la imagen. Cuánto tarda en llegar no se ha publicado. A 42 nudos, un cuarto de segundo son 5,4 m, casi el largo de la lancha; la lancha fantasma muestra dónde la ve el piloto.", + "p4": "Desde mediados de 2024 están documentadas las interferencias y las señales falsas de GPS sobre el mar Negro; probablemente por eso el fabricante también menciona navegación inercial y visual. En septiembre de 2022, según Elon Musk y la versión corregida de un libro de 2023 sobre él, Ucrania esperaba cobertura de Starlink cerca de Crimea que nunca se había activado; al principio, el libro decía que se había desactivado. Aquí los satélites se dibujan bajos, cercanos y mucho más grandes que los reales.", + "delayLabel": "Retraso del vídeo", + "now": "Dónde la ve el piloto", + "top": "A velocidad máxima", + "carrier": "Enlace", + "boat": "Qué hace la lancha", + "path": "Cómo llega una orden a la lancha", + "step1": "Piloto: mueve una palanca o un joystick.", + "step2": "Estación terrestre: sube la orden por radio.", + "step3": "Satélite: la baja hasta la lancha.", + "step4": "Lancha: el ordenador mueve el acelerador o la tobera.", + "step5": "Vídeo: vuelve por el mismo camino, con algo de retraso." + }, + "horizon": { + "title": "Tan baja que puede esconderse tras la curvatura del mar", + "p1": "El mar se curva, así que desde h metros de altura el horizonte está a unos 3,86 × √h km. La cámara de la lancha, a unos 0,7 m, solo ve unos 3 km. El mar 3D es plano, porque en 1,5 km la curvatura real baja menos de 0,2 m; el dibujo de abajo la exagera.", + "p2": "El radar de un buque choca con la misma curva. Las ondas de radio se curvan un poco, así que su línea de vista hasta una lancha de 0,5 m es de unos 4,12 × (√h1 + √h2) km, con las alturas en metros: unos 21 km para un radar a 20 m. Eso es solo lo más lejos que podría verla, y aun así el buque tendría como mucho unos 16 minutos de aviso a 42 nudos.", + "p3": "La detección real llega más tarde. La sección transversal de radar de una lancha pequeña, lo grande que parece al radar, es de unos 0,5 a 5 m²; la del Magura no se ha publicado. Una norma de radar costero espera detectar un blanco de 1 m² a 1 m de altura a unos 9 km, con un radar avanzado y hasta el estado de la mar 4 (olas de 1,25 a 2,5 m): unos 7 minutos de aviso a 42 nudos. Las lanchas pequeñas pueden ocultarse tras las olas hasta la mitad del tiempo, y los ecos parásitos del mar, los ecos de las propias olas, pueden tapar los suyos.", + "p4": "Quienes las manejan dicen que son difíciles de detectar: un comandante del Grupo 13 del GUR dijo que al radar le cuesta ver el casco de plástico y que la lancha desprende poco calor. Son afirmaciones; no se ha publicado ninguna medición. Según un desmontaje ruso, la cubeta del motor sobre espuma reduce el calentamiento del casco por fuera. Las lanchas son grises, y los ataques más conocidos fueron de noche o al amanecer. Toca los botones para cambiar el estado de la mar.", + "caption": "Vista lateral del radar, el mar y la lancha; curvatura y alturas exageradas.", + "heightLabel": "Altura del radar", + "radar": "Línea de vista del radar", + "minutes": "Aviso del buque a 42 nudos", + "camera": "Horizonte de la cámara", + "detect": "Detección esperada", + "waves": "Altura de las olas", + "hidden": "Oculta tras las olas" + }, + "fleet": { + "title": "Barata, pequeña y en constante cambio", + "p1": "Un Magura V5 cuesta alrededor de un cuarto de millón de dólares. Sea Baby, una lancha distinta del SBU, el servicio de seguridad de Ucrania, cuesta unos 221.000 USD. El GUR valora en unos 65 millones de USD el patrullero Serguéi Kótov, hundido en marzo de 2024 tras un ataque de lanchas Magura.", + "p2": "Las lanchas casi nunca actúan solas: según lo publicado, muchos ataques usaron grupos de unas cinco a diez, y quienes las desarrollan dicen que cada una tiene su papel. Los buques que se dieron por hundidos recibieron varios impactos. Las dos acompañantes del recorrido, y su formación, son una elección del modelo.", + "p3": "El diseño no deja de cambiar. En diciembre de 2024, el GUR equipó lanchas V5 con dos misiles aire-aire R-73, rebautizados como Sea Dragon; los botones muestran esta versión, que solo cambia la cubierta, no el recorrido. En 2025, el GUR presentó el Magura V7, más grande, de 7,2 m de largo y con hasta 650 kg de carga.", + "p4": "La lista de abajo se ciñe a lo que dice cada parte, y muchas afirmaciones están en disputa. El Ministerio de Defensa británico cuenta al menos 26 buques de la Armada rusa dañados o destruidos de febrero de 2022 a junio de 2024 con todo tipo de armas, alrededor de un tercio de la Flota del mar Negro.", + "carries": "A bordo", + "cost": "Coste por lancha", + "ship": "Patrullero, valor según el GUR", + "events": "Registro público, según lo publicado", + "event1": "29 de octubre de 2022, Sebastopol: drones navales y aéreos alcanzaron la fragata Almirante Makárov y un dragaminas; Rusia reconoció daños menores.", + "event2": "Del 31 de enero al 1 de febrero de 2024, lago Donuzlav: lanchas Magura V5 hundieron la corbeta Ivanovets; el GUR afirma que hubo seis impactos y en el vídeo se ven unos tres.", + "event3": "Del 4 al 5 de marzo de 2024, estrecho de Kerch: lanchas Magura V5 hundieron el patrullero Serguéi Kótov; según Ucrania, participaron cinco lanchas.", + "event4": "31 de diciembre de 2024, cabo Tarjankut: el GUR dice que lanchas Magura V5 derribaron helicópteros Mi-8 con misiles R-73, la primera vez para un dron naval; primero afirmó haber derribado uno y luego dos.", + "event5": "2 de mayo de 2025, cerca de Novorosíisk: el GUR dice que lanchas Magura V7 derribaron dos cazas Su-30SM con misiles Sidewinder; Rusia no lo ha confirmado." + } + } +} diff --git a/explainers/naval-drone/locales/fr.json b/explainers/naval-drone/locales/fr.json new file mode 100644 index 00000000..a3aff5d6 --- /dev/null +++ b/explainers/naval-drone/locales/fr.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "Comment fonctionne un drone naval", + "eyebrow": "Drone naval", + "tagline": "Un bateau sans personne à bord : il plane sur sa coque, une pompe à eau le propulse et on le pilote par satellite.", + "description": "Un drone naval Magura V5 en 3D : comment sa coque plane, comment un hydrojet le propulse, sa liaison satellite, et pourquoi un bateau bas se voit mal.", + "summary": "La coque qui plane, l’hydrojet, la liaison satellite et son retard, la courbure de la mer, et une conception qui évolue.", + "socialAlt": "Un bateau gris sans équipage glisse sur la mer à l’aube, des gerbes d’embruns jaillissant de ses bouchains, à côté du titre « Comment fonctionne un drone naval »" + }, + "stage": { + "label": "Modèle interactif du drone naval", + "gauge": "Mesures du trajet en direct" + }, + "controls": { + "label": "Commandes du drone naval", + "views": { + "cutaway": { + "name": "Vue en coupe", + "short": "Coupe", + "hint": "Vue en coupe (C)" + }, + "flow": { + "name": "Flux d’eau", + "short": "Flux", + "hint": "Flux d’eau (W)" + }, + "links": { + "name": "Liaisons radio", + "short": "Liaisons", + "hint": "Liaisons radio (K)" + }, + "labels": { + "name": "Étiquettes", + "short": "Étiquettes", + "hint": "Étiquettes (L)" + } + }, + "fit": { + "label": "Équipement du pont" + }, + "fitOptions": { + "standard": "Standard", + "missile": "Rails lance-missiles" + }, + "momentOptions": { + "hullSpeed": "Vitesse de carène", + "humpPeak": "Pic de la bosse", + "onPlane": "Le bateau plane", + "cruiseSpeed": "En croisière", + "throttleUp": "Début du sprint", + "topSpeed": "Vitesse maximale", + "alongside": "Au navire" + }, + "speedMarkOptions": { + "hullSpeed": "Vitesse de carène", + "hump": "Bosse", + "planing": "Déjaugeage", + "cruise": "Croisière", + "top": "Vitesse maximale" + }, + "helmOptions": { + "left": "Gauche", + "straight": "Tout droit", + "right": "Droite", + "reverse": "Marche arrière" + }, + "linkOptions": { + "satellite": "Starlink", + "backup": "Satellite de secours", + "lost": "Liaison perdue" + }, + "seaStateOptions": { + "smooth": "Belle", + "slight": "Peu agitée", + "moderate": "Agitée", + "rough": "Forte" + } + }, + "timeline": { + "label": "Temps du trajet", + "phases": "Aller à une étape du trajet", + "speed": "Vitesse de lecture", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "comparé à la vitesse normale", + "phase": { + "launch": "Départ", + "hump": "Bosse", + "cruise": "Croisière", + "sprint": "Sprint", + "arrival": "Arrivée" + }, + "during": { + "launch": "le bateau quitte la cale au ralenti", + "hump": "le bateau accélère et se met à planer", + "cruise": "le bateau file à 22 nœuds, des heures de route écourtées", + "sprint": "le bateau fonce à 42 nœuds avec deux autres", + "arrival": "le bateau parcourt les 170 derniers mètres jusqu’au navire" + }, + "jump": { + "launch": "Aller au départ", + "hump": "Aller à la bosse", + "cruise": "Aller à la croisière", + "sprint": "Aller au sprint", + "arrival": "Aller à l’arrivée" + } + }, + "readouts": { + "clock": "Horloge du trajet", + "speed": "Vitesse", + "mode": "Coque", + "distance": "Jusqu’au navire" + }, + "mode": { + "floating": "Elle flotte", + "hump": "Elle franchit la bosse", + "planing": "Elle plane" + }, + "units": { + "speed": "{{kn}} nd, {{kmh}} km/h", + "m": "{{value}} m", + "km": "{{value}} km", + "percent": "{{value}} %", + "degrees": "{{value}}°", + "kilonewtons": "{{value}} kN", + "mps": "{{ms}} m/s, {{kmh}} km/h", + "ms": "{{value}} ms", + "minutes": "{{value}} min" + }, + "parts": { + "hull": "Coque en composite", + "chines": "Bouchains", + "sprayRails": "Déflecteurs d’embruns", + "payloadBay": "Soute de charge utile", + "fuelTanks": "Réservoirs de carburant", + "engine": "Moteur", + "electronicsBay": "Électronique", + "starlinkPanels": "Antennes Starlink", + "backupPanel": "Antenne de secours", + "cameraDome": "Dôme de caméra", + "bowCamera": "Caméra avant", + "intake": "Prise d’eau et grille", + "duct": "Conduit d’aspiration", + "driveShaft": "Arbre de transmission", + "impeller": "Turbine", + "waterjet": "Hydrojet", + "stator": "Aubes du stator", + "nozzle": "Tuyère", + "steeringNozzle": "Tuyère orientable", + "reverseBucket": "Godet de marche arrière", + "missileRails": "Rails lance-missiles", + "jetStream": "Jet d’eau", + "bowWave": "Vague d’étrave", + "spray": "Embruns", + "wake": "Sillage", + "wettedLength": "Longueur mouillée", + "videoGhost": "Vue retardée du pilote", + "satLink": "Liaison Starlink", + "backupLink": "Liaison de secours", + "satellite": "Satellite Starlink", + "backupSatellite": "Satellite de secours", + "groundStation": "Station au sol", + "ship": "Navire à l’ancre", + "shipRadar": "Radar du navire", + "companions": "Bateaux d’accompagnement" + }, + "hull": { + "lift": "{{dynamic}} % par la vitesse, {{buoyant}} % par la flottaison", + "wetted": "{{m}} m, contre {{rest}} m au repos", + "ratio": "{{times}} fois la vitesse de carène de {{hullSpeed}} nd", + "ratioStill": "À l’arrêt ; vitesse de carène de {{hullSpeed}} nd" + }, + "jet": { + "flow": "{{kg}} kg par seconde, environ {{litres}} litres", + "backing": "Freinage, godet baissé", + "astern": "Renvoyé vers l’avant par le godet", + "noEfficiency": "Nul tant qu’il n’avance pas", + "push": { + "left": "Tuyère à {{angle}}° à gauche : l’avant vire à gauche", + "right": "Tuyère à {{angle}}° à droite : l’avant vire à droite", + "straight": "Droit vers l’arrière : cap maintenu", + "reverse": "Godet baissé : jet vers l’avant et le bas" + } + }, + "link": { + "now": "{{m}} m derrière le bateau à {{kn}} nd", + "nowLost": "Pas d’image tant que la liaison est coupée", + "top": "{{m}} m à 42 nd, {{lengths}} longueurs de bateau", + "carrier": { + "satellite": "Starlink, deux terminaux", + "backup": "Antenne satellite de secours", + "lost": "Aucune" + }, + "boat": { + "satellite": "Pilotage d’après la vidéo", + "backup": "Pilotage par la liaison de secours", + "lost": "Garde son cap dans le modèle ; comportement réel non publié" + } + }, + "horizon": { + "camera": "{{km}} km depuis un objectif à {{m}} m de haut", + "detect": "Environ {{km}} km, soit {{min}} min d’alerte à 42 nd", + "detectBeyond": "Non couvert : la norme s’arrête à l’état de mer 4", + "waves": { + "smooth": "{{from}} à {{to}} m, état de mer 2", + "slight": "{{from}} à {{to}} m, état de mer 3", + "moderate": "{{from}} à {{to}} m, état de mer 4", + "rough": "{{from}} à {{to}} m, état de mer 5" + }, + "hidden": { + "smooth": "Aucun chiffre par mer si calme ; les vagues restent sous son pont de 0,5 m", + "slight": "Jusqu’à la moitié du temps : vagues aussi hautes que son pont", + "moderate": "Jusqu’à la moitié du temps : vagues plus hautes que son pont", + "rough": "Jusqu’à la moitié du temps : vagues bien plus hautes que son pont" + }, + "canvas": { + "radar": "Radar du navire", + "boat": "Bateau, 0,5 m de haut", + "lineOfSight": "Ligne de vue", + "detection": "Détection attendue", + "km": "{{value}} km", + "note": "Courbure de la mer exagérée" + } + }, + "fleet": { + "carries": { + "standard": "Jusqu’à {{kg}} kg de charge utile, à l’avant", + "missile": "Deux missiles R-73 sur rails ; soute avant conservée dans le modèle" + }, + "cost": "De {{from}} à {{to}} dollars environ", + "ship": "Environ {{value}} millions de dollars" + }, + "sections": { + "overview": { + "title": "Une vedette rapide sans personne à bord", + "p1": "Voici un Magura V5, un drone naval ukrainien : un bateau rapide sans personne à bord. L’entreprise d’État SpetsTechnoExport le construit pour le GUR, le renseignement militaire ukrainien ; il a été dévoilé en juillet 2023. Long de 5,5 m, il ne dépasse l’eau que de 0,5 m et pèse moins d’une tonne. Un pilote, très loin, le dirige par satellite.", + "p2": "À l’extérieur, une coque basse et grise en composite : du tissu de fibres pris dans de la résine. Sur le carénage surélevé, à l’arrière, se trouvent deux antennes Starlink et une antenne satellite de secours ; vers l’avant, un dôme bas avec une caméra thermique, et une caméra avant fixe. À la poupe, l’hydrojet fait avancer le bateau. À l’intérieur : une soute qui peut recevoir jusqu’à 320 kg, les réservoirs, et le moteur, dans un bac calé par de la mousse, sous l’électronique. Activez la vue en coupe (C) pour les voir, ou attendez le chapitre sur l’hydrojet.", + "p3": "Le modèle suit un seul trajet. Le bateau quitte une cale, se met à planer et file à 22 nœuds, environ 41 km/h ; un nœud vaut un mille marin par heure. Puis il fonce à 42 nœuds, environ 78 km/h, avec deux autres bateaux, jusqu’à ce que sa proue atteigne un navire gris à l’ancre.", + "p4": "À vitesse normale, le bateau va aussi vite qu’un vrai, mais le trajet est réduit à deux minutes : une croisière d’environ 20 heures, sur toute son autonomie de 450 milles marins (environ 800 km), dure ici 42 secondes, et le sprint est aussi écourté. Le navire est à 1,5 km, et non à des centaines de kilomètres. L’horloge compte le temps du modèle ; faites glisser la barre de lecture pour vous déplacer.", + "mode": "Coque", + "covered": "Distance parcourue", + "done": "Avancement du trajet" + }, + "hull": { + "title": "Il cesse de flotter et glisse sur l’eau", + "p1": "Au repos, l’eau que le bateau écarte le porte : c’est la poussée d’Archimède. Quand il accélère, l’eau qui frappe le fond incliné le pousse vers le haut, de plus en plus fort. Dès que cette poussée porte l’essentiel du poids, la coque se soulève et glisse : le bateau plane, ou déjauge. L’arrière du fond reste toujours dans l’eau.", + "p2": "Tout bateau crée une vague d’étrave. Vers 5,7 nœuds, elle est aussi longue que cette coque de 5,5 m : c’est la vitesse de carène, un repère pour les bateaux qui ne font que flotter, pas une limite ; à 42 nœuds, le Magura va environ 7 fois plus vite. Il doit d’abord franchir la bosse de résistance : il fait de grosses vagues, et la traînée, l’eau qui le freine, grimpe en flèche. Les arêtes vives du fond, les bouchains, ajoutent de la traînée ici et ne deviennent utiles qu’une fois qu’il plane.", + "p3": "Déplacez le curseur et observez l’avant. C’est près de la bosse qu’il monte le plus, puis il redescend : cet angle, c’est l’assiette, avec un minimum de traînée vers 3 à 4 degrés. Le bateau déjauge vers 15 à 18 nœuds ; le modèle bascule à 16. La partie mouillée du fond, 5,1 m au repos dans le modèle, raccourcit : environ 4,5 m à 15 nœuds, 1,5 à 2 m à 42. Faute de données publiées sur la bosse, le pic de 6 degrés et la répartition du poids sont propres au modèle.", + "p4": "Le fond forme un V profond qui amortit les chocs des vagues ; le vrai angle n’étant pas publié, le modèle prend 20 degrés à l’arrière, avec un V plus marqué vers l’avant. L’eau se détache net de l’arête vive au bas de l’arrière, coupé droit. De chaque côté, un mince déflecteur d’embruns, une supposition du modèle, rejette les gerbes vers l’extérieur. Trop d’assiette fait rebondir un bateau qui plane : c’est le marsouinage.", + "speedLabel": "Vitesse du bateau", + "mode": "Coque", + "trim": "Angle de la proue (assiette)", + "lift": "Poids porté", + "wetted": "Longueur mouillée", + "ratio": "Par rapport à la vitesse de carène" + }, + "jet": { + "title": "Une pompe au lieu d’une hélice", + "p1": "La coque ouverte, suivez l’énergie : les réservoirs de carburant alimentent le moteur, dont l’arbre de transmission entraîne la pompe à l’arrière, l’hydrojet. L’eau entre par une grille dans le fond. Une turbine, sorte de ventilateur, l’accélère ; des aubes fixes, le stator, redressent le flux ; une tuyère la projette vers l’arrière.", + "p2": "Le bateau avance parce que la pompe accélère l’eau : la poussée vaut la masse d’eau par seconde multipliée par l’écart entre la vitesse du jet et celle du bateau. Même une pompe parfaite ne transforme en mouvement que 2 / (1 + vitesse du jet / vitesse du bateau) de son travail : 80 % quand le jet va 1,5 fois plus vite que le bateau, bien moins à basse vitesse.", + "p3": "Au-delà d’environ 25 à 30 nœuds, un hydrojet vaut une hélice ou mieux, et comme rien ne dépasse sous la coque, le bateau passe en eau peu profonde. La grille se dégage presque toujours seule, mais algues et cordages peuvent encore la boucher. Le premier bateau de cette famille, en mai 2022, avait un moteur hors-bord ; plus tard en 2022, des bateaux comme celui retrouvé près de Sébastopol avaient un moteur de jet-ski Rotax et une pompe Sea-Doo. Le moteur du Magura n’est pas dévoilé : le modèle suppose un moteur à essence de type jet-ski qui entraîne directement la pompe ; on estime qu’elle débite 210 litres par seconde à 42 nœuds.", + "p4": "Il n’y a pas de gouvernail. La tuyère orientable dévie le jet jusqu’à 27 degrés de chaque côté : tournée à droite, elle fait virer l’avant à droite. Pour s’arrêter ou reculer, un godet de marche arrière s’abaisse sur la tuyère et renvoie le jet vers l’avant et le bas. Le curseur règle l’accélérateur, que le modèle traduit en débit de la pompe. Lancez la lecture pour reprendre le trajet.", + "throttleLabel": "Accélérateur", + "boat": "Vitesse du bateau", + "flow": "Débit de la pompe", + "velocity": "Vitesse du jet", + "thrust": "Poussée", + "efficiency": "Rendement idéal", + "push": "Direction du jet" + }, + "link": { + "title": "Piloté depuis des centaines de kilomètres", + "p1": "Selon des sources russes, un Magura capturé en Crimée en novembre 2023 portait deux terminaux Starlink, une antenne satellite de secours Kymeta, un GPS, un ordinateur sous Linux et un routeur à deux cartes SIM utilisable près des côtes. Le fabricant revendique aussi une radio maillée, un pilotage autonome et jusqu’à trois flux vidéo.", + "p2": "Le pilote travaille sur un pupitre logé dans une mallette rigide. Les longs trajets peuvent être programmés à l’avance, mais un pilote surveille tout du long et mène souvent la fin à la main : le bateau n’est pas entièrement autonome. Ce que fait un vrai bateau quand la liaison tombe n’est pas publié ; le modèle le garde sur son cap.", + "p3": "Le délai de Starlink est d’ordinaire de 25 à 35 ms environ sur terre, et peut dépasser 100 ms en mer. Le débit montant, 10 à 30 mégabits par seconde, à peu près ce que demandent trois flux vidéo, fixe la netteté de l’image. Le retard de l’image n’est pas publié. À 42 nœuds, un quart de seconde, c’est 5,4 m, environ une longueur de bateau ; le bateau fantôme montre où le pilote le voit.", + "p4": "Le brouillage et la falsification du GPS au-dessus de la mer Noire sont documentés depuis la mi-2024 : c’est sans doute pourquoi le fabricant annonce aussi une navigation inertielle et visuelle. En septembre 2022, selon Elon Musk et la version corrigée d’un livre de 2023 sur lui, l’Ukraine comptait sur une couverture Starlink près de la Crimée qui n’avait jamais été activée ; le livre disait d’abord qu’elle avait été coupée. Ici, les satellites sont dessinés bas, proches et bien plus grands que les vrais.", + "delayLabel": "Retard de la vidéo", + "now": "Là où le pilote le voit", + "top": "À vitesse maximale", + "carrier": "Liaison", + "boat": "Ce que fait le bateau", + "path": "Comment une commande atteint le bateau", + "step1": "Pilote : il bouge un levier ou un joystick.", + "step2": "Station au sol : elle envoie la commande au satellite par radio.", + "step3": "Satellite : il la renvoie vers le bateau.", + "step4": "Bateau : l’ordinateur actionne l’accélérateur ou la tuyère.", + "step5": "Vidéo : elle revient par le même chemin, avec un léger retard." + }, + "horizon": { + "title": "Assez bas pour se cacher derrière la courbure de la mer", + "p1": "La mer est courbe : depuis h mètres de haut, l’horizon est à environ 3,86 × √h km. La caméra du bateau, à environ 0,7 m de l’eau, ne voit qu’à 3 km environ. La mer en 3D est plate, car sur 1,5 km la vraie courbure descend de moins de 0,2 m ; le dessin ci-dessous l’exagère.", + "p2": "Le radar d’un navire bute sur la même courbure. Les ondes radio se courbent un peu, si bien que sa ligne de vue vers un bateau haut de 0,5 m atteint environ 4,12 × (√h1 + √h2) km, hauteurs en mètres : environ 21 km pour un radar à 20 m. Ce n’est que la distance maximale où il pourrait voir le bateau, et même alors le navire aurait au plus environ 16 minutes d’alerte à 42 nœuds.", + "p3": "En réalité, la détection vient plus tard. La surface équivalente radar d’un petit bateau, sa taille apparente pour un radar, est d’environ 0,5 à 5 m² ; aucun chiffre n’est publié pour le Magura. Une norme de radar côtier prévoit qu’une cible de 1 m² à 1 m au-dessus de l’eau soit repérée à environ 9 km, par un radar de pointe, jusqu’à l’état de mer 4 (vagues de 1,25 à 2,5 m) : environ 7 minutes d’alerte à 42 nœuds. Un petit bateau peut se cacher derrière les vagues jusqu’à la moitié du temps, et le fouillis de mer, l’écho des vagues elles-mêmes, peut noyer le sien.", + "p4": "Leurs opérateurs disent ces bateaux difficiles à repérer : un commandant du Groupe 13 du GUR a déclaré que les radars voient mal la coque en plastique et que le bateau dégage peu de chaleur. Ce sont des affirmations ; aucune mesure n’est publiée. D’après des Russes qui en ont démonté un, le bac du moteur calé dans la mousse limite l’échauffement de la paroi extérieure. Les bateaux sont gris, et les attaques les plus connues ont eu lieu de nuit ou à l’aube. Appuyez sur les boutons pour changer l’état de mer.", + "caption": "Vue de côté du radar, de la mer et du bateau ; courbure et hauteurs exagérées.", + "heightLabel": "Hauteur du radar", + "radar": "Ligne de vue du radar", + "minutes": "Alerte du navire à 42 nd", + "camera": "Horizon de la caméra", + "detect": "Détection attendue", + "waves": "Hauteur des vagues", + "hidden": "Masqué par les vagues" + }, + "fleet": { + "title": "Bon marché, petit et en pleine évolution", + "p1": "Un Magura V5 coûte environ un quart de million de dollars. Le Sea Baby, un autre bateau construit par le SBU, le service de sécurité ukrainien, coûte environ 221 000 dollars. Le GUR évalue à environ 65 millions de dollars le patrouilleur Sergueï Kotov, coulé en mars 2024 après une attaque de Magura.", + "p2": "Ces bateaux agissent rarement seuls : de nombreuses attaques rapportées en engageaient environ cinq à dix, chacun avec son rôle, selon les concepteurs. Les navires signalés comme coulés ont été touchés plusieurs fois. Les deux bateaux d’accompagnement du trajet et leur formation sont un choix du modèle.", + "p3": "La conception évolue sans cesse. En décembre 2024, le GUR a armé des V5 de deux missiles air-air R-73, rebaptisés Sea Dragon. Les boutons montrent cette version, qui ne change que le pont, pas le trajet. En 2025, le GUR a présenté le Magura V7, plus grand : 7,2 m de long et jusqu’à 650 kg de charge utile.", + "p4": "La chronologie ci-dessous s’en tient aux déclarations de chaque camp, et beaucoup sont contestées. Le ministère britannique de la Défense compte au moins 26 navires de guerre russes endommagés ou détruits de février 2022 à juin 2024, toutes armes confondues, soit environ un tiers de la flotte de la mer Noire.", + "carries": "À bord", + "cost": "Coût par bateau", + "ship": "Patrouilleur, valeur selon le GUR", + "events": "Événements rapportés publiquement", + "event1": "29 oct. 2022, Sébastopol : des drones marins et aériens ont touché la frégate Amiral Makarov et un dragueur de mines ; la Russie a reconnu des dégâts mineurs.", + "event2": "Du 31 janv. au 1er févr. 2024, lac Donouzlav : des Magura V5 ont coulé la corvette Ivanovets ; le GUR revendique six coups au but, la vidéo en montre environ trois.", + "event3": "Du 4 au 5 mars 2024, détroit de Kertch : des Magura V5 ont coulé le patrouilleur Sergueï Kotov ; selon l’Ukraine, cinq bateaux y ont pris part.", + "event4": "31 déc. 2024, cap Tarkhankout : selon le GUR, des Magura V5 ont abattu des hélicoptères Mi-8 avec des missiles R-73, une première pour un drone naval ; il en a d’abord revendiqué un, puis deux.", + "event5": "2 mai 2025, près de Novorossiisk : selon le GUR, des Magura V7 ont abattu deux chasseurs Su-30SM avec des Sidewinder ; la Russie ne l’a pas confirmé." + } + } +} diff --git a/explainers/naval-drone/locales/ja.json b/explainers/naval-drone/locales/ja.json new file mode 100644 index 00000000..324b5850 --- /dev/null +++ b/explainers/naval-drone/locales/ja.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "水上ドローンが動くしくみ", + "eyebrow": "水上ドローン", + "tagline": "だれも乗っていないボートです。船体で水面を滑り、水を噴くポンプで進み、衛星で操縦されます。", + "description": "水上ドローンのマグラV5を3Dで。船体が滑走するしくみ、ウォータージェットの推進、衛星通信、低いボートが見つけにくいわけを見ていきます。", + "summary": "滑走する船体、ウォータージェット、衛星通信とその遅れ、海面の丸み、変わり続ける設計。", + "socialAlt": "夜明けの海を滑るように走る、灰色の無人ボート。船底の角(チャイン)から、しぶきが飛び散っています。横には「水上ドローンが動くしくみ」というタイトルがあります。" + }, + "stage": { + "label": "水上ドローンのインタラクティブモデル", + "gauge": "航走中のリアルタイム計測" + }, + "controls": { + "label": "水上ドローンの操作", + "views": { + "cutaway": { + "name": "断面表示", + "short": "断面", + "hint": "断面表示(C)" + }, + "flow": { + "name": "水の流れ", + "short": "水流", + "hint": "水の流れ(W)" + }, + "links": { + "name": "無線通信", + "short": "通信", + "hint": "無線通信(K)" + }, + "labels": { + "name": "ラベル", + "short": "ラベル", + "hint": "ラベル(L)" + } + }, + "fit": { + "label": "甲板の装備" + }, + "fitOptions": { + "standard": "標準", + "missile": "ミサイルレール" + }, + "momentOptions": { + "hullSpeed": "船体速度", + "humpPeak": "抵抗の山の頂点", + "onPlane": "滑走中", + "cruiseSpeed": "巡航中", + "throttleUp": "全速力へ加速", + "topSpeed": "最高速度", + "alongside": "船に到着" + }, + "speedMarkOptions": { + "hullSpeed": "船体速度", + "hump": "抵抗の山", + "planing": "滑走", + "cruise": "巡航", + "top": "最高速度" + }, + "helmOptions": { + "left": "左", + "straight": "直進", + "right": "右", + "reverse": "後進" + }, + "linkOptions": { + "satellite": "スターリンク", + "backup": "予備の衛星", + "lost": "通信途絶" + }, + "seaStateOptions": { + "smooth": "なめらか", + "slight": "やや波がある", + "moderate": "かなり波がある", + "rough": "波がやや高い" + } + }, + "timeline": { + "label": "航走の経過時間", + "phases": "航走の場面へ移動", + "speed": "再生速度", + "value": "{{time}}、{{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "通常速度に対する倍率", + "phase": { + "launch": "出発", + "hump": "抵抗の山", + "cruise": "巡航", + "sprint": "全速力", + "arrival": "到着" + }, + "during": { + "launch": "ボートがアイドリングでスロープを離れる", + "hump": "ボートが加速して滑走に入る", + "cruise": "何時間もの道のりを縮めて、ボートが22ノットで巡航する", + "sprint": "ボートがほかの2隻と42ノットで走る", + "arrival": "ボートが最後の170mを走り、船に着く" + }, + "jump": { + "launch": "出発へ移動", + "hump": "抵抗の山へ移動", + "cruise": "巡航へ移動", + "sprint": "全速力へ移動", + "arrival": "到着へ移動" + } + }, + "readouts": { + "clock": "航走の経過時間", + "speed": "速さ", + "mode": "船体の状態", + "distance": "船までの距離" + }, + "mode": { + "floating": "浮いている", + "hump": "抵抗の山を登っている", + "planing": "滑走している" + }, + "units": { + "speed": "{{kn}}ノット、時速{{kmh}}km", + "m": "{{value}}m", + "km": "{{value}}km", + "percent": "{{value}}%", + "degrees": "{{value}}°", + "kilonewtons": "{{value}}kN", + "mps": "秒速{{ms}}m、時速{{kmh}}km", + "ms": "{{value}}ミリ秒", + "minutes": "{{value}}分" + }, + "parts": { + "hull": "複合材の船体", + "chines": "チャイン", + "sprayRails": "スプレーレール", + "payloadBay": "ペイロード区画", + "fuelTanks": "燃料タンク", + "engine": "エンジン", + "electronicsBay": "電子機器", + "starlinkPanels": "スターリンクのアンテナ", + "backupPanel": "予備アンテナ", + "cameraDome": "カメラのドーム", + "bowCamera": "船首のカメラ", + "intake": "取水口と格子", + "duct": "取水ダクト", + "driveShaft": "駆動軸", + "impeller": "インペラー", + "waterjet": "ウォータージェット", + "stator": "ステーターの羽根", + "nozzle": "ノズル", + "steeringNozzle": "操舵ノズル", + "reverseBucket": "リバースバケット", + "missileRails": "ミサイルレール", + "jetStream": "噴流", + "bowWave": "船首波", + "spray": "しぶき", + "wake": "航跡", + "wettedLength": "ぬれた船底の長さ", + "videoGhost": "パイロットに遅れて見える位置", + "satLink": "スターリンクの通信", + "backupLink": "予備の通信", + "satellite": "スターリンク衛星", + "backupSatellite": "予備の衛星", + "groundStation": "地上局", + "ship": "停泊中の船", + "shipRadar": "船のレーダー", + "companions": "随伴するボート" + }, + "hull": { + "lift": "速さで{{dynamic}}%、浮力で{{buoyant}}%", + "wetted": "{{m}}m、停止時は{{rest}}m", + "ratio": "船体速度{{hullSpeed}}ノットの{{times}}倍", + "ratioStill": "停止中、船体速度は{{hullSpeed}}ノット" + }, + "jet": { + "flow": "毎秒{{kg}}kg、約{{litres}}リットル", + "backing": "バケットを下ろして停止中", + "astern": "バケットで前向きに変えている", + "noEfficiency": "前へ進んでいないあいだはなし", + "push": { + "left": "ノズル左{{angle}}°:船首が左へ", + "right": "ノズル右{{angle}}°:船首が右へ", + "straight": "まっすぐ後ろ:針路を保つ", + "reverse": "バケットを下ろす:噴流は前と下へ" + } + }, + "link": { + "now": "{{kn}}ノットで、ボートの{{m}}m後ろ", + "nowLost": "通信が切れているあいだは映像なし", + "top": "42ノットで{{m}}m、ボートの長さの{{lengths}}倍", + "carrier": { + "satellite": "スターリンク、端末2台", + "backup": "予備の衛星アンテナ", + "lost": "なし" + }, + "boat": { + "satellite": "パイロットが映像を見て操る", + "backup": "パイロットが予備の通信で操る", + "lost": "モデルでは設定した針路を保つ。本物の動きは公表されていない" + } + }, + "horizon": { + "camera": "{{km}}km、レンズの高さ{{m}}m", + "detect": "約{{km}}km、42ノットなら猶予は{{min}}分", + "detectBeyond": "対象外:基準が扱うのは海況4まで", + "waves": { + "smooth": "{{from}}mから{{to}}m、海況2", + "slight": "{{from}}mから{{to}}m、海況3", + "moderate": "{{from}}mから{{to}}m、海況4", + "rough": "{{from}}mから{{to}}m、海況5" + }, + "hidden": { + "smooth": "これほど穏やかな海の数字はない:波は高さ0.5mの甲板より低い", + "slight": "最大で半分の時間:波が甲板と同じ高さ", + "moderate": "最大で半分の時間:波が甲板より高い", + "rough": "最大で半分の時間:波が甲板よりはるかに高い" + }, + "canvas": { + "radar": "船のレーダー", + "boat": "ボート、高さ0.5m", + "lineOfSight": "見通し距離", + "detection": "探知の目安", + "km": "{{value}}km", + "note": "海面の丸みは誇張しています" + } + }, + "fleet": { + "carries": { + "standard": "船首に最大{{kg}}kgのペイロード", + "missile": "レールにR-73ミサイル2発。モデルでは船首区画も残す" + }, + "cost": "約{{from}}ドルから{{to}}ドル", + "ship": "約{{value}}00万ドル" + }, + "sections": { + "overview": { + "title": "だれも乗っていないスピードボート", + "p1": "これはウクライナの水上ドローン、マグラV5です。だれも乗っていない、速いボートです。国営企業スペツテクノエクスポルトが、ウクライナ国防省情報総局(GUR)向けにつくっています。初公開は2023年7月です。長さは5.5m、甲板は水面からわずか0.5m、重さは1トン未満です。遠くにいるパイロットが、衛星を通して操ります。", + "p2": "外から見えるのは、低い灰色の船体です。繊維の布を樹脂で固めた複合材です。後ろの一段高いカバーにはスターリンクのアンテナ2基と予備の衛星アンテナ、前寄りには赤外線カメラの入った低いドームと、固定の船首のカメラがあります。船尾のウォータージェットがボートを進めます。中には最大320kgのペイロード区画と燃料タンク、発泡材で包んだケース入りのエンジンがあり、その上に電子機器が載っています。断面表示(C)をオンにするか、ウォータージェットの章まで進むと見えます。", + "p3": "モデルでは1回の航走を追います。ボートはスロープを離れて滑走に入り、22ノット、時速約41kmで巡航します。1ノットは、1時間に1海里進む速さです。そのあと、ほかの2隻と42ノット、時速約78kmで走り、停泊中の灰色の船に船首が届いたところで終わります。", + "p4": "ふつうの再生速度なら、ボートは本物と同じ速さで動きます。ただし、道のりは2分に縮めています。航続距離いっぱいの450海里、約800kmを走る約20時間の巡航が、ここでは42秒です。全速力の区間も短くしています。船は1.5km先にいて、何百kmも先ではありません。時計が示すのはモデルの中の時間です。タイムラインをドラッグすると移動できます。", + "mode": "船体の状態", + "covered": "走った距離", + "done": "航走の進み具合" + }, + "hull": { + "title": "浮かぶのをやめて、水面を滑りはじめる", + "p1": "止まっているときは、ボートが押しのけた水がボートを支えます。これが浮力です。速くなるほど、斜めの船底に当たる水がボートを強く押し上げます。この力が重さの大半を支えると、船体は持ち上がって水面を滑ります。これが滑走です。船底の後ろの部分は、いつも水につかったままです。", + "p2": "どんな船も、船首に波を立てます。約5.7ノットで、この波の長さが5.5mの船体と同じになります。これが船体速度で、浮いて進むだけの船に使う目安です。上限ではありません。マグラは42ノットで、その約7倍の速さで走ります。まず越えるのが抵抗の山です。ここでボートは大きな波を立て、抵抗、つまりボートを引き止める水の力が急に増えます。船底の鋭い角、チャインもここでは抵抗を増やし、役に立つのは滑走に入ってからです。", + "p3": "スライダーを動かして、船首を見てください。抵抗の山のあたりでいちばん持ち上がり、そのあと落ち着きます。この角度がトリムで、抵抗は約3度から4度でいちばん小さくなります。滑走は約15ノットから18ノットで始まり、モデルは16ノットで切り替えます。モデルでは、ぬれた船底の長さは止まっているとき5.1mです。15ノットで約4.5m、42ノットでは1.5mから2mに縮みます。抵抗の山のデータは公表されていないので、ピークの6度と重さの割合は、モデル独自の数字です。", + "p4": "船底は深いV字で、波の衝撃をやわらげます。本当の角度は公表されていないので、モデルでは船尾で20度とし、前ほど鋭くしています。四角い船尾の鋭い下の縁で、水はきれいに離れます。両側の細いスプレーレールはモデルの推測で、しぶきを外へ飛ばします。トリムが大きすぎると、滑走中のボートは跳ねはじめます。これがポーポイジングです。", + "speedLabel": "ボートの速さ", + "mode": "船体の状態", + "trim": "船首の角度(トリム)", + "lift": "重さを支えている力", + "wetted": "ぬれた船底の長さ", + "ratio": "船体速度との比較" + }, + "jet": { + "title": "プロペラのかわりにポンプ", + "p1": "船体を開いたまま、力の流れをたどりましょう。燃料タンクがエンジンに燃料を送り、エンジンの駆動軸が船尾のポンプ、ウォータージェットを回します。水は船底の格子から入ります。回転するインペラー、つまり扇風機の羽根のような回転体が水を加速し、固定された羽根のステーターがうずを取り、ノズルが後ろへ噴き出します。", + "p2": "ボートを進める力は、ポンプで水を加速して生まれます。推力は、1秒あたりの水の質量に、噴流がボートよりどれだけ速く出るかをかけたものです。完璧なポンプでも、仕事のうちボートを進めるのは、2 / (1 + 噴流の速さ / ボートの速さ) だけです。噴流がボートの1.5倍の速さなら80%、低速ではずっと少なくなります。", + "p3": "約25ノットから30ノットを超えると、ジェットの効率はプロペラと同じか、それ以上になります。船体の下に何もないので、浅い水でも走れます。格子はたいてい自然にきれいになりますが、海藻やロープでふさがることはあります。この系統で最初のボートは2022年5月に現れ、船外機を積んでいました。同じ年の後半には、セバストポリの近くで見つかったボートなどが、ロータックスの水上バイク用エンジンとシードゥのポンプを使っていました。マグラのエンジンは公表されていないため、モデルでは、ポンプを直接回す水上バイク型のガソリンエンジンを想定しています。ポンプが送る水は、42ノットで推定毎秒210リットルです。", + "p4": "舵はありません。操舵ノズルが噴流を左右に最大27度振ります。右へ振ると、船首は右を向きます。止まったり下がったりするときは、リバースバケットがノズルにかぶさり、噴流を前と下へ向けます。スライダーはスロットルで、モデルではポンプの流量として扱います。再生ボタンを押すと航走に戻ります。", + "throttleLabel": "スロットル", + "boat": "ボートの速さ", + "flow": "ポンプを通る水の量", + "velocity": "噴流の速さ", + "thrust": "推力", + "efficiency": "理想的な効率", + "push": "噴流の向き" + }, + "link": { + "title": "何百kmも離れた場所から操る", + "p1": "2023年11月にクリミアで捕獲されたマグラには、ロシア側の説明によると、スターリンクの端末2台、カイメタ社の予備の衛星アンテナ、GPS、Linuxのコンピューター、岸の近くで使う、SIMカード2枚のルーターが積まれていました。メーカーはさらに、メッシュ無線、自律制御、最大3本の映像ストリームを備えると主張しています。", + "p2": "パイロットは、頑丈なケースに入った操縦卓を使います。長い道のりは事前にプログラムできますが、パイロットがずっと見守り、最後の区間は手で操ることがよくあります。つまり、完全な自律型ではありません。通信が切れたとき本物のボートがどう動くかは公表されていないので、モデルでは設定した針路を保ちます。", + "p3": "スターリンクのふつうの遅れは、陸上で約25ミリ秒から35ミリ秒、海上では100ミリ秒を超えることもあります。映像の鮮明さを決めるのはアップロードの速さで、毎秒10メガビットから30メガビット、映像3本にだいたい必要な量です。映像がどれだけ遅れて届くかは公表されていません。42ノットでは、4分の1秒は5.4m、ボートほぼ1隻分です。うっすら見えるボートが、パイロットに見えている位置です。", + "p4": "黒海では2024年半ばから、GPSの妨害やなりすましが記録されています。メーカーが慣性航法と映像による航法も挙げているのは、おそらくそのためです。イーロン・マスク本人の説明と、彼を描いた2023年の本の訂正版によると、2022年9月、ウクライナはクリミアの近くでスターリンクが使えると考えていましたが、そこでは一度も有効になっていませんでした。本は最初、停止されたと書いていました。ここでは衛星を本物よりずっと大きく、低く近くに描いています。", + "delayLabel": "映像の遅れ", + "now": "パイロットに見えている位置", + "top": "最高速度では", + "carrier": "通信手段", + "boat": "ボートの動き", + "path": "指令がボートに届くまで", + "step1": "パイロット:レバーかジョイスティックを動かします。", + "step2": "地上局:指令を電波で上へ送ります。", + "step3": "衛星:それをボートへ送り下ろします。", + "step4": "ボート:コンピューターがスロットルやノズルを動かします。", + "step5": "映像:同じ道を、少し遅れて戻ります。" + }, + "horizon": { + "title": "海の丸みの陰に隠れるほど低い", + "p1": "海面は丸く曲がっているので、高さhメートルから見た水平線は約3.86 × √h km先です。ボートのカメラは約0.7mの高さなので、約3kmしか見えません。1.5kmなら本物の海面でも0.2m足らずしか下がらないので、3Dの海は平らにしています。下の図は丸みを誇張しています。", + "p2": "船のレーダーも、同じ丸みにぶつかります。電波は少し曲がるので、高さ0.5mのボートまでの見通し距離は約4.12 × (√h1 + √h2) kmです。高さはメートルで表し、高さ20mのレーダーなら約21kmになります。これはボートが見えうるいちばん遠い距離にすぎず、それでも42ノットなら、船の猶予は長くて約16分です。", + "p3": "実際に見つかるのは、もっと近づいてからです。小さなボートのレーダー反射断面積、つまりレーダーから見た大きさは約0.5m²から5m²で、マグラの数字は公表されていません。沿岸レーダーのある基準では、海面から1mの高さにある1m²の標的を、高性能なレーダーが約9kmで見つけるとしています。それも海況4、つまり波の高さ1.25mから2.5mまでの話です。42ノットなら猶予は約7分です。小さなボートは最大で半分の時間、波に隠れることがあり、波自身の反射、シークラッターがボートの反射をかき消すこともあります。", + "p4": "運用する側は、このボートは見つけにくいと話しています。GURの「グループ13」の指揮官は、プラスチックの船体はレーダーに映りにくく、ボートは熱もあまり出さないと述べました。これは主張で、測定結果は公表されていません。ロシア側の分解報告では、発泡材で包んだエンジンのケースが、外側の船体が温まるのを抑えるとされています。ボートは灰色で、よく知られた攻撃は夜か明け方でした。チップで海況を変えてみましょう。", + "caption": "レーダー、海、ボートを横から見た図。丸みと高さは誇張しています。", + "heightLabel": "レーダーの高さ", + "radar": "レーダーの見通し距離", + "minutes": "42ノットでの船の猶予", + "camera": "カメラの水平線", + "detect": "探知の目安", + "waves": "波の高さ", + "hidden": "波に隠れる時間" + }, + "fleet": { + "title": "安く、小さく、どんどん変わる", + "p1": "マグラV5の値段は、1隻およそ25万ドルです。ウクライナ保安庁(SBU)がつくる別のボート、シーベイビーは約22万1000ドルです。GURは、2024年3月にマグラの攻撃で沈んだ哨戒艦「セルゲイ・コトフ」の価値を、約6500万ドルと見積もっています。", + "p2": "これらのボートが単独で動くことは、めったにありません。報じられた攻撃の多くは5隻から10隻ほどのグループで、開発者によると1隻ごとに役割があります。沈んだと報じられた船は、どれも数発の命中を受けていました。航走に加わる2隻と、その並び方は、モデルが決めたものです。", + "p3": "設計は変わり続けています。2024年12月、GURはV5に、「シードラゴン」と改名した空対空ミサイルR-73を2発載せました。チップでこの装備を見られます。変わるのは甲板だけで、航走は同じです。2025年には、GURがより大きなマグラV7を公開しました。長さ7.2mで、最大650kgのペイロードを積めます。", + "p4": "下の記録はそれぞれの側の発表に沿っていて、主張の多くには異論があります。英国防省によると、2022年2月から2024年6月までに、あらゆる兵器で損傷または破壊されたロシア海軍の艦艇は少なくとも26隻で、黒海艦隊の約3分の1にあたります。", + "carries": "積んでいるもの", + "cost": "1隻あたりの値段", + "ship": "哨戒艦(GURの見積もり)", + "events": "報じられた公の記録", + "event1": "2022年10月29日、セバストポリ:水上ドローンと空中ドローンが、フリゲート「アドミラル・マカロフ」と掃海艇1隻に命中しました。ロシアが認めたのは軽い損傷だけです。", + "event2": "2024年1月31日から2月1日、ドヌズラフ湖:マグラV5がコルベット「イワノベツ」を沈めました。GURは6発命中と主張していますが、映像で見えるのは約3発です。", + "event3": "2024年3月4日から5日、ケルチ海峡:マグラV5が哨戒艦「セルゲイ・コトフ」を沈めました。ウクライナによると、5隻が加わりました。", + "event4": "2024年12月31日、タルハンクト岬:GURによると、マグラV5がR-73ミサイルでMi-8ヘリコプターを撃墜しました。水上ドローンによる撃墜は初めてです。GURは最初1機、のちに2機と主張しました。", + "event5": "2025年5月2日、ノボロシースクの近く:GURによると、マグラV7がサイドワインダーでSu-30SM戦闘機2機を撃墜しました。ロシアは確認していません。" + } + } +} diff --git a/explainers/naval-drone/locales/pt.json b/explainers/naval-drone/locales/pt.json new file mode 100644 index 00000000..efd1aa1b --- /dev/null +++ b/explainers/naval-drone/locales/pt.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "Como funciona um drone naval", + "eyebrow": "Drone naval", + "tagline": "Um barco sem ninguém a bordo: desliza sobre o casco, anda com uma bomba d'água e é pilotado por satélite.", + "description": "Um drone naval Magura V5 em 3D: como o casco plana, como um jato d'água o move, a conexão por satélite e por que um barco baixo é difícil de ver.", + "summary": "O casco de planeio, o jato d'água, a conexão por satélite e seu atraso, a curvatura do mar e um projeto que muda.", + "socialAlt": "Um barco cinza não tripulado deslizando sobre o mar ao amanhecer, com respingos saindo das quinas. Ao lado, o título “Como funciona um drone naval”" + }, + "stage": { + "label": "Modelo interativo de um drone naval", + "gauge": "Dados do percurso em tempo real" + }, + "controls": { + "label": "Controles do drone naval", + "views": { + "cutaway": { + "name": "Vista em corte", + "short": "Corte", + "hint": "Vista em corte (C)" + }, + "flow": { + "name": "Fluxo de água", + "short": "Fluxo", + "hint": "Fluxo de água (W)" + }, + "links": { + "name": "Conexões de rádio", + "short": "Conexões", + "hint": "Conexões de rádio (K)" + }, + "labels": { + "name": "Rótulos", + "short": "Rótulos", + "hint": "Rótulos (L)" + } + }, + "fit": { + "label": "Equipamento do convés" + }, + "fitOptions": { + "standard": "Padrão", + "missile": "Trilhos de mísseis" + }, + "momentOptions": { + "hullSpeed": "Velocidade de casco", + "humpPeak": "Pico da corcova", + "onPlane": "Em planeio", + "cruiseSpeed": "Em cruzeiro", + "throttleUp": "Início da arrancada", + "topSpeed": "Velocidade máxima", + "alongside": "Junto ao navio" + }, + "speedMarkOptions": { + "hullSpeed": "Velocidade de casco", + "hump": "Corcova", + "planing": "Planeio", + "cruise": "Cruzeiro", + "top": "Velocidade máxima" + }, + "helmOptions": { + "left": "Esquerda", + "straight": "Em frente", + "right": "Direita", + "reverse": "Ré" + }, + "linkOptions": { + "satellite": "Starlink", + "backup": "Satélite reserva", + "lost": "Conexão perdida" + }, + "seaStateOptions": { + "smooth": "Calmo", + "slight": "Pouco agitado", + "moderate": "Moderado", + "rough": "Agitado" + } + }, + "timeline": { + "label": "Tempo de percurso", + "phases": "Ir para uma parte do percurso", + "speed": "Velocidade de reprodução", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "em relação ao normal", + "phase": { + "launch": "Lançamento", + "hump": "Corcova", + "cruise": "Cruzeiro", + "sprint": "Arrancada", + "arrival": "Chegada" + }, + "during": { + "launch": "o barco sai da rampa em marcha lenta", + "hump": "o barco acelera e entra em planeio", + "cruise": "o barco segue a 22 nós, numa viagem encurtada", + "sprint": "o barco arranca a 42 nós com mais dois", + "arrival": "o barco percorre os últimos 170 m até o navio" + }, + "jump": { + "launch": "Ir para o lançamento", + "hump": "Ir para a corcova", + "cruise": "Ir para o cruzeiro", + "sprint": "Ir para a arrancada", + "arrival": "Ir para a chegada" + } + }, + "readouts": { + "clock": "Relógio do percurso", + "speed": "Velocidade", + "mode": "Casco", + "distance": "Até o navio" + }, + "mode": { + "floating": "Flutuando", + "hump": "Subindo a corcova", + "planing": "Em planeio" + }, + "units": { + "speed": "{{kn}} nós, {{kmh}} km/h", + "m": "{{value}} m", + "km": "{{value}} km", + "percent": "{{value}} %", + "degrees": "{{value}}°", + "kilonewtons": "{{value}} kN", + "mps": "{{ms}} m/s, {{kmh}} km/h", + "ms": "{{value}} ms", + "minutes": "{{value}} min" + }, + "parts": { + "hull": "Casco de compósito", + "chines": "Quinas", + "sprayRails": "Defletores de respingos", + "payloadBay": "Compartimento de carga", + "fuelTanks": "Tanques de combustível", + "engine": "Motor", + "electronicsBay": "Eletrônica", + "starlinkPanels": "Antenas Starlink", + "backupPanel": "Antena reserva", + "cameraDome": "Cúpula da câmera", + "bowCamera": "Câmera de proa", + "intake": "Entrada e grade", + "duct": "Duto de entrada", + "driveShaft": "Eixo de transmissão", + "impeller": "Rotor", + "waterjet": "Propulsor a jato d'água", + "stator": "Aletas do estator", + "nozzle": "Bocal", + "steeringNozzle": "Bocal de direção", + "reverseBucket": "Concha de ré", + "missileRails": "Trilhos de mísseis", + "jetStream": "Jato de saída", + "bowWave": "Onda de proa", + "spray": "Respingos", + "wake": "Esteira", + "wettedLength": "Comprimento molhado", + "videoGhost": "Visão atrasada do piloto", + "satLink": "Conexão Starlink", + "backupLink": "Conexão reserva", + "satellite": "Satélite Starlink", + "backupSatellite": "Satélite reserva", + "groundStation": "Estação terrestre", + "ship": "Navio ancorado", + "shipRadar": "Radar do navio", + "companions": "Barcos acompanhantes" + }, + "hull": { + "lift": "{{dynamic}} % pela velocidade, {{buoyant}} % pela flutuação", + "wetted": "{{m}} m, contra {{rest}} m em repouso", + "ratio": "{{times}} vezes a velocidade de casco, de {{hullSpeed}} nós", + "ratioStill": "Parado; velocidade de casco de {{hullSpeed}} nós" + }, + "jet": { + "flow": "{{kg}} kg por segundo, cerca de {{litres}} litros", + "backing": "Parando, concha abaixada", + "astern": "A concha joga o jato para a frente", + "noEfficiency": "Nenhuma enquanto não avança", + "push": { + "left": "Bocal {{angle}}° à esquerda: proa vira à esquerda", + "right": "Bocal {{angle}}° à direita: proa vira à direita", + "straight": "Direto para trás: mantém o rumo", + "reverse": "Concha abaixada: jato para a frente e para baixo" + } + }, + "link": { + "now": "{{m}} m atrás do barco a {{kn}} nós", + "nowLost": "Sem imagem enquanto a conexão está fora", + "top": "{{m}} m a 42 nós, {{lengths}} comprimentos do barco", + "carrier": { + "satellite": "Starlink, dois terminais", + "backup": "Antena de satélite reserva", + "lost": "Nenhuma" + }, + "boat": { + "satellite": "Piloto conduz pelo vídeo", + "backup": "Piloto conduz pela conexão reserva", + "lost": "Mantém o rumo no modelo; o comportamento real não foi divulgado" + } + }, + "horizon": { + "camera": "{{km}} km de uma lente a {{m}} m de altura", + "detect": "Cerca de {{km}} km, {{min}} min de alerta a 42 nós", + "detectBeyond": "Fora da norma, que vai só até o estado do mar 4", + "waves": { + "smooth": "{{from}} a {{to}} m, estado do mar 2", + "slight": "{{from}} a {{to}} m, estado do mar 3", + "moderate": "{{from}} a {{to}} m, estado do mar 4", + "rough": "{{from}} a {{to}} m, estado do mar 5" + }, + "hidden": { + "smooth": "Sem dados com mar tão calmo; as ondas ficam abaixo do convés de 0,5 m", + "slight": "Até metade do tempo: ondas da altura do convés", + "moderate": "Até metade do tempo: ondas acima do convés", + "rough": "Até metade do tempo: ondas bem acima do convés" + }, + "canvas": { + "radar": "Radar do navio", + "boat": "Barco, 0,5 m de altura", + "lineOfSight": "Linha de visada", + "detection": "Detecção esperada", + "km": "{{value}} km", + "note": "Curvatura do mar exagerada" + } + }, + "fleet": { + "carries": { + "standard": "Uma carga de até {{kg}} kg na proa", + "missile": "Dois mísseis R-73 em trilhos; o modelo mantém o compartimento da proa" + }, + "cost": "Cerca de US$ {{from}} a {{to}}", + "ship": "Cerca de US$ {{value}} milhões" + }, + "sections": { + "overview": { + "title": "Uma lancha sem ninguém a bordo", + "p1": "Este é um Magura V5, um drone naval ucraniano: um barco rápido sem ninguém a bordo. A estatal SpetsTechnoExport o fabrica para o GUR, a inteligência militar da Ucrânia; ele foi apresentado em julho de 2023. Tem 5,5 m de comprimento, fica só 0,5 m acima da água e pesa menos de uma tonelada. Um piloto distante o conduz por satélite.", + "p2": "Por fora, você vê um casco baixo e cinza de compósito, tecido de fibra preso em resina. Na carenagem elevada de trás ficam duas antenas Starlink e uma antena de satélite reserva; perto da proa, uma cúpula baixa com câmera térmica e uma câmera de proa fixa. O propulsor a jato d'água na popa move o barco. Dentro ficam um compartimento de carga para até 320 kg, os tanques de combustível e o motor numa cuba sobre espuma, com a eletrônica por cima. Ligue a vista em corte (C) para vê-los ou espere o capítulo do jato.", + "p3": "O modelo acompanha um percurso. O barco sai de uma rampa, entra em planeio e segue a 22 nós, cerca de 41 km/h; um nó é uma milha náutica por hora. Depois, arranca a 42 nós, cerca de 78 km/h, com mais dois barcos, até a proa chegar a um navio cinza ancorado.", + "p4": "Na velocidade normal, o barco anda tão rápido quanto um de verdade, mas a viagem cabe em dois minutos: um cruzeiro de cerca de 20 horas por todo o seu alcance de 450 milhas náuticas, cerca de 800 km, dura aqui 42 segundos, e a arrancada também é mais curta. O navio está a 1,5 km, e não a centenas de quilômetros. O relógio conta o tempo do modelo; arraste a linha do tempo para mudar de momento.", + "mode": "Casco", + "covered": "Distância percorrida", + "done": "Progresso do percurso" + }, + "hull": { + "title": "Ele para de boiar e passa a deslizar", + "p1": "Parado, o barco é sustentado pela água que ele empurra para o lado: a flutuação. Quando ele acelera, a água que bate no fundo inclinado o empurra para cima com cada vez mais força. Assim que esse empurrão carrega a maior parte do peso, o casco sobe e desliza: é o planeio. A parte de trás do fundo continua sempre na água.", + "p2": "Todo barco forma uma onda na proa. A cerca de 5,7 nós, ela fica tão comprida quanto este casco de 5,5 m: é a velocidade de casco, uma regra prática para barcos que só flutuam, e não um limite; a 42 nós, o Magura anda cerca de 7 vezes mais rápido. Antes, ele sobe a corcova de resistência, onde faz ondas grandes e o arrasto, a água que o segura, cresce rápido. As bordas afiadas do fundo, as quinas, aumentam o arrasto aqui e só compensam depois que ele entra em planeio.", + "p3": "Mova o controle e observe a proa. Ela sobe mais perto da corcova e depois baixa: esse ângulo é o trim, com menor arrasto em cerca de 3 a 4 graus. O planeio começa por volta de 15 a 18 nós; o modelo muda aos 16. O fundo molhado, de 5,1 m em repouso no modelo, encolhe de cerca de 4,5 m a 15 nós para 1,5 a 2 m a 42. Como não há dados publicados sobre a corcova, o pico de 6 graus e a divisão do peso são do próprio modelo.", + "p4": "O fundo é um V profundo, que amortece as pancadas das ondas; o modelo usa 20 graus na popa, mais fechado na proa, pois o ângulo real não foi publicado. A água se solta limpa da borda inferior afiada da popa reta. Um fino defletor de respingos em cada lateral, um palpite do modelo, joga os respingos para fora. Trim demais faz um barco em planeio quicar: é o efeito golfinho.", + "speedLabel": "Velocidade do barco", + "mode": "Casco", + "trim": "Ângulo da proa (trim)", + "lift": "Peso sustentado", + "wetted": "Comprimento molhado", + "ratio": "Em relação à velocidade de casco" + }, + "jet": { + "title": "Uma bomba no lugar da hélice", + "p1": "Com o casco aberto, siga a energia: os tanques de combustível alimentam o motor, cujo eixo de transmissão gira a bomba da popa, o propulsor a jato d'água. A água entra por uma grade no fundo. Um rotor giratório, parecido com um ventilador, a acelera; aletas fixas, o estator, desfazem o giro; um bocal a dispara para trás.", + "p2": "O impulso vem de acelerar a água na bomba: a força de propulsão é a massa de água por segundo vezes o quanto o jato sai mais rápido que o barco. Mesmo uma bomba perfeita só transforma em movimento 2 / (1 + velocidade do jato / velocidade do barco) do seu trabalho: 80 % com o jato a 1,5 vez a velocidade do barco, bem menos em baixa velocidade.", + "p3": "Acima de cerca de 25 a 30 nós, um jato iguala ou supera uma hélice, e sem nada embaixo do casco o barco pode andar em águas rasas. A grade quase sempre se limpa sozinha, mas algas e cabos ainda podem entupi-la. O primeiro barco desta família, em maio de 2022, tinha motor de popa; barcos do fim de 2022, como um achado perto de Sebastopol, usavam motor de jet ski Rotax e bomba Sea-Doo. O motor do Magura não foi divulgado, então o modelo supõe um motor a gasolina do tipo de jet ski que gira a bomba diretamente. Estima-se que a bomba movimente 210 litros por segundo a 42 nós.", + "p4": "Não há leme. O bocal de direção desvia o jato até 27 graus para cada lado: vire-o para a direita e a proa vira para a direita. Para parar ou dar ré, uma concha de ré desce sobre o bocal e manda o jato para a frente e para baixo. O controle ajusta o acelerador, que o modelo trata como a vazão da bomba. Aperte o play para voltar ao percurso.", + "throttleLabel": "Acelerador", + "boat": "Velocidade do barco", + "flow": "Vazão da bomba", + "velocity": "Velocidade do jato", + "thrust": "Força de propulsão", + "efficiency": "Eficiência ideal", + "push": "Direção do jato" + }, + "link": { + "title": "Pilotado a centenas de quilômetros", + "p1": "Segundo relatos russos, um Magura capturado na Crimeia em novembro de 2023 levava dois terminais Starlink, uma antena de satélite reserva da Kymeta, GPS, um computador com Linux e um roteador com dois chips para uso perto da costa. O fabricante também alega ter rede de rádio em malha, controle autônomo e até três transmissões de vídeo.", + "p2": "O piloto fica num console dentro de uma maleta rígida. Viagens longas podem ser programadas antes, mas um piloto acompanha tudo e muitas vezes conduz o trecho final à mão: o barco não é totalmente autônomo. O que um barco real faz quando perde a conexão não foi divulgado; o modelo o mantém no rumo.", + "p3": "O atraso típico do Starlink é de cerca de 25 a 35 ms em terra e pode passar de 100 ms no mar. O upload, de 10 a 30 megabits por segundo, mais ou menos o que três vídeos exigem, define a nitidez da imagem. Quanto ela chega atrasada não foi publicado. A 42 nós, um quarto de segundo equivale a 5,4 m, cerca de um comprimento de barco; o barco fantasma mostra onde o piloto o vê.", + "p4": "Bloqueio e falsificação de GPS sobre o mar Negro são registrados desde meados de 2024; provavelmente é por isso que o fabricante também anuncia navegação inercial e visual. Em setembro de 2022, segundo Elon Musk e a versão corrigida de um livro de 2023 sobre ele, a Ucrânia esperava cobertura do Starlink perto da Crimeia que nunca tinha sido ligada; antes, o livro dizia que ela tinha sido desligada. Aqui, os satélites aparecem baixos, próximos e bem maiores que os reais.", + "delayLabel": "Atraso do vídeo", + "now": "Onde o piloto o vê", + "top": "Na velocidade máxima", + "carrier": "Conexão", + "boat": "O que o barco faz", + "path": "Como um comando chega ao barco", + "step1": "Piloto: mexe numa alavanca ou joystick.", + "step2": "Estação terrestre: manda o comando ao satélite por rádio.", + "step3": "Satélite: repassa o comando ao barco.", + "step4": "Barco: o computador mexe no acelerador ou no bocal.", + "step5": "Vídeo: volta pelo mesmo caminho, um pouco atrasado." + }, + "horizon": { + "title": "Baixo o bastante para se esconder atrás da curvatura do mar", + "p1": "O mar se curva, então, de h metros de altura, o horizonte fica a cerca de 3,86 × √h km. A câmera do barco, a cerca de 0,7 m da água, enxerga só uns 3 km. O mar em 3D é plano, pois em 1,5 km a curvatura real desce menos de 0,2 m; o desenho abaixo a exagera.", + "p2": "O radar de um navio enfrenta a mesma curva. As ondas de rádio se curvam um pouco, então sua linha de visada até um barco de 0,5 m é de cerca de 4,12 × (√h1 + √h2) km, com as alturas em metros: cerca de 21 km para um radar a 20 m de altura. Essa é só a maior distância a que ele poderia ver o barco, e mesmo assim o navio teria no máximo cerca de 16 minutos de alerta a 42 nós.", + "p3": "A detecção real vem depois. A seção reta radar de um barco pequeno, o tamanho que ele parece ter para o radar, fica em torno de 0,5 a 5 m²; nenhuma foi publicada para o Magura. Uma norma de radar costeiro prevê que um alvo de 1 m², a 1 m da água, seja detectado a cerca de 9 km por um radar avançado, até o estado do mar 4 (ondas de 1,25 a 2,5 m): cerca de 7 minutos de alerta a 42 nós. Barcos pequenos podem se esconder atrás das ondas até metade do tempo, e o retorno do mar, o eco das próprias ondas, pode abafar o deles.", + "p4": "Os operadores dizem que os barcos são difíceis de detectar: um comandante do Grupo 13 do GUR disse que o radar tem dificuldade com o casco de plástico e que o barco emite pouco calor. São alegações; nenhuma medição foi publicada. Uma análise russa relatou que a cuba do motor, assentada em espuma, reduz o aquecimento da superfície externa. Os barcos são cinza, e os ataques mais conhecidos foram à noite ou ao amanhecer. Toque nos botões para mudar o estado do mar.", + "caption": "Vista lateral do radar, do mar e do barco; curvatura e alturas exageradas.", + "heightLabel": "Altura do radar", + "radar": "Linha de visada do radar", + "minutes": "Alerta do navio a 42 nós", + "camera": "Horizonte da câmera", + "detect": "Detecção esperada", + "waves": "Altura das ondas", + "hidden": "Escondido atrás das ondas" + }, + "fleet": { + "title": "Barato, pequeno e mudando rápido", + "p1": "Um Magura V5 custa cerca de um quarto de milhão de dólares. O Sea Baby, um barco diferente feito pelo SBU, o serviço de segurança ucraniano, custa cerca de US$ 221 mil. O GUR avalia em cerca de US$ 65 milhões o navio-patrulha Sergey Kotov, afundado em março de 2024 após um ataque de barcos Magura.", + "p2": "Os barcos raramente atuam sozinhos: muitos ataques relatados usaram grupos de cerca de cinco a dez, cada barco com uma função, segundo os desenvolvedores. Os navios dados como afundados levaram vários impactos. Os dois acompanhantes no percurso, e a formação deles, são escolha do modelo.", + "p3": "O projeto não para de mudar. Em dezembro de 2024, o GUR equipou barcos V5 com dois mísseis ar-ar R-73, rebatizados de Sea Dragon; os botões mostram essa versão, que muda só o convés, não o percurso. Em 2025, o GUR apresentou o Magura V7, maior, com 7,2 m de comprimento e até 650 kg de carga.", + "p4": "O registro abaixo se limita às palavras de cada lado, e muitas alegações são contestadas. O Ministério da Defesa do Reino Unido conta pelo menos 26 navios da Marinha russa danificados ou destruídos de fevereiro de 2022 a junho de 2024, por todo tipo de arma, cerca de um terço da Frota do Mar Negro.", + "carries": "A bordo", + "cost": "Custo por barco", + "ship": "Navio-patrulha, avaliado pelo GUR", + "events": "Registro público, conforme relatado", + "event1": "29 de outubro de 2022, Sebastopol: drones navais e aéreos atingiram a fragata Admiral Makarov e um caça-minas; a Rússia admitiu danos leves.", + "event2": "31 de janeiro a 1º de fevereiro de 2024, lago Donuzlav: barcos Magura V5 afundaram a corveta Ivanovets; o GUR alega seis impactos, o vídeo mostra cerca de três.", + "event3": "4 e 5 de março de 2024, estreito de Kerch: barcos Magura V5 afundaram o navio-patrulha Sergey Kotov; a Ucrânia disse que cinco barcos participaram.", + "event4": "31 de dezembro de 2024, cabo Tarkhankut: o GUR diz que barcos Magura V5 derrubaram helicópteros Mi-8 com mísseis R-73, algo inédito para um drone naval; primeiro alegou um, depois dois.", + "event5": "2 de maio de 2025, perto de Novorossiysk: o GUR diz que barcos Magura V7 derrubaram dois caças Su-30SM com mísseis Sidewinder; a Rússia não confirmou." + } + } +} diff --git a/explainers/naval-drone/locales/uk.json b/explainers/naval-drone/locales/uk.json new file mode 100644 index 00000000..90b15581 --- /dev/null +++ b/explainers/naval-drone/locales/uk.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "Як працює морський дрон", + "eyebrow": "Морський дрон", + "tagline": "Катер без людей на борту: він ковзає по воді, його рухає водяний насос, а керують ним через супутник.", + "description": "Морський дрон Magura V5 у 3D: як глісує корпус, як працює водомет, супутниковий зв’язок і чому низький катер важко помітити.", + "summary": "Корпус, що глісує, водомет, супутниковий зв’язок і його затримка, кривизна моря та мінлива конструкція.", + "socialAlt": "Сірий безекіпажний катер на світанку ковзає морем, а з-під його вилиць летять бризки. Поруч заголовок «Як працює морський дрон»" + }, + "stage": { + "label": "Інтерактивна модель морського дрона", + "gauge": "Поточні показники переходу" + }, + "controls": { + "label": "Керування моделлю морського дрона", + "views": { + "cutaway": { + "name": "Розріз", + "short": "Розріз", + "hint": "Розріз (C)" + }, + "flow": { + "name": "Потік води", + "short": "Потік", + "hint": "Потік води (W)" + }, + "links": { + "name": "Радіоканали", + "short": "Канали", + "hint": "Радіоканали (K)" + }, + "labels": { + "name": "Підписи", + "short": "Підписи", + "hint": "Підписи (L)" + } + }, + "fit": { + "label": "Оснащення палуби" + }, + "fitOptions": { + "standard": "Стандартне", + "missile": "Ракетні напрямні" + }, + "momentOptions": { + "hullSpeed": "Швидкість корпусу", + "humpPeak": "Вершина горба", + "onPlane": "Уже глісує", + "cruiseSpeed": "Крейсерський хід", + "throttleUp": "Початок ривка", + "topSpeed": "Максимальна швидкість", + "alongside": "Біля корабля" + }, + "speedMarkOptions": { + "hullSpeed": "Швидкість корпусу", + "hump": "Горб опору", + "planing": "Глісування", + "cruise": "Крейсерська", + "top": "Максимальна" + }, + "helmOptions": { + "left": "Ліворуч", + "straight": "Прямо", + "right": "Праворуч", + "reverse": "Задній хід" + }, + "linkOptions": { + "satellite": "Starlink", + "backup": "Резервний супутник", + "lost": "Зв’язок втрачено" + }, + "seaStateOptions": { + "smooth": "Тихе", + "slight": "Слабке", + "moderate": "Помірне", + "rough": "Сильне" + } + }, + "timeline": { + "label": "Час переходу", + "phases": "Обрати етап переходу", + "speed": "Швидкість відтворення", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "відносно звичайної швидкості", + "phase": { + "launch": "Старт", + "hump": "Горб опору", + "cruise": "Крейсерський хід", + "sprint": "Ривок", + "arrival": "Прибуття" + }, + "during": { + "launch": "катер на малому ходу відходить від сліпу", + "hump": "катер розганяється й виходить на глісування", + "cruise": "катер іде 22 вузлами, години шляху скорочено", + "sprint": "катер мчить 42 вузлами, з ним ще два", + "arrival": "катер проходить останні 170 м до корабля" + }, + "jump": { + "launch": "Перейти до старту", + "hump": "Перейти до горба опору", + "cruise": "Перейти до крейсерського ходу", + "sprint": "Перейти до ривка", + "arrival": "Перейти до прибуття" + } + }, + "readouts": { + "clock": "Час переходу", + "speed": "Швидкість", + "mode": "Корпус", + "distance": "До корабля" + }, + "mode": { + "floating": "Пливе", + "hump": "Долає горб опору", + "planing": "Глісує" + }, + "units": { + "speed": "{{kn}} вуз., {{kmh}} км/год", + "m": "{{value}} м", + "km": "{{value}} км", + "percent": "{{value}} %", + "degrees": "{{value}}°", + "kilonewtons": "{{value}} кН", + "mps": "{{ms}} м/с, {{kmh}} км/год", + "ms": "{{value}} мс", + "minutes": "{{value}} хв" + }, + "parts": { + "hull": "Композитний корпус", + "chines": "Вилиці", + "sprayRails": "Бризковідбійники", + "payloadBay": "Відсік корисного навантаження", + "fuelTanks": "Паливні баки", + "engine": "Двигун", + "electronicsBay": "Електроніка", + "starlinkPanels": "Антени Starlink", + "backupPanel": "Резервна антена", + "cameraDome": "Купол камери", + "bowCamera": "Носова камера", + "intake": "Водозабір і решітка", + "duct": "Водозабірний канал", + "driveShaft": "Приводний вал", + "impeller": "Крильчатка", + "waterjet": "Водомет", + "stator": "Лопатки статора", + "nozzle": "Сопло", + "steeringNozzle": "Поворотне сопло", + "reverseBucket": "Заслінка заднього ходу", + "missileRails": "Ракетні напрямні", + "jetStream": "Струмінь водомета", + "bowWave": "Носова хвиля", + "spray": "Бризки", + "wake": "Кільватерний слід", + "wettedLength": "Змочена довжина днища", + "videoGhost": "Запізніле відео пілота", + "satLink": "Канал Starlink", + "backupLink": "Резервний канал", + "satellite": "Супутник Starlink", + "backupSatellite": "Резервний супутник", + "groundStation": "Наземна станція", + "ship": "Корабель на якорі", + "shipRadar": "Корабельний радар", + "companions": "Інші катери групи" + }, + "hull": { + "lift": "{{dynamic}} % завдяки швидкості, {{buoyant}} % завдяки плавучості", + "wetted": "{{m}} м, а без руху {{rest}} м", + "ratio": "У {{times}} раза більше за швидкість корпусу ({{hullSpeed}} вуз.)", + "ratioStill": "Стоїть, швидкість корпусу {{hullSpeed}} вуз." + }, + "jet": { + "flow": "{{kg}} кг за секунду, близько {{litres}} л", + "backing": "Гальмує, заслінку опущено", + "astern": "Заслінка повертає струмінь уперед", + "noEfficiency": "Немає без руху вперед", + "push": { + "left": "Сопло {{angle}}° ліворуч: ніс повертає ліворуч", + "right": "Сопло {{angle}}° праворуч: ніс повертає праворуч", + "straight": "Прямо назад: тримає курс", + "reverse": "Заслінку опущено: струмінь уперед і вниз" + } + }, + "link": { + "now": "{{m}} м позаду катера на {{kn}} вуз.", + "nowLost": "Без зв’язку немає й зображення", + "top": "{{m}} м на 42 вуз., {{lengths}} довжини катера", + "carrier": { + "satellite": "Starlink, два термінали", + "backup": "Резервна супутникова антена", + "lost": "Немає" + }, + "boat": { + "satellite": "Пілот веде катер за відео", + "backup": "Пілот керує через резервний канал", + "lost": "У моделі тримає заданий курс; що робить справжній, не оприлюднено" + } + }, + "horizon": { + "camera": "{{km}} км з об’єктива на висоті {{m}} м", + "detect": "Близько {{km}} км, {{min}} хв попередження на 42 вуз.", + "detectBeyond": "Не визначено: стандарт описує хвилювання лише до 4 балів", + "waves": { + "smooth": "Від {{from}} до {{to}} м, хвилювання 2 бали", + "slight": "Від {{from}} до {{to}} м, хвилювання 3 бали", + "moderate": "Від {{from}} до {{to}} м, хвилювання 4 бали", + "rough": "Від {{from}} до {{to}} м, хвилювання 5 балів" + }, + "hidden": { + "smooth": "Для такої гладі даних немає: хвилі нижчі за палубу 0,5 м", + "slight": "До половини часу: хвилі заввишки з палубу", + "moderate": "До половини часу: хвилі вищі за палубу", + "rough": "До половини часу: хвилі набагато вищі за палубу" + }, + "canvas": { + "radar": "Корабельний радар", + "boat": "Катер, 0,5 м заввишки", + "lineOfSight": "Пряма видимість", + "detection": "Очікуване виявлення", + "km": "{{value}} км", + "note": "Кривизну моря перебільшено" + } + }, + "fleet": { + "carries": { + "standard": "Корисне навантаження до {{kg}} кг у носі", + "missile": "Дві ракети Р-73 на напрямних; у моделі носовий відсік лишається" + }, + "cost": "Приблизно від {{from}} до {{to}} доларів", + "ship": "Близько {{value}} млн доларів" + }, + "sections": { + "overview": { + "title": "Швидкісний катер без людей на борту", + "p1": "Перед вами Magura V5, український морський дрон: швидкісний катер без людей на борту. Його виготовляє державна компанія «Спецтехноекспорт» для ГУР, воєнної розвідки України; уперше катер показали в липні 2023 року. Він завдовжки 5,5 м, піднімається над водою лише на 0,5 м і важить менше за тонну. Пілот здалеку керує ним через супутник.", + "p2": "Зовні видно низький сірий корпус із композиту, тканини з волокна, залитої смолою. На піднятому обтічнику на кормі стоять дві антени Starlink і резервна супутникова антена, ближче до носа низький купол із тепловізором і нерухома носова камера. Рухає катер водомет на кормі. Усередині відсік корисного навантаження до 320 кг, паливні баки й двигун у коробі, обкладеному піною, а над ним електроніка. Щоб їх побачити, увімкніть «Розріз» (C) або дочекайтеся розділу про водомет.", + "p3": "Модель показує один перехід. Катер відходить від сліпу, виходить на глісування й іде 22 вузлами, це близько 41 км/год; вузол означає одну морську милю за годину. Потім він мчить 42 вузлами, приблизно 78 км/год, разом із ще двома катерами, доки його ніс не дійде до сірого корабля на якорі.", + "p4": "За звичайної швидкості катер іде так само швидко, як справжній, але шлях скорочено до двох хвилин. Крейсерський хід на всю дальність, 450 морських миль, тобто приблизно 800 км, триває близько 20 годин, а тут 42 секунди; ривок теж коротший. Корабель стоїть за 1,5 км, а не за сотні кілометрів. Годинник показує час моделі, а шкалу часу можна перетягувати.", + "mode": "Корпус", + "covered": "Пройдено", + "done": "Прогрес переходу" + }, + "hull": { + "title": "Уже не пливе, а ковзає по воді", + "p1": "Коли катер стоїть, його тримає вода, яку він витісняє: це плавучість. Коли він розганяється, вода б’є в похиле днище й тисне вгору дедалі сильніше. Щойно цей тиск бере на себе більшу частину ваги, корпус піднімається й ковзає: це глісування. Задня частина днища завжди лишається у воді.", + "p2": "Кожне судно здіймає носову хвилю. На швидкості близько 5,7 вузла вона така ж довга, як цей 5,5-метровий корпус. Це швидкість корпусу, проста мірка для суден, які лише пливуть. Але це не межа: на 42 вузлах Magura йде приблизно в 7 разів швидше. Спершу катер долає горб опору: здіймає великі хвилі, а опір, сила, якою вода його стримує, різко зростає. Гострі ребра днища, вилиці, тут теж додають опору, а користь від них лише на глісуванні.", + "p3": "Посуньте повзунок і стежте за носом. Найвище він піднімається біля горба, а потім опускається. Цей кут називають диферентом; опір найменший, коли він від 3 до 4 градусів. Глісування починається десь від 15 до 18 вузлів, а модель перемикається на 16. Змочена частина днища в моделі без руху має 5,1 м, на 15 вузлах близько 4,5 м, а на 42 вузлах від 1,5 до 2 м. Даних про горб не оприлюднено, тож пік у 6 градусів і розподіл ваги модель обрала сама.", + "p4": "Днище має форму глибокої літери V, що пом’якшує удари хвиль. Справжній кут не оприлюднено, тож модель бере 20 градусів на кормі й гостріший кут ближче до носа. Від гострого нижнього краю плоскої корми вода відривається чисто. Тонкий бризковідбійник уздовж кожного борту, припущення моделі, відкидає бризки вбік. Завеликий диферент змушує катер на глісуванні підстрибувати: це дельфінування.", + "speedLabel": "Швидкість катера", + "mode": "Корпус", + "trim": "Кут носа (диферент)", + "lift": "Що тримає вагу", + "wetted": "Змочена довжина днища", + "ratio": "Порівняно зі швидкістю корпусу" + }, + "jet": { + "title": "Насос замість гвинта", + "p1": "У розрізі видно шлях енергії: паливні баки живлять двигун, а його приводний вал обертає насос на кормі, водомет. Вода заходить через решітку в днищі. Крильчатка, ротор, схожий на вентилятор, обертається й розганяє її; нерухомі лопатки, статор, прибирають закручування потоку; сопло викидає струмінь назад.", + "p2": "Рушійна сила з’являється, бо насос розганяє воду: тяга дорівнює масі води за секунду, помноженій на те, наскільки струмінь швидший за катер. Навіть ідеальний насос перетворює на рух лише 2 / (1 + швидкість струменя / швидкість катера) своєї роботи: 80 %, коли струмінь у 1,5 раза швидший за катер, і набагато менше на малій швидкості.", + "p3": "Десь від 25 до 30 вузлів і вище водомет не поступається гвинту або перевершує його, а оскільки під корпусом нічого немає, катер може ходити мілководдям. Решітка здебільшого очищається сама, але водорості й мотузки все ще можуть її забити. Перший катер цієї лінійки, що з’явився у травні 2022 року, мав підвісний мотор; пізніші катери 2022 року, як-от той, що знайшли біля Севастополя, мали двигун Rotax від гідроцикла і насос Sea-Doo. Двигун Magura не розголошують, тож модель припускає бензиновий двигун гідроциклового типу, що обертає насос безпосередньо. Насос прокачує, за оцінкою, 210 літрів за секунду на 42 вузлах.", + "p4": "Керма немає. Поворотне сопло відхиляє струмінь до 27 градусів у кожен бік: відхиліть його праворуч, і ніс поверне праворуч. Для зупинки чи заднього ходу на сопло опускається заслінка заднього ходу й спрямовує струмінь уперед і вниз. Повзунок задає газ, який модель вважає витратою насоса. Натисніть відтворення, щоб повернутися до переходу.", + "throttleLabel": "Газ", + "boat": "Швидкість катера", + "flow": "Вода через насос", + "velocity": "Швидкість струменя", + "thrust": "Тяга", + "efficiency": "Ідеальний ККД", + "push": "Дія струменя" + }, + "link": { + "title": "Керують ним за сотні кілометрів", + "p1": "За російськими даними, на катері Magura, захопленому в Криму в листопаді 2023 року, були два термінали Starlink, резервна супутникова антена Kymeta, GPS, комп’ютер на Linux і роутер на дві SIM-картки для зв’язку біля берега. Виробник також заявляє про mesh-радіомережу, автономне керування й до трьох відеопотоків.", + "p2": "Пілот сидить за пультом у міцному кейсі. Довгі переходи можна запрограмувати заздалегідь, але пілот стежить усю дорогу й часто веде останню ділянку вручну, тож катер не повністю автономний. Що робить справжній катер, коли зв’язок обривається, не оприлюднено; модель тримає його на курсі.", + "p3": "Звичайна затримка Starlink на суходолі від 25 до 35 мс, а в морі може перевищувати 100 мс. Чіткість картинки визначає швидкість передавання з катера, від 10 до 30 мегабіт за секунду, приблизно стільки, скільки потрібно трьом відеопотокам. Наскільки картинка запізнюється, не оприлюднено. На 42 вузлах чверть секунди дорівнює 5,4 м, приблизно довжині катера; катер-привид показує, де його бачить пілот.", + "p4": "Глушіння й підміну GPS над Чорним морем документують із середини 2024 року; імовірно, тому виробник згадує й інерціальну та візуальну навігацію. У вересні 2022 року, за версією Ілона Маска й виправленого видання книжки 2023 року про нього, Україна розраховувала на покриття Starlink біля Криму, якого ніколи не вмикали; спершу в книжці писали, що його вимкнули. Супутники тут намальовано низько, близько й набагато більшими за справжні.", + "delayLabel": "Затримка відео", + "now": "Де його бачить пілот", + "top": "На максимальній швидкості", + "carrier": "Канал зв’язку", + "boat": "Що робить катер", + "path": "Як команда потрапляє на катер", + "step1": "Пілот: рухає важіль або джойстик.", + "step2": "Наземна станція: надсилає команду вгору радіосигналом.", + "step3": "Супутник: передає її вниз, на катер.", + "step4": "Катер: комп’ютер змінює газ або повертає сопло.", + "step5": "Відео: повертається тим самим шляхом, з невеликим запізненням." + }, + "horizon": { + "title": "Такий низький, що ховається за кривизною моря", + "p1": "Море вигинається, тож з висоти h метрів горизонт лежить приблизно за 3,86 × √h км. Камера катера на висоті близько 0,7 м бачить лише приблизно на 3 км. Море в 3D пласке, бо на 1,5 км справжня поверхня опускається менш ніж на 0,2 м; на малюнку нижче кривизну перебільшено.", + "p2": "Корабельний радар упирається в ту саму кривизну. Радіохвилі трохи огинають Землю, тож його пряма видимість до катера заввишки 0,5 м становить приблизно 4,12 × (√h1 + √h2) км, висоти в метрах: близько 21 км для радара на висоті 20 м. Це лише найбільша відстань, на якій він міг би побачити катер, і навіть тоді корабель мав би щонайбільше близько 16 хвилин попередження, якщо катер іде 42 вузлами.", + "p3": "Насправді катер помічають пізніше. Ефективна площа розсіювання невеликого катера, тобто наскільки великим він здається радару, становить приблизно від 0,5 до 5 м²; для Magura її не оприлюднено. Стандарт для берегових радарів передбачає, що радар вищого класу виявить ціль 1 м² на висоті 1 м приблизно за 9 км за хвилювання до 4 балів (хвилі від 1,25 до 2,5 м): це близько 7 хвилин попередження для корабля, якщо катер іде 42 вузлами. Невеликі катери можуть ховатися за хвилями до половини часу, а морські завади, відбиття від самих хвиль, можуть заглушити їхній сигнал.", + "p4": "Оператори кажуть, що ці катери важко помітити: командир «Групи 13» ГУР казав, що радару важко побачити пластиковий корпус, а катер виділяє мало тепла. Це заяви; жодних вимірювань не оприлюднено. За російським розбором, короб двигуна, обкладений піною, зменшує нагрівання зовнішньої обшивки. Катери сірі, а найвідоміші атаки відбувалися вночі або на світанку. Натискайте кнопки, щоб змінити хвилювання моря.", + "caption": "Вигляд збоку на радар, море й катер; кривизну й висоти перебільшено.", + "heightLabel": "Висота радара", + "radar": "Пряма видимість радара", + "minutes": "Попередження для корабля (катер на 42 вуз.)", + "camera": "Горизонт камери", + "detect": "Очікуване виявлення", + "waves": "Висота хвиль", + "hidden": "Ховається за хвилями" + }, + "fleet": { + "title": "Дешеві, малі й мінливі", + "p1": "Magura V5 коштує приблизно чверть мільйона доларів. Sea Baby, інший катер, який будує СБУ, Служба безпеки України, коштує близько 221 000 доларів. Патрульний корабель «Сергій Котов», що затонув у березні 2024 року після атаки Magura, ГУР оцінює приблизно в 65 мільйонів доларів.", + "p2": "Катери рідко діють поодинці: під час багатьох атак, про які повідомляли, їх було приблизно від п’яти до десяти, і, за словами розробників, кожен мав свою роль. Кораблі, про потоплення яких повідомляли, отримали по кілька влучань. Два інші катери в переході та їхнє шикування обрано для моделі.", + "p3": "Конструкція весь час змінюється. У грудні 2024 року ГУР встановило на катери V5 по дві ракети «повітря-повітря» Р-73, перейменовані на Sea Dragon; кнопки показують це оснащення, яке змінює лише палубу, а не перехід. У 2025 році ГУР показало більший катер Magura V7 завдовжки 7,2 м, з корисним навантаженням до 650 кг.", + "p4": "У переліку нижче лише слова кожної зі сторін, і чимало заяв спірні. Міністерство оборони Великої Британії нараховує щонайменше 26 кораблів ВМФ Росії, пошкоджених або знищених з лютого 2022 до червня 2024 року всіма видами зброї, це приблизно третина Чорноморського флоту.", + "carries": "На борту", + "cost": "Ціна одного катера", + "ship": "Патрульний корабель, оцінка ГУР", + "events": "Відкриті дані, як про них повідомляли", + "event1": "29 жовтня 2022 р., Севастополь: морські й повітряні дрони влучили у фрегат «Адмірал Макаров» і тральщик; Росія визнала незначні пошкодження.", + "event2": "З 31 січня на 1 лютого 2024 р., озеро Донузлав: катери Magura V5 потопили корвет «Івановець»; ГУР заявляє про шість влучань, на відео видно близько трьох.", + "event3": "З 4 на 5 березня 2024 р., Керченська протока: катери Magura V5 потопили патрульний корабель «Сергій Котов»; за словами України, в атаці брали участь п’ять катерів.", + "event4": "31 грудня 2024 р., мис Тарханкут: за словами ГУР, катери Magura V5 збили ракетами Р-73 гелікоптери Мі-8, уперше для морського дрона; спершу ГУР заявляло про один, згодом про два.", + "event5": "2 травня 2025 р., біля Новоросійська: за словами ГУР, катери Magura V7 збили два Су-30СМ ракетами Sidewinder; Росія цього не підтвердила." + } + } +} diff --git a/explainers/naval-drone/locales/zh.json b/explainers/naval-drone/locales/zh.json new file mode 100644 index 00000000..a9684c5c --- /dev/null +++ b/explainers/naval-drone/locales/zh.json @@ -0,0 +1,322 @@ +{ + "meta": { + "title": "海上无人艇如何工作", + "eyebrow": "海上无人艇", + "tagline": "一艘没人驾驶的小艇:船体贴着水面滑行,靠水泵推进,通过卫星操控。", + "description": "用3D模型看Magura V5海上无人艇:船体怎样滑行,喷水推进器怎样推动它,卫星链路,还有低矮的小艇为什么难被发现。", + "summary": "滑行的船体、喷水推进器、卫星链路及其延迟、海面的弧度,还有不断变化的设计。", + "socialAlt": "黎明时分,一艘灰色的无人艇贴着海面疾驰,浪花从船底两侧的折角线溅起。旁边是标题:海上无人艇如何工作。" + }, + "stage": { + "label": "海上无人艇互动模型", + "gauge": "实时航行读数" + }, + "controls": { + "label": "无人艇模型控制", + "views": { + "cutaway": { + "name": "剖面", + "short": "剖面", + "hint": "剖面(C)" + }, + "flow": { + "name": "水流", + "short": "水流", + "hint": "水流(W)" + }, + "links": { + "name": "无线电链路", + "short": "链路", + "hint": "无线电链路(K)" + }, + "labels": { + "name": "标注", + "short": "标注", + "hint": "标注(L)" + } + }, + "fit": { + "label": "甲板配置" + }, + "fitOptions": { + "standard": "标准", + "missile": "导弹导轨" + }, + "momentOptions": { + "hullSpeed": "船体速度", + "humpPeak": "阻力峰顶", + "onPlane": "进入滑行", + "cruiseSpeed": "巡航", + "throttleUp": "开始冲刺", + "topSpeed": "最高速度", + "alongside": "抵达大船旁" + }, + "speedMarkOptions": { + "hullSpeed": "船体速度", + "hump": "阻力峰", + "planing": "滑行", + "cruise": "巡航", + "top": "最高速度" + }, + "helmOptions": { + "left": "左转", + "straight": "直行", + "right": "右转", + "reverse": "倒车" + }, + "linkOptions": { + "satellite": "星链", + "backup": "备用卫星", + "lost": "链路中断" + }, + "seaStateOptions": { + "smooth": "小浪", + "slight": "轻浪", + "moderate": "中浪", + "rough": "大浪" + } + }, + "timeline": { + "label": "航行时间", + "phases": "跳到航程的某个阶段", + "speed": "播放速度", + "value": "{{time}},{{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "相对正常速度", + "phase": { + "launch": "出发", + "hump": "阻力峰", + "cruise": "巡航", + "sprint": "冲刺", + "arrival": "抵达" + }, + "during": { + "launch": "小艇低速驶离下水滑道", + "hump": "小艇加速进入滑行", + "cruise": "小艇以22节巡航,几小时的航程被缩短", + "sprint": "小艇和另外两艘一起以42节冲刺", + "arrival": "小艇驶完最后170米,抵达大船" + }, + "jump": { + "launch": "跳到出发", + "hump": "跳到阻力峰", + "cruise": "跳到巡航", + "sprint": "跳到冲刺", + "arrival": "跳到抵达" + } + }, + "readouts": { + "clock": "航行时钟", + "speed": "航速", + "mode": "船体状态", + "distance": "离大船还有" + }, + "mode": { + "floating": "漂浮", + "hump": "爬越阻力峰", + "planing": "滑行" + }, + "units": { + "speed": "{{kn}}节,{{kmh}}千米/小时", + "m": "{{value}}米", + "km": "{{value}}千米", + "percent": "{{value}}%", + "degrees": "{{value}}°", + "kilonewtons": "{{value}}千牛", + "mps": "{{ms}}米/秒,{{kmh}}千米/小时", + "ms": "{{value}}毫秒", + "minutes": "{{value}}分钟" + }, + "parts": { + "hull": "复合材料船体", + "chines": "折角线", + "sprayRails": "防溅条", + "payloadBay": "载荷舱", + "fuelTanks": "油箱", + "engine": "发动机", + "electronicsBay": "电子设备", + "starlinkPanels": "星链天线", + "backupPanel": "备用天线", + "cameraDome": "摄像机罩", + "bowCamera": "船头摄像机", + "intake": "进水口和格栅", + "duct": "进水管道", + "driveShaft": "传动轴", + "impeller": "叶轮", + "waterjet": "喷水推进器", + "stator": "定子导叶", + "nozzle": "喷口", + "steeringNozzle": "转向喷口", + "reverseBucket": "倒车斗", + "missileRails": "导弹导轨", + "jetStream": "喷流", + "bowWave": "船头波", + "spray": "浪花", + "wake": "尾迹", + "wettedLength": "船底浸水长度", + "videoGhost": "操作员看到的延迟画面", + "satLink": "星链链路", + "backupLink": "备用链路", + "satellite": "星链卫星", + "backupSatellite": "备用卫星", + "groundStation": "地面站", + "ship": "锚泊的大船", + "shipRadar": "大船的雷达", + "companions": "同行的小艇" + }, + "hull": { + "lift": "{{dynamic}}%靠速度托起,{{buoyant}}%靠浮力", + "wetted": "{{m}}米,静止时{{rest}}米", + "ratio": "是船体速度{{hullSpeed}}节的{{times}}倍", + "ratioStill": "静止;船体速度为{{hullSpeed}}节" + }, + "jet": { + "flow": "每秒{{kg}}千克,约{{litres}}升", + "backing": "正在停船,倒车斗放下", + "astern": "被倒车斗折向前方", + "noEfficiency": "不前进时为零", + "push": { + "left": "喷口左偏{{angle}}°:船头左转", + "right": "喷口右偏{{angle}}°:船头右转", + "straight": "笔直向后:保持航向", + "reverse": "倒车斗放下:水流朝前下方" + } + }, + "link": { + "now": "航速{{kn}}节时,在小艇后方{{m}}米", + "nowLost": "链路中断时没有画面", + "top": "42节时{{m}}米,约{{lengths}}个艇长", + "carrier": { + "satellite": "星链,两台终端", + "backup": "备用卫星天线", + "lost": "无" + }, + "boat": { + "satellite": "操作员看着视频操控", + "backup": "操作员通过备用链路操控", + "lost": "模型里保持设定航向;真实情况没有公开" + } + }, + "horizon": { + "camera": "{{km}}千米,镜头离水面{{m}}米", + "detect": "约{{km}}千米,小艇42节时有{{min}}分钟预警", + "detectBeyond": "不在范围内:该标准只到4级海况", + "waves": { + "smooth": "{{from}}到{{to}}米,2级海况", + "slight": "{{from}}到{{to}}米,3级海况", + "moderate": "{{from}}到{{to}}米,4级海况", + "rough": "{{from}}到{{to}}米,5级海况" + }, + "hidden": { + "smooth": "这么平静时没有数据;浪低于0.5米高的甲板", + "slight": "最多一半时间:浪和甲板一样高", + "moderate": "最多一半时间:浪高过甲板", + "rough": "最多一半时间:浪远高过甲板" + }, + "canvas": { + "radar": "大船的雷达", + "boat": "小艇,高0.5米", + "lineOfSight": "视距", + "detection": "预计发现距离", + "km": "{{value}}千米", + "note": "海面弧度已夸大" + } + }, + "fleet": { + "carries": { + "standard": "船头载荷最多{{kg}}千克", + "missile": "导轨上两枚R-73导弹;模型保留了船头载荷舱" + }, + "cost": "约{{from}}到{{to}}美元", + "ship": "约{{value}}00万美元" + }, + "sections": { + "overview": { + "title": "快艇上,一个人也没有", + "p1": "这是Magura V5,一款乌克兰海上无人艇,也就是一艘没人驾驶的快艇。它由国营企业SpetsTechnoExport为乌克兰国防部情报总局(GUR)制造,2023年7月首次亮相。艇长5.5米,甲板离水面只有0.5米,重不到1吨。远方的操作员通过卫星操控它。", + "p2": "从外面看,它有一个低矮的灰色船体,用复合材料制成,也就是封在树脂里的纤维布。艇尾凸起的整流罩上有两副星链天线和一副备用卫星天线;靠近船头,低矮的罩子里装着热成像摄像机,旁边是一台固定的船头摄像机。艇尾的喷水推进器推着它走。船体里有最多装320千克的载荷舱、油箱和发动机,发动机装在垫着泡沫的铝槽里,上方是电子设备。打开剖面(C)就能看到,也可以等到讲喷水推进器的那一章。", + "p3": "模型演示一次出航。小艇驶离下水滑道,进入滑行,以22节巡航,约合每小时41千米;1节就是每小时1海里。接着它和另外两艘小艇一起以42节冲刺,约合每小时78千米,直到船头抵达一艘锚泊的灰色大船。", + "p4": "按正常速度播放时,小艇跑得和真艇一样快,但航程压缩成了两分钟:跑完450海里(约800千米)的最大航程,要巡航约20小时,这里只有42秒,冲刺也缩短了。大船只在1.5千米外,而不是几百千米外。时钟按模型时间计时;拖动时间轴可以跳转。", + "mode": "船体状态", + "covered": "已航行距离", + "done": "航程进度" + }, + "hull": { + "title": "不再浮着,开始滑行", + "p1": "静止时,小艇排开的水把它托住,这叫浮力。小艇一加速,水撞上倾斜的船底,往上推的力越来越大。等这股力托起大部分重量,船体就会抬起,贴着水面掠过:这就是滑行。船底靠后的部分始终留在水里。", + "p2": "每艘船都会推起一道船头波。航速约5.7节时,这道波和5.5米的船体一样长,这就是船体速度。这是只靠浮力漂浮的船的经验法则,并不是上限:Magura跑到42节时,约是它的7倍。小艇先要爬过阻力峰,这时它掀起大浪,阻力,也就是水拖住它的力,急剧上升。船底两侧锋利的棱边叫折角线,在这里也会增加阻力,要等滑行起来才显出好处。", + "p3": "拖动滑块,留意船头。快到阻力峰时它抬得最高,然后慢慢放低:这个角度叫纵倾,约3到4度时阻力最小。小艇大约在15到18节开始滑行,模型在16节切换。模型里静止时船底浸水长5.1米,15节时约4.5米,到42节只剩1.5到2米。阻力峰数据没有公开,所以6度的峰值和重量怎么分担,都是模型自己定的。", + "p4": "船底是很深的V形,能减轻海浪的撞击;真实角度没有公开,模型在艇尾用20度,越往前越尖。方形艇尾的下缘很锋利,水流在这里干净地脱开。两侧各有一条细细的防溅条,把浪花往外甩,这是模型的猜测。纵倾太大,滑行艇会一起一伏地跳,这叫海豚运动。", + "speedLabel": "航速", + "mode": "船体状态", + "trim": "船头角度(纵倾)", + "lift": "重量由谁托起", + "wetted": "船底浸水长度", + "ratio": "与船体速度相比" + }, + "jet": { + "title": "用水泵代替螺旋桨", + "p1": "把船体剖开,跟着动力走:油箱给发动机供油,发动机的传动轴带动艇尾的水泵,也就是喷水推进器。水从船底的格栅进来。旋转的叶轮是个像风扇的转子,给水加速;固定的叶片叫定子,消掉水流的旋转;最后喷口把水朝后喷出。", + "p2": "小艇靠给水泵里的水加速来前进:推力等于每秒流过的水的质量,乘以喷流比小艇快出的速度。即使水泵完美无缺,用来推动小艇的功也只占 2 / (1 + 喷流速度 / 艇速):喷流是艇速的1.5倍时为80%,低速时要少得多。", + "p3": "航速超过约25到30节,喷水推进器就能赶上甚至超过螺旋桨;船体下面没有东西,小艇还能在浅水里跑。格栅大多能自己保持通畅,但水草和绳子仍可能把它堵住。这个系列的第一艘艇出现在2022年5月,装的是舷外机;2022年晚些时候的小艇,比如在塞瓦斯托波尔附近发现的那艘,用的是Rotax水上摩托发动机和Sea-Doo水泵。Magura的发动机没有公开,所以模型假定它是水上摩托那类汽油发动机,直接带动水泵;在42节时,水泵每秒约抽送210升水,这是估算。", + "p4": "它没有舵。转向喷口能把水流往左右各偏最多27度:往右偏,船头就向右转。要停船或倒车时,倒车斗落下罩住喷口,把水流转向前下方。滑块控制油门,模型把它当作水泵的流量。按播放键回到航程。", + "throttleLabel": "油门", + "boat": "航速", + "flow": "流过水泵的水", + "velocity": "喷流速度", + "thrust": "推力", + "efficiency": "理想效率", + "push": "喷流方向" + }, + "link": { + "title": "在几百千米外遥控", + "p1": "据俄方说法,2023年11月在克里米亚缴获的一艘Magura上,有两台星链终端、一副Kymeta备用卫星天线、GPS、一台Linux电脑,还有一台近岸使用的双SIM卡路由器。制造商还声称它有网状无线电、自主控制和最多三路视频流。", + "p2": "操作员面前是一台装在硬箱里的控制台。长途航行可以事先编程,但操作员全程盯着,最后一段常常手动操控,所以小艇并不是完全自主的。真实的小艇断了链路会怎样,没有公开;模型让它保持航向。", + "p3": "星链的典型延迟在陆地上约25到35毫秒,在海上可能超过100毫秒。上传速度为每秒10到30兆比特,大约够三路视频流,它决定画面有多清晰。画面晚到多久,没有公开。在42节时,四分之一秒就是5.4米,约一个艇长;影子小艇就是操作员看到的位置。", + "p4": "从2024年年中开始,黑海上空的GPS干扰和伪造已有记录,这很可能就是制造商还列出惯性导航和视觉导航的原因。据埃隆·马斯克的说法和一本2023年出版、后经更正的马斯克传记,2022年9月,乌克兰以为克里米亚附近有星链覆盖,可那里从未开通;这本书起初说是被关掉了。这里的卫星画得又低又近,比真实的大得多。", + "delayLabel": "视频延迟", + "now": "操作员看到的位置", + "top": "最高速度时", + "carrier": "链路", + "boat": "小艇怎么做", + "path": "指令怎样传到小艇", + "step1": "操作员:推一下推杆或摇杆。", + "step2": "地面站:用无线电把指令发上去。", + "step3": "卫星:把指令转发给小艇。", + "step4": "小艇:电脑调节油门或喷口。", + "step5": "视频:沿原路传回,晚到一点。" + }, + "horizon": { + "title": "矮到能躲在海面的弧度后面", + "p1": "海面是弯的,所以从h米高处望去,地平线约在3.86 × √h千米外。小艇的摄像机离水面约0.7米,只能看到约3千米远。3D海面是平的,因为在1.5千米内,真实的弧度下降不到0.2米;下面的图把弧度夸大了。", + "p2": "大船的雷达也受海面弧度限制。无线电波会稍稍弯曲,所以雷达对0.5米高小艇的视距约为4.12 × (√h1 + √h2)千米,高度以米为单位:雷达高20米时约21千米。这只是它最远可能看到小艇的距离;即使这样,面对42节的小艇,大船最多也只有约16分钟预警。", + "p3": "真正发现小艇要晚一些。小艇的雷达截面积,即它在雷达眼里有多大,约0.5到5平方米;Magura的数据没有公开。一项海岸雷达标准要求,先进雷达在4级及以下海况(浪高1.25到2.5米)中,应在约9千米处发现离海面1米、大小1平方米的目标。面对42节的小艇,这只留下约7分钟预警。小艇最多一半时间会藏在浪后,海杂波,也就是海浪自己的回波,还可能淹没小艇的回波。", + "p4": "操作这些小艇的人说,它们很难被发现:情报总局第13小组的一名指挥官说,雷达很难看到塑料船体,小艇散发的热量也很少。这些是他们的说法,没有公布任何测量结果。据俄方拆解报告,发动机所在的铝槽嵌在泡沫里,能减少外壳受热。小艇漆成灰色,最出名的几次袭击都在夜里或黎明。点按钮可以改变海况。", + "caption": "侧视图:雷达、海面和小艇;弧度和高度已夸大。", + "heightLabel": "雷达高度", + "radar": "雷达视距", + "minutes": "小艇42节时,大船的预警时间", + "camera": "摄像机地平线", + "detect": "预计发现距离", + "waves": "浪高", + "hidden": "藏在浪后" + }, + "fleet": { + "title": "便宜、小巧,变化很快", + "p1": "一艘Magura V5大约值25万美元。Sea Baby是乌克兰安全局(SBU)造的另一款无人艇,约22.1万美元。2024年3月,“谢尔盖·科托夫”号巡逻舰遭Magura袭击后沉没,情报总局估计它值约6500万美元。", + "p2": "这些小艇很少单独行动:据报道,许多袭击都是约五到十艘一组出动,研发人员说每艘各有分工。据报道被击沉的船都中了好几次。这次出航里的两艘同行小艇和它们的队形,是模型自己定的。", + "p3": "这款设计一直在变。2024年12月,情报总局给V5小艇装上两枚R-73空空导弹,改称“海龙”;点按钮可以看这种配置,它只改变甲板,不改变航程。2025年,情报总局展示了更大的Magura V7,艇长7.2米,载荷最多650千克。", + "p4": "下面的记录只列出各方自己的说法,其中很多都有争议。英国国防部统计,2022年2月到2024年6月,各种武器共击伤或击毁至少26艘俄罗斯海军舰船,约占黑海舰队的三分之一。", + "carries": "艇上装载", + "cost": "每艘造价", + "ship": "巡逻舰,情报总局估价", + "events": "公开记录,据报道", + "event1": "2022年10月29日,塞瓦斯托波尔:海上和空中无人机袭击了“马卡罗夫海军上将”号护卫舰和一艘扫雷舰;俄罗斯只承认轻微损伤。", + "event2": "2024年1月31日至2月1日,多努兹拉夫湖:Magura V5击沉了“伊万诺韦茨”号导弹艇;情报总局称命中六次,视频里约三次。", + "event3": "2024年3月4日至5日,刻赤海峡:Magura V5击沉了“谢尔盖·科托夫”号巡逻舰;乌克兰称有五艘小艇参与。", + "event4": "2024年12月31日,塔尔汉库特角:情报总局称,Magura V5用R-73导弹击落了Mi-8直升机,是海上无人艇首次击落飞机;它起初说一架,后来说两架。", + "event5": "2025年5月2日,新罗西斯克附近:情报总局称,Magura V7用“响尾蛇”导弹击落两架Su-30SM战机;俄罗斯尚未证实。" + } + } +} diff --git a/explainers/naval-drone/public/cover.webp b/explainers/naval-drone/public/cover.webp new file mode 100644 index 00000000..a1b47c83 Binary files /dev/null and b/explainers/naval-drone/public/cover.webp differ diff --git a/explainers/naval-drone/public/social/og-image.png b/explainers/naval-drone/public/social/og-image.png new file mode 100644 index 00000000..46c5a97b Binary files /dev/null and b/explainers/naval-drone/public/social/og-image.png differ diff --git a/explainers/naval-drone/src/ids.ts b/explainers/naval-drone/src/ids.ts new file mode 100644 index 00000000..e5afd423 --- /dev/null +++ b/explainers/naval-drone/src/ids.ts @@ -0,0 +1,161 @@ +export const PART_IDS = [ + 'hull', + 'chines', + 'sprayRails', + 'payloadBay', + 'fuelTanks', + 'engine', + 'electronicsBay', + 'starlinkPanels', + 'backupPanel', + 'cameraDome', + 'bowCamera', + 'intake', + 'duct', + 'driveShaft', + 'impeller', + 'waterjet', + 'stator', + 'nozzle', + 'steeringNozzle', + 'reverseBucket', + 'missileRails', + 'jetStream', + 'bowWave', + 'spray', + 'wake', + 'wettedLength', + 'videoGhost', + 'satLink', + 'backupLink', + 'satellite', + 'backupSatellite', + 'groundStation', + 'ship', + 'shipRadar', + 'companions', +] as const; + +export type PartId = (typeof PART_IDS)[number]; + +export const PHASE_IDS = ['launch', 'hump', 'cruise', 'sprint', 'arrival'] as const; + +export type PhaseId = (typeof PHASE_IDS)[number]; + +export const MOMENT_IDS = [ + 'hullSpeed', + 'humpPeak', + 'onPlane', + 'cruiseSpeed', + 'throttleUp', + 'topSpeed', + 'alongside', +] as const; + +export type MomentId = (typeof MOMENT_IDS)[number]; + +export const PRESET_IDS = ['overview', 'hull', 'jet', 'link', 'horizon', 'fleet'] as const; + +export type PresetId = (typeof PRESET_IDS)[number]; + +export const FIT_IDS = ['standard', 'missile'] as const; + +export type FitId = (typeof FIT_IDS)[number]; + +export const HELM_IDS = ['left', 'straight', 'right', 'reverse'] as const; + +export type HelmId = (typeof HELM_IDS)[number]; + +export const LINK_MODES = ['satellite', 'backup', 'lost'] as const; + +export type LinkMode = (typeof LINK_MODES)[number]; + +export const SEA_STATE_IDS = ['smooth', 'slight', 'moderate', 'rough'] as const; + +export type SeaStateId = (typeof SEA_STATE_IDS)[number]; + +export const SPEED_MARK_IDS = ['hullSpeed', 'hump', 'planing', 'cruise', 'top'] as const; + +export type SpeedMarkId = (typeof SPEED_MARK_IDS)[number]; + +export const HULL_MODES = ['floating', 'hump', 'planing'] as const; + +export type HullMode = (typeof HULL_MODES)[number]; + +export type CameraView = 'chase' | 'waterline' | 'stern' | 'sky' | 'eye' | 'group'; + +export type RegionId = 'scene' | 'boat' | 'ship' | 'shore'; + +export type AnchorId = + 'boat' | 'dome' | 'stern' | 'ship' | 'satellite' | 'backupSatellite' | 'groundStation'; + +export type Point = readonly [x: number, y: number, z: number]; + +export interface RoutePose { + position: Point; + heading: number; +} + +export type CompanionReading = RoutePose; + +export interface BoatReading extends RoutePose { + knots: number; + distance: number; + held: boolean; +} + +export interface PlaningReading { + knots: number; + mode: HullMode; + trim: number; + heave: number; + transomDepth: number; + wettedLength: number; + keelWettedLength: number; + chineWettedLength: number; + liftShare: number; + speedLengthRatio: number; +} + +export interface JetReading { + throttle: number; + flow: number; + jetSpeed: number; + thrust: number; + efficiency: number; + impellerShare: number; + nozzleAngle: number; + bucket: number; +} + +export interface SeaReading { + state: SeaStateId; + waveHeight: number; +} + +export interface LinkReading { + mode: LinkMode; + ghost: RoutePose | null; +} + +export interface ViewOptions { + cutaway: boolean; + flow: boolean; + links: boolean; + labels: boolean; +} + +export interface AssemblyState { + phase: number; + playing: boolean; + boat: BoatReading; + planing: PlaningReading; + jet: JetReading; + companions: readonly CompanionReading[]; + sea: SeaReading; + link: LinkReading; + fit: FitId; + view: ViewOptions; + waterSection: boolean; + wettedBar: boolean; +} diff --git a/explainers/naval-drone/src/index.ts b/explainers/naval-drone/src/index.ts new file mode 100644 index 00000000..7ac55114 --- /dev/null +++ b/explainers/naval-drone/src/index.ts @@ -0,0 +1,28 @@ +import './style.css'; +import { defineExplainer } from '@core/explainer'; +import { SCENE_OPTIONS, mountNavalDroneScene } from './scene'; +import { PART_INFO } from './scene/partInfo'; +import { PRESETS, createNavalDroneStore } from './state'; +import type { NavalDroneStoreState } from './state'; +import { NAVAL_DRONE_TIMELINE } from './timeline'; +import { + CHAPTER_ACTIONS, + NAVAL_DRONE_CHOICES, + NAVAL_DRONE_READOUTS, + VIEW_TOGGLES, + mountNavalDroneUi, +} from './ui'; + +export default defineExplainer({ + id: 'naval-drone', + timeline: NAVAL_DRONE_TIMELINE, + presets: PRESETS, + parts: PART_INFO, + createStore: () => createNavalDroneStore(), + dock: { choices: NAVAL_DRONE_CHOICES, toggles: VIEW_TOGGLES }, + readouts: NAVAL_DRONE_READOUTS, + actions: CHAPTER_ACTIONS, + scene: SCENE_OPTIONS, + mountScene: mountNavalDroneScene, + mountUi: mountNavalDroneUi, +}); diff --git a/explainers/naval-drone/src/model/fleet.test.ts b/explainers/naval-drone/src/model/fleet.test.ts new file mode 100644 index 00000000..baaedc03 --- /dev/null +++ b/explainers/naval-drone/src/model/fleet.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { BOAT_COST_USD, KOTOV_VALUE_MUSD, PAYLOAD_MAX_KG } from './fleet'; + +describe('fleet figures', () => { + it('costs about a quarter of a million dollars and carries up to 320 kg', () => { + expect(BOAT_COST_USD).toEqual([250000, 273000]); + expect(PAYLOAD_MAX_KG).toBe(320); + }); + + it('quotes the patrol ship at the value GUR gave', () => { + expect(KOTOV_VALUE_MUSD).toBe(65); + }); +}); diff --git a/explainers/naval-drone/src/model/fleet.ts b/explainers/naval-drone/src/model/fleet.ts new file mode 100644 index 00000000..75c1f386 --- /dev/null +++ b/explainers/naval-drone/src/model/fleet.ts @@ -0,0 +1,3 @@ +export const BOAT_COST_USD = [250000, 273000] as const; +export const PAYLOAD_MAX_KG = 320; +export const KOTOV_VALUE_MUSD = 65; diff --git a/explainers/naval-drone/src/model/horizon.test.ts b/explainers/naval-drone/src/model/horizon.test.ts new file mode 100644 index 00000000..a68ccae8 --- /dev/null +++ b/explainers/naval-drone/src/model/horizon.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + DETECTION_KM, + LENS_HEIGHT_M, + RADAR_HEIGHT_M, + detectionCovered, + hiddenUpToHalf, + minutesAtTopSpeed, + radarLineOfSightKm, + surfaceDropM, + visualHorizonKm, +} from './horizon'; + +describe('horizon', () => { + it('sees a 0.5 m boat from a 20 m radar out to about 21 km', () => { + expect(radarLineOfSightKm(20)).toBeCloseTo(21.3, 1); + expect(RADAR_HEIGHT_M).toEqual({ min: 5, max: 50, step: 1, default: 20 }); + }); + + it('puts the horizon about 3 km from the 0.7 m camera', () => { + expect(LENS_HEIGHT_M).toBe(0.7); + expect(visualHorizonKm(LENS_HEIGHT_M)).toBeCloseTo(3.2, 1); + }); + + it('crosses 21 km at 42 kn in about 16 minutes and the 9 km detection range in about 7', () => { + expect(minutesAtTopSpeed(radarLineOfSightKm(20))).toBeCloseTo(16.5, 1); + expect(DETECTION_KM).toBe(9.26); + expect(minutesAtTopSpeed(DETECTION_KM)).toBeCloseTo(7.1, 1); + }); + + it('bends the sea so the line of sight grazes it at the radar horizon', () => { + const radarHeight = 20; + const horizonKm = radarLineOfSightKm(radarHeight) - radarLineOfSightKm(0); + expect(surfaceDropM(horizonKm)).toBeCloseTo(radarHeight, 9); + expect(surfaceDropM(0)).toBe(0); + }); + + it('expects detection only up to a moderate sea, sea state 4', () => { + expect(detectionCovered('smooth')).toBe(true); + expect(detectionCovered('moderate')).toBe(true); + expect(detectionCovered('rough')).toBe(false); + }); + + it('hides the boat behind waves up to half the time from a slight sea up', () => { + expect(hiddenUpToHalf('smooth')).toBe(false); + expect(hiddenUpToHalf('slight')).toBe(true); + expect(hiddenUpToHalf('rough')).toBe(true); + }); +}); diff --git a/explainers/naval-drone/src/model/horizon.ts b/explainers/naval-drone/src/model/horizon.ts new file mode 100644 index 00000000..e2fa5bba --- /dev/null +++ b/explainers/naval-drone/src/model/horizon.ts @@ -0,0 +1,45 @@ +import { SEA_STATE_IDS } from '../ids'; +import type { SeaStateId } from '../ids'; +import { BOAT } from './layout'; +import { knotsToMs } from './scale'; + +export const RADAR_K = 4.12; +export const VISUAL_K = 3.86; +export const TARGET_HEIGHT_M = 0.5; +export const LENS_HEIGHT_M = BOAT.lensHeight; +export const RADAR_HEIGHT_M = { min: 5, max: 50, step: 1, default: 20 } as const; +export const DETECTION_KM = 9.26; +export const METRES_PER_KM = 1000; +export const SECONDS_PER_MINUTE = 60; + +const DETECTION_COVERED_UP_TO: SeaStateId = 'moderate'; +const HIDDEN_FROM: SeaStateId = 'slight'; + +export function radarLineOfSightKm(radarHeight: number): number { + return RADAR_K * (Math.sqrt(radarHeight) + Math.sqrt(TARGET_HEIGHT_M)); +} + +export function visualHorizonKm(eyeHeight: number): number { + return VISUAL_K * Math.sqrt(eyeHeight); +} + +export function minutesAtTopSpeed(km: number): number { + const seconds = (km * METRES_PER_KM) / knotsToMs(BOAT.topKnots); + return seconds / SECONDS_PER_MINUTE; +} + +export function surfaceDropM(km: number): number { + return (km / RADAR_K) ** 2; +} + +function seaRank(state: SeaStateId): number { + return SEA_STATE_IDS.indexOf(state); +} + +export function detectionCovered(state: SeaStateId): boolean { + return seaRank(state) <= seaRank(DETECTION_COVERED_UP_TO); +} + +export function hiddenUpToHalf(state: SeaStateId): boolean { + return seaRank(state) >= seaRank(HIDDEN_FROM); +} diff --git a/explainers/naval-drone/src/model/hull.test.ts b/explainers/naval-drone/src/model/hull.test.ts new file mode 100644 index 00000000..24e4fdc9 --- /dev/null +++ b/explainers/naval-drone/src/model/hull.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { toDegrees } from '@core/math'; +import { + FLOATING_BELOW_KN, + HULL_SPEED_KN, + PLANING_FROM_KN, + TRANSOM_DEPTH_KEYS, + TRIM_KEYS_DEG, + WETTED_KEYS, + heaveFor, + liftShareAt, + modeAt, + planingAt, + speedLengthRatio, +} from './hull'; +import { BOAT } from './layout'; + +describe('hull model', () => { + it('gives the 5.7 kn hull speed of a 5.5 m boat', () => { + expect(HULL_SPEED_KN).toBe(5.7); + expect(speedLengthRatio(HULL_SPEED_KN)).toBeCloseTo(1.34, 2); + }); + + it('floats below 6 kn, climbs the hump and planes from 16 kn', () => { + expect(modeAt(0)).toBe('floating'); + expect(modeAt(FLOATING_BELOW_KN - 0.1)).toBe('floating'); + expect(modeAt(11)).toBe('hump'); + expect(modeAt(PLANING_FROM_KN)).toBe('planing'); + expect(modeAt(42)).toBe('planing'); + expect(PLANING_FROM_KN).toBeGreaterThanOrEqual(15); + expect(PLANING_FROM_KN).toBeLessThanOrEqual(18); + }); + + it('takes trim and wetted length from 15 kn up from the facts sheet', () => { + expect(toDegrees(planingAt(15).trim)).toBeCloseTo(4, 9); + expect(toDegrees(planingAt(22).trim)).toBeCloseTo(4, 9); + expect(toDegrees(planingAt(30).trim)).toBeCloseTo(3, 9); + expect(toDegrees(planingAt(42).trim)).toBeCloseTo(2.5, 9); + expect(planingAt(15).wettedLength).toBe(4.5); + expect(planingAt(22).wettedLength).toBe(3.2); + expect(planingAt(42).wettedLength).toBe(1.7); + }); + + it('lifts the bow most near the hump and settles to 3 to 4 degrees', () => { + const peak = Math.max(...TRIM_KEYS_DEG.map(([, trim]) => trim)); + expect(peak).toBe(6); + expect(toDegrees(planingAt(11).trim)).toBeCloseTo(peak, 9); + expect(peak).toBeLessThanOrEqual(15); + }); + + it('sits at its static draft at rest', () => { + const rest = planingAt(0); + expect(rest.trim).toBe(0); + expect(rest.heave).toBeCloseTo(0, 9); + expect(rest.transomDepth).toBe(BOAT.staticDraft); + expect(rest.wettedLength).toBe(BOAT.waterlineLength); + expect(rest.liftShare).toBe(0); + }); + + it('rises as it planes', () => { + expect(planingAt(11).heave).toBeCloseTo(0.146, 2); + expect(planingAt(22).heave).toBeCloseTo(0.2, 2); + expect(planingAt(42).heave).toBeCloseTo(0.28, 2); + expect(heaveFor(0, BOAT.staticDraft)).toBe(0); + }); + + it('puts the keel in the water ahead of the transom by depth over trim', () => { + const top = planingAt(42); + expect(top.keelWettedLength).toBeCloseTo(top.transomDepth / Math.sin(top.trim), 9); + expect(top.keelWettedLength).toBeCloseTo(3.67, 2); + expect(top.chineWettedLength).toBe(0); + const cruise = planingAt(22); + expect((cruise.keelWettedLength + cruise.chineWettedLength) / 2).toBeCloseTo(3.2, 9); + expect(planingAt(5.7).keelWettedLength).toBe(BOAT.waterlineLength); + }); + + it('splits the weight between speed and floating with Savitsky', () => { + expect(liftShareAt(5.7, 5.1)).toBeCloseTo(0.099, 2); + expect(planingAt(11).liftShare).toBeCloseTo(0.356, 2); + expect(planingAt(22).liftShare).toBeCloseTo(0.807, 2); + expect(planingAt(42).liftShare).toBeCloseTo(0.982, 2); + for (let knots = 1; knots <= 42; knots += 1) { + expect(planingAt(knots).liftShare).toBeGreaterThan(planingAt(knots - 1).liftShare); + } + }); + + it('clamps speeds to the boat range and keeps every key table ordered', () => { + expect(planingAt(60).knots).toBe(BOAT.topKnots); + expect(planingAt(-3).knots).toBe(0); + [TRIM_KEYS_DEG, WETTED_KEYS, TRANSOM_DEPTH_KEYS].forEach((keys) => { + keys.slice(1).forEach(([knots], index) => expect(knots).toBeGreaterThan(keys[index][0])); + }); + }); +}); diff --git a/explainers/naval-drone/src/model/hull.ts b/explainers/naval-drone/src/model/hull.ts new file mode 100644 index 00000000..68d8e20b --- /dev/null +++ b/explainers/naval-drone/src/model/hull.ts @@ -0,0 +1,114 @@ +import { clamp, toRadians } from '@core/math'; +import type { HullMode, PlaningReading } from '../ids'; +import { valueAt } from './keyframes'; +import type { Keyframes } from './keyframes'; +import { BOAT } from './layout'; +import { FEET_PER_METRE, GRAVITY, knotsToMs } from './scale'; + +export const HULL_SPEED_KN = 5.7; +export const FLOATING_BELOW_KN = 6; +export const PLANING_FROM_KN = 16; +export const HUMP_PEAK_KN = 11; +export const SAVITSKY_BEAM = 1.5; + +const SAVITSKY_SPEED_TERM = 0.012; +const SAVITSKY_BUOYANCY_TERM = 0.0055; +const SAVITSKY_SPEED_POWER = 0.5; +const SAVITSKY_BUOYANCY_POWER = 2.5; +const CV_VALID_FROM = 0.6; +const KEEL_TRIM_FROM = toRadians(1); +const MEAN_TO_SUM = 2; + +export const TRIM_KEYS_DEG: Keyframes = [ + [0, 0], + [4, 0.4], + [5.7, 1.2], + [8, 3.5], + [11, 6], + [13, 5.2], + [15, 4], + [22, 4], + [30, 3], + [42, 2.5], +]; + +export const WETTED_KEYS: Keyframes = [ + [0, 5.1], + [5.7, 5.1], + [8, 4.8], + [11, 4.4], + [13, 4.4], + [15, 4.5], + [22, 3.2], + [30, 2.8], + [42, 1.7], +]; + +export const TRANSOM_DEPTH_KEYS: Keyframes = [ + [0, 0.32], + [4, 0.33], + [5.7, 0.35], + [8, 0.42], + [11, 0.46], + [13, 0.42], + [15, 0.37], + [22, 0.31], + [30, 0.23], + [42, 0.16], +]; + +export function modeAt(knots: number): HullMode { + if (knots < FLOATING_BELOW_KN) return 'floating'; + if (knots < PLANING_FROM_KN) return 'hump'; + return 'planing'; +} + +export function beamFroude(knots: number): number { + return knotsToMs(knots) / Math.sqrt(GRAVITY * SAVITSKY_BEAM); +} + +function savitskySplit(lambda: number, cv: number): number { + const speedTerm = SAVITSKY_SPEED_TERM * lambda ** SAVITSKY_SPEED_POWER; + const buoyancyTerm = (SAVITSKY_BUOYANCY_TERM * lambda ** SAVITSKY_BUOYANCY_POWER) / (cv * cv); + return speedTerm / (speedTerm + buoyancyTerm); +} + +export function liftShareAt(knots: number, wettedLength: number): number { + const lambda = wettedLength / SAVITSKY_BEAM; + const cv = beamFroude(Math.max(knots, 0)); + if (cv >= CV_VALID_FROM) return savitskySplit(lambda, cv); + return savitskySplit(lambda, CV_VALID_FROM) * (cv / CV_VALID_FROM); +} + +export function heaveFor(trim: number, transomDepth: number): number { + return BOAT.halfLength * Math.sin(trim) + BOAT.staticDraft * Math.cos(trim) - transomDepth; +} + +export function keelWettedFor(trim: number, transomDepth: number): number { + if (trim < KEEL_TRIM_FROM) return BOAT.waterlineLength; + return Math.min(BOAT.waterlineLength, transomDepth / Math.sin(trim)); +} + +export function speedLengthRatio(knots: number): number { + return knots / Math.sqrt(BOAT.length * FEET_PER_METRE); +} + +export function planingAt(knots: number): PlaningReading { + const speed = clamp(knots, 0, BOAT.topKnots); + const trim = toRadians(valueAt(TRIM_KEYS_DEG, speed)); + const transomDepth = valueAt(TRANSOM_DEPTH_KEYS, speed); + const wettedLength = valueAt(WETTED_KEYS, speed); + const keelWettedLength = keelWettedFor(trim, transomDepth); + return { + knots: speed, + mode: modeAt(speed), + trim, + heave: heaveFor(trim, transomDepth), + transomDepth, + wettedLength, + keelWettedLength, + chineWettedLength: clamp(MEAN_TO_SUM * wettedLength - keelWettedLength, 0, keelWettedLength), + liftShare: liftShareAt(speed, wettedLength), + speedLengthRatio: speedLengthRatio(speed), + }; +} diff --git a/explainers/naval-drone/src/model/index.ts b/explainers/naval-drone/src/model/index.ts new file mode 100644 index 00000000..ea28f415 --- /dev/null +++ b/explainers/naval-drone/src/model/index.ts @@ -0,0 +1,12 @@ +export * from './fleet'; +export * from './horizon'; +export * from './hull'; +export * from './jet'; +export * from './keyframes'; +export * from './layout'; +export * from './link'; +export * from './playback'; +export * from './run'; +export * from './scale'; +export * from './sea'; +export * from './trial'; diff --git a/explainers/naval-drone/src/model/jet.test.ts b/explainers/naval-drone/src/model/jet.test.ts new file mode 100644 index 00000000..22254de4 --- /dev/null +++ b/explainers/naval-drone/src/model/jet.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { toDegrees } from '@core/math'; +import { + NOZZLE_AREA, + RESISTANCE_KEYS_KN, + STEER_MAX, + TOP_FLOW, + helmNozzle, + idealEfficiency, + jetAt, + knotsAtThrottle, + resistanceAt, + steadyFlowAt, + steadyJetSpeedAt, + throttleAt, +} from './jet'; +import { knotsToMs } from './scale'; + +describe('waterjet model', () => { + it('takes the drag at cruise and top speed from the facts sheet', () => { + expect(resistanceAt(22)).toBe(1650); + expect(resistanceAt(42)).toBe(2300); + RESISTANCE_KEYS_KN.slice(1).forEach(([, kn], index) => { + expect(kn).toBeGreaterThanOrEqual(RESISTANCE_KEYS_KN[index][1]); + }); + }); + + it('reproduces the facts sheet jet at 42 kn', () => { + expect(NOZZLE_AREA).toBe(0.0064); + expect(TOP_FLOW).toBeCloseTo(213, 0); + expect(steadyJetSpeedAt(42)).toBeCloseTo(32.4, 1); + const top = jetAt(1, 42, 0, false); + expect(top.thrust).toBeCloseTo(2300, 6); + expect(top.efficiency).toBeCloseTo(0.8, 2); + }); + + it('balances thrust and drag at every steady speed', () => { + [4, 5.7, 11, 15, 22, 30].forEach((knots) => { + const jet = jetAt(throttleAt(knots), knots, 0, false); + expect(jet.thrust).toBeCloseTo(resistanceAt(knots), 6); + expect(jet.flow).toBeCloseTo(steadyFlowAt(knots), 6); + }); + }); + + it('maps throttle to speed and back', () => { + expect(throttleAt(0)).toBe(0); + expect(throttleAt(42)).toBe(1); + expect(throttleAt(22)).toBeCloseTo(0.694, 2); + expect(throttleAt(11)).toBeCloseTo(0.554, 2); + [0.2, 0.5, 0.694, 0.9].forEach((share) => { + expect(throttleAt(knotsAtThrottle(share))).toBeCloseTo(share, 6); + }); + for (let knots = 1; knots <= 42; knots += 1) { + expect(throttleAt(knots)).toBeGreaterThan(throttleAt(knots - 1)); + } + }); + + it('is poor at low speed and good at high speed', () => { + expect(jetAt(throttleAt(5.7), 5.7, 0, false).efficiency).toBeLessThan(0.5); + expect(jetAt(throttleAt(22), 22, 0, false).efficiency).toBeCloseTo(0.67, 2); + expect(idealEfficiency(10, 0)).toBe(0); + expect(idealEfficiency(2 * knotsToMs(20), knotsToMs(20))).toBeCloseTo(2 / 3, 9); + }); + + it('steers 27 degrees each way and drops the bucket in reverse', () => { + expect(toDegrees(STEER_MAX)).toBeCloseTo(27, 9); + expect(helmNozzle('left')).toBe(-STEER_MAX); + expect(helmNozzle('right')).toBe(STEER_MAX); + expect(helmNozzle('straight')).toBe(0); + const reverse = jetAt(0.5, 0, helmNozzle('reverse'), true); + expect(reverse.bucket).toBe(1); + expect(reverse.thrust).toBe(0); + expect(reverse.efficiency).toBe(0); + expect(reverse.flow).toBeCloseTo(0.5 * TOP_FLOW, 9); + expect(jetAt(1, 42, 1, false).nozzleAngle).toBe(STEER_MAX); + }); +}); diff --git a/explainers/naval-drone/src/model/jet.ts b/explainers/naval-drone/src/model/jet.ts new file mode 100644 index 00000000..7307cf9e --- /dev/null +++ b/explainers/naval-drone/src/model/jet.ts @@ -0,0 +1,102 @@ +import { clamp, toRadians } from '@core/math'; +import type { HelmId, JetReading } from '../ids'; +import { valueAt } from './keyframes'; +import type { Keyframes } from './keyframes'; +import { BOAT } from './layout'; +import { SEAWATER_DENSITY, knotsToMs } from './scale'; + +export const NOZZLE_AREA = 0.0064; +export const NEWTONS_PER_KILONEWTON = 1000; +export const MAX_RPM = 8000; +export const STEER_MAX = toRadians(27); + +export const RESISTANCE_KEYS_KN: Keyframes = [ + [0, 0], + [4, 0.25], + [5.7, 0.5], + [8, 1], + [11, 1.45], + [15, 1.6], + [22, 1.65], + [30, 1.9], + [42, 2.3], +]; + +const FLOW_PER_JET_SPEED = SEAWATER_DENSITY * NOZZLE_AREA; +const QUADRATIC_FOUR = 4; +const IDEAL_EFFICIENCY_NUMERATOR = 2; +const BISECTION_STEPS = 48; + +const HELM_NOZZLE: Readonly> = { + left: -STEER_MAX, + straight: 0, + right: STEER_MAX, + reverse: 0, +}; + +function boatKnots(knots: number): number { + return clamp(knots, 0, BOAT.topKnots); +} + +export function resistanceAt(knots: number): number { + return valueAt(RESISTANCE_KEYS_KN, boatKnots(knots)) * NEWTONS_PER_KILONEWTON; +} + +export function steadyJetSpeedAt(knots: number): number { + const inflow = knotsToMs(boatKnots(knots)); + const pressure = (QUADRATIC_FOUR * resistanceAt(knots)) / FLOW_PER_JET_SPEED; + return (inflow + Math.sqrt(inflow * inflow + pressure)) / 2; +} + +export function steadyFlowAt(knots: number): number { + return FLOW_PER_JET_SPEED * steadyJetSpeedAt(knots); +} + +export const TOP_FLOW = steadyFlowAt(BOAT.topKnots); + +export function throttleAt(knots: number): number { + return clamp(steadyFlowAt(knots) / TOP_FLOW, 0, 1); +} + +export function knotsAtThrottle(share: number): number { + const target = clamp(share, 0, 1); + let low = 0; + let high: number = BOAT.topKnots; + for (let step = 0; step < BISECTION_STEPS; step += 1) { + const middle = (low + high) / 2; + if (throttleAt(middle) < target) low = middle; + else high = middle; + } + return (low + high) / 2; +} + +export function helmNozzle(helm: HelmId): number { + return HELM_NOZZLE[helm]; +} + +export function idealEfficiency(jetSpeed: number, inflow: number): number { + if (inflow <= 0 || jetSpeed <= 0) return 0; + return IDEAL_EFFICIENCY_NUMERATOR / (1 + jetSpeed / inflow); +} + +export function jetAt( + throttle: number, + knots: number, + nozzleAngle: number, + reverse: boolean, +): JetReading { + const share = clamp(throttle, 0, 1); + const flow = share * TOP_FLOW; + const jetSpeed = flow / FLOW_PER_JET_SPEED; + const inflow = knotsToMs(boatKnots(knots)); + return { + throttle: share, + flow, + jetSpeed, + thrust: reverse ? 0 : flow * (jetSpeed - inflow), + efficiency: reverse ? 0 : idealEfficiency(jetSpeed, inflow), + impellerShare: share, + nozzleAngle: clamp(nozzleAngle, -STEER_MAX, STEER_MAX), + bucket: reverse ? 1 : 0, + }; +} diff --git a/explainers/naval-drone/src/model/keyframes.test.ts b/explainers/naval-drone/src/model/keyframes.test.ts new file mode 100644 index 00000000..2790b889 --- /dev/null +++ b/explainers/naval-drone/src/model/keyframes.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { firstCrossing, integralTo, valueAt } from './keyframes'; +import type { Keyframes } from './keyframes'; + +const RAMP: Keyframes = [ + [0, 0], + [10, 10], + [20, 10], +]; + +describe('keyframes', () => { + it('interpolates between keys and holds the ends', () => { + expect(valueAt(RAMP, -5)).toBe(0); + expect(valueAt(RAMP, 5)).toBe(5); + expect(valueAt(RAMP, 15)).toBe(10); + expect(valueAt(RAMP, 25)).toBe(10); + }); + + it('integrates the area under the keys exactly', () => { + expect(integralTo(RAMP, 0)).toBe(0); + expect(integralTo(RAMP, 5)).toBeCloseTo(12.5, 9); + expect(integralTo(RAMP, 10)).toBeCloseTo(50, 9); + expect(integralTo(RAMP, 20)).toBeCloseTo(150, 9); + expect(integralTo(RAMP, 99)).toBeCloseTo(150, 9); + }); + + it('finds the first time a value is reached', () => { + expect(firstCrossing(RAMP, 0)).toBe(0); + expect(firstCrossing(RAMP, 4)).toBeCloseTo(4, 9); + expect(firstCrossing(RAMP, 10)).toBe(10); + expect(firstCrossing(RAMP, 11)).toBeNaN(); + }); +}); diff --git a/explainers/naval-drone/src/model/keyframes.ts b/explainers/naval-drone/src/model/keyframes.ts new file mode 100644 index 00000000..06fd3765 --- /dev/null +++ b/explainers/naval-drone/src/model/keyframes.ts @@ -0,0 +1,45 @@ +import { clamp } from '@core/math'; + +export type Keyframe = readonly [x: number, y: number]; +export type Keyframes = readonly Keyframe[]; + +function spanIndex(keys: Keyframes, x: number): number { + const index = keys.findIndex((key) => x <= key[0]); + if (index <= 0) return 0; + return index - 1; +} + +export function valueAt(keys: Keyframes, x: number): number { + const first = keys[0]; + const last = keys[keys.length - 1]; + if (x <= first[0]) return first[1]; + if (x >= last[0]) return last[1]; + const index = spanIndex(keys, x); + const [x0, y0] = keys[index]; + const [x1, y1] = keys[index + 1]; + return y0 + ((x - x0) / (x1 - x0)) * (y1 - y0); +} + +export function integralTo(keys: Keyframes, x: number): number { + const end = clamp(x, keys[0][0], keys[keys.length - 1][0]); + let total = 0; + for (let index = 0; index < keys.length - 1; index += 1) { + const [x0, y0] = keys[index]; + const x1 = keys[index + 1][0]; + if (end <= x0) break; + const to = Math.min(end, x1); + total += ((y0 + valueAt(keys, to)) / 2) * (to - x0); + } + return total; +} + +export function firstCrossing(keys: Keyframes, y: number): number { + for (let index = 0; index < keys.length - 1; index += 1) { + const [x0, y0] = keys[index]; + const [x1, y1] = keys[index + 1]; + if (y0 === y) return x0; + if ((y0 - y) * (y1 - y) < 0) return x0 + ((y - y0) / (y1 - y0)) * (x1 - x0); + } + const last = keys[keys.length - 1]; + return last[1] === y ? last[0] : Number.NaN; +} diff --git a/explainers/naval-drone/src/model/layout.test.ts b/explainers/naval-drone/src/model/layout.test.ts new file mode 100644 index 00000000..673f18b0 --- /dev/null +++ b/explainers/naval-drone/src/model/layout.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { toDegrees } from '@core/math'; +import { + BACKUP_PANEL_X, + BACKUP_SATELLITE_OFFSET, + BOAT, + BOAT_BOUNDS, + BOW_CAMERA, + DOME, + FAIRING, + FINAL_HEADING, + HULL_STATIONS, + JET, + PANEL, + SATELLITE_OFFSET, + STARLINK_PANEL_XS, + START, + STEM, + STUB, + TRANSOM_X, + VENT_BOX, + chineHeight, + skyPoint, + stemXAt, +} from './layout'; + +const SPEC_CHINE_HEIGHTS = [-0.083, -0.08, -0.078, -0.08, 0.002, 0.111, 0.22, 0.33]; + +describe('boat layout', () => { + it('takes the main dimensions from the facts sheet', () => { + expect(BOAT.length).toBe(5.5); + expect(BOAT.beam).toBe(1.5); + expect(BOAT.freeboard).toBe(0.5); + expect(BOAT.cruiseKnots).toBe(22); + expect(BOAT.topKnots).toBe(42); + expect(BOAT.halfLength * 2).toBe(BOAT.length); + expect(TRANSOM_X).toBe(-BOAT.halfLength); + }); + + it('runs the stations from the transom to the bow inside the beam', () => { + expect(HULL_STATIONS[0].x).toBe(TRANSOM_X); + expect(HULL_STATIONS[0].keel).toBe(-BOAT.staticDraft); + HULL_STATIONS.slice(1).forEach((station, index) => { + expect(station.x).toBeGreaterThan(HULL_STATIONS[index].x); + expect(station.deadrise).toBeGreaterThanOrEqual(HULL_STATIONS[index].deadrise); + }); + HULL_STATIONS.forEach((station) => { + expect(station.sheer[0]).toBeLessThanOrEqual(BOAT.beam / 2); + expect(station.knuckle[0]).toBeLessThanOrEqual(station.sheer[0]); + expect(station.chineHalfBreadth).toBeLessThan(station.knuckle[0]); + expect(station.deck).toBeGreaterThan(station.sheer[1]); + }); + expect(Math.max(...HULL_STATIONS.map((station) => station.sheer[0]))).toBe(BOAT.beam / 2); + }); + + it('keeps 20 degrees of deadrise aft and puts the chine where the spec says', () => { + expect(toDegrees(HULL_STATIONS[0].deadrise)).toBeCloseTo(20, 9); + HULL_STATIONS.forEach((station, index) => { + expect(chineHeight(station)).toBeCloseTo(SPEC_CHINE_HEIGHTS[index], 2); + }); + expect(chineHeight(HULL_STATIONS[0])).toBeLessThan(0); + }); + + it('rakes the stem so the static waterline is about 5.1 m long', () => { + expect(STEM.top[0]).toBe(BOAT.halfLength); + expect(stemXAt(0) - TRANSOM_X).toBeCloseTo(BOAT.waterlineLength, 1); + expect(stemXAt(STEM.top[1])).toBeCloseTo(STEM.top[0], 9); + }); + + it('orders the deck items from the stern forward on the centreline', () => { + const order = [ + VENT_BOX.x[0], + BACKUP_PANEL_X, + ...STARLINK_PANEL_XS, + STUB.x, + DOME.x, + BOW_CAMERA.x[0], + ]; + order.slice(1).forEach((x, index) => expect(x).toBeGreaterThan(order[index])); + expect(DOME.x).toBeCloseTo(BOAT.halfLength - BOAT.length / 3, 1); + }); + + it('keeps the fairing at the freeboard and the panels on top of it', () => { + expect(FAIRING.top).toBe(BOAT.freeboard); + expect(PANEL.top).toBeCloseTo(FAIRING.top + PANEL.thickness + PANEL.raise, 9); + expect(BACKUP_PANEL_X - PANEL.length / 2).toBeGreaterThan(VENT_BOX.x[1]); + expect(STARLINK_PANEL_XS[1] + PANEL.length / 2).toBeLessThan(FAIRING.frontTopX); + }); + + it('puts the dome lens 0.7 m above the water', () => { + expect(DOME.lens).toBe(0.7); + expect(DOME.lens).toBeLessThan(DOME.top); + expect(DOME.top).toBeCloseTo(DOME.base + DOME.height + DOME.radius, 9); + }); + + it('runs the jet on its axis just above the bottom of the transom', () => { + expect(JET.axisY).toBeGreaterThan(-BOAT.staticDraft); + expect(JET.axisY).toBeLessThan(0); + expect(JET.steeringNozzle.pivotX).toBe(JET.nozzle.x[0]); + expect(JET.nozzle.exitDiameter).toBe(0.09); + expect(BOAT_BOUNDS.x[0]).toBeLessThanOrEqual(JET.steeringNozzle.x[0]); + }); + + it('draws the satellites ahead and above in the frame of the final heading', () => { + expect(toDegrees(FINAL_HEADING)).toBeCloseTo(-20, 9); + expect(START.heading).toBe(0); + const main = skyPoint([0, 0, 0], SATELLITE_OFFSET); + expect(main[1]).toBe(SATELLITE_OFFSET.up); + expect(main[0] * Math.cos(FINAL_HEADING) + main[2] * Math.sin(FINAL_HEADING)).toBeCloseTo( + SATELLITE_OFFSET.ahead, + 9, + ); + const backup = skyPoint([10, 0, 5], BACKUP_SATELLITE_OFFSET); + const sideX = -Math.sin(FINAL_HEADING); + const sideZ = Math.cos(FINAL_HEADING); + expect((backup[0] - 10) * sideX + (backup[2] - 5) * sideZ).toBeCloseTo( + BACKUP_SATELLITE_OFFSET.side, + 9, + ); + }); +}); diff --git a/explainers/naval-drone/src/model/layout.ts b/explainers/naval-drone/src/model/layout.ts new file mode 100644 index 00000000..e00f1a3c --- /dev/null +++ b/explainers/naval-drone/src/model/layout.ts @@ -0,0 +1,256 @@ +import { toRadians } from '@core/math'; +import type { Pose } from '@core/path'; +import type { Point } from '../ids'; + +export type Extent = readonly [min: number, max: number]; +export type SectionPoint = readonly [z: number, y: number]; + +export interface Box { + x: Extent; + y: Extent; + z: Extent; +} + +export interface SkyOffset { + ahead: number; + up: number; + side: number; +} + +export interface HullStation { + x: number; + keel: number; + deadrise: number; + chineHalfBreadth: number; + knuckle: SectionPoint; + sheer: SectionPoint; + deck: number; +} + +export const BOAT = { + length: 5.5, + beam: 1.5, + freeboard: 0.5, + halfLength: 2.75, + staticDraft: 0.32, + waterlineLength: 5.1, + chineBeam: 1.3, + deadriseAft: toRadians(20), + mass: 1000, + lensHeight: 0.7, + cruiseKnots: 22, + topKnots: 42, +} as const; + +export const TRANSOM_X = -BOAT.halfLength; + +function station( + x: number, + keel: number, + deadriseDegrees: number, + chineHalfBreadth: number, + knuckle: SectionPoint, + sheer: SectionPoint, + deck: number, +): HullStation { + return { x, keel, deadrise: toRadians(deadriseDegrees), chineHalfBreadth, knuckle, sheer, deck }; +} + +export const HULL_STATIONS: readonly HullStation[] = [ + station(-2.75, -0.32, 20, 0.65, [0.72, 0.08], [0.74, 0.26], 0.31), + station(-2, -0.32, 20, 0.66, [0.73, 0.09], [0.75, 0.27], 0.32), + station(-1, -0.32, 20, 0.665, [0.73, 0.1], [0.75, 0.29], 0.34), + station(0, -0.32, 20, 0.66, [0.73, 0.12], [0.75, 0.33], 0.38), + station(1, -0.3, 26, 0.62, [0.7, 0.17], [0.72, 0.38], 0.43), + station(1.6, -0.24, 33, 0.54, [0.62, 0.23], [0.64, 0.42], 0.47), + station(2.1, -0.14, 42, 0.4, [0.48, 0.32], [0.5, 0.46], 0.5), + station(2.4, 0.02, 50, 0.26, [0.3, 0.41], [0.32, 0.49], 0.52), +]; + +export function chineHeight(hullStation: HullStation): number { + return hullStation.keel + hullStation.chineHalfBreadth * Math.tan(hullStation.deadrise); +} + +export const STEM = { + forefoot: [2.35, -0.05] as const, + top: [BOAT.halfLength, 0.52] as const, + chineMeetsAt: 0.5, +} as const; + +export function stemXAt(y: number): number { + const [x0, y0] = STEM.forefoot; + const [x1, y1] = STEM.top; + return x0 + ((y - y0) / (y1 - y0)) * (x1 - x0); +} + +export const HULL_DETAIL = { + keelStraightTo: 0.5, + chineFlat: { width: 0.03, endX: 2.3 }, + sprayRail: { width: 0.025, x: [0.2, 2.45] as Extent }, + knuckleMergeX: 2.55, + deckCentreWidth: 0.6, + edgeBandDepth: 0.05, +} as const; + +export const FAIRING = { + x: [-2.62, -0.3] as Extent, + frontTopX: -0.62, + top: 0.5, + topHalfWidth: 0.5, + baseHalfWidth: 0.62, +} as const; + +export const VENT_BOX = { x: [-2.58, -2.32] as Extent, halfWidth: 0.18, top: 0.65 } as const; + +export const PANEL = { + length: 0.4, + width: 0.6, + thickness: 0.04, + raise: 0.01, + frameLength: 0.46, + frameWidth: 0.66, + top: 0.55, +} as const; + +export const BACKUP_PANEL_X = -2.05; +export const STARLINK_PANEL_XS = [-1.55, -1.05] as const; + +export const STUB = { x: -0.12, radius: 0.02, height: 0.14 } as const; + +export const DOME = { + x: 0.92, + base: 0.426, + ringRadius: 0.14, + radius: 0.11, + height: 0.2, + top: 0.736, + lens: BOAT.lensHeight, +} as const; + +export const HATCHES = { + forward: { x: [1.15, 1.75] as Extent, halfWidth: 0.26, handles: 8 }, + mid: { x: [-0.15, 0.55] as Extent, halfWidth: 0.3, handles: 6 }, + recess: 0.01, + handle: { bar: 0.04, stem: 0.02 }, +} as const; + +export const BOW_CAMERA = { + x: [2.12, 2.42] as Extent, + width: 0.16, + height: 0.1, + window: { width: 0.08, height: 0.05, y: 0.58 }, +} as const; + +export const PAYLOAD_BAY = { + bulkheads: [0.75, 2.05] as Extent, + box: { x: [0.85, 1.95] as Extent, halfWidth: 0.38, y: [-0.18, 0.36] as Extent }, +} as const; + +export const FUEL_TANKS = { + x: [-0.55, 0.55] as Extent, + centreZ: 0.27, + width: 0.42, + y: [-0.12, 0.2] as Extent, +} as const; + +export const ENGINE_TUB = { + x: [-1.85, -0.65] as Extent, + halfWidth: 0.42, + y: [-0.26, 0.22] as Extent, +} as const; + +export const ENGINE = { + x: [-1.6, -0.85] as Extent, + halfWidth: 0.22, + y: [-0.2, 0.16] as Extent, + couplerX: -1.65, +} as const; + +export const ELECTRONICS = { + x: [-1.55, -0.75] as Extent, + halfWidth: 0.35, + y: [0.24, 0.44] as Extent, +} as const; + +export const IMPELLER = { + x: -2.55, + diameter: 0.155, + hubRadius: 0.035, + blades: 3, + statorVanes: 7, +} as const; + +export const JET = { + axisY: -0.15, + intake: { x: [-2.25, -1.75] as Extent, halfWidth: 0.11, bars: 5, y: -0.3 }, + duct: { endX: -2.5, innerDiameter: 0.157 }, + shaft: { x: [-2.55, -1.65] as Extent, radius: 0.0125, entryX: -2.15 }, + housing: { x: [-2.9, -2.5] as Extent, outerDiameter: 0.24 }, + stator: { x: [-2.88, -2.75] as Extent }, + nozzle: { x: [-3, -2.9] as Extent, inletDiameter: 0.16, exitDiameter: 0.09 }, + steeringNozzle: { x: [-3.12, -3] as Extent, pivotX: -3 }, + bucket: { pivot: [-2.98, -0.02] as const, width: 0.22, depth: 0.18 }, +} as const; + +export const MISSILE_FIT = { + railZ: 0.52, + x: [-0.9, 0.9] as Extent, + railTop: 0.62, + raise: toRadians(8), + missile: { length: 2.9, diameter: 0.17 }, +} as const; + +export const BOAT_BOUNDS: Box = { x: [-3.25, 2.75], y: [-0.35, 0.8], z: [-0.75, 0.75] }; + +export const WATER_SECTION = { + x: [-3.4, 3.1] as Extent, + z: [-2.6, 0] as Extent, + depth: 1.2, +} as const; + +export const SHORELINE_X = 0; + +export const SLIPWAY = { + x: [-22, 3] as Extent, + z: [-2, 2] as Extent, + top: 1.5, + foot: -0.4, +} as const; + +export const START: Pose = { x: 6, z: 0, heading: 0 }; + +export const ROUTE_TURN = { lead: 180, radius: 350, turn: -toRadians(20) } as const; + +export const FINAL_HEADING = START.heading + ROUTE_TURN.turn; + +export const SHIP = { + length: 110, + beam: 14, + draft: 4, + deck: 6, + bridgeTop: 15, + mastTop: 30, + radarHeight: 20, + mastX: 12, + reach: 62, +} as const; + +export const GROUND_STATION: Point = [-70, 3, -60]; + +export const SATELLITE_OFFSET: SkyOffset = { ahead: 800, up: 160, side: -70 }; +export const BACKUP_SATELLITE_OFFSET: SkyOffset = { ahead: 860, up: 175, side: 60 }; + +export function skyPoint(position: Point, offset: SkyOffset): Point { + const aheadX = Math.cos(FINAL_HEADING); + const aheadZ = Math.sin(FINAL_HEADING); + return [ + position[0] + offset.ahead * aheadX - offset.side * aheadZ, + offset.up, + position[2] + offset.ahead * aheadZ + offset.side * aheadX, + ]; +} + +export const FORMATION = { back: 30, side: 18, joinSide: 120, joinStart: 70, joinEnd: 84 } as const; + +export const SCENE_BOUNDS: Box = { x: [-150, 1550], y: [-6, 40], z: [-550, 150] }; +export const SHORE_BOUNDS: Box = { x: [-80, 20], y: [-1, 8], z: [-70, 30] }; diff --git a/explainers/naval-drone/src/model/link.test.ts b/explainers/naval-drone/src/model/link.test.ts new file mode 100644 index 00000000..4400dd18 --- /dev/null +++ b/explainers/naval-drone/src/model/link.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { VIDEO_DELAY_MS, boatLengths, lagMetres } from './link'; + +describe('video delay', () => { + it('lets the reader set the delay from 50 ms to a full second, 250 ms at first', () => { + expect(VIDEO_DELAY_MS).toEqual({ min: 50, max: 1000, step: 10, default: 250 }); + }); + + it('turns a quarter second at 42 kn into 5.4 m, about one boat length', () => { + expect(lagMetres(250, 42)).toBeCloseTo(5.4, 1); + expect(boatLengths(lagMetres(250, 42))).toBeCloseTo(1, 1); + }); + + it('grows with the delay and the speed and vanishes at rest', () => { + expect(lagMetres(1000, 42)).toBeCloseTo(21.6, 1); + expect(lagMetres(250, 22)).toBeCloseTo(2.83, 2); + expect(lagMetres(250, 0)).toBe(0); + }); +}); diff --git a/explainers/naval-drone/src/model/link.ts b/explainers/naval-drone/src/model/link.ts new file mode 100644 index 00000000..b5fe8d71 --- /dev/null +++ b/explainers/naval-drone/src/model/link.ts @@ -0,0 +1,13 @@ +import { BOAT } from './layout'; +import { knotsToMs } from './scale'; + +export const VIDEO_DELAY_MS = { min: 50, max: 1000, step: 10, default: 250 } as const; +export const MS_PER_SECOND = 1000; + +export function lagMetres(delayMs: number, knots: number): number { + return (knotsToMs(knots) * delayMs) / MS_PER_SECOND; +} + +export function boatLengths(metres: number): number { + return metres / BOAT.length; +} diff --git a/explainers/naval-drone/src/model/playback.test.ts b/explainers/naval-drone/src/model/playback.test.ts new file mode 100644 index 00000000..e92b16fe --- /dev/null +++ b/explainers/naval-drone/src/model/playback.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { SPEED_RANGE, rate } from './playback'; +import { RUN_SECONDS } from './run'; + +describe('playback', () => { + it('plays the run in two minutes at normal speed, one model second a second', () => { + expect(rate(1)).toBe(1); + expect(RUN_SECONDS / rate(1)).toBe(120); + }); + + it('runs from a quarter to four times normal speed', () => { + expect(SPEED_RANGE).toEqual({ min: 0.25, max: 4, step: 0.25, default: 1 }); + expect(RUN_SECONDS / rate(SPEED_RANGE.max)).toBe(30); + expect(RUN_SECONDS / rate(SPEED_RANGE.min)).toBe(480); + }); +}); diff --git a/explainers/naval-drone/src/model/playback.ts b/explainers/naval-drone/src/model/playback.ts new file mode 100644 index 00000000..6915faa5 --- /dev/null +++ b/explainers/naval-drone/src/model/playback.ts @@ -0,0 +1,7 @@ +export const SPEED_RANGE = { min: 0.25, max: 4, step: 0.25, default: 1 } as const; + +const MODEL_SECONDS_PER_SECOND = 1; + +export function rate(speed: number): number { + return speed * MODEL_SECONDS_PER_SECOND; +} diff --git a/explainers/naval-drone/src/model/run.test.ts b/explainers/naval-drone/src/model/run.test.ts new file mode 100644 index 00000000..e6c7b7fd --- /dev/null +++ b/explainers/naval-drone/src/model/run.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { toDegrees } from '@core/math'; +import { MOMENT_IDS, PHASE_IDS } from '../ids'; +import { BOAT, FINAL_HEADING, FORMATION, ROUTE_TURN, SHIP, START } from './layout'; +import { + FINAL_LENGTH, + HELD_PHASE, + MOMENTS, + PHASE_RANGES, + ROUTE, + ROUTE_END, + RUN_DISTANCE, + RUN_SECONDS, + RUN_STEER, + SHIP_CENTRE, + SHIP_STANDOFF_M, + SHIP_HEADING, + TURN_END_DISTANCE, + TURN_END_TIME, + TURN_START_TIME, + boatAt, + companionsAt, + distanceAt, + distanceToShip, + phaseAt, + phaseShareAt, + poseAtDistance, + speedAt, + steerAt, + timeAtDistance, +} from './run'; + +describe('run timeline', () => { + it('covers the two minute run with the five phases in order', () => { + expect(RUN_SECONDS).toBe(120); + expect(PHASE_RANGES[PHASE_IDS[0]].start).toBe(0); + expect(PHASE_RANGES[PHASE_IDS[PHASE_IDS.length - 1]].end).toBe(RUN_SECONDS); + PHASE_IDS.slice(1).forEach((id, index) => { + expect(PHASE_RANGES[id].start).toBe(PHASE_RANGES[PHASE_IDS[index]].end); + }); + expect(phaseAt(0)).toBe('launch'); + expect(phaseAt(20)).toBe('hump'); + expect(phaseAt(60)).toBe('cruise'); + expect(phaseAt(90)).toBe('sprint'); + expect(phaseAt(RUN_SECONDS)).toBe('arrival'); + expect(phaseShareAt(25.5)).toBeCloseTo(0.5, 9); + }); + + it('idles below hull speed at launch, cruises at 22 kn and sprints at 42 kn', () => { + expect(speedAt(0)).toBe(0); + expect(speedAt(PHASE_RANGES.launch.end)).toBeLessThan(5.7); + expect(speedAt(60)).toBe(BOAT.cruiseKnots); + expect(speedAt(100)).toBe(BOAT.topKnots); + expect(speedAt(RUN_SECONDS)).toBe(BOAT.topKnots); + }); + + it('places the moments on the speed profile', () => { + expect(MOMENTS.hullSpeed).toBeCloseTo(18.97, 2); + expect(MOMENTS.humpPeak).toBe(28); + expect(MOMENTS.onPlane).toBeCloseTo(33, 9); + expect(MOMENTS.cruiseSpeed).toBe(40); + expect(MOMENTS.throttleUp).toBe(PHASE_RANGES.sprint.start); + expect(MOMENTS.topSpeed).toBe(88); + expect(MOMENTS.alongside).toBe(RUN_SECONDS); + MOMENT_IDS.slice(1).forEach((id, index) => { + expect(MOMENTS[id]).toBeGreaterThan(MOMENTS[MOMENT_IDS[index]]); + }); + }); + + it('integrates the speed into about 1.46 km of sea', () => { + expect(RUN_DISTANCE).toBeCloseTo(1458.8, 0); + expect(distanceAt(40)).toBeCloseTo(172.85, 2); + expect(distanceAt(HELD_PHASE)).toBeCloseTo(399.2, 1); + expect(distanceAt(RUN_SECONDS) - distanceAt(PHASE_RANGES.arrival.start)).toBeCloseTo(172.9, 0); + expect(timeAtDistance(distanceAt(50))).toBeCloseTo(50, 6); + expect(distanceToShip(RUN_SECONDS)).toBe(SHIP_STANDOFF_M); + }); +}); + +describe('route and ship', () => { + it('turns 20 degrees to port and ends on the final heading', () => { + expect(ROUTE.length).toBeCloseTo(RUN_DISTANCE, 6); + expect(FINAL_LENGTH).toBeCloseTo(1156.7, 0); + expect(ROUTE.end.heading).toBeCloseTo(FINAL_HEADING, 9); + expect(ROUTE_END[0]).toBeCloseTo(1392.6, 0); + expect(ROUTE_END[2]).toBeCloseTo(-416.7, 0); + expect(toDegrees(poseAtDistance(ROUTE_TURN.lead / 2).heading)).toBeCloseTo(0, 9); + expect(poseAtDistance(0).position).toEqual([START.x, 0, START.z]); + expect(TURN_START_TIME).toBeCloseTo(40.63, 1); + expect(TURN_END_TIME).toBeCloseTo(51.43, 1); + expect(distanceAt(52)).toBeGreaterThan(TURN_END_DISTANCE); + }); + + it('holds the hull and jet chapters on the straight final leg', () => { + expect(boatAt(HELD_PHASE).heading).toBeCloseTo(FINAL_HEADING, 9); + expect(boatAt(HELD_PHASE).held).toBe(false); + expect(boatAt(HELD_PHASE).knots).toBe(BOAT.cruiseKnots); + }); + + it('puts the ship side square across the run, a short gap past the bow at the route end', () => { + const along = + (SHIP_CENTRE[0] - ROUTE_END[0]) * Math.cos(FINAL_HEADING) + + (SHIP_CENTRE[2] - ROUTE_END[2]) * Math.sin(FINAL_HEADING); + expect(along - SHIP.beam / 2).toBeCloseTo(BOAT.halfLength + SHIP_STANDOFF_M, 9); + expect(SHIP_HEADING - FINAL_HEADING).toBeCloseTo(Math.PI / 2, 9); + }); + + it('steers the nozzle to port only through the turn', () => { + expect(steerAt(20)).toBeCloseTo(0, 9); + expect(steerAt((TURN_START_TIME + TURN_END_TIME) / 2)).toBeCloseTo(-RUN_STEER, 9); + expect(steerAt(70)).toBeCloseTo(0, 9); + }); +}); + +describe('companions', () => { + it('stay away until 70 s and then close in from both sides', () => { + expect(companionsAt(FORMATION.joinStart - 1)).toEqual([]); + const joining = companionsAt(77); + expect(joining).toHaveLength(2); + const [port, starboard] = companionsAt(100); + const base = poseAtDistance(distanceAt(100) - FORMATION.back); + const side = (point: readonly number[]) => + (point[0] - base.position[0]) * -Math.sin(base.heading) + + (point[2] - base.position[2]) * Math.cos(base.heading); + expect(side(port.position)).toBeCloseTo(-FORMATION.side, 6); + expect(side(starboard.position)).toBeCloseTo(FORMATION.side, 6); + expect(port.heading).toBeCloseTo(base.heading, 9); + }); + + it('turn inward while they close in', () => { + const [port, starboard] = companionsAt(77); + expect(port.heading).toBeGreaterThan(FINAL_HEADING); + expect(starboard.heading).toBeLessThan(FINAL_HEADING); + }); +}); diff --git a/explainers/naval-drone/src/model/run.ts b/explainers/naval-drone/src/model/run.ts new file mode 100644 index 00000000..8b53ad4f --- /dev/null +++ b/explainers/naval-drone/src/model/run.ts @@ -0,0 +1,178 @@ +import { clamp, lerp, smoothstep, toRadians } from '@core/math'; +import { Route, arc, line } from '@core/path'; +import type { BoatReading, CompanionReading, MomentId, PhaseId, Point, RoutePose } from '../ids'; +import { HULL_SPEED_KN, HUMP_PEAK_KN, PLANING_FROM_KN } from './hull'; +import { firstCrossing, integralTo, valueAt } from './keyframes'; +import type { Keyframes } from './keyframes'; +import { BOAT, FINAL_HEADING, FORMATION, ROUTE_TURN, SHIP, START } from './layout'; +import type { Box } from './layout'; +import { knotsToMs } from './scale'; + +export const RUN_SECONDS = 120; +export const HELD_PHASE = 60; + +export const PHASE_RANGES: Readonly> = { + launch: { start: 0, end: 15 }, + hump: { start: 15, end: 36 }, + cruise: { start: 36, end: 78 }, + sprint: { start: 78, end: 112 }, + arrival: { start: 112, end: RUN_SECONDS }, +}; + +export const SPEED_KEYS: Keyframes = [ + [0, 0], + [5, 3], + [15, 4], + [22, 7], + [28, 11], + [34, 17], + [40, 22], + [78, 22], + [88, 42], + [120, 42], +]; + +export const RUN_STEER = toRadians(8); +const STEER_EASE_SECONDS = 0.5; +const BISECTION_STEPS = 48; +const MIN_FORWARD_SPEED = 0.1; +const SIDES = [-1, 1] as const; +const SMOOTHSTEP_SLOPE = 6; + +function runTime(t: number): number { + return clamp(t, 0, RUN_SECONDS); +} + +export function speedAt(t: number): number { + return valueAt(SPEED_KEYS, runTime(t)); +} + +export function distanceAt(t: number): number { + return knotsToMs(integralTo(SPEED_KEYS, runTime(t))); +} + +export const RUN_DISTANCE = distanceAt(RUN_SECONDS); + +export function timeAtDistance(distance: number): number { + const target = clamp(distance, 0, RUN_DISTANCE); + let low = 0; + let high = RUN_SECONDS; + for (let step = 0; step < BISECTION_STEPS; step += 1) { + const middle = (low + high) / 2; + if (distanceAt(middle) < target) low = middle; + else high = middle; + } + return (low + high) / 2; +} + +export function timeAtKnots(knots: number): number { + return firstCrossing(SPEED_KEYS, knots); +} + +const ARC_LENGTH = ROUTE_TURN.radius * Math.abs(ROUTE_TURN.turn); + +export const TURN_START_DISTANCE = ROUTE_TURN.lead; +export const TURN_END_DISTANCE = ROUTE_TURN.lead + ARC_LENGTH; +export const FINAL_LENGTH = RUN_DISTANCE - TURN_END_DISTANCE; + +export const ROUTE = new Route(START, [ + line(ROUTE_TURN.lead), + arc(ROUTE_TURN.radius, ROUTE_TURN.turn), + line(FINAL_LENGTH), +]); + +export const TURN_START_TIME = timeAtDistance(TURN_START_DISTANCE); +export const TURN_END_TIME = timeAtDistance(TURN_END_DISTANCE); + +export const ROUTE_END: Point = [ROUTE.end.x, 0, ROUTE.end.z]; + +export const SHIP_STANDOFF_M = 4; +const SHIP_SIDE_GAP = BOAT.halfLength + SHIP_STANDOFF_M + SHIP.beam / 2; + +export const SHIP_CENTRE: Point = [ + ROUTE_END[0] + SHIP_SIDE_GAP * Math.cos(FINAL_HEADING), + 0, + ROUTE_END[2] + SHIP_SIDE_GAP * Math.sin(FINAL_HEADING), +]; + +export const SHIP_HEADING = FINAL_HEADING + Math.PI / 2; + +export const SHIP_BOUNDS: Box = { + x: [SHIP_CENTRE[0] - SHIP.reach, SHIP_CENTRE[0] + SHIP.reach], + y: [-SHIP.draft, SHIP.mastTop + 2], + z: [SHIP_CENTRE[2] - SHIP.reach, SHIP_CENTRE[2] + SHIP.reach], +}; + +export function poseAtDistance(distance: number): RoutePose { + const pose = ROUTE.poseAt(clamp(distance, 0, ROUTE.length) / ROUTE.length); + return { position: [pose.x, 0, pose.z], heading: pose.heading }; +} + +export function boatAt(t: number): BoatReading { + const distance = distanceAt(t); + return { ...poseAtDistance(distance), knots: speedAt(t), distance, held: false }; +} + +export function steerAt(t: number): number { + const into = smoothstep( + t, + TURN_START_TIME - STEER_EASE_SECONDS, + TURN_START_TIME + STEER_EASE_SECONDS, + ); + const out = smoothstep(t, TURN_END_TIME - STEER_EASE_SECONDS, TURN_END_TIME + STEER_EASE_SECONDS); + return Math.sign(ROUTE_TURN.turn) * RUN_STEER * into * (1 - out); +} + +export function distanceToShip(t: number): number { + return RUN_DISTANCE - distanceAt(t) + SHIP_STANDOFF_M; +} + +export function phaseAt(t: number): PhaseId { + const value = runTime(t); + const found = (Object.keys(PHASE_RANGES) as PhaseId[]).find((id) => value < PHASE_RANGES[id].end); + return found ?? 'arrival'; +} + +export function phaseShareAt(t: number): number { + const { start, end } = PHASE_RANGES[phaseAt(t)]; + return clamp((runTime(t) - start) / (end - start), 0, 1); +} + +export const MOMENTS: Readonly> = { + hullSpeed: timeAtKnots(HULL_SPEED_KN), + humpPeak: timeAtKnots(HUMP_PEAK_KN), + onPlane: timeAtKnots(PLANING_FROM_KN), + cruiseSpeed: timeAtKnots(BOAT.cruiseKnots), + throttleUp: PHASE_RANGES.sprint.start, + topSpeed: timeAtKnots(BOAT.topKnots), + alongside: RUN_SECONDS, +}; + +function joinShare(t: number): number { + return smoothstep(t, FORMATION.joinStart, FORMATION.joinEnd); +} + +function joinShareRate(t: number): number { + const span = FORMATION.joinEnd - FORMATION.joinStart; + const share = clamp((t - FORMATION.joinStart) / span, 0, 1); + return (SMOOTHSTEP_SLOPE * share * (1 - share)) / span; +} + +export function companionsAt(t: number): readonly CompanionReading[] { + if (t < FORMATION.joinStart) return []; + const side = lerp(FORMATION.joinSide, FORMATION.side, joinShare(t)); + const inwardSpeed = (FORMATION.joinSide - FORMATION.side) * joinShareRate(t); + const forwardSpeed = Math.max(knotsToMs(speedAt(t)), MIN_FORWARD_SPEED); + const inward = Math.atan2(inwardSpeed, forwardSpeed); + const base = poseAtDistance(distanceAt(t) - FORMATION.back); + const starboardX = -Math.sin(base.heading); + const starboardZ = Math.cos(base.heading); + return SIDES.map((sign) => ({ + position: [ + base.position[0] + sign * side * starboardX, + 0, + base.position[2] + sign * side * starboardZ, + ] as Point, + heading: base.heading - sign * inward, + })); +} diff --git a/explainers/naval-drone/src/model/scale.test.ts b/explainers/naval-drone/src/model/scale.test.ts new file mode 100644 index 00000000..07d94a0b --- /dev/null +++ b/explainers/naval-drone/src/model/scale.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { METRES_PER_UNIT, knotsToKmh, knotsToMs, metresToUnits, msToKmh, msToKnots } from './scale'; + +describe('scale', () => { + it('draws everything at one metre per unit', () => { + expect(METRES_PER_UNIT).toBe(1); + expect(metresToUnits(5.5)).toBe(5.5); + }); + + it('converts the facts sheet speeds', () => { + expect(knotsToKmh(42)).toBeCloseTo(77.8, 1); + expect(knotsToKmh(22)).toBeCloseTo(40.7, 1); + expect(knotsToMs(42)).toBeCloseTo(21.6, 1); + expect(knotsToMs(22)).toBeCloseTo(11.3, 1); + expect(msToKnots(knotsToMs(5.7))).toBeCloseTo(5.7, 9); + expect(msToKmh(knotsToMs(42))).toBeCloseTo(knotsToKmh(42), 9); + }); +}); diff --git a/explainers/naval-drone/src/model/scale.ts b/explainers/naval-drone/src/model/scale.ts new file mode 100644 index 00000000..cb5968f4 --- /dev/null +++ b/explainers/naval-drone/src/model/scale.ts @@ -0,0 +1,27 @@ +export const METRES_PER_UNIT = 1; +export const KMH_PER_KNOT = 1.852; +export const KMH_PER_MS = 3.6; +export const MS_PER_KNOT = KMH_PER_KNOT / KMH_PER_MS; +export const GRAVITY = 9.81; +export const SEAWATER_DENSITY = 1025; +export const FEET_PER_METRE = 3.28084; + +export function metresToUnits(metres: number): number { + return metres / METRES_PER_UNIT; +} + +export function knotsToMs(knots: number): number { + return knots * MS_PER_KNOT; +} + +export function msToKnots(ms: number): number { + return ms / MS_PER_KNOT; +} + +export function knotsToKmh(knots: number): number { + return knots * KMH_PER_KNOT; +} + +export function msToKmh(ms: number): number { + return ms * KMH_PER_MS; +} diff --git a/explainers/naval-drone/src/model/sea.test.ts b/explainers/naval-drone/src/model/sea.test.ts new file mode 100644 index 00000000..346f9843 --- /dev/null +++ b/explainers/naval-drone/src/model/sea.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { SEA_STATE_IDS } from '../ids'; +import { DEFAULT_SEA_STATE, SEA_STATES, seaAt, waveHeightOf } from './sea'; + +describe('sea states', () => { + it('follows WMO code 3700 from smooth to rough', () => { + expect(SEA_STATE_IDS.map((id) => SEA_STATES[id].wmo)).toEqual([2, 3, 4, 5]); + expect(SEA_STATES.slight).toEqual({ wmo: 3, from: 0.5, to: 1.25 }); + expect(SEA_STATES.rough).toEqual({ wmo: 5, from: 2.5, to: 4 }); + }); + + it('joins the bands without gaps', () => { + SEA_STATE_IDS.slice(1).forEach((id, index) => { + expect(SEA_STATES[id].from).toBe(SEA_STATES[SEA_STATE_IDS[index]].to); + }); + }); + + it('draws the middle of each band and starts smooth', () => { + expect(DEFAULT_SEA_STATE).toBe('smooth'); + expect(waveHeightOf('smooth')).toBeCloseTo(0.3, 9); + expect(waveHeightOf('slight')).toBeCloseTo(0.875, 9); + expect(seaAt('moderate')).toEqual({ state: 'moderate', waveHeight: 1.875 }); + }); +}); diff --git a/explainers/naval-drone/src/model/sea.ts b/explainers/naval-drone/src/model/sea.ts new file mode 100644 index 00000000..b3256bd0 --- /dev/null +++ b/explainers/naval-drone/src/model/sea.ts @@ -0,0 +1,25 @@ +import type { SeaReading, SeaStateId } from '../ids'; + +export interface SeaState { + wmo: number; + from: number; + to: number; +} + +export const SEA_STATES: Readonly> = { + smooth: { wmo: 2, from: 0.1, to: 0.5 }, + slight: { wmo: 3, from: 0.5, to: 1.25 }, + moderate: { wmo: 4, from: 1.25, to: 2.5 }, + rough: { wmo: 5, from: 2.5, to: 4 }, +}; + +export const DEFAULT_SEA_STATE: SeaStateId = 'smooth'; + +export function waveHeightOf(state: SeaStateId): number { + const { from, to } = SEA_STATES[state]; + return (from + to) / 2; +} + +export function seaAt(state: SeaStateId): SeaReading { + return { state, waveHeight: waveHeightOf(state) }; +} diff --git a/explainers/naval-drone/src/model/trial.test.ts b/explainers/naval-drone/src/model/trial.test.ts new file mode 100644 index 00000000..e0a26beb --- /dev/null +++ b/explainers/naval-drone/src/model/trial.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { SPEED_MARK_IDS } from '../ids'; +import { + MARK_TOLERANCE_KN, + SPEED_MARKS, + THROTTLE_PERCENT, + TRIAL_KNOTS, + speedMarkAt, + stepTo, + throttlePercentOf, + throttleShareOf, + trialKnotsOf, +} from './trial'; + +describe('speed trial', () => { + it('lets the reader set any speed from rest to the 42 kn top speed in tenths', () => { + expect(TRIAL_KNOTS).toEqual({ min: 0, max: 42, step: 0.1 }); + expect(THROTTLE_PERCENT).toEqual({ min: 0, max: 100, step: 1 }); + }); + + it('marks hull speed, the hump, planing, cruise and top speed', () => { + expect(SPEED_MARK_IDS.map((mark) => SPEED_MARKS[mark])).toEqual([5.7, 11, 16, 22, 42]); + expect(MARK_TOLERANCE_KN).toBe(0.25); + }); + + it('keeps a trial speed inside the slider and on its tenths', () => { + expect(trialKnotsOf(-3)).toBe(0); + expect(trialKnotsOf(50)).toBe(42); + expect(trialKnotsOf(11.04)).toBe(11); + expect(trialKnotsOf(5.66)).toBe(5.7); + }); + + it('keeps any value inside its range and on its step', () => { + const range = { min: 50, max: 1000, step: 10 }; + expect(stepTo(333, range)).toBe(330); + expect(stepTo(10, range)).toBe(50); + expect(stepTo(2000, range)).toBe(1000); + expect(stepTo(0.7, { min: 0, max: 1, step: 0.1 })).toBe(0.7); + expect(stepTo(0.35, { min: 0, max: 1, step: 0.05 })).toBe(0.35); + }); + + it('turns throttle percents into a share of full flow and back', () => { + expect(throttleShareOf(55)).toBeCloseTo(0.55, 12); + expect(throttleShareOf(140)).toBe(1); + expect(throttleShareOf(-5)).toBe(0); + expect(throttlePercentOf(0.694)).toBe(69); + expect(throttlePercentOf(1.2)).toBe(100); + }); + + it('finds the mark within a quarter knot and none between marks', () => { + expect(speedMarkAt(5.9)).toBe('hullSpeed'); + expect(speedMarkAt(21.8)).toBe('cruise'); + expect(speedMarkAt(42)).toBe('top'); + expect(speedMarkAt(5.4)).toBeUndefined(); + expect(speedMarkAt(30)).toBeUndefined(); + }); +}); diff --git a/explainers/naval-drone/src/model/trial.ts b/explainers/naval-drone/src/model/trial.ts new file mode 100644 index 00000000..e1d27376 --- /dev/null +++ b/explainers/naval-drone/src/model/trial.ts @@ -0,0 +1,54 @@ +import { clamp } from '@core/math'; +import type { SpeedMarkId } from '../ids'; +import { HULL_SPEED_KN, HUMP_PEAK_KN, PLANING_FROM_KN } from './hull'; +import { BOAT } from './layout'; + +export interface SteppedRange { + min: number; + max: number; + step: number; +} + +export const PERCENT = 100; +export const TRIAL_KNOTS = { min: 0, max: BOAT.topKnots, step: 0.1 } as const; +export const THROTTLE_PERCENT = { min: 0, max: PERCENT, step: 1 } as const; +export const MARK_TOLERANCE_KN = 0.25; + +export const SPEED_MARKS: Readonly> = { + hullSpeed: HULL_SPEED_KN, + hump: HUMP_PEAK_KN, + planing: PLANING_FROM_KN, + cruise: BOAT.cruiseKnots, + top: BOAT.topKnots, +}; + +const DECIMAL_POINT = '.'; +const WHOLE_STEP_DECIMALS = 0; + +function decimalsOf(step: number): number { + const [, fraction] = String(step).split(DECIMAL_POINT); + return fraction?.length ?? WHOLE_STEP_DECIMALS; +} + +export function stepTo(value: number, range: SteppedRange): number { + const stepped = Math.round(clamp(value, range.min, range.max) / range.step) * range.step; + return Number(stepped.toFixed(decimalsOf(range.step))); +} + +export function trialKnotsOf(knots: number): number { + return stepTo(knots, TRIAL_KNOTS); +} + +export function throttleShareOf(percent: number): number { + return clamp(percent, THROTTLE_PERCENT.min, THROTTLE_PERCENT.max) / PERCENT; +} + +export function throttlePercentOf(share: number): number { + return stepTo(share * PERCENT, THROTTLE_PERCENT); +} + +export function speedMarkAt(knots: number): SpeedMarkId | undefined { + return (Object.keys(SPEED_MARKS) as SpeedMarkId[]).find( + (mark) => Math.abs(SPEED_MARKS[mark] - knots) <= MARK_TOLERANCE_KN, + ); +} diff --git a/explainers/naval-drone/src/scene/assembly.ts b/explainers/naval-drone/src/scene/assembly.ts new file mode 100644 index 00000000..4354a2c5 --- /dev/null +++ b/explainers/naval-drone/src/scene/assembly.ts @@ -0,0 +1,33 @@ +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, Point, RegionId } from '../ids'; +import { createNavalDroneAssembly } from './navalDroneAssembly'; + +export interface AssemblyResources { + materials: MaterialLibrary; + textures: SceneTextures; +} + +export interface ChaseTarget { + position: Point; + heading: number; + length: number; + ship: Point; +} + +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; + chaseTarget(): ChaseTarget; + warmUp?(compile: (object: Object3D) => void): void; + dispose(): void; +} + +export function createAssembly(resources: AssemblyResources, state: AssemblyState): Assembly { + return createNavalDroneAssembly(resources, state); +} diff --git a/explainers/naval-drone/src/scene/bindings.test.ts b/explainers/naval-drone/src/scene/bindings.test.ts new file mode 100644 index 00000000..3744eba5 --- /dev/null +++ b/explainers/naval-drone/src/scene/bindings.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from 'vitest'; +import { NO_SAFE_AREA } from '@core/scene/lens'; +import type { ViewportSize } from '@core/scene/lens'; +import type { AssemblyState } from '../ids'; +import { HELD_PHASE, boatAt, companionsAt, planingAt, seaAt } from '../model'; +import { createNavalDroneStore } from '../state'; +import { bindStore } from './bindings'; +import type { NavalDroneController } from './controller'; + +const DESKTOP_STAGE: ViewportSize = { width: 835, height: 900, safe: NO_SAFE_AREA }; +const PHONE_STAGE: ViewportSize = { width: 390, height: 330, safe: NO_SAFE_AREA }; + +function fakeTargets() { + const received: AssemblyState[] = []; + const snapshots: AssemblyState[] = []; + const record = (state: AssemblyState) => { + received.push(state); + snapshots.push({ ...state }); + }; + const frame = vi.fn(); + const navalDrone = { build: record, setState: record, views: { frame } }; + const resizes: ((size: ViewportSize) => void)[] = []; + const resize = (listener: (size: ViewportSize) => void) => { + resizes.push(listener); + listener(DESKTOP_STAGE); + return () => resizes.splice(resizes.indexOf(listener), 1); + }; + return { + resizes, + received, + snapshots, + frame, + targets: { + navalDrone: navalDrone as unknown as NavalDroneController, + labelVisibility: { setWanted: vi.fn() }, + highlighter: { setHighlight: vi.fn() }, + labels: { show: vi.fn() }, + viewport: { onResize: resize }, + }, + }; +} + +describe('scene bindings', () => { + it('builds the scene at the store moment and frames the first chapter', () => { + const { received, frame, targets } = fakeTargets(); + bindStore(createNavalDroneStore({ phase: 100 }), targets); + expect(received[0]).toMatchObject({ + phase: 100, + playing: true, + boat: boatAt(100), + planing: planingAt(boatAt(100).knots), + companions: companionsAt(100), + sea: seaAt('smooth'), + link: { mode: 'satellite', ghost: null }, + fit: 'standard', + view: { cutaway: false, flow: false, links: false, labels: true }, + waterSection: false, + wettedBar: false, + }); + expect(frame).toHaveBeenCalledWith('chase', false, undefined); + }); + + it('hands the scene the held boat, the jet and the hull chapter flags', () => { + const { received, frame, targets } = fakeTargets(); + const store = createNavalDroneStore(); + bindStore(store, targets); + store.getState().applyPreset('hull'); + expect(frame).toHaveBeenLastCalledWith('waterline', true, undefined); + expect(received.at(-1)).toMatchObject({ + phase: HELD_PHASE, + playing: false, + boat: { knots: 11, held: true }, + waterSection: true, + wettedBar: true, + view: { cutaway: false, flow: true }, + }); + store.getState().applyPreset('jet'); + store.getState().setHelm('reverse'); + expect(received.at(-1)).toMatchObject({ boat: { knots: 0, held: true }, jet: { bucket: 1 } }); + store.getState().play(); + expect(received.at(-1)?.boat.held).toBe(false); + }); + + it('hands the scene the sea, the link ghost and the deck fit', () => { + const { received, targets } = fakeTargets(); + const store = createNavalDroneStore(); + bindStore(store, targets); + store.getState().applyPreset('link'); + expect(received.at(-1)?.link.ghost).not.toBeNull(); + store.getState().setLinkMode('lost'); + expect(received.at(-1)?.link).toEqual({ mode: 'lost', ghost: null }); + store.getState().applyPreset('horizon'); + expect(received.at(-1)?.sea).toEqual(seaAt('slight')); + store.getState().setFit('missile'); + expect(received.at(-1)?.fit).toBe('missile'); + }); + + it('hands the scene each view change once', () => { + const { received, targets } = fakeTargets(); + const store = createNavalDroneStore(); + bindStore(store, targets); + const calls = received.length; + store.getState().toggleView('links'); + expect(received).toHaveLength(calls + 1); + expect(received.at(-1)?.view.links).toBe(true); + }); + + it('never changes the state object the assembly got last', () => { + const { received, snapshots, targets } = fakeTargets(); + const store = createNavalDroneStore(); + bindStore(store, targets); + store.getState().setPhase(10); + store.getState().setPhase(90); + const last = received.length - 1; + expect(received[last]).not.toBe(received[last - 1]); + expect(received[last - 1]).toEqual(snapshots[last - 1]); + }); + + it('labels only the parts the scene draws', () => { + const { targets } = fakeTargets(); + const store = createNavalDroneStore({ phase: 0 }); + bindStore(store, targets); + const wanted = () => + targets.labelVisibility.setWanted.mock.lastCall?.[0] as ReadonlySet; + store.getState().applyPreset('hull'); + expect(wanted()).toContain('bowWave'); + expect(wanted()).toContain('wettedLength'); + store.getState().setTrialKnots(30); + expect(wanted()).not.toContain('bowWave'); + expect(wanted()).toContain('spray'); + store.getState().applyPreset('fleet'); + expect(wanted()).toContain('companions'); + expect(wanted()).not.toContain('missileRails'); + store.getState().setFit('missile'); + expect(wanted()).toContain('missileRails'); + }); + + it('drops the minor pump labels on a phone-sized stage', () => { + const { resizes, targets } = fakeTargets(); + const store = createNavalDroneStore(); + bindStore(store, targets); + const wanted = () => + targets.labelVisibility.setWanted.mock.lastCall?.[0] as ReadonlySet; + store.getState().applyPreset('jet'); + expect(wanted()).toContain('driveShaft'); + resizes.forEach((listener) => listener(PHONE_STAGE)); + expect(wanted()).not.toContain('driveShaft'); + expect(wanted()).toContain('impeller'); + resizes.forEach((listener) => listener(DESKTOP_STAGE)); + expect(wanted()).toContain('driveShaft'); + }); + + it('stops listening once unbound', () => { + const { received, targets } = fakeTargets(); + const store = createNavalDroneStore(); + const unbind = bindStore(store, targets); + unbind(); + const calls = received.length; + store.getState().setPhase(50); + expect(received).toHaveLength(calls); + }); +}); diff --git a/explainers/naval-drone/src/scene/bindings.ts b/explainers/naval-drone/src/scene/bindings.ts new file mode 100644 index 00000000..d8977892 --- /dev/null +++ b/explainers/naval-drone/src/scene/bindings.ts @@ -0,0 +1,96 @@ +import { bindPresets } from '@core/scene/presetBinder'; +import type { LabelPolicy, PresetTargets } from '@core/scene/presetBinder'; +import type { Viewport } from '@core/scene/viewport'; +import type { AssemblyState } from '../ids'; +import { + CHAPTER_CONTROL_DEFAULTS, + DEFAULT_FIT, + DEFAULT_VIEW, + PRESETS, + assemblyFlags, + runAt, +} from '../state'; +import type { + NavalDroneState, + NavalDroneStore, + NavalDroneStoreState, + Preset, + RunSource, +} from '../state'; +import type { NavalDroneController } from './controller'; +import { DrawnLabels } from './drawnLabels'; +import { isCompactStage } from './viewFit'; + +export interface SceneTargets extends PresetTargets { + navalDrone: NavalDroneController; + labelVisibility: LabelPolicy; + viewport: Pick; +} + +type AssemblySource = RunSource & Pick; + +const BLANK_SOURCE: AssemblySource = { + ...CHAPTER_CONTROL_DEFAULTS, + phase: 0, + preset: 'overview', + playing: false, + fit: DEFAULT_FIT, + view: DEFAULT_VIEW, +}; + +function readingOf( + source: AssemblySource, +): Omit { + const { boat, planing, jet, companions, sea, link } = runAt(source); + return { boat, planing, jet, companions, sea, link, ...assemblyFlags(source) }; +} + +function copyInto(target: AssemblyState, source: AssemblySource): AssemblyState { + Object.assign(target, readingOf(source)); + target.phase = source.phase; + target.playing = source.playing; + target.fit = source.fit; + target.view = source.view; + return target; +} + +function blankState(): AssemblyState { + const { phase, playing, fit, view } = BLANK_SOURCE; + return { phase, playing, fit, view, ...readingOf(BLANK_SOURCE) }; +} + +class DoubleBufferedAssemblyState { + private heldByAssembly = blankState(); + private free = blankState(); + + handOver(source: AssemblySource): AssemblyState { + const next = copyInto(this.free, source); + this.free = this.heldByAssembly; + this.heldByAssembly = next; + return next; + } +} + +export function bindStore(store: NavalDroneStore, targets: SceneTargets): () => void { + const { navalDrone, labelVisibility, viewport } = targets; + const assemblyState = new DoubleBufferedAssemblyState(); + const drawnLabels = new DrawnLabels(labelVisibility); + const handOver = (state: AssemblySource) => { + const next = assemblyState.handOver(state); + drawnLabels.follow(next); + return next; + }; + const push = (state: AssemblySource) => navalDrone.setState(handOver(state)); + navalDrone.build(handOver(store.getState())); + const unsubscribers = [ + viewport.onResize((size) => drawnLabels.setCompact(isCompactStage(size))), + store.subscribe(push), + bindPresets(targets, store, { + presets: PRESETS, + views: navalDrone.views, + labels: drawnLabels, + prepare: push, + }), + ]; + return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); +} diff --git a/explainers/naval-drone/src/scene/cameraViews.test.ts b/explainers/naval-drone/src/scene/cameraViews.test.ts new file mode 100644 index 00000000..6f680639 --- /dev/null +++ b/explainers/naval-drone/src/scene/cameraViews.test.ts @@ -0,0 +1,211 @@ +import { Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { toRadians } from '@core/math'; +import { PRESETS } from '../state'; +import { + BACKUP_SATELLITE_OFFSET, + BOAT, + DOME, + FAIRING, + JET, + SATELLITE_OFFSET, + TRANSOM_X, + skyPoint, +} from '../model'; +import { + EYE_VIEW, + LOOK_AROUND_POLAR, + ORBIT_VIEWS, + SKY_VIEW, + VIEW_DISTANCE, + cameraViews, + eyePitch, + eyePose, + groupPose, + orbitPose, + skyPitch, + skyPose, +} from './cameraViews'; +import type { Point } from '../ids'; +import type { FollowTarget } from './cameraViews'; +import { fitsView, rightOf, vectorOf } from './viewFit'; + +const PHONE = { vertical: 0.2, horizontal: 0.27 }; +const DESKTOP = { vertical: 0.25, horizontal: 0.42 }; + +const TARGET: FollowTarget = { + position: [400, 0.2, -100], + heading: 0, + length: BOAT.length, + ship: [1400, 0, -420], + trim: 0, + lens: [400 + DOME.x, 0.2 + DOME.lens, -100], +}; + +function offsetOf(pose: { position: Vector3; target: Vector3 }): Vector3 { + return pose.position.clone().sub(pose.target); +} + +function expectPivotNear(pose: { position: Vector3; target: Vector3 }, point: Vector3): void { + const forward = offsetOf(pose).negate().normalize(); + expect(point.clone().sub(pose.target).dot(forward)).toBeCloseTo(0, 6); + expect(pose.target.distanceTo(point)).toBeLessThan(offsetOf(pose).length() / 2); +} + +describe('camera views', () => { + const views = cameraViews(() => TARGET); + + it('offers every view the chapters ask for, all following the boat', () => { + Object.values(PRESETS).forEach((preset) => { + expect(views[preset.camera]).toBeDefined(); + expect(views[preset.camera].follow, preset.camera).toBe('position'); + }); + }); + + it('leaves the camera alone until the boat exists', () => { + const empty = cameraViews(() => null); + Object.values(empty).forEach((view) => expect(view.pose(PHONE)).toBeNull()); + }); + + it('limits the zoom of the close views', () => { + expect(views.stern.distance).toEqual({ min: 1.2, max: 30 }); + expect(views.waterline.distance).toEqual({ min: 4, max: 80 }); + expect(views.eye.distance).toEqual({ min: EYE_VIEW.look, max: EYE_VIEW.look }); + expect(views.chase.distance).toBeUndefined(); + expect(VIEW_DISTANCE.sky).toBeUndefined(); + }); + + it('chases from behind on the port quarter, 18 degrees up, 2.2 lengths wide', () => { + const pose = orbitPose(TARGET, ORBIT_VIEWS.chase, PHONE); + expect(pose.target.toArray()).toEqual([400, 0.4, -100]); + const offset = offsetOf(pose); + expect(offset.x).toBeLessThan(0); + expect(offset.z).toBeLessThan(0); + expect(Math.asin(offset.y / offset.length())).toBeCloseTo(toRadians(18), 9); + expect(Math.atan2(offset.z, offset.x)).toBeCloseTo(toRadians(-150), 9); + expect(offset.length()).toBeCloseTo((2.2 * BOAT.length) / (2 * PHONE.horizontal), 9); + }); + + it('moves the boat off the centre on a phone so the long labels fit beside it', () => { + const sides = { chase: -1, waterline: 1, stern: 1 } as const; + (['chase', 'waterline', 'stern'] as const).forEach((view) => { + const wide = orbitPose(TARGET, ORBIT_VIEWS[view], PHONE); + const phone = orbitPose(TARGET, ORBIT_VIEWS[view], PHONE, true); + const right = rightOf(offsetOf(phone).normalize()); + const subject = wide.target.clone().sub(phone.target); + expect(sides[view] * subject.dot(right), view).toBeGreaterThan(0.25); + }); + const waterline = (compact: boolean) => + offsetOf(orbitPose(TARGET, ORBIT_VIEWS.waterline, PHONE, compact)).length(); + expect(waterline(true)).toBeGreaterThan(waterline(false)); + }); + + it('turns the views with the heading', () => { + const turned = orbitPose({ ...TARGET, heading: Math.PI / 2 }, ORBIT_VIEWS.waterline, PHONE); + const offset = offsetOf(turned); + expect(offset.x).toBeGreaterThan(0); + expect(Math.abs(offset.z)).toBeLessThan(1e-9); + }); + + it('sits abeam to port just above the water for the waterline', () => { + const pose = orbitPose(TARGET, ORBIT_VIEWS.waterline, DESKTOP); + const offset = offsetOf(pose); + expect(offset.z).toBeLessThan(0); + expect(Math.abs(offset.x)).toBeLessThan(1e-9); + expect(pose.position.y).toBeGreaterThan(TARGET.position[1]); + expect(pose.position.y).toBeLessThan(TARGET.position[1] + 1); + }); + + it('aims between the pump axis and the deck behind the transom, dropping with the trim', () => { + const level = orbitPose(TARGET, ORBIT_VIEWS.stern, PHONE); + expect(level.target.x).toBeCloseTo(400 + TRANSOM_X - 0.15, 9); + expect(level.target.y).toBeGreaterThan(0.2 + JET.axisY); + expect(level.target.y).toBeLessThan(0.2 + FAIRING.top); + const trimmed = orbitPose({ ...TARGET, trim: toRadians(4) }, ORBIT_VIEWS.stern, PHONE); + expect(trimmed.target.y).toBeLessThan(level.target.y); + }); + + it('backs the stern view off until the pump and the top of the stern fit', () => { + const stern: Point[] = [ + [TRANSOM_X, 0.65, -0.18], + [FAIRING.frontTopX, FAIRING.top, FAIRING.topHalfWidth], + [JET.steeringNozzle.x[0], JET.axisY, 0], + ]; + const points = stern.map((point) => vectorOf(point).add(vectorOf(TARGET.position))); + [PHONE, DESKTOP].forEach((slopes) => { + const pose = orbitPose(TARGET, ORBIT_VIEWS.stern, slopes); + expect(offsetOf(pose).length()).toBeGreaterThanOrEqual(2.4 / (2 * slopes.horizontal) - 1e-9); + expect(fitsView(pose, points, slopes, 0.93)).toBe(true); + }); + const wide = { vertical: 0.5, horizontal: 0.5 }; + expect(offsetOf(orbitPose(TARGET, ORBIT_VIEWS.stern, wide)).length()).toBeCloseTo(2.4, 9); + }); + + it('looks from just forward of the dome along the heading with the bow fairing low in the frame', () => { + const pose = eyePose(TARGET, PHONE); + const lens = vectorOf(TARGET.lens); + const ahead = pose.position.clone().sub(lens); + expect(ahead.x).toBeGreaterThan(DOME.ringRadius); + expect(lens.y + ahead.y).toBeGreaterThan(TARGET.position[1] + DOME.top); + expect(Math.abs(ahead.z)).toBeLessThan(1e-9); + expect(pose.target.x).toBeGreaterThan(pose.position.x); + const window = vectorOf(EYE_VIEW.window).add(lens); + const margin = 0.01; + expect(fitsView(pose, [window], PHONE, EYE_VIEW.windowShare + margin)).toBe(true); + expect(fitsView(pose, [window], PHONE, EYE_VIEW.windowShare - margin)).toBe(false); + expect(eyePitch(DESKTOP)).toBeGreaterThan(eyePitch(PHONE)); + }); + + it('rides with the lens through the waves and turns in place when dragged', () => { + const calm = eyePose(TARGET, PHONE); + const raised: Point = [TARGET.lens[0], TARGET.lens[1] + 0.8, TARGET.lens[2] + 0.1]; + const rough = eyePose({ ...TARGET, lens: raised }, PHONE); + const shift = rough.position.clone().sub(calm.position); + expect(shift.x).toBeCloseTo(0, 9); + expect(shift.y).toBeCloseTo(0.8, 9); + expect(shift.z).toBeCloseTo(0.1, 9); + expect(calm.position.distanceTo(calm.target)).toBeCloseTo(EYE_VIEW.look, 9); + expect(LOOK_AROUND_POLAR.eye).toBeGreaterThan(Math.PI / 2); + }); + + it('looks past the port companion at the lead boat and the ship, turning around the lead', () => { + const sprint: FollowTarget = { ...TARGET, ship: [920, 0, -100] }; + const lead = vectorOf(sprint.position); + const companion = new Vector3(370, 0, -118); + const ship = new Vector3(920, 20, -100); + [PHONE, DESKTOP].forEach((slopes) => { + const pose = groupPose(sprint, slopes); + expect(pose.position.x).toBeLessThan(companion.x); + expect(pose.position.z).toBeLessThan(companion.z); + expect(pose.position.distanceTo(companion)).toBeLessThan(pose.position.distanceTo(lead)); + expect(pose.position.distanceTo(lead)).toBeLessThan(60); + expect(fitsView(pose, [lead, companion, ship], slopes, 0.9)).toBe(true); + expectPivotNear(pose, lead); + }); + }); + + it('keeps the boat low in the sky view with both satellites up toward the top', () => { + [PHONE, DESKTOP].forEach((slopes) => { + const pose = skyPose(TARGET, slopes); + const behind = pose.position.clone().sub(vectorOf(TARGET.position)); + expect(behind.x).toBeLessThan(-SKY_VIEW.back.min + 1); + expect(-behind.x).toBeLessThanOrEqual(SKY_VIEW.back.max + 1); + expect(pose.target.y).toBeGreaterThan(pose.position.y); + expect(fitsView(pose, [vectorOf(TARGET.position)], slopes, 1)).toBe(true); + expectPivotNear(pose, vectorOf(TARGET.position)); + }); + const satellites = [SATELLITE_OFFSET, BACKUP_SATELLITE_OFFSET].map((offset) => + vectorOf(skyPoint(TARGET.position, offset)), + ); + expect(satellites.every((point) => point.y > 100)).toBe(true); + }); + + it('centres the sky view when everything fits, else puts the boat at the bottom', () => { + expect(skyPitch([-0.05, 0.1], PHONE)).toBeCloseTo(0.025, 9); + const low = -0.1; + expect(skyPitch([low, 0.5], PHONE)).toBeCloseTo( + low + Math.atan(PHONE.vertical * SKY_VIEW.fill), + 9, + ); + }); +}); diff --git a/explainers/naval-drone/src/scene/cameraViews.ts b/explainers/naval-drone/src/scene/cameraViews.ts new file mode 100644 index 00000000..cdf3ba8e --- /dev/null +++ b/explainers/naval-drone/src/scene/cameraViews.ts @@ -0,0 +1,315 @@ +import { Vector3 } from 'three'; +import { toRadians } from '@core/math'; +import type { CameraDistance } from '@core/scene/camera'; +import type { CustomView } from '@core/scene/cameraViews'; +import type { CameraPose } from '@core/scene/frameBox'; +import type { FramingSlopes } from '@core/scene/lens'; +import type { CameraView, Point } from '../ids'; +import { + BACKUP_SATELLITE_OFFSET, + BOAT, + BOW_CAMERA, + DOME, + FAIRING, + FORMATION, + HULL_STATIONS, + JET, + SATELLITE_OFFSET, + SHIP, + TRANSOM_X, + VENT_BOX, + skyPoint, +} from '../model'; +import type { ChaseTarget } from './assembly'; +import { + boatToWorld, + direction, + fitDistance, + fitsView, + nearestFit, + pivotNear, + rightOf, + vectorOf, +} from './viewFit'; +import type { BoatFrame } from './viewFit'; + +export interface FollowTarget extends ChaseTarget { + trim: number; + lens: Point; +} + +export type FollowSource = () => FollowTarget | null; +export type CompactSource = () => boolean; + +type OrbitView = Extract; + +interface CompactFraming { + lead: number; + widen: number; +} + +interface OrbitSpec { + bearing: number; + elevation: number; + width(target: FollowTarget): number; + aim: Point; + compact?: CompactFraming; + keep?: readonly Point[]; +} + +const STERN_AIM_AFT = 0.15; +const STERN_WIDTH_M = 2.4; +const CHASE_WIDTH_LENGTHS = 2.2; +const WATERLINE_WIDTH_LENGTHS = 1.4; +const KEEP_FILL = 0.92; +const BOAT_LEAD = 0.12; +const WIDE_FRAMING: CompactFraming = { lead: 0, widen: 1 }; +const MAX_ZOOM_OUT = 3; +const SIDES = [-1, 1] as const; +const [TRANSOM_STATION] = HULL_STATIONS; + +const STERN_AIM_Y = (JET.axisY + FAIRING.top) / 2; + +const STERN_KEEP: readonly Point[] = [ + [JET.steeringNozzle.x[0], JET.axisY, 0], + [JET.intake.x[1], JET.intake.y, 0], + ...SIDES.flatMap((side): Point[] => [ + [VENT_BOX.x[0], VENT_BOX.top, side * VENT_BOX.halfWidth], + [FAIRING.frontTopX, FAIRING.top, side * FAIRING.topHalfWidth], + [TRANSOM_X, TRANSOM_STATION.deck, side * TRANSOM_STATION.sheer[0]], + ]), +]; + +export const ORBIT_VIEWS: Readonly> = { + chase: { + bearing: toRadians(-150), + elevation: toRadians(18), + width: (target) => CHASE_WIDTH_LENGTHS * target.length, + aim: [0, 0.2, 0], + compact: { lead: -BOAT_LEAD, widen: 1 }, + }, + waterline: { + bearing: toRadians(-90), + elevation: toRadians(1.5), + width: (target) => WATERLINE_WIDTH_LENGTHS * target.length, + aim: [0, 0.1, 0], + compact: { lead: BOAT_LEAD, widen: 1.2 }, + }, + stern: { + bearing: toRadians(-145), + elevation: toRadians(12), + width: () => STERN_WIDTH_M, + aim: [TRANSOM_X - STERN_AIM_AFT, STERN_AIM_Y, 0], + compact: { lead: BOAT_LEAD, widen: 1 }, + keep: STERN_KEEP, + }, +}; + +export const GROUP_VIEW = { + behind: { min: 10, max: 160 }, + outboard: 2, + height: 4, + fill: 0.86, +} as const; + +const PORT = -1; + +export const SKY_VIEW = { + bearing: toRadians(175), + height: 4, + back: { min: 45, max: 160 }, + fill: 0.72, + aimAhead: 45, +} as const; + +const EYE_CLEARANCE_M = 0.06; + +export const EYE_VIEW = { + forward: DOME.ringRadius + EYE_CLEARANCE_M, + rise: DOME.top - DOME.lens + EYE_CLEARANCE_M, + window: [BOW_CAMERA.x[1] - DOME.x, BOW_CAMERA.window.y - DOME.lens, 0] as Point, + windowShare: 0.65, + look: 0.1, + lookUp: toRadians(60), +} as const; + +export const VIEW_DISTANCE: Readonly>> = { + stern: { min: 1.2, max: 30 }, + waterline: { min: 4, max: 80 }, + eye: { min: EYE_VIEW.look, max: EYE_VIEW.look }, +}; + +export const LOOK_AROUND_POLAR: Readonly>> = { + eye: Math.PI / 2 + EYE_VIEW.lookUp, +}; + +function frameOf(target: FollowTarget): BoatFrame { + return { position: target.position, heading: target.heading, trim: target.trim }; +} + +function level(target: FollowTarget): BoatFrame { + return { ...frameOf(target), trim: 0 }; +} + +export function orbitPose( + target: FollowTarget, + spec: OrbitSpec, + slopes: FramingSlopes, + compact = false, +): CameraPose { + const frame = frameOf(target); + const away = direction(target.heading + spec.bearing, spec.elevation); + const { lead, widen } = (compact ? spec.compact : undefined) ?? WIDE_FRAMING; + const width = spec.width(target) * widen; + const aim = boatToWorld(frame, spec.aim, true).addScaledVector(rightOf(away), -lead * width); + const keep = (spec.keep ?? []).map((point) => boatToWorld(frame, point, true)); + const nearest = fitDistance(width, slopes); + return nearestFit( + (distance) => ({ position: aim.clone().addScaledVector(away, distance), target: aim.clone() }), + (pose) => fitsView(pose, keep, slopes, KEEP_FILL), + { min: nearest, max: nearest * MAX_ZOOM_OUT }, + ); +} + +function groupPoints(target: FollowTarget): Vector3[] { + const frame = level(target); + const companion = (along: number, height: number) => + boatToWorld(frame, [along - FORMATION.back, height, PORT * FORMATION.side], false); + const [shipX, , shipZ] = target.ship; + return [ + vectorOf(target.position), + companion(-BOAT.halfLength, 0), + companion(BOAT.halfLength, BOAT.freeboard), + new Vector3(shipX, SHIP.mastTop, shipZ), + ]; +} + +function middle(values: readonly number[]): number { + return (Math.min(...values) + Math.max(...values)) / 2; +} + +function bearingFrom(from: Vector3, point: Vector3, heading: number): number { + const turn = Math.atan2(point.z - from.z, point.x - from.x) - heading; + return Math.atan2(Math.sin(turn), Math.cos(turn)); +} + +function groupPoseAt(target: FollowTarget, behind: number, points: readonly Vector3[]): CameraPose { + const { height, outboard } = GROUP_VIEW; + const slot: Point = [-(FORMATION.back + behind), height, PORT * (FORMATION.side + outboard)]; + const position = boatToWorld(level(target), slot, false); + const turn = middle(points.map((point) => bearingFrom(position, point, target.heading))); + const pitch = middle(points.map((point) => elevationFrom(position, point))); + return { position, target: position.clone().add(direction(target.heading + turn, pitch)) }; +} + +export function groupPose(target: FollowTarget, slopes: FramingSlopes): CameraPose { + const points = groupPoints(target); + const pose = nearestFit( + (behind) => groupPoseAt(target, behind, points), + (candidate) => fitsView(candidate, points, slopes, GROUP_VIEW.fill), + GROUP_VIEW.behind, + ); + return pivotNear(pose, vectorOf(target.position)); +} + +function skyPoints(target: FollowTarget): Vector3[] { + return [ + vectorOf(target.position), + vectorOf(skyPoint(target.position, SATELLITE_OFFSET)), + vectorOf(skyPoint(target.position, BACKUP_SATELLITE_OFFSET)), + ]; +} + +function elevationFrom(from: Vector3, point: Vector3): number { + const rise = point.y - from.y; + return Math.atan2(rise, Math.hypot(point.x - from.x, point.z - from.z)); +} + +export function skyPitch(elevations: readonly number[], slopes: FramingSlopes): number { + const low = Math.min(...elevations); + const high = Math.max(...elevations); + const reach = Math.atan(slopes.vertical * SKY_VIEW.fill); + return Math.min((low + high) / 2, low + reach); +} + +function skyPoseAt( + target: FollowTarget, + back: number, + points: readonly Vector3[], + slopes: FramingSlopes, +): CameraPose { + const behind = direction(target.heading + SKY_VIEW.bearing, 0).multiplyScalar(back); + const position = vectorOf(target.position).add(behind).setY(SKY_VIEW.height); + const ahead = boatToWorld(level(target), [SKY_VIEW.aimAhead, 0, 0], false); + const azimuth = Math.atan2(ahead.z - position.z, ahead.x - position.x); + const pitch = skyPitch( + points.map((point) => elevationFrom(position, point)), + slopes, + ); + const reach = back + SKY_VIEW.aimAhead; + return { position, target: position.clone().addScaledVector(direction(azimuth, pitch), reach) }; +} + +export function skyPose(target: FollowTarget, slopes: FramingSlopes): CameraPose { + const points = skyPoints(target); + const pose = nearestFit( + (back) => skyPoseAt(target, back, points, slopes), + (candidate) => fitsView(candidate, points, slopes, SKY_VIEW.fill), + SKY_VIEW.back, + ); + return pivotNear(pose, vectorOf(target.position)); +} + +export function eyePitch(slopes: FramingSlopes): number { + const [windowX, windowY] = EYE_VIEW.window; + const down = Math.atan2(windowY - EYE_VIEW.rise, windowX - EYE_VIEW.forward); + return down + Math.atan(slopes.vertical * EYE_VIEW.windowShare); +} + +export function eyePose(target: FollowTarget, slopes: FramingSlopes): CameraPose { + const frame = { ...frameOf(target), position: target.lens }; + const { forward, rise, look } = EYE_VIEW; + const pitch = eyePitch(slopes); + const ahead: Point = [forward + look * Math.cos(pitch), rise + look * Math.sin(pitch), 0]; + return { + position: boatToWorld(frame, [forward, rise, 0], true), + target: boatToWorld(frame, ahead, true), + }; +} + +function followView( + source: FollowSource, + pose: (target: FollowTarget, slopes: FramingSlopes) => CameraPose, + distance?: CameraDistance, +): CustomView { + return { + pose: (slopes) => { + const target = source(); + return target ? pose(target, slopes) : null; + }, + follow: 'position', + distance, + }; +} + +function orbitView(source: FollowSource, compact: CompactSource, view: OrbitView): CustomView { + return followView( + source, + (target, slopes) => orbitPose(target, ORBIT_VIEWS[view], slopes, compact()), + VIEW_DISTANCE[view], + ); +} + +export function cameraViews( + source: FollowSource, + compact: CompactSource = () => false, +): Record { + return { + chase: orbitView(source, compact, 'chase'), + waterline: orbitView(source, compact, 'waterline'), + stern: orbitView(source, compact, 'stern'), + sky: followView(source, skyPose, VIEW_DISTANCE.sky), + eye: followView(source, eyePose, VIEW_DISTANCE.eye), + group: followView(source, groupPose, VIEW_DISTANCE.group), + }; +} diff --git a/explainers/naval-drone/src/scene/constants.ts b/explainers/naval-drone/src/scene/constants.ts new file mode 100644 index 00000000..3605f441 --- /dev/null +++ b/explainers/naval-drone/src/scene/constants.ts @@ -0,0 +1,637 @@ +import { toRadians } from '@core/math'; +import { FAIRING, HATCHES, JET } from '../model/layout'; +import { THEME } from '../theme'; + +type Triple = readonly [number, number, number]; + +export const HULL_LINES = { + keelCurveTo: 2.1, + stemBlend: [2.25, 2.4] as const, + softness: 0.02, + epsilon: 1e-6, + flatTaperFrom: 1.6, + panelShare: 0.6, + railTaper: 0.15, + cutX: 2.3, + stations: { aftSpacing: 0.05, bowFrom: 2, bowSpacing: 0.02, tolerance: 0.004 }, +} as const; + +export const SHELL = { + thickness: 0.012, + fairing: 0.024, + railRise: 0.016, + railUv: [0.3, 0.31, 0.29] as Triple, + jetHole: 0.112, + centre: [-0.5, 0.1, 0] as Triple, + port: [0, 0, 1] as Triple, +} as const; + +export const SURFACE_MAPS = { + edgeBand: 0.05, + side: { + size: [512, 128] as const, + span: 1.2, + mottle: { cells: [56, 10] as const, strength: 0.07, seed: 11 }, + }, + deck: { + size: [1024, 256] as const, + mottle: { cells: [72, 18] as const, strength: 0.06, seed: 5 }, + seams: { + colour: '#b4b4b4', + width: 0.006, + reach: FAIRING.topHalfWidth, + across: [-2.3, -1.8, -1.3, -0.8, 0.75, 2.05], + along: [ + { z: -0.44, x: FAIRING.x }, + { z: 0.44, x: FAIRING.x }, + ], + }, + groove: { colour: '#3c3c3c', width: 0.01 }, + lip: { colour: '#f4f4f4', width: 0.004 }, + radius: 0.03, + }, + panel: { + size: [128, 192] as const, + cells: [6, 9] as const, + line: '#d5d9dd', + base: '#ffffff', + sheen: '#e9ecef', + }, + grain: { size: 64, low: 0.6, high: 0.84, seed: 3, repeat: [10, 3] as const }, +} as const; + +export const FAIRING_SHAPE = { + sideSamples: 24, + frontSamples: 4, + recess: 0.012, + centre: [-1.5, 0.35, 0] as Triple, +} as const; + +const HATCH_INSET = 0.035; + +function handleRow(xs: readonly number[], z: number): Triple[] { + return xs.flatMap((x) => [ + [x, z, 0], + [x, -z, 0], + ]); +} + +export const DECK_ITEMS = { + slopeStep: 0.05, + panelFloor: FAIRING.top - FAIRING_SHAPE.recess, + vent: { + frame: 0.018, + inset: 0.014, + bottom: FAIRING.top, + capOverhang: 0.008, + capThickness: 0.012, + slat: { pitch: 0.022, depth: 0.024, thickness: 0.004, angle: toRadians(35) }, + }, + panel: { edgeUv: 0.02, foot: 0.012, footInset: 0.05, glow: '#8fc4ff' }, + stub: { flange: 0.03, flangeHeight: 0.006, capSamples: 6 }, + dome: { + ringInner: 0.114, + ringDepth: 0.03, + ringRise: 0.01, + segments: 40, + capSamples: 10, + skirt: { flare: 0.004, height: 0.02 }, + window: { lift: 1.012, azimuth: 24, from: 22, to: 62 }, + }, + bowCamera: { + samples: 14, + around: 14, + tailWidth: 0.02, + tailHeight: 0.004, + sink: 0.012, + squareness: 0.55, + proud: 0.003, + }, + handle: { washer: 0.011, washerHeight: 0.003, stem: 0.006, barRadius: 0.0045 }, + handleSpots: [ + ...handleRow([1.27, 1.45, 1.63], HATCHES.forward.halfWidth - HATCH_INSET), + [HATCHES.forward.x[0] + HATCH_INSET, 0, Math.PI / 2], + [HATCHES.forward.x[1] - HATCH_INSET, 0, Math.PI / 2], + ...handleRow([-0.02, 0.2, 0.42], HATCHES.mid.halfWidth - HATCH_INSET), + ] as readonly Triple[], +} as const; + +export const WAVES = { + direction: toRadians(205), + lengthPerMetre: 30, + lengthPower: 0.5, + minHeight: 0.1, + defaultHeight: 0.3, + phaseStep: 1.7, + components: [ + { length: 1, angle: 0, share: 0.24 }, + { length: 0.83, angle: toRadians(-23), share: 0.18 }, + { length: 0.71, angle: toRadians(31), share: 0.15 }, + { length: 0.53, angle: toRadians(-38), share: 0.12 }, + { length: 0.37, angle: toRadians(64), share: 0.1 }, + { length: 0.29, angle: toRadians(-71), share: 0.08 }, + { length: 0.19, angle: toRadians(12), share: 0.08 }, + { length: 0.13, angle: toRadians(-17), share: 0.05 }, + ], +} as const; + +export const WET = { + rise: 0.025, + fade: 0.06, + darken: 0.62, + roughness: 0.22, + murk: 0.6, + murkDepth: 0.3, +} as const; + +export const SUN_DIRECTION: Triple = [0.172, 0.122, -0.977]; + +export const LIGHT_RIG = { + distance: 1000, + sun: SUN_DIRECTION, + key: { color: '#ffc58a', intensity: 1.35 }, + fill: { color: '#7f9bb8', intensity: 0.35, direction: [-0.35, 1, 0.45] as Triple }, + rim: { color: '#a9bfdc', intensity: 1.1, direction: [-0.3, 0.16, 0.94] as Triple }, + sky: { color: '#7f9bb8', ground: '#1b2a3a', intensity: 1.75 }, +} as const; + +export const HAZE = { colour: '#8d9aa6', near: 400, far: 5000 } as const; + +export const HIGHLIGHT_DIM = { saturation: 0.55, brightness: 0.66, emissive: 0.4 } as const; + +export const SCENE_LIMITS = { + cameraNear: 0.05, + cameraFar: 9000, + maxPolarAngle: Math.PI / 2, + cameraMinDistance: 1, + cameraMaxDistance: 2500, +} as const; + +export const JET_SHAPE = { + segments: 40, + bore: 0.0785, + housing: { + ringRadius: 0.112, + flange: { + radius: 0.15, + aft: -2.768, + bolts: 8, + boltRadius: 0.135, + bolt: 0.009, + boltHead: 0.008, + }, + bowl: [ + [-2.768, 0.12], + [-2.8, 0.126], + [-2.85, 0.125], + [-2.885, 0.117], + ] as readonly (readonly [number, number])[], + aftFace: 0.104, + }, + nozzle: { wall: 0.007 }, + steering: { + outer: 0.052, + inner: 0.045, + sleeve: 0.057, + sleeveLength: 0.014, + pin: { radius: 0.009, height: 0.016 }, + arm: { length: 0.085, width: 0.018, thickness: 0.008, ball: 0.011 }, + }, + impeller: { + hub: [ + [-2.505, 0.008], + [-2.515, 0.022], + [-2.53, 0.032], + [-2.548, 0.035], + [-2.585, 0.035], + [-2.595, 0.029], + ] as readonly (readonly [number, number])[], + lead: -2.518, + chord: 0.064, + wrap: 1.75, + sweep: 0.32, + clearance: 0.002, + thickness: 0.0032, + radial: 7, + along: 12, + blurOpacity: 0.32, + }, + stator: { + cone: [ + [-2.75, 0.036], + [-2.8, 0.034], + [-2.86, 0.027], + [-2.92, 0.014], + [-2.95, 0.004], + ] as readonly (readonly [number, number])[], + lead: -2.755, + trail: -2.875, + curl: 0.6, + thickness: 0.003, + radial: 5, + along: 8, + }, + shaft: { collar: 0.022, collarLength: 0.03, coupler: 0.045, couplerLength: 0.035 }, + grate: { width: 0.008, height: 0.018 }, + duct: { + rings: 20, + around: 36, + wall: 0.008, + squareness: 0.45, + entry: [0.22, 1.25] as const, + pull: [0.38, 0.32] as const, + }, + bucket: { + radius: 0.09, + arc: [toRadians(100), toRadians(290)] as const, + centre: [-3.165, -0.15] as const, + thickness: 0.006, + stow: toRadians(-110), + arm: { width: 0.022, thickness: 0.008 }, + boss: 0.016, + bracket: { x: [-3.0, -2.905] as const, bottom: -0.085, thickness: 0.01 }, + samples: [14, 6] as const, + }, +} as const; + +export const INTERNALS = { + clearance: 0.022, + bay: { foreHalfWidth: 0.27, samples: 10 }, + tank: { samples: 10, fill: 0.58, neck: { x: 0.36, z: 0.2, radius: 0.032, cap: 0.04 } }, + tub: { wall: 0.004, floorLift: 0.058, foamTop: -0.02 }, + engine: { + sump: { x: [-1.58, -0.87] as const, halfWidth: 0.15, y: [-0.2, -0.06] as const }, + block: { x: [-1.6, -0.85] as const, halfWidth: 0.2, y: [-0.08, 0.06] as const }, + head: { x: [-1.57, -0.88] as const, halfWidth: 0.16, y: [0.06, 0.118] as const }, + cover: { x: [-1.55, -0.9] as const, halfWidth: 0.13, y: [0.118, 0.155] as const }, + rounding: 0.02, + coilXs: [-1.46, -1.225, -0.99] as const, + coil: [0.06, 0.03, 0.05] as Triple, + plenum: { x: [-1.48, -0.97] as const, z: -0.235, y: 0.075, radius: 0.042 }, + runner: { radius: 0.019, from: [-0.155, 0.09] as const }, + throttle: { radius: 0.03, length: 0.06 }, + exhaust: { + radius: 0.022, + collector: [-1.0, 0.24, -0.0] as Triple, + outlet: [-2.755, 0.04, 0.36] as Triple, + }, + muffler: { centre: [-1.85, 0.0, 0.33] as Triple, radius: 0.06, length: 0.22 }, + flange: { radius: 0.06, length: 0.03 }, + mounts: { xs: [-1.48, -0.97] as const, z: 0.185, size: [0.05, 0.03, 0.04] as Triple }, + }, + tray: { thickness: 0.008 }, + electronics: { + computer: { x: [-1.5, -1.17] as const, z: [-0.3, -0.04] as const, height: 0.075 }, + router: { x: [-1.08, -0.88] as const, z: [-0.28, -0.12] as const, height: 0.045 }, + power: { x: [-1.18, -0.95] as const, z: [0.06, 0.28] as const, height: 0.045 }, + puck: { x: -0.86, z: 0.2, radius: 0.04, height: 0.02 }, + canister: { x: [-1.46, -1.3] as const, z: 0.24, radius: 0.034 }, + }, + cable: { radius: 0.007, sag: 0.04 }, + detail: { + runnerRise: 0.03, + headerShare: 0.6, + exhaustGrow: 1.2, + mountRise: 0.02, + }, + routes: { + headers: [ + [0, 0.08, 0.16], + [0, 0.04, 0.215], + ] as Triple[], + pipe: [-1.4, 0, 0.25] as Triple, + tail: [-2.3, 0.02, 0.35] as Triple, + panels: [ + { from: [-1.4, 0.323, -0.2] as Triple, to: [-2.05, 0.48, -0.1] as Triple }, + { from: [-1.3, 0.323, -0.1] as Triple, to: [-1.55, 0.48, -0.08] as Triple }, + { from: [-1.0, 0.293, -0.2] as Triple, to: [-1.05, 0.48, -0.08] as Triple }, + ], + }, +} as const; + +export const SKY = { + radius: 7000, + widthSegments: 48, + heightSegments: 24, + renderOrder: -10, + colours: { + zenith: '#1a2740', + upper: '#41587a', + sunHorizon: '#f0a868', + haze: HAZE.colour, + glow: '#ffcf94', + sun: '#fff1d8', + cloudLit: '#f6c79a', + cloudShade: '#8b97a8', + }, + heights: { band: 0.08, upper: 0.32 }, + glow: { tight: 900, broad: 9, tightGain: 2.4, broadGain: 0.45, disc: 0.99985 }, + clouds: { + cover: 0.6, + sharpness: 0.3, + scale: 0.3, + stretch: 6, + from: 0.04, + to: 0.6, + opacity: 0.4, + }, +} as const; + +const THEME_SEA = { deep: THEME.seaDeep, lit: THEME.seaLit, foam: THEME.foam } as const; + +export const SEA = { + rings: 168, + segments: 128, + innerRadius: 0.35, + growth: 1.062, + snap: 0.5, + renderOrder: 1, + colours: { + deep: THEME_SEA.deep, + lit: THEME_SEA.lit, + foam: THEME_SEA.foam, + scatter: '#2f6b74', + }, + ripples: { + size: 256, + waves: 28, + seed: 17, + scales: [0.19, 0.53] as const, + flow: [ + [0.021, 0.013], + [-0.017, 0.026], + ] as const, + strength: 0.55, + broad: 0.031, + fadeFrom: 40, + fadeTo: 900, + }, + foam: { size: 128, seed: 23, scale: 0.22, crestFrom: 0.78, crestTo: 1.15 }, + glitter: { sharp: 1400, broad: 90, sharpGain: 7, broadGain: 0.6, farSharp: 60 }, + fresnel: { base: 0.02, power: 5 }, + hullMargin: 0.004, + plan: { columns: 128, rows: 40, x: [-3, 3] as const, y: [-0.45, 0.9] as const, scale: 0.8 }, +} as const; + +export const SECTION_LOOK = { + depth: 1, + backDepth: 5, + backReach: 3, + backRise: 0.5, + clear: 0.16, + rim: 0.03, + edge: 0.3, + elevation: [20, 35] as const, + faces: [6, 15] as const, + soft: 0.6, + side: [-3, 0] as const, + deep: '#0a2433', + glint: '#c6ecf2', + samples: { along: 96, across: 40 }, + renderOrder: -3, +} as const; + +export const MOTION = { + bow: 2, + stern: 2.3, + side: 0.65, + planedResponse: 0.35, + heaveDamping: 0.25, + maxPitch: toRadians(12), + maxRoll: toRadians(14), + shipSide: 7, + shipEnd: 50, + shipResponse: 0.6, + swell: 3, + lag: 1, + held: 0.12, +} as const; + +export const SEA_FOAM: Readonly> = { + smooth: 0, + slight: 0.3, + moderate: 0.65, + rough: 1, +}; + +export const WAKE = { + start: -JET.nozzle.x[1], + samples: 72, + length: 320, + power: 1.7, + across: 7, + armAcross: 3, + lift: 0.03, + coreHalfWidth: 0.62, + coreSpread: toRadians(4.5), + kelvin: toRadians(19.47), + planedArm: toRadians(10.5), + armWidth: [0.35, 4] as const, + armReach: 140, + fade: { core: 12, wash: 90, arm: 55 }, + speedFrom: [0.4, 9] as const, + foamScale: 1, + emphasis: 1.35, + tint: '#5fb3bd', + renderOrder: 2, +} as const; + +export const HULL_WATER = { + bow: { + rows: 16, + columns: 9, + length: [0.7, 2.6] as const, + height: 0.34, + peakAt: 0.22, + curl: 0.55, + spread: 1.8, + flare: toRadians(18), + onFrom: [0.5, 4] as const, + offFrom: [12.5, 16] as const, + peakKnots: 11, + }, + waterline: { rows: 28, columns: 3, width: 0.16, from: [0.3, 3] as const }, + stern: { + rows: 7, + columns: 10, + length: 4.2, + halfWidth: 1.5, + height: 0.26, + peak: 0.42, + width: 0.22, + }, + spray: { + rows: 10, + columns: 9, + onFrom: [11, 18] as const, + root: [0.25, 0.9] as const, + range: [0.6, 2.2] as const, + up: toRadians(6), + back: toRadians(50), + drop: 0.3, + trail: 1.2, + skim: 0.02, + }, + looks: { + bow: { opacity: 0.95, scroll: 0.5, streaks: 3.5, stretch: 0.5, colour: '#f2f7f8', floor: 0.6 }, + waterline: { opacity: 0.85, scroll: 1.2, streaks: 9, stretch: 0.35, colour: '#eef4f6' }, + stern: { opacity: 0.5, scroll: 0.2, streaks: 2.5, stretch: 0.6, colour: '#e9f1f3' }, + spray: { opacity: 0.85, scroll: 1.6, streaks: 8, stretch: 1.1, colour: '#ffffff' }, + }, + profiles: { + bow: [0.04, 0.5] as const, + sheet: [0.1, 0.5] as const, + line: [0.0, 1.0] as const, + mound: [0.25, 0.55] as const, + }, + emphasis: 1.3, + levels: { + bowFloor: 0.35, + sternPeak: 10, + sternWidth: 3.5, + sternOn: [4, 7] as const, + shown: 0.01, + }, + tuning: { + hullGap: 0.004, + lineLift: 0.012, + bowStart: 0.05, + bowDecay: 2.4, + bowFlare: 0.35, + bowFall: 0.15, + bowLean: 0.6, + sternStart: 0.1, + sternTaper: 1.15, + sternLift: 0.015, + anchorBack: 0.4, + sprayOut: 0.08, + }, +} as const; + +export const JET_STREAM = { + segments: 16, + rings: 14, + exitRadius: 0.042, + spread: 0.09, + lengthPerSpeed: 0.055, + length: [0.35, 2.1] as const, + reverse: { rings: 10, radius: 0.035, length: 0.75, angle: toRadians(35), dive: 0.3, fade: 0.5 }, + look: { opacity: 0.6, scroll: 0, streaks: 3.5, stretch: 1, colour: '#dff3ff' }, + profile: [0.05, 0.55] as const, + rooster: { + count: 640, + size: 0.34, + life: 0.8, + aft: 6.5, + up: 3.6, + spread: 1.1, + seed: 41, + onFrom: [15, 30] as const, + }, + emphasis: 1.3, + tuning: { + reverseOn: 0.5, + minLength: 0.1, + throttleFade: 0.2, + anchorShare: 0.4, + reverseCentre: [-3.17, -0.15] as const, + reverseArc: 0.6, + reverseSink: 0.1, + reverseGrow: 2.6, + shown: 0.01, + jitter: [0.4, 0.45, 0.7, 0.5] as const, + speedDrift: 0.05, + lateralGain: 2, + alpha: 0.22, + }, +} as const; + +export const WETTED_BAR = { + samples: 24, + width: 0.035, + tick: 0.14, + line: 0.012, + lift: 0.012, + offset: 0.03, + below: 0.006, + colour: '#ffd36b', +} as const; + +export const ANIMATION = { + impellerCap: 5, + blurFrom: 6, + rpmToHertz: 1 / 60, + domeScan: toRadians(38), + domeRate: 0.28, + radarRate: 2.1, +} as const; + +export const LABEL_SPOTS = { + chinesX: -1.2, + railShare: 0.8, + outboard: 0.04, + wakeBehind: 5, + bowOut: 0.45, +} as const; + +export const MISSILE_SHAPE = { + rail: { length: 1.8, height: 0.05, width: 0.07 }, + pylons: [-0.7, 0.62] as const, + pylonLength: 0.22, + pylonWidth: 0.05, + hanger: 0.012, + profile: [ + [0, 0], + [0.06, 0.035], + [0.18, 0.068], + [0.32, 0.085], + [2.72, 0.085], + [2.86, 0.07], + [2.9, 0.062], + ] as readonly (readonly [number, number])[], + segments: 24, + fins: { root: 0.24, tip: 0.1, span: 0.11, from: 2.58, thickness: 0.006 }, + seekerGrow: 1.02, +} as const; + +export interface BeamLook { + startRadius: number; + endRadius: number; + opacity: number; + core: number; + glow: boolean; + dash: { period: number; duty: number; speed: number; logScale: number; floor: number }; + fade: readonly [start: number, end: number]; + segments: number; +} + +const BEAM_DASH = { period: 2.4, duty: 0.55, speed: 1.4, logScale: 22, floor: 0.3 } as const; +const BEAM_BASE = { core: 0.8, glow: false, dash: BEAM_DASH, segments: 14 } as const; + +export const BEAMS = { + up: { ...BEAM_BASE, startRadius: 0.06, endRadius: 1.2, opacity: 0.95, fade: [0.003, 0.03] }, + down: { ...BEAM_BASE, startRadius: 1.2, endRadius: 0.6, opacity: 0.9, fade: [0.02, 0.01] }, + labelShare: 0.08, + panelGlow: 0.55, +} as const satisfies Record; + +export const GHOST = { + colour: '#ffffff', + opacity: 0.16, + tether: { + ...BEAM_BASE, + startRadius: 0.03, + endRadius: 0.03, + opacity: 0.7, + dash: { ...BEAM_DASH, period: 0 }, + fade: [0.01, 0.01], + segments: 6, + }, +} as const satisfies Record; + +export const COMPANION = { + segments: 18, + exit: 0.05, + stubSides: 6, + shoulder: Math.SQRT1_2, +} as const; diff --git a/explainers/naval-drone/src/scene/controller.dom.test.ts b/explainers/naval-drone/src/scene/controller.dom.test.ts new file mode 100644 index 00000000..1fad0edb --- /dev/null +++ b/explainers/naval-drone/src/scene/controller.dom.test.ts @@ -0,0 +1,153 @@ +import { Box3, Vector3 } from 'three'; +import { describe, expect, it, vi } from 'vitest'; +import { MaterialLibrary } from '@core/scene/materials'; +import { createSceneTextures } from '@core/scene/textures'; +import type { AssemblyState } from '../ids'; +import { HELD_PHASE } from '../model'; +import { CHAPTER_CONTROL_DEFAULTS, DEFAULT_VIEW, runAt } from '../state'; +import { EYE_VIEW, LOOK_AROUND_POLAR, VIEW_DISTANCE } from './cameraViews'; +import { NavalDroneController } from './controller'; +import type { NavalDroneControllerDependencies } from './controller'; + +const PHONE = { vertical: 0.2, horizontal: 0.27 }; +const LEVEL_LIMIT = Math.PI / 2; +const PHONE_WIDTH_PX = 390; +const DESKTOP_WIDTH_PX = 835; + +function assemblyState(changes: Partial = {}): AssemblyState { + const reading = runAt({ ...CHAPTER_CONTROL_DEFAULTS, phase: HELD_PHASE, preset: 'overview' }); + return { + phase: HELD_PHASE, + playing: false, + boat: reading.boat, + planing: reading.planing, + jet: reading.jet, + companions: reading.companions, + sea: reading.sea, + link: reading.link, + fit: 'standard', + view: DEFAULT_VIEW, + waterSection: true, + wettedBar: false, + ...changes, + }; +} + +function fakeShell(stageWidth = DESKTOP_WIDTH_PX) { + const rig = { + framing: () => PHONE, + follow: vi.fn(), + setDistanceLimits: vi.fn(), + jumpTo: vi.fn(), + tweenTo: vi.fn(), + setBounds: vi.fn(), + camera: { position: new Vector3(0, 0, 30) }, + controls: { target: new Vector3(), maxPolarAngle: LEVEL_LIMIT }, + }; + const shell = { + scene: { add: vi.fn() }, + 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, shell: shell as unknown as NavalDroneControllerDependencies }; +} + +describe('naval drone controller', () => { + it('adds the scene and keeps the camera target above the sea floor of the bounds', () => { + const { rig, shell } = fakeShell(); + const navalDrone = new NavalDroneController(shell); + navalDrone.build(assemblyState()); + expect(shell.scene.add).toHaveBeenCalledTimes(1); + expect(shell.labels.attach).toHaveBeenCalledTimes(1); + const [bounds, floor] = rig.setBounds.mock.lastCall ?? []; + expect(bounds).toBeInstanceOf(Box3); + expect(floor).toBe((bounds as Box3).min.y); + expect(floor).toBeLessThan(0); + }); + + it('frames a view around the boat and follows it once the scene is built', () => { + const { rig, shell } = fakeShell(); + const navalDrone = new NavalDroneController(shell); + navalDrone.views.frame('chase', true); + expect(rig.tweenTo).not.toHaveBeenCalled(); + const state = assemblyState(); + navalDrone.build(state); + navalDrone.views.frame('chase', true); + const target = rig.tweenTo.mock.lastCall?.[0]?.target as Vector3 | undefined; + expect(target?.x).toBeCloseTo(state.boat.position[0], 1); + expect(rig.follow).toHaveBeenLastCalledWith(expect.anything(), 'position'); + expect(rig.setDistanceLimits).toHaveBeenLastCalledWith({}); + }); + + it('moves the boat off the centre of a phone-sized stage', () => { + const state = assemblyState(); + const aimOn = (width: number) => { + const { rig, shell } = fakeShell(width); + const navalDrone = new NavalDroneController(shell); + navalDrone.build(state); + navalDrone.views.frame('waterline', false); + return (rig.jumpTo.mock.lastCall?.[0] as { target: Vector3 }).target; + }; + const wide = aimOn(DESKTOP_WIDTH_PX); + const phone = aimOn(PHONE_WIDTH_PX); + expect(wide.x).toBeCloseTo(state.boat.position[0], 1); + expect(phone.distanceTo(wide)).toBeGreaterThan(0.5); + }); + + it('rides the deck in the eye view so the camera moves with the waves', () => { + const { rig, shell } = fakeShell(); + const navalDrone = new NavalDroneController(shell); + navalDrone.build(assemblyState()); + navalDrone.views.frame('eye', false); + const eyeAnchor = rig.follow.mock.lastCall?.[0] as Vector3 | undefined; + navalDrone.views.frame('chase', false); + const boatAnchor = rig.follow.mock.lastCall?.[0] as Vector3 | undefined; + expect(eyeAnchor).toBeDefined(); + expect(eyeAnchor).not.toBe(boatAnchor); + }); + + it('turns in place from the dome in the eye view without a sea limit on the zoom', () => { + const { rig, shell } = fakeShell(); + const navalDrone = new NavalDroneController(shell); + navalDrone.build(assemblyState()); + navalDrone.views.frame('eye', false); + expect(rig.controls.maxPolarAngle).toBe(LOOK_AROUND_POLAR.eye); + expect(rig.setDistanceLimits).toHaveBeenLastCalledWith(VIEW_DISTANCE.eye); + const pose = rig.jumpTo.mock.lastCall?.[0] as { position: Vector3; target: Vector3 }; + expect(pose.position.distanceTo(pose.target)).toBeCloseTo(EYE_VIEW.look, 9); + }); + + it('lets the sky view look up without the camera reaching the sea, then levels again', () => { + const { rig, shell } = fakeShell(); + const navalDrone = new NavalDroneController(shell); + navalDrone.build(assemblyState()); + navalDrone.views.frame('sky', false); + expect(rig.controls.maxPolarAngle).toBeGreaterThan(LEVEL_LIMIT); + const limits = rig.setDistanceLimits.mock.lastCall?.[0] as { max: number }; + expect(limits.max).toBeGreaterThan(0); + navalDrone.views.frame('stern', true); + expect(rig.controls.maxPolarAngle).toBe(LEVEL_LIMIT); + expect(rig.setDistanceLimits).toHaveBeenLastCalledWith({ min: 1.2, max: 30 }); + }); + + it('keeps drawing frames while the run plays or the reader holds the boat', () => { + const { shell } = fakeShell(); + const navalDrone = new NavalDroneController(shell); + expect(navalDrone.update(0.016)).toBe(false); + navalDrone.build(assemblyState()); + expect(navalDrone.update(0.016)).toBe(false); + navalDrone.setState(assemblyState({ playing: true })); + expect(navalDrone.update(0.016)).toBe(true); + const held = assemblyState(); + navalDrone.setState({ ...held, boat: { ...held.boat, held: true } }); + expect(navalDrone.update(0.016)).toBe(true); + navalDrone.dispose(); + expect(navalDrone.update(0.016)).toBe(false); + }); +}); diff --git a/explainers/naval-drone/src/scene/controller.ts b/explainers/naval-drone/src/scene/controller.ts new file mode 100644 index 00000000..83423ddf --- /dev/null +++ b/explainers/naval-drone/src/scene/controller.ts @@ -0,0 +1,115 @@ +import { Vector3 } from 'three'; +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 { AnchorId, AssemblyState, CameraView, RegionId } from '../ids'; +import { createAssembly } from './assembly'; +import type { Assembly } from './assembly'; +import { LOOK_AROUND_POLAR, VIEW_DISTANCE, cameraViews } from './cameraViews'; +import type { FollowTarget } from './cameraViews'; +import { isCompactStage, lookUpLimits } from './viewFit'; + +export type NavalDroneControllerDependencies = Pick< + SceneShell, + 'scene' | 'materials' | 'textures' | 'labels' | 'rig' | 'viewport' +>; + +export type ViewFramer = Pick, 'frame'>; + +const SEA_CLEARANCE_M = 1; +const FOLLOWED_ANCHOR: Partial> = { eye: 'dome' }; + +export class NavalDroneController { + readonly views: ViewFramer; + private readonly dependencies: NavalDroneControllerDependencies; + private readonly cameraViews: CameraViews; + private readonly defaultMaxPolar: number; + private assembly: Assembly | null = null; + private state: AssemblyState | null = null; + private cancelWarmUp: () => void = () => {}; + private followed: AnchorId = 'boat'; + + constructor(dependencies: NavalDroneControllerDependencies) { + this.dependencies = dependencies; + this.defaultMaxPolar = dependencies.rig.controls.maxPolarAngle; + this.cameraViews = new CameraViews(dependencies.rig, { + views: cameraViews( + () => this.followTarget(), + () => isCompactStage({ width: dependencies.viewport.element.clientWidth }), + ), + region: (id) => this.assembly?.region(id) ?? null, + anchor: () => this.assembly?.anchor(this.followed) ?? null, + }); + this.views = { frame: (view, animate, variant) => this.frame(view, animate, variant) }; + } + + build(state: AssemblyState): void { + const { scene, materials, textures, labels, rig } = this.dependencies; + this.dispose(); + const assembly = createAssembly({ materials, textures }, state); + this.assembly = assembly; + this.state = state; + scene.add(assembly.root); + labels.attach(assembly.labelAnchors()); + const bounds = assembly.region('scene'); + rig.setBounds(bounds, bounds.min.y); + this.cancelWarmUp = whenIdle(() => assembly.warmUp?.((object) => this.compileHidden(object))); + } + + setState(state: AssemblyState): void { + this.state = state; + this.assembly?.setState(state); + } + + update(deltaSeconds: number): boolean { + const { camera, controls } = this.dependencies.rig; + return ( + this.assembly?.update(deltaSeconds, camera.position.distanceTo(controls.target)) ?? false + ); + } + + dispose(): void { + this.cancelWarmUp(); + this.assembly?.root.removeFromParent(); + this.assembly?.dispose(); + this.assembly = null; + } + + private followTarget(): FollowTarget | null { + const { assembly, state } = this; + if (!assembly || !state) return null; + const lens = assembly.anchor('dome').getWorldPosition(new Vector3()); + return { ...assembly.chaseTarget(), trim: state.planing.trim, lens: [lens.x, lens.y, lens.z] }; + } + + private frame(view: CameraView, animate: boolean, variant?: string): void { + this.followed = FOLLOWED_ANCHOR[view] ?? 'boat'; + this.cameraViews.frame(view, animate, variant); + this.limitLookUp(view); + } + + private limitLookUp(view: CameraView): void { + const { rig } = this.dependencies; + const lookAround = LOOK_AROUND_POLAR[view]; + if (lookAround !== undefined) { + rig.controls.maxPolarAngle = lookAround; + return; + } + const pose = this.cameraViews.pose(view); + if (!pose) return; + const limits = lookUpLimits(pose, this.defaultMaxPolar, SEA_CLEARANCE_M); + rig.controls.maxPolarAngle = limits.maxPolarAngle; + if (limits.maxDistance !== null) { + rig.setDistanceLimits({ ...VIEW_DISTANCE[view], max: limits.maxDistance }); + } + } + + 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/naval-drone/src/scene/drawnLabels.test.ts b/explainers/naval-drone/src/scene/drawnLabels.test.ts new file mode 100644 index 00000000..6fa68274 --- /dev/null +++ b/explainers/naval-drone/src/scene/drawnLabels.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest'; +import { boatAt, jetAt, planingAt, seaAt } from '../model'; +import { DEFAULT_VIEW } from '../state'; +import { DrawnLabels } from './drawnLabels'; +import { isCompactStage } from './viewFit'; +import type { DrawnSource } from './drawnLabels'; + +const ALL = new Set([ + 'satLink', + 'backupLink', + 'satellite', + 'backupSatellite', + 'videoGhost', + 'missileRails', + 'companions', + 'bowWave', + 'spray', + 'wettedLength', + 'jetStream', + 'hull', +]); + +function source(knots: number, changes: Partial = {}): DrawnSource { + return { + view: DEFAULT_VIEW, + link: { mode: 'satellite', ghost: null }, + fit: 'standard', + companions: [], + planing: planingAt(knots), + boat: { ...boatAt(60), knots }, + wettedBar: false, + jet: jetAt(knots / 42, knots, 0, false), + ...changes, + }; +} + +function shown(changes: DrawnSource): ReadonlySet { + const policy = { setWanted: vi.fn() }; + const labels = new DrawnLabels(policy); + labels.setWanted(ALL, ALL); + labels.follow(changes); + return policy.setWanted.mock.lastCall?.[0] as ReadonlySet; +} + +describe('drawn labels', () => { + it('hides the labels of what the scene does not draw at rest', () => { + expect([...shown(source(0))]).toEqual(['hull']); + }); + + it('shows the bow wave below planing and the spray from the hump on', () => { + expect(shown(source(11))).toContain('bowWave'); + expect(shown(source(11))).toContain('spray'); + expect(shown(source(22))).not.toContain('bowWave'); + expect(shown(source(22))).toContain('spray'); + expect(shown(source(22))).toContain('jetStream'); + }); + + it('labels the beam of the link in use while the links are shown', () => { + const links = { ...DEFAULT_VIEW, links: true }; + expect(shown(source(22, { view: links }))).toContain('satLink'); + const backup = shown(source(22, { view: links, link: { mode: 'backup', ghost: null } })); + expect(backup).toContain('backupLink'); + expect(backup).not.toContain('satLink'); + expect(shown(source(22, { link: { mode: 'satellite', ghost: null } }))).not.toContain( + 'satLink', + ); + }); + + it('labels the satellites only while the links are shown, as the scene draws them', () => { + const links = { ...DEFAULT_VIEW, links: true }; + ['satellite', 'backupSatellite'].forEach((part) => { + expect(shown(source(22, { view: links })), part).toContain(part); + expect( + shown(source(22, { view: links, link: { mode: 'lost', ghost: null } })), + part, + ).toContain(part); + expect(shown(source(22)), part).not.toContain(part); + }); + }); + + it('labels the ghost, the rails, the companions and the wetted bar when drawn', () => { + const drawn = shown( + source(42, { + link: { mode: 'satellite', ghost: { position: [0, 0, 0], heading: 0 } }, + fit: 'missile', + companions: [{ position: [0, 0, 0], heading: 0 }], + wettedBar: true, + sea: seaAt('smooth'), + } as Partial), + ); + ['videoGhost', 'missileRails', 'companions', 'wettedLength'].forEach((part) => + expect(drawn).toContain(part), + ); + }); + + it('drops the minor pump labels while the stage is phone-sized', () => { + const policy = { setWanted: vi.fn() }; + const labels = new DrawnLabels(policy); + const pump = new Set(['impeller', 'nozzle', 'driveShaft', 'steeringNozzle']); + labels.setWanted(pump, pump); + const wanted = () => [...(policy.setWanted.mock.lastCall?.[0] as ReadonlySet)]; + labels.setCompact(isCompactStage({ width: 390 })); + expect(wanted()).toEqual(['impeller', 'steeringNozzle']); + const calls = policy.setWanted.mock.calls.length; + labels.setCompact(isCompactStage({ width: 400 })); + expect(policy.setWanted.mock.calls).toHaveLength(calls); + labels.setCompact(isCompactStage({ width: 835 })); + expect(wanted()).toEqual([...pump]); + }); + + it('publishes again only when a drawn part changes', () => { + const policy = { setWanted: vi.fn() }; + const labels = new DrawnLabels(policy); + labels.setWanted(ALL, ALL); + labels.follow(source(22)); + const calls = policy.setWanted.mock.calls.length; + labels.follow(source(23)); + expect(policy.setWanted.mock.calls).toHaveLength(calls); + labels.follow(source(10)); + expect(policy.setWanted.mock.calls).toHaveLength(calls + 1); + }); +}); diff --git a/explainers/naval-drone/src/scene/drawnLabels.ts b/explainers/naval-drone/src/scene/drawnLabels.ts new file mode 100644 index 00000000..657ef9f9 --- /dev/null +++ b/explainers/naval-drone/src/scene/drawnLabels.ts @@ -0,0 +1,87 @@ +import type { LabelPolicy } from '@core/scene/presetBinder'; +import type { AssemblyState, PartId } from '../ids'; + +export type DrawnSource = Pick< + AssemblyState, + 'view' | 'link' | 'fit' | 'companions' | 'planing' | 'boat' | 'wettedBar' | 'jet' +>; + +type DrawnTest = (state: DrawnSource) => boolean; + +const NO_PARTS: ReadonlySet = new Set(); +const MOVING_KNOTS = 0.5; + +const COMPACT_HIDDEN: ReadonlySet = new Set([ + 'driveShaft', + 'stator', + 'nozzle', + 'fuelTanks', +]); + +const DRAWN_WHEN: Readonly>> = { + satLink: ({ view, link }) => view.links && link.mode === 'satellite', + backupLink: ({ view, link }) => view.links && link.mode === 'backup', + satellite: ({ view }) => view.links, + backupSatellite: ({ view }) => view.links, + videoGhost: ({ link }) => link.ghost !== null, + missileRails: ({ fit }) => fit === 'missile', + companions: ({ companions }) => companions.length > 0, + bowWave: ({ planing, boat }) => planing.mode !== 'planing' && boat.knots > MOVING_KNOTS, + spray: ({ planing }) => planing.mode !== 'floating', + wettedLength: ({ wettedBar }) => wettedBar, + jetStream: ({ jet }) => jet.flow > 0, +}; + +const DRAWN_TESTS = Object.entries(DRAWN_WHEN) as readonly [PartId, DrawnTest][]; + +export class DrawnLabels implements LabelPolicy { + private readonly policy: LabelPolicy; + private readonly hidden = new Set(); + private wanted = NO_PARTS; + private pinned = NO_PARTS; + private compact = false; + + constructor(policy: LabelPolicy) { + this.policy = policy; + } + + setWanted(wanted: ReadonlySet, pinned: ReadonlySet): void { + this.wanted = wanted; + this.pinned = pinned; + this.publish(); + } + + follow(state: DrawnSource): void { + if (this.hideUndrawn(state)) this.publish(); + } + + setCompact(compact: boolean): void { + if (compact === this.compact) return; + this.compact = compact; + this.publish(); + } + + private hideUndrawn(state: DrawnSource): boolean { + let changed = false; + for (const [part, isDrawn] of DRAWN_TESTS) { + const hidden = !isDrawn(state); + if (hidden === this.hidden.has(part)) continue; + if (hidden) this.hidden.add(part); + else this.hidden.delete(part); + changed = true; + } + return changed; + } + + private publish(): void { + this.policy.setWanted(this.drawn(this.wanted), this.drawn(this.pinned)); + } + + private drawn(parts: ReadonlySet): ReadonlySet { + return new Set([...parts].filter((part) => this.fits(part) && !this.hidden.has(part))); + } + + private fits(part: string): boolean { + return !this.compact || !COMPACT_HIDDEN.has(part); + } +} diff --git a/explainers/naval-drone/src/scene/finishes.ts b/explainers/naval-drone/src/scene/finishes.ts new file mode 100644 index 00000000..fb003050 --- /dev/null +++ b/explainers/naval-drone/src/scene/finishes.ts @@ -0,0 +1,150 @@ +import { DoubleSide } from 'three'; +import type { Texture } from 'three'; +import type { MaterialFinish } from '@core/scene/materials'; +import { THEME } from '../theme'; + +export const PAINT = { + hull: THEME.hull, + deck: THEME.deck, + panel: THEME.panel, + jetBlack: THEME.jetBlack, + glass: THEME.glass, + section: THEME.section, + inner: '#5b6065', + pocket: '#262a2e', + cap: '#eceeef', + louvre: '#5d656d', + dome: '#7d858d', + ring: '#3d4349', + foam: '#d8bf55', + tub: '#aeb5bc', + block: '#8f969d', + head: '#23262a', + manifold: '#3a3d42', + exhaust: '#7a6656', + tank: '#eef0ec', + petrol: '#b98a34', + box: '#3a3f46', + router: '#e4e5e1', + cable: '#17181a', + hose: '#2a2d30', + steel: '#c9ced3', + rubber: '#141516', + stator: '#3b3f44', + bay: '#5a6168', + bulkhead: '#454b51', + missile: '#8f979e', + rail: '#4a5157', +} as const; + +const PAINT_REFLECTION = 0.9; +const DECAL = { polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -2 } as const; +const METAL_REFLECTION = 1.6; +const GLASS_REFLECTION = 2.4; + +function matte(color: string, roughness: number, metalness = 0): MaterialFinish { + return { color, roughness, metalness, envMapIntensity: PAINT_REFLECTION }; +} + +function metal(color: string, roughness: number, metalness = 0.85): MaterialFinish { + return { color, roughness, metalness, envMapIntensity: METAL_REFLECTION }; +} + +export const FINISHES = { + inner: { ...matte(PAINT.inner, 0.85), side: DoubleSide }, + section: { ...matte(PAINT.section, 0.7), side: DoubleSide }, + pocket: matte(PAINT.pocket, 0.8), + deckPlain: matte(PAINT.deck, 0.7, 0.05), + cap: matte(PAINT.cap, 0.55), + louvre: matte(PAINT.louvre, 0.7), + dome: matte(PAINT.dome, 0.45), + ring: matte(PAINT.ring, 0.6, 0.2), + glass: { + color: PAINT.glass, + metalness: 0.3, + roughness: 0.06, + envMapIntensity: GLASS_REFLECTION, + ...DECAL, + polygonOffsetUnits: DECAL.polygonOffsetUnits * 2, + }, + handle: metal(PAINT.steel, 0.3), + foam: { ...matte(PAINT.foam, 0.95), side: DoubleSide }, + tub: { ...metal(PAINT.tub, 0.42, 0.75), side: DoubleSide }, + block: metal(PAINT.block, 0.38, 0.7), + head: matte(PAINT.head, 0.5, 0.3), + manifold: matte(PAINT.manifold, 0.55, 0.2), + exhaust: metal(PAINT.exhaust, 0.5, 0.6), + tank: { + color: PAINT.tank, + roughness: 0.55, + metalness: 0, + transparent: true, + opacity: 0.62, + depthWrite: false, + envMapIntensity: PAINT_REFLECTION, + }, + petrol: { + color: PAINT.petrol, + roughness: 0.3, + metalness: 0, + transparent: true, + opacity: 0.75, + envMapIntensity: PAINT_REFLECTION, + }, + box: matte(PAINT.box, 0.55, 0.3), + router: matte(PAINT.router, 0.5), + cable: matte(PAINT.cable, 0.6), + hose: matte(PAINT.hose, 0.7), + steel: metal(PAINT.steel, 0.24, 0.95), + steelTwoSided: { ...metal(PAINT.steel, 0.24, 0.95), side: DoubleSide }, + rubber: matte(PAINT.rubber, 0.85), + jetBlack: matte(PAINT.jetBlack, 0.55, 0.15), + jetInside: { ...matte(PAINT.jetBlack, 0.7, 0.1), side: DoubleSide }, + jetTwoSided: { ...matte(PAINT.jetBlack, 0.55, 0.15), side: DoubleSide }, + stator: { ...matte(PAINT.stator, 0.5, 0.4), side: DoubleSide }, + bay: matte(PAINT.bay, 0.75), + bulkhead: { ...matte(PAINT.bulkhead, 0.8), side: DoubleSide }, + missile: matte(PAINT.missile, 0.5, 0.2), + rail: metal(PAINT.rail, 0.5, 0.6), +} as const satisfies Record; + +export interface BoatMaps { + side: Texture; + deck: Texture; + cells: Texture; + grain: Texture; +} + +export interface BoatLooks { + side: MaterialFinish; + deck: MaterialFinish; + panel: MaterialFinish; + companionSide: MaterialFinish; + companionDeck: MaterialFinish; +} + +export function createBoatLooks(maps: BoatMaps): BoatLooks { + const side = { + color: PAINT.hull, + map: maps.side, + roughnessMap: maps.grain, + roughness: 1, + metalness: 0.05, + envMapIntensity: PAINT_REFLECTION, + }; + const deck = { + color: PAINT.deck, + map: maps.deck, + roughnessMap: maps.grain, + roughness: 1, + metalness: 0.05, + envMapIntensity: PAINT_REFLECTION, + }; + return { + side, + deck, + panel: { color: PAINT.panel, map: maps.cells, roughness: 0.42, metalness: 0.05 }, + companionSide: { ...side }, + companionDeck: { ...deck }, + }; +} diff --git a/explainers/naval-drone/src/scene/geometry/curves.test.ts b/explainers/naval-drone/src/scene/geometry/curves.test.ts new file mode 100644 index 00000000..881957c9 --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/curves.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { mergeSorted, monotoneCubic, spread } from './curves'; + +describe('curves', () => { + it('passes through the knots and stays monotone between them', () => { + const curve = monotoneCubic([0, 1, 2, 3], [0, 0, 1, 3]); + expect(curve(1)).toBe(0); + expect(curve(2)).toBe(1); + for (let x = 0; x < 3; x += 0.05) + expect(curve(x + 0.05)).toBeGreaterThanOrEqual(curve(x) - 1e-12); + expect(curve(-1)).toBe(0); + expect(curve(9)).toBe(3); + }); + + it('spreads and merges sample positions', () => { + expect(spread(0, 1, 4)).toEqual([0, 0.25, 0.5, 0.75, 1]); + expect(mergeSorted([0.3, 0.1, 0.1001, 0.2], 0.001)).toEqual([0.1, 0.2, 0.3]); + }); +}); diff --git a/explainers/naval-drone/src/scene/geometry/curves.ts b/explainers/naval-drone/src/scene/geometry/curves.ts new file mode 100644 index 00000000..e207a8a0 --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/curves.ts @@ -0,0 +1,57 @@ +const MONOTONE_LIMIT = 3; + +function tangents(xs: readonly number[], ys: readonly number[]): number[] { + const secants = xs.slice(1).map((x, index) => (ys[index + 1] - ys[index]) / (x - xs[index])); + const slopes = xs.map((_, index) => { + if (index === 0) return secants[0]; + if (index === xs.length - 1) return secants[secants.length - 1]; + const before = secants[index - 1]; + const after = secants[index]; + return before * after <= 0 ? 0 : (before + after) / 2; + }); + secants.forEach((secant, index) => { + if (secant === 0) { + slopes[index] = 0; + slopes[index + 1] = 0; + return; + } + const a = slopes[index] / secant; + const b = slopes[index + 1] / secant; + const length = Math.hypot(a, b); + if (length > MONOTONE_LIMIT) { + slopes[index] = (MONOTONE_LIMIT / length) * a * secant; + slopes[index + 1] = (MONOTONE_LIMIT / length) * b * secant; + } + }); + return slopes; +} + +export function monotoneCubic(xs: readonly number[], ys: readonly number[]): (x: number) => number { + const slopes = tangents(xs, ys); + const last = xs.length - 1; + return (x) => { + if (x <= xs[0]) return ys[0]; + if (x >= xs[last]) return ys[last]; + let index = 0; + while (index < last - 1 && x > xs[index + 1]) index += 1; + const width = xs[index + 1] - xs[index]; + const t = (x - xs[index]) / width; + const t2 = t * t; + const t3 = t2 * t; + return ( + (2 * t3 - 3 * t2 + 1) * ys[index] + + (t3 - 2 * t2 + t) * width * slopes[index] + + (-2 * t3 + 3 * t2) * ys[index + 1] + + (t3 - t2) * width * slopes[index + 1] + ); + }; +} + +export function spread(from: number, to: number, steps: number): number[] { + return Array.from({ length: steps + 1 }, (_, index) => from + ((to - from) * index) / steps); +} + +export function mergeSorted(values: readonly number[], tolerance: number): number[] { + const sorted = [...values].sort((a, b) => a - b); + return sorted.filter((value, index) => index === 0 || value - sorted[index - 1] > tolerance); +} diff --git a/explainers/naval-drone/src/scene/geometry/fitted.test.ts b/explainers/naval-drone/src/scene/geometry/fitted.test.ts new file mode 100644 index 00000000..724217a7 --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/fitted.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { fittedOutline, resample } from './fitted'; +import { bottomYAt } from './hullLines'; + +describe('fitted boxes', () => { + it('lifts the floor onto the hull bottom and keeps the box top', () => { + const outline = fittedOutline( + { x: [0, 1], z: () => [-0.4, 0.4], y: [-0.2, 0.3], clearance: 0.02 }, + 0, + ); + expect(outline[0]).toEqual([0.4, 0.3]); + const floor = outline.slice(2); + floor.forEach(([z, y]) => expect(y).toBeGreaterThanOrEqual(bottomYAt(0, z) + 0.02 - 1e-9)); + expect(floor.some(([z]) => z === 0)).toBe(true); + }); + + it('resamples a loop evenly by length', () => { + const square = resample( + [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ], + 8, + ); + expect(square).toHaveLength(8); + expect(square[2][0]).toBeCloseTo(1, 9); + expect(square[2][1]).toBeCloseTo(0, 9); + expect(square[1][0]).toBeCloseTo(0.5, 9); + }); +}); diff --git a/explainers/naval-drone/src/scene/geometry/fitted.ts b/explainers/naval-drone/src/scene/geometry/fitted.ts new file mode 100644 index 00000000..a97b8706 --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/fitted.ts @@ -0,0 +1,87 @@ +import { CatmullRomCurve3, TubeGeometry, Vector3 } from 'three'; +import type { BufferGeometry } from 'three'; +import { mergeParts } from '../parts/context'; +import { spread } from './curves'; +import { flatPolygon } from './flat'; +import { bottomYAt } from './hullLines'; +import type { Pair } from './hullLines'; +import { gridSurface, orientFrom } from './surface'; +import type { Vec3 } from './surface'; + +export interface FittedBox { + x: readonly [number, number]; + z: (x: number) => readonly [number, number]; + y: readonly [number, number]; + clearance: number; +} + +const FLOOR_SAMPLES = 10; +const OUTLINE_POINTS = 44; +const ROUNDING_PASSES = 2; +const CHAIKIN = 0.25; + +export function fittedOutline(box: FittedBox, x: number): Pair[] { + const [zMin, zMax] = box.z(x); + const [yMin, yMax] = box.y; + const floor = (z: number) => Math.max(yMin, bottomYAt(x, z) + box.clearance); + const bottom = spread(zMin, zMax, FLOOR_SAMPLES); + if (zMin < 0 && zMax > 0) bottom.push(0); + bottom.sort((a, b) => a - b); + return [[zMax, yMax], [zMin, yMax], ...bottom.map((z): Pair => [z, Math.min(floor(z), yMax)])]; +} + +function chaikin(points: readonly Pair[]): Pair[] { + return points.flatMap((point, index) => { + const next = points[(index + 1) % points.length]; + return [ + [point[0] + (next[0] - point[0]) * CHAIKIN, point[1] + (next[1] - point[1]) * CHAIKIN], + [ + point[0] + (next[0] - point[0]) * (1 - CHAIKIN), + point[1] + (next[1] - point[1]) * (1 - CHAIKIN), + ], + ] as Pair[]; + }); +} + +export function resample(loop: readonly Pair[], count: number): Pair[] { + const closed = [...loop, loop[0]]; + const lengths = [0]; + for (let index = 1; index < closed.length; index += 1) { + const [a, b] = [closed[index - 1], closed[index]]; + lengths.push(lengths[index - 1] + Math.hypot(b[0] - a[0], b[1] - a[1])); + } + const total = lengths[lengths.length - 1]; + return Array.from({ length: count }, (_, index) => { + const target = (index / count) * total; + let at = 1; + while (at < lengths.length - 1 && lengths[at] < target) at += 1; + const share = (target - lengths[at - 1]) / Math.max(lengths[at] - lengths[at - 1], 1e-9); + const [a, b] = [closed[at - 1], closed[at]]; + return [a[0] + (b[0] - a[0]) * share, a[1] + (b[1] - a[1]) * share]; + }); +} + +export function roundedOutline(box: FittedBox, x: number, rounded = true): Pair[] { + let loop = fittedOutline(box, x); + if (rounded) for (let pass = 0; pass < ROUNDING_PASSES; pass += 1) loop = chaikin(loop); + return resample(loop, OUTLINE_POINTS); +} + +export function fittedSolid(box: FittedBox, samples: number, rounded = true): BufferGeometry { + const xs = spread(box.x[0], box.x[1], samples); + const loops = xs.map((x) => roundedOutline(box, x, rounded)); + const rows = loops[0].map((_, index) => + xs.map((x, column): Vec3 => [x, loops[column][index][1], loops[column][index][0]]), + ); + rows.push(rows[0]); + const centre: Vec3 = [(box.x[0] + box.x[1]) / 2, (box.y[0] + box.y[1]) / 2, 0]; + const cap = (column: number) => flatPolygon(loops[column], (z, y) => [xs[column], y, z]); + return mergeParts( + [gridSurface(rows), cap(0), cap(xs.length - 1)].map((part) => orientFrom(part, centre)), + ); +} + +export function tubeAlong(points: readonly Vec3[], radius: number, segments = 8): BufferGeometry { + const curve = new CatmullRomCurve3(points.map((point) => new Vector3(...point))); + return new TubeGeometry(curve, Math.max(points.length * 6, 12), radius, segments, false); +} diff --git a/explainers/naval-drone/src/scene/geometry/flat.test.ts b/explainers/naval-drone/src/scene/geometry/flat.test.ts new file mode 100644 index 00000000..3874740b --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/flat.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { band, flatPolygon, offsetPolyline, pinToAxis } from './flat'; + +describe('flat helpers', () => { + it('offsets a corner with a mitre', () => { + const [start, corner, end] = offsetPolyline( + [ + [0, 0], + [1, 0], + [1, 1], + ], + 0.1, + ); + expect(start[1]).toBeCloseTo(0.1, 9); + expect(corner[0]).toBeCloseTo(0.9, 9); + expect(corner[1]).toBeCloseTo(0.1, 9); + expect(end[0]).toBeCloseTo(0.9, 9); + }); + + it('skips repeated points', () => { + const offset = offsetPolyline( + [ + [0, 0], + [1, 0], + [1, 0], + [2, 0], + ], + 0.2, + ); + offset.forEach(([, y]) => expect(y).toBeCloseTo(0.2, 9)); + }); + + it('slides an offset end back onto the centreline', () => { + expect(pinToAxis([0.1, 0.5], [1, 1])).toEqual([0, 0.4]); + }); + + it('builds a closed band and fills it', () => { + const outline = band( + [ + [0, 0], + [2, 0], + ], + 0.1, + ); + expect(outline).toHaveLength(4); + const geometry = flatPolygon(outline, (a, b) => [a, b, 0]); + expect(geometry.getIndex()!.count).toBe(6); + }); +}); diff --git a/explainers/naval-drone/src/scene/geometry/flat.ts b/explainers/naval-drone/src/scene/geometry/flat.ts new file mode 100644 index 00000000..2b743f53 --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/flat.ts @@ -0,0 +1,83 @@ +import { BufferAttribute, BufferGeometry, ShapeUtils, Vector2 } from 'three'; +import type { Pair } from './hullLines'; +import type { Uv, Vec3 } from './surface'; + +export type Place = (a: number, b: number) => Vec3; + +const XYZ = 3; +const MITER_LIMIT = 4; +const DISTINCT = 1e-6; + +export function flatPolygon( + outline: readonly Pair[], + place: Place, + holes: readonly (readonly Pair[])[] = [], + uv?: (a: number, b: number) => Uv, +): BufferGeometry { + const contour = outline.map(([a, b]) => new Vector2(a, b)); + const holePoints = holes.map((hole) => hole.map(([a, b]) => new Vector2(a, b))); + const faces = ShapeUtils.triangulateShape(contour, holePoints); + const all = [...contour, ...holePoints.flat()]; + const geometry = new BufferGeometry(); + geometry.setAttribute( + 'position', + new BufferAttribute( + new Float32Array(all.flatMap((point) => [...place(point.x, point.y)])), + XYZ, + ), + ); + geometry.setAttribute( + 'uv', + new BufferAttribute( + new Float32Array(all.flatMap((point) => [...(uv?.(point.x, point.y) ?? [point.x, point.y])])), + 2, + ), + ); + geometry.setIndex(faces.flat()); + geometry.computeVertexNormals(); + return geometry; +} + +function distance(a: Pair, b: Pair): number { + return Math.hypot(b[0] - a[0], b[1] - a[1]); +} + +function leftNormal(from: Pair, to: Pair): Pair { + const length = distance(from, to); + return [-(to[1] - from[1]) / length, (to[0] - from[0]) / length]; +} + +function neighbour(points: readonly Pair[], index: number, step: number): Pair | null { + for (let at = index + step; at >= 0 && at < points.length; at += step) { + if (distance(points[at], points[index]) > DISTINCT) return points[at]; + } + return null; +} + +export function offsetPolyline(points: readonly Pair[], amount: number): Pair[] { + return points.map((point, index) => { + const previous = neighbour(points, index, -1); + const next = neighbour(points, index, 1); + const before = previous ? leftNormal(previous, point) : null; + const after = next ? leftNormal(point, next) : null; + if (!before || !after) { + const normal = before ?? after ?? [0, 0]; + return [point[0] + normal[0] * amount, point[1] + normal[1] * amount]; + } + const sum: Pair = [before[0] + after[0], before[1] + after[1]]; + const length = Math.hypot(sum[0], sum[1]); + if (length < DISTINCT) return [point[0] + after[0] * amount, point[1] + after[1] * amount]; + const miter: Pair = [sum[0] / length, sum[1] / length]; + const cosine = Math.max(miter[0] * after[0] + miter[1] * after[1], 1 / MITER_LIMIT); + return [point[0] + (miter[0] * amount) / cosine, point[1] + (miter[1] * amount) / cosine]; + }); +} + +export function pinToAxis(inner: Pair, along: Pair): Pair { + if (Math.abs(along[0]) < DISTINCT) return [0, inner[1]]; + return [0, inner[1] - (inner[0] * along[1]) / along[0]]; +} + +export function band(outer: readonly Pair[], amount: number): Pair[] { + return [...outer, ...offsetPolyline(outer, amount).reverse()]; +} diff --git a/explainers/naval-drone/src/scene/geometry/hullLines.test.ts b/explainers/naval-drone/src/scene/geometry/hullLines.test.ts new file mode 100644 index 00000000..451901ad --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/hullLines.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { BOAT, HULL_DETAIL, HULL_STATIONS, STEM, chineHeight, stemXAt } from '../../model/layout'; +import { HULL_LINES } from '../constants'; +import { + bottomYAt, + deckYAt, + halfBreadthAt, + hullSectionAt, + hullStationXs, + keelAt, + stemYAt, +} from './hullLines'; + +const CLOSE = 6; + +describe('hull lines', () => { + it('passes through every frozen station', () => { + HULL_STATIONS.forEach((station) => { + const section = hullSectionAt(station.x); + expect(section.chine[0]).toBeCloseTo(station.chineHalfBreadth, CLOSE); + expect(section.chine[1]).toBeCloseTo(chineHeight(station), CLOSE); + expect(section.sheer[0]).toBeCloseTo(station.sheer[0], CLOSE); + expect(section.sheer[1]).toBeCloseTo(station.sheer[1], CLOSE); + expect(section.knuckle[0]).toBeCloseTo(station.knuckle[0], CLOSE); + expect(section.deck).toBeCloseTo(station.deck, CLOSE); + }); + }); + + it('keeps the keel straight aft and runs it up the raked stem', () => { + [-2.75, -1, 0, HULL_DETAIL.keelStraightTo].forEach((x) => + expect(keelAt(x)).toBeCloseTo(HULL_STATIONS[0].keel, CLOSE), + ); + expect(keelAt(STEM.forefoot[0])).toBeCloseTo(STEM.forefoot[1], 2); + expect(keelAt(2.6)).toBeCloseTo(stemYAt(2.6), CLOSE); + expect(stemYAt(stemXAt(0.3))).toBeCloseTo(0.3, CLOSE); + for (let x = HULL_DETAIL.keelStraightTo; x < BOAT.halfLength; x += 0.05) { + expect(keelAt(x + 0.05)).toBeGreaterThanOrEqual(keelAt(x) - 1e-9); + } + }); + + it('closes every line at the stem head', () => { + const bow = hullSectionAt(BOAT.halfLength); + expect(bow.sheer[0]).toBeCloseTo(0, CLOSE); + expect(bow.sheer[1]).toBeCloseTo(STEM.top[1], CLOSE); + expect(bow.chine[0]).toBeCloseTo(0, CLOSE); + expect(bow.keel).toBeCloseTo(STEM.top[1], CLOSE); + }); + + it('merges the knuckle into the sheer and tapers the chine flat and rail', () => { + const merged = hullSectionAt(HULL_DETAIL.knuckleMergeX + 0.05); + expect(merged.knuckle).toEqual(merged.sheer); + expect(hullSectionAt(0).flat[0] - hullSectionAt(0).chine[0]).toBeCloseTo( + HULL_DETAIL.chineFlat.width, + CLOSE, + ); + expect(hullSectionAt(HULL_DETAIL.chineFlat.endX).flat[0]).toBeCloseTo( + hullSectionAt(HULL_DETAIL.chineFlat.endX).chine[0], + CLOSE, + ); + expect(hullSectionAt(1).rail).toBeCloseTo(HULL_DETAIL.sprayRail.width, CLOSE); + expect(hullSectionAt(-1).rail).toBe(0); + }); + + it('keeps the deck panel inside the sheer and flat across the centre', () => { + for (let x = -2.75; x <= 2.7; x += 0.25) { + const section = hullSectionAt(x); + expect(section.panelEdge[0]).toBeLessThanOrEqual(HULL_DETAIL.deckCentreWidth / 2 + 1e-9); + expect(section.panelEdge[0]).toBeLessThan(section.sheer[0]); + expect(deckYAt(x, 0)).toBeCloseTo(section.deck, CLOSE); + } + }); + + it('measures the hull plan for the water mask', () => { + expect(halfBreadthAt(0, -0.4)).toBe(0); + expect(halfBreadthAt(-3, 0)).toBe(0); + const atWaterline = halfBreadthAt(0, 0); + expect(atWaterline).toBeGreaterThan(hullSectionAt(0).chine[0]); + expect(atWaterline).toBeLessThan(hullSectionAt(0).knuckle[0]); + expect(bottomYAt(0, 0.3)).toBeCloseTo(-0.32 + 0.3 * Math.tan(BOAT.deadriseAft), CLOSE); + }); + + it('samples the stations densely and includes the cut', () => { + const xs = hullStationXs(); + expect(xs[0]).toBeCloseTo(-BOAT.halfLength, CLOSE); + expect(xs[xs.length - 1]).toBeCloseTo(BOAT.halfLength, CLOSE); + expect(xs.some((x) => Math.abs(x - HULL_LINES.cutX) < 1e-9)).toBe(true); + xs.slice(1).forEach((x, index) => expect(x).toBeGreaterThan(xs[index])); + }); +}); diff --git a/explainers/naval-drone/src/scene/geometry/hullLines.ts b/explainers/naval-drone/src/scene/geometry/hullLines.ts new file mode 100644 index 00000000..7920910f --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/hullLines.ts @@ -0,0 +1,191 @@ +import { clamp, lerp, smoothstep } from '@core/math'; +import { + FAIRING, + HULL_DETAIL, + HULL_STATIONS, + JET, + STEM, + TRANSOM_X, + chineHeight, + stemXAt, +} from '../../model/layout'; +import type { HullStation } from '../../model/layout'; +import { HULL_LINES } from '../constants'; +import { mergeSorted, monotoneCubic, spread } from './curves'; + +export type Pair = readonly [z: number, y: number]; + +export interface HullSection { + x: number; + keel: number; + intake: Pair; + chine: Pair; + flat: Pair; + knuckle: Pair; + rail: number; + sheer: Pair; + split: Pair; + panelEdge: Pair; + deck: number; +} + +const BOW_X = STEM.top[0]; +const STATION_XS = HULL_STATIONS.map((station) => station.x); +const CHINE_END_X = stemXAt(STEM.chineMeetsAt); +const KNUCKLE_END_X = HULL_DETAIL.knuckleMergeX; + +export function stemYAt(x: number): number { + const [x0, y0] = STEM.forefoot; + const [x1, y1] = STEM.top; + return y0 + ((x - x0) / (x1 - x0)) * (y1 - y0); +} + +function mergingLine( + value: (station: HullStation) => number, + endX: number, + endValue: number, +): (x: number) => number { + return monotoneCubic([...STATION_XS, endX], [...HULL_STATIONS.map(value), endValue]); +} + +const keelCurve = (() => { + const aft = HULL_STATIONS.filter((station) => station.x <= HULL_LINES.keelCurveTo); + const knots: [number, number][] = [ + ...aft.map((station): [number, number] => [station.x, station.keel]), + [HULL_DETAIL.keelStraightTo, aft[0].keel], + [STEM.forefoot[0], STEM.forefoot[1]], + [HULL_LINES.stemBlend[1], stemYAt(HULL_LINES.stemBlend[1])], + ]; + knots.sort((a, b) => a[0] - b[0]); + return monotoneCubic( + knots.map(([x]) => x), + knots.map(([, y]) => y), + ); +})(); + +const chineZ = mergingLine((station) => station.chineHalfBreadth, CHINE_END_X, 0); +const chineY = mergingLine(chineHeight, CHINE_END_X, STEM.chineMeetsAt); +const sheerZ = mergingLine((station) => station.sheer[0], BOW_X, 0); +const sheerY = mergingLine((station) => station.sheer[1], BOW_X, STEM.top[1]); +const deckY = mergingLine((station) => station.deck, BOW_X, STEM.top[1]); +const knuckleZ = mergingLine((station) => station.knuckle[0], KNUCKLE_END_X, sheerZ(KNUCKLE_END_X)); +const knuckleY = mergingLine((station) => station.knuckle[1], KNUCKLE_END_X, sheerY(KNUCKLE_END_X)); + +export function keelAt(x: number): number { + const [from, to] = HULL_LINES.stemBlend; + if (x >= to) return stemYAt(x); + return lerp(keelCurve(x), stemYAt(x), smoothstep(x, from, to)); +} + +function softMin(a: number, b: number): number { + const k = HULL_LINES.softness; + return -k * Math.log(Math.exp(-a / k) + Math.exp(-b / k)); +} + +function onBottom(keel: number, chine: Pair, z: number): Pair { + return [z, keel + ((chine[1] - keel) * z) / Math.max(chine[0], HULL_LINES.epsilon)]; +} + +function taper(x: number, [from, to]: readonly [number, number], ramp: number): number { + return smoothstep(x, from, from + ramp) * (1 - smoothstep(x, to - ramp, to)); +} + +export function hullSectionAt(xAt: number): HullSection { + const x = clamp(xAt, TRANSOM_X, BOW_X); + const keel = keelAt(x); + const stem = stemYAt(x); + const chine: Pair = x >= CHINE_END_X ? [0, stem] : [chineZ(x), chineY(x)]; + const sheer: Pair = [sheerZ(x), sheerY(x)]; + const knuckle: Pair = x >= KNUCKLE_END_X ? sheer : [knuckleZ(x), knuckleY(x)]; + const deck = deckY(x); + const flatWidth = + HULL_DETAIL.chineFlat.width * + (1 - smoothstep(x, HULL_LINES.flatTaperFrom, HULL_DETAIL.chineFlat.endX)); + const panelZ = softMin(HULL_DETAIL.deckCentreWidth / 2, HULL_LINES.panelShare * sheer[0]); + const splitShare = clamp( + (FAIRING.baseHalfWidth - panelZ) / Math.max(sheer[0] - panelZ, HULL_LINES.epsilon), + 0, + 1, + ); + return { + x, + keel, + intake: onBottom(keel, chine, Math.min(JET.intake.halfWidth, chine[0] / 2)), + chine, + flat: [chine[0] + flatWidth, chine[1]], + knuckle, + rail: HULL_DETAIL.sprayRail.width * taper(x, HULL_DETAIL.sprayRail.x, HULL_LINES.railTaper), + sheer, + split: [lerp(panelZ, sheer[0], splitShare), lerp(deck, sheer[1], splitShare)], + panelEdge: [panelZ, deck], + deck, + }; +} + +export function deckYAt(x: number, z: number): number { + const section = hullSectionAt(x); + const [edgeZ] = section.panelEdge; + const across = Math.abs(z); + if (across <= edgeZ) return section.deck; + const share = clamp( + (across - edgeZ) / Math.max(section.sheer[0] - edgeZ, HULL_LINES.epsilon), + 0, + 1, + ); + return lerp(section.deck, section.sheer[1], share); +} + +export function bottomYAt(x: number, z: number): number { + const section = hullSectionAt(x); + return onBottom(section.keel, section.chine, Math.abs(z))[1]; +} + +function along(from: Pair, to: Pair, y: number): number { + const share = clamp((y - from[1]) / Math.max(to[1] - from[1], HULL_LINES.epsilon), 0, 1); + return lerp(from[0], to[0], share); +} + +export function halfBreadthAt(x: number, y: number): number { + if (x < TRANSOM_X || x > BOW_X) return 0; + const section = hullSectionAt(x); + if (y <= section.keel) return 0; + if (y <= section.chine[1]) return along([0, section.keel], section.chine, y); + if (y <= section.knuckle[1]) return along(section.flat, section.knuckle, y); + if (y <= section.sheer[1]) return along(section.knuckle, section.sheer, y); + return section.sheer[0]; +} + +export function outerProfile(section: HullSection): Pair[] { + return [ + [0, section.keel], + section.intake, + section.chine, + section.flat, + section.knuckle, + section.sheer, + section.split, + section.panelEdge, + [0, section.deck], + ]; +} + +export function hullStationXs(): number[] { + const { aftSpacing, bowFrom, bowSpacing, tolerance } = HULL_LINES.stations; + const aft = spread(TRANSOM_X, bowFrom, Math.round((bowFrom - TRANSOM_X) / aftSpacing)); + const bow = spread(bowFrom, BOW_X, Math.round((BOW_X - bowFrom) / bowSpacing)); + const special = [ + ...JET.intake.x, + ...FAIRING.x, + FAIRING.frontTopX, + ...HULL_DETAIL.sprayRail.x, + HULL_DETAIL.chineFlat.endX, + HULL_LINES.cutX, + KNUCKLE_END_X, + CHINE_END_X, + ...HULL_LINES.stemBlend, + ]; + const regular = [...aft, ...bow].filter((x) => + special.every((value) => Math.abs(x - value) > tolerance), + ); + return mergeSorted([...special, ...regular], tolerance); +} diff --git a/explainers/naval-drone/src/scene/geometry/sheet.ts b/explainers/naval-drone/src/scene/geometry/sheet.ts new file mode 100644 index 00000000..b721fbf3 --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/sheet.ts @@ -0,0 +1,79 @@ +import { LatheGeometry, Vector2, Vector3 } from 'three'; +import type { BufferGeometry } from 'three'; +import { mergeParts } from '../parts/context'; +import type { Pair } from './hullLines'; +import { flatPolygon } from './flat'; +import { flipWinding, gridSurface } from './surface'; +import type { Vec3 } from './surface'; + +export type ProfilePoint = readonly [axial: number, radius: number, smooth?: boolean]; +export type Arc = 'full' | 'starboard' | 'port'; + +const ARCS: Readonly> = { + full: [0, Math.PI * 2], + starboard: [-Math.PI / 2, Math.PI], + port: [Math.PI / 2, Math.PI], +}; + +function crisp(profile: readonly ProfilePoint[]): Vector2[] { + return profile.flatMap(([axial, radius, smooth], index) => { + const point = new Vector2(Math.max(radius, 0), axial); + const corner = !smooth && index > 0 && index < profile.length - 1; + return corner ? [point, point.clone()] : [point]; + }); +} + +export function turned( + profile: readonly ProfilePoint[], + segments: number, + arc: Arc = 'full', +): BufferGeometry { + const [start, length] = ARCS[arc]; + const geometry = new LatheGeometry(crisp(profile), segments, start, length); + geometry.rotateZ(-Math.PI / 2); + return geometry; +} + +export function turnedCaps(profile: readonly ProfilePoint[]): BufferGeometry { + const outline = (sign: number): Pair[] => + profile.map(([axial, radius]): Pair => [axial, sign * Math.max(radius, 0)]); + const place = (a: number, b: number): Vec3 => [a, b, 0]; + return mergeParts([flatPolygon(outline(1), place), flatPolygon(outline(-1), place)]); +} + +function gridNormals(grid: readonly (readonly Vec3[])[]): Vector3[][] { + const at = (i: number, j: number) => new Vector3(...grid[i][j]); + const rows = grid.length; + const columns = grid[0].length; + return grid.map((row, i) => + row.map((_, j) => { + const across = at(Math.min(i + 1, rows - 1), j).sub(at(Math.max(i - 1, 0), j)); + const along = at(i, Math.min(j + 1, columns - 1)).sub(at(i, Math.max(j - 1, 0))); + return across.cross(along).normalize(); + }), + ); +} + +export function thickSheet(grid: readonly (readonly Vec3[])[], thickness: number): BufferGeometry { + const normals = gridNormals(grid); + const shifted = (sign: number) => + grid.map((row, i) => + row.map((point, j): Vec3 => { + const n = normals[i][j]; + const half = (sign * thickness) / 2; + return [point[0] + n.x * half, point[1] + n.y * half, point[2] + n.z * half]; + }), + ); + const top = shifted(1); + const bottom = shifted(-1); + const last = grid.length - 1; + const end = grid[0].length - 1; + const rim = (pick: (side: Vec3[][]) => Vec3[]) => gridSurface([pick(top), pick(bottom)]); + const edges = [ + rim((side) => side[0]), + flipWinding(rim((side) => side[last])), + flipWinding(rim((side) => side.map((row) => row[0]))), + rim((side) => side.map((row) => row[end])), + ]; + return mergeParts([gridSurface(top), flipWinding(gridSurface(bottom)), ...edges]); +} diff --git a/explainers/naval-drone/src/scene/geometry/solids.ts b/explainers/naval-drone/src/scene/geometry/solids.ts new file mode 100644 index 00000000..d268e494 --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/solids.ts @@ -0,0 +1,39 @@ +import { BoxGeometry, CylinderGeometry, LatheGeometry, Vector2 } from 'three'; +import type { BufferGeometry } from 'three'; +import type { Vec3 } from './surface'; + +export type Axis = 'x' | 'y' | 'z'; +type Span = readonly [number, number]; + +const QUARTER_TURN = Math.PI / 2; +const ROD_SEGMENTS = 12; + +const middle = ([from, to]: Span) => (from + to) / 2; + +export function boxAt(size: Vec3, centre: Vec3, turn = 0): BufferGeometry { + return new BoxGeometry(...size).rotateY(turn).translate(...centre); +} + +export function boxBetween(x: Span, y: Span, z: Span): BufferGeometry { + return boxAt([x[1] - x[0], y[1] - y[0], z[1] - z[0]], [middle(x), middle(y), middle(z)]); +} + +export function rod( + axis: Axis, + radius: number, + length: number, + at: Vec3, + segments = ROD_SEGMENTS, +): BufferGeometry { + const piece = new CylinderGeometry(radius, radius, length, segments); + if (axis === 'x') piece.rotateZ(QUARTER_TURN); + if (axis === 'z') piece.rotateX(QUARTER_TURN); + return piece.translate(...at); +} + +export function lathe(points: readonly (readonly [number, number])[], segments: number) { + return new LatheGeometry( + points.map(([radius, height]) => new Vector2(radius, height)), + segments, + ); +} diff --git a/explainers/naval-drone/src/scene/geometry/surface.ts b/explainers/naval-drone/src/scene/geometry/surface.ts new file mode 100644 index 00000000..13897b0c --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/surface.ts @@ -0,0 +1,102 @@ +import { BufferAttribute, BufferGeometry, Float32BufferAttribute, Vector3 } from 'three'; + +export type Vec3 = readonly [x: number, y: number, z: number]; +export type Uv = readonly [u: number, v: number]; + +export interface GridOptions { + uv?: (row: number, column: number) => Uv; + keep?: (row: number, column: number) => boolean; +} + +const XYZ = 3; +const TRIANGLE = 3; + +export function gridSurface(rows: readonly (readonly Vec3[])[], options: GridOptions = {}) { + const columns = rows[0].length; + const positions: number[] = []; + const uvs: number[] = []; + rows.forEach((row, r) => + row.forEach((point, c) => { + positions.push(point[0], point[1], point[2]); + const uv = options.uv?.(r, c) ?? [c / (columns - 1), r / (rows.length - 1)]; + uvs.push(uv[0], uv[1]); + }), + ); + const indices: number[] = []; + for (let r = 0; r < rows.length - 1; r += 1) { + for (let c = 0; c < columns - 1; c += 1) { + if (options.keep && !options.keep(r, c)) continue; + const a = r * columns + c; + const b = a + columns; + indices.push(a, b, a + 1, a + 1, b, b + 1); + } + } + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, XYZ)); + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + return geometry; +} + +export function flipWinding(geometry: BufferGeometry): BufferGeometry { + const index = geometry.getIndex(); + if (index) { + const array = index.array; + for (let at = 0; at < array.length; at += TRIANGLE) { + const second = array[at + 1]; + array[at + 1] = array[at + 2]; + array[at + 2] = second; + } + index.needsUpdate = true; + } + geometry.computeVertexNormals(); + return geometry; +} + +function facing(geometry: BufferGeometry, reference: Vector3): number { + const position = geometry.getAttribute('position'); + const index = geometry.getIndex(); + const count = index ? index.count : position.count; + const vertex = (at: number) => + new Vector3().fromBufferAttribute(position, index ? index.getX(at) : at); + let sum = 0; + for (let at = 0; at < count; at += TRIANGLE) { + const a = vertex(at); + const b = vertex(at + 1); + const c = vertex(at + 2); + const normal = new Vector3().subVectors(b, a).cross(new Vector3().subVectors(c, a)); + const middle = a.add(b).add(c).divideScalar(TRIANGLE).sub(reference); + sum += normal.dot(middle); + } + return sum; +} + +export function orientFrom( + geometry: BufferGeometry, + reference: Vec3, + outward = true, +): BufferGeometry { + const sum = facing(geometry, new Vector3(...reference)); + if (outward ? sum < 0 : sum > 0) flipWinding(geometry); + return geometry; +} + +export function mirrorZ(geometry: BufferGeometry): BufferGeometry { + const mirrored = geometry.clone(); + mirrored.scale(1, 1, -1); + return flipWinding(mirrored); +} + +export function polygonFan(points: readonly Vec3[], uv?: (point: Vec3) => Uv): BufferGeometry { + const positions = points.flatMap((point) => [...point]); + const uvs = points.flatMap((point) => [...(uv?.(point) ?? [0, 0])]); + const indices: number[] = []; + for (let at = 1; at < points.length - 1; at += 1) indices.push(0, at, at + 1); + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(new Float32Array(positions), XYZ)); + geometry.setAttribute('uv', new BufferAttribute(new Float32Array(uvs), 2)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + return geometry; +} diff --git a/explainers/naval-drone/src/scene/geometry/trail.test.ts b/explainers/naval-drone/src/scene/geometry/trail.test.ts new file mode 100644 index 00000000..f3a436a1 --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/trail.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { poseAtDistance } from '../../model/run'; +import { pathTrail, routeTrail, straightTrail, trailSpacing } from './trail'; + +describe('wake trails', () => { + it('spaces samples densely near the boat', () => { + const spacing = trailSpacing(5, 100, 2); + expect(spacing).toEqual([0, 6.25, 25, 56.25, 100]); + }); + + it('follows the run route behind the transom and stops at the slipway', () => { + const trail = routeTrail(poseAtDistance, 50, 2.75, [0, 10, 60]); + expect(trail[0].x).toBeCloseTo(poseAtDistance(47.25).position[0], 6); + expect(trail[1].x).toBeCloseTo(poseAtDistance(37.25).position[0], 6); + expect(trail[2].live).toBe(false); + }); + + it('draws a straight trail behind a held boat', () => { + const trail = straightTrail({ position: [10, 0, 0], heading: 0 }, 2, [0, 5]); + expect(trail.map((sample) => sample.x)).toEqual([8, 3]); + expect(trail.every((sample) => sample.z === 0)).toBe(true); + }); + + it('resamples a recorded path by distance', () => { + const history = [0, 1, 2].map((step) => ({ + position: [10 - step * 4, 0, 0] as const, + heading: 0, + })); + const trail = pathTrail(history, 1, [0, 3, 10]); + expect(trail[0].x).toBeCloseTo(9, 9); + expect(trail[1].x).toBeCloseTo(6, 9); + expect(trail[2].live).toBe(false); + }); +}); diff --git a/explainers/naval-drone/src/scene/geometry/trail.ts b/explainers/naval-drone/src/scene/geometry/trail.ts new file mode 100644 index 00000000..c86cc8c8 --- /dev/null +++ b/explainers/naval-drone/src/scene/geometry/trail.ts @@ -0,0 +1,76 @@ +import type { RoutePose } from '../../ids'; + +export interface TrailSample { + x: number; + z: number; + heading: number; + behind: number; + live: boolean; +} + +export function trailSpacing(count: number, length: number, power: number): number[] { + return Array.from({ length: count }, (_, index) => length * (index / (count - 1)) ** power); +} + +export function routeTrail( + poseAt: (distance: number) => RoutePose, + distance: number, + offset: number, + spacing: readonly number[], +): TrailSample[] { + return spacing.map((behind) => { + const along = distance - offset - behind; + const pose = poseAt(Math.max(along, 0)); + return { + x: pose.position[0], + z: pose.position[2], + heading: pose.heading, + behind, + live: along >= 0, + }; + }); +} + +export function straightTrail( + pose: RoutePose, + offset: number, + spacing: readonly number[], +): TrailSample[] { + const dx = Math.cos(pose.heading); + const dz = Math.sin(pose.heading); + return spacing.map((behind) => ({ + x: pose.position[0] - dx * (offset + behind), + z: pose.position[2] - dz * (offset + behind), + heading: pose.heading, + behind, + live: true, + })); +} + +export function pathTrail( + history: readonly RoutePose[], + offset: number, + spacing: readonly number[], +): TrailSample[] { + const lengths = [0]; + for (let index = 1; index < history.length; index += 1) { + const [a, b] = [history[index - 1].position, history[index].position]; + lengths.push(lengths[index - 1] + Math.hypot(b[0] - a[0], b[2] - a[2])); + } + const total = lengths[lengths.length - 1]; + return spacing.map((behind) => { + const target = behind + offset; + let at = 1; + while (at < lengths.length - 1 && lengths[at] < target) at += 1; + const span = Math.max(lengths[at] - lengths[at - 1], 1e-9); + const share = Math.min(Math.max((target - lengths[at - 1]) / span, 0), 1); + const [a, b] = [history[at - 1], history[at] ?? history[at - 1]]; + return { + x: a.position[0] + (b.position[0] - a.position[0]) * share, + z: a.position[2] + (b.position[2] - a.position[2]) * share, + heading: a.heading + (b.heading - a.heading) * share, + behind, + live: target <= total, + }; + }); +} diff --git a/explainers/naval-drone/src/scene/index.ts b/explainers/naval-drone/src/scene/index.ts new file mode 100644 index 00000000..6fe8281e --- /dev/null +++ b/explainers/naval-drone/src/scene/index.ts @@ -0,0 +1,24 @@ +import { createLabelVisibility } from '@core/scene/presetBinder'; +import type { SceneShell } from '@core/scene/shell'; +import type { NavalDroneStore } from '../state'; +import { bindStore } from './bindings'; +import { NavalDroneController } from './controller'; +import { navalDroneLight } from './lighting'; +import { LABEL_PRIORITY } from './partInfo'; + +export { SCENE_OPTIONS } from './sceneOptions'; + +export function mountNavalDroneScene(shell: SceneShell, store: NavalDroneStore): () => void { + const restoreLight = navalDroneLight(shell.lighting); + const navalDrone = new NavalDroneController(shell); + const removeFrame = shell.onFrame((deltaSeconds) => navalDrone.update(deltaSeconds)); + const labelVisibility = createLabelVisibility(shell, LABEL_PRIORITY); + const unbind = bindStore(store, { navalDrone, labelVisibility, ...shell }); + return () => { + removeFrame(); + labelVisibility.dispose(); + unbind(); + navalDrone.dispose(); + restoreLight(); + }; +} diff --git a/explainers/naval-drone/src/scene/lighting.test.ts b/explainers/naval-drone/src/scene/lighting.test.ts new file mode 100644 index 00000000..7951a1fc --- /dev/null +++ b/explainers/naval-drone/src/scene/lighting.test.ts @@ -0,0 +1,53 @@ +import type { Color } from 'three'; +import { DirectionalLight, Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { SUN_DIRECTION } from './constants'; +import { navalDroneLight } from './lighting'; + +function shellLights() { + return { + key: new DirectionalLight('#ffffff', 1), + fill: new DirectionalLight('#ffffff', 1), + rim: new DirectionalLight('#ffffff', 1), + }; +} + +function warmth(colour: Color): number { + return colour.r - colour.b; +} + +describe('naval drone light', () => { + it('keys warm and low from the rising sun', () => { + const lights = shellLights(); + navalDroneLight(lights); + const key = lights.key.position.clone().normalize(); + expect(key.dot(new Vector3(...SUN_DIRECTION).normalize())).toBeGreaterThan(0.99); + expect(key.y).toBeGreaterThan(0); + expect(key.y).toBeLessThan(0.2); + expect(warmth(lights.key.color)).toBeGreaterThan(0); + }); + + it('fills cool from the sky and rims cold and low from the far side', () => { + const lights = shellLights(); + navalDroneLight(lights); + expect(lights.fill.color.b).toBeGreaterThan(lights.fill.color.r); + expect(lights.fill.position.clone().normalize().y).toBeGreaterThan(0.6); + expect(lights.fill.intensity).toBeLessThan(lights.key.intensity); + const rim = lights.rim.position.clone().normalize(); + expect(rim.dot(lights.key.position.clone().normalize())).toBeLessThan(0); + expect(rim.y).toBeLessThan(0.3); + expect(warmth(lights.rim.color)).toBeLessThan(0); + }); + + it('puts the shell lights back as they were', () => { + const lights = shellLights(); + lights.key.position.set(1, 2, 3); + const restore = navalDroneLight(lights); + restore(); + Object.values(lights).forEach((light) => { + expect(light.intensity).toBe(1); + expect(light.color.getHexString()).toBe('ffffff'); + }); + expect(lights.key.position.toArray()).toEqual([1, 2, 3]); + }); +}); diff --git a/explainers/naval-drone/src/scene/lighting.ts b/explainers/naval-drone/src/scene/lighting.ts new file mode 100644 index 00000000..7476cdfd --- /dev/null +++ b/explainers/naval-drone/src/scene/lighting.ts @@ -0,0 +1,59 @@ +import type { DirectionalLight } from 'three'; +import type { Lighting } from '@core/scene/lighting'; +import { LIGHT_RIG } from './constants'; + +interface LightSetting { + readonly color: string; + readonly intensity: number; + readonly position: readonly [number, number, number]; +} + +type LightName = 'key' | 'fill' | 'rim'; + +function scaled(direction: readonly [number, number, number]): [number, number, number] { + const length = Math.hypot(...direction); + return direction.map((value) => (value / length) * LIGHT_RIG.distance) as [ + number, + number, + number, + ]; +} + +export const NAVAL_DRONE_LIGHT: Readonly> = { + key: { + color: LIGHT_RIG.key.color, + intensity: LIGHT_RIG.key.intensity, + position: scaled(LIGHT_RIG.sun), + }, + 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.fromArray(setting.position); + return () => { + light.color.copy(color); + light.intensity = intensity; + light.position.copy(position); + }; +} + +export function navalDroneLight(lighting: Pick): () => void { + const restorers = (Object.keys(NAVAL_DRONE_LIGHT) as LightName[]).map((name) => + apply(lighting[name], NAVAL_DRONE_LIGHT[name]), + ); + return () => restorers.forEach((restore) => restore()); +} diff --git a/explainers/naval-drone/src/scene/navalDroneAssembly.dom.test.ts b/explainers/naval-drone/src/scene/navalDroneAssembly.dom.test.ts new file mode 100644 index 00000000..7ae3ae7d --- /dev/null +++ b/explainers/naval-drone/src/scene/navalDroneAssembly.dom.test.ts @@ -0,0 +1,217 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { InstancedMesh, Mesh, Vector3 } from 'three'; +import type { Object3D } from 'three'; +import { MaterialLibrary } from '@core/scene/materials'; +import { isShown } from '@core/scene/parts'; +import { createSceneTextures } from '@core/scene/textures'; +import { PART_IDS } from '../ids'; +import type { AnchorId, AssemblyState, PartId, RegionId } from '../ids'; +import { helmNozzle, jetAt, throttleAt } from '../model/jet'; +import { planingAt } from '../model/hull'; +import { JET } from '../model/layout'; +import { HELD_PHASE, boatAt, companionsAt, poseAtDistance } from '../model/run'; +import { seaAt } from '../model/sea'; +import { NavalDroneAssembly } from './navalDroneAssembly'; + +const ANCHORS: readonly AnchorId[] = [ + 'boat', + 'dome', + 'stern', + 'ship', + 'satellite', + 'backupSatellite', + 'groundStation', +]; +const REGIONS: readonly RegionId[] = ['scene', 'boat', 'ship', 'shore']; +const VIEW = { cutaway: false, flow: false, links: false, labels: true }; +const FRAME = 1 / 60; +const HELD_KNOTS = 22; +const SPRINT = 100; +const TRIANGLE_BUDGET = 260_000; + +interface Patch extends Partial> { + view?: Partial; + helm?: 'left' | 'straight' | 'right' | 'reverse'; + knots?: number; +} + +function stateAt(phase: number, patch: Patch = {}): AssemblyState { + const run = boatAt(phase); + const held = patch.knots !== undefined; + const helm = patch.helm ?? 'straight'; + const knots = held ? (helm === 'reverse' ? 0 : patch.knots!) : run.knots; + const { view, ...rest } = patch; + delete rest.knots; + delete rest.helm; + return { + phase, + playing: false, + boat: { ...run, knots, held }, + planing: planingAt(knots), + jet: jetAt( + throttleAt(held ? patch.knots! : knots), + knots, + helmNozzle(helm), + helm === 'reverse', + ), + companions: held ? [] : companionsAt(phase), + sea: seaAt('smooth'), + link: { mode: 'satellite', ghost: null }, + fit: 'standard', + waterSection: false, + wettedBar: false, + ...rest, + view: { ...VIEW, ...view }, + }; +} + +let assembly: NavalDroneAssembly; +let textures: ReturnType; + +function show(state: AssemblyState): void { + assembly.setState(state); + assembly.root.updateMatrixWorld(true); +} + +function label(id: PartId): Object3D { + const anchor = assembly.labelAnchors().get(id); + if (!anchor) throw new Error(`No label for ${id}`); + return anchor; +} + +function local(object: Object3D): Vector3 { + const [body] = assembly.anchor('boat').children; + assembly.root.updateMatrixWorld(true); + return body.worldToLocal(object.getWorldPosition(new Vector3())); +} + +function shownTriangles(): number { + let total = 0; + assembly.root.traverse((object) => { + if (!(object instanceof Mesh) || !isShown(object)) return; + const geometry = object.geometry; + const count = (geometry.getIndex()?.count ?? geometry.getAttribute('position').count) / 3; + total += count * (object instanceof InstancedMesh ? object.count : 1); + }); + return total; +} + +beforeAll(() => { + textures = createSceneTextures(); + assembly = new NavalDroneAssembly( + { materials: new MaterialLibrary(), textures }, + stateAt(HELD_PHASE), + ); +}); + +afterAll(() => { + assembly.dispose(); + textures.dispose(); +}); + +describe('naval drone assembly', () => { + it('gives every part a label anchor and every anchor and region a place', () => { + const anchors = assembly.labelAnchors(); + PART_IDS.forEach((id) => expect(anchors.has(id), id).toBe(true)); + ANCHORS.forEach((id) => expect(assembly.anchor(id)).toBeDefined()); + REGIONS.forEach((id) => { + const box = assembly.region(id); + expect(box.isEmpty(), id).toBe(false); + expect(Number.isFinite(box.min.x + box.max.z), id).toBe(true); + }); + }); + + it('keeps the posed boat inside its region and reports the chase target', () => { + show(stateAt(SPRINT)); + const target = assembly.chaseTarget(); + const state = stateAt(SPRINT); + expect(target.position[0]).toBeCloseTo(state.boat.position[0], 6); + expect(target.position[1]).toBeCloseTo(state.planing.heave, 6); + expect(target.length).toBe(5.5); + expect(assembly.region('boat').containsPoint(assembly.anchor('boat').position)).toBe(true); + }); + + it('swings the steering nozzle exit to starboard on right helm and to port on left', () => { + show(stateAt(HELD_PHASE, { knots: HELD_KNOTS, helm: 'straight' })); + expect(local(assembly.anchor('stern')).z).toBeCloseTo(0, 6); + show(stateAt(HELD_PHASE, { knots: HELD_KNOTS, helm: 'right' })); + const right = local(assembly.anchor('stern')); + expect(right.z).toBeGreaterThan(0.04); + expect(right.x).toBeLessThan(JET.steeringNozzle.pivotX); + show(stateAt(HELD_PHASE, { knots: HELD_KNOTS, helm: 'left' })); + expect(local(assembly.anchor('stern')).z).toBeLessThan(-0.04); + }); + + it('lowers the reverse bucket over the nozzle exit only in reverse', () => { + show(stateAt(HELD_PHASE, { knots: HELD_KNOTS, helm: 'straight' })); + const stowed = local(label('reverseBucket')); + show(stateAt(HELD_PHASE, { knots: HELD_KNOTS, helm: 'reverse' })); + const down = local(label('reverseBucket')); + expect(stowed.y).toBeGreaterThan(JET.axisY + 0.1); + expect(down.y).toBeLessThan(stowed.y - 0.1); + expect(down.y).toBeLessThan(JET.axisY + JET.bucket.depth * 0.6); + expect(down.x).toBeLessThan(JET.steeringNozzle.x[0]); + }); + + it('draws the links, fit, companions, section, wetted bar and ghost only when asked', () => { + show(stateAt(HELD_PHASE)); + ['satLink', 'backupLink', 'missileRails', 'companions', 'wettedLength', 'videoGhost'].forEach( + (id) => expect(isShown(label(id as PartId)), id).toBe(false), + ); + show(stateAt(HELD_PHASE, { view: { links: true } })); + expect(isShown(label('satLink'))).toBe(true); + expect(isShown(label('backupLink'))).toBe(false); + show(stateAt(HELD_PHASE, { view: { links: true }, link: { mode: 'backup', ghost: null } })); + expect(isShown(label('backupLink'))).toBe(true); + expect(isShown(label('satLink'))).toBe(false); + show(stateAt(HELD_PHASE, { view: { links: true }, link: { mode: 'lost', ghost: null } })); + expect(isShown(label('satLink')) || isShown(label('backupLink'))).toBe(false); + show(stateAt(SPRINT, { fit: 'missile' })); + expect(isShown(label('missileRails'))).toBe(true); + expect(isShown(label('companions'))).toBe(true); + show(stateAt(HELD_PHASE, { wettedBar: true, waterSection: true })); + expect(isShown(label('wettedLength'))).toBe(true); + const ghost = poseAtDistance(boatAt(HELD_PHASE).distance - 5); + show(stateAt(HELD_PHASE, { link: { mode: 'satellite', ghost } })); + expect(isShown(label('videoGhost'))).toBe(true); + }); + + it('opens the cutaway only when asked', () => { + show(stateAt(HELD_PHASE)); + expect(isShown(label('engine'))).toBe(false); + show(stateAt(HELD_PHASE, { view: { cutaway: true } })); + expect(isShown(label('engine'))).toBe(true); + expect(isShown(label('impeller'))).toBe(true); + }); + + it('animates exactly while playing or held', () => { + show(stateAt(HELD_PHASE)); + expect(assembly.update(FRAME, 10)).toBe(false); + show(stateAt(HELD_PHASE, { playing: true })); + expect(assembly.update(FRAME, 10)).toBe(true); + show(stateAt(HELD_PHASE, { knots: HELD_KNOTS })); + expect(assembly.update(FRAME, 10)).toBe(true); + show(stateAt(HELD_PHASE, { knots: HELD_KNOTS, helm: 'reverse' })); + expect(assembly.update(FRAME, 10)).toBe(true); + }); + + it('stays inside the triangle budget with everything drawn', () => { + show( + stateAt(SPRINT, { + fit: 'missile', + waterSection: true, + wettedBar: true, + view: { cutaway: true, flow: true, links: true }, + }), + ); + expect(shownTriangles()).toBeLessThan(TRIANGLE_BUDGET); + }); + + it('compiles hidden variants during warm-up', () => { + show(stateAt(HELD_PHASE)); + const compiled: Object3D[] = []; + assembly.warmUp((object) => compiled.push(object)); + expect(compiled.length).toBeGreaterThan(0); + compiled.forEach((object) => expect(isShown(object)).toBe(false)); + }); +}); diff --git a/explainers/naval-drone/src/scene/navalDroneAssembly.ts b/explainers/naval-drone/src/scene/navalDroneAssembly.ts new file mode 100644 index 00000000..36c4f914 --- /dev/null +++ b/explainers/naval-drone/src/scene/navalDroneAssembly.ts @@ -0,0 +1,433 @@ +import { Box3, Group, HemisphereLight, Matrix4, Mesh, Points, Sprite } from 'three'; +import type { Material, Object3D } from 'three'; +import type { MaterialLibrary } from '@core/scene/materials'; +import { anchorAt, isShown } from '@core/scene/parts'; +import { ResourceTracker } from '@core/scene/resources'; +import type { AnchorId, AssemblyState, PartId, RegionId } from '../ids'; +import { + BACKUP_PANEL_X, + BACKUP_SATELLITE_OFFSET, + BOAT, + BOW_CAMERA, + DOME, + FORMATION, + HULL_DETAIL, + HULL_STATIONS, + PANEL, + SATELLITE_OFFSET, + STARLINK_PANEL_XS, + skyPoint, +} from '../model/layout'; +import { SHIP_CENTRE, SHIP_HEADING, companionsAt, poseAtDistance } from '../model/run'; +import { knotsToMs } from '../model/scale'; +import type { Assembly, AssemblyResources, ChaseTarget } from './assembly'; +import { + ANIMATION, + LABEL_SPOTS, + LIGHT_RIG, + MOTION, + SEA_FOAM, + SUN_DIRECTION, + SURFACE_MAPS, + WAKE, +} from './constants'; +import { createBoatLooks } from './finishes'; +import { hullSectionAt } from './geometry/hullLines'; +import { pathTrail, routeTrail, straightTrail, trailSpacing } from './geometry/trail'; +import type { TrailSample } from './geometry/trail'; +import { BoatPart } from './parts/boat/boat'; +import { CompanionPart, buildCompanionGeometry } from './parts/boat/companion'; +import { buildMissileFit } from './parts/boat/missiles'; +import type { MissileFit } from './parts/boat/missiles'; +import type { PartContext } from './parts/context'; +import { GhostPart } from './parts/effects/ghost'; +import { LinksPart } from './parts/effects/links'; +import { cellTexture, deckTexture, hullSideTexture, noiseTexture } from './parts/surfaces'; +import { + SHIP_SPAN, + SmoothMotion, + applyMotion, + motionBlend, + responseFor, + waveMotion, +} from './parts/water/boatMotion'; +import { FlowPart } from './parts/water/flow'; +import { HullWaterPart } from './parts/water/hullWater'; +import { JetStreamPart } from './parts/water/jetStream'; +import { SeaPart } from './parts/water/sea'; +import { foamTexture } from './parts/water/seaMaps'; +import { placeSection, sectionFrame } from './parts/water/sectionMask'; +import { WakePart } from './parts/water/wake'; +import type { WakeParams } from './parts/water/wake'; +import { WaterSectionPart } from './parts/water/waterSection'; +import { WATER, resetWater, waveVectors } from './parts/water/waves'; +import { WettedBarPart } from './parts/water/wettedBar'; +import { createBackupSatellite, createStarlinkSatellite } from './parts/world/satellites'; +import type { SatellitePart } from './parts/world/satellites'; +import { createShip } from './parts/world/ship'; +import type { ShipPart } from './parts/world/ship'; +import { createShore } from './parts/world/shore'; +import { createSky } from './parts/world/sky'; +import { boatRegion, staticRegions } from './regions'; + +type Drawable = (Mesh | Points | Sprite) & { material: Material }; + +interface Companion { + part: CompanionPart; + wake: WakePart; + water: HullWaterPart; + missiles: MissileFit; + motion: SmoothMotion; +} + +const COMPANION_COUNT = 2; +const HISTORY_STEP = 0.25; +const HISTORY_STEPS = 60; + +function isDrawable(object: Object3D): object is Drawable { + const drawable = object instanceof Mesh || object instanceof Points || object instanceof Sprite; + return drawable && !Array.isArray((object as Drawable).material); +} + +function createContext(resources: AssemblyResources, tracker: ResourceTracker): PartContext { + const { size, low, high, seed, repeat } = SURFACE_MAPS.grain; + const grain = tracker.track(noiseTexture(size, low, high, seed)); + grain.repeat.set(...repeat); + const looks = createBoatLooks({ + side: tracker.track(hullSideTexture()), + deck: tracker.track(deckTexture()), + cells: tracker.track(cellTexture()), + grain, + }); + return { ...resources, tracker, looks }; +} + +export class NavalDroneAssembly implements Assembly { + readonly root = new Group(); + private readonly tracker = new ResourceTracker(); + private readonly materials: MaterialLibrary; + private readonly boat: BoatPart; + private readonly sea: SeaPart; + private readonly section: WaterSectionPart; + private readonly wake: WakePart; + private readonly hullWater: HullWaterPart; + private readonly jetStream: JetStreamPart; + private readonly flow: FlowPart; + private readonly wetted: WettedBarPart; + private readonly missiles: MissileFit; + private readonly companions: Companion[]; + private readonly ghost: GhostPart; + private readonly links: LinksPart; + private readonly ship: ShipPart; + private readonly shipRoll = new Group(); + private readonly satellite: SatellitePart; + private readonly backupSatellite: SatellitePart; + private readonly stationAnchor: Object3D; + private readonly labels = new Map(); + private readonly named: Readonly>; + private readonly regions: Readonly>; + private readonly frame = new Matrix4(); + private readonly boatMotion = new SmoothMotion(); + private readonly spacing = trailSpacing(WAKE.samples, WAKE.length, WAKE.power); + private seaState: AssemblyState['sea']['state']; + private state: AssemblyState; + + constructor(resources: AssemblyResources, state: AssemblyState) { + this.materials = resources.materials; + this.state = state; + this.seaState = state.sea.state; + resetWater(state.sea.waveHeight); + const context = createContext(resources, this.tracker); + const foam = this.tracker.track(foamTexture()); + this.boat = new BoatPart(context); + this.sea = new SeaPart(context, foam); + this.section = new WaterSectionPart(context, { ...this.sea.hullMask, ...this.sea.section }); + this.wake = new WakePart(context, 'wake', foam); + this.hullWater = new HullWaterPart(context, foam, { bow: 'bowWave', spray: 'spray' }); + this.jetStream = new JetStreamPart(context, foam, this.boat.jet.steering); + this.flow = new FlowPart(context, this.boat.jet.flowPath); + this.wetted = new WettedBarPart(context); + this.missiles = buildMissileFit(context); + this.boat.body.add( + this.hullWater.object, + this.jetStream.reverse, + this.jetStream.rooster.points, + this.flow.cloud.points, + this.wetted.object, + this.missiles.object, + ); + const shapes = buildCompanionGeometry(); + Object.values(shapes).forEach((geometry) => this.tracker.track(geometry)); + this.companions = Array.from({ length: COMPANION_COUNT }, () => { + const part = new CompanionPart(context, shapes); + const water = new HullWaterPart(context, foam, { bow: 'companions', spray: 'companions' }); + const missiles = buildMissileFit(context); + part.body.add(water.object, missiles.object); + const wake = new WakePart(context, 'companions', foam); + return { part, wake, water, missiles, motion: new SmoothMotion() }; + }); + this.ghost = new GhostPart(context, shapes.outline); + this.links = new LinksPart(context); + this.ship = createShip(context); + this.shipRoll.add(this.ship.object); + const shipHolder = new Group(); + shipHolder.position.set(SHIP_CENTRE[0], 0, SHIP_CENTRE[2]); + shipHolder.rotation.y = -SHIP_HEADING; + shipHolder.add(this.shipRoll); + this.satellite = createStarlinkSatellite(context); + this.backupSatellite = createBackupSatellite(context); + const sunYaw = Math.atan2(SUN_DIRECTION[0], SUN_DIRECTION[2]); + this.satellite.object.rotation.y = sunYaw; + this.backupSatellite.object.rotation.y = sunYaw; + const shore = createShore(context); + this.stationAnchor = shore.stationAnchor; + const { sky } = LIGHT_RIG; + this.root.add( + new HemisphereLight(sky.color, sky.ground, sky.intensity), + createSky(context, foam), + this.sea.mesh, + this.section.object, + shore.object, + shipHolder, + this.satellite.object, + this.backupSatellite.object, + this.wake.mesh, + this.boat.object, + ...this.companions.flatMap((companion) => [companion.part.object, companion.wake.mesh]), + this.ghost.object, + this.links.object, + ); + this.named = { + boat: this.boat.object, + dome: anchorAt(this.boat.deck.object, DOME.x, DOME.lens, 0), + stern: this.boat.jet.stern, + ship: this.ship.object, + satellite: this.satellite.anchor, + backupSatellite: this.backupSatellite.anchor, + groundStation: this.stationAnchor, + }; + this.regions = { ...staticRegions(), boat: new Box3() }; + this.collectLabels(); + this.setState(state); + } + + private collectLabels(): void { + const body = this.boat.body; + const at = (x: number, y: number, z: number) => anchorAt(body, x, y, z); + const [railFrom, railTo] = HULL_DETAIL.sprayRail.x; + const rail = hullSectionAt(railFrom + (railTo - railFrom) * LABEL_SPOTS.railShare); + const chine = hullSectionAt(LABEL_SPOTS.chinesX); + const middle = HULL_STATIONS[3]; + const spots: [PartId, Object3D][] = [ + ['hull', at(middle.x, middle.deck, 0)], + ['chines', at(chine.x, chine.chine[1], -(chine.flat[0] + LABEL_SPOTS.outboard))], + [ + 'sprayRails', + at(rail.x, rail.knuckle[1], -(rail.knuckle[0] + rail.rail + LABEL_SPOTS.outboard)), + ], + ['starlinkPanels', at((STARLINK_PANEL_XS[0] + STARLINK_PANEL_XS[1]) / 2, PANEL.top, 0)], + ['backupPanel', at(BACKUP_PANEL_X, PANEL.top, 0)], + ['cameraDome', anchorAt(this.boat.deck.dome, 0, DOME.top - DOME.base, 0)], + ['bowCamera', at((BOW_CAMERA.x[0] + BOW_CAMERA.x[1]) / 2, BOW_CAMERA.window.y, 0)], + ...this.boat.internals.labels, + ...this.boat.jet.labels, + ['missileRails', this.missiles.anchor], + ['jetStream', this.jetStream.anchor], + ['bowWave', this.hullWater.bowAnchor], + ['spray', this.hullWater.sprayAnchor], + ['wake', this.wake.anchor], + ['wettedLength', this.wetted.anchor], + ['videoGhost', this.ghost.anchor], + ['satLink', this.links.satAnchor], + ['backupLink', this.links.backupAnchor], + ['satellite', this.satellite.anchor], + ['backupSatellite', this.backupSatellite.anchor], + ['groundStation', this.stationAnchor], + ['ship', this.ship.deckAnchor], + ['shipRadar', this.ship.radarAnchor], + ['companions', anchorAt(this.companions[0].part.body, middle.x, middle.deck, 0)], + ]; + spots.forEach(([id, anchor]) => this.labels.set(id, anchor)); + } + + setState(state: AssemblyState): void { + this.state = state; + this.applySea(state); + this.boat.setState(state); + this.settleBoats(state.playing || state.boat.held ? 0 : 1); + this.missiles.object.visible = state.fit === 'missile'; + this.sea.openPort(state.view.cutaway); + sectionFrame(state.boat.position, state.boat.heading, this.frame); + placeSection(this.sea.section, this.frame, state.waterSection); + this.section.place(this.frame, state.waterSection); + this.wake.setTrail(this.boatTrail(state), this.wakeParams(state)); + this.hullWater.setState(state, this.boat.body); + this.jetStream.setState(state, this.boat.body); + this.flow.setState(state); + this.wetted.setState(state, this.boat.body); + this.placeCompanions(state); + this.placeSky(state); + this.ghost.setState(state, this.boat.object); + this.links.setState(state, { + body: this.boat.body, + satellite: this.satellite.anchor, + backupSatellite: this.backupSatellite.anchor, + station: this.stationAnchor, + starlinkGlow: this.boat.deck.starlinkGlow, + backupGlow: this.boat.deck.backupGlow, + }); + this.rollShip(); + boatRegion(this.boat.object, this.regions.boat); + } + + private applySea(state: AssemblyState): void { + this.sea.setWaveScale(state.sea.waveHeight, SEA_FOAM[state.sea.state]); + if (state.sea.state === this.seaState) return; + this.seaState = state.sea.state; + WATER.uWaves.value = waveVectors(state.sea.waveHeight); + } + + private settleBoats(blend: number): void { + const { boat, planing, companions } = this.state; + const response = responseFor(planing.liftShare); + const scale = boat.held ? MOTION.held : 1; + const target = waveMotion(boat.position, boat.heading, response); + applyMotion(this.boat.body, this.boatMotion.follow(target, blend, scale)); + this.sea.followBoat(this.boat.body); + companions.forEach((reading, index) => { + const companion = this.companions[index]; + if (!companion) return; + const wave = waveMotion(reading.position, reading.heading, response); + applyMotion(companion.part.body, companion.motion.follow(wave, blend, scale)); + }); + } + + private wakeParams(state: AssemblyState): WakeParams { + return { + knots: state.boat.knots, + plane: state.planing.liftShare, + bowAhead: BOAT.waterlineLength, + emphasis: state.view.flow, + }; + } + + private boatTrail(state: AssemblyState): TrailSample[] { + const { boat } = state; + return boat.held + ? straightTrail(boat, WAKE.start, this.spacing) + : routeTrail(poseAtDistance, boat.distance, WAKE.start, this.spacing); + } + + private companionTrail(index: number, phase: number): TrailSample[] | null { + const history = Array.from({ length: HISTORY_STEPS }, (_, step) => phase - step * HISTORY_STEP) + .filter((time) => time >= FORMATION.joinStart) + .map((time) => companionsAt(time)[index]); + if (history.length < 2) return null; + return pathTrail(history, WAKE.start, this.spacing); + } + + private placeCompanions(state: AssemblyState): void { + const { planing, fit } = state; + this.companions.forEach((companion, index) => { + const reading = state.companions[index]; + companion.part.object.visible = reading !== undefined; + companion.wake.mesh.visible = false; + if (!reading) return; + companion.part.object.position.set(reading.position[0], planing.heave, reading.position[2]); + companion.part.object.rotation.set(0, -reading.heading, planing.trim, 'YZX'); + companion.missiles.object.visible = fit === 'missile'; + companion.water.setState(state, companion.part.body); + const trail = this.companionTrail(index, state.phase); + if (trail) companion.wake.setTrail(trail, this.wakeParams(state)); + }); + } + + private placeSky(state: AssemblyState): void { + this.satellite.object.visible = state.view.links; + this.backupSatellite.object.visible = state.view.links; + const { position } = state.boat; + this.satellite.object.position.set(...skyPoint(position, SATELLITE_OFFSET)); + this.backupSatellite.object.position.set(...skyPoint(position, BACKUP_SATELLITE_OFFSET)); + this.satellite.object.updateMatrixWorld(true); + this.backupSatellite.object.updateMatrixWorld(true); + } + + private rollShip(): void { + applyMotion( + this.shipRoll, + waveMotion(SHIP_CENTRE, SHIP_HEADING, MOTION.shipResponse, SHIP_SPAN), + ); + } + + update(deltaSeconds: number, _cameraDistance: number): boolean { + const { playing, boat } = this.state; + if (!playing && !boat.held) return false; + WATER.uSeaTime.value += deltaSeconds; + if (boat.held) { + const travel = knotsToMs(boat.knots) * deltaSeconds; + WATER.uSeaDrift.value.x += Math.cos(boat.heading) * travel; + WATER.uSeaDrift.value.y += Math.sin(boat.heading) * travel; + } + this.settleBoats(motionBlend(deltaSeconds)); + this.boat.advance(deltaSeconds, this.state); + this.hullWater.advance(deltaSeconds); + this.hullWater.setState(this.state, this.boat.body); + this.jetStream.advance(deltaSeconds, this.state); + this.flow.advance(deltaSeconds); + this.wetted.setState(this.state, this.boat.body); + this.companions.forEach((companion) => { + if (!companion.part.object.visible) return; + companion.water.advance(deltaSeconds); + companion.water.setState(this.state, companion.part.body); + }); + this.links.advance(deltaSeconds); + this.ship.radar.rotation.y += ANIMATION.radarRate * deltaSeconds; + this.rollShip(); + return true; + } + + labelAnchors(): ReadonlyMap { + return this.labels; + } + + anchor(id: AnchorId): Object3D { + return this.named[id]; + } + + region(id: RegionId): Box3 { + return this.regions[id]; + } + + chaseTarget(): ChaseTarget { + const { boat, planing } = this.state; + const [x, y, z] = boat.position; + return { + position: [x, y + planing.heave, z], + heading: boat.heading, + length: BOAT.length, + ship: SHIP_CENTRE, + }; + } + + 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.root.removeFromParent(); + this.materials.clearRegistered(); + this.tracker.dispose(); + } +} + +export function createNavalDroneAssembly( + resources: AssemblyResources, + state: AssemblyState, +): Assembly { + return new NavalDroneAssembly(resources, state); +} diff --git a/explainers/naval-drone/src/scene/partInfo.test.ts b/explainers/naval-drone/src/scene/partInfo.test.ts new file mode 100644 index 00000000..24d3ede2 --- /dev/null +++ b/explainers/naval-drone/src/scene/partInfo.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { PART_IDS } from '../ids'; +import { PRESETS } from '../state'; +import { LABEL_PRIORITY, PART_INFO } from './partInfo'; + +describe('part labels', () => { + 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 impeller first and the hull last', () => { + expect([...LABEL_PRIORITY].sort()).toEqual([...PART_IDS].sort()); + expect(LABEL_PRIORITY[0]).toBe('impeller'); + expect(LABEL_PRIORITY.at(-1)).toBe('hull'); + }); + + it('labels the pump, the insides and the backup link on the left', () => { + ( + [ + 'waterjet', + 'engine', + 'fuelTanks', + 'electronicsBay', + 'intake', + 'duct', + 'driveShaft', + 'impeller', + 'wake', + 'jetStream', + 'wettedLength', + 'backupPanel', + 'backupLink', + 'groundStation', + 'companions', + 'shipRadar', + 'hull', + ] as const + ).forEach((id) => expect(PART_INFO[id].side, id).toBe('left')); + ( + ['stator', 'nozzle', 'steeringNozzle', 'satLink', 'satellite', 'ship', 'cameraDome'] as const + ).forEach((id) => expect(PART_INFO[id].side, id).toBe('right')); + }); + + it('splits the labels of the busy chapters across both sides', () => { + (['overview', 'jet', 'link'] as const).forEach((id) => { + const sides = PRESETS[id].labels.map((part) => PART_INFO[part].side); + expect(sides, id).toContain('left'); + expect(sides, id).toContain('right'); + }); + }); +}); diff --git a/explainers/naval-drone/src/scene/partInfo.ts b/explainers/naval-drone/src/scene/partInfo.ts new file mode 100644 index 00000000..f28c3213 --- /dev/null +++ b/explainers/naval-drone/src/scene/partInfo.ts @@ -0,0 +1,70 @@ +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([ + 'waterjet', + 'engine', + 'fuelTanks', + 'electronicsBay', + 'intake', + 'duct', + 'driveShaft', + 'impeller', + 'wake', + 'jetStream', + 'wettedLength', + 'backupPanel', + 'backupLink', + 'groundStation', + 'companions', + 'shipRadar', + 'hull', +]); + +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[] = [ + 'impeller', + 'steeringNozzle', + 'reverseBucket', + 'nozzle', + 'stator', + 'intake', + 'duct', + 'driveShaft', + 'jetStream', + 'wettedLength', + 'spray', + 'bowWave', + 'wake', + 'chines', + 'sprayRails', + 'cameraDome', + 'bowCamera', + 'starlinkPanels', + 'backupPanel', + 'satLink', + 'backupLink', + 'videoGhost', + 'satellite', + 'backupSatellite', + 'groundStation', + 'payloadBay', + 'engine', + 'fuelTanks', + 'electronicsBay', + 'waterjet', + 'missileRails', + 'ship', + 'shipRadar', + 'companions', + 'hull', +]; diff --git a/explainers/naval-drone/src/scene/parts/boat/boat.ts b/explainers/naval-drone/src/scene/parts/boat/boat.ts new file mode 100644 index 00000000..f25a4337 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/boat/boat.ts @@ -0,0 +1,110 @@ +import { Group, Mesh } from 'three'; +import type { MaterialFinish } from '@core/scene/materials'; +import type { AssemblyState } from '../../../ids'; +import { applyBoatPose } from '../../pose'; +import { ANIMATION, JET_SHAPE } from '../../constants'; +import { MAX_RPM } from '../../../model/jet'; +import { FINISHES } from '../../finishes'; +import { mergeParts } from '../context'; +import type { PartContext } from '../context'; +import { wetSurface } from '../water/wetSurface'; +import { CutawaySwitch } from './cutaway'; +import { buildDeckItems } from './deckItems'; +import type { DeckItems } from './deckItems'; +import { buildFairing } from './fairing'; +import { buildInternals } from './internals'; +import type { Internals } from './internals'; +import { buildHullShell } from './hullShell'; +import type { Look, ShellGroup, ShellPiece } from './hullShell'; +import { buildWaterjet } from './waterjet'; +import type { Waterjet } from './waterjet'; + +export class BoatPart { + readonly object = new Group(); + readonly body = new Group(); + readonly cutaway = new CutawaySwitch(); + readonly deck: DeckItems; + readonly jet: Waterjet; + readonly internals: Internals; + private readonly context: PartContext; + private clock = 0; + + constructor(context: PartContext) { + this.context = context; + this.object.add(this.body); + this.buildShell(); + this.deck = buildDeckItems(context); + this.jet = buildWaterjet(context, this.cutaway); + this.internals = buildInternals(context); + this.body.add(this.deck.object, this.jet.object, this.cutaway.opened(this.internals.object)); + } + + private finish(look: Look): MaterialFinish { + switch (look) { + case 'side': + return this.context.looks.side; + case 'deck': + return this.context.looks.deck; + case 'inner': + return FINISHES.inner; + default: + return FINISHES.section; + } + } + + private mesh(pieces: readonly ShellPiece[], group: ShellGroup, look: Look): Mesh { + const geometry = this.context.tracker.track(mergeParts(pieces.map((piece) => piece.geometry))); + const material = this.context.materials.get(group, this.finish(look)); + if (look === 'side') wetSurface(material); + return new Mesh(geometry, material); + } + + private buildShell(): void { + const pieces = buildHullShell(); + const keys = new Set(pieces.map((piece) => `${piece.group}|${piece.look}|${piece.side}`)); + keys.forEach((key) => { + const [group, look, side] = key.split('|') as [ShellGroup, Look, ShellPiece['side']]; + const chosen = pieces.filter( + (piece) => piece.group === group && piece.look === look && piece.side === side, + ); + const mesh = this.mesh(chosen, group, look); + if (side === 'portAft') this.cutaway.whole(mesh); + if (side === 'opened') this.cutaway.opened(mesh); + this.body.add(mesh); + }); + const fairing = buildFairing(); + const { tracker, materials, looks } = this.context; + const deck = materials.get('hull', looks.deck); + const pocket = materials.get('hull', FINISHES.pocket); + this.body.add( + new Mesh(tracker.track(fairing.outer), deck), + this.cutaway.whole(new Mesh(tracker.track(fairing.outerPort), deck)), + new Mesh(tracker.track(fairing.pockets), pocket), + this.cutaway.whole(new Mesh(tracker.track(fairing.pocketsPort), pocket)), + this.cutaway.opened( + new Mesh(tracker.track(fairing.inner), materials.get('hull', FINISHES.inner)), + ), + this.cutaway.opened( + new Mesh(tracker.track(fairing.cut), materials.get('hull', FINISHES.section)), + ), + ); + } + + advance(deltaSeconds: number, state: AssemblyState): void { + this.clock += deltaSeconds; + const hertz = state.jet.impellerShare * MAX_RPM * ANIMATION.rpmToHertz; + const shown = Math.min(hertz, ANIMATION.impellerCap); + this.jet.impeller.rotation.x += Math.PI * 2 * shown * deltaSeconds; + this.jet.blur.visible = hertz > ANIMATION.blurFrom; + this.deck.dome.rotation.y = ANIMATION.domeScan * Math.sin(this.clock * ANIMATION.domeRate); + } + + setState(state: AssemblyState): void { + applyBoatPose(this.object, state.boat, state.planing); + const hertz = state.jet.impellerShare * MAX_RPM * ANIMATION.rpmToHertz; + this.jet.blur.visible = hertz > ANIMATION.blurFrom; + this.cutaway.set(state.view.cutaway); + this.jet.steering.rotation.y = state.jet.nozzleAngle; + this.jet.bucket.rotation.z = JET_SHAPE.bucket.stow * (1 - state.jet.bucket); + } +} diff --git a/explainers/naval-drone/src/scene/parts/boat/companion.ts b/explainers/naval-drone/src/scene/parts/boat/companion.ts new file mode 100644 index 00000000..b1d3e109 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/boat/companion.ts @@ -0,0 +1,111 @@ +import { Group, Mesh } from 'three'; +import type { BufferGeometry, Material } from 'three'; +import type { MaterialFinish } from '@core/scene/materials'; +import { BOW_CAMERA, DOME, FAIRING, JET, PANEL, STUB, VENT_BOX } from '../../../model/layout'; +import { COMPANION } from '../../constants'; +import { FINISHES } from '../../finishes'; +import { deckYAt } from '../../geometry/hullLines'; +import { turned } from '../../geometry/sheet'; +import { boxAt, lathe, rod } from '../../geometry/solids'; +import { mergeParts } from '../context'; +import type { PartContext } from '../context'; +import { PANEL_XS, buildFairing } from './fairing'; +import { buildHullShell } from './hullShell'; +import { wetSurface } from '../water/wetSurface'; + +export interface CompanionGeometry { + side: BufferGeometry; + deck: BufferGeometry; + light: BufferGeometry; + dark: BufferGeometry; + outline: BufferGeometry; +} + +function domeShape(): BufferGeometry { + const { segments } = COMPANION; + const shoulder = DOME.radius * COMPANION.shoulder; + return lathe( + [ + [0, 0], + [DOME.radius, 0], + [DOME.radius, DOME.height], + [shoulder, DOME.height + shoulder], + [0, DOME.height + DOME.radius], + ], + segments, + ).translate(DOME.x, DOME.base, 0); +} + +function jetShape(): BufferGeometry { + const aft = JET.steeringNozzle.x[0]; + const fore = JET.housing.x[1]; + const housing = turned( + [ + [aft, COMPANION.exit], + [JET.nozzle.x[0], JET.nozzle.exitDiameter / 2], + [JET.nozzle.x[1], JET.housing.outerDiameter / 2], + [fore, JET.housing.outerDiameter / 2], + [fore, 0], + ], + COMPANION.segments, + ); + housing.translate(0, JET.axisY, 0); + return housing; +} + +export function buildCompanionGeometry(): CompanionGeometry { + const pieces = buildHullShell().filter((piece) => piece.side !== 'opened'); + const pick = (look: 'side' | 'deck') => + mergeParts(pieces.filter((piece) => piece.look === look).map((piece) => piece.geometry)); + const fairing = buildFairing(); + [fairing.inner, fairing.cut, fairing.pockets, fairing.pocketsPort].forEach((part) => + part.dispose(), + ); + const side = pick('side'); + const deck = mergeParts([pick('deck'), fairing.outer, fairing.outerPort]); + const slab = (x: number) => + boxAt( + [PANEL.length, PANEL.thickness + PANEL.raise, PANEL.width], + [x, PANEL.top - PANEL.thickness / 2, 0], + ); + const vent = boxAt( + [VENT_BOX.x[1] - VENT_BOX.x[0], VENT_BOX.top - FAIRING.top, VENT_BOX.halfWidth * 2], + [(VENT_BOX.x[0] + VENT_BOX.x[1]) / 2, (VENT_BOX.top + FAIRING.top) / 2, 0], + ); + const cameraX = (BOW_CAMERA.x[0] + BOW_CAMERA.x[1]) / 2; + const camera = boxAt( + [BOW_CAMERA.x[1] - BOW_CAMERA.x[0], BOW_CAMERA.height, BOW_CAMERA.width], + [cameraX, deckYAt(cameraX, 0) + BOW_CAMERA.height / 2, 0], + ); + const stub = rod( + 'y', + STUB.radius, + STUB.height, + [STUB.x, deckYAt(STUB.x, 0) + STUB.height / 2, 0], + COMPANION.stubSides, + ); + return { + side, + deck, + light: mergeParts([...PANEL_XS.map(slab), vent, domeShape()]), + dark: mergeParts([jetShape(), camera, stub]), + outline: mergeParts([side.clone(), deck.clone()]), + }; +} + +export class CompanionPart { + readonly object = new Group(); + readonly body = new Group(); + + constructor(context: PartContext, shapes: CompanionGeometry) { + const material = (finish: MaterialFinish): Material => + context.materials.get('companions', finish); + this.object.add(this.body); + this.body.add( + new Mesh(shapes.side, wetSurface(material(context.looks.companionSide))), + new Mesh(shapes.deck, material(context.looks.companionDeck)), + new Mesh(shapes.light, material(FINISHES.cap)), + new Mesh(shapes.dark, material(FINISHES.jetBlack)), + ); + } +} diff --git a/explainers/naval-drone/src/scene/parts/boat/cutaway.ts b/explainers/naval-drone/src/scene/parts/boat/cutaway.ts new file mode 100644 index 00000000..f26482bf --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/boat/cutaway.ts @@ -0,0 +1,25 @@ +import type { Object3D } from 'three'; + +export class CutawaySwitch { + private readonly wholeParts: Object3D[] = []; + private readonly openedParts: Object3D[] = []; + private cut = false; + + whole(object: T): T { + this.wholeParts.push(object); + object.visible = !this.cut; + return object; + } + + opened(object: T): T { + this.openedParts.push(object); + object.visible = this.cut; + return object; + } + + set(cut: boolean): void { + this.cut = cut; + this.wholeParts.forEach((object) => (object.visible = !cut)); + this.openedParts.forEach((object) => (object.visible = cut)); + } +} diff --git a/explainers/naval-drone/src/scene/parts/boat/deckItems.ts b/explainers/naval-drone/src/scene/parts/boat/deckItems.ts new file mode 100644 index 00000000..16538657 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/boat/deckItems.ts @@ -0,0 +1,281 @@ +import { + BoxGeometry, + Group, + Matrix4, + Mesh, + PlaneGeometry, + Quaternion, + SphereGeometry, + Vector3, +} from 'three'; +import type { BufferGeometry, MeshStandardMaterial } from 'three'; +import { toRadians } from '@core/math'; +import { BOW_CAMERA, DOME, HATCHES, PANEL, STUB, VENT_BOX } from '../../../model/layout'; +import { DECK_ITEMS } from '../../constants'; +import { FINISHES } from '../../finishes'; +import { spread } from '../../geometry/curves'; +import { deckYAt } from '../../geometry/hullLines'; +import { boxAt, lathe, rod } from '../../geometry/solids'; +import { gridSurface, orientFrom, polygonFan } from '../../geometry/surface'; +import type { Vec3 } from '../../geometry/surface'; +import { instanced, mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; +import { PANEL_XS } from './fairing'; + +export interface DeckItems { + object: Group; + dome: Group; + starlinkGlow: MeshStandardMaterial; + backupGlow: MeshStandardMaterial; +} + +const QUARTER_TURN = Math.PI / 2; +const BOX_TOP_FACE = [8, 12] as const; +const Y_AXIS = new Vector3(0, 1, 0); +const STUB_SEGMENTS = 12; + +function ventBox(context: PartContext): Group { + const { frame, slat, inset, capOverhang, capThickness, bottom } = DECK_ITEMS.vent; + const [aft, fore] = VENT_BOX.x; + const half = VENT_BOX.halfWidth; + const capBase = VENT_BOX.top - capThickness; + const height = capBase - bottom; + const middleY = bottom + height / 2; + const middleX = (aft + fore) / 2; + const length = fore - aft; + const core = boxAt([length - 2 * inset, height, 2 * (half - inset)], [middleX, middleY, 0]); + const posts = [aft + frame / 2, fore - frame / 2].flatMap((x) => + [-1, 1].map((side) => boxAt([frame, height, frame], [x, middleY, side * (half - frame / 2)])), + ); + const rims = [bottom + frame / 2, capBase - frame / 2].map((y) => + boxAt([length, frame, 2 * half], [middleX, y, 0]), + ); + const front = boxAt([frame, height, 2 * half], [fore - frame / 2, middleY, 0]); + const slatHeight = height - 2 * frame; + const slats: BufferGeometry[] = []; + const sideCount = Math.floor((length - 2 * frame) / slat.pitch); + spread(aft + frame + slat.pitch / 2, fore - frame - slat.pitch / 2, sideCount - 1).forEach((x) => + [-1, 1].forEach((side) => + slats.push( + boxAt( + [slat.depth, slatHeight, slat.thickness], + [x, middleY, side * (half - inset / 2)], + side * slat.angle, + ), + ), + ), + ); + const aftCount = Math.floor((2 * half - 2 * frame) / slat.pitch); + spread(-half + frame + slat.pitch / 2, half - frame - slat.pitch / 2, aftCount - 1).forEach((z) => + slats.push( + boxAt([slat.thickness, slatHeight, slat.depth], [aft + inset / 2, middleY, z], slat.angle), + ), + ); + const cap = boxAt( + [length + 2 * capOverhang, capThickness, 2 * (half + capOverhang)], + [middleX, capBase + capThickness / 2, 0], + ); + const group = new Group(); + group.add( + partMesh(context, core, 'hull', FINISHES.pocket), + partMesh(context, mergeParts([...posts, ...rims, front]), 'hull', FINISHES.deckPlain), + partMesh(context, mergeParts(slats), 'hull', FINISHES.louvre), + partMesh(context, cap, 'hull', FINISHES.cap), + ); + return group; +} + +function panelSlab(x: number): BufferGeometry { + const slab = new BoxGeometry(PANEL.length, PANEL.thickness, PANEL.width); + const uv = slab.getAttribute('uv'); + for (let at = 0; at < uv.count; at += 1) { + if (at < BOX_TOP_FACE[0] || at >= BOX_TOP_FACE[1]) + uv.setXY(at, DECK_ITEMS.panel.edgeUv, DECK_ITEMS.panel.edgeUv); + } + slab.translate(x, PANEL.top - PANEL.thickness / 2, 0); + return slab; +} + +function panelFeet(xs: readonly number[]): BufferGeometry { + const { foot, footInset } = DECK_ITEMS.panel; + const floor = DECK_ITEMS.panelFloor; + const height = PANEL.top - PANEL.thickness - floor; + const feet = xs.flatMap((x) => + [-1, 1].flatMap((sx) => + [-1, 1].map((sz) => + rod('y', foot, height, [ + x + sx * (PANEL.length / 2 - footInset), + floor + height / 2, + sz * (PANEL.width / 2 - footInset), + ]), + ), + ), + ); + return mergeParts(feet); +} + +function glowFinish(context: PartContext) { + return { ...context.looks.panel, emissive: DECK_ITEMS.panel.glow, emissiveIntensity: 0 }; +} + +function stub(context: PartContext): Mesh { + const { flange, flangeHeight, capSamples } = DECK_ITEMS.stub; + const radius = STUB.radius; + const shaft = STUB.height - radius; + const cap = spread(0, QUARTER_TURN, capSamples).map((angle): [number, number] => [ + radius * Math.cos(angle), + shaft + radius * Math.sin(angle), + ]); + const geometry = lathe( + [ + [0, -flangeHeight], + [flange, -flangeHeight], + [flange, flangeHeight], + [radius, flangeHeight], + ...cap, + ], + STUB_SEGMENTS, + ); + geometry.translate(STUB.x, deckYAt(STUB.x, 0), 0); + return partMesh(context, geometry, 'hull', FINISHES.ring); +} + +function domeParts(context: PartContext): { dome: Group; ring: Mesh } { + const { ringInner, ringDepth, ringRise, segments, capSamples, skirt, window } = DECK_ITEMS.dome; + const ring = partMesh( + context, + lathe( + [ + [ringInner, -ringDepth], + [ringInner, ringRise], + [DOME.ringRadius, ringRise], + [DOME.ringRadius, -ringDepth], + ], + segments, + ), + 'cameraDome', + FINISHES.ring, + ); + ring.position.set(DOME.x, DOME.base, 0); + const shaft = DOME.height; + const cap = spread(0, QUARTER_TURN, capSamples).map((angle): [number, number] => [ + DOME.radius * Math.cos(angle), + shaft + DOME.radius * Math.sin(angle), + ]); + const body = lathe( + [ + [0, 0], + [DOME.radius + skirt.flare, 0], + [DOME.radius + skirt.flare, skirt.height], + [DOME.radius, skirt.height + skirt.flare], + ...cap, + ], + segments, + ); + const patch = (radius: number, azimuth: number, from: number, to: number) => { + const geometry = new SphereGeometry( + radius, + segments / 2, + capSamples, + Math.PI - toRadians(azimuth), + 2 * toRadians(azimuth), + toRadians(90 - to), + toRadians(to - from), + ); + geometry.translate(0, shaft, 0); + return geometry; + }; + const dome = new Group(); + dome.position.set(DOME.x, DOME.base, 0); + const windowMesh = partMesh( + context, + patch(DOME.radius * window.lift, window.azimuth, window.from, window.to), + 'cameraDome', + FINISHES.glass, + ); + dome.add(partMesh(context, body, 'cameraDome', FINISHES.dome), windowMesh); + return { dome, ring }; +} + +function bowCamera(context: PartContext): Group { + const { samples, around, tailWidth, tailHeight, sink, squareness, proud } = DECK_ITEMS.bowCamera; + const [aft, fore] = BOW_CAMERA.x; + const sections = spread(0, 1, samples).map((share) => { + const x = aft + (fore - aft) * share; + const ease = Math.sin((share * Math.PI) / 2); + const width = tailWidth + (BOW_CAMERA.width / 2 - tailWidth) * ease; + const height = tailHeight + (BOW_CAMERA.height - tailHeight) * Math.sqrt(share); + const base = deckYAt(x, 0) - sink; + return spread(0, Math.PI, around).map((angle): Vec3 => { + const c = Math.cos(angle); + const s = Math.sin(angle); + return [ + x, + base + (height + sink) * Math.sign(s) * Math.abs(s) ** squareness, + width * Math.sign(c) * Math.abs(c) ** squareness, + ]; + }); + }); + const rows = sections[0].map((_, index) => sections.map((section) => section[index])); + const shell = orientFrom(gridSurface(rows), [(aft + fore) / 2, deckYAt(fore, 0), 0]); + const front = orientFrom(polygonFan(sections[sections.length - 1]), [aft, deckYAt(fore, 0), 0]); + const { width, height, y } = BOW_CAMERA.window; + const glass = new PlaneGeometry(width, height); + glass.rotateY(QUARTER_TURN); + glass.translate(fore + proud, y, 0); + const group = new Group(); + group.add( + partMesh(context, mergeParts([shell, front]), 'bowCamera', FINISHES.deckPlain), + partMesh(context, glass, 'bowCamera', FINISHES.glass), + ); + return group; +} + +function handleGeometry(): BufferGeometry { + const { washer, washerHeight, stem, barRadius } = DECK_ITEMS.handle; + const { bar, stem: stemHeight } = HATCHES.handle; + return mergeParts([ + rod('y', washer, washerHeight, [0, washerHeight / 2, 0]), + rod('y', stem, stemHeight, [0, stemHeight / 2, 0]), + rod('x', barRadius, bar, [0, stemHeight, 0]), + ]); +} + +function handleMatrices(): Matrix4[] { + const spots = DECK_ITEMS.handleSpots; + const slope = DECK_ITEMS.slopeStep; + return spots.map(([x, z, turn]) => { + const y = deckYAt(x, z); + const pitch = Math.atan2(deckYAt(x + slope, z) - deckYAt(x - slope, z), 2 * slope); + const rotation = new Quaternion() + .setFromAxisAngle(new Vector3(0, 0, 1), pitch) + .multiply(new Quaternion().setFromAxisAngle(Y_AXIS, turn)); + return new Matrix4().compose(new Vector3(x, y, z), rotation, new Vector3(1, 1, 1)); + }); +} + +export function buildDeckItems(context: PartContext): DeckItems { + const starlinkGlow = context.materials.get('starlinkPanels', glowFinish(context)); + const backupGlow = context.materials.get('backupPanel', glowFinish(context)); + const [backupX, ...starlinkXs] = PANEL_XS; + const starlink = new Mesh( + context.tracker.track(mergeParts(starlinkXs.map(panelSlab))), + starlinkGlow, + ); + const backup = new Mesh(context.tracker.track(panelSlab(backupX)), backupGlow); + const { dome, ring } = domeParts(context); + const object = new Group(); + object.add( + ventBox(context), + starlink, + backup, + partMesh(context, panelFeet(starlinkXs), 'starlinkPanels', FINISHES.ring), + partMesh(context, panelFeet([backupX]), 'backupPanel', FINISHES.ring), + stub(context), + ring, + dome, + bowCamera(context), + instanced(context, handleGeometry(), 'hull', FINISHES.handle, handleMatrices()), + ); + return { object, dome, starlinkGlow, backupGlow }; +} diff --git a/explainers/naval-drone/src/scene/parts/boat/fairing.ts b/explainers/naval-drone/src/scene/parts/boat/fairing.ts new file mode 100644 index 00000000..689633ca --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/boat/fairing.ts @@ -0,0 +1,183 @@ +import type { BufferGeometry } from 'three'; +import { lerp } from '@core/math'; +import { BACKUP_PANEL_X, FAIRING, PANEL, STARLINK_PANEL_XS } from '../../../model/layout'; +import { FAIRING_SHAPE, SHELL } from '../../constants'; +import { spread } from '../../geometry/curves'; +import { flatPolygon } from '../../geometry/flat'; +import { deckYAt, hullSectionAt } from '../../geometry/hullLines'; +import type { Pair } from '../../geometry/hullLines'; +import { gridSurface, mirrorZ, orientFrom } from '../../geometry/surface'; +import type { Uv, Vec3 } from '../../geometry/surface'; +import { mergeParts } from '../context'; +import { acrossDeck, alongHull, fairingCutBand } from './hullShell'; + +export interface FairingGeometry { + outer: BufferGeometry; + outerPort: BufferGeometry; + pockets: BufferGeometry; + pocketsPort: BufferGeometry; + inner: BufferGeometry; + cut: BufferGeometry; +} + +export const PANEL_XS = [BACKUP_PANEL_X, ...STARLINK_PANEL_XS] as const; + +const HALF_FRAME = { x: PANEL.frameLength / 2, z: PANEL.frameWidth / 2 } as const; +const deckUv = (x: number, z: number): Uv => [alongHull(x), acrossDeck(z)]; + +function baseHalfWidthAt(x: number): number { + return Math.min(FAIRING.baseHalfWidth, hullSectionAt(x).sheer[0]); +} + +function topOutline(): Pair[] { + const [aft] = FAIRING.x; + const outline: Pair[] = [[aft, 0]]; + PANEL_XS.forEach((x) => { + outline.push( + [x - HALF_FRAME.x, 0], + [x - HALF_FRAME.x, HALF_FRAME.z], + [x + HALF_FRAME.x, HALF_FRAME.z], + [x + HALF_FRAME.x, 0], + ); + }); + outline.push( + [FAIRING.frontTopX, 0], + [FAIRING.frontTopX, FAIRING.topHalfWidth], + [aft, FAIRING.topHalfWidth], + ); + return outline; +} + +function top(y: number, inset = 0): BufferGeometry { + const outline = inset > 0 ? insetTop(inset) : topOutline(); + return flatPolygon(outline, (x, z) => [x, y, z], [], deckUv); +} + +function insetTop(inset: number): Pair[] { + const [aft] = FAIRING.x; + return [ + [aft + inset, 0], + [FAIRING.frontTopX - inset, 0], + [FAIRING.frontTopX - inset, FAIRING.topHalfWidth - inset], + [aft + inset, FAIRING.topHalfWidth - inset], + ]; +} + +function sideRows(inset: number): Vec3[][] { + const [aft, fore] = FAIRING.x; + const shares = spread(0, 1, FAIRING_SHAPE.sideSamples); + const upper = shares.map((share): Vec3 => [ + lerp(aft + inset, FAIRING.frontTopX - inset, share), + FAIRING.top - inset, + FAIRING.topHalfWidth - inset, + ]); + const lower = shares.map((share): Vec3 => { + const x = lerp(aft + inset, fore - inset, share); + const z = baseHalfWidthAt(x) - inset; + return [x, deckYAt(x, z), z]; + }); + return [lower, upper]; +} + +function frontRows(inset: number): Vec3[][] { + const [, fore] = FAIRING.x; + const x = fore - inset; + const edge = hullSectionAt(x).panelEdge[0]; + const base = baseHalfWidthAt(x) - inset; + const shares = [ + ...spread(0, edge / base, FAIRING_SHAPE.frontSamples), + ...spread(edge / base, 1, FAIRING_SHAPE.frontSamples).slice(1), + ]; + const upper = shares.map((share): Vec3 => [ + FAIRING.frontTopX - inset, + FAIRING.top - inset, + share * (FAIRING.topHalfWidth - inset), + ]); + const lower = shares.map((share): Vec3 => { + const z = share * base; + return [x, deckYAt(x, z) - inset, z]; + }); + return [lower, upper]; +} + +function aftFace(inset: number): BufferGeometry { + const x = FAIRING.x[0] + inset; + const section = hullSectionAt(x); + const base = baseHalfWidthAt(x) - inset; + const outline: Pair[] = [ + [0, section.deck - inset], + [section.panelEdge[0], section.deck - inset], + [base, deckYAt(x, base) - inset], + [FAIRING.topHalfWidth - inset, FAIRING.top - inset], + [0, FAIRING.top - inset], + ]; + return flatPolygon( + outline, + (z, y) => [x, y, z], + [], + (z) => deckUv(x, z), + ); +} + +function shell(inset: number, withTop: boolean): BufferGeometry { + const sideUv = + (rows: Vec3[][]) => + (row: number, column: number): Uv => { + const [x, , z] = rows[row][column]; + return deckUv(x, z); + }; + const side = sideRows(inset); + const front = frontRows(inset); + const parts = [ + gridSurface(side, { uv: sideUv(side) }), + gridSurface(front, { uv: sideUv(front) }), + aftFace(inset), + ]; + if (withTop) parts.push(top(FAIRING.top)); + else parts.push(top(FAIRING.top - inset, inset)); + return mergeParts(parts.map((part) => orientFrom(part, FAIRING_SHAPE.centre, withTop))); +} + +function pockets(): BufferGeometry { + const floor = FAIRING.top - FAIRING_SHAPE.recess; + const parts = PANEL_XS.flatMap((x) => { + const [aft, fore] = [x - HALF_FRAME.x, x + HALF_FRAME.x]; + const wall = (from: Vec3, to: Vec3): BufferGeometry => + gridSurface([ + [from, to], + [ + [from[0], FAIRING.top, from[2]], + [to[0], FAIRING.top, to[2]], + ], + ]); + const centre: Vec3 = [x, floor + FAIRING_SHAPE.recess / 2, 0]; + return [ + flatPolygon( + [ + [aft, 0], + [fore, 0], + [fore, HALF_FRAME.z], + [aft, HALF_FRAME.z], + ], + (px, pz) => [px, floor, pz], + ), + wall([aft, floor, 0], [aft, floor, HALF_FRAME.z]), + wall([fore, floor, 0], [fore, floor, HALF_FRAME.z]), + wall([aft, floor, HALF_FRAME.z], [fore, floor, HALF_FRAME.z]), + ].map((part) => orientFrom(part, centre, false)); + }); + return mergeParts(parts); +} + +export function buildFairing(): FairingGeometry { + const outer = shell(0, true); + const pocket = pockets(); + return { + outer, + outerPort: mirrorZ(outer.clone()), + pockets: pocket, + pocketsPort: mirrorZ(pocket.clone()), + inner: shell(SHELL.fairing, false), + cut: fairingCutBand(), + }; +} diff --git a/explainers/naval-drone/src/scene/parts/boat/hullShell.ts b/explainers/naval-drone/src/scene/parts/boat/hullShell.ts new file mode 100644 index 00000000..3ae8fb22 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/boat/hullShell.ts @@ -0,0 +1,364 @@ +import type { BufferGeometry } from 'three'; +import { BOAT, FAIRING, HULL_DETAIL, JET, TRANSOM_X } from '../../../model/layout'; +import { HULL_LINES, SHELL, SURFACE_MAPS } from '../../constants'; +import { band, flatPolygon, offsetPolyline, pinToAxis } from '../../geometry/flat'; +import { deckYAt, hullSectionAt, hullStationXs } from '../../geometry/hullLines'; +import type { HullSection, Pair } from '../../geometry/hullLines'; +import { gridSurface, mirrorZ, orientFrom } from '../../geometry/surface'; +import type { Uv, Vec3 } from '../../geometry/surface'; + +export type PointName = + 'keel' | 'intake' | 'chine' | 'flat' | 'knuckle' | 'sheer' | 'split' | 'panelEdge' | 'centre'; + +export type Look = 'side' | 'deck' | 'inner' | 'section'; +export type ShellGroup = 'hull' | 'chines' | 'sprayRails'; + +export interface ShellPiece { + geometry: BufferGeometry; + group: ShellGroup; + look: Look; + side: 'starboard' | 'portAft' | 'portFore' | 'opened'; +} + +type RowNames = readonly PointName[]; +type Keep = (row: number, x: number) => boolean; + +const HOLE_SEGMENTS = 20; + +export function pointOf(section: HullSection, name: PointName): Pair { + switch (name) { + case 'keel': + return [0, section.keel]; + case 'centre': + return [0, section.deck]; + default: + return section[name]; + } +} + +function gap(a: Pair, b: Pair): number { + return Math.hypot(a[0] - b[0], a[1] - b[1]); +} + +const SIDE_ORDER: readonly PointName[] = ['sheer', 'knuckle', 'flat', 'chine', 'intake', 'keel']; + +export function sideDepth(section: HullSection, name: PointName): number { + let depth = 0; + for (let at = 1; at < SIDE_ORDER.length; at += 1) { + depth += gap(pointOf(section, SIDE_ORDER[at - 1]), pointOf(section, SIDE_ORDER[at])); + if (SIDE_ORDER[at] === name) return depth; + } + return 0; +} + +export function alongHull(x: number): number { + return (x - TRANSOM_X) / BOAT.length; +} + +export function acrossDeck(z: number): number { + return (z + BOAT.beam / 2) / BOAT.beam; +} + +function sideUv(section: HullSection, name: PointName): Uv { + return [alongHull(section.x), sideDepth(section, name) / SURFACE_MAPS.side.span]; +} + +function deckUv(section: HullSection, name: PointName): Uv { + return [alongHull(section.x), acrossDeck(pointOf(section, name)[0])]; +} + +const inIntake = (x: number) => x > JET.intake.x[0] && x < JET.intake.x[1]; +const inFairing = (x: number) => x > FAIRING.x[0] && x < FAIRING.x[1]; + +function rowsOf( + sections: readonly HullSection[], + pick: (section: HullSection, row: number) => Pair, + count: number, +) { + return Array.from({ length: count }, (_, row) => + sections.map((section): Vec3 => { + const [z, y] = pick(section, row); + return [section.x, y, z]; + }), + ); +} + +function strip( + sections: readonly HullSection[], + names: RowNames, + look: 'side' | 'deck', + keep?: Keep, +): BufferGeometry { + const rows = rowsOf(sections, (section, row) => pointOf(section, names[row]), names.length); + const uv = look === 'side' ? sideUv : deckUv; + const geometry = gridSurface(rows, { + uv: (row, column) => uv(sections[column], names[row]), + keep: keep + ? (row, column) => keep(row, (sections[column].x + sections[column + 1].x) / 2) + : undefined, + }); + return orientFrom(geometry, SHELL.centre); +} + +interface StripSpec { + names: RowNames; + look: 'side' | 'deck'; + group: ShellGroup; + until?: number; + keep?: Keep; +} + +const OUTER_STRIPS: readonly StripSpec[] = [ + { + names: ['keel', 'intake', 'chine'], + look: 'side', + group: 'hull', + keep: (row, x) => row > 0 || !inIntake(x), + }, + { names: ['chine', 'flat'], look: 'side', group: 'chines', until: HULL_DETAIL.chineFlat.endX }, + { names: ['flat', 'knuckle'], look: 'side', group: 'hull' }, + { names: ['knuckle', 'sheer'], look: 'side', group: 'hull' }, + { + names: ['sheer', 'split', 'panelEdge'], + look: 'deck', + group: 'hull', + keep: (row, x) => row === 0 || !inFairing(x), + }, + { names: ['panelEdge', 'centre'], look: 'deck', group: 'hull', keep: (_, x) => !inFairing(x) }, +]; + +function sliceIndex(sections: readonly HullSection[], x: number): number { + const found = sections.findIndex((section) => section.x >= x - HULL_LINES.stations.tolerance); + return found < 0 ? sections.length - 1 : found; +} + +function sidePieces( + sections: readonly HullSection[], + build: (part: readonly HullSection[]) => BufferGeometry, + group: ShellGroup, + look: Look, +): ShellPiece[] { + const cut = sliceIndex(sections, HULL_LINES.cutX); + const pieces: ShellPiece[] = [ + { geometry: build(sections), group, look, side: 'starboard' }, + { geometry: mirrorZ(build(sections.slice(0, cut + 1))), group, look, side: 'portAft' }, + ]; + if (cut < sections.length - 1) { + const fore = sections.slice(cut); + pieces.push({ geometry: mirrorZ(build(fore)), group, look, side: 'portFore' }); + } + return pieces; +} + +function railRows(section: HullSection): Pair[] { + const { knuckle, sheer, rail } = section; + const share = rail / HULL_DETAIL.sprayRail.width; + const length = Math.max(gap(knuckle, sheer), HULL_LINES.epsilon); + const rise = SHELL.railRise * share; + return [ + knuckle, + [knuckle[0] + rail, knuckle[1]], + [ + knuckle[0] + ((sheer[0] - knuckle[0]) / length) * rise, + knuckle[1] + ((sheer[1] - knuckle[1]) / length) * rise, + ], + ]; +} + +function railStrip(sections: readonly HullSection[]): BufferGeometry { + const rows = rowsOf(sections, (section, row) => railRows(section)[row], SHELL.railUv.length); + const geometry = gridSurface(rows, { + uv: (row, column) => [alongHull(sections[column].x), SHELL.railUv[row]], + }); + return orientFrom(geometry, SHELL.centre); +} + +const INNER_NAMES: readonly PointName[] = [ + 'keel', + 'intake', + 'chine', + 'knuckle', + 'sheer', + 'split', + 'panelEdge', + 'centre', +]; + +export function innerProfile(section: HullSection, thickness = SHELL.thickness): Pair[] { + const outer = INNER_NAMES.map((name) => pointOf(section, name)); + const inner = offsetPolyline(outer, thickness); + const last = inner.length - 1; + inner[0] = pinToAxis(inner[0], [outer[1][0] - outer[0][0], outer[1][1] - outer[0][1]]); + inner[last] = pinToAxis(inner[last], [ + outer[last][0] - outer[last - 1][0], + outer[last][1] - outer[last - 1][1], + ]); + return inner; +} + +function innerStrip(sections: readonly HullSection[]): BufferGeometry { + const profiles = sections.map((section) => innerProfile(section)); + const rows = Array.from({ length: INNER_NAMES.length }, (_, row) => + sections.map((section, column): Vec3 => { + const [z, y] = profiles[column][row]; + return [section.x, y, z]; + }), + ); + const geometry = gridSurface(rows, { + keep: (row, column) => { + const x = (sections[column].x + sections[column + 1].x) / 2; + if (row === 0 && inIntake(x)) return false; + return row < INNER_NAMES.indexOf('split') || !inFairing(x); + }, + }); + return orientFrom(geometry, SHELL.centre, false); +} + +const atZero = (a: number, b: number): Vec3 => [a, b, 0]; + +function centreBands(sections: readonly HullSection[]): BufferGeometry[] { + const runs = (test: (x: number) => boolean) => { + const groups: HullSection[][] = []; + sections.forEach((section) => { + if (section.x > HULL_LINES.cutX + HULL_LINES.epsilon) return; + if (!test(section.x)) { + groups.push([]); + return; + } + if (groups.length === 0) groups.push([]); + groups[groups.length - 1].push(section); + }); + return groups.filter((group) => group.length > 1); + }; + const near = HULL_LINES.epsilon; + const keelRuns = runs((x) => x <= JET.intake.x[0] + near || x >= JET.intake.x[1] - near); + const deckRuns = runs((x) => x <= FAIRING.x[0] + near || x >= FAIRING.x[1] - near); + const keel = keelRuns.map((run) => + flatPolygon( + [ + ...run.map((section): Pair => [section.x, section.keel]), + ...run.map((section): Pair => [section.x, innerProfile(section)[0][1]]).reverse(), + ], + atZero, + ), + ); + const deck = deckRuns.map((run) => + flatPolygon( + [ + ...run.map((section): Pair => [section.x, section.deck]), + ...run + .map((section): Pair => [section.x, innerProfile(section)[INNER_NAMES.length - 1][1]]) + .reverse(), + ], + atZero, + ), + ); + return [...keel, ...deck].map((geometry) => orientFrom(geometry, SHELL.port)); +} + +function transomOutline(section: HullSection): Pair[] { + const outer = INNER_NAMES.slice(0, -1).map((name) => pointOf(section, name)); + outer.splice(3, 0, section.flat); + const radius = SHELL.jetHole; + const hole = Array.from({ length: HOLE_SEGMENTS + 1 }, (_, index): Pair => { + const angle = Math.PI / 2 - (index / HOLE_SEGMENTS) * Math.PI; + return [radius * Math.cos(angle), JET.axisY + radius * Math.sin(angle)]; + }); + return [...outer, [0, section.deck], ...hole]; +} + +function transomPieces(section: HullSection): ShellPiece[] { + const outline = transomOutline(section); + const uv = (z: number, y: number): Uv => [ + 0, + Math.max(0, deckYAt(TRANSOM_X, z) - y) / SURFACE_MAPS.side.span, + ]; + const at = + (x: number) => + (z: number, y: number): Vec3 => [x, y, z]; + const outer = orientFrom(flatPolygon(outline, at(TRANSOM_X), [], uv), SHELL.centre); + const inner = orientFrom( + flatPolygon(outline, at(TRANSOM_X + SHELL.thickness)), + SHELL.centre, + false, + ); + const [keel, , , , , , , , deckCentre] = outline; + const top = JET.axisY + SHELL.jetHole; + const bottom = JET.axisY - SHELL.jetHole; + const strip = (from: number, to: number): Pair[] => [ + [TRANSOM_X, from], + [TRANSOM_X + SHELL.thickness, from], + [TRANSOM_X + SHELL.thickness, to], + [TRANSOM_X, to], + ]; + const cut = [strip(keel[1], bottom), strip(top, deckCentre[1])].map((rectangle) => + orientFrom(flatPolygon(rectangle, atZero), SHELL.port), + ); + return [ + { geometry: outer, group: 'hull', look: 'side', side: 'starboard' }, + { geometry: mirrorZ(outer.clone()), group: 'hull', look: 'side', side: 'portAft' }, + { geometry: inner, group: 'hull', look: 'inner', side: 'opened' }, + ...cut.map((geometry): ShellPiece => ({ + geometry, + group: 'hull', + look: 'section', + side: 'opened', + })), + ]; +} + +function cutRing(section: HullSection): BufferGeometry { + const outer = [...INNER_NAMES.slice(0, 3), 'flat' as const, ...INNER_NAMES.slice(3)].map((name) => + pointOf(section, name), + ); + const inner = innerProfile(section); + const ring = [...outer, ...inner.reverse()].map(([z, y]): Pair => [-z, y]); + const place = (z: number, y: number): Vec3 => [section.x, y, z]; + return orientFrom(flatPolygon(ring, place), [section.x + 1, 0, 0]); +} + +export function buildHullShell(): ShellPiece[] { + const sections = hullStationXs().map(hullSectionAt); + const pieces: ShellPiece[] = OUTER_STRIPS.flatMap((spec) => { + const limited = spec.until + ? sections.filter((section) => section.x <= spec.until! + HULL_LINES.epsilon) + : sections; + return sidePieces( + limited, + (part) => strip(part, spec.names, spec.look, spec.keep), + spec.group, + spec.look, + ); + }); + const [railFrom, railTo] = HULL_DETAIL.sprayRail.x; + const railSections = sections.filter((section) => section.x >= railFrom && section.x <= railTo); + pieces.push(...sidePieces(railSections, railStrip, 'sprayRails', 'side')); + const cutIndex = sliceIndex(sections, HULL_LINES.cutX); + const aft = sections.slice(0, cutIndex + 1); + pieces.push( + { geometry: innerStrip(aft), group: 'hull', look: 'inner', side: 'opened' }, + ...centreBands(sections).map((geometry): ShellPiece => ({ + geometry, + group: 'hull', + look: 'section', + side: 'opened', + })), + { geometry: cutRing(sections[cutIndex]), group: 'hull', look: 'section', side: 'opened' }, + ...transomPieces(sections[0]), + ); + return pieces; +} + +export function fairingBandProfile(): Pair[] { + const [aft, fore] = FAIRING.x; + return [ + [aft, deckYAt(aft, 0)], + [aft, FAIRING.top], + [FAIRING.frontTopX, FAIRING.top], + [fore, deckYAt(fore, 0)], + ]; +} + +export function fairingCutBand(): BufferGeometry { + return orientFrom(flatPolygon(band(fairingBandProfile(), -SHELL.fairing), atZero), SHELL.port); +} diff --git a/explainers/naval-drone/src/scene/parts/boat/internals.ts b/explainers/naval-drone/src/scene/parts/boat/internals.ts new file mode 100644 index 00000000..2a3cb18a --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/boat/internals.ts @@ -0,0 +1,382 @@ +import { Group } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { anchorAt } from '@core/scene/parts'; +import { lerp } from '@core/math'; +import type { PartId } from '../../../ids'; +import { + ELECTRONICS, + ENGINE, + ENGINE_TUB, + FUEL_TANKS, + JET, + PAYLOAD_BAY, +} from '../../../model/layout'; +import { roundedRectShape, extrudeProfileAlongX } from '@core/scene/geometry/extrude'; +import { INTERNALS, SHELL } from '../../constants'; +import { FINISHES } from '../../finishes'; +import { spread } from '../../geometry/curves'; +import { fittedSolid, tubeAlong } from '../../geometry/fitted'; +import type { FittedBox } from '../../geometry/fitted'; +import { flatPolygon, offsetPolyline, pinToAxis } from '../../geometry/flat'; +import { bottomYAt, hullSectionAt } from '../../geometry/hullLines'; +import type { Pair } from '../../geometry/hullLines'; +import { boxBetween, rod } from '../../geometry/solids'; +import { gridSurface, orientFrom } from '../../geometry/surface'; +import type { Vec3 } from '../../geometry/surface'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; +import { innerProfile } from './hullShell'; + +export interface Internals { + object: Group; + labels: ReadonlyMap; +} + +type InternalPart = 'payloadBay' | 'fuelTanks' | 'engine' | 'electronicsBay'; + +const middle = ([a, b]: readonly [number, number]) => (a + b) / 2; + +function roundedBlock(part: { + x: readonly [number, number]; + halfWidth: number; + y: readonly [number, number]; +}) { + const shape = roundedRectShape( + { minA: -part.halfWidth, maxA: part.halfWidth, minB: part.y[0], maxB: part.y[1] }, + INTERNALS.engine.rounding, + ); + return extrudeProfileAlongX(shape, part.x[0], part.x[1]); +} + +function payloadBay(): BufferGeometry { + const { box } = PAYLOAD_BAY; + const fitted: FittedBox = { + x: box.x, + z: (x) => { + const share = (x - box.x[0]) / (box.x[1] - box.x[0]); + const half = lerp(box.halfWidth, INTERNALS.bay.foreHalfWidth, share); + return [-half, half]; + }, + y: box.y, + clearance: INTERNALS.clearance, + }; + return fittedSolid(fitted, INTERNALS.bay.samples, false); +} + +function bulkheads(): BufferGeometry { + return mergeParts( + PAYLOAD_BAY.bulkheads.map((x) => { + const inner = innerProfile(hullSectionAt(x)); + const outline: Pair[] = [...inner, [0, inner[inner.length - 1][1]]]; + return flatPolygon(outline, (z, y) => [x, y, z]); + }), + ); +} + +function tanks(): { shells: BufferGeometry; petrol: BufferGeometry; necks: BufferGeometry } { + const half = FUEL_TANKS.width / 2; + const level = lerp(FUEL_TANKS.y[0], FUEL_TANKS.y[1], INTERNALS.tank.fill); + const box = (side: number, top: number): FittedBox => ({ + x: FUEL_TANKS.x, + z: () => { + const centre = side * FUEL_TANKS.centreZ; + return [centre - half, centre + half]; + }, + y: [FUEL_TANKS.y[0], top], + clearance: INTERNALS.clearance, + }); + const inset = (fitted: FittedBox): FittedBox => ({ + ...fitted, + x: [fitted.x[0] + SHELL.thickness, fitted.x[1] - SHELL.thickness], + z: (x) => { + const [a, b] = fitted.z(x); + return [a + SHELL.thickness, b - SHELL.thickness]; + }, + clearance: fitted.clearance + SHELL.thickness, + }); + const sides = [-1, 1]; + const { neck } = INTERNALS.tank; + const deck = hullSectionAt(neck.x).deck - SHELL.thickness; + const necks = sides.flatMap((side) => { + const height = deck - FUEL_TANKS.y[1]; + return [ + rod('y', neck.radius, height, [neck.x, FUEL_TANKS.y[1] + height / 2, side * neck.z]), + rod('y', neck.cap, neck.radius, [neck.x, deck - neck.radius / 2, side * neck.z]), + ]; + }); + return { + shells: mergeParts( + sides.map((side) => fittedSolid(box(side, FUEL_TANKS.y[1]), INTERNALS.tank.samples)), + ), + petrol: mergeParts( + sides.map((side) => fittedSolid(inset(box(side, level)), INTERNALS.tank.samples)), + ), + necks: mergeParts(necks), + }; +} + +function tubSection(): { outer: Pair[]; inner: Pair[] } { + const x = middle(ENGINE_TUB.x); + const floor = (z: number) => bottomYAt(x, z) + INTERNALS.tub.floorLift; + const zs = spread(0, ENGINE_TUB.halfWidth, 4); + const outer: Pair[] = [ + ...zs.map((z): Pair => [z, floor(z)]), + [ENGINE_TUB.halfWidth, ENGINE_TUB.y[1]], + ]; + const inner = offsetPolyline(outer, INTERNALS.tub.wall); + inner[0] = pinToAxis(inner[0], [outer[1][0] - outer[0][0], outer[1][1] - outer[0][1]]); + return { outer, inner }; +} + +function extrudeAlongX(points: readonly Pair[], x: readonly [number, number]): BufferGeometry { + return gridSurface(points.map(([z, y]) => [[x[0], y, z] as Vec3, [x[1], y, z] as Vec3])); +} + +function tub(): { metal: BufferGeometry; cut: BufferGeometry } { + const { outer, inner } = tubSection(); + const ends = ENGINE_TUB.x.map((x) => + flatPolygon([...outer, [0, ENGINE_TUB.y[1]]], (z, y) => [x, y, z]), + ); + const rim = gridSurface([ + [ + [ENGINE_TUB.x[0], ENGINE_TUB.y[1], outer[outer.length - 1][0]], + [ENGINE_TUB.x[1], ENGINE_TUB.y[1], outer[outer.length - 1][0]], + ], + [ + [ENGINE_TUB.x[0], ENGINE_TUB.y[1], inner[inner.length - 1][0]], + [ENGINE_TUB.x[1], ENGINE_TUB.y[1], inner[inner.length - 1][0]], + ], + ]); + const cut = flatPolygon( + [ + [ENGINE_TUB.x[0], outer[0][1]], + [ENGINE_TUB.x[1], outer[0][1]], + [ENGINE_TUB.x[1], inner[0][1]], + [ENGINE_TUB.x[0], inner[0][1]], + ], + (a, b) => [a, b, 0], + ); + return { + metal: mergeParts([ + extrudeAlongX(outer, ENGINE_TUB.x), + extrudeAlongX(inner, ENGINE_TUB.x), + rim, + ...ends, + ]), + cut: orientFrom(cut, SHELL.port), + }; +} + +function foam(): BufferGeometry { + const from = Math.max(ENGINE_TUB.x[0], JET.intake.x[1] + INTERNALS.clearance); + const x: readonly [number, number] = [from, ENGINE_TUB.x[1]]; + const section = hullSectionAt(middle(ENGINE_TUB.x)); + const [keel, intake, chine, knuckle] = innerProfile(section); + const top = INTERNALS.tub.foamTop; + const share = (top - chine[1]) / (knuckle[1] - chine[1]); + const side: Pair = [lerp(chine[0], knuckle[0], share), top]; + const { outer } = tubSection(); + const wallZ = ENGINE_TUB.halfWidth; + const outline: Pair[] = [ + keel, + intake, + chine, + side, + [wallZ, top], + ...[...outer.slice(0, -1)].reverse(), + ]; + const caps = x.map((at) => flatPolygon(outline, (z, y) => [at, y, z])); + const surface = gridSurface([ + [ + [x[0], top, wallZ], + [x[1], top, wallZ], + ], + [ + [x[0], top, side[0]], + [x[1], top, side[0]], + ], + ]); + const cut = flatPolygon( + [ + [x[0], keel[1]], + [x[1], keel[1]], + [x[1], outer[0][1]], + [x[0], outer[0][1]], + ], + (a, b) => [a, b, 0], + ); + return mergeParts([...caps, surface, cut]); +} + +function engineBody(): { + block: BufferGeometry; + dark: BufferGeometry; + manifold: BufferGeometry; + exhaust: BufferGeometry; + rubber: BufferGeometry; +} { + const spec = INTERNALS.engine; + const detail = INTERNALS.detail; + const coils = spec.coilXs.map((x) => + boxBetween( + [x - spec.coil[0] / 2, x + spec.coil[0] / 2], + [spec.cover.y[1], spec.cover.y[1] + spec.coil[2]], + [-spec.coil[1] / 2, spec.coil[1] / 2], + ), + ); + const { plenum, runner, throttle } = spec; + const runners = cylinderXs().map((x) => + tubeAlong( + [ + [x, runner.from[1], runner.from[0]], + [x, plenum.y + detail.runnerRise, (runner.from[0] + plenum.z) / 2], + [x, plenum.y, plenum.z], + ], + runner.radius, + ), + ); + const manifold = mergeParts([ + rod('x', plenum.radius, plenum.x[1] - plenum.x[0], [middle(plenum.x), plenum.y, plenum.z]), + rod('x', throttle.radius, throttle.length, [ + plenum.x[0] - throttle.length / 2, + plenum.y, + plenum.z, + ]), + ...runners, + ]); + const { exhaust, muffler } = spec; + const { routes } = INTERNALS; + const [collectorX, collectorY, collectorZ] = exhaust.collector; + const headers = cylinderXs().map((x) => + tubeAlong( + [ + ...routes.headers.map(([, y, z]): Vec3 => [x, y, z]), + [lerp(x, collectorX, detail.headerShare), collectorY, collectorZ], + ], + exhaust.radius, + ), + ); + const pipeRadius = exhaust.radius * detail.exhaustGrow; + const [mufflerX, mufflerY, mufflerZ] = muffler.centre; + const pipe = tubeAlong( + [ + [collectorX, collectorY, collectorZ], + routes.pipe, + [mufflerX + muffler.length / 2, mufflerY, mufflerZ], + ], + pipeRadius, + ); + const tail = tubeAlong( + [[mufflerX - muffler.length / 2, mufflerY, mufflerZ], routes.tail, exhaust.outlet], + pipeRadius, + ); + const { flange, mounts } = spec; + const block = mergeParts([ + roundedBlock(spec.sump), + roundedBlock(spec.block), + roundedBlock(spec.head), + rod('x', flange.radius, flange.length, [ENGINE.x[0] - flange.length / 2, JET.axisY, 0]), + ]); + const dark = mergeParts([roundedBlock(spec.cover), ...coils]); + const rubber = mergeParts( + mounts.xs.flatMap((x) => + [-1, 1].map((side) => { + const z = side * mounts.z; + const floor = bottomYAt(x, z) + INTERNALS.tub.floorLift; + return boxBetween( + [x - mounts.size[0] / 2, x + mounts.size[0] / 2], + [floor, floor + mounts.size[1] + detail.mountRise], + [z - mounts.size[2] / 2, z + mounts.size[2] / 2], + ); + }), + ), + ); + return { + block, + dark, + manifold, + exhaust: mergeParts([ + ...headers, + pipe, + tail, + rod('x', muffler.radius, muffler.length, muffler.centre), + ]), + rubber, + }; +} + +function cylinderXs(): number[] { + return [...INTERNALS.engine.coilXs]; +} + +function electronics(): { tray: BufferGeometry; dark: BufferGeometry; light: BufferGeometry } { + const { tray, electronics: items } = INTERNALS; + const [x0, x1] = ELECTRONICS.x; + const half = ELECTRONICS.halfWidth; + const base = ELECTRONICS.y[0]; + const top = base + tray.thickness; + const { computer, router, power, puck, canister } = items; + const box = (part: { + x: readonly [number, number]; + z: readonly [number, number]; + height: number; + }) => boxBetween(part.x, [top, top + part.height], part.z); + return { + tray: boxBetween([x0, x1], [base, top], [-half, half]), + dark: mergeParts([ + box(computer), + box(power), + rod('y', puck.radius, puck.height, [puck.x, top + puck.height / 2, puck.z]), + rod('x', canister.radius, canister.x[1] - canister.x[0], [ + middle(canister.x), + top + canister.radius, + canister.z, + ]), + ]), + light: box(router), + }; +} + +function cables(): BufferGeometry { + const { radius, sag } = INTERNALS.cable; + return mergeParts( + INTERNALS.routes.panels.map(({ from, to }) => + tubeAlong([from, [(from[0] + to[0]) / 2, to[1] - sag, (from[2] + to[2]) / 2], to], radius), + ), + ); +} + +export function buildInternals(context: PartContext): Internals { + const object = new Group(); + const fuel = tanks(); + const tubParts = tub(); + const engine = engineBody(); + const bay = electronics(); + object.add( + partMesh(context, payloadBay(), 'payloadBay', FINISHES.bay), + partMesh(context, bulkheads(), 'payloadBay', FINISHES.bulkhead), + partMesh(context, fuel.petrol, 'fuelTanks', FINISHES.petrol), + partMesh(context, fuel.shells, 'fuelTanks', FINISHES.tank), + partMesh(context, fuel.necks, 'fuelTanks', FINISHES.hose), + partMesh(context, tubParts.metal, 'engine', FINISHES.tub), + partMesh(context, tubParts.cut, 'engine', FINISHES.section), + partMesh(context, foam(), 'engine', FINISHES.foam), + partMesh(context, engine.block, 'engine', FINISHES.block), + partMesh(context, engine.dark, 'engine', FINISHES.head), + partMesh(context, engine.manifold, 'engine', FINISHES.manifold), + partMesh(context, engine.exhaust, 'engine', FINISHES.exhaust), + partMesh(context, engine.rubber, 'engine', FINISHES.rubber), + partMesh(context, bay.tray, 'electronicsBay', FINISHES.tub), + partMesh(context, bay.dark, 'electronicsBay', FINISHES.box), + partMesh(context, bay.light, 'electronicsBay', FINISHES.router), + partMesh(context, cables(), 'electronicsBay', FINISHES.cable), + ); + const at = (x: number, y: number, z: number) => anchorAt(object, x, y, z); + const labels = new Map([ + ['payloadBay', at(middle(PAYLOAD_BAY.box.x), PAYLOAD_BAY.box.y[1], 0)], + ['fuelTanks', at(middle(FUEL_TANKS.x), FUEL_TANKS.y[1], -FUEL_TANKS.centreZ)], + ['engine', at(middle(ENGINE.x), ENGINE.y[1], 0)], + ['electronicsBay', at(middle(ELECTRONICS.x), ELECTRONICS.y[1], 0)], + ]); + return { object, labels }; +} diff --git a/explainers/naval-drone/src/scene/parts/boat/missiles.ts b/explainers/naval-drone/src/scene/parts/boat/missiles.ts new file mode 100644 index 00000000..f3f1ae3c --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/boat/missiles.ts @@ -0,0 +1,98 @@ +import { BoxGeometry, Group } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { MISSILE_FIT } from '../../../model/layout'; +import { MISSILE_SHAPE } from '../../constants'; +import { FINISHES } from '../../finishes'; +import { deckYAt } from '../../geometry/hullLines'; +import { lathe } from '../../geometry/solids'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +export interface MissileFit { + object: Group; + anchor: Object3D; +} + +const QUARTER_TURN = Math.PI / 2; +const FIN_COUNT = 4; + +function finSet(fins: typeof MISSILE_SHAPE.fins, radius: number): BufferGeometry[] { + return Array.from({ length: FIN_COUNT }, (_, index) => { + const fin = new BoxGeometry(fins.root, fins.span, fins.thickness); + const position = fin.getAttribute('position'); + for (let at = 0; at < position.count; at += 1) { + if (position.getY(at) > 0) { + position.setX(at, position.getX(at) * (fins.tip / fins.root) + (fins.root - fins.tip) / 2); + } + } + fin.computeVertexNormals(); + fin.translate(-(fins.from + fins.root / 2), radius + fins.span / 2, 0); + fin.rotateX(QUARTER_TURN / 2 + index * QUARTER_TURN); + return fin; + }); +} + +function turnedBody(points: readonly (readonly [number, number])[], grow: number): BufferGeometry { + return lathe( + points.map(([along, radius]) => [radius * grow, along]), + MISSILE_SHAPE.segments, + ).rotateZ(QUARTER_TURN); +} + +function missile(): { body: BufferGeometry; seeker: BufferGeometry } { + const { profile, fins, seekerGrow } = MISSILE_SHAPE; + const radius = MISSILE_FIT.missile.diameter / 2; + return { + body: mergeParts([turnedBody(profile, 1), ...finSet(fins, radius)]), + seeker: turnedBody(profile.slice(0, 2), seekerGrow), + }; +} + +export function buildMissileFit(context: PartContext): MissileFit { + const object = new Group(); + const { rail, pylons, pylonLength, pylonWidth, hanger } = MISSILE_SHAPE; + const [aftX, foreX] = MISSILE_FIT.x; + const slope = Math.tan(MISSILE_FIT.raise); + const railTopAt = (x: number) => MISSILE_FIT.railTop + (x - aftX) * slope; + const { body, seeker } = missile(); + const missileLength = MISSILE_FIT.missile.length; + const radius = MISSILE_FIT.missile.diameter / 2; + [-1, 1].forEach((side) => { + const z = side * MISSILE_FIT.railZ; + const group = new Group(); + const railBar = new BoxGeometry(rail.length, rail.height, rail.width); + railBar.translate(rail.length / 2, -rail.height / 2, 0); + const mount = new Group(); + mount.position.set(aftX, MISSILE_FIT.railTop, z); + mount.rotation.z = MISSILE_FIT.raise; + const missileBody = body.clone(); + missileBody.translate((rail.length + missileLength) / 2, radius + hanger, 0); + const missileTip = seeker.clone(); + missileTip.translate((rail.length + missileLength) / 2, radius + hanger, 0); + mount.add( + partMesh(context, railBar, 'missileRails', FINISHES.rail), + partMesh(context, missileBody, 'missileRails', FINISHES.missile), + partMesh(context, missileTip, 'missileRails', FINISHES.glass), + ); + const posts = pylons.map((x) => { + const top = railTopAt(x) - rail.height; + const bottom = deckYAt(x, z) - hanger; + const post = new BoxGeometry(pylonLength, top - bottom, pylonWidth); + post.translate(x, (top + bottom) / 2, z); + return post; + }); + group.add(mount, partMesh(context, mergeParts(posts), 'missileRails', FINISHES.rail)); + object.add(group); + }); + body.dispose(); + seeker.dispose(); + const anchor = new Group(); + anchor.position.set( + (aftX + foreX) / 2, + railTopAt((aftX + foreX) / 2) + MISSILE_FIT.missile.diameter, + -MISSILE_FIT.railZ, + ); + object.add(anchor); + object.visible = false; + return { object, anchor }; +} diff --git a/explainers/naval-drone/src/scene/parts/boat/waterjet.ts b/explainers/naval-drone/src/scene/parts/boat/waterjet.ts new file mode 100644 index 00000000..a6668123 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/boat/waterjet.ts @@ -0,0 +1,489 @@ +import { + DoubleSide, + Group, + Matrix4, + Mesh, + MeshStandardMaterial, + Quaternion, + RingGeometry, + Vector3, +} from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { lerp } from '@core/math'; +import { anchorAt } from '@core/scene/parts'; +import type { PartId } from '../../../ids'; +import { IMPELLER, JET } from '../../../model/layout'; +import { JET_SHAPE } from '../../constants'; +import { FINISHES, PAINT } from '../../finishes'; +import { spread } from '../../geometry/curves'; +import { flatPolygon } from '../../geometry/flat'; +import { bottomYAt } from '../../geometry/hullLines'; +import type { Pair } from '../../geometry/hullLines'; +import { thickSheet, turned, turnedCaps } from '../../geometry/sheet'; +import type { Arc, ProfilePoint } from '../../geometry/sheet'; +import { boxAt, rod } from '../../geometry/solids'; +import { gridSurface, orientFrom } from '../../geometry/surface'; +import type { Vec3 } from '../../geometry/surface'; +import { instanced, mergeParts, partMesh, registered } from '../context'; +import type { PartContext } from '../context'; +import type { CutawaySwitch } from './cutaway'; + +export interface Waterjet { + object: Group; + impeller: Group; + blur: Mesh; + steering: Group; + bucket: Group; + stern: Object3D; + labels: ReadonlyMap; + flowPath: readonly Vec3[]; +} + +const QUARTER_TURN = Math.PI / 2; +const FULL_TURN = Math.PI * 2; +const AXIS = JET.axisY; +const BOLT_SIDES = 6; + +function sgnPow(value: number, exponent: number): number { + return Math.sign(value) * Math.abs(value) ** exponent; +} + +function atAxis(geometry: BufferGeometry): BufferGeometry { + geometry.translate(0, AXIS, 0); + return geometry; +} + +function housingProfile(): ProfilePoint[] { + const { ringRadius, flange, bowl, aftFace } = JET_SHAPE.housing; + const [aftX] = JET.housing.x; + const [, foreX] = JET.housing.x; + const bowlPoints = [...bowl] + .reverse() + .map(([x, r], index): ProfilePoint => [x, r, index > 0 && index < bowl.length - 1]); + return [ + [aftX, aftFace], + ...bowlPoints, + [flange.aft, flange.radius], + [JET.stator.x[1], flange.radius], + [JET.stator.x[1], ringRadius], + [foreX, ringRadius], + [foreX, JET_SHAPE.bore], + [aftX, JET_SHAPE.bore], + [aftX, aftFace], + ]; +} + +function nozzleProfile(): ProfilePoint[] { + const [exitX, inletX] = JET.nozzle.x; + const wall = JET_SHAPE.nozzle.wall; + const inlet = JET.nozzle.inletDiameter / 2; + const exit = JET.nozzle.exitDiameter / 2; + return [ + [exitX, exit], + [inletX, inlet], + [inletX, inlet - wall], + [exitX, exit - wall], + [exitX, exit], + ]; +} + +function steeringProfile(): ProfilePoint[] { + const { outer, inner, sleeve, sleeveLength } = JET_SHAPE.steering; + const length = JET.steeringNozzle.x[1] - JET.steeringNozzle.x[0]; + return [ + [-length, outer], + [0, outer], + [0, sleeve], + [sleeveLength, sleeve], + [sleeveLength, inner + JET_SHAPE.nozzle.wall / 2], + [-length, inner], + [-length, outer], + ]; +} + +function halves( + context: PartContext, + cutaway: CutawaySwitch, + profile: readonly ProfilePoint[], + group: PartId, + parent: Group, +): void { + const piece = (arc: Arc) => atAxis(turned(profile, JET_SHAPE.segments / 2, arc)); + parent.add( + partMesh(context, piece('starboard'), group, FINISHES.jetBlack), + cutaway.whole(partMesh(context, piece('port'), group, FINISHES.jetBlack)), + cutaway.opened(partMesh(context, atAxis(turnedCaps(profile)), group, FINISHES.section)), + ); +} + +function flangeBolts(context: PartContext): Mesh { + const { flange } = JET_SHAPE.housing; + const head = rod('x', flange.bolt, flange.boltHead, [0, 0, 0], BOLT_SIDES); + const matrices = spread(0, FULL_TURN, flange.bolts) + .slice(0, -1) + .map((angle) => + new Matrix4().makeTranslation( + flange.aft - flange.boltHead / 2, + AXIS + flange.boltRadius * Math.sin(angle), + flange.boltRadius * Math.cos(angle), + ), + ); + return instanced(context, head, 'waterjet', FINISHES.steel, matrices); +} + +function bladeGrid(index: number): Vec3[][] { + const { lead, chord, wrap, sweep, clearance, radial, along } = JET_SHAPE.impeller; + const hub = IMPELLER.hubRadius; + const tip = IMPELLER.diameter / 2 - clearance; + const base = (index / IMPELLER.blades) * FULL_TURN; + return spread(hub * 0.92, tip, radial).map((radius) => { + const share = (radius - hub) / (tip - hub); + const trim = Math.max(share, 0) ** 2 * 0.22; + return spread(trim, 1, along).map((s): Vec3 => { + const angle = base + sweep * Math.max(share, 0) + s * wrap; + return [lead - s * chord, -radius * Math.sin(angle), radius * Math.cos(angle)]; + }); + }); +} + +function impeller(context: PartContext): { group: Group; blur: Mesh } { + const hubProfile: ProfilePoint[] = [ + [JET_SHAPE.impeller.hub[JET_SHAPE.impeller.hub.length - 1][0], 0], + ...[...JET_SHAPE.impeller.hub].reverse().map(([x, r]): ProfilePoint => [x, r, true]), + [JET_SHAPE.impeller.hub[0][0], 0], + ]; + const hub = turned(hubProfile, JET_SHAPE.segments); + const blades = Array.from({ length: IMPELLER.blades }, (_, index) => + thickSheet(bladeGrid(index), JET_SHAPE.impeller.thickness), + ); + const group = new Group(); + group.position.set(0, AXIS, 0); + group.add(partMesh(context, mergeParts([hub, ...blades]), 'impeller', FINISHES.steelTwoSided)); + const disc = new RingGeometry(IMPELLER.hubRadius, IMPELLER.diameter / 2, JET_SHAPE.segments); + disc.rotateY(QUARTER_TURN); + disc.translate(IMPELLER.x, AXIS, 0); + const blurMaterial = registered( + context, + 'impeller', + new MeshStandardMaterial({ + color: PAINT.steel, + metalness: 0.8, + roughness: 0.35, + transparent: true, + opacity: JET_SHAPE.impeller.blurOpacity, + depthWrite: false, + side: DoubleSide, + }), + ); + const blur = new Mesh(context.tracker.track(disc), blurMaterial); + blur.visible = false; + return { group, blur }; +} + +function stator(context: PartContext): Mesh { + const { cone, lead, trail, curl, thickness, radial, along } = JET_SHAPE.stator; + const coneProfile: ProfilePoint[] = [ + [cone[cone.length - 1][0], 0], + ...[...cone].reverse().map(([x, r]): ProfilePoint => [x, r, true]), + [cone[0][0], 0], + ]; + const coneRadius = (x: number) => { + const index = cone.findIndex(([at]) => at <= x); + if (index <= 0) return cone[0][1]; + const [a, b] = [cone[index - 1], cone[index]]; + return lerp(a[1], b[1], (x - a[0]) / (b[0] - a[0])); + }; + const vanes = Array.from({ length: IMPELLER.statorVanes }, (_, index) => { + const base = (index / IMPELLER.statorVanes) * FULL_TURN; + const grid = spread(0, 1, radial).map((share) => + spread(0, 1, along).map((s): Vec3 => { + const x = lerp(lead, trail, s); + const radius = lerp(coneRadius(x) * 0.95, JET_SHAPE.bore - 0.001, share); + const angle = base + curl * (1 - s) ** 2; + return [x, AXIS - radius * Math.sin(angle), radius * Math.cos(angle)]; + }), + ); + return thickSheet(grid, thickness); + }); + return partMesh( + context, + mergeParts([atAxis(turned(coneProfile, JET_SHAPE.segments / 2)), ...vanes]), + 'stator', + FINISHES.stator, + ); +} + +function grate(context: PartContext): Mesh { + const { width, height } = JET_SHAPE.grate; + const [aft, fore] = JET.intake.x; + const half = JET.intake.halfWidth; + const middle = (aft + fore) / 2; + const bars = spread(-half * 0.8, half * 0.8, JET.intake.bars - 1).map((z) => + boxAt([fore - aft, height, width], [middle, bottomYAt(middle, z) + height / 2, z]), + ); + return partMesh(context, mergeParts(bars), 'intake', FINISHES.steel); +} + +function bezier(a: Vec3, b: Vec3, c: Vec3, d: Vec3, t: number): Vec3 { + const u = 1 - t; + const w = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t]; + return [0, 1, 2].map( + (axis) => w[0] * a[axis] + w[1] * b[axis] + w[2] * c[axis] + w[3] * d[axis], + ) as unknown as Vec3; +} + +function ductPoint(theta: number, t: number, grow: number): Vec3 { + const { squareness, entry, pull } = JET_SHAPE.duct; + const [aft, fore] = JET.intake.x; + const middle = (aft + fore) / 2; + const s = Math.sin(theta); + const c = Math.cos(theta); + const z = (JET.intake.halfWidth + grow) * sgnPow(s, squareness); + const x = middle + ((fore - aft) / 2 + grow) * sgnPow(c, squareness); + const start: Vec3 = [x, bottomYAt(x, z), z]; + const radius = JET.duct.innerDiameter / 2 + grow; + const end: Vec3 = [JET.duct.endX, AXIS + radius * c, radius * s]; + const span = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]); + const slope = lerp(entry[0], entry[1], (1 - c) / 2); + const length = Math.hypot(1, slope); + const lift: Vec3 = [ + start[0] - (span * pull[0]) / length, + start[1] + (span * pull[0] * slope) / length, + start[2], + ]; + const ease: Vec3 = [end[0] + span * pull[1], end[1], end[2]]; + return bezier(start, lift, ease, end, t); +} + +function ductSurface(from: number, to: number, grow: number): BufferGeometry { + const { rings, around } = JET_SHAPE.duct; + const thetas = spread(from, to, around / 2); + const rows = thetas.map((theta) => spread(0, 1, rings).map((t) => ductPoint(theta, t, grow))); + return gridSurface(rows); +} + +function ductCut(theta: number): BufferGeometry { + const { rings, wall } = JET_SHAPE.duct; + const ts = spread(0, 1, rings); + const inner = ts.map((t): Pair => { + const [x, y] = ductPoint(theta, t, 0); + return [x, y]; + }); + const outer = ts.map((t): Pair => { + const [x, y] = ductPoint(theta, t, wall); + return [x, y]; + }); + return flatPolygon([...inner, ...outer.reverse()], (a, b) => [a, b, 0]); +} + +function duct(context: PartContext, cutaway: CutawaySwitch): Group { + const wall = JET_SHAPE.duct.wall; + const group = new Group(); + const half = (from: number, to: number) => ({ + inner: ductSurface(from, to, 0), + outer: ductSurface(from, to, wall), + }); + const starboard = half(0, Math.PI); + const port = half(Math.PI, FULL_TURN); + group.add( + partMesh(context, starboard.inner, 'duct', FINISHES.jetInside), + partMesh(context, starboard.outer, 'duct', FINISHES.inner), + cutaway.whole(partMesh(context, port.inner, 'duct', FINISHES.jetInside)), + cutaway.whole(partMesh(context, port.outer, 'duct', FINISHES.inner)), + cutaway.opened( + partMesh(context, mergeParts([ductCut(0), ductCut(Math.PI)]), 'duct', FINISHES.section), + ), + ); + return group; +} + +export function ductCentreline(): Vec3[] { + const thetas = spread(0, FULL_TURN, JET_SHAPE.duct.around).slice(0, -1); + return spread(0, 1, JET_SHAPE.duct.rings).map((t) => { + const points = thetas.map((theta) => ductPoint(theta, t, 0)); + return [0, 1, 2].map( + (axis) => points.reduce((sum, point) => sum + point[axis], 0) / points.length, + ) as unknown as Vec3; + }); +} + +function shaft(context: PartContext): Mesh { + const { collar, collarLength, coupler, couplerLength } = JET_SHAPE.shaft; + const [from, to] = JET.shaft.x; + const along = (radius: number, length: number, centre: number) => + rod('x', radius, length, [centre, AXIS, 0]); + return partMesh( + context, + mergeParts([ + along(JET.shaft.radius, to - from, (from + to) / 2), + along(collar, collarLength, JET.shaft.entryX), + along(coupler, couplerLength, to - couplerLength / 2), + ]), + 'driveShaft', + FINISHES.steel, + ); +} + +function steering(context: PartContext): { group: Group; stern: Object3D } { + const { sleeve, pin, arm } = JET_SHAPE.steering; + const group = new Group(); + group.position.set(JET.steeringNozzle.pivotX, AXIS, 0); + const pins = [-1, 1].map((side) => + rod('y', pin.radius, pin.height, [0, side * (sleeve + pin.height / 2), 0]), + ); + const lever = boxAt( + [arm.width, arm.thickness, arm.length], + [0, sleeve + pin.height - arm.thickness / 2, arm.length / 2], + ); + const knob = rod('y', arm.ball, arm.thickness * 2, [0, sleeve + pin.height, arm.length]); + group.add( + partMesh( + context, + turned(steeringProfile(), JET_SHAPE.segments), + 'steeringNozzle', + FINISHES.jetBlack, + ), + partMesh(context, mergeParts([...pins, lever, knob]), 'steeringNozzle', FINISHES.steel), + ); + const stern = new Group(); + stern.position.set(JET.steeringNozzle.x[0] - JET.steeringNozzle.pivotX, 0, 0); + group.add(stern); + return { group, stern }; +} + +function barBetween(from: Vec3, to: Vec3, width: number, thickness: number): BufferGeometry { + const start = new Vector3(...from); + const direction = new Vector3(...to).sub(start); + const bar = boxAt([direction.length(), width, thickness], [0, 0, 0]); + bar.applyQuaternion( + new Quaternion().setFromUnitVectors(new Vector3(1, 0, 0), direction.clone().normalize()), + ); + bar.translate(...start.add(direction.multiplyScalar(0.5)).toArray()); + return bar; +} + +function bucket(context: PartContext): { group: Group; brackets: Mesh } { + const { radius, arc, centre, thickness, arm, boss, bracket, samples } = JET_SHAPE.bucket; + const [pivotX, pivotY] = JET.bucket.pivot; + const halfWidth = JET.bucket.width / 2; + const cx = centre[0] - pivotX; + const cy = centre[1] - pivotY; + const hood = thickSheet( + spread(-halfWidth, halfWidth, samples[1]).map((z) => + spread(arc[0], arc[1], samples[0]).map((angle): Vec3 => [ + cx + radius * Math.cos(angle), + cy + radius * Math.sin(angle), + z, + ]), + ), + thickness, + ); + const splitterArc = spread(arc[0] + 0.5, arc[1] - 0.25, samples[0]).map((angle): Pair => [ + cx + radius * 0.97 * Math.cos(angle), + cy + radius * 0.97 * Math.sin(angle), + ]); + const splitter = flatPolygon([[cx + radius * 0.35, cy], ...splitterArc], (a, b) => [a, b, 0]); + const armEnd = (side: number): Vec3 => [ + cx + radius * Math.cos(arc[0] + 0.45), + cy + radius * Math.sin(arc[0] + 0.45), + side * (halfWidth + arm.thickness / 2), + ]; + const arms = [-1, 1].map((side) => + barBetween( + [0, 0, side * (halfWidth + arm.thickness / 2)], + armEnd(side), + arm.width, + arm.thickness, + ), + ); + const pin = rod('z', boss, JET.bucket.width + 4 * arm.thickness, [0, 0, 0]); + const group = new Group(); + group.position.set(pivotX, pivotY, 0); + group.add( + partMesh(context, mergeParts([hood, splitter]), 'reverseBucket', FINISHES.jetTwoSided), + partMesh(context, mergeParts([...arms, pin]), 'reverseBucket', FINISHES.steel), + ); + const plate = (side: number) => { + const outline: Pair[] = [ + [bracket.x[1], AXIS + JET_SHAPE.housing.aftFace * 0.4], + [bracket.x[1], bracket.bottom], + [bracket.x[0], bracket.bottom], + [pivotX - boss, pivotY + boss], + [pivotX + boss, pivotY + boss], + ]; + const z = side * (halfWidth + 2.5 * arm.thickness); + return mergeParts( + [-1, 1].map((face) => + orientFrom( + flatPolygon(outline, (a, b) => [a, b, z + (face * bracket.thickness) / 2]), + [pivotX, pivotY, z - face], + ), + ), + ); + }; + const brackets = partMesh( + context, + mergeParts([plate(-1), plate(1)]), + 'reverseBucket', + FINISHES.jetTwoSided, + ); + return { group, brackets }; +} + +function labelSpots( + object: Group, + steeringGroup: Group, + bucketGroup: Group, +): Map { + const at = anchorAt; + const middle = ([a, b]: readonly [number, number]) => (a + b) / 2; + const [pivotX, pivotY] = JET.bucket.pivot; + const [cx, cy] = JET_SHAPE.bucket.centre; + return new Map([ + ['intake', at(object, middle(JET.intake.x), JET.intake.y, 0)], + ['duct', at(object, (JET.intake.x[0] + JET.duct.endX) / 2, (JET.intake.y + AXIS) / 2, 0)], + ['driveShaft', at(object, middle(JET.shaft.x), AXIS, 0)], + ['impeller', at(object, IMPELLER.x, AXIS, 0)], + ['waterjet', at(object, middle(JET.housing.x), AXIS + JET_SHAPE.housing.ringRadius, 0)], + ['stator', at(object, middle(JET.stator.x), AXIS, 0)], + ['nozzle', at(object, middle(JET.nozzle.x), AXIS, 0)], + [ + 'steeringNozzle', + at(steeringGroup, middle(JET.steeringNozzle.x) - JET.steeringNozzle.pivotX, 0, 0), + ], + ['reverseBucket', at(bucketGroup, cx - pivotX, cy - pivotY + JET_SHAPE.bucket.radius, 0)], + ]); +} + +export function buildWaterjet(context: PartContext, cutaway: CutawaySwitch): Waterjet { + const object = new Group(); + halves(context, cutaway, housingProfile(), 'waterjet', object); + halves(context, cutaway, nozzleProfile(), 'nozzle', object); + const spinner = impeller(context); + const steer = steering(context); + const hood = bucket(context); + object.add( + flangeBolts(context), + spinner.group, + spinner.blur, + stator(context), + grate(context), + duct(context, cutaway), + shaft(context), + steer.group, + hood.group, + hood.brackets, + ); + const centreline = ductCentreline(); + const exit = JET.steeringNozzle.x[0]; + return { + object, + impeller: spinner.group, + blur: spinner.blur, + steering: steer.group, + bucket: hood.group, + stern: steer.stern, + labels: labelSpots(object, steer.group, hood.group), + flowPath: [...centreline, [IMPELLER.x, AXIS, 0], [JET.stator.x[0], AXIS, 0], [exit, AXIS, 0]], + }; +} diff --git a/explainers/naval-drone/src/scene/parts/context.ts b/explainers/naval-drone/src/scene/parts/context.ts new file mode 100644 index 00000000..a721ff96 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/context.ts @@ -0,0 +1,81 @@ +import { InstancedMesh, Mesh } from 'three'; +import type { BufferGeometry, Material, Matrix4 } from 'three'; +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; +import type { + MaterialFinish, + MaterialLibrary, + STRUCTURE_GROUP, + UNDIMMED_GROUP, +} from '@core/scene/materials'; +import type { ResourceTracker } from '@core/scene/resources'; +import type { SceneTextures } from '@core/scene/textures'; +import type { PartId } from '../../ids'; +import type { BoatLooks } from '../finishes'; + +export type EmphasisGroup = PartId | typeof STRUCTURE_GROUP | typeof UNDIMMED_GROUP; + +export interface PartContext { + materials: MaterialLibrary; + textures: SceneTextures; + tracker: ResourceTracker; + looks: BoatLooks; +} + +export function partMesh( + context: PartContext, + geometry: BufferGeometry, + group: EmphasisGroup, + finish: MaterialFinish, +): Mesh { + return new Mesh(context.tracker.track(geometry), context.materials.get(group, finish)); +} + +export function registered( + context: PartContext, + group: EmphasisGroup, + material: T, +): T { + context.materials.register(group, context.tracker.track(material)); + return material; +} + +export function instanced( + context: PartContext, + geometry: BufferGeometry, + group: EmphasisGroup, + finish: MaterialFinish, + matrices: readonly Matrix4[], +): InstancedMesh { + const mesh = new InstancedMesh( + context.tracker.track(geometry), + context.materials.get(group, finish), + matrices.length, + ); + matrices.forEach((matrix, index) => mesh.setMatrixAt(index, matrix)); + mesh.instanceMatrix.needsUpdate = true; + mesh.computeBoundingSphere(); + return mesh; +} + +function sharedAttributes(parts: readonly BufferGeometry[]): string[] { + const [first, ...rest] = parts; + return Object.keys(first.attributes).filter((name) => + rest.every((part) => part.getAttribute(name) !== undefined), + ); +} + +export function mergeParts(parts: readonly BufferGeometry[]): BufferGeometry { + if (parts.length === 1) return parts[0]; + const indexed = parts.every((part) => part.index !== null); + const prepared = parts.map((part) => (indexed || !part.index ? part : part.toNonIndexed())); + const names = sharedAttributes(prepared); + prepared.forEach((part) => + Object.keys(part.attributes) + .filter((name) => !names.includes(name)) + .forEach((name) => part.deleteAttribute(name)), + ); + const merged = mergeGeometries(prepared); + new Set([...parts, ...prepared]).forEach((part) => part.dispose()); + if (!merged) throw new Error('Could not merge the part geometries'); + return merged; +} diff --git a/explainers/naval-drone/src/scene/parts/effects/beam.ts b/explainers/naval-drone/src/scene/parts/effects/beam.ts new file mode 100644 index 00000000..7e77d2a5 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/effects/beam.ts @@ -0,0 +1,124 @@ +import { + AdditiveBlending, + Color, + CylinderGeometry, + DoubleSide, + Mesh, + NormalBlending, + Quaternion, + ShaderMaterial, + Vector2, + Vector3, + Vector4, +} from 'three'; +import type { BeamLook } from '../../constants'; +import { registered } from '../context'; +import type { EmphasisGroup, PartContext } from '../context'; + +const VERTEX = /* glsl */ ` +uniform float uLength; +uniform vec2 uRadius; +uniform float uMinWidth; +varying float vAlong; +varying vec3 vNormalView; +varying vec3 vViewPosition; +void main() { + vAlong = position.y; + vec4 axis = modelViewMatrix * vec4(0.0, position.y * uLength, 0.0, 1.0); + float radius = max(mix(uRadius.x, uRadius.y, position.y), -axis.z * uMinWidth); + vec3 placed = vec3(position.x * radius, position.y * uLength, position.z * radius); + vec4 view = modelViewMatrix * vec4(placed, 1.0); + vViewPosition = -view.xyz; + vNormalView = normalize(normalMatrix * vec3(position.x, 0.0, position.z)); + gl_Position = projectionMatrix * view; +} +`; + +const FRAGMENT = /* glsl */ ` +uniform vec3 uColour; +uniform float uOpacity; +uniform float uOffset; +uniform float uLength; +uniform float uCore; +uniform vec4 uDash; +uniform float uDashFloor; +uniform vec2 uFade; +varying float vAlong; +varying vec3 vNormalView; +varying vec3 vViewPosition; +void main() { + float facing = abs(dot(normalize(vNormalView), normalize(vViewPosition))); + float body = pow(facing, uCore); + float distance = vAlong * uLength; + float along = uDash.w > 0.0 ? log(1.0 + distance / uDash.w) * uDash.w : distance; + float phase = fract(along / max(uDash.x, 1e-3) - uOffset); + float dash = uDash.x > 0.0 + ? smoothstep(0.0, 0.12, phase) * (1.0 - smoothstep(uDash.y, uDash.y + 0.12, phase)) + : 1.0; + float fade = smoothstep(0.0, uFade.x, vAlong) * (1.0 - smoothstep(1.0 - uFade.y, 1.0, vAlong)); + float alpha = uOpacity * body * mix(uDashFloor, 1.0, dash) * fade; + gl_FragColor = vec4(uColour, alpha); + #include +} +`; + +const Y_AXIS = new Vector3(0, 1, 0); +const UNIT_RADIUS = 1; +const MIN_WIDTH = 0.0016; + +export class Beam { + readonly mesh: Mesh; + private readonly material: ShaderMaterial; + private readonly look: BeamLook; + private readonly direction = new Vector3(); + private readonly turn = new Quaternion(); + + constructor(context: PartContext, group: EmphasisGroup, look: BeamLook, colour: string) { + this.look = look; + const geometry = new CylinderGeometry(UNIT_RADIUS, UNIT_RADIUS, 1, look.segments, 1, true); + geometry.translate(0, 0.5, 0); + const { dash } = look; + this.material = registered( + context, + group, + new ShaderMaterial({ + uniforms: { + uColour: { value: new Color(colour) }, + uOpacity: { value: look.opacity }, + uOffset: { value: 0 }, + uLength: { value: 1 }, + uCore: { value: look.core }, + uRadius: { value: new Vector2(look.startRadius, look.endRadius) }, + uMinWidth: { value: MIN_WIDTH }, + uDash: { value: new Vector4(dash.period, dash.duty, dash.speed, dash.logScale) }, + uDashFloor: { value: dash.floor }, + uFade: { value: new Vector2(...look.fade) }, + }, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + transparent: true, + depthWrite: false, + blending: look.glow ? AdditiveBlending : NormalBlending, + side: DoubleSide, + toneMapped: false, + }), + ); + this.mesh = new Mesh(context.tracker.track(geometry), this.material); + this.mesh.frustumCulled = false; + this.mesh.visible = false; + } + + span(from: Vector3, to: Vector3, endRadius = this.look.endRadius): void { + this.direction.subVectors(to, from); + const length = this.direction.length(); + this.mesh.position.copy(from); + this.mesh.quaternion.copy(this.turn.setFromUnitVectors(Y_AXIS, this.direction.normalize())); + this.material.uniforms.uLength.value = length; + (this.material.uniforms.uRadius.value as Vector2).y = endRadius; + } + + advance(deltaSeconds: number): void { + const offset = this.material.uniforms.uOffset.value + deltaSeconds * this.look.dash.speed; + this.material.uniforms.uOffset.value = offset - Math.floor(offset); + } +} diff --git a/explainers/naval-drone/src/scene/parts/effects/ghost.ts b/explainers/naval-drone/src/scene/parts/effects/ghost.ts new file mode 100644 index 00000000..4254fdc3 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/effects/ghost.ts @@ -0,0 +1,51 @@ +import { AdditiveBlending, Group, Mesh, MeshBasicMaterial, Vector3 } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import type { AssemblyState } from '../../../ids'; +import { BOAT, HULL_STATIONS } from '../../../model/layout'; +import { GHOST } from '../../constants'; +import { applyBoatPose } from '../../pose'; +import { registered } from '../context'; +import type { PartContext } from '../context'; +import { Beam } from './beam'; + +const BOW = new Vector3(BOAT.halfLength, HULL_STATIONS[HULL_STATIONS.length - 1].deck, 0); + +export class GhostPart { + readonly object = new Group(); + readonly anchor = new Group(); + private readonly hull: Mesh; + private readonly tether: Beam; + private readonly from = new Vector3(); + private readonly to = new Vector3(); + + constructor(context: PartContext, outline: BufferGeometry) { + const material = new MeshBasicMaterial({ + color: GHOST.colour, + transparent: true, + opacity: GHOST.opacity, + depthWrite: false, + blending: AdditiveBlending, + toneMapped: false, + }); + this.hull = new Mesh(outline, registered(context, 'videoGhost', material)); + this.anchor.position.set(0, HULL_STATIONS[3].deck, 0); + this.hull.add(this.anchor); + this.tether = new Beam(context, 'videoGhost', GHOST.tether, GHOST.colour); + this.tether.mesh.visible = true; + this.object.add(this.hull, this.tether.mesh); + this.object.visible = false; + } + + setState(state: AssemblyState, boat: Object3D): void { + const { ghost } = state.link; + this.object.visible = ghost !== null; + if (!ghost) return; + applyBoatPose(this.hull, ghost, state.planing); + this.hull.updateMatrixWorld(true); + boat.updateMatrixWorld(true); + this.tether.span( + this.from.copy(BOW).applyMatrix4(this.hull.matrixWorld), + this.to.copy(BOW).applyMatrix4(boat.matrixWorld), + ); + } +} diff --git a/explainers/naval-drone/src/scene/parts/effects/links.ts b/explainers/naval-drone/src/scene/parts/effects/links.ts new file mode 100644 index 00000000..e2c6a797 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/effects/links.ts @@ -0,0 +1,95 @@ +import { Group, Vector3 } from 'three'; +import type { MeshStandardMaterial, Object3D } from 'three'; +import type { AssemblyState, PartId } from '../../../ids'; +import { BACKUP_PANEL_X, PANEL, STARLINK_PANEL_XS } from '../../../model/layout'; +import { THEME } from '../../../theme'; +import { BEAMS } from '../../constants'; +import type { PartContext } from '../context'; +import { Beam } from './beam'; + +export interface LinkEnds { + body: Object3D; + satellite: Object3D; + backupSatellite: Object3D; + station: Object3D; + starlinkGlow: MeshStandardMaterial; + backupGlow: MeshStandardMaterial; +} + +class LinkBundle { + readonly group = new Group(); + readonly anchor = new Group(); + readonly ups: Beam[]; + readonly down: Beam; + + constructor(context: PartContext, id: PartId, colour: string, count: number) { + this.ups = Array.from({ length: count }, () => new Beam(context, id, BEAMS.up, colour)); + this.down = new Beam(context, id, BEAMS.down, colour); + [...this.ups, this.down].forEach((beam) => { + beam.mesh.visible = true; + this.group.add(beam.mesh); + }); + this.ups[0].mesh.add(this.anchor); + } + + beams(): Beam[] { + return [...this.ups, this.down]; + } +} + +export class LinksPart { + readonly object = new Group(); + private readonly starlink: LinkBundle; + private readonly backup: LinkBundle; + private readonly start = new Vector3(); + private readonly sky = new Vector3(); + private readonly ground = new Vector3(); + + constructor(context: PartContext) { + this.starlink = new LinkBundle(context, 'satLink', THEME.satLink, STARLINK_PANEL_XS.length); + this.backup = new LinkBundle(context, 'backupLink', THEME.backupLink, 1); + this.object.add(this.starlink.group, this.backup.group); + } + + get satAnchor(): Object3D { + return this.starlink.anchor; + } + + get backupAnchor(): Object3D { + return this.backup.anchor; + } + + setState(state: AssemblyState, ends: LinkEnds): void { + const mode = state.view.links ? state.link.mode : 'lost'; + this.starlink.group.visible = mode === 'satellite'; + this.backup.group.visible = mode === 'backup'; + ends.starlinkGlow.emissiveIntensity = state.link.mode === 'satellite' ? BEAMS.panelGlow : 0; + ends.backupGlow.emissiveIntensity = state.link.mode === 'backup' ? BEAMS.panelGlow : 0; + ends.body.updateMatrixWorld(true); + ends.station.getWorldPosition(this.ground); + this.place(this.starlink, STARLINK_PANEL_XS, ends.satellite, ends.body); + this.place(this.backup, [BACKUP_PANEL_X], ends.backupSatellite, ends.body); + } + + private place( + bundle: LinkBundle, + xs: readonly number[], + satellite: Object3D, + body: Object3D, + ): void { + satellite.getWorldPosition(this.sky); + bundle.ups.forEach((beam, index) => { + this.start.set(xs[index], PANEL.top, 0).applyMatrix4(body.matrixWorld); + beam.span(this.start, this.sky); + }); + bundle.down.span(this.sky, this.ground); + const length = this.start.distanceTo(this.sky); + bundle.anchor.position.set(0, length * BEAMS.labelShare, 0); + } + + advance(deltaSeconds: number): void { + [...this.starlink.beams(), ...this.backup.beams()].forEach((beam) => + beam.advance(deltaSeconds), + ); + } +} diff --git a/explainers/naval-drone/src/scene/parts/surfaces.ts b/explainers/naval-drone/src/scene/parts/surfaces.ts new file mode 100644 index 00000000..62d9ed00 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/surfaces.ts @@ -0,0 +1,196 @@ +import { + CanvasTexture, + Color, + DataTexture, + LinearFilter, + LinearMipmapLinearFilter, + NoColorSpace, + RGBAFormat, + RepeatWrapping, + SRGBColorSpace, +} from 'three'; +import type { Texture } from 'three'; +import { BOAT, HATCHES, TRANSOM_X } from '../../model/layout'; +import { SURFACE_MAPS } from '../constants'; +import { THEME } from '../../theme'; + +type Paint = (context: CanvasRenderingContext2D, width: number, height: number) => void; + +const ANISOTROPY = 8; +const CHANNELS = 4; +const BYTE = 255; + +export function seededRandom(seed: number): () => number { + let state = seed >>> 0 || 1; + return () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + return (state >>> 0) / 0x100000000; + }; +} + +export function canvasTexture( + width: number, + height: number, + paint: Paint, + repeat = false, +): CanvasTexture { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (context) paint(context, width, height); + const texture = new CanvasTexture(canvas); + texture.colorSpace = SRGBColorSpace; + texture.anisotropy = ANISOTROPY; + texture.flipY = false; + if (repeat) { + texture.wrapS = RepeatWrapping; + texture.wrapT = RepeatWrapping; + } + return texture; +} + +export function mottle( + context: CanvasRenderingContext2D, + width: number, + height: number, + { cells, strength, seed }: { cells: readonly [number, number]; strength: number; seed: number }, +): void { + const random = seededRandom(seed); + const [columns, rows] = cells; + const small = document.createElement('canvas'); + small.width = columns; + small.height = rows; + const pen = small.getContext('2d'); + if (!pen) return; + const image = pen.createImageData(columns, rows); + for (let at = 0; at < columns * rows; at += 1) { + const shade = Math.round(BYTE * (0.5 + (random() - 0.5) * strength)); + image.data.set([shade, shade, shade, BYTE], at * CHANNELS); + } + pen.putImageData(image, 0, 0); + context.save(); + context.globalCompositeOperation = 'overlay'; + context.imageSmoothingEnabled = true; + context.drawImage(small, 0, 0, width, height); + context.restore(); +} + +function ratioStyle(from: string, to: string): string { + const base = new Color(from); + const target = new Color(to); + return new Color(target.r / base.r, target.g / base.g, target.b / base.b).getStyle(); +} + +export function hullSideTexture(): Texture { + const { size, span, mottle: look } = SURFACE_MAPS.side; + return canvasTexture(size[0], size[1], (context, width, height) => { + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, width, height); + mottle(context, width, height, look); + context.fillStyle = ratioStyle(THEME.hull, THEME.hullDark); + context.fillRect(0, 0, width, (SURFACE_MAPS.edgeBand / span) * height); + }); +} + +function deckPixel(x: number, z: number, width: number, height: number): [number, number] { + return [((x - TRANSOM_X) / BOAT.length) * width, ((z + BOAT.beam / 2) / BOAT.beam) * height]; +} + +function hatchGroove( + context: CanvasRenderingContext2D, + width: number, + height: number, + hatch: { x: readonly [number, number]; halfWidth: number }, +): void { + const { groove, lip, radius } = SURFACE_MAPS.deck; + const [x0, z0] = deckPixel(hatch.x[0], -hatch.halfWidth, width, height); + const [x1, z1] = deckPixel(hatch.x[1], hatch.halfWidth, width, height); + const corner = (radius / BOAT.length) * width; + const pixels = (metres: number) => (metres / BOAT.length) * width; + const outline = (inset: number) => { + context.beginPath(); + context.roundRect(x0 + inset, z0 + inset, x1 - x0 - 2 * inset, z1 - z0 - 2 * inset, corner); + }; + context.strokeStyle = groove.colour; + context.lineWidth = pixels(groove.width); + outline(0); + context.stroke(); + context.strokeStyle = lip.colour; + context.lineWidth = pixels(lip.width); + outline(pixels(groove.width)); + context.stroke(); +} + +export function deckTexture(): Texture { + const { size, mottle: look, seams } = SURFACE_MAPS.deck; + return canvasTexture(size[0], size[1], (context, width, height) => { + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, width, height); + mottle(context, width, height, look); + context.fillStyle = seams.colour; + const seamWidth = Math.max(1, (seams.width / BOAT.length) * width); + const [, seamTop] = deckPixel(0, -seams.reach, width, height); + const [, seamBottom] = deckPixel(0, seams.reach, width, height); + seams.across.forEach((x) => { + const [px] = deckPixel(x, 0, width, height); + context.fillRect(px, seamTop, seamWidth, seamBottom - seamTop); + }); + seams.along.forEach(({ z, x }) => { + const [from, row] = deckPixel(x[0], z, width, height); + const [to] = deckPixel(x[1], z, width, height); + context.fillRect(from, row, to - from, seamWidth); + }); + hatchGroove(context, width, height, HATCHES.forward); + hatchGroove(context, width, height, HATCHES.mid); + }); +} + +export function cellTexture(): Texture { + const { size, cells, line, base, sheen } = SURFACE_MAPS.panel; + return canvasTexture(size[0], size[1], (context, width, height) => { + const gradient = context.createLinearGradient(0, 0, width, height); + gradient.addColorStop(0, base); + gradient.addColorStop(1, sheen); + context.fillStyle = gradient; + context.fillRect(0, 0, width, height); + context.fillStyle = line; + for (let column = 1; column < cells[0]; column += 1) { + context.fillRect((column / cells[0]) * width, 0, 1, height); + } + for (let row = 1; row < cells[1]; row += 1) { + context.fillRect(0, (row / cells[1]) * height, width, 1); + } + }); +} + +export function repeating(pixels: Uint8Array, size: number, mipmaps = true): DataTexture { + const texture = new DataTexture(pixels, size, size, RGBAFormat); + texture.wrapS = RepeatWrapping; + texture.wrapT = RepeatWrapping; + texture.magFilter = LinearFilter; + texture.minFilter = mipmaps ? LinearMipmapLinearFilter : LinearFilter; + texture.generateMipmaps = mipmaps; + texture.anisotropy = ANISOTROPY; + texture.colorSpace = NoColorSpace; + texture.needsUpdate = true; + return texture; +} + +export function noiseTexture(size: number, low: number, high: number, seed: number): Texture { + const random = seededRandom(seed); + const pixels = new Uint8Array(size * size * CHANNELS); + const coarse = Array.from({ length: (size / 4) ** 2 }, () => random()); + const coarseAt = (x: number, y: number) => + coarse[(Math.floor(y / 4) % (size / 4)) * (size / 4) + (Math.floor(x / 4) % (size / 4))]; + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const value = low + (high - low) * (0.6 * coarseAt(x, y) + 0.4 * random()); + const byte = Math.round(value * BYTE); + pixels.set([byte, byte, byte, BYTE], (y * size + x) * CHANNELS); + } + } + return repeating(pixels, size); +} diff --git a/explainers/naval-drone/src/scene/parts/water/boatMotion.ts b/explainers/naval-drone/src/scene/parts/water/boatMotion.ts new file mode 100644 index 00000000..0dce0f14 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/boatMotion.ts @@ -0,0 +1,82 @@ +import type { Object3D } from 'three'; +import { clamp, lerp } from '@core/math'; +import type { Point } from '../../../ids'; +import { MOTION } from '../../constants'; +import { WATER, seaHeightAt } from './waves'; + +export interface Motion { + heave: number; + pitch: number; + roll: number; +} + +export interface MotionSpan { + bow: number; + stern: number; + side: number; +} + +export const BOAT_SPAN: MotionSpan = { bow: MOTION.bow, stern: MOTION.stern, side: MOTION.side }; +export const SHIP_SPAN: MotionSpan = { + bow: MOTION.shipEnd, + stern: MOTION.shipEnd, + side: MOTION.shipSide, +}; + +export function applyMotion(body: Object3D, motion: Motion): void { + body.position.y = motion.heave; + body.rotation.set(motion.roll, 0, motion.pitch, 'ZXY'); +} + +export function waveMotion( + position: Point, + heading: number, + response: number, + span: MotionSpan = BOAT_SPAN, +): Motion { + const ahead = [Math.cos(heading), Math.sin(heading)]; + const starboard = [-Math.sin(heading), Math.cos(heading)]; + const sample = (along: number, across: number) => + seaHeightAt( + position[0] + ahead[0] * along + starboard[0] * across, + position[2] + ahead[1] * along + starboard[1] * across, + WATER, + MOTION.swell, + ); + const bow = sample(span.bow, 0); + const stern = sample(-span.stern, 0); + const port = sample(0, -span.side); + const right = sample(0, span.side); + return { + heave: ((bow + stern + port + right) / 4) * lerp(1, response, MOTION.heaveDamping), + pitch: clamp( + Math.atan2(bow - stern, span.bow + span.stern) * response, + -MOTION.maxPitch, + MOTION.maxPitch, + ), + roll: clamp( + -Math.atan2(right - port, 2 * span.side) * response, + -MOTION.maxRoll, + MOTION.maxRoll, + ), + }; +} + +export class SmoothMotion { + private readonly current: Motion = { heave: 0, pitch: 0, roll: 0 }; + + follow(target: Motion, blend: number, scale: number): Motion { + this.current.heave += (target.heave * scale - this.current.heave) * blend; + this.current.pitch += (target.pitch * scale - this.current.pitch) * blend; + this.current.roll += (target.roll * scale - this.current.roll) * blend; + return this.current; + } +} + +export function motionBlend(deltaSeconds: number): number { + return 1 - Math.exp(-deltaSeconds / MOTION.lag); +} + +export function responseFor(liftShare: number): number { + return lerp(1, MOTION.planedResponse, clamp(liftShare, 0, 1)); +} diff --git a/explainers/naval-drone/src/scene/parts/water/flow.ts b/explainers/naval-drone/src/scene/parts/water/flow.ts new file mode 100644 index 00000000..681ef2ec --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/flow.ts @@ -0,0 +1,161 @@ +import { Color, Vector3 } from 'three'; +import { clamp, lerp } from '@core/math'; +import { PointCloud, createPointMaterial } from '@core/scene/pointCloud'; +import type { AssemblyState } from '../../../ids'; +import { IMPELLER, JET } from '../../../model/layout'; +import { knotsToMs } from '../../../model/scale'; +import { JET_STREAM } from '../../constants'; +import type { Vec3 } from '../../geometry/surface'; +import { registered } from '../context'; +import type { PartContext } from '../context'; + +interface Segment { + from: Vector3; + to: Vector3; + length: number; + speed: number; + radius: [number, number]; + swirl: number; +} + +const GOLDEN = 0.6180339887; +const SPREAD = 0.7548776662; +const ANGLE = 0.569840291; +const FLOW_COUNT = 240; +const FLOW_SIZE = 0.045; +const RADIUS = { intake: 0.1, duct: 0.07, nozzle: 0.035, jet: 0.05 }; +const SPEEDS = { intake: 2.2, duct: 3.4, pump: 9, nozzle: 16, jet: 18 }; +const APPROACH: Vec3[] = [ + [-0.9, -0.38, 0], + [-1.55, -0.34, 0], +]; +const JET_LENGTH = 1.6; +const SWIRL = 2.4; +const SWIRL_REACH = 0.02; +const SLOW = '#5fa8ff'; +const FAST = '#ffffff'; +const PLAYBACK = 0.18; +const TURN = [-0.08, -0.05] as const; +const INTAKE_EDGE = -1.75; +const MIN_THROTTLE = 0.25; +const ALPHA = 0.95; + +function segmentSpeed(x: number, boatSpeed: number, jetSpeed: number): number { + if (x > INTAKE_EDGE) return Math.max(boatSpeed, SPEEDS.intake); + if (x > JET.duct.endX) return SPEEDS.duct; + if (x > JET.stator.x[0]) return SPEEDS.pump; + if (x > JET.nozzle.x[0]) return SPEEDS.nozzle; + return Math.max(jetSpeed, SPEEDS.jet); +} + +function segmentRadius(x: number): number { + if (x > INTAKE_EDGE) return RADIUS.intake; + if (x > JET.nozzle.x[1]) return RADIUS.duct; + if (x > JET.steeringNozzle.x[0]) return RADIUS.nozzle; + return RADIUS.jet; +} + +export class FlowPart { + readonly cloud: PointCloud; + private readonly base: readonly Vec3[]; + private segments: Segment[] = []; + private total = 1; + private clock = 0; + private visible = false; + private readonly slow = new Color(SLOW); + private readonly fast = new Color(FAST); + private readonly mixed = new Color(); + + constructor(context: PartContext, path: readonly Vec3[]) { + this.base = [...APPROACH, ...path]; + const material = registered( + context, + 'jetStream', + createPointMaterial(context.textures.dot, FLOW_SIZE), + ); + this.cloud = new PointCloud(FLOW_COUNT, material); + context.tracker.track({ dispose: () => this.cloud.dispose() }); + } + + private path(state: AssemblyState): Vector3[] { + const points = this.base.map((point) => new Vector3(...point)); + const exit = points[points.length - 1]; + const { nozzleAngle, bucket } = state.jet; + if (bucket > 0.5) { + const { reverse } = JET_STREAM; + points.push( + exit.clone().add(new Vector3(...TURN, 0)), + exit.clone().add(new Vector3(reverse.length * Math.cos(reverse.angle), -reverse.dive, 0)), + ); + return points; + } + const direction = new Vector3(-Math.cos(nozzleAngle), 0, Math.sin(nozzleAngle)); + const pivot = new Vector3(JET.steeringNozzle.pivotX, JET.axisY, 0); + const rotatedExit = pivot + .clone() + .add(direction.clone().multiplyScalar(JET.steeringNozzle.pivotX - JET.steeringNozzle.x[0])); + points[points.length - 1] = rotatedExit; + points.push(rotatedExit.clone().add(direction.multiplyScalar(JET_LENGTH))); + return points; + } + + setState(state: AssemblyState): void { + this.visible = state.view.flow && state.jet.flow > 0; + this.cloud.points.visible = this.visible; + if (!this.visible) return; + const points = this.path(state); + const boatSpeed = knotsToMs(state.boat.knots); + this.segments = points.slice(1).map((to, index) => { + const from = points[index]; + const middle = (from.x + to.x) / 2; + return { + from, + to, + length: from.distanceTo(to), + speed: + segmentSpeed(middle, boatSpeed, state.jet.jetSpeed) * + clamp(state.jet.throttle, MIN_THROTTLE, 1), + radius: [segmentRadius(from.x), segmentRadius(to.x)], + swirl: middle < IMPELLER.x + SWIRL_REACH && middle > JET.stator.x[0] ? SWIRL : 0, + }; + }); + this.total = this.segments.reduce((sum, segment) => sum + segment.length / segment.speed, 0); + this.place(); + } + + private place(): void { + const point = new Vector3(); + for (let index = 0; index < FLOW_COUNT; index += 1) { + const phase = ((index * GOLDEN + (this.clock * PLAYBACK) / this.total) % 1) * this.total; + let time = phase; + let segment = this.segments[0]; + for (const candidate of this.segments) { + segment = candidate; + const span = candidate.length / candidate.speed; + if (time <= span) break; + time -= span; + } + const share = clamp((time * segment.speed) / Math.max(segment.length, 1e-6), 0, 1); + point.lerpVectors(segment.from, segment.to, share); + const radius = + lerp(segment.radius[0], segment.radius[1], share) * Math.sqrt((index * SPREAD) % 1); + const angle = ((index * ANGLE) % 1) * Math.PI * 2 + segment.swirl * share; + this.cloud.setPoint( + index, + point.x, + point.y + radius * Math.cos(angle), + point.z + radius * Math.sin(angle), + ); + const speed = clamp(segment.speed / SPEEDS.jet, 0, 1); + this.mixed.copy(this.slow).lerp(this.fast, speed); + this.cloud.setColor(index, this.mixed.r, this.mixed.g, this.mixed.b, ALPHA); + } + this.cloud.commit(); + } + + advance(deltaSeconds: number): void { + if (!this.visible) return; + this.clock += deltaSeconds; + this.place(); + } +} diff --git a/explainers/naval-drone/src/scene/parts/water/foamSheet.ts b/explainers/naval-drone/src/scene/parts/water/foamSheet.ts new file mode 100644 index 00000000..8c1ea3b3 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/foamSheet.ts @@ -0,0 +1,149 @@ +import { + BufferAttribute, + BufferGeometry, + Color, + DoubleSide, + DynamicDrawUsage, + Mesh, + ShaderMaterial, + Vector4, +} from 'three'; +import type { Texture } from 'three'; +import type { Vec3 } from '../../geometry/surface'; +import { registered } from '../context'; +import { WATER, WAVE_GLSL } from './waves'; +import type { EmphasisGroup, PartContext } from '../context'; + +export interface SheetLook { + opacity: number; + scroll: number; + streaks: number; + stretch: number; + colour: string; + floor?: number; +} + +const SURFACE_CLIP = '0.02'; + +const VERTEX = /* glsl */ ` +varying vec2 vUv; +varying vec3 vNormalView; +varying vec3 vView; +varying vec3 vWorld; +void main() { + vUv = uv; + vWorld = (modelMatrix * vec4(position, 1.0)).xyz; + vec4 view = modelViewMatrix * vec4(position, 1.0); + vView = -view.xyz; + vNormalView = normalize(normalMatrix * normal); + gl_Position = projectionMatrix * view; +} +`; + +const FRAGMENT = /* glsl */ ` +${WAVE_GLSL} +varying vec3 vWorld; +uniform sampler2D uCellMap; +uniform vec3 uColour; +uniform vec4 uSheet; +uniform float uTime; +uniform float uStrength; +uniform float uFloor; +uniform vec3 uProfile; +varying vec2 vUv; +varying vec3 vNormalView; +varying vec3 vView; +void main() { + if (uProfile.z > 0.5 && vWorld.y < seaHeight(vWorld.xz) - ${SURFACE_CLIP}) discard; + float along = vUv.x; + float outward = vUv.y; + vec2 flow = vec2(along * uSheet.z, outward * uSheet.w - uTime * uSheet.y); + float coarse = texture2D(uCellMap, flow).r; + float fine = texture2D(uCellMap, flow * vec2(2.3, 1.6) + vec2(0.37, 0.11)).r; + float streak = smoothstep(0.5, 0.82, coarse * 0.55 + fine * 0.55); + float body = smoothstep(0.0, max(uProfile.x, 1e-3), outward) * (1.0 - smoothstep(uProfile.y, 1.0, outward)); + body *= mix(1.0, smoothstep(0.0, 0.18, along) * (1.0 - smoothstep(0.78, 1.0, along)), uProfile.z); + float facing = abs(dot(normalize(vNormalView), normalize(vView))); + float mist = (1.0 - outward) * 0.3 * (0.5 + coarse); + float alpha = uSheet.x * uStrength * body * (mist + mix(uFloor, 1.0, (0.4 + 0.6 * facing) * streak)); + gl_FragColor = vec4(uColour, clamp(alpha, 0.0, 0.95)); + #include +} +`; + +export function foamSheetMaterial( + context: PartContext, + group: EmphasisGroup, + cellMap: Texture, + look: SheetLook, + profile: readonly [number, number], + surface = true, +): ShaderMaterial { + return registered( + context, + group, + new ShaderMaterial({ + uniforms: { + uCellMap: { value: cellMap }, + uColour: { value: new Color(look.colour) }, + uSheet: { value: new Vector4(look.opacity, look.scroll, look.streaks, look.stretch) }, + uTime: { value: 0 }, + uStrength: { value: 1 }, + uFloor: { value: look.floor ?? 0 }, + uSeaTime: WATER.uSeaTime, + uSeaDrift: WATER.uSeaDrift, + uWaves: WATER.uWaves, + uProfile: { value: [...profile, surface ? 1 : 0] }, + }, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + transparent: true, + depthWrite: false, + side: DoubleSide, + toneMapped: false, + }), + ); +} + +export class SheetGrid { + readonly mesh: Mesh; + private readonly rows: number; + private readonly columns: number; + private readonly positions: Float32Array; + private readonly geometry = new BufferGeometry(); + + constructor(context: PartContext, material: ShaderMaterial, rows: number, columns: number) { + this.rows = rows; + this.columns = columns; + this.positions = new Float32Array(rows * columns * 3); + const uvs = new Float32Array(rows * columns * 2); + const indices: number[] = []; + for (let r = 0; r < rows; r += 1) { + for (let c = 0; c < columns; c += 1) { + uvs.set([r / (rows - 1), c / (columns - 1)], (r * columns + c) * 2); + if (r < rows - 1 && c < columns - 1) { + const a = r * columns + c; + indices.push(a, a + columns, a + 1, a + 1, a + columns, a + columns + 1); + } + } + } + this.geometry.setAttribute( + 'position', + new BufferAttribute(this.positions, 3).setUsage(DynamicDrawUsage), + ); + this.geometry.setAttribute('uv', new BufferAttribute(uvs, 2)); + this.geometry.setIndex(indices); + this.mesh = new Mesh(context.tracker.track(this.geometry), material); + this.mesh.frustumCulled = false; + } + + set(point: (row: number, column: number) => Vec3): void { + for (let r = 0; r < this.rows; r += 1) { + for (let c = 0; c < this.columns; c += 1) { + this.positions.set(point(r, c), (r * this.columns + c) * 3); + } + } + this.geometry.getAttribute('position').needsUpdate = true; + this.geometry.computeVertexNormals(); + } +} diff --git a/explainers/naval-drone/src/scene/parts/water/hullPlan.test.ts b/explainers/naval-drone/src/scene/parts/water/hullPlan.test.ts new file mode 100644 index 00000000..8440dd74 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/hullPlan.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { SEA } from '../../constants'; +import { halfBreadthAt } from '../../geometry/hullLines'; +import { hullPlanTexture } from './hullPlan'; + +const BYTE = 255; + +describe('hull plan', () => { + it('stores the hull half-breadth for the water mask', () => { + const texture = hullPlanTexture(); + const { columns, rows, x, y, scale } = SEA.plan; + const data = texture.image.data as Uint8Array; + const column = Math.floor(((0 - x[0]) / (x[1] - x[0])) * columns); + const row = Math.floor(((0 - y[0]) / (y[1] - y[0])) * rows); + const stored = (data[(row * columns + column) * 4] / BYTE) * scale; + const centreX = x[0] + ((column + 0.5) / columns) * (x[1] - x[0]); + const centreY = y[0] + ((row + 0.5) / rows) * (y[1] - y[0]); + expect(stored).toBeCloseTo(halfBreadthAt(centreX, centreY), 2); + expect(data[0]).toBe(0); + texture.dispose(); + }); +}); diff --git a/explainers/naval-drone/src/scene/parts/water/hullPlan.ts b/explainers/naval-drone/src/scene/parts/water/hullPlan.ts new file mode 100644 index 00000000..3a939504 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/hullPlan.ts @@ -0,0 +1,49 @@ +import { DataTexture, LinearFilter, NoColorSpace, RGBAFormat, Vector4 } from 'three'; +import { HULL_LINES, SEA } from '../../constants'; +import { halfBreadthAt } from '../../geometry/hullLines'; + +const CHANNELS = 4; +const BYTE = 255; + +export function hullPlanTexture(): DataTexture { + const { columns, rows, x, y, scale } = SEA.plan; + const pixels = new Uint8Array(columns * rows * CHANNELS); + for (let row = 0; row < rows; row += 1) { + const at = y[0] + ((row + 0.5) / rows) * (y[1] - y[0]); + for (let column = 0; column < columns; column += 1) { + const along = x[0] + ((column + 0.5) / columns) * (x[1] - x[0]); + const value = Math.round(Math.min(halfBreadthAt(along, at) / scale, 1) * BYTE); + pixels.set([value, 0, 0, BYTE], (row * columns + column) * CHANNELS); + } + } + const texture = new DataTexture(pixels, columns, rows, RGBAFormat); + texture.magFilter = LinearFilter; + texture.minFilter = LinearFilter; + texture.colorSpace = NoColorSpace; + texture.needsUpdate = true; + return texture; +} + +export function hullPlanRange(): Vector4 { + const { x, y } = SEA.plan; + return new Vector4(x[0], x[1], y[0], y[1]); +} + +export const HULL_MASK_GLSL = /* glsl */ ` +uniform mat4 uBoatInverse; +uniform sampler2D uHullPlan; +uniform vec4 uHullPlanRange; +uniform float uOpenPort; + +bool insideHull(vec3 world, float margin) { + vec3 local = (uBoatInverse * vec4(world, 1.0)).xyz; + if (uOpenPort > 0.5 && local.z < 0.0 && local.x < ${HULL_LINES.cutX.toFixed(2)}) return false; + vec2 uv = vec2( + (local.x - uHullPlanRange.x) / (uHullPlanRange.y - uHullPlanRange.x), + (local.y - uHullPlanRange.z) / (uHullPlanRange.w - uHullPlanRange.z) + ); + if (uv.x <= 0.0 || uv.x >= 1.0 || uv.y <= 0.0) return false; + float halfBreadth = texture2D(uHullPlan, vec2(uv.x, min(uv.y, 0.999))).r * ${SEA.plan.scale.toFixed(3)}; + return abs(local.z) < halfBreadth - margin; +} +`; diff --git a/explainers/naval-drone/src/scene/parts/water/hullWater.ts b/explainers/naval-drone/src/scene/parts/water/hullWater.ts new file mode 100644 index 00000000..42e844ea --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/hullWater.ts @@ -0,0 +1,195 @@ +import { Group } from 'three'; +import type { Matrix4, Object3D, ShaderMaterial, Texture } from 'three'; +import { clamp, lerp, smoothstep } from '@core/math'; +import type { AssemblyState, PartId } from '../../../ids'; +import { BOAT, HULL_DETAIL, TRANSOM_X } from '../../../model/layout'; +import { HULL_WATER, LABEL_SPOTS } from '../../constants'; +import { halfBreadthAt, hullSectionAt } from '../../geometry/hullLines'; +import type { Vec3 } from '../../geometry/surface'; +import type { PartContext } from '../context'; +import { SheetGrid, foamSheetMaterial } from './foamSheet'; +import type { SheetLook } from './foamSheet'; +import { seaHeightAt } from './waves'; + +type Side = -1 | 1; +type Kind = 'bow' | 'waterline' | 'stern' | 'spray'; +type Shape = (row: number, column: number, side: Side) => Vec3; + +interface Sheet { + material: ShaderMaterial; + grids: SheetGrid[]; +} + +const SIDES: readonly Side[] = [-1, 1]; +const TUNING = HULL_WATER.tuning; +const SHOWN = HULL_WATER.levels.shown; + +function hump(knots: number, peak: number, width: number): number { + return Math.exp(-(((knots - peak) / width) ** 2)); +} + +export function waterLevels(knots: number): Record { + const { bow, spray, waterline, levels } = HULL_WATER; + return { + bow: + smoothstep(knots, ...bow.onFrom) * + (1 - smoothstep(knots, ...bow.offFrom)) * + lerp(levels.bowFloor, 1, smoothstep(knots, bow.onFrom[0], bow.peakKnots)), + stern: hump(knots, levels.sternPeak, levels.sternWidth) * smoothstep(knots, ...levels.sternOn), + spray: smoothstep(knots, ...spray.onFrom), + waterline: smoothstep(knots, ...waterline.from), + }; +} + +export class LocalWater { + private elements: number[] = []; + + use(matrix: Matrix4): void { + this.elements = matrix.elements; + } + + level(x: number, z: number): number { + const e = this.elements; + const surface = seaHeightAt(e[0] * x + e[8] * z + e[12], e[2] * x + e[10] * z + e[14]); + return (surface - e[13] - e[1] * x - e[9] * z) / e[5]; + } +} + +export class HullWaterPart { + readonly object = new Group(); + readonly bowAnchor = new Group(); + readonly sprayAnchor = new Group(); + private readonly water = new LocalWater(); + private readonly sheets: Record; + private clock = 0; + private levels = waterLevels(0); + + constructor(context: PartContext, foamMap: Texture, groups: { bow: PartId; spray: PartId }) { + const { looks, profiles } = HULL_WATER; + const sheet = ( + group: PartId, + look: SheetLook, + profile: readonly [number, number], + sides: number, + kind: Kind, + ): Sheet => { + const material = foamSheetMaterial(context, group, foamMap, look, profile); + const { rows, columns } = HULL_WATER[kind]; + const grids = Array.from( + { length: sides }, + () => new SheetGrid(context, material, rows, columns), + ); + grids.forEach((grid) => this.object.add(grid.mesh)); + return { material, grids }; + }; + this.sheets = { + bow: sheet(groups.bow, looks.bow, profiles.bow, SIDES.length, 'bow'), + waterline: sheet('wake', looks.waterline, profiles.line, SIDES.length, 'waterline'), + stern: sheet(groups.bow, looks.stern, profiles.mound, 1, 'stern'), + spray: sheet(groups.spray, looks.spray, profiles.sheet, SIDES.length, 'spray'), + }; + this.sheets.bow.grids[0].mesh.add(this.bowAnchor); + this.sheets.spray.grids[0].mesh.add(this.sprayAnchor); + } + + setState(state: AssemblyState, body: Object3D): void { + const { boat, planing, view } = state; + body.updateMatrixWorld(true); + this.water.use(body.matrixWorld); + this.levels = waterLevels(boat.knots); + const emphasis = view.flow ? HULL_WATER.emphasis : 1; + const front = TRANSOM_X + planing.keelWettedLength; + const chineX = TRANSOM_X + Math.max(planing.chineWettedLength, HULL_WATER.spray.root[0]); + this.place('bow', emphasis, this.bowShape(front)); + this.place('waterline', emphasis, this.lineShape(front)); + this.place('stern', emphasis, this.sternShape()); + this.place('spray', emphasis, this.sprayShape(chineX, boat.knots)); + const [portSpray] = this.sheets.spray.grids; + portSpray.mesh.visible &&= !view.cutaway; + const bowX = front - TUNING.anchorBack; + const bowY = this.water.level(front, 0); + const bowSide = -(halfBreadthAt(bowX, bowY) + LABEL_SPOTS.bowOut); + this.bowAnchor.position.set(bowX, bowY + HULL_WATER.bow.height / 2, bowSide); + const chine = hullSectionAt(chineX); + this.sprayAnchor.position.set(chineX, chine.chine[1], -chine.flat[0] - TUNING.sprayOut); + } + + private place(kind: Kind, emphasis: number, shape: Shape): void { + const { material, grids } = this.sheets[kind]; + const level = this.levels[kind]; + material.uniforms.uStrength.value = level * emphasis; + grids.forEach((grid, index) => { + grid.mesh.visible = level > SHOWN; + if (grid.mesh.visible) grid.set((row, column) => shape(row, column, SIDES[index])); + }); + } + + private hullSide(x: number, side: Side): { y: number; z: number } { + const y = this.water.level(x, side * hullSectionAt(x).chine[0]); + return { y, z: side * (halfBreadthAt(x, y) + TUNING.hullGap) }; + } + + private bowShape(front: number): Shape { + const { length, height, peakAt, curl, spread, flare, rows, columns } = HULL_WATER.bow; + const reach = lerp(length[0], length[1], this.levels.bow); + return (row, column, side) => { + const along = row / (rows - 1); + const x = front - TUNING.bowStart - along * reach; + const { y, z } = this.hullSide(x, side); + const rise = + along < peakAt + ? Math.sin((along / peakAt) * (Math.PI / 2)) + : Math.exp(-(along - peakAt) * TUNING.bowDecay); + const crest = height * this.levels.bow * rise; + const share = column / (columns - 1); + const out = (spread * crest + along * reach * Math.tan(flare) * TUNING.bowFlare) * share; + const arc = Math.sin(Math.min(share / curl, 1) * Math.PI) * crest; + const fall = share > curl ? ((share - curl) / (1 - curl)) ** 2 * crest * TUNING.bowFall : 0; + return [x - share * crest * TUNING.bowLean, y + arc - fall, z + side * out]; + }; + } + + private lineShape(front: number): Shape { + const { rows, columns, width } = HULL_WATER.waterline; + return (row, column, side) => { + const x = lerp(front, TRANSOM_X, row / (rows - 1)); + const { y, z } = this.hullSide(x, side); + return [x, y + TUNING.lineLift, z + side * width * (column / (columns - 1))]; + }; + } + + private sternShape(): Shape { + const { rows, columns, length, halfWidth, height, peak, width } = HULL_WATER.stern; + return (row, column) => { + const z = lerp(-halfWidth, halfWidth, row / (rows - 1)); + const share = column / (columns - 1); + const x = TRANSOM_X - TUNING.sternStart - share * length; + const across = 1 - (z / (halfWidth * TUNING.sternTaper)) ** 2; + const bump = Math.exp(-(((share - peak) / width) ** 2)) * across; + return [x, this.water.level(x, z) + height * this.levels.stern * bump + TUNING.sternLift, z]; + }; + } + + private sprayShape(chineX: number, knots: number): Shape { + const { rows, columns, range, root, onFrom, up, back, trail, drop, skim } = HULL_WATER.spray; + const reach = lerp(range[0], range[1], smoothstep(knots, onFrom[0], BOAT.topKnots)); + const rootLength = lerp(root[0], root[1], this.levels.spray); + return (row, column, side) => { + const x = chineX + (row / (rows - 1)) * rootLength; + const section = hullSectionAt(clamp(x, TRANSOM_X, HULL_DETAIL.chineFlat.endX)); + const distance = (column / (columns - 1)) * reach * this.levels.spray; + const out = Math.cos(up) * distance; + const px = x - Math.sin(back) * out * trail; + const pz = side * (section.flat[0] + Math.cos(back) * out); + const rise = section.chine[1] + Math.sin(up) * distance - drop * distance * distance; + return [px, Math.max(rise, this.water.level(px, pz) + skim), pz]; + }; + } + + advance(deltaSeconds: number): void { + this.clock += deltaSeconds; + Object.values(this.sheets).forEach( + ({ material }) => (material.uniforms.uTime.value = this.clock), + ); + } +} diff --git a/explainers/naval-drone/src/scene/parts/water/jetStream.ts b/explainers/naval-drone/src/scene/parts/water/jetStream.ts new file mode 100644 index 00000000..c9869aa0 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/jetStream.ts @@ -0,0 +1,151 @@ +import { AdditiveBlending, Group } from 'three'; +import type { Object3D, ShaderMaterial, Texture } from 'three'; +import { lerp, smoothstep } from '@core/math'; +import { PointCloud, createPointMaterial } from '@core/scene/pointCloud'; +import type { AssemblyState } from '../../../ids'; +import { JET } from '../../../model/layout'; +import { GRAVITY, knotsToMs } from '../../../model/scale'; +import { JET_STREAM } from '../../constants'; +import type { Vec3 } from '../../geometry/surface'; +import { registered } from '../context'; +import type { PartContext } from '../context'; +import { seededRandom } from '../surfaces'; +import { SheetGrid, foamSheetMaterial } from './foamSheet'; +import { LocalWater } from './hullWater'; + +const TUNING = JET_STREAM.tuning; +const EXIT = JET.steeringNozzle.x[0] - JET.steeringNozzle.pivotX; + +export class JetStreamPart { + readonly reverse = new Group(); + readonly rooster: PointCloud; + readonly anchor = new Group(); + private readonly column: SheetGrid; + private readonly sides: SheetGrid[]; + private readonly columnMaterial: ShaderMaterial; + private readonly reverseMaterial: ShaderMaterial; + private readonly water = new LocalWater(); + private readonly seeds: readonly (readonly number[])[]; + private clock = 0; + private level = 0; + + constructor(context: PartContext, foamMap: Texture, steering: Object3D) { + const { segments, rings, reverse, look, profile, rooster } = JET_STREAM; + const material = (opacity: number) => + foamSheetMaterial(context, 'jetStream', foamMap, { ...look, opacity }, profile, false); + this.columnMaterial = material(look.opacity); + this.reverseMaterial = material(look.opacity * reverse.fade); + this.column = new SheetGrid(context, this.columnMaterial, segments + 1, rings + 1); + steering.add(this.column.mesh); + this.column.mesh.add(this.anchor); + this.sides = [-1, 1].map(() => { + const grid = new SheetGrid(context, this.reverseMaterial, segments + 1, reverse.rings + 1); + this.reverse.add(grid.mesh); + return grid; + }); + const points = createPointMaterial(context.textures.dot, rooster.size, AdditiveBlending); + this.rooster = new PointCloud(rooster.count, registered(context, 'wake', points)); + const random = seededRandom(rooster.seed); + this.seeds = Array.from({ length: rooster.count }, () => [ + random(), + random(), + random(), + random(), + ]); + context.tracker.track({ dispose: () => this.rooster.dispose() }); + } + + private tube( + grid: SheetGrid, + rings: number, + centre: (share: number) => Vec3, + radius: (share: number) => number, + ) { + const { segments } = JET_STREAM; + grid.set((row, column) => { + const share = column / rings; + const angle = (row / segments) * Math.PI * 2; + const [x, y, z] = centre(share); + const r = radius(share); + return [x, y + r * Math.cos(angle), z + r * Math.sin(angle)]; + }); + } + + setState(state: AssemblyState, body: Object3D): void { + const { jet, boat, view } = state; + const { rings, exitRadius, spread, reverse } = JET_STREAM; + const emphasis = view.flow ? JET_STREAM.emphasis : 1; + const length = lerp( + ...JET_STREAM.length, + smoothstep(jet.jetSpeed * JET_STREAM.lengthPerSpeed, 0, 2), + ); + const backing = jet.bucket > TUNING.reverseOn; + this.column.mesh.visible = jet.flow > 0 && !backing; + this.reverse.visible = jet.flow > 0 && backing; + const strength = emphasis * smoothstep(jet.throttle, 0, TUNING.throttleFade); + const scroll = jet.jetSpeed / Math.max(length, TUNING.minLength); + [this.columnMaterial, this.reverseMaterial].forEach(({ uniforms }) => { + uniforms.uStrength.value = strength; + uniforms.uSheet.value.y = scroll; + }); + const drop = (share: number) => + 0.5 * GRAVITY * ((share * length) / Math.max(jet.jetSpeed, 1)) ** 2; + this.tube( + this.column, + rings, + (share) => [EXIT - share * length, -drop(share), 0], + (share) => exitRadius + share * length * spread, + ); + this.anchor.position.set(EXIT - length * TUNING.anchorShare, 0, 0); + const [cx, cy] = TUNING.reverseCentre; + this.sides.forEach((grid, index) => { + const side = index * 2 - 1; + this.tube( + grid, + reverse.rings, + (share) => [ + cx + share * reverse.length * Math.cos(reverse.angle), + cy - + reverse.dive * Math.sin(share * Math.PI * TUNING.reverseArc) - + share * TUNING.reverseSink, + side * share * reverse.length * Math.sin(reverse.angle), + ], + (share) => reverse.radius * (1 + share * TUNING.reverseGrow), + ); + }); + body.updateMatrixWorld(true); + this.water.use(body.matrixWorld); + this.level = backing + ? 0 + : smoothstep(boat.knots, ...JET_STREAM.rooster.onFrom) * jet.throttle * emphasis; + this.placeRooster(knotsToMs(boat.knots)); + } + + private placeRooster(speed: number): void { + const { count, life, aft, up, spread } = JET_STREAM.rooster; + this.rooster.points.visible = this.level > TUNING.shown; + if (!this.rooster.points.visible) return; + const [lateralBase, liftBase, aftBase, aftSpread] = TUNING.jitter; + for (let index = 0; index < count; index += 1) { + const [phase, across, rise, drift] = this.seeds[index]; + const age = ((this.clock / life + phase) % 1) * life; + const lift = up * (liftBase + (1 - liftBase) * rise) * this.level; + const x = + JET.steeringNozzle.x[0] - + (aft * (aftBase + aftSpread * drift) + speed * TUNING.speedDrift) * age; + const z = (across - 0.5) * spread * (lateralBase + drift) * age * TUNING.lateralGain; + const height = lift * age - 0.5 * GRAVITY * age * age; + const alpha = height > 0 ? (1 - age / life) * TUNING.alpha * this.level : 0; + this.rooster.setPoint(index, x, this.water.level(x, z) + Math.max(height, 0), z); + this.rooster.setColor(index, 1, 1, 1, alpha); + } + this.rooster.commit(); + } + + advance(deltaSeconds: number, state: AssemblyState): void { + this.clock += deltaSeconds; + this.columnMaterial.uniforms.uTime.value = this.clock; + this.reverseMaterial.uniforms.uTime.value = this.clock; + this.placeRooster(knotsToMs(state.boat.knots)); + } +} diff --git a/explainers/naval-drone/src/scene/parts/water/sea.ts b/explainers/naval-drone/src/scene/parts/water/sea.ts new file mode 100644 index 00000000..40f82c23 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/sea.ts @@ -0,0 +1,231 @@ +import { + BufferGeometry, + Color, + Float32BufferAttribute, + Matrix4, + Mesh, + ShaderMaterial, + UniformsLib, + UniformsUtils, + Vector2, + Vector4, +} from 'three'; +import type { Camera, Object3D, Texture } from 'three'; +import { UNDIMMED_GROUP } from '@core/scene/materials'; +import { LIGHT_RIG, SEA } from '../../constants'; +import { registered } from '../context'; +import type { PartContext } from '../context'; +import { HULL_MASK_GLSL, hullPlanRange, hullPlanTexture } from './hullPlan'; +import { rippleTexture } from './seaMaps'; +import { SKY_GLSL, skyUniforms } from './skyShade'; +import { SECTION_GLSL, sectionUniforms } from './sectionMask'; +import type { SectionUniforms } from './sectionMask'; +import { WATER, WAVE_GLSL } from './waves'; + +const VERTEX = /* glsl */ ` +#include +${WAVE_GLSL} +uniform vec2 uGridOrigin; +uniform vec2 uCellShape; +varying vec3 vWorld; +void main() { + vec2 at = position.xz + uGridOrigin; + float cell = length(position.xz) * uCellShape.x + uCellShape.y; + vec3 wave = seaWave(at, cell); + vec3 world = vec3(at.x, wave.x, at.y); + vWorld = world; + vec4 mvPosition = viewMatrix * vec4(world, 1.0); + gl_Position = projectionMatrix * mvPosition; + #include +} +`; + +const FRAGMENT = /* glsl */ ` +#include +${WAVE_GLSL} +${SKY_GLSL} +${HULL_MASK_GLSL} +${SECTION_GLSL} +uniform sampler2D uRipples; +uniform sampler2D uFoamMap; +uniform vec3 uDeep; +uniform vec3 uLit; +uniform vec3 uScatter; +uniform vec3 uFoamColour; +uniform vec3 uSunColour; +uniform vec4 uRippleScale; +uniform vec4 uRippleFlow; +uniform vec2 uRippleFade; +uniform vec4 uGlitter; +uniform vec4 uFoamShape; +uniform vec2 uFresnel; +uniform float uFoamLevel; +uniform float uWaveScale; +uniform float uHullMargin; +varying vec3 vWorld; + +void main() { + float cut = sectionCut(vWorld); + if (cut > 0.99 || insideHull(vWorld, uHullMargin)) discard; + float dist = length(cameraPosition - vWorld); + float footprint = dist * 0.002; + vec3 wave = seaWave(vWorld.xz, footprint * 4.0); + vec2 base = vWorld.xz + uSeaDrift; + vec2 near = 1.0 - smoothstep(uRippleFade.x, uRippleFade.y, dist) * vec2(0.65, 0.95); + vec2 r1 = texture2D(uRipples, base * uRippleScale.x + uSeaTime * uRippleFlow.xy).xy - 0.5; + vec2 r2 = texture2D(uRipples, base * uRippleScale.y + uSeaTime * uRippleFlow.zw).xy - 0.5; + vec2 r3 = texture2D(uRipples, base * uRippleScale.w + uSeaTime * uRippleFlow.yx * 0.3).xy - 0.5; + vec2 ripple = (r1 * near.x + r2 * near.y * 0.7 + r3 * 0.55) * uRippleScale.z; + vec3 normal = normalize(vec3(-wave.y - ripple.x, 1.0, -wave.z - ripple.y)); + vec3 view = normalize(cameraPosition - vWorld); + float facing = max(dot(normal, view), 0.0); + float fresnel = uFresnel.x + (1.0 - uFresnel.x) * pow(1.0 - facing, uFresnel.y); + vec3 reflected = reflect(-view, normal); + reflected.y = abs(reflected.y); + vec3 sky = skyColour(reflected); + float sunSide = pow(0.5 + 0.5 * dot(normalize(-view.xz + vec2(1e-5)), normalize(uSun.xz)), 2.0); + float crest = wave.x / max(uWaveScale, 0.05); + vec3 body = mix(uDeep, uLit, sunSide * 0.85); + body += uScatter * max(crest, 0.0) * 0.16 * (0.3 + sunSide) * (1.0 - facing * 0.7); + vec3 colour = mix(body, sky, fresnel); + float sunDot = max(dot(reflected, uSun), 0.0); + float sharp = mix(uGlitter.x, uGlitter.w, smoothstep(30.0, 1200.0, dist)); + colour += uSunColour * (pow(sunDot, sharp) * uGlitter.z + pow(sunDot, uGlitter.y) * 0.25) * (0.3 + 0.7 * fresnel); + float noise = texture2D(uFoamMap, base * uFoamShape.x + uSeaTime * 0.012).r; + float streaks = texture2D(uFoamMap, base * uFoamShape.x * 4.3 + vec2(0.37, 0.61)).r; + float fine = texture2D(uFoamMap, base * uFoamShape.x * 11.0 + vec2(0.71, 0.13)).r; + float cap = smoothstep(uFoamShape.y, uFoamShape.z, crest + (noise - 0.5) * 0.45); + float lace = smoothstep(0.5, 0.56, streaks * 0.55 + fine * 0.45 + cap * 0.2); + float foam = cap * lace * uFoamLevel * (1.0 - smoothstep(60.0, 450.0, dist)); + colour = mix(colour, uFoamColour * (0.72 + 0.28 * sunSide), foam); + gl_FragColor = vec4(colour, 1.0 - cut); + #include + #include +} +`; + +function polarGrid(): BufferGeometry { + const { rings, segments, innerRadius, growth } = SEA; + const positions: number[] = [0, 0, 0]; + for (let ring = 0; ring <= rings; ring += 1) { + const radius = innerRadius * growth ** ring; + for (let segment = 0; segment < segments; segment += 1) { + const angle = (segment / segments) * Math.PI * 2; + positions.push(Math.cos(angle) * radius, 0, Math.sin(angle) * radius); + } + } + const indices: number[] = []; + for (let segment = 0; segment < segments; segment += 1) { + indices.push(0, 1 + ((segment + 1) % segments), 1 + segment); + } + for (let ring = 0; ring < rings; ring += 1) { + const inner = 1 + ring * segments; + const outer = inner + segments; + for (let segment = 0; segment < segments; segment += 1) { + const next = (segment + 1) % segments; + indices.push( + inner + segment, + inner + next, + outer + segment, + inner + next, + outer + next, + outer + segment, + ); + } + } + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.setIndex(indices); + return geometry; +} + +export class SeaPart { + readonly mesh: Mesh; + readonly section: SectionUniforms; + readonly hullMask: Record; + private readonly material: ShaderMaterial; + private readonly boatInverse = new Matrix4(); + + constructor(context: PartContext, foamMap: Texture) { + const { colours, ripples, glitter, foam, fresnel } = SEA; + this.section = sectionUniforms(); + const { tracker } = context; + this.material = registered( + context, + UNDIMMED_GROUP, + new ShaderMaterial({ + uniforms: { + ...UniformsUtils.clone(UniformsLib.fog), + ...skyUniforms(), + ...this.section, + uSeaTime: WATER.uSeaTime, + uSeaDrift: WATER.uSeaDrift, + uWaves: WATER.uWaves, + uFoamLevel: WATER.uFoamLevel, + uWaveScale: { value: 1 }, + uGridOrigin: { value: new Vector2() }, + uCellShape: { value: new Vector2(SEA.growth - 1, SEA.innerRadius) }, + uBoatInverse: { value: this.boatInverse }, + uHullPlan: { value: tracker.track(hullPlanTexture()) }, + uHullPlanRange: { value: hullPlanRange() }, + uHullMargin: { value: SEA.hullMargin }, + uOpenPort: { value: 0 }, + uRipples: { value: tracker.track(rippleTexture()) }, + uFoamMap: { value: foamMap }, + uDeep: { value: new Color(colours.deep) }, + uLit: { value: new Color(colours.lit) }, + uScatter: { value: new Color(colours.scatter) }, + uFoamColour: { value: new Color(colours.foam) }, + uSunColour: { value: new Color(LIGHT_RIG.key.color) }, + uRippleScale: { + value: new Vector4( + ripples.scales[0], + ripples.scales[1], + ripples.strength, + ripples.broad, + ), + }, + uRippleFlow: { value: new Vector4(...ripples.flow[0], ...ripples.flow[1]) }, + uRippleFade: { value: new Vector2(ripples.fadeFrom, ripples.fadeTo) }, + uGlitter: { + value: new Vector4(glitter.sharp, glitter.broad, glitter.sharpGain, glitter.farSharp), + }, + uFoamShape: { value: new Vector4(foam.scale, foam.crestFrom, foam.crestTo, 0) }, + uFresnel: { value: new Vector2(fresnel.base, fresnel.power) }, + }, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + fog: true, + transparent: true, + toneMapped: false, + }), + ); + const { uBoatInverse, uHullPlan, uHullPlanRange, uOpenPort } = this.material.uniforms; + this.hullMask = { uBoatInverse, uHullPlan, uHullPlanRange, uOpenPort }; + this.mesh = new Mesh(tracker.track(polarGrid()), this.material); + this.mesh.frustumCulled = false; + this.mesh.renderOrder = SEA.renderOrder; + const origin = this.material.uniforms.uGridOrigin.value as Vector2; + this.mesh.onBeforeRender = (_renderer, _scene, camera: Camera) => { + const snap = SEA.snap; + origin.set( + Math.round(camera.position.x / snap) * snap, + Math.round(camera.position.z / snap) * snap, + ); + }; + } + + followBoat(body: Object3D): void { + body.updateMatrixWorld(true); + this.boatInverse.copy(body.matrixWorld).invert(); + } + + openPort(open: boolean): void { + this.material.uniforms.uOpenPort.value = open ? 1 : 0; + } + + setWaveScale(waveHeight: number, foamLevel: number): void { + this.material.uniforms.uWaveScale.value = waveHeight / 2; + WATER.uFoamLevel.value = foamLevel; + } +} diff --git a/explainers/naval-drone/src/scene/parts/water/seaMaps.ts b/explainers/naval-drone/src/scene/parts/water/seaMaps.ts new file mode 100644 index 00000000..ad806da5 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/seaMaps.ts @@ -0,0 +1,102 @@ +import type { DataTexture } from 'three'; +import { SEA } from '../../constants'; +import { repeating, seededRandom } from '../surfaces'; + +const CHANNELS = 4; +const BYTE = 255; +const HALF = 0.5; +const SLOPE_GAIN = 0.5; +const MIN_FREQUENCY = 3; +const FREQUENCY_SPAN = 26; +const FOAM_OCTAVES = 4; +const FOAM_BASE = 8; + +interface RippleWave { + kx: number; + ky: number; + amplitude: number; + phase: number; +} + +export function rippleWaves(count: number, seed: number): RippleWave[] { + const random = seededRandom(seed); + return Array.from({ length: count }, () => { + const frequency = MIN_FREQUENCY + Math.floor(random() * FREQUENCY_SPAN); + const angle = random() * Math.PI * 2; + const kx = Math.round(Math.cos(angle) * frequency); + const ky = Math.round(Math.sin(angle) * frequency); + const length = Math.max(Math.hypot(kx, ky), 1); + return { kx, ky, amplitude: 1 / length, phase: random() * Math.PI * 2 }; + }); +} + +export function rippleTexture(): DataTexture { + const { size, waves, seed } = SEA.ripples; + const set = rippleWaves(waves, seed); + const pixels = new Uint8Array(size * size * CHANNELS); + const total = set.reduce((sum, wave) => sum + wave.amplitude * Math.hypot(wave.kx, wave.ky), 0); + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + let dx = 0; + let dy = 0; + set.forEach(({ kx, ky, amplitude, phase }) => { + const angle = ((kx * x + ky * y) / size) * Math.PI * 2 + phase; + const slope = amplitude * Math.cos(angle); + dx += slope * kx; + dy += slope * ky; + }); + const offset = (y * size + x) * CHANNELS; + pixels[offset] = Math.round((HALF + (SLOPE_GAIN * dx) / total) * BYTE); + pixels[offset + 1] = Math.round((HALF + (SLOPE_GAIN * dy) / total) * BYTE); + pixels[offset + 2] = BYTE; + pixels[offset + 3] = BYTE; + } + } + return repeating(pixels, size); +} + +function periodicNoise( + size: number, + cells: number, + random: () => number, +): (x: number, y: number) => number { + const grid = Array.from({ length: cells * cells }, () => random()); + const at = (i: number, j: number) => + grid[(((j % cells) + cells) % cells) * cells + (((i % cells) + cells) % cells)]; + return (x, y) => { + const fx = (x / size) * cells; + const fy = (y / size) * cells; + const i = Math.floor(fx); + const j = Math.floor(fy); + const tx = fx - i; + const ty = fy - j; + const sx = tx * tx * (3 - 2 * tx); + const sy = ty * ty * (3 - 2 * ty); + const bottom = at(i, j) + (at(i + 1, j) - at(i, j)) * sx; + const top = at(i, j + 1) + (at(i + 1, j + 1) - at(i, j + 1)) * sx; + return bottom + (top - bottom) * sy; + }; +} + +export function foamTexture(): DataTexture { + const { size, seed } = SEA.foam; + const random = seededRandom(seed); + const octaves = Array.from({ length: FOAM_OCTAVES }, (_, index) => + periodicNoise(size, FOAM_BASE * 2 ** index, random), + ); + const pixels = new Uint8Array(size * size * CHANNELS); + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + let value = 0; + let weight = HALF; + octaves.forEach((noise) => { + value += weight * noise(x, y); + weight *= HALF; + }); + const cells = 1 - Math.abs(2 * octaves[1](x, y) - 1); + const byte = Math.round(Math.min(1, value * 0.9 + cells * 0.35) * BYTE); + pixels.set([byte, byte, byte, BYTE], (y * size + x) * CHANNELS); + } + } + return repeating(pixels, size); +} diff --git a/explainers/naval-drone/src/scene/parts/water/sectionMask.ts b/explainers/naval-drone/src/scene/parts/water/sectionMask.ts new file mode 100644 index 00000000..8889f462 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/sectionMask.ts @@ -0,0 +1,79 @@ +import { Matrix4, Vector2, Vector4 } from 'three'; +import type { Point } from '../../../ids'; +import { WATER_SECTION } from '../../../model/layout'; +import { SECTION_LOOK } from '../../constants'; + +export interface SectionUniforms { + uSection: { value: Matrix4 }; + uSectionBox: { value: Vector4 }; + uSectionDepth: { value: Vector2 }; +} + +export function sectionUniforms(): SectionUniforms { + const [x0, x1] = WATER_SECTION.x; + const [z0, z1] = WATER_SECTION.z; + return { + uSection: { value: new Matrix4() }, + uSectionBox: { value: new Vector4(x0, x1, z0, z1) }, + uSectionDepth: { value: new Vector2(SECTION_LOOK.depth, 0) }, + }; +} + +export function sectionFrame(position: Point, heading: number, target: Matrix4): Matrix4 { + return target.makeRotationY(-heading).setPosition(position[0], 0, position[2]); +} + +export function placeSection(uniforms: SectionUniforms, frame: Matrix4, on: boolean): void { + uniforms.uSection.value.copy(frame).invert(); + uniforms.uSectionDepth.value.y = on ? 1 : 0; +} + +const slope = (degrees: number) => Math.tan((degrees * Math.PI) / 180).toFixed(4); +const [LOW, HIGH] = SECTION_LOOK.elevation.map(slope); +const [FACE_LOW, FACE_HIGH] = SECTION_LOOK.faces.map(slope); +const [SIDE_FROM, SIDE_TO] = SECTION_LOOK.side; +const SOFT = SECTION_LOOK.soft; + +export const SECTION_GLSL = /* glsl */ ` +uniform mat4 uSection; +uniform vec4 uSectionBox; +uniform vec2 uSectionDepth; + +float sectionRise() { + vec3 c = (uSection * vec4(cameraPosition, 1.0)).xyz; + vec2 centre = vec2(uSectionBox.x + uSectionBox.y, uSectionBox.z + uSectionBox.w) * 0.5; + return c.y / max(length(c.xz - centre), 0.001); +} + +float sectionFade() { + if (uSectionDepth.y < 0.5) return 0.0; + float side = (uSection * vec4(cameraPosition, 1.0)).z; + return (1.0 - smoothstep(${LOW}, ${HIGH}, sectionRise())) * (1.0 - smoothstep(${SIDE_FROM.toFixed(2)}, ${SIDE_TO.toFixed(2)}, side)); +} + +float edgeIn(float value, vec2 span, float soft) { + return smoothstep(0.0, soft, min(value - span.x, span.y - value)); +} + +float throughFace(vec3 c, vec3 d, bool alongX, float plane, float outside, vec2 span) { + float start = alongX ? c.x : c.z; + float step = alongX ? d.x : d.z; + if (outside * (start - plane) <= 0.0 || outside * step >= 0.0) return 0.0; + float t = (plane - start) / step; + vec3 hit = c + d * t; + if (t <= 1.0 || hit.y >= 0.0) return 0.0; + return edgeIn(alongX ? hit.z : hit.x, span, ${SOFT.toFixed(2)}) * edgeIn(hit.y, vec2(-uSectionDepth.x, ${SOFT.toFixed(2)}), ${SOFT.toFixed(2)}); +} + +float sectionCut(vec3 world) { + float fade = sectionFade(); + if (fade <= 0.0) return 0.0; + vec3 p = (uSection * vec4(world, 1.0)).xyz; + vec3 c = (uSection * vec4(cameraPosition, 1.0)).xyz; + vec3 d = p - c; + vec4 b = uSectionBox; + float top = edgeIn(p.x, b.xy, ${SOFT.toFixed(2)}) * edgeIn(p.z, b.zw, ${SOFT.toFixed(2)}); + float faces = max(throughFace(c, d, false, b.z, -1.0, b.xy), max(throughFace(c, d, true, b.x, -1.0, b.zw), throughFace(c, d, true, b.y, 1.0, b.zw))); + return fade * max(top, faces * (1.0 - smoothstep(${FACE_LOW}, ${FACE_HIGH}, sectionRise()))); +} +`; diff --git a/explainers/naval-drone/src/scene/parts/water/skyShade.ts b/explainers/naval-drone/src/scene/parts/water/skyShade.ts new file mode 100644 index 00000000..3eb31515 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/skyShade.ts @@ -0,0 +1,42 @@ +import { Color, Vector3, Vector4 } from 'three'; +import { SKY, SUN_DIRECTION } from '../../constants'; + +export function skyUniforms() { + const { colours, heights, glow } = SKY; + return { + uZenith: { value: new Color(colours.zenith) }, + uUpper: { value: new Color(colours.upper) }, + uSunHorizon: { value: new Color(colours.sunHorizon) }, + uHaze: { value: new Color(colours.haze) }, + uGlow: { value: new Color(colours.glow) }, + uSunDisc: { value: new Color(colours.sun) }, + uSun: { value: new Vector3(...SUN_DIRECTION).normalize() }, + uSkyHeights: { value: new Vector3(heights.band, heights.upper, 0) }, + uSkyGlow: { value: new Vector4(glow.tight, glow.broad, glow.tightGain, glow.broadGain) }, + }; +} + +export const SKY_GLSL = /* glsl */ ` +uniform vec3 uZenith; +uniform vec3 uUpper; +uniform vec3 uSunHorizon; +uniform vec3 uHaze; +uniform vec3 uGlow; +uniform vec3 uSunDisc; +uniform vec3 uSun; +uniform vec3 uSkyHeights; +uniform vec4 uSkyGlow; + +vec3 skyColour(vec3 direction) { + float up = max(direction.y, 0.0); + vec2 flat2 = normalize(direction.xz + vec2(1e-5)); + float sunward = 0.5 + 0.5 * dot(flat2, normalize(uSun.xz)); + vec3 horizon = mix(uHaze, uSunHorizon, pow(sunward, 4.0)); + vec3 sky = mix(horizon, uUpper, smoothstep(0.0, uSkyHeights.y, pow(up, 0.8))); + sky = mix(sky, uZenith, smoothstep(uSkyHeights.y * 0.6, 1.0, up)); + sky = mix(sky, uHaze, (1.0 - smoothstep(0.0, uSkyHeights.x, up)) * (1.0 - sunward) * 0.6); + float near = max(dot(direction, uSun), 0.0); + sky += uGlow * (pow(near, uSkyGlow.x) * uSkyGlow.z + pow(near, uSkyGlow.y) * uSkyGlow.w * exp(-up * 5.0)); + return sky; +} +`; diff --git a/explainers/naval-drone/src/scene/parts/water/wake.ts b/explainers/naval-drone/src/scene/parts/water/wake.ts new file mode 100644 index 00000000..239cd492 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/wake.ts @@ -0,0 +1,208 @@ +import { + Group, + BufferAttribute, + BufferGeometry, + Color, + DynamicDrawUsage, + Mesh, + ShaderMaterial, + Vector2, +} from 'three'; +import type { Texture } from 'three'; +import { clamp, lerp, smoothstep } from '@core/math'; +import type { PartId } from '../../../ids'; +import { LABEL_SPOTS, SEA, WAKE } from '../../constants'; +import { THEME } from '../../../theme'; +import type { TrailSample } from '../../geometry/trail'; +import { registered } from '../context'; +import type { PartContext } from '../context'; +import { WATER, WAVE_GLSL } from './waves'; + +export interface WakeParams { + knots: number; + plane: number; + bowAhead: number; + emphasis: boolean; +} + +const VERTEX = /* glsl */ ` +${WAVE_GLSL} +attribute vec4 aWake; +attribute float aHeading; +uniform vec2 uCellShape; +varying vec4 vWake; +varying vec3 vWorld; +varying float vHeading; +void main() { + vHeading = aHeading; + float cell = length(position.xz - cameraPosition.xz) * uCellShape.x + uCellShape.y; + vec3 world = vec3(position.x, seaWave(position.xz, cell).x + ${WAKE.lift.toFixed(3)}, position.z); + vWake = aWake; + vWorld = world; + gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0); +} +`; + +const FRAGMENT = /* glsl */ ` +uniform sampler2D uFoamMap; +uniform vec3 uFoam; +uniform vec3 uTint; +uniform vec4 uShape; +uniform float uFoamScale; +uniform float uSeaTime; +uniform vec2 uSeaDrift; +varying vec4 vWake; +varying vec3 vWorld; +varying float vHeading; +float foamAt(vec2 p) { + return texture2D(uFoamMap, p).r; +} +void main() { + float behind = vWake.x; + float across = vWake.y; + vec2 dir = vec2(cos(vHeading), sin(vHeading)); + vec2 p = vWorld.xz + uSeaDrift; + vec2 q = vec2(dot(p, dir), dot(p, vec2(-dir.y, dir.x))) * uFoamScale; + float t = uSeaTime; + vec2 warp = vec2(foamAt(q * 0.05 + vec2(t * 0.013, 0.0)), foamAt(q * 0.05 + vec2(0.5, t * 0.011))) - 0.5; + vec2 w = q + warp * 1.6; + float fine = mix(foamAt(w * 1.7 + vec2(0.37, -t * 0.06)), 0.6, smoothstep(10.0, 50.0, distance(cameraPosition, vWorld))); + float churn = foamAt(w * 0.55 + vec2(0.0, t * 0.04)) * 0.55 + fine * 0.45; + float streaks = foamAt(w * vec2(0.03, 0.3)) * 0.55 + foamAt(w * vec2(0.09, 0.9) + vec2(0.21, 0.6)) * 0.45; + float n = mix(churn, streaks, smoothstep(4.0, 60.0, behind)); + float edge = abs(across) + warp.x * 0.3; + float density; + if (vWake.z < 0.5) { + float body = 1.0 - smoothstep(0.3, 0.85, edge); + float lines = exp(-pow((abs(across) - 0.86) / 0.16, 2.0)) * exp(-behind / (uShape.z * 0.6)); + density = body * (exp(-behind / uShape.x) * 1.1 + exp(-behind / uShape.y) * 0.5) + lines * 0.6; + } else { + density = (1.0 - smoothstep(0.2, 0.85, edge)) * smoothstep(0.0, 4.0, behind) * exp(-behind / uShape.z) * 0.65; + } + density = clamp(density * uShape.w * vWake.w * smoothstep(0.0, 0.1, behind), 0.0, 1.0); + float foam = smoothstep(0.85 - density * 0.55, 1.2 - density * 0.55, n) * smoothstep(0.0, 0.12, density); + float tint = clamp(density, 0.0, 1.0) * 0.16; + vec3 colour = mix(uTint, uFoam * (0.88 + 0.12 * churn), foam / max(foam + tint, 1e-3)); + gl_FragColor = vec4(colour, clamp(max(foam * 0.8, tint), 0.0, 0.8)); + #include +} +`; + +export class WakePart { + readonly mesh: Mesh; + readonly anchor = new Group(); + private readonly material: ShaderMaterial; + private readonly positions: Float32Array; + private readonly params: Float32Array; + private readonly headings: Float32Array; + private readonly geometry = new BufferGeometry(); + + constructor(context: PartContext, group: PartId, foamMap: Texture) { + const { samples, across, armAcross } = WAKE; + const count = samples * across + 2 * samples * armAcross; + this.positions = new Float32Array(count * 3); + this.params = new Float32Array(count * 4); + this.headings = new Float32Array(count); + const position = new BufferAttribute(this.positions, 3).setUsage(DynamicDrawUsage); + const wake = new BufferAttribute(this.params, 4).setUsage(DynamicDrawUsage); + this.geometry.setAttribute('position', position); + this.geometry.setAttribute('aWake', wake); + this.geometry.setAttribute( + 'aHeading', + new BufferAttribute(this.headings, 1).setUsage(DynamicDrawUsage), + ); + this.geometry.setIndex(this.indices()); + this.material = registered( + context, + group, + new ShaderMaterial({ + uniforms: { + uSeaTime: WATER.uSeaTime, + uSeaDrift: WATER.uSeaDrift, + uWaves: WATER.uWaves, + uCellShape: { value: new Vector2(SEA.growth - 1, SEA.innerRadius) }, + uFoamMap: { value: foamMap }, + uFoam: { value: new Color(THEME.foam) }, + uTint: { value: new Color(WAKE.tint) }, + uShape: { value: [WAKE.fade.core, WAKE.fade.wash, WAKE.fade.arm, 1] }, + uFoamScale: { value: WAKE.foamScale }, + }, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + transparent: true, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -2, + polygonOffsetUnits: -2, + toneMapped: false, + }), + ); + this.mesh = new Mesh(context.tracker.track(this.geometry), this.material); + this.mesh.frustumCulled = false; + this.mesh.renderOrder = WAKE.renderOrder; + this.mesh.add(this.anchor); + } + + private indices(): number[] { + const { samples, across, armAcross } = WAKE; + const indices: number[] = []; + const strip = (start: number, width: number) => { + for (let i = 0; i < samples - 1; i += 1) { + for (let j = 0; j < width - 1; j += 1) { + const a = start + i * width + j; + const b = a + width; + indices.push(a, b, a + 1, a + 1, b, b + 1); + } + } + }; + strip(0, across); + strip(samples * across, armAcross); + strip(samples * across + samples * armAcross, armAcross); + return indices; + } + + private put( + index: number, + sample: TrailSample, + lateral: number, + params: readonly [behind: number, across: number, kind: number], + ) { + const sx = -Math.sin(sample.heading); + const sz = Math.cos(sample.heading); + this.positions.set([sample.x + sx * lateral, 0, sample.z + sz * lateral], index * 3); + this.params.set([...params, sample.live ? 1 : 0], index * 4); + this.headings[index] = sample.heading; + } + + setTrail(trail: readonly TrailSample[], params: WakeParams): void { + const { across, armAcross, samples } = WAKE; + const strength = smoothstep(params.knots, ...WAKE.speedFrom); + const armAngle = lerp(WAKE.kelvin, WAKE.planedArm, params.plane); + const armStart = lerp(params.bowAhead, 0, params.plane); + trail.forEach((sample, i) => { + const half = WAKE.coreHalfWidth + sample.behind * Math.tan(WAKE.coreSpread); + for (let j = 0; j < across; j += 1) { + const v = (j / (across - 1)) * 2 - 1; + this.put(i * across + j, sample, v * half, [sample.behind, v, 0]); + } + const reach = clamp(sample.behind + armStart, 0, WAKE.armReach); + const offset = WAKE.coreHalfWidth + reach * Math.tan(armAngle); + const width = lerp(WAKE.armWidth[0], WAKE.armWidth[1], reach / WAKE.armReach); + [-1, 1].forEach((side, arm) => { + const base = samples * across + arm * samples * armAcross + i * armAcross; + for (let j = 0; j < armAcross; j += 1) { + const v = (j / (armAcross - 1)) * 2 - 1; + this.put(base + j, sample, side * (offset + v * width), [reach, v, 1]); + } + }); + }); + this.geometry.getAttribute('position').needsUpdate = true; + this.geometry.getAttribute('aWake').needsUpdate = true; + this.geometry.getAttribute('aHeading').needsUpdate = true; + const spot = trail.find((sample) => sample.behind >= LABEL_SPOTS.wakeBehind) ?? trail[0]; + this.anchor.position.set(spot.x, 0, spot.z); + const shape = this.material.uniforms.uShape.value as number[]; + shape[3] = strength * (params.emphasis ? WAKE.emphasis : 1); + this.mesh.visible = strength > 0; + } +} diff --git a/explainers/naval-drone/src/scene/parts/water/waterSection.ts b/explainers/naval-drone/src/scene/parts/water/waterSection.ts new file mode 100644 index 00000000..cece8541 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/waterSection.ts @@ -0,0 +1,166 @@ +import { + BufferGeometry, + Color, + DoubleSide, + Float32BufferAttribute, + Group, + Mesh, + ShaderMaterial, +} from 'three'; +import type { Matrix4 } from 'three'; +import { UNDIMMED_GROUP } from '@core/scene/materials'; +import { BOAT, TRANSOM_X, WATER_SECTION } from '../../../model/layout'; +import { THEME } from '../../../theme'; +import { SECTION_LOOK } from '../../constants'; +import { mergeParts, registered } from '../context'; +import type { PartContext } from '../context'; +import { HULL_MASK_GLSL } from './hullPlan'; +import { SECTION_GLSL } from './sectionMask'; +import { WATER, WAVE_GLSL } from './waves'; + +type Corner = readonly [x: number, z: number]; + +const VERTEX = /* glsl */ ` +${WAVE_GLSL} +uniform float uBack; +attribute vec2 aWall; +varying vec3 vWorld; +varying vec2 vWall; +varying float vDepth; +void main() { + vec4 world = modelMatrix * vec4(position, 1.0); + float surface = seaHeight(world.xz); + float rise = uBack * step(${TRANSOM_X.toFixed(3)}, position.x) * step(position.x, ${BOAT.halfLength.toFixed(3)}); + world.y = mix(world.y, surface + rise * ${SECTION_LOOK.backRise.toFixed(2)}, aWall.x); + vWorld = world.xyz; + vWall = aWall; + vDepth = surface - world.y; + gl_Position = projectionMatrix * viewMatrix * world; +} +`; + +const FRAGMENT = /* glsl */ ` +${WAVE_GLSL} +${HULL_MASK_GLSL} +${SECTION_GLSL} +uniform vec3 uShallow; +uniform vec3 uDeep; +uniform vec3 uGlint; +uniform vec4 uLook; +uniform float uBack; +varying vec3 vWorld; +varying vec2 vWall; +varying float vDepth; +void main() { + float fade = sectionFade(); + if (fade <= 0.0 || (uBack > 0.5 && uOpenPort > 0.5 && insideHull(vWorld, -0.002))) discard; + float depth = clamp(vDepth / uLook.x, 0.0, 1.0); + float edge = smoothstep(0.0, uLook.w, vWall.y); + vec3 colour = mix(uShallow, uDeep, sqrt(depth)); + float rim = (1.0 - smoothstep(0.0, uLook.z, vDepth)) * edge; + if (uBack > 0.5) { + colour = mix(uDeep, colour, 0.35 * edge * (1.0 - depth)); + gl_FragColor = vec4(colour, 1.0); + } else { + float alpha = uLook.y * pow(1.0 - depth, 1.6) * edge; + gl_FragColor = vec4(mix(colour, uGlint, rim), max(alpha, rim * 0.85)); + } + #include +} +`; + +function wall( + from: Corner, + to: Corner, + samples: number, + bottom: number = SECTION_LOOK.depth, +): BufferGeometry { + const positions: number[] = []; + const marks: number[] = []; + const indices: number[] = []; + for (let step = 0; step <= samples; step += 1) { + const share = step / samples; + const x = from[0] + (to[0] - from[0]) * share; + const z = from[1] + (to[1] - from[1]) * share; + const edge = Math.min(share, 1 - share) * 2; + positions.push(x, -bottom, z, x, 0, z); + marks.push(0, edge, 1, edge); + if (step < samples) + indices.push(step * 2, step * 2 + 2, step * 2 + 1, step * 2 + 1, step * 2 + 2, step * 2 + 3); + } + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)); + geometry.setAttribute('aWall', new Float32BufferAttribute(marks, 2)); + geometry.setIndex(indices); + return geometry; +} + +function material( + context: PartContext, + uniforms: Record, + back: boolean, +): ShaderMaterial { + const { depth, clear, rim, edge } = SECTION_LOOK; + return registered( + context, + UNDIMMED_GROUP, + new ShaderMaterial({ + uniforms: { + ...uniforms, + uSeaTime: WATER.uSeaTime, + uSeaDrift: WATER.uSeaDrift, + uWaves: WATER.uWaves, + uShallow: { value: new Color(THEME.waterWall) }, + uDeep: { value: new Color(SECTION_LOOK.deep) }, + uGlint: { value: new Color(SECTION_LOOK.glint) }, + uLook: { value: [depth, clear, rim, edge] }, + uBack: { value: back ? 1 : 0 }, + }, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + transparent: true, + depthWrite: false, + side: DoubleSide, + toneMapped: false, + }), + ); +} + +export class WaterSectionPart { + readonly object = new Group(); + + constructor(context: PartContext, shared: Record) { + const [x0, x1] = WATER_SECTION.x; + const [z0, z1] = WATER_SECTION.z; + const { along, across } = SECTION_LOOK.samples; + const glass = mergeParts([ + wall([x0, z0], [x1, z0], along), + wall([x0, z1], [x0, z0], across), + wall([x1, z1], [x1, z0], across), + ]); + const front = new Mesh(context.tracker.track(glass), material(context, shared, false)); + const back = new Mesh( + context.tracker.track( + wall( + [x0 - SECTION_LOOK.backReach, z1], + [x1 + SECTION_LOOK.backReach, z1], + along, + SECTION_LOOK.backDepth, + ), + ), + material(context, shared, true), + ); + back.renderOrder = SECTION_LOOK.renderOrder; + front.renderOrder = SECTION_LOOK.renderOrder + 1; + [front, back].forEach((mesh) => (mesh.frustumCulled = false)); + this.object.add(back, front); + this.object.matrixAutoUpdate = false; + this.object.visible = false; + } + + place(frame: Matrix4, on: boolean): void { + this.object.matrix.copy(frame); + this.object.matrixWorldNeedsUpdate = true; + this.object.visible = on; + } +} diff --git a/explainers/naval-drone/src/scene/parts/water/waves.test.ts b/explainers/naval-drone/src/scene/parts/water/waves.test.ts new file mode 100644 index 00000000..0d14cf94 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/waves.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { GRAVITY } from '../../../model/scale'; +import { MOTION, WAVES } from '../../constants'; +import { createWaterUniforms, seaHeightAt, waveVectors } from './waves'; +import { responseFor, waveMotion } from './boatMotion'; + +const SIGMA_TO_SIGNIFICANT = 4; + +describe('waves', () => { + it('scales the components to the significant wave height', () => { + const waveHeight = 0.875; + const waves = waveVectors(waveHeight); + expect(waves).toHaveLength(WAVES.components.length); + const variance = waves.reduce((sum, wave) => sum + (wave.w * wave.w) / 2, 0); + expect(SIGMA_TO_SIGNIFICANT * Math.sqrt(variance)).toBeCloseTo(waveHeight, 6); + waves.forEach((wave) => { + const k = Math.hypot(wave.x, wave.y); + expect(wave.z).toBeCloseTo(Math.sqrt(GRAVITY * k), 9); + }); + }); + + it('moves the pattern back past a held boat as the drift grows', () => { + const uniforms = createWaterUniforms(); + const before = seaHeightAt(3, 4, uniforms); + uniforms.uSeaDrift.value.set(2, 0); + expect(seaHeightAt(1, 4, uniforms)).toBeCloseTo(before, 9); + }); + + it('settles a boat on the waves and calms it as it planes', () => { + expect(responseFor(0)).toBe(1); + expect(responseFor(1)).toBeLessThan(0.5); + const motion = waveMotion([0, 0, 0], 0, 1); + expect(Math.abs(motion.pitch)).toBeLessThanOrEqual(MOTION.maxPitch); + expect(Number.isFinite(motion.heave + motion.roll)).toBe(true); + }); +}); diff --git a/explainers/naval-drone/src/scene/parts/water/waves.ts b/explainers/naval-drone/src/scene/parts/water/waves.ts new file mode 100644 index 00000000..0e6c098f --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/waves.ts @@ -0,0 +1,89 @@ +import { Vector2, Vector4 } from 'three'; +import { GRAVITY } from '../../../model/scale'; +import { WAVES } from '../../constants'; + +export interface WaterUniforms { + uSeaTime: { value: number }; + uSeaDrift: { value: Vector2 }; + uWaves: { value: Vector4[] }; + uFoamLevel: { value: number }; +} + +const SIGNIFICANT_TO_SIGMA = 4; +const ENERGY_TO_AMPLITUDE = 2; + +export function waveVectors(waveHeight: number): Vector4[] { + const sigma = waveHeight / SIGNIFICANT_TO_SIGMA; + const scale = WAVES.lengthPerMetre * Math.max(waveHeight, WAVES.minHeight) ** WAVES.lengthPower; + return WAVES.components.map(({ length, angle, share }) => { + const wavelength = length * scale; + const k = (2 * Math.PI) / wavelength; + const direction = WAVES.direction + angle; + return new Vector4( + Math.cos(direction) * k, + Math.sin(direction) * k, + Math.sqrt(GRAVITY * k), + sigma * Math.sqrt(ENERGY_TO_AMPLITUDE * share), + ); + }); +} + +export function createWaterUniforms(): WaterUniforms { + return { + uSeaTime: { value: 0 }, + uSeaDrift: { value: new Vector2() }, + uWaves: { value: waveVectors(WAVES.defaultHeight) }, + uFoamLevel: { value: 0 }, + }; +} + +export const WATER = createWaterUniforms(); + +export function resetWater(waveHeight: number): void { + WATER.uSeaTime.value = 0; + WATER.uSeaDrift.value.set(0, 0); + WATER.uWaves.value = waveVectors(waveHeight); +} + +export function seaHeightAt( + x: number, + z: number, + uniforms: WaterUniforms = WATER, + components: number = WAVES.components.length, +): number { + const px = x + uniforms.uSeaDrift.value.x; + const pz = z + uniforms.uSeaDrift.value.y; + const time = uniforms.uSeaTime.value; + return uniforms.uWaves.value.slice(0, components).reduce((height, wave, index) => { + const phase = wave.x * px + wave.y * pz - wave.z * time + index * WAVES.phaseStep; + const k = Math.hypot(wave.x, wave.y); + return height + wave.w * Math.cos(phase) + 0.5 * k * wave.w * wave.w * Math.cos(2 * phase); + }, 0); +} + +export const WAVE_GLSL = /* glsl */ ` +uniform float uSeaTime; +uniform vec2 uSeaDrift; +uniform vec4 uWaves[${WAVES.components.length}]; + +vec3 seaWave(vec2 at, float fade) { + vec2 p = at + uSeaDrift; + vec3 result = vec3(0.0); + for (int i = 0; i < ${WAVES.components.length}; i++) { + vec4 w = uWaves[i]; + float k = length(w.xy); + float keep = 1.0 - smoothstep(1.0, 2.2, fade * k); + float phase = dot(w.xy, p) - w.z * uSeaTime + float(i) * ${WAVES.phaseStep.toFixed(4)}; + float a = w.w * keep; + float second = 0.5 * k * a * a; + result.x += a * cos(phase) + second * cos(2.0 * phase); + float slope = -(a * sin(phase) + 2.0 * second * sin(2.0 * phase)); + result.yz += slope * w.xy; + } + return result; +} + +float seaHeight(vec2 at) { + return seaWave(at, 0.0).x; +} +`; diff --git a/explainers/naval-drone/src/scene/parts/water/wetSurface.ts b/explainers/naval-drone/src/scene/parts/water/wetSurface.ts new file mode 100644 index 00000000..f3a05c07 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/wetSurface.ts @@ -0,0 +1,66 @@ +import type { Material, WebGLProgramParametersWithUniforms } from 'three'; +import { Color } from 'three'; +import { THEME } from '../../../theme'; +import { WET } from '../../constants'; +import { WATER, WAVE_GLSL } from './waves'; + +const KEY = 'naval-drone-wet'; + +const VERTEX_HEAD = /* glsl */ ` +varying vec3 vWetWorld; +`; + +const VERTEX_BODY = /* glsl */ ` +#include +vWetWorld = (modelMatrix * vec4(transformed, 1.0)).xyz; +`; + +const FRAGMENT_HEAD = /* glsl */ ` +varying vec3 vWetWorld; +${WAVE_GLSL} +`; + +const FRAGMENT_COLOUR = /* glsl */ ` +#include +float wetLine = seaHeight(vWetWorld.xz) + ${WET.rise.toFixed(3)}; +float wetness = 1.0 - smoothstep(wetLine, wetLine + ${WET.fade.toFixed(3)}, vWetWorld.y); +diffuseColor.rgb *= mix(1.0, ${WET.darken.toFixed(3)}, wetness); +`; + +const MURK = new Color(THEME.seaDeep); + +const FRAGMENT_MURK = /* glsl */ ` +outgoingLight = mix(outgoingLight, vec3(${MURK.toArray() + .map((value) => value.toFixed(3)) + .join( + ', ', + )}), smoothstep(0.0, ${WET.murkDepth.toFixed(2)}, wetLine - vWetWorld.y) * ${WET.murk.toFixed(2)}); +#include +`; + +const FRAGMENT_ROUGHNESS = /* glsl */ ` +#include +roughnessFactor = mix(roughnessFactor, ${WET.roughness.toFixed(3)}, wetness); +`; + +function inject(shader: WebGLProgramParametersWithUniforms): void { + shader.uniforms.uSeaTime = WATER.uSeaTime; + shader.uniforms.uSeaDrift = WATER.uSeaDrift; + shader.uniforms.uWaves = WATER.uWaves; + shader.vertexShader = + VERTEX_HEAD + shader.vertexShader.replace('#include ', VERTEX_BODY); + shader.fragmentShader = + FRAGMENT_HEAD + + shader.fragmentShader + .replace('#include ', FRAGMENT_COLOUR) + .replace('#include ', FRAGMENT_ROUGHNESS) + .replace('#include ', FRAGMENT_MURK); +} + +export function wetSurface(material: T): T { + if (material.customProgramCacheKey() === KEY) return material; + material.onBeforeCompile = inject; + material.customProgramCacheKey = () => KEY; + material.needsUpdate = true; + return material; +} diff --git a/explainers/naval-drone/src/scene/parts/water/wettedBar.ts b/explainers/naval-drone/src/scene/parts/water/wettedBar.ts new file mode 100644 index 00000000..25fe310b --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/water/wettedBar.ts @@ -0,0 +1,114 @@ +import { + BufferAttribute, + BufferGeometry, + DoubleSide, + DynamicDrawUsage, + Group, + Mesh, + MeshBasicMaterial, +} from 'three'; +import type { Object3D } from 'three'; +import { lerp } from '@core/math'; +import type { AssemblyState } from '../../../ids'; +import { TRANSOM_X } from '../../../model/layout'; +import { WETTED_BAR } from '../../constants'; +import { halfBreadthAt, hullSectionAt } from '../../geometry/hullLines'; +import type { Vec3 } from '../../geometry/surface'; +import { registered } from '../context'; +import type { PartContext } from '../context'; +import { LocalWater } from './hullWater'; + +const QUADS = WETTED_BAR.samples - 1 + 2 + 1; + +export class WettedBarPart { + readonly object = new Group(); + readonly anchor = new Group(); + private readonly positions = new Float32Array(QUADS * 4 * 3); + private readonly geometry = new BufferGeometry(); + private readonly water = new LocalWater(); + private quad = 0; + + constructor(context: PartContext) { + const indices: number[] = []; + for (let quad = 0; quad < QUADS; quad += 1) { + const a = quad * 4; + indices.push(a, a + 1, a + 2, a + 2, a + 1, a + 3); + } + this.geometry.setAttribute( + 'position', + new BufferAttribute(this.positions, 3).setUsage(DynamicDrawUsage), + ); + this.geometry.setIndex(indices); + const material = registered( + context, + 'wettedLength', + new MeshBasicMaterial({ + color: WETTED_BAR.colour, + side: DoubleSide, + toneMapped: false, + polygonOffset: true, + polygonOffsetFactor: -4, + polygonOffsetUnits: -4, + }), + ); + const mesh = new Mesh(context.tracker.track(this.geometry), material); + mesh.frustumCulled = false; + this.object.add(mesh, this.anchor); + this.object.visible = false; + } + + private add(a: Vec3, b: Vec3, c: Vec3, d: Vec3): void { + this.positions.set([...a, ...b, ...c, ...d], this.quad * 12); + this.quad += 1; + } + + private edge(x: number): { y: number; z: number } { + const guess = this.water.level(x, -hullSectionAt(x).chine[0]); + return { y: guess + WETTED_BAR.lift, z: -(halfBreadthAt(x, guess) + WETTED_BAR.offset) }; + } + + setState(state: AssemblyState, body: Object3D): void { + this.object.visible = state.wettedBar; + if (!state.wettedBar) return; + body.updateMatrixWorld(true); + this.water.use(body.matrixWorld); + const { planing } = state; + const { samples, width, tick, line } = WETTED_BAR; + this.quad = 0; + const end = TRANSOM_X + planing.wettedLength; + const points = Array.from({ length: samples }, (_, index) => { + const x = lerp(TRANSOM_X, end, index / (samples - 1)); + const { y, z } = this.edge(x); + return [x, y, z] as Vec3; + }); + points.slice(1).forEach((point, index) => { + const previous = points[index]; + this.add(previous, [previous[0], previous[1], previous[2] - width], point, [ + point[0], + point[1], + point[2] - width, + ]); + }); + [points[0], points[samples - 1]].forEach(([x, y, z]) => + this.add( + [x - line, y, z], + [x + line, y, z], + [x - line, y + tick, z], + [x + line, y + tick, z], + ), + ); + const keelX = TRANSOM_X + planing.keelWettedLength; + const chineX = TRANSOM_X + planing.chineWettedLength; + const keel = hullSectionAt(keelX); + const chine = hullSectionAt(chineX); + const below = WETTED_BAR.below; + this.add( + [keelX, keel.keel - below, 0], + [keelX, keel.keel - below + line, 0], + [chineX, chine.chine[1] - below, -chine.chine[0]], + [chineX, chine.chine[1] - below + line, -chine.chine[0]], + ); + this.geometry.getAttribute('position').needsUpdate = true; + this.anchor.position.set(...points[Math.floor(samples / 2)]); + } +} diff --git a/explainers/naval-drone/src/scene/parts/world/satellites.ts b/explainers/naval-drone/src/scene/parts/world/satellites.ts new file mode 100644 index 00000000..f6d50452 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/world/satellites.ts @@ -0,0 +1,61 @@ +import { BoxGeometry, Group } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import type { MaterialFinish } from '@core/scene/materials'; +import { toRadians } from '@core/math'; +import type { PartId } from '../../../ids'; +import type { Vec3 } from '../../geometry/surface'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +export interface SatellitePart { + object: Group; + anchor: Object3D; +} + +interface Spec { + body: Vec3; + wing: Vec3; + wings: readonly number[]; + foil?: boolean; +} + +const WING_TILT = toRadians(55); +const STARLINK: Spec = { body: [7, 0.7, 3.4], wing: [30, 0.2, 5.5], wings: [1] }; +const BACKUP: Spec = { body: [5, 5, 5], wing: [14, 0.2, 4.5], wings: [-1, 1], foil: true }; +const CELL_TINT = '#26385e'; + +const FINISH = { metalness: 0.85, roughness: 0.32, envMapIntensity: 1.6, fog: false } as const; +const FOIL: MaterialFinish = { ...FINISH, color: '#c9a65a' }; +const WHITE: MaterialFinish = { ...FINISH, color: '#e8ebee', metalness: 0.2 }; + +function wing(spec: Spec, side: number): BufferGeometry { + const panel = new BoxGeometry(...spec.wing); + panel.rotateX(WING_TILT); + panel.translate((side * (spec.body[0] + spec.wing[0])) / 2, 0, 0); + return panel; +} + +function build( + context: PartContext, + group: PartId, + spec: Spec, + panels: MaterialFinish, +): SatellitePart { + const object = new Group(); + const body = partMesh(context, new BoxGeometry(...spec.body), group, spec.foil ? FOIL : WHITE); + const wings = spec.wings.map((side) => wing(spec, side)); + object.add(body, partMesh(context, mergeParts(wings), group, panels)); + return { object, anchor: body }; +} + +export function createStarlinkSatellite(context: PartContext): SatellitePart { + return build(context, 'satellite', STARLINK, panelFinish(context)); +} + +export function createBackupSatellite(context: PartContext): SatellitePart { + return build(context, 'backupSatellite', BACKUP, panelFinish(context)); +} + +function panelFinish(context: PartContext): MaterialFinish { + return { ...context.looks.panel, ...FINISH, color: CELL_TINT }; +} diff --git a/explainers/naval-drone/src/scene/parts/world/ship.ts b/explainers/naval-drone/src/scene/parts/world/ship.ts new file mode 100644 index 00000000..87dcc00e --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/world/ship.ts @@ -0,0 +1,330 @@ +import { CylinderGeometry, Group, Matrix4, Mesh, RepeatWrapping } from 'three'; +import { extrudePlan, roundedRectShape } from '@core/scene/geometry/extrude'; +import type { BufferGeometry, Object3D } from 'three'; +import type { MaterialFinish } from '@core/scene/materials'; +import { anchorAt } from '@core/scene/parts'; +import { SHIP } from '../../../model/layout'; +import { THEME } from '../../../theme'; +import { monotoneCubic, spread } from '../../geometry/curves'; +import { tubeAlong } from '../../geometry/fitted'; +import { flatPolygon } from '../../geometry/flat'; +import type { Pair } from '../../geometry/hullLines'; +import { boxAt, rod } from '../../geometry/solids'; +import { gridSurface, mirrorZ, orientFrom } from '../../geometry/surface'; +import type { Vec3 } from '../../geometry/surface'; +import { instanced, mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; +import { canvasTexture } from '../surfaces'; +import { wetSurface } from '../water/wetSurface'; + +export interface ShipPart { + object: Group; + radar: Object3D; + deckAnchor: Object3D; + radarAnchor: Object3D; +} + +type Lines = readonly (readonly [number, number])[]; +type Block = readonly [ + x0: number, + x1: number, + y0: number, + y1: number, + halfWidth: number, + corner: number, + lean: number, +]; + +const STERN = -SHIP.length / 2; +const BOW = SHIP.length / 2; +const HOUSE_TOP = 9; + +const KEEL: Lines = [ + [STERN, -1.2], + [-30, -SHIP.draft], + [32, -SHIP.draft], + [46, 0], + [BOW, 9.6], +]; +const WATERLINE: Lines = [ + [STERN, 5.6], + [-24, SHIP.beam / 2], + [6, SHIP.beam / 2], + [40, 2], + [46, 0], + [BOW, 0], +]; +const DECK_EDGE: Lines = [ + [STERN, 6.4], + [-24, 7.3], + [16, 7.3], + [44, 4.2], + [BOW, 0], +]; +const SHEER: Lines = [ + [STERN, 6.8], + [-28, SHIP.deck], + [8, SHIP.deck], + [36, 7.2], + [BOW, 9.6], +]; + +const HULL = { columns: 64, rows: 12, rowBias: 1.6, bilge: 4, tile: 22, span: 14, base: -5 }; +const WALL_TILE = 8; +const BLOCKS: readonly Block[] = [ + [-20, 26, SHIP.deck, HOUSE_TOP, 6.2, 2.5, 0.25], + [4, 24, HOUSE_TOP, 12, 5.4, 2, 0.3], + [12, 23, 12, SHIP.bridgeTop, 6.6, 1.6, 0], +]; +const BRIDGE_WINDOWS: Block = [11.9, 23.1, 13.1, 14.5, 6.65, 1.7, 0]; +const FUNNEL = { + x: -10, + base: HOUSE_TOP, + height: 8, + radii: [1, 1.25], + scale: [4, 2.2], + rake: 0.12, +}; +const FUNNEL_CAP = 0.6; +const STRAKE = { drop: 0.6, radius: 0.16, samples: 28, ends: [2, 4] } as const; +const MAST = { radius: 0.24, yard: [0.18, 24, 7] as const, lead: 1.4 }; +const RADAR: Vec3 = [0.5, 0.22, 4.2]; +const PEDESTAL = [0.18, 0.8] as const; +const CHAIN: readonly Vec3[] = [ + [46, 5.6, 4.4], + [49, 1.4, 4.9], + [51.5, -3, 5.2], +]; +const LAMPS: readonly Vec3[] = [ + [26.05, 8.2, 0], + [23.15, 14, 4], + [23.15, 14, -4], + [SHIP.mastX, SHIP.mastTop, 0], +]; +const LAMP_SIZE = 0.22; +const PORTS = { count: 6, y: 4, radius: 2, boot: 0.6, map: [256, 128] as const } as const; +const WINDOWS = { xs: [0.2, 0.45, 0.8] as const, y: 0.42, size: [0.08, 0.12] as const, map: 64 }; +const CHAIN_RADIUS = 0.13; +const CHAIN_SIDES = 6; +const REFLECTION = 0.8; +const FACING = 0.5; +const FUNNEL_SIDES = 20; + +const PAINT = { + hull: THEME.ship, + deck: '#5d6268', + dark: '#23272b', + boot: '#2a2e33', + port: '#1b1f24', + white: '#ffffff', +}; + +function matte(color: string, roughness: number, metalness = 0.1): MaterialFinish { + return { color, roughness, metalness, envMapIntensity: REFLECTION }; +} + +const DARK = matte(PAINT.dark, 0.5, 0.3); +const DECKING = matte(PAINT.deck, 0.9); +const LAMP: MaterialFinish = { color: '#fff1d6', emissive: '#ffcf8a', emissiveIntensity: 0.6 }; + +function curve(lines: Lines): (x: number) => number { + return monotoneCubic( + lines.map(([x]) => x), + lines.map(([, value]) => value), + ); +} + +const keelAt = curve(KEEL); +const waterlineAt = curve(WATERLINE); +const deckAt = curve(DECK_EDGE); +const sheerAt = curve(SHEER); + +export function shipHalfBreadth(x: number, y: number): number { + if (y >= 0) return waterlineAt(x) + (deckAt(x) - waterlineAt(x)) * (y / sheerAt(x)); + const depth = keelAt(x); + if (depth >= 0) return 0; + return waterlineAt(x) * Math.max(0, 1 - (y / depth) ** HULL.bilge) ** (1 / HULL.bilge); +} + +const hullUv = (x: number, y: number): [number, number] => [ + x / HULL.tile, + (y - HULL.base) / HULL.span, +]; + +function hull(): BufferGeometry { + const xs = spread(STERN, BOW, HULL.columns); + const rows = spread(0, 1, HULL.rows).map((share, row) => + xs.map((x): Vec3 => { + const y = keelAt(x) + (sheerAt(x) - keelAt(x)) * share ** HULL.rowBias; + return [x, y, row === 0 ? 0 : shipHalfBreadth(x, y)]; + }), + ); + const side = orientFrom( + gridSurface(rows, { uv: (row, column) => hullUv(rows[row][column][0], rows[row][column][1]) }), + [0, 0, -1], + ); + const ys = spread(keelAt(STERN), sheerAt(STERN), HULL.rows); + const outline = [...ys, ...[...ys].reverse()].map((y, index): Pair => [ + (index < ys.length ? 1 : -1) * shipHalfBreadth(STERN, y), + y, + ]); + const transom = orientFrom( + flatPolygon(outline, (z, y) => [STERN, y, z], [], hullUv), + [0, 0, 0], + ); + return mergeParts([side, mirrorZ(side.clone()), transom]); +} + +function deck(): BufferGeometry { + const xs = spread(STERN, BOW, HULL.columns); + const edge = (side: number) => xs.map((x): Vec3 => [x, sheerAt(x), side * deckAt(x)]); + return orientFrom(gridSurface([edge(-1), edge(1)]), [0, 0, 0]); +} + +function block([x0, x1, y0, y1, half, corner, lean]: Block): BufferGeometry { + const solid = extrudePlan( + roundedRectShape({ minA: x0, maxA: x1, minB: -half, maxB: half }, corner), + y0, + y1, + ); + const centre = (x0 + x1) / 2; + const position = solid.getAttribute('position'); + for (let at = 0; at < position.count; at += 1) { + const inset = (lean * (position.getY(at) - y0)) / (y1 - y0); + const x = position.getX(at); + const z = position.getZ(at); + position.setX(at, x - Math.sign(x - centre) * inset); + position.setZ(at, z - Math.sign(z) * inset); + } + solid.computeVertexNormals(); + const normal = solid.getAttribute('normal'); + const uv = solid.getAttribute('uv'); + for (let at = 0; at < uv.count; at += 1) { + const across = Math.abs(normal.getX(at)) > FACING ? position.getZ(at) : position.getX(at); + uv.setXY(at, across / WALL_TILE, (position.getY(at) - SHIP.deck) / WALL_TILE); + } + return solid; +} + +function funnelPiece(radius: number, height: number, base: number): BufferGeometry { + const [width, depth] = FUNNEL.scale; + return new CylinderGeometry(radius, radius * FUNNEL.radii[1], height, FUNNEL_SIDES) + .scale(width, 1, depth) + .translate(0, base + height / 2, 0) + .rotateZ(FUNNEL.rake) + .translate(FUNNEL.x, 0, 0); +} + +function strake(side: number): BufferGeometry { + const [aft, fore] = STRAKE.ends; + return tubeAlong( + spread(STERN + aft, BOW - fore, STRAKE.samples).map((x): Vec3 => { + const y = sheerAt(x) - STRAKE.drop; + return [x, y, side * shipHalfBreadth(x, y)]; + }), + STRAKE.radius, + CHAIN_SIDES, + ); +} + +function hullTexture() { + const texture = canvasTexture(...PORTS.map, (pen, width, height) => { + const v = (y: number) => (height * (y - HULL.base)) / HULL.span; + pen.fillStyle = PAINT.hull; + pen.fillRect(0, 0, width, height); + pen.fillStyle = PAINT.boot; + pen.fillRect(0, 0, width, v(PORTS.boot)); + pen.fillStyle = PAINT.port; + for (let port = 0; port < PORTS.count; port += 1) { + pen.beginPath(); + pen.arc(((port + 0.5) / PORTS.count) * width, v(PORTS.y), PORTS.radius, 0, Math.PI * 2); + pen.fill(); + } + }); + texture.wrapS = RepeatWrapping; + return texture; +} + +function wallTexture() { + const [width, height] = WINDOWS.size; + return canvasTexture( + WINDOWS.map, + WINDOWS.map, + (pen, size) => { + pen.fillStyle = PAINT.white; + pen.fillRect(0, 0, size, size); + pen.fillStyle = PAINT.port; + WINDOWS.xs.forEach((x) => + pen.fillRect(x * size, WINDOWS.y * size, width * size, height * size), + ); + }, + true, + ); +} + +function placed(spots: readonly Vec3[]): Matrix4[] { + return spots.map((spot) => new Matrix4().makeTranslation(...spot)); +} + +export function createShip(context: PartContext): ShipPart { + const track = context.tracker.track.bind(context.tracker); + const walls = { ...matte(PAINT.hull, 0.7), map: track(wallTexture()) }; + const hullFinish = { ...matte(PAINT.white, 0.75), map: track(hullTexture()) }; + const height = SHIP.mastTop - SHIP.bridgeTop; + const [thickness, yardY, span] = MAST.yard; + const mast = mergeParts([ + rod('y', MAST.radius, height, [SHIP.mastX, SHIP.bridgeTop + height / 2, 0]), + boxAt([thickness, thickness, span], [SHIP.mastX, yardY, 0]), + boxAt( + [MAST.lead, thickness, thickness], + [SHIP.mastX + MAST.lead / 2, SHIP.radarHeight - PEDESTAL[1], 0], + ), + ]); + const radar = new Group(); + radar.position.set(SHIP.mastX + MAST.lead, SHIP.radarHeight, 0); + radar.add( + partMesh( + context, + mergeParts([ + rod('y', PEDESTAL[0], PEDESTAL[1], [0, -PEDESTAL[1] / 2, 0]), + boxAt(RADAR, [0, RADAR[1] / 2, 0]), + ]), + 'shipRadar', + DARK, + ), + ); + const object = new Group(); + object.add( + new Mesh(track(hull()), wetSurface(context.materials.get('ship', hullFinish))), + partMesh(context, deck(), 'ship', DECKING), + partMesh(context, mergeParts(BLOCKS.map(block)), 'ship', walls), + partMesh(context, funnelPiece(1, FUNNEL.height, FUNNEL.base), 'ship', matte(PAINT.hull, 0.7)), + partMesh( + context, + mergeParts([ + block(BRIDGE_WINDOWS), + funnelPiece(FUNNEL.radii[0], FUNNEL_CAP, FUNNEL.base + FUNNEL.height), + tubeAlong(CHAIN, CHAIN_RADIUS, CHAIN_SIDES), + strake(-1), + strake(1), + ]), + 'ship', + DARK, + ), + partMesh(context, mast, 'shipRadar', walls), + instanced( + context, + boxAt([LAMP_SIZE, LAMP_SIZE, LAMP_SIZE], [0, 0, 0]), + 'ship', + LAMP, + placed(LAMPS), + ), + radar, + ); + return { + object, + radar, + deckAnchor: anchorAt(object, 0, SHIP.deck, 0), + radarAnchor: anchorAt(radar, 0, RADAR[1], 0), + }; +} diff --git a/explainers/naval-drone/src/scene/parts/world/shore.ts b/explainers/naval-drone/src/scene/parts/world/shore.ts new file mode 100644 index 00000000..8288059b --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/world/shore.ts @@ -0,0 +1,217 @@ +import { + BoxGeometry, + BufferAttribute, + Color, + CylinderGeometry, + DoubleSide, + Group, + SphereGeometry, +} from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { clamp, lerp, smoothstep } from '@core/math'; +import { STRUCTURE_GROUP, UNDIMMED_GROUP } from '@core/scene/materials'; +import type { MaterialFinish } from '@core/scene/materials'; +import { anchorAt } from '@core/scene/parts'; +import { GROUND_STATION, SLIPWAY } from '../../../model/layout'; +import { THEME } from '../../../theme'; +import { gridSurface } from '../../geometry/surface'; +import type { Vec3 } from '../../geometry/surface'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; +import { canvasTexture } from '../surfaces'; + +export interface ShorePart { + object: Group; + stationAnchor: Object3D; +} + +type Range = readonly [number, number]; + +const GRID = { x: [-1200, 160] as Range, z: [-2500, 2500] as Range, fine: [-140, 60] as Range }; +const CELL = 2.5; +const GROWTH = 1.25; +const SHELF = 0.075; +const BEACH = { width: 30, slope: 1 / 15 } as const; +const TINT_JITTER = 0.4; +const FINE_MARGIN = 10; +const DUNE = { from: 22, height: 2.2, waves: [0.083, 0.061, 0.047, 1.3] as const }; +const LAND = { from: 140, rise: 0.012, hills: 4, scale: 0.06 } as const; +const SLAB = { thickness: 0.35, reveal: 0.16, shoulder: 3, apron: [-28, 4] as const }; +const PAD = { half: [17, 13] as const, blend: 16, thickness: 0.3 } as const; +const COAST = { amplitude: 24, scale: 0.35, ripple: 5, rippleScale: 2.4 } as const; +const SHADE = { base: 0.7, range: 0.5 } as const; +const TINTS: readonly (readonly [string, number])[] = [ + ['#4a463d', -3], + ['#514c42', 7], + [THEME.beach, 9], + [THEME.beach, 18], + ['#8a8858', 34], + ['#6f6e50', 70], + [THEME.shore, 140], +]; +const RADOMES: readonly Range[] = [ + [-7, 1.25], + [0, 1.75], + [7, 1.4], +]; +const DOME_SINK = 0.25; +const FENCE = { half: [15, 11] as const, height: 2.2, opacity: 0.45 } as const; +const ANCHOR_LIFT = 2; +const GROOVES = { size: [256, 8] as const, pitch: 6, groove: 2 } as const; +const DOME_SEGMENTS = [24, 14] as const; +const PAINT = { groove: '#7d7d77', white: '#ffffff' } as const; + +const GROUND: MaterialFinish = { + color: PAINT.white, + vertexColors: true, + roughness: 0.95, + envMapIntensity: 0.3, +}; +const RADOME: MaterialFinish = { color: THEME.radome, roughness: 0.55, envMapIntensity: 0.8 }; +const METAL: MaterialFinish = { color: '#6d7378', roughness: 0.5, metalness: 0.6 }; +const PAD_FINISH: MaterialFinish = { color: '#8d8c86', roughness: 0.9 }; +const MESH: MaterialFinish = { + ...METAL, + transparent: true, + opacity: FENCE.opacity, + depthWrite: false, + side: DoubleSide, +}; + +export function slabTop(x: number): number { + const [from, to] = SLIPWAY.x; + return lerp(SLIPWAY.top, SLIPWAY.foot, clamp((x - from) / (to - from), 0, 1)); +} + +function dune(x: number, z: number): number { + const [a, b, c, warp] = DUNE.waves; + return 0.5 + 0.5 * Math.sin(x * a + Math.sin(z * c) * warp) * Math.sin(z * b + x * c); +} + +function inlandOf(x: number, z: number): number { + const swing = (amplitude: number, scale: number) => amplitude * (2 * dune(0, z * scale) - 1); + return swing(COAST.amplitude, COAST.scale) + swing(COAST.ripple, COAST.rippleScale) - x; +} + +function naturalHeight(x: number, z: number): number { + const inland = inlandOf(x, z); + if (inland < 0) return inland * SHELF; + const land = Math.max(inland - LAND.from, 0); + return ( + Math.min(inland, BEACH.width) * BEACH.slope + + smoothstep(inland, DUNE.from, DUNE.from + BEACH.width) * DUNE.height * dune(x, z) + + land * LAND.rise + + LAND.hills * smoothstep(land, 0, LAND.from) * dune(x * LAND.scale, z * LAND.scale) + ); +} + +function gap(x: number, z: number, xs: Range, half: number): number { + return Math.hypot(Math.max(xs[0] - x, x - xs[1], 0), Math.max(Math.abs(z) - half, 0)); +} + +export function shoreHeight(x: number, z: number): number { + const natural = naturalHeight(x, z); + const [apronX, apronHalf] = SLAB.apron; + const slip = gap(x, z, [apronX, SLIPWAY.x[1]], x < SLIPWAY.x[0] ? apronHalf : SLIPWAY.z[1]); + const cut = slabTop(Math.max(x, SLIPWAY.x[0])) - SLAB.reveal; + const height = lerp(Math.min(natural, cut), natural, smoothstep(slip, 0, SLAB.shoulder)); + const [hx, hz] = PAD.half; + const pad = gap(x - GROUND_STATION[0], (z - GROUND_STATION[2]) * (hx / hz), [-hx, hx], hx); + return lerp(GROUND_STATION[1], height, smoothstep(pad, 0, PAD.blend)); +} + +function lines([from, to]: Range, [fineFrom, fineTo]: Range): number[] { + const values: number[] = []; + for (let at = fineFrom; at <= fineTo; at += CELL) values.push(at); + [ + [fineFrom, from, -1], + [values[values.length - 1], to, 1], + ].forEach(([start, end, sign]) => { + for (let at = start, step = CELL; sign * (end - at) > 0; step *= GROWTH) { + at += sign * step; + values.push(sign > 0 ? Math.min(at, end) : Math.max(at, end)); + } + }); + return [...new Set(values)].sort((a, b) => a - b); +} + +function tint(x: number, y: number, z: number, target: Color): Color { + const inland = inlandOf(x, z); + const level = inland < 0 ? y : inland; + const index = TINTS.findIndex(([, at]) => level < at); + if (index <= 0) return target.set(TINTS[index < 0 ? TINTS.length - 1 : 0][0]); + const [[low, from], [high, to]] = [TINTS[index - 1], TINTS[index]]; + const share = clamp((level - from) / (to - from) + (dune(z, x) - 0.5) * TINT_JITTER, 0, 1); + return target.set(low).lerp(new Color(high), share); +} + +function ground(): BufferGeometry { + const xs = lines(GRID.x, [GRID.fine[0], SLIPWAY.x[1] + FINE_MARGIN]); + const zs = lines(GRID.z, GRID.fine); + const rows = zs.map((z) => xs.map((x): Vec3 => [x, shoreHeight(x, z), z])); + const surface = gridSurface(rows); + const colour = new Color(); + const colours = rows.flatMap((row) => + row.flatMap(([x, y, z]) => + tint(x, y, z, colour) + .multiplyScalar(SHADE.base + SHADE.range * dune(x, z)) + .toArray(), + ), + ); + return surface.setAttribute('color', new BufferAttribute(new Float32Array(colours), 3)); +} + +function slab(x: Range, top: Range, half: number): BufferGeometry { + const box = new BoxGeometry(Math.hypot(x[1] - x[0], top[1] - top[0]), SLAB.thickness, 2 * half); + box.translate(0, -SLAB.thickness / 2, 0); + box.rotateZ(Math.atan2(top[1] - top[0], x[1] - x[0])); + return box.translate((x[0] + x[1]) / 2, (top[0] + top[1]) / 2, 0); +} + +function slipway(context: PartContext): Object3D { + const { size, pitch, groove } = GROOVES; + const map = canvasTexture(...size, (pen, width, height) => { + pen.fillStyle = THEME.concrete; + pen.fillRect(0, 0, width, height); + pen.fillStyle = PAINT.groove; + for (let x = pitch; x < width; x += pitch) pen.fillRect(x, 0, groove, height); + }); + const [apronX, apronHalf] = SLAB.apron; + const parts = [ + slab(SLIPWAY.x, [SLIPWAY.top, SLIPWAY.foot], SLIPWAY.z[1]), + slab([apronX, SLIPWAY.x[0]], [SLIPWAY.top, SLIPWAY.top], apronHalf), + ]; + const finish = { color: PAINT.white, roughness: 0.85, map: context.tracker.track(map) }; + return partMesh(context, mergeParts(parts), STRUCTURE_GROUP, finish); +} + +function station(context: PartContext): { object: Group; anchor: Object3D } { + const object = new Group(); + object.position.set(...GROUND_STATION); + const domes = RADOMES.map(([dx, radius]) => + new SphereGeometry(radius, ...DOME_SEGMENTS).translate(dx, radius * (1 - DOME_SINK), 0), + ); + const [px, pz] = PAD.half; + const [fx, fz] = FENCE.half; + const fence = new CylinderGeometry(Math.SQRT2, Math.SQRT2, FENCE.height, 4, 1, true) + .rotateY(Math.PI / 4) + .scale(fx, 1, fz) + .translate(0, FENCE.height / 2, 0); + object.add( + partMesh(context, new BoxGeometry(2 * px, PAD.thickness, 2 * pz), 'groundStation', PAD_FINISH), + partMesh(context, mergeParts(domes), 'groundStation', RADOME), + partMesh(context, fence, 'groundStation', MESH), + ); + const [, middle] = RADOMES[1]; + return { + object, + anchor: anchorAt(object, 0, middle * (2 - DOME_SINK) + ANCHOR_LIFT, 0), + }; +} + +export function createShore(context: PartContext): ShorePart { + const object = new Group(); + const { object: base, anchor } = station(context); + object.add(partMesh(context, ground(), UNDIMMED_GROUP, GROUND), slipway(context), base); + return { object, stationAnchor: anchor }; +} diff --git a/explainers/naval-drone/src/scene/parts/world/sky.ts b/explainers/naval-drone/src/scene/parts/world/sky.ts new file mode 100644 index 00000000..e877f768 --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/world/sky.ts @@ -0,0 +1,83 @@ +import { BackSide, Color, Mesh, ShaderMaterial, SphereGeometry, Vector4 } from 'three'; +import type { Camera, Texture } from 'three'; +import { UNDIMMED_GROUP } from '@core/scene/materials'; +import { SKY } from '../../constants'; +import { registered } from '../context'; +import type { PartContext } from '../context'; +import { SKY_GLSL, skyUniforms } from '../water/skyShade'; + +const VERTEX = /* glsl */ ` +varying vec3 vDirection; +void main() { + vDirection = position; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +const FRAGMENT = /* glsl */ ` +${SKY_GLSL} +uniform vec3 uCloudLit; +uniform vec3 uCloudShade; +uniform vec4 uClouds; +uniform vec3 uCloudBand; +uniform float uDisc; +uniform sampler2D uFoamMap; +varying vec3 vDirection; + +void main() { + vec3 direction = normalize(vDirection); + vec3 colour = skyColour(direction); + float up = max(direction.y, 0.0); + vec2 plane = direction.xz / (direction.y + 0.3) * uClouds.z; + vec2 streak = vec2(plane.x * 0.6 + plane.y, (plane.y - plane.x * 0.5) * uClouds.w); + float cloud = texture2D(uFoamMap, streak, 2.0).r; + float cover = smoothstep(uClouds.x, uClouds.x + uClouds.y, cloud); + cover *= smoothstep(uCloudBand.x, uCloudBand.x + 0.2, up) * (1.0 - smoothstep(uCloudBand.y * 0.5, uCloudBand.y, up)); + float sunward = 0.5 + 0.5 * dot(normalize(direction.xz + vec2(1e-5)), normalize(uSun.xz)); + float near = max(dot(direction, uSun), 0.0); + vec3 cloudColour = mix(uCloudShade, uCloudLit, clamp(pow(sunward, 2.0) * 1.1 + near * 0.3 - 0.1, 0.0, 1.0)); + colour = mix(colour, cloudColour, cover * uCloudBand.z); + colour = mix(colour, uSunDisc * 1.6, smoothstep(uDisc, uDisc + (1.0 - uDisc) * 0.35, near)); + colour = mix(uHaze, colour, smoothstep(-0.02, 0.0, direction.y)); + gl_FragColor = vec4(colour, 1.0); + #include +} +`; + +export function createSky(context: PartContext, foamMap: Texture): Mesh { + const { clouds, colours, glow } = SKY; + const material = registered( + context, + UNDIMMED_GROUP, + new ShaderMaterial({ + uniforms: { + ...skyUniforms(), + uCloudLit: { value: new Color(colours.cloudLit) }, + uCloudShade: { value: new Color(colours.cloudShade) }, + uClouds: { + value: new Vector4(clouds.cover, clouds.sharpness, clouds.scale, clouds.stretch), + }, + uCloudBand: { value: [clouds.from, clouds.to, clouds.opacity] }, + uDisc: { value: glow.disc }, + uFoamMap: { value: foamMap }, + }, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + side: BackSide, + depthWrite: false, + toneMapped: false, + }), + ); + const geometry = context.tracker.track( + new SphereGeometry(SKY.radius, SKY.widthSegments, SKY.heightSegments), + ); + const dome = new Mesh(geometry, material); + dome.frustumCulled = false; + dome.renderOrder = SKY.renderOrder; + dome.onBeforeRender = (_renderer, _scene, camera: Camera) => { + dome.position.copy(camera.position); + dome.parent?.worldToLocal(dome.position); + dome.updateMatrixWorld(); + }; + return dome; +} diff --git a/explainers/naval-drone/src/scene/parts/world/world.test.ts b/explainers/naval-drone/src/scene/parts/world/world.test.ts new file mode 100644 index 00000000..d468ce5e --- /dev/null +++ b/explainers/naval-drone/src/scene/parts/world/world.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { GROUND_STATION, SHIP, SLIPWAY } from '../../../model/layout'; +import { shipHalfBreadth } from './ship'; +import { shoreHeight, slabTop } from './shore'; + +describe('world shapes', () => { + it('runs the slipway from its top down to its foot', () => { + expect(slabTop(SLIPWAY.x[0])).toBeCloseTo(SLIPWAY.top, 9); + expect(slabTop(SLIPWAY.x[1])).toBeCloseTo(SLIPWAY.foot, 9); + }); + + it('keeps the shore under the slab, flat under the station and below water out at sea', () => { + const x = (SLIPWAY.x[0] + SLIPWAY.x[1]) / 2; + expect(shoreHeight(x, 0)).toBeLessThan(slabTop(x)); + expect(shoreHeight(GROUND_STATION[0], GROUND_STATION[2])).toBeCloseTo(GROUND_STATION[1], 6); + expect(shoreHeight(40, 0)).toBeLessThan(0); + expect(shoreHeight(-60, 30)).toBeGreaterThan(1); + }); + + it('gives the ship its waterline beam amidships and a fine stem', () => { + expect(shipHalfBreadth(0, 0)).toBeCloseTo(SHIP.beam / 2, 6); + expect(shipHalfBreadth(0, -SHIP.draft)).toBeCloseTo(0, 6); + expect(shipHalfBreadth(SHIP.length / 2, 1)).toBeLessThan(0.5); + }); +}); diff --git a/explainers/naval-drone/src/scene/pose.test.ts b/explainers/naval-drone/src/scene/pose.test.ts new file mode 100644 index 00000000..02f96f12 --- /dev/null +++ b/explainers/naval-drone/src/scene/pose.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { Object3D, Vector3 } from 'three'; +import { toRadians } from '@core/math'; +import { applyBoatPose } from './pose'; + +function bowDirection(object: Object3D): Vector3 { + object.updateMatrixWorld(true); + return new Vector3(1, 0, 0).transformDirection(object.matrixWorld); +} + +describe('boat pose', () => { + it('places the boat at its route point raised by the heave', () => { + const boat = new Object3D(); + applyBoatPose(boat, { position: [10, 0, -4], heading: 0 }, { heave: 0.2, trim: 0 }); + expect(boat.position.toArray()).toEqual([10, 0.2, -4]); + }); + + it('points the bow along the heading, turning toward +z as the heading grows', () => { + const boat = new Object3D(); + const heading = toRadians(30); + applyBoatPose(boat, { position: [0, 0, 0], heading }, { heave: 0, trim: 0 }); + const bow = bowDirection(boat); + expect(bow.x).toBeCloseTo(Math.cos(heading), 9); + expect(bow.z).toBeCloseTo(Math.sin(heading), 9); + const starboard = new Vector3(0, 0, 1).transformDirection(boat.matrixWorld); + expect(starboard.x).toBeCloseTo(-Math.sin(heading), 9); + expect(starboard.z).toBeCloseTo(Math.cos(heading), 9); + }); + + it('lifts the bow with a positive trim whatever the heading', () => { + const boat = new Object3D(); + const trim = toRadians(6); + applyBoatPose(boat, { position: [0, 0, 0], heading: toRadians(-20) }, { heave: 0, trim }); + const bow = bowDirection(boat); + expect(bow.y).toBeCloseTo(Math.sin(trim), 9); + expect(Math.atan2(bow.z, bow.x)).toBeCloseTo(toRadians(-20), 9); + }); +}); diff --git a/explainers/naval-drone/src/scene/pose.ts b/explainers/naval-drone/src/scene/pose.ts new file mode 100644 index 00000000..9efb5b7a --- /dev/null +++ b/explainers/naval-drone/src/scene/pose.ts @@ -0,0 +1,14 @@ +import type { Object3D } from 'three'; +import type { BoatReading, PlaningReading } from '../ids'; + +export const BOAT_EULER_ORDER = 'YZX'; + +export function applyBoatPose( + object: Object3D, + boat: Pick, + planing: Pick, +): void { + const [x, y, z] = boat.position; + object.position.set(x, y + planing.heave, z); + object.rotation.set(0, -boat.heading, planing.trim, BOAT_EULER_ORDER); +} diff --git a/explainers/naval-drone/src/scene/regions.test.ts b/explainers/naval-drone/src/scene/regions.test.ts new file mode 100644 index 00000000..4e22a2dd --- /dev/null +++ b/explainers/naval-drone/src/scene/regions.test.ts @@ -0,0 +1,24 @@ +import { Group, Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { GROUND_STATION, SLIPWAY } from '../model/layout'; +import { ROUTE_END, SHIP_CENTRE } from '../model/run'; +import { boatRegion, staticRegions } from './regions'; + +describe('regions', () => { + it('hold the shore, the ship and the whole run', () => { + const regions = staticRegions(); + expect(regions.shore.containsPoint(new Vector3(SLIPWAY.x[0], 1, 0))).toBe(true); + expect(regions.shore.containsPoint(new Vector3(...GROUND_STATION))).toBe(true); + expect(regions.ship.containsPoint(new Vector3(SHIP_CENTRE[0], 5, SHIP_CENTRE[2]))).toBe(true); + expect(regions.scene.containsPoint(new Vector3(ROUTE_END[0], 0, ROUTE_END[2]))).toBe(true); + expect(regions.scene.containsBox(regions.ship)).toBe(true); + }); + + it('follows the posed boat', () => { + const boat = new Group(); + boat.position.set(100, 0.2, -30); + const box = boatRegion(boat, staticRegions().scene.clone()); + expect(box.containsPoint(boat.position)).toBe(true); + expect(box.max.x - box.min.x).toBeLessThan(7); + }); +}); diff --git a/explainers/naval-drone/src/scene/regions.ts b/explainers/naval-drone/src/scene/regions.ts new file mode 100644 index 00000000..3e985012 --- /dev/null +++ b/explainers/naval-drone/src/scene/regions.ts @@ -0,0 +1,21 @@ +import type { Box3 } from 'three'; +import type { Object3D } from 'three'; +import { regionFromSpec } from '@core/scene/regions'; +import type { RegionId } from '../ids'; +import { BOAT_BOUNDS, SCENE_BOUNDS, SHORE_BOUNDS } from '../model/layout'; +import { SHIP_BOUNDS } from '../model/run'; + +export function staticRegions(): Readonly, Box3>> { + return { + scene: regionFromSpec(SCENE_BOUNDS), + ship: regionFromSpec(SHIP_BOUNDS), + shore: regionFromSpec(SHORE_BOUNDS), + }; +} + +const LOCAL_BOAT = regionFromSpec(BOAT_BOUNDS); + +export function boatRegion(boat: Object3D, target: Box3): Box3 { + boat.updateMatrixWorld(true); + return target.copy(LOCAL_BOAT).applyMatrix4(boat.matrixWorld); +} diff --git a/explainers/naval-drone/src/scene/sceneOptions.test.ts b/explainers/naval-drone/src/scene/sceneOptions.test.ts new file mode 100644 index 00000000..e4927409 --- /dev/null +++ b/explainers/naval-drone/src/scene/sceneOptions.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { SCENE_BOUNDS } from '../model/layout'; +import { SHIP_CENTRE } from '../model/run'; +import { HAZE, SKY } from './constants'; +import { SCENE_OPTIONS } from './sceneOptions'; + +describe('scene options', () => { + it('turns the stage off and hazes the horizon in the background colour', () => { + expect(SCENE_OPTIONS.stage).toBe(false); + expect(SCENE_OPTIONS.background).toBe(HAZE.colour); + expect(SCENE_OPTIONS.fog?.color).toBe(SCENE_OPTIONS.background); + expect(SCENE_OPTIONS.fog?.near).toBe(400); + expect(SCENE_OPTIONS.fog?.far).toBe(5000); + expect(SCENE_OPTIONS.gaugeSide).toBe('top'); + }); + + it('keeps the camera above the sea and reaches the sky dome and the ship', () => { + const camera = SCENE_OPTIONS.camera ?? {}; + expect(camera.far).toBeGreaterThan(SKY.radius); + expect(camera.near).toBe(0.05); + expect(camera.maxPolarAngle).toBeCloseTo(Math.PI / 2, 9); + expect(camera.distance).toEqual({ min: 1, max: 2500 }); + expect(camera.distance?.max).toBeGreaterThan(Math.hypot(SHIP_CENTRE[0], SHIP_CENTRE[2])); + expect(camera.far).toBeGreaterThan(SCENE_BOUNDS.x[1] - SCENE_BOUNDS.x[0]); + }); + + it('never dims the water effects, the ghost and the beams', () => { + expect(SCENE_OPTIONS.highlight?.undimmed).toEqual([ + 'jetStream', + 'bowWave', + 'spray', + 'wake', + 'wettedLength', + 'videoGhost', + 'satLink', + 'backupLink', + ]); + expect(SCENE_OPTIONS.highlight?.dim?.brightness).toBeGreaterThan(0.45); + }); +}); diff --git a/explainers/naval-drone/src/scene/sceneOptions.ts b/explainers/naval-drone/src/scene/sceneOptions.ts new file mode 100644 index 00000000..6008ace6 --- /dev/null +++ b/explainers/naval-drone/src/scene/sceneOptions.ts @@ -0,0 +1,28 @@ +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[] = [ + 'jetStream', + 'bowWave', + 'spray', + 'wake', + 'wettedLength', + 'videoGhost', + 'satLink', + 'backupLink', +]; + +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/naval-drone/src/scene/viewFit.test.ts b/explainers/naval-drone/src/scene/viewFit.test.ts new file mode 100644 index 00000000..4f2374ed --- /dev/null +++ b/explainers/naval-drone/src/scene/viewFit.test.ts @@ -0,0 +1,81 @@ +import { Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { toRadians } from '@core/math'; +import { + boatToWorld, + direction, + fitDistance, + fitsView, + lookUpLimits, + nearestFit, + pivotNear, + polarOf, +} from './viewFit'; + +const SLOPES = { vertical: 0.2, horizontal: 0.34 }; +const LEVEL = { position: [10, 0.5, -4] as const, heading: 0, trim: 0 }; + +describe('view fitting', () => { + it('turns boat points into the world with the heading, the trim only when pitched', () => { + expect(boatToWorld(LEVEL, [1, 0, 0], true).toArray()).toEqual([11, 0.5, -4]); + const turned = boatToWorld({ ...LEVEL, heading: Math.PI / 2 }, [1, 0, 0], false); + expect(turned.x).toBeCloseTo(10, 9); + expect(turned.z).toBeCloseTo(-3, 9); + const trimmed = { ...LEVEL, trim: toRadians(10) }; + expect(boatToWorld(trimmed, [-2, 0, 0], true).y).toBeLessThan(0.5); + expect(boatToWorld(trimmed, [-2, 0, 0], false).y).toBe(0.5); + }); + + it('points a direction from its azimuth and elevation', () => { + const up = direction(0, toRadians(30)); + expect(up.y).toBeCloseTo(0.5, 9); + expect(direction(Math.PI / 2, 0).z).toBeCloseTo(1, 9); + }); + + it('fits a width across the stage', () => { + expect(fitDistance(6.8, SLOPES)).toBeCloseTo(10, 9); + }); + + it('keeps points in front of the camera and inside the frame', () => { + const pose = { position: new Vector3(0, 0, 0), target: new Vector3(10, 0, 0) }; + expect(fitsView(pose, [new Vector3(10, 1, 2)], SLOPES, 1)).toBe(true); + expect(fitsView(pose, [new Vector3(10, 3, 0)], SLOPES, 1)).toBe(false); + expect(fitsView(pose, [new Vector3(-10, 0, 0)], SLOPES, 1)).toBe(false); + }); + + it('finds the nearest distance that fits, or the farthest allowed', () => { + const poseAt = (distance: number) => ({ + position: new Vector3(-distance, 0, 0), + target: new Vector3(0, 0, 0), + }); + const point = [new Vector3(0, 2, 0)]; + const near = nearestFit(poseAt, (pose) => fitsView(pose, point, SLOPES, 1), { + min: 1, + max: 100, + }); + expect(near.position.x).toBeCloseTo(-10, 3); + const far = nearestFit(poseAt, () => false, { min: 1, max: 100 }); + expect(far.position.x).toBeCloseTo(-100, 6); + }); + + it('turns the camera around the point of its view nearest the subject', () => { + const pose = { position: new Vector3(0, 10, 0), target: new Vector3(100, 10, 0) }; + const pivoted = pivotNear(pose, new Vector3(30, 2, 5)); + expect(pivoted.position.toArray()).toEqual([0, 10, 0]); + expect(pivoted.target.toArray()).toEqual([30, 10, 0]); + }); + + it('lets a camera look up only as far as its view asks, and never under the sea', () => { + const level = { position: new Vector3(-10, 5, 0), target: new Vector3(0, 0, 0) }; + expect(lookUpLimits(level, Math.PI / 2, 1)).toEqual({ + maxPolarAngle: Math.PI / 2, + maxDistance: null, + }); + const upward = { position: new Vector3(-40, 4, 0), target: new Vector3(0, 12, 0) }; + const limits = lookUpLimits(upward, Math.PI / 2, 1); + expect(limits.maxPolarAngle).toBeGreaterThan(polarOf(upward)); + expect(limits.maxPolarAngle).toBeLessThan(polarOf(upward) + 0.01); + const lowest = 12 + (limits.maxDistance ?? 0) * Math.cos(limits.maxPolarAngle); + expect(lowest).toBeCloseTo(1, 9); + }); +}); diff --git a/explainers/naval-drone/src/scene/viewFit.ts b/explainers/naval-drone/src/scene/viewFit.ts new file mode 100644 index 00000000..a317d306 --- /dev/null +++ b/explainers/naval-drone/src/scene/viewFit.ts @@ -0,0 +1,111 @@ +import { Euler, Vector3 } from 'three'; +import type { CameraPose } from '@core/scene/frameBox'; +import type { FramingSlopes, ViewportSize } from '@core/scene/lens'; +import type { Point } from '../ids'; +import { BOAT_EULER_ORDER } from './pose'; + +export interface BoatFrame { + position: Point; + heading: number; + trim: number; +} + +export interface DistanceRange { + min: number; + max: number; +} + +export interface PolarLimits { + maxPolarAngle: number; + maxDistance: number | null; +} + +const UP = new Vector3(0, 1, 0); +const COMPACT_STAGE_WIDTH_PX = 600; +const SOLVE_STEPS = 32; +const POLAR_SLACK = 0.005; + +export function isCompactStage(size: Pick): boolean { + return size.width < COMPACT_STAGE_WIDTH_PX; +} + +export function vectorOf(point: Point): Vector3 { + return new Vector3(point[0], point[1], point[2]); +} + +export function direction(azimuth: number, elevation: number): Vector3 { + const level = Math.cos(elevation); + return new Vector3(level * Math.cos(azimuth), Math.sin(elevation), level * Math.sin(azimuth)); +} + +export function rightOf(away: Vector3): Vector3 { + return away.clone().negate().cross(UP).normalize(); +} + +export function boatToWorld(frame: BoatFrame, point: Point, pitched: boolean): Vector3 { + const rotation = new Euler(0, -frame.heading, pitched ? frame.trim : 0, BOAT_EULER_ORDER); + return vectorOf(point).applyEuler(rotation).add(vectorOf(frame.position)); +} + +export function fitDistance(width: number, slopes: FramingSlopes): number { + return width / (2 * slopes.horizontal); +} + +export function fitsView( + pose: CameraPose, + points: readonly Vector3[], + slopes: FramingSlopes, + fill: number, +): boolean { + const forward = pose.target.clone().sub(pose.position).normalize(); + const right = rightOf(forward.clone().negate()); + const up = right.clone().cross(forward); + return points.every((point) => { + const offset = point.clone().sub(pose.position); + const depth = offset.dot(forward); + if (depth <= 0) return false; + const across = Math.abs(offset.dot(right)) / depth; + const rise = Math.abs(offset.dot(up)) / depth; + return across <= slopes.horizontal * fill && rise <= slopes.vertical * fill; + }); +} + +export function nearestFit( + poseAt: (distance: number) => CameraPose, + fits: (pose: CameraPose) => boolean, + range: DistanceRange, +): CameraPose { + if (fits(poseAt(range.min))) return poseAt(range.min); + let low = range.min; + let high = range.max; + for (let step = 0; step < SOLVE_STEPS; step += 1) { + const middle = (low + high) / 2; + if (fits(poseAt(middle))) high = middle; + else low = middle; + } + return poseAt(high); +} + +export function pivotNear(pose: CameraPose, point: Vector3): CameraPose { + const forward = pose.target.clone().sub(pose.position).normalize(); + const depth = point.clone().sub(pose.position).dot(forward); + return { + position: pose.position.clone(), + target: pose.position.clone().addScaledVector(forward, depth), + }; +} + +export function polarOf(pose: CameraPose): number { + return pose.position.clone().sub(pose.target).angleTo(UP); +} + +export function lookUpLimits( + pose: CameraPose, + defaultMaxPolar: number, + seaClearance: number, +): PolarLimits { + const polar = polarOf(pose); + if (polar <= defaultMaxPolar) return { maxPolarAngle: defaultMaxPolar, maxDistance: null }; + const limit = polar + POLAR_SLACK; + return { maxPolarAngle: limit, maxDistance: (pose.target.y - seaClearance) / -Math.cos(limit) }; +} diff --git a/explainers/naval-drone/src/state/derived.test.ts b/explainers/naval-drone/src/state/derived.test.ts new file mode 100644 index 00000000..427a6179 --- /dev/null +++ b/explainers/naval-drone/src/state/derived.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { toRadians } from '@core/math'; +import { + HELD_PHASE, + boatAt, + companionsAt, + distanceToShip, + jetAt, + lagMetres, + planingAt, + poseAtDistance, + seaAt, + steerAt, + throttleAt, +} from '../model'; +import { assemblyFlags, followedKnots, runAt } from './derived'; +import type { RunSource } from './derived'; + +const RUNNING: RunSource = { + phase: 45, + trialKnots: null, + helm: 'straight', + linkMode: 'satellite', + videoDelayMs: 250, + seaState: 'smooth', + preset: 'overview', +}; + +function held(knots: number, helm: RunSource['helm'] = 'straight'): RunSource { + return { ...RUNNING, phase: HELD_PHASE, trialKnots: knots, helm, preset: 'jet' }; +} + +describe('run readings', () => { + it('follows the run under the scrubber while nobody holds the boat', () => { + const reading = runAt(RUNNING); + const boat = boatAt(45); + expect(reading.boat).toEqual(boat); + expect(reading.planing).toEqual(planingAt(boat.knots)); + expect(reading.jet).toEqual(jetAt(throttleAt(boat.knots), boat.knots, steerAt(45), false)); + expect(reading.jet.nozzleAngle).toBeCloseTo(-toRadians(8), 9); + expect(reading.companions).toEqual(companionsAt(45)); + expect(reading.distanceToShip).toBeCloseTo(distanceToShip(45), 9); + expect(reading.sea).toEqual(seaAt('smooth')); + }); + + it('brings the companions in for the sprint', () => { + expect(runAt({ ...RUNNING, phase: 100 }).companions).toHaveLength(2); + }); + + it('holds the boat in place at the trial speed', () => { + const reading = runAt(held(42)); + const place = boatAt(HELD_PHASE); + expect(reading.boat).toEqual({ ...place, knots: 42, held: true }); + expect(reading.planing).toEqual(planingAt(42)); + expect(reading.jet).toEqual(jetAt(throttleAt(42), 42, 0, false)); + expect(reading.companions).toEqual([]); + }); + + it('swings the nozzle full over for each helm', () => { + expect(runAt(held(22, 'left')).jet.nozzleAngle).toBeCloseTo(-toRadians(27), 9); + expect(runAt(held(22, 'right')).jet.nozzleAngle).toBeCloseTo(toRadians(27), 9); + }); + + it('stops the boat with the bucket down in reverse but keeps the pump running', () => { + const reading = runAt(held(22, 'reverse')); + expect(reading.boat.knots).toBe(0); + expect(reading.planing).toEqual(planingAt(0)); + expect(reading.jet).toMatchObject({ throttle: throttleAt(22), bucket: 1, thrust: 0 }); + }); + + it('draws the delayed video ghost only in the link chapter while the link is up', () => { + const link: RunSource = { ...RUNNING, phase: 100, preset: 'link', videoDelayMs: 500 }; + const boat = boatAt(100); + expect(runAt(link).link).toEqual({ + mode: 'satellite', + ghost: poseAtDistance(boat.distance - lagMetres(500, boat.knots)), + }); + expect(runAt({ ...link, linkMode: 'backup' }).link.ghost).not.toBeNull(); + expect(runAt({ ...link, linkMode: 'lost' }).link).toEqual({ mode: 'lost', ghost: null }); + expect(runAt({ ...link, preset: 'overview' }).link.ghost).toBeNull(); + }); + + it('reads the sea state the reader picks', () => { + expect(runAt({ ...RUNNING, seaState: 'rough' }).sea).toEqual(seaAt('rough')); + }); + + it('remembers the last reading until a source changes', () => { + const first = runAt({ ...RUNNING, phase: 70 }); + expect(runAt({ ...RUNNING, phase: 70 })).toBe(first); + expect(runAt({ ...RUNNING, phase: 70, seaState: 'slight' })).not.toBe(first); + }); + + it('follows the run speed in the sliders until the reader holds the boat', () => { + expect(followedKnots({ phase: 28, trialKnots: null })).toBe(11); + expect(followedKnots({ phase: 28, trialKnots: 30 })).toBe(30); + }); + + it('opens the water section in the hull and jet chapters only, the bar only in the hull one', () => { + expect(assemblyFlags({ preset: 'overview' })).toEqual({ + waterSection: false, + wettedBar: false, + }); + expect(assemblyFlags({ preset: 'link' }).waterSection).toBe(false); + expect(assemblyFlags({ preset: 'hull' })).toEqual({ + waterSection: true, + wettedBar: true, + }); + expect(assemblyFlags({ preset: 'jet' })).toEqual({ + waterSection: true, + wettedBar: false, + }); + }); +}); diff --git a/explainers/naval-drone/src/state/derived.ts b/explainers/naval-drone/src/state/derived.ts new file mode 100644 index 00000000..6e6a1e90 --- /dev/null +++ b/explainers/naval-drone/src/state/derived.ts @@ -0,0 +1,128 @@ +import type { + BoatReading, + CompanionReading, + HelmId, + JetReading, + LinkReading, + PlaningReading, + SeaReading, +} from '../ids'; +import { + boatAt, + companionsAt, + distanceToShip, + helmNozzle, + jetAt, + lagMetres, + planingAt, + poseAtDistance, + seaAt, + speedAt, + steerAt, + throttleAt, +} from '../model'; +import type { NavalDroneState } from './store'; + +const RUN_SOURCE_KEYS = [ + 'phase', + 'trialKnots', + 'helm', + 'linkMode', + 'videoDelayMs', + 'seaState', + 'preset', +] as const; + +export type RunSource = Pick; +export type FlagSource = Pick; + +export interface RunReading { + boat: BoatReading; + planing: PlaningReading; + jet: JetReading; + companions: readonly CompanionReading[]; + distanceToShip: number; + sea: SeaReading; + link: LinkReading; +} + +export interface AssemblyFlags { + waterSection: boolean; + wettedBar: boolean; +} + +type Motion = Pick; + +const NO_COMPANIONS: readonly CompanionReading[] = []; +const STOPPED_KNOTS = 0; + +function runningAt(phase: number): Motion { + const boat = boatAt(phase); + return { + boat, + planing: planingAt(boat.knots), + jet: jetAt(throttleAt(boat.knots), boat.knots, steerAt(phase), false), + companions: companionsAt(phase), + }; +} + +function heldAt(phase: number, trialKnots: number, helm: HelmId): Motion { + const reverse = helm === 'reverse'; + const knots = reverse ? STOPPED_KNOTS : trialKnots; + return { + boat: { ...boatAt(phase), knots, held: true }, + planing: planingAt(knots), + jet: jetAt(throttleAt(trialKnots), knots, helmNozzle(helm), reverse), + companions: NO_COMPANIONS, + }; +} + +function linkOf(source: RunSource, boat: BoatReading): LinkReading { + const { linkMode: mode, videoDelayMs, preset } = source; + if (preset !== 'link' || mode === 'lost') return { mode, ghost: null }; + return { mode, ghost: poseAtDistance(boat.distance - lagMetres(videoDelayMs, boat.knots)) }; +} + +function readingOf(source: RunSource): RunReading { + const { phase, trialKnots, helm, seaState } = source; + const motion = trialKnots === null ? runningAt(phase) : heldAt(phase, trialKnots, helm); + return { + ...motion, + distanceToShip: distanceToShip(phase), + sea: seaAt(seaState), + link: linkOf(source, motion.boat), + }; +} + +interface Remembered { + source: RunSource; + reading: RunReading; +} + +let last: Remembered | null = null; + +function sourceOf(state: RunSource): RunSource { + const { phase, trialKnots, helm, linkMode, videoDelayMs, seaState, preset } = state; + return { phase, trialKnots, helm, linkMode, videoDelayMs, seaState, preset }; +} + +function isRemembered(remembered: Remembered | null, source: RunSource): remembered is Remembered { + return ( + remembered !== null && RUN_SOURCE_KEYS.every((key) => remembered.source[key] === source[key]) + ); +} + +export function runAt(state: RunSource): Readonly { + if (isRemembered(last, state)) return last.reading; + const source = sourceOf(state); + last = { source, reading: readingOf(source) }; + return last.reading; +} + +export function followedKnots(state: Pick): number { + return state.trialKnots ?? speedAt(state.phase); +} + +export function assemblyFlags({ preset }: FlagSource): AssemblyFlags { + return { waterSection: preset === 'hull' || preset === 'jet', wettedBar: preset === 'hull' }; +} diff --git a/explainers/naval-drone/src/state/index.ts b/explainers/naval-drone/src/state/index.ts new file mode 100644 index 00000000..029bc932 --- /dev/null +++ b/explainers/naval-drone/src/state/index.ts @@ -0,0 +1,3 @@ +export * from './derived'; +export * from './presets'; +export * from './store'; diff --git a/explainers/naval-drone/src/state/presets.test.ts b/explainers/naval-drone/src/state/presets.test.ts new file mode 100644 index 00000000..80eb79fb --- /dev/null +++ b/explainers/naval-drone/src/state/presets.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { PART_IDS, PRESET_IDS } from '../ids'; +import { HELD_PHASE, SPEED_RANGE } from '../model'; +import { PRESETS } from './presets'; + +describe('chapter presets', () => { + it('has the six chapters in reading order', () => { + expect(Object.keys(PRESETS)).toEqual([...PRESET_IDS]); + }); + + it('frames each chapter with its own camera view', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].camera)).toEqual([ + 'chase', + 'waterline', + 'stern', + 'sky', + 'eye', + 'group', + ]); + }); + + it('labels and highlights only known parts', () => { + Object.entries(PRESETS).forEach(([id, preset]) => + [...preset.labels, ...preset.highlight].forEach((part) => + expect(PART_IDS, `${id}: ${part}`).toContain(part), + ), + ); + }); + + it('highlights the labelled parts of the hull, jet and link chapters', () => { + expect(PRESETS.hull.highlight).toEqual(PRESETS.hull.labels); + expect(PRESETS.link.highlight).toEqual([ + 'starlinkPanels', + 'backupPanel', + ...PRESETS.link.labels, + ]); + expect(PRESETS.jet.highlight).toEqual([...PRESETS.jet.labels, 'duct', 'jetStream', 'waterjet']); + expect(PRESETS.horizon.highlight).toEqual(['cameraDome', 'bowCamera', 'ship', 'shipRadar']); + expect(PRESETS.overview.highlight).toEqual([]); + expect(PRESETS.fleet.highlight).toEqual([]); + }); + + it('plays every chapter at normal speed', () => { + Object.values(PRESETS).forEach((preset) => { + expect(preset.speed).toBe(1); + expect(preset.speed).toBeGreaterThanOrEqual(SPEED_RANGE.min); + }); + }); + + it('holds the boat on the final leg for the hull and the jet, and seeks the others', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].pauseAt)).toEqual([ + undefined, + HELD_PHASE, + HELD_PHASE, + undefined, + undefined, + undefined, + ]); + expect(PRESET_IDS.map((id) => PRESETS[id].startAt)).toEqual([ + 0, + undefined, + undefined, + 52, + 62, + 96, + ]); + }); + + it('sets the cutaway, the water flow and the links for each chapter, never the labels', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].view)).toEqual([ + { cutaway: false, flow: false, links: false }, + { cutaway: false, flow: true, links: false }, + { cutaway: true, flow: true, links: false }, + { cutaway: false, flow: false, links: true }, + { cutaway: false, flow: false, links: false }, + { cutaway: false, flow: false, links: false }, + ]); + }); + + it('starts each chapter control only in the chapter that owns it', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].controls)).toEqual([ + undefined, + ['trialKnots'], + ['trialKnots', 'helm'], + ['linkMode', 'videoDelayMs'], + ['radarHeight', 'seaState'], + undefined, + ]); + Object.entries(PRESETS).forEach(([id, preset]) => + Object.keys(preset.start ?? {}).forEach((control) => + expect(preset.controls, `${id}: ${control}`).toContain(control), + ), + ); + expect(PRESETS.hull.start).toEqual({ trialKnots: 11 }); + expect(PRESETS.jet.start).toEqual({ trialKnots: 22, helm: 'straight' }); + expect(PRESETS.horizon.start).toEqual({ seaState: 'slight' }); + }); +}); diff --git a/explainers/naval-drone/src/state/presets.ts b/explainers/naval-drone/src/state/presets.ts new file mode 100644 index 00000000..e8eb30a4 --- /dev/null +++ b/explainers/naval-drone/src/state/presets.ts @@ -0,0 +1,131 @@ +import type { ScenePreset } from '@core/scene/presetBinder'; +import type { + CameraView, + FitId, + HelmId, + LinkMode, + PartId, + PresetId, + SeaStateId, + ViewOptions, +} from '../ids'; +import { HELD_PHASE, SPEED_MARKS } from '../model'; + +export interface ChapterControls { + trialKnots: number | null; + helm: HelmId; + linkMode: LinkMode; + videoDelayMs: number; + radarHeight: number; + seaState: SeaStateId; +} + +export type ChapterControl = keyof ChapterControls; + +export interface Preset extends ScenePreset { + view: Partial; + controls?: readonly ChapterControl[]; + start?: Partial; +} + +export const DEFAULT_FIT: FitId = 'standard'; + +const NORMAL_SPEED = 1; + +const CHAPTER_START_SECONDS = { + overview: 0, + link: 52, + horizon: 62, + fleet: 96, +} as const; + +const HULL_PARTS: readonly PartId[] = [ + 'hull', + 'chines', + 'sprayRails', + 'bowWave', + 'spray', + 'wake', + 'wettedLength', +]; + +const JET_LABELS: readonly PartId[] = [ + 'intake', + 'driveShaft', + 'impeller', + 'stator', + 'nozzle', + 'steeringNozzle', + 'reverseBucket', + 'engine', + 'fuelTanks', +]; + +const LINK_ANTENNAS: readonly PartId[] = ['starlinkPanels', 'backupPanel']; + +const LINK_LABELS: readonly PartId[] = [ + 'satLink', + 'backupLink', + 'satellite', + 'backupSatellite', + 'groundStation', + 'videoGhost', +]; + +export const PRESETS: Record = { + overview: { + camera: 'chase', + speed: NORMAL_SPEED, + view: { cutaway: false, flow: false, links: false }, + startAt: CHAPTER_START_SECONDS.overview, + labels: ['hull', 'waterjet', 'starlinkPanels', 'backupPanel', 'cameraDome', 'bowCamera'], + highlight: [], + }, + hull: { + camera: 'waterline', + speed: NORMAL_SPEED, + view: { cutaway: false, flow: true, links: false }, + pauseAt: HELD_PHASE, + controls: ['trialKnots'], + start: { trialKnots: SPEED_MARKS.hump }, + labels: HULL_PARTS, + highlight: HULL_PARTS, + }, + jet: { + camera: 'stern', + speed: NORMAL_SPEED, + view: { cutaway: true, flow: true, links: false }, + pauseAt: HELD_PHASE, + controls: ['trialKnots', 'helm'], + start: { trialKnots: SPEED_MARKS.cruise, helm: 'straight' }, + labels: JET_LABELS, + highlight: [...JET_LABELS, 'duct', 'jetStream', 'waterjet'], + }, + link: { + camera: 'sky', + speed: NORMAL_SPEED, + view: { cutaway: false, flow: false, links: true }, + startAt: CHAPTER_START_SECONDS.link, + controls: ['linkMode', 'videoDelayMs'], + labels: LINK_LABELS, + highlight: [...LINK_ANTENNAS, ...LINK_LABELS], + }, + horizon: { + camera: 'eye', + speed: NORMAL_SPEED, + view: { cutaway: false, flow: false, links: false }, + startAt: CHAPTER_START_SECONDS.horizon, + controls: ['radarHeight', 'seaState'], + start: { seaState: 'slight' }, + labels: ['ship', 'shipRadar', 'bowCamera', 'wake'], + highlight: ['cameraDome', 'bowCamera', 'ship', 'shipRadar'], + }, + fleet: { + camera: 'group', + speed: NORMAL_SPEED, + view: { cutaway: false, flow: false, links: false }, + startAt: CHAPTER_START_SECONDS.fleet, + labels: ['companions', 'ship', 'missileRails', 'starlinkPanels'], + highlight: [], + }, +}; diff --git a/explainers/naval-drone/src/state/store.test.ts b/explainers/naval-drone/src/state/store.test.ts new file mode 100644 index 00000000..be6052c5 --- /dev/null +++ b/explainers/naval-drone/src/state/store.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest'; +import { MOMENT_IDS, PRESET_IDS } from '../ids'; +import { HELD_PHASE, MOMENTS, RUN_SECONDS, knotsAtThrottle, speedAt, throttleAt } from '../model'; +import { PRESETS } from './presets'; +import { CHAPTER_CONTROL_DEFAULTS, DEFAULT_VIEW, createNavalDroneStore } from './store'; +import type { NavalDroneStore } from './store'; + +function heldStore(knots = 30): NavalDroneStore { + const store = createNavalDroneStore({ phase: 50, playing: true }); + store.getState().setTrialKnots(knots); + return store; +} + +describe('naval drone store', () => { + it('starts on the overview at the slipway with every chapter control at rest', () => { + expect(createNavalDroneStore().getState()).toMatchObject({ + phase: 0, + speed: 1, + preset: 'overview', + fit: 'standard', + trialKnots: null, + helm: 'straight', + linkMode: 'satellite', + videoDelayMs: 250, + radarHeight: 20, + seaState: 'smooth', + view: { cutaway: false, flow: false, links: false, labels: true }, + }); + }); + + it('opens with the view the overview chapter asks for', () => { + expect(DEFAULT_VIEW).toMatchObject(PRESETS.overview.view); + }); + + it('runs the whole route in two minutes and stops at the ship', () => { + const store = createNavalDroneStore({ phase: 0, playing: true }); + store.getState().tick(60); + expect(store.getState().phase).toBeCloseTo(60, 9); + store.getState().tick(90); + expect(store.getState().phase).toBe(RUN_SECONDS); + expect(store.getState().playing).toBe(false); + }); + + it('pauses at each moment', () => { + const store = createNavalDroneStore({ playing: true }); + MOMENT_IDS.forEach((moment) => { + store.getState().play(); + store.getState().seekMoment(moment); + expect(store.getState().playing, moment).toBe(false); + expect(store.getState().phase, moment).toBeCloseTo(MOMENTS[moment], 9); + }); + }); + + it('keeps the delay and the radar height on their sliders', () => { + const store = createNavalDroneStore(); + store.getState().setVideoDelay(2000); + expect(store.getState().videoDelayMs).toBe(1000); + store.getState().setVideoDelay(10); + expect(store.getState().videoDelayMs).toBe(50); + store.getState().setVideoDelay(333); + expect(store.getState().videoDelayMs).toBe(330); + store.getState().setRadarHeight(80); + expect(store.getState().radarHeight).toBe(50); + store.getState().setRadarHeight(1); + expect(store.getState().radarHeight).toBe(5); + }); +}); + +describe('speed trial', () => { + it('takes the boat over and pauses the run when the reader sets a speed', () => { + const store = heldStore(27.04); + expect(store.getState()).toMatchObject({ trialKnots: 27, playing: false, phase: 50 }); + store.getState().setTrialKnots(60); + expect(store.getState().trialKnots).toBe(42); + store.getState().setTrialKnots(-1); + expect(store.getState().trialKnots).toBe(0); + }); + + it('sets the speed that a throttle setting holds', () => { + const store = createNavalDroneStore({ playing: true }); + store.getState().setThrottle(55); + expect(store.getState().playing).toBe(false); + expect(store.getState().trialKnots).toBeCloseTo(knotsAtThrottle(0.55), 9); + expect(throttleAt(store.getState().trialKnots ?? 0)).toBeCloseTo(0.55, 6); + store.getState().setThrottle(150); + expect(store.getState().trialKnots).toBeCloseTo(42, 6); + }); + + it('jumps to each speed mark', () => { + const store = createNavalDroneStore({ playing: true }); + store.getState().setSpeedMark('hullSpeed'); + expect(store.getState()).toMatchObject({ trialKnots: 5.7, playing: false }); + store.getState().setSpeedMark('planing'); + expect(store.getState().trialKnots).toBe(16); + }); + + it('holds the run speed of the moment when the helm moves first', () => { + const store = createNavalDroneStore({ phase: 30, playing: true }); + store.getState().setHelm('left'); + expect(store.getState()).toMatchObject({ helm: 'left', playing: false }); + expect(store.getState().trialKnots).toBeCloseTo(speedAt(30), 1); + store.getState().setTrialKnots(35); + store.getState().setHelm('reverse'); + expect(store.getState()).toMatchObject({ helm: 'reverse', trialKnots: 35 }); + }); + + it('lets go of the boat and centres the helm on release', () => { + const store = heldStore(); + store.getState().setHelm('right'); + store.getState().releaseTrial(); + expect(store.getState()).toMatchObject({ trialKnots: null, helm: 'straight' }); + }); + + it('lets go of the boat when the run plays again', () => { + const store = heldStore(); + store.getState().setHelm('reverse'); + store.getState().play(); + expect(store.getState()).toMatchObject({ trialKnots: null, helm: 'straight', playing: true }); + }); + + it.each([ + ['the scrubber', (store: NavalDroneStore) => store.getState().setPhase(70)], + ['a step', (store: NavalDroneStore) => store.getState().step(0.5)], + ['a phase chip', (store: NavalDroneStore) => store.getState().jumpToPhase('sprint')], + ['a moment chip', (store: NavalDroneStore) => store.getState().seekMoment('topSpeed')], + ])('lets go of the boat on %s', (_, seek) => { + const store = heldStore(); + seek(store); + expect(store.getState().trialKnots).toBeNull(); + expect(store.getState().playing).toBe(false); + }); + + it('never lets go of the boat in the update that changes the chapter', () => { + const store = createNavalDroneStore({ + preset: 'hull', + phase: HELD_PHASE, + playing: false, + trialKnots: 11, + }); + store.setState({ preset: 'jet', playing: true, phase: MOMENTS.topSpeed, trialKnots: 22 }); + expect(store.getState().trialKnots).toBe(22); + }); + + it('keeps holding the boat while the phase stays put', () => { + const store = heldStore(); + store.getState().setPhase(50); + store.getState().setFit('missile'); + store.getState().toggleView('cutaway'); + store.getState().setSpeed(2); + expect(store.getState().trialKnots).toBe(30); + }); + + it('holds the boat at the hull and jet chapter speeds when the chapter seeks', () => { + const store = createNavalDroneStore({ phase: 10, playing: true }); + store.getState().applyPreset('hull'); + expect(store.getState()).toMatchObject({ + phase: HELD_PHASE, + playing: false, + trialKnots: 11, + helm: 'straight', + }); + store.getState().setHelm('left'); + store.getState().applyPreset('jet'); + expect(store.getState()).toMatchObject({ phase: HELD_PHASE, trialKnots: 22, helm: 'straight' }); + }); + + it('lets go of the boat and plays on in the chapters without a trial', () => { + const store = createNavalDroneStore({ phase: 10, playing: true }); + store.getState().applyPreset('jet'); + store.getState().applyPreset('link'); + expect(store.getState()).toMatchObject({ + trialKnots: null, + helm: 'straight', + phase: 52, + playing: true, + }); + }); + + it('plays on after the reader takes the helm in a chapter that paused the run itself', () => { + const store = createNavalDroneStore({ phase: 10, playing: true }); + store.getState().applyPreset('jet'); + store.getState().setHelm('right'); + store.getState().setTrialKnots(30); + store.getState().applyPreset('link'); + expect(store.getState()).toMatchObject({ trialKnots: null, helm: 'straight', playing: true }); + }); + + it('keeps the pause a trial makes while the run plays', () => { + const store = createNavalDroneStore({ phase: 10, playing: true }); + store.getState().applyPreset('jet'); + store.getState().play(); + store.getState().setTrialKnots(30); + store.getState().applyPreset('link'); + expect(store.getState()).toMatchObject({ trialKnots: null, playing: false }); + }); +}); + +describe('chapter controls', () => { + it.each([ + ['hull', 'trialKnots', 30], + ['jet', 'helm', 'reverse'], + ['link', 'linkMode', 'lost'], + ['link', 'videoDelayMs', 600], + ['horizon', 'radarHeight', 40], + ['horizon', 'seaState', 'rough'], + ] as const)( + 'resets the %s chapter control %s in every other chapter', + (chapter, field, value) => { + const store = createNavalDroneStore(); + PRESET_IDS.forEach((id) => { + store.getState().applyPreset(chapter); + store.setState({ [field]: value }); + store.getState().applyPreset(id); + const expected = + id === chapter ? value : (PRESETS[id].start?.[field] ?? CHAPTER_CONTROL_DEFAULTS[field]); + expect(store.getState()[field], id).toBe(expected); + }); + }, + ); + + it('keeps every control when the same chapter is applied again', () => { + const store = createNavalDroneStore(); + store.getState().applyPreset('horizon'); + store.getState().setSeaState('moderate'); + store.getState().setRadarHeight(35); + store.getState().applyPreset('horizon'); + expect(store.getState()).toMatchObject({ seaState: 'moderate', radarHeight: 35 }); + }); + + it('opens the horizon chapter on a slight sea', () => { + const store = createNavalDroneStore(); + store.getState().applyPreset('horizon'); + expect(store.getState()).toMatchObject({ seaState: 'slight', phase: 62 }); + }); + + it('keeps the deck fit and the labels through every chapter', () => { + const store = createNavalDroneStore(); + store.getState().setFit('missile'); + PRESET_IDS.forEach((id) => { + store.getState().applyPreset(id); + expect(store.getState().fit, id).toBe('missile'); + expect(store.getState().view.labels, id).toBe(true); + }); + }); + + it('seeks the chapters without a trial and keeps playing', () => { + const store = createNavalDroneStore({ phase: 80, playing: true }); + store.getState().applyPreset('fleet'); + expect(store.getState()).toMatchObject({ phase: 96, playing: true, speed: 1 }); + store.getState().applyPreset('horizon'); + expect(store.getState()).toMatchObject({ phase: 62, playing: true }); + }); +}); diff --git a/explainers/naval-drone/src/state/store.ts b/explainers/naval-drone/src/state/store.ts new file mode 100644 index 00000000..b4881bd1 --- /dev/null +++ b/explainers/naval-drone/src/state/store.ts @@ -0,0 +1,138 @@ +import type { ExplainerStore, Playback, PlaybackState } from '@core/explainer'; +import { createExplainerStore } from '@core/store'; +import { shallow } from 'zustand/vanilla/shallow'; +import type { + FitId, + HelmId, + LinkMode, + MomentId, + PresetId, + SeaStateId, + SpeedMarkId, + ViewOptions, +} from '../ids'; +import { + DEFAULT_SEA_STATE, + MOMENTS, + RADAR_HEIGHT_M, + SPEED_MARKS, + SPEED_RANGE, + VIDEO_DELAY_MS, + knotsAtThrottle, + speedAt, + stepTo, + throttleShareOf, + trialKnotsOf, +} from '../model'; +import { NAVAL_DRONE_TIMELINE } from '../timeline'; +import { DEFAULT_FIT, PRESETS } from './presets'; +import type { ChapterControls, Preset } from './presets'; + +export type ViewState = { [Key in keyof ViewOptions]: ViewOptions[Key] }; + +export interface NavalDroneFields extends ChapterControls { + fit: FitId; + view: ViewState; + preset: PresetId; +} + +export interface NavalDroneOwnActions { + setFit(fit: FitId): void; + setTrialKnots(knots: number): void; + setThrottle(percent: number): void; + setSpeedMark(mark: SpeedMarkId): void; + setHelm(helm: HelmId): void; + releaseTrial(): void; + setLinkMode(mode: LinkMode): void; + setVideoDelay(delayMs: number): void; + setRadarHeight(metres: number): void; + setSeaState(sea: SeaStateId): void; + seekMoment(moment: MomentId): void; +} + +export type NavalDroneState = PlaybackState & NavalDroneFields; +export type NavalDroneStoreState = Playback & NavalDroneFields & NavalDroneOwnActions; +export type NavalDroneStore = ExplainerStore; + +export const DEFAULT_VIEW: ViewState = { cutaway: false, flow: false, links: false, labels: true }; +export const DEFAULT_HELM: HelmId = 'straight'; +const START_SPEED = PRESETS.overview.speed ?? SPEED_RANGE.default; + +export const CHAPTER_CONTROL_DEFAULTS: ChapterControls = { + trialKnots: null, + helm: DEFAULT_HELM, + linkMode: 'satellite', + videoDelayMs: VIDEO_DELAY_MS.default, + radarHeight: RADAR_HEIGHT_M.default, + seaState: DEFAULT_SEA_STATE, +}; + +function presetFields(preset: Preset, state: NavalDroneFields): Partial { + if (PRESETS[state.preset] === preset) return {}; + return { ...CHAPTER_CONTROL_DEFAULTS, ...preset.start }; +} + +type TrialWatch = readonly [phase: number, playing: boolean, preset: PresetId]; + +function shouldRelease(next: TrialWatch, previous: TrialWatch): boolean { + const [phase, playing, preset] = next; + const [lastPhase, wasPlaying, lastPreset] = previous; + return preset === lastPreset && ((playing && !wasPlaying) || phase !== lastPhase); +} + +function releaseTrialOnRun(store: NavalDroneStore): NavalDroneStore { + store.subscribe( + (state): TrialWatch => [state.phase, state.playing, state.preset], + (next, previous) => { + if (shouldRelease(next, previous)) store.getState().releaseTrial(); + }, + { equalityFn: shallow }, + ); + return store; +} + +export function createNavalDroneStore( + overrides: Partial = {}, +): NavalDroneStore { + const store = createExplainerStore( + { + timeline: NAVAL_DRONE_TIMELINE, + presets: PRESETS, + defaults: { preset: 'overview', speed: START_SPEED, view: DEFAULT_VIEW }, + extend: (set, get) => { + const hold = (fields: Pick & Partial) => { + if (get().playing) get().pause(); + set(fields); + }; + return { + ...CHAPTER_CONTROL_DEFAULTS, + fit: DEFAULT_FIT, + setFit: (fit) => set({ fit }), + setTrialKnots: (knots) => hold({ trialKnots: trialKnotsOf(knots) }), + setThrottle: (percent) => hold({ trialKnots: knotsAtThrottle(throttleShareOf(percent)) }), + setSpeedMark: (mark) => hold({ trialKnots: SPEED_MARKS[mark] }), + setHelm: (helm) => { + const { trialKnots, phase } = get(); + hold({ trialKnots: trialKnots ?? trialKnotsOf(speedAt(phase)), helm }); + }, + releaseTrial: () => { + const { trialKnots, helm } = get(); + if (trialKnots === null && helm === DEFAULT_HELM) return; + set({ trialKnots: null, helm: DEFAULT_HELM }); + }, + setLinkMode: (linkMode) => set({ linkMode }), + setVideoDelay: (delayMs) => set({ videoDelayMs: stepTo(delayMs, VIDEO_DELAY_MS) }), + setRadarHeight: (metres) => set({ radarHeight: stepTo(metres, RADAR_HEIGHT_M) }), + setSeaState: (seaState) => set({ seaState }), + seekMoment: (moment) => { + get().pause(); + get().setPhase(MOMENTS[moment]); + }, + }; + }, + presetState: presetFields, + }, + overrides, + ); + return releaseTrialOnRun(store); +} diff --git a/explainers/naval-drone/src/style.css b/explainers/naval-drone/src/style.css new file mode 100644 index 00000000..d4f56a07 --- /dev/null +++ b/explainers/naval-drone/src/style.css @@ -0,0 +1,222 @@ +:root { + --launch: #7fb3d5; + --hump: #ffb347; + --cruise: #6fd38a; + --sprint: #ff7a59; + --arrival: #b48cff; + --floating: #5fa8ff; + --planing: #4fd1c5; + --sat-link: #5fa8ff; + --backup-link: #ffd24c; + --sea: #2b6f8f; + --hull: #9aa3ab; +} + +.section-readouts, +.range-widget:has([data-control='trial-knots']), +.range-widget:has([data-control='throttle']), +.range-widget:has([data-control='video-delay']), +.range-widget:has([data-control='radar-height']) { + --readout-min-width: 8rem; +} + +.range-widget__header { + flex-wrap: wrap; +} + +.range-widget__readouts, +.range-widget__readouts > div, +.range-widget__header label { + min-width: 0; +} + +.range-widget__header label, +.range-widget__readouts :is(dt, dd) { + overflow-wrap: break-word; + hyphens: auto; +} + +@media (max-width: 599px) { + .range-widget__readouts dd { + min-block-size: 2lh; + } + + .range-widget__readouts > div:last-child:nth-child(odd) { + grid-column: 1 / -1; + } +} + +.section-readouts { + margin-block: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.section-readouts:has([data-readout='overview-mode']) { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.section-readouts:has([data-readout='jet-push']), +.section-readouts:has([data-readout='fleet-carries']), +.section-readouts > div:has([data-readout='link-boat']) { + grid-column: 1 / -1; +} + +.section-readouts:has([data-readout='jet-push']), +.section-readouts:has([data-readout='fleet-carries']) { + grid-template-columns: minmax(0, 1fr); +} + +[data-control='trial-knots'] { + --range-color: var(--planing); +} + +[data-control='throttle'] { + --range-color: var(--hump); +} + +[data-control='video-delay'] { + --range-color: var(--sat-link); +} + +[data-control='radar-height'] { + --range-color: var(--sprint); +} + +[data-readout='horizon-radar'], +[data-readout='horizon-minutes'] { + color: var(--sprint); +} + +[data-readout='link-now'], +[data-readout='link-top'] { + color: var(--sat-link); +} + +.horizon-view { + display: grid; + justify-items: center; + gap: 8px; + margin: 4px 0 16px; +} + +.horizon-view canvas { + display: block; + width: 100%; + height: auto; + border-radius: var(--radius-small); +} + +.horizon-view figcaption { + color: var(--muted); + font-size: 0.8125rem; + text-align: center; +} + +.signal-path__title { + margin: 24px 0 8px; + color: var(--text); + font-weight: 600; +} + +.signal-path { + display: grid; + gap: 8px; + margin: 0 0 24px; + padding: 0; + list-style: none; + counter-reset: signal-step; +} + +.signal-path li { + position: relative; + padding: 10px 14px 10px 44px; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--sat-link) 8%, transparent), + transparent + ); + counter-increment: signal-step; +} + +.signal-path li::before { + content: counter(signal-step); + position: absolute; + top: 10px; + left: 14px; + width: 20px; + color: var(--sat-link); + font-family: var(--font-mono); + font-weight: 500; + text-align: center; +} + +.chip[data-action='moment'][data-value='hullSpeed'], +.chip[data-action='moment'][data-value='humpPeak'], +.chip[data-action='moment'][data-value='onPlane'] { + --tone: var(--hump); +} + +.chip[data-action='moment'][data-value='cruiseSpeed'] { + --tone: var(--cruise); +} + +.chip[data-action='moment'][data-value='throttleUp'], +.chip[data-action='moment'][data-value='topSpeed'] { + --tone: var(--sprint); +} + +.chip[data-action='moment'][data-value='alongside'] { + --tone: var(--arrival); +} + +.chip[data-action='speedMark'][data-value='hullSpeed'] { + --tone: var(--floating); +} + +.chip[data-action='speedMark'][data-value='hump'] { + --tone: var(--hump); +} + +.chip[data-action='speedMark'][data-value='planing'], +.chip[data-action='speedMark'][data-value='cruise'], +.chip[data-action='speedMark'][data-value='top'] { + --tone: var(--planing); +} + +.chip[data-action='helm'] { + --tone: var(--hump); +} + +.chip[data-action='linkMode'][data-value='satellite'] { + --tone: var(--sat-link); +} + +.chip[data-action='linkMode'][data-value='backup'] { + --tone: var(--backup-link); +} + +.chip[data-action='linkMode'][data-value='lost'] { + --tone: var(--muted); +} + +.chip[data-action='seaState'] { + --tone: var(--sea); +} + +.chip[data-action='fit'] { + --tone: var(--hull); +} + +@media (pointer: coarse) { + .chip[data-action] { + height: 44px; + } +} + +@media (max-width: 480px) { + .section-readouts:has([data-readout='overview-mode']) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/explainers/naval-drone/src/theme.ts b/explainers/naval-drone/src/theme.ts new file mode 100644 index 00000000..711e388a --- /dev/null +++ b/explainers/naval-drone/src/theme.ts @@ -0,0 +1,37 @@ +import { THEME as CORE_THEME } from '@core/theme'; + +export const THEME = { + ...CORE_THEME, + launch: '#7fb3d5', + hump: '#ffb347', + cruise: '#6fd38a', + sprint: '#ff7a59', + arrival: '#b48cff', + floating: '#5fa8ff', + planing: '#4fd1c5', + hull: '#4b5259', + hullDark: '#30353a', + deck: '#6c747c', + panel: '#c8cdd2', + jetBlack: '#1b1d20', + glass: '#16222e', + section: '#e6c27a', + foam: '#eef4f6', + seaDeep: '#0e2433', + seaLit: '#1f3d4f', + waterWall: '#2b6f8f', + flowSlow: '#5fa8ff', + flowFast: '#ffffff', + satLink: '#5fa8ff', + backupLink: '#ffd24c', + ghost: '#ffffff', + ship: '#7a8590', + shore: '#4a4a3f', + beach: '#b8a98a', + concrete: '#9a9a94', + radome: '#e8ecef', + sun: '#ffc58a', + skyTop: '#1a2740', + skyHorizon: '#f0a868', + haze: '#8d9aa6', +} as const; diff --git a/explainers/naval-drone/src/timeline.test.ts b/explainers/naval-drone/src/timeline.test.ts new file mode 100644 index 00000000..9b91d63b --- /dev/null +++ b/explainers/naval-drone/src/timeline.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { PHASE_IDS } from './ids'; +import { PHASE_RANGES, RUN_SECONDS } from './model'; +import { THEME } from './theme'; +import { NAVAL_DRONE_TIMELINE, SPEED_RANGE } from './timeline'; + +describe('naval drone timeline', () => { + it('runs the route once through the five phases and stops at the ship', () => { + const { phases, cycle, loop } = NAVAL_DRONE_TIMELINE; + expect(cycle).toBe(RUN_SECONDS); + expect(loop).toBe(false); + expect(phases.map((phase) => phase.id)).toEqual([...PHASE_IDS]); + phases.forEach((phase) => expect(phase).toMatchObject(PHASE_RANGES[phase.id as never])); + 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('colours each phase with its theme tone and names it from the locale', () => { + expect(NAVAL_DRONE_TIMELINE.phases.map((phase) => phase.tone)).toEqual( + PHASE_IDS.map((id) => THEME[id]), + ); + NAVAL_DRONE_TIMELINE.phases.forEach((phase) => { + expect(phase.labelKey).toBe(`timeline.phase.${phase.id}`); + expect(phase.jumpLabelKey).toBe(`timeline.jump.${phase.id}`); + }); + }); + + it('scrubs in tenths of a second and nudges half a second, or five with shift', () => { + expect(NAVAL_DRONE_TIMELINE.step).toBe(0.1); + expect(NAVAL_DRONE_TIMELINE.nudge).toEqual({ fine: 0.5, coarse: 5 }); + expect(NAVAL_DRONE_TIMELINE.labelKey).toBe('timeline.label'); + expect(NAVAL_DRONE_TIMELINE.phasesLabelKey).toBe('timeline.phases'); + }); + + it('plays the run in two minutes at normal speed, from a quarter to four times as fast', () => { + const { rate, speed } = NAVAL_DRONE_TIMELINE; + expect(speed).toMatchObject({ min: 0.25, max: 4, step: 0.25, labelKey: 'timeline.speed' }); + expect(RUN_SECONDS / rate(1)).toBe(120); + expect(rate(2)).toBe(2); + expect(SPEED_RANGE.default).toBe(1); + }); +}); diff --git a/explainers/naval-drone/src/timeline.ts b/explainers/naval-drone/src/timeline.ts new file mode 100644 index 00000000..a2479220 --- /dev/null +++ b/explainers/naval-drone/src/timeline.ts @@ -0,0 +1,48 @@ +import type { Phase, Timeline } from '@core/explainer'; +import { PHASE_IDS } from './ids'; +import { PHASE_RANGES, RUN_SECONDS, SPEED_RANGE, rate } from './model'; +import { + JUMP_KEYS, + PHASE_KEYS, + describePhase, + describeSpeed, + formatPhase, + formatSpeed, +} from './ui/format'; +import { PHASE_TONES } from './ui/palette'; + +const SCRUBBER_STEP_SECONDS = 0.1; +const FINE_STEP_SECONDS = 0.5; +const COARSE_STEP_SECONDS = 5; + +const RUN_PHASES: readonly Phase[] = PHASE_IDS.map((id) => ({ + id, + start: PHASE_RANGES[id].start, + end: PHASE_RANGES[id].end, + labelKey: PHASE_KEYS[id], + jumpLabelKey: JUMP_KEYS[id], + tone: PHASE_TONES[id], +})); + +export const NAVAL_DRONE_TIMELINE: Timeline = { + cycle: RUN_SECONDS, + loop: false, + step: SCRUBBER_STEP_SECONDS, + nudge: { fine: FINE_STEP_SECONDS, coarse: COARSE_STEP_SECONDS }, + labelKey: 'timeline.label', + phasesLabelKey: 'timeline.phases', + formatPhase, + describePhase, + rate, + phases: RUN_PHASES, + speed: { + min: SPEED_RANGE.min, + max: SPEED_RANGE.max, + step: SPEED_RANGE.step, + labelKey: 'timeline.speed', + format: formatSpeed, + describe: describeSpeed, + }, +}; + +export { SPEED_RANGE }; diff --git a/explainers/naval-drone/src/ui/actions.test.ts b/explainers/naval-drone/src/ui/actions.test.ts new file mode 100644 index 00000000..cb3417a0 --- /dev/null +++ b/explainers/naval-drone/src/ui/actions.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { MOMENT_IDS, SPEED_MARK_IDS } from '../ids'; +import { MOMENTS, SPEED_MARKS } from '../model'; +import { createNavalDroneStore } from '../state'; +import { CHAPTER_ACTIONS, MOMENT_TOLERANCE_SECONDS } from './actions'; + +describe('chapter actions', () => { + it('pauses at each moment and marks it as current', () => { + const store = createNavalDroneStore({ playing: true }); + MOMENT_IDS.forEach((moment) => { + store.getState().play(); + CHAPTER_ACTIONS.moment.run(store.getState(), moment); + expect(store.getState().playing).toBe(false); + expect(store.getState().phase).toBeCloseTo(MOMENTS[moment], 9); + expect(CHAPTER_ACTIONS.moment.current?.(store.getState())).toBe(moment); + }); + }); + + it('keeps a moment current within its tolerance and marks none between them', () => { + const near = createNavalDroneStore({ phase: MOMENTS.topSpeed + 0.25 }).getState(); + expect(CHAPTER_ACTIONS.moment.current?.(near)).toBe('topSpeed'); + const between = createNavalDroneStore({ phase: 60 }).getState(); + expect(CHAPTER_ACTIONS.moment.current?.(between)).toBe(''); + expect(() => CHAPTER_ACTIONS.moment.run(between, 'launch')).toThrow(); + }); + + it('never marks two moments at once', () => { + const seconds = MOMENT_IDS.map((moment) => MOMENTS[moment]); + seconds + .slice(1) + .forEach((next, index) => + expect(next - seconds[index], MOMENT_IDS[index + 1]).toBeGreaterThan( + 2 * MOMENT_TOLERANCE_SECONDS, + ), + ); + }); + + it('holds the boat at each speed mark and marks it as current', () => { + const store = createNavalDroneStore({ phase: 60, playing: true }); + SPEED_MARK_IDS.forEach((mark) => { + CHAPTER_ACTIONS.speedMark.run(store.getState(), mark); + expect(store.getState()).toMatchObject({ trialKnots: SPEED_MARKS[mark], playing: false }); + expect(CHAPTER_ACTIONS.speedMark.current?.(store.getState())).toBe(mark); + }); + }); + + it('marks the speed mark the run passes while nobody holds the boat', () => { + const atHump = createNavalDroneStore({ phase: MOMENTS.humpPeak }).getState(); + expect(CHAPTER_ACTIONS.speedMark.current?.(atHump)).toBe('hump'); + const between = createNavalDroneStore({ phase: 31 }).getState(); + expect(CHAPTER_ACTIONS.speedMark.current?.(between)).toBe(''); + }); + + it('switches the helm, the link, the sea and the deck fit in their own chapters', () => { + const cases = [ + ['helm', 'reverse', 'jet'], + ['linkMode', 'backup', 'link'], + ['seaState', 'rough', 'horizon'], + ['fit', 'missile', 'fleet'], + ] as const; + cases.forEach(([action, value, preset]) => { + const store = createNavalDroneStore(); + CHAPTER_ACTIONS[action].run(store.getState(), value); + expect(store.getState().preset, action).toBe(preset); + expect(store.getState()[action], action).toBe(value); + expect(CHAPTER_ACTIONS[action].current?.(store.getState()), action).toBe(value); + }); + expect(() => + CHAPTER_ACTIONS.seaState.run(createNavalDroneStore().getState(), 'storm'), + ).toThrow(); + }); + + it('brings back the hull chapter when its speed marks are used after the jet took over', () => { + const store = createNavalDroneStore(); + store.getState().applyPreset('jet'); + CHAPTER_ACTIONS.speedMark.run(store.getState(), 'hullSpeed'); + expect(store.getState()).toMatchObject({ preset: 'hull', trialKnots: SPEED_MARKS.hullSpeed }); + CHAPTER_ACTIONS.speedMark.run(store.getState(), 'planing'); + expect(store.getState()).toMatchObject({ preset: 'hull', trialKnots: SPEED_MARKS.planing }); + }); +}); diff --git a/explainers/naval-drone/src/ui/actions.ts b/explainers/naval-drone/src/ui/actions.ts new file mode 100644 index 00000000..d5c03192 --- /dev/null +++ b/explainers/naval-drone/src/ui/actions.ts @@ -0,0 +1,63 @@ +import type { ChapterAction } from '@core/explainer'; +import { parseOption } from '@core/ui/parse'; +import { FIT_IDS, HELM_IDS, LINK_MODES, MOMENT_IDS, SEA_STATE_IDS, SPEED_MARK_IDS } from '../ids'; +import type { MomentId, PresetId } from '../ids'; +import { MOMENTS, speedMarkAt } from '../model'; +import { followedKnots } from '../state'; +import type { NavalDroneStoreState } from '../state'; + +const NOTHING_CURRENT = ''; +export const MOMENT_TOLERANCE_SECONDS = 0.3; + +type ChapterChange = (state: NavalDroneStoreState, ...args: A) => void; + +export function inChapter( + preset: PresetId, + change: ChapterChange, +): ChapterChange { + return (state, ...args) => { + if (state.preset !== preset) state.applyPreset(preset); + change(state, ...args); + }; +} + +function momentAt(state: NavalDroneStoreState): MomentId | undefined { + return MOMENT_IDS.find( + (moment) => Math.abs(MOMENTS[moment] - state.phase) <= MOMENT_TOLERANCE_SECONDS, + ); +} + +export const CHAPTER_ACTIONS: Record> = { + moment: { + run: inChapter('overview', (state, value: string) => + state.seekMoment(parseOption(value, MOMENT_IDS)), + ), + current: (state) => momentAt(state) ?? NOTHING_CURRENT, + }, + speedMark: { + run: inChapter('hull', (state, value: string) => + state.setSpeedMark(parseOption(value, SPEED_MARK_IDS)), + ), + current: (state) => speedMarkAt(followedKnots(state)) ?? NOTHING_CURRENT, + }, + helm: { + run: inChapter('jet', (state, value: string) => state.setHelm(parseOption(value, HELM_IDS))), + current: (state) => state.helm, + }, + linkMode: { + run: inChapter('link', (state, value: string) => + state.setLinkMode(parseOption(value, LINK_MODES)), + ), + current: (state) => state.linkMode, + }, + seaState: { + run: inChapter('horizon', (state, value: string) => + state.setSeaState(parseOption(value, SEA_STATE_IDS)), + ), + current: (state) => state.seaState, + }, + fit: { + run: inChapter('fleet', (state, value: string) => state.setFit(parseOption(value, FIT_IDS))), + current: (state) => state.fit, + }, +}; diff --git a/explainers/naval-drone/src/ui/canvasLabels.test.ts b/explainers/naval-drone/src/ui/canvasLabels.test.ts new file mode 100644 index 00000000..c7f0d92e --- /dev/null +++ b/explainers/naval-drone/src/ui/canvasLabels.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { intersects, placeText, segmentBoxes, shiftInto, textBox } from './canvasLabels'; +import type { Box } from './canvasLabels'; + +const METRICS = { font: 10, ascent: 0.8, descent: 0.2 }; +const BOUNDS: Box = { left: 0, top: 0, right: 200, bottom: 100 }; + +describe('canvas labels', () => { + it('boxes a text around its anchor for each alignment', () => { + expect(textBox(40, { x: 50, baseline: 30, align: 'left' }, METRICS)).toEqual({ + left: 50, + right: 90, + top: 22, + bottom: 32, + }); + expect(textBox(40, { x: 50, baseline: 30, align: 'center' }, METRICS).left).toBe(30); + expect(textBox(40, { x: 50, baseline: 30, align: 'right' }, METRICS).left).toBe(10); + }); + + it('slides a label back inside the canvas sideways', () => { + const anchor = { x: 190, baseline: 30, align: 'left' as const }; + const placed = shiftInto(textBox(40, anchor, METRICS), anchor, BOUNDS); + expect(placed.box.right).toBe(200); + expect(placed.x).toBe(160); + }); + + it('takes the first free spot inside the canvas and marks it taken', () => { + const taken: Box[] = [{ left: 0, top: 20, right: 100, bottom: 40 }]; + const spot = placeText( + 40, + [ + { x: 50, baseline: 30, align: 'center' }, + { x: 50, baseline: 4, align: 'center' }, + { x: 50, baseline: 60, align: 'center' }, + ], + taken, + BOUNDS, + METRICS, + ); + expect(spot.baseline).toBe(60); + expect(taken).toHaveLength(2); + }); + + it('falls back to the first spot when none is free', () => { + const spot = placeText(40, [{ x: 50, baseline: 30, align: 'left' }], [BOUNDS], BOUNDS, METRICS); + expect(spot.baseline).toBe(30); + }); + + it('covers a sloping line with small boxes', () => { + const boxes = segmentBoxes(0, 100, 25, (x) => x / 2, 1); + expect(boxes).toHaveLength(4); + expect(boxes[1]).toEqual({ left: 25, right: 50, top: 11.5, bottom: 26 }); + expect(intersects(boxes[0], { left: 10, right: 12, top: 5, bottom: 6 })).toBe(true); + expect(intersects(boxes[0], { left: 10, right: 12, top: 40, bottom: 50 })).toBe(false); + }); +}); diff --git a/explainers/naval-drone/src/ui/canvasLabels.ts b/explainers/naval-drone/src/ui/canvasLabels.ts new file mode 100644 index 00000000..cf1b692b --- /dev/null +++ b/explainers/naval-drone/src/ui/canvasLabels.ts @@ -0,0 +1,92 @@ +import { clamp } from '@core/math'; + +export interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +export type TextAlign = 'left' | 'right' | 'center'; + +export interface Anchor { + x: number; + baseline: number; + align: TextAlign; +} + +export interface Placed extends Anchor { + box: Box; +} + +export interface TextMetrics { + font: number; + ascent: number; + descent: number; +} + +export function intersects(a: Box, b: Box): boolean { + return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; +} + +function isInside(box: Box, bounds: Box): boolean { + return box.top >= bounds.top && box.bottom <= bounds.bottom; +} + +function alignOffset(width: number, align: TextAlign): number { + return { left: 0, center: width / 2, right: width }[align]; +} + +export function textBox(width: number, anchor: Anchor, metrics: TextMetrics): Box { + const left = anchor.x - alignOffset(width, anchor.align); + return { + left, + right: left + width, + top: anchor.baseline - metrics.font * metrics.ascent, + bottom: anchor.baseline + metrics.font * metrics.descent, + }; +} + +export function shiftInto(box: Box, anchor: Anchor, bounds: Box): Placed { + const shift = clamp(0, bounds.left - box.left, bounds.right - box.right); + return { + ...anchor, + x: anchor.x + shift, + box: { ...box, left: box.left + shift, right: box.right + shift }, + }; +} + +export function placeText( + width: number, + candidates: readonly Anchor[], + taken: Box[], + bounds: Box, + metrics: TextMetrics, +): Placed { + const placed = candidates.map((anchor) => + shiftInto(textBox(width, anchor, metrics), anchor, bounds), + ); + const free = placed.find( + (spot) => isInside(spot.box, bounds) && !taken.some((box) => intersects(box, spot.box)), + ); + const chosen = free ?? placed[0]; + taken.push(chosen.box); + return chosen; +} + +export function segmentBoxes( + from: number, + to: number, + step: number, + yAt: (x: number) => number, + slack: number, +): Box[] { + const boxes: Box[] = []; + for (let left = from; left < to; left += step) { + const right = Math.min(left + step, to); + const top = Math.min(yAt(left), yAt(right)); + const bottom = Math.max(yAt(left), yAt(right)); + boxes.push({ left, right, top: top - slack, bottom: bottom + slack }); + } + return boxes; +} diff --git a/explainers/naval-drone/src/ui/dock.ts b/explainers/naval-drone/src/ui/dock.ts new file mode 100644 index 00000000..f31a2754 --- /dev/null +++ b/explainers/naval-drone/src/ui/dock.ts @@ -0,0 +1,42 @@ +import type { Choice, ViewToggle } from '@core/explainer'; +import { parseOption } from '@core/ui/parse'; +import { FIT_IDS } from '../ids'; +import type { NavalDroneStoreState } from '../state'; + +const CUTAWAY_ICON = + ''; +const FLOW_ICON = + ''; +const LINKS_ICON = + ''; +const LABELS_ICON = + ''; + +export const NAVAL_DRONE_CHOICES: readonly Choice[] = [ + { + id: 'fit', + labelKey: 'controls.fit.label', + shortcut: 'F', + options: FIT_IDS.map((fit) => ({ value: fit, labelKey: `controls.fitOptions.${fit}` })), + select: (state) => state.fit, + apply: (state, value) => state.setFit(parseOption(value, FIT_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('cutaway', 'C', CUTAWAY_ICON), + viewToggle('flow', 'W', FLOW_ICON), + viewToggle('links', 'K', LINKS_ICON), + viewToggle('labels', 'L', LABELS_ICON), +]; diff --git a/explainers/naval-drone/src/ui/format.dom.test.ts b/explainers/naval-drone/src/ui/format.dom.test.ts new file mode 100644 index 00000000..769905c9 --- /dev/null +++ b/explainers/naval-drone/src/ui/format.dom.test.ts @@ -0,0 +1,160 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { toRadians } from '@core/math'; +import { initI18n } from '@core/i18n'; +import en from '../../locales/en.json'; +import { SEA_STATE_IDS } from '../ids'; +import { + clockText, + describePhase, + describeSpeed, + formatBoatOrBacking, + formatBoatSpeed, + formatCameraHorizon, + formatCanvasKm, + formatCarries, + formatCost, + formatDegrees, + formatDelay, + formatDetection, + formatDistance, + formatEfficiency, + formatFlow, + formatHidden, + formatHullRatio, + formatJetSpeed, + formatKm, + formatLift, + formatLinkBoat, + formatLinkCarrier, + formatLinkNow, + formatLinkTop, + formatMetres, + formatMinutes, + formatMode, + formatPercent, + formatPhase, + formatPush, + formatShipValue, + formatSpeed, + formatThrust, + formatWaves, + formatWetted, + shownKnots, +} from './format'; +import { fill, isFilled } from './testing'; + +const { units, timeline, hull, jet, link, horizon, fleet } = en; + +describe('naval drone formatting', () => { + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + it('shows the run clock as minutes and seconds of model time', () => { + expect(clockText(0)).toBe('0:00'); + expect(clockText(65.9)).toBe('1:05'); + expect(clockText(120)).toBe('2:00'); + expect(formatPhase(65)).toBe(fill(timeline.clock, { clock: '1:05' })); + }); + + it('describes the scrubber with what happens in each phase', () => { + expect(describePhase(100)).toBe( + fill(timeline.value, { time: formatPhase(100), phase: timeline.during.sprint }), + ); + expect(describePhase(120)).toContain(timeline.during.arrival); + }); + + it('names the playback factor', () => { + expect(formatSpeed(0.25)).toBe(fill(timeline.speedFormat, { factor: '0.25' })); + expect(describeSpeed()).toBe(timeline.speedValue); + }); + + it('shows knots in tenths below 10 and whole above, with km/h', () => { + expect(formatBoatSpeed(5.7)).toBe(fill(units.speed, { kn: '5.7', kmh: '11' })); + expect(formatBoatSpeed(22)).toBe(fill(units.speed, { kn: '22', kmh: '41' })); + expect(formatBoatSpeed(42)).toBe(fill(units.speed, { kn: '42', kmh: '78' })); + expect(formatBoatSpeed(9.97)).toBe(fill(units.speed, { kn: '10', kmh: '19' })); + expect(formatBoatSpeed(11)).toBe(fill(units.speed, { kn: '11', kmh: '20' })); + }); + + it('pairs the km/h with the knots it shows', () => { + expect(shownKnots(9.94)).toBe(9.9); + expect(shownKnots(21.6)).toBe(22); + expect(formatBoatSpeed(21.6)).toBe(formatBoatSpeed(22)); + }); + + it('names the hull mode', () => { + expect(formatMode('planing')).toBe(en.mode.planing); + }); + + it('shows the distance to the ship in tens of metres, in km from 1,000 m', () => { + expect(formatDistance(1458.8)).toBe(fill(units.km, { value: '1.5' })); + expect(formatDistance(518.4)).toBe(fill(units.m, { value: '520' })); + expect(formatDistance(997)).toBe(fill(units.km, { value: '1.0' })); + expect(formatDistance(0)).toBe(fill(units.m, { value: '0' })); + }); + + it('shows percents, trim and the lift split in whole numbers that add up', () => { + expect(formatPercent(0.694)).toBe(fill(units.percent, { value: '69' })); + expect(formatDegrees(toRadians(6))).toBe(fill(units.degrees, { value: '6.0' })); + expect(formatLift(0.364)).toBe(fill(hull.lift, { dynamic: '36', buoyant: '64' })); + }); + + it('compares the wetted length with the length at rest', () => { + expect(formatWetted(1.7)).toBe(fill(hull.wetted, { m: '1.7', rest: '5.1' })); + }); + + it('compares the speed with the 5.7 kn hull speed, or says the boat stands still', () => { + expect(formatHullRatio(11)).toBe(fill(hull.ratio, { times: '1.9', hullSpeed: '5.7' })); + expect(formatHullRatio(42)).toBe(fill(hull.ratio, { times: '7.4', hullSpeed: '5.7' })); + expect(formatHullRatio(0)).toBe(fill(hull.ratioStill, { hullSpeed: '5.7' })); + }); + + it('reads the pump in kg and litres a second, m/s and kN, or says it backs', () => { + expect(formatFlow(212.7)).toBe(fill(jet.flow, { kg: '213', litres: '208' })); + expect(formatJetSpeed(32.4)).toBe(fill(units.mps, { ms: '32', kmh: '117' })); + expect(formatThrust(2300, 'straight')).toBe(fill(units.kilonewtons, { value: '2.30' })); + expect(formatThrust(2300, 'reverse')).toBe(jet.astern); + expect(formatBoatOrBacking(0, 'reverse')).toBe(jet.backing); + expect(formatBoatOrBacking(22, 'left')).toBe(formatBoatSpeed(22)); + expect(formatEfficiency(0.8, 42)).toBe(fill(units.percent, { value: '80' })); + expect(formatEfficiency(0, 0)).toBe(jet.noEfficiency); + }); + + it('says where the nozzle pushes the stern', () => { + expect(formatPush('left', -toRadians(27))).toBe(fill(jet.push.left, { angle: '27' })); + expect(formatPush('right', toRadians(27))).toBe(fill(jet.push.right, { angle: '27' })); + expect(formatPush('straight', -toRadians(8))).toBe(fill(jet.push.left, { angle: '8' })); + expect(formatPush('straight', 0)).toBe(jet.push.straight); + expect(formatPush('reverse', 0)).toBe(jet.push.reverse); + }); + + it('turns the video delay into metres now and at top speed', () => { + expect(formatDelay(250)).toBe(fill(units.ms, { value: '250' })); + expect(formatLinkNow('satellite', 250, 22)).toBe(fill(link.now, { m: '2.8', kn: '22' })); + expect(formatLinkNow('lost', 250, 22)).toBe(link.nowLost); + expect(formatLinkTop(250)).toBe(fill(link.top, { m: '5.4', lengths: '1.0' })); + expect(formatLinkCarrier('backup')).toBe(link.carrier.backup); + expect(formatLinkBoat('lost')).toBe(link.boat.lost); + }); + + it('reads the horizons, the detection range and the waves', () => { + expect(formatMetres(20)).toBe(fill(units.m, { value: '20' })); + expect(formatKm(21.34)).toBe(fill(units.km, { value: '21.3' })); + expect(formatMinutes(16.46)).toBe(fill(units.minutes, { value: '16.5' })); + expect(formatCameraHorizon()).toBe(fill(horizon.camera, { km: '3.2', m: '0.7' })); + expect(formatDetection('moderate')).toBe(fill(horizon.detect, { km: '9.3', min: '7.1' })); + expect(formatDetection('rough')).toBe(horizon.detectBeyond); + expect(formatWaves('slight')).toBe(fill(horizon.waves.slight, { from: '0.5', to: '1.25' })); + expect(formatCanvasKm(30)).toBe(fill(horizon.canvas.km, { value: '30' })); + SEA_STATE_IDS.forEach((sea) => { + expect(isFilled(formatWaves(sea)), sea).toBe(true); + expect(formatHidden(sea)).toBe(horizon.hidden[sea]); + }); + }); + + it('quotes the payload, the cost and the ship value', () => { + expect(formatCarries('standard')).toBe(fill(fleet.carries.standard, { kg: '320' })); + expect(formatCarries('missile')).toBe(fleet.carries.missile); + expect(formatCost()).toBe(fill(fleet.cost, { from: '250,000', to: '273,000' })); + expect(formatShipValue()).toBe(fill(fleet.ship, { value: '65' })); + }); +}); diff --git a/explainers/naval-drone/src/ui/format.ts b/explainers/naval-drone/src/ui/format.ts new file mode 100644 index 00000000..33ee7328 --- /dev/null +++ b/explainers/naval-drone/src/ui/format.ts @@ -0,0 +1,248 @@ +import { formatFixed, formatNumber } from '@core/format'; +import { t } from '@core/i18n'; +import { toDegrees } from '@core/math'; +import { PHASE_IDS } from '../ids'; +import type { FitId, HelmId, HullMode, LinkMode, PhaseId, SeaStateId } from '../ids'; +import { + BOAT, + BOAT_COST_USD, + DETECTION_KM, + HULL_SPEED_KN, + KOTOV_VALUE_MUSD, + LENS_HEIGHT_M, + METRES_PER_KM, + NEWTONS_PER_KILONEWTON, + PAYLOAD_MAX_KG, + PERCENT, + SEAWATER_DENSITY, + SEA_STATES, + SECONDS_PER_MINUTE, + boatLengths, + detectionCovered, + knotsToKmh, + lagMetres, + minutesAtTopSpeed, + msToKmh, + phaseAt, + visualHorizonKm, +} from '../model'; + +const WHOLE = 0; +const TENTHS = 1; +const HUNDREDTHS = 2; +const CLOCK_DIGITS = 2; +const CLOCK_PAD = '0'; +const CLOCK_SEPARATOR = ':'; +const WHOLE_KNOTS_FROM = 10; +const METRES_STEP = 10; +const LITRES_PER_CUBIC_METRE = 1000; +const KG_PER_LITRE = SEAWATER_DENSITY / LITRES_PER_CUBIC_METRE; +const STRAIGHT_NOZZLE_DEG = 0.5; + +function phaseKeys(group: string): Readonly> { + return Object.fromEntries(PHASE_IDS.map((id) => [id, `timeline.${group}.${id}`])) as Record< + PhaseId, + string + >; +} + +export const PHASE_KEYS = phaseKeys('phase'); +export const JUMP_KEYS = phaseKeys('jump'); +const DURING_KEYS = phaseKeys('during'); + +function whole(value: number): string { + return formatFixed(value, WHOLE); +} + +function tenths(value: number): string { + return formatFixed(value, TENTHS); +} + +function percentOf(share: number): number { + return Math.round(share * PERCENT); +} + +export function clockText(seconds: number): string { + const total = Math.floor(seconds); + const minutes = Math.floor(total / SECONDS_PER_MINUTE); + const rest = total % SECONDS_PER_MINUTE; + return [String(minutes), String(rest).padStart(CLOCK_DIGITS, CLOCK_PAD)].join(CLOCK_SEPARATOR); +} + +export function formatPhase(seconds: number): string { + return t('timeline.clock', { clock: clockText(seconds) }); +} + +export function describePhase(seconds: number): string { + return t('timeline.value', { + time: formatPhase(seconds), + phase: t(DURING_KEYS[phaseAt(seconds)]), + }); +} + +export function formatSpeed(speed: number): string { + return t('timeline.speedFormat', { factor: formatNumber(speed) }); +} + +export function describeSpeed(): string { + return t('timeline.speedValue'); +} + +function roundTo(value: number, fractionDigits: number): number { + return Number(value.toFixed(fractionDigits)); +} + +export function shownKnots(knots: number): number { + const inTenths = roundTo(knots, TENTHS); + return inTenths < WHOLE_KNOTS_FROM ? inTenths : roundTo(knots, WHOLE); +} + +function knotsText(knots: number): string { + const shown = shownKnots(knots); + return formatFixed(shown, shown < WHOLE_KNOTS_FROM ? TENTHS : WHOLE); +} + +export function formatBoatSpeed(knots: number): string { + const shown = shownKnots(knots); + return t('units.speed', { kn: knotsText(shown), kmh: whole(knotsToKmh(shown)) }); +} + +export function formatMode(mode: HullMode): string { + return t(`mode.${mode}`); +} + +export function formatDistance(metres: number): string { + const rounded = Math.round(metres / METRES_STEP) * METRES_STEP; + if (rounded < METRES_PER_KM) return t('units.m', { value: whole(rounded) }); + return t('units.km', { value: tenths(metres / METRES_PER_KM) }); +} + +export function formatPercent(share: number): string { + return t('units.percent', { value: whole(percentOf(share)) }); +} + +export function formatDegrees(radians: number): string { + return t('units.degrees', { value: tenths(toDegrees(radians)) }); +} + +export function formatLift(liftShare: number): string { + const dynamic = percentOf(liftShare); + return t('hull.lift', { dynamic: whole(dynamic), buoyant: whole(PERCENT - dynamic) }); +} + +export function formatWetted(metres: number): string { + return t('hull.wetted', { m: tenths(metres), rest: tenths(BOAT.waterlineLength) }); +} + +export function formatHullRatio(knots: number): string { + const hullSpeed = tenths(HULL_SPEED_KN); + if (knots <= 0) return t('hull.ratioStill', { hullSpeed }); + return t('hull.ratio', { times: tenths(knots / HULL_SPEED_KN), hullSpeed }); +} + +export function formatBoatOrBacking(knots: number, helm: HelmId): string { + return helm === 'reverse' ? t('jet.backing') : formatBoatSpeed(knots); +} + +export function formatFlow(kgPerSecond: number): string { + return t('jet.flow', { kg: whole(kgPerSecond), litres: whole(kgPerSecond / KG_PER_LITRE) }); +} + +export function formatJetSpeed(metresPerSecond: number): string { + return t('units.mps', { ms: whole(metresPerSecond), kmh: whole(msToKmh(metresPerSecond)) }); +} + +export function formatThrust(newtons: number, helm: HelmId): string { + if (helm === 'reverse') return t('jet.astern'); + return t('units.kilonewtons', { + value: formatFixed(newtons / NEWTONS_PER_KILONEWTON, HUNDREDTHS), + }); +} + +export function formatEfficiency(efficiency: number, knots: number): string { + return knots <= 0 ? t('jet.noEfficiency') : formatPercent(efficiency); +} + +export function formatPush(helm: HelmId, nozzleAngle: number): string { + if (helm === 'reverse') return t('jet.push.reverse'); + const degrees = toDegrees(nozzleAngle); + if (Math.abs(degrees) < STRAIGHT_NOZZLE_DEG) return t('jet.push.straight'); + const side = degrees < 0 ? 'left' : 'right'; + return t(`jet.push.${side}`, { angle: whole(Math.abs(degrees)) }); +} + +export function formatDelay(delayMs: number): string { + return t('units.ms', { value: whole(delayMs) }); +} + +export function formatLinkNow(mode: LinkMode, delayMs: number, knots: number): string { + if (mode === 'lost') return t('link.nowLost'); + return t('link.now', { m: tenths(lagMetres(delayMs, knots)), kn: knotsText(knots) }); +} + +export function formatLinkTop(delayMs: number): string { + const metres = lagMetres(delayMs, BOAT.topKnots); + return t('link.top', { m: tenths(metres), lengths: tenths(boatLengths(metres)) }); +} + +export function formatLinkCarrier(mode: LinkMode): string { + return t(`link.carrier.${mode}`); +} + +export function formatLinkBoat(mode: LinkMode): string { + return t(`link.boat.${mode}`); +} + +export function formatMetres(metres: number): string { + return t('units.m', { value: whole(metres) }); +} + +export function formatKm(km: number): string { + return t('units.km', { value: tenths(km) }); +} + +export function formatMinutes(minutes: number): string { + return t('units.minutes', { value: tenths(minutes) }); +} + +export function formatCameraHorizon(): string { + return t('horizon.camera', { + km: tenths(visualHorizonKm(LENS_HEIGHT_M)), + m: tenths(LENS_HEIGHT_M), + }); +} + +export function formatDetection(sea: SeaStateId): string { + if (!detectionCovered(sea)) return t('horizon.detectBeyond'); + return t('horizon.detect', { + km: tenths(DETECTION_KM), + min: tenths(minutesAtTopSpeed(DETECTION_KM)), + }); +} + +export function formatWaves(sea: SeaStateId): string { + const { from, to } = SEA_STATES[sea]; + return t(`horizon.waves.${sea}`, { from: formatNumber(from), to: formatNumber(to) }); +} + +export function formatHidden(sea: SeaStateId): string { + return t(`horizon.hidden.${sea}`); +} + +export function formatCanvasKm(km: number): string { + return t('horizon.canvas.km', { value: whole(km) }); +} + +export function formatCarries(fit: FitId): string { + if (fit === 'missile') return t('fleet.carries.missile'); + return t('fleet.carries.standard', { kg: whole(PAYLOAD_MAX_KG) }); +} + +export function formatCost(): string { + const [from, to] = BOAT_COST_USD; + return t('fleet.cost', { from: whole(from), to: whole(to) }); +} + +export function formatShipValue(): string { + return t('fleet.ship', { value: whole(KOTOV_VALUE_MUSD) }); +} diff --git a/explainers/naval-drone/src/ui/horizonView.dom.test.ts b/explainers/naval-drone/src/ui/horizonView.dom.test.ts new file mode 100644 index 00000000..e6e82ced --- /dev/null +++ b/explainers/naval-drone/src/ui/horizonView.dom.test.ts @@ -0,0 +1,170 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { initI18n } from '@core/i18n'; +import type { CanvasFrame } from '@core/ui/canvasSurface'; +import en from '../../locales/en.json'; +import { + DETECTION_KM, + RADAR_HEIGHT_M, + detectionCovered, + radarLineOfSightKm, + surfaceDropM, +} from '../model'; +import { intersects } from './canvasLabels'; +import type { Box } from './canvasLabels'; +import { + AXIS_KM, + TICKS_KM, + horizonLayout, + blockedBoxes, + paintHorizon, + sightHeightM, + surfaceY, + xOfKm, + yOfMetres, +} from './horizonView'; +import type { HorizonScene } from './horizonView'; +import { fill } from './testing'; + +const PHONE: CanvasFrame = { width: 358, height: 168, ratio: 1, fontFamily: 'sans-serif' }; +const DESKTOP: CanvasFrame = { width: 640, height: 300, ratio: 1, fontFamily: 'sans-serif' }; +const CHAR_WIDTHS = [6, 8] as const; +let charWidth: number = CHAR_WIDTHS[0]; +const { canvas } = en.horizon; + +type Align = 'left' | 'center' | 'right'; + +interface Drawn { + text: string; + x: number; + y: number; + align: Align; +} + +function recordingContext() { + const texts: Drawn[] = []; + const noop = () => {}; + const context = { + font: '', + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + textAlign: 'left' as Align, + textBaseline: 'alphabetic', + measureText: (text: string) => ({ width: text.length * charWidth }), + strokeText: noop, + lineJoin: 'round', + fillText: (text: string, x: number, y: number) => + texts.push({ text, x, y, align: context.textAlign }), + beginPath: noop, + moveTo: noop, + lineTo: noop, + closePath: noop, + fill: noop, + stroke: noop, + arc: noop, + setLineDash: noop, + }; + return { texts, context: context as unknown as CanvasRenderingContext2D }; +} + +function paint(scene: HorizonScene, frame = PHONE): Drawn[] { + const { texts, context } = recordingContext(); + paintHorizon(context, frame, scene); + return texts; +} + +describe('horizon view layout', () => { + it('fits the 35 km axis, the tallest mast and the sea drop inside the canvas', () => { + [PHONE, DESKTOP].forEach(({ width, height }) => { + const layout = horizonLayout(width, height); + expect(xOfKm(layout, 0)).toBeGreaterThan(0); + expect(xOfKm(layout, AXIS_KM)).toBeLessThanOrEqual(width); + expect(yOfMetres(layout, RADAR_HEIGHT_M.max)).toBeGreaterThan(layout.plot.top); + expect(surfaceY(layout, AXIS_KM)).toBeLessThan(layout.plot.bottom); + expect(layout.tickBaseline).toBeLessThan(height); + expect(layout.metrics.font).toBeGreaterThanOrEqual(11); + }); + }); + + it('grazes the sea with the line of sight and reaches the top of the boat', () => { + [5, 20, 50].forEach((radarHeight) => { + const tangentKm = radarLineOfSightKm(radarHeight) - radarLineOfSightKm(0); + expect(sightHeightM(radarHeight, tangentKm)).toBeCloseTo(-surfaceDropM(tangentKm), 9); + const boatKm = radarLineOfSightKm(radarHeight); + expect(sightHeightM(radarHeight, boatKm)).toBeCloseTo(-surfaceDropM(boatKm) + 0.5, 9); + }); + }); +}); + +describe('horizon view labels', () => { + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + it('places the radar label first, the only one that may cross the detection mark', () => { + const labels = paint({ radarHeight: 20, seaState: 'slight' }).filter( + (drawn) => !drawn.text.endsWith(' km'), + ); + expect(labels[0].text).toBe(canvas.radar); + }); + + it('draws every text from the locale, ticks at 0, 10, 20 and 30 km', () => { + const texts = paint({ radarHeight: 20, seaState: 'slight' }).map((drawn) => drawn.text); + expect(texts).toEqual( + expect.arrayContaining([ + canvas.radar, + canvas.boat, + canvas.lineOfSight, + canvas.detection, + canvas.note, + ...TICKS_KM.map((km) => fill(canvas.km, { value: String(km) })), + ]), + ); + }); + + it('marks the expected detection only up to a moderate sea', () => { + const rough = paint({ radarHeight: 20, seaState: 'rough' }).map((drawn) => drawn.text); + expect(rough).not.toContain(canvas.detection); + expect(rough).toContain(canvas.boat); + }); + + it.each(CHAR_WIDTHS)( + 'keeps labels of %d px a letter apart and off the line and the sea', + (width) => { + charWidth = width; + [PHONE, DESKTOP].forEach((frame) => + [5, 8, 12, 20, 35, 50].forEach((radarHeight) => + (['smooth', 'moderate', 'rough'] as const).forEach((seaState) => { + const scene = { radarHeight, seaState }; + const { context, texts } = recordingContext(); + paintHorizon(context, frame, scene); + const layout = horizonLayout(frame.width, frame.height); + const blocked = blockedBoxes(layout, scene); + const labels = texts.filter((drawn) => !drawn.text.endsWith(' km')); + const boxes = labels.map((drawn) => labelBox(drawn, layout.metrics.font)); + boxes.forEach((box, index) => { + const where = `${frame.width} px, ${radarHeight} m, ${seaState}: ${labels[index].text}`; + expect(box.bottom, where).toBeLessThanOrEqual(layout.plot.bottom); + const marks = index === 0 ? blocked : [...blocked, detectionMark(layout, scene)]; + [...boxes.slice(index + 1), ...marks].forEach((other) => + expect(intersects(box, other), where).toBe(false), + ); + }); + }), + ), + ); + charWidth = CHAR_WIDTHS[0]; + }, + ); +}); + +function detectionMark(layout: ReturnType, scene: HorizonScene): Box { + if (!detectionCovered(scene.seaState)) return { left: 0, right: 0, top: 0, bottom: 0 }; + const x = xOfKm(layout, DETECTION_KM); + return { left: x - 1, right: x + 1, top: layout.plot.top, bottom: layout.plot.bottom }; +} + +function labelBox(drawn: Drawn, font: number): Box { + const width = drawn.text.length * charWidth; + const offset = { left: 0, center: width / 2, right: width }[drawn.align]; + const left = drawn.x - offset; + return { left, right: left + width, top: drawn.y - font * 0.78, bottom: drawn.y + font * 0.24 }; +} diff --git a/explainers/naval-drone/src/ui/horizonView.ts b/explainers/naval-drone/src/ui/horizonView.ts new file mode 100644 index 00000000..14c3499a --- /dev/null +++ b/explainers/naval-drone/src/ui/horizonView.ts @@ -0,0 +1,506 @@ +import { withAlpha } from '@core/color'; +import { currentLanguage, t } from '@core/i18n'; +import { FULL_TURN, clamp } from '@core/math'; +import { CanvasSurface, canvasFont } from '@core/ui/canvasSurface'; +import type { CanvasFrame } from '@core/ui/canvasSurface'; +import type { SeaStateId } from '../ids'; +import { + DETECTION_KM, + RADAR_HEIGHT_M, + RADAR_K, + detectionCovered, + radarLineOfSightKm, + seaAt, + surfaceDropM, +} from '../model'; +import { THEME } from '../theme'; +import { placeText, segmentBoxes, shiftInto, textBox } from './canvasLabels'; +import type { Anchor, Box, Placed, TextAlign, TextMetrics } from './canvasLabels'; +import { formatCanvasKm } from './format'; + +export interface HorizonScene { + radarHeight: number; + seaState: SeaStateId; +} + +export interface HorizonLayout { + metrics: TextMetrics; + bounds: Box; + plot: Box; + pxPerKm: number; + pxPerMetre: number; + tickBaseline: number; +} + +interface LabelPainter { + context: CanvasRenderingContext2D; + layout: HorizonLayout; + taken: Box[]; +} + +type Candidates = (width: number) => readonly Anchor[]; + +export const AXIS_KM = 35; +export const TICKS_KM = [0, 10, 20, 30] as const; + +const MAST_HEADROOM_M = 6; +const SEA_MARGIN_M = 4; +const SKY_TOP_M = RADAR_HEIGHT_M.max + MAST_HEADROOM_M; +const SEA_FLOOR_M = -surfaceDropM(AXIS_KM) - SEA_MARGIN_M; +const FONT = { min: 11, max: 13, perWidth: 0.02 } as const; +const TEXT = { ascent: 0.78, descent: 0.24 } as const; +const SPACE = { pad: 6, gap: 4, shipGutter: 18, tick: 3 } as const; +const SHIP = { aft: 14, bow: 10, deckM: 4, minHullPx: 5, antenna: 5 } as const; +const BOAT_ICON = { length: 10, height: 4 } as const; +const WAVE = { pxPerMetre: 1.5, periodPx: 9, stepPx: 1.5 } as const; +const LINE = { surface: 1.5, sight: 1.8, mast: 1.5, tick: 1, halo: 3, dash: [4, 4] } as const; +const OBSTACLE = { stepPx: 12, slackPx: 2 } as const; +const SEA_FILL_ALPHA = 0.32; +const SIGHT_SHARES = [0, 0.18, 0.36, 0.5, 0.62, 0.74] as const; +const SEA_ROWS = [0, 1, 2, 3] as const; +const NOTE_ROWS = [0, 1, 2] as const; +const SKY_ROWS = [0, 1, 2] as const; +const TANGENT_SLOPE = 2 / RADAR_K; + +const COLORS = { + sea: withAlpha(THEME.waterWall, SEA_FILL_ALPHA), + surface: THEME.waterWall, + ship: THEME.ship, + antenna: THEME.text, + sight: THEME.sprint, + detection: THEME.hump, + boat: THEME.panel, + muted: THEME.muted, + halo: THEME.background, +} as const; + +export function horizonLayout(width: number, height: number): HorizonLayout { + const font = clamp(Math.round(width * FONT.perWidth), FONT.min, FONT.max); + const tickBaseline = height - SPACE.pad - font * TEXT.descent; + const plot = { + left: SPACE.pad + SPACE.shipGutter, + top: SPACE.pad + font + SPACE.gap, + right: width - SPACE.pad, + bottom: tickBaseline - font * TEXT.ascent - SPACE.gap - SPACE.tick, + }; + return { + metrics: { font, ...TEXT }, + bounds: { left: SPACE.pad, top: SPACE.pad, right: width - SPACE.pad, bottom: plot.bottom }, + plot, + pxPerKm: (plot.right - plot.left) / AXIS_KM, + pxPerMetre: (plot.bottom - plot.top) / (SKY_TOP_M - SEA_FLOOR_M), + tickBaseline, + }; +} + +export function xOfKm(layout: HorizonLayout, km: number): number { + return layout.plot.left + km * layout.pxPerKm; +} + +export function yOfMetres(layout: HorizonLayout, metres: number): number { + return layout.plot.top + (SKY_TOP_M - metres) * layout.pxPerMetre; +} + +export function surfaceY(layout: HorizonLayout, km: number): number { + return yOfMetres(layout, -surfaceDropM(km)); +} + +export function sightHeightM(radarHeight: number, km: number): number { + return radarHeight - TANGENT_SLOPE * Math.sqrt(radarHeight) * km; +} + +function kmOfX(layout: HorizonLayout, x: number): number { + return (x - layout.plot.left) / layout.pxPerKm; +} + +export function sightY(layout: HorizonLayout, radarHeight: number, x: number): number { + return yOfMetres(layout, sightHeightM(radarHeight, kmOfX(layout, x))); +} + +function surfaceYAtX(layout: HorizonLayout, x: number): number { + return surfaceY(layout, kmOfX(layout, x)); +} + +function waveAmplitude(scene: HorizonScene): number { + return seaAt(scene.seaState).waveHeight * WAVE.pxPerMetre; +} + +function seaClearance(scene: HorizonScene): number { + return OBSTACLE.slackPx + waveAmplitude(scene); +} + +function waveOffset(x: number, amplitude: number): number { + return -amplitude * Math.abs(Math.sin((Math.PI * x) / WAVE.periodPx)); +} + +function traceSurface( + context: CanvasRenderingContext2D, + layout: HorizonLayout, + amplitude: number, +): void { + const end = layout.plot.right + SPACE.pad; + context.beginPath(); + context.moveTo(0, surfaceYAtX(layout, 0)); + for (let x = WAVE.stepPx; x <= end; x += WAVE.stepPx) { + context.lineTo(x, surfaceYAtX(layout, x) + waveOffset(x, amplitude)); + } +} + +function paintSea( + context: CanvasRenderingContext2D, + layout: HorizonLayout, + scene: HorizonScene, +): void { + const amplitude = waveAmplitude(scene); + traceSurface(context, layout, amplitude); + context.lineTo(layout.plot.right + SPACE.pad, layout.plot.bottom); + context.lineTo(0, layout.plot.bottom); + context.closePath(); + context.fillStyle = COLORS.sea; + context.fill(); + traceSurface(context, layout, amplitude); + context.strokeStyle = COLORS.surface; + context.lineWidth = LINE.surface; + context.stroke(); +} + +function drawText( + context: CanvasRenderingContext2D, + text: string, + placed: Placed, + color: string, +): void { + context.textAlign = placed.align; + context.lineJoin = 'round'; + context.lineWidth = LINE.halo; + context.strokeStyle = COLORS.halo; + context.strokeText(text, placed.x, placed.baseline); + context.fillStyle = color; + context.fillText(text, placed.x, placed.baseline); +} + +function paintTicks(context: CanvasRenderingContext2D, layout: HorizonLayout): void { + context.strokeStyle = COLORS.muted; + context.lineWidth = LINE.tick; + TICKS_KM.forEach((km) => { + const x = xOfKm(layout, km); + context.beginPath(); + context.moveTo(x, layout.plot.bottom); + context.lineTo(x, layout.plot.bottom + SPACE.tick); + context.stroke(); + const text = formatCanvasKm(km); + const anchor: Anchor = { x, baseline: layout.tickBaseline, align: 'center' }; + const box = textBox(context.measureText(text).width, anchor, layout.metrics); + drawText(context, text, shiftInto(box, anchor, layout.bounds), COLORS.muted); + }); +} + +function shipBox(layout: HorizonLayout, radarHeight: number): Box { + const mastX = xOfKm(layout, 0); + return { + left: mastX - SHIP.aft, + right: mastX + SHIP.bow, + top: yOfMetres(layout, radarHeight) - SHIP.antenna, + bottom: yOfMetres(layout, 0), + }; +} + +function paintShip( + context: CanvasRenderingContext2D, + layout: HorizonLayout, + radarHeight: number, +): void { + const mastX = xOfKm(layout, 0); + const water = yOfMetres(layout, 0); + const deck = Math.min(yOfMetres(layout, SHIP.deckM), water - SHIP.minHullPx); + context.fillStyle = COLORS.ship; + context.beginPath(); + context.moveTo(mastX - SHIP.aft, deck); + context.lineTo(mastX + SHIP.bow, deck); + context.lineTo(mastX + SHIP.bow - SHIP.minHullPx, water); + context.lineTo(mastX - SHIP.aft, water); + context.closePath(); + context.fill(); + const antenna = yOfMetres(layout, radarHeight); + context.strokeStyle = COLORS.ship; + context.lineWidth = LINE.mast; + context.beginPath(); + context.moveTo(mastX, deck); + context.lineTo(mastX, antenna); + context.stroke(); + context.strokeStyle = COLORS.antenna; + context.beginPath(); + context.moveTo(mastX - SHIP.antenna, antenna); + context.lineTo(mastX + SHIP.antenna, antenna); + context.stroke(); +} + +function boatBox(layout: HorizonLayout, km: number): Box { + const x = xOfKm(layout, km); + const water = surfaceY(layout, km); + const half = BOAT_ICON.length / 2; + return { left: x - half, right: x + half, top: water - BOAT_ICON.height, bottom: water }; +} + +function paintBoat(context: CanvasRenderingContext2D, box: Box): void { + context.fillStyle = COLORS.boat; + context.beginPath(); + context.moveTo(box.left, box.top); + context.lineTo(box.right, box.top + BOAT_ICON.height / 2); + context.lineTo(box.right - BOAT_ICON.height, box.bottom); + context.lineTo(box.left, box.bottom); + context.closePath(); + context.fill(); +} + +function detectionBox(layout: HorizonLayout): Box { + const x = xOfKm(layout, DETECTION_KM); + return { + left: x - LINE.tick, + right: x + LINE.tick, + top: layout.plot.top, + bottom: layout.plot.bottom, + }; +} + +function paintDetection(context: CanvasRenderingContext2D, box: Box): void { + const x = (box.left + box.right) / 2; + context.strokeStyle = COLORS.detection; + context.lineWidth = LINE.tick; + context.setLineDash(LINE.dash); + context.beginPath(); + context.moveTo(x, box.top); + context.lineTo(x, box.bottom); + context.stroke(); + context.setLineDash([]); +} + +function paintSight( + context: CanvasRenderingContext2D, + layout: HorizonLayout, + radarHeight: number, + boatKm: number, +): void { + const startX = xOfKm(layout, 0); + const startY = yOfMetres(layout, radarHeight); + context.strokeStyle = COLORS.sight; + context.lineWidth = LINE.sight; + context.beginPath(); + context.moveTo(startX, startY); + context.lineTo(xOfKm(layout, boatKm), yOfMetres(layout, sightHeightM(radarHeight, boatKm))); + context.stroke(); + context.beginPath(); + context.arc(startX, startY, LINE.sight, 0, FULL_TURN); + context.fillStyle = COLORS.sight; + context.fill(); +} + +export function blockedBoxes(layout: HorizonLayout, scene: HorizonScene): Box[] { + const { stepPx, slackPx } = OBSTACLE; + const boatKm = radarLineOfSightKm(scene.radarHeight); + const sight = segmentBoxes( + xOfKm(layout, 0), + xOfKm(layout, boatKm), + stepPx, + (x) => sightY(layout, scene.radarHeight, x), + slackPx, + ); + const surface = segmentBoxes( + layout.bounds.left, + layout.bounds.right, + stepPx, + (x) => surfaceYAtX(layout, x), + seaClearance(scene), + ); + const marks = [shipBox(layout, scene.radarHeight), boatBox(layout, boatKm)]; + return [...marks, ...sight, ...surface]; +} + +function lineHeight(layout: HorizonLayout): number { + return layout.metrics.font + SPACE.gap; +} + +function baselineAbove(layout: HorizonLayout, y: number): number { + return y - SPACE.gap - layout.metrics.font * TEXT.descent; +} + +function baselineBelow(layout: HorizonLayout, y: number, row: number): number { + return y + SPACE.gap + layout.metrics.font * TEXT.ascent + row * lineHeight(layout); +} + +function radarCandidates(layout: HorizonLayout, radarHeight: number): Candidates { + const ship = shipBox(layout, radarHeight); + const baseline = baselineAbove(layout, ship.top); + return () => [ + { x: xOfKm(layout, 0), baseline, align: 'center' }, + { x: ship.right + SPACE.gap, baseline, align: 'left' }, + ]; +} + +function skyAbove(layout: HorizonLayout, scene: HorizonScene, x: number): number { + const from = x - OBSTACLE.stepPx; + const crest = surfaceYAtX(layout, from) - seaClearance(scene); + return Math.min(sightY(layout, scene.radarHeight, from) - OBSTACLE.slackPx, crest); +} + +function skyRows(layout: HorizonLayout, x: number, align: TextAlign): Anchor[] { + return SKY_ROWS.map((row) => ({ + x, + baseline: layout.plot.top + layout.metrics.font + row * lineHeight(layout), + align, + })); +} + +function boatCandidates(layout: HorizonLayout, scene: HorizonScene, boat: Box): Candidates { + const x = (boat.left + boat.right) / 2; + const clearance = seaClearance(scene); + return (width) => [ + ...SEA_ROWS.map((row) => ({ + x, + baseline: baselineBelow(layout, surfaceYAtX(layout, x + width / 2) + clearance, row), + align: 'center' as const, + })), + ...SEA_ROWS.map((row) => ({ + x: boat.left, + baseline: baselineBelow(layout, surfaceYAtX(layout, boat.left + width) + clearance, row), + align: 'left' as const, + })), + { + x: boat.right, + baseline: baselineAbove(layout, skyAbove(layout, scene, boat.right - width)), + align: 'right' as const, + }, + ]; +} + +function sightCandidates(layout: HorizonLayout, scene: HorizonScene, boatKm: number): Candidates { + return () => + SIGHT_SHARES.map((share) => { + const x = xOfKm(layout, share * boatKm) + SHIP.bow + SPACE.gap; + return { + x, + baseline: baselineAbove(layout, skyAbove(layout, scene, x)), + align: 'left' as const, + }; + }); +} + +function detectionCandidates(layout: HorizonLayout, scene: HorizonScene): Candidates { + const line = xOfKm(layout, DETECTION_KM); + const right = line + SPACE.gap; + const left = line - SPACE.gap; + const clearance = seaClearance(scene); + return (width) => [ + ...SEA_ROWS.flatMap((row) => [ + { + x: right, + baseline: baselineBelow(layout, surfaceYAtX(layout, right + width) + clearance, row), + align: 'left' as const, + }, + { + x: left, + baseline: baselineBelow(layout, surfaceYAtX(layout, left) + clearance, row), + align: 'right' as const, + }, + ]), + ...skyRows(layout, right, 'left'), + ...skyRows(layout, left, 'right'), + ]; +} + +function noteCandidates(layout: HorizonLayout): Candidates { + return () => + NOTE_ROWS.map((row) => ({ + x: layout.bounds.right, + baseline: layout.plot.top - SPACE.gap + row * lineHeight(layout), + align: 'right' as const, + })); +} + +function label(painter: LabelPainter, key: string, candidates: Candidates, color: string): void { + const { context, layout, taken } = painter; + const text = t(key); + const width = context.measureText(text).width; + drawText( + context, + text, + placeText(width, candidates(width), taken, layout.bounds, layout.metrics), + color, + ); +} + +function paintLabels(painter: LabelPainter, scene: HorizonScene, boatKm: number): void { + const { layout } = painter; + const boat = boatBox(layout, boatKm); + label( + painter, + 'horizon.canvas.radar', + radarCandidates(layout, scene.radarHeight), + COLORS.antenna, + ); + if (detectionCovered(scene.seaState)) painter.taken.push(detectionBox(layout)); + label(painter, 'horizon.canvas.boat', boatCandidates(layout, scene, boat), COLORS.boat); + label( + painter, + 'horizon.canvas.lineOfSight', + sightCandidates(layout, scene, boatKm), + COLORS.sight, + ); + if (detectionCovered(scene.seaState)) { + label( + painter, + 'horizon.canvas.detection', + detectionCandidates(layout, scene), + COLORS.detection, + ); + } + label(painter, 'horizon.canvas.note', noteCandidates(layout), COLORS.muted); +} + +export function paintHorizon( + context: CanvasRenderingContext2D, + frame: CanvasFrame, + scene: HorizonScene, +): void { + const layout = horizonLayout(frame.width, frame.height); + const boatKm = radarLineOfSightKm(scene.radarHeight); + const covered = detectionCovered(scene.seaState); + const boat = boatBox(layout, boatKm); + context.font = canvasFont(frame, layout.metrics.font); + context.textBaseline = 'alphabetic'; + paintSea(context, layout, scene); + paintTicks(context, layout); + if (covered) paintDetection(context, detectionBox(layout)); + paintShip(context, layout, scene.radarHeight); + paintSight(context, layout, scene.radarHeight, boatKm); + paintBoat(context, boat); + paintLabels({ context, layout, taken: blockedBoxes(layout, scene) }, scene, boatKm); +} + +export class HorizonView { + private readonly surface: CanvasSurface; + private painted: HorizonScene | null = null; + private language = currentLanguage(); + + constructor(canvas: HTMLCanvasElement) { + this.surface = new CanvasSurface(canvas); + } + + draw(scene: HorizonScene): void { + const language = currentLanguage(); + if (this.isPainted(scene) && language === this.language) return; + this.painted = { ...scene }; + this.language = language; + this.surface.paint((context, frame) => paintHorizon(context, frame, scene)); + } + + dispose(): void { + this.surface.dispose(); + } + + private isPainted(scene: HorizonScene): boolean { + return ( + this.painted?.radarHeight === scene.radarHeight && this.painted.seaState === scene.seaState + ); + } +} diff --git a/explainers/naval-drone/src/ui/index.ts b/explainers/naval-drone/src/ui/index.ts new file mode 100644 index 00000000..fc255f1e --- /dev/null +++ b/explainers/naval-drone/src/ui/index.ts @@ -0,0 +1,24 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import type { NavalDroneStore } from '../state'; +import { mountFleetReadouts } from './mountFleetReadouts'; +import { mountHorizonControl } from './mountHorizonControl'; +import { mountHullControl } from './mountHullControl'; +import { mountJetControl } from './mountJetControl'; +import { mountLinkControl } from './mountLinkControl'; +import { mountOverviewReadouts } from './mountOverviewReadouts'; + +export { CHAPTER_ACTIONS } from './actions'; +export { NAVAL_DRONE_CHOICES, VIEW_TOGGLES } from './dock'; +export { NAVAL_DRONE_READOUTS } from './readouts'; + +export function mountNavalDroneUi(root: Document, store: NavalDroneStore): Disposer { + return disposeAll([ + mountOverviewReadouts(root, store), + mountHullControl(root, store), + mountJetControl(root, store), + mountLinkControl(root, store), + mountHorizonControl(root, store), + mountFleetReadouts(root, store), + ]); +} diff --git a/explainers/naval-drone/src/ui/mountFleetReadouts.ts b/explainers/naval-drone/src/ui/mountFleetReadouts.ts new file mode 100644 index 00000000..df351fe7 --- /dev/null +++ b/explainers/naval-drone/src/ui/mountFleetReadouts.ts @@ -0,0 +1,12 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { NavalDroneStore } from '../state'; +import { formatCarries, formatCost, formatShipValue } from './format'; + +export function mountFleetReadouts(root: Document, store: NavalDroneStore): Disposer { + return mountLiveReadouts(root, store, { + 'fleet-carries': (state) => formatCarries(state.fit), + 'fleet-cost': () => formatCost(), + 'fleet-ship': () => formatShipValue(), + }); +} diff --git a/explainers/naval-drone/src/ui/mountHorizonControl.ts b/explainers/naval-drone/src/ui/mountHorizonControl.ts new file mode 100644 index 00000000..eb1d6b07 --- /dev/null +++ b/explainers/naval-drone/src/ui/mountHorizonControl.ts @@ -0,0 +1,51 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import { requireElement } from '@core/ui/dom'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import type { SeaStateId } from '../ids'; +import { RADAR_HEIGHT_M, minutesAtTopSpeed, radarLineOfSightKm } from '../model'; +import type { NavalDroneStore } from '../state'; +import { + formatCameraHorizon, + formatDetection, + formatHidden, + formatKm, + formatMetres, + formatMinutes, + formatWaves, +} from './format'; +import { HorizonView } from './horizonView'; +import { inChapter } from './actions'; + +type SelectedRadar = readonly [radarHeight: number, seaState: SeaStateId]; + +export function mountHorizonControl(root: Document, store: NavalDroneStore): Disposer { + const view = new HorizonView(requireElement(root, '[data-view="horizon"]')); + const unmount = disposeAll([ + mountRangeWidget(root, store, { + control: 'radar-height', + range: RADAR_HEIGHT_M, + select: (state): SelectedRadar => [state.radarHeight, state.seaState], + value: ([radarHeight]) => radarHeight, + format: ([radarHeight]) => formatMetres(radarHeight), + set: inChapter('horizon', (state, radarHeight: number) => state.setRadarHeight(radarHeight)), + readouts: { + 'horizon-radar': ([radarHeight]) => formatKm(radarLineOfSightKm(radarHeight)), + 'horizon-minutes': ([radarHeight]) => + formatMinutes(minutesAtTopSpeed(radarLineOfSightKm(radarHeight))), + }, + after: ([radarHeight, seaState]) => view.draw({ radarHeight, seaState }), + }), + mountLiveReadouts(root, store, { + 'horizon-camera': () => formatCameraHorizon(), + 'horizon-detect': (state) => formatDetection(state.seaState), + 'horizon-waves': (state) => formatWaves(state.seaState), + 'horizon-hidden': (state) => formatHidden(state.seaState), + }), + ]); + return () => { + unmount(); + view.dispose(); + }; +} diff --git a/explainers/naval-drone/src/ui/mountHullControl.ts b/explainers/naval-drone/src/ui/mountHullControl.ts new file mode 100644 index 00000000..0f61787d --- /dev/null +++ b/explainers/naval-drone/src/ui/mountHullControl.ts @@ -0,0 +1,38 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import { TRIAL_KNOTS, planingAt, trialKnotsOf } from '../model'; +import { followedKnots } from '../state'; +import type { NavalDroneStore } from '../state'; +import { + formatBoatSpeed, + formatDegrees, + formatHullRatio, + formatLift, + formatMode, + formatWetted, +} from './format'; +import { inChapter } from './actions'; + +type SelectedSpeed = readonly [knots: number]; + +function planingOf([knots]: SelectedSpeed) { + return planingAt(knots); +} + +export function mountHullControl(root: Document, store: NavalDroneStore): Disposer { + return mountRangeWidget(root, store, { + control: 'trial-knots', + range: TRIAL_KNOTS, + select: (state): SelectedSpeed => [trialKnotsOf(followedKnots(state))], + value: ([knots]) => knots, + format: ([knots]) => formatBoatSpeed(knots), + set: inChapter('hull', (state, knots: number) => state.setTrialKnots(knots)), + readouts: { + 'hull-mode': (selected) => formatMode(planingOf(selected).mode), + 'hull-trim': (selected) => formatDegrees(planingOf(selected).trim), + 'hull-lift': (selected) => formatLift(planingOf(selected).liftShare), + 'hull-wetted': (selected) => formatWetted(planingOf(selected).wettedLength), + 'hull-ratio': ([knots]) => formatHullRatio(knots), + }, + }); +} diff --git a/explainers/naval-drone/src/ui/mountJetControl.ts b/explainers/naval-drone/src/ui/mountJetControl.ts new file mode 100644 index 00000000..19ca4038 --- /dev/null +++ b/explainers/naval-drone/src/ui/mountJetControl.ts @@ -0,0 +1,61 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import { TEXT_REFRESH_INTERVAL_MS } from '@core/ui/throttle'; +import type { HelmId } from '../ids'; +import { THROTTLE_PERCENT, throttleAt, throttlePercentOf, throttleShareOf } from '../model'; +import { followedKnots, runAt } from '../state'; +import type { NavalDroneStore, NavalDroneStoreState } from '../state'; +import { + formatBoatOrBacking, + formatEfficiency, + formatFlow, + formatJetSpeed, + formatPercent, + formatPush, + formatThrust, + shownKnots, +} from './format'; +import { inChapter } from './actions'; + +type SelectedThrottle = readonly [ + percent: number, + helm: HelmId, + trialKnots: number | null, + knots: number, +]; + +function jetOf(state: NavalDroneStoreState) { + return runAt(state).jet; +} + +export function mountJetControl(root: Document, store: NavalDroneStore): Disposer { + return disposeAll([ + mountRangeWidget(root, store, { + control: 'throttle', + range: THROTTLE_PERCENT, + select: (state): SelectedThrottle => [ + throttlePercentOf(throttleAt(followedKnots(state))), + state.helm, + state.trialKnots, + shownKnots(runAt(state).boat.knots), + ], + value: ([percent]) => percent, + format: ([percent]) => formatPercent(throttleShareOf(percent)), + set: inChapter('jet', (state, percent: number) => state.setThrottle(percent)), + refreshIntervalMs: TEXT_REFRESH_INTERVAL_MS, + readouts: { + 'jet-boat': ([, helm, , knots]) => formatBoatOrBacking(knots, helm), + 'jet-flow': (_, state) => formatFlow(jetOf(state).flow), + 'jet-velocity': (_, state) => formatJetSpeed(jetOf(state).jetSpeed), + 'jet-thrust': (_, state) => formatThrust(jetOf(state).thrust, state.helm), + 'jet-efficiency': ([, , , knots], state) => + formatEfficiency(jetOf(state).efficiency, knots), + }, + }), + mountLiveReadouts(root, store, { + 'jet-push': (state) => formatPush(state.helm, jetOf(state).nozzleAngle), + }), + ]); +} diff --git a/explainers/naval-drone/src/ui/mountLinkControl.ts b/explainers/naval-drone/src/ui/mountLinkControl.ts new file mode 100644 index 00000000..6ed69bea --- /dev/null +++ b/explainers/naval-drone/src/ui/mountLinkControl.ts @@ -0,0 +1,44 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import type { LinkMode } from '../ids'; +import { VIDEO_DELAY_MS } from '../model'; +import { runAt } from '../state'; +import type { NavalDroneStore } from '../state'; +import { + formatDelay, + formatLinkBoat, + formatLinkCarrier, + formatLinkNow, + formatLinkTop, + shownKnots, +} from './format'; +import { inChapter } from './actions'; + +type SelectedDelay = readonly [delayMs: number, mode: LinkMode, knots: number]; + +export function mountLinkControl(root: Document, store: NavalDroneStore): Disposer { + return disposeAll([ + mountRangeWidget(root, store, { + control: 'video-delay', + range: VIDEO_DELAY_MS, + select: (state): SelectedDelay => [ + state.videoDelayMs, + state.linkMode, + shownKnots(runAt(state).boat.knots), + ], + value: ([delayMs]) => delayMs, + format: ([delayMs]) => formatDelay(delayMs), + set: inChapter('link', (state, delayMs: number) => state.setVideoDelay(delayMs)), + readouts: { + 'link-now': ([delayMs, mode, knots]) => formatLinkNow(mode, delayMs, knots), + 'link-top': ([delayMs]) => formatLinkTop(delayMs), + }, + }), + mountLiveReadouts(root, store, { + 'link-carrier': (state) => formatLinkCarrier(state.linkMode), + 'link-boat': (state) => formatLinkBoat(state.linkMode), + }), + ]); +} diff --git a/explainers/naval-drone/src/ui/mountOverviewReadouts.ts b/explainers/naval-drone/src/ui/mountOverviewReadouts.ts new file mode 100644 index 00000000..0d331234 --- /dev/null +++ b/explainers/naval-drone/src/ui/mountOverviewReadouts.ts @@ -0,0 +1,14 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { RUN_SECONDS, distanceAt } from '../model'; +import { runAt } from '../state'; +import type { NavalDroneStore } from '../state'; +import { formatDistance, formatMode, formatPercent } from './format'; + +export function mountOverviewReadouts(root: Document, store: NavalDroneStore): Disposer { + return mountLiveReadouts(root, store, { + 'overview-mode': (state) => formatMode(runAt(state).planing.mode), + 'overview-covered': (state) => formatDistance(distanceAt(state.phase)), + 'overview-done': (state) => formatPercent(state.phase / RUN_SECONDS), + }); +} diff --git a/explainers/naval-drone/src/ui/mountUi.dom.test.ts b/explainers/naval-drone/src/ui/mountUi.dom.test.ts new file mode 100644 index 00000000..6692ffe0 --- /dev/null +++ b/explainers/naval-drone/src/ui/mountUi.dom.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initI18n } from '@core/i18n'; +import { mountActions } from '@core/ui/actions'; +import { TEXT_REFRESH_INTERVAL_MS } from '@core/ui/throttle'; +import chapters from '../../chapters.html?raw'; +import en from '../../locales/en.json'; +import { FIT_IDS, HELM_IDS, LINK_MODES, SEA_STATE_IDS, SPEED_MARK_IDS } from '../ids'; +import { + HELD_PHASE, + MOMENTS, + distanceAt, + knotsAtThrottle, + speedAt, + throttleAt, + throttlePercentOf, +} from '../model'; +import { createNavalDroneStore, runAt } from '../state'; +import type { NavalDroneStore } from '../state'; +import { CHAPTER_ACTIONS } from './actions'; +import { + formatBoatSpeed, + formatDistance, + formatHullRatio, + formatLinkNow, + formatPercent, + shownKnots, +} from './format'; +import { fill, isFilled } from './testing'; +import { mountNavalDroneUi } from '.'; + +const { units, hull, jet, link, horizon, fleet } = en; +const PHASE_NUDGE = 0.05; + +function readout(id: string): string | null | undefined { + return document.querySelector(`[data-readout="${id}"]`)?.textContent; +} + +function allReadouts(): string[] { + return [...document.querySelectorAll('[data-readout]')].map((node) => node.textContent ?? ''); +} + +function click(action: string, value: string): void { + document.querySelector(`[data-action="${action}"][data-value="${value}"]`)?.click(); +} + +function pressed(action: string, value: string): string | null | undefined { + return document + .querySelector(`[data-action="${action}"][data-value="${value}"]`) + ?.getAttribute('aria-pressed'); +} + +function input(control: string): HTMLInputElement { + const found = document.querySelector(`[data-control="${control}"]`); + if (!found) throw new Error(`No control ${control}`); + return found; +} + +function slide(control: string, value: number): void { + const range = input(control); + range.value = String(value); + range.dispatchEvent(new Event('input')); +} + +function output(control: string): string | null | undefined { + return document.querySelector(`output[for="${control}"]`)?.textContent; +} + +function settle(): void { + vi.advanceTimersByTime(TEXT_REFRESH_INTERVAL_MS); +} + +describe('chapter widgets', () => { + let store: NavalDroneStore; + let dispose: () => void; + + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + beforeEach(() => { + vi.useFakeTimers(); + document.body.innerHTML = chapters; + store = createNavalDroneStore({ playing: false, phase: 0 }); + dispose = mountNavalDroneUi(document, store); + mountActions(document, store, CHAPTER_ACTIONS); + }); + + afterEach(() => { + dispose(); + vi.useRealTimers(); + }); + + it('offers a chip for every option the chapters switch', () => { + const values = (action: string) => + [...document.querySelectorAll(`[data-action="${action}"]`)].map((node) => + node.getAttribute('data-value'), + ); + expect(values('speedMark')).toEqual([...SPEED_MARK_IDS]); + expect(values('helm')).toEqual([...HELM_IDS]); + expect(values('linkMode')).toEqual([...LINK_MODES]); + expect(values('seaState')).toEqual([...SEA_STATE_IDS]); + expect(values('fit')).toEqual([...FIT_IDS]); + }); + + it('follows the hull mode, the distance covered and the share of the run in the overview', () => { + expect(readout('overview-mode')).toBe(en.mode.floating); + expect(readout('overview-covered')).toBe(fill(units.m, { value: '0' })); + expect(readout('overview-done')).toBe(fill(units.percent, { value: '0' })); + click('moment', 'topSpeed'); + expect(pressed('moment', 'topSpeed')).toBe('true'); + expect(readout('overview-mode')).toBe(en.mode.planing); + expect(readout('overview-covered')).toBe(formatDistance(distanceAt(MOMENTS.topSpeed))); + expect(readout('overview-done')).toBe(formatPercent(MOMENTS.topSpeed / 120)); + click('moment', 'alongside'); + expect(readout('overview-done')).toBe(fill(units.percent, { value: '100' })); + }); + + it('follows the run on the hull slider until the reader takes the boat over', () => { + store.getState().setPhase(MOMENTS.humpPeak); + expect(Number(input('trial-knots').value)).toBeCloseTo(speedAt(MOMENTS.humpPeak), 6); + expect(output('trial-knots')).toBe(fill(units.speed, { kn: '11', kmh: '20' })); + expect(readout('hull-mode')).toBe(en.mode.hump); + expect(pressed('speedMark', 'hump')).toBe('true'); + store.getState().play(); + slide('trial-knots', 42); + expect(store.getState()).toMatchObject({ trialKnots: 42, playing: false }); + expect(readout('hull-mode')).toBe(en.mode.planing); + expect(readout('hull-trim')).toBe(fill(units.degrees, { value: '2.5' })); + expect(readout('hull-wetted')).toBe(fill(hull.wetted, { m: '1.7', rest: '5.1' })); + expect(readout('hull-ratio')).toBe(formatHullRatio(42)); + expect(readout('hull-lift')).toContain('98'); + expect(pressed('speedMark', 'top')).toBe('true'); + store.getState().play(); + expect(store.getState().trialKnots).toBeNull(); + expect(output('trial-knots')).toBe(formatBoatSpeed(speedAt(store.getState().phase))); + }); + + it('holds the boat at each speed mark', () => { + click('speedMark', 'hullSpeed'); + expect(output('trial-knots')).toBe(formatBoatSpeed(5.7)); + expect(readout('hull-ratio')).toBe(fill(hull.ratio, { times: '1.0', hullSpeed: '5.7' })); + click('speedMark', 'planing'); + expect(readout('hull-mode')).toBe(en.mode.planing); + slide('trial-knots', 0); + expect(readout('hull-ratio')).toBe(fill(hull.ratioStill, { hullSpeed: '5.7' })); + }); + + it('follows the run throttle on the jet slider and holds the boat when it moves', () => { + store.getState().setPhase(HELD_PHASE); + settle(); + expect(Number(input('throttle').value)).toBe(throttlePercentOf(throttleAt(22))); + expect(readout('jet-boat')).toBe(formatBoatSpeed(22)); + slide('throttle', 100); + settle(); + expect(store.getState().trialKnots).toBeCloseTo(knotsAtThrottle(1), 6); + expect(output('throttle')).toBe(fill(units.percent, { value: '100' })); + expect(readout('jet-flow')).toBe(fill(jet.flow, { kg: '213', litres: '207' })); + expect(readout('jet-velocity')).toBe(fill(units.mps, { ms: '32', kmh: '117' })); + expect(readout('jet-thrust')).toBe(fill(units.kilonewtons, { value: '2.30' })); + expect(readout('jet-efficiency')).toBe(fill(units.percent, { value: '80' })); + }); + + it('keeps the jet boat speed with the gauge while the run speeds up', () => { + for (let phase = MOMENTS.humpPeak; phase <= HELD_PHASE; phase += PHASE_NUDGE) { + store.getState().setPhase(phase); + settle(); + expect(readout('jet-boat'), `${phase}`).toBe( + formatBoatSpeed(runAt(store.getState()).boat.knots), + ); + } + }); + + it('swings the nozzle and backs the boat with the helm chips', () => { + store.getState().setPhase(HELD_PHASE); + expect(readout('jet-push')).toBe(jet.push.straight); + click('helm', 'left'); + expect(pressed('helm', 'left')).toBe('true'); + expect(store.getState().trialKnots).toBe(22); + expect(readout('jet-push')).toBe(fill(jet.push.left, { angle: '27' })); + click('helm', 'right'); + expect(readout('jet-push')).toBe(fill(jet.push.right, { angle: '27' })); + click('helm', 'reverse'); + settle(); + expect(readout('jet-push')).toBe(jet.push.reverse); + expect(readout('jet-boat')).toBe(jet.backing); + expect(readout('jet-thrust')).toBe(jet.astern); + expect(readout('jet-efficiency')).toBe(jet.noEfficiency); + expect(output('throttle')).toBe(formatPercent(throttleAt(22))); + }); + + it('turns the video delay into distance and follows the link mode', () => { + store.getState().setPhase(MOMENTS.topSpeed); + expect(output('video-delay')).toBe(fill(units.ms, { value: '250' })); + expect(readout('link-now')).toBe(fill(link.now, { m: '5.4', kn: '42' })); + expect(readout('link-top')).toBe(fill(link.top, { m: '5.4', lengths: '1.0' })); + slide('video-delay', 1000); + expect(readout('link-top')).toBe(fill(link.top, { m: '21.6', lengths: '3.9' })); + expect(readout('link-carrier')).toBe(link.carrier.satellite); + click('linkMode', 'backup'); + expect(pressed('linkMode', 'backup')).toBe('true'); + expect(readout('link-boat')).toBe(link.boat.backup); + click('linkMode', 'lost'); + expect(readout('link-now')).toBe(link.nowLost); + expect(readout('link-carrier')).toBe(link.carrier.lost); + }); + + it('gives the video lag at the speed the boat readouts show while the run speeds up', () => { + for (let phase = MOMENTS.throttleUp; phase <= MOMENTS.topSpeed; phase += PHASE_NUDGE) { + store.getState().setPhase(phase); + const knots = shownKnots(runAt(store.getState()).boat.knots); + expect(readout('link-now'), `${phase}`).toBe(formatLinkNow('satellite', 250, knots)); + } + }); + + it('reads the radar line of sight and the sea state', () => { + expect(output('radar-height')).toBe(fill(units.m, { value: '20' })); + expect(readout('horizon-radar')).toBe(fill(units.km, { value: '21.3' })); + expect(readout('horizon-minutes')).toBe(fill(units.minutes, { value: '16.5' })); + slide('radar-height', 5); + expect(readout('horizon-radar')).toBe(fill(units.km, { value: '12.1' })); + expect(readout('horizon-camera')).toBe(fill(horizon.camera, { km: '3.2', m: '0.7' })); + expect(readout('horizon-detect')).toBe(fill(horizon.detect, { km: '9.3', min: '7.1' })); + expect(store.getState().preset).toBe('horizon'); + expect(readout('horizon-hidden')).toBe(horizon.hidden.slight); + click('seaState', 'rough'); + expect(pressed('seaState', 'rough')).toBe('true'); + expect(readout('horizon-detect')).toBe(horizon.detectBeyond); + expect(readout('horizon-waves')).toBe(fill(horizon.waves.rough, { from: '2.5', to: '4' })); + expect(readout('horizon-hidden')).toBe(horizon.hidden.rough); + }); + + it('quotes the payload for the deck fit, the cost and the ship value', () => { + expect(readout('fleet-carries')).toBe(fill(fleet.carries.standard, { kg: '320' })); + expect(readout('fleet-cost')).toBe(fill(fleet.cost, { from: '250,000', to: '273,000' })); + expect(readout('fleet-ship')).toBe(fill(fleet.ship, { value: '65' })); + click('fit', 'missile'); + expect(pressed('fit', 'missile')).toBe('true'); + expect(readout('fleet-carries')).toBe(fleet.carries.missile); + }); + + it('fills every readout in every chapter state', () => { + [0, 28, 60, 100, 120].forEach((phase) => { + store.getState().setPhase(phase); + allReadouts().forEach((text) => expect(isFilled(text), `${phase}: ${text}`).toBe(true)); + }); + click('helm', 'reverse'); + click('linkMode', 'lost'); + click('seaState', 'rough'); + allReadouts().forEach((text) => expect(isFilled(text), text).toBe(true)); + }); +}); diff --git a/explainers/naval-drone/src/ui/palette.ts b/explainers/naval-drone/src/ui/palette.ts new file mode 100644 index 00000000..7d5a2ddd --- /dev/null +++ b/explainers/naval-drone/src/ui/palette.ts @@ -0,0 +1,16 @@ +import type { HullMode, PhaseId } from '../ids'; +import { THEME } from '../theme'; + +export const PHASE_TONES: Readonly> = { + launch: THEME.launch, + hump: THEME.hump, + cruise: THEME.cruise, + sprint: THEME.sprint, + arrival: THEME.arrival, +}; + +export const MODE_TONES: Readonly> = { + floating: THEME.floating, + hump: THEME.hump, + planing: THEME.planing, +}; diff --git a/explainers/naval-drone/src/ui/readouts.test.ts b/explainers/naval-drone/src/ui/readouts.test.ts new file mode 100644 index 00000000..51350c9f --- /dev/null +++ b/explainers/naval-drone/src/ui/readouts.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { createNavalDroneStore } from '../state'; +import { THEME } from '../theme'; +import { NAVAL_DRONE_READOUTS } from './readouts'; + +function readout(id: string) { + const found = NAVAL_DRONE_READOUTS.find((candidate) => candidate.id === id); + if (!found) throw new Error(`No readout ${id}`); + return found; +} + +describe('gauge readouts', () => { + it('shows the clock, the speed, the hull mode and the distance to the ship', () => { + expect(NAVAL_DRONE_READOUTS.map((row) => row.id)).toEqual([ + 'clock', + 'speed', + 'mode', + 'distance', + ]); + NAVAL_DRONE_READOUTS.forEach((row) => expect(row.labelKey).toBe(`readouts.${row.id}`)); + expect(NAVAL_DRONE_READOUTS.map((row) => row.numeric)).toEqual([true, true, false, true]); + }); + + it('tints the hull mode with its tone', () => { + const tone = readout('mode').tone; + expect(tone?.(createNavalDroneStore({ phase: 0 }).getState())).toBe(THEME.floating); + expect(tone?.(createNavalDroneStore({ phase: 28 }).getState())).toBe(THEME.hump); + expect(tone?.(createNavalDroneStore({ phase: 60 }).getState())).toBe(THEME.planing); + expect(tone?.(createNavalDroneStore({ phase: 60, trialKnots: 4 }).getState())).toBe( + THEME.floating, + ); + }); + + it('meters nothing', () => { + NAVAL_DRONE_READOUTS.forEach((row) => expect(row.meter, row.id).toBeUndefined()); + }); +}); diff --git a/explainers/naval-drone/src/ui/readouts.ts b/explainers/naval-drone/src/ui/readouts.ts new file mode 100644 index 00000000..06e70bd5 --- /dev/null +++ b/explainers/naval-drone/src/ui/readouts.ts @@ -0,0 +1,33 @@ +import type { Readout } from '@core/explainer'; +import { runAt } from '../state'; +import type { NavalDroneStoreState } from '../state'; +import { formatBoatSpeed, formatDistance, formatMode, formatPhase } from './format'; +import { MODE_TONES } from './palette'; + +export const NAVAL_DRONE_READOUTS: readonly Readout[] = [ + { + id: 'clock', + labelKey: 'readouts.clock', + numeric: true, + value: (state) => formatPhase(state.phase), + }, + { + id: 'speed', + labelKey: 'readouts.speed', + numeric: true, + value: (state) => formatBoatSpeed(runAt(state).boat.knots), + }, + { + id: 'mode', + labelKey: 'readouts.mode', + numeric: false, + value: (state) => formatMode(runAt(state).planing.mode), + tone: (state) => MODE_TONES[runAt(state).planing.mode], + }, + { + id: 'distance', + labelKey: 'readouts.distance', + numeric: true, + value: (state) => formatDistance(runAt(state).distanceToShip), + }, +]; diff --git a/explainers/naval-drone/src/ui/testing.ts b/explainers/naval-drone/src/ui/testing.ts new file mode 100644 index 00000000..b8e1cc66 --- /dev/null +++ b/explainers/naval-drone/src/ui/testing.ts @@ -0,0 +1,9 @@ +export function fill(template: string, values: Readonly>): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => values[name] ?? ''); +} + +const PLACEHOLDER = /\{\{|\}\}/; + +export function isFilled(text: string | null | undefined): boolean { + return Boolean(text) && !PLACEHOLDER.test(text ?? ''); +} diff --git a/scripts/cards/naval-drone.html b/scripts/cards/naval-drone.html new file mode 100644 index 00000000..48a1bde9 --- /dev/null +++ b/scripts/cards/naval-drone.html @@ -0,0 +1,163 @@ + + + + + How a naval drone works: social card + + + + + + +
+ +
+
+

+ + Naval drone +

+

How a naval drone works

+

+ A boat with nobody on board: it skims on its hull, drives with a water pump and is steered + by satellite. +

+
    +
  1. Boat
  2. +
  3. Hull
  4. +
  5. Jet
  6. +
  7. Link
  8. +
  9. Horizon
  10. +
  11. Fleet
  12. +
+
+

whatmakesittick.github.io/naval-drone

+ + +