From b93bbab23bdf6615bd53802679ed17e0f420a1f2 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:13:24 +0200 Subject: [PATCH] Add fillet flight path geometry and Alt/Speed wheels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/lib/domain/mission/route_geometry.dart | 221 ++++++++++++++++++ .../providers/current_mission_provider.dart | 18 ++ app/lib/ui/screens/plan/plan_screen.dart | 141 +++++++++-- app/lib/ui/widgets/mission_map.dart | 19 +- app/lib/ui/widgets/value_wheel.dart | 163 +++++++++++++ app/test/domain/route_geometry_test.dart | 75 ++++++ 6 files changed, 617 insertions(+), 20 deletions(-) create mode 100644 app/lib/domain/mission/route_geometry.dart create mode 100644 app/lib/ui/widgets/value_wheel.dart create mode 100644 app/test/domain/route_geometry_test.dart diff --git a/app/lib/domain/mission/route_geometry.dart b/app/lib/domain/mission/route_geometry.dart new file mode 100644 index 0000000..c488ce0 --- /dev/null +++ b/app/lib/domain/mission/route_geometry.dart @@ -0,0 +1,221 @@ +import 'dart:math' as math; + +import 'package:latlong2/latlong.dart'; + +import '../waypoint/flat_waypoint_list.dart'; + +/// Ein Streckenabschnitt der Flugpfad-Darstellung: entweder ein +/// Geradenstueck zwischen zwei Tangentenpunkten oder ein abgetasteter +/// Kreisbogen (Fillet) an einer Kursaenderung (Architektur-Doku 3.7/4.6). +class RouteSegment { + const RouteSegment({required this.points, required this.bad}); + + final List points; + final bool bad; +} + +class RouteGeometry { + const RouteGeometry({required this.segments, required this.vertexBad}); + + final List segments; + + /// Pro Wegpunkt: true, wenn die Kursaenderung an diesem Punkt mit dem + /// minimalen Kurvenradius der Drohne nicht fliegbar ist (Tangentenlaenge + /// ueberschreitet Fangradius oder angrenzendes Geradenstueck, oder der + /// Kurswinkel ist zu scharf). + final List vertexBad; + + static const empty = RouteGeometry(segments: [], vertexBad: []); +} + +class _V { + const _V(this.x, this.y); + final double x; + final double y; + + _V operator -(_V other) => _V(x - other.x, y - other.y); + _V operator +(_V other) => _V(x + other.x, y + other.y); + _V scaled(double f) => _V(x * f, y * f); + double get length => math.sqrt(x * x + y * y); + double dot(_V other) => x * other.x + y * other.y; + double cross(_V other) => x * other.y - y * other.x; +} + +class _MetricPoint { + const _MetricPoint(this.pos, this.alt, this.speed); + final _V pos; + final double alt; + final double speed; +} + +class _VertexArc { + const _VertexArc({ + required this.t1, + required this.t2, + required this.center, + required this.radius, + required this.angleStart, + required this.angleEnd, + }); + + final _V t1; + final _V t2; + final _V center; + final double radius; + final double angleStart; + final double angleEnd; +} + +const _maxTurnAngle = 160 * math.pi / 180; + +/// Baut den physikalisch fundierten Flugpfad: Geradenstuecke + tangentiale +/// Kreisboegen an jeder Kursaenderung (Doku 3.7), abgeleitet aus dem +/// minimalen Kurvenradius der Drohne. Ersetzt einen naiven Spline durch +/// alle Punkte (Doku 4.6) - ein Spline wuerde unrealistische Kurvenradien +/// suggerieren und ein falsches Sicherheitsgefuehl bei engen Kurven geben. +/// +/// Windkorrektur des effektiven Kurvenradius (HTML-Demonstrator: +/// vertexGroundSpeed-Anpassung) ist noch nicht portiert, da das Wind-System +/// (Doku 3.8) in der Flutter-App noch nicht existiert. +RouteGeometry buildRouteGeometry( + List waypoints, { + required double minTurnRadius, + required double maxClimbRate, + required double maxDescentRate, +}) { + final n = waypoints.length; + if (n == 0) return RouteGeometry.empty; + + final ref = LatLng(waypoints.first.lat, waypoints.first.lon); + final pts = [for (final w in waypoints) _toMeters(w, ref)]; + + final vertexBad = List.filled(n, false); + final arcs = List<_VertexArc?>.filled(n, null); + + for (var i = 1; i < n - 1; i++) { + final pPrev = pts[i - 1]; + final pCur = pts[i]; + final pNext = pts[i + 1]; + + final inVec = pCur.pos - pPrev.pos; + final outVec = pNext.pos - pCur.pos; + final inLen = inVec.length; + final outLen = outVec.length; + final vIn = inLen > 1e-6 ? inVec.scaled(1 / inLen) : const _V(1, 0); + final vOut = outLen > 1e-6 ? outVec.scaled(1 / outLen) : const _V(1, 0); + + final dot = vIn.dot(vOut).clamp(-1.0, 1.0); + final turnAngle = math.acos(dot); + if (turnAngle <= 0.02) continue; + + final cappedAngle = math.min(turnAngle, _maxTurnAngle); + final t = minTurnRadius * math.tan(cappedAngle / 2); + + var bad = false; + if (turnAngle >= _maxTurnAngle) bad = true; + if (t > inLen * 0.9 || t > outLen * 0.9) bad = true; + if (t > waypoints[i].catchRadiusM) bad = true; + + final turnLeft = vIn.cross(vOut) > 0; + final normalIn = + turnLeft ? _V(-vIn.y, vIn.x) : _V(vIn.y, -vIn.x); + + final t1 = pCur.pos - vIn.scaled(t); + final t2 = pCur.pos + vOut.scaled(t); + final center = t1 + normalIn.scaled(minTurnRadius); + final angleStart = math.atan2(t1.y - center.y, t1.x - center.x); + final angleEnd = angleStart + (turnLeft ? cappedAngle : -cappedAngle); + + vertexBad[i] = bad; + arcs[i] = _VertexArc( + t1: t1, + t2: t2, + center: center, + radius: minTurnRadius, + angleStart: angleStart, + angleEnd: angleEnd, + ); + } + + final legClimbBad = List.filled(math.max(n - 1, 0), false); + for (var i = 0; i < n - 1; i++) { + final a = pts[i]; + final b = pts[i + 1]; + final horiz = (b.pos - a.pos).length; + legClimbBad[i] = _isClimbBad( + a.alt, + b.alt, + horiz, + b.speed, + maxClimbRate: maxClimbRate, + maxDescentRate: maxDescentRate, + ); + } + + final segments = []; + for (var leg = 0; leg < n - 1; leg++) { + final startArc = arcs[leg]; + final endArc = arcs[leg + 1]; + final startPt = (leg == 0) ? pts[0].pos : (startArc?.t2 ?? pts[leg].pos); + final endPt = + (leg == n - 2) ? pts[n - 1].pos : (endArc?.t1 ?? pts[leg + 1].pos); + + segments.add(RouteSegment( + points: [_fromMeters(startPt, ref), _fromMeters(endPt, ref)], + bad: legClimbBad[leg], + )); + + if (leg + 1 <= n - 2 && endArc != null) { + const steps = 14; + final arcPoints = [ + for (var k = 0; k <= steps; k++) + _fromMeters( + _pointOnCircle( + endArc.center, + endArc.radius, + endArc.angleStart + + (endArc.angleEnd - endArc.angleStart) * k / steps, + ), + ref, + ), + ]; + segments.add(RouteSegment(points: arcPoints, bad: vertexBad[leg + 1])); + } + } + + return RouteGeometry(segments: segments, vertexBad: vertexBad); +} + +_V _pointOnCircle(_V center, double radius, double angle) => + _V(center.x + radius * math.cos(angle), center.y + radius * math.sin(angle)); + +bool _isClimbBad( + double altA, + double altB, + double horizDist, + double speed, { + required double maxClimbRate, + required double maxDescentRate, +}) { + final vert = altB - altA; + if (horizDist < 0.001) return vert.abs() > 0.001; + final rate = vert / (horizDist / speed); + if (rate > 0) return rate > maxClimbRate; + return rate.abs() > maxDescentRate; +} + +// Equirektangulare Naeherung relativ zum ersten Wegpunkt - ausreichend fuer +// missionstypische Distanzen (HTML-Demonstrator: toMeters()/fromMeters()). +_MetricPoint _toMeters(Waypoint w, LatLng ref) { + final dLat = (w.lat - ref.latitude) * 110540; + final dLon = + (w.lon - ref.longitude) * 111320 * math.cos(ref.latitude * math.pi / 180); + return _MetricPoint(_V(dLon, dLat), w.altitudeM, w.speedMs); +} + +LatLng _fromMeters(_V m, LatLng ref) { + return LatLng( + ref.latitude + m.y / 110540, + ref.longitude + m.x / (111320 * math.cos(ref.latitude * math.pi / 180)), + ); +} diff --git a/app/lib/ui/providers/current_mission_provider.dart b/app/lib/ui/providers/current_mission_provider.dart index 418bcd3..29512cb 100644 --- a/app/lib/ui/providers/current_mission_provider.dart +++ b/app/lib/ui/providers/current_mission_provider.dart @@ -23,6 +23,24 @@ class CurrentMissionNotifier extends Notifier> { ]; } + /// Aktualisiert die Zielhoehe eines Wegpunkts (Alt-Drehrad im + /// Editier-Modus schreibt live in den Zielpunkt). + void setAltitude(int index, double altitudeM) { + state = [ + for (var i = 0; i < state.length; i++) + if (i == index) state[i].copyWith(altitudeM: altitudeM) else state[i], + ]; + } + + /// Aktualisiert die Zielgeschwindigkeit eines Wegpunkts (Speed-Drehrad im + /// Editier-Modus schreibt live in den Zielpunkt). + void setSpeed(int index, double speedMs) { + state = [ + for (var i = 0; i < state.length; i++) + if (i == index) state[i].copyWith(speedMs: speedMs) else state[i], + ]; + } + /// Schaltet die Execute-Aktion eines Wegpunkts um (Halo-Radialmenue: /// erneutes Antippen derselben Aktion setzt sie zurueck auf "none"). void toggleAction(int index, WaypointAction action) { diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index fee9fda..6280af2 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -6,11 +6,13 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; +import '../../../domain/mission/route_geometry.dart'; import '../../../domain/waypoint/flat_waypoint_list.dart'; import '../../providers/current_mission_provider.dart'; import '../../widgets/halo_menu.dart'; import '../../widgets/mission_map.dart'; import '../../widgets/reticle_button.dart'; +import '../../widgets/value_wheel.dart'; /// Reticle-Zustand (HTML-Demonstrator: Variable `mode`). /// idle: kein Wegpunkt in der Naehe, Tap legt einen neuen an. @@ -39,10 +41,26 @@ class _PlanScreenState extends ConsumerState { // TODO: aus dem aktiven Drohnen-Profil (Doku 3.5) uebernehmen, sobald die // Profilverwaltung existiert. Werte entsprechen dem Default-Profil - // T1 Ranger im HTML-Demonstrator (curAlt=60, cruiseSpeed=15, catchRadius=60). - static const _defaultAltitudeM = 60.0; - static const _defaultSpeedMs = 15.0; + // T1 Ranger im HTML-Demonstrator (DEFAULT_DRONE). + static const _altMin = -500.0; + static const _altMax = 2000.0; + static const _altStep = 10.0; + static const _altPxPerStep = 14.0; + static const _altMajorEvery = 5; + + static const _speedMin = 13.0; + static const _speedMax = 25.0; + static const _speedStep = 1.0; + static const _speedPxPerStep = 14.0; + static const _speedMajorEvery = 5; + static const _defaultCatchRadiusM = 60.0; + static const _minTurnRadius = 35.0; + static const _maxClimbRate = 5.0; + static const _maxDescentRate = 6.0; + + double _curAlt = 60; + double _curSpeed = 15; // Radius des Reticle-Buttons (siehe ReticleButton: 55px Durchmesser). static const _reticleRadiusPx = 55 / 2; @@ -138,8 +156,8 @@ class _PlanScreenState extends ConsumerState { Waypoint( lat: center.latitude, lon: center.longitude, - altitudeM: _defaultAltitudeM, - speedMs: _defaultSpeedMs, + altitudeM: _curAlt, + speedMs: _curSpeed, catchRadiusM: _defaultCatchRadiusM, ), ); @@ -148,7 +166,15 @@ class _PlanScreenState extends ConsumerState { _mode = _ReticleMode.snapped; }); case _ReticleMode.snapped: - setState(() => _mode = _ReticleMode.editing); + final waypoints = ref.read(currentMissionProvider); + if (_targetIndex >= 0 && _targetIndex < waypoints.length) { + final target = waypoints[_targetIndex]; + setState(() { + _mode = _ReticleMode.editing; + _curAlt = target.altitudeM; + _curSpeed = target.speedMs; + }); + } case _ReticleMode.editing: setState(() => _mode = _ReticleMode.snapped); } @@ -174,13 +200,30 @@ class _PlanScreenState extends ConsumerState { } } + void _onAltChanged(double value) { + setState(() => _curAlt = value); + if (_mode == _ReticleMode.editing && _targetIndex >= 0) { + ref.read(currentMissionProvider.notifier).setAltitude(_targetIndex, value); + } + } + + void _onSpeedChanged(double value) { + setState(() => _curSpeed = value); + if (_mode == _ReticleMode.editing && _targetIndex >= 0) { + ref.read(currentMissionProvider.notifier).setSpeed(_targetIndex, value); + } + } + @override Widget build(BuildContext context) { final waypoints = ref.watch(currentMissionProvider); - final routePoints = [ - for (final wp in waypoints) LatLng(wp.lat, wp.lon), - ]; + final routeGeometry = buildRouteGeometry( + waypoints, + minTurnRadius: _minTurnRadius, + maxClimbRate: _maxClimbRate, + maxDescentRate: _maxDescentRate, + ); List? rubberBand; if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) { @@ -196,16 +239,26 @@ class _PlanScreenState extends ConsumerState { ? waypoints[_targetIndex] : null; + final wheelBottomInset = math.max( + MediaQuery.of(context).size.height * 0.06, + 124.0, + ); + return Stack( children: [ MissionMap( mapController: _mapController, onMapReady: () => setState(() => _mapReady = true), - routeLine: routePoints, + routeSegments: routeGeometry.segments, rubberBandLine: rubberBand, waypointMarkers: [ for (var i = 0; i < waypoints.length; i++) - _markerFor(waypoints[i], isTarget: i == _targetIndex), + _markerFor( + waypoints[i], + isTarget: i == _targetIndex, + isBad: i < routeGeometry.vertexBad.length && + routeGeometry.vertexBad[i], + ), ], ), if (_mode == _ReticleMode.editing && targetWaypoint != null) @@ -221,22 +274,84 @@ class _PlanScreenState extends ConsumerState { alignment: Alignment.center, child: ReticleButton(icon: _reticleIcon, onTap: _onReticleTap), ), + Positioned( + top: 56, + bottom: wheelBottomInset, + left: 14, + child: ValueWheel( + value: _curAlt, + min: _altMin, + max: _altMax, + step: _altStep, + pxPerStep: _altPxPerStep, + majorEvery: _altMajorEvery, + onChanged: _onAltChanged, + ), + ), + Positioned( + top: 56, + bottom: wheelBottomInset, + left: 70, + child: Center(child: _readout('Alt', _curAlt, 'm')), + ), + Positioned( + top: 56, + bottom: wheelBottomInset, + right: 14, + child: ValueWheel( + value: _curSpeed, + min: _speedMin, + max: _speedMax, + step: _speedStep, + pxPerStep: _speedPxPerStep, + majorEvery: _speedMajorEvery, + onChanged: _onSpeedChanged, + ), + ), + Positioned( + top: 56, + bottom: wheelBottomInset, + right: 70, + child: Center(child: _readout('Speed', _curSpeed, 'm/s')), + ), ], ); } + Widget _readout(String label, double value, String unit) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '$label: ${value.round()} $unit', + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ); + } + IconData get _reticleIcon => switch (_mode) { _ReticleMode.idle => Icons.add, _ReticleMode.snapped => Icons.edit, _ReticleMode.editing => Icons.check, }; - CircleMarker _markerFor(Waypoint wp, {required bool isTarget}) { + CircleMarker _markerFor( + Waypoint wp, { + required bool isTarget, + required bool isBad, + }) { final highlighted = isTarget && _mode != _ReticleMode.idle; return CircleMarker( point: LatLng(wp.lat, wp.lon), radius: highlighted ? 9 : 6, - color: const Color(0xFF2B2B28), + color: isBad ? const Color(0xFFE2574A) : const Color(0xFF2B2B28), borderStrokeWidth: highlighted ? 3 : 1.5, borderColor: Colors.white, ); diff --git a/app/lib/ui/widgets/mission_map.dart b/app/lib/ui/widgets/mission_map.dart index 99b449d..e30e260 100644 --- a/app/lib/ui/widgets/mission_map.dart +++ b/app/lib/ui/widgets/mission_map.dart @@ -3,6 +3,8 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../domain/mission/route_geometry.dart'; + /// Vollbild-OSM-Karte, entspricht dem Leaflet-`#map` im HTML-Demonstrator: /// randlos, ohne Zoom-Buttons (Pinch-to-Zoom/Pan reicht), Standard-OSM-Tiles. class MissionMap extends StatelessWidget { @@ -10,7 +12,7 @@ class MissionMap extends StatelessWidget { super.key, this.mapController, this.waypointMarkers = const [], - this.routeLine, + this.routeSegments = const [], this.rubberBandLine, this.onMapReady, }); @@ -20,10 +22,9 @@ class MissionMap extends StatelessWidget { /// Kreis-Marker fuer die Wegpunkte der aktuellen Mission (Doku 3.7). final List waypointMarkers; - /// Verbindungslinie zwischen den Wegpunkten. Bewusst eine gerade Strecke - - /// die physikalisch fundierte Geraden+Kreisbogen-Geometrie (Doku 3.7/4.6) - /// ist ein separater, noch offener Schritt. - final List? routeLine; + /// Geraden + Kreisbogen-Segmente des Flugpfads (Doku 3.7/4.6); rot + /// markierte Segmente ueberschreiten Steig-/Sinkrate oder Kurvenradius. + final List routeSegments; /// Gestrichelte Vorschaulinie vom letzten Wegpunkt zur aktuellen /// Kartenmitte, waehrend noch kein neuer Punkt bestaetigt wurde. @@ -55,8 +56,12 @@ class MissionMap extends StatelessWidget { ), PolylineLayer( polylines: [ - if (routeLine != null && routeLine!.length > 1) - Polyline(points: routeLine!, strokeWidth: 3, color: Colors.white), + for (final segment in routeSegments) + Polyline( + points: segment.points, + strokeWidth: 3, + color: segment.bad ? const Color(0xFFE2574A) : Colors.white, + ), if (rubberBandLine != null) Polyline( points: rubberBandLine!, diff --git a/app/lib/ui/widgets/value_wheel.dart b/app/lib/ui/widgets/value_wheel.dart new file mode 100644 index 0000000..b266c8e --- /dev/null +++ b/app/lib/ui/widgets/value_wheel.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; + +/// Vertikales Drehrad zum Einstellen eines Werts per Swipe (HTML- +/// Demonstrator: .wheel/#altWheel/#spdWheel + setupWheel()). Der Tick an +/// der Bildschirmmitte (blauer Indikator) zeigt den aktuellen Wert; nach +/// oben ziehen erhoeht den Wert. +class ValueWheel extends StatefulWidget { + const ValueWheel({ + super.key, + required this.value, + required this.min, + required this.max, + required this.step, + required this.pxPerStep, + required this.majorEvery, + required this.onChanged, + }); + + final double value; + final double min; + final double max; + final double step; + final double pxPerStep; + final int majorEvery; + final ValueChanged onChanged; + + @override + State createState() => _ValueWheelState(); +} + +class _ValueWheelState extends State { + double? _dragStartY; + double _dragStartValue = 0; + + void _onDragStart(DragStartDetails details) { + _dragStartY = details.globalPosition.dy; + _dragStartValue = widget.value; + } + + void _onDragUpdate(DragUpdateDetails details) { + final startY = _dragStartY; + if (startY == null) return; + final deltaY = startY - details.globalPosition.dy; + final deltaSteps = deltaY / widget.pxPerStep; + final raw = _dragStartValue + deltaSteps * widget.step; + var stepped = (raw / widget.step).round() * widget.step; + stepped = stepped.clamp(widget.min, widget.max); + widget.onChanged(stepped); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onVerticalDragStart: _onDragStart, + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: (_) => _dragStartY = null, + child: Container( + width: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + gradient: LinearGradient( + colors: [ + Colors.black.withValues(alpha: 0.9), + const Color(0xFF333333).withValues(alpha: 0.9), + Colors.black.withValues(alpha: 0.9), + ], + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(22), + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (rect) => const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black, + Colors.black, + Colors.transparent, + ], + stops: [0, 0.1, 0.9, 1], + ).createShader(rect), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: CustomPaint( + painter: _TapePainter( + value: widget.value, + min: widget.min, + max: widget.max, + step: widget.step, + pxPerStep: widget.pxPerStep, + majorEvery: widget.majorEvery, + ), + ), + ), + Container(height: 2, color: const Color(0xFF4D9DFF)), + ], + ), + ), + ), + ), + ); + } +} + +class _TapePainter extends CustomPainter { + const _TapePainter({ + required this.value, + required this.min, + required this.max, + required this.step, + required this.pxPerStep, + required this.majorEvery, + }); + + final double value; + final double min; + final double max; + final double step; + final double pxPerStep; + final int majorEvery; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final centerX = size.width / 2; + final majorPaint = Paint()..color = Colors.white.withValues(alpha: 0.85); + final minorPaint = Paint()..color = Colors.white.withValues(alpha: 0.32); + + final nearestGridIndex = ((value - min) / step).round(); + final visibleHalfCount = (size.height / 2 / pxPerStep).ceil() + 1; + + for (var i = -visibleHalfCount; i <= visibleHalfCount; i++) { + final gridIndex = nearestGridIndex + i; + final tickValue = min + gridIndex * step; + if (tickValue < min || tickValue > max) continue; + + final y = centerY + (tickValue - value) / step * pxPerStep; + final isMajor = gridIndex % majorEvery == 0; + final halfWidth = size.width * (isMajor ? 0.35 : 0.19); + final thickness = isMajor ? 2.0 : 1.0; + + canvas.drawRect( + Rect.fromCenter( + center: Offset(centerX, y), + width: halfWidth * 2, + height: thickness, + ), + isMajor ? majorPaint : minorPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _TapePainter oldDelegate) => + oldDelegate.value != value || + oldDelegate.min != min || + oldDelegate.max != max || + oldDelegate.step != step; +} diff --git a/app/test/domain/route_geometry_test.dart b/app/test/domain/route_geometry_test.dart new file mode 100644 index 0000000..19057fc --- /dev/null +++ b/app/test/domain/route_geometry_test.dart @@ -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); + }); + }); +}