Add fillet flight path geometry and Alt/Speed wheels

- RouteGeometry (lib/domain/mission/route_geometry.dart): physically
  grounded flight path - straight segments + tangential arcs at each
  course change, sized from the drone's minimum turn radius (doc 3.7/4.6),
  replacing a naive spline that would suggest unrealistic turn radii.
  Marks a vertex "bad" (turn angle >= 160°, tangent length exceeding 90%
  of either adjacent leg, or exceeding the waypoint's catch radius) and a
  leg "bad" when the required climb/descent rate exceeds the drone's
  max climb/descent rate. Pure Dart, no Flutter dependency, so it stays
  usable if the mission domain is ever split into its own package (4.22).
  Wind-based turn-radius correction from the prototype isn't ported yet -
  the wind system (doc 3.8) doesn't exist in the Flutter app.
- MissionMap now draws each RouteSegment as its own Polyline, colored red
  when bad instead of a single plain white line; waypoint markers turn red
  too when their vertex is bad.
- ValueWheel: vertical drag-to-adjust tape control (HTML prototype's
  #altWheel/#spdWheel), custom-painted tick marks, blue center indicator,
  gradient fade at top/bottom. Wired into PlanScreen on both screen edges
  with Alt/Speed readouts.
- curAlt/curSpeed now live in PlanScreen state instead of fixed constants:
  dropping a new waypoint uses whatever the wheels are currently set to;
  entering editing mode on an existing waypoint loads its values into the
  wheels; adjusting a wheel while editing writes live into that waypoint
  (provider gained setAltitude/setSpeed to match the existing
  moveWaypoint/toggleAction pattern).

Added dedicated unit tests for the geometry (empty list, straight line,
feasible 90° turn producing a line-arc-line segment sequence, an
infeasible near-180° turn, and an infeasible descent rate) rather than
relying on eyeballing it on the emulator - this is exactly the kind of
ported-math correctness that's hard to verify visually but easy to get
subtly wrong. All 11 tests (previous 6 + these 5) and flutter analyze
pass. Also manually verified the wheels on the Pixel_10a emulator: drag
changes the value and the on-screen readout in real time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-07-27 23:13:24 +02:00
co-authored by Claude Sonnet 5
parent 6dcb765b80
commit b93bbab23b
6 changed files with 617 additions and 20 deletions
+75
View File
@@ -0,0 +1,75 @@
import 'package:dmc_app/domain/mission/route_geometry.dart';
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
import 'package:flutter_test/flutter_test.dart';
Waypoint _wp(double lat, double lon, {double alt = 60, double speed = 15}) =>
Waypoint(lat: lat, lon: lon, altitudeM: alt, speedMs: speed, catchRadiusM: 60);
void main() {
group('buildRouteGeometry', () {
const params = (minTurnRadius: 35.0, maxClimbRate: 5.0, maxDescentRate: 6.0);
test('leere Wegpunktliste liefert leere Geometrie', () {
final geometry = buildRouteGeometry(
const [],
minTurnRadius: params.minTurnRadius,
maxClimbRate: params.maxClimbRate,
maxDescentRate: params.maxDescentRate,
);
expect(geometry.segments, isEmpty);
expect(geometry.vertexBad, isEmpty);
});
test('zwei Wegpunkte ergeben eine einzelne Geradenverbindung', () {
final geometry = buildRouteGeometry(
[_wp(0, 0), _wp(0, 0.01)],
minTurnRadius: params.minTurnRadius,
maxClimbRate: params.maxClimbRate,
maxDescentRate: params.maxDescentRate,
);
expect(geometry.segments, hasLength(1));
expect(geometry.segments.single.bad, isFalse);
expect(geometry.vertexBad, [false, false]);
});
test('sanfte 90-Grad-Kurve mit ausreichend Platz ist fliegbar', () {
final geometry = buildRouteGeometry(
[_wp(0, 0), _wp(0, 0.01), _wp(0.01, 0.01)],
minTurnRadius: params.minTurnRadius,
maxClimbRate: params.maxClimbRate,
maxDescentRate: params.maxDescentRate,
);
expect(geometry.vertexBad[1], isFalse);
// Gerade -> Bogen -> Gerade.
expect(geometry.segments, hasLength(3));
expect(geometry.segments[1].points.length, greaterThan(2));
});
test('scharfe Kehrtwende ueberschreitet den maximalen Kurswinkel', () {
final geometry = buildRouteGeometry(
[_wp(0, 0), _wp(0, 0.01), _wp(0, 0.001)],
minTurnRadius: params.minTurnRadius,
maxClimbRate: params.maxClimbRate,
maxDescentRate: params.maxDescentRate,
);
expect(geometry.vertexBad[1], isTrue);
});
test('zu steiler Sinkflug markiert das Geradenstueck als bad', () {
// ~1113 m horizontal bei 15 m/s ~ 74s Flugzeit; 500 m Sinken darin
// ergibt eine Sinkrate weit ueber der maxDescentRate von 6 m/s.
final geometry = buildRouteGeometry(
[_wp(0, 0, alt: 500), _wp(0, 0.01, alt: 0)],
minTurnRadius: params.minTurnRadius,
maxClimbRate: params.maxClimbRate,
maxDescentRate: params.maxDescentRate,
);
expect(geometry.segments.single.bad, isTrue);
});
});
}