Vision radar: graded obstacle sectors around the centre of the video, the nearest distance per side, the obstacle overhead, and BLOCKED on a side whose sensors cannot see once the motors run. On aircraft with 360° sensing, sides without their own readings are filled from the perception ring. The same sectors sit on the attitude ball's rim, turned by the heading. Settings > Vision gains Display radar chart and the braking / sensing distances per direction. On-screen sticks: the rail's STICKS button hands the aircraft to two stick pads after a slide-to-confirm. The 25 Hz stream runs natively and sends zeros once Dart's last command is 300 ms old, so a stalled UI leaves the aircraft hovering. The sticks close when the remote takes over, on RTH or on landing. They are simulator-only on aircraft with a remote unless Settings > General > Developer allows real flights; Spark and Mavic Air on Wi-Fi may fly them for real. Which MSDK axis is forward in velocity + body mode is coded from DJI's documentation and must be checked in the simulator first. AirSense: a banner for manned aircraft at warning level 2 and up, with distance, direction and ICAO code; the planes on the map; the full list in a sheet and Settings > Safety; and the aircraft's own avoidance action with its countdown. Fly zones: DJI GEO zones around the aircraft (or the phone) on the map with their height limits, a banner for FlySafe notices and in/near-zone states, take-off refused in restricted and locked authorization zones, self-unlock of authorization zones, the aircraft's licences (enable, sync from the DJI account, remove all; ground only), and the GEO database version with an update button. Tested with unit and widget tests and screenshot renders only; nothing has run on an aircraft or the simulator yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
9.5 KiB
Dart
251 lines
9.5 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/foundation.dart' show listEquals;
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../../flight_model.dart';
|
|
import '../../../fly/hud_geometry.dart';
|
|
import '../../../fly/state/aircraft_state.dart';
|
|
import '../../../fly/vision_radar.dart';
|
|
import '../../../theme.dart';
|
|
import '../hud_kit.dart';
|
|
|
|
/// GO 4's attitude ball (plan P2): the horizon from the aircraft's pitch and
|
|
/// roll, with a north-up radar on top — the aircraft arrow in the middle
|
|
/// pointing along its heading, the home point and the pilot (phone GPS)
|
|
/// plotted by bearing and distance, and a tick on the rim for the gimbal's yaw.
|
|
///
|
|
/// Plan P8 adds GO 4's `DJIVisualObstacleRadarView` ring: the vision radar's
|
|
/// graded sectors drawn on the rim, turned with the heading (the ball is
|
|
/// north-up, the sensors are not).
|
|
class AttitudeBall extends StatelessWidget {
|
|
const AttitudeBall({super.key, required this.model, this.size = 96});
|
|
|
|
final FlightModel model;
|
|
final double size;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: Listenable.merge(<Listenable>[model.aircraft, model.gimbal, model.vision]),
|
|
builder: (BuildContext context, _) {
|
|
final AttitudeBallData d = AttitudeBallData.from(
|
|
model.aircraft,
|
|
gimbalYaw: model.gimbal.yaw,
|
|
phoneLat: model.phoneLatitude,
|
|
phoneLon: model.phoneLongitude,
|
|
radar: VisionRadar.of(model.vision),
|
|
);
|
|
return SizedBox(
|
|
width: size,
|
|
height: size + 12,
|
|
child: Column(children: <Widget>[
|
|
SizedBox.square(dimension: size, child: CustomPaint(painter: AttitudeBallPainter(d))),
|
|
const SizedBox(height: 1),
|
|
Text(d.hasPoints ? '${d.rangeM.toStringAsFixed(0)} m' : '', style: HudText.label),
|
|
]),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// One graded radar sector on the ball's rim.
|
|
typedef BallObstacle = ({double start, double sweep, RadarBand band});
|
|
|
|
/// Everything the painter needs, resolved from the models so it can be compared
|
|
/// for [CustomPainter.shouldRepaint] and built in tests.
|
|
@immutable
|
|
class AttitudeBallData {
|
|
const AttitudeBallData({
|
|
this.pitch,
|
|
this.roll,
|
|
this.heading,
|
|
this.gimbalYaw,
|
|
this.homeBearing,
|
|
this.homeDistance,
|
|
this.pilotBearing,
|
|
this.pilotDistance,
|
|
this.rangeM = 100,
|
|
this.obstacles = const <BallObstacle>[],
|
|
});
|
|
|
|
/// Bearings and distances are taken from the aircraft to each point.
|
|
factory AttitudeBallData.from(AircraftState a, {double? gimbalYaw, double? phoneLat, double? phoneLon, VisionRadar? radar}) {
|
|
final double? lat = a.latitude, lon = a.longitude;
|
|
final bool fix = lat != null && lon != null && !(lat == 0 && lon == 0);
|
|
double? hb, hd, pb, pd;
|
|
if (fix && a.homeLatitude != null && a.homeLongitude != null && a.homeLocationSet != false) {
|
|
hb = HudGeometry.bearing(lat, lon, a.homeLatitude!, a.homeLongitude!);
|
|
hd = HudGeometry.distance(lat, lon, a.homeLatitude!, a.homeLongitude!);
|
|
}
|
|
if (fix && phoneLat != null && phoneLon != null) {
|
|
pb = HudGeometry.bearing(lat, lon, phoneLat, phoneLon);
|
|
pd = HudGeometry.distance(lat, lon, phoneLat, phoneLon);
|
|
}
|
|
return AttitudeBallData(
|
|
pitch: a.pitch,
|
|
roll: a.roll,
|
|
heading: a.heading,
|
|
gimbalYaw: gimbalYaw,
|
|
homeBearing: hb,
|
|
homeDistance: hd,
|
|
pilotBearing: pb,
|
|
pilotDistance: pd,
|
|
rangeM: HudGeometry.ballRange(math.max(hd ?? 0, pd ?? 0)),
|
|
obstacles: <BallObstacle>[
|
|
for (final RadarSideView side in radar?.sides.values ?? const <RadarSideView>[])
|
|
for (final RadarSector s in side.sectors)
|
|
if (s.band != RadarBand.none) (start: s.start, sweep: s.sweep, band: s.band),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Graded radar sectors, degrees clockwise from the *nose*; the painter
|
|
/// turns them by [heading].
|
|
final List<BallObstacle> obstacles;
|
|
|
|
final double? pitch;
|
|
final double? roll;
|
|
final double? heading;
|
|
final double? gimbalYaw;
|
|
final double? homeBearing;
|
|
final double? homeDistance;
|
|
final double? pilotBearing;
|
|
final double? pilotDistance;
|
|
final double rangeM;
|
|
|
|
bool get hasPoints => homeDistance != null || pilotDistance != null;
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is AttitudeBallData &&
|
|
other.pitch == pitch &&
|
|
other.roll == roll &&
|
|
other.heading == heading &&
|
|
other.gimbalYaw == gimbalYaw &&
|
|
other.homeBearing == homeBearing &&
|
|
other.homeDistance == homeDistance &&
|
|
other.pilotBearing == pilotBearing &&
|
|
other.pilotDistance == pilotDistance &&
|
|
other.rangeM == rangeM &&
|
|
listEquals(other.obstacles, obstacles);
|
|
|
|
@override
|
|
int get hashCode =>
|
|
Object.hash(pitch, roll, heading, gimbalYaw, homeBearing, homeDistance, pilotBearing, pilotDistance, rangeM, Object.hashAll(obstacles));
|
|
}
|
|
|
|
class AttitudeBallPainter extends CustomPainter {
|
|
AttitudeBallPainter(this.d);
|
|
|
|
final AttitudeBallData d;
|
|
|
|
static const Color _sky = Color(0x803D7BF0);
|
|
static const Color _ground = Color(0x806B4E2E);
|
|
static const Color _pilot = Color(0xFF5B93F5);
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final double r = size.shortestSide / 2;
|
|
final Offset c = size.center(Offset.zero);
|
|
final Rect ball = Rect.fromCircle(center: c, radius: r);
|
|
|
|
// ── Horizon ────────────────────────────────────────────────────────────
|
|
canvas.save();
|
|
canvas.clipPath(Path()..addOval(ball));
|
|
canvas.drawRect(ball, Paint()..color = _sky);
|
|
canvas.translate(c.dx, c.dy);
|
|
canvas.rotate(-(d.roll ?? 0) * math.pi / 180);
|
|
final double hy = HudGeometry.horizonShift(d.pitch ?? 0) * r;
|
|
canvas.drawRect(Rect.fromLTRB(-2 * r, hy, 2 * r, 2 * r), Paint()..color = _ground);
|
|
canvas.drawLine(Offset(-r, hy), Offset(r, hy), Paint()
|
|
..color = const Color(0xCCEAF0FA)
|
|
..strokeWidth = 1);
|
|
canvas.restore();
|
|
|
|
// ── Rim, range ring and north tick ─────────────────────────────────────
|
|
canvas.drawCircle(c, r - 0.5, Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1
|
|
..color = const Color(0x66EAF0FA));
|
|
canvas.drawCircle(c, r / 2, Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 0.6
|
|
..color = const Color(0x33EAF0FA));
|
|
_label(canvas, 'N', c + Offset(0, -r + 7), Glass.ink, 8);
|
|
|
|
// ── Obstacle ring (plan P8): sensor sectors turned by the heading ──────
|
|
final double heading = d.heading ?? 0;
|
|
for (final BallObstacle o in d.obstacles) {
|
|
canvas.drawArc(
|
|
Rect.fromCircle(center: c, radius: r - 2.5),
|
|
(heading + o.start + 0.5 - 90) * math.pi / 180,
|
|
(o.sweep - 1) * math.pi / 180,
|
|
false,
|
|
Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = o.band == RadarBand.danger ? 4 : 3
|
|
..color = RadarColors.of(o.band),
|
|
);
|
|
}
|
|
|
|
// ── Gimbal yaw tick (on the rim) ───────────────────────────────────────
|
|
final double? gy = d.gimbalYaw;
|
|
if (gy != null) {
|
|
final double a = gy * math.pi / 180;
|
|
final Offset dir = Offset(math.sin(a), -math.cos(a));
|
|
canvas.drawLine(c + dir * (r - 7), c + dir * (r - 1), Paint()
|
|
..color = Glass.subject
|
|
..strokeWidth = 2.5
|
|
..strokeCap = StrokeCap.round);
|
|
}
|
|
|
|
// ── Home and pilot ─────────────────────────────────────────────────────
|
|
final double plot = r - 6;
|
|
if (d.homeBearing != null && d.homeDistance != null) {
|
|
final Offset p = c +
|
|
HudGeometry.ballOffset(bearingDeg: d.homeBearing!, distanceM: d.homeDistance!, radius: plot, rangeM: d.rangeM);
|
|
canvas.drawCircle(p, 5.5, Paint()..color = Glass.sat);
|
|
_label(canvas, 'H', p, const Color(0xFF0A1120), 7);
|
|
}
|
|
if (d.pilotBearing != null && d.pilotDistance != null) {
|
|
final Offset p = c +
|
|
HudGeometry.ballOffset(bearingDeg: d.pilotBearing!, distanceM: d.pilotDistance!, radius: plot, rangeM: d.rangeM);
|
|
canvas.drawCircle(p, 3.5, Paint()..color = _pilot);
|
|
canvas.drawCircle(p, 3.5, Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1
|
|
..color = Colors.white);
|
|
}
|
|
|
|
// ── Aircraft arrow (heading, north-up) ─────────────────────────────────
|
|
canvas.save();
|
|
canvas.translate(c.dx, c.dy);
|
|
canvas.rotate((d.heading ?? 0) * math.pi / 180);
|
|
final Path arrow = Path()
|
|
..moveTo(0, -9)
|
|
..lineTo(6, 6)
|
|
..lineTo(0, 3)
|
|
..lineTo(-6, 6)
|
|
..close();
|
|
canvas.drawPath(arrow, Paint()..color = d.heading == null ? HudColors.off : Colors.white);
|
|
canvas.drawPath(arrow, Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 0.8
|
|
..color = const Color(0xFF0A1120));
|
|
canvas.restore();
|
|
}
|
|
|
|
void _label(Canvas canvas, String t, Offset centre, Color color, double size) {
|
|
final TextPainter tp = TextPainter(
|
|
text: TextSpan(text: t, style: TextStyle(fontFamily: PV.fontMono, fontSize: size, fontWeight: FontWeight.w700, color: color)),
|
|
textDirection: TextDirection.ltr,
|
|
)..layout();
|
|
tp.paint(canvas, centre - Offset(tp.width / 2, tp.height / 2));
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(AttitudeBallPainter old) => old.d != d;
|
|
}
|