From 3697d45cf770213f6c4f2bc855730af6b4eb700d Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:24:22 +0200 Subject: [PATCH 01/55] feat(fpv-drone): freeze the ids, layout, scale, theme and assembly contract --- explainers/fpv-drone/src/ids.ts | 123 +++++++++++++++ explainers/fpv-drone/src/model/layout.test.ts | 112 ++++++++++++++ explainers/fpv-drone/src/model/layout.ts | 101 ++++++++++++ explainers/fpv-drone/src/model/scale.test.ts | 14 ++ explainers/fpv-drone/src/model/scale.ts | 15 ++ explainers/fpv-drone/src/scene/assembly.ts | 34 ++++ .../src/scene/placeholderAssembly.ts | 145 ++++++++++++++++++ explainers/fpv-drone/src/theme.ts | 29 ++++ 8 files changed, 573 insertions(+) create mode 100644 explainers/fpv-drone/src/ids.ts create mode 100644 explainers/fpv-drone/src/model/layout.test.ts create mode 100644 explainers/fpv-drone/src/model/layout.ts create mode 100644 explainers/fpv-drone/src/model/scale.test.ts create mode 100644 explainers/fpv-drone/src/model/scale.ts create mode 100644 explainers/fpv-drone/src/scene/assembly.ts create mode 100644 explainers/fpv-drone/src/scene/placeholderAssembly.ts create mode 100644 explainers/fpv-drone/src/theme.ts 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/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/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/scene/assembly.ts b/explainers/fpv-drone/src/scene/assembly.ts new file mode 100644 index 00000000..ee6ae045 --- /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 { createPlaceholderAssembly } from './placeholderAssembly'; + +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 createPlaceholderAssembly(resources, state); +} diff --git a/explainers/fpv-drone/src/scene/placeholderAssembly.ts b/explainers/fpv-drone/src/scene/placeholderAssembly.ts new file mode 100644 index 00000000..1b994ff7 --- /dev/null +++ b/explainers/fpv-drone/src/scene/placeholderAssembly.ts @@ -0,0 +1,145 @@ +import { Box3, BoxGeometry, Group, Mesh, PlaneGeometry } from 'three'; +import type { Object3D } from 'three'; +import { STRUCTURE_GROUP, UNDIMMED_GROUP } from '@core/scene/materials'; +import { anchorAt } from '@core/scene/parts'; +import { regionFromSpec } from '@core/scene/regions'; +import { ResourceTracker } from '@core/scene/resources'; +import type { AnchorId, AssemblyState, PartId, Point, RegionId } from '../ids'; +import { + CROSSROADS, + CROSSROADS_BOUNDS, + DRONE, + DRONE_LAYOUT, + ROUTE_BOUNDS, + SCENE_BOUNDS, + STATION, + STATION_BOUNDS, +} from '../model/layout'; +import { droneUnits } from '../model/scale'; +import { THEME } from '../theme'; +import type { Assembly, AssemblyResources, ChaseTarget } from './assembly'; + +const GROUND_SIZE = 600; +const STATION_SIZE: Point = [3, 1.2, 3]; +const CROSSROADS_SIZE: Point = [8, 0.2, 8]; +const HALF = 0.5; + +function pointOf(point: Point): Point { + return [point[0], point[1], point[2]]; +} + +class PlaceholderAssembly implements Assembly { + readonly root = new Group(); + private readonly tracker = new ResourceTracker(); + private readonly resources: AssemblyResources; + private readonly drone = new Group(); + private readonly labels = new Map(); + private readonly named: Record; + private readonly regions: Record; + private chase: ChaseTarget = { position: [0, 0, 0], heading: 0, pitch: 0, roll: 0, span: 1 }; + + constructor(resources: AssemblyResources, state: AssemblyState) { + this.resources = resources; + const station = this.block(STATION_SIZE, STATION, 'groundStation', THEME.oliveDark); + const crossroads = this.block(CROSSROADS_SIZE, CROSSROADS, 'crossroads', THEME.earth); + const body = this.block( + [droneUnits(DRONE.plateLength), droneUnits(DRONE.plateGap), droneUnits(DRONE.plateWidth)], + [0, 0, 0], + 'frame', + THEME.carbon, + ); + this.drone.add(body); + const camera = anchorAt(this.drone, ...this.scaled(DRONE_LAYOUT.camera)); + this.labels.set('camera', camera); + this.root.add(this.ground(), station, crossroads, this.drone); + this.named = { + drone: this.drone, + camera, + station, + crossroads, + }; + this.regions = { + scene: regionFromSpec(SCENE_BOUNDS), + station: regionFromSpec(STATION_BOUNDS), + crossroads: regionFromSpec(CROSSROADS_BOUNDS), + route: regionFromSpec(ROUTE_BOUNDS), + drone: new Box3(), + }; + this.setState(state); + } + + private scaled(point: Point): Point { + return [droneUnits(point[0]), droneUnits(point[1]), droneUnits(point[2])]; + } + + private ground(): Mesh { + const geometry = this.tracker.track(new PlaneGeometry(GROUND_SIZE, GROUND_SIZE)); + const mesh = new Mesh( + geometry, + this.resources.materials.get(UNDIMMED_GROUP, { color: THEME.grass, roughness: 1 }), + ); + mesh.rotation.x = -Math.PI * HALF; + return mesh; + } + + private block( + size: Point, + at: Point, + group: PartId | typeof STRUCTURE_GROUP, + color: string, + ): Mesh { + const geometry = this.tracker.track(new BoxGeometry(...size)); + const mesh = new Mesh(geometry, this.resources.materials.get(group, { color, roughness: 0.8 })); + mesh.position.set(at[0], at[1] + size[1] * HALF, at[2]); + if (group !== STRUCTURE_GROUP) this.labels.set(group, mesh); + return mesh; + } + + setState(state: AssemblyState): void { + const { position, heading, pitch, roll } = state.flight; + this.drone.position.set(position[0], position[1] + droneUnits(DRONE.restHeight), position[2]); + this.drone.rotation.set(roll, -heading, -pitch, 'YZX'); + this.drone.updateMatrixWorld(); + this.regions.drone.setFromObject(this.drone); + this.chase = { + position: pointOf(position), + heading, + pitch, + roll, + span: droneUnits(DRONE.wheelbase), + }; + } + + update(): boolean { + return false; + } + + 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; + } + + dispose(): void { + this.root.removeFromParent(); + this.resources.materials.clearRegistered(); + this.tracker.dispose(); + } +} + +export function createPlaceholderAssembly( + resources: AssemblyResources, + state: AssemblyState, +): Assembly { + return new PlaceholderAssembly(resources, state); +} diff --git a/explainers/fpv-drone/src/theme.ts b/explainers/fpv-drone/src/theme.ts new file mode 100644 index 00000000..b6b8295d --- /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: '#5a6b7d', + skyHorizon: '#b9c3cc', +} as const; From 3af65bb9f9fafe2adcd15f8ed252998f70311157 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:39:02 +0200 Subject: [PATCH 02/55] feat(fpv-drone): add the sortie clock, flight path and figures model --- .../fpv-drone/src/model/figures.test.ts | 46 ++++ explainers/fpv-drone/src/model/figures.ts | 58 +++++ explainers/fpv-drone/src/model/flight.test.ts | 173 ++++++++++++++ explainers/fpv-drone/src/model/flight.ts | 225 ++++++++++++++++++ .../fpv-drone/src/model/playback.test.ts | 16 ++ explainers/fpv-drone/src/model/playback.ts | 5 + .../fpv-drone/src/model/profile.test.ts | 52 ++++ explainers/fpv-drone/src/model/profile.ts | 75 ++++++ explainers/fpv-drone/src/model/sortie.test.ts | 49 ++++ explainers/fpv-drone/src/model/sortie.ts | 61 +++++ 10 files changed, 760 insertions(+) create mode 100644 explainers/fpv-drone/src/model/figures.test.ts create mode 100644 explainers/fpv-drone/src/model/figures.ts create mode 100644 explainers/fpv-drone/src/model/flight.test.ts create mode 100644 explainers/fpv-drone/src/model/flight.ts create mode 100644 explainers/fpv-drone/src/model/playback.test.ts create mode 100644 explainers/fpv-drone/src/model/playback.ts create mode 100644 explainers/fpv-drone/src/model/profile.test.ts create mode 100644 explainers/fpv-drone/src/model/profile.ts create mode 100644 explainers/fpv-drone/src/model/sortie.test.ts create mode 100644 explainers/fpv-drone/src/model/sortie.ts 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..e63a6ed3 --- /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, + distanceAt, + 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 = distanceAt(seconds); + expect(distance).toBeGreaterThanOrEqual(last); + last = distance; + } + expect(distanceAt(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(distanceAt(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(distanceAt(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(distanceAt(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..334b4ff0 --- /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 distanceAt(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 = distanceAt(seconds - half); + const to = distanceAt(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(distanceAt(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(distanceAt(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/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/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/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..7d8330de --- /dev/null +++ b/explainers/fpv-drone/src/model/sortie.ts @@ -0,0 +1,61 @@ +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 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; +} From 20980eb8cea761fdf78df476a55729b901a8cb45 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:41:51 +0200 Subject: [PATCH 03/55] feat(fpv-drone): add the motor mix, tilt, power, payload and battery model --- .../fpv-drone/src/model/battery.test.ts | 75 ++++++++++ explainers/fpv-drone/src/model/battery.ts | 81 +++++++++++ explainers/fpv-drone/src/model/motors.test.ts | 131 ++++++++++++++++++ explainers/fpv-drone/src/model/motors.ts | 82 +++++++++++ .../fpv-drone/src/model/payload.test.ts | 43 ++++++ explainers/fpv-drone/src/model/payload.ts | 43 ++++++ explainers/fpv-drone/src/model/power.test.ts | 23 +++ explainers/fpv-drone/src/model/power.ts | 23 +++ explainers/fpv-drone/src/model/tilt.test.ts | 38 +++++ explainers/fpv-drone/src/model/tilt.ts | 21 +++ 10 files changed, 560 insertions(+) create mode 100644 explainers/fpv-drone/src/model/battery.test.ts create mode 100644 explainers/fpv-drone/src/model/battery.ts create mode 100644 explainers/fpv-drone/src/model/motors.test.ts create mode 100644 explainers/fpv-drone/src/model/motors.ts create mode 100644 explainers/fpv-drone/src/model/payload.test.ts create mode 100644 explainers/fpv-drone/src/model/payload.ts create mode 100644 explainers/fpv-drone/src/model/power.test.ts create mode 100644 explainers/fpv-drone/src/model/power.ts create mode 100644 explainers/fpv-drone/src/model/tilt.test.ts create mode 100644 explainers/fpv-drone/src/model/tilt.ts 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..1d3f2586 --- /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 { 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 * SECONDS_PER_MINUTE; +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/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..cc841fcb --- /dev/null +++ b/explainers/fpv-drone/src/model/payload.ts @@ -0,0 +1,43 @@ +import { + MASS_G, + MIN_THRUST_TO_WEIGHT, + MOTOR_COUNT, + PACK, + THRUST_PER_MOTOR_G, + USABLE_ENERGY_SHARE, +} from './figures'; +import { cruisePowerW } from './power'; +import { SECONDS_PER_MINUTE } from './sortie'; + +export const PAYLOAD_RANGE = { + min: 0, + max: MASS_G.maxPayload, + step: 50, + default: MASS_G.defaultPayload, +} as const; + +const MINUTES_PER_HOUR = SECONDS_PER_MINUTE; +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/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/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); +} From 4e0de5ab326064603c872b3f815470263764545c Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:43:37 +0200 Subject: [PATCH 04/55] feat(fpv-drone): add the link, flight mode and speed comparison model --- explainers/fpv-drone/src/model/flight.test.ts | 12 ++-- explainers/fpv-drone/src/model/flight.ts | 10 ++-- explainers/fpv-drone/src/model/index.ts | 15 +++++ explainers/fpv-drone/src/model/link.test.ts | 59 +++++++++++++++++++ explainers/fpv-drone/src/model/link.ts | 47 +++++++++++++++ explainers/fpv-drone/src/model/modes.test.ts | 17 ++++++ explainers/fpv-drone/src/model/modes.ts | 14 +++++ explainers/fpv-drone/src/model/speeds.test.ts | 20 +++++++ explainers/fpv-drone/src/model/speeds.ts | 18 ++++++ 9 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 explainers/fpv-drone/src/model/index.ts create mode 100644 explainers/fpv-drone/src/model/link.test.ts create mode 100644 explainers/fpv-drone/src/model/link.ts create mode 100644 explainers/fpv-drone/src/model/modes.test.ts create mode 100644 explainers/fpv-drone/src/model/modes.ts create mode 100644 explainers/fpv-drone/src/model/speeds.test.ts create mode 100644 explainers/fpv-drone/src/model/speeds.ts diff --git a/explainers/fpv-drone/src/model/flight.test.ts b/explainers/fpv-drone/src/model/flight.test.ts index e63a6ed3..21d0d075 100644 --- a/explainers/fpv-drone/src/model/flight.test.ts +++ b/explainers/fpv-drone/src/model/flight.test.ts @@ -7,7 +7,7 @@ import { ORBIT_ENTRY_DISTANCE, PHASE_DISTANCES, ROUTE, - distanceAt, + distanceFlownAt, flightAt, pitchAt, rollAt, @@ -66,11 +66,11 @@ 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 = distanceAt(seconds); + const distance = distanceFlownAt(seconds); expect(distance).toBeGreaterThanOrEqual(last); last = distance; } - expect(distanceAt(SORTIE_SECONDS)).toBeCloseTo(ROUTE.length, 6); + expect(distanceFlownAt(SORTIE_SECONDS)).toBeCloseTo(ROUTE.length, 6); }); it('sits on the pad at the start and at the end', () => { @@ -98,13 +98,13 @@ describe('flightAt', () => { const cruise = flightAt(MOMENTS.cruise); expect(cruise.height).toBeCloseTo(CRUISE_HEIGHT, 9); expect(cruise.speedKmh).toBeCloseTo(SPEED_KMH.cruise, 9); - expect(distanceAt(MOMENTS.cruise)).toBeCloseTo(PHASE_DISTANCES.climb.to, 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(distanceAt(MOMENTS.onStation)).toBeCloseTo(ORBIT_ENTRY_DISTANCE, 9); + 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); @@ -117,7 +117,7 @@ describe('flightAt', () => { }); it('turns for home at forty nine seconds and stops over the pad before touching down', () => { - expect(distanceAt(MOMENTS.turnHome)).toBeCloseTo(PHASE_DISTANCES.orbit.to, 9); + 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); diff --git a/explainers/fpv-drone/src/model/flight.ts b/explainers/fpv-drone/src/model/flight.ts index 334b4ff0..af9e0453 100644 --- a/explainers/fpv-drone/src/model/flight.ts +++ b/explainers/fpv-drone/src/model/flight.ts @@ -154,7 +154,7 @@ export function heightAt(seconds: number): number { return HEIGHT.at(clampSeconds(seconds)); } -export function distanceAt(seconds: number): number { +export function distanceFlownAt(seconds: number): number { const time = clampSeconds(seconds); const phase = phaseAt(time); const { start, end } = PHASE_RANGES[phase]; @@ -183,14 +183,14 @@ function overlapShare(arc: Arc, from: number, to: number): number { export function rollAt(seconds: number): number { const half = ATTITUDE_BLEND_S / 2; - const from = distanceAt(seconds - half); - const to = distanceAt(seconds + half); + 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(distanceAt(seconds) / ROUTE.length).heading; + return ROUTE.poseAt(distanceFlownAt(seconds) / ROUTE.length).heading; } function rateOf(read: (seconds: number) => number, seconds: number): number { @@ -203,7 +203,7 @@ export function isArmedAt(seconds: number): boolean { export function flightAt(seconds: number): FlightMotion { const time = clampSeconds(seconds); - const pose = ROUTE.poseAt(distanceAt(time) / ROUTE.length); + const pose = ROUTE.poseAt(distanceFlownAt(time) / ROUTE.length); const height = HEIGHT.at(time); const position: Point = [pose.x, height, pose.z]; return { 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/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/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); +} From c561d8c955cb21a428fed0ae6724b0f618ddb462 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:47:18 +0200 Subject: [PATCH 05/55] feat(fpv-drone): add the timeline, number formats and phase palette --- explainers/fpv-drone/src/timeline.test.ts | 40 ++++ explainers/fpv-drone/src/timeline.ts | 48 +++++ .../fpv-drone/src/ui/format.dom.test.ts | 139 +++++++++++++ explainers/fpv-drone/src/ui/format.ts | 186 ++++++++++++++++++ explainers/fpv-drone/src/ui/palette.ts | 13 ++ explainers/fpv-drone/src/ui/testing.ts | 9 + 6 files changed, 435 insertions(+) create mode 100644 explainers/fpv-drone/src/timeline.test.ts create mode 100644 explainers/fpv-drone/src/timeline.ts create mode 100644 explainers/fpv-drone/src/ui/format.dom.test.ts create mode 100644 explainers/fpv-drone/src/ui/format.ts create mode 100644 explainers/fpv-drone/src/ui/palette.ts create mode 100644 explainers/fpv-drone/src/ui/testing.ts 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/format.dom.test.ts b/explainers/fpv-drone/src/ui/format.dom.test.ts new file mode 100644 index 00000000..31b5596d --- /dev/null +++ b/explainers/fpv-drone/src/ui/format.dom.test.ts @@ -0,0 +1,139 @@ +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, + 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(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..33c00203 --- /dev/null +++ b/explainers/fpv-drone/src/ui/format.ts @@ -0,0 +1,186 @@ +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 = ':'; + +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); +} + +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 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: whole(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/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/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 ?? ''); +} From bfe96f6ceba9248ab3c7309b4b4c428b4677c03f Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:48:51 +0200 Subject: [PATCH 06/55] feat(fpv-drone): add the presets, store and derived sortie readings --- .../fpv-drone/src/state/derived.test.ts | 21 +++ explainers/fpv-drone/src/state/derived.ts | 42 ++++++ explainers/fpv-drone/src/state/index.ts | 3 + .../fpv-drone/src/state/presets.test.ts | 70 ++++++++++ explainers/fpv-drone/src/state/presets.ts | 112 ++++++++++++++++ explainers/fpv-drone/src/state/store.test.ts | 121 ++++++++++++++++++ explainers/fpv-drone/src/state/store.ts | 110 ++++++++++++++++ 7 files changed, 479 insertions(+) create mode 100644 explainers/fpv-drone/src/state/derived.test.ts create mode 100644 explainers/fpv-drone/src/state/derived.ts create mode 100644 explainers/fpv-drone/src/state/index.ts create mode 100644 explainers/fpv-drone/src/state/presets.test.ts create mode 100644 explainers/fpv-drone/src/state/presets.ts create mode 100644 explainers/fpv-drone/src/state/store.test.ts create mode 100644 explainers/fpv-drone/src/state/store.ts 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..0da97e16 --- /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 to its second of the flight and never pauses', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].startAt)).toEqual([0, 5, 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..0f8459c2 --- /dev/null +++ b/explainers/fpv-drone/src/state/presets.ts @@ -0,0 +1,112 @@ +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> = { + overview: 0, + flight: 5, + 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 }, + startAt: CHAPTER_START_S.flight, + 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..ae0cd5e1 --- /dev/null +++ b/explainers/fpv-drone/src/state/store.test.ts @@ -0,0 +1,121 @@ +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 }, + }); + }); + + 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..e80d7028 --- /dev/null +++ b/explainers/fpv-drone/src/state/store.ts @@ -0,0 +1,110 @@ +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; +} + +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; +} + +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, + 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 }), + seekMoment: (moment) => { + get().pause(); + get().setPhase(MOMENTS[moment]); + }, + }), + presetState: chapterControls, + }, + overrides, + ); +} From 30ca0b45167c5b24a76d397105c0fd261373ce26 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:51:21 +0200 Subject: [PATCH 07/55] feat(fpv-drone): add the dock, gauge readouts, chapter actions and widgets --- explainers/fpv-drone/src/ui/actions.test.ts | 62 +++++ explainers/fpv-drone/src/ui/actions.ts | 53 +++++ explainers/fpv-drone/src/ui/dock.ts | 45 ++++ explainers/fpv-drone/src/ui/index.ts | 28 +++ .../fpv-drone/src/ui/mountFlightReadouts.ts | 11 + .../fpv-drone/src/ui/mountLimitsReadouts.ts | 13 ++ .../fpv-drone/src/ui/mountLinkReadouts.ts | 26 +++ .../fpv-drone/src/ui/mountModeReadouts.ts | 11 + .../fpv-drone/src/ui/mountOverviewReadouts.ts | 14 ++ .../fpv-drone/src/ui/mountPayloadControl.ts | 24 ++ .../fpv-drone/src/ui/mountPowerReadouts.ts | 12 + .../fpv-drone/src/ui/mountTiltControl.ts | 30 +++ .../fpv-drone/src/ui/mountUi.dom.test.ts | 220 ++++++++++++++++++ explainers/fpv-drone/src/ui/readouts.test.ts | 49 ++++ explainers/fpv-drone/src/ui/readouts.ts | 39 ++++ 15 files changed, 637 insertions(+) create mode 100644 explainers/fpv-drone/src/ui/actions.test.ts create mode 100644 explainers/fpv-drone/src/ui/actions.ts create mode 100644 explainers/fpv-drone/src/ui/dock.ts create mode 100644 explainers/fpv-drone/src/ui/index.ts create mode 100644 explainers/fpv-drone/src/ui/mountFlightReadouts.ts create mode 100644 explainers/fpv-drone/src/ui/mountLimitsReadouts.ts create mode 100644 explainers/fpv-drone/src/ui/mountLinkReadouts.ts create mode 100644 explainers/fpv-drone/src/ui/mountModeReadouts.ts create mode 100644 explainers/fpv-drone/src/ui/mountOverviewReadouts.ts create mode 100644 explainers/fpv-drone/src/ui/mountPayloadControl.ts create mode 100644 explainers/fpv-drone/src/ui/mountPowerReadouts.ts create mode 100644 explainers/fpv-drone/src/ui/mountTiltControl.ts create mode 100644 explainers/fpv-drone/src/ui/mountUi.dom.test.ts create mode 100644 explainers/fpv-drone/src/ui/readouts.test.ts create mode 100644 explainers/fpv-drone/src/ui/readouts.ts 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/index.ts b/explainers/fpv-drone/src/ui/index.ts new file mode 100644 index 00000000..d3d3dee4 --- /dev/null +++ b/explainers/fpv-drone/src/ui/index.ts @@ -0,0 +1,28 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import type { FpvStore } from '../state'; +import { mountFlightReadouts } from './mountFlightReadouts'; +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), + ]); +} 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/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..8d6d753b --- /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, formatPercent, 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]) => formatPercent(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..543c3c7f --- /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.percent, { value: '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.percent, { value: '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/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), + }, +]; From 294fd74d163834f97d365dd29f1d927b165b0e59 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:04:57 +0200 Subject: [PATCH 08/55] feat(fpv-drone): add the camera views, scene controller, bindings and labels --- .../fpv-drone/src/scene/bindings.test.ts | 141 ++++++++++++++++++ explainers/fpv-drone/src/scene/bindings.ts | 101 +++++++++++++ .../fpv-drone/src/scene/cameraViews.test.ts | 113 ++++++++++++++ explainers/fpv-drone/src/scene/cameraViews.ts | 114 ++++++++++++++ .../fpv-drone/src/scene/controller.test.ts | 106 +++++++++++++ explainers/fpv-drone/src/scene/controller.ts | 67 +++++++++ .../fpv-drone/src/scene/drawnLabels.test.ts | 46 ++++++ explainers/fpv-drone/src/scene/drawnLabels.ts | 59 ++++++++ explainers/fpv-drone/src/scene/index.ts | 50 +++++++ .../fpv-drone/src/scene/partInfo.test.ts | 42 ++++++ explainers/fpv-drone/src/scene/partInfo.ts | 44 ++++++ 11 files changed, 883 insertions(+) create mode 100644 explainers/fpv-drone/src/scene/bindings.test.ts create mode 100644 explainers/fpv-drone/src/scene/bindings.ts create mode 100644 explainers/fpv-drone/src/scene/cameraViews.test.ts create mode 100644 explainers/fpv-drone/src/scene/cameraViews.ts create mode 100644 explainers/fpv-drone/src/scene/controller.test.ts create mode 100644 explainers/fpv-drone/src/scene/controller.ts create mode 100644 explainers/fpv-drone/src/scene/drawnLabels.test.ts create mode 100644 explainers/fpv-drone/src/scene/drawnLabels.ts create mode 100644 explainers/fpv-drone/src/scene/index.ts create mode 100644 explainers/fpv-drone/src/scene/partInfo.test.ts create mode 100644 explainers/fpv-drone/src/scene/partInfo.ts 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..ca703cdf --- /dev/null +++ b/explainers/fpv-drone/src/scene/cameraViews.test.ts @@ -0,0 +1,113 @@ +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('follows the drone everywhere but the pilot view', () => { + (['chase', 'top', 'close', 'side', 'fpv'] as const).forEach((id) => + expect(views[id].follow, id).toBe(true), + ); + expect(views.pilot.follow).toBeFalsy(); + }); + + 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('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..72f3a8c1 --- /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: 2, aimAhead: 0, aimUp: 0 }, + close: { bearing: toRadians(-60), elevation: toRadians(20), spans: 1, aimAhead: 0, aimUp: 0.2 }, + side: { bearing: toRadians(90), elevation: toRadians(5), spans: 1.4, aimAhead: 0, aimUp: 0 }, +}; + +export const FIXED_VIEWS: Readonly>> = { + pilot: { region: 'route', direction: [-1, 0.25, 0.1], 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(width: number, slopes: FramingSlopes): number { + return width / (2 * slopes.horizontal); +} + +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: true, + }; +} + +function fpvView(source: ChaseSource): CustomView { + return { + pose: () => { + const chase = source(); + return chase ? fpvPose(chase) : null; + }, + follow: true, + }; +} + +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/controller.test.ts b/explainers/fpv-drone/src/scene/controller.test.ts new file mode 100644 index 00000000..39121a7f --- /dev/null +++ b/explainers/fpv-drone/src/scene/controller.test.ts @@ -0,0 +1,106 @@ +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()); + }); + + 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); + expect(rig.jumpTo).toHaveBeenCalledTimes(1); + }); + + 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..b9495aa3 --- /dev/null +++ b/explainers/fpv-drone/src/scene/controller.ts @@ -0,0 +1,67 @@ +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'; + +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 = () => {}; + + 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 + ); + } + + 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/index.ts b/explainers/fpv-drone/src/scene/index.ts new file mode 100644 index 00000000..c14a96c4 --- /dev/null +++ b/explainers/fpv-drone/src/scene/index.ts @@ -0,0 +1,50 @@ +import { createLabelVisibility } from '@core/scene/presetBinder'; +import type { SceneOptions, SceneShell } from '@core/scene/shell'; +import type { PartId } from '../ids'; +import { SCENE_BOUNDS } from '../model'; +import type { FpvStore } from '../state'; +import { THEME } from '../theme'; +import { bindStore } from './bindings'; +import { FpvController } from './controller'; +import { LABEL_PRIORITY } from './partInfo'; + +const UNDIMMED: readonly PartId[] = ['controlLink', 'videoLink', 'spinArrows']; +const HAZE = { near: 300, far: 1600 } as const; +const SCENE_SPAN = SCENE_BOUNDS.x[1] - SCENE_BOUNDS.x[0]; +const CAMERA_LIMITS = { + near: 0.1, + far: 4000, + minDistance: 2, + maxDistance: 1.5 * SCENE_SPAN, + maxPolarAngle: Math.PI * 0.86, +} as const; +const HIGHLIGHT_DIM = { saturation: 0.55, brightness: 0.62, emissive: 0.45 } as const; + +export const SCENE_OPTIONS: SceneOptions = { + background: THEME.skyHorizon, + fog: { color: THEME.skyHorizon, near: HAZE.near, far: HAZE.far }, + stage: false, + camera: { + near: CAMERA_LIMITS.near, + far: CAMERA_LIMITS.far, + maxPolarAngle: CAMERA_LIMITS.maxPolarAngle, + distance: { min: CAMERA_LIMITS.minDistance, max: CAMERA_LIMITS.maxDistance }, + }, + gaugeSide: 'top', + highlight: { dim: HIGHLIGHT_DIM, undimmed: UNDIMMED }, +}; + +export function mountFpvScene(shell: SceneShell, store: FpvStore): () => void { + const restoreLight = (): void => {}; + const drone = new FpvController(shell); + const removeFrame = shell.onFrame((deltaSeconds) => 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/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', +]; From aafa7d39e5034d631823e87d7f227e29ff51e71d Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:05:41 +0200 Subject: [PATCH 09/55] feat(fpv-drone): add the explainer entry, manifest, styles and placeholder images --- explainers/fpv-drone/explainer.json | 12 ++ explainers/fpv-drone/public/cover.webp | Bin 0 -> 31496 bytes .../fpv-drone/public/social/og-image.png | Bin 0 -> 114707 bytes explainers/fpv-drone/src/index.ts | 22 +++ explainers/fpv-drone/src/style.css | 173 ++++++++++++++++++ 5 files changed, 207 insertions(+) create mode 100644 explainers/fpv-drone/explainer.json create mode 100644 explainers/fpv-drone/public/cover.webp create mode 100644 explainers/fpv-drone/public/social/og-image.png create mode 100644 explainers/fpv-drone/src/index.ts create mode 100644 explainers/fpv-drone/src/style.css diff --git a/explainers/fpv-drone/explainer.json b/explainers/fpv-drone/explainer.json new file mode 100644 index 00000000..1e0e09c6 --- /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"], + "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/public/cover.webp b/explainers/fpv-drone/public/cover.webp new file mode 100644 index 0000000000000000000000000000000000000000..4360fefa01e842e527400c72eb2396dd32f0c1cb GIT binary patch literal 31496 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 Rm5~4d002ovPDHLkV1m2sa!vpM literal 0 HcmV?d00001 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/style.css b/explainers/fpv-drone/src/style.css new file mode 100644 index 00000000..1c606c02 --- /dev/null +++ b/explainers/fpv-drone/src/style.css @@ -0,0 +1,173 @@ +: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: #5b6b3a; +} + +.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)); + } +} From 9a367fc3ccd6ef84a1d0c5bd68e8d13d06492ce0 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:05:56 +0200 Subject: [PATCH 10/55] feat(fpv-drone): add the social card template --- scripts/cards/fpv-drone.html | 163 +++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 scripts/cards/fpv-drone.html 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

+ + + From 5430913724da2b7c48b4eda1fcb1a97300ae53fc Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:34:35 +0200 Subject: [PATCH 11/55] feat(fpv-drone): write the English chapters and locale --- explainers/fpv-drone/chapters.html | 630 +++++++++++++++++++++++++++ explainers/fpv-drone/locales/en.json | 286 ++++++++++++ 2 files changed, 916 insertions(+) create mode 100644 explainers/fpv-drone/chapters.html create mode 100644 explainers/fpv-drone/locales/en.json diff --git a/explainers/fpv-drone/chapters.html b/explainers/fpv-drone/chapters.html new file mode 100644 index 00000000..afcab398 --- /dev/null +++ b/explainers/fpv-drone/chapters.html @@ -0,0 +1,630 @@ +
+

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 four to five times their + weight in thrust, so they hover at 20 to 25 % throttle and keep plenty for a climb or a sprint. + Long-range quads carry less, at least two to one, because a bigger battery weighs them down. +

+

+ + + + + +

+
+
+
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. The ESC 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, + racers and front-line pilots alike, fly acro: 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 reach 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 13 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 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
+
13 min
+
+
+
+
+
+
Pack voltage now
+
23.8 V
+
+
+
Current now
+
20 A
+
+
+
+ +
+

06

+

Fast, cheap and everywhere

+

+ 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 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/locales/en.json b/explainers/fpv-drone/locales/en.json new file mode 100644 index 00000000..d104da3d --- /dev/null +++ b/explainers/fpv-drone/locales/en.json @@ -0,0 +1,286 @@ +{ + "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}} %", + "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 670 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 four to five times their weight in thrust, so they hover at 20 to 25 % throttle and keep plenty for a climb or a sprint. Long-range quads carry less, at least two to one, because a bigger battery weighs them down.", + "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. The ESC 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, racers and front-line pilots alike, fly acro: 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 reach 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 13 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 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": "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 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" + } + } +} From 5c1c5cf8f6c237df63e4ae9adc7e27eba9fe3fc7 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:03:39 +0200 Subject: [PATCH 12/55] feat(fpv-drone): translate the copy into seven languages --- explainers/fpv-drone/locales/de.json | 286 +++++++++++++++++++++++++++ explainers/fpv-drone/locales/es.json | 286 +++++++++++++++++++++++++++ explainers/fpv-drone/locales/fr.json | 286 +++++++++++++++++++++++++++ explainers/fpv-drone/locales/ja.json | 286 +++++++++++++++++++++++++++ explainers/fpv-drone/locales/pt.json | 286 +++++++++++++++++++++++++++ explainers/fpv-drone/locales/uk.json | 286 +++++++++++++++++++++++++++ explainers/fpv-drone/locales/zh.json | 286 +++++++++++++++++++++++++++ 7 files changed, 2002 insertions(+) create mode 100644 explainers/fpv-drone/locales/de.json create mode 100644 explainers/fpv-drone/locales/es.json create mode 100644 explainers/fpv-drone/locales/fr.json create mode 100644 explainers/fpv-drone/locales/ja.json create mode 100644 explainers/fpv-drone/locales/pt.json create mode 100644 explainers/fpv-drone/locales/uk.json create mode 100644 explainers/fpv-drone/locales/zh.json diff --git a/explainers/fpv-drone/locales/de.json b/explainers/fpv-drone/locales/de.json new file mode 100644 index 00000000..e8399fb9 --- /dev/null +++ b/explainers/fpv-drone/locales/de.json @@ -0,0 +1,286 @@ +{ + "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 schärft die Drohne, die Propeller laufen an, und sie 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}} %", + "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": "Stellung 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 670 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“, die Sicht aus der ersten Person. 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 Kohlefaser, zwei Platten und vier Arme. 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 Zelle 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 arbeiten 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. Der Pilot schärft die Drohne neben einem Unterstand, sie 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, Höhen und der Akku sind Entscheidungen des Modells, innerhalb der Werte, die ein echter 7-Zoll-Quad fliegt.", + "p4": "Die Uhr oben zählt echte Sekunden seit dem Schärfen. 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 Stellung des Piloten. Der Schalter Flugspur zeichnet den Weg hinter der Drohne. Der Schalter Drehrichtung zeigt, wie herum 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 mit dem Gewicht der Drohne nach oben drücken. 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 dreht die Drohne im Gegenzug mit. 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 0,58 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 das Vier- bis Fünffache ihres Gewichts an Schub. Sie schweben bei 20 bis 25 % Gas und behalten viel Reserve zum Steigen oder Sprinten. Long-Range-Quads haben weniger, mindestens zwei zu eins, weil der größere Akku sie schwer macht.", + "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. Der ESC meldet 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, Rennfahrer wie Frontpiloten, fliegen Acro: Er 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 schärft die Drohne: Er startet Motoren und Stabilisierung. Der Controller verweigert das Schärfen, 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 entschlüsselt das Paket 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 Stellung des Piloten und der Drohne laufen zwei Funkstrecken. Der Steuerfunk trägt die Knüppelstellungen hinaus. 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 entschlüsseln. 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 reichen 10 bis 15 km weit. Gutes Analogvideo hat aber immer noch die kürzeste Verzögerung, und es wird 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. 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": "Entfernung jetzt", + "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. Jede 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 13 Minuten. Ein größerer Akku hilft nicht lange: 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 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": "Spannung des Packs jetzt", + "amps": "Strom jetzt" + }, + "limits": { + "title": "Schnell, billig und überall", + "p1": "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 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 das Rennen folgte. 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 Zelle etwa 400 bis 500 US-Dollar, und die Ukraine plante für 2025 den Kauf von 4,5 Millionen FPV-Drohnen. In der Praxis arbeiten 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 Stellung 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" + } + } +} diff --git a/explainers/fpv-drone/locales/es.json b/explainers/fpv-drone/locales/es.json new file mode 100644 index 00000000..7d0e940f --- /dev/null +++ b/explainers/fpv-drone/locales/es.json @@ -0,0 +1,286 @@ +{ + "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 fuera 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": "Giro a casa", + "touchdown": "Aterrizaje" + }, + "moveOptions": { + "hover": "Flotar", + "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": "Plusmarquista" + } + }, + "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 flotando 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, flota 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}} %", + "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: cuánto de rápido 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. A fondo de palanca pide unos 670 grados por segundo de serie", + "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, plusmarquista 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, viendo 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 es de fibra de carbono, dos placas y cuatro brazos. 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. Fabricada en serie para un ejército, la estructura 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 trabajan 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 que le toca, para que puedas verlo. Las velocidades, las alturas y la batería son elecciones del propio modelo, dentro de lo que vuela un dron real de 7 pulgadas.", + "p4": "El reloj de arriba cuenta segundos reales desde el armado, 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 flotar, las cuatro hélices juntas tienen que empujar hacia arriba exactamente con el peso del dron. Cada motor puede dar hasta unos 2 kg de empuje, así que un dron de 1,4 kg flota 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 flotando, 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 llevan entre cuatro y cinco veces su peso en empuje, así que flotan con un 20 a 25 % de gas y les sobra mucho para subir o acelerar. Los de largo alcance llevan menos, al menos dos a uno, porque la batería grande pesa.", + "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. El ESC 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 de serie, cada palanca manda una velocidad de giro. Lleva la palanca de alabeo a la mitad y el dron rueda a ritmo constante; céntrala y el dron deja de rodar y mantiene el ángulo que haya alcanzado, incluso boca abajo. De serie, 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, tanto los de carreras como los del frente, vuelan en acro: da control total con viento y permite al dron ponerse en cualquier postura. Toca los botones para comparar los tres modos.", + "p4": "Hay dos seguridades que importan. 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, de serie, 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 llegan a 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 interferir.", + "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. Flotar 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 flotan lo menos posible.", + "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 flotar 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 13 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 de 4 a 6.", + "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 flotando. 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 para flotar", + "minutes": "Tiempo de vuelo", + "volts": "Tensión del pack ahora", + "amps": "Corriente ahora" + }, + "limits": { + "title": "Rápidos, baratos y por todas partes", + "p1": "¿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 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. Fabricada por miles para un ejército, la estructura cuesta unos 400 a 500 dólares, y Ucrania planeaba comprar 4,5 millones de drones FPV en 2025. En la práctica trabajan 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" + } + } +} diff --git a/explainers/fpv-drone/locales/fr.json b/explainers/fpv-drone/locales/fr.json new file mode 100644 index 00000000..de0eea50 --- /dev/null +++ b/explainers/fpv-drone/locales/fr.json @@ -0,0 +1,286 @@ +{ + "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": "Recordman" + } + }, + "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 vol 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": "Au pilote" + }, + "units": { + "kmh": "{{value}} km/h", + "m": "{{value}} m", + "g": "{{value}} g", + "kg": "{{value}} kg", + "percent": "{{value}} %", + "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 rouler ou tanguer. 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 670 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, recordman 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 est en fibre de carbone, deux plaques et quatre bras. 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. Produite en série pour une armée, la cellule 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 travaillent à 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 que vole un vrai drone de 7 pouces.", + "p4": "L’horloge, en haut, compte les secondes réelles depuis l’armement : 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 exactement du 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 ce qu’il a. 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 fait tourner le drone en retour. 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 de quatre à cinq fois leur poids en poussée : ils tiennent en stationnaire à 20 à 25 % de gaz et gardent une large réserve pour monter ou sprinter. Les drones longue portée en ont moins, au moins deux pour un, parce qu’une plus grosse batterie les alourdit.", + "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. L’ESC renvoie 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 roule à vitesse constante ; recentrez-le et le drone cesse de rouler 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, coureurs comme pilotes du front, volent en acro : il 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 comptent. 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 portent à 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. Les vraies é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 croiser à cette masse : cela donne environ 13 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 de 4 à 6.", + "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 du pack actuelle", + "amps": "Courant actuel" + }, + "limits": { + "title": "Rapides, bon marché et partout", + "p1": "À 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 de 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. Produite par milliers pour une armée, la cellule coûte environ 400 à 500 dollars, et l’Ukraine prévoyait d’acheter 4,5 millions de drones FPV en 2025. En pratique, ils travaillent à environ 10 à 20 km du pilote. Pour aller plus loin, les équipes embarquent un relais sur un second drone : un système ukrainien vole sur 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" + } + } +} diff --git a/explainers/fpv-drone/locales/ja.json b/explainers/fpv-drone/locales/ja.json new file mode 100644 index 00000000..d1a4a540 --- /dev/null +++ b/explainers/fpv-drone/locales/ja.json @@ -0,0 +1,286 @@ +{ + "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}}%", + "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": "傾きの制限なし。スティックをいっぱいに倒すと、初期設定で毎秒約670度を求めます", + "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%でホバリングして、上昇やダッシュのための余裕をたっぷり残します。長距離機の余裕はそれより小さく、少なくとも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キロビットなら、1語を27マイクロ秒で送ります。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%を使えるものとし、その重さで巡航するのに必要な電力で割ります。カメラを積んだ状態で約13分です。大きなバッテリーにしても長くは効きません。バッテリーが増えれば重さが増え、それを運ぶ電力も増えるので、足したパックほど得られるものが小さくなります。本物の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": "どんな機体か" + } + } +} diff --git a/explainers/fpv-drone/locales/pt.json b/explainers/fpv-drone/locales/pt.json new file mode 100644 index 00000000..363c1989 --- /dev/null +++ b/explainers/fpv-drone/locales/pt.json @@ -0,0 +1,286 @@ +{ + "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": "Girar 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}} %", + "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 670 graus por segundo por padrão", + "angle": "Um limite fixo de inclinação, que também limita a velocidade. Nada de flips ou rolamentos", + "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 e óculos. Fabricada em massa para um exército, a estrutura 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 trabalham 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 o armamento, 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 exatamente com o 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 do mesmo lado, 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 carregam de quatro a cinco vezes o próprio peso em empuxo, então pairam com 20 a 25 % de aceleração e sobra bastante para subir ou disparar. Os de longo alcance carregam menos, pelo menos dois para um, porque a bateria maior pesa.", + "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 na 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 aceleração 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 aceleração, um bit de telemetria e uma soma de verificação, enviada em 27 microssegundos a 600 kilobits por segundo. O ESC 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 em 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, tanto os de corrida quanto os da linha de frente, voa em acro: ele 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 importam. Uma chave no rádio arma o drone, ou seja, liga os motores e a estabilização. A controladora se recusa a armar se a aceleração estiver alta 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 aceleração.", + "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 chegam a 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á o que interferir.", + "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 para carregar o drone. Cada 100 g a mais aumentam o peso total, reduzem a relação entre empuxo e peso, aumentam a aceleração necessária 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 13 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 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": "Empuxo para peso", + "hover": "Aceleração 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": "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 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 e óculos; só o kit do frame sai por cerca de 130 a 150 dólares. Fabricada aos milhares para um exército, a estrutura 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 trabalham 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 é" + } + } +} diff --git a/explainers/fpv-drone/locales/uk.json b/explainers/fpv-drone/locales/uk.json new file mode 100644 index 00000000..2c7cb826 --- /dev/null +++ b/explainers/fpv-drone/locales/uk.json @@ -0,0 +1,286 @@ +{ + "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}} %", + "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": "Нахил не обмежено. Повне відхилення стіка за замовчуванням просить близько 670 градусів за секунду", + "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 % газу, і їм лишається багато запасу на підйом чи ривок. Далекобійні мають менше, щонайменше два до одного, бо їх обтяжує велика батарея.", + "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 кілобіт за секунду. 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 % енергії збірки й ділить їх на потужність, потрібну для крейсерського польоту з такою вагою. З камерою на борту виходить близько 13 хвилин. Більша батарея допомагає недовго: більше батареї означає більше ваги, а більше ваги означає більше потужності, щоб її нести, тож кожна додаткова збірка дає менше за попередню. Справжні 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": "Що це" + } + } +} diff --git a/explainers/fpv-drone/locales/zh.json b/explainers/fpv-drone/locales/zh.json new file mode 100644 index 00000000..b7f791c0 --- /dev/null +++ b/explainers/fpv-drone/locales/zh.json @@ -0,0 +1,286 @@ +{ + "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}}%", + "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}} 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": "不限倾角。摇杆打到底,默认要求每秒约670度", + "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%的油门,剩下大量余量用来爬升或冲刺。远航无人机的推力比小一些,至少两比一,因为更大的电池压住了它。", + "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微秒内发完。电调会把电机的真实转速回传,飞控就能滤掉每副桨带来的振动。", + "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%算作可用,再除以这个重量下巡航所需的功率,带着摄像头得出约13分钟。换更大的电池管不了多久:电池越多,重量越大,带着它飞就要更大的功率,所以每多加一块电池,收益都比上一块小。真实的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": "这是什么" + } + } +} From 60a3a29eee32d681bef82c50848c395794a0c9f9 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:08:23 +0200 Subject: [PATCH 13/55] feat(fpv-drone): ship the explainer in all eight languages --- explainers/fpv-drone/explainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/explainers/fpv-drone/explainer.json b/explainers/fpv-drone/explainer.json index 1e0e09c6..b628ed85 100644 --- a/explainers/fpv-drone/explainer.json +++ b/explainers/fpv-drone/explainer.json @@ -4,7 +4,7 @@ "cover": "cover.webp", "entry": "src/index.ts", "chapters": "chapters.html", - "locales": ["en"], + "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" From 236cea0ffe00d2be1c888660d42c7ca00cad8a8b Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:47:28 +0200 Subject: [PATCH 14/55] feat(fpv-drone): add the scene constants and finishes --- explainers/fpv-drone/src/scene/constants.ts | 357 ++++++++++++++++++++ explainers/fpv-drone/src/scene/finishes.ts | 185 ++++++++++ 2 files changed, 542 insertions(+) create mode 100644 explainers/fpv-drone/src/scene/constants.ts create mode 100644 explainers/fpv-drone/src/scene/finishes.ts diff --git a/explainers/fpv-drone/src/scene/constants.ts b/explainers/fpv-drone/src/scene/constants.ts new file mode 100644 index 00000000..00b9c4bc --- /dev/null +++ b/explainers/fpv-drone/src/scene/constants.ts @@ -0,0 +1,357 @@ +import { toRadians } from '@core/math'; +import type { PartId } from '../ids'; +import { + CROSSROADS, + DRONE, + DRONE_LAYOUT, + PAD, + ROAD, + SCENE_BOUNDS, + TREELINE, +} from '../model/layout'; +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.006, inboard: 0.03, segments: 10 }, +} as const; + +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: [-DRONE.restHeight, 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 SCALED_LABELS = { + propellerTop: 0.012, +} as const; + +export const SKY = { + radius: 6000, + widthSegments: 32, + heightSegments: 16, + renderOrder: -10, + cloud: { scale: 2.4, cover: 0.42, softness: 0.3, lift: 0.08, from: 0.02, to: 0.6 }, + blend: 0.55, +} as const; + +export const HAZE = { near: 350, far: 2400 } as const; + +export const FIELD = { + extent: { x: [-260, 700] as Extent, z: [-420, 360] as Extent }, + cell: 10, + outer: 9000, + outerSink: 0.08, + texture: { size: 256, metres: 24, stripes: 4, stripeDepth: 0.07, mottle: 0.16, seed: 17 }, + patches: { scale: 46, from: 0.48, to: 0.68, seed: 23 }, + tracks: { scale: 180, from: 0.56, to: 0.62, seed: 29, share: 0.35 }, +} 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, rut: 0.22, rutWidth: 0.1, depth: 0.22, 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.1, height: 2, depth: 0.05 }, + }, + car: { + at: [8, 0, 7] as Triple, + heading: toRadians(-40), + body: [4.2, 0.6, 1.8] as Triple, + cabin: [2, 0.55, 1.6] as Triple, + cabinShift: -0.2, + clearance: 0.25, + wheel: { radius: 0.32, width: 0.22, axle: 1.4, segments: 12 }, + sag: toRadians(4), + }, +} 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: [9, 15] as Extent, + tiers: 3, + radius: 0.32, + overlap: 0.42, + segments: 8, + trunk: { radius: 0.25, share: 0.12 }, + }, + poplar: { + height: [12, 19] as Extent, + trunk: { radius: [0.14, 0.32] as Extent, segments: 7 }, + branches: { count: 7, from: 0.3, length: 0.3, spread: toRadians(18), radius: 0.06 }, + }, +} 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: { perMetre: 1.6, size: [0.5, 0.22, 0.34] as Triple, segments: 8 }, + }, + 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: 0.05, + 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.12, + endRadius: 0.45, + opacity: 0.9, + core: 1.6, + glow: true, + dash: { period: 3, duty: 0.5, speed: 2.4, logScale: 30, floor: 0.3 }, + fade: [0.01, 0.02], + segments: 12, + }, + video: { + startRadius: 0.5, + endRadius: 3.2, + opacity: 0.38, + core: 1.2, + glow: false, + dash: { period: 6, duty: 0.6, speed: -1.6, logScale: 40, floor: 0.35 }, + fade: [0.02, 0.01], + segments: 16, + }, +} 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 SCENE_LIMITS = { + cameraNear: 0.2, + cameraFar: 14000, + cameraMinDistance: 2, + cameraMaxDistance: SCENE_BOUNDS.x[1] - SCENE_BOUNDS.x[0] + 400, + maxPolarAngle: Math.PI * 0.84, +} 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/finishes.ts b/explainers/fpv-drone/src/scene/finishes.ts new file mode 100644 index 00000000..305a685b --- /dev/null +++ b/explainers/fpv-drone/src/scene/finishes.ts @@ -0,0 +1,185 @@ +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: '#4b4a48', + shedDoor: '#3a3128', + carRust: '#5c3b2a', + carGlass: '#1d2328', + tyre: '#1b1c1e', + trunk: '#4a3a2c', + conifer: THEME.tree, + 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.6, 0.4), + 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(PAINT.conifer, 0.95), + 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, color: '#ffffff', map }; +} From 9b7aee4a0b2863fb85edcbd4e8bd477a66addb01 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:47:28 +0200 Subject: [PATCH 15/55] feat(fpv-drone): add the geometry helpers and the part context --- .../src/scene/geometry/arrowTexture.ts | 73 +++++++ .../fpv-drone/src/scene/geometry/blade.ts | 94 ++++++++ .../fpv-drone/src/scene/geometry/noise.ts | 42 ++++ .../fpv-drone/src/scene/geometry/rings.ts | 137 ++++++++++++ .../fpv-drone/src/scene/geometry/rods.ts | 32 +++ .../src/scene/geometry/surfaceMaps.ts | 205 ++++++++++++++++++ .../fpv-drone/src/scene/geometry/trail.ts | 64 ++++++ .../fpv-drone/src/scene/parts/context.ts | 63 ++++++ 8 files changed, 710 insertions(+) create mode 100644 explainers/fpv-drone/src/scene/geometry/arrowTexture.ts create mode 100644 explainers/fpv-drone/src/scene/geometry/blade.ts create mode 100644 explainers/fpv-drone/src/scene/geometry/noise.ts create mode 100644 explainers/fpv-drone/src/scene/geometry/rings.ts create mode 100644 explainers/fpv-drone/src/scene/geometry/rods.ts create mode 100644 explainers/fpv-drone/src/scene/geometry/surfaceMaps.ts create mode 100644 explainers/fpv-drone/src/scene/geometry/trail.ts create mode 100644 explainers/fpv-drone/src/scene/parts/context.ts 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.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/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.ts b/explainers/fpv-drone/src/scene/geometry/surfaceMaps.ts new file mode 100644 index 00000000..83d16127 --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/surfaceMaps.ts @@ -0,0 +1,205 @@ +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; + rut: number; + rutWidth: number; + depth: 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; +const ROAD_TINT = [1, 0.96, 0.9] as const; + +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); +} + +export function roadShade(layout: RoadLayout, u: number, v: number): Rgba { + const ruts = [0.5 - layout.rut, 0.5 + layout.rut]; + const rut = ruts.reduce( + (most, centre) => Math.max(most, Math.exp(-(((u - centre) / layout.rutWidth) ** 2))), + 0, + ); + const wear = fractalNoise(u * ROAD_SCALE, v * ROAD_SCALE, ROAD_OCTAVES, layout.seed, ROAD_SCALE); + const level = 1 + layout.depth * rut - 0.2 * (wear - 0.5); + return [level * ROAD_TINT[0], level * ROAD_TINT[1], level * ROAD_TINT[2], 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.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/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; +} From eca9860fd5392d3a194e1d0f326111debd189712 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:47:28 +0200 Subject: [PATCH 16/55] feat(fpv-drone): build the drone with its frame, motors, props, battery, camera and antennas --- .../src/scene/parts/drone/antennas.ts | 174 ++++++++++++++++++ .../src/scene/parts/drone/battery.ts | 120 ++++++++++++ .../src/scene/parts/drone/cameraModule.ts | 113 ++++++++++++ .../fpv-drone/src/scene/parts/drone/drone.ts | 109 +++++++++++ .../fpv-drone/src/scene/parts/drone/frame.ts | 111 +++++++++++ .../fpv-drone/src/scene/parts/drone/motor.ts | 114 ++++++++++++ .../src/scene/parts/drone/propeller.ts | 125 +++++++++++++ .../fpv-drone/src/scene/parts/drone/stack.ts | 107 +++++++++++ .../src/scene/parts/effects/spinArrows.ts | 64 +++++++ explainers/fpv-drone/src/scene/pose.ts | 12 ++ 10 files changed, 1049 insertions(+) create mode 100644 explainers/fpv-drone/src/scene/parts/drone/antennas.ts create mode 100644 explainers/fpv-drone/src/scene/parts/drone/battery.ts create mode 100644 explainers/fpv-drone/src/scene/parts/drone/cameraModule.ts create mode 100644 explainers/fpv-drone/src/scene/parts/drone/drone.ts create mode 100644 explainers/fpv-drone/src/scene/parts/drone/frame.ts create mode 100644 explainers/fpv-drone/src/scene/parts/drone/motor.ts create mode 100644 explainers/fpv-drone/src/scene/parts/drone/propeller.ts create mode 100644 explainers/fpv-drone/src/scene/parts/drone/stack.ts create mode 100644 explainers/fpv-drone/src/scene/parts/effects/spinArrows.ts create mode 100644 explainers/fpv-drone/src/scene/pose.ts 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..8d2a605b --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/drone/drone.ts @@ -0,0 +1,109 @@ +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 } from '../../../model/scale'; +import { DRONE_BOX, PROP } from '../../constants'; +import { applyDronePose } from '../../pose'; +import type { PartContext } from '../context'; +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 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); + 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.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.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); + } + + advance(deltaSeconds: number): void { + this.propellers.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..bf4846e3 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/drone/frame.ts @@ -0,0 +1,111 @@ +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 { DRONE, 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; + const height = DRONE.restHeight - PLATE.thickness; + return mergeParts( + MOTOR_PART_IDS.map((id) => { + const motor = MOTOR_POSITIONS[id]; + const { direction } = armLine(id); + const peg = new CylinderGeometry(foot.radius, foot.radius * 0.8, height, foot.segments); + peg.translate( + motor[0] - direction[0] * foot.inboard, + -PLATE.thickness - 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/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/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); +} From f2cc6103ffb350bf7cbdd2e602455868086ef51d Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:47:28 +0200 Subject: [PATCH 17/55] feat(fpv-drone): add the field, roads, treeline, shrubs, sky and crossroads --- .../src/scene/parts/world/crossing.ts | 97 ++++++++++++++++ .../fpv-drone/src/scene/parts/world/field.ts | 108 ++++++++++++++++++ .../fpv-drone/src/scene/parts/world/shrubs.ts | 61 ++++++++++ .../fpv-drone/src/scene/parts/world/sky.ts | 104 +++++++++++++++++ .../fpv-drone/src/scene/parts/world/trees.ts | 102 +++++++++++++++++ 5 files changed, 472 insertions(+) create mode 100644 explainers/fpv-drone/src/scene/parts/world/crossing.ts create mode 100644 explainers/fpv-drone/src/scene/parts/world/field.ts create mode 100644 explainers/fpv-drone/src/scene/parts/world/shrubs.ts create mode 100644 explainers/fpv-drone/src/scene/parts/world/sky.ts create mode 100644 explainers/fpv-drone/src/scene/parts/world/trees.ts 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..bd7d7e47 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/crossing.ts @@ -0,0 +1,97 @@ +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]; + +const QUARTER_TURN = Math.PI / 2; +const ENDS = [-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 shedGeometry(): { walls: BufferGeometry; roof: BufferGeometry; door: BufferGeometry } { + const { size, roof, door } = CROSSING.shed; + const [length, wall, depth] = size; + const walls = centredBox([0, 0, 0], size); + const gable = new Shape(); + const half = depth / 2 + roof.overhang; + gable.moveTo(-half, wall - 0.05); + gable.lineTo(half, wall - 0.05); + gable.lineTo(0, wall + roof.rise); + gable.closePath(); + const roofGeometry = extrudeProfileAlongX( + gable, + -length / 2 - roof.overhang, + length / 2 + roof.overhang, + ); + const doorGeometry = centredBox( + [length * 0.2, 0, -depth / 2 - door.depth / 2], + [door.width, door.height, door.depth], + ); + return { walls, roof: roofGeometry, door: doorGeometry }; +} + +function carGeometry(): { body: BufferGeometry; glass: BufferGeometry; wheels: BufferGeometry } { + const { body, cabin, cabinShift, clearance, wheel } = CROSSING.car; + const shell = centredBox([0, clearance, 0], body); + const top = centredBox([body[0] * cabinShift, clearance + body[1], 0], cabin); + const glass = centredBox( + [body[0] * cabinShift + cabin[0] / 2, clearance + body[1] + 0.05, 0], + [0.05, cabin[1] * 0.7, cabin[2] * 0.85], + ); + const wheels = 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 * (body[2] / 2 - wheel.width * 0.3)); + return tyre; + }), + ); + return { body: mergeParts([shell, top]), glass, wheels: mergeParts(wheels) }; +} + +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), + ); + 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..5cb262ae --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/field.ts @@ -0,0 +1,108 @@ +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); + +export function fieldColour(x: number, z: number, target: Color): Color { + const { patches, tracks } = FIELD; + const patch = fractalNoise(x / patches.scale, z / patches.scale, NOISE_OCTAVES, patches.seed); + const worn = fractalNoise(x / tracks.scale, z / tracks.scale, NOISE_OCTAVES, tracks.seed); + target.copy(GRASS).lerp(STRAW, smoothstep(patch, patches.from, patches.to)); + return target.lerp(EARTH, smoothstep(worn, tracks.from, tracks.to) * tracks.share); +} + +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/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..9b1013b6 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/sky.ts @@ -0,0 +1,104 @@ +import { BackSide, Color, Mesh, ShaderMaterial, SphereGeometry, 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 { 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 vec4 uCloud; +uniform vec3 uCloudBand; +uniform float uBlend; +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; +} + +void main() { + vec3 direction = normalize(vDirection); + float up = max(direction.y, 0.0); + vec3 sky = mix(uHorizon, uTop, smoothstep(0.0, uBlend, up)); + vec2 plane = direction.xz / (up + uCloud.w) * uCloud.x; + float cloud = fbm(plane + vec2(2.3, 7.1)); + float cover = smoothstep(uCloud.y, uCloud.y + uCloud.z, cloud); + float band = smoothstep(uCloudBand.x, uCloudBand.x + 0.08, up) * (1.0 - smoothstep(uCloudBand.y * 0.7, uCloudBand.y, up)); + vec3 lit = mix(sky, uHorizon, 0.55); + vec3 shade = mix(sky, uTop, 0.35); + sky = mix(sky, mix(shade, lit, cover), band * uCloudBand.z); + vec3 colour = mix(uHorizon, sky, smoothstep(-0.01, 0.0, direction.y)); + gl_FragColor = vec4(colour, 1.0); + #include +} +`; + +export function skyUniforms() { + const { cloud, blend } = SKY; + return { + uTop: { value: new Color(THEME.skyTop) }, + uHorizon: { value: new Color(THEME.skyHorizon) }, + uCloud: { value: new Vector4(cloud.scale, cloud.cover, cloud.softness, cloud.lift) }, + uCloudBand: { value: new Vector3(cloud.from, cloud.to, 0.9) }, + uBlend: { value: blend }, + }; +} + +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/trees.ts b/explainers/fpv-drone/src/scene/parts/world/trees.ts new file mode 100644 index 00000000..61cfb355 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/trees.ts @@ -0,0 +1,102 @@ +import { 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 { 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; +} + +const SEEDS = { jitterX: 1, jitterZ: 2, kind: 3, height: 4, turn: 5 } as const; + +export function treeSpots(): TreeSpot[] { + const spots: TreeSpot[] = []; + TREES.rows.forEach((row, rowIndex) => { + const [from, to] = TREES.line.x; + for (let x = from + row.offset, index = 0; x <= to; x += row.spacing, index += 1) { + const seed = TREES.seed + rowIndex * 100; + const poplar = hash2(index, seed + SEEDS.kind) < TREES.poplarShare; + const range = poplar ? TREES.poplar.height : TREES.conifer.height; + spots.push({ + x: x + (hash2(index, seed + SEEDS.jitterX) - 0.5) * row.jitter * 2, + z: TREES.line.z + row.z + (hash2(index, seed + SEEDS.jitterZ) - 0.5) * row.jitter * 2, + height: lerp(range[0], range[1], hash2(index, seed + SEEDS.height)), + poplar, + turn: hash2(index, seed + SEEDS.turn) * Math.PI * 2, + }); + } + }); + return spots; +} + +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 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(spot.height * radius * share, tierHeight, segments); + cone.translate(0, base + tierHeight / 2, 0); + cone.rotateY(spot.turn); + cone.translate(spot.x, 0, spot.z); + crowns.push(cone); + } + const stem = new CylinderGeometry(trunk.radius * 0.7, trunk.radius, trunkHeight * 1.4, 6); + stem.translate(spot.x, trunkHeight * 0.7, spot.z); + return { crown: mergeParts(crowns), trunk: stem }; +} + +function poplarGeometry(spot: TreeSpot): BufferGeometry { + const { trunk, branches } = 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 = Array.from({ length: branches.count }, (_, index) => { + const share = branches.from + ((1 - branches.from) * index) / branches.count; + const angle = spot.turn + (index * Math.PI * 2) / branches.count; + const base: Vec3 = [spot.x, spot.height * share, spot.z]; + const length = spot.height * branches.length * (1 - share * 0.5); + const tip: Vec3 = [ + spot.x + Math.cos(angle) * Math.sin(branches.spread) * length, + spot.height * share + Math.cos(branches.spread) * length, + spot.z + Math.sin(angle) * Math.sin(branches.spread) * length, + ]; + return rod(base, tip, branches.radius, 5, branches.radius * 0.4); + }); + 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; +} From f1232eea2b17a59c719602116a6e80d7b6a78d13 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:47:28 +0200 Subject: [PATCH 18/55] feat(fpv-drone): add the pilot's station and the launch pad --- .../fpv-drone/src/scene/parts/world/pad.ts | 57 +++++ .../src/scene/parts/world/station.ts | 196 ++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 explainers/fpv-drone/src/scene/parts/world/pad.ts create mode 100644 explainers/fpv-drone/src/scene/parts/world/station.ts 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/station.ts b/explainers/fpv-drone/src/scene/parts/world/station.ts new file mode 100644 index 00000000..c7acc132 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/station.ts @@ -0,0 +1,196 @@ +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 { STATION } from '../../../model/layout'; +import { STATION_SET } from '../../constants'; +import { WORLD_FINISHES } from '../../finishes'; +import { hash2 } from '../../geometry/noise'; +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 count = Math.round(length * sandbags.perMetre); + return Array.from({ length: count }, (_, index) => { + const bag = new SphereGeometry(0.5, sandbags.segments, sandbags.segments / 2); + bag.scale(...sandbags.size); + const share = (index + 0.5) / count; + const wobble = (hash2(index, alongX ? 7 : 8) - 0.5) * 0.12; + bag.rotateY(alongX ? 0 : QUARTER_TURN); + bag.translate( + from[0] + (alongX ? share * length : wobble), + top + sandbags.size[1] / 2, + from[2] + (alongX ? wobble : share * length), + ); + return bag; + }); +} + +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), + }; +} From 896e6cb6e711df3e744e1aa2fcfe6640978e1903 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:47:28 +0200 Subject: [PATCH 19/55] feat(fpv-drone): add the link beams and the flight track --- .../fpv-drone/src/scene/parts/effects/beam.ts | 123 ++++++++++++++++ .../src/scene/parts/effects/effects.ts | 80 +++++++++++ .../src/scene/parts/effects/track.ts | 132 ++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 explainers/fpv-drone/src/scene/parts/effects/beam.ts create mode 100644 explainers/fpv-drone/src/scene/parts/effects/effects.ts create mode 100644 explainers/fpv-drone/src/scene/parts/effects/track.ts 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/effects.ts b/explainers/fpv-drone/src/scene/parts/effects/effects.ts new file mode 100644 index 00000000..68a27fad --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/effects/effects.ts @@ -0,0 +1,80 @@ +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 { TrackEffect } from './track'; + +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 controlLabel: Object3D; + private readonly videoLabel: Object3D; + private readonly from = new Vector3(); + private readonly to = 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.controlLabel = beamLabel(this.control); + this.videoLabel = beamLabel(this.video); + this.object.add(this.track.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); + } + + advance(deltaSeconds: number): void { + this.control.advance(deltaSeconds); + this.video.advance(deltaSeconds); + } +} 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; + }); + } +} From e7f936c1a0c34e66fdc8b9669cc30559e56b9c87 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:47:28 +0200 Subject: [PATCH 20/55] feat(fpv-drone): assemble the scene with regions, options and lighting --- explainers/fpv-drone/src/scene/fpvAssembly.ts | 148 ++++++++++++++++++ explainers/fpv-drone/src/scene/lighting.ts | 37 +++++ explainers/fpv-drone/src/scene/regions.ts | 23 +++ .../fpv-drone/src/scene/sceneOptions.ts | 17 ++ 4 files changed, 225 insertions(+) create mode 100644 explainers/fpv-drone/src/scene/fpvAssembly.ts create mode 100644 explainers/fpv-drone/src/scene/lighting.ts create mode 100644 explainers/fpv-drone/src/scene/regions.ts create mode 100644 explainers/fpv-drone/src/scene/sceneOptions.ts 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/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/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.ts b/explainers/fpv-drone/src/scene/sceneOptions.ts new file mode 100644 index 00000000..23de4b42 --- /dev/null +++ b/explainers/fpv-drone/src/scene/sceneOptions.ts @@ -0,0 +1,17 @@ +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 }, + }, + gaugeSide: 'top', + highlight: { dim: HIGHLIGHT_DIM, undimmed: UNDIMMED_PARTS }, +}; From 667aa1364819bfaae40bc73c66b17e8495f12ea7 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 19:47:28 +0200 Subject: [PATCH 21/55] test(fpv-drone): cover the assembly anchors, regions, spin and view flags --- .../src/scene/fpvAssembly.dom.test.ts | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 explainers/fpv-drone/src/scene/fpvAssembly.dom.test.ts 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..eeb4d14a --- /dev/null +++ b/explainers/fpv-drone/src/scene/fpvAssembly.dom.test.ts @@ -0,0 +1,334 @@ +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 { PROP } from './constants'; +import { FpvAssembly } from './fpvAssembly'; +import { propName } from './parts/drone/propeller'; + +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); + }); + + 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('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(); + }); +}); From b094e6aa8fc8eb2a69bc7353c31bb9b191e2977b Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:03:04 +0200 Subject: [PATCH 22/55] feat(fpv-drone): start the fog beyond the crossroads --- explainers/fpv-drone/src/scene/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/explainers/fpv-drone/src/scene/constants.ts b/explainers/fpv-drone/src/scene/constants.ts index 00b9c4bc..f7bcfd47 100644 --- a/explainers/fpv-drone/src/scene/constants.ts +++ b/explainers/fpv-drone/src/scene/constants.ts @@ -173,7 +173,7 @@ export const SKY = { blend: 0.55, } as const; -export const HAZE = { near: 350, far: 2400 } 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 }, From 71fae00d24caaee8b8eeb5a05d034f4d7ee37dd5 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:03:04 +0200 Subject: [PATCH 23/55] test(fpv-drone): cover the blade, trail, textures, world scatter, regions, light and options --- .../src/scene/geometry/blade.test.ts | 56 +++++++++++++++++ .../src/scene/geometry/rings.test.ts | 36 +++++++++++ .../src/scene/geometry/surfaceMaps.test.ts | 54 ++++++++++++++++ .../src/scene/geometry/trail.test.ts | 53 ++++++++++++++++ .../fpv-drone/src/scene/lighting.test.ts | 44 +++++++++++++ .../scene/parts/effects/spinArrows.test.ts | 23 +++++++ .../src/scene/parts/world/world.test.ts | 61 +++++++++++++++++++ .../fpv-drone/src/scene/regions.test.ts | 23 +++++++ .../fpv-drone/src/scene/sceneOptions.test.ts | 29 +++++++++ 9 files changed, 379 insertions(+) create mode 100644 explainers/fpv-drone/src/scene/geometry/blade.test.ts create mode 100644 explainers/fpv-drone/src/scene/geometry/rings.test.ts create mode 100644 explainers/fpv-drone/src/scene/geometry/surfaceMaps.test.ts create mode 100644 explainers/fpv-drone/src/scene/geometry/trail.test.ts create mode 100644 explainers/fpv-drone/src/scene/lighting.test.ts create mode 100644 explainers/fpv-drone/src/scene/parts/effects/spinArrows.test.ts create mode 100644 explainers/fpv-drone/src/scene/parts/world/world.test.ts create mode 100644 explainers/fpv-drone/src/scene/regions.test.ts create mode 100644 explainers/fpv-drone/src/scene/sceneOptions.test.ts 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/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/surfaceMaps.test.ts b/explainers/fpv-drone/src/scene/geometry/surfaceMaps.test.ts new file mode 100644 index 00000000..0273fd15 --- /dev/null +++ b/explainers/fpv-drone/src/scene/geometry/surfaceMaps.test.ts @@ -0,0 +1,54 @@ +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 and road shades near one with gentle variation', () => { + const grass = fieldShade(FIELD.texture, 0.3, 0.7); + const dirt = roadShade(ROADS.texture, 0.5 - ROADS.texture.rut, 0.2); + for (const channel of [...grass.slice(0, 3), ...dirt.slice(0, 3)]) { + expect(channel).toBeGreaterThan(0.6); + expect(channel).toBeLessThan(1.4); + } + expect(dirt[0]).toBeGreaterThan(roadShade(ROADS.texture, 0.5, 0.2)[0]); + }); +}); 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/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/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/world/world.test.ts b/explainers/fpv-drone/src/scene/parts/world/world.test.ts new file mode 100644 index 00000000..23d883e0 --- /dev/null +++ b/explainers/fpv-drone/src/scene/parts/world/world.test.ts @@ -0,0 +1,61 @@ +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 { fieldColour } from './field'; +import { scatterShrubs } from './shrubs'; +import { 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('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('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); + } + }); +}); 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/sceneOptions.test.ts b/explainers/fpv-drone/src/scene/sceneOptions.test.ts new file mode 100644 index 00000000..ad6d1f61 --- /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('top'); + }); + + 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); + }); +}); From ed2e4ca5a716681ced8784fe91c8fb1c390c1c3e Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:05:02 +0200 Subject: [PATCH 24/55] feat(fpv-drone): draw the radio links as thin fading paths --- explainers/fpv-drone/src/scene/constants.ts | 24 +++++++++---------- .../src/scene/parts/effects/beam.test.ts | 22 +++++++++++++++++ 2 files changed, 34 insertions(+), 12 deletions(-) create mode 100644 explainers/fpv-drone/src/scene/parts/effects/beam.test.ts diff --git a/explainers/fpv-drone/src/scene/constants.ts b/explainers/fpv-drone/src/scene/constants.ts index f7bcfd47..0bd990c5 100644 --- a/explainers/fpv-drone/src/scene/constants.ts +++ b/explainers/fpv-drone/src/scene/constants.ts @@ -307,24 +307,24 @@ export interface BeamLook { export const BEAMS = { control: { - startRadius: 0.12, - endRadius: 0.45, - opacity: 0.9, - core: 1.6, - glow: true, + 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.01, 0.02], - segments: 12, + fade: [0.25, 0.02], + segments: 8, }, video: { - startRadius: 0.5, - endRadius: 3.2, - opacity: 0.38, + 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.01], - segments: 16, + fade: [0.02, 0.25], + segments: 8, }, } as const satisfies Record; 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); + }); +}); From 32d196ef4ac4284b8a7fcea015b84d1cdb67eb5c Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:05:41 +0200 Subject: [PATCH 25/55] feat(fpv-drone): stand the drone low on short printed feet --- explainers/fpv-drone/src/scene/constants.ts | 10 +++++++--- explainers/fpv-drone/src/scene/fpvAssembly.dom.test.ts | 3 ++- explainers/fpv-drone/src/scene/parts/drone/drone.ts | 5 +++-- explainers/fpv-drone/src/scene/parts/drone/frame.ts | 7 +++---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/explainers/fpv-drone/src/scene/constants.ts b/explainers/fpv-drone/src/scene/constants.ts index 0bd990c5..ba52e03e 100644 --- a/explainers/fpv-drone/src/scene/constants.ts +++ b/explainers/fpv-drone/src/scene/constants.ts @@ -37,9 +37,13 @@ export const ARMS = { motorRearLeft: [-0.08, -0.02] as Extent, }, overhang: 0.02, - foot: { radius: 0.006, inboard: 0.03, segments: 10 }, + 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, @@ -149,7 +153,7 @@ export const GPS = { export const DRONE_BOX = { x: [-0.14 - PROP.radius, 0.11 + PROP.radius] as Extent, - y: [-DRONE.restHeight, 0.095] 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; @@ -288,7 +292,7 @@ export const STATION_SET = { export const LAUNCH_PAD = { at: PAD, size: droneUnits(0.45), - thickness: 0.05, + thickness: droneUnits(PAD_TOP_METRES), border: 0.25, markLift: 0.012, labelInset: 0.4, diff --git a/explainers/fpv-drone/src/scene/fpvAssembly.dom.test.ts b/explainers/fpv-drone/src/scene/fpvAssembly.dom.test.ts index eeb4d14a..a2b1f9b2 100644 --- a/explainers/fpv-drone/src/scene/fpvAssembly.dom.test.ts +++ b/explainers/fpv-drone/src/scene/fpvAssembly.dom.test.ts @@ -8,7 +8,7 @@ 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 { PROP } from './constants'; +import { LAUNCH_PAD, PROP } from './constants'; import { FpvAssembly } from './fpvAssembly'; import { propName } from './parts/drone/propeller'; @@ -172,6 +172,7 @@ describe('fpv assembly', () => { 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', () => { diff --git a/explainers/fpv-drone/src/scene/parts/drone/drone.ts b/explainers/fpv-drone/src/scene/parts/drone/drone.ts index 8d2a605b..35a3f89b 100644 --- a/explainers/fpv-drone/src/scene/parts/drone/drone.ts +++ b/explainers/fpv-drone/src/scene/parts/drone/drone.ts @@ -3,8 +3,8 @@ 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 } from '../../../model/scale'; -import { DRONE_BOX, PROP } from '../../constants'; +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 { SpinArrowsPart } from '../effects/spinArrows'; @@ -50,6 +50,7 @@ export class DronePart { 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); diff --git a/explainers/fpv-drone/src/scene/parts/drone/frame.ts b/explainers/fpv-drone/src/scene/parts/drone/frame.ts index bf4846e3..4fcda90f 100644 --- a/explainers/fpv-drone/src/scene/parts/drone/frame.ts +++ b/explainers/fpv-drone/src/scene/parts/drone/frame.ts @@ -3,7 +3,7 @@ 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 { DRONE, MOTOR_POSITIONS } from '../../../model/layout'; +import { MOTOR_POSITIONS } from '../../../model/layout'; import { ARMS, PLATE, STANDOFFS } from '../../constants'; import { FINISHES } from '../../finishes'; import { mergeParts, partMesh } from '../context'; @@ -75,15 +75,14 @@ function standoffGeometry(): BufferGeometry { function feetGeometry(): BufferGeometry { const { foot } = ARMS; - const height = DRONE.restHeight - PLATE.thickness; return mergeParts( MOTOR_PART_IDS.map((id) => { const motor = MOTOR_POSITIONS[id]; const { direction } = armLine(id); - const peg = new CylinderGeometry(foot.radius, foot.radius * 0.8, height, foot.segments); + const peg = new CylinderGeometry(foot.radius, foot.tip, foot.height, foot.segments); peg.translate( motor[0] - direction[0] * foot.inboard, - -PLATE.thickness - height / 2, + -PLATE.thickness - foot.height / 2, motor[2] - direction[1] * foot.inboard, ); return peg; From 9e6c9ecd141d47488bda3f42cafb75882047ea0f Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:06:13 +0200 Subject: [PATCH 26/55] feat(fpv-drone): paint the dirt roads in earth tones with wheel ruts --- explainers/fpv-drone/src/scene/constants.ts | 13 +++++++++- explainers/fpv-drone/src/scene/finishes.ts | 2 +- .../src/scene/geometry/surfaceMaps.test.ts | 21 ++++++++++++---- .../src/scene/geometry/surfaceMaps.ts | 24 ++++++++++++------- 4 files changed, 45 insertions(+), 15 deletions(-) diff --git a/explainers/fpv-drone/src/scene/constants.ts b/explainers/fpv-drone/src/scene/constants.ts index ba52e03e..c34264c3 100644 --- a/explainers/fpv-drone/src/scene/constants.ts +++ b/explainers/fpv-drone/src/scene/constants.ts @@ -194,7 +194,18 @@ export const ROADS = { lift: 0.03, along: { z: CROSSROADS[2], x: [-80, 520] as Extent }, across: { x: CROSSROADS[0], z: [-300, 200] as Extent }, - texture: { size: 64, rut: 0.22, rutWidth: 0.1, depth: 0.22, metres: 8, seed: 41 }, + 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 = { diff --git a/explainers/fpv-drone/src/scene/finishes.ts b/explainers/fpv-drone/src/scene/finishes.ts index 305a685b..ac99b16a 100644 --- a/explainers/fpv-drone/src/scene/finishes.ts +++ b/explainers/fpv-drone/src/scene/finishes.ts @@ -181,5 +181,5 @@ export function fieldFinish(map: Texture): MaterialFinish { } export function roadFinish(map: Texture): MaterialFinish { - return { ...WORLD_FINISHES.road, color: '#ffffff', map }; + return { ...WORLD_FINISHES.road, map }; } diff --git a/explainers/fpv-drone/src/scene/geometry/surfaceMaps.test.ts b/explainers/fpv-drone/src/scene/geometry/surfaceMaps.test.ts index 0273fd15..99c907da 100644 --- a/explainers/fpv-drone/src/scene/geometry/surfaceMaps.test.ts +++ b/explainers/fpv-drone/src/scene/geometry/surfaceMaps.test.ts @@ -42,13 +42,24 @@ describe('surface maps', () => { expect(discAlpha(PROP.disc, 1)).toBe(0); }); - it('keeps the field and road shades near one with gentle variation', () => { - const grass = fieldShade(FIELD.texture, 0.3, 0.7); - const dirt = roadShade(ROADS.texture, 0.5 - ROADS.texture.rut, 0.2); - for (const channel of [...grass.slice(0, 3), ...dirt.slice(0, 3)]) { + 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); } - expect(dirt[0]).toBeGreaterThan(roadShade(ROADS.texture, 0.5, 0.2)[0]); + }); + + 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 index 83d16127..a0a87ef7 100644 --- a/explainers/fpv-drone/src/scene/geometry/surfaceMaps.ts +++ b/explainers/fpv-drone/src/scene/geometry/surfaceMaps.ts @@ -48,9 +48,13 @@ export interface FieldLayout { export interface RoadLayout { size: number; + base: number; rut: number; rutWidth: number; - depth: number; + rutDepth: number; + dust: number; + dustWidth: number; + wear: number; seed: number; } @@ -69,7 +73,6 @@ const FIELD_SCALE = 6; const FIELD_TINT = [0.98, 1, 0.9] as const; const ROAD_OCTAVES = 3; const ROAD_SCALE = 5; -const ROAD_TINT = [1, 0.96, 0.9] as const; export function paintTexture( width: number, @@ -189,15 +192,20 @@ 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 ruts = [0.5 - layout.rut, 0.5 + layout.rut]; - const rut = ruts.reduce( - (most, centre) => Math.max(most, Math.exp(-(((u - centre) / layout.rutWidth) ** 2))), - 0, + 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 = 1 + layout.depth * rut - 0.2 * (wear - 0.5); - return [level * ROAD_TINT[0], level * ROAD_TINT[1], level * ROAD_TINT[2], OPAQUE]; + 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 { From 6358da7e344145c435d74303565b8f1b6e9332d2 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:07:00 +0200 Subject: [PATCH 27/55] feat(fpv-drone): give the field mown bands, tufts and bare earth spots --- explainers/fpv-drone/src/scene/constants.ts | 7 +++-- .../fpv-drone/src/scene/parts/world/field.ts | 27 +++++++++++++++---- .../src/scene/parts/world/world.test.ts | 27 ++++++++++++++++++- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/explainers/fpv-drone/src/scene/constants.ts b/explainers/fpv-drone/src/scene/constants.ts index c34264c3..12365545 100644 --- a/explainers/fpv-drone/src/scene/constants.ts +++ b/explainers/fpv-drone/src/scene/constants.ts @@ -181,12 +181,15 @@ export const HAZE = { near: 500, far: 2600 } as const; export const FIELD = { extent: { x: [-260, 700] as Extent, z: [-420, 360] as Extent }, - cell: 10, + cell: 6, outer: 9000, outerSink: 0.08, - texture: { size: 256, metres: 24, stripes: 4, stripeDepth: 0.07, mottle: 0.16, seed: 17 }, + 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 = { diff --git a/explainers/fpv-drone/src/scene/parts/world/field.ts b/explainers/fpv-drone/src/scene/parts/world/field.ts index 5cb262ae..0ae6442b 100644 --- a/explainers/fpv-drone/src/scene/parts/world/field.ts +++ b/explainers/fpv-drone/src/scene/parts/world/field.ts @@ -18,12 +18,29 @@ 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, tracks } = FIELD; - const patch = fractalNoise(x / patches.scale, z / patches.scale, NOISE_OCTAVES, patches.seed); - const worn = fractalNoise(x / tracks.scale, z / tracks.scale, NOISE_OCTAVES, tracks.seed); - target.copy(GRASS).lerp(STRAW, smoothstep(patch, patches.from, patches.to)); - return target.lerp(EARTH, smoothstep(worn, tracks.from, tracks.to) * tracks.share); + 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 { diff --git a/explainers/fpv-drone/src/scene/parts/world/world.test.ts b/explainers/fpv-drone/src/scene/parts/world/world.test.ts index 23d883e0..57cd0f0d 100644 --- a/explainers/fpv-drone/src/scene/parts/world/world.test.ts +++ b/explainers/fpv-drone/src/scene/parts/world/world.test.ts @@ -3,7 +3,7 @@ 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 { fieldColour } from './field'; +import { fieldColour, mownBand } from './field'; import { scatterShrubs } from './shrubs'; import { treeSpots } from './trees'; @@ -29,6 +29,31 @@ describe('field colour', () => { }); }); +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(); From 246412a1b98a472731a0f9dbf34a6a7b8ca1c325 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:07:32 +0200 Subject: [PATCH 28/55] feat(fpv-drone): add a pillow geometry for sandbags --- .../src/scene/geometry/pillow.test.ts | 35 +++++++++++++++++++ .../fpv-drone/src/scene/geometry/pillow.ts | 30 ++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 explainers/fpv-drone/src/scene/geometry/pillow.test.ts create mode 100644 explainers/fpv-drone/src/scene/geometry/pillow.ts 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; +} From 03846fbd17687844585184bd83ec5d308715651a Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:07:32 +0200 Subject: [PATCH 29/55] feat(fpv-drone): stack flat sandbags in three rows on the dugout logs --- explainers/fpv-drone/src/scene/constants.ts | 12 +++++- .../src/scene/parts/world/station.ts | 37 +++++++++++-------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/explainers/fpv-drone/src/scene/constants.ts b/explainers/fpv-drone/src/scene/constants.ts index 12365545..95593a9a 100644 --- a/explainers/fpv-drone/src/scene/constants.ts +++ b/explainers/fpv-drone/src/scene/constants.ts @@ -273,7 +273,17 @@ export const STATION_SET = { alongX: 5.5, alongZ: 5.5, log: { radius: 0.13, rows: 4, segments: 10, jitter: 0.05 }, - sandbags: { perMetre: 1.6, size: [0.5, 0.22, 0.34] as Triple, segments: 8 }, + 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, diff --git a/explainers/fpv-drone/src/scene/parts/world/station.ts b/explainers/fpv-drone/src/scene/parts/world/station.ts index c7acc132..6434be99 100644 --- a/explainers/fpv-drone/src/scene/parts/world/station.ts +++ b/explainers/fpv-drone/src/scene/parts/world/station.ts @@ -1,4 +1,4 @@ -import { CylinderGeometry, Group, SphereGeometry } from 'three'; +import { CylinderGeometry, Group } from 'three'; import type { BufferGeometry, Object3D } from 'three'; import { box } from '@core/scene/geometry/box'; import { anchorAt } from '@core/scene/parts'; @@ -6,6 +6,7 @@ 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'; @@ -46,20 +47,26 @@ function logRow(from: Vec3, alongX: boolean, length: number, row: number): Buffe function sandbagsOn(from: Vec3, alongX: boolean, length: number, top: number): BufferGeometry[] { const { sandbags } = STATION_SET.dugout; - const count = Math.round(length * sandbags.perMetre); - return Array.from({ length: count }, (_, index) => { - const bag = new SphereGeometry(0.5, sandbags.segments, sandbags.segments / 2); - bag.scale(...sandbags.size); - const share = (index + 0.5) / count; - const wobble = (hash2(index, alongX ? 7 : 8) - 0.5) * 0.12; - bag.rotateY(alongX ? 0 : QUARTER_TURN); - bag.translate( - from[0] + (alongX ? share * length : wobble), - top + sandbags.size[1] / 2, - from[2] + (alongX ? wobble : share * length), - ); - return bag; - }); + 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 } { From 91fdf9061320f7faac5fed27059149013f2464de Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:08:32 +0200 Subject: [PATCH 30/55] feat(fpv-drone): shape the wreck as a pickup and give the shed a door and a rusty roof --- explainers/fpv-drone/src/scene/constants.ts | 15 +- explainers/fpv-drone/src/scene/finishes.ts | 6 +- .../src/scene/parts/world/crossing.test.ts | 29 ++++ .../src/scene/parts/world/crossing.ts | 137 ++++++++++++++---- 4 files changed, 150 insertions(+), 37 deletions(-) create mode 100644 explainers/fpv-drone/src/scene/parts/world/crossing.test.ts diff --git a/explainers/fpv-drone/src/scene/constants.ts b/explainers/fpv-drone/src/scene/constants.ts index 95593a9a..e8e406ff 100644 --- a/explainers/fpv-drone/src/scene/constants.ts +++ b/explainers/fpv-drone/src/scene/constants.ts @@ -219,17 +219,20 @@ export const CROSSING = { size: [6, 2.6, 4.5] as Triple, roof: { rise: 1.4, overhang: 0.4 }, heading: toRadians(12), - door: { width: 1.1, height: 2, depth: 0.05 }, + 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), - body: [4.2, 0.6, 1.8] as Triple, - cabin: [2, 0.55, 1.6] as Triple, - cabinShift: -0.2, - clearance: 0.25, - wheel: { radius: 0.32, width: 0.22, axle: 1.4, segments: 12 }, 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; diff --git a/explainers/fpv-drone/src/scene/finishes.ts b/explainers/fpv-drone/src/scene/finishes.ts index ac99b16a..a3689040 100644 --- a/explainers/fpv-drone/src/scene/finishes.ts +++ b/explainers/fpv-drone/src/scene/finishes.ts @@ -43,7 +43,8 @@ export const PAINT = { pad: '#3b3d40', padMark: '#a9ad92', shedWall: '#7a6e58', - shedRoof: '#4b4a48', + shedRoof: '#7a4c33', + shedTrim: '#5a4d3c', shedDoor: '#3a3128', carRust: '#5c3b2a', carGlass: '#1d2328', @@ -120,7 +121,8 @@ export const WORLD_FINISHES = { polygonOffsetUnits: -2, }, shedWall: matte(PAINT.shedWall, 0.9), - shedRoof: metal(PAINT.shedRoof, 0.6, 0.4), + 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 }, 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 index bd7d7e47..903bbcf3 100644 --- a/explainers/fpv-drone/src/scene/parts/world/crossing.ts +++ b/explainers/fpv-drone/src/scene/parts/world/crossing.ts @@ -9,9 +9,11 @@ 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({ @@ -24,45 +26,120 @@ function centredBox(at: Triple, size: Triple): BufferGeometry { }); } -function shedGeometry(): { walls: BufferGeometry; roof: BufferGeometry; door: BufferGeometry } { - const { size, roof, door } = CROSSING.shed; +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 walls = centredBox([0, 0, 0], size); const gable = new Shape(); const half = depth / 2 + roof.overhang; - gable.moveTo(-half, wall - 0.05); - gable.lineTo(half, wall - 0.05); + gable.moveTo(-half, wall - ROOF_SEAT); + gable.lineTo(half, wall - ROOF_SEAT); gable.lineTo(0, wall + roof.rise); gable.closePath(); - const roofGeometry = extrudeProfileAlongX( - gable, - -length / 2 - roof.overhang, - length / 2 + roof.overhang, - ); - const doorGeometry = centredBox( - [length * 0.2, 0, -depth / 2 - door.depth / 2], - [door.width, door.height, door.depth], - ); - return { walls, roof: roofGeometry, door: doorGeometry }; + 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 carGeometry(): { body: BufferGeometry; glass: BufferGeometry; wheels: BufferGeometry } { - const { body, cabin, cabinShift, clearance, wheel } = CROSSING.car; - const shell = centredBox([0, clearance, 0], body); - const top = centredBox([body[0] * cabinShift, clearance + body[1], 0], cabin); - const glass = centredBox( - [body[0] * cabinShift + cabin[0] / 2, clearance + body[1] + 0.05, 0], - [0.05, cabin[1] * 0.7, cabin[2] * 0.85], +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, + }), ); - const wheels = 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 * (body[2] / 2 - wheel.width * 0.3)); - return tyre; + 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 { body: mergeParts([shell, top]), glass, wheels: mergeParts(wheels) }; + 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 { @@ -82,6 +159,8 @@ export function createCrossing(context: PartContext): CrossingPart { 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(); From 55299fae013b459c496dbf1d181e52b38a145cef Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:09:42 +0200 Subject: [PATCH 31/55] feat(fpv-drone): vary the conifers and branch the bare poplars --- explainers/fpv-drone/src/scene/constants.ts | 18 ++- explainers/fpv-drone/src/scene/finishes.ts | 3 +- .../fpv-drone/src/scene/parts/world/trees.ts | 116 ++++++++++++++---- .../src/scene/parts/world/world.test.ts | 25 +++- 4 files changed, 129 insertions(+), 33 deletions(-) diff --git a/explainers/fpv-drone/src/scene/constants.ts b/explainers/fpv-drone/src/scene/constants.ts index e8e406ff..22076e84 100644 --- a/explainers/fpv-drone/src/scene/constants.ts +++ b/explainers/fpv-drone/src/scene/constants.ts @@ -245,17 +245,27 @@ export const TREES = { seed: 53, poplarShare: 0.35, conifer: { - height: [9, 15] as Extent, + height: [7, 18] as Extent, tiers: 3, - radius: 0.32, + 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, 19] as Extent, + height: [12, 20] as Extent, trunk: { radius: [0.14, 0.32] as Extent, segments: 7 }, - branches: { count: 7, from: 0.3, length: 0.3, spread: toRadians(18), radius: 0.06 }, + 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; diff --git a/explainers/fpv-drone/src/scene/finishes.ts b/explainers/fpv-drone/src/scene/finishes.ts index a3689040..783a2b43 100644 --- a/explainers/fpv-drone/src/scene/finishes.ts +++ b/explainers/fpv-drone/src/scene/finishes.ts @@ -50,7 +50,6 @@ export const PAINT = { carGlass: '#1d2328', tyre: '#1b1c1e', trunk: '#4a3a2c', - conifer: THEME.tree, poplar: '#6b5d50', } as const; @@ -128,7 +127,7 @@ export const WORLD_FINISHES = { 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(PAINT.conifer, 0.95), + conifer: { ...matte('#ffffff', 0.95), vertexColors: true }, poplar: matte(PAINT.poplar, 0.9), } as const satisfies Record; diff --git a/explainers/fpv-drone/src/scene/parts/world/trees.ts b/explainers/fpv-drone/src/scene/parts/world/trees.ts index 61cfb355..528fe88b 100644 --- a/explainers/fpv-drone/src/scene/parts/world/trees.ts +++ b/explainers/fpv-drone/src/scene/parts/world/trees.ts @@ -1,11 +1,11 @@ -import { ConeGeometry, CylinderGeometry, Group } from 'three'; +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 { rod } from '../../geometry/rods'; +import { along, rod } from '../../geometry/rods'; import type { Vec3 } from '../../geometry/rods'; import { mergeParts, partMesh } from '../context'; import type { PartContext } from '../context'; @@ -16,66 +16,130 @@ export interface TreeSpot { height: number; poplar: boolean; turn: number; + girth: number; + tone: number; } -const SEEDS = { jitterX: 1, jitterZ: 2, kind: 3, height: 4, turn: 5 } as const; +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 seed = TREES.seed + rowIndex * 100; - const poplar = hash2(index, seed + SEEDS.kind) < TREES.poplarShare; + 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 + (hash2(index, seed + SEEDS.jitterX) - 0.5) * row.jitter * 2, - z: TREES.line.z + row.z + (hash2(index, seed + SEEDS.jitterZ) - 0.5) * row.jitter * 2, - height: lerp(range[0], range[1], hash2(index, seed + SEEDS.height)), + 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: hash2(index, seed + SEEDS.turn) * Math.PI * 2, + 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(spot.height * radius * share, tierHeight, segments); + const cone = new ConeGeometry(width * share, tierHeight, segments); cone.translate(0, base + tierHeight / 2, 0); cone.rotateY(spot.turn); - cone.translate(spot.x, 0, spot.z); crowns.push(cone); } - const stem = new CylinderGeometry(trunk.radius * 0.7, trunk.radius, trunkHeight * 1.4, 6); - stem.translate(spot.x, trunkHeight * 0.7, spot.z); - return { crown: mergeParts(crowns), trunk: stem }; + 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 poplarGeometry(spot: TreeSpot): BufferGeometry { - const { trunk, branches } = TREES.poplar; +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 = Array.from({ length: branches.count }, (_, index) => { + 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 * Math.PI * 2) / 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 * 0.5); - const tip: Vec3 = [ - spot.x + Math.cos(angle) * Math.sin(branches.spread) * length, - spot.height * share + Math.cos(branches.spread) * length, - spot.z + Math.sin(angle) * Math.sin(branches.spread) * length, - ]; - return rod(base, tip, branches.radius, 5, branches.radius * 0.4); - }); + 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]); } diff --git a/explainers/fpv-drone/src/scene/parts/world/world.test.ts b/explainers/fpv-drone/src/scene/parts/world/world.test.ts index 57cd0f0d..5c79f4d9 100644 --- a/explainers/fpv-drone/src/scene/parts/world/world.test.ts +++ b/explainers/fpv-drone/src/scene/parts/world/world.test.ts @@ -5,7 +5,7 @@ import { FIELD, ROADS, SHRUBS, TREES } from '../../constants'; import { PAINT } from '../../finishes'; import { fieldColour, mownBand } from './field'; import { scatterShrubs } from './shrubs'; -import { treeSpots } from './trees'; +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); @@ -70,6 +70,29 @@ describe('treeline', () => { }); }); +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(); From 7c648c6301fd872866694cd5da1c979697360205 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:11:14 +0200 Subject: [PATCH 32/55] feat(fpv-drone): switch to the real scene with its lighting and options --- explainers/fpv-drone/src/scene/assembly.ts | 4 +- explainers/fpv-drone/src/scene/index.ts | 34 +--- .../src/scene/placeholderAssembly.ts | 145 ------------------ 3 files changed, 6 insertions(+), 177 deletions(-) delete mode 100644 explainers/fpv-drone/src/scene/placeholderAssembly.ts diff --git a/explainers/fpv-drone/src/scene/assembly.ts b/explainers/fpv-drone/src/scene/assembly.ts index ee6ae045..455ba3b1 100644 --- a/explainers/fpv-drone/src/scene/assembly.ts +++ b/explainers/fpv-drone/src/scene/assembly.ts @@ -2,7 +2,7 @@ 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 { createPlaceholderAssembly } from './placeholderAssembly'; +import { createFpvAssembly } from './fpvAssembly'; export interface AssemblyResources { materials: MaterialLibrary; @@ -30,5 +30,5 @@ export interface Assembly { } export function createAssembly(resources: AssemblyResources, state: AssemblyState): Assembly { - return createPlaceholderAssembly(resources, state); + return createFpvAssembly(resources, state); } diff --git a/explainers/fpv-drone/src/scene/index.ts b/explainers/fpv-drone/src/scene/index.ts index c14a96c4..af87422c 100644 --- a/explainers/fpv-drone/src/scene/index.ts +++ b/explainers/fpv-drone/src/scene/index.ts @@ -1,41 +1,15 @@ import { createLabelVisibility } from '@core/scene/presetBinder'; -import type { SceneOptions, SceneShell } from '@core/scene/shell'; -import type { PartId } from '../ids'; -import { SCENE_BOUNDS } from '../model'; +import type { SceneShell } from '@core/scene/shell'; import type { FpvStore } from '../state'; -import { THEME } from '../theme'; import { bindStore } from './bindings'; import { FpvController } from './controller'; +import { fpvLight } from './lighting'; import { LABEL_PRIORITY } from './partInfo'; -const UNDIMMED: readonly PartId[] = ['controlLink', 'videoLink', 'spinArrows']; -const HAZE = { near: 300, far: 1600 } as const; -const SCENE_SPAN = SCENE_BOUNDS.x[1] - SCENE_BOUNDS.x[0]; -const CAMERA_LIMITS = { - near: 0.1, - far: 4000, - minDistance: 2, - maxDistance: 1.5 * SCENE_SPAN, - maxPolarAngle: Math.PI * 0.86, -} as const; -const HIGHLIGHT_DIM = { saturation: 0.55, brightness: 0.62, emissive: 0.45 } as const; - -export const SCENE_OPTIONS: SceneOptions = { - background: THEME.skyHorizon, - fog: { color: THEME.skyHorizon, near: HAZE.near, far: HAZE.far }, - stage: false, - camera: { - near: CAMERA_LIMITS.near, - far: CAMERA_LIMITS.far, - maxPolarAngle: CAMERA_LIMITS.maxPolarAngle, - distance: { min: CAMERA_LIMITS.minDistance, max: CAMERA_LIMITS.maxDistance }, - }, - gaugeSide: 'top', - highlight: { dim: HIGHLIGHT_DIM, undimmed: UNDIMMED }, -}; +export { SCENE_OPTIONS } from './sceneOptions'; export function mountFpvScene(shell: SceneShell, store: FpvStore): () => void { - const restoreLight = (): void => {}; + const restoreLight = fpvLight(shell.lighting); const drone = new FpvController(shell); const removeFrame = shell.onFrame((deltaSeconds) => drone.update(deltaSeconds)); const labelVisibility = createLabelVisibility(shell, LABEL_PRIORITY); diff --git a/explainers/fpv-drone/src/scene/placeholderAssembly.ts b/explainers/fpv-drone/src/scene/placeholderAssembly.ts deleted file mode 100644 index 1b994ff7..00000000 --- a/explainers/fpv-drone/src/scene/placeholderAssembly.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Box3, BoxGeometry, Group, Mesh, PlaneGeometry } from 'three'; -import type { Object3D } from 'three'; -import { STRUCTURE_GROUP, UNDIMMED_GROUP } from '@core/scene/materials'; -import { anchorAt } from '@core/scene/parts'; -import { regionFromSpec } from '@core/scene/regions'; -import { ResourceTracker } from '@core/scene/resources'; -import type { AnchorId, AssemblyState, PartId, Point, RegionId } from '../ids'; -import { - CROSSROADS, - CROSSROADS_BOUNDS, - DRONE, - DRONE_LAYOUT, - ROUTE_BOUNDS, - SCENE_BOUNDS, - STATION, - STATION_BOUNDS, -} from '../model/layout'; -import { droneUnits } from '../model/scale'; -import { THEME } from '../theme'; -import type { Assembly, AssemblyResources, ChaseTarget } from './assembly'; - -const GROUND_SIZE = 600; -const STATION_SIZE: Point = [3, 1.2, 3]; -const CROSSROADS_SIZE: Point = [8, 0.2, 8]; -const HALF = 0.5; - -function pointOf(point: Point): Point { - return [point[0], point[1], point[2]]; -} - -class PlaceholderAssembly implements Assembly { - readonly root = new Group(); - private readonly tracker = new ResourceTracker(); - private readonly resources: AssemblyResources; - private readonly drone = new Group(); - private readonly labels = new Map(); - private readonly named: Record; - private readonly regions: Record; - private chase: ChaseTarget = { position: [0, 0, 0], heading: 0, pitch: 0, roll: 0, span: 1 }; - - constructor(resources: AssemblyResources, state: AssemblyState) { - this.resources = resources; - const station = this.block(STATION_SIZE, STATION, 'groundStation', THEME.oliveDark); - const crossroads = this.block(CROSSROADS_SIZE, CROSSROADS, 'crossroads', THEME.earth); - const body = this.block( - [droneUnits(DRONE.plateLength), droneUnits(DRONE.plateGap), droneUnits(DRONE.plateWidth)], - [0, 0, 0], - 'frame', - THEME.carbon, - ); - this.drone.add(body); - const camera = anchorAt(this.drone, ...this.scaled(DRONE_LAYOUT.camera)); - this.labels.set('camera', camera); - this.root.add(this.ground(), station, crossroads, this.drone); - this.named = { - drone: this.drone, - camera, - station, - crossroads, - }; - this.regions = { - scene: regionFromSpec(SCENE_BOUNDS), - station: regionFromSpec(STATION_BOUNDS), - crossroads: regionFromSpec(CROSSROADS_BOUNDS), - route: regionFromSpec(ROUTE_BOUNDS), - drone: new Box3(), - }; - this.setState(state); - } - - private scaled(point: Point): Point { - return [droneUnits(point[0]), droneUnits(point[1]), droneUnits(point[2])]; - } - - private ground(): Mesh { - const geometry = this.tracker.track(new PlaneGeometry(GROUND_SIZE, GROUND_SIZE)); - const mesh = new Mesh( - geometry, - this.resources.materials.get(UNDIMMED_GROUP, { color: THEME.grass, roughness: 1 }), - ); - mesh.rotation.x = -Math.PI * HALF; - return mesh; - } - - private block( - size: Point, - at: Point, - group: PartId | typeof STRUCTURE_GROUP, - color: string, - ): Mesh { - const geometry = this.tracker.track(new BoxGeometry(...size)); - const mesh = new Mesh(geometry, this.resources.materials.get(group, { color, roughness: 0.8 })); - mesh.position.set(at[0], at[1] + size[1] * HALF, at[2]); - if (group !== STRUCTURE_GROUP) this.labels.set(group, mesh); - return mesh; - } - - setState(state: AssemblyState): void { - const { position, heading, pitch, roll } = state.flight; - this.drone.position.set(position[0], position[1] + droneUnits(DRONE.restHeight), position[2]); - this.drone.rotation.set(roll, -heading, -pitch, 'YZX'); - this.drone.updateMatrixWorld(); - this.regions.drone.setFromObject(this.drone); - this.chase = { - position: pointOf(position), - heading, - pitch, - roll, - span: droneUnits(DRONE.wheelbase), - }; - } - - update(): boolean { - return false; - } - - 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; - } - - dispose(): void { - this.root.removeFromParent(); - this.resources.materials.clearRegistered(); - this.tracker.dispose(); - } -} - -export function createPlaceholderAssembly( - resources: AssemblyResources, - state: AssemblyState, -): Assembly { - return new PlaceholderAssembly(resources, state); -} From e398fb2e1c76ae8ca02a9ece24a1d7d177107b75 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:21:08 +0200 Subject: [PATCH 33/55] fix(fpv-drone): match the power readouts to what the model renders --- explainers/fpv-drone/chapters.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/explainers/fpv-drone/chapters.html b/explainers/fpv-drone/chapters.html index afcab398..b8bf2149 100644 --- a/explainers/fpv-drone/chapters.html +++ b/explainers/fpv-drone/chapters.html @@ -529,18 +529,18 @@

Twelve minutes in the air<
Flight time
-
13 min
+
12.3 min
Pack voltage now
-
23.8 V
+
23.9 V
Current now
-
20 A
+
17.6 A
From f70955ea8aee2197e151f10a99391a313362a036 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:21:27 +0200 Subject: [PATCH 34/55] fix(fpv-drone): say what the tilt thrust is a share of --- explainers/fpv-drone/locales/en.json | 1 + explainers/fpv-drone/src/ui/format.dom.test.ts | 2 ++ explainers/fpv-drone/src/ui/format.ts | 4 ++++ explainers/fpv-drone/src/ui/mountTiltControl.ts | 4 ++-- explainers/fpv-drone/src/ui/mountUi.dom.test.ts | 4 ++-- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/explainers/fpv-drone/locales/en.json b/explainers/fpv-drone/locales/en.json index d104da3d..20d6337b 100644 --- a/explainers/fpv-drone/locales/en.json +++ b/explainers/fpv-drone/locales/en.json @@ -119,6 +119,7 @@ "g": "{{value}} g", "kg": "{{value}} kg", "percent": "{{value}} %", + "ofHover": "{{percent}} % of hover", "volts": "{{value}} V", "amps": "{{value}} A", "ms": "{{value}} ms", diff --git a/explainers/fpv-drone/src/ui/format.dom.test.ts b/explainers/fpv-drone/src/ui/format.dom.test.ts index 31b5596d..63a29e2b 100644 --- a/explainers/fpv-drone/src/ui/format.dom.test.ts +++ b/explainers/fpv-drone/src/ui/format.dom.test.ts @@ -28,6 +28,7 @@ import { formatMilliseconds, formatMinutes, formatMix, + formatOfHover, formatPercent, formatPhase, formatPicture, @@ -87,6 +88,7 @@ describe('fpv formatting', () => { 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' })); diff --git a/explainers/fpv-drone/src/ui/format.ts b/explainers/fpv-drone/src/ui/format.ts index 33c00203..f1db67d8 100644 --- a/explainers/fpv-drone/src/ui/format.ts +++ b/explainers/fpv-drone/src/ui/format.ts @@ -95,6 +95,10 @@ 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) }); } diff --git a/explainers/fpv-drone/src/ui/mountTiltControl.ts b/explainers/fpv-drone/src/ui/mountTiltControl.ts index 8d6d753b..3e67b065 100644 --- a/explainers/fpv-drone/src/ui/mountTiltControl.ts +++ b/explainers/fpv-drone/src/ui/mountTiltControl.ts @@ -9,7 +9,7 @@ import { thrustShareAt, } from '../model'; import type { FpvStore } from '../state'; -import { formatDegrees, formatPercent, formatPush, formatSeconds } from './format'; +import { formatDegrees, formatOfHover, formatPush, formatSeconds } from './format'; type SelectedTilt = readonly [tilt: number]; @@ -23,7 +23,7 @@ export function mountTiltControl(root: Document, store: FpvStore): Disposer { set: (state, tilt) => state.setTilt(tilt), readouts: { 'tilt-push': ([tilt]) => formatPush(accelerationAt(tilt), accelerationInG(tilt)), - 'tilt-thrust': ([tilt]) => formatPercent(thrustShareAt(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 index 543c3c7f..f7f3766f 100644 --- a/explainers/fpv-drone/src/ui/mountUi.dom.test.ts +++ b/explainers/fpv-drone/src/ui/mountUi.dom.test.ts @@ -102,13 +102,13 @@ describe('chapter widgets', () => { 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.percent, { value: '115' })); + 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.percent, { value: '141' })); + 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: '∞' })); From b794e0af7585ca1e0e1f28ece09b991d49a49097 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:21:40 +0200 Subject: [PATCH 35/55] fix(fpv-drone): print the receiver sensitivity with a true minus sign --- explainers/fpv-drone/src/ui/format.dom.test.ts | 2 +- explainers/fpv-drone/src/ui/format.ts | 8 +++++++- explainers/fpv-drone/src/ui/mountUi.dom.test.ts | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/explainers/fpv-drone/src/ui/format.dom.test.ts b/explainers/fpv-drone/src/ui/format.dom.test.ts index 63a29e2b..5d679964 100644 --- a/explainers/fpv-drone/src/ui/format.dom.test.ts +++ b/explainers/fpv-drone/src/ui/format.dom.test.ts @@ -91,7 +91,7 @@ describe('fpv formatting', () => { 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(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' })); }); diff --git a/explainers/fpv-drone/src/ui/format.ts b/explainers/fpv-drone/src/ui/format.ts index f1db67d8..24953122 100644 --- a/explainers/fpv-drone/src/ui/format.ts +++ b/explainers/fpv-drone/src/ui/format.ts @@ -30,6 +30,7 @@ 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< @@ -54,6 +55,11 @@ 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); @@ -116,7 +122,7 @@ export function formatMilliseconds(ms: number): string { } export function formatDbm(dbm: number): string { - return t('units.dbm', { value: whole(dbm) }); + return t('units.dbm', { value: signedWhole(dbm) }); } export function formatMinutes(minutes: number): string { diff --git a/explainers/fpv-drone/src/ui/mountUi.dom.test.ts b/explainers/fpv-drone/src/ui/mountUi.dom.test.ts index f7f3766f..97ce234c 100644 --- a/explainers/fpv-drone/src/ui/mountUi.dom.test.ts +++ b/explainers/fpv-drone/src/ui/mountUi.dom.test.ts @@ -132,12 +132,12 @@ describe('chapter widgets', () => { 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-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-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); From aa742280cd87c6b7aff1e389281bebf3cbefb7ef Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:21:50 +0200 Subject: [PATCH 36/55] fix(fpv-drone): take the acro rate in the mode limit from the model --- explainers/fpv-drone/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/explainers/fpv-drone/locales/en.json b/explainers/fpv-drone/locales/en.json index 20d6337b..d1597a0c 100644 --- a/explainers/fpv-drone/locales/en.json +++ b/explainers/fpv-drone/locales/en.json @@ -177,7 +177,7 @@ "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 670 degrees a second by default", + "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" } From 1b9c23992a0a938ba890f9325fd80be0672c3e90 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:22:08 +0200 Subject: [PATCH 37/55] refactor(fpv-drone): give minutes per hour its own constant --- explainers/fpv-drone/src/model/battery.ts | 4 ++-- explainers/fpv-drone/src/model/payload.ts | 3 +-- explainers/fpv-drone/src/model/sortie.ts | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/explainers/fpv-drone/src/model/battery.ts b/explainers/fpv-drone/src/model/battery.ts index 1d3f2586..b3e8a9a1 100644 --- a/explainers/fpv-drone/src/model/battery.ts +++ b/explainers/fpv-drone/src/model/battery.ts @@ -6,7 +6,7 @@ import type { FlightMotion } from './flight'; import { totalThrustG } from './motors'; import { allUpG } from './payload'; import { powerAt } from './power'; -import { SECONDS_PER_MINUTE, SORTIE_SECONDS, clampSeconds } from './sortie'; +import { MINUTES_PER_HOUR, SECONDS_PER_MINUTE, SORTIE_SECONDS, clampSeconds } from './sortie'; export const BATTERY = { cells: PACK.cells, @@ -20,7 +20,7 @@ export const BATTERY = { export const BATTERY_GRID_S = 0.5; -const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * SECONDS_PER_MINUTE; +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; diff --git a/explainers/fpv-drone/src/model/payload.ts b/explainers/fpv-drone/src/model/payload.ts index cc841fcb..b0ed77e8 100644 --- a/explainers/fpv-drone/src/model/payload.ts +++ b/explainers/fpv-drone/src/model/payload.ts @@ -7,7 +7,7 @@ import { USABLE_ENERGY_SHARE, } from './figures'; import { cruisePowerW } from './power'; -import { SECONDS_PER_MINUTE } from './sortie'; +import { MINUTES_PER_HOUR } from './sortie'; export const PAYLOAD_RANGE = { min: 0, @@ -16,7 +16,6 @@ export const PAYLOAD_RANGE = { default: MASS_G.defaultPayload, } as const; -const MINUTES_PER_HOUR = SECONDS_PER_MINUTE; const MAX_THRUST_G = MOTOR_COUNT * THRUST_PER_MOTOR_G.flight; export const USABLE_ENERGY_WH = USABLE_ENERGY_SHARE * PACK.energyWh; diff --git a/explainers/fpv-drone/src/model/sortie.ts b/explainers/fpv-drone/src/model/sortie.ts index 7d8330de..adda95bc 100644 --- a/explainers/fpv-drone/src/model/sortie.ts +++ b/explainers/fpv-drone/src/model/sortie.ts @@ -4,6 +4,7 @@ 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; From 8a610cd5bec4f4698487f607db7cfdfcec85d463 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:22:11 +0200 Subject: [PATCH 38/55] fix(fpv-drone): lighten the flight mode chip tone to match its siblings --- explainers/fpv-drone/src/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/explainers/fpv-drone/src/style.css b/explainers/fpv-drone/src/style.css index 1c606c02..34ab3de0 100644 --- a/explainers/fpv-drone/src/style.css +++ b/explainers/fpv-drone/src/style.css @@ -10,7 +10,7 @@ --video-link: #5fa8ff; --clockwise: #ff9a3c; --counter: #5fd0ff; - --olive: #5b6b3a; + --olive: #a8b86c; } .section-readouts, From cce5177df80cc5ecec41813ef73d9cee3eff0e89 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Fri, 2 Oct 2026 20:22:49 +0200 Subject: [PATCH 39/55] fix(fpv-drone): copy fixes from review --- explainers/fpv-drone/chapters.html | 31 ++++++++++++++-------------- explainers/fpv-drone/locales/en.json | 12 +++++------ 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/explainers/fpv-drone/chapters.html b/explainers/fpv-drone/chapters.html index b8bf2149..c4fb9878 100644 --- a/explainers/fpv-drone/chapters.html +++ b/explainers/fpv-drone/chapters.html @@ -131,9 +131,10 @@

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 four to five times their - weight in thrust, so they hover at 20 to 25 % throttle and keep plenty for a climb or a sprint. - Long-range quads carry less, at least two to one, because a bigger battery weighs them down. + 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.