diff --git a/explainers/black-hole/src/scene/cameraViews.test.ts b/explainers/black-hole/src/scene/cameraViews.test.ts index 7712e52b..fa241bc1 100644 --- a/explainers/black-hole/src/scene/cameraViews.test.ts +++ b/explainers/black-hole/src/scene/cameraViews.test.ts @@ -38,7 +38,7 @@ describe('camera views', () => { it('sits the probe camera 2.6 units from the probe and follows it', () => { const { spec, pose } = customPose('probe', ANCHORS); - expect(spec.follow).toBe(true); + expect(spec.follow).toBe('position'); expect(spec.distance).toEqual(PROBE_DISTANCE); expect(pose?.target).toEqual(ANCHORS.probe); expect(pose?.position.distanceTo(ANCHORS.probe)).toBeCloseTo(2.6, 5); diff --git a/explainers/black-hole/src/scene/cameraViews.ts b/explainers/black-hole/src/scene/cameraViews.ts index 8170773c..02fc8676 100644 --- a/explainers/black-hole/src/scene/cameraViews.ts +++ b/explainers/black-hole/src/scene/cameraViews.ts @@ -51,7 +51,7 @@ export function cameraViews( ): Readonly>> { return { ...FRAMED_VIEWS, - probe: customView(anchors, probePose, { follow: true, distance: PROBE_DISTANCE }), + probe: customView(anchors, probePose, { follow: 'position', distance: PROBE_DISTANCE }), ship: customView(anchors, shipPose, { distance: SHIP_DISTANCE }), }; } diff --git a/explainers/black-hole/src/scene/controller.test.ts b/explainers/black-hole/src/scene/controller.test.ts index 722740b1..5247e35a 100644 --- a/explainers/black-hole/src/scene/controller.test.ts +++ b/explainers/black-hole/src/scene/controller.test.ts @@ -103,10 +103,11 @@ describe('black hole controller', () => { controller.views.frame('probe', false); expect(rig.follow).toHaveBeenLastCalledWith( expect.objectContaining({ position: expect.anything() }), + 'position', ); expect(rig.jumpTo.mock.lastCall?.[0]?.target).toEqual(new Vector3(4, 3, 0)); controller.views.frame('ship', false); - expect(rig.follow).toHaveBeenLastCalledWith(null); + expect(rig.follow).toHaveBeenLastCalledWith(null, undefined); expect(rig.jumpTo.mock.lastCall?.[0]?.position).toEqual(new Vector3(16, 12, 0)); }); diff --git a/explainers/fpv-drone/chapters.html b/explainers/fpv-drone/chapters.html new file mode 100644 index 00000000..6c7fb4b8 --- /dev/null +++ b/explainers/fpv-drone/chapters.html @@ -0,0 +1,632 @@ +
+

01

+

+ Four motors, a battery and a pair of goggles +

+

+ You are looking at an FPV drone: a small quadcopter flown by a pilot who wears + goggles and sees through its camera. FPV stands for first-person view. This one is a 7-inch + long-range model, named after its propellers: each is about 7.5 inches across, about 190 mm. The + frame is carbon fibre, two plates and four arms. Without a battery it weighs + 630 to 705 g; with one it comes to about 1 kg. A hobby version costs USD 550 to 950 without + radio and goggles. Built in bulk for an army, the airframe comes to about USD 400 to 500. +

+

+ Drones like this fly in large numbers in the war in Ukraine. Ukraine's defence ministry bought + more than 1.5 million drones in 2024 and planned to buy 4.5 million FPV drones in 2025. In + practice they work about 10 to 20 km from the pilot. Scout teams fly them to look at roads, + fields and treelines, and that is what the drone in the model does: its payload is a + camera, the pilot's eye. +

+

+ The model follows one short reconnaissance sortie of 80 seconds. The pilot arms the drone beside + a dugout, lifts off from a pad, climbs to 40 m and cruises out at 70 km/h. Over a crossroads 350 + m away the drone circles one and a half times at 50 m, then flies home and lands on the pad. The + distances are real metres, but a real sortie would be far longer. The drone is drawn ten times + too big so you can still see it. Its speeds, heights and the battery it uses are the model's own + choices, inside the ranges a real 7-inch quad flies. +

+

+ The clock at the top counts real seconds since arming, so T+0:42 means 42 seconds into the + flight. Drag the timeline to any moment, or tap a chip below to jump there. Watch the readouts + as you go: speed, height, the battery voltage and how far the drone is from the pilot. The + radio links switch draws the two beams between the drone and the + pilot's station. The flight track switch draws the path behind + the drone. The spin arrows switch shows which way each propeller turns, with + its motor number. +

+

+ + + + + +

+
+
+
Battery left
+
25.2 V, 100 %
+
+
+
Distance from the pilot
+
6 m
+
+
+
Flight flown
+
0 %
+
+
+
+ +
+

02

+

+ No wings, no rudder: it steers with motor speed +

+

+ A quadcopter has no wings, no rudder and no moving control surfaces at all. Every move it makes + is a change in the speed of one or more motors. To hover, the four + propellers together must push up with exactly the drone's weight. Each motor + can give up to about 2 kg of thrust, so a 1.4 kg drone hovers on a small part of what it has. + Thrust grows with the square of the propeller speed: double the speed and you get four times the + push. +

+

+ Look at the spin direction arrows: two propellers turn clockwise and two turn + counter-clockwise, in diagonal pairs. There is a reason. A spinning propeller twists back on the + drone. If all four spun the same way, the body would spin the other way. With two turning each + way, the twists cancel. That also gives the pilot a way to turn: speed up one pair and slow the + other, and the twists no longer cancel. The drone turns about its vertical axis, the opposite + way to the faster pair. Pilots call this yaw. +

+

+ To tilt, the drone speeds up one side and slows the other. Speed up the two rear motors and the + nose dips; that is pitch. Speed up the left pair and the drone leans right; that is roll. The + total thrust stays the same, but now it points at an angle, so part of it pushes sideways. That + is how a quad moves: it tilts, and the tilted thrust drags it along. Tap the chips to see which + motors speed up for each move. The rear motors are numbers 1 and 3, as the flight control + software Betaflight counts them. +

+

+ The slider shows what a tilt does. Tilt the drone 30 degrees and it pushes sideways with about + 0.58 of gravity, close to 5.7 m/s². To hold its height at the same time it needs about 15 % more + thrust than in a hover, because only part of the thrust still points up. At 45 degrees the push + equals gravity and the thrust must rise by 41 %. Freestyle quads carry about four to five times + their weight in thrust, so they hover at about 20 to 25 % throttle and keep plenty for a climb + or a sprint. Long-range quads carry less because a bigger battery weighs them down: this one has + about 5.7 to 1, and heavy-lift builds go as low as 2 to 1. +

+

+ + + + + +

+
+
+
Motor shares
+
+ All four motors share the load evenly: motor 1 at 18 %, motor 2 at 18 %, motor 3 at 18 %, + motor 4 at 18 % of maximum thrust +
+
+
+
Why it works
+
+ The four thrusts add up to exactly the drone's weight, and the two clockwise propellers + cancel the twist of the two counter-clockwise ones, so nothing turns. +
+
+
+
+
+ + 30° +
+ +
+
+
Sideways push
+
5.7 m/s², 0.58 g
+
+
+
Thrust needed
+
115 % of hover
+
+
+
0 to 100 km/h
+
4.9 s
+
+
+
+
+ +
+

03

+

+ A thousand corrections a second +

+

+ A quad cannot balance on its own, and no human can react fast enough to balance it by hand. The + flight controller, a small board in the stack between the plates, does it. Its + gyroscope measures how fast the drone is rotating about each axis 8,000 times a second. A + control loop called PID compares that with what the pilot asks for and corrects the four motor + speeds, 4,000 or 8,000 times a second. P pushes toward the asked-for rate, I removes slow drift, + D damps the fast wobbles. Several thousand corrections a second, every second of the flight. +

+

+ Under the flight controller sits the ESC, the electronic speed controller: four + in one board, rated 50 to 55 A per motor. It turns each new throttle value into timed pulses for + the three windings of its motor. The two boards talk over DShot, a digital protocol: one 16-bit + word per motor, 11 bits of throttle, a telemetry bit and a checksum, sent in 27 microseconds at + 600 kilobits a second. With bidirectional DShot the ESC also reports the real motor speed back, + so the controller can filter out the vibration each propeller makes. +

+

+ What do the sticks command? In acro mode, the default, each stick commands a + rotation rate. Push the roll stick halfway and the drone rolls at a steady rate; centre it and + the drone stops rolling and holds the angle it has reached, even upside down. By default, full + stick asks for about 670 degrees a second, almost two full rolls. Angle mode is for beginners: + the stick sets a tilt angle, and the drone levels itself when you let go. Most FPV pilots fly + acro, from racers to freestyle pilots: it gives full control in wind and lets the drone take any + attitude. Tap the chips to compare the three modes. +

+

+ Two safety features matter. A switch on the radio arms the drone, which starts + the motors and the stabilisation; the controller refuses to arm if the throttle is up or the + drone is tilted too far. If the radio packets stop, the failsafe waits 1.5 seconds, then by + default cuts the motors and the drone drops. The GPS module on its mast is + optional. Betaflight flies without it. With it, GPS Rescue can fly the drone back toward where + it was armed when the link is lost, but it is an emergency aid, not a return to home: the pilot + must take back control, or it crashes. +

+

+ + + +

+
+
+
What the stick commands
+
+ A rotation rate: how fast to roll or pitch. Centre the stick and the drone holds whatever + angle it has reached +
+
+
+
Limit
+
+ No tilt limit. Full stick asks for about 670 degrees a second by default +
+
+
+

+ How a stick move reaches the propellers +

+
    +
  1. + Radio: the pilot moves a stick; the radio reads it and sends a packet, 250 + times a second in the model. +
  2. +
  3. + Receiver antenna: the receiver on the drone decodes the packet and hands the + stick values to the flight controller. +
  4. +
  5. + Gyro: the flight controller reads how fast the drone is turning, 8,000 times + a second. +
  6. +
  7. + PID loop: it compares the asked-for rate with the measured one and works out + four new throttle values. +
  8. +
  9. + ESC: each value goes out as a 16-bit DShot word in 27 microseconds, and the + ESC times the pulses to the motor. +
  10. +
  11. + Propellers: the motor speeds change, the thrusts change, and the drone tilts, + turns or climbs. +
  12. +
+
+ +
+

04

+ +

+ Two radio links run between the pilot's station and the drone. The + control link carries the stick positions out. In the model it is ExpressLRS, an + open-source system that works on 900 MHz or 2.4 GHz. It sends small packets at a fixed rate, 50 + to 1,000 times a second on 2.4 GHz. Slower packets reach farther: the receiver can listen longer + for each one and still decode a weaker signal. At 50 Hz it hears signals down to −115 dBm, at + 500 Hz only down to −105 dBm. That difference of 10 dB is about 3.2 times the reach in open air. + Flown at a low rate, in line of sight from a height, a single 2.4 GHz link has reached over 100 + km. +

+

+ The video link comes back the other way, from the + video antenna at the rear. Analogue video on 5.8 GHz is the classic choice: 40 + channels in five bands, a standard-definition picture that fades gracefully into snow as the + drone gets far away, and a delay of about 20 to 30 ms from camera to eye. Digital systems send a + 1080p picture at 100 frames a second with a delay of about 20 ms, and the best units are rated + for 10 to 15 km. But good analogue is still the lowest-latency option, and it degrades gently + instead of freezing. Tap the chips to compare. +

+

+ Both links use circular polarised antennas: the wave turns as it travels. A reflection off the + ground or a wall flips the turning direction, so the antenna rejects most of the bounced signal + that would otherwise smear the picture. The goggles put two small 1080p screens in front of the + pilot's eyes, with a field of view of 44 to 46 degrees. A field team usually adds a second + screen so a navigator can watch too, puts the radio antenna on a mast, and sometimes flies a + second drone high up as a repeater to carry the links over a ridge. Some FPV drones trade both + radios for a thin optical fibre unwound from a spool of 10 to 20 km: with no radio link, there + is nothing to jam. +

+

+ Use the second chip row to change the packet rate and watch three numbers move: the time between + packets, the weakest signal the receiver still hears and the reach compared with 500 Hz. The + readouts below follow the model: the distance from the pilot and how strong the control link is. + In this short sortie the drone never gets farther than about 400 m, so the link stays strong. + Real teams work at 10 to 20 km, where the packet rate, the antennas and a clear line of sight + decide whether the picture holds. Radio power is the last lever, not the first. +

+

+ + +

+
+
+
Video delay
+
About 20 to 30 ms
+
+
+
Picture
+
+ Standard definition, 40 channels to choose from, fades to snow at range +
+
+
+

+ + + + +

+
+
+
Time between packets
+
4 ms
+
+
+
Weakest signal it still hears
+
−108 dBm
+
+
+
Reach compared with 500 Hz
+
1.4 times
+
+
+
+
+
Distance now
+
224 m
+
+
+
Control link
+
Strong
+
+
+
+ +
+

05

+

Twelve minutes in the air

+

+ The battery on the top plate is a 6S pack: six lithium cells in series. With + LiPo cells that is 22.2 V nominal and 25.2 V full. The Li-ion pack in the model is 21.6 V + nominal and 25.2 V full. Pilots land at about 3.5 V a cell, 21 V for the pack, and never go + below 3 V a cell, where the cells take damage. This pack holds 4,200 mAh, about 91 Wh, and + weighs about 450 g. A battery's C rating says how much current it can give as a multiple of its + capacity: a 1,800 mAh LiPo rated 95C could in theory deliver 171 A, while a Li-ion cell manages + 30 to 45 A. +

+

+ Why Li-ion for long range? Energy per kilogram. A 6S 1,800 mAh LiPo stores 40 Wh in 288 g, about + 139 Wh/kg. The Li-ion pack stores 91 Wh in 450 g, about 200 Wh/kg, roughly 45 % more. The price + is current: Li-ion cells cannot deliver the bursts a racing quad wants, which is fine for a + scout that cruises. And cruising is where the energy goes furthest. Hovering costs more power + than flying forward at a moderate speed, because propellers work better in moving air. + Long-range pilots plan routes that keep the drone moving and hover as little as possible. +

+

+ Drag the slider to load the drone. Each extra 100 g raises the all-up weight, lowers the + thrust-to-weight ratio, raises the throttle needed to hover and cuts the flight time. The model + takes 80 % of the pack's energy as usable and divides it by the power needed to cruise at that + weight, which gives about 12 minutes with the camera on board. A bigger battery does not fix + this for long: more battery means more weight, which means more power to carry it, so each extra + pack gives less than the last. Real 7-inch long-range quads fly 12 to 30 minutes; a 5-inch + freestyle quad on a small LiPo manages about 4 to 6. +

+

+ Watch the voltage during the flight. It sags under load, because current through the pack's + internal resistance costs volts, and it recovers when the drone eases off: a 6S can show 21 V in + a hard climb and 23 V in a hover. Cold makes this worse. At minus 20 °C a lithium cell keeps + only about 65 % of its capacity, and its resistance rises, so the low-voltage warning comes + early. Pilots keep their packs warm until launch. The readouts below show the pack's voltage and + current as the model flies the sortie, and the battery gauge at the top fills from them. +

+
+
+ + 300 g +
+ +
+
+
All-up weight
+
1,400 g
+
+
+
Thrust to weight
+
5.7 to 1
+
+
+
Throttle to hover
+
18 % of maximum thrust, about 42 % of top motor speed
+
+
+
Flight time
+
12.3 min
+
+
+
+
+
+
Pack voltage now
+
23.9 V
+
+
+
Current now
+
17.6 A
+
+
+
+ +
+

06

+

Fast, cheap and everywhere

+

+ You are now looking through the pilot's goggles: this is the picture from the drone's own camera + on its way home. How fast can it go? A 7-inch long-range quad like this one tops out at about + 140 km/h and cruises at 60 to 80 km/h. Racing quads are quicker: the Drone Racing League's + RacerX set an official 263 km/h in 2017. The record is in another world. On 11 December 2025 the + Peregreen V4, built by Luke and Mike Bell in South Africa, set a Guinness record of 657.59 km/h + for a battery-powered quadcopter. It is a purpose-built machine, nothing like a typical FPV + drone. Tap the chips to see how long each would take to cover the 350 m to the + crossroads. +

+

+ Wind is the real limit in the field. A rule of thumb says to fly only when the wind is below two + thirds of the drone's top speed, and quads this size handle about 32 to 40 km/h of steady wind. + Every gust costs battery, because the drone has to tilt into it and the tilted thrust takes more + power. This is also where an FPV quad differs from a camera drone such as a Mavic. A Mavic holds + its position with GPS and a barometer when you let go of the sticks. An FPV quad in acro holds + only its attitude and drifts with the wind. Nothing stops it but the pilot. +

+

+ That makes it hard to learn. Most new pilots crash repeatedly in their first hours, so they + start in a simulator: 20 to 60 hours in a program such as Velocidrone or Liftoff before the + first real flight is common advice. The first pilots to fly with goggles did so around 2007, as + a hobby, and racing followed. The first world championship was flown in Shenzhen in 2018 by 128 + pilots from 34 countries, and a 15-year-old won it. +

+

+ Cheap and simple is why these drones are everywhere. A hobby quad costs USD 550 to 950 without + radio and goggles; a frame kit alone is about USD 130 to 150. Built by the thousand for an army, + the airframe comes to about USD 400 to 500, and Ukraine planned to buy 4.5 million FPV drones in + 2025. In practice they work about 10 to 20 km from the pilot. To reach farther, teams fly a + repeater on a second drone: one Ukrainian system rides a larger drone at 500 m, stays up for an + hour and relays signals up to 30 km. The pilot's station in the model is far + simpler: a dugout, a case, a mast with an antenna, and a pilot in goggles. +

+

+ + + +

+
+
+
Top speed
+
140 km/h
+
+
+
Time over the 350 m to the crossroads
+
9 s
+
+
+
What it is
+
+ A 7-inch long-range quad like the one in the model: about 140 km/h flat out, 60 to 80 km/h + in cruise +
+
+
+
diff --git a/explainers/fpv-drone/explainer.json b/explainers/fpv-drone/explainer.json new file mode 100644 index 00000000..b628ed85 --- /dev/null +++ b/explainers/fpv-drone/explainer.json @@ -0,0 +1,12 @@ +{ + "slug": "fpv-drone", + "tags": ["aircraft", "flight", "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 camouflaged FPV quadcopter banking low over a field under a grey sky, its four propellers blurred and a radio beam reaching back to a pilot's dugout, beside the title How an FPV drone flies" + } +} diff --git a/explainers/fpv-drone/locales/de.json b/explainers/fpv-drone/locales/de.json new file mode 100644 index 00000000..3b1db0f0 --- /dev/null +++ b/explainers/fpv-drone/locales/de.json @@ -0,0 +1,290 @@ +{ + "meta": { + "title": "So fliegt eine FPV-Drohne", + "eyebrow": "FPV-Drohne", + "tagline": "Vier Motoren, eine Kamera und eine Videobrille: Der Pilot fliegt wie aus der Drohne heraus, und jede Bewegung ist nur eine Änderung der Motordrehzahl.", + "description": "Begleiten Sie eine 7-Zoll-FPV-Drohne in 3D auf einem kurzen Aufklärungsflug: vier Motoren ohne Ruder, Regelkreis, zwei Funkstrecken, Akku und Grenzen.", + "summary": "Vier Motoren und kein Ruder, ein Flugcontroller, der tausende Male pro Sekunde nachregelt, zwei Funkstrecken, der Akku, der die Flugzeit bestimmt, und die Grenzen von Tempo, Wind und Reichweite.", + "socialAlt": "Ein getarnter FPV-Quadcopter legt sich unter grauem Himmel tief über einem Feld in die Kurve, seine vier Propeller sind verwischt, und ein Funkstrahl reicht zurück zum Unterstand des Piloten. Daneben steht der Titel „So fliegt eine FPV-Drohne“" + }, + "stage": { + "label": "Interaktives Modell einer FPV-Drohne", + "gauge": "Live-Flugdaten" + }, + "controls": { + "label": "Steuerung des Modells", + "views": { + "links": { + "name": "Funkverbindungen", + "short": "Funk", + "hint": "Funkverbindungen (K)" + }, + "track": { + "name": "Flugspur", + "short": "Spur", + "hint": "Flugspur (T)" + }, + "arrows": { + "name": "Drehrichtung", + "short": "Pfeile", + "hint": "Drehrichtung (A)" + }, + "labels": { + "name": "Beschriftungen", + "short": "Namen", + "hint": "Beschriftungen (L)" + } + }, + "video": { + "label": "Video" + }, + "videoOptions": { + "analogue": "Analog", + "digital": "Digital" + }, + "momentOptions": { + "liftoff": "Abheben", + "cruise": "Reiseflug", + "onStation": "Über der Kreuzung", + "turnHome": "Kurve nach Hause", + "touchdown": "Aufsetzen" + }, + "moveOptions": { + "hover": "Schweben", + "forward": "Vorwärts", + "roll": "Rollen nach rechts", + "yaw": "Drehen nach rechts", + "climb": "Steigen" + }, + "modeOptions": { + "acro": "Acro", + "angle": "Angle", + "horizon": "Horizon" + }, + "packetRateOptions": { + "50": "50 Hz", + "150": "150 Hz", + "250": "250 Hz", + "500": "500 Hz" + }, + "speedsterOptions": { + "longRange": "Long-Range-Quad", + "racer": "Renn-Quad", + "record": "Rekordhalter" + } + }, + "timeline": { + "label": "Flugzeit", + "phases": "Zu einem Abschnitt des Flugs springen", + "speed": "Wiedergabetempo", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "im Vergleich zur Echtzeit", + "phase": { + "takeoff": "Start", + "climb": "Steigflug", + "transit": "Hinflug zur Kreuzung", + "orbit": "Über der Kreuzung", + "return": "Heimflug", + "landing": "Landung" + }, + "during": { + "takeoff": "der Pilot gibt die Motoren frei, die Propeller laufen an, und die Drohne hebt vom Pad in einen niedrigen Schwebeflug ab", + "climb": "die Drohne neigt die Nase, beschleunigt auf 70 km/h und steigt auf 40 m", + "transit": "die Drohne fliegt mit 70 km/h geradeaus über das Feld", + "orbit": "die Drohne kreist anderthalbmal in 50 m Höhe um die Kreuzung, die Kamera blickt nach innen", + "return": "die Drohne fliegt mit 70 km/h heim und richtet sich mit einer S-Kurve auf das Pad aus", + "landing": "die Drohne wird langsamer, sinkt, schwebt über dem Pad und setzt auf" + }, + "jump": { + "takeoff": "Zum Start springen", + "climb": "Zum Steigflug springen", + "transit": "Zum Hinflug springen", + "orbit": "Zu den Kreisen über der Kreuzung springen", + "return": "Zum Heimflug springen", + "landing": "Zur Landung springen" + } + }, + "readouts": { + "clock": "Flugzeit", + "speed": "Tempo", + "height": "Höhe", + "battery": "Akku", + "distance": "Zum Piloten" + }, + "units": { + "kmh": "{{value}} km/h", + "m": "{{value}} m", + "g": "{{value}} g", + "kg": "{{value}} kg", + "percent": "{{value}} %", + "ofHover": "{{percent}} % des Schwebeschubs", + "volts": "{{value}} V", + "amps": "{{value}} A", + "ms": "{{value}} ms", + "dbm": "{{value}} dBm", + "minutes": "{{value}} min", + "seconds": "{{value}} s", + "degrees": "{{value}}°", + "ms2": "{{value}} m/s²", + "times": "{{factor}}-mal", + "battery": "{{volts}} V, {{share}} %", + "push": "{{ms2}} m/s², {{g}} g", + "ratio": "{{ratio}} zu 1", + "hover": "{{thrust}} % des maximalen Schubs, etwa {{speed}} % der Höchstdrehzahl" + }, + "parts": { + "frame": "Rahmen", + "motorFrontLeft": "Motor 4 (vorn links)", + "motorFrontRight": "Motor 2 (vorn rechts)", + "motorRearLeft": "Motor 3 (hinten links)", + "motorRearRight": "Motor 1 (hinten rechts)", + "propellers": "Propeller", + "battery": "Akku", + "stack": "Flugcontroller und ESC", + "camera": "Kamera", + "videoAntenna": "Videoantenne", + "receiverAntenna": "Empfängerantenne", + "gpsModule": "GPS-Modul", + "groundStation": "Bodenstation des Piloten", + "controlLink": "Steuerfunk", + "videoLink": "Videofunk", + "spinArrows": "Drehrichtung", + "crossroads": "Kreuzung", + "launchPad": "Startpad" + }, + "flight": { + "mix": { + "hover": "Alle vier Motoren teilen sich die Last gleichmäßig: Motor 1 mit {{m1}} %, Motor 2 mit {{m2}} %, Motor 3 mit {{m3}} %, Motor 4 mit {{m4}} % des maximalen Schubs", + "forward": "Das hintere Paar dreht schneller, das vordere langsamer: Motor 1 mit {{m1}} %, Motor 2 mit {{m2}} %, Motor 3 mit {{m3}} %, Motor 4 mit {{m4}} % des maximalen Schubs", + "roll": "Das linke Paar dreht schneller, das rechte langsamer: Motor 1 mit {{m1}} %, Motor 2 mit {{m2}} %, Motor 3 mit {{m3}} %, Motor 4 mit {{m4}} % des maximalen Schubs", + "yaw": "Die beiden Motoren gegen den Uhrzeigersinn drehen schneller, die beiden im Uhrzeigersinn langsamer: Motor 1 mit {{m1}} %, Motor 2 mit {{m2}} %, Motor 3 mit {{m3}} %, Motor 4 mit {{m4}} % des maximalen Schubs", + "climb": "Alle vier Motoren drehen gemeinsam schneller: Motor 1 mit {{m1}} %, Motor 2 mit {{m2}} %, Motor 3 mit {{m3}} %, Motor 4 mit {{m4}} % des maximalen Schubs" + }, + "why": { + "hover": "Die vier Schübe ergeben zusammen genau das Gewicht der Drohne, und die beiden Propeller im Uhrzeigersinn heben das Drehmoment der beiden anderen auf. Nichts dreht sich.", + "forward": "Hinten mehr Schub als vorn kippt die Nase nach unten. Ein Teil des Schubs zeigt jetzt nach vorn, also beschleunigt die Drohne.", + "roll": "Links mehr Schub als rechts drückt die rechte Seite nach unten. Ein Teil des Schubs zeigt jetzt nach rechts, also rutscht die Drohne dorthin.", + "yaw": "Der Gesamtschub bleibt gleich, aber die Drehmomente heben sich nicht mehr auf. Der Rumpf dreht sich im Uhrzeigersinn, entgegen den schnelleren Propellern.", + "climb": "Der Schub ist jetzt größer als das Gewicht, also beschleunigt die Drohne nach oben. Nichts kippt, weil sich alle vier Motoren gleich stark verändert haben." + } + }, + "mode": { + "stick": { + "acro": "Eine Drehrate: wie schnell die Drohne rollt oder nickt. Knüppel in die Mitte, und sie hält den Winkel, den sie gerade erreicht hat", + "angle": "Einen Neigungswinkel: wie weit sich die Drohne aus der Waagerechten legt. Knüppel in die Mitte, und sie richtet sich selbst auf", + "horizon": "Nahe der Mitte einen Neigungswinkel, bei vollem Ausschlag eine Drehrate: Die Drohne richtet sich auf, kann aber noch einen Flip fliegen" + }, + "limit": { + "acro": "Keine Neigungsgrenze. Voller Ausschlag fordert standardmäßig etwa {{rate}} Grad pro Sekunde", + "angle": "Eine feste Neigungsgrenze, die auch das Tempo begrenzt. Keine Flips, keine Rollen", + "horizon": "Keine Grenze bei vollem Ausschlag, eine Neigungsgrenze nahe der Mitte" + } + }, + "link": { + "latency": { + "analogue": "Etwa 20 bis 30 ms", + "digital": "Etwa 20 ms, bei den besten Geräten 15 ms" + }, + "picture": { + "analogue": "Standardauflösung, 40 Kanäle zur Auswahl, verrauscht mit der Entfernung", + "digital": "1080p mit 100 Bildern pro Sekunde, scharf bis zum Abbruch, etwa 10 km Reichweite" + }, + "signal": { + "strong": "Stark", + "good": "Gut", + "weak": "Schwach", + "lost": "Verloren" + } + }, + "speeds": { + "who": { + "longRange": "Ein 7-Zoll-Long-Range-Quad wie der im Modell: etwa 140 km/h Spitze, 60 bis 80 km/h im Reiseflug", + "racer": "Der RacerX der Drone Racing League von 2017, ein Renn-Quad für einen Rekord: offiziell 263 km/h", + "record": "Peregreen V4, Guinness-Rekordhalter seit dem 11. Dezember 2025: eine eigens gebaute Rekordmaschine, mit einer typischen FPV-Drohne nicht zu vergleichen" + } + }, + "sections": { + "overview": { + "title": "Vier Motoren, ein Akku und eine Videobrille", + "p1": "Hier sehen Sie eine FPV-Drohne: einen kleinen Quadcopter, den ein Pilot mit Videobrille steuert, der durch die Kamera der Drohne blickt. FPV steht für „First Person View“, also die Ich-Perspektive. Dieses Exemplar ist ein 7-Zoll-Modell für große Reichweiten. Der Name kommt von den Propellern: Jeder misst etwa 7,5 Zoll, also rund 190 mm. Der Rahmen besteht aus zwei Platten und vier Armen aus Kohlefaser. Ohne Akku wiegt die Drohne 630 bis 705 g, mit Akku kommt sie auf etwa 1 kg. Eine Hobbyversion kostet 550 bis 950 US-Dollar ohne Fernsteuerung und Brille. In großen Stückzahlen für eine Armee gebaut, kostet die Drohne selbst etwa 400 bis 500 US-Dollar.", + "p2": "Drohnen wie diese fliegen in großer Zahl im Krieg in der Ukraine. Das ukrainische Verteidigungsministerium kaufte 2024 mehr als 1,5 Millionen Drohnen und plante für 2025 den Kauf von 4,5 Millionen FPV-Drohnen. In der Praxis fliegen sie etwa 10 bis 20 km vom Piloten entfernt. Aufklärungsteams fliegen mit ihnen Straßen, Felder und Baumreihen ab. Genau das tut die Drohne im Modell: Ihre Nutzlast ist eine Kamera, das Auge des Piloten.", + "p3": "Das Modell zeigt einen kurzen Aufklärungsflug von 80 Sekunden. Neben einem Unterstand gibt der Pilot die Motoren frei, die Drohne hebt von einem Pad ab, steigt auf 40 m und fliegt mit 70 km/h hinaus. Über einer Kreuzung in 350 m Entfernung kreist sie anderthalbmal in 50 m Höhe, dann fliegt sie heim und landet auf dem Pad. Die Entfernungen sind echte Meter, aber ein echter Flug wäre viel länger. Die Drohne ist zehnmal zu groß gezeichnet, damit Sie sie noch sehen. Tempo, Flughöhen und Akku hat das Modell selbst gewählt, innerhalb der Werte, mit denen ein echter 7-Zoll-Quad fliegt.", + "p4": "Die Uhr oben zählt echte Sekunden seit der Freigabe der Motoren. T+0:42 heißt also: 42 Sekunden Flug. Ziehen Sie die Zeitleiste an eine beliebige Stelle, oder tippen Sie unten auf eine Schaltfläche, um dorthin zu springen. Behalten Sie dabei die Anzeigen im Blick: Tempo, Höhe, Akkuspannung und die Entfernung zwischen Drohne und Pilot. Der Schalter Funkverbindungen zeichnet die beiden Strahlen zwischen der Drohne und der Bodenstation des Piloten. Der Schalter Flugspur zeichnet den Weg hinter der Drohne. Der Schalter Drehrichtung zeigt, in welche Richtung sich jeder Propeller dreht, mit der Nummer seines Motors.", + "battery": "Akku übrig", + "distance": "Entfernung zum Piloten", + "done": "Bisher geflogen" + }, + "flight": { + "title": "Keine Flügel, kein Ruder: Gesteuert wird mit Drehzahl", + "p1": "Ein Quadcopter hat keine Flügel, kein Ruder und überhaupt keine beweglichen Steuerflächen. Jede Bewegung ist eine Änderung der Drehzahl eines oder mehrerer Motoren. Zum Schweben müssen die vier Propeller zusammen genau so stark nach oben drücken, wie die Drohne wiegt. Jeder Motor liefert bis zu etwa 2 kg Schub. Eine 1,4 kg schwere Drohne schwebt also mit einem kleinen Teil ihrer Kraft. Der Schub wächst mit dem Quadrat der Drehzahl: doppelte Drehzahl, vierfacher Schub.", + "p2": "Sehen Sie sich die Pfeile der Drehrichtung an: Zwei Propeller drehen im Uhrzeigersinn, zwei dagegen, jeweils diagonal gegenüber. Das hat einen Grund. Ein drehender Propeller will die Drohne in die Gegenrichtung drehen. Drehten alle vier gleich herum, würde sich der Rumpf andersherum drehen. Mit zwei pro Richtung heben sich die Drehmomente auf. Das gibt dem Piloten zugleich eine Möglichkeit zum Drehen: Ein Paar schneller, das andere langsamer, und die Drehmomente heben sich nicht mehr auf. Die Drohne dreht sich um ihre Hochachse, entgegen dem schnelleren Paar. Piloten nennen das Gieren oder Yaw.", + "p3": "Zum Neigen dreht die Drohne eine Seite schneller und die andere langsamer. Drehen die beiden hinteren Motoren schneller, senkt sich die Nase: Das ist Nicken. Dreht das linke Paar schneller, legt sich die Drohne nach rechts: Das ist Rollen. Der Gesamtschub bleibt gleich, zeigt aber jetzt schräg, und ein Teil davon drückt zur Seite. So bewegt sich ein Quad: Er neigt sich, und der schräge Schub zieht ihn mit. Tippen Sie auf die Schaltflächen, um zu sehen, welche Motoren für welche Bewegung schneller drehen. Die hinteren Motoren sind die Nummern 1 und 3, so zählt sie die Flugsoftware Betaflight.", + "p4": "Der Regler zeigt, was eine Neigung bewirkt. Neigen Sie die Drohne um 30 Grad, und sie drückt mit etwa dem 0,58-Fachen der Erdbeschleunigung zur Seite, knapp 5,7 m/s². Um dabei die Höhe zu halten, braucht sie etwa 15 % mehr Schub als im Schwebeflug, denn nur noch ein Teil des Schubs zeigt nach oben. Bei 45 Grad entspricht der Schub zur Seite der Erdbeschleunigung, und der Gesamtschub muss um 41 % steigen. Freestyle-Quads haben etwa das Vier- bis Fünffache ihres Gewichts an Schub. Sie schweben bei etwa 20 bis 25 % Gas und behalten viel Reserve zum Steigen oder Sprinten. Long-Range-Quads haben weniger, weil der größere Akku sie schwer macht: Dieser hier kommt auf etwa 5,7 zu 1, und Modelle für schwere Lasten gehen bis auf 2 zu 1 herunter.", + "mix": "Anteile der Motoren", + "why": "Warum das funktioniert", + "tiltLabel": "Neigungswinkel", + "push": "Schub zur Seite", + "thrust": "Nötiger Schub", + "sprint": "0 auf 100 km/h" + }, + "controller": { + "title": "Tausend Korrekturen pro Sekunde", + "p1": "Ein Quad kann sich nicht selbst im Gleichgewicht halten, und kein Mensch reagiert schnell genug, um ihn von Hand auszubalancieren. Das übernimmt der Flugcontroller, eine kleine Platine im Stapel zwischen den Platten. Sein Gyroskop misst 8.000-mal pro Sekunde, wie schnell sich die Drohne um jede Achse dreht. Ein Regelkreis namens PID vergleicht das mit dem, was der Pilot verlangt, und korrigiert die vier Drehzahlen, 4.000- oder 8.000-mal pro Sekunde. P drückt in Richtung der gewünschten Drehrate, I beseitigt langsames Abdriften, D dämpft das schnelle Zittern. Mehrere tausend Korrekturen pro Sekunde, in jeder Sekunde des Flugs.", + "p2": "Unter dem Flugcontroller sitzt der ESC, der elektronische Drehzahlregler: vier Regler auf einer Platine, ausgelegt auf 50 bis 55 A pro Motor. Er macht aus jedem neuen Gaswert zeitlich genau gesetzte Impulse für die drei Wicklungen seines Motors. Die beiden Platinen sprechen über DShot, ein digitales Protokoll: ein 16-Bit-Wort pro Motor, 11 Bit Gas, ein Telemetriebit und eine Prüfsumme, gesendet in 27 Mikrosekunden bei 600 Kilobit pro Sekunde. Mit bidirektionalem DShot meldet der ESC auch die echte Drehzahl zurück, damit der Controller die Vibration jedes Propellers herausfiltern kann.", + "p3": "Was befehlen die Knüppel? Im Acro-Modus, dem Standard, befiehlt jeder Knüppel eine Drehrate. Halber Ausschlag beim Rollen, und die Drohne rollt mit gleichbleibendem Tempo; Knüppel in die Mitte, und sie hört auf zu rollen und hält den erreichten Winkel, auch kopfüber. Standardmäßig fordert voller Ausschlag etwa 670 Grad pro Sekunde, fast zwei ganze Rollen. Der Angle-Modus ist für Anfänger: Der Knüppel gibt einen Neigungswinkel vor, und die Drohne richtet sich auf, sobald Sie loslassen. Die meisten FPV-Piloten fliegen Acro, vom Rennpiloten bis zum Freestyle-Piloten. Der Modus gibt volle Kontrolle im Wind und erlaubt der Drohne jede Lage. Tippen Sie auf die Schaltflächen, um die drei Modi zu vergleichen.", + "p4": "Zwei Sicherheitsfunktionen sind wichtig. Ein Schalter an der Fernsteuerung gibt die Motoren frei, Piloten sagen dazu „Arming“. Dann laufen die Motoren an, und die Stabilisierung arbeitet. Der Controller verweigert die Freigabe, wenn das Gas nicht unten steht oder die Drohne zu schräg liegt. Bleiben die Funkpakete aus, wartet die Failsafe-Funktion 1,5 Sekunden und stellt dann standardmäßig die Motoren ab. Die Drohne fällt. Das GPS-Modul auf seinem Mast ist optional. Betaflight fliegt auch ohne. Mit GPS kann die Funktion GPS Rescue die Drohne bei Funkverlust zurück in Richtung des Startorts fliegen. Das ist aber eine Nothilfe, keine automatische Heimkehr: Der Pilot muss die Steuerung wieder übernehmen, sonst stürzt die Drohne ab.", + "stick": "Was der Knüppel befiehlt", + "limit": "Grenze", + "path": "So kommt eine Knüppelbewegung bei den Propellern an", + "step1": "Fernsteuerung: Der Pilot bewegt einen Knüppel. Die Fernsteuerung liest ihn ab und sendet ein Paket, im Modell 250-mal pro Sekunde.", + "step2": "Empfängerantenne: Der Empfänger an der Drohne wertet das Paket aus und gibt die Knüppelwerte an den Flugcontroller weiter.", + "step3": "Gyroskop: Der Flugcontroller liest 8.000-mal pro Sekunde ab, wie schnell sich die Drohne dreht.", + "step4": "PID-Regelkreis: Er vergleicht die gewünschte Drehrate mit der gemessenen und berechnet vier neue Gaswerte.", + "step5": "ESC: Jeder Wert geht als 16-Bit-DShot-Wort in 27 Mikrosekunden hinaus, und der ESC setzt die Impulse für den Motor.", + "step6": "Propeller: Die Drehzahlen ändern sich, die Schübe ändern sich, und die Drohne neigt sich, dreht oder steigt." + }, + "link": { + "title": "Zwei Funkstrecken: eine zum Steuern, eine zum Sehen", + "p1": "Zwischen der Bodenstation des Piloten und der Drohne laufen zwei Funkstrecken. Der Steuerfunk überträgt die Knüppelstellungen zur Drohne. Im Modell ist das ExpressLRS, ein quelloffenes System, das auf 900 MHz oder 2,4 GHz arbeitet. Es sendet kleine Pakete in festem Takt, auf 2,4 GHz 50- bis 1.000-mal pro Sekunde. Langsamere Pakete reichen weiter: Der Empfänger kann jedem Paket länger zuhören und so ein schwächeres Signal noch auswerten. Bei 50 Hz hört er Signale bis −115 dBm, bei 500 Hz nur bis −105 dBm. Diese 10 dB Unterschied bedeuten im Freien etwa die 3,2-fache Reichweite. Mit niedriger Rate, in Sichtverbindung aus großer Höhe, hat eine einzelne 2,4-GHz-Strecke schon über 100 km geschafft.", + "p2": "Der Videofunk läuft in die Gegenrichtung, von der Videoantenne am Heck. Analoges Video auf 5,8 GHz ist der Klassiker: 40 Kanäle in fünf Bändern, ein Bild in Standardauflösung, das mit wachsender Entfernung sanft im Rauschen versinkt, und eine Verzögerung von etwa 20 bis 30 ms von der Kamera bis zum Auge. Digitale Systeme senden ein 1080p-Bild mit 100 Bildern pro Sekunde und etwa 20 ms Verzögerung, die besten Geräte sind für 10 bis 15 km ausgelegt. Gutes Analogvideo hat aber immer noch die kürzeste Verzögerung, und es wird allmählich schlechter, statt einzufrieren. Tippen Sie auf die Schaltflächen, um zu vergleichen.", + "p3": "Beide Strecken nutzen zirkular polarisierte Antennen: Die Welle dreht sich, während sie unterwegs ist. Eine Reflexion am Boden oder an einer Wand kehrt die Drehrichtung um, und die Antenne weist den größten Teil des zurückgeworfenen Signals ab, das sonst das Bild verschmieren würde. Die Videobrille setzt dem Piloten zwei kleine 1080p-Bildschirme vor die Augen, mit einem Sichtfeld von 44 bis 46 Grad. Ein Team im Feld ergänzt meist einen zweiten Bildschirm, damit ein Navigator mitsehen kann, setzt die Funkantenne auf einen Mast und lässt manchmal eine zweite Drohne hoch oben als Relais fliegen, um die Verbindungen über einen Hügel zu bringen. Manche FPV-Drohnen tauschen beide Funkstrecken gegen eine dünne Glasfaser, die von einer Spule mit 10 bis 20 km abrollt: Ohne Funk gibt es nichts zu stören.", + "p4": "Ändern Sie mit der zweiten Reihe Schaltflächen die Paketrate und beobachten Sie drei Zahlen: die Zeit zwischen den Paketen, das schwächste Signal, das der Empfänger noch hört, und die Reichweite im Vergleich zu 500 Hz. Die Anzeigen darunter folgen dem Modell: die Entfernung zum Piloten und die Stärke des Steuerfunks. Bei diesem kurzen Flug entfernt sich die Drohne nie weiter als etwa 400 m, die Verbindung bleibt also stark. Echte Teams arbeiten auf 10 bis 20 km Entfernung. Dort entscheiden Paketrate, Antennen und freie Sicht, ob das Bild hält. Die Sendeleistung ist der letzte Hebel, nicht der erste.", + "latency": "Videoverzögerung", + "picture": "Bild", + "period": "Zeit zwischen den Paketen", + "sensitivity": "Schwächstes noch hörbares Signal", + "reach": "Reichweite im Vergleich zu 500 Hz", + "distance": "Aktuelle Entfernung", + "signal": "Steuerfunk" + }, + "power": { + "title": "Zwölf Minuten in der Luft", + "p1": "Der Akku auf der oberen Platte ist ein 6S-Pack: sechs Lithiumzellen in Reihe. Mit LiPo-Zellen sind das 22,2 V nominal und 25,2 V voll geladen. Der Li-Ion-Pack im Modell hat 21,6 V nominal und 25,2 V voll geladen. Piloten landen bei etwa 3,5 V pro Zelle, also 21 V für den Pack, und gehen nie unter 3 V pro Zelle, denn dort nehmen die Zellen Schaden. Dieser Pack fasst 4.200 mAh, etwa 91 Wh, und wiegt etwa 450 g. Die C-Rate eines Akkus sagt, wie viel Strom er liefern kann, als Vielfaches seiner Kapazität: Ein 1.800-mAh-LiPo mit 95C könnte theoretisch 171 A liefern, eine Li-Ion-Zelle schafft 30 bis 45 A.", + "p2": "Warum Li-Ion für große Reichweiten? Wegen der Energie pro Kilogramm. Ein 6S-LiPo mit 1.800 mAh speichert 40 Wh in 288 g, etwa 139 Wh/kg. Der Li-Ion-Pack speichert 91 Wh in 450 g, etwa 200 Wh/kg, rund 45 % mehr. Der Preis dafür ist der Strom: Li-Ion-Zellen liefern nicht die Spitzen, die ein Renn-Quad will. Für einen Aufklärer im Reiseflug ist das egal. Und im Reiseflug kommt die Energie am weitesten. Schweben kostet mehr Leistung als mäßig schneller Vorwärtsflug, weil Propeller in bewegter Luft besser arbeiten. Long-Range-Piloten planen ihre Routen so, dass die Drohne in Bewegung bleibt und so wenig wie möglich schwebt.", + "p3": "Ziehen Sie den Regler, um die Drohne zu beladen. Alle weiteren 100 g erhöhen das Gesamtgewicht, senken das Schub-Gewichts-Verhältnis, erhöhen das Gas zum Schweben und verkürzen die Flugzeit. Das Modell rechnet 80 % der Energie des Packs als nutzbar und teilt sie durch die Leistung, die der Reiseflug bei diesem Gewicht braucht. Mit der Kamera an Bord ergibt das etwa 12 Minuten. Ein größerer Akku hilft nur begrenzt: Mehr Akku heißt mehr Gewicht, und mehr Gewicht heißt mehr Leistung, um es zu tragen. Jeder zusätzliche Pack bringt also weniger als der vorige. Echte 7-Zoll-Long-Range-Quads fliegen 12 bis 30 Minuten, ein 5-Zoll-Freestyle-Quad mit kleinem LiPo schafft etwa 4 bis 6.", + "p4": "Beobachten Sie die Spannung während des Flugs. Unter Last sackt sie ab, weil der Strom am Innenwiderstand des Packs Volt kostet, und sie erholt sich, sobald die Drohne das Gas zurücknimmt: Ein 6S kann in einem harten Steigflug 21 V zeigen und im Schwebeflug 23 V. Kälte macht das schlimmer. Bei minus 20 °C behält eine Lithiumzelle nur etwa 65 % ihrer Kapazität, und ihr Widerstand steigt, sodass die Warnung vor niedriger Spannung früh kommt. Piloten halten ihre Packs bis zum Start warm. Die Anzeigen unten zeigen Spannung und Strom des Packs, während das Modell den Flug fliegt, und aus ihnen füllt sich die Akkuanzeige oben.", + "payloadLabel": "Nutzlast", + "weight": "Gesamtgewicht", + "twr": "Schub zu Gewicht", + "hover": "Gas zum Schweben", + "minutes": "Flugzeit", + "volts": "Aktuelle Packspannung", + "amps": "Aktueller Strom" + }, + "limits": { + "title": "Schnell, billig und überall", + "p1": "Sie blicken jetzt durch die Videobrille des Piloten: Das ist das Bild, das die Drohne auf dem Heimflug mit ihrer eigenen Kamera aufnimmt. Wie schnell kann sie fliegen? Ein 7-Zoll-Long-Range-Quad wie dieser schafft etwa 140 km/h Spitze und fliegt mit 60 bis 80 km/h im Reiseflug. Renn-Quads sind schneller: Der RacerX der Drone Racing League erreichte 2017 offiziell 263 km/h. Der Rekord liegt in einer anderen Welt. Am 11. Dezember 2025 stellte die Peregreen V4, gebaut von Luke und Mike Bell aus Südafrika, mit 657,59 km/h den Guinness-Rekord für batteriebetriebene Quadcopter auf. Sie ist eine eigens gebaute Maschine und hat mit einer typischen FPV-Drohne nichts gemein. Tippen Sie auf die Schaltflächen, um zu sehen, wie lange jede für die 350 m bis zur Kreuzung bräuchte.", + "p2": "Die eigentliche Grenze im Feld ist der Wind. Eine Faustregel sagt: Nur fliegen, wenn der Wind unter zwei Dritteln der Höchstgeschwindigkeit der Drohne bleibt. Quads dieser Größe vertragen etwa 32 bis 40 km/h stetigen Wind. Jede Böe kostet Akku, denn die Drohne muss sich gegen sie neigen, und der schräge Schub braucht mehr Leistung. Hier zeigt sich auch der Unterschied zwischen einem FPV-Quad und einer Kameradrohne wie einer Mavic. Eine Mavic hält ihre Position mit GPS und Barometer, sobald Sie die Knüppel loslassen. Ein FPV-Quad im Acro-Modus hält nur seine Lage und treibt mit dem Wind ab. Nichts hält ihn auf außer dem Piloten.", + "p3": "Das macht das Lernen schwer. Die meisten neuen Piloten stürzen in den ersten Stunden immer wieder ab, also beginnen sie im Simulator: 20 bis 60 Stunden in einem Programm wie Velocidrone oder Liftoff vor dem ersten echten Flug sind ein üblicher Rat. Die ersten Piloten flogen um 2007 mit Videobrille, als Hobby, und bald folgten die ersten Rennen. Die erste Weltmeisterschaft fand 2018 in Shenzhen statt, mit 128 Piloten aus 34 Ländern, und ein 15-Jähriger gewann sie.", + "p4": "Billig und einfach, darum sind diese Drohnen überall. Ein Hobby-Quad kostet 550 bis 950 US-Dollar ohne Fernsteuerung und Brille, ein Rahmenbausatz allein etwa 130 bis 150 US-Dollar. Zu Tausenden für eine Armee gebaut, kostet die Drohne selbst etwa 400 bis 500 US-Dollar, und die Ukraine plante für 2025 den Kauf von 4,5 Millionen FPV-Drohnen. In der Praxis fliegen sie etwa 10 bis 20 km vom Piloten entfernt. Um weiter zu kommen, fliegen Teams ein Relais auf einer zweiten Drohne: Ein ukrainisches System sitzt auf einer größeren Drohne in 500 m Höhe, bleibt eine Stunde oben und leitet Signale bis zu 30 km weit. Die Bodenstation des Piloten im Modell ist viel einfacher: ein Unterstand, ein Koffer, ein Mast mit Antenne und ein Pilot mit Videobrille.", + "speed": "Höchstgeschwindigkeit", + "field": "Zeit für die 350 m bis zur Kreuzung", + "who": "Was es ist" + } + }, + "goggles": { + "caption": "Videobrillen-Ansicht" + } +} diff --git a/explainers/fpv-drone/locales/en.json b/explainers/fpv-drone/locales/en.json new file mode 100644 index 00000000..9c66cac8 --- /dev/null +++ b/explainers/fpv-drone/locales/en.json @@ -0,0 +1,290 @@ +{ + "meta": { + "title": "How an FPV drone flies", + "eyebrow": "FPV drone", + "tagline": "Four motors, a camera and a pair of goggles: the pilot flies from inside the drone, one motor speed change at a time.", + "description": "Follow a 7-inch FPV drone in 3D through one short scouting flight: four motors and no rudder, the gyro loop, two radio links, the battery and the limits.", + "summary": "Four motors and no rudder, a flight controller that corrects thousands of times a second, two radio links, the battery that sets the flight time, and the limits of speed, wind and range.", + "socialAlt": "A camouflaged FPV quadcopter banking low over a field under a grey sky, its four propellers blurred and a radio beam reaching back to a pilot's dugout, beside the title How an FPV drone flies" + }, + "stage": { + "label": "Interactive FPV drone model", + "gauge": "Live flight readings" + }, + "controls": { + "label": "FPV drone controls", + "views": { + "links": { + "name": "Radio links", + "short": "Links", + "hint": "Radio links (K)" + }, + "track": { + "name": "Flight track", + "short": "Track", + "hint": "Flight track (T)" + }, + "arrows": { + "name": "Spin arrows", + "short": "Arrows", + "hint": "Spin arrows (A)" + }, + "labels": { + "name": "Labels", + "short": "Labels", + "hint": "Labels (L)" + } + }, + "video": { + "label": "Video" + }, + "videoOptions": { + "analogue": "Analogue", + "digital": "Digital" + }, + "momentOptions": { + "liftoff": "Lift-off", + "cruise": "Cruise", + "onStation": "Over the crossroads", + "turnHome": "Turn for home", + "touchdown": "Touchdown" + }, + "moveOptions": { + "hover": "Hover", + "forward": "Forward", + "roll": "Roll right", + "yaw": "Yaw right", + "climb": "Climb" + }, + "modeOptions": { + "acro": "Acro", + "angle": "Angle", + "horizon": "Horizon" + }, + "packetRateOptions": { + "50": "50 Hz", + "150": "150 Hz", + "250": "250 Hz", + "500": "500 Hz" + }, + "speedsterOptions": { + "longRange": "Long-range quad", + "racer": "Racing quad", + "record": "Record holder" + } + }, + "timeline": { + "label": "Flight time", + "phases": "Jump to a part of the flight", + "speed": "Playback speed", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "compared with real time", + "phase": { + "takeoff": "Takeoff", + "climb": "Climb", + "transit": "Out to the crossroads", + "orbit": "Over the crossroads", + "return": "Home", + "landing": "Landing" + }, + "during": { + "takeoff": "the pilot arms the drone, the propellers spin up and it lifts off the pad into a low hover", + "climb": "the drone tips its nose down, accelerates to 70 km/h and climbs to 40 m", + "transit": "the drone cruises straight out over the field at 70 km/h", + "orbit": "the drone circles the crossroads one and a half times at 50 m while the camera looks in", + "return": "the drone flies home at 70 km/h and lines up on the pad with an S-turn", + "landing": "the drone slows, descends, hovers over the pad and touches down" + }, + "jump": { + "takeoff": "Jump to the takeoff", + "climb": "Jump to the climb", + "transit": "Jump to the flight out", + "orbit": "Jump to the circles over the crossroads", + "return": "Jump to the flight home", + "landing": "Jump to the landing" + } + }, + "readouts": { + "clock": "Flight time", + "speed": "Speed", + "height": "Height", + "battery": "Battery", + "distance": "From the pilot" + }, + "units": { + "kmh": "{{value}} km/h", + "m": "{{value}} m", + "g": "{{value}} g", + "kg": "{{value}} kg", + "percent": "{{value}} %", + "ofHover": "{{percent}} % of hover", + "volts": "{{value}} V", + "amps": "{{value}} A", + "ms": "{{value}} ms", + "dbm": "{{value}} dBm", + "minutes": "{{value}} min", + "seconds": "{{value}} s", + "degrees": "{{value}}°", + "ms2": "{{value}} m/s²", + "times": "{{factor}} times", + "battery": "{{volts}} V, {{share}} %", + "push": "{{ms2}} m/s², {{g}} g", + "ratio": "{{ratio}} to 1", + "hover": "{{thrust}} % of maximum thrust, about {{speed}} % of top motor speed" + }, + "parts": { + "frame": "Frame", + "motorFrontLeft": "Motor 4 (front left)", + "motorFrontRight": "Motor 2 (front right)", + "motorRearLeft": "Motor 3 (rear left)", + "motorRearRight": "Motor 1 (rear right)", + "propellers": "Propellers", + "battery": "Battery", + "stack": "Flight controller and ESC", + "camera": "Camera", + "videoAntenna": "Video antenna", + "receiverAntenna": "Receiver antenna", + "gpsModule": "GPS module", + "groundStation": "Pilot's station", + "controlLink": "Control link", + "videoLink": "Video link", + "spinArrows": "Spin direction", + "crossroads": "Crossroads", + "launchPad": "Launch pad" + }, + "flight": { + "mix": { + "hover": "All four motors share the load evenly: motor 1 at {{m1}} %, motor 2 at {{m2}} %, motor 3 at {{m3}} %, motor 4 at {{m4}} % of maximum thrust", + "forward": "The rear pair speeds up and the front pair slows down: motor 1 at {{m1}} %, motor 2 at {{m2}} %, motor 3 at {{m3}} %, motor 4 at {{m4}} % of maximum thrust", + "roll": "The left pair speeds up and the right pair slows down: motor 1 at {{m1}} %, motor 2 at {{m2}} %, motor 3 at {{m3}} %, motor 4 at {{m4}} % of maximum thrust", + "yaw": "The two counter-clockwise motors speed up and the two clockwise ones slow down: motor 1 at {{m1}} %, motor 2 at {{m2}} %, motor 3 at {{m3}} %, motor 4 at {{m4}} % of maximum thrust", + "climb": "All four motors speed up together: motor 1 at {{m1}} %, motor 2 at {{m2}} %, motor 3 at {{m3}} %, motor 4 at {{m4}} % of maximum thrust" + }, + "why": { + "hover": "The four thrusts add up to exactly the drone's weight, and the two clockwise propellers cancel the twist of the two counter-clockwise ones, so nothing turns.", + "forward": "More thrust at the back than at the front tips the nose down. Part of the thrust now points forward, so the drone accelerates.", + "roll": "More thrust on the left than on the right drops the right side. Part of the thrust now points right, so the drone slides that way.", + "yaw": "The total thrust stays the same, but the twists no longer cancel. The body turns clockwise, the opposite way to the faster propellers.", + "climb": "Thrust is now more than weight, so the drone accelerates upward. Nothing tips, because all four changed by the same amount." + } + }, + "mode": { + "stick": { + "acro": "A rotation rate: how fast to roll or pitch. Centre the stick and the drone holds whatever angle it has reached", + "angle": "A tilt angle: how far to lean from level. Centre the stick and the drone levels itself", + "horizon": "A tilt angle near the centre, a rotation rate at full stick, so it self-levels but can still flip" + }, + "limit": { + "acro": "No tilt limit. Full stick asks for about {{rate}} degrees a second by default", + "angle": "A set tilt limit, which also caps the speed. No flips or rolls", + "horizon": "No limit at full stick, a tilt limit near the centre" + } + }, + "link": { + "latency": { + "analogue": "About 20 to 30 ms", + "digital": "About 20 ms, 15 ms on the best units" + }, + "picture": { + "analogue": "Standard definition, 40 channels to choose from, fades to snow at range", + "digital": "1080p at 100 frames a second, sharp until it drops, about 10 km of reach" + }, + "signal": { + "strong": "Strong", + "good": "Good", + "weak": "Weak", + "lost": "Lost" + } + }, + "speeds": { + "who": { + "longRange": "A 7-inch long-range quad like the one in the model: about 140 km/h flat out, 60 to 80 km/h in cruise", + "racer": "The Drone Racing League's RacerX of 2017, a racing quad built for a record: 263 km/h officially", + "record": "Peregreen V4, Guinness record holder since 11 December 2025: a purpose-built speed machine, nothing like a typical FPV drone" + } + }, + "sections": { + "overview": { + "title": "Four motors, a battery and a pair of goggles", + "p1": "You are looking at an FPV drone: a small quadcopter flown by a pilot who wears goggles and sees through its camera. FPV stands for first-person view. This one is a 7-inch long-range model, named after its propellers: each is about 7.5 inches across, about 190 mm. The frame is carbon fibre, two plates and four arms. Without a battery it weighs 630 to 705 g; with one it comes to about 1 kg. A hobby version costs USD 550 to 950 without radio and goggles. Built in bulk for an army, the airframe comes to about USD 400 to 500.", + "p2": "Drones like this fly in large numbers in the war in Ukraine. Ukraine's defence ministry bought more than 1.5 million drones in 2024 and planned to buy 4.5 million FPV drones in 2025. In practice they work about 10 to 20 km from the pilot. Scout teams fly them to look at roads, fields and treelines, and that is what the drone in the model does: its payload is a camera, the pilot's eye.", + "p3": "The model follows one short reconnaissance sortie of 80 seconds. The pilot arms the drone beside a dugout, lifts off from a pad, climbs to 40 m and cruises out at 70 km/h. Over a crossroads 350 m away the drone circles one and a half times at 50 m, then flies home and lands on the pad. The distances are real metres, but a real sortie would be far longer. The drone is drawn ten times too big so you can still see it. Its speeds, heights and the battery it uses are the model's own choices, inside the ranges a real 7-inch quad flies.", + "p4": "The clock at the top counts real seconds since arming, so T+0:42 means 42 seconds into the flight. Drag the timeline to any moment, or tap a chip below to jump there. Watch the readouts as you go: speed, height, the battery voltage and how far the drone is from the pilot. The radio links switch draws the two beams between the drone and the pilot's station. The flight track switch draws the path behind the drone. The spin arrows switch shows which way each propeller turns, with its motor number.", + "battery": "Battery left", + "distance": "Distance from the pilot", + "done": "Flight flown" + }, + "flight": { + "title": "No wings, no rudder: it steers with motor speed", + "p1": "A quadcopter has no wings, no rudder and no moving control surfaces at all. Every move it makes is a change in the speed of one or more motors. To hover, the four propellers together must push up with exactly the drone's weight. Each motor can give up to about 2 kg of thrust, so a 1.4 kg drone hovers on a small part of what it has. Thrust grows with the square of the propeller speed: double the speed and you get four times the push.", + "p2": "Look at the spin direction arrows: two propellers turn clockwise and two turn counter-clockwise, in diagonal pairs. There is a reason. A spinning propeller twists back on the drone. If all four spun the same way, the body would spin the other way. With two turning each way, the twists cancel. That also gives the pilot a way to turn: speed up one pair and slow the other, and the twists no longer cancel. The drone turns about its vertical axis, the opposite way to the faster pair. Pilots call this yaw.", + "p3": "To tilt, the drone speeds up one side and slows the other. Speed up the two rear motors and the nose dips; that is pitch. Speed up the left pair and the drone leans right; that is roll. The total thrust stays the same, but now it points at an angle, so part of it pushes sideways. That is how a quad moves: it tilts, and the tilted thrust drags it along. Tap the chips to see which motors speed up for each move. The rear motors are numbers 1 and 3, as the flight control software Betaflight counts them.", + "p4": "The slider shows what a tilt does. Tilt the drone 30 degrees and it pushes sideways with about 0.58 of gravity, close to 5.7 m/s². To hold its height at the same time it needs about 15 % more thrust than in a hover, because only part of the thrust still points up. At 45 degrees the push equals gravity and the thrust must rise by 41 %. Freestyle quads carry about four to five times their weight in thrust, so they hover at about 20 to 25 % throttle and keep plenty for a climb or a sprint. Long-range quads carry less because a bigger battery weighs them down: this one has about 5.7 to 1, and heavy-lift builds go as low as 2 to 1.", + "mix": "Motor shares", + "why": "Why it works", + "tiltLabel": "Tilt angle", + "push": "Sideways push", + "thrust": "Thrust needed", + "sprint": "0 to 100 km/h" + }, + "controller": { + "title": "A thousand corrections a second", + "p1": "A quad cannot balance on its own, and no human can react fast enough to balance it by hand. The flight controller, a small board in the stack between the plates, does it. Its gyroscope measures how fast the drone is rotating about each axis 8,000 times a second. A control loop called PID compares that with what the pilot asks for and corrects the four motor speeds, 4,000 or 8,000 times a second. P pushes toward the asked-for rate, I removes slow drift, D damps the fast wobbles. Several thousand corrections a second, every second of the flight.", + "p2": "Under the flight controller sits the ESC, the electronic speed controller: four in one board, rated 50 to 55 A per motor. It turns each new throttle value into timed pulses for the three windings of its motor. The two boards talk over DShot, a digital protocol: one 16-bit word per motor, 11 bits of throttle, a telemetry bit and a checksum, sent in 27 microseconds at 600 kilobits a second. With bidirectional DShot the ESC also reports the real motor speed back, so the controller can filter out the vibration each propeller makes.", + "p3": "What do the sticks command? In acro mode, the default, each stick commands a rotation rate. Push the roll stick halfway and the drone rolls at a steady rate; centre it and the drone stops rolling and holds the angle it has reached, even upside down. By default, full stick asks for about 670 degrees a second, almost two full rolls. Angle mode is for beginners: the stick sets a tilt angle, and the drone levels itself when you let go. Most FPV pilots fly acro, from racers to freestyle pilots: it gives full control in wind and lets the drone take any attitude. Tap the chips to compare the three modes.", + "p4": "Two safety features matter. A switch on the radio arms the drone, which starts the motors and the stabilisation; the controller refuses to arm if the throttle is up or the drone is tilted too far. If the radio packets stop, the failsafe waits 1.5 seconds, then by default cuts the motors and the drone drops. The GPS module on its mast is optional. Betaflight flies without it. With it, GPS Rescue can fly the drone back toward where it was armed when the link is lost, but it is an emergency aid, not a return to home: the pilot must take back control, or it crashes.", + "stick": "What the stick commands", + "limit": "Limit", + "path": "How a stick move reaches the propellers", + "step1": "Radio: the pilot moves a stick; the radio reads it and sends a packet, 250 times a second in the model.", + "step2": "Receiver antenna: the receiver on the drone decodes the packet and hands the stick values to the flight controller.", + "step3": "Gyro: the flight controller reads how fast the drone is turning, 8,000 times a second.", + "step4": "PID loop: it compares the asked-for rate with the measured one and works out four new throttle values.", + "step5": "ESC: each value goes out as a 16-bit DShot word in 27 microseconds, and the ESC times the pulses to the motor.", + "step6": "Propellers: the motor speeds change, the thrusts change, and the drone tilts, turns or climbs." + }, + "link": { + "title": "Two radios: one to steer, one to see", + "p1": "Two radio links run between the pilot's station and the drone. The control link carries the stick positions out. In the model it is ExpressLRS, an open-source system that works on 900 MHz or 2.4 GHz. It sends small packets at a fixed rate, 50 to 1,000 times a second on 2.4 GHz. Slower packets reach farther: the receiver can listen longer for each one and still decode a weaker signal. At 50 Hz it hears signals down to −115 dBm, at 500 Hz only down to −105 dBm. That difference of 10 dB is about 3.2 times the reach in open air. Flown at a low rate, in line of sight from a height, a single 2.4 GHz link has reached over 100 km.", + "p2": "The video link comes back the other way, from the video antenna at the rear. Analogue video on 5.8 GHz is the classic choice: 40 channels in five bands, a standard-definition picture that fades gracefully into snow as the drone gets far away, and a delay of about 20 to 30 ms from camera to eye. Digital systems send a 1080p picture at 100 frames a second with a delay of about 20 ms, and the best units are rated for 10 to 15 km. But good analogue is still the lowest-latency option, and it degrades gently instead of freezing. Tap the chips to compare.", + "p3": "Both links use circular polarised antennas: the wave turns as it travels. A reflection off the ground or a wall flips the turning direction, so the antenna rejects most of the bounced signal that would otherwise smear the picture. The goggles put two small 1080p screens in front of the pilot's eyes, with a field of view of 44 to 46 degrees. A field team usually adds a second screen so a navigator can watch too, puts the radio antenna on a mast, and sometimes flies a second drone high up as a repeater to carry the links over a ridge. Some FPV drones trade both radios for a thin optical fibre unwound from a spool of 10 to 20 km: with no radio link, there is nothing to jam.", + "p4": "Use the second chip row to change the packet rate and watch three numbers move: the time between packets, the weakest signal the receiver still hears and the reach compared with 500 Hz. The readouts below follow the model: the distance from the pilot and how strong the control link is. In this short sortie the drone never gets farther than about 400 m, so the link stays strong. Real teams work at 10 to 20 km, where the packet rate, the antennas and a clear line of sight decide whether the picture holds. Radio power is the last lever, not the first.", + "latency": "Video delay", + "picture": "Picture", + "period": "Time between packets", + "sensitivity": "Weakest signal it still hears", + "reach": "Reach compared with 500 Hz", + "distance": "Distance now", + "signal": "Control link" + }, + "power": { + "title": "Twelve minutes in the air", + "p1": "The battery on the top plate is a 6S pack: six lithium cells in series. With LiPo cells that is 22.2 V nominal and 25.2 V full. The Li-ion pack in the model is 21.6 V nominal and 25.2 V full. Pilots land at about 3.5 V a cell, 21 V for the pack, and never go below 3 V a cell, where the cells take damage. This pack holds 4,200 mAh, about 91 Wh, and weighs about 450 g. A battery's C rating says how much current it can give as a multiple of its capacity: a 1,800 mAh LiPo rated 95C could in theory deliver 171 A, while a Li-ion cell manages 30 to 45 A.", + "p2": "Why Li-ion for long range? Energy per kilogram. A 6S 1,800 mAh LiPo stores 40 Wh in 288 g, about 139 Wh/kg. The Li-ion pack stores 91 Wh in 450 g, about 200 Wh/kg, roughly 45 % more. The price is current: Li-ion cells cannot deliver the bursts a racing quad wants, which is fine for a scout that cruises. And cruising is where the energy goes furthest. Hovering costs more power than flying forward at a moderate speed, because propellers work better in moving air. Long-range pilots plan routes that keep the drone moving and hover as little as possible.", + "p3": "Drag the slider to load the drone. Each extra 100 g raises the all-up weight, lowers the thrust-to-weight ratio, raises the throttle needed to hover and cuts the flight time. The model takes 80 % of the pack's energy as usable and divides it by the power needed to cruise at that weight, which gives about 12 minutes with the camera on board. A bigger battery does not fix this for long: more battery means more weight, which means more power to carry it, so each extra pack gives less than the last. Real 7-inch long-range quads fly 12 to 30 minutes; a 5-inch freestyle quad on a small LiPo manages about 4 to 6.", + "p4": "Watch the voltage during the flight. It sags under load, because current through the pack's internal resistance costs volts, and it recovers when the drone eases off: a 6S can show 21 V in a hard climb and 23 V in a hover. Cold makes this worse. At minus 20 °C a lithium cell keeps only about 65 % of its capacity, and its resistance rises, so the low-voltage warning comes early. Pilots keep their packs warm until launch. The readouts below show the pack's voltage and current as the model flies the sortie, and the battery gauge at the top fills from them.", + "payloadLabel": "Payload", + "weight": "All-up weight", + "twr": "Thrust to weight", + "hover": "Throttle to hover", + "minutes": "Flight time", + "volts": "Pack voltage now", + "amps": "Current now" + }, + "limits": { + "title": "Fast, cheap and everywhere", + "p1": "You are now looking through the pilot's goggles: this is the picture from the drone's own camera on its way home. How fast can it go? A 7-inch long-range quad like this one tops out at about 140 km/h and cruises at 60 to 80 km/h. Racing quads are quicker: the Drone Racing League's RacerX set an official 263 km/h in 2017. The record is in another world. On 11 December 2025 the Peregreen V4, built by Luke and Mike Bell in South Africa, set a Guinness record of 657.59 km/h for a battery-powered quadcopter. It is a purpose-built machine, nothing like a typical FPV drone. Tap the chips to see how long each would take to cover the 350 m to the crossroads.", + "p2": "Wind is the real limit in the field. A rule of thumb says to fly only when the wind is below two thirds of the drone's top speed, and quads this size handle about 32 to 40 km/h of steady wind. Every gust costs battery, because the drone has to tilt into it and the tilted thrust takes more power. This is also where an FPV quad differs from a camera drone such as a Mavic. A Mavic holds its position with GPS and a barometer when you let go of the sticks. An FPV quad in acro holds only its attitude and drifts with the wind. Nothing stops it but the pilot.", + "p3": "That makes it hard to learn. Most new pilots crash repeatedly in their first hours, so they start in a simulator: 20 to 60 hours in a program such as Velocidrone or Liftoff before the first real flight is common advice. The first pilots to fly with goggles did so around 2007, as a hobby, and racing followed. The first world championship was flown in Shenzhen in 2018 by 128 pilots from 34 countries, and a 15-year-old won it.", + "p4": "Cheap and simple is why these drones are everywhere. A hobby quad costs USD 550 to 950 without radio and goggles; a frame kit alone is about USD 130 to 150. Built by the thousand for an army, the airframe comes to about USD 400 to 500, and Ukraine planned to buy 4.5 million FPV drones in 2025. In practice they work about 10 to 20 km from the pilot. To reach farther, teams fly a repeater on a second drone: one Ukrainian system rides a larger drone at 500 m, stays up for an hour and relays signals up to 30 km. The pilot's station in the model is far simpler: a dugout, a case, a mast with an antenna, and a pilot in goggles.", + "speed": "Top speed", + "field": "Time over the 350 m to the crossroads", + "who": "What it is" + } + }, + "goggles": { + "caption": "Goggles view" + } +} diff --git a/explainers/fpv-drone/locales/es.json b/explainers/fpv-drone/locales/es.json new file mode 100644 index 00000000..e502305b --- /dev/null +++ b/explainers/fpv-drone/locales/es.json @@ -0,0 +1,290 @@ +{ + "meta": { + "title": "Cómo vuela un dron FPV", + "eyebrow": "Dron FPV", + "tagline": "Cuatro motores, una cámara y unas gafas: el piloto vuela como si estuviera dentro del dron, y cada movimiento es solo un cambio en la velocidad de un motor.", + "description": "Sigue en 3D a un dron FPV de 7 pulgadas en un breve vuelo de reconocimiento: cuatro motores sin timón, el giroscopio, dos radios, la batería y los límites.", + "summary": "Cuatro motores y ningún timón, una controladora que corrige miles de veces por segundo, dos enlaces de radio, la batería que marca el tiempo de vuelo y los límites de velocidad, viento y alcance.", + "socialAlt": "Un cuadricóptero FPV camuflado se inclina a baja altura sobre un campo bajo un cielo gris, con sus cuatro hélices desdibujadas y un haz de radio que vuelve hasta el refugio del piloto, junto al título «Cómo vuela un dron FPV»" + }, + "stage": { + "label": "Modelo interactivo de un dron FPV", + "gauge": "Datos de vuelo en tiempo real" + }, + "controls": { + "label": "Controles del dron FPV", + "views": { + "links": { + "name": "Enlaces de radio", + "short": "Enlaces", + "hint": "Enlaces de radio (K)" + }, + "track": { + "name": "Ruta de vuelo", + "short": "Ruta", + "hint": "Ruta de vuelo (T)" + }, + "arrows": { + "name": "Sentido de giro", + "short": "Flechas", + "hint": "Sentido de giro (A)" + }, + "labels": { + "name": "Etiquetas", + "short": "Etiquetas", + "hint": "Etiquetas (L)" + } + }, + "video": { + "label": "Vídeo" + }, + "videoOptions": { + "analogue": "Analógico", + "digital": "Digital" + }, + "momentOptions": { + "liftoff": "Despegue", + "cruise": "Crucero", + "onStation": "Sobre el cruce", + "turnHome": "Vuelta a casa", + "touchdown": "Aterrizaje" + }, + "moveOptions": { + "hover": "Estacionario", + "forward": "Avanzar", + "roll": "Alabeo a la derecha", + "yaw": "Guiñada a la derecha", + "climb": "Subir" + }, + "modeOptions": { + "acro": "Acro", + "angle": "Angle", + "horizon": "Horizon" + }, + "packetRateOptions": { + "50": "50 Hz", + "150": "150 Hz", + "250": "250 Hz", + "500": "500 Hz" + }, + "speedsterOptions": { + "longRange": "Dron de largo alcance", + "racer": "Dron de carreras", + "record": "Poseedor del récord" + } + }, + "timeline": { + "label": "Tiempo de vuelo", + "phases": "Saltar a una parte del vuelo", + "speed": "Velocidad de reproducción", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "respecto al tiempo real", + "phase": { + "takeoff": "Despegue", + "climb": "Ascenso", + "transit": "Ida al cruce", + "orbit": "Sobre el cruce", + "return": "Regreso", + "landing": "Aterrizaje" + }, + "during": { + "takeoff": "el piloto arma el dron, las hélices arrancan y el dron despega de la plataforma y se queda en vuelo estacionario a baja altura", + "climb": "el dron baja el morro, acelera hasta 70 km/h y sube a 40 m", + "transit": "el dron cruza el campo en línea recta a 70 km/h", + "orbit": "el dron da una vuelta y media al cruce a 50 m mientras la cámara mira hacia dentro", + "return": "el dron vuelve a casa a 70 km/h y se alinea con la plataforma con una curva en S", + "landing": "el dron frena, desciende, se queda en estacionario sobre la plataforma y aterriza" + }, + "jump": { + "takeoff": "Saltar al despegue", + "climb": "Saltar al ascenso", + "transit": "Saltar al vuelo de ida", + "orbit": "Saltar a las vueltas sobre el cruce", + "return": "Saltar al vuelo de regreso", + "landing": "Saltar al aterrizaje" + } + }, + "readouts": { + "clock": "Tiempo de vuelo", + "speed": "Velocidad", + "height": "Altura", + "battery": "Batería", + "distance": "Hasta el piloto" + }, + "units": { + "kmh": "{{value}} km/h", + "m": "{{value}} m", + "g": "{{value}} g", + "kg": "{{value}} kg", + "percent": "{{value}} %", + "ofHover": "{{percent}} % del empuje en estacionario", + "volts": "{{value}} V", + "amps": "{{value}} A", + "ms": "{{value}} ms", + "dbm": "{{value}} dBm", + "minutes": "{{value}} min", + "seconds": "{{value}} s", + "degrees": "{{value}}°", + "ms2": "{{value}} m/s²", + "times": "{{factor}} veces", + "battery": "{{volts}} V, {{share}} %", + "push": "{{ms2}} m/s², {{g}} g", + "ratio": "{{ratio}} a 1", + "hover": "{{thrust}} % del empuje máximo, alrededor del {{speed}} % de las revoluciones máximas" + }, + "parts": { + "frame": "Chasis", + "motorFrontLeft": "Motor 4 (delantero izquierdo)", + "motorFrontRight": "Motor 2 (delantero derecho)", + "motorRearLeft": "Motor 3 (trasero izquierdo)", + "motorRearRight": "Motor 1 (trasero derecho)", + "propellers": "Hélices", + "battery": "Batería", + "stack": "Controladora de vuelo y ESC", + "camera": "Cámara", + "videoAntenna": "Antena de vídeo", + "receiverAntenna": "Antena del receptor", + "gpsModule": "Módulo GPS", + "groundStation": "Puesto del piloto", + "controlLink": "Enlace de control", + "videoLink": "Enlace de vídeo", + "spinArrows": "Sentido de giro", + "crossroads": "Cruce", + "launchPad": "Plataforma de despegue" + }, + "flight": { + "mix": { + "hover": "Los cuatro motores se reparten la carga por igual: motor 1 al {{m1}} %, motor 2 al {{m2}} %, motor 3 al {{m3}} %, motor 4 al {{m4}} % del empuje máximo", + "forward": "El par trasero acelera y el delantero frena: motor 1 al {{m1}} %, motor 2 al {{m2}} %, motor 3 al {{m3}} %, motor 4 al {{m4}} % del empuje máximo", + "roll": "El par izquierdo acelera y el derecho frena: motor 1 al {{m1}} %, motor 2 al {{m2}} %, motor 3 al {{m3}} %, motor 4 al {{m4}} % del empuje máximo", + "yaw": "Los dos motores que giran en sentido antihorario aceleran y los dos que giran en sentido horario frenan: motor 1 al {{m1}} %, motor 2 al {{m2}} %, motor 3 al {{m3}} %, motor 4 al {{m4}} % del empuje máximo", + "climb": "Los cuatro motores aceleran a la vez: motor 1 al {{m1}} %, motor 2 al {{m2}} %, motor 3 al {{m3}} %, motor 4 al {{m4}} % del empuje máximo" + }, + "why": { + "hover": "Los cuatro empujes suman justo el peso del dron, y las dos hélices que giran en sentido horario anulan la torsión de las dos que giran al revés, así que nada gira.", + "forward": "Más empuje detrás que delante inclina el morro hacia abajo. Parte del empuje apunta ahora hacia delante, así que el dron acelera.", + "roll": "Más empuje a la izquierda que a la derecha hunde el lado derecho. Parte del empuje apunta ahora a la derecha, así que el dron se desliza hacia allí.", + "yaw": "El empuje total no cambia, pero las torsiones ya no se anulan. El cuerpo gira en sentido horario, al revés que las hélices más rápidas.", + "climb": "El empuje ya supera el peso, así que el dron acelera hacia arriba. Nada se inclina, porque los cuatro motores han cambiado lo mismo." + } + }, + "mode": { + "stick": { + "acro": "Una velocidad de giro: a qué velocidad alabea o cabecea. Centra la palanca y el dron mantiene el ángulo al que haya llegado", + "angle": "Un ángulo de inclinación: cuánto se aparta de la horizontal. Centra la palanca y el dron se nivela solo", + "horizon": "Un ángulo de inclinación cerca del centro y una velocidad de giro a fondo de palanca: se nivela solo, pero aún puede dar una voltereta" + }, + "limit": { + "acro": "Sin límite de inclinación. Con la palanca a fondo pide unos {{rate}} grados por segundo por defecto", + "angle": "Un límite fijo de inclinación, que también limita la velocidad. Ni volteretas ni toneles", + "horizon": "Sin límite a fondo de palanca, con límite de inclinación cerca del centro" + } + }, + "link": { + "latency": { + "analogue": "Entre 20 y 30 ms, aproximadamente", + "digital": "Unos 20 ms, 15 ms en los mejores equipos" + }, + "picture": { + "analogue": "Definición estándar, 40 canales para elegir, se llena de nieve con la distancia", + "digital": "1080p a 100 imágenes por segundo, nítida hasta que se corta, unos 10 km de alcance" + }, + "signal": { + "strong": "Fuerte", + "good": "Buena", + "weak": "Débil", + "lost": "Perdida" + } + }, + "speeds": { + "who": { + "longRange": "Un dron de largo alcance de 7 pulgadas como el del modelo: unos 140 km/h a fondo, de 60 a 80 km/h en crucero", + "racer": "El RacerX de la Drone Racing League de 2017, un dron de carreras construido para un récord: 263 km/h oficiales", + "record": "Peregreen V4, poseedor del récord Guinness desde el 11 de diciembre de 2025: una máquina hecha a medida para la velocidad, nada que ver con un dron FPV normal" + } + }, + "sections": { + "overview": { + "title": "Cuatro motores, una batería y unas gafas", + "p1": "Lo que tienes delante es un dron FPV: un pequeño cuadricóptero que pilota una persona con gafas que ve a través de la cámara del dron. FPV son las siglas inglesas de «vista en primera persona». Este es un modelo de largo alcance de 7 pulgadas, y el nombre viene de sus hélices: cada una mide unas 7,5 pulgadas, alrededor de 190 mm. El chasis son dos placas y cuatro brazos de fibra de carbono. Sin batería pesa de 630 a 705 g; con ella se queda en 1 kg, más o menos. Una versión de aficionado cuesta de 550 a 950 dólares sin emisora ni gafas. Fabricado en serie para un ejército, el dron en sí sale por unos 400 a 500 dólares.", + "p2": "Drones como este vuelan por miles en la guerra de Ucrania. El Ministerio de Defensa ucraniano compró más de 1,5 millones de drones en 2024 y planeaba comprar 4,5 millones de drones FPV en 2025. En la práctica operan a unos 10 a 20 km del piloto. Los equipos de reconocimiento los usan para observar caminos, campos y arboledas, y eso es lo que hace el dron del modelo: su carga es una cámara, el ojo del piloto.", + "p3": "El modelo sigue un breve vuelo de reconocimiento de 80 segundos. El piloto arma el dron junto a un refugio, el dron despega de una plataforma, sube a 40 m y sale a 70 km/h. Sobre un cruce a 350 m da una vuelta y media a 50 m de altura, y después vuelve a casa y aterriza en la plataforma. Las distancias son metros reales, pero un vuelo de verdad sería mucho más largo. El dron está dibujado diez veces más grande de lo real, para que puedas verlo. Las velocidades, las alturas y la batería son elecciones del propio modelo, dentro de los valores habituales de un dron real de 7 pulgadas.", + "p4": "El reloj de arriba cuenta segundos reales desde que se armó el dron, así que T+0:42 quiere decir 42 segundos de vuelo. Arrastra la línea de tiempo hasta cualquier momento, o toca un botón de abajo para saltar a él. Mientras tanto, fíjate en los datos: la velocidad, la altura, la tensión de la batería y la distancia entre el dron y el piloto. El botón de enlaces de radio dibuja los dos haces entre el dron y el puesto del piloto. El de ruta de vuelo dibuja el rastro que deja el dron. El de sentido de giro muestra hacia dónde gira cada hélice, con el número de su motor.", + "battery": "Batería restante", + "distance": "Distancia hasta el piloto", + "done": "Vuelo completado" + }, + "flight": { + "title": "Sin alas ni timón: se gobierna con la velocidad de los motores", + "p1": "Un cuadricóptero no tiene alas, ni timón, ni ninguna superficie de control móvil. Cada movimiento que hace es un cambio en la velocidad de uno o varios motores. Para mantenerse en vuelo estacionario, las cuatro hélices juntas tienen que empujar hacia arriba con una fuerza igual al peso del dron. Cada motor puede dar hasta unos 2 kg de empuje, así que un dron de 1,4 kg se mantiene en estacionario con una pequeña parte de lo que tiene. El empuje crece con el cuadrado de la velocidad de la hélice: dobla la velocidad y tendrás cuatro veces más empuje.", + "p2": "Mira las flechas del sentido de giro: dos hélices giran en sentido horario y dos en sentido antihorario, en parejas diagonales. Hay un motivo. Una hélice que gira retuerce el dron en sentido contrario. Si las cuatro girasen igual, el cuerpo daría vueltas al revés. Con dos en cada sentido, las torsiones se anulan. Eso también le da al piloto una forma de girar: acelera un par y frena el otro, y las torsiones dejan de anularse. El dron gira sobre su eje vertical, al revés que el par más rápido. Los pilotos lo llaman guiñada o yaw.", + "p3": "Para inclinarse, el dron acelera un lado y frena el otro. Acelera los dos motores traseros y el morro baja: eso es el cabeceo. Acelera el par izquierdo y el dron se ladea a la derecha: eso es el alabeo. El empuje total no cambia, pero ahora apunta en ángulo, así que parte de él empuja hacia un lado. Así se mueve un cuadricóptero: se inclina, y el empuje inclinado tira de él. Toca los botones para ver qué motores aceleran en cada movimiento. Los motores traseros son el 1 y el 3, tal como los numera Betaflight, el programa de control de vuelo.", + "p4": "El control deslizante muestra qué hace la inclinación. Inclina el dron 30 grados y empuja hacia un lado con unas 0,58 veces la gravedad, casi 5,7 m/s². Para mantener la altura al mismo tiempo necesita alrededor de un 15 % más de empuje que en vuelo estacionario, porque solo una parte del empuje sigue apuntando hacia arriba. A 45 grados el empuje lateral iguala a la gravedad y el empuje total tiene que subir un 41 %. Los drones de freestyle tienen un empuje de unas cuatro a cinco veces su peso, así que se mantienen en estacionario con alrededor de un 20 a 25 % de gas y les sobra mucho para subir o acelerar. Los de largo alcance tienen menos, porque una batería más grande pesa: este tiene unos 5,7 a 1, y los drones para cargas pesadas bajan hasta 2 a 1.", + "mix": "Reparto entre motores", + "why": "Por qué funciona", + "tiltLabel": "Ángulo de inclinación", + "push": "Empuje lateral", + "thrust": "Empuje necesario", + "sprint": "De 0 a 100 km/h" + }, + "controller": { + "title": "Mil correcciones por segundo", + "p1": "Un cuadricóptero no puede mantener el equilibrio solo, y ninguna persona reacciona tan rápido como para equilibrarlo a mano. Lo hace la controladora de vuelo, una pequeña placa en la torre entre las dos placas del chasis. Su giroscopio mide 8.000 veces por segundo a qué velocidad gira el dron en cada eje. Un bucle de control llamado PID compara eso con lo que pide el piloto y corrige la velocidad de los cuatro motores, 4.000 u 8.000 veces por segundo. P empuja hacia la velocidad de giro pedida, I elimina la deriva lenta, D amortigua los temblores rápidos. Varios miles de correcciones por segundo, cada segundo del vuelo.", + "p2": "Debajo de la controladora está el ESC, el variador electrónico de velocidad: cuatro en una sola placa, de 50 a 55 A por motor. Convierte cada nuevo valor de gas en pulsos sincronizados para los tres bobinados de su motor. Las dos placas se hablan por DShot, un protocolo digital: una palabra de 16 bits por motor, 11 bits de gas, un bit de telemetría y una suma de control, enviada en 27 microsegundos a 600 kilobits por segundo. Con DShot bidireccional, el ESC también devuelve la velocidad real del motor, y así la controladora puede filtrar la vibración de cada hélice.", + "p3": "¿Qué mandan las palancas? En el modo acro, el que viene por defecto, cada palanca manda una velocidad de giro. Lleva la palanca de alabeo a la mitad y el dron alabea a ritmo constante; céntrala y el dron deja de alabear y mantiene el ángulo que haya alcanzado, incluso boca abajo. Por defecto, la palanca a fondo pide unos 670 grados por segundo, casi dos vueltas completas. El modo angle es para principiantes: la palanca fija un ángulo de inclinación, y el dron se nivela solo cuando la sueltas. La mayoría de los pilotos FPV vuelan en acro, desde los de carreras hasta los de freestyle: este modo da control total con viento y permite al dron ponerse en cualquier postura. Toca los botones para comparar los tres modos.", + "p4": "Hay dos funciones de seguridad clave. Un interruptor de la emisora arma el dron, es decir, arranca los motores y la estabilización. La controladora se niega a armar si el gas está subido o el dron está demasiado inclinado. Si los paquetes de radio dejan de llegar, el failsafe espera 1,5 segundos y después, por defecto, corta los motores y el dron cae. El módulo GPS de su mástil es opcional. Betaflight vuela sin él. Con él, la función GPS Rescue puede llevar el dron de vuelta hacia donde se armó si se pierde el enlace, pero es una ayuda de emergencia, no un regreso automático a casa: el piloto tiene que retomar el control, o el dron se estrella.", + "stick": "Qué manda la palanca", + "limit": "Límite", + "path": "Cómo llega un movimiento de palanca a las hélices", + "step1": "Emisora: el piloto mueve una palanca; la emisora la lee y envía un paquete, 250 veces por segundo en el modelo.", + "step2": "Antena del receptor: el receptor del dron decodifica el paquete y pasa los valores de las palancas a la controladora.", + "step3": "Giroscopio: la controladora lee a qué velocidad gira el dron, 8.000 veces por segundo.", + "step4": "Bucle PID: compara la velocidad de giro pedida con la medida y calcula cuatro nuevos valores de gas.", + "step5": "ESC: cada valor sale como una palabra DShot de 16 bits en 27 microsegundos, y el ESC sincroniza los pulsos del motor.", + "step6": "Hélices: cambian las velocidades de los motores, cambian los empujes, y el dron se inclina, gira o sube." + }, + "link": { + "title": "Dos radios: una para gobernar y otra para ver", + "p1": "Entre el puesto del piloto y el dron funcionan dos enlaces de radio. El enlace de control lleva la posición de las palancas hasta el dron. En el modelo es ExpressLRS, un sistema de código abierto que funciona en 900 MHz o en 2,4 GHz. Envía paquetes pequeños a un ritmo fijo, de 50 a 1.000 veces por segundo en 2,4 GHz. Los paquetes más lentos llegan más lejos: el receptor puede escuchar cada uno durante más tiempo y aun así decodificar una señal más débil. A 50 Hz oye señales de hasta −115 dBm; a 500 Hz, solo hasta −105 dBm. Esa diferencia de 10 dB equivale a unas 3,2 veces más alcance al aire libre. A ritmo bajo, con línea de vista desde una altura, un solo enlace de 2,4 GHz ha llegado a más de 100 km.", + "p2": "El enlace de vídeo vuelve en sentido contrario, desde la antena de vídeo de la cola. El vídeo analógico en 5,8 GHz es la opción clásica: 40 canales en cinco bandas, una imagen en definición estándar que se va llenando de nieve poco a poco cuando el dron se aleja, y un retraso de unos 20 a 30 ms de la cámara al ojo. Los sistemas digitales envían una imagen 1080p a 100 imágenes por segundo con un retraso de unos 20 ms, y los mejores equipos tienen un alcance nominal de 10 a 15 km. Pero un buen analógico sigue siendo la opción con menos retraso, y se degrada poco a poco en vez de congelarse. Toca los botones para comparar.", + "p3": "Los dos enlaces usan antenas de polarización circular: la onda gira mientras viaja. Un rebote en el suelo o en una pared invierte el sentido de giro, así que la antena rechaza casi toda la señal rebotada que, si no, emborronaría la imagen. Las gafas ponen dos pequeñas pantallas 1080p delante de los ojos del piloto, con un campo de visión de 44 a 46 grados. Un equipo en el terreno suele añadir una segunda pantalla para que un navegante también mire, sube la antena de la emisora a un mástil y, a veces, pone un segundo dron bien alto como repetidor para pasar los enlaces por encima de una loma. Algunos drones FPV cambian las dos radios por una fibra óptica muy fina que se desenrolla de una bobina de 10 a 20 km: sin enlace de radio, no hay nada que inhibir.", + "p4": "Usa la segunda fila de botones para cambiar la frecuencia de los paquetes y mira cómo cambian tres números: el tiempo entre paquetes, la señal más débil que el receptor aún oye y el alcance comparado con 500 Hz. Los datos de abajo siguen el modelo: la distancia hasta el piloto y la fuerza del enlace de control. En este vuelo tan corto el dron nunca se aleja más de unos 400 m, así que el enlace se mantiene fuerte. Los equipos reales trabajan a 10 a 20 km, y ahí la frecuencia de los paquetes, las antenas y una línea de vista despejada deciden si la imagen aguanta. La potencia de la radio es la última palanca, no la primera.", + "latency": "Retraso del vídeo", + "picture": "Imagen", + "period": "Tiempo entre paquetes", + "sensitivity": "Señal más débil que aún oye", + "reach": "Alcance comparado con 500 Hz", + "distance": "Distancia ahora", + "signal": "Enlace de control" + }, + "power": { + "title": "Doce minutos en el aire", + "p1": "La batería de la placa superior es un pack 6S: seis celdas de litio en serie. Con celdas LiPo, eso son 22,2 V nominales y 25,2 V a plena carga. El pack de Li-ion del modelo tiene 21,6 V nominales y 25,2 V a plena carga. Los pilotos aterrizan a unos 3,5 V por celda, 21 V para el pack, y nunca bajan de 3 V por celda, porque ahí las celdas se dañan. Este pack guarda 4.200 mAh, unos 91 Wh, y pesa unos 450 g. La tasa C de una batería dice cuánta corriente puede dar como múltiplo de su capacidad: una LiPo de 1.800 mAh con 95C podría dar en teoría 171 A, mientras que una celda de Li-ion da de 30 a 45 A.", + "p2": "¿Por qué Li-ion para el largo alcance? Por la energía por kilo. Una LiPo 6S de 1.800 mAh guarda 40 Wh en 288 g, unos 139 Wh/kg. El pack de Li-ion guarda 91 Wh en 450 g, unos 200 Wh/kg, alrededor de un 45 % más. El precio es la corriente: las celdas de Li-ion no dan los picos que quiere un dron de carreras, pero a un explorador que vuela en crucero le da igual. Y en crucero es donde la energía cunde más. El vuelo estacionario cuesta más potencia que avanzar a velocidad moderada, porque las hélices trabajan mejor en aire en movimiento. Los pilotos de largo alcance planean rutas que mantienen el dron en movimiento y reducen al mínimo el vuelo estacionario.", + "p3": "Arrastra el control deslizante para cargar el dron. Cada 100 g de más suben el peso total, bajan la relación empuje-peso, suben el gas necesario para el vuelo estacionario y recortan el tiempo de vuelo. El modelo toma como útil el 80 % de la energía del pack y la divide por la potencia que hace falta para volar en crucero con ese peso, lo que da unos 12 minutos con la cámara a bordo. Una batería más grande no lo arregla por mucho tiempo: más batería es más peso, y más peso pide más potencia para cargarlo, así que cada pack extra rinde menos que el anterior. Los drones reales de largo alcance de 7 pulgadas vuelan de 12 a 30 minutos; uno de freestyle de 5 pulgadas con una LiPo pequeña aguanta unos 4 a 6 minutos.", + "p4": "Fíjate en la tensión durante el vuelo. Baja con la carga, porque la corriente que pasa por la resistencia interna del pack cuesta voltios, y se recupera cuando el dron afloja: un 6S puede marcar 21 V en una subida fuerte y 23 V en estacionario. El frío lo empeora. A 20 °C bajo cero una celda de litio conserva solo un 65 % de su capacidad, aproximadamente, y su resistencia sube, así que el aviso de tensión baja llega antes. Los pilotos mantienen los packs calientes hasta el despegue. Los datos de abajo muestran la tensión y la corriente del pack mientras el modelo vuela, y con ellos se llena el indicador de batería de arriba.", + "payloadLabel": "Carga útil", + "weight": "Peso total", + "twr": "Empuje frente a peso", + "hover": "Gas en estacionario", + "minutes": "Tiempo de vuelo", + "volts": "Tensión del pack ahora", + "amps": "Corriente ahora" + }, + "limits": { + "title": "Rápidos, baratos y por todas partes", + "p1": "Ahora miras a través de las gafas del piloto: es la imagen de la cámara del propio dron mientras vuelve a casa. ¿Cuánto corre? Un dron de largo alcance de 7 pulgadas como este alcanza unos 140 km/h y vuela en crucero a 60 a 80 km/h. Los de carreras son más rápidos: el RacerX de la Drone Racing League marcó 263 km/h oficiales en 2017. El récord está en otro mundo. El 11 de diciembre de 2025, el Peregreen V4, construido por Luke y Mike Bell en Sudáfrica, fijó un récord Guinness de 657,59 km/h para un cuadricóptero a batería. Es una máquina hecha a medida, nada que ver con un dron FPV normal. Toca los botones para ver cuánto tardaría cada uno en recorrer los 350 m hasta el cruce.", + "p2": "El verdadero límite en el terreno es el viento. Una regla dice que solo se vuela cuando el viento está por debajo de dos tercios de la velocidad máxima del dron, y los drones de este tamaño aguantan un viento constante de unos 32 a 40 km/h. Cada racha cuesta batería, porque el dron tiene que inclinarse contra ella y el empuje inclinado pide más potencia. Aquí también está la diferencia entre un dron FPV y un dron de cámara como un Mavic. Un Mavic mantiene su posición con GPS y barómetro cuando sueltas las palancas. Un dron FPV en acro solo mantiene su postura y se va con el viento. Nada lo detiene salvo el piloto.", + "p3": "Por eso cuesta aprender. La mayoría de los pilotos nuevos se estrellan una y otra vez en sus primeras horas, así que empiezan en un simulador: de 20 a 60 horas en un programa como Velocidrone o Liftoff antes del primer vuelo real es un consejo habitual. Los primeros pilotos que volaron con gafas lo hicieron hacia 2007, por afición, y las carreras vinieron después. El primer campeonato del mundo se disputó en Shenzhen en 2018, con 128 pilotos de 34 países, y lo ganó un chico de 15 años.", + "p4": "Baratos y sencillos: por eso están por todas partes. Un dron de aficionado cuesta de 550 a 950 dólares sin emisora ni gafas; solo el kit del chasis sale por unos 130 a 150 dólares. Fabricado por miles para un ejército, el dron en sí cuesta unos 400 a 500 dólares, y Ucrania planeaba comprar 4,5 millones de drones FPV en 2025. En la práctica operan a unos 10 a 20 km del piloto. Para llegar más lejos, los equipos montan un repetidor en un segundo dron: un sistema ucraniano va sobre un dron más grande a 500 m, aguanta una hora en el aire y retransmite señales hasta 30 km. El puesto del piloto del modelo es mucho más sencillo: un refugio, una maleta, un mástil con antena y un piloto con gafas.", + "speed": "Velocidad máxima", + "field": "Tiempo para los 350 m hasta el cruce", + "who": "Qué es" + } + }, + "goggles": { + "caption": "Vista de gafas" + } +} diff --git a/explainers/fpv-drone/locales/fr.json b/explainers/fpv-drone/locales/fr.json new file mode 100644 index 00000000..134fb259 --- /dev/null +++ b/explainers/fpv-drone/locales/fr.json @@ -0,0 +1,290 @@ +{ + "meta": { + "title": "Comment vole un drone FPV", + "eyebrow": "Drone FPV", + "tagline": "Quatre moteurs, une caméra et un masque vidéo : le pilote vole comme s’il était dans le drone, et chaque mouvement n’est qu’un changement de régime moteur.", + "description": "Suivez en 3D un drone FPV de 7 pouces en vol de reconnaissance : quatre moteurs sans gouverne, le gyroscope, deux radios, la batterie et les limites.", + "summary": "Quatre moteurs et aucune gouverne, un contrôleur de vol qui corrige des milliers de fois par seconde, deux liaisons radio, la batterie qui fixe l’autonomie, et les limites de vitesse, de vent et de portée.", + "socialAlt": "Un quadricoptère FPV camouflé s’incline en rase-mottes au-dessus d’un champ sous un ciel gris, ses quatre hélices floues et un faisceau radio qui rejoint l’abri du pilote, à côté du titre « Comment vole un drone FPV »" + }, + "stage": { + "label": "Modèle interactif d’un drone FPV", + "gauge": "Mesures de vol en direct" + }, + "controls": { + "label": "Commandes du drone FPV", + "views": { + "links": { + "name": "Liaisons radio", + "short": "Liaisons", + "hint": "Liaisons radio (K)" + }, + "track": { + "name": "Trajectoire de vol", + "short": "Trajectoire", + "hint": "Trajectoire de vol (T)" + }, + "arrows": { + "name": "Sens de rotation", + "short": "Flèches", + "hint": "Sens de rotation (A)" + }, + "labels": { + "name": "Étiquettes", + "short": "Étiquettes", + "hint": "Étiquettes (L)" + } + }, + "video": { + "label": "Vidéo" + }, + "videoOptions": { + "analogue": "Analogique", + "digital": "Numérique" + }, + "momentOptions": { + "liftoff": "Décollage", + "cruise": "Croisière", + "onStation": "Sur le carrefour", + "turnHome": "Demi-tour vers l’abri", + "touchdown": "Atterrissage" + }, + "moveOptions": { + "hover": "Vol stationnaire", + "forward": "En avant", + "roll": "Roulis à droite", + "yaw": "Lacet à droite", + "climb": "Montée" + }, + "modeOptions": { + "acro": "Acro", + "angle": "Angle", + "horizon": "Horizon" + }, + "packetRateOptions": { + "50": "50 Hz", + "150": "150 Hz", + "250": "250 Hz", + "500": "500 Hz" + }, + "speedsterOptions": { + "longRange": "Drone longue portée", + "racer": "Drone de course", + "record": "Détenteur du record" + } + }, + "timeline": { + "label": "Temps de vol", + "phases": "Aller à une étape du vol", + "speed": "Vitesse de lecture", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "par rapport au temps réel", + "phase": { + "takeoff": "Décollage", + "climb": "Montée", + "transit": "Aller vers le carrefour", + "orbit": "Sur le carrefour", + "return": "Retour", + "landing": "Atterrissage" + }, + "during": { + "takeoff": "le pilote arme le drone, les hélices se lancent et le drone quitte sa plateforme pour un stationnaire à basse hauteur", + "climb": "le drone pique du nez, accélère jusqu’à 70 km/h et monte à 40 m", + "transit": "le drone file tout droit au-dessus du champ à 70 km/h", + "orbit": "le drone fait un tour et demi autour du carrefour à 50 m, la caméra tournée vers l’intérieur", + "return": "le drone rentre à 70 km/h et s’aligne sur la plateforme par une courbe en S", + "landing": "le drone ralentit, descend, reste en stationnaire au-dessus de la plateforme et se pose" + }, + "jump": { + "takeoff": "Aller au décollage", + "climb": "Aller à la montée", + "transit": "Aller au trajet aller", + "orbit": "Aller aux tours au-dessus du carrefour", + "return": "Aller au vol retour", + "landing": "Aller à l’atterrissage" + } + }, + "readouts": { + "clock": "Temps de vol", + "speed": "Vitesse", + "height": "Hauteur", + "battery": "Batterie", + "distance": "Jusqu’au pilote" + }, + "units": { + "kmh": "{{value}} km/h", + "m": "{{value}} m", + "g": "{{value}} g", + "kg": "{{value}} kg", + "percent": "{{value}} %", + "ofHover": "{{percent}} % du stationnaire", + "volts": "{{value}} V", + "amps": "{{value}} A", + "ms": "{{value}} ms", + "dbm": "{{value}} dBm", + "minutes": "{{value}} min", + "seconds": "{{value}} s", + "degrees": "{{value}}°", + "ms2": "{{value}} m/s²", + "times": "{{factor}} fois", + "battery": "{{volts}} V, {{share}} %", + "push": "{{ms2}} m/s², {{g}} g", + "ratio": "{{ratio}} pour 1", + "hover": "{{thrust}} % de la poussée maximale, soit environ {{speed}} % du régime maximal" + }, + "parts": { + "frame": "Châssis", + "motorFrontLeft": "Moteur 4 (avant gauche)", + "motorFrontRight": "Moteur 2 (avant droit)", + "motorRearLeft": "Moteur 3 (arrière gauche)", + "motorRearRight": "Moteur 1 (arrière droit)", + "propellers": "Hélices", + "battery": "Batterie", + "stack": "Contrôleur de vol et ESC", + "camera": "Caméra", + "videoAntenna": "Antenne vidéo", + "receiverAntenna": "Antenne du récepteur", + "gpsModule": "Module GPS", + "groundStation": "Poste du pilote", + "controlLink": "Liaison de commande", + "videoLink": "Liaison vidéo", + "spinArrows": "Sens de rotation", + "crossroads": "Carrefour", + "launchPad": "Plateforme de décollage" + }, + "flight": { + "mix": { + "hover": "Les quatre moteurs se partagent la charge à égalité : moteur 1 à {{m1}} %, moteur 2 à {{m2}} %, moteur 3 à {{m3}} %, moteur 4 à {{m4}} % de la poussée maximale", + "forward": "La paire arrière accélère et la paire avant ralentit : moteur 1 à {{m1}} %, moteur 2 à {{m2}} %, moteur 3 à {{m3}} %, moteur 4 à {{m4}} % de la poussée maximale", + "roll": "La paire gauche accélère et la paire droite ralentit : moteur 1 à {{m1}} %, moteur 2 à {{m2}} %, moteur 3 à {{m3}} %, moteur 4 à {{m4}} % de la poussée maximale", + "yaw": "Les deux moteurs qui tournent dans le sens inverse des aiguilles d’une montre accélèrent, les deux autres ralentissent : moteur 1 à {{m1}} %, moteur 2 à {{m2}} %, moteur 3 à {{m3}} %, moteur 4 à {{m4}} % de la poussée maximale", + "climb": "Les quatre moteurs accélèrent ensemble : moteur 1 à {{m1}} %, moteur 2 à {{m2}} %, moteur 3 à {{m3}} %, moteur 4 à {{m4}} % de la poussée maximale" + }, + "why": { + "hover": "Les quatre poussées font exactement le poids du drone, et les deux hélices qui tournent dans le sens des aiguilles d’une montre annulent le couple des deux autres : rien ne tourne.", + "forward": "Plus de poussée à l’arrière qu’à l’avant fait piquer le nez. Une partie de la poussée pointe maintenant vers l’avant, et le drone accélère.", + "roll": "Plus de poussée à gauche qu’à droite fait baisser le côté droit. Une partie de la poussée pointe maintenant vers la droite, et le drone glisse de ce côté.", + "yaw": "La poussée totale ne change pas, mais les couples ne s’annulent plus. Le corps tourne dans le sens des aiguilles d’une montre, à l’inverse des hélices les plus rapides.", + "climb": "La poussée dépasse maintenant le poids, et le drone accélère vers le haut. Rien ne bascule, car les quatre moteurs ont changé de la même quantité." + } + }, + "mode": { + "stick": { + "acro": "Une vitesse de rotation : à quelle vitesse partir en roulis ou en tangage. Manche au centre, le drone garde l’angle qu’il a atteint", + "angle": "Un angle d’inclinaison : de combien s’écarter de l’horizontale. Manche au centre, le drone se remet à plat tout seul", + "horizon": "Un angle d’inclinaison près du centre, une vitesse de rotation en butée : il se remet à plat, mais peut encore faire un flip" + }, + "limit": { + "acro": "Aucune limite d’inclinaison. Le manche en butée demande environ {{rate}} degrés par seconde par défaut", + "angle": "Une limite d’inclinaison fixée, qui limite aussi la vitesse. Ni flips ni tonneaux", + "horizon": "Aucune limite en butée, une limite d’inclinaison près du centre" + } + }, + "link": { + "latency": { + "analogue": "Environ 20 à 30 ms", + "digital": "Environ 20 ms, 15 ms sur les meilleurs systèmes" + }, + "picture": { + "analogue": "Définition standard, 40 canaux au choix, se noie dans la neige avec la distance", + "digital": "1080p à 100 images par seconde, nette jusqu’à la coupure, environ 10 km de portée" + }, + "signal": { + "strong": "Forte", + "good": "Bonne", + "weak": "Faible", + "lost": "Perdue" + } + }, + "speeds": { + "who": { + "longRange": "Un drone longue portée de 7 pouces comme celui du modèle : environ 140 km/h à fond, 60 à 80 km/h en croisière", + "racer": "Le RacerX de la Drone Racing League, en 2017, un drone de course construit pour un record : 263 km/h officiels", + "record": "Peregreen V4, détenteur du record Guinness depuis le 11 décembre 2025 : une machine conçue pour la vitesse, rien à voir avec un drone FPV ordinaire" + } + }, + "sections": { + "overview": { + "title": "Quatre moteurs, une batterie et un masque", + "p1": "Vous avez sous les yeux un drone FPV : un petit quadricoptère piloté par une personne qui porte un masque vidéo et voit à travers la caméra du drone. FPV signifie « vue à la première personne ». Celui-ci est un modèle longue portée de 7 pouces. Il doit son nom à ses hélices : chacune mesure environ 7,5 pouces, soit à peu près 190 mm. Le châssis se compose de deux plaques et de quatre bras en fibre de carbone. Sans batterie, le drone pèse de 630 à 705 g ; avec, il approche 1 kg. Une version de loisir coûte de 550 à 950 dollars sans radio ni masque. Produit en série pour une armée, le drone seul revient à environ 400 à 500 dollars.", + "p2": "Des drones comme celui-ci volent en grand nombre dans la guerre en Ukraine. Le ministère ukrainien de la Défense a acheté plus de 1,5 million de drones en 2024 et prévoyait d’acheter 4,5 millions de drones FPV en 2025. En pratique, ils volent à environ 10 à 20 km du pilote. Les équipes de reconnaissance s’en servent pour observer des routes, des champs et des lisières, et c’est ce que fait le drone du modèle : sa charge utile est une caméra, l’œil du pilote.", + "p3": "Le modèle suit un court vol de reconnaissance de 80 secondes. Le pilote arme le drone à côté d’un abri, le drone décolle d’une plateforme, monte à 40 m et part en croisière à 70 km/h. Au-dessus d’un carrefour situé à 350 m, il fait un tour et demi à 50 m, puis il rentre et se pose sur la plateforme. Les distances sont de vrais mètres, mais un vrai vol serait bien plus long. Le drone est dessiné dix fois trop grand pour que vous puissiez le voir. Ses vitesses, ses hauteurs et sa batterie sont des choix du modèle, dans les plages où vole un vrai drone de 7 pouces.", + "p4": "L’horloge, en haut, compte les secondes réelles depuis que le drone est armé : T+0:42 signifie donc 42 secondes de vol. Faites glisser la barre de lecture jusqu’au moment de votre choix, ou appuyez sur un bouton ci-dessous pour y aller directement. En chemin, surveillez les indicateurs : la vitesse, la hauteur, la tension de la batterie et la distance entre le drone et le pilote. Le bouton Liaisons radio trace les deux faisceaux entre le drone et le poste du pilote. Le bouton Trajectoire de vol trace le chemin parcouru derrière le drone. Le bouton Sens de rotation montre dans quel sens tourne chaque hélice, avec le numéro de son moteur.", + "battery": "Batterie restante", + "distance": "Distance au pilote", + "done": "Vol effectué" + }, + "flight": { + "title": "Ni ailes ni gouverne : il se dirige au régime moteur", + "p1": "Un quadricoptère n’a ni ailes, ni gouverne, ni aucune surface de commande mobile. Chacun de ses mouvements est un changement de régime d’un ou plusieurs moteurs. Pour rester en stationnaire, les quatre hélices doivent ensemble pousser vers le haut avec une force égale au poids du drone. Chaque moteur peut fournir jusqu’à environ 2 kg de poussée : un drone de 1,4 kg tient donc en l’air avec une petite part de sa poussée disponible. La poussée grandit avec le carré de la vitesse de l’hélice : doublez le régime, et la poussée est multipliée par quatre.", + "p2": "Regardez les flèches du sens de rotation : deux hélices tournent dans le sens des aiguilles d’une montre, deux dans l’autre, par paires en diagonale. Ce n’est pas un hasard. Une hélice qui tourne pousse le drone à tourner dans l’autre sens. Si les quatre tournaient dans le même sens, le corps tournerait dans l’autre. Avec deux dans chaque sens, les couples s’annulent. Cela donne aussi au pilote un moyen de tourner : accélérez une paire et ralentissez l’autre, et les couples ne s’annulent plus. Le drone pivote autour de son axe vertical, à l’inverse de la paire la plus rapide. Les pilotes appellent cela le lacet, ou yaw.", + "p3": "Pour s’incliner, le drone accélère un côté et ralentit l’autre. Accélérez les deux moteurs arrière et le nez pique : c’est le tangage. Accélérez la paire gauche et le drone penche à droite : c’est le roulis. La poussée totale ne change pas, mais elle pointe maintenant de biais, et une partie pousse sur le côté. Voilà comment un quad se déplace : il s’incline, et la poussée inclinée l’entraîne. Appuyez sur les boutons pour voir quels moteurs accélèrent à chaque mouvement. Les moteurs arrière portent les numéros 1 et 3, dans la numérotation de Betaflight, le logiciel de contrôle de vol.", + "p4": "Le curseur montre ce que fait une inclinaison. Inclinez le drone de 30 degrés, et il pousse sur le côté avec environ 0,58 fois la gravité, près de 5,7 m/s². Pour tenir sa hauteur en même temps, il lui faut environ 15 % de poussée de plus qu’en stationnaire, car seule une partie de la poussée pointe encore vers le haut. À 45 degrés, la poussée latérale égale la gravité et la poussée totale doit augmenter de 41 %. Les drones de freestyle disposent d’environ quatre à cinq fois leur poids en poussée : ils tiennent en stationnaire à environ 20 à 25 % de gaz et gardent une large réserve pour monter ou sprinter. Les drones longue portée en ont moins, parce qu’une plus grosse batterie les alourdit : celui-ci a environ 5,7 pour 1, et les drones conçus pour les lourdes charges descendent jusqu’à 2 pour 1.", + "mix": "Répartition entre moteurs", + "why": "Pourquoi ça marche", + "tiltLabel": "Angle d’inclinaison", + "push": "Poussée latérale", + "thrust": "Poussée nécessaire", + "sprint": "De 0 à 100 km/h" + }, + "controller": { + "title": "Mille corrections par seconde", + "p1": "Un quad ne peut pas tenir en équilibre seul, et personne ne réagit assez vite pour l’équilibrer à la main. C’est le travail du contrôleur de vol, une petite carte dans la pile entre les deux plaques. Son gyroscope mesure 8 000 fois par seconde la vitesse à laquelle le drone tourne autour de chaque axe. Une boucle de régulation appelée PID compare cette mesure à ce que demande le pilote et corrige les quatre régimes moteur, 4 000 ou 8 000 fois par seconde. P pousse vers la vitesse demandée, I efface la dérive lente, D amortit les tremblements rapides. Plusieurs milliers de corrections par seconde, à chaque seconde du vol.", + "p2": "Sous le contrôleur de vol se trouve l’ESC, le variateur électronique : quatre sur une seule carte, prévus pour 50 à 55 A par moteur. Il transforme chaque nouvelle valeur de gaz en impulsions bien minutées pour les trois bobinages de son moteur. Les deux cartes se parlent en DShot, un protocole numérique : un mot de 16 bits par moteur, 11 bits de gaz, un bit de télémétrie et une somme de contrôle, envoyé en 27 microsecondes à 600 kilobits par seconde. Avec le DShot bidirectionnel, l’ESC renvoie aussi le régime réel du moteur, ce qui permet au contrôleur de filtrer la vibration de chaque hélice.", + "p3": "Que commandent les manches ? En mode acro, le mode par défaut, chaque manche commande une vitesse de rotation. Poussez le manche de roulis à mi-course et le drone tourne en roulis à vitesse constante ; recentrez-le et le drone cesse de tourner et garde l’angle atteint, même à l’envers. Par défaut, le manche en butée demande environ 670 degrés par seconde, presque deux tonneaux complets. Le mode angle s’adresse aux débutants : le manche fixe un angle d’inclinaison, et le drone se remet à plat dès que vous lâchez. La plupart des pilotes FPV volent en acro, des pilotes de course aux pilotes de freestyle : ce mode donne un contrôle total dans le vent et laisse le drone prendre n’importe quelle attitude. Appuyez sur les boutons pour comparer les trois modes.", + "p4": "Deux sécurités sont essentielles. Un interrupteur de la radio arme le drone, c’est-à-dire met en route les moteurs et la stabilisation. Le contrôleur refuse d’armer si les gaz ne sont pas au minimum ou si le drone est trop incliné. Si les paquets radio cessent d’arriver, le failsafe attend 1,5 seconde, puis coupe par défaut les moteurs, et le drone tombe. Le module GPS sur son mât est facultatif. Betaflight vole sans lui. Avec lui, la fonction GPS Rescue peut ramener le drone vers l’endroit où il a été armé si la liaison est perdue. Mais c’est une aide d’urgence, pas un retour automatique : le pilote doit reprendre la main, sinon le drone s’écrase.", + "stick": "Ce que commande le manche", + "limit": "Limite", + "path": "Le trajet d’un mouvement de manche jusqu’aux hélices", + "step1": "Radio : le pilote bouge un manche ; la radio le lit et envoie un paquet, 250 fois par seconde dans le modèle.", + "step2": "Antenne du récepteur : le récepteur du drone décode le paquet et transmet les valeurs des manches au contrôleur de vol.", + "step3": "Gyroscope : le contrôleur de vol lit la vitesse de rotation du drone, 8 000 fois par seconde.", + "step4": "Boucle PID : il compare la vitesse demandée à la vitesse mesurée et calcule quatre nouvelles valeurs de gaz.", + "step5": "ESC : chaque valeur part en un mot DShot de 16 bits en 27 microsecondes, et l’ESC minute les impulsions du moteur.", + "step6": "Hélices : les régimes changent, les poussées changent, et le drone s’incline, tourne ou monte." + }, + "link": { + "title": "Deux radios : une pour diriger, une pour voir", + "p1": "Deux liaisons radio relient le poste du pilote au drone. La liaison de commande transporte la position des manches vers le drone. Dans le modèle, c’est ExpressLRS, un système libre qui fonctionne en 900 MHz ou en 2,4 GHz. Il envoie de petits paquets à une cadence fixe, de 50 à 1 000 fois par seconde en 2,4 GHz. Les paquets plus lents portent plus loin : le récepteur peut écouter chacun plus longtemps et décoder un signal plus faible. À 50 Hz, il entend des signaux jusqu’à −115 dBm ; à 500 Hz, seulement jusqu’à −105 dBm. Cet écart de 10 dB vaut environ 3,2 fois la portée en terrain dégagé. À faible cadence, en vue directe depuis une hauteur, une seule liaison 2,4 GHz a déjà dépassé 100 km.", + "p2": "La liaison vidéo fait le chemin inverse, depuis l’antenne vidéo à l’arrière. La vidéo analogique en 5,8 GHz est le choix classique : 40 canaux dans cinq bandes, une image en définition standard qui se noie doucement dans la neige quand le drone s’éloigne, et un délai d’environ 20 à 30 ms de la caméra à l’œil. Les systèmes numériques envoient une image 1080p à 100 images par seconde avec un délai d’environ 20 ms, et les meilleurs sont annoncés pour 10 à 15 km. Mais une bonne liaison analogique reste la solution au délai le plus court, et elle se dégrade en douceur au lieu de se figer. Appuyez sur les boutons pour comparer.", + "p3": "Les deux liaisons utilisent des antennes à polarisation circulaire : l’onde tourne sur elle-même en se propageant. Une réflexion sur le sol ou sur un mur inverse ce sens de rotation, si bien que l’antenne rejette l’essentiel du signal réfléchi qui, sinon, brouillerait l’image. Le masque place deux petits écrans 1080p devant les yeux du pilote, avec un champ de vision de 44 à 46 degrés. Une équipe sur le terrain ajoute en général un second écran pour qu’un navigateur suive aussi, installe l’antenne radio sur un mât et fait parfois voler un second drone très haut, en relais, pour faire passer les liaisons par-dessus une crête. Certains drones FPV remplacent les deux radios par une fibre optique très fine, déroulée depuis une bobine de 10 à 20 km : sans liaison radio, il n’y a rien à brouiller.", + "p4": "Avec la seconde rangée de boutons, changez la cadence des paquets et regardez trois chiffres bouger : le temps entre deux paquets, le signal le plus faible que le récepteur entend encore, et la portée comparée à 500 Hz. Les indicateurs du bas suivent le modèle : la distance au pilote et la force de la liaison de commande. Sur ce court vol, le drone ne s’éloigne jamais à plus de 400 m environ, et la liaison reste forte. Sur le terrain, les équipes travaillent à 10 à 20 km, et là, la cadence des paquets, les antennes et une vue directe dégagée décident si l’image tient. La puissance d’émission est le dernier levier, pas le premier.", + "latency": "Délai de la vidéo", + "picture": "Image", + "period": "Temps entre deux paquets", + "sensitivity": "Signal le plus faible encore entendu", + "reach": "Portée comparée à 500 Hz", + "distance": "Distance actuelle", + "signal": "Liaison de commande" + }, + "power": { + "title": "Douze minutes en l’air", + "p1": "La batterie sur la plaque supérieure est un pack 6S : six cellules au lithium en série. Avec des cellules LiPo, cela fait 22,2 V nominaux et 25,2 V à pleine charge. Le pack Li-ion du modèle fait 21,6 V nominaux et 25,2 V à pleine charge. Les pilotes se posent vers 3,5 V par cellule, soit 21 V pour le pack, et ne descendent jamais sous 3 V par cellule, où les cellules s’abîment. Ce pack contient 4 200 mAh, environ 91 Wh, et pèse environ 450 g. Le taux C d’une batterie indique le courant qu’elle peut fournir, en multiple de sa capacité : une LiPo de 1 800 mAh en 95C pourrait en théorie débiter 171 A, quand une cellule Li-ion fournit 30 à 45 A.", + "p2": "Pourquoi du Li-ion pour la longue portée ? Pour l’énergie par kilo. Une LiPo 6S de 1 800 mAh stocke 40 Wh dans 288 g, environ 139 Wh/kg. Le pack Li-ion stocke 91 Wh dans 450 g, environ 200 Wh/kg, soit à peu près 45 % de plus. Le prix à payer, c’est le courant : les cellules Li-ion ne fournissent pas les pointes qu’exige un drone de course, ce qui ne gêne pas un éclaireur qui vole en croisière. Et c’est en croisière que l’énergie va le plus loin. Le stationnaire coûte plus de puissance qu’un vol en avant à vitesse modérée, parce que les hélices travaillent mieux dans de l’air en mouvement. Les pilotes longue portée tracent des routes qui gardent le drone en mouvement et limitent le stationnaire au minimum.", + "p3": "Faites glisser le curseur pour charger le drone. Chaque tranche de 100 g de plus augmente la masse totale, réduit le rapport poussée sur poids, augmente les gaz nécessaires au stationnaire et rogne l’autonomie. Le modèle compte 80 % de l’énergie du pack comme utilisable et la divise par la puissance qu’il faut pour voler en croisière à cette masse : cela donne environ 12 minutes avec la caméra à bord. Une plus grosse batterie n’arrange rien bien longtemps : plus de batterie, c’est plus de masse, donc plus de puissance pour la porter, et chaque pack de plus rapporte moins que le précédent. Les vrais drones longue portée de 7 pouces volent de 12 à 30 minutes ; un drone de freestyle de 5 pouces sur une petite LiPo tient environ 4 à 6 minutes.", + "p4": "Observez la tension pendant le vol. Elle s’affaisse sous la charge, car le courant qui traverse la résistance interne du pack coûte des volts, et elle remonte quand le drone relâche l’effort : un 6S peut afficher 21 V dans une montée brutale et 23 V en stationnaire. Le froid aggrave tout. À moins 20 °C, une cellule au lithium ne garde qu’environ 65 % de sa capacité, et sa résistance augmente, si bien que l’alerte de tension basse arrive tôt. Les pilotes gardent leurs packs au chaud jusqu’au décollage. Les indicateurs ci-dessous montrent la tension et le courant du pack pendant que le modèle vole, et la jauge de batterie en haut se remplit à partir d’eux.", + "payloadLabel": "Charge utile", + "weight": "Masse totale", + "twr": "Poussée sur poids", + "hover": "Gaz pour le stationnaire", + "minutes": "Autonomie", + "volts": "Tension actuelle du pack", + "amps": "Courant actuel" + }, + "limits": { + "title": "Rapides, bon marché et partout", + "p1": "Vous voyez maintenant à travers le masque du pilote : c’est l’image de la caméra embarquée du drone, sur le chemin du retour. À quelle vitesse va-t-il ? Un drone longue portée de 7 pouces comme celui-ci plafonne à environ 140 km/h et croise à 60 à 80 km/h. Les drones de course vont plus vite : le RacerX de la Drone Racing League a été chronométré à 263 km/h officiels en 2017. Le record, lui, est d’un autre monde. Le 11 décembre 2025, le Peregreen V4, construit par Luke et Mike Bell en Afrique du Sud, a établi un record Guinness à 657,59 km/h pour un quadricoptère à batterie. C’est une machine conçue pour cela, rien à voir avec un drone FPV ordinaire. Appuyez sur les boutons pour voir combien de temps chacun mettrait à parcourir les 350 m jusqu’au carrefour.", + "p2": "La vraie limite sur le terrain, c’est le vent. Une règle simple dit de ne voler que si le vent reste sous les deux tiers de la vitesse maximale du drone, et les drones de cette taille tiennent un vent établi d’environ 32 à 40 km/h. Chaque rafale coûte de la batterie, car le drone doit s’incliner contre elle, et la poussée inclinée demande plus de puissance. C’est aussi là qu’un drone FPV se distingue d’un drone à caméra comme un Mavic. Un Mavic tient sa position grâce au GPS et à un baromètre quand vous lâchez les manches. Un drone FPV en acro ne tient que son attitude et part avec le vent. Rien ne l’arrête, à part le pilote.", + "p3": "C’est ce qui rend l’apprentissage difficile. La plupart des nouveaux pilotes s’écrasent encore et encore pendant leurs premières heures, alors ils commencent en simulateur : 20 à 60 heures sur un programme comme Velocidrone ou Liftoff avant le premier vrai vol, c’est le conseil habituel. Les premiers pilotes à voler avec un masque l’ont fait vers 2007, en loisir, et la course a suivi. Le premier championnat du monde s’est couru à Shenzhen en 2018, avec 128 pilotes de 34 pays, et c’est un garçon de 15 ans qui l’a gagné.", + "p4": "Bon marché et simples : voilà pourquoi ces drones sont partout. Un drone de loisir coûte de 550 à 950 dollars sans radio ni masque ; le kit de châssis seul revient à environ 130 à 150 dollars. Produit par milliers pour une armée, le drone seul coûte environ 400 à 500 dollars, et l’Ukraine prévoyait d’acheter 4,5 millions de drones FPV en 2025. En pratique, ils volent à environ 10 à 20 km du pilote. Pour aller plus loin, les équipes embarquent un relais sur un second drone : un système ukrainien est porté par un plus gros drone à 500 m, tient une heure en l’air et retransmet les signaux jusqu’à 30 km. Le poste du pilote du modèle est bien plus simple : un abri, une mallette, un mât avec une antenne, et un pilote sous son masque.", + "speed": "Vitesse maximale", + "field": "Temps pour les 350 m jusqu’au carrefour", + "who": "De quoi il s’agit" + } + }, + "goggles": { + "caption": "Vue du masque" + } +} diff --git a/explainers/fpv-drone/locales/ja.json b/explainers/fpv-drone/locales/ja.json new file mode 100644 index 00000000..604cefab --- /dev/null +++ b/explainers/fpv-drone/locales/ja.json @@ -0,0 +1,290 @@ +{ + "meta": { + "title": "FPVドローンが飛ぶしくみ", + "eyebrow": "FPVドローン", + "tagline": "4つのモーター、1台のカメラ、そしてゴーグル。パイロットはドローンの中にいるように飛び、動きのすべてはモーターの回転数の変化だけで生まれます。", + "description": "7インチのFPVドローンによる短い偵察飛行を3Dでたどります。舵のない4つのモーター、ジャイロと制御ループ、2つの無線、バッテリー、そして限界。", + "summary": "舵のない4つのモーター、毎秒数千回補正するフライトコントローラー、2つの無線、飛行時間を決めるバッテリー、そして速度と風と距離の限界。", + "socialAlt": "灰色の空の下、迷彩色のFPVクアッドコプターが野原の上を低く傾いて旋回しています。4枚のプロペラはぶれて見え、電波のビームがパイロットの掩体へ伸びています。横にはタイトル「FPVドローンが飛ぶしくみ」" + }, + "stage": { + "label": "FPVドローンのインタラクティブモデル", + "gauge": "飛行中のリアルタイム計測" + }, + "controls": { + "label": "FPVドローンの操作", + "views": { + "links": { + "name": "無線リンク", + "short": "無線", + "hint": "無線リンク(K)" + }, + "track": { + "name": "飛行経路", + "short": "経路", + "hint": "飛行経路(T)" + }, + "arrows": { + "name": "回転方向", + "short": "矢印", + "hint": "回転方向(A)" + }, + "labels": { + "name": "ラベル", + "short": "ラベル", + "hint": "ラベル(L)" + } + }, + "video": { + "label": "映像" + }, + "videoOptions": { + "analogue": "アナログ", + "digital": "デジタル" + }, + "momentOptions": { + "liftoff": "離陸", + "cruise": "巡航", + "onStation": "交差点の上空", + "turnHome": "帰路へ旋回", + "touchdown": "着陸" + }, + "moveOptions": { + "hover": "ホバリング", + "forward": "前進", + "roll": "右へロール", + "yaw": "右へヨー", + "climb": "上昇" + }, + "modeOptions": { + "acro": "アクロ", + "angle": "アングル", + "horizon": "ホライゾン" + }, + "packetRateOptions": { + "50": "50Hz", + "150": "150Hz", + "250": "250Hz", + "500": "500Hz" + }, + "speedsterOptions": { + "longRange": "長距離機", + "racer": "レース機", + "record": "記録保持機" + } + }, + "timeline": { + "label": "飛行時間", + "phases": "飛行の場面を選んで移動", + "speed": "再生速度", + "value": "{{time}}、{{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "実時間に対する倍率", + "phase": { + "takeoff": "離陸", + "climb": "上昇", + "transit": "交差点へ", + "orbit": "交差点の上空", + "return": "帰路", + "landing": "着陸" + }, + "during": { + "takeoff": "パイロットがドローンをアームし、プロペラが回り始め、パッドを離れて低くホバリングする", + "climb": "ドローンが機首を下げ、時速70kmまで加速して40mまで上昇する", + "transit": "ドローンが時速70kmで野原の上をまっすぐ飛ぶ", + "orbit": "ドローンが高さ50mで交差点を1周半まわり、カメラは内側を向く", + "return": "ドローンが時速70kmで帰り、S字の旋回でパッドに向きを合わせる", + "landing": "ドローンが減速して降下し、パッドの上でホバリングしてから着陸する" + }, + "jump": { + "takeoff": "離陸の場面へ移動", + "climb": "上昇の場面へ移動", + "transit": "往路の場面へ移動", + "orbit": "交差点を回る場面へ移動", + "return": "帰路の場面へ移動", + "landing": "着陸の場面へ移動" + } + }, + "readouts": { + "clock": "飛行時間", + "speed": "速度", + "height": "高さ", + "battery": "バッテリー", + "distance": "パイロットから" + }, + "units": { + "kmh": "時速{{value}}km", + "m": "{{value}}m", + "g": "{{value}}g", + "kg": "{{value}}kg", + "percent": "{{value}}%", + "ofHover": "ホバリング推力の{{percent}}%", + "volts": "{{value}}V", + "amps": "{{value}}A", + "ms": "{{value}}ms", + "dbm": "{{value}}dBm", + "minutes": "{{value}}分", + "seconds": "{{value}}秒", + "degrees": "{{value}}°", + "ms2": "{{value}}m/s²", + "times": "{{factor}}倍", + "battery": "{{volts}}V、{{share}}%", + "push": "{{ms2}}m/s²、{{g}}g", + "ratio": "{{ratio}}対1", + "hover": "最大推力の{{thrust}}%、モーター最高回転数のおよそ{{speed}}%" + }, + "parts": { + "frame": "フレーム", + "motorFrontLeft": "モーター4(左前)", + "motorFrontRight": "モーター2(右前)", + "motorRearLeft": "モーター3(左後ろ)", + "motorRearRight": "モーター1(右後ろ)", + "propellers": "プロペラ", + "battery": "バッテリー", + "stack": "フライトコントローラーとESC", + "camera": "カメラ", + "videoAntenna": "映像アンテナ", + "receiverAntenna": "受信機アンテナ", + "gpsModule": "GPSモジュール", + "groundStation": "パイロットの陣地", + "controlLink": "操縦リンク", + "videoLink": "映像リンク", + "spinArrows": "回転方向", + "crossroads": "交差点", + "launchPad": "離着陸パッド" + }, + "flight": { + "mix": { + "hover": "4つのモーターが均等に負担します。モーター1は{{m1}}%、モーター2は{{m2}}%、モーター3は{{m3}}%、モーター4は{{m4}}%、いずれも最大推力に対する割合です", + "forward": "後ろの2つが速く、前の2つが遅くなります。モーター1は{{m1}}%、モーター2は{{m2}}%、モーター3は{{m3}}%、モーター4は{{m4}}%、いずれも最大推力に対する割合です", + "roll": "左の2つが速く、右の2つが遅くなります。モーター1は{{m1}}%、モーター2は{{m2}}%、モーター3は{{m3}}%、モーター4は{{m4}}%、いずれも最大推力に対する割合です", + "yaw": "反時計回りの2つが速く、時計回りの2つが遅くなります。モーター1は{{m1}}%、モーター2は{{m2}}%、モーター3は{{m3}}%、モーター4は{{m4}}%、いずれも最大推力に対する割合です", + "climb": "4つのモーターがそろって速くなります。モーター1は{{m1}}%、モーター2は{{m2}}%、モーター3は{{m3}}%、モーター4は{{m4}}%、いずれも最大推力に対する割合です" + }, + "why": { + "hover": "4つの推力を足すとちょうど機体の重さになります。時計回りの2枚のプロペラが、反時計回りの2枚のねじる力を打ち消すので、機体は回りません。", + "forward": "後ろの推力が前より大きいと、機首が下がります。推力の一部が前を向くので、ドローンは加速します。", + "roll": "左の推力が右より大きいと、右側が下がります。推力の一部が右を向くので、ドローンは右へ流れます。", + "yaw": "推力の合計は変わりませんが、ねじる力が打ち消し合わなくなります。機体は時計回りに回り、速いほうのプロペラとは逆の向きです。", + "climb": "推力が重さを上回るので、ドローンは上へ加速します。4つとも同じだけ変わるので、機体は傾きません。" + } + }, + "mode": { + "stick": { + "acro": "回転の速さ。どれだけ速くロールやピッチをするか。スティックを中央に戻すと、そのときの角度を保ちます", + "angle": "傾きの角度。水平からどれだけ傾けるか。スティックを中央に戻すと、自動で水平に戻ります", + "horizon": "中央付近では傾きの角度、いっぱいに倒すと回転の速さ。自動で水平に戻りますが、宙返りもできます" + }, + "limit": { + "acro": "傾きの制限なし。スティックをいっぱいに倒すと、初期設定で毎秒約{{rate}}度の回転を指示します", + "angle": "傾きに決まった上限があり、速度もそれで制限されます。宙返りもロールもできません", + "horizon": "いっぱいに倒せば制限なし、中央付近では傾きの上限あり" + } + }, + "link": { + "latency": { + "analogue": "約20ミリ秒から30ミリ秒", + "digital": "約20ミリ秒、最良の機器なら15ミリ秒" + }, + "picture": { + "analogue": "標準画質、40チャンネルから選べる、遠くなると砂嵐になる", + "digital": "1080p、毎秒100コマ、途切れるまで鮮明、到達距離は約10km" + }, + "signal": { + "strong": "強い", + "good": "良好", + "weak": "弱い", + "lost": "途絶" + } + }, + "speeds": { + "who": { + "longRange": "モデルと同じ7インチの長距離機。全開で時速約140km、巡航は時速60kmから80km", + "racer": "ドローン・レーシング・リーグが2017年に記録のために作ったレース機RacerX。公式記録は時速263km", + "record": "2025年12月11日からギネス記録を持つPeregreen V4。速さのためだけに作られた機体で、ふつうのFPVドローンとはまるで別物です" + } + }, + "sections": { + "overview": { + "title": "4つのモーター、バッテリー、そしてゴーグル", + "p1": "いま見ているのはFPVドローンです。小さなクアッドコプターで、パイロットはゴーグルをかけ、機体のカメラの映像を見ながら飛ばします。FPVは「一人称視点」という意味の英語の略です。この機体は7インチの長距離型で、名前はプロペラから来ています。1枚の直径が約7.5インチ、およそ190mmです。フレームはカーボン製で、2枚の板と4本のアームでできています。バッテリーなしの重さは630gから705g、バッテリーを積むと約1kgになります。趣味用の機体は、送信機とゴーグルを除いて550ドルから950ドルです。軍のために量産された機体なら、1機あたり約400ドルから500ドルです。", + "p2": "このようなドローンは、ウクライナの戦争で大量に飛んでいます。ウクライナ国防省は2024年に150万機を超えるドローンを買い、2025年には450万機のFPVドローンを買う計画を立てました。実際の運用では、パイロットから約10kmから20km離れた場所で使われます。偵察チームはこれで道路や野原や林を見に行きます。モデルのドローンがしているのも、まさにそれです。積んでいるのはカメラ、つまりパイロットの目です。", + "p3": "モデルは、80秒の短い偵察飛行を1回たどります。パイロットは掩体のそばでドローンをアームし、機体はパッドから離陸して40mまで上がり、時速70kmで飛び出します。350m先の交差点の上空で高さ50mのまま1周半まわり、それから帰ってきてパッドに着陸します。距離は本物のメートルですが、本物の飛行はもっとずっと長くなります。ドローンは、見えるように10倍大きく描いてあります。速度や高さ、使うバッテリーはモデルが独自に決めたもので、本物の7インチ機が飛ぶ範囲に収めてあります。", + "p4": "上の時計は、アームした瞬間からの実際の秒数を数えます。T+0:42なら、飛行開始から42秒です。タイムラインをドラッグすれば、好きな瞬間へ移れます。下のチップをタップすると、その場面へ飛べます。進めながら表示にも注目してください。速度、高さ、バッテリーの電圧、そしてパイロットからの距離です。無線リンクをオンにすると、ドローンとパイロットの陣地を結ぶ2本のビームが現れます。飛行経路をオンにすると、機体の通った跡が描かれます。回転方向をオンにすると、各プロペラがどちら向きに回るかが、モーターの番号とともに表示されます。", + "battery": "バッテリー残量", + "distance": "パイロットからの距離", + "done": "飛行の進み具合" + }, + "flight": { + "title": "翼も舵もなし。モーターの回転数で操る", + "p1": "クアッドコプターには翼も舵もなく、動く操縦翼面はひとつもありません。動きのすべては、1つまたは複数のモーターの回転数の変化です。ホバリングするには、4枚のプロペラが合わせて、機体の重さとちょうど同じ力で上へ押さなければなりません。モーター1つで最大約2kgの推力を出せるので、1.4kgの機体は持てる力のごく一部で浮いていることになります。推力はプロペラの回転数の2乗に比例します。回転数を2倍にすると、推力は4倍です。", + "p2": "回転方向の矢印を見てください。2枚のプロペラは時計回り、2枚は反時計回りで、対角線上の2枚が同じ向きです。これには理由があります。回るプロペラは、機体を逆向きにねじります。4枚とも同じ向きに回れば、機体は反対向きに回ってしまいます。2枚ずつ逆向きにすると、ねじる力が打ち消し合います。これはパイロットに、向きを変える手段も与えます。一方の組を速く、もう一方を遅くすると、ねじる力が釣り合わなくなります。ドローンは縦の軸のまわりに、速いほうの組とは逆向きに回ります。パイロットはこれをヨーと呼びます。", + "p3": "傾くときは、片側のモーターを速く、反対側を遅くします。後ろの2つを速くすると機首が下がります。これがピッチです。左の2つを速くすると機体は右へ傾きます。これがロールです。推力の合計は変わりませんが、向きが斜めになるので、一部が横へ押します。クアッドコプターはこうして動きます。まず傾き、傾いた推力に引っぱられて進むのです。チップをタップすると、それぞれの動きでどのモーターが速くなるかが分かります。後ろのモーターは1番と3番です。これはフライト制御ソフトのBetaflightの数え方です。", + "p4": "スライダーは、傾くと何が起きるかを示します。ドローンを30度傾けると、重力加速度の約0.58倍、ほぼ5.7m/s²で横に押します。同時に高さを保つには、ホバリングより約15%多い推力が要ります。上を向いている推力が一部だけになるからです。45度では横向きの力が重力加速度と等しくなり、推力は41%増やさなければなりません。フリースタイル機は自分の重さの約4倍から5倍の推力を持ち、スロットル約20%から25%でホバリングして、上昇やダッシュのための余裕をたっぷり残します。長距離機の推力重量比はそれより低めです。大きなバッテリーが重いからで、この機体では約5.7対1、重い荷物を運ぶ機体では2対1まで下がります。", + "mix": "モーターの分担", + "why": "なぜそうなるか", + "tiltLabel": "傾きの角度", + "push": "横向きの力", + "thrust": "必要な推力", + "sprint": "時速0kmから100kmまで" + }, + "controller": { + "title": "毎秒1000回の補正", + "p1": "クアッドコプターは自分ではバランスを取れず、人間の反応では手で釣り合わせるには遅すぎます。それをこなすのがフライトコントローラー、2枚の板のあいだのスタックにある小さな基板です。そのジャイロは、機体が各軸のまわりにどれだけ速く回っているかを毎秒8,000回測ります。PIDと呼ばれる制御ループがそれをパイロットの指示と比べ、4つのモーターの回転数を毎秒4,000回または8,000回補正します。Pは求められた速さへ押し、Iはゆっくりしたずれを消し、Dは速い揺れを抑えます。毎秒数千回の補正が、飛行中ずっと続きます。", + "p2": "フライトコントローラーの下にはESC、電子スピードコントローラーがあります。1枚の基板に4つ分が載り、モーター1つあたり50Aから55Aに対応します。ESCは新しいスロットル値を受け取るたびに、それをタイミングを合わせたパルスに変え、モーターの3つの巻線へ送ります。2枚の基板はDShotというデジタル規格で話します。モーター1つにつき16ビットのデータで、11ビットがスロットル、1ビットがテレメトリー、残りがチェックサムです。これを毎秒600キロビットの速さで、27マイクロ秒かけて送ります。双方向DShotを使えば、ESCはモーターの実際の回転数も送り返すので、コントローラーは各プロペラの振動を取り除けます。", + "p3": "スティックは何を指示しているのでしょう。初期設定のアクロモードでは、各スティックが回転の速さを指示します。ロールのスティックを半分倒すと、機体は一定の速さで回り続けます。中央に戻すと回転が止まり、そのときの角度を保ちます。たとえ逆さまでもです。初期設定では、いっぱいに倒すと毎秒約670度で回るよう指示します。ほぼ2回転分です。アングルモードは初心者向けです。スティックが傾きの角度を決め、手を離すと機体は自動で水平に戻ります。レースからフリースタイルまで、FPVパイロットの多くはアクロで飛びます。風の中でも完全に操縦でき、どんな姿勢も取れるからです。チップをタップして、3つのモードを比べてください。", + "p4": "大事な安全機能が2つあります。送信機のスイッチでドローンをアームすると、モーターと姿勢制御が動き始めます。スロットルが下がっていなかったり、機体が傾きすぎていたりすると、コントローラーはアームを拒みます。無線のパケットが途絶えると、フェイルセーフは1.5秒待ち、それから初期設定ではモーターを止めます。ドローンは落ちます。マストの上のGPSモジュールは、なくても構いません。BetaflightはGPSなしで飛べます。GPSがあれば、通信が切れたときにGPSレスキュー機能がアームした場所の方向へ機体を戻せます。ただしこれは緊急用の助けで、自動帰還ではありません。パイロットが操縦を取り戻さなければ、機体は墜落します。", + "stick": "スティックが指示するもの", + "limit": "制限", + "path": "スティックの動きがプロペラに届くまで", + "step1": "送信機:パイロットがスティックを動かします。送信機がそれを読み取り、パケットを送ります。モデルでは毎秒250回です。", + "step2": "受信機アンテナ:機体の受信機がパケットを解読し、スティックの値をフライトコントローラーに渡します。", + "step3": "ジャイロ:フライトコントローラーが、機体がどれだけ速く回っているかを毎秒8,000回読み取ります。", + "step4": "PIDループ:求められた速さと測った速さを比べ、4つの新しいスロットル値を計算します。", + "step5": "ESC:それぞれの値が16ビットのDShotデータとして27マイクロ秒で送られ、ESCがモーターへのパルスのタイミングを決めます。", + "step6": "プロペラ:モーターの回転数が変わり、推力が変わり、機体が傾いたり、回ったり、上昇したりします。" + }, + "link": { + "title": "2つの無線。ひとつは操るため、ひとつは見るため", + "p1": "パイロットの陣地と機体のあいだには、2つの無線リンクが通っています。操縦リンクは、スティックの位置を機体へ運びます。モデルではExpressLRSを使っています。オープンソースのシステムで、900MHzか2.4GHzで動きます。小さなパケットを一定の間隔で送り、2.4GHzでは毎秒50回から1,000回です。パケットの間隔が長いほど、遠くまで届きます。受信機が1つのパケットを長く聞けるので、弱い信号でも解読できるからです。50Hzでは−115dBmまで聞こえますが、500Hzでは−105dBmまでです。この10dBの差は、見通しのよい場所なら到達距離にして約3.2倍にあたります。低いレートで、高い場所から見通しを保てば、2.4GHzのリンク1本で100kmを超えた例もあります。", + "p2": "映像リンクは逆向きに、機体後部の映像アンテナから戻ってきます。5.8GHzのアナログ映像が昔からの定番です。5つのバンドに40チャンネル、標準画質の映像は機体が遠ざかるにつれてゆっくり砂嵐に変わり、カメラから目までの遅れは約20ミリ秒から30ミリ秒です。デジタル方式は1080pの映像を毎秒100コマ、約20ミリ秒の遅れで送り、最良の機器なら公称で10kmから15km届きます。それでも、質のよいアナログはいまも遅れがいちばん小さい選択肢です。しかも固まるのではなく、少しずつ荒れていきます。チップをタップして比べてください。", + "p3": "どちらのリンクも円偏波のアンテナを使います。電波が進みながら回転するのです。地面や壁で反射すると回転の向きが反転するので、アンテナは跳ね返ってきた信号の大半を退けられます。放っておけば映像をにじませる信号です。ゴーグルはパイロットの目の前に小さな1080pの画面を2枚置き、視野角は44度から46度です。現場のチームはふつう、ナビゲーターも見られるように2台目の画面を足し、送信機のアンテナをマストに上げ、ときには2機目のドローンを高く飛ばして中継にし、尾根の向こうへリンクを届けます。FPVドローンの中には、2つの無線を細い光ファイバーに置き換えたものもあります。10kmから20kmの糸巻きから繰り出すもので、無線リンクがないので妨害するものがありません。", + "p4": "2列目のチップでパケットレートを変え、3つの数字が動くのを見てください。パケットの間隔、受信機がまだ聞き取れるいちばん弱い信号、そして500Hzと比べた到達距離です。下の表示はモデルに合わせて動きます。パイロットからの距離と、操縦リンクの強さです。この短い飛行では機体は約400mより遠くへ行かないので、リンクは強いままです。実際のチームは10kmから20km先まで飛ばし、そこではパケットレートとアンテナと見通しのよさが、映像が途切れないかどうかを決めます。送信出力は最後の手段で、最初の手段ではありません。", + "latency": "映像の遅れ", + "picture": "映像", + "period": "パケットの間隔", + "sensitivity": "まだ聞き取れる最も弱い信号", + "reach": "500Hzと比べた到達距離", + "distance": "いまの距離", + "signal": "操縦リンク" + }, + "power": { + "title": "空に12分", + "p1": "上の板に載ったバッテリーは6Sパック、つまりリチウムセル6本の直列です。LiPoセルなら公称22.2V、満充電で25.2Vです。モデルのLi-ionパックは公称21.6V、満充電で25.2Vです。パイロットはセルあたり約3.5V、パックで21Vになったら着陸し、セルあたり3Vより下には決して下げません。そこまで下がるとセルが傷むからです。このパックは4,200mAh、約91Whで、重さは約450gです。バッテリーのC値は、容量の何倍の電流を流せるかを表します。1,800mAhで95CのLiPoなら、理屈のうえでは171Aを流せます。Li-ionのセルは30Aから45Aです。", + "p2": "長距離機がLi-ionを選ぶのはなぜでしょう。1kgあたりのエネルギーのためです。6Sで1,800mAhのLiPoは、288gに40Whを蓄えます。約139Wh/kgです。Li-ionパックは450gに91Wh、約200Wh/kgで、およそ45%多くなります。その代わり電流が出ません。Li-ionセルはレース機が求める瞬発力を出せませんが、巡航する偵察機には問題ありません。そして巡航こそ、エネルギーがいちばん長持ちする飛び方です。ホバリングは、ほどよい速さで前進するより大きな電力を食います。プロペラは流れる空気の中のほうがよく働くからです。長距離のパイロットは、機体を動かし続け、ホバリングをできるだけ減らすルートを組みます。", + "p3": "スライダーをドラッグして、ドローンに荷物を積んでみましょう。100g増えるごとに全備重量が増え、推力重量比が下がり、ホバリングに必要なスロットルが上がり、飛行時間が縮みます。モデルはパックのエネルギーの80%を使えるものとし、その重さで巡航するのに必要な電力で割ります。カメラを積んだ状態で約12分です。大きなバッテリーにしても長くは効きません。バッテリーが増えれば重さが増え、それを運ぶ電力も増えるので、足したパックほど得られるものが小さくなります。本物の7インチ長距離機は12分から30分飛びます。小さなLiPoを積んだ5インチのフリースタイル機は約4分から6分です。", + "p4": "飛行中の電圧に注目してください。負荷がかかると電圧は落ち込みます。パックの内部抵抗を電流が流れると、そのぶん電圧が食われるからです。機体が力を抜くと戻ります。6Sなら、きつい上昇で21V、ホバリングで23Vを示すこともあります。寒さはこれを悪くします。氷点下20度では、リチウムセルの容量は約65%しか残らず、抵抗も上がるので、低電圧の警告が早く出ます。パイロットは離陸までパックを温めておきます。下の表示は、モデルが飛ぶあいだのパックの電圧と電流です。上のバッテリー計は、この値をもとに満ち欠けします。", + "payloadLabel": "ペイロード", + "weight": "全備重量", + "twr": "推力重量比", + "hover": "ホバリングのスロットル", + "minutes": "飛行時間", + "volts": "いまのパック電圧", + "amps": "いまの電流" + }, + "limits": { + "title": "速くて、安くて、どこにでも", + "p1": "いま見ているのは、パイロットのゴーグルに映る映像です。帰路を飛ぶ機体自身のカメラがとらえたものです。どれだけ速く飛べるのでしょう。この機体のような7インチの長距離機は、最高で時速約140km、巡航は時速60kmから80kmです。レース機はもっと速く、ドローン・レーシング・リーグのRacerXは2017年に公式記録で時速263kmを出しました。世界記録は別世界です。2025年12月11日、南アフリカのルークとマイクのベル親子が作ったPeregreen V4が、バッテリー駆動のクアッドコプターとして時速657.59kmのギネス記録を立てました。記録のためだけに作られた機体で、ふつうのFPVドローンとは別物です。チップをタップすると、それぞれが交差点までの350mを飛ぶのにかかる時間が分かります。", + "p2": "現場での本当の限界は風です。目安は、風速が機体の最高速度の3分の2を下回るときだけ飛ぶこと。この大きさの機体なら、時速約32kmから40kmの安定した風までこなせます。突風のたびにバッテリーが減ります。機体が風に向かって傾かなければならず、傾いた推力は余計に電力を食うからです。FPV機がMavicのようなカメラドローンと違うのも、ここです。Mavicはスティックから手を離すと、GPSと気圧計でその場にとどまります。アクロモードのFPV機は姿勢を保つだけで、風とともに流されます。止められるのはパイロットだけです。", + "p3": "だから習得は簡単ではありません。新しいパイロットの多くは最初の数時間で何度も墜落させるので、まずシミュレーターから始めます。VelocidroneやLiftoffのようなソフトで20時間から60時間練習してから初めて実機を飛ばす、というのがよくある助言です。ゴーグルをかけて飛んだ最初のパイロットたちは2007年ごろに現れ、趣味として始まり、レースが続きました。最初の世界選手権は2018年に深圳で開かれ、34か国から128人のパイロットが参加し、優勝したのは15歳の少年でした。", + "p4": "安くて単純だから、このドローンはどこにでもあります。趣味用の機体は送信機とゴーグルを除いて550ドルから950ドル、フレームのキットだけなら約130ドルから150ドルです。軍のために何千機と作れば機体は約400ドルから500ドルになり、ウクライナは2025年に450万機のFPVドローンを買う計画を立てました。実際の運用では、パイロットから約10kmから20km離れた場所で使われます。もっと遠くへ届かせるために、チームは2機目のドローンに中継器を載せます。ウクライナのあるシステムは、より大きなドローンに載って高さ500mを飛び、1時間とどまって、信号を30km先まで中継します。モデルのパイロットの陣地はずっと簡素です。掩体、ケース、アンテナを立てたマスト、そしてゴーグルをかけたパイロットだけです。", + "speed": "最高速度", + "field": "交差点までの350mにかかる時間", + "who": "どんな機体か" + } + }, + "goggles": { + "caption": "ゴーグル視点" + } +} diff --git a/explainers/fpv-drone/locales/pt.json b/explainers/fpv-drone/locales/pt.json new file mode 100644 index 00000000..f0d173c5 --- /dev/null +++ b/explainers/fpv-drone/locales/pt.json @@ -0,0 +1,290 @@ +{ + "meta": { + "title": "Como voa um drone FPV", + "eyebrow": "Drone FPV", + "tagline": "Quatro motores, uma câmera e um par de óculos: o piloto voa como se estivesse dentro do drone, e cada movimento é só uma mudança na rotação de um motor.", + "description": "Siga em 3D um drone FPV de 7 polegadas num curto voo de reconhecimento: quatro motores sem leme, o giroscópio, dois rádios, a bateria e os limites.", + "summary": "Quatro motores e nenhum leme, uma controladora que corrige milhares de vezes por segundo, dois rádios, a bateria que define o tempo de voo e os limites de velocidade, vento e alcance.", + "socialAlt": "Um quadricóptero FPV camuflado faz uma curva baixa sobre um campo sob um céu cinzento, com as quatro hélices borradas e um feixe de rádio que volta até o abrigo do piloto. Ao lado, o título “Como voa um drone FPV”" + }, + "stage": { + "label": "Modelo interativo de um drone FPV", + "gauge": "Dados de voo em tempo real" + }, + "controls": { + "label": "Controles do drone FPV", + "views": { + "links": { + "name": "Conexões de rádio", + "short": "Conexões", + "hint": "Conexões de rádio (K)" + }, + "track": { + "name": "Trajeto de voo", + "short": "Trajeto", + "hint": "Trajeto de voo (T)" + }, + "arrows": { + "name": "Sentido de giro", + "short": "Setas", + "hint": "Sentido de giro (A)" + }, + "labels": { + "name": "Rótulos", + "short": "Rótulos", + "hint": "Rótulos (L)" + } + }, + "video": { + "label": "Vídeo" + }, + "videoOptions": { + "analogue": "Analógico", + "digital": "Digital" + }, + "momentOptions": { + "liftoff": "Decolagem", + "cruise": "Cruzeiro", + "onStation": "Sobre o cruzamento", + "turnHome": "Curva para casa", + "touchdown": "Pouso" + }, + "moveOptions": { + "hover": "Pairar", + "forward": "Avançar", + "roll": "Rolar para a direita", + "yaw": "Guinar para a direita", + "climb": "Subir" + }, + "modeOptions": { + "acro": "Acro", + "angle": "Angle", + "horizon": "Horizon" + }, + "packetRateOptions": { + "50": "50 Hz", + "150": "150 Hz", + "250": "250 Hz", + "500": "500 Hz" + }, + "speedsterOptions": { + "longRange": "Drone de longo alcance", + "racer": "Drone de corrida", + "record": "Recordista" + } + }, + "timeline": { + "label": "Tempo de voo", + "phases": "Ir para uma parte do voo", + "speed": "Velocidade de reprodução", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "em relação ao tempo real", + "phase": { + "takeoff": "Decolagem", + "climb": "Subida", + "transit": "Ida ao cruzamento", + "orbit": "Sobre o cruzamento", + "return": "Volta", + "landing": "Pouso" + }, + "during": { + "takeoff": "o piloto arma o drone, as hélices giram e ele sai da plataforma e fica pairando baixo", + "climb": "o drone abaixa o nariz, acelera até 70 km/h e sobe a 40 m", + "transit": "o drone cruza o campo em linha reta a 70 km/h", + "orbit": "o drone dá uma volta e meia em torno do cruzamento a 50 m, com a câmera voltada para dentro", + "return": "o drone volta para casa a 70 km/h e se alinha com a plataforma numa curva em S", + "landing": "o drone freia, desce, paira sobre a plataforma e pousa" + }, + "jump": { + "takeoff": "Ir para a decolagem", + "climb": "Ir para a subida", + "transit": "Ir para o voo de ida", + "orbit": "Ir para as voltas sobre o cruzamento", + "return": "Ir para o voo de volta", + "landing": "Ir para o pouso" + } + }, + "readouts": { + "clock": "Tempo de voo", + "speed": "Velocidade", + "height": "Altura", + "battery": "Bateria", + "distance": "Até o piloto" + }, + "units": { + "kmh": "{{value}} km/h", + "m": "{{value}} m", + "g": "{{value}} g", + "kg": "{{value}} kg", + "percent": "{{value}} %", + "ofHover": "{{percent}} % do empuxo para pairar", + "volts": "{{value}} V", + "amps": "{{value}} A", + "ms": "{{value}} ms", + "dbm": "{{value}} dBm", + "minutes": "{{value}} min", + "seconds": "{{value}} s", + "degrees": "{{value}}°", + "ms2": "{{value}} m/s²", + "times": "{{factor}} vezes", + "battery": "{{volts}} V, {{share}} %", + "push": "{{ms2}} m/s², {{g}} g", + "ratio": "{{ratio}} para 1", + "hover": "{{thrust}} % do empuxo máximo, cerca de {{speed}} % da rotação máxima" + }, + "parts": { + "frame": "Frame", + "motorFrontLeft": "Motor 4 (dianteiro esquerdo)", + "motorFrontRight": "Motor 2 (dianteiro direito)", + "motorRearLeft": "Motor 3 (traseiro esquerdo)", + "motorRearRight": "Motor 1 (traseiro direito)", + "propellers": "Hélices", + "battery": "Bateria", + "stack": "Controladora de voo e ESC", + "camera": "Câmera", + "videoAntenna": "Antena de vídeo", + "receiverAntenna": "Antena do receptor", + "gpsModule": "Módulo GPS", + "groundStation": "Posto do piloto", + "controlLink": "Conexão de controle", + "videoLink": "Conexão de vídeo", + "spinArrows": "Sentido de giro", + "crossroads": "Cruzamento", + "launchPad": "Plataforma de decolagem" + }, + "flight": { + "mix": { + "hover": "Os quatro motores dividem a carga por igual: motor 1 a {{m1}} %, motor 2 a {{m2}} %, motor 3 a {{m3}} %, motor 4 a {{m4}} % do empuxo máximo", + "forward": "O par traseiro acelera e o dianteiro desacelera: motor 1 a {{m1}} %, motor 2 a {{m2}} %, motor 3 a {{m3}} %, motor 4 a {{m4}} % do empuxo máximo", + "roll": "O par esquerdo acelera e o direito desacelera: motor 1 a {{m1}} %, motor 2 a {{m2}} %, motor 3 a {{m3}} %, motor 4 a {{m4}} % do empuxo máximo", + "yaw": "Os dois motores que giram no sentido anti-horário aceleram e os dois no sentido horário desaceleram: motor 1 a {{m1}} %, motor 2 a {{m2}} %, motor 3 a {{m3}} %, motor 4 a {{m4}} % do empuxo máximo", + "climb": "Os quatro motores aceleram juntos: motor 1 a {{m1}} %, motor 2 a {{m2}} %, motor 3 a {{m3}} %, motor 4 a {{m4}} % do empuxo máximo" + }, + "why": { + "hover": "Os quatro empuxos somam exatamente o peso do drone, e as duas hélices que giram no sentido horário cancelam a torção das duas que giram ao contrário, então nada gira.", + "forward": "Mais empuxo atrás do que na frente inclina o nariz para baixo. Parte do empuxo agora aponta para a frente, então o drone acelera.", + "roll": "Mais empuxo à esquerda do que à direita derruba o lado direito. Parte do empuxo agora aponta para a direita, então o drone escorrega para lá.", + "yaw": "O empuxo total não muda, mas as torções deixam de se cancelar. O corpo gira no sentido horário, ao contrário das hélices mais rápidas.", + "climb": "O empuxo agora é maior que o peso, então o drone acelera para cima. Nada se inclina, porque os quatro motores mudaram na mesma medida." + } + }, + "mode": { + "stick": { + "acro": "Uma velocidade de giro: com que rapidez rolar ou arfar. Centralize o stick e o drone mantém o ângulo em que estiver", + "angle": "Um ângulo de inclinação: quanto se afastar do nível. Centralize o stick e o drone se nivela sozinho", + "horizon": "Um ângulo de inclinação perto do centro e uma velocidade de giro com o stick no fim de curso: se nivela sozinho, mas ainda consegue dar um flip" + }, + "limit": { + "acro": "Sem limite de inclinação. Com o stick no fim de curso, pede cerca de {{rate}} graus por segundo por padrão", + "angle": "Um limite fixo de inclinação, que também limita a velocidade. Nada de flips nem rolls", + "horizon": "Sem limite no fim de curso, com limite de inclinação perto do centro" + } + }, + "link": { + "latency": { + "analogue": "Cerca de 20 a 30 ms", + "digital": "Cerca de 20 ms, 15 ms nos melhores equipamentos" + }, + "picture": { + "analogue": "Definição padrão, 40 canais para escolher, vira chuvisco com a distância", + "digital": "1080p a 100 quadros por segundo, nítida até cair, cerca de 10 km de alcance" + }, + "signal": { + "strong": "Forte", + "good": "Boa", + "weak": "Fraca", + "lost": "Perdida" + } + }, + "speeds": { + "who": { + "longRange": "Um drone de longo alcance de 7 polegadas como o do modelo: cerca de 140 km/h no máximo, 60 a 80 km/h em cruzeiro", + "racer": "O RacerX da Drone Racing League, de 2017, um drone de corrida construído para um recorde: 263 km/h oficiais", + "record": "Peregreen V4, recordista do Guinness desde 11 de dezembro de 2025: uma máquina feita sob medida para a velocidade, nada parecida com um drone FPV comum" + } + }, + "sections": { + "overview": { + "title": "Quatro motores, uma bateria e um par de óculos", + "p1": "Você está vendo um drone FPV: um pequeno quadricóptero pilotado por alguém que usa óculos e enxerga pela câmera do drone. FPV é a sigla em inglês para visão em primeira pessoa. Este é um modelo de longo alcance de 7 polegadas, e o nome vem das hélices: cada uma tem cerca de 7,5 polegadas de diâmetro, uns 190 mm. O frame, a estrutura, é de fibra de carbono: duas placas e quatro braços. Sem bateria ele pesa de 630 a 705 g; com bateria, chega a cerca de 1 kg. Uma versão para hobby custa de 550 a 950 dólares sem rádio nem óculos. Fabricado em massa para um exército, o drone em si sai por cerca de 400 a 500 dólares.", + "p2": "Drones como este voam em grande número na guerra na Ucrânia. O Ministério da Defesa ucraniano comprou mais de 1,5 milhão de drones em 2024 e planejava comprar 4,5 milhões de drones FPV em 2025. Na prática, eles operam a cerca de 10 a 20 km do piloto. Equipes de reconhecimento os usam para observar estradas, campos e linhas de árvores, e é isso que o drone do modelo faz: sua carga é uma câmera, o olho do piloto.", + "p3": "O modelo acompanha um curto voo de reconhecimento de 80 segundos. O piloto arma o drone ao lado de um abrigo, o drone decola de uma plataforma, sobe a 40 m e segue a 70 km/h. Sobre um cruzamento a 350 m dali, ele dá uma volta e meia a 50 m de altura, depois volta para casa e pousa na plataforma. As distâncias são metros reais, mas um voo de verdade seria bem mais longo. O drone aparece dez vezes maior do que deveria, para que você consiga vê-lo. As velocidades, as alturas e a bateria são escolhas do próprio modelo, dentro das faixas em que um drone real de 7 polegadas voa.", + "p4": "O relógio lá no alto conta segundos reais desde que o drone foi armado, então T+0:42 quer dizer 42 segundos de voo. Arraste a linha do tempo até qualquer momento ou toque num dos botões abaixo para ir direto até lá. Pelo caminho, fique de olho nos mostradores: velocidade, altura, tensão da bateria e a distância entre o drone e o piloto. O botão conexões de rádio desenha os dois feixes entre o drone e o posto do piloto. O botão trajeto de voo desenha o rastro atrás do drone. O botão sentido de giro mostra para que lado cada hélice gira, com o número do motor.", + "battery": "Bateria restante", + "distance": "Distância até o piloto", + "done": "Voo concluído" + }, + "flight": { + "title": "Sem asas nem leme: ele se guia pela rotação dos motores", + "p1": "Um quadricóptero não tem asas, nem leme, nem nenhuma superfície de controle móvel. Cada movimento que ele faz é uma mudança na rotação de um ou mais motores. Para pairar, as quatro hélices juntas precisam empurrar para cima com uma força igual ao peso do drone. Cada motor consegue dar até cerca de 2 kg de empuxo, então um drone de 1,4 kg paira usando só uma pequena parte do que tem. O empuxo cresce com o quadrado da rotação da hélice: dobre a rotação e o empuxo fica quatro vezes maior.", + "p2": "Olhe as setas do sentido de giro: duas hélices giram no sentido horário e duas no sentido anti-horário, em pares diagonais. Há um motivo. Uma hélice girando torce o drone no sentido contrário. Se as quatro girassem no mesmo sentido, o corpo rodaria para o outro. Com duas em cada sentido, as torções se cancelam. Isso também dá ao piloto um jeito de virar: acelere um par e desacelere o outro, e as torções deixam de se cancelar. O drone gira em torno do eixo vertical, ao contrário do par mais rápido. Os pilotos chamam isso de guinada, ou yaw.", + "p3": "Para se inclinar, o drone acelera um lado e desacelera o outro. Acelere os dois motores traseiros e o nariz abaixa: isso é a arfagem. Acelere o par esquerdo e o drone pende para a direita: isso é a rolagem. O empuxo total não muda, mas agora aponta inclinado, e parte dele empurra para o lado. É assim que um quadricóptero se move: ele se inclina, e o empuxo inclinado o arrasta. Toque nos botões para ver quais motores aceleram em cada movimento. Os motores traseiros são o 1 e o 3, como os numera o Betaflight, o programa de controle de voo.", + "p4": "O controle deslizante mostra o que a inclinação faz. Incline o drone 30 graus e ele empurra para o lado com cerca de 0,58 da gravidade, perto de 5,7 m/s². Para manter a altura ao mesmo tempo, ele precisa de cerca de 15 % mais empuxo do que pairando, porque só uma parte do empuxo ainda aponta para cima. A 45 graus, o empuxo lateral se iguala à gravidade e o empuxo total tem de subir 41 %. Drones de freestyle têm empuxo de cerca de quatro a cinco vezes o próprio peso, então pairam com cerca de 20 a 25 % de acelerador e sobra bastante para subir ou disparar. Os de longo alcance têm menos, porque uma bateria maior pesa: este tem cerca de 5,7 para 1, e drones feitos para cargas pesadas chegam a apenas 2 para 1.", + "mix": "Divisão entre motores", + "why": "Por que funciona", + "tiltLabel": "Ângulo de inclinação", + "push": "Empuxo lateral", + "thrust": "Empuxo necessário", + "sprint": "De 0 a 100 km/h" + }, + "controller": { + "title": "Mil correções por segundo", + "p1": "Um quadricóptero não consegue se equilibrar sozinho, e nenhuma pessoa reage rápido o bastante para equilibrá-lo à mão. Quem faz isso é a controladora de voo, uma pequena placa na torre entre as placas do frame. O giroscópio dela mede 8.000 vezes por segundo com que rapidez o drone gira em cada eixo. Um ciclo de controle chamado PID compara isso com o que o piloto pede e corrige a rotação dos quatro motores, 4.000 ou 8.000 vezes por segundo. O P empurra na direção da velocidade pedida, o I elimina a deriva lenta, o D amortece os tremores rápidos. Vários milhares de correções por segundo, a cada segundo do voo.", + "p2": "Embaixo da controladora fica o ESC, o controlador eletrônico de velocidade: quatro numa placa só, de 50 a 55 A por motor. Ele transforma cada novo valor de acelerador em pulsos cronometrados para as três bobinas do seu motor. As duas placas conversam por DShot, um protocolo digital: uma palavra de 16 bits por motor, 11 bits de acelerador, um bit de telemetria e uma soma de verificação, enviada em 27 microssegundos a 600 kilobits por segundo. Com o DShot bidirecional, o ESC também devolve a rotação real do motor, e assim a controladora consegue filtrar a vibração de cada hélice.", + "p3": "O que os sticks comandam? No modo acro, o padrão, cada stick comanda uma velocidade de giro. Leve o stick de rolagem até a metade e o drone rola num ritmo constante; centralize-o e o drone para de rolar e mantém o ângulo a que chegou, mesmo de cabeça para baixo. Por padrão, o stick no fim de curso pede cerca de 670 graus por segundo, quase duas voltas completas. O modo angle é para iniciantes: o stick define um ângulo de inclinação, e o drone se nivela sozinho quando você solta. A maioria dos pilotos FPV voa em acro, dos pilotos de corrida aos de freestyle: esse modo dá controle total no vento e deixa o drone assumir qualquer posição. Toque nos botões para comparar os três modos.", + "p4": "Duas proteções são essenciais. Uma chave no rádio arma o drone, ou seja, liga os motores e a estabilização. A controladora se recusa a armar se o acelerador não estiver no mínimo ou se o drone estiver inclinado demais. Se os pacotes de rádio param de chegar, o failsafe espera 1,5 segundo e então, por padrão, corta os motores e o drone cai. O módulo GPS no seu mastro é opcional. O Betaflight voa sem ele. Com ele, a função GPS Rescue pode levar o drone de volta na direção de onde foi armado se a conexão cair, mas é uma ajuda de emergência, não um retorno automático para casa: o piloto precisa retomar o controle, ou o drone cai.", + "stick": "O que o stick comanda", + "limit": "Limite", + "path": "Como um movimento do stick chega às hélices", + "step1": "Rádio: o piloto move um stick; o rádio lê o movimento e envia um pacote, 250 vezes por segundo no modelo.", + "step2": "Antena do receptor: o receptor no drone decodifica o pacote e entrega os valores dos sticks à controladora de voo.", + "step3": "Giroscópio: a controladora lê com que rapidez o drone está girando, 8.000 vezes por segundo.", + "step4": "Ciclo PID: ela compara a velocidade pedida com a medida e calcula quatro novos valores de acelerador.", + "step5": "ESC: cada valor sai como uma palavra DShot de 16 bits em 27 microssegundos, e o ESC cronometra os pulsos para o motor.", + "step6": "Hélices: as rotações mudam, os empuxos mudam, e o drone se inclina, gira ou sobe." + }, + "link": { + "title": "Dois rádios: um para guiar, outro para ver", + "p1": "Entre o posto do piloto e o drone funcionam duas conexões de rádio. A conexão de controle leva a posição dos sticks até o drone. No modelo, é o ExpressLRS, um sistema de código aberto que funciona em 900 MHz ou 2,4 GHz. Ele envia pacotes pequenos num ritmo fixo, de 50 a 1.000 vezes por segundo em 2,4 GHz. Pacotes mais lentos chegam mais longe: o receptor pode escutar cada um por mais tempo e ainda assim decodificar um sinal mais fraco. A 50 Hz ele ouve sinais até −115 dBm; a 500 Hz, só até −105 dBm. Essa diferença de 10 dB equivale a cerca de 3,2 vezes mais alcance ao ar livre. Em ritmo baixo, com linha de visada a partir de uma altura, uma única conexão de 2,4 GHz já chegou a mais de 100 km.", + "p2": "A conexão de vídeo volta no sentido contrário, a partir da antena de vídeo na cauda. O vídeo analógico em 5,8 GHz é a escolha clássica: 40 canais em cinco bandas, uma imagem em definição padrão que vai virando chuvisco aos poucos conforme o drone se afasta, e um atraso de cerca de 20 a 30 ms da câmera ao olho. Os sistemas digitais mandam uma imagem 1080p a 100 quadros por segundo com atraso de cerca de 20 ms, e os melhores equipamentos têm alcance nominal de 10 a 15 km. Mas um bom analógico ainda é a opção com menor atraso, e ele piora aos poucos em vez de congelar. Toque nos botões para comparar.", + "p3": "As duas conexões usam antenas de polarização circular: a onda gira enquanto viaja. Um reflexo no chão ou numa parede inverte o sentido de giro, então a antena rejeita a maior parte do sinal refletido, que de outro modo borraria a imagem. Os óculos colocam duas pequenas telas 1080p diante dos olhos do piloto, com campo de visão de 44 a 46 graus. Uma equipe em campo costuma acrescentar uma segunda tela para um navegador acompanhar, coloca a antena do rádio num mastro e às vezes põe um segundo drone bem alto como repetidor, para passar as conexões por cima de um morro. Alguns drones FPV trocam os dois rádios por uma fibra óptica finíssima, desenrolada de um carretel de 10 a 20 km: sem conexão de rádio, não há sinal para bloquear.", + "p4": "Use a segunda fileira de botões para mudar a taxa de pacotes e veja três números mudarem: o tempo entre pacotes, o sinal mais fraco que o receptor ainda ouve e o alcance em comparação com 500 Hz. Os mostradores abaixo seguem o modelo: a distância até o piloto e a força da conexão de controle. Neste voo curto, o drone nunca passa de uns 400 m, então a conexão continua forte. Equipes reais trabalham a 10 a 20 km, e aí a taxa de pacotes, as antenas e uma linha de visada limpa decidem se a imagem aguenta. A potência do rádio é a última alavanca, não a primeira.", + "latency": "Atraso do vídeo", + "picture": "Imagem", + "period": "Tempo entre pacotes", + "sensitivity": "Sinal mais fraco que ainda ouve", + "reach": "Alcance em comparação com 500 Hz", + "distance": "Distância agora", + "signal": "Conexão de controle" + }, + "power": { + "title": "Doze minutos no ar", + "p1": "A bateria na placa de cima é um pack 6S: seis células de lítio em série. Com células LiPo, são 22,2 V nominais e 25,2 V quando cheia. O pack de Li-ion do modelo tem 21,6 V nominais e 25,2 V quando cheio. Os pilotos pousam com cerca de 3,5 V por célula, 21 V no pack, e nunca descem de 3 V por célula, porque aí as células se danificam. Este pack guarda 4.200 mAh, cerca de 91 Wh, e pesa cerca de 450 g. A taxa C de uma bateria diz quanta corrente ela consegue fornecer, como múltiplo da capacidade: uma LiPo de 1.800 mAh com 95C poderia, em teoria, entregar 171 A, enquanto uma célula de Li-ion dá de 30 a 45 A.", + "p2": "Por que Li-ion para longo alcance? Pela energia por quilo. Uma LiPo 6S de 1.800 mAh guarda 40 Wh em 288 g, cerca de 139 Wh/kg. O pack de Li-ion guarda 91 Wh em 450 g, cerca de 200 Wh/kg, uns 45 % a mais. O preço é a corrente: células de Li-ion não entregam os picos que um drone de corrida quer, o que não faz diferença para um batedor que voa em cruzeiro. E é em cruzeiro que a energia rende mais. Pairar custa mais potência do que voar para a frente em velocidade moderada, porque as hélices trabalham melhor no ar em movimento. Pilotos de longo alcance planejam rotas que mantêm o drone em movimento e pairam o mínimo possível.", + "p3": "Arraste o controle deslizante para carregar o drone. Cada 100 g a mais aumentam o peso total, reduzem a relação entre empuxo e peso, pedem mais acelerador para pairar e cortam o tempo de voo. O modelo considera úteis 80 % da energia do pack e divide isso pela potência necessária para voar em cruzeiro com aquele peso, o que dá cerca de 12 minutos com a câmera a bordo. Uma bateria maior não resolve por muito tempo: mais bateria é mais peso, e mais peso pede mais potência para carregá-lo, então cada pack a mais rende menos que o anterior. Drones reais de longo alcance de 7 polegadas voam de 12 a 30 minutos; um de freestyle de 5 polegadas com uma LiPo pequena aguenta cerca de 4 a 6.", + "p4": "Observe a tensão durante o voo. Ela cai sob carga, porque a corrente que passa pela resistência interna do pack custa volts, e se recupera quando o drone alivia: um 6S pode marcar 21 V numa subida forte e 23 V pairando. O frio piora tudo. A 20 °C negativos, uma célula de lítio guarda só cerca de 65 % da capacidade, e sua resistência sobe, então o aviso de tensão baixa chega cedo. Os pilotos mantêm os packs aquecidos até a decolagem. Os mostradores abaixo mostram a tensão e a corrente do pack enquanto o modelo voa, e é com eles que o indicador de bateria lá em cima se preenche.", + "payloadLabel": "Carga útil", + "weight": "Peso total", + "twr": "Relação empuxo-peso", + "hover": "Acelerador para pairar", + "minutes": "Tempo de voo", + "volts": "Tensão do pack agora", + "amps": "Corrente agora" + }, + "limits": { + "title": "Rápidos, baratos e por toda parte", + "p1": "Agora você está olhando pelos óculos do piloto: esta é a imagem da própria câmera do drone na volta para casa. Quão rápido ele vai? Um drone de longo alcance de 7 polegadas como este chega a cerca de 140 km/h e voa em cruzeiro a 60 a 80 km/h. Os de corrida são mais rápidos: o RacerX da Drone Racing League marcou 263 km/h oficiais em 2017. O recorde está em outro mundo. Em 11 de dezembro de 2025, o Peregreen V4, construído por Luke e Mike Bell na África do Sul, estabeleceu um recorde do Guinness de 657,59 km/h para um quadricóptero a bateria. É uma máquina feita sob medida, nada parecida com um drone FPV comum. Toque nos botões para ver quanto tempo cada um levaria para percorrer os 350 m até o cruzamento.", + "p2": "O verdadeiro limite em campo é o vento. Uma regra prática diz para voar só quando o vento está abaixo de dois terços da velocidade máxima do drone, e drones deste tamanho aguentam vento constante de cerca de 32 a 40 km/h. Cada rajada custa bateria, porque o drone precisa se inclinar contra ela e o empuxo inclinado pede mais potência. É aqui também que um drone FPV difere de um drone de câmera como um Mavic. Um Mavic segura a posição com GPS e barômetro quando você solta os sticks. Um drone FPV em acro só mantém a atitude e vai embora com o vento. Nada o segura além do piloto.", + "p3": "Por isso é difícil aprender. A maioria dos pilotos novos cai várias vezes nas primeiras horas, então começa num simulador: de 20 a 60 horas num programa como Velocidrone ou Liftoff antes do primeiro voo real é um conselho comum. Os primeiros pilotos a voar com óculos fizeram isso por volta de 2007, como hobby, e as corridas vieram depois. O primeiro campeonato mundial foi disputado em Shenzhen em 2018, com 128 pilotos de 34 países, e quem venceu foi um garoto de 15 anos.", + "p4": "Baratos e simples: é por isso que esses drones estão por toda parte. Um drone de hobby custa de 550 a 950 dólares sem rádio nem óculos; só o kit do frame sai por cerca de 130 a 150 dólares. Fabricado aos milhares para um exército, o drone em si custa cerca de 400 a 500 dólares, e a Ucrânia planejava comprar 4,5 milhões de drones FPV em 2025. Na prática, eles operam a cerca de 10 a 20 km do piloto. Para ir mais longe, as equipes põem um repetidor num segundo drone: um sistema ucraniano vai a bordo de um drone maior a 500 m, fica uma hora no ar e retransmite sinais a até 30 km. O posto do piloto no modelo é bem mais simples: um abrigo, uma maleta, um mastro com antena e um piloto de óculos.", + "speed": "Velocidade máxima", + "field": "Tempo nos 350 m até o cruzamento", + "who": "O que é" + } + }, + "goggles": { + "caption": "Visão dos óculos" + } +} diff --git a/explainers/fpv-drone/locales/uk.json b/explainers/fpv-drone/locales/uk.json new file mode 100644 index 00000000..a7c941c0 --- /dev/null +++ b/explainers/fpv-drone/locales/uk.json @@ -0,0 +1,290 @@ +{ + "meta": { + "title": "Як літає FPV-дрон", + "eyebrow": "FPV-дрон", + "tagline": "Чотири мотори, камера й окуляри: пілот летить ніби зсередини дрона, і кожен рух тут лише зміна обертів моторів.", + "description": "Простежте в 3D короткий розвідувальний виліт 7-дюймового FPV-дрона: чотири мотори без керма, гіроскоп, два радіоканали, батарея й межі можливого.", + "summary": "Чотири мотори й жодного керма, політний контролер, що робить тисячі поправок за секунду, два радіоканали, батарея, яка визначає час польоту, і межі швидкості, вітру й дальності.", + "socialAlt": "Камуфльований FPV-квадрокоптер закладає низький віраж над полем під сірим небом, чотири його гвинти розмиті, а назад до бліндажа пілота тягнеться радіопромінь. Поруч заголовок «Як літає FPV-дрон»" + }, + "stage": { + "label": "Інтерактивна модель FPV-дрона", + "gauge": "Поточні показники польоту" + }, + "controls": { + "label": "Керування моделлю FPV-дрона", + "views": { + "links": { + "name": "Радіоканали", + "short": "Канали", + "hint": "Радіоканали (K)" + }, + "track": { + "name": "Слід польоту", + "short": "Слід", + "hint": "Слід польоту (T)" + }, + "arrows": { + "name": "Напрям обертання", + "short": "Стрілки", + "hint": "Напрям обертання (A)" + }, + "labels": { + "name": "Підписи", + "short": "Підписи", + "hint": "Підписи (L)" + } + }, + "video": { + "label": "Відео" + }, + "videoOptions": { + "analogue": "Аналогове", + "digital": "Цифрове" + }, + "momentOptions": { + "liftoff": "Відрив від землі", + "cruise": "Крейсерський політ", + "onStation": "Над перехрестям", + "turnHome": "Розворот додому", + "touchdown": "Посадка" + }, + "moveOptions": { + "hover": "Зависання", + "forward": "Уперед", + "roll": "Крен праворуч", + "yaw": "Рискання праворуч", + "climb": "Набір висоти" + }, + "modeOptions": { + "acro": "Acro", + "angle": "Angle", + "horizon": "Horizon" + }, + "packetRateOptions": { + "50": "50 Гц", + "150": "150 Гц", + "250": "250 Гц", + "500": "500 Гц" + }, + "speedsterOptions": { + "longRange": "Квадрокоптер для дальніх польотів", + "racer": "Гоночний квадрокоптер", + "record": "Рекордсмен" + } + }, + "timeline": { + "label": "Час польоту", + "phases": "Перейти до етапу польоту", + "speed": "Швидкість відтворення", + "value": "{{time}}, {{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "порівняно з реальним часом", + "phase": { + "takeoff": "Зліт", + "climb": "Набір висоти", + "transit": "Політ до перехрестя", + "orbit": "Над перехрестям", + "return": "Додому", + "landing": "Посадка" + }, + "during": { + "takeoff": "пілот активує дрон, гвинти розкручуються, і він відривається від майданчика й зависає низько над ним", + "climb": "дрон нахиляє ніс, розганяється до 70 км/год і піднімається на 40 м", + "transit": "дрон летить рівно над полем зі швидкістю 70 км/год", + "orbit": "дрон робить півтора кола над перехрестям на висоті 50 м, а камера дивиться всередину кола", + "return": "дрон летить додому зі швидкістю 70 км/год і виходить на майданчик S-подібним маневром", + "landing": "дрон гальмує, знижується, зависає над майданчиком і сідає" + }, + "jump": { + "takeoff": "Перейти до зльоту", + "climb": "Перейти до набору висоти", + "transit": "Перейти до польоту до перехрестя", + "orbit": "Перейти до кіл над перехрестям", + "return": "Перейти до польоту додому", + "landing": "Перейти до посадки" + } + }, + "readouts": { + "clock": "Час польоту", + "speed": "Швидкість", + "height": "Висота", + "battery": "Батарея", + "distance": "До пілота" + }, + "units": { + "kmh": "{{value}} км/год", + "m": "{{value}} м", + "g": "{{value}} г", + "kg": "{{value}} кг", + "percent": "{{value}} %", + "ofHover": "{{percent}} % від тяги зависання", + "volts": "{{value}} В", + "amps": "{{value}} А", + "ms": "{{value}} мс", + "dbm": "{{value}} дБм", + "minutes": "{{value}} хв", + "seconds": "{{value}} с", + "degrees": "{{value}}°", + "ms2": "{{value}} м/с²", + "times": "у {{factor}} раза", + "battery": "{{volts}} В, {{share}} %", + "push": "{{ms2}} м/с², {{g}} g", + "ratio": "{{ratio}} до 1", + "hover": "{{thrust}} % максимальної тяги, приблизно {{speed}} % максимальних обертів" + }, + "parts": { + "frame": "Рама", + "motorFrontLeft": "Мотор 4 (передній лівий)", + "motorFrontRight": "Мотор 2 (передній правий)", + "motorRearLeft": "Мотор 3 (задній лівий)", + "motorRearRight": "Мотор 1 (задній правий)", + "propellers": "Гвинти", + "battery": "Батарея", + "stack": "Політний контролер і ESC", + "camera": "Камера", + "videoAntenna": "Відеоантена", + "receiverAntenna": "Антена приймача", + "gpsModule": "Модуль GPS", + "groundStation": "Позиція пілота", + "controlLink": "Канал керування", + "videoLink": "Відеоканал", + "spinArrows": "Напрям обертання", + "crossroads": "Перехрестя", + "launchPad": "Злітний майданчик" + }, + "flight": { + "mix": { + "hover": "Усі чотири мотори ділять навантаження порівну: мотор 1 на {{m1}} %, мотор 2 на {{m2}} %, мотор 3 на {{m3}} %, мотор 4 на {{m4}} % максимальної тяги", + "forward": "Задня пара пришвидшується, передня сповільнюється: мотор 1 на {{m1}} %, мотор 2 на {{m2}} %, мотор 3 на {{m3}} %, мотор 4 на {{m4}} % максимальної тяги", + "roll": "Ліва пара пришвидшується, права сповільнюється: мотор 1 на {{m1}} %, мотор 2 на {{m2}} %, мотор 3 на {{m3}} %, мотор 4 на {{m4}} % максимальної тяги", + "yaw": "Два мотори, що крутяться проти годинникової стрілки, пришвидшуються, а два, що за стрілкою, сповільнюються: мотор 1 на {{m1}} %, мотор 2 на {{m2}} %, мотор 3 на {{m3}} %, мотор 4 на {{m4}} % максимальної тяги", + "climb": "Усі чотири мотори пришвидшуються разом: мотор 1 на {{m1}} %, мотор 2 на {{m2}} %, мотор 3 на {{m3}} %, мотор 4 на {{m4}} % максимальної тяги" + }, + "why": { + "hover": "Чотири тяги в сумі дорівнюють вазі дрона, а два гвинти, що крутяться за годинниковою стрілкою, гасять закрут двох інших, тож ніщо не обертається.", + "forward": "Ззаду тяга більша, ніж спереду, і ніс опускається. Частина тяги тепер спрямована вперед, тож дрон розганяється.", + "roll": "Ліворуч тяга більша, ніж праворуч, і правий бік просідає. Частина тяги тепер спрямована праворуч, тож дрон зсувається туди.", + "yaw": "Загальна тяга не змінюється, але закрути вже не гасять один одного. Корпус повертається за годинниковою стрілкою, у протилежний бік до швидших гвинтів.", + "climb": "Тяга тепер більша за вагу, тож дрон розганяється вгору. Ніщо не нахиляється, бо всі чотири мотори змінили оберти однаково." + } + }, + "mode": { + "stick": { + "acro": "Швидкість обертання: як швидко крутитися по крену чи тангажу. Поверніть стік у центр, і дрон збереже той кут, якого досяг", + "angle": "Кут нахилу: наскільки відхилитися від горизонту. Поверніть стік у центр, і дрон сам вирівняється", + "horizon": "Біля центру кут нахилу, на повному відхиленні швидкість обертання: дрон сам вирівнюється, але все ще може зробити фліп" + }, + "limit": { + "acro": "Нахил не обмежено. Повне відхилення стіка за замовчуванням задає близько {{rate}} градусів за секунду", + "angle": "Заданий ліміт нахилу, який обмежує і швидкість. Жодних фліпів і бочок", + "horizon": "Без ліміту на повному відхиленні, з лімітом нахилу біля центру" + } + }, + "link": { + "latency": { + "analogue": "Приблизно від 20 до 30 мс", + "digital": "Близько 20 мс, на найкращих комплектах 15 мс" + }, + "picture": { + "analogue": "Стандартна чіткість, 40 каналів на вибір, на відстані картинка тоне в шумі", + "digital": "1080p зі 100 кадрами за секунду, чітка аж до обриву, дальність близько 10 км" + }, + "signal": { + "strong": "Сильний", + "good": "Добрий", + "weak": "Слабкий", + "lost": "Втрачено" + } + }, + "speeds": { + "who": { + "longRange": "7-дюймовий квадрокоптер для дальніх польотів, як у моделі: близько 140 км/год на повній тязі, від 60 до 80 км/год у крейсерському польоті", + "racer": "RacerX від Drone Racing League 2017 року, гоночний квадрокоптер, збудований заради рекорду: офіційно 263 км/год", + "record": "Peregreen V4, рекордсмен Книги Гіннеса з 11 грудня 2025 року: машина, зібрана спеціально заради швидкості, нічого спільного зі звичайним FPV-дроном" + } + }, + "sections": { + "overview": { + "title": "Чотири мотори, батарея й окуляри", + "p1": "Перед вами FPV-дрон: невеликий квадрокоптер, яким пілот керує в окулярах, дивлячись через його камеру. FPV означає «вид від першої особи». Це 7-дюймова модель для дальніх польотів, і назву їй дали гвинти: кожен має близько 7,5 дюйма в діаметрі, тобто близько 190 мм. Рама з вуглепластику: дві пластини й чотири промені. Без батареї дрон важить від 630 до 705 г, з батареєю виходить близько 1 кг. Хобійна версія коштує від 550 до 950 доларів без пульта й окулярів. Планер, зібраний масово для армії, обходиться приблизно від 400 до 500 доларів.", + "p2": "Такі дрони масово літають на війні в Україні. Міністерство оборони України закупило понад 1,5 мільйона дронів у 2024 році й планувало придбати 4,5 мільйона FPV-дронів у 2025-му. На практиці пілот керує ними з відстані приблизно від 10 до 20 км. Розвідувальні групи літають ними, щоб роздивитися дороги, поля й лісосмуги, і саме це робить дрон у моделі. Як корисне навантаження він несе камеру, око пілота.", + "p3": "Модель відтворює один короткий розвідувальний виліт на 80 секунд. Пілот активує дрон біля бліндажа, той злітає з майданчика, піднімається на 40 м і вилітає над полем зі швидкістю 70 км/год. Над перехрестям за 350 м дрон робить півтора кола на висоті 50 м, а потім повертається й сідає на майданчик. Відстані тут справжні, у метрах, але справжній виліт був би набагато довшим. Сам дрон намальовано вдесятеро більшим, щоб його було видно. Швидкості, висоти й використану батарею модель обрала сама, у межах того, як літає справжній 7-дюймовий квадрокоптер.", + "p4": "Годинник угорі відлічує реальні секунди від активації, тож T+0:42 означає 42-гу секунду польоту. Перетягніть шкалу часу до будь-якої миті або натисніть кнопку нижче, щоб одразу туди перейти. Стежте за показниками: швидкість, висота, напруга батареї і відстань від дрона до пілота. Перемикач «Радіоканали» малює два промені між дроном і позицією пілота. Перемикач «Слід польоту» малює шлях позаду дрона. Перемикач «Напрям обертання» показує, куди крутиться кожен гвинт, і номер його мотора.", + "battery": "Залишок батареї", + "distance": "Відстань до пілота", + "done": "Пройдено маршруту" + }, + "flight": { + "title": "Ні крил, ні керма: лише оберти моторів", + "p1": "У квадрокоптера немає ні крил, ні керма, ні взагалі жодних рухомих поверхонь керування. Кожен його рух означає зміну обертів одного або кількох моторів. Щоб зависнути, чотири гвинти разом мають штовхати вгору рівно з силою ваги дрона. Кожен мотор може дати приблизно до 2 кг тяги, тож дрон вагою 1,4 кг висить на малій частці своїх можливостей. Тяга зростає як квадрат обертів гвинта: подвойте оберти, і поштовх зросте вчетверо.", + "p2": "Погляньте на стрілки напряму обертання: два гвинти крутяться за годинниковою стрілкою, два проти, по діагоналі. На те є причина. Гвинт, що крутиться, закручує дрон у відповідь. Якби всі чотири крутилися в один бік, корпус обертався б у протилежний. Коли по два крутяться в кожен бік, закрути гасять один одного. Це ж дає пілотові спосіб повертати: пришвидшіть одну пару й сповільніть другу, і закрути вже не зрівноважені. Дрон обертається навколо вертикальної осі в протилежний бік до швидшої пари. Пілоти називають це yaw, або рисканням.", + "p3": "Щоб нахилитися, дрон пришвидшує один бік і сповільнює інший. Пришвидште два задні мотори, і ніс опуститься: це тангаж. Пришвидште ліву пару, і дрон нахилиться праворуч: це крен. Загальна тяга не змінюється, але тепер вона спрямована під кутом, і частина її штовхає вбік. Саме так рухається квадрокоптер: він нахиляється, а нахилена тяга тягне його за собою. Натискайте кнопки, щоб побачити, які мотори пришвидшуються для кожного руху. Задні мотори мають номери 1 і 3, так їх нумерує політна програма Betaflight.", + "p4": "Повзунок показує, що дає нахил. Нахиліть дрон на 30 градусів, і він штовхатиме вбік із силою близько 0,58 g, тобто майже 5,7 м/с². Щоб водночас утримати висоту, йому потрібно приблизно на 15 % більше тяги, ніж у зависанні, бо вгору тепер дивиться лише частина тяги. На 45 градусах поштовх дорівнює силі тяжіння, а тягу треба підняти на 41 %. Фристайлові квадрокоптери мають приблизно в чотири-п’ять разів більше тяги, ніж важать. Тож для зависання їм вистачає близько 20 чи 25 % газу, і лишається багато запасу на підйом чи ривок. У квадрокоптерів для дальніх польотів запас менший, бо їх обтяжує більша батарея: у цього тяга до ваги приблизно 5,7 до 1, а в дронів для важких вантажів буває й 2 до 1.", + "mix": "Частки моторів", + "why": "Чому це працює", + "tiltLabel": "Кут нахилу", + "push": "Бічний поштовх", + "thrust": "Потрібна тяга", + "sprint": "Від 0 до 100 км/год" + }, + "controller": { + "title": "Тисяча поправок за секунду", + "p1": "Квадрокоптер не може сам тримати рівновагу, а людина не встигає балансувати його вручну. Це робить політний контролер, невелика плата в стеку між пластинами. Його гіроскоп вимірює, як швидко дрон обертається навколо кожної осі, 8 000 разів на секунду. Контур керування, який називають PID, порівнює це з тим, чого просить пілот, і виправляє оберти чотирьох моторів 4 000 або 8 000 разів на секунду. P штовхає до бажаної швидкості обертання, I прибирає повільний дрейф, D гасить швидкі коливання. Кілька тисяч поправок за секунду, кожної секунди польоту.", + "p2": "Під політним контролером стоїть ESC, електронний регулятор обертів: чотири регулятори на одній платі, розраховані на струм від 50 до 55 А на мотор. Він перетворює кожне нове значення газу на точно розраховані імпульси для трьох обмоток свого мотора. Дві плати спілкуються через DShot, цифровий протокол: одне 16-бітне слово на мотор, 11 біт газу, біт телеметрії й контрольна сума, передані за 27 мікросекунд на швидкості 600 кілобіт за секунду. З двонаправленим DShot ESC ще й передає назад справжні оберти мотора, тож контролер може відфільтрувати вібрацію кожного гвинта.", + "p3": "Що задають стіки? У стандартному режимі Acro кожен стік задає швидкість обертання. Відхиліть стік крену наполовину, і дрон крутитиметься зі сталою швидкістю; поверніть його в центр, і дрон зупиниться й збереже досягнутий кут, навіть догори дриґом. За замовчуванням повне відхилення задає близько 670 градусів за секунду, майже дві повні бочки. Режим Angle для початківців: стік задає кут нахилу, а щойно ви його відпускаєте, дрон сам вирівнюється. Більшість FPV-пілотів, від гонщиків до пілотів фристайлу, літає в Acro: він дає повний контроль навіть у вітер і дозволяє дрону прийняти будь-яке положення. Натискайте кнопки, щоб порівняти три режими.", + "p4": "Важливі два запобіжники. Перемикач на пульті активує дрон: вмикає мотори й стабілізацію. Контролер відмовить в активації, якщо газ не на мінімумі або дрон надто нахилений. Якщо радіопакети зникають, failsafe чекає 1,5 секунди, а тоді за замовчуванням вимикає мотори, і дрон падає. Модуль GPS на щоглі необов’язковий. Betaflight літає й без нього. З ним функція GPS Rescue при втраті зв’язку може повести дрон назад до місця, де його активували. Але це аварійна допомога, а не повернення додому: пілот мусить знову взяти керування, інакше дрон розіб’ється.", + "stick": "Що задає стік", + "limit": "Обмеження", + "path": "Як рух стіка доходить до гвинтів", + "step1": "Пульт: пілот рухає стік; пульт зчитує його й надсилає пакет, у моделі 250 разів на секунду.", + "step2": "Антена приймача: приймач на дроні розкодовує пакет і передає значення стіків політному контролеру.", + "step3": "Гіроскоп: політний контролер зчитує, як швидко дрон обертається, 8 000 разів на секунду.", + "step4": "Контур PID: він порівнює бажану швидкість обертання з виміряною й обчислює чотири нові значення газу.", + "step5": "ESC: кожне значення виходить 16-бітним словом DShot за 27 мікросекунд, і ESC розставляє імпульси для мотора.", + "step6": "Гвинти: оберти моторів змінюються, змінюються тяги, і дрон нахиляється, повертається або набирає висоту." + }, + "link": { + "title": "Два радіоканали: один керує, другий показує", + "p1": "Між позицією пілота і дроном працюють два радіоканали. Канал керування несе до дрона положення стіків. У моделі це ExpressLRS, система з відкритим кодом, що працює на 900 МГц або 2,4 ГГц. Вона шле маленькі пакети зі сталою частотою, на 2,4 ГГц від 50 до 1 000 разів на секунду. Рідші пакети долітають далі: приймач може довше слухати кожен із них і розкодувати слабший сигнал. На 50 Гц він чує сигнали аж до −115 дБм, на 500 Гц лише до −105 дБм. Ця різниця в 10 дБ дає приблизно у 3,2 раза більшу дальність на відкритому просторі. На низькій частоті, у прямій видимості з висоти, один канал 2,4 ГГц діставав далі ніж на 100 км.", + "p2": "Відеоканал іде у зворотний бік, від відеоантени в хвості. Класикою лишається аналогове відео на 5,8 ГГц: 40 каналів у п’яти діапазонах, картинка стандартної чіткості, яка з відстанню м’яко тоне в шумі, і затримка приблизно від 20 до 30 мс від камери до ока. Цифрові системи передають картинку 1080p зі 100 кадрами за секунду із затримкою близько 20 мс, а найкращі комплекти розраховані на дальність від 10 до 15 км. Але добре аналогове відео й досі має найменшу затримку, і воно погіршується поступово, а не завмирає. Натискайте кнопки, щоб порівняти.", + "p3": "Обидва канали використовують антени з круговою поляризацією: хвиля обертається, поки летить. Відбиття від землі чи стіни змінює напрям обертання, тож антена відкидає більшу частину відбитого сигналу, який інакше розмазав би картинку. Окуляри ставлять перед очима пілота два маленькі екрани 1080p із полем зору від 44 до 46 градусів. Польова група зазвичай додає другий екран, щоб штурман теж бачив, піднімає антену пульта на щоглу, а іноді запускає високо другий дрон як ретранслятор, щоб перекинути канали через пагорб. Деякі FPV-дрони взагалі обходяться без радіо: обидва канали йдуть тонким оптоволокном, що розмотується з котушки завдовжки від 10 до 20 км, і глушити тут нічого.", + "p4": "Другим рядом кнопок змініть частоту пакетів і стежте за трьома числами: час між пакетами, найслабший сигнал, який приймач ще чує, і дальність порівняно з 500 Гц. Показники нижче йдуть за моделлю: відстань до пілота й сила каналу керування. У цьому короткому вильоті дрон не віддаляється далі ніж на 400 м, тож зв’язок лишається сильним. Справжні групи працюють на відстані від 10 до 20 км, і там частота пакетів, антени й чиста лінія видимості вирішують, чи втримається картинка. Потужність радіо тут останній важіль, а не перший.", + "latency": "Затримка відео", + "picture": "Картинка", + "period": "Час між пакетами", + "sensitivity": "Найслабший сигнал, який ще чути", + "reach": "Дальність порівняно з 500 Гц", + "distance": "Відстань зараз", + "signal": "Канал керування" + }, + "power": { + "title": "Дванадцять хвилин у повітрі", + "p1": "Батарея на верхній пластині має схему 6S: шість літієвих елементів послідовно. З елементами LiPo це 22,2 В номінально й 25,2 В у повному заряді. Збірка Li-ion у моделі має 21,6 В номінально й 25,2 В у повному заряді. Пілоти сідають приблизно на 3,5 В на елемент, тобто 21 В на збірку, і ніколи не опускаються нижче 3 В на елемент, бо там елементи псуються. Ця збірка вміщує 4 200 мА·год, близько 91 Вт·год, і важить близько 450 г. Рейтинг C показує, який струм батарея може віддати, у кратних її ємності: LiPo на 1 800 мА·год із рейтингом 95C теоретично видасть 171 А, тоді як елемент Li-ion тягне від 30 до 45 А.", + "p2": "Чому для дальніх польотів беруть Li-ion? Через енергію на кілограм. LiPo 6S на 1 800 мА·год зберігає 40 Вт·год у 288 г, близько 139 Вт·год/кг. Збірка Li-ion зберігає 91 Вт·год у 450 г, близько 200 Вт·год/кг, тобто приблизно на 45 % більше. Платити за це доводиться струмом: елементи Li-ion не дають ривків, яких хоче гоночний квадрокоптер, але розвіднику, що летить рівно, це й не потрібно. А саме в рівному польоті енергія йде найдалі. Зависання коштує більше потужності, ніж політ уперед із помірною швидкістю, бо гвинти працюють краще в рухомому повітрі. Пілоти, що літають далеко, планують маршрути так, щоб дрон рухався і висів якомога менше.", + "p3": "Потягніть повзунок, щоб навантажити дрон. Кожні зайві 100 г збільшують злітну вагу, зменшують співвідношення тяги до ваги, підвищують газ, потрібний для зависання, і скорочують час польоту. Модель вважає корисними 80 % енергії збірки й ділить їх на потужність, потрібну для крейсерського польоту з такою вагою. З камерою на борту виходить близько 12 хвилин. Більша батарея допомагає недовго: більше батареї означає більше ваги, а більше ваги означає більше потужності, щоб її нести, тож кожна додаткова збірка дає менше за попередню. Справжні 7-дюймові квадрокоптери для дальніх польотів літають від 12 до 30 хвилин, а 5-дюймовий фристайловий на маленькому LiPo тримається в повітрі приблизно від 4 до 6 хвилин.", + "p4": "Стежте за напругою під час польоту. Під навантаженням вона просідає, бо струм через внутрішній опір збірки з’їдає вольти, і відновлюється, коли дрон скидає газ: 6S може показати 21 В у різкому підйомі й 23 В у зависанні. Холод усе погіршує. При мінус 20 °C літієвий елемент зберігає лише близько 65 % ємності, а його опір зростає, тож попередження про низьку напругу з’являється рано. Пілоти тримають збірки в теплі до самого запуску. Показники нижче показують напругу й струм збірки, поки модель летить, і за ними ж заповнюється індикатор батареї вгорі.", + "payloadLabel": "Корисне навантаження", + "weight": "Злітна вага", + "twr": "Тяга до ваги", + "hover": "Газ для зависання", + "minutes": "Час польоту", + "volts": "Напруга збірки зараз", + "amps": "Струм зараз" + }, + "limits": { + "title": "Швидкі, дешеві й усюди", + "p1": "Тепер ви дивитеся через окуляри пілота: це картинка з власної камери дрона, який летить додому. Як швидко він може летіти? 7-дюймовий квадрокоптер для дальніх польотів, як цей, розганяється приблизно до 140 км/год, а крейсерська швидкість у нього від 60 до 80 км/год. Гоночні швидші: RacerX від Drone Racing League у 2017 році офіційно показав 263 км/год. А рекорд належить зовсім іншому світу. 11 грудня 2025 року Peregreen V4, збудований Люком і Майком Беллами з Південної Африки, встановив рекорд Гіннеса для квадрокоптера на батареї: 657,59 км/год. Це машина, зібрана спеціально заради рекорду, нічого спільного зі звичайним FPV-дроном. Натискайте кнопки, щоб побачити, за скільки кожен із них подолав би 350 м до перехрестя.", + "p2": "У полі справжню межу ставить вітер. Правило таке: літати лише тоді, коли вітер слабший за дві третини максимальної швидкості дрона, а квадрокоптери такого розміру витримують стійкий вітер приблизно від 32 до 40 км/год. Кожен порив коштує заряду, бо дрон мусить нахилитися проти вітру, а нахилена тяга потребує більше потужності. Тут же й різниця між FPV-квадрокоптером і дроном із камерою на кшталт Mavic. Mavic тримає позицію за GPS і барометром, щойно ви відпускаєте стіки. FPV-квадрокоптер в Acro тримає лише своє положення й пливе за вітром. Зупинити його може тільки пілот.", + "p3": "Через це вчитися важко. Більшість новачків у перші години розбиваються раз у раз, тож починають із симулятора. Звична порада така: перед першим справжнім польотом провести від 20 до 60 годин у програмі на кшталт Velocidrone чи Liftoff. Перші пілоти полетіли в окулярах близько 2007 року, як хобі, а за цим прийшли перегони. Перший чемпіонат світу відбувся в Шеньчжені у 2018 році: 128 пілотів із 34 країн, а переміг 15-річний хлопець.", + "p4": "Дешевизна й простота: ось чому ці дрони всюди. Хобійний квадрокоптер коштує від 550 до 950 доларів без пульта й окулярів, а сам набір рами приблизно від 130 до 150 доларів. Зібраний тисячами для армії, планер обходиться приблизно від 400 до 500 доларів, і Україна планувала придбати 4,5 мільйона FPV-дронів у 2025 році. На практиці пілот керує ними з відстані приблизно від 10 до 20 км. Щоб дістати далі, групи піднімають ретранслятор на другому дроні: одна українська система летить на більшому дроні на висоті 500 м, тримається в повітрі годину й передає сигнали на відстань до 30 км. Позиція пілота в моделі значно простіша: бліндаж, кейс, щогла з антеною й пілот в окулярах.", + "speed": "Максимальна швидкість", + "field": "Час на 350 м до перехрестя", + "who": "Що це" + } + }, + "goggles": { + "caption": "Вид з окулярів" + } +} diff --git a/explainers/fpv-drone/locales/zh.json b/explainers/fpv-drone/locales/zh.json new file mode 100644 index 00000000..7d3358de --- /dev/null +++ b/explainers/fpv-drone/locales/zh.json @@ -0,0 +1,290 @@ +{ + "meta": { + "title": "FPV无人机是怎么飞的", + "eyebrow": "FPV无人机", + "tagline": "四台电机、一只摄像头、一副眼镜:飞手像坐在无人机里一样飞,每一个动作都只是改一下电机转速。", + "description": "用3D模型跟随一架7英寸FPV无人机完成一次短途侦察飞行:没有舵的四台电机、陀螺仪控制环、两条无线电链路、电池和极限。", + "summary": "四台电机却没有舵,每秒修正几千次的飞控,两条无线电链路,决定续航的电池,还有速度、风和航程的极限。", + "socialAlt": "灰色天空下,一架迷彩涂装的FPV四旋翼无人机在田野上方低空倾斜转弯,四副螺旋桨转成模糊的圆盘,一道无线电波束连回飞手的掩体。旁边是标题:FPV无人机是怎么飞的" + }, + "stage": { + "label": "FPV无人机互动模型", + "gauge": "实时飞行读数" + }, + "controls": { + "label": "FPV无人机模型控制", + "views": { + "links": { + "name": "无线电链路", + "short": "链路", + "hint": "无线电链路(K)" + }, + "track": { + "name": "飞行航迹", + "short": "航迹", + "hint": "飞行航迹(T)" + }, + "arrows": { + "name": "旋转方向", + "short": "箭头", + "hint": "旋转方向(A)" + }, + "labels": { + "name": "标注", + "short": "标注", + "hint": "标注(L)" + } + }, + "video": { + "label": "图传" + }, + "videoOptions": { + "analogue": "模拟", + "digital": "数字" + }, + "momentOptions": { + "liftoff": "离地", + "cruise": "巡航", + "onStation": "到达路口", + "turnHome": "转向返航", + "touchdown": "着陆" + }, + "moveOptions": { + "hover": "悬停", + "forward": "前飞", + "roll": "向右横滚", + "yaw": "向右偏航", + "climb": "爬升" + }, + "modeOptions": { + "acro": "手动模式", + "angle": "自稳模式", + "horizon": "半自稳模式" + }, + "packetRateOptions": { + "50": "50赫兹", + "150": "150赫兹", + "250": "250赫兹", + "500": "500赫兹" + }, + "speedsterOptions": { + "longRange": "远航无人机", + "racer": "竞速无人机", + "record": "纪录保持者" + } + }, + "timeline": { + "label": "飞行时间", + "phases": "跳到飞行的某个阶段", + "speed": "播放速度", + "value": "{{time}},{{phase}}", + "clock": "T+{{clock}}", + "speedFormat": "{{factor}}×", + "speedValue": "相对于真实时间", + "phase": { + "takeoff": "起飞", + "climb": "爬升", + "transit": "飞往路口", + "orbit": "路口上空", + "return": "返航", + "landing": "着陆" + }, + "during": { + "takeoff": "飞手解锁无人机,螺旋桨转起来,无人机离开起降板,在低空悬停", + "climb": "无人机低下机头,加速到每小时70千米,爬升到40米", + "transit": "无人机以每小时70千米直线飞过田野", + "orbit": "无人机在50米高度绕路口飞一圈半,摄像头朝向圈内", + "return": "无人机以每小时70千米返航,用一个S形弯对准起降板", + "landing": "无人机减速、下降,在起降板上方悬停,然后落地" + }, + "jump": { + "takeoff": "跳到起飞", + "climb": "跳到爬升", + "transit": "跳到去程", + "orbit": "跳到绕路口盘旋", + "return": "跳到返航", + "landing": "跳到着陆" + } + }, + "readouts": { + "clock": "飞行时间", + "speed": "速度", + "height": "高度", + "battery": "电池", + "distance": "离飞手" + }, + "units": { + "kmh": "{{value}}千米/小时", + "m": "{{value}}米", + "g": "{{value}}克", + "kg": "{{value}}千克", + "percent": "{{value}}%", + "ofHover": "悬停推力的{{percent}}%", + "volts": "{{value}}伏", + "amps": "{{value}}安", + "ms": "{{value}}毫秒", + "dbm": "{{value}} dBm", + "minutes": "{{value}}分钟", + "seconds": "{{value}}秒", + "degrees": "{{value}}°", + "ms2": "{{value}}米/秒²", + "times": "{{factor}}倍", + "battery": "{{volts}}伏,{{share}}%", + "push": "{{ms2}}米/秒²,{{g}}倍重力加速度", + "ratio": "{{ratio}}比1", + "hover": "最大推力的{{thrust}}%,约为最高转速的{{speed}}%" + }, + "parts": { + "frame": "机架", + "motorFrontLeft": "4号电机(左前)", + "motorFrontRight": "2号电机(右前)", + "motorRearLeft": "3号电机(左后)", + "motorRearRight": "1号电机(右后)", + "propellers": "螺旋桨", + "battery": "电池", + "stack": "飞控和电调", + "camera": "摄像头", + "videoAntenna": "图传天线", + "receiverAntenna": "接收机天线", + "gpsModule": "GPS模块", + "groundStation": "飞手阵地", + "controlLink": "控制链路", + "videoLink": "图传链路", + "spinArrows": "旋转方向", + "crossroads": "路口", + "launchPad": "起降板" + }, + "flight": { + "mix": { + "hover": "四台电机平均分担:1号电机{{m1}}%,2号电机{{m2}}%,3号电机{{m3}}%,4号电机{{m4}}%(均指最大推力)", + "forward": "后面一对加速,前面一对减速:1号电机{{m1}}%,2号电机{{m2}}%,3号电机{{m3}}%,4号电机{{m4}}%(均指最大推力)", + "roll": "左边一对加速,右边一对减速:1号电机{{m1}}%,2号电机{{m2}}%,3号电机{{m3}}%,4号电机{{m4}}%(均指最大推力)", + "yaw": "逆时针转的两台加速,顺时针转的两台减速:1号电机{{m1}}%,2号电机{{m2}}%,3号电机{{m3}}%,4号电机{{m4}}%(均指最大推力)", + "climb": "四台电机一起加速:1号电机{{m1}}%,2号电机{{m2}}%,3号电机{{m3}}%,4号电机{{m4}}%(均指最大推力)" + }, + "why": { + "hover": "四股推力加起来正好等于无人机的重量,两副顺时针转的桨抵消了两副逆时针转的桨的反扭力,所以机身不会转。", + "forward": "后面的推力比前面大,机头就低下去。一部分推力现在朝前,无人机于是加速。", + "roll": "左边的推力比右边大,右侧就沉下去。一部分推力现在朝右,无人机于是向右滑过去。", + "yaw": "总推力不变,可反扭力不再抵消。机身顺时针转动,方向和转得更快的那两副桨相反。", + "climb": "推力现在大于重量,无人机于是向上加速。机身不会倾斜,因为四台电机改变的量一样多。" + } + }, + "mode": { + "stick": { + "acro": "一个转动速率:横滚或俯仰转多快。摇杆回中,无人机就保持当前到达的角度", + "angle": "一个倾斜角度:偏离水平多远。摇杆回中,无人机自己回到水平", + "horizon": "摇杆靠近中位时给角度,打到底时给转动速率:能自动回平,也还能翻跟头" + }, + "limit": { + "acro": "不限倾角。摇杆打到底,默认要求每秒约{{rate}}度", + "angle": "有固定的倾角上限,速度也因此受限。不能翻跟头或横滚", + "horizon": "摇杆打到底时不限,靠近中位时有倾角上限" + } + }, + "link": { + "latency": { + "analogue": "约20到30毫秒", + "digital": "约20毫秒,最好的设备15毫秒" + }, + "picture": { + "analogue": "标清,40个频道可选,距离一远画面就变成雪花", + "digital": "1080p、每秒100帧,断开前一直清晰,航程约10千米" + }, + "signal": { + "strong": "强", + "good": "良好", + "weak": "弱", + "lost": "丢失" + } + }, + "speeds": { + "who": { + "longRange": "像模型里这样的7英寸远航无人机:全速约每小时140千米,巡航每小时60到80千米", + "racer": "无人机竞速联盟2017年的RacerX,为破纪录而造的竞速机:官方成绩每小时263千米", + "record": "Peregreen V4,2025年12月11日起的吉尼斯纪录保持者:专为速度打造的机器,和普通FPV无人机完全不是一回事" + } + }, + "sections": { + "overview": { + "title": "四台电机、一块电池、一副眼镜", + "p1": "你眼前是一架FPV无人机:一架小型四旋翼机,飞手戴着眼镜,透过它的摄像头看画面来飞。FPV是英文“第一人称视角”的缩写。这一架是7英寸远航型,名字来自螺旋桨:每副桨直径约7.5英寸,也就是约190毫米。机架是碳纤维的,两块板加四根机臂。不带电池重630到705克,装上电池约1千克。玩家版不含遥控器和眼镜,售价550到950美元。为军队批量生产的机体,每架约400到500美元。", + "p2": "这样的无人机在乌克兰的战争中被大量使用。乌克兰国防部2024年采购了超过150万架无人机,并计划在2025年采购450万架FPV无人机。实际使用时,它们通常在离飞手约10到20千米的地方作业。侦察小组用它们查看道路、田野和林带,模型里的无人机做的正是这件事:它的载荷是一只摄像头,也就是飞手的眼睛。", + "p3": "模型演示一次80秒的短途侦察飞行。飞手在掩体旁解锁无人机,无人机从起降板起飞,爬升到40米,以每小时70千米飞出去。到了350米外的路口上空,它在50米高度绕一圈半,然后返航,落在起降板上。距离都是真实的米数,但真正的侦察飞行要长得多。无人机画大了十倍,不然你看不见它。速度、高度和所用的电池都是模型自己定的,不过都在真实7英寸无人机的范围之内。", + "p4": "顶部的时钟从解锁那一刻起按真实秒数计时,所以T+0:42就是飞了42秒。你可以把时间轴拖到任何时刻,也可以点下方的按钮直接跳过去。一边看一边留意读数:速度、高度、电池电压,还有无人机离飞手有多远。打开无线电链路开关,会画出无人机和飞手阵地之间的两道波束。打开飞行航迹开关,无人机身后会画出飞过的路线。打开旋转方向开关,会显示每副桨朝哪个方向转,还有它的电机编号。", + "battery": "剩余电量", + "distance": "离飞手的距离", + "done": "飞行进度" + }, + "flight": { + "title": "没有机翼,没有舵:靠电机转速来转向", + "p1": "四旋翼机没有机翼,没有舵,也没有任何活动的操纵面。它的每一个动作,都是一台或几台电机转速的变化。要悬停,四副螺旋桨合起来向上的推力必须正好等于无人机的重量。每台电机最大能给出约2千克推力,所以一架1.4千克的无人机悬停时只用了一小部分力气。推力和螺旋桨转速的平方成正比:转速翻一倍,推力就变成四倍。", + "p2": "看看旋转方向的箭头:两副桨顺时针转,两副逆时针转,对角线上的一对方向相同。这是有原因的。桨转起来,会反过来扭动无人机。如果四副桨朝同一个方向转,机身就会朝反方向转。两副朝一边、两副朝另一边,反扭力就互相抵消了。这也给了飞手一个转向的办法:让一对加速、另一对减速,反扭力就不再抵消。无人机绕着竖直轴转动,方向和转得快的那一对相反。飞手把这叫作偏航。", + "p3": "要倾斜,无人机就让一边加速、另一边减速。两台后电机加速,机头就低下去,这叫俯仰。左边一对加速,无人机就向右倾,这叫横滚。总推力不变,但现在是斜着的,有一部分推力推向侧面。四旋翼机就是这样移动的:先倾斜,再让倾斜的推力拖着它走。点一下按钮,看看每种动作是哪些电机在加速。后面两台电机的编号是1和3,这是飞控软件Betaflight的编法。", + "p4": "滑块展示倾斜会带来什么。把无人机倾斜30度,它就以约0.58倍重力加速度向侧面推,接近5.7米/秒²。要同时保住高度,它需要比悬停多约15%的推力,因为只剩一部分推力还朝上。到45度时,侧向推力等于重力,总推力得增加41%。花飞无人机的推力大约是自身重量的四到五倍,所以悬停只需约20%到25%的油门,剩下大量余量用来爬升或冲刺。远航无人机的推重比要低一些,因为更大的电池更重:这一架约为5.7比1,重载机型甚至低到2比1。", + "mix": "电机分配", + "why": "为什么行得通", + "tiltLabel": "倾斜角度", + "push": "侧向推力", + "thrust": "所需推力", + "sprint": "0到100千米/小时" + }, + "controller": { + "title": "每秒一千次修正", + "p1": "四旋翼机自己站不稳,人的反应也远远不够快,没法靠手去平衡它。做这件事的是飞控,也就是两块板之间那叠电路板里的一块小板。它的陀螺仪每秒测量8000次无人机绕每根轴转动的速率。一个叫PID的控制环把测量值和飞手的指令作比较,然后修正四台电机的转速,每秒4000次或8000次。P把转速推向指令要求的速率,I消除缓慢的漂移,D压住快速的抖动。每秒几千次修正,飞行中的每一秒都是如此。", + "p2": "飞控下面是电调,也就是电子调速器,英文简称ESC:四路做在一块板上,每台电机50到55安。它把每一个新的油门值变成有精确时序的脉冲,送进电机的三组绕组。两块板之间用一种叫DShot的数字协议通信:每台电机一个16位的数据帧,其中11位是油门,1位是遥测请求,还有校验码,以每秒600千比特的速率在27微秒内发完。如果用双向DShot,电调还会把电机的真实转速回传,飞控就能滤掉每副桨带来的振动。", + "p3": "摇杆到底在指挥什么?在默认的手动模式里,每根摇杆指挥的是一个转动速率。横滚杆推到一半,无人机就以稳定的速率滚转;杆一回中,无人机就停止滚转,保持已经到达的角度,哪怕是倒过来的。默认设置下,杆打到底要求每秒约670度,差不多两整圈。自稳模式是给新手的:摇杆给出一个倾斜角度,一松手无人机就自己回到水平。从竞速到花式飞行,多数FPV飞手都用手动模式:它在风里给出完全的控制权,也允许无人机摆出任何姿态。点一下按钮,比较三种模式。", + "p4": "有两项安全功能很重要。遥控器上的一个开关负责解锁,解锁后电机和增稳才开始工作。如果油门没收到底,或者无人机倾斜过大,飞控会拒绝解锁。要是无线电数据包断了,失控保护会等1.5秒,然后默认关掉电机,无人机掉下来。桅杆上的GPS模块是可选的。Betaflight没有它也能飞。有了它,失联时GPS救援功能可以把无人机往解锁地点的方向带回去,但那只是应急手段,不是自动返航:飞手必须重新接管,否则无人机会坠毁。", + "stick": "摇杆指挥什么", + "limit": "限制", + "path": "摇杆的动作怎样传到螺旋桨", + "step1": "遥控器:飞手推动摇杆;遥控器读出位置,发出一个数据包,模型里每秒发250次。", + "step2": "接收机天线:无人机上的接收机解码数据包,把摇杆数值交给飞控。", + "step3": "陀螺仪:飞控读取无人机的转动速率,每秒8000次。", + "step4": "PID控制环:把指令要求的速率和测到的速率作比较,算出四个新的油门值。", + "step5": "电调:每个油门值变成一个16位的DShot数据帧,在27微秒内发出,电调据此安排送往电机的脉冲。", + "step6": "螺旋桨:电机转速变了,推力变了,无人机于是倾斜、转向或爬升。" + }, + "link": { + "title": "两套无线电:一套用来操控,一套用来看", + "p1": "飞手阵地和无人机之间跑着两条无线电链路。控制链路把摇杆位置送出去。模型里用的是ExpressLRS,一套开源系统,工作在900兆赫或2.4吉赫。它以固定频率发送小数据包,在2.4吉赫上每秒50到1000次。包发得越慢,传得越远:接收机对每个包能多听一会儿,更弱的信号也能解出来。50赫兹时,它能听到弱至−115 dBm的信号;500赫兹时只能到−105 dBm。这10分贝的差别,在开阔地上大约相当于3.2倍的距离。用低频率、在高处保持视距,单条2.4吉赫链路曾传到100多千米外。", + "p2": "图传链路反方向传回来,从机尾的图传天线发出。5.8吉赫模拟图传是经典选择:五个频段共40个频道,标清画面,无人机飞远时画面慢慢变成雪花,从摄像头到眼睛的延迟约20到30毫秒。数字系统传的是1080p、每秒100帧的画面,延迟约20毫秒,最好的设备标称可达10到15千米。但好的模拟图传仍然是延迟最低的选择,而且它是慢慢变差,而不是突然卡住。点一下按钮比比看。", + "p3": "两条链路都用圆极化天线:电波一边前进一边旋转。碰到地面或墙壁反射回来,旋转方向就反了过来,天线因此能挡掉大部分反射信号,不然画面会被它们弄花。眼镜把两块小小的1080p屏幕放在飞手眼前,视场角44到46度。野外小组通常会再加一块屏幕让领航员一起看,把遥控天线架到桅杆上,有时还会放一架无人机飞到高空当中继,把链路送过山脊。有些FPV无人机干脆不用这两套无线电,改用一根细细的光纤,从10到20千米长的线轴上放出来:没有无线电链路,也就没什么可干扰的。", + "p4": "用第二排按钮改变数据包频率,看三个数字怎么变:数据包的间隔、接收机还能听到的最弱信号,以及和500赫兹相比的距离。下面的读数跟着模型走:离飞手的距离,还有控制链路有多强。这趟短途飞行里,无人机离飞手最远不过约400米,所以链路一直很强。真实的小组在10到20千米外作业,那里数据包频率、天线和一条干净的视线决定了画面保不保得住。发射功率是最后一个手段,不是第一个。", + "latency": "图传延迟", + "picture": "画面", + "period": "数据包间隔", + "sensitivity": "还能听到的最弱信号", + "reach": "与500赫兹相比的距离", + "distance": "当前距离", + "signal": "控制链路" + }, + "power": { + "title": "在天上飞十二分钟", + "p1": "顶板上的电池是一组6S电池:六节锂电芯串联。用LiPo电芯的话,标称22.2伏,充满25.2伏。模型里用的Li-ion电池组标称21.6伏,充满25.2伏。飞手一般在每节约3.5伏、整组21伏时降落,绝不让每节低于3伏,再低电芯就会受损。这组电池容量4200毫安时,约91瓦时,重约450克。电池的C值说明它能放出多大的电流,以容量的倍数表示:一块1800毫安时、95C的LiPo理论上能放出171安,而一节Li-ion电芯只有30到45安。", + "p2": "远航为什么用Li-ion?为了每千克的能量。一块6S、1800毫安时的LiPo在288克里装了40瓦时,约139瓦时/千克。Li-ion电池组在450克里装了91瓦时,约200瓦时/千克,大约多了45%。代价是电流:Li-ion电芯给不出竞速机想要的爆发力,但对一架巡航的侦察机来说无所谓。而在巡航时,同样的能量能飞得最远。悬停比以中等速度前飞更耗功率,因为螺旋桨在流动的空气里效率更高。远航飞手规划航线时,会让无人机一直动起来,尽量少悬停。", + "p3": "拖动滑块给无人机增加载荷。每多100克,全重就增加,推重比就下降,悬停所需的油门就上升,续航就缩短。模型把电池能量的80%算作可用,再除以这个重量下巡航所需的功率,带着摄像头得出约12分钟。换更大的电池管不了多久:电池越多,重量越大,带着它飞就要更大的功率,所以每多加一块电池,收益都比上一块小。真实的7英寸远航无人机能飞12到30分钟;用小LiPo的5英寸花飞机只能飞约4到6分钟。", + "p4": "飞行时留意电压。负载一上来电压就往下掉,因为电流流过电池组的内阻会吃掉电压;无人机一松油门,电压又会回升:一组6S在猛拉爬升时可能只有21伏,悬停时却有23伏。天冷会更糟。零下20摄氏度时,锂电芯只剩约65%的容量,内阻也会上升,所以低电压警报来得更早。飞手会把电池捂热,直到起飞。下方的读数显示模型飞行时电池组的电压和电流,顶部的电量表也是根据它们填充的。", + "payloadLabel": "载荷", + "weight": "全重", + "twr": "推重比", + "hover": "悬停油门", + "minutes": "续航", + "volts": "当前电池电压", + "amps": "当前电流" + }, + "limits": { + "title": "快、便宜、到处都是", + "p1": "现在你是透过飞手的眼镜在看:这是无人机返航时它自己的摄像头拍到的画面。它能飞多快?像这样的7英寸远航无人机最高约每小时140千米,巡航每小时60到80千米。竞速机更快:无人机竞速联盟的RacerX在2017年创下每小时263千米的官方成绩。纪录则是另一个世界。2025年12月11日,南非的卢克·贝尔和迈克·贝尔父子打造的Peregreen V4以每小时657.59千米创下电池动力四旋翼机的吉尼斯纪录。那是一台专门打造的机器,和普通FPV无人机完全不是一回事。点一下按钮,看看每一架飞完到路口的350米各要多久。", + "p2": "野外真正的限制是风。有一条经验法则:只在风速低于无人机最高速度三分之二时飞,这个尺寸的无人机能顶住约每小时32到40千米的稳定风。每一阵风都要耗电,因为无人机得迎风倾斜,而倾斜的推力需要更多功率。FPV无人机和Mavic这类航拍机的区别也在这里。你一松开摇杆,Mavic就靠GPS和气压计定在原地。手动模式下的FPV无人机只保持姿态,会随风飘走。除了飞手,没有什么能拦住它。", + "p3": "所以它很难学。多数新手在最初几个小时里一次次摔机,于是先从模拟器练起:在Velocidrone或Liftoff这类程序里飞20到60小时,再去真飞第一次,这是常见的建议。最早戴眼镜飞的飞手出现在2007年前后,当时是业余爱好,竞速随后兴起。第一届世锦赛2018年在深圳举行,来自34个国家的128名飞手参赛,冠军是一个15岁的少年。", + "p4": "便宜又简单,所以这种无人机到处都是。玩家版不含遥控器和眼镜要550到950美元;光一套机架约130到150美元。为军队成千上万地生产,机体约400到500美元,乌克兰计划2025年采购450万架FPV无人机。实际使用时,它们通常在离飞手约10到20千米处作业。要飞得更远,小组会在另一架无人机上装中继:乌克兰的一套系统搭在一架更大的无人机上,飞在500米高度,能停留一小时,把信号转发到30千米外。模型里的飞手阵地简单得多:一个掩体、一只箱子、一根带天线的桅杆,还有一位戴着眼镜的飞手。", + "speed": "最高速度", + "field": "飞完到路口的350米所需时间", + "who": "这是什么" + } + }, + "goggles": { + "caption": "眼镜视角" + } +} diff --git a/explainers/fpv-drone/public/cover.webp b/explainers/fpv-drone/public/cover.webp new file mode 100644 index 00000000..1128cd78 Binary files /dev/null and b/explainers/fpv-drone/public/cover.webp differ diff --git a/explainers/fpv-drone/public/social/og-image.png b/explainers/fpv-drone/public/social/og-image.png new file mode 100644 index 00000000..61ed8c40 Binary files /dev/null and b/explainers/fpv-drone/public/social/og-image.png differ diff --git a/explainers/fpv-drone/src/ids.ts b/explainers/fpv-drone/src/ids.ts new file mode 100644 index 00000000..23a87888 --- /dev/null +++ b/explainers/fpv-drone/src/ids.ts @@ -0,0 +1,123 @@ +export const PART_IDS = [ + 'frame', + 'motorFrontLeft', + 'motorFrontRight', + 'motorRearLeft', + 'motorRearRight', + 'propellers', + 'battery', + 'stack', + 'camera', + 'videoAntenna', + 'receiverAntenna', + 'gpsModule', + 'groundStation', + 'controlLink', + 'videoLink', + 'spinArrows', + 'crossroads', + 'launchPad', +] as const; + +export type PartId = (typeof PART_IDS)[number]; + +export const MOTOR_PART_IDS = [ + 'motorRearRight', + 'motorFrontRight', + 'motorRearLeft', + 'motorFrontLeft', +] as const satisfies readonly PartId[]; + +export type MotorPartId = (typeof MOTOR_PART_IDS)[number]; + +export const PHASE_IDS = ['takeoff', 'climb', 'transit', 'orbit', 'return', 'landing'] as const; + +export type PhaseId = (typeof PHASE_IDS)[number]; + +export const MOMENT_IDS = ['liftoff', 'cruise', 'onStation', 'turnHome', 'touchdown'] as const; + +export type MomentId = (typeof MOMENT_IDS)[number]; + +export const VIDEO_IDS = ['analogue', 'digital'] as const; + +export type VideoId = (typeof VIDEO_IDS)[number]; + +export const MOVE_IDS = ['hover', 'forward', 'roll', 'yaw', 'climb'] as const; + +export type MoveId = (typeof MOVE_IDS)[number]; + +export const FLIGHT_MODE_IDS = ['acro', 'angle', 'horizon'] as const; + +export type FlightModeId = (typeof FLIGHT_MODE_IDS)[number]; + +export const PACKET_RATES = [50, 150, 250, 500] as const; + +export type PacketRate = (typeof PACKET_RATES)[number]; + +export const SPEEDSTER_IDS = ['longRange', 'racer', 'record'] as const; + +export type SpeedsterId = (typeof SPEEDSTER_IDS)[number]; + +export const PRESET_IDS = ['overview', 'flight', 'controller', 'link', 'power', 'limits'] as const; + +export type PresetId = (typeof PRESET_IDS)[number]; + +export type CameraView = 'chase' | 'top' | 'close' | 'side' | 'pilot' | 'fpv'; + +export type RegionId = 'scene' | 'station' | 'drone' | 'crossroads' | 'route'; + +export type AnchorId = 'drone' | 'camera' | 'station' | 'crossroads'; + +export type SpinDirection = 'clockwise' | 'counterClockwise'; + +export type Point = readonly [x: number, y: number, z: number]; + +export type MotorShares = readonly [m1: number, m2: number, m3: number, m4: number]; + +export interface FlightReading { + position: Point; + heading: number; + pitch: number; + roll: number; + speedKmh: number; + height: number; + verticalSpeed: number; + onGround: boolean; + armed: boolean; + propRate: number; +} + +export interface MotorReading { + shares: MotorShares; +} + +export interface BatteryReading { + share: number; + volts: number; + amps: number; + usedMah: number; +} + +export interface LinkReading { + distance: number; + signal: number; +} + +export interface ViewOptions { + links: boolean; + track: boolean; + arrows: boolean; + labels: boolean; +} + +export interface AssemblyState { + phase: number; + flight: FlightReading; + motors: MotorReading; + battery: BatteryReading; + link: LinkReading; + video: VideoId; + move: MoveId; + playing: boolean; + view: ViewOptions; +} diff --git a/explainers/fpv-drone/src/index.ts b/explainers/fpv-drone/src/index.ts new file mode 100644 index 00000000..e8a9ec2d --- /dev/null +++ b/explainers/fpv-drone/src/index.ts @@ -0,0 +1,22 @@ +import './style.css'; +import { defineExplainer } from '@core/explainer'; +import { SCENE_OPTIONS, mountFpvScene } from './scene'; +import { PART_INFO } from './scene/partInfo'; +import { PRESETS, createFpvStore } from './state'; +import type { FpvStoreState } from './state'; +import { FPV_TIMELINE } from './timeline'; +import { CHAPTER_ACTIONS, FPV_CHOICES, FPV_READOUTS, VIEW_TOGGLES, mountFpvUi } from './ui'; + +export default defineExplainer({ + id: 'fpv-drone', + timeline: FPV_TIMELINE, + presets: PRESETS, + parts: PART_INFO, + createStore: () => createFpvStore(), + dock: { choices: FPV_CHOICES, toggles: VIEW_TOGGLES }, + readouts: FPV_READOUTS, + actions: CHAPTER_ACTIONS, + scene: SCENE_OPTIONS, + mountScene: mountFpvScene, + mountUi: mountFpvUi, +}); diff --git a/explainers/fpv-drone/src/model/battery.test.ts b/explainers/fpv-drone/src/model/battery.test.ts new file mode 100644 index 00000000..dba53750 --- /dev/null +++ b/explainers/fpv-drone/src/model/battery.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { + BATTERY, + BATTERY_GRID_S, + batteryAt, + loadedVolts, + openCircuitVolts, + usedMahAt, + wattsAt, +} from './battery'; +import { MASS_G } from './figures'; +import { flightAt } from './flight'; +import { allUpG } from './payload'; +import { cruisePowerW, hoverPowerW } from './power'; +import { SORTIE_SECONDS } from './sortie'; + +const PAYLOAD = MASS_G.defaultPayload; + +describe('battery', () => { + it('is a 6S Li-ion pack with the sheet figures', () => { + expect(BATTERY.cells).toBe(6); + expect(BATTERY.nominalVolts).toBeCloseTo(21.6, 9); + expect(BATTERY.fullVolts).toBeCloseTo(25.2, 9); + expect(BATTERY.emptyVolts).toBe(18); + expect(BATTERY.capacityMah).toBe(4200); + expect(BATTERY.resistanceOhm).toBe(0.06); + expect(BATTERY_GRID_S).toBe(0.5); + }); + + it('sags under load and rests at the open circuit voltage', () => { + expect(openCircuitVolts(1)).toBeCloseTo(BATTERY.fullVolts, 9); + expect(openCircuitVolts(0)).toBe(BATTERY.emptyVolts); + expect(loadedVolts(25.2, 0)).toBeCloseTo(25.2, 9); + const sagged = loadedVolts(25.2, 400); + expect(sagged).toBeLessThan(25.2); + expect(sagged).toBeGreaterThan(24); + expect((400 / sagged) * BATTERY.resistanceOhm).toBeCloseTo(25.2 - sagged, 6); + }); + + it('draws the hover power at rest and a little less in cruise at the default load', () => { + const hover = wattsAt(flightAt(5), PAYLOAD); + expect(hover).toBeCloseTo(hoverPowerW(allUpG(PAYLOAD)), 6); + const cruise = wattsAt(flightAt(20), PAYLOAD); + expect(cruise).toBeCloseTo(cruisePowerW(allUpG(PAYLOAD)), 1); + expect(cruise).toBeLessThan(hover); + expect(wattsAt(flightAt(SORTIE_SECONDS), PAYLOAD)).toBe(0); + }); + + it('starts full and only ever drains', () => { + expect(batteryAt(0, PAYLOAD)).toMatchObject({ share: 1, usedMah: 0 }); + expect(batteryAt(0, PAYLOAD).volts).toBeCloseTo(BATTERY.fullVolts, 9); + let previous = 0; + for (let seconds = 0; seconds <= SORTIE_SECONDS; seconds += 0.25) { + const used = usedMahAt(seconds, PAYLOAD); + expect(used).toBeGreaterThanOrEqual(previous); + previous = used; + } + }); + + it('lands the short sortie with most of the pack left', () => { + const { share, volts, amps } = batteryAt(SORTIE_SECONDS, PAYLOAD); + expect(share).toBeGreaterThan(0.85); + expect(share).toBeLessThan(0.93); + expect(volts).toBeLessThan(BATTERY.fullVolts); + expect(amps).toBe(0); + expect(batteryAt(40, PAYLOAD).amps).toBeGreaterThan(5); + expect(batteryAt(40, PAYLOAD).amps).toBeLessThan(30); + }); + + it('uses more with a heavier payload and reuses its integration', () => { + const heavy = batteryAt(SORTIE_SECONDS, 1500); + expect(heavy.share).toBeLessThan(batteryAt(SORTIE_SECONDS, PAYLOAD).share); + expect(batteryAt(30, 1500).usedMah).toBe(batteryAt(30, 1500).usedMah); + }); +}); diff --git a/explainers/fpv-drone/src/model/battery.ts b/explainers/fpv-drone/src/model/battery.ts new file mode 100644 index 00000000..b3e8a9a1 --- /dev/null +++ b/explainers/fpv-drone/src/model/battery.ts @@ -0,0 +1,81 @@ +import { clamp, lerp } from '@core/math'; +import type { BatteryReading } from '../ids'; +import { PACK } from './figures'; +import { flightAt } from './flight'; +import type { FlightMotion } from './flight'; +import { totalThrustG } from './motors'; +import { allUpG } from './payload'; +import { powerAt } from './power'; +import { MINUTES_PER_HOUR, SECONDS_PER_MINUTE, SORTIE_SECONDS, clampSeconds } from './sortie'; + +export const BATTERY = { + cells: PACK.cells, + nominalVolts: PACK.cells * PACK.cellNominalV, + fullVolts: PACK.cells * PACK.cellFullV, + emptyVolts: PACK.cells * PACK.cellEmptyV, + capacityMah: PACK.capacityMah, + massG: PACK.massG, + resistanceOhm: PACK.resistanceOhm, +} as const; + +export const BATTERY_GRID_S = 0.5; + +const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR; +const MILLI = 1000; +const GRID_STEPS = Math.ceil(SORTIE_SECONDS / BATTERY_GRID_S); +const HALF = 0.5; + +const usedMahGrids = new Map(); + +export function openCircuitVolts(share: number): number { + return lerp(BATTERY.emptyVolts, BATTERY.fullVolts, clamp(share, 0, 1)); +} + +export function loadedVolts(openVolts: number, watts: number): number { + const discriminant = openVolts * openVolts - 4 * watts * BATTERY.resistanceOhm; + if (discriminant <= 0) return openVolts * HALF; + return (openVolts + Math.sqrt(discriminant)) * HALF; +} + +export function wattsAt(flight: FlightMotion, payloadG: number): number { + return powerAt(totalThrustG(flight, allUpG(payloadG)), flight.speedKmh); +} + +function shareLeft(usedMah: number): number { + return clamp(1 - usedMah / BATTERY.capacityMah, 0, 1); +} + +function integrate(payloadG: number): Float64Array { + const grid = new Float64Array(GRID_STEPS + 1); + let used = 0; + for (let step = 0; step < GRID_STEPS; step += 1) { + const watts = wattsAt(flightAt(step * BATTERY_GRID_S), payloadG); + const volts = loadedVolts(openCircuitVolts(shareLeft(used)), watts); + used += ((watts / volts) * BATTERY_GRID_S * MILLI) / SECONDS_PER_HOUR; + grid[step + 1] = used; + } + return grid; +} + +function usedMahGrid(payloadG: number): Float64Array { + const remembered = usedMahGrids.get(payloadG); + if (remembered) return remembered; + const grid = integrate(payloadG); + usedMahGrids.set(payloadG, grid); + return grid; +} + +export function usedMahAt(seconds: number, payloadG: number): number { + const grid = usedMahGrid(payloadG); + const position = clampSeconds(seconds) / BATTERY_GRID_S; + const index = Math.min(Math.floor(position), GRID_STEPS - 1); + return lerp(grid[index], grid[index + 1], position - index); +} + +export function batteryAt(seconds: number, payloadG: number): BatteryReading { + const usedMah = usedMahAt(seconds, payloadG); + const share = shareLeft(usedMah); + const watts = wattsAt(flightAt(seconds), payloadG); + const volts = loadedVolts(openCircuitVolts(share), watts); + return { share, volts, amps: watts / volts, usedMah }; +} diff --git a/explainers/fpv-drone/src/model/figures.test.ts b/explainers/fpv-drone/src/model/figures.test.ts new file mode 100644 index 00000000..2990ce20 --- /dev/null +++ b/explainers/fpv-drone/src/model/figures.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + HOVER_POWER_REF, + MASS_G, + PACK, + SENSITIVITY_DBM, + THRUST_PER_MOTOR_G, + TOP_SPEED_KMH, + VIDEO_FIGURES, +} from './figures'; + +describe('figures', () => { + it('takes the masses and the thrust from the facts sheet', () => { + expect(MASS_G).toEqual({ dry: 650, battery: 450, defaultPayload: 300, maxPayload: 1500 }); + expect(THRUST_PER_MOTOR_G).toEqual({ flight: 2000, bench: 2200 }); + }); + + it('describes the 6S Li-ion pack', () => { + expect(PACK.cells).toBe(6); + expect(PACK.cells * PACK.cellNominalV).toBeCloseTo(21.6, 9); + expect(PACK.cells * PACK.cellFullV).toBeCloseTo(25.2, 9); + expect(PACK.cells * PACK.cellEmptyV).toBe(18); + expect(PACK.capacityMah).toBe(4200); + expect(PACK.energyWh).toBe(91); + expect(PACK.massG).toBe(450); + }); + + it('references the hover power at the all-up weight without payload', () => { + expect(HOVER_POWER_REF.allUpG).toBe(MASS_G.dry + MASS_G.battery); + }); + + it('lists the receiver sensitivity per packet rate and the video systems', () => { + expect(SENSITIVITY_DBM).toEqual({ 50: -115, 150: -112, 250: -108, 500: -105 }); + expect(VIDEO_FIGURES.analogue).toEqual({ latencyMinMs: 20, latencyMaxMs: 30, channels: 40 }); + expect(VIDEO_FIGURES.digital).toEqual({ + latencyMs: 20, + rangeKm: 10, + lines: 1080, + framesPerSecond: 100, + }); + }); + + it('quotes the three top speeds', () => { + expect(TOP_SPEED_KMH).toEqual({ longRange: 140, racer: 263, record: 657.59 }); + }); +}); diff --git a/explainers/fpv-drone/src/model/figures.ts b/explainers/fpv-drone/src/model/figures.ts new file mode 100644 index 00000000..6ebe4eb8 --- /dev/null +++ b/explainers/fpv-drone/src/model/figures.ts @@ -0,0 +1,58 @@ +import type { PacketRate, SpeedsterId, VideoId } from '../ids'; + +export const MASS_G = { dry: 650, battery: 450, defaultPayload: 300, maxPayload: 1500 } as const; + +export const MOTOR_COUNT = 4; +export const THRUST_PER_MOTOR_G = { flight: 2000, bench: 2200 } as const; + +export const PACK = { + cells: 6, + cellNominalV: 3.6, + cellFullV: 4.2, + cellEmptyV: 3, + capacityMah: 4200, + energyWh: 91, + massG: MASS_G.battery, + resistanceOhm: 0.06, +} as const; + +export const HOVER_POWER_REF = { watts: 250, allUpG: 1100 } as const; +export const CRUISE_POWER_FACTOR = 0.8; +export const USABLE_ENERGY_SHARE = 0.8; +export const MIN_THRUST_TO_WEIGHT = 1.2; +export const CRUISE_TILT_DEG = 30; + +export const MAX_RATE_DEG_S = 670; + +export const SENSITIVITY_DBM: Readonly> = { + 50: -115, + 150: -112, + 250: -108, + 500: -105, +}; +export const LINK_REACH_M = { near: 50, far: 10_000 } as const; + +export interface AnalogueVideoFigures { + latencyMinMs: number; + latencyMaxMs: number; + channels: number; +} + +export interface DigitalVideoFigures { + latencyMs: number; + rangeKm: number; + lines: number; + framesPerSecond: number; +} + +export const VIDEO_FIGURES: Readonly> = + { + analogue: { latencyMinMs: 20, latencyMaxMs: 30, channels: 40 }, + digital: { latencyMs: 20, rangeKm: 10, lines: 1080, framesPerSecond: 100 }, + }; + +export const TOP_SPEED_KMH: Readonly> = { + longRange: 140, + racer: 263, + record: 657.59, +}; diff --git a/explainers/fpv-drone/src/model/flight.test.ts b/explainers/fpv-drone/src/model/flight.test.ts new file mode 100644 index 00000000..21d0d075 --- /dev/null +++ b/explainers/fpv-drone/src/model/flight.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { toDegrees, toRadians } from '@core/math'; +import { PHASE_IDS } from '../ids'; +import { + DRAG_ACCEL_PER_SPEED_SQ, + HOVER_STOP_S, + ORBIT_ENTRY_DISTANCE, + PHASE_DISTANCES, + ROUTE, + distanceFlownAt, + flightAt, + pitchAt, + rollAt, +} from './flight'; +import { + CROSSROADS, + CRUISE_HEIGHT, + HOVER_HEIGHT, + ORBIT, + ORBIT_HEIGHT, + PAD, + ROUTE_LENGTH, + SPEED_KMH, +} from './layout'; +import { MOMENTS, PHASE_RANGES, SORTIE_SECONDS } from './sortie'; + +const CRUISE_TIME = 20; +const ORBIT_TIME = 38; +const TILT_TOLERANCE_DEG = 1; + +function expectOnPad(seconds: number): void { + const { position, height, onGround, speedKmh } = flightAt(seconds); + expect(position[0]).toBeCloseTo(PAD[0], 4); + expect(position[2]).toBeCloseTo(PAD[2], 4); + expect(height).toBe(0); + expect(onGround).toBe(true); + expect(speedKmh).toBe(0); +} + +describe('route', () => { + it('flies the frozen route from the pad back to the pad', () => { + expect(ROUTE.length).toBeCloseTo(ROUTE_LENGTH, 9); + expect(PHASE_DISTANCES.landing.to).toBe(ROUTE.length); + expect(ROUTE.end.x).toBeCloseTo(PAD[0], 6); + expect(ROUTE.end.z).toBeCloseTo(PAD[2], 6); + }); + + it('marks the distance at the end of every phase', () => { + expect(PHASE_IDS.map((id) => PHASE_DISTANCES[id].to)).toEqual([ + 0, + 78, + 350, + 350 + ORBIT.laps * 2 * Math.PI * ORBIT.radius, + PHASE_DISTANCES.return.to, + ROUTE.length, + ]); + expect(PHASE_DISTANCES.return.to).toBeCloseTo(990, 0); + expect(ROUTE.length).toBeCloseTo(1070, 0); + PHASE_IDS.slice(1).forEach((id, index) => + expect(PHASE_DISTANCES[id].from).toBe(PHASE_DISTANCES[PHASE_IDS[index]].to), + ); + }); +}); + +describe('flightAt', () => { + it('moves forward monotonically and covers the whole route', () => { + let last = -1; + for (let seconds = 0; seconds <= SORTIE_SECONDS; seconds += 0.1) { + const distance = distanceFlownAt(seconds); + expect(distance).toBeGreaterThanOrEqual(last); + last = distance; + } + expect(distanceFlownAt(SORTIE_SECONDS)).toBeCloseTo(ROUTE.length, 6); + }); + + it('sits on the pad at the start and at the end', () => { + expectOnPad(0); + expectOnPad(SORTIE_SECONDS); + expectOnPad(MOMENTS.touchdown); + expectOnPad(MOMENTS.touchdown + 1); + expect(flightAt(0).armed).toBe(true); + expect(flightAt(SORTIE_SECONDS).armed).toBe(false); + expect(flightAt(SORTIE_SECONDS).propRate).toBe(0); + }); + + it('spins up, lifts off at two seconds and hovers at two metres at five', () => { + expect(flightAt(1).propRate).toBeGreaterThan(0); + expect(flightAt(1).onGround).toBe(true); + expect(flightAt(MOMENTS.liftoff).propRate).toBe(1); + expect(flightAt(MOMENTS.liftoff + 0.5).onGround).toBe(false); + const hover = flightAt(PHASE_RANGES.climb.start); + expect(hover.height).toBeCloseTo(HOVER_HEIGHT, 9); + expect(hover.speedKmh).toBe(0); + expect(hover.position[0]).toBeCloseTo(PAD[0], 6); + }); + + it('reaches the cruise height and speed at thirteen seconds', () => { + const cruise = flightAt(MOMENTS.cruise); + expect(cruise.height).toBeCloseTo(CRUISE_HEIGHT, 9); + expect(cruise.speedKmh).toBeCloseTo(SPEED_KMH.cruise, 9); + expect(distanceFlownAt(MOMENTS.cruise)).toBeCloseTo(PHASE_DISTANCES.climb.to, 9); + expect(flightAt(9).verticalSpeed).toBeGreaterThan(0); + }); + + it('arrives at the orbit entry at twenty seven seconds and circles the crossroads', () => { + const entry = flightAt(MOMENTS.onStation); + expect(distanceFlownAt(MOMENTS.onStation)).toBeCloseTo(ORBIT_ENTRY_DISTANCE, 9); + expect(entry.position[0]).toBeCloseTo(ORBIT.entryX, 6); + expect(entry.position[2]).toBeCloseTo(0, 6); + const { position, speedKmh, height } = flightAt(ORBIT_TIME); + expect(Math.hypot(position[0] - CROSSROADS[0], position[2] - CROSSROADS[2])).toBeCloseTo( + ORBIT.radius, + 6, + ); + expect(speedKmh).toBeCloseTo(SPEED_KMH.orbit, 9); + expect(height).toBeCloseTo(ORBIT_HEIGHT, 9); + }); + + it('turns for home at forty nine seconds and stops over the pad before touching down', () => { + expect(distanceFlownAt(MOMENTS.turnHome)).toBeCloseTo(PHASE_DISTANCES.orbit.to, 9); + expect(flightAt(60).speedKmh).toBeCloseTo(SPEED_KMH.cruise, 9); + const stop = flightAt(HOVER_STOP_S); + expect(stop.speedKmh).toBeCloseTo(0, 9); + expect(stop.height).toBeCloseTo(HOVER_HEIGHT, 9); + expect(stop.position[0]).toBeCloseTo(PAD[0], 4); + expect(flightAt(77).verticalSpeed).toBeLessThan(0); + }); + + it('pitches nose down in cruise, about thirty degrees at seventy km/h', () => { + expect(DRAG_ACCEL_PER_SPEED_SQ).toBeCloseTo(0.015, 3); + const cruise = flightAt(CRUISE_TIME); + expect(cruise.pitch).toBeGreaterThan(0); + expect(Math.abs(toDegrees(cruise.pitch) - 30)).toBeLessThan(TILT_TOLERANCE_DEG); + expect(flightAt(9).pitch).toBeGreaterThan(0); + expect(flightAt(PHASE_RANGES.climb.start).pitch).toBeCloseTo(0, 9); + expect(flightAt(72).pitch).toBeLessThan(0); + expect(pitchAt(0)).toBe(0); + }); + + it('banks left in the orbit and levels out on the straights', () => { + const orbit = flightAt(ORBIT_TIME); + expect(orbit.roll).toBeLessThan(-toRadians(20)); + expect(flightAt(CRUISE_TIME).roll).toBeCloseTo(0, 9); + expect(flightAt(60).roll).toBeCloseTo(0, 9); + expect(rollAt(MOMENTS.onStation - 1)).toBeCloseTo(0, 9); + expect(rollAt(MOMENTS.onStation + 1)).toBeLessThan(0); + }); + + it('eases the bank in over the blend time instead of snapping', () => { + const before = rollAt(MOMENTS.onStation - 0.2); + const during = rollAt(MOMENTS.onStation); + const after = rollAt(MOMENTS.onStation + 0.2); + expect(before).toBeLessThan(0); + expect(during).toBeLessThan(before); + expect(after).toBeLessThan(during); + }); + + it('turns left into the orbit and the first half of the S-turn, then right to line up', () => { + expect(flightAt(ORBIT_TIME).headingRate).toBeLessThan(0); + expect(flightAt(62).headingRate).toBeLessThan(0); + expect(flightAt(65.5).headingRate).toBeGreaterThan(0); + expect(flightAt(CRUISE_TIME).headingRate).toBeCloseTo(0, 6); + expect(flightAt(CRUISE_TIME).pitchRate).toBeCloseTo(0, 6); + expect(Math.abs(flightAt(9).pitchRate)).toBeGreaterThan(0); + expect(Math.abs(flightAt(MOMENTS.onStation).rollRate)).toBeGreaterThan(0); + }); + + it('never flies below the ground', () => { + for (let seconds = 0; seconds <= SORTIE_SECONDS; seconds += 0.25) { + expect(flightAt(seconds).height).toBeGreaterThanOrEqual(0); + } + }); +}); diff --git a/explainers/fpv-drone/src/model/flight.ts b/explainers/fpv-drone/src/model/flight.ts new file mode 100644 index 00000000..af9e0453 --- /dev/null +++ b/explainers/fpv-drone/src/model/flight.ts @@ -0,0 +1,225 @@ +import { clamp, lerp, toRadians, wrapAngle } from '@core/math'; +import { Route, arc, line } from '@core/path'; +import type { FlightReading, PhaseId, Point } from '../ids'; +import { CRUISE_TILT_DEG } from './figures'; +import { + CRUISE_HEIGHT, + FINAL_LENGTH, + HOMEBOUND_LENGTH, + HOVER_HEIGHT, + ORBIT, + ORBIT_HEIGHT, + ORBIT_LENGTH, + OUTBOUND_LENGTH, + PAD, + ROUTE_STEPS, + SPEED_KMH, + START_HEADING, + S_TURN_LENGTH, + S_TURN_RADIUS, +} from './layout'; +import { Profile } from './profile'; +import type { Knot } from './profile'; +import { GRAVITY, toKmh, toMetresPerSecond } from './scale'; +import { MOMENTS, PHASE_RANGES, SORTIE_SECONDS, clampSeconds, phaseAt } from './sortie'; + +export interface FlightMotion extends FlightReading { + acceleration: number; + headingRate: number; + pitchRate: number; + rollRate: number; +} + +interface Arc { + from: number; + to: number; + turn: number; + radius: number; +} + +interface DistanceRange { + from: number; + to: number; +} + +export const ROUTE = new Route({ x: PAD[0], z: PAD[2], heading: START_HEADING }, [ + line(OUTBOUND_LENGTH), + arc(ORBIT.radius, ROUTE_STEPS.orbitTurn), + line(HOMEBOUND_LENGTH), + arc(S_TURN_RADIUS, ROUTE_STEPS.sTurnFirst), + arc(S_TURN_RADIUS, ROUTE_STEPS.sTurnSecond), + line(FINAL_LENGTH), +]); + +export const CLIMB_LENGTH = 78; +export const ORBIT_ENTRY_DISTANCE = OUTBOUND_LENGTH; +export const ORBIT_EXIT_DISTANCE = ORBIT_ENTRY_DISTANCE + ORBIT_LENGTH; +export const S_TURN_START_DISTANCE = ORBIT_EXIT_DISTANCE + HOMEBOUND_LENGTH; +export const S_TURN_MIDDLE_DISTANCE = S_TURN_START_DISTANCE + S_TURN_LENGTH / 2; +export const FINAL_START_DISTANCE = S_TURN_START_DISTANCE + S_TURN_LENGTH; + +export const ATTITUDE_BLEND_S = 0.6; +export const HOVER_STOP_S = 75; +const HOVER_HOLD_S = 1; +const ORBIT_SPEED_BLEND_S = 2; +const HEIGHT_BLEND_S = 3; +const RETURN_SPEED_BLEND_S = 3; +const PROP_SPIN_UP_S = 1.5; +const PROP_IDLE_RATE = 0.5; +const RATE_STEP_S = 0.05; +const MIN_WINDOW_M = 1e-6; + +const CRUISE_MPS = toMetresPerSecond(SPEED_KMH.cruise); +const ORBIT_MPS = toMetresPerSecond(SPEED_KMH.orbit); + +export const DRAG_ACCEL_PER_SPEED_SQ = + (GRAVITY * Math.tan(toRadians(CRUISE_TILT_DEG))) / CRUISE_MPS ** 2; + +export const PHASE_DISTANCES: Readonly> = { + takeoff: { from: 0, to: 0 }, + climb: { from: 0, to: CLIMB_LENGTH }, + transit: { from: CLIMB_LENGTH, to: ORBIT_ENTRY_DISTANCE }, + orbit: { from: ORBIT_ENTRY_DISTANCE, to: ORBIT_EXIT_DISTANCE }, + return: { from: ORBIT_EXIT_DISTANCE, to: FINAL_START_DISTANCE }, + landing: { from: FINAL_START_DISTANCE, to: ROUTE.length }, +}; + +const SPEED_KNOTS: readonly Knot[] = [ + [0, 0], + [PHASE_RANGES.climb.start, 0], + [PHASE_RANGES.climb.end, CRUISE_MPS], + [PHASE_RANGES.orbit.start, CRUISE_MPS], + [PHASE_RANGES.orbit.start + ORBIT_SPEED_BLEND_S, ORBIT_MPS], + [PHASE_RANGES.orbit.end, ORBIT_MPS], + [PHASE_RANGES.orbit.end + RETURN_SPEED_BLEND_S, CRUISE_MPS], + [PHASE_RANGES.return.end, CRUISE_MPS], + [HOVER_STOP_S, 0], + [SORTIE_SECONDS, 0], +]; + +const HEIGHT_KNOTS: readonly Knot[] = [ + [0, 0], + [MOMENTS.liftoff, 0], + [PHASE_RANGES.climb.start, HOVER_HEIGHT], + [PHASE_RANGES.climb.end, CRUISE_HEIGHT], + [PHASE_RANGES.orbit.start, CRUISE_HEIGHT], + [PHASE_RANGES.orbit.start + HEIGHT_BLEND_S, ORBIT_HEIGHT], + [PHASE_RANGES.orbit.end, ORBIT_HEIGHT], + [PHASE_RANGES.orbit.end + HEIGHT_BLEND_S, CRUISE_HEIGHT], + [PHASE_RANGES.return.end, CRUISE_HEIGHT], + [HOVER_STOP_S, HOVER_HEIGHT], + [HOVER_STOP_S + HOVER_HOLD_S, HOVER_HEIGHT], + [MOMENTS.touchdown, 0], + [SORTIE_SECONDS, 0], +]; + +const PROP_KNOTS: readonly Knot[] = [ + [0, 0], + [PROP_SPIN_UP_S, PROP_IDLE_RATE], + [MOMENTS.liftoff, 1], + [MOMENTS.touchdown, 1], + [SORTIE_SECONDS, 0], +]; + +const SPEED = new Profile(SPEED_KNOTS); +const HEIGHT = new Profile(HEIGHT_KNOTS); +const PROPS = new Profile(PROP_KNOTS); + +const ARCS: readonly Arc[] = [ + { + from: ORBIT_ENTRY_DISTANCE, + to: ORBIT_EXIT_DISTANCE, + turn: Math.sign(ROUTE_STEPS.orbitTurn), + radius: ORBIT.radius, + }, + { + from: S_TURN_START_DISTANCE, + to: S_TURN_MIDDLE_DISTANCE, + turn: Math.sign(ROUTE_STEPS.sTurnFirst), + radius: S_TURN_RADIUS, + }, + { + from: S_TURN_MIDDLE_DISTANCE, + to: FINAL_START_DISTANCE, + turn: Math.sign(ROUTE_STEPS.sTurnSecond), + radius: S_TURN_RADIUS, + }, +]; + +export function speedAt(seconds: number): number { + return SPEED.at(clampSeconds(seconds)); +} + +export function heightAt(seconds: number): number { + return HEIGHT.at(clampSeconds(seconds)); +} + +export function distanceFlownAt(seconds: number): number { + const time = clampSeconds(seconds); + const phase = phaseAt(time); + const { start, end } = PHASE_RANGES[phase]; + const { from, to } = PHASE_DISTANCES[phase]; + const run = SPEED.integralTo(end) - SPEED.integralTo(start); + if (run <= 0) return from; + const flown = SPEED.integralTo(clamp(time, start, end)) - SPEED.integralTo(start); + return lerp(from, to, flown / run); +} + +export function pitchAt(seconds: number): number { + const speed = speedAt(seconds); + const acceleration = SPEED.slopeAt(clampSeconds(seconds)); + return Math.atan((acceleration + DRAG_ACCEL_PER_SPEED_SQ * speed * speed) / GRAVITY); +} + +function bankIn(arc: Arc, speed: number): number { + return arc.turn * Math.atan((speed * speed) / (GRAVITY * arc.radius)); +} + +function overlapShare(arc: Arc, from: number, to: number): number { + const width = to - from; + if (width < MIN_WINDOW_M) return from >= arc.from && from < arc.to ? 1 : 0; + return clamp(Math.min(to, arc.to) - Math.max(from, arc.from), 0, width) / width; +} + +export function rollAt(seconds: number): number { + const half = ATTITUDE_BLEND_S / 2; + const from = distanceFlownAt(seconds - half); + const to = distanceFlownAt(seconds + half); + const speed = speedAt(seconds); + return ARCS.reduce((roll, arc) => roll + overlapShare(arc, from, to) * bankIn(arc, speed), 0); +} + +export function headingAt(seconds: number): number { + return ROUTE.poseAt(distanceFlownAt(seconds) / ROUTE.length).heading; +} + +function rateOf(read: (seconds: number) => number, seconds: number): number { + return wrapAngle(read(seconds + RATE_STEP_S) - read(seconds - RATE_STEP_S)) / (2 * RATE_STEP_S); +} + +export function isArmedAt(seconds: number): boolean { + return seconds >= 0 && seconds < SORTIE_SECONDS; +} + +export function flightAt(seconds: number): FlightMotion { + const time = clampSeconds(seconds); + const pose = ROUTE.poseAt(distanceFlownAt(time) / ROUTE.length); + const height = HEIGHT.at(time); + const position: Point = [pose.x, height, pose.z]; + return { + position, + heading: pose.heading, + pitch: pitchAt(time), + roll: rollAt(time), + speedKmh: toKmh(SPEED.at(time)), + height, + verticalSpeed: HEIGHT.slopeAt(time), + onGround: height <= 0, + armed: isArmedAt(seconds), + propRate: PROPS.at(time), + acceleration: SPEED.slopeAt(time), + headingRate: rateOf(headingAt, time), + pitchRate: rateOf(pitchAt, time), + rollRate: rateOf(rollAt, time), + }; +} diff --git a/explainers/fpv-drone/src/model/index.ts b/explainers/fpv-drone/src/model/index.ts new file mode 100644 index 00000000..5193c7ca --- /dev/null +++ b/explainers/fpv-drone/src/model/index.ts @@ -0,0 +1,15 @@ +export * from './battery'; +export * from './figures'; +export * from './flight'; +export * from './layout'; +export * from './link'; +export * from './modes'; +export * from './motors'; +export * from './payload'; +export * from './playback'; +export * from './power'; +export * from './profile'; +export * from './scale'; +export * from './sortie'; +export * from './speeds'; +export * from './tilt'; diff --git a/explainers/fpv-drone/src/model/layout.test.ts b/explainers/fpv-drone/src/model/layout.test.ts new file mode 100644 index 00000000..ea6faa25 --- /dev/null +++ b/explainers/fpv-drone/src/model/layout.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { FULL_TURN } from '@core/math'; +import { Route, arc, line } from '@core/path'; +import { MOTOR_PART_IDS } from '../ids'; +import { + CROSSROADS, + CROSSROADS_BOUNDS, + DRONE, + DRONE_LAYOUT, + FINAL_LENGTH, + HOMEBOUND_LENGTH, + MOTOR_POSITIONS, + MOTOR_SPIN, + ORBIT, + ORBIT_LENGTH, + OUTBOUND_LENGTH, + PAD, + ROUTE_LENGTH, + ROUTE_STEPS, + SCENE_BOUNDS, + START_HEADING, + S_TURN_RADIUS, + STATION, + TREELINE, +} from './layout'; + +function route(): Route { + return new Route({ x: PAD[0], z: PAD[2], heading: START_HEADING }, [ + line(OUTBOUND_LENGTH), + arc(ORBIT.radius, ROUTE_STEPS.orbitTurn), + line(HOMEBOUND_LENGTH), + arc(S_TURN_RADIUS, ROUTE_STEPS.sTurnFirst), + arc(S_TURN_RADIUS, ROUTE_STEPS.sTurnSecond), + line(FINAL_LENGTH), + ]); +} + +describe('layout', () => { + it('takes the drone dimensions from the facts sheet', () => { + expect(DRONE.propInches).toBe(7.5); + expect(DRONE.propDiameter).toBeCloseTo(0.1905, 4); + expect(DRONE.blades).toBe(3); + expect(DRONE.wheelbase).toBe(0.34); + expect(DRONE.motorDiameter).toBe(0.035); + }); + + it('spreads the front arms wider than the rear ones, nose toward +x and right toward +z', () => { + const { motorFrontLeft, motorFrontRight, motorRearLeft, motorRearRight } = MOTOR_POSITIONS; + expect(motorFrontLeft[0]).toBeGreaterThan(0); + expect(motorFrontRight[0]).toBeGreaterThan(0); + expect(motorRearLeft[0]).toBeLessThan(0); + expect(motorRearRight[0]).toBeLessThan(0); + expect(motorFrontRight[2]).toBeGreaterThan(0); + expect(motorRearRight[2]).toBeGreaterThan(0); + expect(motorFrontLeft[2]).toBeLessThan(0); + expect(motorRearLeft[2]).toBeLessThan(0); + expect(Math.abs(motorFrontRight[2])).toBeGreaterThan(Math.abs(motorRearRight[2])); + const diagonal = Math.hypot( + motorFrontLeft[0] - motorRearRight[0], + motorFrontLeft[2] - motorRearRight[2], + ); + expect(diagonal).toBeCloseTo(DRONE.wheelbase, 2); + }); + + it('spins the rear right and front left motors clockwise, the other two the opposite way', () => { + expect(MOTOR_PART_IDS).toEqual([ + 'motorRearRight', + 'motorFrontRight', + 'motorRearLeft', + 'motorFrontLeft', + ]); + expect(MOTOR_SPIN.motorRearRight).toBe('clockwise'); + expect(MOTOR_SPIN.motorFrontLeft).toBe('clockwise'); + expect(MOTOR_SPIN.motorFrontRight).toBe('counterClockwise'); + expect(MOTOR_SPIN.motorRearLeft).toBe('counterClockwise'); + }); + + it('puts the camera in the nose, the battery on top and the antennas at the back', () => { + expect(DRONE_LAYOUT.camera[0]).toBeGreaterThan(0); + expect(DRONE_LAYOUT.battery[1]).toBeGreaterThan(DRONE_LAYOUT.stack[1]); + expect(DRONE_LAYOUT.videoAntenna[0]).toBeLessThan(0); + DRONE_LAYOUT.receiverAntennas.forEach((antenna) => expect(antenna[0]).toBeLessThan(0)); + }); + + it('flies out 350 m, circles the crossroads one and a half laps and comes home to the pad', () => { + expect(OUTBOUND_LENGTH).toBe(350); + expect(ORBIT_LENGTH).toBeCloseTo(1.5 * FULL_TURN * 35, 9); + expect(ROUTE_LENGTH).toBeCloseTo(350 + ORBIT_LENGTH + 200 + Math.PI * 35 + 80, 9); + const flown = route(); + expect(flown.length).toBeCloseTo(ROUTE_LENGTH, 9); + expect(flown.end.x).toBeCloseTo(PAD[0], 6); + expect(flown.end.z).toBeCloseTo(PAD[2], 6); + expect(Math.cos(flown.end.heading)).toBeCloseTo(-1, 6); + }); + + it('turns left around the crossroads, which sits at the orbit centre', () => { + expect(ORBIT.turn).toBe(-1); + expect(CROSSROADS).toEqual([ORBIT.entryX, 0, -ORBIT.radius]); + const flown = route(); + const halfLap = flown.poseAt((OUTBOUND_LENGTH + ORBIT_LENGTH / 3) / flown.length); + expect(halfLap.z).toBeCloseTo(2 * CROSSROADS[2], 6); + }); + + it('keeps the station, the route and the treeline inside the scene bounds', () => { + expect(SCENE_BOUNDS.x[0]).toBeLessThan(STATION[0]); + expect(SCENE_BOUNDS.x[1]).toBeGreaterThan(ORBIT.entryX + ORBIT.radius); + expect(SCENE_BOUNDS.z[0]).toBeLessThan(TREELINE.z); + expect(SCENE_BOUNDS.z[0]).toBeLessThan(CROSSROADS[2] - ORBIT.radius); + expect(CROSSROADS_BOUNDS.x[0]).toBeLessThan(CROSSROADS[0]); + expect(CROSSROADS_BOUNDS.x[1]).toBeGreaterThan(CROSSROADS[0]); + }); +}); diff --git a/explainers/fpv-drone/src/model/layout.ts b/explainers/fpv-drone/src/model/layout.ts new file mode 100644 index 00000000..36e4bd49 --- /dev/null +++ b/explainers/fpv-drone/src/model/layout.ts @@ -0,0 +1,101 @@ +import { FULL_TURN, toRadians } from '@core/math'; +import type { MotorPartId, Point, SpinDirection } from '../ids'; + +export type Extent = readonly [min: number, max: number]; + +export interface Box { + x: Extent; + y: Extent; + z: Extent; +} + +const QUARTER_TURN = FULL_TURN / 4; + +export const STATION: Point = [0, 0, 0]; +export const PAD: Point = [6, 0, 0]; +export const START_HEADING = 0; + +export const HOVER_HEIGHT = 2; +export const CRUISE_HEIGHT = 40; +export const ORBIT_HEIGHT = 50; + +export const ORBIT = { radius: 35, laps: 1.5, entryX: 356, turn: -1 } as const; +export const ORBIT_CENTRE: Point = [ORBIT.entryX, 0, ORBIT.turn * ORBIT.radius]; +export const CROSSROADS: Point = ORBIT_CENTRE; + +export const OUTBOUND_LENGTH = ORBIT.entryX - PAD[0]; +export const ORBIT_LENGTH = ORBIT.laps * FULL_TURN * ORBIT.radius; +export const HOMEBOUND_LENGTH = 200; +export const S_TURN_RADIUS = 35; +export const S_TURN_LENGTH = 2 * QUARTER_TURN * S_TURN_RADIUS; +export const FINAL_LENGTH = OUTBOUND_LENGTH - HOMEBOUND_LENGTH - 2 * S_TURN_RADIUS; +export const ROUTE_LENGTH = + OUTBOUND_LENGTH + ORBIT_LENGTH + HOMEBOUND_LENGTH + S_TURN_LENGTH + FINAL_LENGTH; + +export const ROUTE_STEPS = { + orbitTurn: ORBIT.turn * ORBIT.laps * FULL_TURN, + sTurnFirst: ORBIT.turn * QUARTER_TURN, + sTurnSecond: -ORBIT.turn * QUARTER_TURN, +} as const; + +export const SPEED_KMH = { cruise: 70, orbit: 54 } as const; + +export const TREELINE = { z: -110, x: [-40, 420] as Extent } as const; +export const ROAD = { halfWidth: 2.5 } as const; + +export const SCENE_BOUNDS: Box = { x: [-40, 430], y: [0, 90], z: [-140, 110] }; +export const STATION_BOUNDS: Box = { x: [-6, 10], y: [0, 4], z: [-5, 5] }; +export const CROSSROADS_BOUNDS: Box = { + x: [CROSSROADS[0] - 16, CROSSROADS[0] + 16], + y: [0, 4], + z: [CROSSROADS[2] - 16, CROSSROADS[2] + 16], +}; +export const ROUTE_BOUNDS: Box = { x: [-10, 400], y: [0, 60], z: [-80, 10] }; + +export const DRONE = { + propInches: 7.5, + propDiameter: 0.1905, + blades: 3, + wheelbase: 0.34, + armWidth: 0.018, + armThickness: 0.006, + plateLength: 0.27, + plateWidth: 0.05, + plateGap: 0.034, + motorDiameter: 0.035, + motorHeight: 0.021, + standoffHeight: 0.034, + batteryLength: 0.13, + batteryWidth: 0.045, + batteryHeight: 0.025, + cameraTilt: toRadians(25), + cameraSize: 0.019, + restHeight: 0.035, +} as const; + +export const MOTOR_POSITIONS: Readonly> = { + motorRearRight: [-0.14, 0, 0.1], + motorFrontRight: [0.11, 0, 0.13], + motorRearLeft: [-0.14, 0, -0.1], + motorFrontLeft: [0.11, 0, -0.13], +}; + +export const MOTOR_SPIN: Readonly> = { + motorRearRight: 'clockwise', + motorFrontRight: 'counterClockwise', + motorRearLeft: 'counterClockwise', + motorFrontLeft: 'clockwise', +}; + +export const DRONE_LAYOUT = { + centre: [0, 0, 0] as Point, + camera: [0.12, 0.02, 0] as Point, + battery: [-0.02, 0.047, 0] as Point, + stack: [0, 0.012, 0] as Point, + videoAntenna: [-0.12, 0.045, 0] as Point, + receiverAntennas: [ + [-0.13, 0.01, 0.03], + [-0.13, 0.01, -0.03], + ] as readonly Point[], + gpsModule: [-0.1, 0.085, 0] as Point, +} as const; diff --git a/explainers/fpv-drone/src/model/link.test.ts b/explainers/fpv-drone/src/model/link.test.ts new file mode 100644 index 00000000..868a3172 --- /dev/null +++ b/explainers/fpv-drone/src/model/link.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { PACKET_RATES } from '../ids'; +import { flightAt } from './flight'; +import { CROSSROADS, ORBIT, PAD } from './layout'; +import { + SIGNAL_THRESHOLDS, + VIDEO_SYSTEMS, + distanceAt, + linkAt, + packetPeriodMs, + rangeFactor, + sensitivityDbm, + signalGradeOf, + signalShareAt, +} from './link'; + +describe('radio link', () => { + it('measures the distance from the pilot station', () => { + expect(distanceAt(PAD)).toBe(6); + expect(distanceAt([356, 40, 0])).toBeCloseTo(Math.hypot(356, 40), 9); + expect(linkAt(flightAt(38).position).distance).toBeGreaterThan(CROSSROADS[0] - ORBIT.radius); + }); + + it('spaces the packets by the rate', () => { + expect(PACKET_RATES.map(packetPeriodMs)).toEqual([20, 1000 / 150, 4, 2]); + }); + + it('hears weaker signals at slower rates and reaches about 3.2 times farther at 50 Hz', () => { + expect(PACKET_RATES.map(sensitivityDbm)).toEqual([-115, -112, -108, -105]); + expect(rangeFactor(500)).toBe(1); + expect(rangeFactor(250)).toBeCloseTo(1.41, 2); + expect(rangeFactor(150)).toBeCloseTo(2.24, 2); + expect(rangeFactor(50)).toBeCloseTo(3.16, 2); + }); + + it('keeps the signal strong over the whole short sortie and loses it at ten kilometres', () => { + expect(signalShareAt(6)).toBe(1); + expect(signalShareAt(50)).toBe(1); + expect(signalShareAt(10_000)).toBe(0); + expect(signalShareAt(20_000)).toBe(0); + expect(signalShareAt(400)).toBeGreaterThan(SIGNAL_THRESHOLDS.strong); + expect(signalShareAt(400)).toBeLessThan(0.7); + for (let seconds = 0; seconds <= 80; seconds += 1) { + expect(signalGradeOf(linkAt(flightAt(seconds).position).signal)).toBe('strong'); + } + }); + + it('grades the signal by the thresholds', () => { + expect(signalGradeOf(0.6)).toBe('strong'); + expect(signalGradeOf(0.59)).toBe('good'); + expect(signalGradeOf(0.3)).toBe('good'); + expect(signalGradeOf(0.1)).toBe('weak'); + expect(signalGradeOf(0.04)).toBe('lost'); + }); + + it('tabulates the two video systems', () => { + expect(Object.keys(VIDEO_SYSTEMS)).toEqual(['analogue', 'digital']); + }); +}); diff --git a/explainers/fpv-drone/src/model/link.ts b/explainers/fpv-drone/src/model/link.ts new file mode 100644 index 00000000..4645c6fc --- /dev/null +++ b/explainers/fpv-drone/src/model/link.ts @@ -0,0 +1,47 @@ +import { clamp } from '@core/math'; +import type { LinkReading, PacketRate, Point } from '../ids'; +import { LINK_REACH_M, SENSITIVITY_DBM, VIDEO_FIGURES } from './figures'; +import { STATION } from './layout'; + +export type SignalGrade = 'strong' | 'good' | 'weak' | 'lost'; + +export const VIDEO_SYSTEMS = VIDEO_FIGURES; +export const REFERENCE_PACKET_RATE: PacketRate = 500; +export const SIGNAL_THRESHOLDS = { strong: 0.6, good: 0.3, weak: 0.05 } as const; + +const MILLISECONDS_PER_SECOND = 1000; +const DECIBELS_PER_DECADE_OF_AMPLITUDE = 20; + +export function distanceAt(position: Point): number { + return Math.hypot(position[0] - STATION[0], position[1] - STATION[1], position[2] - STATION[2]); +} + +export function packetPeriodMs(rate: PacketRate): number { + return MILLISECONDS_PER_SECOND / rate; +} + +export function sensitivityDbm(rate: PacketRate): number { + return SENSITIVITY_DBM[rate]; +} + +export function rangeFactor(rate: PacketRate): number { + const gain = SENSITIVITY_DBM[REFERENCE_PACKET_RATE] - SENSITIVITY_DBM[rate]; + return 10 ** (gain / DECIBELS_PER_DECADE_OF_AMPLITUDE); +} + +export function signalShareAt(distance: number): number { + const reach = Math.log(LINK_REACH_M.far / LINK_REACH_M.near); + return clamp(1 - Math.log(distance / LINK_REACH_M.near) / reach, 0, 1); +} + +export function signalGradeOf(share: number): SignalGrade { + if (share >= SIGNAL_THRESHOLDS.strong) return 'strong'; + if (share >= SIGNAL_THRESHOLDS.good) return 'good'; + if (share >= SIGNAL_THRESHOLDS.weak) return 'weak'; + return 'lost'; +} + +export function linkAt(position: Point): LinkReading { + const distance = distanceAt(position); + return { distance, signal: signalShareAt(distance) }; +} diff --git a/explainers/fpv-drone/src/model/modes.test.ts b/explainers/fpv-drone/src/model/modes.test.ts new file mode 100644 index 00000000..f922ddfa --- /dev/null +++ b/explainers/fpv-drone/src/model/modes.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { FLIGHT_MODE_IDS } from '../ids'; +import { DEFAULT_MAX_RATE_DEG_S, FLIGHT_MODES } from './modes'; + +describe('flight modes', () => { + it('names the stick and the limit text of acro, angle and horizon', () => { + expect(Object.keys(FLIGHT_MODES)).toEqual([...FLIGHT_MODE_IDS]); + FLIGHT_MODE_IDS.forEach((id) => { + expect(FLIGHT_MODES[id].stickKey).toBe(`mode.stick.${id}`); + expect(FLIGHT_MODES[id].limitKey).toBe(`mode.limit.${id}`); + }); + }); + + it('quotes the default maximum rate', () => { + expect(DEFAULT_MAX_RATE_DEG_S).toBe(670); + }); +}); diff --git a/explainers/fpv-drone/src/model/modes.ts b/explainers/fpv-drone/src/model/modes.ts new file mode 100644 index 00000000..acf6b157 --- /dev/null +++ b/explainers/fpv-drone/src/model/modes.ts @@ -0,0 +1,14 @@ +import { FLIGHT_MODE_IDS } from '../ids'; +import type { FlightModeId } from '../ids'; +import { MAX_RATE_DEG_S } from './figures'; + +export interface FlightMode { + stickKey: string; + limitKey: string; +} + +export const DEFAULT_MAX_RATE_DEG_S = MAX_RATE_DEG_S; + +export const FLIGHT_MODES: Readonly> = Object.fromEntries( + FLIGHT_MODE_IDS.map((id) => [id, { stickKey: `mode.stick.${id}`, limitKey: `mode.limit.${id}` }]), +) as Record; diff --git a/explainers/fpv-drone/src/model/motors.test.ts b/explainers/fpv-drone/src/model/motors.test.ts new file mode 100644 index 00000000..bccfef79 --- /dev/null +++ b/explainers/fpv-drone/src/model/motors.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { MOTOR_PART_IDS, MOVE_IDS } from '../ids'; +import { MASS_G } from './figures'; +import { flightAt } from './flight'; +import { + DEMO_HOVER_SHARE, + DEMO_MIX_DELTA, + MOTOR_MAX_THRUST_G, + MOTOR_MIXES, + PITCH_SIGNS, + ROLL_SIGNS, + YAW_SIGNS, + motorsAt, + speedingMotors, + totalThrustG, +} from './motors'; +import { allUpG } from './payload'; +import { MOMENTS } from './sortie'; + +const PAYLOAD = MASS_G.defaultPayload; +const CRUISE_TIME = 20; +const ORBIT_TIME = 38; +const HOVER_TIME = 4.5; + +function sum(shares: readonly number[]): number { + return shares.reduce((total, share) => total + share, 0); +} + +describe('motor mixing', () => { + it('orders the motors as Betaflight does and signs the mixes by position and spin', () => { + expect(MOTOR_PART_IDS).toEqual([ + 'motorRearRight', + 'motorFrontRight', + 'motorRearLeft', + 'motorFrontLeft', + ]); + expect(PITCH_SIGNS).toEqual({ + motorRearRight: 1, + motorFrontRight: -1, + motorRearLeft: 1, + motorFrontLeft: -1, + }); + expect(ROLL_SIGNS).toEqual({ + motorRearRight: -1, + motorFrontRight: -1, + motorRearLeft: 1, + motorFrontLeft: 1, + }); + expect(YAW_SIGNS).toEqual({ + motorRearRight: -1, + motorFrontRight: 1, + motorRearLeft: 1, + motorFrontLeft: -1, + }); + }); + + it('holds the hover with four equal shares and nothing before arming', () => { + const hover = motorsAt(flightAt(HOVER_TIME), PAYLOAD); + const expected = allUpG(PAYLOAD) / MOTOR_MAX_THRUST_G; + hover.shares.forEach((share) => expect(share).toBeCloseTo(expected, 3)); + expect(expected).toBeCloseTo(0.175, 3); + expect(totalThrustG(flightAt(80), allUpG(PAYLOAD))).toBe(0); + expect(sum(motorsAt(flightAt(80), PAYLOAD).shares)).toBe(0); + }); + + it('spins up on the pad to the hover thrust at lift-off', () => { + const idle = totalThrustG(flightAt(1), allUpG(PAYLOAD)); + expect(idle).toBeGreaterThan(0); + expect(idle).toBeLessThan(allUpG(PAYLOAD)); + expect(totalThrustG(flightAt(MOMENTS.liftoff), allUpG(PAYLOAD))).toBeCloseTo( + allUpG(PAYLOAD), + 6, + ); + }); + + it('needs more thrust tilted in cruise than level in the hover', () => { + expect(totalThrustG(flightAt(CRUISE_TIME), allUpG(PAYLOAD))).toBeGreaterThan( + 1.1 * totalThrustG(flightAt(HOVER_TIME), allUpG(PAYLOAD)), + ); + }); + + it('speeds up the rear motors while the nose tilts down and the left ones banking left', () => { + const pitching = motorsAt(flightAt(7), PAYLOAD); + expect(pitching.shares[0]).toBeGreaterThan(pitching.shares[1]); + expect(pitching.shares[2]).toBeGreaterThan(pitching.shares[3]); + const banking = motorsAt(flightAt(MOMENTS.onStation), PAYLOAD); + expect(banking.shares[0]).toBeGreaterThan(banking.shares[2]); + expect(banking.shares[1]).toBeGreaterThan(banking.shares[3]); + }); + + it('turns left in the orbit with the clockwise pair faster', () => { + const turning = motorsAt(flightAt(ORBIT_TIME), PAYLOAD); + expect(turning.shares[0]).toBeGreaterThan(turning.shares[1]); + expect(turning.shares[3]).toBeGreaterThan(turning.shares[2]); + expect(turning.shares[0]).toBeCloseTo(turning.shares[3], 6); + }); + + it('keeps every share between zero and one through the sortie and a heavy load', () => { + for (let seconds = 0; seconds <= 80; seconds += 0.5) { + motorsAt(flightAt(seconds), 1500).shares.forEach((share) => { + expect(share).toBeGreaterThanOrEqual(0); + expect(share).toBeLessThanOrEqual(1); + }); + } + }); + + it('tabulates the chapter mixes around the hover share', () => { + expect(DEMO_HOVER_SHARE).toBeCloseTo(0.175, 9); + expect(MOTOR_MIXES.hover).toEqual([ + DEMO_HOVER_SHARE, + DEMO_HOVER_SHARE, + DEMO_HOVER_SHARE, + DEMO_HOVER_SHARE, + ]); + const up = DEMO_HOVER_SHARE + DEMO_MIX_DELTA; + const down = DEMO_HOVER_SHARE - DEMO_MIX_DELTA; + expect(MOTOR_MIXES.forward).toEqual([up, down, up, down]); + expect(MOTOR_MIXES.roll).toEqual([down, down, up, up]); + expect(MOTOR_MIXES.yaw).toEqual([down, up, up, down]); + expect(MOTOR_MIXES.climb).toEqual([up, up, up, up]); + MOVE_IDS.forEach((move) => expect(MOTOR_MIXES[move]).toHaveLength(4)); + }); + + it('names the motors that speed up for each move', () => { + expect(speedingMotors('hover')).toEqual(MOTOR_PART_IDS); + expect(speedingMotors('forward')).toEqual(['motorRearRight', 'motorRearLeft']); + expect(speedingMotors('roll')).toEqual(['motorRearLeft', 'motorFrontLeft']); + expect(speedingMotors('yaw')).toEqual(['motorFrontRight', 'motorRearLeft']); + expect(speedingMotors('climb')).toEqual(MOTOR_PART_IDS); + }); +}); diff --git a/explainers/fpv-drone/src/model/motors.ts b/explainers/fpv-drone/src/model/motors.ts new file mode 100644 index 00000000..1ed8da23 --- /dev/null +++ b/explainers/fpv-drone/src/model/motors.ts @@ -0,0 +1,82 @@ +import { clamp } from '@core/math'; +import { MOTOR_PART_IDS } from '../ids'; +import type { MotorPartId, MotorReading, MotorShares, MoveId } from '../ids'; +import { MASS_G, MOTOR_COUNT, THRUST_PER_MOTOR_G } from './figures'; +import type { FlightMotion } from './flight'; +import { MOTOR_POSITIONS, MOTOR_SPIN } from './layout'; +import { allUpG } from './payload'; + +export const MOTOR_MAX_THRUST_G = MOTOR_COUNT * THRUST_PER_MOTOR_G.flight; +export const PITCH_MIX = 0.04; +export const ROLL_MIX = 0.04; +export const YAW_MIX = 0.03; +export const DEMO_MIX_DELTA = 0.05; + +type Sign = -1 | 0 | 1; +type MotorSigns = Readonly>; + +function signsBy(pick: (id: MotorPartId) => boolean): MotorSigns { + return Object.fromEntries( + MOTOR_PART_IDS.map((id) => [id, pick(id) ? 1 : -1] as const), + ) as MotorSigns; +} + +export const PITCH_SIGNS = signsBy((id) => MOTOR_POSITIONS[id][0] < 0); +export const ROLL_SIGNS = signsBy((id) => MOTOR_POSITIONS[id][2] < 0); +export const YAW_SIGNS = signsBy((id) => MOTOR_SPIN[id] === 'counterClockwise'); + +const NO_SIGNS = signsBy(() => false); +const ALL_SIGNS = signsBy(() => true); + +const MOVE_SIGNS: Readonly> = { + hover: NO_SIGNS, + forward: PITCH_SIGNS, + roll: ROLL_SIGNS, + yaw: YAW_SIGNS, + climb: ALL_SIGNS, +}; + +export function totalThrustG(flight: FlightMotion, allUp: number): number { + if (!flight.armed) return 0; + const level = allUp / (Math.cos(flight.pitch) * Math.cos(flight.roll)); + return flight.onGround ? level * flight.propRate * flight.propRate : level; +} + +function sharesOf(share: (id: MotorPartId) => number): MotorShares { + const [m1, m2, m3, m4] = MOTOR_PART_IDS.map((id) => clamp(share(id), 0, 1)); + return [m1, m2, m3, m4]; +} + +export function motorsAt(flight: FlightMotion, payloadG: number): MotorReading { + const base = totalThrustG(flight, allUpG(payloadG)) / MOTOR_MAX_THRUST_G; + const pitchTerm = PITCH_MIX * flight.pitchRate; + const rollTerm = ROLL_MIX * flight.rollRate; + const yawTerm = YAW_MIX * flight.headingRate; + return { + shares: sharesOf( + (id) => + base + PITCH_SIGNS[id] * pitchTerm + ROLL_SIGNS[id] * rollTerm + YAW_SIGNS[id] * yawTerm, + ), + }; +} + +export const DEMO_HOVER_SHARE = allUpG(MASS_G.defaultPayload) / MOTOR_MAX_THRUST_G; + +function demoMix(move: MoveId): MotorShares { + const signs = MOVE_SIGNS[move]; + const delta = move === 'hover' ? 0 : DEMO_MIX_DELTA; + return sharesOf((id) => DEMO_HOVER_SHARE + signs[id] * delta); +} + +export const MOTOR_MIXES: Readonly> = { + hover: demoMix('hover'), + forward: demoMix('forward'), + roll: demoMix('roll'), + yaw: demoMix('yaw'), + climb: demoMix('climb'), +}; + +export function speedingMotors(move: MoveId): readonly MotorPartId[] { + if (move === 'hover') return MOTOR_PART_IDS; + return MOTOR_PART_IDS.filter((id) => MOVE_SIGNS[move][id] > 0); +} diff --git a/explainers/fpv-drone/src/model/payload.test.ts b/explainers/fpv-drone/src/model/payload.test.ts new file mode 100644 index 00000000..47854b73 --- /dev/null +++ b/explainers/fpv-drone/src/model/payload.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { MASS_G } from './figures'; +import { + PAYLOAD_RANGE, + USABLE_ENERGY_WH, + allUpG, + flightMinutes, + hoverSpeedShare, + hoverThrustShare, + thrustToWeight, +} from './payload'; + +describe('payload', () => { + it('slides from nothing to one and a half kilos starting at the default load', () => { + expect(PAYLOAD_RANGE).toEqual({ min: 0, max: 1500, step: 50, default: 300 }); + }); + + it('weighs the dry frame, the battery and the payload together', () => { + expect(allUpG(0)).toBe(1100); + expect(allUpG(MASS_G.defaultPayload)).toBe(1400); + }); + + it('gives about 5.7 to 1 thrust to weight at the default load, hovering near 18 % thrust', () => { + expect(thrustToWeight(MASS_G.defaultPayload)).toBeCloseTo(5.71, 2); + expect(thrustToWeight(0)).toBeCloseTo(7.27, 2); + expect(hoverThrustShare(MASS_G.defaultPayload)).toBeCloseTo(0.175, 3); + expect(hoverSpeedShare(MASS_G.defaultPayload)).toBeCloseTo(0.418, 3); + }); + + it('flies about twelve minutes at the default load and less with more weight', () => { + expect(USABLE_ENERGY_WH).toBeCloseTo(72.8, 9); + const minutes = flightMinutes(MASS_G.defaultPayload); + expect(minutes).toBeGreaterThan(11.5); + expect(minutes).toBeLessThan(14); + expect(flightMinutes(0)).toBeGreaterThan(minutes); + expect(flightMinutes(PAYLOAD_RANGE.max)).toBeLessThan(minutes); + expect(flightMinutes(PAYLOAD_RANGE.max)).toBeGreaterThan(0); + }); + + it('cannot fly once thrust to weight falls under 1.2', () => { + expect(flightMinutes(6000)).toBe(0); + }); +}); diff --git a/explainers/fpv-drone/src/model/payload.ts b/explainers/fpv-drone/src/model/payload.ts new file mode 100644 index 00000000..b0ed77e8 --- /dev/null +++ b/explainers/fpv-drone/src/model/payload.ts @@ -0,0 +1,42 @@ +import { + MASS_G, + MIN_THRUST_TO_WEIGHT, + MOTOR_COUNT, + PACK, + THRUST_PER_MOTOR_G, + USABLE_ENERGY_SHARE, +} from './figures'; +import { cruisePowerW } from './power'; +import { MINUTES_PER_HOUR } from './sortie'; + +export const PAYLOAD_RANGE = { + min: 0, + max: MASS_G.maxPayload, + step: 50, + default: MASS_G.defaultPayload, +} as const; + +const MAX_THRUST_G = MOTOR_COUNT * THRUST_PER_MOTOR_G.flight; + +export const USABLE_ENERGY_WH = USABLE_ENERGY_SHARE * PACK.energyWh; + +export function allUpG(payloadG: number): number { + return MASS_G.dry + MASS_G.battery + payloadG; +} + +export function thrustToWeight(payloadG: number): number { + return MAX_THRUST_G / allUpG(payloadG); +} + +export function hoverThrustShare(payloadG: number): number { + return 1 / thrustToWeight(payloadG); +} + +export function hoverSpeedShare(payloadG: number): number { + return Math.sqrt(hoverThrustShare(payloadG)); +} + +export function flightMinutes(payloadG: number): number { + if (thrustToWeight(payloadG) < MIN_THRUST_TO_WEIGHT) return 0; + return (USABLE_ENERGY_WH / cruisePowerW(allUpG(payloadG))) * MINUTES_PER_HOUR; +} diff --git a/explainers/fpv-drone/src/model/playback.test.ts b/explainers/fpv-drone/src/model/playback.test.ts new file mode 100644 index 00000000..464581f3 --- /dev/null +++ b/explainers/fpv-drone/src/model/playback.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { SPEED_RANGE, rate } from './playback'; +import { SORTIE_SECONDS } from './sortie'; + +describe('playback', () => { + it('plays the sortie in real time at normal speed', () => { + expect(rate(1)).toBe(1); + expect(SORTIE_SECONDS / rate(1)).toBe(80); + }); + + it('runs from a quarter to four times real time', () => { + expect(SPEED_RANGE).toEqual({ min: 0.25, max: 4, step: 0.25, default: 1 }); + expect(SORTIE_SECONDS / rate(SPEED_RANGE.max)).toBe(20); + expect(SORTIE_SECONDS / rate(SPEED_RANGE.min)).toBe(320); + }); +}); diff --git a/explainers/fpv-drone/src/model/playback.ts b/explainers/fpv-drone/src/model/playback.ts new file mode 100644 index 00000000..097fe093 --- /dev/null +++ b/explainers/fpv-drone/src/model/playback.ts @@ -0,0 +1,5 @@ +export const SPEED_RANGE = { min: 0.25, max: 4, step: 0.25, default: 1 } as const; + +export function rate(speed: number): number { + return speed; +} diff --git a/explainers/fpv-drone/src/model/power.test.ts b/explainers/fpv-drone/src/model/power.test.ts new file mode 100644 index 00000000..e07016b5 --- /dev/null +++ b/explainers/fpv-drone/src/model/power.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { HOVER_POWER_REF } from './figures'; +import { cruisePowerW, forwardFactor, hoverPowerW, powerAt } from './power'; + +describe('power', () => { + it('eases the forward flight saving in by the cruise speed', () => { + expect(forwardFactor(0)).toBe(1); + expect(forwardFactor(35)).toBeCloseTo(0.9, 9); + expect(forwardFactor(70)).toBeCloseTo(0.8, 9); + expect(forwardFactor(140)).toBeCloseTo(0.8, 9); + }); + + it('grows with thrust to the power of one and a half', () => { + expect(powerAt(HOVER_POWER_REF.allUpG, 0)).toBe(HOVER_POWER_REF.watts); + expect(powerAt(4 * HOVER_POWER_REF.allUpG, 0)).toBeCloseTo(8 * HOVER_POWER_REF.watts, 9); + expect(powerAt(0, 0)).toBe(0); + }); + + it('hovers a 1.4 kg drone on about 360 W and cruises it on about the same', () => { + expect(hoverPowerW(1400)).toBeCloseTo(359, 0); + expect(cruisePowerW(1400)).toBeCloseTo(356, 0); + }); +}); diff --git a/explainers/fpv-drone/src/model/power.ts b/explainers/fpv-drone/src/model/power.ts new file mode 100644 index 00000000..61e620f5 --- /dev/null +++ b/explainers/fpv-drone/src/model/power.ts @@ -0,0 +1,23 @@ +import { lerp, smoothstep } from '@core/math'; +import { CRUISE_POWER_FACTOR, CRUISE_TILT_DEG, HOVER_POWER_REF } from './figures'; +import { SPEED_KMH } from './layout'; +import { thrustShareAt } from './tilt'; + +const INDUCED_POWER_EXPONENT = 1.5; + +export function forwardFactor(speedKmh: number): number { + return lerp(1, CRUISE_POWER_FACTOR, smoothstep(speedKmh, 0, SPEED_KMH.cruise)); +} + +export function powerAt(thrustG: number, speedKmh: number): number { + const ratio = Math.max(0, thrustG) / HOVER_POWER_REF.allUpG; + return HOVER_POWER_REF.watts * ratio ** INDUCED_POWER_EXPONENT * forwardFactor(speedKmh); +} + +export function hoverPowerW(allUpG: number): number { + return powerAt(allUpG, 0); +} + +export function cruisePowerW(allUpG: number): number { + return powerAt(allUpG * thrustShareAt(CRUISE_TILT_DEG), SPEED_KMH.cruise); +} diff --git a/explainers/fpv-drone/src/model/profile.test.ts b/explainers/fpv-drone/src/model/profile.test.ts new file mode 100644 index 00000000..5455e044 --- /dev/null +++ b/explainers/fpv-drone/src/model/profile.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { Profile } from './profile'; + +const RAMP = new Profile([ + [0, 0], + [4, 8], + [10, 8], + [14, 0], +]); + +describe('time profile', () => { + it('holds the knot values and eases between them', () => { + expect(RAMP.at(0)).toBe(0); + expect(RAMP.at(4)).toBe(8); + expect(RAMP.at(7)).toBe(8); + expect(RAMP.at(2)).toBeCloseTo(4, 9); + expect(RAMP.at(1)).toBeLessThan(2); + expect(RAMP.at(3)).toBeGreaterThan(6); + expect(RAMP.at(14)).toBe(0); + }); + + it('clamps outside the knots', () => { + expect(RAMP.at(-3)).toBe(0); + expect(RAMP.at(20)).toBe(0); + expect(RAMP.slopeAt(-3)).toBe(0); + expect(RAMP.integralTo(-3)).toBe(0); + expect(RAMP.integralTo(20)).toBeCloseTo(RAMP.integralTo(14), 9); + }); + + it('has a zero slope at every knot and its steepest slope midway', () => { + expect(RAMP.slopeAt(0)).toBe(0); + expect(RAMP.slopeAt(4)).toBe(0); + expect(RAMP.slopeAt(2)).toBeCloseTo(3, 9); + expect(RAMP.slopeAt(12)).toBeCloseTo(-3, 9); + }); + + it('integrates each ramp to its mean value times its length', () => { + expect(RAMP.integralTo(4)).toBeCloseTo(16, 9); + expect(RAMP.integralTo(10)).toBeCloseTo(64, 9); + expect(RAMP.integralTo(14)).toBeCloseTo(80, 9); + }); + + it('integrates a partial ramp like a fine sum would', () => { + const steps = 20000; + let sum = 0; + for (let index = 0; index < steps; index += 1) { + const time = ((index + 0.5) / steps) * 3; + sum += (RAMP.at(time) * 3) / steps; + } + expect(RAMP.integralTo(3)).toBeCloseTo(sum, 5); + }); +}); diff --git a/explainers/fpv-drone/src/model/profile.ts b/explainers/fpv-drone/src/model/profile.ts new file mode 100644 index 00000000..0b584f0e --- /dev/null +++ b/explainers/fpv-drone/src/model/profile.ts @@ -0,0 +1,75 @@ +import { clamp, smoothstep } from '@core/math'; + +export type Knot = readonly [at: number, value: number]; + +const SLOPE_PEAK = 6; +const CUBIC = 3; +const QUARTIC_HALF = 0.5; +const HALF = 0.5; + +interface Span { + from: Knot; + to: Knot; + length: number; + integralBefore: number; +} + +function fullIntegral(from: Knot, to: Knot): number { + return (to[0] - from[0]) * (from[1] + to[1]) * HALF; +} + +export class Profile { + private readonly spans: readonly Span[]; + private readonly first: Knot; + private readonly last: Knot; + private readonly total: number; + + constructor(knots: readonly Knot[]) { + const spans: Span[] = []; + let integral = 0; + knots.slice(1).forEach((to, index) => { + const from = knots[index]; + spans.push({ from, to, length: to[0] - from[0], integralBefore: integral }); + integral += fullIntegral(from, to); + }); + this.spans = spans; + this.first = knots[0]; + this.last = knots[knots.length - 1]; + this.total = integral; + } + + at(time: number): number { + const found = this.spanAt(time); + if (!found) return this.edgeValue(time); + const { span, share } = found; + return span.from[1] + (span.to[1] - span.from[1]) * smoothstep(share); + } + + slopeAt(time: number): number { + const found = this.spanAt(time); + if (!found) return 0; + const { span, share } = found; + return ((span.to[1] - span.from[1]) * SLOPE_PEAK * share * (1 - share)) / span.length; + } + + integralTo(time: number): number { + const found = this.spanAt(time); + if (!found) return time <= this.first[0] ? 0 : this.total; + const { span, share } = found; + const rise = span.to[1] - span.from[1]; + const curve = share ** CUBIC - QUARTIC_HALF * share ** (CUBIC + 1); + return span.integralBefore + span.length * (span.from[1] * share + rise * curve); + } + + private edgeValue(time: number): number { + return time <= this.first[0] ? this.first[1] : this.last[1]; + } + + private spanAt(time: number): { span: Span; share: number } | null { + if (time < this.first[0] || time > this.last[0]) return null; + const span = + this.spans.find((candidate) => time <= candidate.to[0]) ?? this.spans[this.spans.length - 1]; + if (!span || span.length === 0) return null; + return { span, share: clamp((time - span.from[0]) / span.length, 0, 1) }; + } +} diff --git a/explainers/fpv-drone/src/model/scale.test.ts b/explainers/fpv-drone/src/model/scale.test.ts new file mode 100644 index 00000000..244257a6 --- /dev/null +++ b/explainers/fpv-drone/src/model/scale.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { DRONE_SCALE, droneUnits, toKmh, toMetresPerSecond } from './scale'; + +describe('scale', () => { + it('draws the drone ten times larger than the ground', () => { + expect(DRONE_SCALE).toBe(10); + expect(droneUnits(0.34)).toBeCloseTo(3.4, 9); + }); + + it('converts speeds between metres per second and km/h', () => { + expect(toKmh(20)).toBeCloseTo(72, 9); + expect(toMetresPerSecond(72)).toBeCloseTo(20, 9); + }); +}); diff --git a/explainers/fpv-drone/src/model/scale.ts b/explainers/fpv-drone/src/model/scale.ts new file mode 100644 index 00000000..7619180b --- /dev/null +++ b/explainers/fpv-drone/src/model/scale.ts @@ -0,0 +1,15 @@ +export const DRONE_SCALE = 10; +export const METRES_PER_SECOND_TO_KMH = 3.6; +export const GRAVITY = 9.81; + +export function droneUnits(metres: number): number { + return metres * DRONE_SCALE; +} + +export function toKmh(metresPerSecond: number): number { + return metresPerSecond * METRES_PER_SECOND_TO_KMH; +} + +export function toMetresPerSecond(kmh: number): number { + return kmh / METRES_PER_SECOND_TO_KMH; +} diff --git a/explainers/fpv-drone/src/model/sortie.test.ts b/explainers/fpv-drone/src/model/sortie.test.ts new file mode 100644 index 00000000..6aba3768 --- /dev/null +++ b/explainers/fpv-drone/src/model/sortie.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { PHASE_IDS } from '../ids'; +import { + MOMENTS, + PHASE_RANGES, + SORTIE_SECONDS, + clockAt, + phaseAt, + phaseShareAt, + sortieShareAt, +} from './sortie'; + +describe('sortie clock', () => { + it('runs eighty seconds through six phases without gaps', () => { + expect(SORTIE_SECONDS).toBe(80); + expect(PHASE_RANGES.takeoff.start).toBe(0); + expect(PHASE_RANGES.landing.end).toBe(SORTIE_SECONDS); + PHASE_IDS.slice(1).forEach((id, index) => + expect(PHASE_RANGES[id].start).toBe(PHASE_RANGES[PHASE_IDS[index]].end), + ); + }); + + it('names the phase under the scrubber', () => { + PHASE_IDS.forEach((id) => expect(phaseAt(PHASE_RANGES[id].start)).toBe(id)); + expect(phaseAt(4.9)).toBe('takeoff'); + expect(phaseAt(20)).toBe('transit'); + expect(phaseAt(SORTIE_SECONDS)).toBe('landing'); + expect(phaseAt(500)).toBe('landing'); + expect(phaseShareAt(9)).toBeCloseTo(0.5, 9); + expect(phaseShareAt(-1)).toBe(0); + }); + + it('places the moments at their seconds', () => { + expect(MOMENTS).toEqual({ liftoff: 2, cruise: 13, onStation: 27, turnHome: 49, touchdown: 78 }); + }); + + it('splits the flight time into minutes and seconds', () => { + expect(clockAt(0)).toEqual({ minutes: 0, seconds: 0 }); + expect(clockAt(42.4)).toEqual({ minutes: 0, seconds: 42 }); + expect(clockAt(79.6)).toEqual({ minutes: 1, seconds: 20 }); + expect(clockAt(61)).toEqual({ minutes: 1, seconds: 1 }); + }); + + it('reads the share of the flight flown', () => { + expect(sortieShareAt(0)).toBe(0); + expect(sortieShareAt(40)).toBe(0.5); + expect(sortieShareAt(90)).toBe(1); + }); +}); diff --git a/explainers/fpv-drone/src/model/sortie.ts b/explainers/fpv-drone/src/model/sortie.ts new file mode 100644 index 00000000..adda95bc --- /dev/null +++ b/explainers/fpv-drone/src/model/sortie.ts @@ -0,0 +1,62 @@ +import { clamp } from '@core/math'; +import { PHASE_IDS } from '../ids'; +import type { MomentId, PhaseId } from '../ids'; + +export const SORTIE_SECONDS = 80; +export const SECONDS_PER_MINUTE = 60; +export const MINUTES_PER_HOUR = 60; + +export interface PhaseRange { + start: number; + end: number; +} + +export interface ClockReading { + minutes: number; + seconds: number; +} + +export const PHASE_RANGES: Readonly> = { + takeoff: { start: 0, end: 5 }, + climb: { start: 5, end: 13 }, + transit: { start: 13, end: 27 }, + orbit: { start: 27, end: 49 }, + return: { start: 49, end: 67 }, + landing: { start: 67, end: SORTIE_SECONDS }, +}; + +export const MOMENTS: Readonly> = { + liftoff: 2, + cruise: PHASE_RANGES.climb.end, + onStation: PHASE_RANGES.orbit.start, + turnHome: PHASE_RANGES.return.start, + touchdown: 78, +}; + +const LAST_PHASE = PHASE_IDS[PHASE_IDS.length - 1]; + +export function clampSeconds(seconds: number): number { + return clamp(seconds, 0, SORTIE_SECONDS); +} + +export function phaseAt(seconds: number): PhaseId { + const value = clampSeconds(seconds); + return PHASE_IDS.find((id) => value < PHASE_RANGES[id].end) ?? LAST_PHASE; +} + +export function phaseShareAt(seconds: number): number { + const { start, end } = PHASE_RANGES[phaseAt(seconds)]; + return clamp((seconds - start) / (end - start), 0, 1); +} + +export function clockAt(seconds: number): ClockReading { + const whole = Math.round(clampSeconds(seconds)); + return { + minutes: Math.floor(whole / SECONDS_PER_MINUTE), + seconds: whole % SECONDS_PER_MINUTE, + }; +} + +export function sortieShareAt(seconds: number): number { + return clampSeconds(seconds) / SORTIE_SECONDS; +} diff --git a/explainers/fpv-drone/src/model/speeds.test.ts b/explainers/fpv-drone/src/model/speeds.test.ts new file mode 100644 index 00000000..2717ce25 --- /dev/null +++ b/explainers/fpv-drone/src/model/speeds.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { SPEEDSTER_IDS } from '../ids'; +import { SPEEDSTERS, secondsOverField } from './speeds'; + +describe('speedsters', () => { + it('lists the long range class, the racer and the record holder', () => { + expect(Object.keys(SPEEDSTERS)).toEqual([...SPEEDSTER_IDS]); + expect(SPEEDSTERS.longRange.topKmh).toBe(140); + expect(SPEEDSTERS.racer.topKmh).toBe(263); + expect(SPEEDSTERS.record.topKmh).toBe(657.59); + SPEEDSTER_IDS.forEach((id) => expect(SPEEDSTERS[id].whoKey).toBe(`speeds.who.${id}`)); + }); + + it('crosses the 350 m to the crossroads in nine, five and two seconds', () => { + expect(secondsOverField(140)).toBeCloseTo(9, 0); + expect(secondsOverField(263)).toBeCloseTo(4.8, 1); + expect(secondsOverField(657.59)).toBeCloseTo(1.9, 1); + expect(secondsOverField(70)).toBeCloseTo(18, 0); + }); +}); diff --git a/explainers/fpv-drone/src/model/speeds.ts b/explainers/fpv-drone/src/model/speeds.ts new file mode 100644 index 00000000..43dc29a3 --- /dev/null +++ b/explainers/fpv-drone/src/model/speeds.ts @@ -0,0 +1,18 @@ +import { SPEEDSTER_IDS } from '../ids'; +import type { SpeedsterId } from '../ids'; +import { TOP_SPEED_KMH } from './figures'; +import { OUTBOUND_LENGTH } from './layout'; +import { toMetresPerSecond } from './scale'; + +export interface Speedster { + topKmh: number; + whoKey: string; +} + +export const SPEEDSTERS: Readonly> = Object.fromEntries( + SPEEDSTER_IDS.map((id) => [id, { topKmh: TOP_SPEED_KMH[id], whoKey: `speeds.who.${id}` }]), +) as Record; + +export function secondsOverField(kmh: number): number { + return OUTBOUND_LENGTH / toMetresPerSecond(kmh); +} diff --git a/explainers/fpv-drone/src/model/tilt.test.ts b/explainers/fpv-drone/src/model/tilt.test.ts new file mode 100644 index 00000000..9bc4c848 --- /dev/null +++ b/explainers/fpv-drone/src/model/tilt.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + SPRINT_KMH, + TILT_RANGE, + accelerationAt, + accelerationInG, + secondsToKmh, + thrustShareAt, +} from './tilt'; + +describe('tilt', () => { + it('pushes sideways by g times the tangent of the tilt', () => { + expect(accelerationAt(10)).toBeCloseTo(1.73, 2); + expect(accelerationAt(20)).toBeCloseTo(3.57, 2); + expect(accelerationAt(30)).toBeCloseTo(5.66, 2); + expect(accelerationAt(45)).toBeCloseTo(9.81, 2); + expect(accelerationInG(45)).toBeCloseTo(1, 9); + expect(accelerationAt(0)).toBe(0); + }); + + it('needs more thrust the more it tilts', () => { + expect(thrustShareAt(0)).toBe(1); + expect(thrustShareAt(30)).toBeCloseTo(1.155, 3); + expect(thrustShareAt(45)).toBeCloseTo(1.414, 3); + expect(thrustShareAt(60)).toBeCloseTo(2, 9); + }); + + it('sprints to a hundred in about five seconds at thirty degrees', () => { + expect(SPRINT_KMH).toBe(100); + expect(secondsToKmh(30, SPRINT_KMH)).toBeCloseTo(4.9, 1); + expect(secondsToKmh(45, SPRINT_KMH)).toBeCloseTo(2.83, 2); + expect(secondsToKmh(0, SPRINT_KMH)).toBe(Number.POSITIVE_INFINITY); + }); + + it('slides from level to sixty degrees starting at thirty', () => { + expect(TILT_RANGE).toEqual({ min: 0, max: 60, step: 1, default: 30 }); + }); +}); diff --git a/explainers/fpv-drone/src/model/tilt.ts b/explainers/fpv-drone/src/model/tilt.ts new file mode 100644 index 00000000..ab30fcaf --- /dev/null +++ b/explainers/fpv-drone/src/model/tilt.ts @@ -0,0 +1,21 @@ +import { toRadians } from '@core/math'; +import { GRAVITY, toMetresPerSecond } from './scale'; + +export const TILT_RANGE = { min: 0, max: 60, step: 1, default: 30 } as const; +export const SPRINT_KMH = 100; + +export function accelerationAt(tiltDeg: number): number { + return GRAVITY * Math.tan(toRadians(tiltDeg)); +} + +export function accelerationInG(tiltDeg: number): number { + return accelerationAt(tiltDeg) / GRAVITY; +} + +export function thrustShareAt(tiltDeg: number): number { + return 1 / Math.cos(toRadians(tiltDeg)); +} + +export function secondsToKmh(tiltDeg: number, kmh: number): number { + return toMetresPerSecond(kmh) / accelerationAt(tiltDeg); +} diff --git a/explainers/fpv-drone/src/scene/assembly.ts b/explainers/fpv-drone/src/scene/assembly.ts new file mode 100644 index 00000000..455ba3b1 --- /dev/null +++ b/explainers/fpv-drone/src/scene/assembly.ts @@ -0,0 +1,34 @@ +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 { createFpvAssembly } from './fpvAssembly'; + +export interface AssemblyResources { + materials: MaterialLibrary; + textures: SceneTextures; +} + +export interface ChaseTarget { + position: Point; + heading: number; + pitch: number; + roll: number; + span: number; +} + +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 createFpvAssembly(resources, state); +} diff --git a/explainers/fpv-drone/src/scene/bindings.test.ts b/explainers/fpv-drone/src/scene/bindings.test.ts new file mode 100644 index 00000000..5d78188d --- /dev/null +++ b/explainers/fpv-drone/src/scene/bindings.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AssemblyState } from '../ids'; +import { batteryAt, flightAt, linkAt, motorsAt } from '../model'; +import { PRESETS, createFpvStore } from '../state'; +import { bindStore, highlightFor } from './bindings'; +import type { FpvController } from './controller'; + +function fakeTargets() { + const received: AssemblyState[] = []; + const snapshots: AssemblyState[] = []; + const record = (state: AssemblyState) => { + received.push(state); + snapshots.push({ ...state }); + }; + const frame = vi.fn(); + const drone = { build: record, setState: record, views: { frame } }; + return { + received, + snapshots, + frame, + targets: { + drone: drone as unknown as FpvController, + labelVisibility: { setWanted: vi.fn() }, + highlighter: { setHighlight: vi.fn() }, + labels: { show: vi.fn() }, + }, + }; +} + +describe('scene bindings', () => { + it('builds the scene at the store moment and frames the first chapter', () => { + const { received, frame, targets } = fakeTargets(); + bindStore(createFpvStore({ phase: 38 }), targets); + const flight = flightAt(38); + expect(received[0]).toEqual({ + phase: 38, + flight, + motors: motorsAt(flight, 300), + battery: batteryAt(38, 300), + link: linkAt(flight.position), + video: 'analogue', + move: 'hover', + playing: true, + view: { links: true, track: true, arrows: false, labels: true }, + }); + expect(frame).toHaveBeenCalledWith('chase', false, undefined); + }); + + it('hands the scene the readings for every change of time, video, move, payload and view', () => { + const { received, targets } = fakeTargets(); + const store = createFpvStore(); + bindStore(store, targets); + store.getState().seekMoment('onStation'); + expect(received.at(-1)?.phase).toBe(27); + store.getState().setVideo('digital'); + expect(received.at(-1)?.video).toBe('digital'); + store.getState().applyPreset('flight'); + store.getState().setMove('yaw'); + expect(received.at(-1)?.move).toBe('yaw'); + store.getState().applyPreset('power'); + store.getState().setPayload(1200); + expect(received.at(-1)?.battery).toEqual(batteryAt(30, 1200)); + store.getState().toggleView('links'); + expect(received.at(-1)?.view.links).toBe(false); + }); + + it('never changes the state object the assembly got last', () => { + const { received, snapshots, targets } = fakeTargets(); + const store = createFpvStore(); + bindStore(store, targets); + store.getState().setPhase(10); + store.getState().setPhase(20); + const last = received.length - 1; + expect(received[last]).not.toBe(received[last - 1]); + expect(received[last - 1]).toEqual(snapshots[last - 1]); + }); + + it('frames each chapter at its moment of the flight', () => { + const { received, frame, targets } = fakeTargets(); + const store = createFpvStore({ phase: 70 }); + bindStore(store, targets); + store.getState().applyPreset('limits'); + expect(frame).toHaveBeenLastCalledWith('fpv', true, undefined); + expect(received.at(-1)?.phase).toBe(52); + }); + + it('highlights the motors that speed up for the chosen move in the flight chapter', () => { + const { targets } = fakeTargets(); + const store = createFpvStore(); + bindStore(store, targets); + const highlighted = () => targets.highlighter.setHighlight.mock.lastCall?.[0] as string[]; + store.getState().applyPreset('flight'); + expect(highlighted()).toEqual([ + 'motorRearRight', + 'motorFrontRight', + 'motorRearLeft', + 'motorFrontLeft', + 'propellers', + ]); + store.getState().setMove('forward'); + expect(highlighted()).toEqual(['motorRearRight', 'motorRearLeft', 'propellers']); + store.getState().setMove('roll'); + expect(highlighted()).toEqual(['motorRearLeft', 'motorFrontLeft', 'propellers']); + store.getState().setMove('yaw'); + expect(highlighted()).toEqual(['motorFrontRight', 'motorRearLeft', 'propellers']); + store.getState().applyPreset('power'); + expect(highlighted()).toEqual(['battery']); + const calls = targets.highlighter.setHighlight.mock.calls.length; + store.getState().setMove('climb'); + expect(targets.highlighter.setHighlight).toHaveBeenCalledTimes(calls); + }); + + it('keeps the preset highlight outside the flight chapter', () => { + expect(highlightFor(PRESETS.link, { preset: 'link', move: 'yaw' })).toEqual( + PRESETS.link.highlight, + ); + }); + + it('labels the links only while they are drawn', () => { + const { targets } = fakeTargets(); + const store = createFpvStore(); + bindStore(store, targets); + const wanted = () => + targets.labelVisibility.setWanted.mock.lastCall?.[0] as ReadonlySet; + store.getState().applyPreset('link'); + expect(wanted()).toContain('controlLink'); + store.getState().toggleView('links'); + expect(wanted()).not.toContain('controlLink'); + expect(wanted()).not.toContain('videoLink'); + }); + + it('stops listening once unbound', () => { + const { received, targets } = fakeTargets(); + const store = createFpvStore(); + const unbind = bindStore(store, targets); + unbind(); + const calls = received.length; + store.getState().setPhase(50); + expect(received).toHaveLength(calls); + }); +}); diff --git a/explainers/fpv-drone/src/scene/bindings.ts b/explainers/fpv-drone/src/scene/bindings.ts new file mode 100644 index 00000000..d119545d --- /dev/null +++ b/explainers/fpv-drone/src/scene/bindings.ts @@ -0,0 +1,101 @@ +import { bindPresets } from '@core/scene/presetBinder'; +import type { LabelPolicy, PresetTargets } from '@core/scene/presetBinder'; +import type { AssemblyState, PartId } from '../ids'; +import { speedingMotors } from '../model'; +import { CHAPTER_CONTROL_DEFAULTS, DEFAULT_VIDEO, DEFAULT_VIEW, PRESETS, sortieAt } from '../state'; +import type { FpvState, FpvStore, FpvStoreState, Preset } from '../state'; +import type { FpvController } from './controller'; +import { DrawnLabels } from './drawnLabels'; + +export interface SceneTargets extends PresetTargets { + drone: FpvController; + labelVisibility: LabelPolicy; +} + +type AssemblySource = Pick; +type HighlightSource = Pick; + +const MOVE_CHAPTER = 'flight'; +const SPINNING_PART: PartId = 'propellers'; + +const BLANK_SOURCE: AssemblySource = { + phase: 0, + video: DEFAULT_VIDEO, + move: CHAPTER_CONTROL_DEFAULTS.move, + payload: CHAPTER_CONTROL_DEFAULTS.payload, + playing: false, + view: DEFAULT_VIEW, +}; + +export function highlightFor(preset: Preset, state: HighlightSource): readonly PartId[] { + if (state.preset !== MOVE_CHAPTER) return preset.highlight; + return [...speedingMotors(state.move), SPINNING_PART]; +} + +function copyInto(target: AssemblyState, source: AssemblySource): AssemblyState { + const { flight, motors, battery, link } = sortieAt(source); + target.phase = source.phase; + target.flight = flight; + target.motors = motors; + target.battery = battery; + target.link = link; + target.video = source.video; + target.move = source.move; + target.playing = source.playing; + target.view = source.view; + return target; +} + +function blankState(): AssemblyState { + const { phase, video, move, playing, view } = BLANK_SOURCE; + const { flight, motors, battery, link } = sortieAt(BLANK_SOURCE); + return { phase, flight, motors, battery, link, video, move, playing, view }; +} + +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; + } +} + +function followMove(store: FpvStore, targets: PresetTargets): () => void { + return store.subscribe( + (state) => state.move, + () => { + const state = store.getState(); + if (state.preset !== MOVE_CHAPTER) return; + targets.highlighter.setHighlight(highlightFor(PRESETS[state.preset], state)); + }, + ); +} + +export function bindStore(store: FpvStore, targets: SceneTargets): () => void { + const { drone, labelVisibility } = 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) => drone.setState(handOver(state)); + drone.build(handOver(store.getState())); + const unsubscribers = [ + store.subscribe(push), + followMove(store, targets), + bindPresets(targets, store, { + presets: PRESETS, + views: drone.views, + labels: drawnLabels, + prepare: push, + highlight: highlightFor, + }), + ]; + return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); +} diff --git a/explainers/fpv-drone/src/scene/cameraViews.test.ts b/explainers/fpv-drone/src/scene/cameraViews.test.ts new file mode 100644 index 00000000..1e76c034 --- /dev/null +++ b/explainers/fpv-drone/src/scene/cameraViews.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import { toRadians } from '@core/math'; +import { regionFromSpec } from '@core/scene/regions'; +import { DRONE, ROUTE_BOUNDS, droneUnits } from '../model'; +import { PRESETS } from '../state'; +import type { ChaseTarget } from './assembly'; +import { + CAMERA_OFFSET, + FIXED_VIEWS, + FOLLOW_VIEWS, + cameraViews, + followPose, + fpvPose, +} from './cameraViews'; + +const SLOPES = { vertical: 0.29, horizontal: 0.45 }; + +const CHASE: ChaseTarget = { + position: [200, 40, 0], + heading: 0, + pitch: 0, + roll: 0, + span: droneUnits(DRONE.wheelbase), +}; + +describe('camera views', () => { + const views = cameraViews(() => CHASE); + + it('offers every view the chapters ask for', () => { + Object.values(PRESETS).forEach((preset) => expect(views[preset.camera]).toBeDefined()); + }); + + it('turns with the drone in the follow views and banks with it in the goggles', () => { + (['chase', 'top', 'close', 'side'] as const).forEach((id) => + expect(views[id].follow, id).toBe('heading'), + ); + expect(views.fpv.follow).toBe('attitude'); + expect(views.pilot.follow).toBeUndefined(); + }); + + it('leaves the camera alone until the drone exists', () => { + const empty = cameraViews(() => null); + (['chase', 'top', 'close', 'side', 'fpv'] as const).forEach((id) => { + const view = empty[id]; + expect('pose' in view && view.pose(SLOPES), id).toBeNull(); + }); + }); + + it('chases from behind and above, looking at the drone', () => { + const { position, target } = followPose(CHASE, FOLLOW_VIEWS.chase, SLOPES); + expect(target.toArray()).toEqual([200, 40, 0]); + const offset = position.clone().sub(target); + expect(offset.x).toBeLessThan(0); + expect(offset.y).toBeGreaterThan(0); + expect(Math.asin(offset.y / offset.length())).toBeCloseTo(toRadians(18), 9); + }); + + it('looks almost straight down for the top view and abeam for the side view', () => { + const top = followPose(CHASE, FOLLOW_VIEWS.top, SLOPES); + const down = top.position.clone().sub(top.target).normalize(); + expect(down.y).toBeGreaterThan(Math.sin(toRadians(75))); + const side = followPose(CHASE, FOLLOW_VIEWS.side, SLOPES); + const abeam = side.position.clone().sub(side.target); + expect(abeam.z).toBeGreaterThan(0); + expect(Math.abs(abeam.x)).toBeLessThan(1e-9); + }); + + it('turns with the heading', () => { + const turned = followPose({ ...CHASE, heading: Math.PI / 2 }, FOLLOW_VIEWS.side, SLOPES); + const offset = turned.position.clone().sub(turned.target); + expect(offset.x).toBeLessThan(0); + expect(Math.abs(offset.z)).toBeLessThan(1e-9); + }); + + it('frames the close view nearer than the chase view', () => { + const close = followPose(CHASE, FOLLOW_VIEWS.close, SLOPES); + const chase = followPose(CHASE, FOLLOW_VIEWS.chase, SLOPES); + expect(close.position.distanceTo(close.target)).toBeLessThan( + chase.position.distanceTo(chase.target), + ); + }); + + it('backs off further when the stage is wider than it is tall', () => { + const wide = { vertical: 0.15, horizontal: 0.45 }; + const close = followPose(CHASE, FOLLOW_VIEWS.close, SLOPES); + const squat = followPose(CHASE, FOLLOW_VIEWS.close, wide); + expect(squat.position.distanceTo(squat.target)).toBeCloseTo( + (close.position.distanceTo(close.target) * SLOPES.vertical) / wide.vertical, + ); + }); + + it('puts the fpv eye at the camera, looking ahead and up by the camera tilt', () => { + const { position, target } = fpvPose(CHASE); + expect(position.toArray()).toEqual([ + CHASE.position[0] + CAMERA_OFFSET[0], + CHASE.position[1] + CAMERA_OFFSET[1], + CHASE.position[2] + CAMERA_OFFSET[2], + ]); + const look = target.clone().sub(position).normalize(); + expect(Math.asin(look.y)).toBeCloseTo(DRONE.cameraTilt, 9); + expect(look.x).toBeGreaterThan(0); + }); + + it('keeps the fpv camera on the nose when the drone pitches forward', () => { + const pitched = fpvPose({ ...CHASE, pitch: toRadians(30) }); + const look = pitched.target.clone().sub(pitched.position).normalize(); + expect(Math.asin(look.y)).toBeCloseTo(DRONE.cameraTilt - toRadians(30), 9); + expect(pitched.position.y).toBeLessThan(CHASE.position[1] + CAMERA_OFFSET[1]); + const turned = fpvPose({ ...CHASE, heading: Math.PI / 2 }); + expect(turned.position.z).toBeGreaterThan(CHASE.position[2]); + }); + + it('frames the route for the pilot from behind the station, slightly above', () => { + expect(FIXED_VIEWS.pilot.region).toBe('route'); + const direction = new Vector3( + ...(FIXED_VIEWS.pilot.direction as readonly [number, number, number]), + ).normalize(); + expect(direction.x).toBeLessThan(-0.9); + expect(direction.y).toBeGreaterThan(0); + expect(regionFromSpec(ROUTE_BOUNDS).isEmpty()).toBe(false); + }); +}); diff --git a/explainers/fpv-drone/src/scene/cameraViews.ts b/explainers/fpv-drone/src/scene/cameraViews.ts new file mode 100644 index 00000000..cbc09eb0 --- /dev/null +++ b/explainers/fpv-drone/src/scene/cameraViews.ts @@ -0,0 +1,114 @@ +import { Euler, Vector3 } from 'three'; +import { toRadians } from '@core/math'; +import type { CustomView, FramedView, ViewSpec } from '@core/scene/cameraViews'; +import type { CameraPose } from '@core/scene/frameBox'; +import type { FramingSlopes } from '@core/scene/lens'; +import type { CameraView, Point, RegionId } from '../ids'; +import { DRONE, DRONE_LAYOUT, droneUnits } from '../model'; +import type { ChaseTarget } from './assembly'; + +export type ChaseSource = () => ChaseTarget | null; + +type FollowView = Extract; +type FixedView = Extract; + +export interface FollowSpec { + bearing: number; + elevation: number; + spans: number; + aimAhead: number; + aimUp: number; +} + +const DRONE_ROTATION_ORDER = 'YZX'; +const FPV_LOOK_AHEAD_M = 60; +const PILOT_MARGIN = 1.05; + +export const FOLLOW_VIEWS: Readonly> = { + chase: { bearing: toRadians(-150), elevation: toRadians(18), spans: 2.2, aimAhead: 0, aimUp: 0 }, + top: { bearing: toRadians(180), elevation: toRadians(80), spans: 1.8, aimAhead: 0, aimUp: 0 }, + close: { bearing: toRadians(-60), elevation: toRadians(20), spans: 1.6, aimAhead: 0, aimUp: 0 }, + side: { bearing: toRadians(90), elevation: toRadians(5), spans: 1.8, aimAhead: 0, aimUp: 0 }, +}; + +export const FIXED_VIEWS: Readonly>> = { + pilot: { region: 'route', direction: [-1, 0.42, 0.12], margin: PILOT_MARGIN }, +}; + +export const CAMERA_OFFSET: Point = [ + droneUnits(DRONE_LAYOUT.camera[0]), + droneUnits(DRONE_LAYOUT.camera[1]), + droneUnits(DRONE_LAYOUT.camera[2]), +]; + +function vectorOf(point: Point): Vector3 { + return new Vector3(point[0], point[1], point[2]); +} + +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)); +} + +function fitDistance(size: number, slopes: FramingSlopes): number { + return size / (2 * Math.min(slopes.horizontal, slopes.vertical)); +} + +function aimPoint(chase: ChaseTarget, spec: FollowSpec): Vector3 { + return vectorOf(chase.position) + .add(direction(chase.heading, 0).multiplyScalar(spec.aimAhead)) + .add(new Vector3(0, spec.aimUp, 0)); +} + +export function followPose( + chase: ChaseTarget, + spec: FollowSpec, + slopes: FramingSlopes, +): CameraPose { + const aim = aimPoint(chase, spec); + const distance = fitDistance(spec.spans * chase.span, slopes); + const offset = direction(chase.heading + spec.bearing, spec.elevation).multiplyScalar(distance); + return { position: aim.clone().add(offset), target: aim }; +} + +export function droneEuler(chase: Pick): Euler { + return new Euler(chase.roll, -chase.heading, -chase.pitch, DRONE_ROTATION_ORDER); +} + +export function fpvPose(chase: ChaseTarget): CameraPose { + const offset = vectorOf(CAMERA_OFFSET).applyEuler(droneEuler(chase)); + const position = vectorOf(chase.position).add(offset); + const look = direction(chase.heading, DRONE.cameraTilt - chase.pitch); + return { position, target: position.clone().addScaledVector(look, FPV_LOOK_AHEAD_M) }; +} + +function followView(source: ChaseSource, spec: FollowSpec): CustomView { + return { + pose: (slopes) => { + const chase = source(); + return chase ? followPose(chase, spec, slopes) : null; + }, + follow: 'heading', + }; +} + +function fpvView(source: ChaseSource): CustomView { + return { + pose: () => { + const chase = source(); + return chase ? fpvPose(chase) : null; + }, + follow: 'attitude', + }; +} + +export function cameraViews(source: ChaseSource): Record> { + return { + chase: followView(source, FOLLOW_VIEWS.chase), + top: followView(source, FOLLOW_VIEWS.top), + close: followView(source, FOLLOW_VIEWS.close), + side: followView(source, FOLLOW_VIEWS.side), + fpv: fpvView(source), + ...FIXED_VIEWS, + }; +} diff --git a/explainers/fpv-drone/src/scene/constants.ts b/explainers/fpv-drone/src/scene/constants.ts new file mode 100644 index 00000000..3c694c25 --- /dev/null +++ b/explainers/fpv-drone/src/scene/constants.ts @@ -0,0 +1,467 @@ +import { toRadians } from '@core/math'; +import type { PartId } from '../ids'; +import { + CROSSROADS, + DRONE, + DRONE_LAYOUT, + PAD, + ROAD, + SCENE_BOUNDS, + TREELINE, +} from '../model/layout'; +import { DEMO_HOVER_SHARE } from '../model/motors'; +import { droneUnits } from '../model/scale'; + +type Triple = readonly [number, number, number]; +type Extent = readonly [number, number]; + +export const PLATE = { + thickness: 0.0025, + x: [-0.15, 0.12] as Extent, + halfWidth: DRONE.plateWidth / 2, + corner: 0.01, + gap: DRONE.plateGap, + holes: [ + { x: -0.11, z: 0, radius: 0.006 }, + { x: 0.07, z: 0, radius: 0.005 }, + ], + weave: { size: 128, tows: 8, towMetres: 0.0032, contrast: 0.42, grain: 0.08, seed: 9 }, +} as const; + +export const ARMS = { + width: DRONE.armWidth, + thickness: DRONE.armThickness, + roots: { + motorFrontRight: [0.05, 0.02] as Extent, + motorFrontLeft: [0.05, -0.02] as Extent, + motorRearRight: [-0.08, 0.02] as Extent, + motorRearLeft: [-0.08, -0.02] as Extent, + }, + overhang: 0.02, + foot: { radius: 0.007, tip: 0.0055, height: 0.018, inboard: 0.03, segments: 10 }, +} as const; + +export const PAD_TOP_METRES = 0.005; + +export const BODY_DROP = DRONE.restHeight - PLATE.thickness - ARMS.foot.height - PAD_TOP_METRES; + +export const STANDOFFS = { + radius: 0.0028, + segments: 8, + spots: [ + [0.09, 0.018], + [0.09, -0.018], + [-0.03, 0.019], + [-0.03, -0.019], + [-0.125, 0.018], + [-0.125, -0.018], + ] as readonly Extent[], +} as const; + +export const MOTOR = { + radius: DRONE.motorDiameter / 2, + base: ARMS.thickness, + heights: { foot: 0.004, stator: 0.003, bell: DRONE.motorHeight - 0.007, dome: 0.002 }, + statorRadius: 0.0165, + ring: { inner: 0.012, thickness: 0.0012 }, + shaft: { radius: 0.0035, height: 0.006 }, + segments: 28, + slots: { size: 64, count: 9, width: 0.45, from: 0.15, to: 0.72 }, + mount: { radius: 0.0165, thickness: 0.002 }, +} as const; + +export const PROP = { + radius: DRONE.propDiameter / 2, + blades: DRONE.blades, + hub: { radius: 0.009, height: 0.005, segments: 16 }, + pitchMetres: 0.094, + incidence: toRadians(3), + pitchAxisShare: 0.3, + samples: 10, + stations: [ + { radius: 0.01, chord: 0.012, thickness: 0.2 }, + { radius: 0.022, chord: 0.02, thickness: 0.12 }, + { radius: 0.04, chord: 0.023, thickness: 0.08 }, + { radius: 0.06, chord: 0.021, thickness: 0.06 }, + { radius: 0.08, chord: 0.016, thickness: 0.05 }, + { radius: 0.092, chord: 0.009, thickness: 0.045 }, + { radius: DRONE.propDiameter / 2, chord: 0.003, thickness: 0.04 }, + ], + camber: 0.035, + spinRate: 26, + blurFrom: 0.6, + blurOpacity: 0.36, + disc: { size: 128, hub: 0.1, tipRing: 0.95, ringWidth: 0.04, ringBoost: 0.9, edgeSoftness: 0.05 }, + discSegments: 40, + lift: 0.0005, +} as const; + +export const PROP_BLUR_FROM = PROP.blurFrom; + +export const BATTERY = { + centre: DRONE_LAYOUT.battery, + size: [DRONE.batteryLength, DRONE.batteryHeight, DRONE.batteryWidth] as Triple, + corner: 0.005, + strap: { x: -0.02, width: 0.02, thickness: 0.002, buckle: [0.012, 0.004, 0.014] as Triple }, + lead: { radius: 0.0015, offsets: [0.004, -0.004] as Extent, segments: 6 }, + plug: { at: [0.07, 0.04, 0.012] as Triple, size: [0.016, 0.008, 0.012] as Triple }, + balance: { at: [-0.082, 0.062, 0.01] as Triple, size: [0.012, 0.005, 0.006] as Triple }, +} as const; + +export const STACK = { + centre: DRONE_LAYOUT.stack, + board: { size: 0.036, thickness: 0.0015, levels: [-0.003, 0.009] as Extent }, + standoff: { radius: 0.0015, inset: 0.015, segments: 6 }, + capacitor: { radius: 0.004, length: 0.012, at: [-0.03, 0.004, 0] as Triple }, + chips: [ + { at: [0.004, 0, 0.004] as Triple, size: [0.008, 0.0015, 0.008] as Triple }, + { at: [-0.006, 0, -0.008] as Triple, size: [0.005, 0.001, 0.005] as Triple }, + { at: [0.009, 0, -0.009] as Triple, size: [0.004, 0.001, 0.004] as Triple }, + ], +} as const; + +export const CAMERA_MODULE = { + centre: DRONE_LAYOUT.camera, + tilt: DRONE.cameraTilt, + cheek: { size: [0.014, 0.03, 0.003] as Triple, z: 0.0125, y: 0.019 }, + analogue: { body: DRONE.cameraSize, lensRadius: 0.0065, barrel: 0.009 }, + digital: { body: 0.022, lensRadius: 0.0085, barrel: 0.011 }, + ring: 0.0015, + segments: 20, +} as const; + +export const VIDEO_ANTENNA = { + centre: DRONE_LAYOUT.videoAntenna, + mount: { size: [0.012, 0.008, 0.012] as Triple }, + analogue: { stub: 0.0025, length: 0.03, cap: 0.009, squash: 0.55, segments: 14 }, + digital: { patch: [0.003, 0.018, 0.024] as Triple, stalk: 0.006, lift: 0.004 }, + top: 0.085, +} as const; + +export const RECEIVER_ANTENNAS = { + bases: DRONE_LAYOUT.receiverAntennas, + holder: { size: [0.01, 0.012, 0.008] as Triple }, + whip: { radius: 0.001, length: 0.07, direction: [-0.5, 0.6, 0.62] as Triple, segments: 6 }, + tip: { radius: 0.0018, share: 0.35 }, +} as const; + +export const GPS = { + centre: DRONE_LAYOUT.gpsModule, + mast: { radius: 0.002, segments: 8 }, + puck: { radius: 0.0125, height: 0.007, segments: 20 }, + patch: { size: 0.012, thickness: 0.0015 }, +} as const; + +export const DRONE_BOX = { + x: [-0.14 - PROP.radius, 0.11 + PROP.radius] as Extent, + y: [-PLATE.thickness - ARMS.foot.height, 0.095] as Extent, + z: [-0.13 - PROP.radius, 0.13 + PROP.radius] as Extent, +} as const; + +export const SPIN_ARROWS = { + size: DRONE.propDiameter * 1.05, + lift: 0.048, + texture: { size: 128, radius: 44, stroke: 9, sweep: toRadians(265), head: 15, font: 46 }, + brightness: { base: 0.72, gain: 5, min: 0.3 }, +} as const; + +export const DOWNWASH = { + radius: PROP.radius * 0.94, + length: 0.42, + around: 20, + along: 16, + waist: { share: 0.26, by: 0.35 }, + flare: { share: 0.2, from: 0.5 }, + lanes: 8, + laneWidth: [0.14, 0.32] as Extent, + laneSkip: 0.22, + dashes: 2.2, + pace: { step: 0.25, count: 3 }, + swirl: toRadians(25), + lengthFloor: 0.55, + fade: { top: 0.06, from: 0.3 }, + opacity: 0.4, + haze: 0.12, + softness: 0.8, + groundFade: 0.8, + colour: '#f3f7fb', + level: { reference: DEMO_HOVER_SHARE * 1.25, gain: 8, max: 1.4, shown: 0.02 }, + flow: { base: 0.35, gain: 0.9, wrap: 4 }, + bend: { max: 1.3, fullAtKmh: 70, power: 1.6, rise: 0.4 }, +} as const; + +export const SCALED_LABELS = { + propellerTop: 0.012, +} as const; + +export const SKY = { + radius: 6000, + widthSegments: 32, + heightSegments: 16, + renderOrder: -10, + rise: 3.2, + haze: 0.018, + cloud: { + scale: 2.2, + cover: 0.5, + softness: 0.16, + lift: 0.1, + band: { from: 0.004, to: 0.85 }, + strength: 0.92, + shading: { reach: 0.25, contrast: 2.4 }, + lit: '#f2f4f7', + shade: '#a1adbb', + }, +} as const; + +export const HAZE = { near: 500, far: 2600 } as const; + +export const FIELD = { + extent: { x: [-260, 700] as Extent, z: [-420, 360] as Extent }, + cell: 6, + outer: 9000, + outerSink: 0.08, + texture: { size: 256, metres: 24, stripes: 4, stripeDepth: 0.1, mottle: 0.18, seed: 17 }, + patches: { scale: 46, from: 0.48, to: 0.68, seed: 23 }, + tufts: { scale: 15, from: 0.55, to: 0.75, seed: 31, share: 0.45 }, + tracks: { scale: 180, from: 0.56, to: 0.62, seed: 29, share: 0.35 }, + bare: { scale: 11, from: 0.7, to: 0.76, seed: 37, share: 0.75 }, + mown: { width: 12, depth: 0.05 }, +} as const; + +export const ROADS = { + halfWidth: ROAD.halfWidth, + lift: 0.03, + along: { z: CROSSROADS[2], x: [-80, 520] as Extent }, + across: { x: CROSSROADS[0], z: [-300, 200] as Extent }, + texture: { + size: 64, + base: 0.84, + rut: 0.24, + rutWidth: 0.07, + rutDepth: 0.24, + dust: 0.14, + dustWidth: 0.12, + wear: 0.12, + metres: 8, + seed: 41, + }, +} as const; + +export const CROSSING = { + centre: CROSSROADS, + anchorLift: 2.5, + shed: { + at: [-10, 0, -9] as Triple, + size: [6, 2.6, 4.5] as Triple, + roof: { rise: 1.4, overhang: 0.4 }, + heading: toRadians(12), + door: { width: 1.2, height: 2, depth: 0.08, shift: 0.2, frame: 0.08 }, + window: { width: 0.8, height: 0.6, sill: 1.1, depth: 0.05 }, + }, + car: { + at: [8, 0, 7] as Triple, + heading: toRadians(-40), + sag: toRadians(4), + sill: { x: [-2.3, 2.3] as Extent, y: [0.62, 0.95] as Extent, halfWidth: 0.9 }, + bonnet: { x: [1.25, 2.3] as Extent, top: 1.2, halfWidth: 0.86 }, + cabin: { x: [0.05, 1.25] as Extent, top: 1.8, halfWidth: 0.82 }, + bed: { x: [-2.3, 0.05] as Extent, top: 1.3, wall: 0.06 }, + glass: { inset: 0.12, bottom: 1.24, top: 1.7, thickness: 0.03 }, + bumper: { depth: 0.12, y: [0.55, 0.75] as Extent, halfWidth: 0.92 }, + wheel: { radius: 0.38, width: 0.24, axle: 1.45, track: 0.86, segments: 14 }, + }, +} as const; + +export const TREES = { + line: TREELINE, + rows: [ + { z: 0, spacing: 7.5, offset: 0, jitter: 3 }, + { z: -9, spacing: 9, offset: 4, jitter: 4 }, + ], + seed: 53, + poplarShare: 0.35, + conifer: { + height: [7, 18] as Extent, + tiers: 3, + radius: [0.26, 0.36] as Extent, + overlap: 0.42, + segments: 8, + trunk: { radius: 0.25, share: 0.12 }, + greens: ['#2f3f2a', '#26352a', '#3a4a2e', '#33442f', '#2b3a30'], + shade: { base: 0.78, tip: 1.08 }, + }, + poplar: { + height: [12, 20] as Extent, + trunk: { radius: [0.14, 0.32] as Extent, segments: 7 }, + branches: { + count: 12, + from: 0.25, + length: 0.28, + spread: [toRadians(16), toRadians(30)] as Extent, + radius: 0.07, + segments: 5, + }, + twigs: { perBranch: 2, at: 0.55, length: 0.45, fork: toRadians(28), radius: 0.03 }, + }, +} as const; + +export const SHRUBS = { + count: 900, + size: 1.6, + lift: 0.45, + seed: 61, + area: { x: [-200, 620] as Extent, z: [-380, 320] as Extent }, + keepOut: { station: 14, road: 4 }, + colours: ['#4c5a32', '#5d6639', '#6a6a3e', '#3f4a2b'], + opacity: 0.85, +} as const; + +export const STATION_SET = { + dugout: { + corner: [-4, 0, -3] as Triple, + alongX: 5.5, + alongZ: 5.5, + log: { radius: 0.13, rows: 4, segments: 10, jitter: 0.05 }, + sandbags: { + size: [0.56, 0.13, 0.34] as const, + squareness: 2.4, + widthSegments: 10, + heightSegments: 6, + spacing: 0.5, + rows: 3, + rowRise: 0.11, + wobble: 0.05, + turn: 0.12, + }, + }, + tripod: { + at: [-1.4, 0, 1.4] as Triple, + apex: 1.6, + spread: 0.7, + legRadius: 0.018, + mast: { height: 0.3, radius: 0.015 }, + patch: { size: [0.04, 0.22, 0.22] as Triple, tilt: toRadians(8) }, + segments: 8, + }, + hardCase: { + at: [-1.3, 0, -0.9] as Triple, + size: [0.62, 0.26, 0.46] as Triple, + lid: 0.03, + goggles: { + offset: [0.08, 0, 0.1] as Triple, + size: [0.11, 0.08, 0.17] as Triple, + lens: { radius: 0.03, spacing: 0.065, depth: 0.012, segments: 12 }, + }, + radio: { + offset: [-0.12, 0, -0.08] as Triple, + size: [0.19, 0.045, 0.28] as Triple, + stick: { radius: 0.006, height: 0.05, spacing: 0.17 }, + antenna: { radius: 0.006, height: 0.18 }, + }, + }, + labelLift: 1.9, +} as const; + +export const LAUNCH_PAD = { + at: PAD, + size: droneUnits(0.45), + thickness: droneUnits(PAD_TOP_METRES), + border: 0.25, + markLift: 0.012, + labelInset: 0.4, +} 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; +} + +export const BEAMS = { + control: { + startRadius: 0.05, + endRadius: 0.12, + opacity: 0.55, + core: 1.4, + glow: false, + dash: { period: 3, duty: 0.5, speed: 2.4, logScale: 30, floor: 0.3 }, + fade: [0.25, 0.02], + segments: 8, + }, + video: { + startRadius: 0.14, + endRadius: 0.06, + opacity: 0.32, + core: 1.2, + glow: false, + dash: { period: 6, duty: 0.6, speed: -1.6, logScale: 40, floor: 0.35 }, + fade: [0.02, 0.25], + segments: 8, + }, +} as const satisfies Record; + +export const LINKS = { + signalFloor: 0.2, + labelShare: { control: 0.45, video: 0.6 }, +} as const; + +export const TRACK = { + step: 0.25, + capacity: 1024, + maxGap: 8, + width: 1.1, + drop: 0.5, + opacity: 0.38, + fadeSeconds: 24, + floor: 0.08, +} as const; + +export const GROUND_WASH = { + radius: 6, + spread: 0.5, + lift: LAUNCH_PAD.thickness + LAUNCH_PAD.markLift * 2, + reach: { full: 1, none: 6 }, + thrust: { reference: DEMO_HOVER_SHARE, max: 1.3 }, + rings: { count: 3, speed: 0.8, wrap: 64 }, + envelope: { inner: [0.05, 0.3] as Extent, outer: 0.55 }, + streaks: { fine: 41, coarse: 13 }, + opacity: 0.75, + colour: '#ddd2b0', + shown: 0.01, +} as const; + +export const WIND_STREAKS = { + count: 120, + box: [30, 12, 30] as Triple, + ahead: 9, + length: { seconds: 0.11, max: 3 }, + width: 0.014, + speedKmh: { from: 50, to: 62 }, + rider: { full: 1, none: 2 }, + near: [2, 5] as Extent, + edge: 0.36, + opacity: 0.38, + colour: '#f4f8fc', + seed: 71, +} as const; + +export const GOGGLES_EYE_REACH = 1; + +export const SCENE_LIMITS = { + cameraNear: 0.2, + cameraFar: 14000, + cameraMinDistance: 2, + cameraMaxDistance: SCENE_BOUNDS.x[1] - SCENE_BOUNDS.x[0] + 400, + maxPolarAngle: Math.PI * 0.84, + targetFloorMargin: 0, +} as const; + +export const HIGHLIGHT_DIM = { saturation: 0.5, brightness: 0.6, emissive: 0.45 } as const; + +export const UNDIMMED_PARTS: readonly PartId[] = ['controlLink', 'videoLink', 'spinArrows']; diff --git a/explainers/fpv-drone/src/scene/controller.test.ts b/explainers/fpv-drone/src/scene/controller.test.ts new file mode 100644 index 00000000..a878e04c --- /dev/null +++ b/explainers/fpv-drone/src/scene/controller.test.ts @@ -0,0 +1,116 @@ +import { Box3, Vector3 } from 'three'; +import { describe, expect, it, vi } from 'vitest'; +import type { AssemblyState } from '../ids'; +import { CHAPTER_CONTROL_DEFAULTS, DEFAULT_VIEW, sortieAt } from '../state'; +import { FpvController } from './controller'; +import type { FpvControllerDependencies } from './controller'; + +const { drone } = vi.hoisted(() => ({ drone: { position: [0, 0, 0] } })); + +vi.mock('./assembly', async () => { + const { Box3: Bounds, Group, Object3D, Vector3: Point } = await import('three'); + const anchor = new Object3D(); + return { + createAssembly: () => ({ + root: new Group(), + setState: vi.fn(), + update: (_delta: number, distance: number) => distance > 0, + labelAnchors: () => new Map(), + anchor: () => anchor, + region: () => new Bounds(new Point(-40, 0, -140), new Point(430, 90, 110)), + chaseTarget: () => ({ position: drone.position, heading: 0, pitch: 0, roll: 0, span: 3.4 }), + dispose: vi.fn(), + }), + }; +}); + +const SLOPES = { vertical: 0.29, horizontal: 0.45 }; + +function state(): AssemblyState { + const { flight, motors, battery, link } = sortieAt({ phase: 0, payload: 300 }); + return { + phase: 0, + flight, + motors, + battery, + link, + video: 'analogue', + move: CHAPTER_CONTROL_DEFAULTS.move, + playing: false, + view: DEFAULT_VIEW, + }; +} + +function fakeShell() { + const rig = { + framing: () => SLOPES, + follow: vi.fn(), + setDistanceLimits: vi.fn(), + jumpTo: vi.fn(), + tweenTo: vi.fn(), + setBounds: vi.fn(), + camera: { position: new Vector3(0, 0, 50) }, + controls: { target: new Vector3() }, + }; + const shell = { + scene: { add: vi.fn() }, + materials: {}, + textures: {}, + labels: { attach: vi.fn(), show: vi.fn() }, + rig, + }; + return { rig, shell: shell as unknown as FpvControllerDependencies }; +} + +describe('fpv controller', () => { + it('adds the scene and sets the camera bounds to the whole field on the ground', () => { + const { rig, shell } = fakeShell(); + const controller = new FpvController(shell); + controller.build(state()); + expect(shell.scene.add).toHaveBeenCalledTimes(1); + expect(shell.labels.attach).toHaveBeenCalledTimes(1); + expect(rig.setBounds).toHaveBeenCalledWith(expect.any(Box3), 0); + }); + + it('frames a view around the drone and follows it once the scene is built', () => { + const { rig, shell } = fakeShell(); + const controller = new FpvController(shell); + controller.views.frame('chase', true); + expect(rig.tweenTo).not.toHaveBeenCalled(); + drone.position = [100, 40, 0]; + controller.build(state()); + controller.views.frame('chase', true); + const target = rig.tweenTo.mock.lastCall?.[0]?.target as Vector3 | undefined; + expect(target?.toArray()).toEqual([100, 40, 0]); + expect(rig.follow).toHaveBeenLastCalledWith(expect.anything(), 'heading'); + }); + + it('frames the pilot view on the route region without following', () => { + const { rig, shell } = fakeShell(); + const controller = new FpvController(shell); + controller.build(state()); + controller.views.frame('pilot', false); + expect(rig.follow).toHaveBeenLastCalledWith(null, undefined); + expect(rig.jumpTo).toHaveBeenCalledTimes(1); + }); + + it('knows when the camera rides in the nose of the drone', () => { + const { rig, shell } = fakeShell(); + const controller = new FpvController(shell); + expect(controller.ridesTheCamera()).toBe(false); + controller.build(state()); + expect(controller.ridesTheCamera()).toBe(false); + rig.camera.position.set(0, 0, 0.5); + expect(controller.ridesTheCamera()).toBe(true); + }); + + it('hands the camera distance to the assembly each frame', () => { + const { shell } = fakeShell(); + const controller = new FpvController(shell); + expect(controller.update(0.016)).toBe(false); + controller.build(state()); + expect(controller.update(0.016)).toBe(true); + controller.dispose(); + expect(controller.update(0.016)).toBe(false); + }); +}); diff --git a/explainers/fpv-drone/src/scene/controller.ts b/explainers/fpv-drone/src/scene/controller.ts new file mode 100644 index 00000000..496cb889 --- /dev/null +++ b/explainers/fpv-drone/src/scene/controller.ts @@ -0,0 +1,77 @@ +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 { AssemblyState, CameraView, RegionId } from '../ids'; +import { createAssembly } from './assembly'; +import type { Assembly } from './assembly'; +import { cameraViews } from './cameraViews'; +import { GOGGLES_EYE_REACH } from './constants'; + +export type FpvControllerDependencies = Pick< + SceneShell, + 'scene' | 'materials' | 'textures' | 'labels' | 'rig' | 'viewport' +>; + +export class FpvController { + readonly views: CameraViews; + private readonly dependencies: FpvControllerDependencies; + private assembly: Assembly | null = null; + private cancelWarmUp: () => void = () => {}; + private readonly eye = new Vector3(); + + constructor(dependencies: FpvControllerDependencies) { + this.dependencies = dependencies; + this.views = new CameraViews(dependencies.rig, { + views: cameraViews(() => this.assembly?.chaseTarget() ?? null), + region: (id) => this.assembly?.region(id) ?? null, + anchor: () => this.assembly?.anchor('drone') ?? null, + }); + } + + build(state: AssemblyState): void { + const { scene, materials, textures, labels, rig } = this.dependencies; + this.dispose(); + const assembly = createAssembly({ materials, textures }, state); + this.assembly = assembly; + 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))); + } + + 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; + } + + setState(state: AssemblyState): void { + 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 + ); + } + + ridesTheCamera(): boolean { + const eye = this.assembly?.anchor('camera'); + if (!eye) return false; + const reach = eye.getWorldPosition(this.eye).distanceTo(this.dependencies.rig.camera.position); + return reach <= GOGGLES_EYE_REACH; + } + + dispose(): void { + this.cancelWarmUp(); + this.assembly?.root.removeFromParent(); + this.assembly?.dispose(); + this.assembly = null; + } +} diff --git a/explainers/fpv-drone/src/scene/drawnLabels.test.ts b/explainers/fpv-drone/src/scene/drawnLabels.test.ts new file mode 100644 index 00000000..a423da80 --- /dev/null +++ b/explainers/fpv-drone/src/scene/drawnLabels.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PartId } from '../ids'; +import { flightAt } from '../model'; +import { DEFAULT_VIEW } from '../state'; +import { DrawnLabels } from './drawnLabels'; +import type { DrawnSource } from './drawnLabels'; + +const WANTED: ReadonlySet = new Set(['controlLink', 'videoLink', 'spinArrows', 'camera']); + +function stateAt(seconds: number, view: Partial = {}): DrawnSource { + return { flight: flightAt(seconds), view: { ...DEFAULT_VIEW, ...view } }; +} + +function shownAt(state: DrawnSource): string[] { + const setWanted = vi.fn(); + const labels = new DrawnLabels({ setWanted }); + labels.follow(state); + labels.setWanted(WANTED, WANTED); + const [, pinned] = setWanted.mock.lastCall as [ReadonlySet, ReadonlySet]; + return [...pinned].sort(); +} + +describe('labels of drawn parts', () => { + it('labels the links while they show and the drone is armed', () => { + expect(shownAt(stateAt(20))).toEqual(['camera', 'controlLink', 'videoLink']); + expect(shownAt(stateAt(20, { links: false }))).toEqual(['camera']); + expect(shownAt(stateAt(80))).toEqual(['camera']); + }); + + it('labels the spin arrows only while they show', () => { + expect(shownAt(stateAt(20, { arrows: true }))).toContain('spinArrows'); + expect(shownAt(stateAt(20, { arrows: false }))).not.toContain('spinArrows'); + }); + + it('asks again only when a part appears or disappears', () => { + const setWanted = vi.fn(); + const labels = new DrawnLabels({ setWanted }); + labels.setWanted(WANTED, WANTED); + labels.follow(stateAt(20)); + const calls = setWanted.mock.calls.length; + labels.follow(stateAt(21)); + expect(setWanted).toHaveBeenCalledTimes(calls); + labels.follow(stateAt(21, { arrows: true })); + expect(setWanted).toHaveBeenCalledTimes(calls + 1); + }); +}); diff --git a/explainers/fpv-drone/src/scene/drawnLabels.ts b/explainers/fpv-drone/src/scene/drawnLabels.ts new file mode 100644 index 00000000..840310d8 --- /dev/null +++ b/explainers/fpv-drone/src/scene/drawnLabels.ts @@ -0,0 +1,59 @@ +import type { LabelPolicy } from '@core/scene/presetBinder'; +import type { AssemblyState, PartId } from '../ids'; + +export type DrawnSource = Pick; + +type DrawnTest = (state: DrawnSource) => boolean; + +const NO_PARTS: ReadonlySet = new Set(); + +const linksShown: DrawnTest = ({ view, flight }) => view.links && flight.armed; + +const DRAWN_WHEN: Readonly>> = { + controlLink: linksShown, + videoLink: linksShown, + spinArrows: ({ view }) => view.arrows, +}; + +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; + + 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(); + } + + 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.hidden.has(part))); + } +} diff --git a/explainers/fpv-drone/src/scene/finishes.ts b/explainers/fpv-drone/src/scene/finishes.ts new file mode 100644 index 00000000..783a2b43 --- /dev/null +++ b/explainers/fpv-drone/src/scene/finishes.ts @@ -0,0 +1,186 @@ +import { AdditiveBlending, DoubleSide } from 'three'; +import type { Texture } from 'three'; +import type { MaterialFinish } from '@core/scene/materials'; +import { THEME } from '../theme'; + +export const PAINT = { + carbon: THEME.carbon, + carbonLight: THEME.carbonLight, + olive: THEME.olive, + oliveDark: THEME.oliveDark, + aluminium: '#aab0b8', + motorBase: THEME.motor, + bell: '#111317', + bellRing: '#c8452f', + copper: '#b4703a', + prop: THEME.prop, + propHub: '#2a3024', + lens: THEME.lens, + lensRing: '#2a2f36', + cameraBody: '#17191d', + cameraDigital: '#2b2f35', + rubber: '#202326', + pcb: '#1f4d33', + chip: '#0f1114', + capacitor: '#2d3a4a', + plug: '#f2c230', + wireRed: '#b3262a', + wireBlack: '#15171a', + balance: '#e8e6dc', + ceramic: '#d9d4c3', + grass: THEME.grass, + straw: THEME.straw, + earth: THEME.earth, + log: '#5a4330', + logEnd: '#9a7b57', + sandbag: '#8b7d5a', + tripod: '#3b3f44', + patch: '#d8dadb', + hardCase: '#2a2e26', + goggles: '#1e2126', + gogglesLens: '#0c1016', + radio: '#2d3136', + pad: '#3b3d40', + padMark: '#a9ad92', + shedWall: '#7a6e58', + shedRoof: '#7a4c33', + shedTrim: '#5a4d3c', + shedDoor: '#3a3128', + carRust: '#5c3b2a', + carGlass: '#1d2328', + tyre: '#1b1c1e', + trunk: '#4a3a2c', + poplar: '#6b5d50', +} as const; + +const PAINT_REFLECTION = 1.6; +const METAL_REFLECTION = 1.4; +const GLASS_REFLECTION = 2.4; +const GROUND_REFLECTION = 0.3; + +function painted(color: string, roughness: number, metalness = 0.1): MaterialFinish { + return { color, metalness, roughness, envMapIntensity: PAINT_REFLECTION }; +} + +function metal(color: string, roughness: number, metalness = 0.8): MaterialFinish { + return { color, metalness, roughness, envMapIntensity: METAL_REFLECTION }; +} + +function matte(color: string, roughness = 0.9): MaterialFinish { + return { color, metalness: 0, roughness, envMapIntensity: GROUND_REFLECTION }; +} + +export const FINISHES = { + printed: painted(PAINT.olive, 0.72, 0.05), + printedDark: painted(PAINT.oliveDark, 0.75, 0.05), + aluminium: metal(PAINT.aluminium, 0.35), + motorBase: metal(PAINT.motorBase, 0.4, 0.7), + bellRing: painted(PAINT.bellRing, 0.45, 0.4), + copper: metal(PAINT.copper, 0.5, 0.6), + prop: painted(PAINT.prop, 0.5, 0.15), + propHub: painted(PAINT.propHub, 0.55, 0.1), + lens: { color: PAINT.lens, metalness: 0.5, roughness: 0.05, envMapIntensity: GLASS_REFLECTION }, + lensRing: metal(PAINT.lensRing, 0.4, 0.6), + cameraBody: painted(PAINT.cameraBody, 0.55, 0.3), + cameraDigital: painted(PAINT.cameraDigital, 0.5, 0.35), + rubber: matte(PAINT.rubber, 0.95), + shrink: painted(PAINT.oliveDark, 0.4, 0.05), + pcb: painted(PAINT.pcb, 0.5, 0.1), + chip: painted(PAINT.chip, 0.4, 0.2), + capacitor: metal(PAINT.capacitor, 0.35, 0.6), + plug: painted(PAINT.plug, 0.45, 0.05), + wireRed: painted(PAINT.wireRed, 0.6, 0.05), + wireBlack: painted(PAINT.wireBlack, 0.6, 0.05), + balance: painted(PAINT.balance, 0.6, 0.02), + ceramic: painted(PAINT.ceramic, 0.6, 0.02), +} as const satisfies Record; + +export const WORLD_FINISHES = { + outerField: matte(PAINT.grass, 0.95), + road: { + ...matte(PAINT.earth, 0.95), + polygonOffset: true, + polygonOffsetFactor: -2, + polygonOffsetUnits: -2, + }, + log: matte(PAINT.log, 0.9), + logEnd: matte(PAINT.logEnd, 0.85), + sandbag: matte(PAINT.sandbag, 0.95), + tripod: metal(PAINT.tripod, 0.45, 0.6), + patch: painted(PAINT.patch, 0.5, 0.1), + hardCase: painted(PAINT.hardCase, 0.7, 0.05), + goggles: painted(PAINT.goggles, 0.45, 0.2), + gogglesLens: { color: PAINT.gogglesLens, metalness: 0.4, roughness: 0.1, envMapIntensity: 2 }, + radio: painted(PAINT.radio, 0.55, 0.2), + pad: matte(PAINT.pad, 0.85), + padMark: { + ...matte(PAINT.padMark, 0.8), + polygonOffset: true, + polygonOffsetFactor: -2, + polygonOffsetUnits: -2, + }, + shedWall: matte(PAINT.shedWall, 0.9), + shedRoof: metal(PAINT.shedRoof, 0.75, 0.35), + shedTrim: matte(PAINT.shedTrim, 0.9), + shedDoor: matte(PAINT.shedDoor, 0.9), + carRust: matte(PAINT.carRust, 0.95), + carGlass: { color: PAINT.carGlass, metalness: 0.3, roughness: 0.3, envMapIntensity: 1.2 }, + tyre: matte(PAINT.tyre, 0.95), + trunk: matte(PAINT.trunk, 0.9), + conifer: { ...matte('#ffffff', 0.95), vertexColors: true }, + poplar: matte(PAINT.poplar, 0.9), +} as const satisfies Record; + +export const GLOW_SPRITE = { + transparent: true, + blending: AdditiveBlending, + depthWrite: false, + toneMapped: false, +} as const; + +export const ARROW_PLANE = { + transparent: true, + depthWrite: false, + side: DoubleSide, + toneMapped: false, +} as const; + +export interface DroneLooks { + carbon: MaterialFinish; + bell: MaterialFinish; +} + +export function carbonFinish(map: Texture): MaterialFinish { + return { + color: PAINT.carbonLight, + map, + metalness: 0.45, + roughness: 0.38, + envMapIntensity: PAINT_REFLECTION, + }; +} + +export function bellFinish(map: Texture): MaterialFinish { + return { + color: PAINT.bell, + map, + metalness: 0.7, + roughness: 0.4, + envMapIntensity: METAL_REFLECTION, + }; +} + +export function fieldFinish(map: Texture): MaterialFinish { + return { + color: '#ffffff', + map, + vertexColors: true, + metalness: 0, + roughness: 0.96, + envMapIntensity: GROUND_REFLECTION, + }; +} + +export function roadFinish(map: Texture): MaterialFinish { + return { ...WORLD_FINISHES.road, map }; +} diff --git a/explainers/fpv-drone/src/scene/fpvAssembly.dom.test.ts b/explainers/fpv-drone/src/scene/fpvAssembly.dom.test.ts new file mode 100644 index 00000000..817469da --- /dev/null +++ b/explainers/fpv-drone/src/scene/fpvAssembly.dom.test.ts @@ -0,0 +1,367 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { InstancedMesh, Mesh, Object3D, Quaternion, Vector3 } from 'three'; +import type { BufferGeometry, Material } from 'three'; +import { MaterialLibrary, UNDIMMED_GROUP } from '@core/scene/materials'; +import { isShown } from '@core/scene/parts'; +import { createSceneTextures } from '@core/scene/textures'; +import { MOTOR_PART_IDS, PART_IDS } from '../ids'; +import type { AnchorId, AssemblyState, MotorPartId, PartId, Point, RegionId } from '../ids'; +import { CROSSROADS, DRONE, MOTOR_POSITIONS, MOTOR_SPIN, PAD, STATION } from '../model/layout'; +import { droneUnits } from '../model/scale'; +import { LAUNCH_PAD, PROP } from './constants'; +import { FpvAssembly } from './fpvAssembly'; +import { propName } from './parts/drone/propeller'; +import { DOWNWASH_NAME } from './parts/effects/downwash'; +import { GROUND_WASH_NAME } from './parts/effects/groundWash'; +import { WIND_STREAKS_NAME } from './parts/effects/windStreaks'; + +const ANCHORS: readonly AnchorId[] = ['drone', 'camera', 'station', 'crossroads']; +const REGIONS: readonly RegionId[] = ['scene', 'station', 'drone', 'crossroads', 'route']; +const TRIANGLE_BUDGET = { drone: 60_000, world: 150_000 }; +const FRAME = 1 / 60; +const CRUISE: Point = [180, 40, 0]; +const CRUISE_PHASE = 20; +const HOVER_SHARES = [0.25, 0.25, 0.25, 0.25] as const; + +interface Patch { + phase?: number; + flight?: Partial; + motors?: Partial; + view?: Partial; + link?: Partial; + video?: AssemblyState['video']; + playing?: boolean; +} + +function stateAt(patch: Patch = {}): AssemblyState { + return { + phase: patch.phase ?? 0, + flight: { + position: PAD, + heading: 0, + pitch: 0, + roll: 0, + speedKmh: 0, + height: 0, + verticalSpeed: 0, + onGround: true, + armed: true, + propRate: 0.3, + ...patch.flight, + }, + motors: { shares: HOVER_SHARES, ...patch.motors }, + battery: { share: 1, volts: 25.2, amps: 0, usedMah: 0 }, + link: { distance: 6, signal: 1, ...patch.link }, + video: patch.video ?? 'analogue', + move: 'hover', + playing: patch.playing ?? false, + view: { links: true, track: true, arrows: true, labels: true, ...patch.view }, + }; +} + +function cruising(patch: Patch = {}): AssemblyState { + return stateAt({ + phase: CRUISE_PHASE, + ...patch, + flight: { + position: CRUISE, + height: CRUISE[1], + speedKmh: 70, + onGround: false, + propRate: 0.8, + ...patch.flight, + }, + }); +} + +let assembly: FpvAssembly; +let materials: MaterialLibrary; +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 worldOf(object: Object3D): Vector3 { + assembly.root.updateMatrixWorld(true); + return object.getWorldPosition(new Vector3()); +} + +function triangles(geometry: BufferGeometry): number { + return (geometry.getIndex()?.count ?? geometry.getAttribute('position').count) / 3; +} + +function shownTriangles(root: Object3D): number { + let total = 0; + root.traverse((object) => { + if (!(object instanceof Mesh) || !isShown(object)) return; + const copies = object instanceof InstancedMesh ? object.count : 1; + total += triangles(object.geometry) * copies; + }); + return total; +} + +function spinner(id: MotorPartId): Object3D { + const found = assembly.root.getObjectByName(propName(id)); + if (!found) throw new Error(`No spinning propeller for ${id}`); + return found; +} + +function bladeTipSweep(id: MotorPartId): Vector3 { + const marker = new Object3D(); + marker.position.set(PROP.radius, 0, 0); + spinner(id).add(marker); + const before = worldOf(marker); + assembly.update(FRAME, 60); + const after = worldOf(marker); + marker.removeFromParent(); + return after.sub(before); +} + +beforeAll(() => { + materials = new MaterialLibrary(); + textures = createSceneTextures(); + assembly = new FpvAssembly({ materials, textures }, stateAt()); +}); + +afterAll(() => { + assembly.dispose(); + textures.dispose(); +}); + +describe('fpv assembly', () => { + it('gives every part a label anchor and every named anchor a place', () => { + expect(assembly.labelAnchors().size).toBe(PART_IDS.length); + for (const id of PART_IDS) expect(assembly.labelAnchors().get(id)).toBeDefined(); + for (const id of ANCHORS) expect(assembly.anchor(id)).toBeDefined(); + expect(assembly.anchor('drone').children.length).toBeGreaterThan(0); + }); + + it('returns the diorama regions and the drone box at its current pose', () => { + show(cruising()); + for (const id of REGIONS) expect(assembly.region(id).isEmpty()).toBe(false); + const drone = assembly.region('drone'); + expect(drone.containsPoint(new Vector3(...CRUISE).setY(CRUISE[1] + 0.4))).toBe(true); + expect(drone.getSize(new Vector3()).x).toBeGreaterThan(droneUnits(DRONE.wheelbase)); + expect(assembly.region('scene').containsBox(drone)).toBe(true); + expect(assembly.region('route').containsBox(drone)).toBe(true); + show(stateAt()); + expect(assembly.region('station').containsBox(assembly.region('drone'))).toBe(true); + expect( + assembly.region('crossroads').containsPoint(worldOf(assembly.anchor('crossroads'))), + ).toBe(true); + }); + + it('hands the camera the drone pose and the drawn wheelbase', () => { + show(cruising({ flight: { heading: 0.4, pitch: 0.3, roll: -0.2 } })); + const chase = assembly.chaseTarget(); + expect(chase.position).toEqual(CRUISE); + expect(chase.heading).toBe(0.4); + expect(chase.pitch).toBe(0.3); + expect(chase.roll).toBe(-0.2); + expect(chase.span).toBe(droneUnits(DRONE.wheelbase)); + }); + + it('rests level on the pad with the arms at the rest height', () => { + show(stateAt()); + const centre = worldOf(assembly.anchor('drone')); + expect(centre.x).toBeCloseTo(PAD[0]); + expect(centre.y).toBeCloseTo(droneUnits(DRONE.restHeight)); + expect(worldOf(label('launchPad')).y).toBeLessThan(0.3); + expect(assembly.region('drone').min.y).toBeCloseTo(LAUNCH_PAD.thickness, 3); + }); + + it('puts every motor at its layout position with the nose along x and right at plus z', () => { + show(stateAt()); + const centre = worldOf(assembly.anchor('drone')); + for (const id of MOTOR_PART_IDS) { + const motor = worldOf(label(id)); + const [x, , z] = MOTOR_POSITIONS[id]; + expect(motor.x).toBeCloseTo(centre.x + droneUnits(x), 3); + expect(motor.z).toBeCloseTo(centre.z + droneUnits(z), 3); + expect(motor.y).toBeGreaterThan(centre.y); + } + expect(worldOf(label('camera')).x).toBeGreaterThan(centre.x + 1); + expect(worldOf(label('videoAntenna')).x).toBeLessThan(centre.x - 0.8); + expect(worldOf(label('battery')).y).toBeGreaterThan(centre.y + 0.4); + }); + + it('spins motors 1 and 4 clockwise and motors 2 and 3 counter-clockwise seen from above', () => { + show(stateAt({ playing: true })); + for (const id of MOTOR_PART_IDS) { + const sweep = bladeTipSweep(id); + const clockwise = MOTOR_SPIN[id] === 'clockwise'; + expect(Math.abs(sweep.z)).toBeGreaterThan(0.01); + expect(sweep.z > 0).toBe(clockwise); + expect(Math.abs(sweep.y)).toBeLessThan(1e-6); + } + expect(MOTOR_SPIN.motorRearRight).toBe('clockwise'); + expect(MOTOR_SPIN.motorFrontLeft).toBe('clockwise'); + expect(MOTOR_SPIN.motorFrontRight).toBe('counterClockwise'); + expect(MOTOR_SPIN.motorRearLeft).toBe('counterClockwise'); + }); + + it('turns the nose toward plus z for a positive heading', () => { + show(cruising({ flight: { heading: Math.PI / 2 } })); + const centre = worldOf(assembly.anchor('drone')); + const nose = worldOf(label('camera')); + expect(nose.z).toBeGreaterThan(centre.z + 1); + expect(Math.abs(nose.x - centre.x)).toBeLessThan(0.3); + }); + + it('pitches the nose down and banks the right side down for positive pitch and roll', () => { + show(cruising()); + const level = worldOf(label('camera')).y; + show(cruising({ flight: { pitch: 0.4 } })); + expect(worldOf(label('camera')).y).toBeLessThan(level - 0.3); + show(cruising({ flight: { roll: 0.4 } })); + const right = worldOf(label('motorFrontRight')); + const left = worldOf(label('motorFrontLeft')); + expect(right.y).toBeLessThan(left.y - 0.8); + }); + + it('looks along the heading plus the camera tilt from the camera anchor', () => { + show(cruising()); + const camera = assembly.anchor('camera'); + const forward = new Vector3(1, 0, 0).applyQuaternion( + camera.getWorldQuaternion(new Quaternion()), + ); + expect(forward.x).toBeCloseTo(Math.cos(DRONE.cameraTilt), 3); + expect(forward.y).toBeCloseTo(Math.sin(DRONE.cameraTilt), 3); + }); + + it('draws the links only while the view shows them and the drone is armed', () => { + show(cruising()); + expect(isShown(label('controlLink'))).toBe(true); + expect(isShown(label('videoLink'))).toBe(true); + show(cruising({ view: { links: false } })); + expect(isShown(label('controlLink'))).toBe(false); + expect(isShown(label('videoLink'))).toBe(false); + show(stateAt({ flight: { armed: false } })); + expect(isShown(label('controlLink'))).toBe(false); + }); + + it('puts the link labels on their beams between the station and the drone', () => { + show(cruising()); + const station = worldOf(assembly.anchor('station')); + const drone = worldOf(assembly.anchor('drone')); + const onBeam = worldOf(label('controlLink')); + const total = station.distanceTo(drone); + expect(onBeam.distanceTo(station) + onBeam.distanceTo(drone)).toBeCloseTo(total, -1); + expect(onBeam.y).toBeGreaterThan(1); + expect(onBeam.y).toBeLessThan(CRUISE[1]); + }); + + it('shows the spin arrows only while the view asks for them', () => { + show(stateAt({ view: { arrows: true } })); + expect(isShown(label('spinArrows'))).toBe(true); + show(stateAt({ view: { arrows: false } })); + expect(isShown(label('spinArrows'))).toBe(false); + }); + + it('swaps the camera and antenna look with the video system', () => { + const visibleMeshes = (id: PartId) => { + let count = 0; + assembly.root.traverse((object) => { + if (!(object instanceof Mesh) || !isShown(object)) return; + if (materials.groupOf(object.material as Material) === id) count += 1; + }); + return count; + }; + show(stateAt({ video: 'analogue' })); + const analogue = visibleMeshes('camera') + visibleMeshes('videoAntenna'); + show(stateAt({ video: 'digital' })); + const digital = visibleMeshes('camera') + visibleMeshes('videoAntenna'); + expect(analogue).toBeGreaterThan(0); + expect(digital).toBe(analogue); + expect(isShown(label('camera'))).toBe(true); + }); + + it('keeps the world anchors at the station, the pad and the crossroads', () => { + show(stateAt()); + const station = worldOf(assembly.anchor('station')); + expect(station.distanceTo(new Vector3(...STATION))).toBeLessThan(4); + expect(worldOf(label('groundStation')).y).toBeGreaterThan(1); + const crossroads = worldOf(assembly.anchor('crossroads')); + expect(crossroads.x).toBeCloseTo(CROSSROADS[0]); + expect(crossroads.z).toBeCloseTo(CROSSROADS[2]); + const pad = worldOf(label('launchPad')); + expect(pad.distanceTo(new Vector3(...PAD))).toBeLessThan(3); + }); + + it('blows the downwash only while the motors push air', () => { + const downwash = () => assembly.root.getObjectByName(DOWNWASH_NAME) ?? assembly.root; + show(stateAt({ motors: { shares: [0, 0, 0, 0] } })); + expect(isShown(downwash())).toBe(false); + show(cruising()); + expect(downwash()).not.toBe(assembly.root); + expect(isShown(downwash())).toBe(true); + }); + + it('raises the ground wash under the drone only near the ground', () => { + const wash = () => assembly.root.getObjectByName(GROUND_WASH_NAME) ?? assembly.root; + show(stateAt({ flight: { position: [PAD[0], 1, PAD[2]], height: 1, onGround: false } })); + expect(wash()).not.toBe(assembly.root); + expect(isShown(wash())).toBe(true); + expect(worldOf(wash()).x).toBeCloseTo(PAD[0]); + expect(worldOf(wash()).y).toBeGreaterThan(LAUNCH_PAD.thickness); + show(cruising()); + expect(isShown(wash())).toBe(false); + }); + + it('blows the wind streaks only at cruise speed', () => { + const wind = () => assembly.root.getObjectByName(WIND_STREAKS_NAME) ?? assembly.root; + show(stateAt()); + expect(wind()).not.toBe(assembly.root); + expect(isShown(wind())).toBe(false); + show(cruising()); + expect(isShown(wind())).toBe(true); + }); + + it('moves on its own only while playing', () => { + show(stateAt()); + expect(assembly.update(FRAME, 60)).toBe(false); + show(stateAt({ playing: true })); + expect(assembly.update(FRAME, 60)).toBe(true); + }); + + it('keeps the sky, the field and the trees out of the highlight', () => { + const backdrop: Mesh[] = []; + assembly.root.traverse((object) => { + if (!(object instanceof Mesh)) return; + if (materials.groupOf(object.material as Material) === UNDIMMED_GROUP) backdrop.push(object); + }); + expect(backdrop.length).toBeGreaterThanOrEqual(3); + }); + + it('stays within the triangle budgets for the drone and the world', () => { + show(cruising()); + const drone = shownTriangles(assembly.anchor('drone')); + const whole = shownTriangles(assembly.root); + expect(drone).toBeLessThan(TRIANGLE_BUDGET.drone); + expect(whole - drone).toBeLessThan(TRIANGLE_BUDGET.world); + }); + + it('compiles every hidden material once when warming up', () => { + show(stateAt({ video: 'analogue' })); + const compiled: Object3D[] = []; + assembly.warmUp((object) => compiled.push(object)); + const seen = new Set(compiled.map((object) => (object as Mesh).material)); + expect(compiled.length).toBeGreaterThan(2); + expect(seen.size).toBe(compiled.length); + expect(compiled.every((object) => !isShown(object))).toBe(true); + }); + + it('frees its resources on dispose', () => { + const extra = new FpvAssembly({ materials, textures }, stateAt()); + expect(() => extra.dispose()).not.toThrow(); + expect(extra.root.parent).toBeNull(); + }); +}); diff --git a/explainers/fpv-drone/src/scene/fpvAssembly.ts b/explainers/fpv-drone/src/scene/fpvAssembly.ts new file mode 100644 index 00000000..7facb359 --- /dev/null +++ b/explainers/fpv-drone/src/scene/fpvAssembly.ts @@ -0,0 +1,148 @@ +import { Box3, Group, Mesh, Points, Sprite } from 'three'; +import type { Material, Object3D } from 'three'; +import type { MaterialLibrary } from '@core/scene/materials'; +import { isShown } from '@core/scene/parts'; +import { ResourceTracker } from '@core/scene/resources'; +import type { AnchorId, AssemblyState, PartId, RegionId } from '../ids'; +import { DRONE } from '../model/layout'; +import { droneUnits } from '../model/scale'; +import type { Assembly, AssemblyResources, ChaseTarget } from './assembly'; +import { MOTOR, PLATE } from './constants'; +import { bellFinish, carbonFinish } from './finishes'; +import { slotTexture, weaveTexture } from './geometry/surfaceMaps'; +import type { PartContext } from './parts/context'; +import { DronePart } from './parts/drone/drone'; +import { EffectsPart } from './parts/effects/effects'; +import { createCrossing } from './parts/world/crossing'; +import { createField } from './parts/world/field'; +import { createLaunchPad } from './parts/world/pad'; +import { createShrubs } from './parts/world/shrubs'; +import { createSky } from './parts/world/sky'; +import { createStation } from './parts/world/station'; +import { createTreeline } from './parts/world/trees'; +import { staticRegions } from './regions'; + +type Drawable = (Mesh | Points | Sprite) & { material: Material }; + +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 weave = tracker.track(weaveTexture(PLATE.weave)); + weave.repeat.setScalar(1 / (PLATE.weave.tows * PLATE.weave.towMetres)); + const slots = tracker.track(slotTexture(MOTOR.slots)); + return { ...resources, tracker, looks: { carbon: carbonFinish(weave), bell: bellFinish(slots) } }; +} + +export class FpvAssembly implements Assembly { + readonly root = new Group(); + private readonly tracker = new ResourceTracker(); + private readonly materials: MaterialLibrary; + private readonly drone: DronePart; + private readonly effects: EffectsPart; + private readonly labels: ReadonlyMap; + private readonly named: Readonly>; + private readonly regions: Readonly>; + private chase: ChaseTarget = { + position: [0, 0, 0], + heading: 0, + pitch: 0, + roll: 0, + span: droneUnits(DRONE.wheelbase), + }; + private playing = false; + + constructor(resources: AssemblyResources, state: AssemblyState) { + this.materials = resources.materials; + const context = createContext(resources, this.tracker); + const station = createStation(context); + const pad = createLaunchPad(context); + const crossing = createCrossing(context); + this.drone = new DronePart(context); + this.effects = new EffectsPart(context, { + drone: this.drone, + patchAntenna: station.patchAntenna, + goggles: station.goggles, + }); + this.root.add( + createSky(context), + createField(context), + createTreeline(context), + createShrubs(context), + station.object, + pad.object, + crossing.object, + this.drone.object, + this.effects.object, + ); + this.labels = new Map([ + ...this.drone.labels, + ['groundStation', station.label], + ['launchPad', pad.label], + ['crossroads', crossing.anchor], + ...this.effects.labels, + ]); + this.named = { + drone: this.drone.object, + camera: this.drone.cameraAnchor, + station: station.label, + crossroads: crossing.anchor, + }; + this.regions = { ...staticRegions(), drone: new Box3() }; + this.setState(state); + } + + setState(state: AssemblyState): void { + const { position, heading, pitch, roll } = state.flight; + this.playing = state.playing; + this.chase = { ...this.chase, position, heading, pitch, roll }; + this.root.updateMatrixWorld(); + this.drone.setState(state); + this.drone.worldBox(this.regions.drone); + this.effects.setState(state); + } + + update(deltaSeconds: number, _cameraDistance: number): boolean { + if (!this.playing) return false; + this.drone.advance(deltaSeconds); + this.effects.advance(deltaSeconds); + 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 { + return this.chase; + } + + 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 createFpvAssembly(resources: AssemblyResources, state: AssemblyState): Assembly { + return new FpvAssembly(resources, state); +} diff --git a/explainers/fpv-drone/src/scene/geometry/arrowTexture.ts b/explainers/fpv-drone/src/scene/geometry/arrowTexture.ts new file mode 100644 index 00000000..913d66f3 --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/arrowTexture.ts @@ -0,0 +1,73 @@ +import { CanvasTexture, SRGBColorSpace } from 'three'; +import type { SpinDirection } from '../../ids'; + +export interface ArrowLayout { + size: number; + radius: number; + stroke: number; + sweep: number; + head: number; + font: number; +} + +const START_ANGLE = -Math.PI / 2; +const HEAD_WIDTH_SHARE = 0.7; +const INK = '#ffffff'; + +function arrowHead( + context: CanvasRenderingContext2D, + layout: ArrowLayout, + angle: number, + forward: 1 | -1, +): void { + const half = layout.size / 2; + const tip = [half + layout.radius * Math.cos(angle), half + layout.radius * Math.sin(angle)]; + const tangent = [-Math.sin(angle) * forward, Math.cos(angle) * forward]; + const radial = [Math.cos(angle), Math.sin(angle)]; + const width = layout.head * HEAD_WIDTH_SHARE; + context.beginPath(); + context.moveTo(tip[0] + tangent[0] * layout.head, tip[1] + tangent[1] * layout.head); + context.lineTo(tip[0] + radial[0] * width, tip[1] + radial[1] * width); + context.lineTo(tip[0] - radial[0] * width, tip[1] - radial[1] * width); + context.closePath(); + context.fill(); +} + +export function drawSpinArrow( + context: CanvasRenderingContext2D, + layout: ArrowLayout, + direction: SpinDirection, + label: string, +): void { + const half = layout.size / 2; + const forward = direction === 'clockwise' ? 1 : -1; + const end = START_ANGLE + forward * layout.sweep; + context.clearRect(0, 0, layout.size, layout.size); + context.strokeStyle = INK; + context.fillStyle = INK; + context.lineWidth = layout.stroke; + context.lineCap = 'round'; + context.beginPath(); + context.arc(half, half, layout.radius, START_ANGLE, end, direction !== 'clockwise'); + context.stroke(); + arrowHead(context, layout, end, forward); + context.font = `bold ${layout.font}px sans-serif`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText(label, half, half); +} + +export function spinArrowTexture( + layout: ArrowLayout, + direction: SpinDirection, + label: string, +): CanvasTexture { + const canvas = document.createElement('canvas'); + canvas.width = layout.size; + canvas.height = layout.size; + const context = canvas.getContext('2d'); + if (context) drawSpinArrow(context, layout, direction, label); + const texture = new CanvasTexture(canvas); + texture.colorSpace = SRGBColorSpace; + return texture; +} diff --git a/explainers/fpv-drone/src/scene/geometry/blade.test.ts b/explainers/fpv-drone/src/scene/geometry/blade.test.ts new file mode 100644 index 00000000..a9a186c3 --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/blade.test.ts @@ -0,0 +1,56 @@ +import { Box3, Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { PROP } from '../constants'; +import { bladeAngle, bladeGeometry, camberShape, spinSign, thicknessShape } from './blade'; + +function bounds(direction: 'clockwise' | 'counterClockwise'): Box3 { + const geometry = bladeGeometry(PROP, direction); + geometry.computeBoundingBox(); + return geometry.boundingBox ?? new Box3(); +} + +function leadingEdgeHeight(direction: 'clockwise' | 'counterClockwise'): number { + const geometry = bladeGeometry(PROP, direction); + const position = geometry.getAttribute('position'); + let highest = new Vector3(0, -Infinity, 0); + for (let index = 0; index < position.count; index += 1) { + const point = new Vector3().fromBufferAttribute(position, index); + if (point.y > highest.y) highest = point; + } + return highest.z; +} + +describe('propeller blade', () => { + it('uses a NACA section that is thickest near the front and cambered toward the middle', () => { + expect(thicknessShape(0)).toBe(0); + expect(thicknessShape(0.3)).toBeGreaterThan(thicknessShape(0.9)); + expect(camberShape(0.4)).toBeCloseTo(1); + expect(camberShape(0)).toBe(0); + expect(camberShape(1)).toBeCloseTo(0); + }); + + it('twists from a steep root to a flat tip', () => { + const root = bladeAngle(PROP, PROP.stations[1].radius); + const tip = bladeAngle(PROP, PROP.radius); + expect(root).toBeGreaterThan(tip + 0.3); + expect(tip).toBeGreaterThan(PROP.incidence); + }); + + it('spans the prop radius along x and stays thin', () => { + const box = bounds('clockwise'); + expect(box.max.x).toBeCloseTo(PROP.radius, 3); + expect(box.min.x).toBeLessThan(PROP.stations[0].radius + 1e-6); + expect(box.max.y - box.min.y).toBeLessThan(0.03); + expect(box.max.z - box.min.z).toBeLessThan(0.03); + }); + + it('raises the leading edge on the side the blade moves toward', () => { + expect(leadingEdgeHeight('clockwise')).toBeGreaterThan(0); + expect(leadingEdgeHeight('counterClockwise')).toBeLessThan(0); + }); + + it('maps clockwise seen from above to a negative turn about y', () => { + expect(spinSign('clockwise')).toBe(-1); + expect(spinSign('counterClockwise')).toBe(1); + }); +}); diff --git a/explainers/fpv-drone/src/scene/geometry/blade.ts b/explainers/fpv-drone/src/scene/geometry/blade.ts new file mode 100644 index 00000000..8f8e68a8 --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/blade.ts @@ -0,0 +1,94 @@ +import type { BufferGeometry } from 'three'; +import type { SpinDirection } from '../../ids'; +import { stitchRings } from './rings'; +import type { Ring, RingPoint } from './rings'; + +export interface BladeStation { + radius: number; + chord: number; + thickness: number; +} + +export interface BladeShape { + stations: readonly BladeStation[]; + pitchMetres: number; + incidence: number; + pitchAxisShare: number; + camber: number; + samples: number; +} + +interface SectionPoint { + fraction: number; + offset: number; +} + +const NACA_SPREAD = 5; +const NACA = [0.2969, -0.126, -0.3516, 0.2843, -0.1036] as const; +const CAMBER_PEAK = 0.4; + +export function thicknessShape(fraction: number): number { + const [root, linear, square, cube, quartic] = NACA; + const x = fraction; + return ( + NACA_SPREAD * + (root * Math.sqrt(x) + linear * x + square * x ** 2 + cube * x ** 3 + quartic * x ** 4) + ); +} + +export function camberShape(fraction: number): number { + const p = CAMBER_PEAK; + if (fraction < p) return (2 * p * fraction - fraction ** 2) / p ** 2; + return (1 - 2 * p + 2 * p * fraction - fraction ** 2) / (1 - p) ** 2; +} + +function cosineSpacing(samples: number): number[] { + return Array.from( + { length: samples + 1 }, + (_, index) => (1 - Math.cos((Math.PI * index) / samples)) / 2, + ); +} + +function sectionLoop(thickness: number, camber: number, samples: number): SectionPoint[] { + const fractions = cosineSpacing(samples); + const upper = [...fractions].reverse().map((fraction) => ({ + fraction, + offset: thicknessShape(fraction) * thickness + camberShape(fraction) * camber, + })); + const lower = fractions.slice(1, -1).map((fraction) => ({ + fraction, + offset: -thicknessShape(fraction) * thickness + camberShape(fraction) * camber, + })); + return [...upper, ...lower]; +} + +export function spinSign(direction: SpinDirection): 1 | -1 { + return direction === 'clockwise' ? -1 : 1; +} + +export function bladeAngle(shape: BladeShape, radius: number): number { + return Math.atan(shape.pitchMetres / (Math.PI * 2 * radius)) + shape.incidence; +} + +function stationRing(shape: BladeShape, station: BladeStation, direction: SpinDirection): Ring { + const angle = bladeAngle(shape, station.radius); + const moving = -spinSign(direction); + const chordAxis = [0, -Math.sin(angle), -moving * Math.cos(angle)] as const; + const normalAxis = [0, Math.cos(angle), -moving * Math.sin(angle)] as const; + const lead = shape.pitchAxisShare * station.chord; + const leadingEdge = [station.radius, -chordAxis[1] * lead, -chordAxis[2] * lead] as const; + return sectionLoop(station.thickness, shape.camber, shape.samples).map((point): RingPoint => { + const alongChord = point.fraction * station.chord; + const across = point.offset * station.chord; + return [ + leadingEdge[0], + leadingEdge[1] + chordAxis[1] * alongChord + normalAxis[1] * across, + leadingEdge[2] + chordAxis[2] * alongChord + normalAxis[2] * across, + ]; + }); +} + +export function bladeGeometry(shape: BladeShape, direction: SpinDirection): BufferGeometry { + const rings = shape.stations.map((station) => stationRing(shape, station, direction)); + return stitchRings(rings, { capStart: true, capEnd: true }); +} diff --git a/explainers/fpv-drone/src/scene/geometry/noise.ts b/explainers/fpv-drone/src/scene/geometry/noise.ts new file mode 100644 index 00000000..314c5eee --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/noise.ts @@ -0,0 +1,42 @@ +const HASH = { x: 127.1, y: 311.7, seed: 74.7, scale: 43758.5453 } as const; +const OCTAVE_GAIN = 0.5; +const OCTAVE_LACUNARITY = 2; + +export function hash2(x: number, y: number, seed = 0): number { + const value = Math.sin(x * HASH.x + y * HASH.y + seed * HASH.seed) * HASH.scale; + return value - Math.floor(value); +} + +function fade(t: number): number { + return t * t * (3 - 2 * t); +} + +function wrap(value: number, period: number): number { + return period > 0 ? ((value % period) + period) % period : value; +} + +export function valueNoise(x: number, y: number, seed = 0, period = 0): number { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const tx = fade(x - x0); + const ty = fade(y - y0); + const corner = (dx: number, dy: number) => + hash2(wrap(x0 + dx, period), wrap(y0 + dy, period), seed); + const bottom = corner(0, 0) + (corner(1, 0) - corner(0, 0)) * tx; + const top = corner(0, 1) + (corner(1, 1) - corner(0, 1)) * tx; + return bottom + (top - bottom) * ty; +} + +export function fractalNoise(x: number, y: number, octaves: number, seed = 0, period = 0): number { + let total = 0; + let weight = 1; + let frequency = 1; + let norm = 0; + for (let octave = 0; octave < octaves; octave += 1) { + total += weight * valueNoise(x * frequency, y * frequency, seed + octave, period * frequency); + norm += weight; + weight *= OCTAVE_GAIN; + frequency *= OCTAVE_LACUNARITY; + } + return total / norm; +} diff --git a/explainers/fpv-drone/src/scene/geometry/pillow.test.ts b/explainers/fpv-drone/src/scene/geometry/pillow.test.ts new file mode 100644 index 00000000..1928b976 --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/pillow.test.ts @@ -0,0 +1,35 @@ +import { Box3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { pillowGeometry } from './pillow'; + +const SHAPE = { + size: [0.56, 0.13, 0.34] as const, + squareness: 2.4, + widthSegments: 12, + heightSegments: 6, +}; + +function topShare(squareness: number): number { + const geometry = pillowGeometry({ ...SHAPE, squareness }); + const position = geometry.getAttribute('position'); + let flat = 0; + for (let index = 0; index < position.count; index += 1) { + if (position.getY(index) > (SHAPE.size[1] / 2) * 0.9) flat += 1; + } + return flat / position.count; +} + +describe('pillow', () => { + it('fills its size exactly', () => { + const geometry = pillowGeometry(SHAPE); + geometry.computeBoundingBox(); + const box = geometry.boundingBox ?? new Box3(); + expect(box.max.x - box.min.x).toBeCloseTo(SHAPE.size[0]); + expect(box.max.y - box.min.y).toBeCloseTo(SHAPE.size[1]); + expect(box.max.z - box.min.z).toBeCloseTo(SHAPE.size[2]); + }); + + it('flattens the top more as the squareness grows', () => { + expect(topShare(2.4)).toBeGreaterThan(topShare(1)); + }); +}); diff --git a/explainers/fpv-drone/src/scene/geometry/pillow.ts b/explainers/fpv-drone/src/scene/geometry/pillow.ts new file mode 100644 index 00000000..35e22e4d --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/pillow.ts @@ -0,0 +1,30 @@ +import { SphereGeometry } from 'three'; +import type { BufferGeometry } from 'three'; + +export interface PillowShape { + size: readonly [length: number, height: number, depth: number]; + squareness: number; + widthSegments: number; + heightSegments: number; +} + +function squared(value: number, exponent: number): number { + return Math.sign(value) * Math.abs(value) ** exponent; +} + +export function pillowGeometry(shape: PillowShape): BufferGeometry { + const geometry = new SphereGeometry(1, shape.widthSegments, shape.heightSegments); + const position = geometry.getAttribute('position'); + const exponent = 1 / shape.squareness; + const [length, height, depth] = shape.size; + for (let index = 0; index < position.count; index += 1) { + position.setXYZ( + index, + (squared(position.getX(index), exponent) * length) / 2, + (squared(position.getY(index), exponent) * height) / 2, + (squared(position.getZ(index), exponent) * depth) / 2, + ); + } + geometry.computeVertexNormals(); + return geometry; +} diff --git a/explainers/fpv-drone/src/scene/geometry/rings.test.ts b/explainers/fpv-drone/src/scene/geometry/rings.test.ts new file mode 100644 index 00000000..df00d990 --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/rings.test.ts @@ -0,0 +1,36 @@ +import { Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { stitchRings } from './rings'; +import type { Ring } from './rings'; + +function circle(x: number, radius: number, count: number): Ring { + return Array.from({ length: count }, (_, index) => { + const angle = (index / count) * Math.PI * 2; + return [x, radius * Math.cos(angle), radius * Math.sin(angle)] as const; + }); +} + +describe('stitch rings', () => { + it('joins the rings into a closed surface with outward normals', () => { + const geometry = stitchRings([circle(0, 1, 12), circle(2, 1, 12)], { + capStart: true, + capEnd: true, + }); + const index = geometry.getIndex(); + expect(index).not.toBeNull(); + expect((index?.count ?? 0) / 3).toBe(12 * 2 + 12 * 2); + const normal = geometry.getAttribute('normal'); + const position = geometry.getAttribute('position'); + let outward = 0; + for (let vertex = 0; vertex < 12; vertex += 1) { + const n = new Vector3().fromBufferAttribute(normal, vertex); + const p = new Vector3().fromBufferAttribute(position, vertex).setX(0); + outward += n.dot(p.normalize()); + } + expect(outward / 12).toBeGreaterThan(0.9); + }); + + it('refuses a single ring', () => { + expect(() => stitchRings([circle(0, 1, 6)])).toThrow(); + }); +}); diff --git a/explainers/fpv-drone/src/scene/geometry/rings.ts b/explainers/fpv-drone/src/scene/geometry/rings.ts new file mode 100644 index 00000000..c129266c --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/rings.ts @@ -0,0 +1,137 @@ +import { BufferAttribute, BufferGeometry, Vector3 } from 'three'; + +export type RingPoint = readonly [x: number, y: number, z: number]; +export type Ring = readonly RingPoint[]; + +export interface StitchOptions { + capStart?: boolean; + capEnd?: boolean; + along?: readonly number[]; + around?: readonly number[]; +} + +const XYZ = 3; +const UV = 2; +const TRIANGLE = 3; + +interface Builder { + positions: number[]; + uvs: number[]; + indices: number[]; +} + +function centroid(ring: Ring): RingPoint { + const sum = ring.reduce<[number, number, number]>( + (total, point) => [total[0] + point[0], total[1] + point[1], total[2] + point[2]], + [0, 0, 0], + ); + return [sum[0] / ring.length, sum[1] / ring.length, sum[2] / ring.length]; +} + +function addVertex(builder: Builder, point: RingPoint, u: number, v: number): number { + builder.positions.push(point[0], point[1], point[2]); + builder.uvs.push(u, v); + return builder.positions.length / XYZ - 1; +} + +function addSides(builder: Builder, rings: readonly Ring[], options: StitchOptions): void { + const count = rings[0].length; + const row = count + 1; + rings.forEach((ring, index) => { + const u = options.along?.[index] ?? index / (rings.length - 1); + for (let around = 0; around < row; around += 1) { + const v = options.around?.[around] ?? around / count; + addVertex(builder, ring[around % count], u, v); + } + }); + for (let index = 0; index < rings.length - 1; index += 1) { + for (let around = 0; around < count; around += 1) { + const a = index * row + around; + const b = a + row; + builder.indices.push(a, b, a + 1, a + 1, b, b + 1); + } + } +} + +function polygonNormal(ring: Ring, centre: RingPoint): Vector3 { + const middle = new Vector3(...centre); + const normal = new Vector3(); + ring.forEach((point, index) => { + const next = ring[(index + 1) % ring.length]; + const a = new Vector3(...point).sub(middle); + const b = new Vector3(...next).sub(middle); + normal.add(a.cross(b)); + }); + return normal; +} + +function addCap(builder: Builder, ring: Ring, neighbour: Ring, u: number): void { + const middle = centroid(ring); + const away = new Vector3(...middle).sub(new Vector3(...centroid(neighbour))); + const forward = polygonNormal(ring, middle).dot(away) >= 0; + const centre = addVertex(builder, middle, u, 0.5); + const first = builder.positions.length / XYZ; + ring.forEach((point, index) => addVertex(builder, point, u, index / ring.length)); + for (let index = 0; index < ring.length; index += 1) { + const a = first + index; + const b = first + ((index + 1) % ring.length); + if (forward) builder.indices.push(centre, a, b); + else builder.indices.push(centre, b, a); + } +} + +function sideOrientation(builder: Builder, rings: readonly Ring[]): number { + const { positions, indices } = builder; + const row = rings[0].length + 1; + const sideTriangles = (rings.length - 1) * (row - 1) * 2; + const vertex = (index: number) => new Vector3().fromArray(positions, index * XYZ); + let sum = 0; + for (let triangle = 0; triangle < sideTriangles; triangle += 1) { + const [a, b, c] = [0, 1, 2].map((offset) => vertex(indices[triangle * TRIANGLE + offset])); + const normal = new Vector3().subVectors(b, a).cross(new Vector3().subVectors(c, a)); + const ring = rings[Math.floor(indices[triangle * TRIANGLE] / row)]; + const middle = new Vector3(...centroid(ring)); + sum += normal.dot(a.add(b).add(c).divideScalar(TRIANGLE).sub(middle)); + } + return sum; +} + +function flipTriangles(indices: number[]): void { + for (let index = 0; index < indices.length; index += TRIANGLE) { + const second = indices[index + 1]; + indices[index + 1] = indices[index + 2]; + indices[index + 2] = second; + } +} + +function weldSeamNormals(geometry: BufferGeometry, rings: number, count: number): void { + const normal = geometry.getAttribute('normal'); + const row = count + 1; + const sum = new Vector3(); + for (let index = 0; index < rings; index += 1) { + const first = index * row; + const last = first + count; + sum.fromBufferAttribute(normal, first).add(new Vector3().fromBufferAttribute(normal, last)); + sum.normalize(); + normal.setXYZ(first, sum.x, sum.y, sum.z); + normal.setXYZ(last, sum.x, sum.y, sum.z); + } + normal.needsUpdate = true; +} + +export function stitchRings(rings: readonly Ring[], options: StitchOptions = {}): BufferGeometry { + if (rings.length < 2) throw new Error('A surface needs at least two rings'); + const builder: Builder = { positions: [], uvs: [], indices: [] }; + addSides(builder, rings, options); + if (sideOrientation(builder, rings) < 0) flipTriangles(builder.indices); + const last = rings.length - 1; + if (options.capStart) addCap(builder, rings[0], rings[1], 0); + if (options.capEnd) addCap(builder, rings[last], rings[last - 1], 1); + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(new Float32Array(builder.positions), XYZ)); + geometry.setAttribute('uv', new BufferAttribute(new Float32Array(builder.uvs), UV)); + geometry.setIndex(builder.indices); + geometry.computeVertexNormals(); + weldSeamNormals(geometry, rings.length, rings[0].length); + return geometry; +} diff --git a/explainers/fpv-drone/src/scene/geometry/rods.ts b/explainers/fpv-drone/src/scene/geometry/rods.ts new file mode 100644 index 00000000..76341dcf --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/rods.ts @@ -0,0 +1,32 @@ +import { CylinderGeometry, Quaternion, Vector3 } from 'three'; +import type { BufferGeometry } from 'three'; + +export type Vec3 = readonly [x: number, y: number, z: number]; + +const Y_AXIS = new Vector3(0, 1, 0); + +export function rod( + from: Vec3, + to: Vec3, + radius: number, + segments: number, + endRadius = radius, +): BufferGeometry { + const start = new Vector3(...from); + const direction = new Vector3(...to).sub(start); + const length = direction.length(); + const geometry = new CylinderGeometry(endRadius, radius, length, segments); + geometry.translate(0, length / 2, 0); + geometry.applyQuaternion(new Quaternion().setFromUnitVectors(Y_AXIS, direction.normalize())); + geometry.translate(start.x, start.y, start.z); + return geometry; +} + +export function along(from: Vec3, direction: Vec3, length: number): Vec3 { + const scale = length / Math.hypot(...direction); + return [ + from[0] + direction[0] * scale, + from[1] + direction[1] * scale, + from[2] + direction[2] * scale, + ]; +} diff --git a/explainers/fpv-drone/src/scene/geometry/surfaceMaps.test.ts b/explainers/fpv-drone/src/scene/geometry/surfaceMaps.test.ts new file mode 100644 index 00000000..99c907da --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/surfaceMaps.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { FIELD, MOTOR, PLATE, PROP, ROADS } from '../constants'; +import { + discAlpha, + fieldShade, + roadShade, + slotShade, + weaveShade, + weaveTexture, +} from './surfaceMaps'; + +describe('surface maps', () => { + it('weaves tows that are brighter in the middle than at their edges', () => { + const tow = 1 / PLATE.weave.tows; + const middle = weaveShade(PLATE.weave, tow * 0.5, tow * 0.5); + const edge = weaveShade(PLATE.weave, tow * 0.5, tow * 0.02); + expect(middle).toBeGreaterThan(edge); + expect(middle).toBeLessThanOrEqual(1.1); + expect(edge).toBeGreaterThan(0); + }); + + it('paints a repeating weave texture in the sRGB colour space', () => { + const texture = weaveTexture(PLATE.weave); + expect(texture.image.width).toBe(PLATE.weave.size); + expect(texture.wrapS).toBe(texture.wrapT); + expect(texture.colorSpace).toBe('srgb'); + texture.dispose(); + }); + + it('cuts dark cooling slots into the lower part of the bell', () => { + const slotU = 0.5 / MOTOR.slots.count; + const betweenU = 1 / MOTOR.slots.count; + const inside = (MOTOR.slots.from + MOTOR.slots.to) / 2; + expect(slotShade(MOTOR.slots, slotU, inside)).toBeLessThan(0.3); + expect(slotShade(MOTOR.slots, betweenU, inside)).toBeCloseTo(1); + expect(slotShade(MOTOR.slots, slotU, 0.95)).toBeCloseTo(1); + }); + + it('fades the blur disc from a clear hub to a bright tip ring', () => { + expect(discAlpha(PROP.disc, 0)).toBe(0); + expect(discAlpha(PROP.disc, PROP.disc.tipRing)).toBeGreaterThan(discAlpha(PROP.disc, 0.6)); + expect(discAlpha(PROP.disc, 1)).toBe(0); + }); + + it('keeps the field shade near one with gentle variation', () => { + for (const channel of fieldShade(FIELD.texture, 0.3, 0.7).slice(0, 3)) { + expect(channel).toBeGreaterThan(0.6); + expect(channel).toBeLessThan(1.4); + } + }); + + it('darkens the road map into two wheel ruts and lightens the dusty crown', () => { + const layout = ROADS.texture; + const rut = roadShade(layout, 0.5 - layout.rut, 0.2)[0]; + const crown = roadShade(layout, 0.5, 0.2)[0]; + const verge = roadShade(layout, 0.02, 0.2)[0]; + expect(rut).toBeLessThan(verge); + expect(crown).toBeGreaterThan(verge); + for (let u = 0; u <= 1; u += 0.05) { + const level = roadShade(layout, u, 0.4)[0]; + expect(level).toBeGreaterThan(0.45); + expect(level).toBeLessThanOrEqual(1); + } + }); +}); diff --git a/explainers/fpv-drone/src/scene/geometry/surfaceMaps.ts b/explainers/fpv-drone/src/scene/geometry/surfaceMaps.ts new file mode 100644 index 00000000..a0a87ef7 --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/surfaceMaps.ts @@ -0,0 +1,213 @@ +import { + ClampToEdgeWrapping, + DataTexture, + LinearFilter, + LinearMipmapLinearFilter, + RGBAFormat, + RepeatWrapping, + SRGBColorSpace, +} from 'three'; +import { clamp, lerp, smoothstep } from '@core/math'; +import { fractalNoise, hash2 } from './noise'; + +export type Rgba = readonly [r: number, g: number, b: number, a: number]; +export type Painter = (u: number, v: number) => Rgba; + +export interface WeaveLayout { + size: number; + tows: number; + contrast: number; + grain: number; + seed: number; +} + +export interface SlotLayout { + size: number; + count: number; + width: number; + from: number; + to: number; +} + +export interface DiscLayout { + size: number; + hub: number; + tipRing: number; + ringWidth: number; + ringBoost: number; + edgeSoftness: number; +} + +export interface FieldLayout { + size: number; + stripes: number; + stripeDepth: number; + mottle: number; + seed: number; +} + +export interface RoadLayout { + size: number; + base: number; + rut: number; + rutWidth: number; + rutDepth: number; + dust: number; + dustWidth: number; + wear: number; + seed: number; +} + +const CHANNELS = 4; +const BYTE = 255; +const OPAQUE = 1; +const TWILL_STEP = 2; +const TWILL_PERIOD = 4; +const WEAVE_OCTAVES = 2; +const WEAVE_GRAIN_SCALE = 12; +const SLOT_SHADE = 0.12; +const SLOT_EDGE = 0.06; +const DISC_FADE = 0.35; +const FIELD_OCTAVES = 4; +const FIELD_SCALE = 6; +const FIELD_TINT = [0.98, 1, 0.9] as const; +const ROAD_OCTAVES = 3; +const ROAD_SCALE = 5; + +export function paintTexture( + width: number, + height: number, + painter: Painter, + repeat = false, +): DataTexture { + const pixels = new Uint8Array(width * height * CHANNELS); + for (let row = 0; row < height; row += 1) { + for (let column = 0; column < width; column += 1) { + const colour = painter((column + 0.5) / width, (row + 0.5) / height); + const offset = (row * width + column) * CHANNELS; + for (let channel = 0; channel < CHANNELS; channel += 1) { + pixels[offset + channel] = Math.round(clamp(colour[channel], 0, 1) * BYTE); + } + } + } + const texture = new DataTexture(pixels, width, height, RGBAFormat); + const wrap = repeat ? RepeatWrapping : ClampToEdgeWrapping; + texture.wrapS = wrap; + texture.wrapT = wrap; + texture.magFilter = LinearFilter; + texture.minFilter = LinearMipmapLinearFilter; + texture.generateMipmaps = true; + texture.colorSpace = SRGBColorSpace; + texture.needsUpdate = true; + return texture; +} + +export function weaveShade(layout: WeaveLayout, u: number, v: number): number { + const column = Math.floor(u * layout.tows); + const row = Math.floor(v * layout.tows); + const over = (((column + row) % TWILL_PERIOD) + TWILL_PERIOD) % TWILL_PERIOD < TWILL_STEP; + const acrossU = (u * layout.tows) % 1; + const acrossV = (v * layout.tows) % 1; + const across = over ? acrossV : acrossU; + const profile = Math.sin(across * Math.PI); + const tow = lerp(1 - layout.contrast, 1, profile); + const grain = fractalNoise( + u * WEAVE_GRAIN_SCALE, + v * WEAVE_GRAIN_SCALE, + WEAVE_OCTAVES, + layout.seed, + WEAVE_GRAIN_SCALE, + ); + return tow * (1 - layout.grain * (grain - 0.5)); +} + +export function weaveTexture(layout: WeaveLayout): DataTexture { + return paintTexture( + layout.size, + layout.size, + (u, v) => { + const shade = weaveShade(layout, u, v); + return [shade, shade, shade, OPAQUE]; + }, + true, + ); +} + +export function slotShade(layout: SlotLayout, u: number, v: number): number { + const across = (u * layout.count) % 1; + const inSlot = + smoothstep(across, 0.5 - layout.width / 2 - SLOT_EDGE, 0.5 - layout.width / 2) * + (1 - smoothstep(across, 0.5 + layout.width / 2, 0.5 + layout.width / 2 + SLOT_EDGE)); + const band = + smoothstep(v, layout.from - SLOT_EDGE, layout.from) * + (1 - smoothstep(v, layout.to, layout.to + SLOT_EDGE)); + return lerp(1, SLOT_SHADE, inSlot * band); +} + +export function slotTexture(layout: SlotLayout): DataTexture { + return paintTexture( + layout.size, + layout.size, + (u, v) => { + const shade = slotShade(layout, u, v); + return [shade, shade, shade, OPAQUE]; + }, + true, + ); +} + +export function discAlpha(layout: DiscLayout, radius: number): number { + const blade = smoothstep(radius, layout.hub, layout.hub + layout.edgeSoftness); + const edge = 1 - smoothstep(radius, 1 - layout.edgeSoftness, 1); + const ring = Math.exp(-(((radius - layout.tipRing) / layout.ringWidth) ** 2)); + return blade * edge * (1 + layout.ringBoost * ring) * (1 - DISC_FADE * radius); +} + +export function discTexture(layout: DiscLayout): DataTexture { + return paintTexture(layout.size, layout.size, (u, v) => { + const radius = Math.hypot(u - 0.5, v - 0.5) * 2; + return [1, 1, 1, discAlpha(layout, radius)]; + }); +} + +export function fieldShade(layout: FieldLayout, u: number, v: number): Rgba { + const stripe = 0.5 + 0.5 * Math.sin(v * layout.stripes * Math.PI * 2); + const mottle = fractalNoise( + u * FIELD_SCALE, + v * FIELD_SCALE, + FIELD_OCTAVES, + layout.seed, + FIELD_SCALE, + ); + const speck = hash2(Math.floor(u * layout.size), Math.floor(v * layout.size), layout.seed); + const level = + 1 - + layout.stripeDepth * stripe - + layout.mottle * (mottle - 0.5) - + 0.08 * Math.max(0, speck - 0.8); + return [level * FIELD_TINT[0], level * FIELD_TINT[1], level * FIELD_TINT[2], OPAQUE]; +} + +export function fieldTexture(layout: FieldLayout): DataTexture { + return paintTexture(layout.size, layout.size, (u, v) => fieldShade(layout, u, v), true); +} + +function bump(value: number, centre: number, width: number): number { + return Math.exp(-(((value - centre) / width) ** 2)); +} + +export function roadShade(layout: RoadLayout, u: number, v: number): Rgba { + const rut = Math.max( + bump(u, 0.5 - layout.rut, layout.rutWidth), + bump(u, 0.5 + layout.rut, layout.rutWidth), + ); + const dust = bump(u, 0.5, layout.dustWidth); + const wear = fractalNoise(u * ROAD_SCALE, v * ROAD_SCALE, ROAD_OCTAVES, layout.seed, ROAD_SCALE); + const level = + layout.base + layout.dust * dust - layout.rutDepth * rut - layout.wear * (wear - 0.5); + return [level, level, level, OPAQUE]; +} + +export function roadTexture(layout: RoadLayout): DataTexture { + return paintTexture(layout.size, layout.size, (u, v) => roadShade(layout, u, v), true); +} diff --git a/explainers/fpv-drone/src/scene/geometry/trail.test.ts b/explainers/fpv-drone/src/scene/geometry/trail.test.ts new file mode 100644 index 00000000..f4f561a9 --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/trail.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { TrailSamples } from './trail'; +import type { TrailSample } from './trail'; + +const STEP = 0.25; +const CAPACITY = 64; +const MAX_GAP = 8; + +function at(x: number): TrailSample { + return { point: [x, 10, 0], heading: 0 }; +} + +describe('trail samples', () => { + it('keeps one slot per step and runs back over the filled slots', () => { + const trail = new TrailSamples(STEP, CAPACITY, MAX_GAP); + for (let phase = 0; phase <= 2; phase += 1 / 60) trail.record(phase, at(phase)); + const run = trail.run(2); + expect(run.to).toBe(8); + expect(run.from).toBe(0); + const fourth = trail.sampleAt(4)?.point[0] ?? 0; + expect(fourth).toBeGreaterThanOrEqual(1); + expect(fourth).toBeLessThan(1.25); + }); + + it('fills the slots a normal tick skips by interpolation', () => { + const trail = new TrailSamples(STEP, CAPACITY, MAX_GAP); + trail.record(0, at(0)); + trail.record(1, at(4)); + expect(trail.sampleAt(2)?.point[0]).toBeCloseTo(2); + expect(trail.run(1)).toEqual({ from: 0, to: 4 }); + }); + + it('starts a fresh run after a jump that is too long to bridge', () => { + const trail = new TrailSamples(STEP, CAPACITY, MAX_GAP); + trail.record(0, at(0)); + trail.record(10, at(100)); + expect(trail.sampleAt(20)).toBeNull(); + expect(trail.run(10)).toEqual({ from: 40, to: 40 }); + }); + + it('keeps earlier samples when the phase scrubs back', () => { + const trail = new TrailSamples(STEP, CAPACITY, MAX_GAP); + for (let phase = 0; phase <= 3; phase += 0.1) trail.record(phase, at(phase)); + trail.record(1.5, at(1.5)); + expect(trail.run(1.5)).toEqual({ from: 0, to: 6 }); + }); + + it('clamps the slot to the capacity', () => { + const trail = new TrailSamples(STEP, CAPACITY, MAX_GAP); + expect(trail.slotOf(1000)).toBe(CAPACITY - 1); + expect(trail.slotOf(-1)).toBe(0); + }); +}); diff --git a/explainers/fpv-drone/src/scene/geometry/trail.ts b/explainers/fpv-drone/src/scene/geometry/trail.ts new file mode 100644 index 00000000..f7d74ce5 --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/trail.ts @@ -0,0 +1,64 @@ +import type { Point } from '../../ids'; + +export interface TrailSample { + point: Point; + heading: number; +} + +export interface TrailRun { + from: number; + to: number; +} + +function mix(a: TrailSample, b: TrailSample, share: number): TrailSample { + return { + point: [ + a.point[0] + (b.point[0] - a.point[0]) * share, + a.point[1] + (b.point[1] - a.point[1]) * share, + a.point[2] + (b.point[2] - a.point[2]) * share, + ], + heading: a.heading + (b.heading - a.heading) * share, + }; +} + +export class TrailSamples { + private readonly slots: (TrailSample | null)[] = []; + private readonly step: number; + private readonly capacity: number; + private readonly maxGap: number; + private last: { slot: number; sample: TrailSample } | null = null; + + constructor(step: number, capacity: number, maxGap: number) { + this.step = step; + this.capacity = capacity; + this.maxGap = maxGap; + } + + slotOf(phase: number): number { + return Math.min(this.capacity - 1, Math.max(0, Math.floor(phase / this.step))); + } + + record(phase: number, sample: TrailSample): void { + const slot = this.slotOf(phase); + const previous = this.last; + if (previous && slot > previous.slot && slot - previous.slot <= this.maxGap) { + for (let between = previous.slot + 1; between < slot; between += 1) { + const share = (between - previous.slot) / (slot - previous.slot); + this.slots[between] = mix(previous.sample, sample, share); + } + } + this.slots[slot] = sample; + this.last = { slot, sample }; + } + + sampleAt(slot: number): TrailSample | null { + return this.slots[slot] ?? null; + } + + run(phase: number): TrailRun { + const to = this.slotOf(phase); + let from = to; + while (from > 0 && this.slots[from - 1]) from -= 1; + return { from, to }; + } +} diff --git a/explainers/fpv-drone/src/scene/index.ts b/explainers/fpv-drone/src/scene/index.ts new file mode 100644 index 00000000..187cdfad --- /dev/null +++ b/explainers/fpv-drone/src/scene/index.ts @@ -0,0 +1,32 @@ +import { createLabelVisibility } from '@core/scene/presetBinder'; +import type { SceneShell } from '@core/scene/shell'; +import type { FpvStore } from '../state'; +import { bindStore } from './bindings'; +import { FpvController } from './controller'; +import { fpvLight } from './lighting'; +import { LABEL_PRIORITY } from './partInfo'; + +export { SCENE_OPTIONS } from './sceneOptions'; + +function reportGoggles(store: FpvStore, through: boolean): void { + const state = store.getState(); + if (state.throughGoggles !== through) state.setThroughGoggles(through); +} + +export function mountFpvScene(shell: SceneShell, store: FpvStore): () => void { + const restoreLight = fpvLight(shell.lighting); + const drone = new FpvController(shell); + const removeFrame = shell.onFrame((deltaSeconds) => { + reportGoggles(store, drone.ridesTheCamera()); + return drone.update(deltaSeconds); + }); + const labelVisibility = createLabelVisibility(shell, LABEL_PRIORITY); + const unbind = bindStore(store, { drone, labelVisibility, ...shell }); + return () => { + removeFrame(); + labelVisibility.dispose(); + unbind(); + drone.dispose(); + restoreLight(); + }; +} diff --git a/explainers/fpv-drone/src/scene/lighting.test.ts b/explainers/fpv-drone/src/scene/lighting.test.ts new file mode 100644 index 00000000..553519c5 --- /dev/null +++ b/explainers/fpv-drone/src/scene/lighting.test.ts @@ -0,0 +1,44 @@ +import { DirectionalLight } from 'three'; +import { describe, expect, it } from 'vitest'; +import { fpvLight } from './lighting'; + +function shellLights() { + return { + key: new DirectionalLight('#ffffff', 1), + fill: new DirectionalLight('#ffffff', 1), + rim: new DirectionalLight('#ffffff', 1), + }; +} + +describe('fpv light', () => { + it('keys cool and high from the left of the field', () => { + const lights = shellLights(); + fpvLight(lights); + const key = lights.key.position.clone().normalize(); + expect(key.z).toBeLessThan(0); + expect(key.y).toBeGreaterThan(0.4); + expect(lights.key.color.b).toBeGreaterThanOrEqual(lights.key.color.r); + }); + + it('fills softly and rims faintly from the far side', () => { + const lights = shellLights(); + fpvLight(lights); + expect(lights.fill.intensity).toBeLessThan(lights.key.intensity); + expect(lights.rim.intensity).toBeLessThan(lights.fill.intensity); + expect( + lights.rim.position.clone().normalize().dot(lights.key.position.clone().normalize()), + ).toBeLessThan(0); + }); + + it('puts the shell lights back as they were', () => { + const lights = shellLights(); + lights.key.position.set(1, 2, 3); + const restore = fpvLight(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/fpv-drone/src/scene/lighting.ts b/explainers/fpv-drone/src/scene/lighting.ts new file mode 100644 index 00000000..9671fe27 --- /dev/null +++ b/explainers/fpv-drone/src/scene/lighting.ts @@ -0,0 +1,37 @@ +import type { DirectionalLight } from 'three'; +import type { Lighting } from '@core/scene/lighting'; + +interface LightSetting { + readonly color: string; + readonly intensity: number; + readonly position: readonly [number, number, number]; +} + +type LightName = 'key' | 'fill' | 'rim'; + +export const FPV_LIGHT: Readonly> = { + key: { color: '#dfe7f0', intensity: 2.2, position: [-180, 420, -560] }, + fill: { color: '#b7c4d4', intensity: 0.95, position: [320, 520, 420] }, + rim: { color: '#cdd7e4', intensity: 0.6, position: [260, 140, 640] }, +}; + +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 fpvLight(lighting: Pick): () => void { + const restorers = (Object.keys(FPV_LIGHT) as LightName[]).map((name) => + apply(lighting[name], FPV_LIGHT[name]), + ); + return () => restorers.forEach((restore) => restore()); +} diff --git a/explainers/fpv-drone/src/scene/partInfo.test.ts b/explainers/fpv-drone/src/scene/partInfo.test.ts new file mode 100644 index 00000000..626c8dc6 --- /dev/null +++ b/explainers/fpv-drone/src/scene/partInfo.test.ts @@ -0,0 +1,42 @@ +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 camera first', () => { + expect([...LABEL_PRIORITY].sort()).toEqual([...PART_IDS].sort()); + expect(LABEL_PRIORITY[0]).toBe('camera'); + }); + + it('labels the nose, the left motors and the ground on the left', () => { + ( + [ + 'camera', + 'videoAntenna', + 'motorFrontLeft', + 'motorRearLeft', + 'groundStation', + 'controlLink', + 'crossroads', + 'launchPad', + ] as const + ).forEach((id) => expect(PART_INFO[id].side, id).toBe('left')); + (['motorFrontRight', 'motorRearRight', 'battery', 'videoLink'] as const).forEach((id) => + expect(PART_INFO[id].side, id).toBe('right'), + ); + }); + + it('splits the labels of the busy chapters across both sides', () => { + (['overview', 'flight', 'link', 'power'] 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/fpv-drone/src/scene/partInfo.ts b/explainers/fpv-drone/src/scene/partInfo.ts new file mode 100644 index 00000000..3a70b6e3 --- /dev/null +++ b/explainers/fpv-drone/src/scene/partInfo.ts @@ -0,0 +1,44 @@ +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([ + 'camera', + 'videoAntenna', + 'motorFrontLeft', + 'motorRearLeft', + 'groundStation', + 'controlLink', + 'crossroads', + 'launchPad', +]); + +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[] = [ + 'camera', + 'battery', + 'propellers', + 'motorFrontRight', + 'motorFrontLeft', + 'motorRearRight', + 'motorRearLeft', + 'stack', + 'videoAntenna', + 'receiverAntenna', + 'controlLink', + 'videoLink', + 'gpsModule', + 'groundStation', + 'spinArrows', + 'frame', + 'crossroads', + 'launchPad', +]; diff --git a/explainers/fpv-drone/src/scene/parts/context.ts b/explainers/fpv-drone/src/scene/parts/context.ts new file mode 100644 index 00000000..88ad712f --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/context.ts @@ -0,0 +1,63 @@ +import { Mesh } from 'three'; +import type { BufferGeometry, Material } 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 { DroneLooks } from '../finishes'; + +export type EmphasisGroup = PartId | typeof STRUCTURE_GROUP | typeof UNDIMMED_GROUP; + +export interface PartContext { + materials: MaterialLibrary; + textures: SceneTextures; + tracker: ResourceTracker; + looks: DroneLooks; +} + +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; +} + +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/fpv-drone/src/scene/parts/drone/antennas.ts b/explainers/fpv-drone/src/scene/parts/drone/antennas.ts new file mode 100644 index 00000000..02533658 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/drone/antennas.ts @@ -0,0 +1,174 @@ +import { CylinderGeometry, Group, SphereGeometry } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { box } from '@core/scene/geometry/box'; +import { anchorAt } from '@core/scene/parts'; +import type { VideoId } from '../../../ids'; +import { GPS, PLATE, RECEIVER_ANTENNAS, VIDEO_ANTENNA } from '../../constants'; +import { FINISHES } from '../../finishes'; +import { along, rod } from '../../geometry/rods'; +import type { Vec3 } from '../../geometry/rods'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +type Triple = readonly [number, number, number]; + +const PLATE_TOP = PLATE.gap + PLATE.thickness; +const SPHERE_SEGMENTS = 8; + +function centredBox(at: Triple, size: Triple): BufferGeometry { + return box({ + minX: at[0] - size[0] / 2, + maxX: at[0] + size[0] / 2, + minY: at[1] - size[1] / 2, + maxY: at[1] + size[1] / 2, + minZ: at[2] - size[2] / 2, + maxZ: at[2] + size[2] / 2, + }); +} + +function mountGeometry(): BufferGeometry { + const { centre, mount } = VIDEO_ANTENNA; + return centredBox([centre[0], PLATE_TOP + mount.size[1] / 2, centre[2]], mount.size); +} + +function lollipopGeometry(): BufferGeometry { + const { centre, mount, analogue } = VIDEO_ANTENNA; + const base = PLATE_TOP + mount.size[1]; + const stub = new CylinderGeometry(analogue.stub, analogue.stub, analogue.length, 8); + stub.translate(centre[0], base + analogue.length / 2, centre[2]); + const cap = new SphereGeometry(analogue.cap, analogue.segments, SPHERE_SEGMENTS); + cap.scale(1, analogue.squash, 1); + cap.translate(centre[0], base + analogue.length + analogue.cap * analogue.squash, centre[2]); + return mergeParts([stub, cap]); +} + +function patchGeometry(): BufferGeometry { + const { centre, mount, digital } = VIDEO_ANTENNA; + const base = PLATE_TOP + mount.size[1]; + const stalk = new CylinderGeometry(digital.stalk / 2, digital.stalk / 2, digital.lift, 8); + stalk.translate(centre[0], base + digital.lift / 2, centre[2]); + const patch = centredBox( + [centre[0], base + digital.lift + digital.patch[1] / 2, centre[2]], + digital.patch, + ); + return mergeParts([stalk, patch]); +} + +export class VideoAntennaPart { + readonly object = new Group(); + readonly top: Object3D; + readonly label: Object3D; + private readonly variants: Readonly>; + + constructor(context: PartContext) { + const { centre, top } = VIDEO_ANTENNA; + this.variants = { + analogue: partMesh(context, lollipopGeometry(), 'videoAntenna', FINISHES.cameraBody), + digital: partMesh(context, patchGeometry(), 'videoAntenna', FINISHES.cameraDigital), + }; + this.object.add( + partMesh(context, mountGeometry(), 'videoAntenna', FINISHES.printed), + this.variants.analogue, + this.variants.digital, + ); + this.top = anchorAt(this.object, centre[0], top, centre[2]); + this.label = anchorAt(this.object, centre[0], top, centre[2]); + } + + setVideo(video: VideoId): void { + this.variants.analogue.visible = video === 'analogue'; + this.variants.digital.visible = video === 'digital'; + } +} + +export function whipTip(base: Triple): Vec3 { + const { holder, whip } = RECEIVER_ANTENNAS; + const start: Vec3 = [base[0], base[1] + holder.size[1], base[2]]; + const direction: Vec3 = [ + whip.direction[0], + whip.direction[1], + Math.sign(base[2]) * whip.direction[2], + ]; + return along(start, direction, whip.length); +} + +function whipGeometry(base: Triple): BufferGeometry { + const { holder, whip, tip } = RECEIVER_ANTENNAS; + const start: Vec3 = [base[0], base[1] + holder.size[1], base[2]]; + const end = whipTip(base); + const direction: Vec3 = [end[0] - start[0], end[1] - start[1], end[2] - start[2]]; + const split = along(start, direction, whip.length * (1 - tip.share)); + return mergeParts([ + rod(start, split, whip.radius, whip.segments), + rod(split, end, tip.radius, whip.segments, tip.radius * 0.8), + ]); +} + +export interface ReceiverPart { + object: Group; + linkEnd: Object3D; + label: Object3D; +} + +export function buildReceiverAntennas(context: PartContext): ReceiverPart { + const { bases, holder } = RECEIVER_ANTENNAS; + const object = new Group(); + object.add( + partMesh( + context, + mergeParts( + bases.map((base) => + centredBox([base[0], base[1] + holder.size[1] / 2, base[2]], holder.size), + ), + ), + 'receiverAntenna', + FINISHES.printed, + ), + partMesh( + context, + mergeParts(bases.map((base) => whipGeometry(base))), + 'receiverAntenna', + FINISHES.cameraBody, + ), + ); + const tips = bases.map((base) => whipTip(base)); + const middle = tips.reduce<[number, number, number]>( + (sum, tip) => [ + sum[0] + tip[0] / tips.length, + sum[1] + tip[1] / tips.length, + sum[2] + tip[2] / tips.length, + ], + [0, 0, 0], + ); + const right = tips.find((tip) => tip[2] > 0) ?? tips[0]; + return { + object, + linkEnd: anchorAt(object, ...middle), + label: anchorAt(object, ...right), + }; +} + +export interface GpsPart { + object: Group; + label: Object3D; +} + +export function buildGps(context: PartContext): GpsPart { + const { centre, mast, puck, patch } = GPS; + const bottom = centre[1] - puck.height / 2; + const pole = new CylinderGeometry(mast.radius, mast.radius, bottom - PLATE_TOP, mast.segments); + pole.translate(centre[0], (bottom + PLATE_TOP) / 2, centre[2]); + const body = new CylinderGeometry(puck.radius, puck.radius, puck.height, puck.segments); + body.translate(...centre); + const ceramic = centredBox( + [centre[0], centre[1] + puck.height / 2 + patch.thickness / 2, centre[2]], + [patch.size, patch.thickness, patch.size], + ); + const object = new Group(); + object.add( + partMesh(context, pole, 'gpsModule', FINISHES.printed), + partMesh(context, body, 'gpsModule', FINISHES.cameraDigital), + partMesh(context, ceramic, 'gpsModule', FINISHES.ceramic), + ); + return { object, label: anchorAt(object, centre[0], centre[1] + puck.height, centre[2]) }; +} diff --git a/explainers/fpv-drone/src/scene/parts/drone/battery.ts b/explainers/fpv-drone/src/scene/parts/drone/battery.ts new file mode 100644 index 00000000..3f3ae315 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/drone/battery.ts @@ -0,0 +1,120 @@ +import { Group } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { box } from '@core/scene/geometry/box'; +import { extrudePlan, roundedRectShape } from '@core/scene/geometry/extrude'; +import { anchorAt } from '@core/scene/parts'; +import { BATTERY, PLATE } from '../../constants'; +import { FINISHES } from '../../finishes'; +import { rod } from '../../geometry/rods'; +import type { Vec3 } from '../../geometry/rods'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +type Triple = readonly [number, number, number]; + +const SIDES = [1, -1] as const; +const LEAD_DROOP = 0.55; + +function centredBox(at: Triple, size: Triple): BufferGeometry { + return box({ + minX: at[0] - size[0] / 2, + maxX: at[0] + size[0] / 2, + minY: at[1] - size[1] / 2, + maxY: at[1] + size[1] / 2, + minZ: at[2] - size[2] / 2, + maxZ: at[2] + size[2] / 2, + }); +} + +export interface BatteryBounds { + x: readonly [number, number]; + y: readonly [number, number]; + z: readonly [number, number]; +} + +export function batteryBounds(): BatteryBounds { + const [cx, cy, cz] = BATTERY.centre; + const [length, height, width] = BATTERY.size; + return { + x: [cx - length / 2, cx + length / 2], + y: [cy - height / 2, cy + height / 2], + z: [cz - width / 2, cz + width / 2], + }; +} + +function packGeometry(bounds: BatteryBounds): BufferGeometry { + const shape = roundedRectShape( + { minA: bounds.x[0], maxA: bounds.x[1], minB: bounds.z[0], maxB: bounds.z[1] }, + BATTERY.corner, + ); + return extrudePlan(shape, bounds.y[0], bounds.y[1]); +} + +function strapGeometry(bounds: BatteryBounds): BufferGeometry { + const { strap } = BATTERY; + const top = bounds.y[1] + strap.thickness / 2; + const plateBottom = PLATE.gap; + const outer = bounds.z[1] + strap.thickness / 2; + const sideHeight = top - plateBottom; + return mergeParts([ + centredBox( + [strap.x, top, 0], + [strap.width, strap.thickness, (outer + strap.thickness / 2) * 2], + ), + ...SIDES.map((side) => + centredBox( + [strap.x, (top + plateBottom) / 2, side * outer], + [strap.width, sideHeight, strap.thickness], + ), + ), + centredBox([strap.x, top + strap.buckle[1] / 2, bounds.z[1] * 0.3], strap.buckle), + ]); +} + +function leadGeometry(bounds: BatteryBounds, offset: number, plugSide: number): BufferGeometry { + const { lead, plug } = BATTERY; + const start: Vec3 = [bounds.x[1], BATTERY.centre[1], offset]; + const plugEnd: Vec3 = [ + plug.at[0] - plug.size[0] / 2, + plug.at[1] + plug.size[1] / 2, + plug.at[2] + plugSide, + ]; + const bend: Vec3 = [ + (start[0] + plugEnd[0]) / 2, + start[1] - (start[1] - plugEnd[1]) * LEAD_DROOP, + (start[2] + plugEnd[2]) / 2, + ]; + return mergeParts([ + rod(start, bend, lead.radius, lead.segments), + rod(bend, plugEnd, lead.radius, lead.segments), + ]); +} + +function balanceGeometry(bounds: BatteryBounds): BufferGeometry { + const { balance, lead } = BATTERY; + const start: Vec3 = [bounds.x[0], bounds.y[1] - 0.004, balance.at[2]]; + const end: Vec3 = [balance.at[0] - balance.size[0] / 2, balance.at[1], balance.at[2]]; + return rod(start, end, lead.radius * 0.8, lead.segments); +} + +export interface BatteryPart { + object: Group; + label: Object3D; +} + +export function buildBattery(context: PartContext): BatteryPart { + const bounds = batteryBounds(); + const object = new Group(); + const { plug, balance, lead } = BATTERY; + object.add( + partMesh(context, packGeometry(bounds), 'battery', FINISHES.shrink), + partMesh(context, strapGeometry(bounds), 'battery', FINISHES.rubber), + partMesh(context, centredBox(plug.at, plug.size), 'battery', FINISHES.plug), + partMesh(context, leadGeometry(bounds, lead.offsets[0], 0.003), 'battery', FINISHES.wireRed), + partMesh(context, leadGeometry(bounds, lead.offsets[1], -0.003), 'battery', FINISHES.wireBlack), + partMesh(context, centredBox(balance.at, balance.size), 'battery', FINISHES.balance), + partMesh(context, balanceGeometry(bounds), 'battery', FINISHES.wireBlack), + ); + const label = anchorAt(object, BATTERY.centre[0] + 0.03, bounds.y[1], 0); + return { object, label }; +} diff --git a/explainers/fpv-drone/src/scene/parts/drone/cameraModule.ts b/explainers/fpv-drone/src/scene/parts/drone/cameraModule.ts new file mode 100644 index 00000000..877deb5b --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/drone/cameraModule.ts @@ -0,0 +1,113 @@ +import { CircleGeometry, CylinderGeometry, Group, RingGeometry } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { box } from '@core/scene/geometry/box'; +import { anchorAt } from '@core/scene/parts'; +import type { VideoId } from '../../../ids'; +import { CAMERA_MODULE } from '../../constants'; +import { FINISHES } from '../../finishes'; +import type { MaterialFinish } from '@core/scene/materials'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +const QUARTER_TURN = Math.PI / 2; +const SIDES = [1, -1] as const; +const GLASS_INSET = 0.0008; + +interface UnitShape { + body: number; + lensRadius: number; + barrel: number; +} + +function cheeksGeometry(): BufferGeometry { + const { centre, cheek } = CAMERA_MODULE; + return mergeParts( + SIDES.map((side) => + box({ + minX: centre[0] - cheek.size[0] / 2, + maxX: centre[0] + cheek.size[0] / 2, + minY: cheek.y - cheek.size[1] / 2, + maxY: cheek.y + cheek.size[1] / 2, + minZ: side * cheek.z - cheek.size[2] / 2, + maxZ: side * cheek.z + cheek.size[2] / 2, + }), + ), + ); +} + +function bodyGeometry(shape: UnitShape): BufferGeometry { + const half = shape.body / 2; + return box({ minX: -half, maxX: half, minY: -half, maxY: half, minZ: -half, maxZ: half }); +} + +function barrelGeometry(shape: UnitShape): BufferGeometry { + const barrel = new CylinderGeometry( + shape.lensRadius, + shape.lensRadius, + shape.barrel, + CAMERA_MODULE.segments, + ); + barrel.rotateZ(-QUARTER_TURN); + barrel.translate(shape.body / 2 + shape.barrel / 2, 0, 0); + return barrel; +} + +function ringGeometry(shape: UnitShape): BufferGeometry { + const front = shape.body / 2 + shape.barrel; + const ring = new RingGeometry( + shape.lensRadius - CAMERA_MODULE.ring, + shape.lensRadius + CAMERA_MODULE.ring * 0.4, + CAMERA_MODULE.segments, + ); + ring.rotateY(QUARTER_TURN); + ring.translate(front + GLASS_INSET, 0, 0); + return ring; +} + +function glassGeometry(shape: UnitShape): BufferGeometry { + const front = shape.body / 2 + shape.barrel; + const glass = new CircleGeometry(shape.lensRadius - CAMERA_MODULE.ring, CAMERA_MODULE.segments); + glass.rotateY(QUARTER_TURN); + glass.translate(front - GLASS_INSET, 0, 0); + return glass; +} + +function buildUnit(context: PartContext, shape: UnitShape, bodyFinish: MaterialFinish): Group { + const unit = new Group(); + unit.add( + partMesh(context, bodyGeometry(shape), 'camera', bodyFinish), + partMesh(context, barrelGeometry(shape), 'camera', FINISHES.cameraBody), + partMesh(context, ringGeometry(shape), 'camera', FINISHES.lensRing), + partMesh(context, glassGeometry(shape), 'camera', FINISHES.lens), + ); + return unit; +} + +export class CameraModulePart { + readonly object = new Group(); + readonly tilted = new Group(); + readonly lens: Object3D; + readonly label: Object3D; + private readonly units: Readonly>; + + constructor(context: PartContext) { + const { centre, tilt, analogue, digital } = CAMERA_MODULE; + this.object.add(partMesh(context, cheeksGeometry(), 'camera', FINISHES.printed)); + this.tilted.position.set(...centre); + this.tilted.rotation.z = tilt; + this.units = { + analogue: buildUnit(context, analogue, FINISHES.cameraBody), + digital: buildUnit(context, digital, FINISHES.cameraDigital), + }; + this.tilted.add(this.units.analogue, this.units.digital); + this.object.add(this.tilted); + const front = digital.body / 2 + digital.barrel; + this.lens = anchorAt(this.tilted, front, 0, 0); + this.label = anchorAt(this.tilted, front, digital.lensRadius, 0); + } + + setVideo(video: VideoId): void { + this.units.analogue.visible = video === 'analogue'; + this.units.digital.visible = video === 'digital'; + } +} diff --git a/explainers/fpv-drone/src/scene/parts/drone/drone.ts b/explainers/fpv-drone/src/scene/parts/drone/drone.ts new file mode 100644 index 00000000..88037bc4 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/drone/drone.ts @@ -0,0 +1,116 @@ +import { Box3, Group, Vector3 } from 'three'; +import type { Object3D } from 'three'; +import { anchorAt } from '@core/scene/parts'; +import { MOTOR_PART_IDS } from '../../../ids'; +import type { AssemblyState, MotorPartId, PartId } from '../../../ids'; +import { DRONE_SCALE, droneUnits } from '../../../model/scale'; +import { BODY_DROP, DRONE_BOX, PROP } from '../../constants'; +import { applyDronePose } from '../../pose'; +import type { PartContext } from '../context'; +import { DownwashPart } from '../effects/downwash'; +import { SpinArrowsPart } from '../effects/spinArrows'; +import { buildGps, buildReceiverAntennas, VideoAntennaPart } from './antennas'; +import { buildBattery } from './battery'; +import { CameraModulePart } from './cameraModule'; +import { buildFrame, frameLabelSpot } from './frame'; +import { buildMotor } from './motor'; +import { PropellerSet } from './propeller'; +import { buildStack } from './stack'; + +export type DroneLabel = Extract< + PartId, + | 'frame' + | MotorPartId + | 'propellers' + | 'battery' + | 'stack' + | 'camera' + | 'videoAntenna' + | 'receiverAntenna' + | 'gpsModule' + | 'spinArrows' +>; + +const PROPELLER_LABEL_MOTOR: MotorPartId = 'motorFrontRight'; + +export class DronePart { + readonly object = new Group(); + readonly body = new Group(); + readonly labels: ReadonlyMap; + readonly cameraAnchor: Object3D; + readonly videoAntennaTop: Object3D; + readonly receiverEnd: Object3D; + readonly propellers: PropellerSet; + private readonly cameraModule: CameraModulePart; + private readonly videoAntenna: VideoAntennaPart; + private readonly arrows: SpinArrowsPart; + private readonly downwash: DownwashPart; + private readonly localBox = new Box3( + new Vector3(DRONE_BOX.x[0], DRONE_BOX.y[0], DRONE_BOX.z[0]), + new Vector3(DRONE_BOX.x[1], DRONE_BOX.y[1], DRONE_BOX.z[1]), + ); + + constructor(context: PartContext) { + this.body.scale.setScalar(DRONE_SCALE); + this.body.position.y = -droneUnits(BODY_DROP); + const motors = MOTOR_PART_IDS.map((id) => [id, buildMotor(context, id)] as const); + this.propellers = new PropellerSet(context); + const battery = buildBattery(context); + const stack = buildStack(context); + const receiver = buildReceiverAntennas(context); + const gps = buildGps(context); + this.cameraModule = new CameraModulePart(context); + this.videoAntenna = new VideoAntennaPart(context); + this.arrows = new SpinArrowsPart(context); + this.downwash = new DownwashPart(context, this.propellers); + this.body.add( + buildFrame(context), + ...motors.map(([, motor]) => motor.object), + this.propellers.object, + battery.object, + stack.object, + this.cameraModule.object, + this.videoAntenna.object, + receiver.object, + gps.object, + this.arrows.object, + this.downwash.mesh, + ); + this.object.add(this.body); + this.cameraAnchor = this.cameraModule.lens; + this.videoAntennaTop = this.videoAntenna.top; + this.receiverEnd = receiver.linkEnd; + const propeller = this.propellers.of(PROPELLER_LABEL_MOTOR); + this.labels = new Map([ + ['frame', anchorAt(this.body, ...frameLabelSpot())], + ...motors.map(([id, motor]) => [id, motor.label] as const), + ['propellers', anchorAt(propeller.object, 0, PROP.hub.height, PROP.radius * 0.6)], + ['battery', battery.label], + ['stack', stack.label], + ['camera', this.cameraModule.label], + ['videoAntenna', this.videoAntenna.label], + ['receiverAntenna', receiver.label], + ['gpsModule', gps.label], + ['spinArrows', this.arrows.label], + ]); + } + + setState(state: AssemblyState): void { + applyDronePose(this.object, state.flight); + this.object.updateMatrixWorld(true); + this.propellers.setRate(state.flight.propRate); + this.cameraModule.setVideo(state.video); + this.videoAntenna.setVideo(state.video); + this.arrows.setState(state.view.arrows, state.motors.shares); + this.downwash.setState(state.motors.shares, state.flight); + } + + advance(deltaSeconds: number): void { + this.propellers.advance(deltaSeconds); + this.downwash.advance(deltaSeconds); + } + + worldBox(target: Box3): Box3 { + return target.copy(this.localBox).applyMatrix4(this.body.matrixWorld); + } +} diff --git a/explainers/fpv-drone/src/scene/parts/drone/frame.ts b/explainers/fpv-drone/src/scene/parts/drone/frame.ts new file mode 100644 index 00000000..4fcda90f --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/drone/frame.ts @@ -0,0 +1,110 @@ +import { CylinderGeometry, Group, Path } from 'three'; +import type { BufferGeometry } from 'three'; +import { extrudePlan, planShape, roundedRectShape } from '@core/scene/geometry/extrude'; +import { MOTOR_PART_IDS } from '../../../ids'; +import type { MotorPartId } from '../../../ids'; +import { MOTOR_POSITIONS } from '../../../model/layout'; +import { ARMS, PLATE, STANDOFFS } from '../../constants'; +import { FINISHES } from '../../finishes'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +const FULL_TURN = Math.PI * 2; +const LABEL_X_SHARE = 0.3; + +export interface ArmLine { + root: readonly [x: number, z: number]; + tip: readonly [x: number, z: number]; + direction: readonly [x: number, z: number]; +} + +export function armLine(id: MotorPartId): ArmLine { + const root = ARMS.roots[id]; + const motor = MOTOR_POSITIONS[id]; + const dx = motor[0] - root[0]; + const dz = motor[2] - root[1]; + const length = Math.hypot(dx, dz); + const direction = [dx / length, dz / length] as const; + return { + root, + tip: [motor[0] + direction[0] * ARMS.overhang, motor[2] + direction[1] * ARMS.overhang], + direction, + }; +} + +function plateGeometry(bottom: number, top: number, holed: boolean): BufferGeometry { + const shape = roundedRectShape( + { minA: PLATE.x[0], maxA: PLATE.x[1], minB: -PLATE.halfWidth, maxB: PLATE.halfWidth }, + PLATE.corner, + ); + if (holed) { + for (const hole of PLATE.holes) { + shape.holes.push(new Path().absarc(hole.x, hole.z, hole.radius, 0, FULL_TURN, false)); + } + } + return extrudePlan(shape, bottom, top); +} + +function armGeometry(id: MotorPartId): BufferGeometry { + const { root, tip, direction } = armLine(id); + const half = ARMS.width / 2; + const across = [-direction[1] * half, direction[0] * half] as const; + const shape = planShape([ + { x: root[0] + across[0], z: root[1] + across[1] }, + { x: tip[0] + across[0], z: tip[1] + across[1] }, + { x: tip[0] - across[0], z: tip[1] - across[1] }, + { x: root[0] - across[0], z: root[1] - across[1] }, + ]); + return extrudePlan(shape, 0, ARMS.thickness); +} + +function standoffGeometry(): BufferGeometry { + return mergeParts( + STANDOFFS.spots.map(([x, z]) => { + const post = new CylinderGeometry( + STANDOFFS.radius, + STANDOFFS.radius, + PLATE.gap, + STANDOFFS.segments, + ); + post.translate(x, PLATE.gap / 2, z); + return post; + }), + ); +} + +function feetGeometry(): BufferGeometry { + const { foot } = ARMS; + return mergeParts( + MOTOR_PART_IDS.map((id) => { + const motor = MOTOR_POSITIONS[id]; + const { direction } = armLine(id); + const peg = new CylinderGeometry(foot.radius, foot.tip, foot.height, foot.segments); + peg.translate( + motor[0] - direction[0] * foot.inboard, + -PLATE.thickness - foot.height / 2, + motor[2] - direction[1] * foot.inboard, + ); + return peg; + }), + ); +} + +export function buildFrame(context: PartContext): Group { + const frame = new Group(); + const carbon = mergeParts([ + plateGeometry(-PLATE.thickness, 0, true), + plateGeometry(PLATE.gap, PLATE.gap + PLATE.thickness, false), + ...MOTOR_PART_IDS.map((id) => armGeometry(id)), + ]); + frame.add( + partMesh(context, carbon, 'frame', context.looks.carbon), + partMesh(context, standoffGeometry(), 'frame', FINISHES.aluminium), + partMesh(context, feetGeometry(), 'frame', FINISHES.printed), + ); + return frame; +} + +export function frameLabelSpot(): readonly [number, number, number] { + return [PLATE.x[0] * LABEL_X_SHARE, PLATE.gap + PLATE.thickness, -PLATE.halfWidth]; +} diff --git a/explainers/fpv-drone/src/scene/parts/drone/motor.ts b/explainers/fpv-drone/src/scene/parts/drone/motor.ts new file mode 100644 index 00000000..12eecdcd --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/drone/motor.ts @@ -0,0 +1,114 @@ +import { CylinderGeometry, Group, LatheGeometry, Vector2 } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { anchorAt } from '@core/scene/parts'; +import type { MotorPartId } from '../../../ids'; +import { MOTOR_POSITIONS } from '../../../model/layout'; +import { MOTOR } from '../../constants'; +import { FINISHES } from '../../finishes'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +const DOME_INSET = 0.85; +const NUT_HEIGHT = 0.003; + +export interface MotorLevels { + foot: number; + stator: number; + bell: number; + top: number; + shaft: number; +} + +export function motorLevels(): MotorLevels { + const { base, heights } = MOTOR; + const foot = base + heights.foot; + const stator = foot + heights.stator; + const bell = stator + heights.bell; + return { foot, stator, bell, top: bell + heights.dome, shaft: bell + MOTOR.shaft.height }; +} + +function footGeometry(levels: MotorLevels): BufferGeometry { + const foot = new CylinderGeometry(MOTOR.radius, MOTOR.radius, MOTOR.heights.foot, MOTOR.segments); + foot.translate(0, MOTOR.base + MOTOR.heights.foot / 2, 0); + const shaft = new CylinderGeometry( + MOTOR.shaft.radius, + MOTOR.shaft.radius, + levels.shaft - levels.bell, + MOTOR.segments / 2, + ); + shaft.translate(0, (levels.bell + levels.shaft) / 2, 0); + const nut = new CylinderGeometry( + MOTOR.shaft.radius * 1.3, + MOTOR.shaft.radius * 1.3, + NUT_HEIGHT, + MOTOR.segments / 2, + ); + nut.translate(0, levels.shaft + NUT_HEIGHT / 2, 0); + return mergeParts([foot, shaft, nut]); +} + +function statorGeometry(levels: MotorLevels): BufferGeometry { + const stator = new CylinderGeometry( + MOTOR.statorRadius, + MOTOR.statorRadius, + MOTOR.heights.stator, + MOTOR.segments, + ); + stator.translate(0, (levels.foot + levels.stator) / 2, 0); + return stator; +} + +function bellSideGeometry(levels: MotorLevels): BufferGeometry { + const side = new CylinderGeometry( + MOTOR.radius, + MOTOR.radius, + MOTOR.heights.bell, + MOTOR.segments, + 1, + true, + ); + side.translate(0, (levels.stator + levels.bell) / 2, 0); + return side; +} + +function bellTopGeometry(levels: MotorLevels): BufferGeometry { + const inner = MOTOR.ring.inner * DOME_INSET; + const profile = [ + new Vector2(MOTOR.shaft.radius, levels.top), + new Vector2(inner, levels.top), + new Vector2(MOTOR.ring.inner, levels.bell), + new Vector2(MOTOR.radius, levels.bell), + ]; + return new LatheGeometry(profile, MOTOR.segments); +} + +function ringGeometry(levels: MotorLevels): BufferGeometry { + const lift = MOTOR.ring.thickness; + const profile = [ + new Vector2(MOTOR.ring.inner, levels.bell), + new Vector2(MOTOR.ring.inner, levels.bell + lift), + new Vector2(MOTOR.radius, levels.bell + lift), + new Vector2(MOTOR.radius, levels.bell), + ]; + return new LatheGeometry(profile, MOTOR.segments); +} + +export interface MotorPart { + object: Group; + label: Object3D; +} + +export function buildMotor(context: PartContext, id: MotorPartId): MotorPart { + const levels = motorLevels(); + const object = new Group(); + object.position.set(...MOTOR_POSITIONS[id]); + object.add( + partMesh(context, footGeometry(levels), id, FINISHES.motorBase), + partMesh(context, statorGeometry(levels), id, FINISHES.copper), + partMesh(context, bellSideGeometry(levels), id, context.looks.bell), + partMesh(context, bellTopGeometry(levels), id, FINISHES.cameraBody), + partMesh(context, ringGeometry(levels), id, FINISHES.bellRing), + ); + const label = anchorAt(object, 0, levels.top, 0); + return { object, label }; +} diff --git a/explainers/fpv-drone/src/scene/parts/drone/propeller.ts b/explainers/fpv-drone/src/scene/parts/drone/propeller.ts new file mode 100644 index 00000000..c08a2ae8 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/drone/propeller.ts @@ -0,0 +1,125 @@ +import { + CircleGeometry, + CylinderGeometry, + DoubleSide, + Group, + Mesh, + MeshBasicMaterial, +} from 'three'; +import type { BufferGeometry } from 'three'; +import { FULL_TURN, smoothstep } from '@core/math'; +import { MOTOR_PART_IDS } from '../../../ids'; +import type { MotorPartId, SpinDirection } from '../../../ids'; +import { MOTOR_POSITIONS, MOTOR_SPIN } from '../../../model/layout'; +import { PROP } from '../../constants'; +import { FINISHES } from '../../finishes'; +import { bladeGeometry, spinSign } from '../../geometry/blade'; +import { discTexture } from '../../geometry/surfaceMaps'; +import { mergeParts, partMesh, registered } from '../context'; +import type { PartContext } from '../context'; +import { motorLevels } from './motor'; + +const DISC_TINT = '#9aa1aa'; +const QUARTER_TURN = Math.PI / 2; +const DISC_LIFT = 0.003; + +export function propName(id: MotorPartId): string { + return `prop:${id}`; +} + +function bladesGeometry(direction: SpinDirection): BufferGeometry { + return mergeParts( + Array.from({ length: PROP.blades }, (_, index) => { + const blade = bladeGeometry(PROP, direction); + blade.rotateY((index / PROP.blades) * FULL_TURN); + return blade; + }), + ); +} + +function hubGeometry(): BufferGeometry { + const { hub } = PROP; + return new CylinderGeometry(hub.radius, hub.radius, hub.height, hub.segments); +} + +export class PropellerPart { + readonly object = new Group(); + readonly spinner = new Group(); + readonly sign: 1 | -1; + private readonly disc: Mesh; + private readonly discMaterial: MeshBasicMaterial; + private rate = 0; + + constructor(context: PartContext, id: MotorPartId, discMap: ReturnType) { + const levels = motorLevels(); + const direction = MOTOR_SPIN[id]; + this.sign = spinSign(direction); + const [x, , z] = MOTOR_POSITIONS[id]; + this.object.position.set(x, levels.bell + PROP.lift + PROP.hub.height / 2, z); + this.spinner.name = propName(id); + this.spinner.add( + partMesh(context, bladesGeometry(direction), 'propellers', FINISHES.prop), + partMesh(context, hubGeometry(), 'propellers', FINISHES.propHub), + ); + this.discMaterial = registered( + context, + 'propellers', + new MeshBasicMaterial({ + color: DISC_TINT, + map: discMap, + transparent: true, + opacity: 0, + depthWrite: false, + side: DoubleSide, + }), + ); + const disc = context.tracker.track(new CircleGeometry(PROP.radius, PROP.discSegments)); + disc.rotateX(-QUARTER_TURN); + disc.translate(0, DISC_LIFT, 0); + this.disc = new Mesh(disc, this.discMaterial); + this.disc.visible = false; + this.object.add(this.spinner, this.disc); + } + + setRate(rate: number): void { + this.rate = rate; + const blur = smoothstep(rate, PROP.blurFrom, 1); + this.discMaterial.opacity = PROP.blurOpacity * blur; + this.disc.visible = blur > 0; + } + + advance(deltaSeconds: number): void { + const turn = this.sign * PROP.spinRate * this.rate * deltaSeconds; + this.spinner.rotation.y = (this.spinner.rotation.y + turn) % FULL_TURN; + } +} + +export class PropellerSet { + readonly object = new Group(); + readonly parts: ReadonlyMap; + + constructor(context: PartContext) { + const discMap = context.tracker.track(discTexture(PROP.disc)); + const parts = new Map(); + for (const id of MOTOR_PART_IDS) { + const part = new PropellerPart(context, id, discMap); + parts.set(id, part); + this.object.add(part.object); + } + this.parts = parts; + } + + setRate(rate: number): void { + this.parts.forEach((part) => part.setRate(rate)); + } + + advance(deltaSeconds: number): void { + this.parts.forEach((part) => part.advance(deltaSeconds)); + } + + of(id: MotorPartId): PropellerPart { + const part = this.parts.get(id); + if (!part) throw new Error(`No propeller for ${id}`); + return part; + } +} diff --git a/explainers/fpv-drone/src/scene/parts/drone/stack.ts b/explainers/fpv-drone/src/scene/parts/drone/stack.ts new file mode 100644 index 00000000..c4822bcf --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/drone/stack.ts @@ -0,0 +1,107 @@ +import { CylinderGeometry, Group } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { box } from '@core/scene/geometry/box'; +import { anchorAt } from '@core/scene/parts'; +import { STACK } from '../../constants'; +import { FINISHES } from '../../finishes'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +type Triple = readonly [number, number, number]; + +const QUARTER_TURN = Math.PI / 2; +const CORNERS = [ + [1, 1], + [1, -1], + [-1, 1], + [-1, -1], +] as const; + +function centredBox(at: Triple, size: Triple): BufferGeometry { + return box({ + minX: at[0] - size[0] / 2, + maxX: at[0] + size[0] / 2, + minY: at[1] - size[1] / 2, + maxY: at[1] + size[1] / 2, + minZ: at[2] - size[2] / 2, + maxZ: at[2] + size[2] / 2, + }); +} + +function boardsGeometry(): BufferGeometry { + const { board, centre } = STACK; + return mergeParts( + board.levels.map((level) => + centredBox( + [centre[0], centre[1] + level, centre[2]], + [board.size, board.thickness, board.size], + ), + ), + ); +} + +function standoffsGeometry(): BufferGeometry { + const { board, standoff, centre } = STACK; + const height = board.levels[1] - board.levels[0]; + return mergeParts( + CORNERS.map(([sx, sz]) => { + const post = new CylinderGeometry( + standoff.radius, + standoff.radius, + height, + standoff.segments, + ); + post.translate( + centre[0] + sx * standoff.inset, + centre[1] + (board.levels[0] + board.levels[1]) / 2, + centre[2] + sz * standoff.inset, + ); + return post; + }), + ); +} + +function capacitorGeometry(): BufferGeometry { + const { capacitor, centre } = STACK; + const can = new CylinderGeometry(capacitor.radius, capacitor.radius, capacitor.length, 12); + can.rotateZ(QUARTER_TURN); + can.translate( + centre[0] + capacitor.at[0], + centre[1] + capacitor.at[1], + centre[2] + capacitor.at[2], + ); + return can; +} + +function chipsGeometry(): BufferGeometry { + const { chips, board, centre } = STACK; + const top = centre[1] + board.levels[1] + board.thickness / 2; + return mergeParts( + chips.map(({ at, size }) => + centredBox([centre[0] + at[0], top + size[1] / 2, centre[2] + at[2]], size), + ), + ); +} + +export interface StackPart { + object: Group; + label: Object3D; +} + +export function buildStack(context: PartContext): StackPart { + const object = new Group(); + object.add( + partMesh(context, boardsGeometry(), 'stack', FINISHES.pcb), + partMesh(context, standoffsGeometry(), 'stack', FINISHES.aluminium), + partMesh(context, capacitorGeometry(), 'stack', FINISHES.capacitor), + partMesh(context, chipsGeometry(), 'stack', FINISHES.chip), + ); + const { centre, board } = STACK; + const label = anchorAt( + object, + centre[0], + centre[1] + board.levels[1], + centre[2] + board.size / 2, + ); + return { object, label }; +} diff --git a/explainers/fpv-drone/src/scene/parts/effects/beam.test.ts b/explainers/fpv-drone/src/scene/parts/effects/beam.test.ts new file mode 100644 index 00000000..3b35072b --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/beam.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { BEAMS } from '../../constants'; + +const DRONE_END_WIDTH = 0.3; +const FAR_FADE = 0.15; + +describe('link beams', () => { + it('draws the control link as a thin path that widens a little toward the drone', () => { + const { startRadius, endRadius, fade } = BEAMS.control; + expect(endRadius * 2).toBeLessThanOrEqual(DRONE_END_WIDTH); + expect(startRadius).toBeLessThan(endRadius); + expect(fade[0]).toBeGreaterThanOrEqual(FAR_FADE); + }); + + it('draws the video link as a faint thin path that tapers toward the goggles', () => { + const { startRadius, endRadius, fade, opacity } = BEAMS.video; + expect(startRadius * 2).toBeLessThanOrEqual(DRONE_END_WIDTH); + expect(endRadius).toBeLessThan(startRadius); + expect(fade[1]).toBeGreaterThanOrEqual(FAR_FADE); + expect(opacity).toBeLessThan(BEAMS.control.opacity); + }); +}); diff --git a/explainers/fpv-drone/src/scene/parts/effects/beam.ts b/explainers/fpv-drone/src/scene/parts/effects/beam.ts new file mode 100644 index 00000000..3c3450e6 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/beam.ts @@ -0,0 +1,123 @@ +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; +varying float vAlong; +varying vec3 vNormalView; +varying vec3 vViewPosition; +void main() { + vAlong = position.y; + float radius = mix(uRadius.x, uRadius.y, position.y); + 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; + +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) }, + 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): 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; + } + + setOpacity(opacity: number): void { + this.material.uniforms.uOpacity.value = opacity; + } + + 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/fpv-drone/src/scene/parts/effects/downwash.test.ts b/explainers/fpv-drone/src/scene/parts/effects/downwash.test.ts new file mode 100644 index 00000000..8ca82ee2 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/downwash.test.ts @@ -0,0 +1,70 @@ +import { Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { toRadians } from '@core/math'; +import { DEMO_HOVER_SHARE } from '../../../model/motors'; +import { DOWNWASH } from '../../constants'; +import { downwashLevels, flowRate, wakeBend } from './downwash'; +import type { WakeFlight } from './downwash'; + +const HOVER = [DEMO_HOVER_SHARE, DEMO_HOVER_SHARE, DEMO_HOVER_SHARE, DEMO_HOVER_SHARE] as const; +const CRUISE: WakeFlight = { + heading: 0, + pitch: toRadians(30), + roll: 0, + speedKmh: 70, + verticalSpeed: 0, +}; + +describe('downwash levels', () => { + it('gives every motor the same level in a steady hover, with room to grow', () => { + const levels = downwashLevels(HOVER); + expect(new Set(levels).size).toBe(1); + expect(levels[0]).toBeGreaterThan(0.5); + expect(levels[0]).toBeLessThan(1); + }); + + it('makes the motors that speed up push visibly more air', () => { + const rise = DEMO_HOVER_SHARE * 0.05; + const levels = downwashLevels([ + DEMO_HOVER_SHARE + rise, + DEMO_HOVER_SHARE - rise, + DEMO_HOVER_SHARE + rise, + DEMO_HOVER_SHARE - rise, + ]); + expect(levels[0] / levels[1]).toBeGreaterThan(1.5); + expect(levels[2]).toBe(levels[0]); + expect(Math.max(...levels)).toBeLessThanOrEqual(DOWNWASH.level.max); + }); + + it('stills the air when the motors stop', () => { + expect(downwashLevels([0, 0, 0, 0])).toEqual([0, 0, 0, 0]); + }); +}); + +describe('wake bend', () => { + it('leaves the wake straight down in a hover', () => { + const bend = wakeBend({ ...CRUISE, pitch: 0, speedKmh: 0 }, new Vector3()); + expect(bend.length()).toBeCloseTo(0); + }); + + it('sweeps the wake back toward the tail at cruise, whatever the heading', () => { + const bend = wakeBend(CRUISE, new Vector3()); + expect(bend.x).toBeLessThan(-DOWNWASH.bend.max * 0.7); + expect(Math.abs(bend.z)).toBeLessThan(1e-6); + expect(bend.length()).toBeCloseTo(DOWNWASH.bend.max); + const turned = wakeBend({ ...CRUISE, heading: Math.PI / 2 }, new Vector3()); + expect(turned.distanceTo(bend)).toBeLessThan(1e-6); + }); + + it('never lifts the wake back up through the props on a descent', () => { + const bend = wakeBend({ ...CRUISE, pitch: 0, speedKmh: 0, verticalSpeed: -20 }, new Vector3()); + expect(bend.y).toBeLessThanOrEqual(DOWNWASH.bend.rise); + }); +}); + +describe('downwash flow', () => { + it('runs the streaks faster through a harder working propeller', () => { + expect(flowRate(1)).toBeGreaterThan(flowRate(0.5)); + expect(flowRate(0)).toBeGreaterThan(0); + }); +}); diff --git a/explainers/fpv-drone/src/scene/parts/effects/downwash.ts b/explainers/fpv-drone/src/scene/parts/effects/downwash.ts new file mode 100644 index 00000000..88c43b12 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/downwash.ts @@ -0,0 +1,224 @@ +import { + Color, + CylinderGeometry, + DoubleSide, + DynamicDrawUsage, + Euler, + InstancedBufferAttribute, + InstancedMesh, + Matrix4, + Quaternion, + ShaderMaterial, + Vector2, + Vector3, +} from 'three'; +import { clamp } from '@core/math'; +import { MOTOR_PART_IDS } from '../../../ids'; +import type { FlightReading, MotorShares } from '../../../ids'; +import { MOTOR_SPIN } from '../../../model/layout'; +import { toMetresPerSecond } from '../../../model/scale'; +import { DOWNWASH } from '../../constants'; +import { spinSign } from '../../geometry/blade'; +import { DRONE_EULER_ORDER } from '../../pose'; +import { registered } from '../context'; +import type { PartContext } from '../context'; +import type { PropellerSet } from '../drone/propeller'; + +const VERTEX = /* glsl */ ` +attribute vec4 aFlow; +uniform vec3 uBend; +uniform float uBendPower; +uniform float uLength; +uniform float uLengthFloor; +uniform float uRadius; +uniform vec2 uWaist; +uniform vec2 uFlare; +uniform float uSwirl; +varying vec2 vSurface; +varying vec4 vFlow; +varying float vFacing; +varying float vHeight; +void main() { + float along = -position.y; + float reach = uLength * mix(uLengthFloor, 1.0, min(aFlow.y, 1.0)); + float girth = 1.0 - uWaist.x * smoothstep(0.0, uWaist.y, along) + + uFlare.x * smoothstep(uFlare.y, 1.0, along); + float turn = uv.x * 6.2831853 + aFlow.z * uSwirl * along; + vec3 local = vec3(sin(turn) * uRadius * girth, -along * reach, cos(turn) * uRadius * girth); + local += uBend * reach * pow(along, uBendPower); + vec4 view = modelViewMatrix * instanceMatrix * vec4(local, 1.0); + vec3 normal = normalize(normalMatrix * vec3(sin(turn), 0.0, cos(turn))); + vSurface = vec2(uv.x, along); + vFlow = aFlow; + vFacing = abs(dot(normal, normalize(-view.xyz))); + vHeight = (modelMatrix * instanceMatrix * vec4(local, 1.0)).y; + gl_Position = projectionMatrix * view; +} +`; + +const FRAGMENT = /* glsl */ ` +uniform vec3 uColour; +uniform float uOpacity; +uniform float uHaze; +uniform float uLanes; +uniform vec2 uLaneWidth; +uniform float uLaneSkip; +uniform float uDashes; +uniform vec2 uPace; +uniform vec2 uFade; +uniform float uSoftness; +uniform float uGroundFade; +varying vec2 vSurface; +varying vec4 vFlow; +varying float vFacing; +varying float vHeight; + +float hash(float n) { + return fract(sin(n * 12.9898) * 43758.5453); +} + +void main() { + float lane = vSurface.x * uLanes; + float id = floor(lane) + floor(vFlow.w + 0.5) * uLanes; + float pick = hash(id); + float glow = mix(0.45, 1.0, hash(id + 5.3)); + float width = mix(uLaneWidth.x, uLaneWidth.y, hash(id + 3.1)); + float line = (1.0 - smoothstep(0.0, width, abs(fract(lane) - 0.5))) * step(uLaneSkip, pick); + float pace = 1.0 + floor(hash(id + 7.7) * uPace.y) * uPace.x; + float cycle = fract(vSurface.y * uDashes - vFlow.x * pace + pick); + float dash = smoothstep(0.1, 0.7, cycle) * (1.0 - smoothstep(0.72, 0.8, cycle)); + float fade = smoothstep(0.0, uFade.x, vSurface.y) * (1.0 - smoothstep(uFade.y, 1.0, vSurface.y)) + * smoothstep(0.0, uGroundFade, vHeight); + float body = pow(vFacing, uSoftness); + float alpha = (line * dash * glow + uHaze) * body * fade * vFlow.y * uOpacity; + gl_FragColor = vec4(uColour, alpha); + #include +} +`; + +export const DOWNWASH_NAME = 'downwash'; + +const FLOW_SIZE = 4; +const DISC_GAP = 0.002; + +export type WakeFlight = Pick< + FlightReading, + 'heading' | 'pitch' | 'roll' | 'speedKmh' | 'verticalSpeed' +>; + +export function downwashLevels(shares: MotorShares): number[] { + const { reference, gain, max } = DOWNWASH.level; + const mean = shares.reduce((sum, share) => sum + share, 0) / shares.length; + if (mean <= 0) return shares.map(() => 0); + const power = mean / reference; + return shares.map((share) => clamp(power * (1 + (gain * (share - mean)) / mean), 0, max)); +} + +const turn = new Euler(); +const toBody = new Quaternion(); + +export function wakeBend(flight: WakeFlight, target: Vector3): Vector3 { + const { max, fullAtKmh, rise } = DOWNWASH.bend; + const speed = toMetresPerSecond(flight.speedKmh); + target.set( + -Math.cos(flight.heading) * speed, + -flight.verticalSpeed, + -Math.sin(flight.heading) * speed, + ); + turn.set(flight.roll, -flight.heading, -flight.pitch, DRONE_EULER_ORDER); + target.applyQuaternion(toBody.setFromEuler(turn).invert()); + target.multiplyScalar(max / toMetresPerSecond(fullAtKmh)).clampLength(0, max); + target.y = Math.min(target.y, rise); + return target; +} + +export function flowRate(level: number): number { + const { base, gain } = DOWNWASH.flow; + return (base + gain * level) * DOWNWASH.dashes; +} + +function tubeGeometry(): CylinderGeometry { + const tube = new CylinderGeometry(1, 1, 1, DOWNWASH.around, DOWNWASH.along, true); + tube.translate(0, -0.5, 0); + return tube; +} + +function downwashMaterial(): ShaderMaterial { + const { radius, length, waist, flare, lanes, laneWidth, fade } = DOWNWASH; + return new ShaderMaterial({ + uniforms: { + uBend: { value: new Vector3() }, + uBendPower: { value: DOWNWASH.bend.power }, + uLength: { value: length }, + uLengthFloor: { value: DOWNWASH.lengthFloor }, + uRadius: { value: radius }, + uWaist: { value: new Vector2(waist.share, waist.by) }, + uFlare: { value: new Vector2(flare.share, flare.from) }, + uSwirl: { value: DOWNWASH.swirl }, + uColour: { value: new Color(DOWNWASH.colour) }, + uOpacity: { value: DOWNWASH.opacity }, + uHaze: { value: DOWNWASH.haze }, + uSoftness: { value: DOWNWASH.softness }, + uGroundFade: { value: DOWNWASH.groundFade }, + uLanes: { value: lanes }, + uLaneWidth: { value: new Vector2(...laneWidth) }, + uLaneSkip: { value: DOWNWASH.laneSkip }, + uDashes: { value: DOWNWASH.dashes }, + uPace: { value: new Vector2(DOWNWASH.pace.step, DOWNWASH.pace.count) }, + uFade: { value: new Vector2(fade.top, fade.from) }, + }, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + transparent: true, + depthWrite: false, + side: DoubleSide, + toneMapped: false, + }); +} + +export class DownwashPart { + readonly mesh: InstancedMesh; + private readonly material: ShaderMaterial; + private readonly flow: InstancedBufferAttribute; + private levels: number[] = MOTOR_PART_IDS.map(() => 0); + + constructor(context: PartContext, propellers: PropellerSet) { + const geometry = context.tracker.track(tubeGeometry()); + this.flow = new InstancedBufferAttribute( + new Float32Array(MOTOR_PART_IDS.length * FLOW_SIZE), + FLOW_SIZE, + ); + this.flow.setUsage(DynamicDrawUsage); + geometry.setAttribute('aFlow', this.flow); + this.material = registered(context, 'propellers', downwashMaterial()); + this.mesh = new InstancedMesh(geometry, this.material, MOTOR_PART_IDS.length); + const place = new Matrix4(); + MOTOR_PART_IDS.forEach((id, index) => { + const { x, y, z } = propellers.of(id).object.position; + this.mesh.setMatrixAt(index, place.makeTranslation(x, y - DISC_GAP, z)); + this.flow.setX(index, 0); + this.flow.setZ(index, spinSign(MOTOR_SPIN[id])); + this.flow.setW(index, index); + }); + this.mesh.name = DOWNWASH_NAME; + this.mesh.frustumCulled = false; + this.mesh.visible = false; + } + + setState(shares: MotorShares, flight: WakeFlight): void { + this.levels = downwashLevels(shares); + this.levels.forEach((level, index) => this.flow.setY(index, level)); + this.flow.needsUpdate = true; + wakeBend(flight, this.material.uniforms.uBend.value); + this.mesh.visible = Math.max(...this.levels) > DOWNWASH.level.shown; + } + + advance(deltaSeconds: number): void { + if (!this.mesh.visible) return; + this.levels.forEach((level, index) => { + const travelled = this.flow.getX(index) + deltaSeconds * flowRate(level); + this.flow.setX(index, travelled % DOWNWASH.flow.wrap); + }); + this.flow.needsUpdate = true; + } +} diff --git a/explainers/fpv-drone/src/scene/parts/effects/effects.ts b/explainers/fpv-drone/src/scene/parts/effects/effects.ts new file mode 100644 index 00000000..ed86c6cc --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/effects.ts @@ -0,0 +1,96 @@ +import { Group, Vector3 } from 'three'; +import type { Object3D } from 'three'; +import { lerp } from '@core/math'; +import { anchorAt } from '@core/scene/parts'; +import type { AssemblyState, PartId } from '../../../ids'; +import { THEME } from '../../../theme'; +import { BEAMS, LINKS } from '../../constants'; +import type { PartContext } from '../context'; +import type { DronePart } from '../drone/drone'; +import { Beam } from './beam'; +import { GroundWash } from './groundWash'; +import { TrackEffect } from './track'; +import { WindStreaks } from './windStreaks'; + +export interface EffectTargets { + drone: DronePart; + patchAntenna: Object3D; + goggles: Object3D; +} + +type EffectLabel = Extract; + +function beamLabel(beam: Beam): Object3D { + return anchorAt(beam.mesh, 0, 0, 0); +} + +function placeLabel(label: Object3D, from: Vector3, to: Vector3, share: number): void { + label.position.set(0, from.distanceTo(to) * share, 0); +} + +export class EffectsPart { + readonly object = new Group(); + readonly labels: ReadonlyMap; + private readonly targets: EffectTargets; + private readonly control: Beam; + private readonly video: Beam; + private readonly track: TrackEffect; + private readonly groundWash: GroundWash; + private readonly wind: WindStreaks; + private readonly controlLabel: Object3D; + private readonly videoLabel: Object3D; + private readonly from = new Vector3(); + private readonly to = new Vector3(); + private readonly eye = new Vector3(); + + constructor(context: PartContext, targets: EffectTargets) { + this.targets = targets; + this.control = new Beam(context, 'controlLink', BEAMS.control, THEME.controlLink); + this.video = new Beam(context, 'videoLink', BEAMS.video, THEME.videoLink); + this.track = new TrackEffect(context); + this.groundWash = new GroundWash(context); + this.wind = new WindStreaks(context); + this.controlLabel = beamLabel(this.control); + this.videoLabel = beamLabel(this.video); + this.object.add( + this.track.mesh, + this.groundWash.mesh, + this.wind.mesh, + this.control.mesh, + this.video.mesh, + ); + this.labels = new Map([ + ['controlLink', this.controlLabel], + ['videoLink', this.videoLabel], + ]); + } + + setState(state: AssemblyState): void { + const { drone, patchAntenna, goggles } = this.targets; + const linked = state.view.links && state.flight.armed; + const strength = lerp(LINKS.signalFloor, 1, state.link.signal); + this.control.mesh.visible = linked; + this.video.mesh.visible = linked; + if (linked) { + patchAntenna.getWorldPosition(this.from); + drone.receiverEnd.getWorldPosition(this.to); + this.control.span(this.from, this.to); + this.control.setOpacity(BEAMS.control.opacity * strength); + placeLabel(this.controlLabel, this.from, this.to, LINKS.labelShare.control); + drone.videoAntennaTop.getWorldPosition(this.from); + goggles.getWorldPosition(this.to); + this.video.span(this.from, this.to); + this.video.setOpacity(BEAMS.video.opacity * strength); + placeLabel(this.videoLabel, this.from, this.to, LINKS.labelShare.video); + } + this.track.setState(state.phase, state.flight, state.view.track); + this.groundWash.setState(state.flight, state.motors.shares); + this.wind.setState(state.flight, drone.cameraAnchor.getWorldPosition(this.eye)); + } + + advance(deltaSeconds: number): void { + this.control.advance(deltaSeconds); + this.video.advance(deltaSeconds); + this.groundWash.advance(deltaSeconds); + } +} diff --git a/explainers/fpv-drone/src/scene/parts/effects/groundWash.test.ts b/explainers/fpv-drone/src/scene/parts/effects/groundWash.test.ts new file mode 100644 index 00000000..1a8298dc --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/groundWash.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { DEMO_HOVER_SHARE } from '../../../model/motors'; +import { GROUND_WASH } from '../../constants'; +import { groundWashRadius, groundWashStrength } from './groundWash'; + +const HOVER = [DEMO_HOVER_SHARE, DEMO_HOVER_SHARE, DEMO_HOVER_SHARE, DEMO_HOVER_SHARE] as const; +const IDLE = HOVER.map((share) => share / 4) as unknown as typeof HOVER; + +describe('ground wash', () => { + it('blows hardest just above the ground in a hover', () => { + expect(groundWashStrength(0, HOVER)).toBeCloseTo(1); + expect(groundWashStrength(GROUND_WASH.reach.full, HOVER)).toBeCloseTo(1); + }); + + it('fades with height and is gone above its reach', () => { + const low = groundWashStrength(2, HOVER); + const high = groundWashStrength(4, HOVER); + expect(high).toBeLessThan(low); + expect(high).toBeGreaterThan(0); + expect(groundWashStrength(GROUND_WASH.reach.none, HOVER)).toBe(0); + expect(groundWashStrength(40, HOVER)).toBe(0); + }); + + it('grows with the thrust while the props spin up', () => { + expect(groundWashStrength(0, IDLE)).toBeLessThan(groundWashStrength(0, HOVER) / 2); + expect(groundWashStrength(0, [0, 0, 0, 0])).toBe(0); + }); + + it('spreads wider the higher the drone hovers', () => { + expect(groundWashRadius(0)).toBe(GROUND_WASH.radius); + expect(groundWashRadius(3)).toBeGreaterThan(groundWashRadius(1)); + }); +}); diff --git a/explainers/fpv-drone/src/scene/parts/effects/groundWash.ts b/explainers/fpv-drone/src/scene/parts/effects/groundWash.ts new file mode 100644 index 00000000..1a159ad3 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/groundWash.ts @@ -0,0 +1,121 @@ +import { Color, Mesh, PlaneGeometry, ShaderMaterial, Vector2, Vector3 } from 'three'; +import { clamp, smoothstep } from '@core/math'; +import { STRUCTURE_GROUP } from '@core/scene/materials'; +import type { FlightReading, MotorShares } from '../../../ids'; +import { GROUND_WASH } from '../../constants'; +import { registered } from '../context'; +import type { PartContext } from '../context'; + +const VERTEX = /* glsl */ ` +varying vec2 vPlane; +void main() { + vPlane = position.xz; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +const FRAGMENT = /* glsl */ ` +uniform vec3 uColour; +uniform float uOpacity; +uniform float uStrength; +uniform float uCycle; +uniform float uRings; +uniform vec3 uEnvelope; +uniform vec2 uStreaks; +varying vec2 vPlane; + +float hash(float n) { + return fract(sin(n * 12.9898) * 43758.5453); +} + +float bins(float angle, float count, float seed) { + float x = (angle / 6.2831853 + 0.5) * count; + float i = floor(x); + float f = fract(x); + float u = f * f * (3.0 - 2.0 * f); + return mix(hash(mod(i, count) + seed), hash(mod(i + 1.0, count) + seed), u); +} + +void main() { + float r = length(vPlane); + float angle = atan(vPlane.y, vPlane.x); + float envelope = smoothstep(uEnvelope.x, uEnvelope.y, r) * (1.0 - smoothstep(uEnvelope.z, 1.0, r)); + float travel = r * uRings - uCycle; + float wave = fract(travel); + float ring = smoothstep(0.0, 0.7, wave) * (1.0 - smoothstep(0.75, 0.95, wave)); + float puffs = bins(angle, uStreaks.y, floor(travel) * 3.7); + float streaks = bins(angle, uStreaks.x, 1.0); + float dust = envelope * mix(0.2, 1.0, streaks * streaks) * (0.25 + ring * puffs * 1.4); + gl_FragColor = vec4(uColour, min(dust * uStrength * uOpacity, 1.0)); + #include +} +`; + +const QUARTER_TURN = Math.PI / 2; + +export const GROUND_WASH_NAME = 'groundWash'; + +export type WashFlight = Pick; + +export function groundWashStrength(height: number, shares: MotorShares): number { + const { reach, thrust } = GROUND_WASH; + const mean = shares.reduce((sum, share) => sum + share, 0) / shares.length; + const nearness = 1 - smoothstep(height, reach.full, reach.none); + return nearness * clamp(mean / thrust.reference, 0, thrust.max); +} + +export function groundWashRadius(height: number): number { + return GROUND_WASH.radius * (1 + GROUND_WASH.spread * Math.max(height, 0)); +} + +function washMaterial(): ShaderMaterial { + const { envelope, streaks } = GROUND_WASH; + return new ShaderMaterial({ + uniforms: { + uColour: { value: new Color(GROUND_WASH.colour) }, + uOpacity: { value: GROUND_WASH.opacity }, + uStrength: { value: 0 }, + uCycle: { value: 0 }, + uRings: { value: GROUND_WASH.rings.count }, + uEnvelope: { value: new Vector3(envelope.inner[0], envelope.inner[1], envelope.outer) }, + uStreaks: { value: new Vector2(streaks.fine, streaks.coarse) }, + }, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + transparent: true, + depthWrite: false, + toneMapped: false, + }); +} + +export class GroundWash { + readonly mesh: Mesh; + private readonly material: ShaderMaterial; + + constructor(context: PartContext) { + const plane = context.tracker.track(new PlaneGeometry(2, 2)); + plane.rotateX(-QUARTER_TURN); + this.material = registered(context, STRUCTURE_GROUP, washMaterial()); + this.mesh = new Mesh(plane, this.material); + this.mesh.name = GROUND_WASH_NAME; + this.mesh.frustumCulled = false; + this.mesh.visible = false; + } + + setState(flight: WashFlight, shares: MotorShares): void { + const strength = groundWashStrength(flight.height, shares); + this.material.uniforms.uStrength.value = strength; + this.mesh.visible = strength > GROUND_WASH.shown; + if (!this.mesh.visible) return; + const [x, , z] = flight.position; + this.mesh.position.set(x, GROUND_WASH.lift, z); + this.mesh.scale.setScalar(groundWashRadius(flight.height)); + } + + advance(deltaSeconds: number): void { + if (!this.mesh.visible) return; + const { speed, wrap } = GROUND_WASH.rings; + const cycle = this.material.uniforms.uCycle.value + deltaSeconds * speed; + this.material.uniforms.uCycle.value = cycle % wrap; + } +} diff --git a/explainers/fpv-drone/src/scene/parts/effects/spinArrows.test.ts b/explainers/fpv-drone/src/scene/parts/effects/spinArrows.test.ts new file mode 100644 index 00000000..9c7942dd --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/spinArrows.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { SPIN_ARROWS } from '../../constants'; +import { arrowBrightness, motorNumber } from './spinArrows'; + +describe('spin arrows', () => { + it('numbers the motors in the Betaflight order', () => { + expect(motorNumber('motorRearRight')).toBe(1); + expect(motorNumber('motorFrontRight')).toBe(2); + expect(motorNumber('motorRearLeft')).toBe(3); + expect(motorNumber('motorFrontLeft')).toBe(4); + }); + + it('brightens the arrows of the motors that work harder', () => { + const even = arrowBrightness([0.25, 0.25, 0.25, 0.25]); + expect(new Set(even).size).toBe(1); + expect(even[0]).toBeCloseTo(SPIN_ARROWS.brightness.base); + const forward = arrowBrightness([0.3, 0.2, 0.3, 0.2]); + expect(forward[0]).toBeGreaterThan(forward[1]); + expect(forward[2]).toBeGreaterThan(forward[3]); + expect(Math.max(...forward)).toBeLessThanOrEqual(1); + expect(Math.min(...forward)).toBeGreaterThanOrEqual(SPIN_ARROWS.brightness.min); + }); +}); diff --git a/explainers/fpv-drone/src/scene/parts/effects/spinArrows.ts b/explainers/fpv-drone/src/scene/parts/effects/spinArrows.ts new file mode 100644 index 00000000..8221adda --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/spinArrows.ts @@ -0,0 +1,64 @@ +import { Group, Mesh, MeshBasicMaterial, PlaneGeometry } from 'three'; +import type { Object3D } from 'three'; +import { clamp } from '@core/math'; +import { MOTOR_PART_IDS } from '../../../ids'; +import type { MotorPartId, MotorShares } from '../../../ids'; +import { MOTOR_POSITIONS, MOTOR_SPIN } from '../../../model/layout'; +import { THEME } from '../../../theme'; +import { SPIN_ARROWS } from '../../constants'; +import { ARROW_PLANE } from '../../finishes'; +import { spinArrowTexture } from '../../geometry/arrowTexture'; +import { registered } from '../context'; +import type { PartContext } from '../context'; + +const QUARTER_TURN = Math.PI / 2; + +export function arrowBrightness(shares: MotorShares): number[] { + const { base, gain, min } = SPIN_ARROWS.brightness; + const mean = shares.reduce((sum, share) => sum + share, 0) / shares.length; + return shares.map((share) => clamp(base + (share - mean) * gain, min, 1)); +} + +export function motorNumber(id: MotorPartId): number { + return MOTOR_PART_IDS.indexOf(id) + 1; +} + +export class SpinArrowsPart { + readonly object = new Group(); + readonly label: Object3D; + private readonly materials: MeshBasicMaterial[] = []; + + constructor(context: PartContext) { + const plane = context.tracker.track(new PlaneGeometry(SPIN_ARROWS.size, SPIN_ARROWS.size)); + plane.rotateX(-QUARTER_TURN); + plane.rotateY(-QUARTER_TURN); + let frontLeft: Mesh | null = null; + for (const id of MOTOR_PART_IDS) { + const direction = MOTOR_SPIN[id]; + const colour = direction === 'clockwise' ? THEME.clockwise : THEME.counterClockwise; + const material = registered( + context, + 'spinArrows', + new MeshBasicMaterial({ + ...ARROW_PLANE, + color: colour, + map: context.tracker.track( + spinArrowTexture(SPIN_ARROWS.texture, direction, String(motorNumber(id))), + ), + }), + ); + const arrow = new Mesh(plane, material); + const [x, , z] = MOTOR_POSITIONS[id]; + arrow.position.set(x, SPIN_ARROWS.lift, z); + this.materials.push(material); + this.object.add(arrow); + if (id === 'motorFrontLeft') frontLeft = arrow; + } + this.label = frontLeft ?? this.object; + } + + setState(shown: boolean, shares: MotorShares): void { + this.object.visible = shown; + arrowBrightness(shares).forEach((level, index) => (this.materials[index].opacity = level)); + } +} diff --git a/explainers/fpv-drone/src/scene/parts/effects/track.ts b/explainers/fpv-drone/src/scene/parts/effects/track.ts new file mode 100644 index 00000000..b488155a --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/track.ts @@ -0,0 +1,132 @@ +import { + BufferAttribute, + BufferGeometry, + Color, + DoubleSide, + DynamicDrawUsage, + Mesh, + ShaderMaterial, +} from 'three'; +import { STRUCTURE_GROUP } from '@core/scene/materials'; +import type { FlightReading } from '../../../ids'; +import { THEME } from '../../../theme'; +import { TRACK } from '../../constants'; +import { TrailSamples } from '../../geometry/trail'; +import type { TrailSample } from '../../geometry/trail'; +import { registered } from '../context'; +import type { PartContext } from '../context'; + +const VERTEX = /* glsl */ ` +attribute float aAge; +attribute float aSide; +varying float vAge; +varying float vSide; +void main() { + vAge = aAge; + vSide = aSide; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +const FRAGMENT = /* glsl */ ` +uniform vec3 uColour; +uniform float uOpacity; +uniform float uFade; +uniform float uFloor; +varying float vAge; +varying float vSide; +void main() { + float trail = uFloor + (1.0 - uFloor) * exp(-vAge / uFade); + float edge = 1.0 - vSide * vSide; + gl_FragColor = vec4(uColour, uOpacity * trail * edge); + #include +} +`; + +const XYZ = 3; +const VERTICES_PER_SAMPLE = 2; +const SIDES = [-1, 1] as const; + +export class TrackEffect { + readonly mesh: Mesh; + private readonly samples = new TrailSamples(TRACK.step, TRACK.capacity, TRACK.maxGap); + private readonly geometry = new BufferGeometry(); + private readonly positions: Float32Array; + private readonly ages: Float32Array; + + constructor(context: PartContext) { + const vertices = (TRACK.capacity + 1) * VERTICES_PER_SAMPLE; + this.positions = new Float32Array(vertices * XYZ); + this.ages = new Float32Array(vertices); + const sides = new Float32Array(vertices); + const indices: number[] = []; + for (let vertex = 0; vertex < vertices; vertex += 1) sides[vertex] = SIDES[vertex % 2]; + for (let sample = 0; sample < TRACK.capacity; sample += 1) { + const a = sample * VERTICES_PER_SAMPLE; + indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3); + } + const position = new BufferAttribute(this.positions, XYZ); + position.setUsage(DynamicDrawUsage); + const age = new BufferAttribute(this.ages, 1); + age.setUsage(DynamicDrawUsage); + this.geometry.setAttribute('position', position); + this.geometry.setAttribute('aAge', age); + this.geometry.setAttribute('aSide', new BufferAttribute(sides, 1)); + this.geometry.setIndex(indices); + this.geometry.setDrawRange(0, 0); + context.tracker.track(this.geometry); + const material = registered( + context, + STRUCTURE_GROUP, + new ShaderMaterial({ + uniforms: { + uColour: { value: new Color(THEME.transit) }, + uOpacity: { value: TRACK.opacity }, + uFade: { value: TRACK.fadeSeconds }, + uFloor: { value: TRACK.floor }, + }, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + transparent: true, + depthWrite: false, + side: DoubleSide, + toneMapped: false, + }), + ); + this.mesh = new Mesh(this.geometry, material); + this.mesh.frustumCulled = false; + } + + setState(phase: number, flight: FlightReading, shown: boolean): void { + const head: TrailSample = { point: flight.position, heading: flight.heading }; + this.samples.record(phase, head); + const run = this.samples.run(phase); + const count = run.to - run.from; + this.mesh.visible = shown && count > 0; + if (!this.mesh.visible) return; + for (let slot = run.from; slot < run.to; slot += 1) { + const sample = this.samples.sampleAt(slot); + if (sample) this.writeSample(slot - run.from, sample, (run.to - slot) * TRACK.step); + } + this.writeSample(count, head, 0); + this.geometry.setDrawRange(0, count * 6); + this.geometry.getAttribute('position').needsUpdate = true; + this.geometry.getAttribute('aAge').needsUpdate = true; + } + + private writeSample(index: number, sample: TrailSample, age: number): void { + const across = [-Math.sin(sample.heading), Math.cos(sample.heading)]; + SIDES.forEach((side, offset) => { + const vertex = index * VERTICES_PER_SAMPLE + offset; + this.positions.set( + [ + sample.point[0] + (across[0] * side * TRACK.width) / 2, + sample.point[1] - TRACK.drop, + sample.point[2] + (across[1] * side * TRACK.width) / 2, + ], + vertex * XYZ, + ); + this.ages[vertex] = age; + }); + } +} diff --git a/explainers/fpv-drone/src/scene/parts/effects/windStreaks.test.ts b/explainers/fpv-drone/src/scene/parts/effects/windStreaks.test.ts new file mode 100644 index 00000000..5cfb2303 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/windStreaks.test.ts @@ -0,0 +1,35 @@ +import { Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { WIND_STREAKS } from '../../constants'; +import { streakSeeds, windFlow, windStrength } from './windStreaks'; + +describe('wind streaks', () => { + it('shows only at cruise speeds and only while armed', () => { + expect(windStrength({ speedKmh: 30, armed: true })).toBe(0); + expect(windStrength({ speedKmh: WIND_STREAKS.speedKmh.from, armed: true })).toBe(0); + expect(windStrength({ speedKmh: 70, armed: true })).toBe(1); + expect(windStrength({ speedKmh: 70, armed: false })).toBe(0); + const orbit = windStrength({ speedKmh: 54, armed: true }); + expect(orbit).toBeGreaterThan(0); + expect(orbit).toBeLessThan(0.5); + }); + + it('lines the streaks up with the flight path', () => { + const flow = windFlow( + { heading: Math.PI / 2, speedKmh: 70, verticalSpeed: 0, armed: true }, + new Vector3(), + ); + expect(flow.z).toBeCloseTo(1); + expect(flow.length()).toBeCloseTo(1); + }); + + it('scatters the streaks over the whole box the same way every time', () => { + const seeds = streakSeeds(WIND_STREAKS.count, WIND_STREAKS.seed); + expect(seeds).toEqual(streakSeeds(WIND_STREAKS.count, WIND_STREAKS.seed)); + expect(Math.min(...seeds)).toBeGreaterThanOrEqual(0); + expect(Math.max(...seeds)).toBeLessThan(1); + const heights = seeds.filter((_, index) => index % 3 === 1); + expect(heights.filter((height) => height < 0.5).length).toBeGreaterThan(heights.length / 4); + expect(heights.filter((height) => height >= 0.5).length).toBeGreaterThan(heights.length / 4); + }); +}); diff --git a/explainers/fpv-drone/src/scene/parts/effects/windStreaks.ts b/explainers/fpv-drone/src/scene/parts/effects/windStreaks.ts new file mode 100644 index 00000000..19972112 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/windStreaks.ts @@ -0,0 +1,167 @@ +import { + BufferAttribute, + BufferGeometry, + Color, + DoubleSide, + Mesh, + ShaderMaterial, + Vector2, + Vector3, +} from 'three'; +import { smoothstep } from '@core/math'; +import { STRUCTURE_GROUP } from '@core/scene/materials'; +import type { FlightReading } from '../../../ids'; +import { toMetresPerSecond } from '../../../model/scale'; +import { WIND_STREAKS } from '../../constants'; +import { hash2 } from '../../geometry/noise'; +import { registered } from '../context'; +import type { PartContext } from '../context'; + +const VERTEX = /* glsl */ ` +attribute vec2 aCorner; +uniform vec3 uBox; +uniform vec3 uFlow; +uniform float uAhead; +uniform float uLength; +uniform float uWidth; +uniform vec3 uEye; +uniform vec2 uRider; +uniform vec2 uNear; +uniform float uEdge; +varying float vFade; +varying vec2 vCorner; +void main() { + vec3 centre = cameraPosition + uFlow * uAhead; + vec3 offset = mod(position * uBox - centre, uBox) - 0.5 * uBox; + vec3 middle = centre + offset; + vec3 across = cross(uFlow, normalize(cameraPosition - middle)); + float span = length(across); + vec3 side = span > 1e-4 ? across / span : vec3(0.0, 1.0, 0.0); + vec3 world = middle + uFlow * aCorner.x * uLength + side * aCorner.y * uWidth; + vec3 reach = abs(offset) / uBox; + float inside = 1.0 - smoothstep(uEdge, 0.5, max(reach.x, max(reach.y, reach.z))); + float near = smoothstep(uNear.x, uNear.y, length(middle - cameraPosition)); + float rider = 1.0 - smoothstep(uRider.x, uRider.y, length(cameraPosition - uEye)); + vFade = inside * near * rider; + vCorner = aCorner; + gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0); +} +`; + +const FRAGMENT = /* glsl */ ` +uniform vec3 uColour; +uniform float uOpacity; +uniform float uStrength; +varying float vFade; +varying vec2 vCorner; +void main() { + float taper = 1.0 - 4.0 * vCorner.x * vCorner.x; + float core = 1.0 - vCorner.y * vCorner.y; + gl_FragColor = vec4(uColour, uOpacity * uStrength * vFade * taper * core); + #include +} +`; + +export const WIND_STREAKS_NAME = 'windStreaks'; + +const XYZ = 3; +const CORNERS = [ + [-0.5, -1], + [0.5, -1], + [-0.5, 1], + [0.5, 1], +] as const; + +export type WindFlight = Pick; + +export function windStrength(flight: Pick): number { + const { from, to } = WIND_STREAKS.speedKmh; + return flight.armed ? smoothstep(flight.speedKmh, from, to) : 0; +} + +export function streakSeeds(count: number, seed: number): Float32Array { + const seeds = new Float32Array(count * XYZ); + for (let index = 0; index < seeds.length; index += 1) { + seeds[index] = hash2(Math.floor(index / XYZ), index % XYZ, seed); + } + return seeds; +} + +export function windFlow(flight: WindFlight, target: Vector3): Vector3 { + const speed = toMetresPerSecond(flight.speedKmh); + return target + .set(Math.cos(flight.heading) * speed, flight.verticalSpeed, Math.sin(flight.heading) * speed) + .normalize(); +} + +function streakGeometry(): BufferGeometry { + const { count, seed } = WIND_STREAKS; + const seeds = streakSeeds(count, seed); + const vertexSeeds = new Float32Array(count * CORNERS.length * XYZ); + const corners = new Float32Array(count * CORNERS.length * 2); + const indices: number[] = []; + for (let streak = 0; streak < count; streak += 1) { + const first = streak * CORNERS.length; + CORNERS.forEach((corner, offset) => { + vertexSeeds.set(seeds.subarray(streak * XYZ, streak * XYZ + XYZ), (first + offset) * XYZ); + corners.set(corner, (first + offset) * 2); + }); + indices.push(first, first + 1, first + 2, first + 2, first + 1, first + 3); + } + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(vertexSeeds, XYZ)); + geometry.setAttribute('aCorner', new BufferAttribute(corners, 2)); + geometry.setIndex(indices); + return geometry; +} + +function streakMaterial(): ShaderMaterial { + const { box, rider, near } = WIND_STREAKS; + return new ShaderMaterial({ + uniforms: { + uBox: { value: new Vector3(...box) }, + uFlow: { value: new Vector3(1, 0, 0) }, + uAhead: { value: WIND_STREAKS.ahead }, + uLength: { value: 0 }, + uWidth: { value: WIND_STREAKS.width }, + uEye: { value: new Vector3() }, + uRider: { value: new Vector2(rider.full, rider.none) }, + uNear: { value: new Vector2(...near) }, + uEdge: { value: WIND_STREAKS.edge }, + uColour: { value: new Color(WIND_STREAKS.colour) }, + uOpacity: { value: WIND_STREAKS.opacity }, + uStrength: { value: 0 }, + }, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + transparent: true, + depthWrite: false, + side: DoubleSide, + toneMapped: false, + }); +} + +export class WindStreaks { + readonly mesh: Mesh; + private readonly material: ShaderMaterial; + + constructor(context: PartContext) { + this.material = registered(context, STRUCTURE_GROUP, streakMaterial()); + this.mesh = new Mesh(context.tracker.track(streakGeometry()), this.material); + this.mesh.name = WIND_STREAKS_NAME; + this.mesh.frustumCulled = false; + this.mesh.visible = false; + } + + setState(flight: WindFlight, eye: Vector3): void { + const strength = windStrength(flight); + const { uniforms } = this.material; + uniforms.uStrength.value = strength; + this.mesh.visible = strength > 0; + if (!this.mesh.visible) return; + windFlow(flight, uniforms.uFlow.value); + const { seconds, max } = WIND_STREAKS.length; + uniforms.uLength.value = Math.min(toMetresPerSecond(flight.speedKmh) * seconds, max); + uniforms.uEye.value.copy(eye); + } +} diff --git a/explainers/fpv-drone/src/scene/parts/world/crossing.test.ts b/explainers/fpv-drone/src/scene/parts/world/crossing.test.ts new file mode 100644 index 00000000..fbc800ee --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/crossing.test.ts @@ -0,0 +1,29 @@ +import { Box3 } from 'three'; +import type { BufferGeometry } from 'three'; +import { describe, expect, it } from 'vitest'; +import { CROSSING } from '../../constants'; +import { carGeometry } from './crossing'; + +function bounds(geometry: BufferGeometry): Box3 { + geometry.computeBoundingBox(); + return geometry.boundingBox ?? new Box3(); +} + +describe('wrecked pickup', () => { + it('stands on four wheels that show below and beside the body', () => { + const { body, wheels } = carGeometry(); + const shell = bounds(body); + const tyres = bounds(wheels); + expect(tyres.min.y).toBeCloseTo(0); + expect(tyres.min.y).toBeLessThan(shell.min.y - 0.3); + expect(tyres.max.z).toBeGreaterThan(CROSSING.car.sill.halfWidth); + expect(wheels.getAttribute('position').count).toBeGreaterThan(4 * CROSSING.car.wheel.segments); + }); + + it('rises from the bed to the bonnet to the cabin roof', () => { + const { bed, bonnet, cabin } = CROSSING.car; + expect(cabin.top).toBeGreaterThan(bed.top); + expect(bed.top).toBeGreaterThan(bonnet.top); + expect(bounds(carGeometry().body).max.y).toBeCloseTo(cabin.top); + }); +}); diff --git a/explainers/fpv-drone/src/scene/parts/world/crossing.ts b/explainers/fpv-drone/src/scene/parts/world/crossing.ts new file mode 100644 index 00000000..903bbcf3 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/crossing.ts @@ -0,0 +1,176 @@ +import { CylinderGeometry, Group, Shape } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { box } from '@core/scene/geometry/box'; +import { extrudeProfileAlongX } from '@core/scene/geometry/extrude'; +import { anchorAt } from '@core/scene/parts'; +import { CROSSING } from '../../constants'; +import { WORLD_FINISHES } from '../../finishes'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +type Triple = readonly [number, number, number]; +type Extent = readonly [number, number]; + +const QUARTER_TURN = Math.PI / 2; +const ENDS = [-1, 1] as const; +const ROOF_SEAT = 0.05; + +function centredBox(at: Triple, size: Triple): BufferGeometry { + return box({ + minX: at[0] - size[0] / 2, + maxX: at[0] + size[0] / 2, + minY: at[1], + maxY: at[1] + size[1], + minZ: at[2] - size[2] / 2, + maxZ: at[2] + size[2] / 2, + }); +} + +interface ShedGeometry { + walls: BufferGeometry; + roof: BufferGeometry; + door: BufferGeometry; + trim: BufferGeometry; + glass: BufferGeometry; +} + +function shedGeometry(): ShedGeometry { + const { size, roof, door, window } = CROSSING.shed; + const [length, wall, depth] = size; + const gable = new Shape(); + const half = depth / 2 + roof.overhang; + gable.moveTo(-half, wall - ROOF_SEAT); + gable.lineTo(half, wall - ROOF_SEAT); + gable.lineTo(0, wall + roof.rise); + gable.closePath(); + const doorX = length * door.shift; + const front = depth / 2; + return { + walls: centredBox([0, 0, 0], size), + roof: extrudeProfileAlongX(gable, -length / 2 - roof.overhang, length / 2 + roof.overhang), + door: centredBox([doorX, 0, front + door.depth / 2], [door.width, door.height, door.depth]), + trim: centredBox( + [doorX, 0, front + door.depth / 4], + [door.width + door.frame * 2, door.height + door.frame, door.depth / 2], + ), + glass: centredBox( + [-length / 2 - window.depth / 2, window.sill, 0], + [window.depth, window.height, window.width], + ), + }; +} + +function span(extent: Extent, y: Extent, halfWidth: number): BufferGeometry { + return box({ + minX: extent[0], + maxX: extent[1], + minY: y[0], + maxY: y[1], + minZ: -halfWidth, + maxZ: halfWidth, + }); +} + +function bodyGeometry(): BufferGeometry { + const { sill, bonnet, cabin, bed, bumper } = CROSSING.car; + const floor = sill.y[1]; + const sideZ = sill.halfWidth - bed.wall / 2; + const sides = ENDS.map((side) => + box({ + minX: bed.x[0], + maxX: bed.x[1], + minY: floor, + maxY: bed.top, + minZ: side * sideZ - bed.wall / 2, + maxZ: side * sideZ + bed.wall / 2, + }), + ); + return mergeParts([ + span(sill.x, sill.y, sill.halfWidth), + span(bonnet.x, [floor, bonnet.top], bonnet.halfWidth), + span(cabin.x, [floor, cabin.top], cabin.halfWidth), + ...sides, + span([bed.x[0], bed.x[0] + bed.wall], [floor, bed.top], sill.halfWidth), + span([sill.x[1], sill.x[1] + bumper.depth], bumper.y, bumper.halfWidth), + span([sill.x[0] - bumper.depth, sill.x[0]], bumper.y, bumper.halfWidth), + ]); +} + +function glassGeometry(): BufferGeometry { + const { cabin, glass } = CROSSING.car; + const y: Extent = [glass.bottom, glass.top]; + const windscreen = box({ + minX: cabin.x[1], + maxX: cabin.x[1] + glass.thickness, + minY: y[0], + maxY: y[1], + minZ: -cabin.halfWidth + glass.inset, + maxZ: cabin.halfWidth - glass.inset, + }); + const windows = ENDS.map((side) => + box({ + minX: cabin.x[0] + glass.inset, + maxX: cabin.x[1] - glass.inset, + minY: y[0], + maxY: y[1], + minZ: side * cabin.halfWidth - glass.thickness / 2, + maxZ: side * cabin.halfWidth + glass.thickness / 2, + }), + ); + return mergeParts([windscreen, ...windows]); +} + +export function wheelsGeometry(): BufferGeometry { + const { wheel } = CROSSING.car; + return mergeParts( + ENDS.flatMap((end) => + ENDS.map((side) => { + const tyre = new CylinderGeometry(wheel.radius, wheel.radius, wheel.width, wheel.segments); + tyre.rotateX(QUARTER_TURN); + tyre.translate(end * wheel.axle, wheel.radius, side * wheel.track); + return tyre; + }), + ), + ); +} + +export function carGeometry(): { + body: BufferGeometry; + glass: BufferGeometry; + wheels: BufferGeometry; +} { + return { body: bodyGeometry(), glass: glassGeometry(), wheels: wheelsGeometry() }; +} + +export interface CrossingPart { + object: Group; + anchor: Object3D; +} + +export function createCrossing(context: PartContext): CrossingPart { + const { centre, shed, car, anchorLift } = CROSSING; + const object = new Group(); + object.position.set(...centre); + const shedParts = shedGeometry(); + const shedGroup = new Group(); + shedGroup.position.set(...shed.at); + shedGroup.rotation.y = shed.heading; + shedGroup.add( + partMesh(context, shedParts.walls, 'crossroads', WORLD_FINISHES.shedWall), + partMesh(context, shedParts.roof, 'crossroads', WORLD_FINISHES.shedRoof), + partMesh(context, shedParts.door, 'crossroads', WORLD_FINISHES.shedDoor), + partMesh(context, shedParts.trim, 'crossroads', WORLD_FINISHES.shedTrim), + partMesh(context, shedParts.glass, 'crossroads', WORLD_FINISHES.carGlass), + ); + const carParts = carGeometry(); + const carGroup = new Group(); + carGroup.position.set(...car.at); + carGroup.rotation.set(0, car.heading, car.sag); + carGroup.add( + partMesh(context, carParts.body, 'crossroads', WORLD_FINISHES.carRust), + partMesh(context, carParts.glass, 'crossroads', WORLD_FINISHES.carGlass), + partMesh(context, carParts.wheels, 'crossroads', WORLD_FINISHES.tyre), + ); + object.add(shedGroup, carGroup); + return { object, anchor: anchorAt(object, 0, anchorLift, 0) }; +} diff --git a/explainers/fpv-drone/src/scene/parts/world/field.ts b/explainers/fpv-drone/src/scene/parts/world/field.ts new file mode 100644 index 00000000..0ae6442b --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/field.ts @@ -0,0 +1,125 @@ +import { BufferAttribute, BufferGeometry, Color, Group, PlaneGeometry } from 'three'; +import type { BufferGeometry as Geometry } from 'three'; +import { smoothstep } from '@core/math'; +import { STRUCTURE_GROUP, UNDIMMED_GROUP } from '@core/scene/materials'; +import { FIELD, ROADS } from '../../constants'; +import { PAINT, WORLD_FINISHES, fieldFinish, roadFinish } from '../../finishes'; +import { fractalNoise } from '../../geometry/noise'; +import { fieldTexture, roadTexture } from '../../geometry/surfaceMaps'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +const XYZ = 3; +const RGB = 3; +const UV = 2; +const NOISE_OCTAVES = 4; +const QUARTER_TURN = Math.PI / 2; +const GRASS = new Color(PAINT.grass); +const STRAW = new Color(PAINT.straw); +const EARTH = new Color(PAINT.earth); + +interface NoiseLayer { + scale: number; + from: number; + to: number; + seed: number; +} + +function noiseShare(x: number, z: number, layer: NoiseLayer): number { + const level = fractalNoise(x / layer.scale, z / layer.scale, NOISE_OCTAVES, layer.seed); + return smoothstep(level, layer.from, layer.to); +} + +export function mownBand(z: number): number { + return Math.sin((Math.PI * z) / FIELD.mown.width); +} + +export function fieldColour(x: number, z: number, target: Color): Color { + const { patches, tufts, tracks, bare, mown } = FIELD; + target.copy(GRASS).lerp(STRAW, noiseShare(x, z, patches)); + target.lerp(STRAW, noiseShare(x, z, tufts) * tufts.share); + target.lerp(EARTH, noiseShare(x, z, tracks) * tracks.share); + target.lerp(EARTH, noiseShare(x, z, bare) * bare.share); + return target.multiplyScalar(1 + mown.depth * mownBand(z)); +} + +export function fieldGeometry(): BufferGeometry { + const { extent, cell, texture } = FIELD; + const columns = Math.ceil((extent.x[1] - extent.x[0]) / cell) + 1; + const rows = Math.ceil((extent.z[1] - extent.z[0]) / cell) + 1; + const count = columns * rows; + const positions = new Float32Array(count * XYZ); + const colours = new Float32Array(count * RGB); + const uvs = new Float32Array(count * UV); + const colour = new Color(); + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const index = row * columns + column; + const x = Math.min(extent.x[0] + column * cell, extent.x[1]); + const z = Math.min(extent.z[0] + row * cell, extent.z[1]); + positions.set([x, 0, z], index * XYZ); + fieldColour(x, z, colour).toArray(colours, index * RGB); + uvs.set([x / texture.metres, z / texture.metres], index * UV); + } + } + const indices: number[] = []; + for (let row = 0; row < rows - 1; row += 1) { + for (let column = 0; column < columns - 1; column += 1) { + const a = row * columns + column; + const b = a + columns; + indices.push(a, b, a + 1, a + 1, b, b + 1); + } + } + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, XYZ)); + geometry.setAttribute('color', new BufferAttribute(colours, RGB)); + geometry.setAttribute('uv', new BufferAttribute(uvs, UV)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + return geometry; +} + +function outerGeometry(): Geometry { + const plane = new PlaneGeometry(FIELD.outer, FIELD.outer); + plane.rotateX(-QUARTER_TURN); + plane.translate(0, -FIELD.outerSink, 0); + return plane; +} + +function roadStrip(length: number, alongX: boolean, at: readonly [number, number]): Geometry { + const width = ROADS.halfWidth * 2; + const plane = new PlaneGeometry(alongX ? length : width, alongX ? width : length); + const uv = plane.getAttribute('uv'); + for (let index = 0; index < uv.count; index += 1) { + const u = uv.getX(index); + const v = uv.getY(index); + const alongShare = alongX ? u : v; + const acrossShare = alongX ? v : u; + uv.setXY(index, acrossShare, (alongShare * length) / ROADS.texture.metres); + } + plane.rotateX(-QUARTER_TURN); + plane.translate(at[0], ROADS.lift, at[1]); + return plane; +} + +export function roadsGeometry(): Geometry { + const { along, across } = ROADS; + const alongLength = along.x[1] - along.x[0]; + const acrossLength = across.z[1] - across.z[0]; + return mergeParts([ + roadStrip(alongLength, true, [(along.x[0] + along.x[1]) / 2, along.z]), + roadStrip(acrossLength, false, [across.x, (across.z[0] + across.z[1]) / 2]), + ]); +} + +export function createField(context: PartContext): Group { + const field = new Group(); + const grass = context.tracker.track(fieldTexture(FIELD.texture)); + const dirt = context.tracker.track(roadTexture(ROADS.texture)); + const mown = partMesh(context, fieldGeometry(), UNDIMMED_GROUP, fieldFinish(grass)); + mown.frustumCulled = false; + const outer = partMesh(context, outerGeometry(), UNDIMMED_GROUP, WORLD_FINISHES.outerField); + outer.frustumCulled = false; + field.add(mown, outer, partMesh(context, roadsGeometry(), STRUCTURE_GROUP, roadFinish(dirt))); + return field; +} diff --git a/explainers/fpv-drone/src/scene/parts/world/pad.ts b/explainers/fpv-drone/src/scene/parts/world/pad.ts new file mode 100644 index 00000000..9d8eebeb --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/pad.ts @@ -0,0 +1,57 @@ +import { Group, PlaneGeometry } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { box } from '@core/scene/geometry/box'; +import { anchorAt } from '@core/scene/parts'; +import { LAUNCH_PAD } from '../../constants'; +import { WORLD_FINISHES } from '../../finishes'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +const QUARTER_TURN = Math.PI / 2; +const SIDES = [1, -1] as const; + +function plateGeometry(): BufferGeometry { + const { at, size, thickness } = LAUNCH_PAD; + return box({ + minX: at[0] - size / 2, + maxX: at[0] + size / 2, + minY: 0, + maxY: thickness, + minZ: at[2] - size / 2, + maxZ: at[2] + size / 2, + }); +} + +function borderGeometry(): BufferGeometry { + const { at, size, thickness, border, markLift } = LAUNCH_PAD; + const y = thickness + markLift; + const inner = size - border * 2; + const strips = SIDES.flatMap((side) => { + const alongX = new PlaneGeometry(size, border); + alongX.rotateX(-QUARTER_TURN); + alongX.translate(at[0], y, at[2] + (side * (inner + border)) / 2); + const alongZ = new PlaneGeometry(border, size); + alongZ.rotateX(-QUARTER_TURN); + alongZ.translate(at[0] + (side * (inner + border)) / 2, y, at[2]); + return [alongX, alongZ]; + }); + return mergeParts(strips); +} + +export interface PadPart { + object: Group; + label: Object3D; +} + +export function createLaunchPad(context: PartContext): PadPart { + const object = new Group(); + object.add( + partMesh(context, plateGeometry(), 'launchPad', WORLD_FINISHES.pad), + partMesh(context, borderGeometry(), 'launchPad', WORLD_FINISHES.padMark), + ); + const { at, size, thickness, labelInset } = LAUNCH_PAD; + return { + object, + label: anchorAt(object, at[0], thickness, at[2] - size / 2 + labelInset), + }; +} diff --git a/explainers/fpv-drone/src/scene/parts/world/shrubs.ts b/explainers/fpv-drone/src/scene/parts/world/shrubs.ts new file mode 100644 index 00000000..76cde09d --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/shrubs.ts @@ -0,0 +1,61 @@ +import { Color, NormalBlending } from 'three'; +import type { Points } from 'three'; +import { lerp } from '@core/math'; +import { UNDIMMED_GROUP } from '@core/scene/materials'; +import { PointCloud, createPointMaterial } from '@core/scene/pointCloud'; +import { PAD, STATION } from '../../../model/layout'; +import { ROADS, SHRUBS } from '../../constants'; +import { hash2 } from '../../geometry/noise'; +import { registered } from '../context'; +import type { PartContext } from '../context'; + +export interface Shrub { + x: number; + z: number; + tone: number; +} + +const CANDIDATES_PER_SHRUB = 3; +const SEEDS = { x: 0, z: 1, tone: 2 } as const; + +function nearStation(x: number, z: number): boolean { + const keep = SHRUBS.keepOut.station; + const minX = Math.min(STATION[0], PAD[0]) - keep; + const maxX = Math.max(STATION[0], PAD[0]) + keep; + return x > minX && x < maxX && Math.abs(z) < keep; +} + +function onRoad(x: number, z: number): boolean { + const keep = ROADS.halfWidth + SHRUBS.keepOut.road; + return Math.abs(z - ROADS.along.z) < keep || Math.abs(x - ROADS.across.x) < keep; +} + +export function scatterShrubs(): Shrub[] { + const { count, seed, area } = SHRUBS; + const shrubs: Shrub[] = []; + for (let index = 0; index < count * CANDIDATES_PER_SHRUB && shrubs.length < count; index += 1) { + const x = lerp(area.x[0], area.x[1], hash2(index, seed + SEEDS.x)); + const z = lerp(area.z[0], area.z[1], hash2(index, seed + SEEDS.z)); + if (nearStation(x, z) || onRoad(x, z)) continue; + shrubs.push({ x, z, tone: hash2(index, seed + SEEDS.tone) }); + } + return shrubs; +} + +export function createShrubs(context: PartContext): Points { + const shrubs = scatterShrubs(); + const material = registered( + context, + UNDIMMED_GROUP, + createPointMaterial(context.textures.dot, SHRUBS.size, NormalBlending), + ); + const cloud = context.tracker.track(new PointCloud(shrubs.length, material)); + const palette = SHRUBS.colours.map((colour) => new Color(colour)); + shrubs.forEach((shrub, index) => { + const tint = palette[Math.floor(shrub.tone * palette.length)]; + cloud.setPoint(index, shrub.x, SHRUBS.lift, shrub.z); + cloud.setColor(index, tint.r, tint.g, tint.b, SHRUBS.opacity); + }); + cloud.commit(); + return cloud.points; +} diff --git a/explainers/fpv-drone/src/scene/parts/world/sky.ts b/explainers/fpv-drone/src/scene/parts/world/sky.ts new file mode 100644 index 00000000..5289c96e --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/sky.ts @@ -0,0 +1,131 @@ +import { + BackSide, + Color, + Mesh, + ShaderMaterial, + SphereGeometry, + Vector2, + Vector3, + Vector4, +} from 'three'; +import type { Camera } from 'three'; +import { UNDIMMED_GROUP } from '@core/scene/materials'; +import { THEME } from '../../../theme'; +import { SKY } from '../../constants'; +import { FPV_LIGHT } from '../../lighting'; +import { registered } from '../context'; +import type { PartContext } from '../context'; + +const VERTEX = /* glsl */ ` +varying vec3 vDirection; +void main() { + vDirection = position; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +const FRAGMENT = /* glsl */ ` +uniform vec3 uTop; +uniform vec3 uHorizon; +uniform vec2 uGradient; +uniform vec4 uCloud; +uniform vec3 uCloudBand; +uniform vec2 uShading; +uniform vec3 uLit; +uniform vec3 uShade; +uniform vec3 uSun; +varying vec3 vDirection; + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +float fbm(vec2 p) { + float total = 0.0; + float weight = 0.5; + for (int octave = 0; octave < 5; octave++) { + total += weight * noise(p); + p *= 2.07; + weight *= 0.5; + } + return total; +} + +vec3 clouds(vec3 sky, float up, vec3 direction) { + float band = smoothstep(uCloudBand.x, uCloudBand.x + 0.08, up) + * (1.0 - smoothstep(uCloudBand.y * 0.7, uCloudBand.y, up)); + if (band <= 0.0) return sky; + vec2 plane = direction.xz / (up + uCloud.w) * uCloud.x + vec2(2.3, 7.1); + float shape = fbm(plane); + float cover = smoothstep(uCloud.y, uCloud.y + uCloud.z, shape); + if (cover <= 0.0) return sky; + float towardSun = fbm(plane + normalize(uSun.xz) * uShading.x); + float lit = clamp(0.5 + (shape - towardSun) * uShading.y, 0.0, 1.0); + vec3 cloud = mix(uShade, uLit, mix(lit, 1.0, 1.0 - cover)); + return mix(sky, cloud, cover * band * uCloudBand.z); +} + +void main() { + vec3 direction = normalize(vDirection); + float up = max(direction.y, 0.0); + vec3 sky = mix(uHorizon, uTop, 1.0 - exp(-up * uGradient.x)); + sky = clouds(sky, up, direction); + sky = mix(uHorizon, sky, smoothstep(0.0, uGradient.y, up)); + vec3 colour = mix(uHorizon, sky, smoothstep(-0.01, 0.0, direction.y)); + gl_FragColor = vec4(colour, 1.0); + #include +} +`; + +export function skyUniforms() { + const { cloud, rise, haze } = SKY; + return { + uTop: { value: new Color(THEME.skyTop) }, + uHorizon: { value: new Color(THEME.skyHorizon) }, + uGradient: { value: new Vector2(rise, haze) }, + uCloud: { value: new Vector4(cloud.scale, cloud.cover, cloud.softness, cloud.lift) }, + uCloudBand: { value: new Vector3(cloud.band.from, cloud.band.to, cloud.strength) }, + uShading: { value: new Vector2(cloud.shading.reach, cloud.shading.contrast) }, + uLit: { value: new Color(cloud.lit) }, + uShade: { value: new Color(cloud.shade) }, + uSun: { value: new Vector3(...FPV_LIGHT.key.position).normalize() }, + }; +} + +export function createSky(context: PartContext): Mesh { + const material = registered( + context, + UNDIMMED_GROUP, + new ShaderMaterial({ + uniforms: skyUniforms(), + 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/fpv-drone/src/scene/parts/world/station.ts b/explainers/fpv-drone/src/scene/parts/world/station.ts new file mode 100644 index 00000000..6434be99 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/station.ts @@ -0,0 +1,203 @@ +import { CylinderGeometry, Group } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { box } from '@core/scene/geometry/box'; +import { anchorAt } from '@core/scene/parts'; +import { STATION } from '../../../model/layout'; +import { STATION_SET } from '../../constants'; +import { WORLD_FINISHES } from '../../finishes'; +import { hash2 } from '../../geometry/noise'; +import { pillowGeometry } from '../../geometry/pillow'; +import { rod } from '../../geometry/rods'; +import type { Vec3 } from '../../geometry/rods'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +type Triple = readonly [number, number, number]; + +const QUARTER_TURN = Math.PI / 2; +const THIRDS = [0, 1, 2] as const; +const SIDES = [1, -1] as const; + +function centredBox(at: Triple, size: Triple): BufferGeometry { + return box({ + minX: at[0] - size[0] / 2, + maxX: at[0] + size[0] / 2, + minY: at[1], + maxY: at[1] + size[1], + minZ: at[2] - size[2] / 2, + maxZ: at[2] + size[2] / 2, + }); +} + +function logRow(from: Vec3, alongX: boolean, length: number, row: number): BufferGeometry { + const { log } = STATION_SET.dugout; + const seed = alongX ? 1 : 2; + const jitter = (hash2(row, seed) - 0.5) * log.jitter * 2; + const trunk = new CylinderGeometry(log.radius, log.radius * 0.92, length, log.segments); + if (alongX) trunk.rotateZ(QUARTER_TURN); + else trunk.rotateX(QUARTER_TURN); + const y = log.radius + row * log.radius * 1.9; + trunk.translate( + from[0] + (alongX ? length / 2 : jitter), + y, + from[2] + (alongX ? jitter : length / 2), + ); + return trunk; +} + +function sandbagsOn(from: Vec3, alongX: boolean, length: number, top: number): BufferGeometry[] { + const { sandbags } = STATION_SET.dugout; + const seed = alongX ? 7 : 8; + const bags: BufferGeometry[] = []; + for (let row = 0; row < sandbags.rows; row += 1) { + const shift = row % 2 === 0 ? 0 : sandbags.spacing / 2; + const count = Math.floor((length - shift) / sandbags.spacing); + for (let index = 0; index < count; index += 1) { + const key = row * 100 + index; + const bag = pillowGeometry(sandbags); + bag.rotateY((hash2(key, seed) - 0.5) * sandbags.turn + (alongX ? 0 : QUARTER_TURN)); + const along = shift + (index + 0.5) * sandbags.spacing; + const wobble = (hash2(key, seed + 1) - 0.5) * sandbags.wobble * 2; + bag.translate( + from[0] + (alongX ? along : wobble), + top + row * sandbags.rowRise + sandbags.size[1] / 2, + from[2] + (alongX ? wobble : along), + ); + bags.push(bag); + } + } + return bags; +} + +function dugoutGeometry(): { logs: BufferGeometry; bags: BufferGeometry } { + const { corner, alongX, alongZ, log } = STATION_SET.dugout; + const logs: BufferGeometry[] = []; + for (let row = 0; row < log.rows; row += 1) { + logs.push(logRow(corner, true, alongX, row), logRow(corner, false, alongZ, row)); + } + const top = log.rows * log.radius * 1.9; + const bags = [ + ...sandbagsOn(corner, true, alongX, top), + ...sandbagsOn(corner, false, alongZ, top), + ]; + return { logs: mergeParts(logs), bags: mergeParts(bags) }; +} + +function tripodGeometry(): { legs: BufferGeometry; patch: BufferGeometry } { + const { at, apex, spread, legRadius, mast, patch, segments } = STATION_SET.tripod; + const top: Vec3 = [at[0], apex, at[2]]; + const legs = THIRDS.map((third) => { + const angle = (third / THIRDS.length) * Math.PI * 2; + const foot: Vec3 = [at[0] + Math.cos(angle) * spread, 0, at[2] + Math.sin(angle) * spread]; + return rod(foot, top, legRadius, segments, legRadius * 0.7); + }); + const pole = new CylinderGeometry(mast.radius, mast.radius, mast.height, segments); + pole.translate(at[0], apex + mast.height / 2, at[2]); + const panel = centredBox([0, -patch.size[1] / 2, 0], patch.size); + panel.rotateZ(patch.tilt); + panel.translate(at[0], apex + mast.height, at[2]); + return { legs: mergeParts([...legs, pole]), patch: panel }; +} + +function hardCaseGeometry(): { + shell: BufferGeometry; + goggles: BufferGeometry; + lenses: BufferGeometry; + radio: BufferGeometry; +} { + const { at, size, lid, goggles, radio } = STATION_SET.hardCase; + const top = size[1]; + const shell = mergeParts([ + centredBox(at, [size[0], size[1] - lid, size[2]]), + centredBox([at[0], at[1] + size[1] - lid, at[2]], [size[0] + 0.02, lid, size[2] + 0.02]), + ]); + const gogglesAt: Triple = [at[0] + goggles.offset[0], top, at[2] + goggles.offset[2]]; + const visor = centredBox(gogglesAt, goggles.size); + const lenses = mergeParts( + SIDES.map((side) => { + const lens = new CylinderGeometry( + goggles.lens.radius, + goggles.lens.radius, + goggles.lens.depth, + goggles.lens.segments, + ); + lens.rotateZ(QUARTER_TURN); + lens.translate( + gogglesAt[0] + goggles.size[0] / 2, + top + goggles.size[1] / 2, + gogglesAt[2] + (side * goggles.lens.spacing) / 2, + ); + return lens; + }), + ); + const radioAt: Triple = [at[0] + radio.offset[0], top, at[2] + radio.offset[2]]; + const sticks = SIDES.map((side) => { + const stick = new CylinderGeometry( + radio.stick.radius, + radio.stick.radius, + radio.stick.height, + 6, + ); + stick.translate( + radioAt[0], + top + radio.size[1] + radio.stick.height / 2, + radioAt[2] + (side * radio.stick.spacing) / 2, + ); + return stick; + }); + const aerial = new CylinderGeometry( + radio.antenna.radius, + radio.antenna.radius, + radio.antenna.height, + 6, + ); + aerial.translate( + radioAt[0] - radio.size[0] / 2, + top + radio.size[1] + radio.antenna.height / 2, + radioAt[2], + ); + return { + shell, + goggles: visor, + lenses, + radio: mergeParts([centredBox(radioAt, radio.size), ...sticks, aerial]), + }; +} + +export interface StationPart { + object: Group; + label: Object3D; + patchAntenna: Object3D; + goggles: Object3D; +} + +export function createStation(context: PartContext): StationPart { + const object = new Group(); + object.position.set(...STATION); + const dugout = dugoutGeometry(); + const tripod = tripodGeometry(); + const hardCase = hardCaseGeometry(); + object.add( + partMesh(context, dugout.logs, 'groundStation', WORLD_FINISHES.log), + partMesh(context, dugout.bags, 'groundStation', WORLD_FINISHES.sandbag), + partMesh(context, tripod.legs, 'groundStation', WORLD_FINISHES.tripod), + partMesh(context, tripod.patch, 'groundStation', WORLD_FINISHES.patch), + partMesh(context, hardCase.shell, 'groundStation', WORLD_FINISHES.hardCase), + partMesh(context, hardCase.goggles, 'groundStation', WORLD_FINISHES.goggles), + partMesh(context, hardCase.lenses, 'groundStation', WORLD_FINISHES.gogglesLens), + partMesh(context, hardCase.radio, 'groundStation', WORLD_FINISHES.radio), + ); + const { tripod: tripodSpec, hardCase: caseSpec, labelLift } = STATION_SET; + const patchTop = tripodSpec.apex + tripodSpec.mast.height; + const gogglesAt: Triple = [ + caseSpec.at[0] + caseSpec.goggles.offset[0], + caseSpec.size[1] + caseSpec.goggles.size[1] / 2, + caseSpec.at[2] + caseSpec.goggles.offset[2], + ]; + return { + object, + label: anchorAt(object, tripodSpec.at[0], labelLift, tripodSpec.at[2]), + patchAntenna: anchorAt(object, tripodSpec.at[0], patchTop, tripodSpec.at[2]), + goggles: anchorAt(object, ...gogglesAt), + }; +} diff --git a/explainers/fpv-drone/src/scene/parts/world/trees.ts b/explainers/fpv-drone/src/scene/parts/world/trees.ts new file mode 100644 index 00000000..528fe88b --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/trees.ts @@ -0,0 +1,166 @@ +import { BufferAttribute, Color, ConeGeometry, CylinderGeometry, Group } from 'three'; +import type { BufferGeometry } from 'three'; +import { lerp } from '@core/math'; +import { UNDIMMED_GROUP } from '@core/scene/materials'; +import { TREES } from '../../constants'; +import { WORLD_FINISHES } from '../../finishes'; +import { hash2 } from '../../geometry/noise'; +import { along, rod } from '../../geometry/rods'; +import type { Vec3 } from '../../geometry/rods'; +import { mergeParts, partMesh } from '../context'; +import type { PartContext } from '../context'; + +export interface TreeSpot { + x: number; + z: number; + height: number; + poplar: boolean; + turn: number; + girth: number; + tone: number; +} + +const SEEDS = { jitterX: 1, jitterZ: 2, kind: 3, height: 4, turn: 5, girth: 6, tone: 7 } as const; +const RGB = 3; +const ROW_SEED_STEP = 100; +const TRUNK_SINK = 1.4; +const TWIG_SIDES = [1, -1] as const; +const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); +const LIMB_SHORTENING = 0.5; +const LIMB_TAPER = 0.35; +const TWIG_TAPER = 0.4; +const TRUNK_TOP_SHARE = 0.7; +const GREENS = TREES.conifer.greens.map((colour) => new Color(colour)); + +export function treeSpots(): TreeSpot[] { + const spots: TreeSpot[] = []; + TREES.rows.forEach((row, rowIndex) => { + const [from, to] = TREES.line.x; + const seed = TREES.seed + rowIndex * ROW_SEED_STEP; + for (let x = from + row.offset, index = 0; x <= to; x += row.spacing, index += 1) { + const random = (key: number) => hash2(index, seed + key); + const poplar = random(SEEDS.kind) < TREES.poplarShare; + const range = poplar ? TREES.poplar.height : TREES.conifer.height; + spots.push({ + x: x + (random(SEEDS.jitterX) - 0.5) * row.jitter * 2, + z: TREES.line.z + row.z + (random(SEEDS.jitterZ) - 0.5) * row.jitter * 2, + height: lerp(range[0], range[1], random(SEEDS.height)), + poplar, + turn: random(SEEDS.turn) * Math.PI * 2, + girth: random(SEEDS.girth), + tone: random(SEEDS.tone), + }); + } + }); + return spots; +} + +export function coniferGreen(tone: number): Color { + return GREENS[Math.min(GREENS.length - 1, Math.floor(tone * GREENS.length))]; +} + +function paintCrown(geometry: BufferGeometry, spot: TreeSpot): BufferGeometry { + const { shade } = TREES.conifer; + const position = geometry.getAttribute('position'); + const colours = new Float32Array(position.count * RGB); + const green = coniferGreen(spot.tone); + const tint = new Color(); + for (let index = 0; index < position.count; index += 1) { + const share = position.getY(index) / spot.height; + tint.copy(green).multiplyScalar(lerp(shade.base, shade.tip, share)); + tint.toArray(colours, index * RGB); + } + geometry.setAttribute('color', new BufferAttribute(colours, RGB)); + return geometry; +} + +function coniferGeometry(spot: TreeSpot): { crown: BufferGeometry; trunk: BufferGeometry } { + const { tiers, radius, overlap, segments, trunk } = TREES.conifer; + const trunkHeight = spot.height * trunk.share; + const crownHeight = spot.height - trunkHeight; + const tierHeight = crownHeight / (tiers - (tiers - 1) * overlap); + const width = spot.height * lerp(radius[0], radius[1], spot.girth); + const crowns: BufferGeometry[] = []; + for (let tier = 0; tier < tiers; tier += 1) { + const share = 1 - tier / tiers; + const base = trunkHeight + tier * tierHeight * (1 - overlap); + const cone = new ConeGeometry(width * share, tierHeight, segments); + cone.translate(0, base + tierHeight / 2, 0); + cone.rotateY(spot.turn); + crowns.push(cone); + } + const crown = paintCrown(mergeParts(crowns), spot); + crown.translate(spot.x, 0, spot.z); + const stem = new CylinderGeometry( + trunk.radius * TRUNK_TOP_SHARE, + trunk.radius, + trunkHeight * TRUNK_SINK, + 6, + ); + stem.translate(spot.x, (trunkHeight * TRUNK_SINK) / 2, spot.z); + return { crown, trunk: stem }; +} + +function limbDirection(angle: number, spread: number): Vec3 { + return [Math.cos(angle) * Math.sin(spread), Math.cos(spread), Math.sin(angle) * Math.sin(spread)]; +} + +export function poplarGeometry(spot: TreeSpot): BufferGeometry { + const { trunk, branches, twigs } = TREES.poplar; + const stem = new CylinderGeometry(trunk.radius[0], trunk.radius[1], spot.height, trunk.segments); + stem.translate(spot.x, spot.height / 2, spot.z); + const limbs: BufferGeometry[] = []; + for (let index = 0; index < branches.count; index += 1) { + const share = branches.from + ((1 - branches.from) * index) / branches.count; + const angle = spot.turn + index * GOLDEN_ANGLE; + const spread = lerp(branches.spread[0], branches.spread[1], hash2(index, spot.turn)); + const base: Vec3 = [spot.x, spot.height * share, spot.z]; + const length = spot.height * branches.length * (1 - share * LIMB_SHORTENING); + const direction = limbDirection(angle, spread); + limbs.push( + rod( + base, + along(base, direction, length), + branches.radius, + branches.segments, + branches.radius * LIMB_TAPER, + ), + ); + const fork = along(base, direction, length * twigs.at); + for (const side of TWIG_SIDES.slice(0, twigs.perBranch)) { + const twig = limbDirection(angle + side * twigs.fork, spread + twigs.fork / 2); + limbs.push( + rod( + fork, + along(fork, twig, length * twigs.length), + twigs.radius, + branches.segments - 1, + twigs.radius * TWIG_TAPER, + ), + ); + } + } + return mergeParts([stem, ...limbs]); +} + +export function createTreeline(context: PartContext): Group { + const crowns: BufferGeometry[] = []; + const trunks: BufferGeometry[] = []; + const poplars: BufferGeometry[] = []; + for (const spot of treeSpots()) { + if (spot.poplar) { + poplars.push(poplarGeometry(spot)); + continue; + } + const conifer = coniferGeometry(spot); + crowns.push(conifer.crown); + trunks.push(conifer.trunk); + } + const group = new Group(); + group.add( + partMesh(context, mergeParts(crowns), UNDIMMED_GROUP, WORLD_FINISHES.conifer), + partMesh(context, mergeParts(trunks), UNDIMMED_GROUP, WORLD_FINISHES.trunk), + partMesh(context, mergeParts(poplars), UNDIMMED_GROUP, WORLD_FINISHES.poplar), + ); + return group; +} diff --git a/explainers/fpv-drone/src/scene/parts/world/world.test.ts b/explainers/fpv-drone/src/scene/parts/world/world.test.ts new file mode 100644 index 00000000..ae77cf53 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/world.test.ts @@ -0,0 +1,123 @@ +import { Color } from 'three'; +import { describe, expect, it } from 'vitest'; +import { PAD, STATION, TREELINE } from '../../../model/layout'; +import { FIELD, ROADS, SHRUBS, TREES } from '../../constants'; +import { PAINT } from '../../finishes'; +import { SCENE_OPTIONS } from '../../sceneOptions'; +import { fieldColour, mownBand } from './field'; +import { scatterShrubs } from './shrubs'; +import { skyUniforms } from './sky'; +import { coniferGreen, poplarGeometry, treeSpots } from './trees'; + +function apart(a: Color, b: Color): number { + return Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b); +} + +describe('field colour', () => { + it('mixes the field between grass and straw with worn patches', () => { + const grass = new Color(PAINT.grass); + const straw = new Color(PAINT.straw); + const samples = Array.from({ length: 200 }, (_, index) => + fieldColour( + FIELD.extent.x[0] + ((index * 37) % (FIELD.extent.x[1] - FIELD.extent.x[0])), + FIELD.extent.z[0] + ((index * 53) % (FIELD.extent.z[1] - FIELD.extent.z[0])), + new Color(), + ), + ); + const greens = samples.filter((colour) => apart(colour, grass) < 0.05).length; + const yellows = samples.filter((colour) => apart(colour, straw) < 0.08).length; + expect(greens).toBeGreaterThan(20); + expect(yellows).toBeGreaterThan(5); + }); +}); + +describe('field texture in the vertex colours', () => { + it('shows a few bare earth spots across the field', () => { + const earth = new Color(PAINT.earth); + let bare = 0; + let total = 0; + for (let x = FIELD.extent.x[0]; x < FIELD.extent.x[1]; x += 7) { + for (let z = FIELD.extent.z[0]; z < FIELD.extent.z[1]; z += 7) { + total += 1; + if (apart(fieldColour(x, z, new Color()), earth) < 0.04) bare += 1; + } + } + expect(bare / total).toBeGreaterThan(0.005); + expect(bare / total).toBeLessThan(0.15); + }); + + it('alternates lighter and darker mown bands across the field', () => { + const quarter = FIELD.mown.width / 2; + expect(mownBand(quarter)).toBeCloseTo(1); + expect(mownBand(quarter * 3)).toBeCloseTo(-1); + const light = fieldColour(100, quarter, new Color()); + const dark = fieldColour(100, quarter + FIELD.mown.width, new Color()); + expect(light.g).toBeGreaterThan(dark.g); + }); +}); + +describe('treeline', () => { + it('lines the far side of the field in two rows of conifers and poplars', () => { + const spots = treeSpots(); + expect(spots.length).toBeGreaterThan(80); + for (const spot of spots) { + expect(spot.x).toBeGreaterThanOrEqual(TREELINE.x[0] - 5); + expect(spot.x).toBeLessThanOrEqual(TREELINE.x[1] + 5); + expect(Math.abs(spot.z - TREELINE.z)).toBeLessThan(20); + expect(spot.height).toBeGreaterThanOrEqual(TREES.conifer.height[0]); + } + const poplars = spots.filter((spot) => spot.poplar).length; + expect(poplars / spots.length).toBeGreaterThan(0.2); + expect(poplars / spots.length).toBeLessThan(0.5); + }); +}); + +describe('treeline variety', () => { + it('spreads the conifers over a wide range of heights and several greens', () => { + const conifers = treeSpots().filter((spot) => !spot.poplar); + const heights = conifers.map((spot) => spot.height); + expect(Math.max(...heights) - Math.min(...heights)).toBeGreaterThan(8); + const greens = new Set(conifers.map((spot) => coniferGreen(spot.tone).getHexString())); + expect(greens.size).toBe(TREES.conifer.greens.length); + }); + + it('gives each bare poplar forked limbs, not a single stick', () => { + const spot = treeSpots().find((candidate) => candidate.poplar); + if (!spot) throw new Error('No poplar in the treeline'); + const geometry = poplarGeometry(spot); + const { branches, twigs } = TREES.poplar; + const rods = 1 + branches.count * (1 + twigs.perBranch); + const triangles = (geometry.getIndex()?.count ?? 0) / 3; + expect(triangles).toBeGreaterThan(rods * 8); + geometry.computeBoundingBox(); + const width = (geometry.boundingBox?.max.x ?? 0) - (geometry.boundingBox?.min.x ?? 0); + expect(width).toBeGreaterThan(spot.height * 0.15); + }); +}); + +describe('shrubs', () => { + it('scatters over the field but keeps off the station, the pad and the roads', () => { + const shrubs = scatterShrubs(); + expect(shrubs.length).toBe(SHRUBS.count); + for (const shrub of shrubs) { + expect(shrub.x).toBeGreaterThanOrEqual(SHRUBS.area.x[0]); + expect(shrub.x).toBeLessThanOrEqual(SHRUBS.area.x[1]); + expect(Math.hypot(shrub.x - STATION[0], shrub.z - STATION[2])).toBeGreaterThan(5); + expect(Math.hypot(shrub.x - PAD[0], shrub.z - PAD[2])).toBeGreaterThan(5); + expect(Math.abs(shrub.z - ROADS.along.z)).toBeGreaterThan(ROADS.halfWidth); + expect(Math.abs(shrub.x - ROADS.across.x)).toBeGreaterThan(ROADS.halfWidth); + } + }); +}); + +describe('sky', () => { + it('meets the fog colour at the horizon and deepens to blue overhead', () => { + const { uHorizon, uTop } = skyUniforms(); + expect(uHorizon.value.equals(new Color(SCENE_OPTIONS.fog?.color))).toBe(true); + const horizon = uHorizon.value.getHSL({ h: 0, s: 0, l: 0 }); + const top = uTop.value.getHSL({ h: 0, s: 0, l: 0 }); + expect(top.l).toBeLessThan(horizon.l); + expect(top.s).toBeGreaterThan(horizon.s); + expect(uTop.value.b).toBeGreaterThan(uTop.value.r * 2); + }); +}); diff --git a/explainers/fpv-drone/src/scene/pose.ts b/explainers/fpv-drone/src/scene/pose.ts new file mode 100644 index 00000000..27d8d9bd --- /dev/null +++ b/explainers/fpv-drone/src/scene/pose.ts @@ -0,0 +1,12 @@ +import type { Object3D } from 'three'; +import type { FlightReading } from '../ids'; +import { DRONE } from '../model/layout'; +import { droneUnits } from '../model/scale'; + +export const DRONE_EULER_ORDER = 'YZX'; + +export function applyDronePose(object: Object3D, flight: FlightReading): void { + const [x, y, z] = flight.position; + object.position.set(x, y + droneUnits(DRONE.restHeight), z); + object.rotation.set(flight.roll, -flight.heading, -flight.pitch, DRONE_EULER_ORDER); +} diff --git a/explainers/fpv-drone/src/scene/regions.test.ts b/explainers/fpv-drone/src/scene/regions.test.ts new file mode 100644 index 00000000..a8d04e70 --- /dev/null +++ b/explainers/fpv-drone/src/scene/regions.test.ts @@ -0,0 +1,23 @@ +import { Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { CROSSROADS, ORBIT_HEIGHT, PAD, STATION } from '../model/layout'; +import { staticRegions } from './regions'; + +describe('static regions', () => { + it('hold the station, the pad, the crossroads and the whole flight', () => { + const regions = staticRegions(); + expect(regions.station.containsPoint(new Vector3(...STATION).setY(1))).toBe(true); + expect(regions.station.containsPoint(new Vector3(...PAD).setY(1))).toBe(true); + expect(regions.crossroads.containsPoint(new Vector3(...CROSSROADS).setY(1))).toBe(true); + expect(regions.route.containsPoint(new Vector3(...CROSSROADS).setY(ORBIT_HEIGHT))).toBe(true); + expect(regions.scene.max.y).toBeGreaterThan(ORBIT_HEIGHT); + expect(regions.scene.containsBox(regions.station)).toBe(true); + expect(regions.scene.containsBox(regions.crossroads)).toBe(true); + }); + + it('hands out fresh boxes every time', () => { + const first = staticRegions(); + first.scene.expandByScalar(100); + expect(staticRegions().scene.equals(first.scene)).toBe(false); + }); +}); diff --git a/explainers/fpv-drone/src/scene/regions.ts b/explainers/fpv-drone/src/scene/regions.ts new file mode 100644 index 00000000..50d2e92f --- /dev/null +++ b/explainers/fpv-drone/src/scene/regions.ts @@ -0,0 +1,23 @@ +import type { Box3 } from 'three'; +import { regionFromSpec } from '@core/scene/regions'; +import type { RegionId } from '../ids'; +import { CROSSROADS_BOUNDS, ROUTE_BOUNDS, SCENE_BOUNDS, STATION_BOUNDS } from '../model/layout'; +import type { Box } from '../model/layout'; + +export type StaticRegionId = Exclude; + +export const STATIC_REGION_SPECS: Readonly> = { + scene: SCENE_BOUNDS, + station: STATION_BOUNDS, + crossroads: CROSSROADS_BOUNDS, + route: ROUTE_BOUNDS, +}; + +export function staticRegions(): Record { + return { + scene: regionFromSpec(STATIC_REGION_SPECS.scene), + station: regionFromSpec(STATIC_REGION_SPECS.station), + crossroads: regionFromSpec(STATIC_REGION_SPECS.crossroads), + route: regionFromSpec(STATIC_REGION_SPECS.route), + }; +} diff --git a/explainers/fpv-drone/src/scene/sceneOptions.test.ts b/explainers/fpv-drone/src/scene/sceneOptions.test.ts new file mode 100644 index 00000000..7e0b92d0 --- /dev/null +++ b/explainers/fpv-drone/src/scene/sceneOptions.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { SCENE_BOUNDS } from '../model/layout'; +import { THEME } from '../theme'; +import { SKY } from './constants'; +import { SCENE_OPTIONS } from './sceneOptions'; + +describe('scene options', () => { + it('turns the stage off and fogs the field in the horizon colour', () => { + expect(SCENE_OPTIONS.stage).toBe(false); + expect(SCENE_OPTIONS.background).toBe(THEME.skyHorizon); + expect(SCENE_OPTIONS.fog?.color).toBe(SCENE_OPTIONS.background); + expect(SCENE_OPTIONS.fog?.near).toBeLessThan(SCENE_OPTIONS.fog?.far ?? 0); + expect(SCENE_OPTIONS.fog?.near).toBeGreaterThan(SCENE_BOUNDS.x[1] - SCENE_BOUNDS.x[0]); + expect(SCENE_OPTIONS.gaugeSide).toBe('right'); + }); + + it('reaches the sky dome and lets the camera look up from below', () => { + const camera = SCENE_OPTIONS.camera ?? {}; + expect(camera.far).toBeGreaterThan(SKY.radius * 2); + expect(camera.near).toBeLessThan(1); + expect(camera.maxPolarAngle).toBeGreaterThan(Math.PI / 2); + expect(camera.distance?.max).toBeGreaterThan(SCENE_BOUNDS.x[1] - SCENE_BOUNDS.x[0]); + }); + + it('never dims the links and the spin arrows', () => { + expect(SCENE_OPTIONS.highlight?.undimmed).toEqual(['controlLink', 'videoLink', 'spinArrows']); + expect(SCENE_OPTIONS.highlight?.dim?.brightness).toBeGreaterThan(0.45); + }); +}); diff --git a/explainers/fpv-drone/src/scene/sceneOptions.ts b/explainers/fpv-drone/src/scene/sceneOptions.ts new file mode 100644 index 00000000..ab6e8b6e --- /dev/null +++ b/explainers/fpv-drone/src/scene/sceneOptions.ts @@ -0,0 +1,18 @@ +import type { SceneOptions } from '@core/scene/shell'; +import { THEME } from '../theme'; +import { HAZE, HIGHLIGHT_DIM, SCENE_LIMITS, UNDIMMED_PARTS } from './constants'; + +export const SCENE_OPTIONS: SceneOptions = { + background: THEME.skyHorizon, + fog: { color: THEME.skyHorizon, 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 }, + floorMargin: SCENE_LIMITS.targetFloorMargin, + }, + gaugeSide: 'right', + highlight: { dim: HIGHLIGHT_DIM, undimmed: UNDIMMED_PARTS }, +}; diff --git a/explainers/fpv-drone/src/state/derived.test.ts b/explainers/fpv-drone/src/state/derived.test.ts new file mode 100644 index 00000000..89f54e6b --- /dev/null +++ b/explainers/fpv-drone/src/state/derived.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { batteryAt, flightAt, linkAt, motorsAt } from '../model'; +import { sortieAt } from './derived'; + +describe('sortie readings', () => { + it('reads the flight, the motors, the battery and the link under the scrubber', () => { + const reading = sortieAt({ phase: 38, payload: 300 }); + expect(reading.flight).toEqual(flightAt(38)); + expect(reading.motors).toEqual(motorsAt(flightAt(38), 300)); + expect(reading.battery).toEqual(batteryAt(38, 300)); + expect(reading.link).toEqual(linkAt(flightAt(38).position)); + }); + + it('reuses the reading until the time or the payload changes', () => { + const first = sortieAt({ phase: 30, payload: 300 }); + expect(sortieAt({ phase: 30, payload: 300 })).toBe(first); + expect(sortieAt({ phase: 30, payload: 800 })).not.toBe(first); + expect(sortieAt({ phase: 30, payload: 800 }).battery.share).toBeLessThan(first.battery.share); + expect(sortieAt({ phase: 31, payload: 300 })).not.toBe(first); + }); +}); diff --git a/explainers/fpv-drone/src/state/derived.ts b/explainers/fpv-drone/src/state/derived.ts new file mode 100644 index 00000000..113ca04e --- /dev/null +++ b/explainers/fpv-drone/src/state/derived.ts @@ -0,0 +1,42 @@ +import type { PlaybackState } from '@core/explainer'; +import type { BatteryReading, LinkReading, MotorReading } from '../ids'; +import { batteryAt, flightAt, linkAt, motorsAt } from '../model'; +import type { FlightMotion } from '../model'; +import type { FpvFields } from './store'; + +export type SortieSource = Pick & Pick; + +export interface SortieReading { + flight: FlightMotion; + motors: MotorReading; + battery: BatteryReading; + link: LinkReading; +} + +interface Remembered extends SortieSource { + reading: SortieReading; +} + +function readingAt(phase: number, payload: number): SortieReading { + const flight = flightAt(phase); + return { + flight, + motors: motorsAt(flight, payload), + battery: batteryAt(phase, payload), + link: linkAt(flight.position), + }; +} + +let last: Remembered | null = null; + +function isRemembered({ phase, payload }: SortieSource): boolean { + return last?.phase === phase && last.payload === payload; +} + +export function sortieAt(source: SortieSource): Readonly { + if (!last || !isRemembered(source)) { + const { phase, payload } = source; + last = { phase, payload, reading: readingAt(phase, payload) }; + } + return last.reading; +} diff --git a/explainers/fpv-drone/src/state/index.ts b/explainers/fpv-drone/src/state/index.ts new file mode 100644 index 00000000..029bc932 --- /dev/null +++ b/explainers/fpv-drone/src/state/index.ts @@ -0,0 +1,3 @@ +export * from './derived'; +export * from './presets'; +export * from './store'; diff --git a/explainers/fpv-drone/src/state/presets.test.ts b/explainers/fpv-drone/src/state/presets.test.ts new file mode 100644 index 00000000..cd1c0192 --- /dev/null +++ b/explainers/fpv-drone/src/state/presets.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { PART_IDS, PRESET_IDS } from '../ids'; +import { SPEED_RANGE } from '../timeline'; +import { PRESETS } from './presets'; + +describe('chapter presets', () => { + it('has the six chapters in reading order', () => { + expect(Object.keys(PRESETS)).toEqual([...PRESET_IDS]); + }); + + it('labels and highlights only known parts, and highlights only labelled ones', () => { + Object.entries(PRESETS).forEach(([id, preset]) => { + [...preset.labels, ...preset.highlight].forEach((part) => + expect(PART_IDS, `${id}: ${part}`).toContain(part), + ); + preset.highlight.forEach((part) => expect(preset.labels, `${id}: ${part}`).toContain(part)); + }); + }); + + it('frames each chapter with its own camera view', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].camera)).toEqual([ + 'chase', + 'top', + 'close', + 'pilot', + 'side', + 'fpv', + ]); + }); + + it('plays every chapter at normal speed', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].speed)).toEqual([1, 1, 1, 1, 1, 1]); + Object.values(PRESETS).forEach((preset) => { + expect(preset.speed).toBeGreaterThanOrEqual(SPEED_RANGE.min); + expect(preset.speed).toBeLessThanOrEqual(SPEED_RANGE.max); + }); + }); + + it('seeks each chapter but flight to its second of the flight and never pauses', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].startAt)).toEqual([0, undefined, 16, 20, 30, 52]); + Object.values(PRESETS).forEach((preset) => expect(preset.pauseAt).toBeUndefined()); + }); + + it('switches the beams, the track and the spin arrows per chapter and never the labels', () => { + expect(PRESETS.overview.view).toEqual({ links: true, track: true }); + expect(PRESETS.flight.view).toEqual({ arrows: true }); + expect(PRESETS.controller.view).toEqual({ arrows: false }); + expect(PRESETS.link.view).toEqual({ links: true }); + expect(PRESETS.power.view).toBeUndefined(); + expect(PRESETS.limits.view).toEqual({ track: true }); + Object.values(PRESETS).forEach((preset) => expect(preset.view?.labels).toBeUndefined()); + }); + + it('gives each chapter its own controls', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].controls)).toEqual([ + undefined, + ['move', 'tilt'], + ['flightMode'], + ['packetRate'], + ['payload'], + ['speedster'], + ]); + }); + + it('highlights nothing in the overview and the limits', () => { + expect(PRESETS.overview.highlight).toEqual([]); + expect(PRESETS.limits.highlight).toEqual([]); + expect(PRESETS.overview.labels).toHaveLength(6); + }); +}); diff --git a/explainers/fpv-drone/src/state/presets.ts b/explainers/fpv-drone/src/state/presets.ts new file mode 100644 index 00000000..23f1fd9f --- /dev/null +++ b/explainers/fpv-drone/src/state/presets.ts @@ -0,0 +1,110 @@ +import type { ScenePreset } from '@core/scene/presetBinder'; +import type { + CameraView, + FlightModeId, + MoveId, + PacketRate, + PartId, + PresetId, + SpeedsterId, + VideoId, + ViewOptions, +} from '../ids'; + +export type ChapterControl = + 'move' | 'tilt' | 'flightMode' | 'packetRate' | 'payload' | 'speedster'; + +export interface Preset extends ScenePreset { + view?: Partial; + controls?: readonly ChapterControl[]; +} + +export const DEFAULT_VIDEO: VideoId = 'analogue'; +export const DEFAULT_MOVE: MoveId = 'hover'; +export const DEFAULT_FLIGHT_MODE: FlightModeId = 'acro'; +export const DEFAULT_PACKET_RATE: PacketRate = 250; +export const DEFAULT_SPEEDSTER: SpeedsterId = 'longRange'; + +const CHAPTER_START_S: Readonly, number>> = { + overview: 0, + controller: 16, + link: 20, + power: 30, + limits: 52, +}; + +const NORMAL_SPEED = 1; + +export const PRESETS: Record = { + overview: { + camera: 'chase', + speed: NORMAL_SPEED, + view: { links: true, track: true }, + startAt: CHAPTER_START_S.overview, + labels: ['frame', 'propellers', 'battery', 'camera', 'videoAntenna', 'groundStation'], + highlight: [], + }, + flight: { + camera: 'top', + speed: NORMAL_SPEED, + view: { arrows: true }, + controls: ['move', 'tilt'], + labels: [ + 'motorFrontLeft', + 'motorFrontRight', + 'motorRearLeft', + 'motorRearRight', + 'propellers', + 'spinArrows', + ], + highlight: [ + 'motorFrontLeft', + 'motorFrontRight', + 'motorRearLeft', + 'motorRearRight', + 'propellers', + ], + }, + controller: { + camera: 'close', + speed: NORMAL_SPEED, + view: { arrows: false }, + startAt: CHAPTER_START_S.controller, + controls: ['flightMode'], + labels: ['stack', 'receiverAntenna', 'gpsModule', 'camera', 'battery'], + highlight: ['stack', 'receiverAntenna'], + }, + link: { + camera: 'pilot', + speed: NORMAL_SPEED, + view: { links: true }, + startAt: CHAPTER_START_S.link, + controls: ['packetRate'], + labels: [ + 'groundStation', + 'controlLink', + 'videoLink', + 'videoAntenna', + 'receiverAntenna', + 'camera', + ], + highlight: ['controlLink', 'videoLink', 'groundStation'], + }, + power: { + camera: 'side', + speed: NORMAL_SPEED, + startAt: CHAPTER_START_S.power, + controls: ['payload'], + labels: ['battery', 'stack', 'motorFrontLeft', 'motorRearLeft', 'propellers'], + highlight: ['battery'], + }, + limits: { + camera: 'fpv', + speed: NORMAL_SPEED, + view: { track: true }, + startAt: CHAPTER_START_S.limits, + controls: ['speedster'], + labels: ['crossroads', 'groundStation'], + highlight: [], + }, +}; diff --git a/explainers/fpv-drone/src/state/store.test.ts b/explainers/fpv-drone/src/state/store.test.ts new file mode 100644 index 00000000..f33fd512 --- /dev/null +++ b/explainers/fpv-drone/src/state/store.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import { MOMENT_IDS, PRESET_IDS } from '../ids'; +import { MOMENTS, SORTIE_SECONDS } from '../model'; +import { PRESETS } from './presets'; +import { DEFAULT_VIEW, createFpvStore } from './store'; + +describe('fpv store', () => { + it('starts on the overview at arming with the chapter defaults', () => { + expect(createFpvStore().getState()).toMatchObject({ + phase: 0, + speed: 1, + preset: 'overview', + video: 'analogue', + move: 'hover', + tilt: 30, + flightMode: 'acro', + payload: 300, + packetRate: 250, + speedster: 'longRange', + view: { links: true, track: true, arrows: false, labels: true }, + throughGoggles: false, + }); + }); + + it('remembers whether the camera rides in the goggles', () => { + const store = createFpvStore(); + store.getState().setThroughGoggles(true); + expect(store.getState().throughGoggles).toBe(true); + }); + + it('opens with the view the overview chapter asks for', () => { + expect(DEFAULT_VIEW).toMatchObject(PRESETS.overview.view ?? {}); + }); + + it('flies the sortie in real time and stops at the end', () => { + const store = createFpvStore({ phase: 0, playing: true }); + store.getState().tick(40); + expect(store.getState().phase).toBeCloseTo(SORTIE_SECONDS / 2, 9); + store.getState().tick(60); + expect(store.getState().phase).toBe(SORTIE_SECONDS); + expect(store.getState().playing).toBe(false); + }); + + it('pauses at each moment', () => { + const store = createFpvStore({ 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 tilt and the payload inside their sliders', () => { + const store = createFpvStore(); + store.getState().setTilt(90); + expect(store.getState().tilt).toBe(60); + store.getState().setTilt(-5); + expect(store.getState().tilt).toBe(0); + store.getState().setPayload(5000); + expect(store.getState().payload).toBe(1500); + store.getState().setPayload(-100); + expect(store.getState().payload).toBe(0); + }); + + it('sets the chips', () => { + const store = createFpvStore(); + store.getState().setVideo('digital'); + store.getState().setMove('yaw'); + store.getState().setFlightMode('angle'); + store.getState().setPacketRate(50); + store.getState().setSpeedster('record'); + expect(store.getState()).toMatchObject({ + video: 'digital', + move: 'yaw', + flightMode: 'angle', + packetRate: 50, + speedster: 'record', + }); + }); + + it.each([ + ['flight', 'move', 'roll', 'hover'], + ['flight', 'tilt', 45, 30], + ['controller', 'flightMode', 'angle', 'acro'], + ['link', 'packetRate', 50, 250], + ['power', 'payload', 900, 300], + ['limits', 'speedster', 'racer', 'longRange'], + ] as const)( + 'keeps the %s chapter control %s and resets it elsewhere', + (chapter, field, value, reset) => { + const store = createFpvStore(); + PRESET_IDS.forEach((id) => { + store.getState().applyPreset(chapter); + store.setState({ [field]: value }); + store.getState().applyPreset(id); + expect(store.getState()[field], id).toBe(id === chapter ? value : reset); + }); + }, + ); + + it('keeps the video choice through every chapter', () => { + const store = createFpvStore(); + store.getState().setVideo('digital'); + PRESET_IDS.forEach((id) => { + store.getState().applyPreset(id); + expect(store.getState().video, id).toBe('digital'); + }); + }); + + it('seeks each chapter without stopping playback at normal speed', () => { + const store = createFpvStore({ phase: 70, playing: true }); + store.getState().applyPreset('power'); + expect(store.getState()).toMatchObject({ phase: 30, playing: true, speed: 1 }); + store.getState().setSpeed(2); + store.getState().applyPreset('limits'); + expect(store.getState()).toMatchObject({ phase: 52, playing: true, speed: 1 }); + }); + + it('turns the spin arrows on for the flight chapter and off for the controller', () => { + const store = createFpvStore(); + store.getState().applyPreset('flight'); + expect(store.getState().view.arrows).toBe(true); + store.getState().applyPreset('controller'); + expect(store.getState().view.arrows).toBe(false); + expect(store.getState().view.labels).toBe(true); + }); +}); diff --git a/explainers/fpv-drone/src/state/store.ts b/explainers/fpv-drone/src/state/store.ts new file mode 100644 index 00000000..9b2fd684 --- /dev/null +++ b/explainers/fpv-drone/src/state/store.ts @@ -0,0 +1,114 @@ +import type { ExplainerStore, Playback, PlaybackState } from '@core/explainer'; +import { clamp } from '@core/math'; +import { createExplainerStore } from '@core/store'; +import type { + FlightModeId, + MomentId, + MoveId, + PacketRate, + PresetId, + SpeedsterId, + VideoId, + ViewOptions, +} from '../ids'; +import { MOMENTS, PAYLOAD_RANGE, SPEED_RANGE, TILT_RANGE } from '../model'; +import { FPV_TIMELINE } from '../timeline'; +import { + DEFAULT_FLIGHT_MODE, + DEFAULT_MOVE, + DEFAULT_PACKET_RATE, + DEFAULT_SPEEDSTER, + DEFAULT_VIDEO, + PRESETS, +} from './presets'; +import type { ChapterControl, Preset } from './presets'; + +export type ViewState = { [Key in keyof ViewOptions]: ViewOptions[Key] }; + +export interface FpvFields { + video: VideoId; + move: MoveId; + tilt: number; + flightMode: FlightModeId; + payload: number; + packetRate: PacketRate; + speedster: SpeedsterId; + view: ViewState; + preset: PresetId; + throughGoggles: boolean; +} + +export interface FpvOwnActions { + setVideo(video: VideoId): void; + setMove(move: MoveId): void; + setTilt(degrees: number): void; + setFlightMode(mode: FlightModeId): void; + setPayload(grams: number): void; + setPacketRate(rate: PacketRate): void; + setSpeedster(speedster: SpeedsterId): void; + seekMoment(moment: MomentId): void; + setThroughGoggles(through: boolean): void; +} + +export type FpvState = PlaybackState & FpvFields; +export type FpvStoreState = Playback & FpvFields & FpvOwnActions; +export type FpvStore = ExplainerStore; + +type ChapterControls = Pick; + +export const DEFAULT_VIEW: ViewState = { links: true, track: true, arrows: false, labels: true }; +const START_SPEED = PRESETS.overview.speed ?? SPEED_RANGE.default; + +export const CHAPTER_CONTROL_DEFAULTS: ChapterControls = { + move: DEFAULT_MOVE, + tilt: TILT_RANGE.default, + flightMode: DEFAULT_FLIGHT_MODE, + packetRate: DEFAULT_PACKET_RATE, + payload: PAYLOAD_RANGE.default, + speedster: DEFAULT_SPEEDSTER, +}; + +const CHAPTER_CONTROLS = Object.keys(CHAPTER_CONTROL_DEFAULTS) as ChapterControl[]; + +function kept( + preset: Preset, + state: FpvFields, + control: K, +): ChapterControls[K] { + return preset.controls?.includes(control) ? state[control] : CHAPTER_CONTROL_DEFAULTS[control]; +} + +function chapterControls(preset: Preset, state: FpvFields): Partial { + return Object.fromEntries( + CHAPTER_CONTROLS.map((control) => [control, kept(preset, state, control)]), + ) as Partial; +} + +export function createFpvStore(overrides: Partial = {}): FpvStore { + return createExplainerStore( + { + timeline: FPV_TIMELINE, + presets: PRESETS, + defaults: { preset: 'overview', speed: START_SPEED, view: DEFAULT_VIEW }, + extend: (set, get) => ({ + ...CHAPTER_CONTROL_DEFAULTS, + video: DEFAULT_VIDEO, + throughGoggles: false, + setVideo: (video) => set({ video }), + setMove: (move) => set({ move }), + setTilt: (degrees) => set({ tilt: clamp(degrees, TILT_RANGE.min, TILT_RANGE.max) }), + setFlightMode: (flightMode) => set({ flightMode }), + setPayload: (grams) => set({ payload: clamp(grams, PAYLOAD_RANGE.min, PAYLOAD_RANGE.max) }), + setPacketRate: (packetRate) => set({ packetRate }), + setSpeedster: (speedster) => set({ speedster }), + setThroughGoggles: (throughGoggles) => set({ throughGoggles }), + seekMoment: (moment) => { + get().pause(); + get().setPhase(MOMENTS[moment]); + }, + }), + presetState: chapterControls, + }, + overrides, + ); +} diff --git a/explainers/fpv-drone/src/style.css b/explainers/fpv-drone/src/style.css new file mode 100644 index 00000000..db53b1bf --- /dev/null +++ b/explainers/fpv-drone/src/style.css @@ -0,0 +1,246 @@ +:root { + --takeoff: #ffb347; + --climb: #ffd24c; + --transit: #5fa8ff; + --orbit: #6fd38a; + --return: #b48cff; + --landing: #ff8a7a; + --battery: #f5a524; + --control-link: #6fd38a; + --video-link: #5fa8ff; + --clockwise: #ff9a3c; + --counter: #5fd0ff; + --olive: #a8b86c; +} + +.section-readouts, +.range-widget:has([data-control='tilt']), +.range-widget:has([data-control='payload']) { + --readout-min-width: 8rem; +} + +.section-readouts { + margin-block: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.section-readouts:has([data-readout='overview-battery']), +.section-readouts:has([data-readout='rate-period']), +.section-readouts:has([data-readout='limits-speed']), +.range-widget:has([data-control='tilt']) .range-widget__readouts { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.section-readouts:has([data-readout='flight-mix']), +.section-readouts:has([data-readout='mode-stick']) { + grid-template-columns: minmax(0, 1fr); +} + +.section-readouts > div:has([data-readout='limits-who']), +.range-widget__readouts > div:has([data-readout='power-hover']) { + grid-column: 1 / -1; +} + +.range-widget:has([data-control='payload']) .range-widget__readouts { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +[data-control='tilt'] { + --range-color: var(--transit); +} + +[data-control='payload'] { + --range-color: var(--battery); +} + +[data-readout='overview-battery'], +[data-readout='power-volts'], +[data-readout='power-amps'] { + color: var(--battery); +} + +[data-readout='link-signal'] { + color: var(--control-link); +} + +[data-readout='link-latency'], +[data-readout='link-picture'] { + color: var(--video-link); +} + +.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(--control-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(--control-link); + font-family: var(--font-mono); + font-weight: 500; + text-align: center; +} + +.chip[data-action='moment'][data-value='liftoff'] { + --tone: var(--takeoff); +} + +.chip[data-action='moment'][data-value='cruise'] { + --tone: var(--transit); +} + +.chip[data-action='moment'][data-value='onStation'] { + --tone: var(--orbit); +} + +.chip[data-action='moment'][data-value='turnHome'] { + --tone: var(--return); +} + +.chip[data-action='moment'][data-value='touchdown'] { + --tone: var(--landing); +} + +.chip[data-action='move'] { + --tone: var(--clockwise); +} + +.chip[data-action='move'][data-value='yaw'] { + --tone: var(--counter); +} + +.chip[data-action='flightMode'] { + --tone: var(--olive); +} + +.chip[data-action='video'] { + --tone: var(--video-link); +} + +.chip[data-action='packetRate'] { + --tone: var(--control-link); +} + +.chip[data-action='speedster'] { + --tone: var(--climb); +} + +@media (pointer: coarse) { + .chip[data-action='moment'], + .chip[data-action='move'], + .chip[data-action='flightMode'], + .chip[data-action='video'], + .chip[data-action='packetRate'], + .chip[data-action='speedster'] { + height: 44px; + } +} + +@media (max-width: 480px) { + .section-readouts:has([data-readout='overview-battery']), + .section-readouts:has([data-readout='rate-period']), + .section-readouts:has([data-readout='limits-speed']), + .range-widget:has([data-control='tilt']) .range-widget__readouts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.goggles-feed { + --goggles-frame: calc(var(--stage-inset) * 0.75); + --goggles-surround: rgb(4 6 8 / 0.62); + --goggles-rim: rgb(255 255 255 / 0.7); + --goggles-vignette: rgb(0 0 0 / 0.45); + --goggles-scanline: rgb(255 255 255 / 0.06); + --goggles-live: #ff5a4d; + + position: absolute; + inset: 0; + z-index: 2; + overflow: hidden; + pointer-events: none; + opacity: 0; + transition: opacity 0.4s ease; +} + +.goggles-feed[data-active='true'] { + opacity: 1; +} + +.goggles-feed[data-video='analogue']::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + to bottom, + var(--goggles-scanline) 0 1px, + transparent 1px 3px + ); +} + +.goggles-feed::after { + content: ''; + position: absolute; + inset: var(--goggles-frame) var(--goggles-frame) var(--scene-safe-bottom, var(--goggles-frame)) + var(--goggles-frame); + border: 2px solid var(--goggles-rim); + border-radius: 1.75rem; + box-shadow: + 0 0 0 100vmax var(--goggles-surround), + inset 0 0 7rem var(--goggles-vignette); +} + +.goggles-feed__label { + position: absolute; + top: calc(var(--goggles-frame) + 0.8rem); + left: calc(var(--goggles-frame) + 0.9rem); + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + padding: 0.25rem 0.7rem; + border-radius: 999px; + background: var(--goggles-surround); + color: #fff; + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 500; +} + +.goggles-feed__label::before { + content: ''; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--goggles-live); +} + +.goggles-feed__video { + opacity: 0.75; +} diff --git a/explainers/fpv-drone/src/theme.ts b/explainers/fpv-drone/src/theme.ts new file mode 100644 index 00000000..87b08959 --- /dev/null +++ b/explainers/fpv-drone/src/theme.ts @@ -0,0 +1,29 @@ +import { THEME as CORE_THEME } from '@core/theme'; + +export const THEME = { + ...CORE_THEME, + takeoff: '#ffb347', + climb: '#ffd24c', + transit: '#5fa8ff', + orbit: '#6fd38a', + return: '#b48cff', + landing: '#ff8a7a', + battery: '#f5a524', + controlLink: '#6fd38a', + videoLink: '#5fa8ff', + clockwise: '#ff9a3c', + counterClockwise: '#5fd0ff', + carbon: '#1c1f24', + carbonLight: '#2e333b', + olive: '#5b6b3a', + oliveDark: '#3e4a28', + motor: '#8e949c', + prop: '#3a4330', + lens: '#15202b', + grass: '#6f7a4a', + straw: '#9a9360', + earth: '#6b5a42', + tree: '#2f3f2a', + skyTop: '#46709f', + skyHorizon: '#b9c3cc', +} as const; diff --git a/explainers/fpv-drone/src/timeline.test.ts b/explainers/fpv-drone/src/timeline.test.ts new file mode 100644 index 00000000..9e10cab7 --- /dev/null +++ b/explainers/fpv-drone/src/timeline.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { PHASE_IDS } from './ids'; +import { SORTIE_SECONDS } from './model'; +import { THEME } from './theme'; +import { FPV_TIMELINE, SPEED_RANGE } from './timeline'; + +describe('fpv timeline', () => { + it('runs the sortie once through the six phases and stops at the end', () => { + const { phases, cycle, loop } = FPV_TIMELINE; + expect(cycle).toBe(SORTIE_SECONDS); + expect(loop).toBe(false); + expect(phases.map((phase) => phase.id)).toEqual([...PHASE_IDS]); + 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 its keys', () => { + expect(FPV_TIMELINE.phases.map((phase) => phase.tone)).toEqual( + PHASE_IDS.map((id) => THEME[id]), + ); + FPV_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(FPV_TIMELINE.step).toBe(0.1); + expect(FPV_TIMELINE.nudge).toEqual({ fine: 0.5, coarse: 5 }); + }); + + it('plays in real time at normal speed, from a quarter to four times as fast', () => { + const { rate, speed } = FPV_TIMELINE; + expect(speed).toMatchObject({ min: 0.25, max: 4, step: 0.25 }); + expect(rate(1)).toBe(1); + expect(rate(2)).toBe(2); + expect(SPEED_RANGE.default).toBe(1); + }); +}); diff --git a/explainers/fpv-drone/src/timeline.ts b/explainers/fpv-drone/src/timeline.ts new file mode 100644 index 00000000..155f9ed4 --- /dev/null +++ b/explainers/fpv-drone/src/timeline.ts @@ -0,0 +1,48 @@ +import type { Phase, Timeline } from '@core/explainer'; +import { PHASE_IDS } from './ids'; +import { PHASE_RANGES, SORTIE_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_S = 0.1; +const FINE_STEP_S = 0.5; +const COARSE_STEP_S = 5; + +const SORTIE_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 FPV_TIMELINE: Timeline = { + cycle: SORTIE_SECONDS, + loop: false, + step: SCRUBBER_STEP_S, + nudge: { fine: FINE_STEP_S, coarse: COARSE_STEP_S }, + labelKey: 'timeline.label', + phasesLabelKey: 'timeline.phases', + formatPhase, + describePhase, + rate, + phases: SORTIE_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/fpv-drone/src/ui/actions.test.ts b/explainers/fpv-drone/src/ui/actions.test.ts new file mode 100644 index 00000000..3e9246a3 --- /dev/null +++ b/explainers/fpv-drone/src/ui/actions.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { MOMENT_IDS } from '../ids'; +import { MOMENTS } from '../model'; +import { createFpvStore } from '../state'; +import { CHAPTER_ACTIONS, MOMENT_TOLERANCE_S } from './actions'; + +describe('chapter actions', () => { + it('pauses at each moment and marks it as current', () => { + const store = createFpvStore({ 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 = createFpvStore({ phase: MOMENTS.onStation + 0.25 }).getState(); + expect(CHAPTER_ACTIONS.moment.current?.(near)).toBe('onStation'); + const past = createFpvStore({ phase: MOMENTS.onStation + 0.4 }).getState(); + expect(CHAPTER_ACTIONS.moment.current?.(past)).toBe(''); + const between = createFpvStore({ phase: 40 }).getState(); + expect(CHAPTER_ACTIONS.moment.current?.(between)).toBe(''); + expect(() => CHAPTER_ACTIONS.moment.run(between, 'landing')).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_S, + ), + ); + }); + + it('switches the move, the flight mode, the packet rate, the speedster and the video', () => { + const store = createFpvStore(); + CHAPTER_ACTIONS.move.run(store.getState(), 'yaw'); + CHAPTER_ACTIONS.flightMode.run(store.getState(), 'horizon'); + CHAPTER_ACTIONS.packetRate.run(store.getState(), '50'); + CHAPTER_ACTIONS.speedster.run(store.getState(), 'record'); + CHAPTER_ACTIONS.video.run(store.getState(), 'digital'); + expect(store.getState()).toMatchObject({ + move: 'yaw', + flightMode: 'horizon', + packetRate: 50, + speedster: 'record', + video: 'digital', + }); + expect(CHAPTER_ACTIONS.move.current?.(store.getState())).toBe('yaw'); + expect(CHAPTER_ACTIONS.flightMode.current?.(store.getState())).toBe('horizon'); + expect(CHAPTER_ACTIONS.packetRate.current?.(store.getState())).toBe('50'); + expect(CHAPTER_ACTIONS.speedster.current?.(store.getState())).toBe('record'); + expect(CHAPTER_ACTIONS.video.current?.(store.getState())).toBe('digital'); + expect(() => CHAPTER_ACTIONS.packetRate.run(store.getState(), '1000')).toThrow(); + expect(() => CHAPTER_ACTIONS.move.run(store.getState(), 'flip')).toThrow(); + }); +}); diff --git a/explainers/fpv-drone/src/ui/actions.ts b/explainers/fpv-drone/src/ui/actions.ts new file mode 100644 index 00000000..3c186b57 --- /dev/null +++ b/explainers/fpv-drone/src/ui/actions.ts @@ -0,0 +1,53 @@ +import type { ChapterAction } from '@core/explainer'; +import { parseOption } from '@core/ui/parse'; +import { + FLIGHT_MODE_IDS, + MOMENT_IDS, + MOVE_IDS, + PACKET_RATES, + SPEEDSTER_IDS, + VIDEO_IDS, +} from '../ids'; +import type { MomentId, PacketRate } from '../ids'; +import { MOMENTS } from '../model'; +import type { FpvStoreState } from '../state'; + +const NOTHING_CURRENT = ''; +export const MOMENT_TOLERANCE_S = 0.3; + +const PACKET_RATE_VALUES = PACKET_RATES.map(String); + +function momentAt(state: FpvStoreState): MomentId | undefined { + return MOMENT_IDS.find((moment) => Math.abs(MOMENTS[moment] - state.phase) <= MOMENT_TOLERANCE_S); +} + +function parsePacketRate(value: string): PacketRate { + return Number(parseOption(value, PACKET_RATE_VALUES)) as PacketRate; +} + +export const CHAPTER_ACTIONS: Record> = { + moment: { + run: (state, value) => state.seekMoment(parseOption(value, MOMENT_IDS)), + current: (state) => momentAt(state) ?? NOTHING_CURRENT, + }, + move: { + run: (state, value) => state.setMove(parseOption(value, MOVE_IDS)), + current: (state) => state.move, + }, + flightMode: { + run: (state, value) => state.setFlightMode(parseOption(value, FLIGHT_MODE_IDS)), + current: (state) => state.flightMode, + }, + packetRate: { + run: (state, value) => state.setPacketRate(parsePacketRate(value)), + current: (state) => String(state.packetRate), + }, + speedster: { + run: (state, value) => state.setSpeedster(parseOption(value, SPEEDSTER_IDS)), + current: (state) => state.speedster, + }, + video: { + run: (state, value) => state.setVideo(parseOption(value, VIDEO_IDS)), + current: (state) => state.video, + }, +}; diff --git a/explainers/fpv-drone/src/ui/dock.ts b/explainers/fpv-drone/src/ui/dock.ts new file mode 100644 index 00000000..8cb7abdc --- /dev/null +++ b/explainers/fpv-drone/src/ui/dock.ts @@ -0,0 +1,45 @@ +import type { Choice, ViewToggle } from '@core/explainer'; +import { parseOption } from '@core/ui/parse'; +import { VIDEO_IDS } from '../ids'; +import type { FpvStoreState } from '../state'; + +const LINKS_ICON = + ''; +const TRACK_ICON = + ''; +const ARROWS_ICON = + ''; +const LABELS_ICON = + ''; + +export const FPV_CHOICES: readonly Choice[] = [ + { + id: 'video', + labelKey: 'controls.video.label', + shortcut: 'V', + options: VIDEO_IDS.map((video) => ({ + value: video, + labelKey: `controls.videoOptions.${video}`, + })), + select: (state) => state.video, + apply: (state, value) => state.setVideo(parseOption(value, VIDEO_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('links', 'K', LINKS_ICON), + viewToggle('track', 'T', TRACK_ICON), + viewToggle('arrows', 'A', ARROWS_ICON), + viewToggle('labels', 'L', LABELS_ICON), +]; diff --git a/explainers/fpv-drone/src/ui/format.dom.test.ts b/explainers/fpv-drone/src/ui/format.dom.test.ts new file mode 100644 index 00000000..5d679964 --- /dev/null +++ b/explainers/fpv-drone/src/ui/format.dom.test.ts @@ -0,0 +1,141 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { formatNumber } from '@core/format'; +import { initI18n, t } from '@core/i18n'; +import en from '../../locales/en.json'; +import { + FLIGHT_MODE_IDS, + MOVE_IDS, + PACKET_RATES, + PHASE_IDS, + SPEEDSTER_IDS, + VIDEO_IDS, +} from '../ids'; +import { MOMENTS, PHASE_RANGES, SORTIE_SECONDS, packetPeriodMs, rangeFactor } from '../model'; +import { + describePhase, + describeSpeed, + formatAmps, + formatBattery, + formatClock, + formatDbm, + formatDegrees, + formatGrams, + formatHoverThrottle, + formatKmh, + formatLatency, + formatLimit, + formatMetres, + formatMilliseconds, + formatMinutes, + formatMix, + formatOfHover, + formatPercent, + formatPhase, + formatPicture, + formatPush, + formatRatio, + formatSeconds, + formatSignal, + formatSpeed, + formatStick, + formatTimes, + formatVolts, + formatWho, + formatWhy, +} from './format'; +import { fill, isFilled } from './testing'; + +const { units, timeline } = en; + +function clock(text: string): string { + return fill(timeline.clock, { clock: text }); +} + +describe('fpv formatting', () => { + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + it('shows the flight time as minutes and seconds since arming', () => { + expect(formatClock(0)).toBe(clock('0:00')); + expect(formatClock(42)).toBe(clock('0:42')); + expect(formatClock(MOMENTS.touchdown)).toBe(clock('1:18')); + expect(formatPhase(SORTIE_SECONDS)).toBe(clock('1:20')); + }); + + it('describes the scrubber with what happens in each phase', () => { + PHASE_IDS.forEach((id) => { + const start = PHASE_RANGES[id].start; + expect(describePhase(start)).toBe( + fill(timeline.value, { time: formatPhase(start), phase: timeline.during[id] }), + ); + }); + }); + + it('names the playback factor', () => { + expect(formatSpeed(0.25)).toBe(fill(timeline.speedFormat, { factor: '0.25' })); + expect(describeSpeed()).toBe(timeline.speedValue); + for (let speed = 0.25; speed <= 4; speed += 0.25) { + const text = t('controls.speedValue', { + value: formatSpeed(speed), + description: describeSpeed(), + }); + expect(isFilled(text), text).toBe(true); + expect(text).toContain(formatNumber(speed)); + } + }); + + it('formats the simple units', () => { + expect(formatKmh(69.6)).toBe(fill(units.kmh, { value: '70' })); + expect(formatMetres(39.7)).toBe(fill(units.m, { value: '40' })); + expect(formatGrams(1400)).toBe(fill(units.g, { value: '1,400' })); + expect(formatPercent(0.874)).toBe(fill(units.percent, { value: '87' })); + expect(formatOfHover(1.154)).toBe(fill(units.ofHover, { percent: '115' })); + expect(formatVolts(24.26)).toBe(fill(units.volts, { value: '24.3' })); + expect(formatAmps(14.49)).toBe(fill(units.amps, { value: '14.5' })); + expect(formatDbm(-108)).toBe(fill(units.dbm, { value: '−108' })); + expect(formatMinutes(12.34)).toBe(fill(units.minutes, { value: '12.3' })); + expect(formatDegrees(30)).toBe(fill(units.degrees, { value: '30' })); + }); + + it('formats the battery, the push, the ratio and the hover throttle', () => { + expect(formatBattery({ volts: 25.2, share: 1, amps: 0, usedMah: 0 })).toBe( + fill(units.battery, { volts: '25.2', share: '100' }), + ); + expect(formatPush(5.66, 0.577)).toBe(fill(units.push, { ms2: '5.7', g: '0.58' })); + expect(formatRatio(5.714)).toBe(fill(units.ratio, { ratio: '5.7' })); + expect(formatHoverThrottle(300)).toBe(fill(units.hover, { thrust: '18', speed: '42' })); + }); + + it('shows the packet timing and the reach with two digits', () => { + expect(PACKET_RATES.map((rate) => formatMilliseconds(packetPeriodMs(rate)))).toEqual( + ['20', '6.7', '4', '2'].map((value) => fill(units.ms, { value })), + ); + expect(PACKET_RATES.map((rate) => formatTimes(rangeFactor(rate)))).toEqual( + ['3.2', '2.2', '1.4', '1'].map((factor) => fill(units.times, { factor })), + ); + }); + + it('shows sprint seconds, with an infinity sign for no tilt', () => { + expect(formatSeconds(4.9)).toBe(fill(units.seconds, { value: '4.9' })); + expect(formatSeconds(Number.POSITIVE_INFINITY)).toBe(fill(units.seconds, { value: '∞' })); + }); + + it('fills every move, mode, video, signal and speedster text', () => { + MOVE_IDS.forEach((move) => { + expect(isFilled(formatMix(move)), move).toBe(true); + expect(formatMix(move)).toContain('%'); + expect(isFilled(formatWhy(move)), move).toBe(true); + }); + FLIGHT_MODE_IDS.forEach((mode) => { + expect(isFilled(formatStick(mode)), mode).toBe(true); + expect(isFilled(formatLimit(mode)), mode).toBe(true); + }); + expect(formatLimit('acro')).toContain('670'); + VIDEO_IDS.forEach((video) => { + expect(formatLatency(video)).toBe(en.link.latency[video]); + expect(formatPicture(video)).toBe(en.link.picture[video]); + }); + expect(formatSignal(1)).toBe(en.link.signal.strong); + expect(formatSignal(0)).toBe(en.link.signal.lost); + SPEEDSTER_IDS.forEach((id) => expect(formatWho(id)).toBe(en.speeds.who[id])); + }); +}); diff --git a/explainers/fpv-drone/src/ui/format.ts b/explainers/fpv-drone/src/ui/format.ts new file mode 100644 index 00000000..24953122 --- /dev/null +++ b/explainers/fpv-drone/src/ui/format.ts @@ -0,0 +1,196 @@ +import { formatFixed, formatNumber, formatSignificant } from '@core/format'; +import { t } from '@core/i18n'; +import { PHASE_IDS } from '../ids'; +import type { + BatteryReading, + FlightModeId, + MotorShares, + MoveId, + PhaseId, + SpeedsterId, + VideoId, +} from '../ids'; +import { + DEFAULT_MAX_RATE_DEG_S, + FLIGHT_MODES, + MOTOR_MIXES, + SPEEDSTERS, + clockAt, + hoverSpeedShare, + hoverThrustShare, + phaseAt, + signalGradeOf, +} from '../model'; + +const WHOLE = 0; +const TENTHS = 1; +const HUNDREDTHS = 2; +const SHORT_DIGITS = 2; +const PERCENT = 100; +const CLOCK_DIGITS = 2; +const CLOCK_PAD = '0'; +const CLOCK_SEPARATOR = ':'; +const MINUS_SIGN = '−'; + +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 percent(share: number): string { + return whole(share * PERCENT); +} + +function signedWhole(value: number): string { + const rounded = Math.round(value); + return rounded < 0 ? `${MINUS_SIGN}${whole(-rounded)}` : whole(rounded); +} + +export function formatClock(seconds: number): string { + const clock = clockAt(seconds); + const padded = String(clock.seconds).padStart(CLOCK_DIGITS, CLOCK_PAD); + return t('timeline.clock', { clock: `${clock.minutes}${CLOCK_SEPARATOR}${padded}` }); +} + +export function formatPhase(seconds: number): string { + return formatClock(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'); +} + +export function formatKmh(kmh: number): string { + return t('units.kmh', { value: whole(kmh) }); +} + +export function formatMetres(metres: number): string { + return t('units.m', { value: whole(metres) }); +} + +export function formatGrams(grams: number): string { + return t('units.g', { value: whole(grams) }); +} + +export function formatPercent(share: number): string { + return t('units.percent', { value: percent(share) }); +} + +export function formatOfHover(share: number): string { + return t('units.ofHover', { percent: percent(share) }); +} + +export function formatVolts(volts: number): string { + return t('units.volts', { value: tenths(volts) }); +} + +export function formatAmps(amps: number): string { + return t('units.amps', { value: tenths(amps) }); +} + +export function formatBattery({ volts, share }: BatteryReading): string { + return t('units.battery', { volts: tenths(volts), share: percent(share) }); +} + +export function formatMilliseconds(ms: number): string { + return t('units.ms', { value: formatSignificant(ms, SHORT_DIGITS) }); +} + +export function formatDbm(dbm: number): string { + return t('units.dbm', { value: signedWhole(dbm) }); +} + +export function formatMinutes(minutes: number): string { + return t('units.minutes', { value: tenths(minutes) }); +} + +export function formatSeconds(seconds: number): string { + return t('units.seconds', { value: formatSignificant(seconds, SHORT_DIGITS) }); +} + +export function formatDegrees(degrees: number): string { + return t('units.degrees', { value: whole(degrees) }); +} + +export function formatPush(metresPerSecondSquared: number, gForce: number): string { + return t('units.push', { + ms2: tenths(metresPerSecondSquared), + g: formatFixed(gForce, HUNDREDTHS), + }); +} + +export function formatTimes(factor: number): string { + return t('units.times', { factor: formatSignificant(factor, SHORT_DIGITS) }); +} + +export function formatRatio(ratio: number): string { + return t('units.ratio', { ratio: tenths(ratio) }); +} + +export function formatHoverThrottle(payloadG: number): string { + return t('units.hover', { + thrust: percent(hoverThrustShare(payloadG)), + speed: percent(hoverSpeedShare(payloadG)), + }); +} + +function mixValues([m1, m2, m3, m4]: MotorShares): Record { + return { m1: percent(m1), m2: percent(m2), m3: percent(m3), m4: percent(m4) }; +} + +export function formatMix(move: MoveId): string { + return t(`flight.mix.${move}`, mixValues(MOTOR_MIXES[move])); +} + +export function formatWhy(move: MoveId): string { + return t(`flight.why.${move}`); +} + +export function formatStick(mode: FlightModeId): string { + return t(FLIGHT_MODES[mode].stickKey); +} + +export function formatLimit(mode: FlightModeId): string { + return t(FLIGHT_MODES[mode].limitKey, { rate: formatNumber(DEFAULT_MAX_RATE_DEG_S) }); +} + +export function formatLatency(video: VideoId): string { + return t(`link.latency.${video}`); +} + +export function formatPicture(video: VideoId): string { + return t(`link.picture.${video}`); +} + +export function formatSignal(share: number): string { + return t(`link.signal.${signalGradeOf(share)}`); +} + +export function formatWho(speedster: SpeedsterId): string { + return t(SPEEDSTERS[speedster].whoKey); +} diff --git a/explainers/fpv-drone/src/ui/index.ts b/explainers/fpv-drone/src/ui/index.ts new file mode 100644 index 00000000..d433594e --- /dev/null +++ b/explainers/fpv-drone/src/ui/index.ts @@ -0,0 +1,30 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import type { FpvStore } from '../state'; +import { mountFlightReadouts } from './mountFlightReadouts'; +import { mountGogglesFeed } from './mountGogglesFeed'; +import { mountLimitsReadouts } from './mountLimitsReadouts'; +import { mountLinkReadouts } from './mountLinkReadouts'; +import { mountModeReadouts } from './mountModeReadouts'; +import { mountOverviewReadouts } from './mountOverviewReadouts'; +import { mountPayloadControl } from './mountPayloadControl'; +import { mountPowerReadouts } from './mountPowerReadouts'; +import { mountTiltControl } from './mountTiltControl'; + +export { CHAPTER_ACTIONS } from './actions'; +export { FPV_CHOICES, VIEW_TOGGLES } from './dock'; +export { FPV_READOUTS } from './readouts'; + +export function mountFpvUi(root: Document, store: FpvStore): Disposer { + return disposeAll([ + mountOverviewReadouts(root, store), + mountFlightReadouts(root, store), + mountTiltControl(root, store), + mountModeReadouts(root, store), + mountLinkReadouts(root, store), + mountPayloadControl(root, store), + mountPowerReadouts(root, store), + mountLimitsReadouts(root, store), + mountGogglesFeed(root, store), + ]); +} diff --git a/explainers/fpv-drone/src/ui/mountFlightReadouts.ts b/explainers/fpv-drone/src/ui/mountFlightReadouts.ts new file mode 100644 index 00000000..a19d0f38 --- /dev/null +++ b/explainers/fpv-drone/src/ui/mountFlightReadouts.ts @@ -0,0 +1,11 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { FpvStore } from '../state'; +import { formatMix, formatWhy } from './format'; + +export function mountFlightReadouts(root: Document, store: FpvStore): Disposer { + return mountLiveReadouts(root, store, { + 'flight-mix': (state) => formatMix(state.move), + 'flight-why': (state) => formatWhy(state.move), + }); +} diff --git a/explainers/fpv-drone/src/ui/mountGogglesFeed.dom.test.ts b/explainers/fpv-drone/src/ui/mountGogglesFeed.dom.test.ts new file mode 100644 index 00000000..7ec8d254 --- /dev/null +++ b/explainers/fpv-drone/src/ui/mountGogglesFeed.dom.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { initI18n } from '@core/i18n'; +import en from '../../locales/en.json'; +import { createFpvStore } from '../state'; +import type { FpvStore } from '../state'; +import { mountGogglesFeed } from './mountGogglesFeed'; + +const STAGE = '
'; + +function feed(): HTMLElement | null { + return document.querySelector('#scene .goggles-feed'); +} + +describe('goggles feed', () => { + let store: FpvStore; + let dispose: () => void; + + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + beforeEach(() => { + document.body.innerHTML = STAGE; + store = createFpvStore({ playing: false, phase: 0 }); + dispose = mountGogglesFeed(document, store); + }); + + afterEach(() => dispose()); + + it('stays hidden from readers and inactive outside the goggles chapter', () => { + expect(feed()?.getAttribute('aria-hidden')).toBe('true'); + expect(feed()?.dataset.active).toBe('false'); + }); + + it('marks the picture as the goggles view once the camera rides in the drone', () => { + store.getState().applyPreset('limits'); + expect(feed()?.dataset.active).toBe('false'); + store.getState().setThroughGoggles(true); + expect(feed()?.dataset.active).toBe('true'); + expect(feed()?.textContent).toBe(`${en.goggles.caption}${en.controls.videoOptions.analogue}`); + store.getState().applyPreset('power'); + expect(feed()?.dataset.active).toBe('false'); + }); + + it('drops the mark when the reader pulls the camera out of the drone', () => { + store.getState().applyPreset('limits'); + store.getState().setThroughGoggles(true); + store.getState().setThroughGoggles(false); + expect(feed()?.dataset.active).toBe('false'); + }); + + it('names the video system the pilot picked', () => { + store.getState().setVideo('digital'); + expect(feed()?.dataset.video).toBe('digital'); + expect(feed()?.textContent).toContain(en.controls.videoOptions.digital); + }); + + it('leaves the stage as it found it and mounts nothing without a scene', () => { + dispose(); + expect(feed()).toBeNull(); + document.body.innerHTML = ''; + dispose = mountGogglesFeed(document, store); + expect(document.querySelector('.goggles-feed')).toBeNull(); + }); +}); diff --git a/explainers/fpv-drone/src/ui/mountGogglesFeed.ts b/explainers/fpv-drone/src/ui/mountGogglesFeed.ts new file mode 100644 index 00000000..ddf0a7e7 --- /dev/null +++ b/explainers/fpv-drone/src/ui/mountGogglesFeed.ts @@ -0,0 +1,57 @@ +import { t } from '@core/i18n'; +import type { Disposer } from '@core/ui/disposers'; +import { watchShallowLocalized } from '@core/ui/subscribe'; +import type { CameraView, VideoId } from '../ids'; +import { PRESETS } from '../state'; +import type { FpvStore, FpvStoreState } from '../state'; + +const SCENE_SELECTOR = '#scene'; +const GOGGLES_CAMERA: CameraView = 'fpv'; +const NOTHING_MOUNTED: Disposer = () => undefined; + +interface Feed { + root: HTMLElement; + caption: HTMLElement; + video: HTMLElement; +} + +function createFeed(document: Document): Feed { + const root = document.createElement('div'); + root.className = 'goggles-feed'; + root.setAttribute('aria-hidden', 'true'); + const label = document.createElement('p'); + label.className = 'goggles-feed__label'; + const caption = document.createElement('span'); + const video = document.createElement('span'); + video.className = 'goggles-feed__video'; + label.append(caption, video); + root.append(label); + return { root, caption, video }; +} + +function isThroughGoggles(state: FpvStoreState): boolean { + return state.throughGoggles && PRESETS[state.preset].camera === GOGGLES_CAMERA; +} + +function show(feed: Feed, active: boolean, video: VideoId): void { + feed.root.dataset.active = String(active); + feed.root.dataset.video = video; + feed.caption.textContent = t('goggles.caption'); + feed.video.textContent = t(`controls.videoOptions.${video}`); +} + +export function mountGogglesFeed(root: Document, store: FpvStore): Disposer { + const scene = root.querySelector(SCENE_SELECTOR); + if (!scene) return NOTHING_MOUNTED; + const feed = createFeed(root); + scene.append(feed.root); + const stop = watchShallowLocalized( + store, + (state) => [isThroughGoggles(state), state.video] as const, + ([active, video]) => show(feed, active, video), + ); + return () => { + stop(); + feed.root.remove(); + }; +} diff --git a/explainers/fpv-drone/src/ui/mountLimitsReadouts.ts b/explainers/fpv-drone/src/ui/mountLimitsReadouts.ts new file mode 100644 index 00000000..62ef7c9b --- /dev/null +++ b/explainers/fpv-drone/src/ui/mountLimitsReadouts.ts @@ -0,0 +1,13 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { SPEEDSTERS, secondsOverField } from '../model'; +import type { FpvStore } from '../state'; +import { formatKmh, formatSeconds, formatWho } from './format'; + +export function mountLimitsReadouts(root: Document, store: FpvStore): Disposer { + return mountLiveReadouts(root, store, { + 'limits-speed': (state) => formatKmh(SPEEDSTERS[state.speedster].topKmh), + 'limits-field': (state) => formatSeconds(secondsOverField(SPEEDSTERS[state.speedster].topKmh)), + 'limits-who': (state) => formatWho(state.speedster), + }); +} diff --git a/explainers/fpv-drone/src/ui/mountLinkReadouts.ts b/explainers/fpv-drone/src/ui/mountLinkReadouts.ts new file mode 100644 index 00000000..5b2d4b2d --- /dev/null +++ b/explainers/fpv-drone/src/ui/mountLinkReadouts.ts @@ -0,0 +1,26 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { packetPeriodMs, rangeFactor, sensitivityDbm } from '../model'; +import { sortieAt } from '../state'; +import type { FpvStore } from '../state'; +import { + formatDbm, + formatLatency, + formatMetres, + formatMilliseconds, + formatPicture, + formatSignal, + formatTimes, +} from './format'; + +export function mountLinkReadouts(root: Document, store: FpvStore): Disposer { + return mountLiveReadouts(root, store, { + 'link-latency': (state) => formatLatency(state.video), + 'link-picture': (state) => formatPicture(state.video), + 'rate-period': (state) => formatMilliseconds(packetPeriodMs(state.packetRate)), + 'rate-sensitivity': (state) => formatDbm(sensitivityDbm(state.packetRate)), + 'rate-reach': (state) => formatTimes(rangeFactor(state.packetRate)), + 'link-distance': (state) => formatMetres(sortieAt(state).link.distance), + 'link-signal': (state) => formatSignal(sortieAt(state).link.signal), + }); +} diff --git a/explainers/fpv-drone/src/ui/mountModeReadouts.ts b/explainers/fpv-drone/src/ui/mountModeReadouts.ts new file mode 100644 index 00000000..0a694d1a --- /dev/null +++ b/explainers/fpv-drone/src/ui/mountModeReadouts.ts @@ -0,0 +1,11 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { FpvStore } from '../state'; +import { formatLimit, formatStick } from './format'; + +export function mountModeReadouts(root: Document, store: FpvStore): Disposer { + return mountLiveReadouts(root, store, { + 'mode-stick': (state) => formatStick(state.flightMode), + 'mode-limit': (state) => formatLimit(state.flightMode), + }); +} diff --git a/explainers/fpv-drone/src/ui/mountOverviewReadouts.ts b/explainers/fpv-drone/src/ui/mountOverviewReadouts.ts new file mode 100644 index 00000000..8cf027b6 --- /dev/null +++ b/explainers/fpv-drone/src/ui/mountOverviewReadouts.ts @@ -0,0 +1,14 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { sortieShareAt } from '../model'; +import { sortieAt } from '../state'; +import type { FpvStore } from '../state'; +import { formatBattery, formatMetres, formatPercent } from './format'; + +export function mountOverviewReadouts(root: Document, store: FpvStore): Disposer { + return mountLiveReadouts(root, store, { + 'overview-battery': (state) => formatBattery(sortieAt(state).battery), + 'overview-distance': (state) => formatMetres(sortieAt(state).link.distance), + 'overview-done': (state) => formatPercent(sortieShareAt(state.phase)), + }); +} diff --git a/explainers/fpv-drone/src/ui/mountPayloadControl.ts b/explainers/fpv-drone/src/ui/mountPayloadControl.ts new file mode 100644 index 00000000..10f3f22b --- /dev/null +++ b/explainers/fpv-drone/src/ui/mountPayloadControl.ts @@ -0,0 +1,24 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import { PAYLOAD_RANGE, allUpG, flightMinutes, thrustToWeight } from '../model'; +import type { FpvStore } from '../state'; +import { formatGrams, formatHoverThrottle, formatMinutes, formatRatio } from './format'; + +type SelectedPayload = readonly [payload: number]; + +export function mountPayloadControl(root: Document, store: FpvStore): Disposer { + return mountRangeWidget(root, store, { + control: 'payload', + range: PAYLOAD_RANGE, + select: (state): SelectedPayload => [state.payload], + value: ([payload]) => payload, + format: ([payload]) => formatGrams(payload), + set: (state, payload) => state.setPayload(payload), + readouts: { + 'power-weight': ([payload]) => formatGrams(allUpG(payload)), + 'power-twr': ([payload]) => formatRatio(thrustToWeight(payload)), + 'power-hover': ([payload]) => formatHoverThrottle(payload), + 'power-minutes': ([payload]) => formatMinutes(flightMinutes(payload)), + }, + }); +} diff --git a/explainers/fpv-drone/src/ui/mountPowerReadouts.ts b/explainers/fpv-drone/src/ui/mountPowerReadouts.ts new file mode 100644 index 00000000..8256d192 --- /dev/null +++ b/explainers/fpv-drone/src/ui/mountPowerReadouts.ts @@ -0,0 +1,12 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { sortieAt } from '../state'; +import type { FpvStore } from '../state'; +import { formatAmps, formatVolts } from './format'; + +export function mountPowerReadouts(root: Document, store: FpvStore): Disposer { + return mountLiveReadouts(root, store, { + 'power-volts': (state) => formatVolts(sortieAt(state).battery.volts), + 'power-amps': (state) => formatAmps(sortieAt(state).battery.amps), + }); +} diff --git a/explainers/fpv-drone/src/ui/mountTiltControl.ts b/explainers/fpv-drone/src/ui/mountTiltControl.ts new file mode 100644 index 00000000..3e67b065 --- /dev/null +++ b/explainers/fpv-drone/src/ui/mountTiltControl.ts @@ -0,0 +1,30 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import { + SPRINT_KMH, + TILT_RANGE, + accelerationAt, + accelerationInG, + secondsToKmh, + thrustShareAt, +} from '../model'; +import type { FpvStore } from '../state'; +import { formatDegrees, formatOfHover, formatPush, formatSeconds } from './format'; + +type SelectedTilt = readonly [tilt: number]; + +export function mountTiltControl(root: Document, store: FpvStore): Disposer { + return mountRangeWidget(root, store, { + control: 'tilt', + range: TILT_RANGE, + select: (state): SelectedTilt => [state.tilt], + value: ([tilt]) => tilt, + format: ([tilt]) => formatDegrees(tilt), + set: (state, tilt) => state.setTilt(tilt), + readouts: { + 'tilt-push': ([tilt]) => formatPush(accelerationAt(tilt), accelerationInG(tilt)), + 'tilt-thrust': ([tilt]) => formatOfHover(thrustShareAt(tilt)), + 'tilt-sprint': ([tilt]) => formatSeconds(secondsToKmh(tilt, SPRINT_KMH)), + }, + }); +} diff --git a/explainers/fpv-drone/src/ui/mountUi.dom.test.ts b/explainers/fpv-drone/src/ui/mountUi.dom.test.ts new file mode 100644 index 00000000..97ce234c --- /dev/null +++ b/explainers/fpv-drone/src/ui/mountUi.dom.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { initI18n } from '@core/i18n'; +import { mountActions } from '@core/ui/actions'; +import chapters from '../../chapters.html?raw'; +import en from '../../locales/en.json'; +import { + FLIGHT_MODE_IDS, + MOMENT_IDS, + MOVE_IDS, + PACKET_RATES, + SPEEDSTER_IDS, + VIDEO_IDS, +} from '../ids'; +import { MOMENTS, SORTIE_SECONDS, flightMinutes, sortieShareAt } from '../model'; +import { createFpvStore } from '../state'; +import type { FpvStore } from '../state'; +import { CHAPTER_ACTIONS } from './actions'; +import { formatMinutes, formatPercent } from './format'; +import { FPV_READOUTS } from './readouts'; +import { fill, isFilled } from './testing'; +import { mountFpvUi } from '.'; + +const { units } = en; + +function readout(id: string): string | null | undefined { + return document.querySelector(`[data-readout="${id}"]`)?.textContent; +} + +function click(action: string, value: string): void { + document.querySelector(`[data-action="${action}"][data-value="${value}"]`)?.click(); +} + +function pressed(action: string, value: string): string | null | undefined { + return document + .querySelector(`[data-action="${action}"][data-value="${value}"]`) + ?.getAttribute('aria-pressed'); +} + +function slide(control: string, value: number): void { + const input = document.querySelector(`[data-control="${control}"]`); + if (!input) throw new Error(`No control ${control}`); + input.value = String(value); + input.dispatchEvent(new Event('input')); +} + +function output(control: string): string | null | undefined { + return document.querySelector(`output[for="${control}"]`)?.textContent; +} + +function expectAllFilled(): void { + document + .querySelectorAll('[data-readout]') + .forEach((element) => + expect(isFilled(element.textContent), element.getAttribute('data-readout') ?? '').toBe(true), + ); +} + +describe('chapter widgets', () => { + let store: FpvStore; + let dispose: () => void; + + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + beforeEach(() => { + document.body.innerHTML = chapters; + store = createFpvStore({ playing: false, phase: 0 }); + dispose = mountFpvUi(document, store); + mountActions(document, store, CHAPTER_ACTIONS); + }); + + afterEach(() => dispose()); + + it('follows the battery, the distance and the share flown in the overview', () => { + expect(readout('overview-battery')).toBe(fill(units.battery, { volts: '25.2', share: '100' })); + expect(readout('overview-distance')).toBe(fill(units.m, { value: '6' })); + expect(readout('overview-done')).toBe(fill(units.percent, { value: '0' })); + click('moment', 'onStation'); + expect(pressed('moment', 'onStation')).toBe('true'); + expect(readout('overview-distance')).toBe(fill(units.m, { value: '358' })); + expect(readout('overview-done')).toBe(formatPercent(sortieShareAt(MOMENTS.onStation))); + click('moment', 'touchdown'); + expect(readout('overview-distance')).toBe(fill(units.m, { value: '6' })); + expect(readout('overview-battery')).not.toBe( + fill(units.battery, { volts: '25.2', share: '100' }), + ); + }); + + it('shows the motor shares and the reason for each move', () => { + expect(pressed('move', 'hover')).toBe('true'); + expect(readout('flight-mix')).toBe( + fill(en.flight.mix.hover, { m1: '18', m2: '18', m3: '18', m4: '18' }), + ); + expect(readout('flight-why')).toBe(en.flight.why.hover); + click('move', 'forward'); + expect(pressed('move', 'forward')).toBe('true'); + expect(readout('flight-mix')).toBe( + fill(en.flight.mix.forward, { m1: '22', m2: '12', m3: '22', m4: '12' }), + ); + expect(readout('flight-why')).toBe(en.flight.why.forward); + }); + + it('turns the tilt slider into push, thrust and sprint time', () => { + expect(output('tilt')).toBe(fill(units.degrees, { value: '30' })); + expect(readout('tilt-push')).toBe(fill(units.push, { ms2: '5.7', g: '0.58' })); + expect(readout('tilt-thrust')).toBe(fill(units.ofHover, { percent: '115' })); + expect(readout('tilt-sprint')).toBe(fill(units.seconds, { value: '4.9' })); + slide('tilt', 45); + expect(store.getState().tilt).toBe(45); + expect(output('tilt')).toBe(fill(units.degrees, { value: '45' })); + expect(readout('tilt-push')).toBe(fill(units.push, { ms2: '9.8', g: '1.00' })); + expect(readout('tilt-thrust')).toBe(fill(units.ofHover, { percent: '141' })); + expect(readout('tilt-sprint')).toBe(fill(units.seconds, { value: '2.8' })); + slide('tilt', 0); + expect(readout('tilt-sprint')).toBe(fill(units.seconds, { value: '∞' })); + }); + + it('describes the stick and the limit of each flight mode', () => { + expect(pressed('flightMode', 'acro')).toBe('true'); + expect(readout('mode-stick')).toBe(en.mode.stick.acro); + expect(readout('mode-limit')).toContain('670'); + click('flightMode', 'angle'); + expect(pressed('flightMode', 'angle')).toBe('true'); + expect(readout('mode-stick')).toBe(en.mode.stick.angle); + expect(readout('mode-limit')).toBe(en.mode.limit.angle); + }); + + it('compares the video systems and the packet rates in the link chapter', () => { + expect(pressed('video', 'analogue')).toBe('true'); + expect(readout('link-latency')).toBe(en.link.latency.analogue); + click('video', 'digital'); + expect(store.getState().video).toBe('digital'); + expect(readout('link-picture')).toBe(en.link.picture.digital); + expect(pressed('packetRate', '250')).toBe('true'); + expect(readout('rate-period')).toBe(fill(units.ms, { value: '4' })); + expect(readout('rate-sensitivity')).toBe(fill(units.dbm, { value: '−108' })); + expect(readout('rate-reach')).toBe(fill(units.times, { factor: '1.4' })); + click('packetRate', '50'); + expect(pressed('packetRate', '50')).toBe('true'); + expect(readout('rate-period')).toBe(fill(units.ms, { value: '20' })); + expect(readout('rate-sensitivity')).toBe(fill(units.dbm, { value: '−115' })); + expect(readout('rate-reach')).toBe(fill(units.times, { factor: '3.2' })); + expect(readout('link-distance')).toBe(fill(units.m, { value: '6' })); + expect(readout('link-signal')).toBe(en.link.signal.strong); + click('moment', 'onStation'); + expect(readout('link-distance')).toBe(fill(units.m, { value: '358' })); + expect(readout('link-signal')).toBe(en.link.signal.strong); + }); + + it('loads the drone with the payload slider and reads the live pack', () => { + expect(output('payload')).toBe(fill(units.g, { value: '300' })); + expect(readout('power-weight')).toBe(fill(units.g, { value: '1,400' })); + expect(readout('power-twr')).toBe(fill(units.ratio, { ratio: '5.7' })); + expect(readout('power-hover')).toBe(fill(units.hover, { thrust: '18', speed: '42' })); + expect(readout('power-minutes')).toBe(formatMinutes(flightMinutes(300))); + expect(readout('power-volts')).toBe(fill(units.volts, { value: '25.2' })); + expect(readout('power-amps')).toBe(fill(units.amps, { value: '0.0' })); + slide('payload', 1000); + expect(store.getState().payload).toBe(1000); + expect(output('payload')).toBe(fill(units.g, { value: '1,000' })); + expect(readout('power-weight')).toBe(fill(units.g, { value: '2,100' })); + expect(readout('power-twr')).toBe(fill(units.ratio, { ratio: '3.8' })); + expect(readout('power-minutes')).toBe(formatMinutes(flightMinutes(1000))); + click('moment', 'cruise'); + expect(readout('power-amps')).not.toBe(fill(units.amps, { value: '0.0' })); + expect(readout('power-volts')).not.toBe(fill(units.volts, { value: '25.2' })); + }); + + it('races the three machines over the field', () => { + expect(pressed('speedster', 'longRange')).toBe('true'); + expect(readout('limits-speed')).toBe(fill(units.kmh, { value: '140' })); + expect(readout('limits-field')).toBe(fill(units.seconds, { value: '9' })); + expect(readout('limits-who')).toBe(en.speeds.who.longRange); + click('speedster', 'record'); + expect(pressed('speedster', 'record')).toBe('true'); + expect(readout('limits-speed')).toBe(fill(units.kmh, { value: '658' })); + expect(readout('limits-field')).toBe(fill(units.seconds, { value: '1.9' })); + expect(readout('limits-who')).toBe(en.speeds.who.record); + }); + + it('fills every readout at every moment and chip', () => { + MOMENT_IDS.forEach((moment) => { + store.getState().seekMoment(moment); + MOVE_IDS.forEach((move) => { + store.setState({ move }); + expectAllFilled(); + }); + FLIGHT_MODE_IDS.forEach((flightMode) => { + store.setState({ flightMode }); + expectAllFilled(); + }); + PACKET_RATES.forEach((packetRate) => { + store.setState({ packetRate }); + expectAllFilled(); + }); + VIDEO_IDS.forEach((video) => { + store.setState({ video }); + expectAllFilled(); + }); + SPEEDSTER_IDS.forEach((speedster) => { + store.setState({ speedster }); + expectAllFilled(); + }); + }); + }); + + it('fills every gauge readout through the whole sortie', () => { + for (let phase = 0; phase <= SORTIE_SECONDS; phase += 2) { + const state = createFpvStore({ phase }).getState(); + FPV_READOUTS.forEach((row) => + expect(isFilled(row.value(state)), `${row.id} at ${phase}`).toBe(true), + ); + } + }); + + it('stops updating once disposed', () => { + dispose(); + store.getState().setPhase(40); + expect(readout('overview-done')).toBe(fill(units.percent, { value: '0' })); + }); +}); diff --git a/explainers/fpv-drone/src/ui/palette.ts b/explainers/fpv-drone/src/ui/palette.ts new file mode 100644 index 00000000..5ff30e3a --- /dev/null +++ b/explainers/fpv-drone/src/ui/palette.ts @@ -0,0 +1,13 @@ +import type { PhaseId } from '../ids'; +import { THEME } from '../theme'; + +export const PHASE_TONES: Readonly> = { + takeoff: THEME.takeoff, + climb: THEME.climb, + transit: THEME.transit, + orbit: THEME.orbit, + return: THEME.return, + landing: THEME.landing, +}; + +export const BATTERY_METER_FILL = THEME.battery; diff --git a/explainers/fpv-drone/src/ui/readouts.test.ts b/explainers/fpv-drone/src/ui/readouts.test.ts new file mode 100644 index 00000000..89047f78 --- /dev/null +++ b/explainers/fpv-drone/src/ui/readouts.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { SORTIE_SECONDS, batteryAt } from '../model'; +import { createFpvStore } from '../state'; +import type { FpvStoreState } from '../state'; +import { THEME } from '../theme'; +import { FPV_READOUTS } from './readouts'; + +function readout(id: string) { + const found = FPV_READOUTS.find((candidate) => candidate.id === id); + if (!found) throw new Error(`No readout ${id}`); + return found; +} + +function stateAt(phase: number, payload = 300): FpvStoreState { + return createFpvStore({ phase, payload }).getState(); +} + +describe('gauge readouts', () => { + it('shows the clock, the speed, the height, the battery and the distance', () => { + expect(FPV_READOUTS.map((row) => row.id)).toEqual([ + 'clock', + 'speed', + 'height', + 'battery', + 'distance', + ]); + FPV_READOUTS.forEach((row) => expect(row.numeric, row.id).toBe(true)); + FPV_READOUTS.forEach((row) => expect(row.labelKey).toBe(`readouts.${row.id}`)); + }); + + it('fills the battery meter with what is left in the pack', () => { + const { meter } = readout('battery'); + expect(meter?.fill).toBe(THEME.battery); + expect(meter?.share(stateAt(0))).toBe(1); + expect(meter?.share(stateAt(SORTIE_SECONDS))).toBeCloseTo( + batteryAt(SORTIE_SECONDS, 300).share, + 9, + ); + expect(meter?.share(stateAt(SORTIE_SECONDS, 1500))).toBeLessThan( + meter?.share(stateAt(SORTIE_SECONDS)) ?? 0, + ); + }); + + it('meters only the battery', () => { + ['clock', 'speed', 'height', 'distance'].forEach((id) => + expect(readout(id).meter).toBeUndefined(), + ); + }); +}); diff --git a/explainers/fpv-drone/src/ui/readouts.ts b/explainers/fpv-drone/src/ui/readouts.ts new file mode 100644 index 00000000..bf225740 --- /dev/null +++ b/explainers/fpv-drone/src/ui/readouts.ts @@ -0,0 +1,39 @@ +import type { Readout } from '@core/explainer'; +import { sortieAt } from '../state'; +import type { FpvStoreState } from '../state'; +import { formatBattery, formatClock, formatKmh, formatMetres } from './format'; +import { BATTERY_METER_FILL } from './palette'; + +export const FPV_READOUTS: readonly Readout[] = [ + { + id: 'clock', + labelKey: 'readouts.clock', + numeric: true, + value: (state) => formatClock(state.phase), + }, + { + id: 'speed', + labelKey: 'readouts.speed', + numeric: true, + value: (state) => formatKmh(sortieAt(state).flight.speedKmh), + }, + { + id: 'height', + labelKey: 'readouts.height', + numeric: true, + value: (state) => formatMetres(sortieAt(state).flight.height), + }, + { + id: 'battery', + labelKey: 'readouts.battery', + numeric: true, + value: (state) => formatBattery(sortieAt(state).battery), + meter: { share: (state) => sortieAt(state).battery.share, fill: BATTERY_METER_FILL }, + }, + { + id: 'distance', + labelKey: 'readouts.distance', + numeric: true, + value: (state) => formatMetres(sortieAt(state).link.distance), + }, +]; diff --git a/explainers/fpv-drone/src/ui/testing.ts b/explainers/fpv-drone/src/ui/testing.ts new file mode 100644 index 00000000..b8e1cc66 --- /dev/null +++ b/explainers/fpv-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/explainers/glider/src/scene/cameraViews.ts b/explainers/glider/src/scene/cameraViews.ts index 828cc409..96489049 100644 --- a/explainers/glider/src/scene/cameraViews.ts +++ b/explainers/glider/src/scene/cameraViews.ts @@ -62,7 +62,7 @@ export function cameraViews( const target = chaseTarget(); return target ? chasePose(target, slopes) : null; }, - follow: true, + follow: 'position', }; return { ...FIXED_VIEWS, chase }; } diff --git a/explainers/heart/src/scene/cameraViews.test.ts b/explainers/heart/src/scene/cameraViews.test.ts index fe639a94..f79065ec 100644 --- a/explainers/heart/src/scene/cameraViews.test.ts +++ b/explainers/heart/src/scene/cameraViews.test.ts @@ -42,7 +42,7 @@ describe('camera views', () => { const valve = anchorAt(21, 2, -1); const view = cameraViews(() => valve).valve as CustomView; const pose = view.pose(SLOPES); - expect(view.follow).toBe(true); + expect(view.follow).toBe('position'); expect(view.distance).toEqual(VALVE_DISTANCE); expect(pose).not.toBeNull(); if (!pose) return; diff --git a/explainers/heart/src/scene/cameraViews.ts b/explainers/heart/src/scene/cameraViews.ts index 39e11477..9887a6d3 100644 --- a/explainers/heart/src/scene/cameraViews.ts +++ b/explainers/heart/src/scene/cameraViews.ts @@ -41,7 +41,7 @@ function valveView(anchor: () => Object3D | null): CustomView { const object = anchor(); return object ? anchorPose(object, VALVE_FRAMING, slopes) : null; }, - follow: true, + follow: 'position', distance: VALVE_DISTANCE, }; } diff --git a/explainers/heart/src/scene/controller.test.ts b/explainers/heart/src/scene/controller.test.ts index fc5fe4a8..b8d3e6cb 100644 --- a/explainers/heart/src/scene/controller.test.ts +++ b/explainers/heart/src/scene/controller.test.ts @@ -87,7 +87,7 @@ describe('heart controller', () => { heart.build(stateWith('mitral')); picked = 'aortic'; heart.views.frame('valve', true); - expect(rig.follow).toHaveBeenLastCalledWith(anchorOf('aortic')); + expect(rig.follow).toHaveBeenLastCalledWith(anchorOf('aortic'), 'position'); expect(lastTarget(rig.tweenTo)?.distanceTo(anchorOf('aortic').position)).toBeCloseTo(0); }); @@ -104,7 +104,7 @@ describe('heart controller', () => { store.getState().applyPreset('valves'); (['tricuspid', 'pulmonary', 'aortic', 'mitral'] as const).forEach((valve) => { store.getState().setValve(valve); - expect(rig.follow, valve).toHaveBeenLastCalledWith(anchorOf(valve)); + expect(rig.follow, valve).toHaveBeenLastCalledWith(anchorOf(valve), 'position'); expect(lastTarget(rig.tweenTo)?.distanceTo(anchorOf(valve).position), valve).toBeCloseTo(0); }); }); diff --git a/explainers/mechanical-watch/src/scene/cameraViews.test.ts b/explainers/mechanical-watch/src/scene/cameraViews.test.ts index b1c09a45..78ee93fb 100644 --- a/explainers/mechanical-watch/src/scene/cameraViews.test.ts +++ b/explainers/mechanical-watch/src/scene/cameraViews.test.ts @@ -44,7 +44,7 @@ describe('camera views', () => { const wheel = anchorAt(20, -60, 12); const view = cameraViews({ ...NO_ANCHORS, wheel: () => wheel }).wheel as CustomView; const pose = view.pose(SLOPES); - expect(view.follow).toBe(true); + expect(view.follow).toBe('position'); expect(pose).not.toBeNull(); expect(pose!.target.distanceTo(wheel.position)).toBeCloseTo(0); expect(pose!.position.y).toBeGreaterThan(pose!.target.y); @@ -60,7 +60,7 @@ describe('camera views', () => { const fork = anchorAt(-40, -50, 24); const view = cameraViews({ ...NO_ANCHORS, fork: () => fork }).escapement as CustomView; const pose = view.pose(SLOPES); - expect(view.follow).toBe(false); + expect(view.follow).toBeUndefined(); expect(pose!.position.x).toBeGreaterThan(pose!.target.x); expect(pose!.position.z).toBeGreaterThan(pose!.target.z); }); diff --git a/explainers/mechanical-watch/src/scene/cameraViews.ts b/explainers/mechanical-watch/src/scene/cameraViews.ts index 61ea1d1d..1ede550b 100644 --- a/explainers/mechanical-watch/src/scene/cameraViews.ts +++ b/explainers/mechanical-watch/src/scene/cameraViews.ts @@ -1,6 +1,7 @@ import { Vector3 } from 'three'; import type { Object3D } from 'three'; import { toRadians } from '@core/math'; +import type { FollowMode } from '@core/scene/camera'; import type { CustomView, FramedView, ViewSpec } from '@core/scene/cameraViews'; import type { CameraPose } from '@core/scene/frameBox'; import type { FramingSlopes } from '@core/scene/lens'; @@ -69,7 +70,7 @@ export function anchorPose( function anchoredView( anchor: () => Object3D | null, framing: AnchorFraming, - follow: boolean, + follow?: FollowMode, ): CustomView { return { pose: (slopes) => { @@ -84,7 +85,7 @@ function anchoredView( export function cameraViews(anchors: ViewAnchors): Record> { return { ...FRAMED_VIEWS, - wheel: anchoredView(anchors.wheel, ANCHORED_VIEWS.wheel, true), - escapement: anchoredView(anchors.fork, ANCHORED_VIEWS.escapement, false), + wheel: anchoredView(anchors.wheel, ANCHORED_VIEWS.wheel, 'position'), + escapement: anchoredView(anchors.fork, ANCHORED_VIEWS.escapement), }; } diff --git a/explainers/oil-rig/src/scene/cameraViews.test.ts b/explainers/oil-rig/src/scene/cameraViews.test.ts index 1eae3694..0115b674 100644 --- a/explainers/oil-rig/src/scene/cameraViews.test.ts +++ b/explainers/oil-rig/src/scene/cameraViews.test.ts @@ -17,7 +17,7 @@ describe('camera views', () => { bit.position.set(0, -300, 0); const view = cameraViews(() => bit).bit as CustomView; const pose = view.pose(SLOPES); - expect(view.follow).toBe(true); + expect(view.follow).toBe('position'); expect(pose).not.toBeNull(); expect(pose!.position.z).toBeGreaterThan(0); expect(pose!.position.x).toBeGreaterThan(0); diff --git a/explainers/oil-rig/src/scene/cameraViews.ts b/explainers/oil-rig/src/scene/cameraViews.ts index 59c4f05b..cadb5d77 100644 --- a/explainers/oil-rig/src/scene/cameraViews.ts +++ b/explainers/oil-rig/src/scene/cameraViews.ts @@ -71,7 +71,7 @@ export function cameraViews( const anchor = bitAnchor(); return anchor ? bitPose(anchor, slopes) : null; }, - follow: true, + follow: 'position', distance: BIT_VIEW.distance, }; return { ...FRAMED_VIEWS, bit }; diff --git a/explainers/reaper/src/scene/cameraViews.test.ts b/explainers/reaper/src/scene/cameraViews.test.ts index c8c67d08..1e56137f 100644 --- a/explainers/reaper/src/scene/cameraViews.test.ts +++ b/explainers/reaper/src/scene/cameraViews.test.ts @@ -37,7 +37,7 @@ describe('camera views', () => { it('follows the aircraft everywhere but the orbit and the strike', () => { (['chase', 'side', 'wide', 'nose'] as const).forEach((id) => - expect(views[id].follow, id).toBe(true), + expect(views[id].follow, id).toBe('position'), ); (['orbit', 'strike'] as const).forEach((id) => expect(views[id].follow, id).toBeFalsy()); }); diff --git a/explainers/reaper/src/scene/cameraViews.ts b/explainers/reaper/src/scene/cameraViews.ts index bea6c6fe..7404e10d 100644 --- a/explainers/reaper/src/scene/cameraViews.ts +++ b/explainers/reaper/src/scene/cameraViews.ts @@ -119,7 +119,7 @@ function followView(source: ChaseSource, spec: FollowSpec): CustomView { const chase = source(); return chase ? followPose(chase, spec, slopes) : null; }, - follow: true, + follow: 'position', }; } diff --git a/explainers/reaper/src/scene/controller.test.ts b/explainers/reaper/src/scene/controller.test.ts index e6098ba3..c5ba0e85 100644 --- a/explainers/reaper/src/scene/controller.test.ts +++ b/explainers/reaper/src/scene/controller.test.ts @@ -84,7 +84,7 @@ describe('reaper controller', () => { reaper.views.frame('chase', true); const target = rig.tweenTo.mock.lastCall?.[0]?.target as Vector3 | undefined; expect(target?.toArray()).toEqual([500, 300, 0]); - expect(rig.follow).toHaveBeenLastCalledWith(expect.anything()); + expect(rig.follow).toHaveBeenLastCalledWith(expect.anything(), 'position'); }); it('frames the orbit and the strike on fixed layout regions without following', () => { @@ -94,12 +94,12 @@ describe('reaper controller', () => { expect(rig.tweenTo).not.toHaveBeenCalled(); reaper.build(STATE); reaper.views.frame('orbit', true); - expect(rig.follow).toHaveBeenLastCalledWith(null); + expect(rig.follow).toHaveBeenLastCalledWith(null, undefined); const orbitTarget = rig.tweenTo.mock.lastCall?.[0]?.target as Vector3 | undefined; expect(orbitTarget?.x).toBeCloseTo(LOITER.centre[0], 6); expect(orbitTarget?.z).toBeCloseTo(LOITER.centre[1], 6); reaper.views.frame('strike', false); - expect(rig.follow).toHaveBeenLastCalledWith(null); + expect(rig.follow).toHaveBeenLastCalledWith(null, undefined); expect(rig.jumpTo).toHaveBeenCalledTimes(1); }); diff --git a/scripts/cards/fpv-drone.html b/scripts/cards/fpv-drone.html new file mode 100644 index 00000000..febf6827 --- /dev/null +++ b/scripts/cards/fpv-drone.html @@ -0,0 +1,163 @@ + + + + + How an FPV drone flies: social card + + + + + + +
+ +
+
+

+ + FPV drone +

+

How an FPV drone flies

+

+ Four motors, a camera and a pair of goggles: the pilot flies from inside the drone, one + motor speed change at a time. +

+
    +
  1. Sortie
  2. +
  3. Flight
  4. +
  5. Controller
  6. +
  7. Link
  8. +
  9. Power
  10. +
  11. Limits
  12. +
+
+

whatmakesittick.github.io/fpv-drone

+ + + diff --git a/src/core/scene/camera.test.ts b/src/core/scene/camera.test.ts index f1e3b43f..b87ef443 100644 --- a/src/core/scene/camera.test.ts +++ b/src/core/scene/camera.test.ts @@ -15,6 +15,12 @@ const BOUNDS = new Box3(new Vector3(-2, 0, -2), new Vector3(2, 4, 2)); const VIEWPORT: ViewportSize = { width: 800, height: 600, safe: NO_SAFE_AREA }; const SCENE_DISTANCE = { min: 3, max: 50 }; const VIEW_MIN_DISTANCE = 1; +const DEFAULT_FLOOR_MARGIN = 1; +const GROUND_TARGET: CameraPose = { position: new Vector3(0, 2, 4), target: new Vector3(0, 0, 0) }; + +function expectVector(actual: Vector3, expected: [number, number, number]): void { + actual.toArray().forEach((value, index) => expect(value).toBeCloseTo(expected[index])); +} function fakeCanvas(): HTMLElement { const root = new EventTarget(); @@ -57,6 +63,23 @@ describe('CameraRig', () => { rig.dispose(); }); + it('keeps the target a metre above the floor unless the explainer sets its own margin', () => { + const lifted = new CameraRig(fakeCanvas()); + lifted.setBounds(BOUNDS, BOUNDS.min.y); + lifted.jumpTo(GROUND_TARGET); + lifted.update(FRAME_SECONDS); + expect(lifted.controls.target.y).toBeCloseTo(DEFAULT_FLOOR_MARGIN); + expect(lifted.camera.position.y).toBeCloseTo(GROUND_TARGET.position.y + DEFAULT_FLOOR_MARGIN); + lifted.dispose(); + + const grounded = new CameraRig(fakeCanvas(), { floorMargin: 0 }); + grounded.setBounds(BOUNDS, BOUNDS.min.y); + grounded.jumpTo(GROUND_TARGET); + grounded.update(FRAME_SECONDS); + expect(grounded.controls.target.y).toBeCloseTo(0); + grounded.dispose(); + }); + it('applies the camera planes and orbit limit an explainer asks for', () => { const rig = new CameraRig(fakeCanvas(), { near: 2, far: 9000, maxPolarAngle: 2 }); expect(rig.camera.near).toBe(2); @@ -92,6 +115,34 @@ describe('CameraRig', () => { expectPose(rig, CLOSE_UP, MOVE); }); + it('swings round an anchor that turns about the vertical when following its heading', () => { + const anchor = new Object3D(); + const rig = new CameraRig(fakeCanvas()); + rig.follow(anchor, 'heading'); + rig.jumpTo(CLOSE_UP); + anchor.rotation.set(Math.PI / 2, Math.PI / 2, 0); + rig.update(FRAME_SECONDS); + expect(rig.camera.position.toArray().map((v) => Math.round(v))).toEqual([4, 3, 0]); + expect(rig.controls.target.toArray().map((v) => Math.round(v))).toEqual([0, 0, 0]); + expect(rig.camera.up.toArray()).toEqual([0, 1, 0]); + rig.dispose(); + }); + + it('banks with an anchor when following its attitude, and stands upright again afterwards', () => { + const anchor = new Object3D(); + const rig = new CameraRig(fakeCanvas()); + rig.follow(anchor, 'attitude'); + rig.jumpTo(CLOSE_UP); + anchor.rotation.z = Math.PI / 4; + rig.update(FRAME_SECONDS); + const side = 3 * Math.SQRT1_2; + expectVector(rig.camera.position, [-side, side, 4]); + expectVector(rig.camera.up, [-Math.SQRT1_2, Math.SQRT1_2, 0]); + rig.follow(null); + expect(rig.camera.up.toArray()).toEqual([0, 1, 0]); + rig.dispose(); + }); + it('follows an anchor that moves with its parent', () => { const parent = new Object3D(); const anchor = new Object3D(); diff --git a/src/core/scene/camera.ts b/src/core/scene/camera.ts index c5ba263d..b3b21a91 100644 --- a/src/core/scene/camera.ts +++ b/src/core/scene/camera.ts @@ -1,4 +1,4 @@ -import { PerspectiveCamera, Sphere, Vector3 } from 'three'; +import { PerspectiveCamera, Quaternion, Sphere, Vector3 } from 'three'; import type { Box3, Object3D } from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { @@ -17,6 +17,14 @@ import type { FramingSlopes, ViewportSize } from './lens'; import { Listeners } from './listeners'; const TARGET_FLOOR_MARGIN = 1; +const WORLD_UP = new Vector3(0, 1, 0); + +export type FollowMode = 'position' | 'heading' | 'attitude'; + +function twistAboutUp(turn: Quaternion): Quaternion { + turn.set(0, turn.y, 0, turn.w); + return turn.lengthSq() > 0 ? turn.normalize() : turn.identity(); +} export interface CameraDistance { min?: number; @@ -28,6 +36,7 @@ export interface CameraOptions { far?: number; maxPolarAngle?: number; distance?: CameraDistance; + floorMargin?: number; } export class CameraRig { @@ -35,10 +44,15 @@ export class CameraRig { readonly controls: OrbitControls; private tween: CameraTween | null = null; private anchor: Object3D | null = null; + private followMode: FollowMode = 'position'; private readonly anchorPosition = new Vector3(); + private readonly anchorRotation = new Quaternion(); private readonly followedPosition = new Vector3(); + private readonly followedRotation = new Quaternion(); private readonly followShift = new Vector3(); + private readonly followTurn = new Quaternion(); private floorHeight = -Infinity; + private readonly floorMargin: number; private boundsRadius = 1; private readonly sceneDistance: CameraDistance; private distanceOverride: CameraDistance = {}; @@ -46,8 +60,14 @@ export class CameraRig { private readonly changes = new Listeners<[]>(); constructor(domElement: HTMLElement, options: CameraOptions = {}) { - const { near = CAMERA_NEAR, far = CAMERA_FAR, maxPolarAngle = CAMERA_MAX_POLAR } = options; + const { + near = CAMERA_NEAR, + far = CAMERA_FAR, + maxPolarAngle = CAMERA_MAX_POLAR, + floorMargin = TARGET_FLOOR_MARGIN, + } = options; this.sceneDistance = options.distance ?? {}; + this.floorMargin = floorMargin; this.camera = new PerspectiveCamera(CAMERA_FOV, 1, near, far); this.controls = new OrbitControls(this.camera, domElement); this.controls.enableDamping = true; @@ -85,8 +105,10 @@ export class CameraRig { this.notifyChange(); } - follow(anchor: Object3D | null): void { + follow(anchor: Object3D | null, mode: FollowMode = 'position'): void { this.anchor = anchor; + this.followMode = mode; + this.camera.up.copy(WORLD_UP); this.syncAnchor(); this.notifyChange(); } @@ -130,13 +152,31 @@ export class CameraRig { private syncAnchor(): void { this.anchor?.getWorldPosition(this.anchorPosition); + this.anchor?.getWorldQuaternion(this.anchorRotation); } private followAnchor(): void { if (!this.anchor) return; const position = this.anchor.getWorldPosition(this.followedPosition); + const rotation = this.anchor.getWorldQuaternion(this.followedRotation); this.shift(this.followShift.subVectors(position, this.anchorPosition)); + this.turnWith(position, rotation); this.anchorPosition.copy(position); + this.anchorRotation.copy(rotation); + } + + private turnWith(pivot: Vector3, rotation: Quaternion): void { + if (this.followMode === 'position') return; + const turn = this.followTurn.copy(this.anchorRotation).invert().premultiply(rotation); + if (this.followMode === 'heading') twistAboutUp(turn); + this.rotateAbout(pivot, turn); + if (this.followMode === 'attitude') this.camera.up.copy(WORLD_UP).applyQuaternion(rotation); + } + + private rotateAbout(pivot: Vector3, turn: Quaternion): void { + this.camera.position.sub(pivot).applyQuaternion(turn).add(pivot); + this.controls.target.sub(pivot).applyQuaternion(turn).add(pivot); + this.tween?.turn(pivot, turn); } private shift(delta: Vector3): void { @@ -174,7 +214,7 @@ export class CameraRig { } private keepTargetAboveFloor(): void { - const minimum = this.floorHeight + TARGET_FLOOR_MARGIN; + const minimum = this.floorHeight + this.floorMargin; const shortfall = minimum - this.controls.target.y; if (shortfall <= 0) return; this.controls.target.y += shortfall; diff --git a/src/core/scene/cameraTween.test.ts b/src/core/scene/cameraTween.test.ts index 8da915b9..ced4f206 100644 --- a/src/core/scene/cameraTween.test.ts +++ b/src/core/scene/cameraTween.test.ts @@ -1,4 +1,4 @@ -import { Vector3 } from 'three'; +import { Quaternion, Vector3 } from 'three'; import { describe, expect, it } from 'vitest'; import { CAMERA_TWEEN_SECONDS } from './constants'; import { CameraTween } from './cameraTween'; @@ -8,6 +8,12 @@ const FROM: CameraPose = { position: new Vector3(0, 3, 4), target: new Vector3(0 const TO: CameraPose = { position: new Vector3(6, 2, 0), target: new Vector3(1, 1, 0) }; const SHIFT = new Vector3(10, -2, 5); const HALFWAY = CAMERA_TWEEN_SECONDS / 2; +const PIVOT = new Vector3(2, 0, 2); +const QUARTER_TURN = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI / 2); + +function turned(point: Vector3): Vector3 { + return point.clone().sub(PIVOT).applyQuaternion(QUARTER_TURN).add(PIVOT); +} function expectCloseTo(actual: Vector3, expected: Vector3): void { expect(actual.distanceTo(expected)).toBeCloseTo(0); @@ -35,6 +41,16 @@ describe('CameraTween', () => { expectCloseTo(end.target, TO.target.clone().add(SHIFT)); }); + it('turns the whole tween about a pivot', () => { + const plain = new CameraTween(FROM, TO); + const spun = new CameraTween(FROM, TO); + spun.turn(PIVOT, QUARTER_TURN); + const expected = plain.advance(HALFWAY); + const actual = spun.advance(HALFWAY); + expectCloseTo(actual.position, turned(expected.position)); + expectCloseTo(actual.target, turned(expected.target)); + }); + it('leaves the poses it was given untouched', () => { const tween = new CameraTween(FROM, TO); tween.shift(SHIFT); diff --git a/src/core/scene/cameraTween.ts b/src/core/scene/cameraTween.ts index 3de70a85..8a0e0c36 100644 --- a/src/core/scene/cameraTween.ts +++ b/src/core/scene/cameraTween.ts @@ -1,4 +1,5 @@ import { Spherical, Vector3 } from 'three'; +import type { Quaternion } from 'three'; import { CAMERA_TWEEN_SECONDS } from './constants'; import type { CameraPose } from './frameBox'; @@ -36,6 +37,13 @@ export class CameraTween { }); } + turn(pivot: Vector3, rotation: Quaternion): void { + [this.from, this.to].forEach((pose) => { + pose.position.sub(pivot).applyQuaternion(rotation).add(pivot); + pose.target.sub(pivot).applyQuaternion(rotation).add(pivot); + }); + } + advance(deltaSeconds: number): CameraPose { this.elapsed = Math.min(CAMERA_TWEEN_SECONDS, this.elapsed + deltaSeconds); return this.poseAt(easeInOutCubic(this.elapsed / CAMERA_TWEEN_SECONDS)); diff --git a/src/core/scene/cameraViews.test.ts b/src/core/scene/cameraViews.test.ts index 4c2283e8..c3eacd80 100644 --- a/src/core/scene/cameraViews.test.ts +++ b/src/core/scene/cameraViews.test.ts @@ -8,7 +8,7 @@ import type { FramingSlopes } from './lens'; type View = 'front' | 'side' | 'chase'; type Region = 'body'; -type Call = readonly [name: string, value: unknown]; +type Call = readonly [name: string, ...values: unknown[]]; const SLOPES: FramingSlopes = { vertical: 0.3, horizontal: 0.5 }; const BODY = new Box3(new Vector3(-1, 0, -2), new Vector3(1, 2, 2)); @@ -18,7 +18,7 @@ const CHASE_DISTANCE = { min: 1 }; const VIEWS: Record> = { front: { region: 'body', direction: [0, 0, 1], margin: FRONT_MARGIN }, side: { region: 'body', direction: { single: [1, 0, 0], wide: [0, 1, 0] }, margin: 1 }, - chase: { pose: () => CHASE_POSE, follow: true, distance: CHASE_DISTANCE }, + chase: { pose: () => CHASE_POSE, follow: 'heading', distance: CHASE_DISTANCE }, }; interface Harness { @@ -31,7 +31,7 @@ function createHarness(region: Box3 | null = BODY): Harness { const calls: Call[] = []; const rig: ViewRig = { framing: () => SLOPES, - follow: (anchor) => calls.push(['follow', anchor]), + follow: (anchor, mode) => calls.push(['follow', anchor, mode]), setDistanceLimits: (limits) => calls.push(['distance', limits]), jumpTo: (pose) => calls.push(['jumpTo', pose]), tweenTo: (pose) => calls.push(['tweenTo', pose]), @@ -77,7 +77,7 @@ describe('CameraViews', () => { const { views, calls, anchor } = createHarness(); views.frame('chase', true); expect(calls).toEqual([ - ['follow', anchor], + ['follow', anchor, 'heading'], ['distance', CHASE_DISTANCE], ['tweenTo', CHASE_POSE], ]); @@ -87,7 +87,7 @@ describe('CameraViews', () => { const { views, calls } = createHarness(); views.frame('front', false); expect(calls).toEqual([ - ['follow', null], + ['follow', null, undefined], ['distance', {}], ['jumpTo', views.pose('front')], ]); diff --git a/src/core/scene/cameraViews.ts b/src/core/scene/cameraViews.ts index c3778553..bd2266d0 100644 --- a/src/core/scene/cameraViews.ts +++ b/src/core/scene/cameraViews.ts @@ -1,6 +1,6 @@ import { Vector3 } from 'three'; import type { Box3, Object3D } from 'three'; -import type { CameraDistance, CameraRig } from './camera'; +import type { CameraDistance, CameraRig, FollowMode } from './camera'; import { frameBox } from './frameBox'; import type { CameraPose } from './frameBox'; import type { FramingSlopes } from './lens'; @@ -8,7 +8,7 @@ import type { FramingSlopes } from './lens'; export type Direction = readonly [x: number, y: number, z: number]; interface ViewBehaviour { - follow?: boolean; + follow?: FollowMode; distance?: CameraDistance; } @@ -72,7 +72,7 @@ export class CameraViews { const pose = this.pose(view, variant); if (!pose) return; const spec = this.options.views[view]; - this.rig.follow(spec.follow ? (this.options.anchor?.() ?? null) : null); + this.rig.follow(spec.follow ? (this.options.anchor?.() ?? null) : null, spec.follow); this.rig.setDistanceLimits(spec.distance ?? {}); if (animate) this.rig.tweenTo(pose); else this.rig.jumpTo(pose);