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:
co-authored by
Claude Sonnet 5
parent
6dcb765b80
commit
b93bbab23b
@@ -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<PlanScreen> {
|
||||
|
||||
// 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<PlanScreen> {
|
||||
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<PlanScreen> {
|
||||
_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<PlanScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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<LatLng>? rubberBand;
|
||||
if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) {
|
||||
@@ -196,16 +239,26 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
? 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<PlanScreen> {
|
||||
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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user