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>
204 lines
6.3 KiB
Dart
204 lines
6.3 KiB
Dart
import 'dart:ui' show Offset, Rect, Size;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'orbit_modes.dart' show QuickShot;
|
|
|
|
/// What the pilot has chosen in an ActiveTrack or TapFly session, before and
|
|
/// while the operator runs: shared by the video overlay and the mode panel.
|
|
class VisionSession extends ChangeNotifier {
|
|
TrackMode trackMode = TrackMode.trace;
|
|
TapFlyDirection direction = TapFlyDirection.forward;
|
|
double speed = 5;
|
|
|
|
/// ActiveTrack Circle's orbit, m/s; negative goes anticlockwise.
|
|
double circleSpeed = 3;
|
|
|
|
/// The QuickShot the next box flies.
|
|
QuickShot quickShot = QuickShot.dronie;
|
|
|
|
void setCircleSpeed(double v) {
|
|
circleSpeed = v;
|
|
notifyListeners();
|
|
}
|
|
|
|
void setQuickShot(QuickShot s) {
|
|
quickShot = s;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// A box drawn (or tapped) on the video, 0..1, waiting for GO.
|
|
Rect? pendingBox;
|
|
|
|
/// When [pendingBox] is a subject the aircraft found itself: its index.
|
|
int? pendingSubject;
|
|
|
|
/// A TapFly target, 0..1, waiting for GO.
|
|
Offset? pendingPoint;
|
|
|
|
/// Trace may fly backwards ahead of a subject coming towards it; null until read.
|
|
bool? retreat;
|
|
|
|
void setRetreat(bool? on) {
|
|
retreat = on;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Picks an auto-sensed subject: its box with its index.
|
|
void pickSubject(int index, Rect box) {
|
|
pendingBox = box;
|
|
pendingSubject = index;
|
|
notifyListeners();
|
|
}
|
|
|
|
void setTrackMode(TrackMode m) {
|
|
trackMode = m;
|
|
notifyListeners();
|
|
}
|
|
|
|
void setDirection(TapFlyDirection d) {
|
|
direction = d;
|
|
notifyListeners();
|
|
}
|
|
|
|
void setSpeed(double v) {
|
|
speed = v;
|
|
notifyListeners();
|
|
}
|
|
|
|
void setPendingBox(Rect? r) {
|
|
pendingBox = r;
|
|
pendingSubject = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
void setPendingPoint(Offset? p) {
|
|
pendingPoint = p;
|
|
notifyListeners();
|
|
}
|
|
|
|
void clearPending() {
|
|
if (pendingBox == null && pendingPoint == null) return;
|
|
pendingBox = null;
|
|
pendingSubject = null;
|
|
pendingPoint = null;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// ActiveTrack's follow styles (plan P7b, GO 4's Trace / Profile / Spotlight).
|
|
enum TrackMode {
|
|
/// Follows behind (or ahead of) the subject.
|
|
trace('Trace', 'TRACE'),
|
|
|
|
/// Flies alongside it.
|
|
profile('Profile', 'PROFILE'),
|
|
|
|
/// Stays put and keeps the camera on it; the pilot flies.
|
|
spotlight('Spotlight', 'SPOTLIGHT'),
|
|
|
|
/// Spotlight that also turns the aircraft (the gimbal alone cannot keep
|
|
/// up): Mavic 2-class aircraft.
|
|
spotlightPro('Spotlight Pro', 'SPOTLIGHT_PRO'),
|
|
|
|
/// Orbits the subject at [VisionSession.circleSpeed].
|
|
circle('Circle', 'CIRCLE');
|
|
|
|
const TrackMode(this.label, this.sdk);
|
|
final String label;
|
|
final String sdk;
|
|
|
|
/// The follow styles GO 4 offers: Spotlight Pro and Circle came with the
|
|
/// omni-sensing aircraft (Mavic 2); everything else has the first three.
|
|
static List<TrackMode> offered({required bool omni}) =>
|
|
<TrackMode>[trace, profile, spotlight, if (omni) spotlightPro, if (omni) circle];
|
|
|
|
static TrackMode? fromSdk(String? s) {
|
|
for (final TrackMode m in values) {
|
|
if (m.sdk == s) return m;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// TapFly's directions (GO 4's Forward / Backward / Free).
|
|
enum TapFlyDirection {
|
|
forward('Forward', 'FORWARD'),
|
|
backward('Backward', 'BACKWARD'),
|
|
|
|
/// Flies towards the point while the pilot turns the aircraft freely.
|
|
free('Free', 'FREE');
|
|
|
|
const TapFlyDirection(this.label, this.sdk);
|
|
final String label;
|
|
final String sdk;
|
|
}
|
|
|
|
/// The live-view geometry the touch layers share: screen points to the
|
|
/// operators' 0..1 picture coordinates.
|
|
///
|
|
/// The native view letterboxes the feed to its own aspect ratio, so the
|
|
/// picture is [contentRect] — centred in the view, with black bars on two
|
|
/// sides. Every mapping goes through that rect; a touch on a bar is no touch.
|
|
abstract final class VisionGeometry {
|
|
/// A drawn box smaller than this (px, either side) is taken as a tap.
|
|
static const double minDrag = 24;
|
|
|
|
/// The box a tap makes, as a fraction of the picture: a person-sized window.
|
|
static const Size tapBox = Size(0.12, 0.22);
|
|
|
|
/// Where the picture of size [video] sits inside [view] (aspect fit,
|
|
/// centred); the whole view while the feed size is unknown.
|
|
static Rect contentRect(Size view, Size? video) {
|
|
if (video == null || video.isEmpty || view.isEmpty) return Offset.zero & view;
|
|
final double aspect = video.width / video.height;
|
|
final double w, h;
|
|
if (view.width / view.height > aspect) {
|
|
h = view.height;
|
|
w = h * aspect;
|
|
} else {
|
|
w = view.width;
|
|
h = w / aspect;
|
|
}
|
|
return Rect.fromLTWH((view.width - w) / 2, (view.height - h) / 2, w, h);
|
|
}
|
|
|
|
/// The 0..1 box between two corners of a drag, clipped to the picture; null
|
|
/// when it is too small to be a deliberate box, or misses the picture.
|
|
static Rect? dragBox(Offset a, Offset b, Rect content) {
|
|
if ((a.dx - b.dx).abs() < minDrag || (a.dy - b.dy).abs() < minDrag) return null;
|
|
final Rect r = Rect.fromPoints(a, b).intersect(content);
|
|
if (r.width < minDrag || r.height < minDrag) return null;
|
|
return Rect.fromLTRB(
|
|
(r.left - content.left) / content.width,
|
|
(r.top - content.top) / content.height,
|
|
(r.right - content.left) / content.width,
|
|
(r.bottom - content.top) / content.height,
|
|
);
|
|
}
|
|
|
|
/// A [tapBox] centred on a tap, kept inside the picture; null on a bar.
|
|
static Rect? tapBoxAt(Offset p, Rect content) {
|
|
final Offset? n = normalise(p, content);
|
|
if (n == null) return null;
|
|
final double cx = n.dx.clamp(tapBox.width / 2, 1 - tapBox.width / 2);
|
|
final double cy = n.dy.clamp(tapBox.height / 2, 1 - tapBox.height / 2);
|
|
return Rect.fromCenter(center: Offset(cx, cy), width: tapBox.width, height: tapBox.height);
|
|
}
|
|
|
|
/// [p] as 0..1 of the picture; null when it falls on a bar.
|
|
static Offset? normalise(Offset p, Rect content) {
|
|
if (!content.contains(p)) return null;
|
|
return Offset((p.dx - content.left) / content.width, (p.dy - content.top) / content.height);
|
|
}
|
|
|
|
static Rect toScreen(Rect r, Rect content) => Rect.fromLTRB(
|
|
content.left + r.left * content.width,
|
|
content.top + r.top * content.height,
|
|
content.left + r.right * content.width,
|
|
content.top + r.bottom * content.height,
|
|
);
|
|
|
|
static Offset pointToScreen(Offset p, Rect content) => Offset(content.left + p.dx * content.width, content.top + p.dy * content.height);
|
|
}
|