Files
PilotVault/Fly App/lib/fly/modes/terrain.dart
T
tajniak81andClaude Opus 5.5 636da1ac2c Give the Fly App every remaining GO 4 flight mode and the missing settings
Intelligent modes: Point of Interest (orbit on the map, live radius and
speed), Follow Me (follows the phone), QuickShots on a boxed subject,
Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and
Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and
Circle. Every mode has a first-use tutorial and stops from the rail.
Waypoints can resume an interrupted mission, change cruise speed in
flight and check terrain clearance (Open-Meteo, on tap).

Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI
output, a link spectrum chart, Wi-Fi name and password, aircraft name,
video cache, battery warning history and pairing, non-smart cell
voltages. Calibration: compass interference meter, per-IMU calibration,
lens infinity focus and a first-manual-focus prompt. Also an RTK chip,
gimbal movement presets and motor tuning, Precision Takeoff gated on the
flight controller, and the precise GEO database's server version.

Built and unit/widget-tested only; nothing has flown yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:06:16 +02:00

140 lines
6.0 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:latlong2/latlong.dart' show LatLng;
import '../hud_geometry.dart';
import 'waypoint_mission.dart';
/// The route's clearance over the ground (plan P7's terrain check).
///
/// Waypoint altitudes are relative to the take-off point, so a route that
/// climbs a hillside can fly into it while every waypoint reads "50 m". This
/// samples the ground along every leg (an elevation service, [ElevationSource])
/// and compares it with the aircraft's planned height above sea level: take-off
/// elevation + the altitude interpolated along the leg.
///
/// Pure apart from the source, so the maths is unit-tested with a fake.
abstract class ElevationSource {
/// Ground elevation in metres above sea level for each point, in order.
Future<List<double>> elevations(List<LatLng> points);
}
/// One sample along the route that comes too close to the ground.
class TerrainIssue {
const TerrainIssue({required this.leg, required this.clearance, required this.at});
/// The leg it is on: from waypoint [leg] to [leg] + 1 (0-based).
final int leg;
/// Metres between the aircraft and the ground there (negative = underground).
final double clearance;
final LatLng at;
MissionIssue toIssue() => clearance < 0
? MissionIssue.error(
'Between waypoints ${leg + 1} and ${leg + 2} the ground rises ${(-clearance).round()} m above the route.',
point: leg + 1,
)
: MissionIssue.warning(
'Between waypoints ${leg + 1} and ${leg + 2} the route passes only ${clearance.round()} m over the ground.',
point: leg + 1,
);
}
class TerrainCheck {
const TerrainCheck(this.source, {this.spacing = 50, this.minClearance = 15, this.maxSamples = 100});
final ElevationSource source;
/// Metres between samples along a leg.
final double spacing;
/// Less clearance than this is reported.
final double minClearance;
/// The service takes at most this many points per request.
final int maxSamples;
/// Samples along the route: every waypoint, and points every [spacing] m
/// between them, each with its leg and planned altitude (relative to take-off).
List<(int leg, LatLng at, double altitude)> samples(WaypointMissionSpec spec) {
final List<(int, LatLng, double)> out = <(int, LatLng, double)>[];
final List<WaypointSpec> p = spec.points;
for (int i = 0; i + 1 < p.length; i++) {
final LatLng a = p[i].at, b = p[i + 1].at;
final double d = HudGeometry.distance(a.latitude, a.longitude, b.latitude, b.longitude);
final int n = (d / spacing).ceil().clamp(1, 1000);
for (int k = 0; k < n; k++) {
final double t = k / n;
out.add((
i,
LatLng(a.latitude + (b.latitude - a.latitude) * t, a.longitude + (b.longitude - a.longitude) * t),
p[i].altitude + (p[i + 1].altitude - p[i].altitude) * t,
));
}
}
if (p.isNotEmpty) out.add((p.length - 2 < 0 ? 0 : p.length - 2, p.last.at, p.last.altitude));
// Evenly thinned to the service's limit.
if (out.length > maxSamples) {
final double step = out.length / maxSamples;
return <(int, LatLng, double)>[for (int k = 0; k < maxSamples; k++) out[(k * step).floor()]];
}
return out;
}
/// The places the route comes within [minClearance] of the ground; [home]
/// is the take-off point the altitudes are measured from. The worst per leg.
Future<List<TerrainIssue>> run(WaypointMissionSpec spec, LatLng home) async {
final List<(int, LatLng, double)> s = samples(spec);
if (s.isEmpty) return const <TerrainIssue>[];
final List<double> ground = await source.elevations(<LatLng>[home, for (final (int, LatLng, double) x in s) x.$2]);
if (ground.length != s.length + 1) throw const FormatException('The elevation service answered for a different number of points');
final double base = ground.first;
final Map<int, TerrainIssue> worst = <int, TerrainIssue>{};
for (int k = 0; k < s.length; k++) {
final (int leg, LatLng at, double alt) = s[k];
final double clearance = base + alt - ground[k + 1];
if (clearance >= minClearance) continue;
final TerrainIssue? w = worst[leg];
if (w == null || clearance < w.clearance) worst[leg] = TerrainIssue(leg: leg, clearance: clearance, at: at);
}
final List<TerrainIssue> out = worst.values.toList()..sort((TerrainIssue a, TerrainIssue b) => a.leg.compareTo(b.leg));
return out;
}
}
/// Open-Meteo's free elevation API (Copernicus 90 m DEM, no key). Sends the
/// sampled coordinates to api.open-meteo.com, so the check only runs when the
/// pilot asks for it.
class OpenMeteoElevation implements ElevationSource {
const OpenMeteoElevation();
@override
Future<List<double>> elevations(List<LatLng> points) async {
final List<double> out = <double>[];
// 100 coordinates per request is the API's limit.
for (int i = 0; i < points.length; i += 100) {
final List<LatLng> chunk = points.sublist(i, (i + 100).clamp(0, points.length));
final Uri uri = Uri.https('api.open-meteo.com', '/v1/elevation', <String, String>{
'latitude': chunk.map((LatLng p) => p.latitude.toStringAsFixed(5)).join(','),
'longitude': chunk.map((LatLng p) => p.longitude.toStringAsFixed(5)).join(','),
});
final HttpClient client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final HttpClientRequest req = await client.getUrl(uri);
final HttpClientResponse res = await req.close().timeout(const Duration(seconds: 15));
final String body = await res.transform(utf8.decoder).join();
if (res.statusCode != 200) throw HttpException('Elevation service answered ${res.statusCode}', uri: uri);
final Object? json = jsonDecode(body);
final Object? list = json is Map ? json['elevation'] : null;
if (list is! List) throw const FormatException('No elevation in the answer');
out.addAll(<double>[for (final Object? e in list) (e as num).toDouble()]);
} finally {
client.close();
}
}
return out;
}
}