Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32143c5bb1 | ||
|
|
6ee0839db5 | ||
|
|
8804d8b6a1 | ||
|
|
869835f234 | ||
|
|
3d9e574309 | ||
|
|
3f79252bb5 |
@@ -248,6 +248,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
String? sessionId,
|
String? sessionId,
|
||||||
int? distance,
|
int? distance,
|
||||||
String? caliber,
|
String? caliber,
|
||||||
|
int? expectedShots,
|
||||||
}) async {
|
}) async {
|
||||||
if (_imagePath == null || _targetType == null) {
|
if (_imagePath == null || _targetType == null) {
|
||||||
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
|
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
|
||||||
@@ -273,6 +274,7 @@ class AnalysisProvider extends ChangeNotifier {
|
|||||||
shots: _shots,
|
shots: _shots,
|
||||||
distanceMeters: distance ?? 25,
|
distanceMeters: distance ?? 25,
|
||||||
caliber: caliber ?? 'unknown',
|
caliber: caliber ?? 'unknown',
|
||||||
|
expectedShots: expectedShots,
|
||||||
);
|
);
|
||||||
|
|
||||||
_state = AnalysisState.success;
|
_state = AnalysisState.success;
|
||||||
|
|||||||
@@ -943,6 +943,11 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
sessionId: sessionProvider.activeSessionId,
|
sessionId: sessionProvider.activeSessionId,
|
||||||
distance: sessionProvider.distance,
|
distance: sessionProvider.distance,
|
||||||
caliber: sessionProvider.currentWeaponCaliber,
|
caliber: sessionProvider.currentWeaponCaliber,
|
||||||
|
// Hors session, shotsPerTarget vaut sa valeur par defaut : mieux
|
||||||
|
// vaut ne rien annoncer qu'annoncer un nombre faux.
|
||||||
|
expectedShots: sessionProvider.isSessionActive
|
||||||
|
? sessionProvider.shotsPerTarget
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
messenger.hideCurrentSnackBar();
|
messenger.hideCurrentSnackBar();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,8 +169,9 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
final prev = _parallelismData;
|
final prev = _parallelismData;
|
||||||
final bool changed = prev == null ||
|
final bool changed = prev == null ||
|
||||||
prev.status != data.status ||
|
prev.status != data.status ||
|
||||||
prev.pitchDegrees.toStringAsFixed(1) !=
|
prev.pose != data.pose ||
|
||||||
data.pitchDegrees.toStringAsFixed(1) ||
|
prev.pitchDeviation.toStringAsFixed(1) !=
|
||||||
|
data.pitchDeviation.toStringAsFixed(1) ||
|
||||||
prev.rollDegrees.toStringAsFixed(1) !=
|
prev.rollDegrees.toStringAsFixed(1) !=
|
||||||
data.rollDegrees.toStringAsFixed(1);
|
data.rollDegrees.toStringAsFixed(1);
|
||||||
|
|
||||||
@@ -218,16 +219,21 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
return 'ALIGNEZ LA CIBLE DANS LE CADRE';
|
return 'ALIGNEZ LA CIBLE DANS LE CADRE';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final bool onGround = _parallelismData!.pose == TargetPose.ground;
|
||||||
|
|
||||||
// Aligné → message de validation (avec bonus si la cible est détectée)
|
// Aligné → message de validation (avec bonus si la cible est détectée)
|
||||||
if (_parallelismData!.isAligned) {
|
if (_parallelismData!.isAligned) {
|
||||||
return _targetReady
|
if (_targetReady) return 'PARFAIT — CIBLE DÉTECTÉE, PRÊT';
|
||||||
? 'PARFAIT — CIBLE DÉTECTÉE, PRÊT'
|
return onGround
|
||||||
|
? 'CIBLE AU SOL — PRÊT À PHOTOGRAPHIER'
|
||||||
: 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER';
|
: 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mal aligné → message directif selon l'axe le plus dévié
|
// Mal aligné → message directif selon l'axe le plus dévié.
|
||||||
final double pitch = _parallelismData!.pitchDegrees;
|
// On raisonne sur les écarts à la pose détectée, pas sur le tangage brut :
|
||||||
final double roll = _parallelismData!.rollDegrees;
|
// à plat, celui-ci vaut -90° alors que le cadrage peut être parfait.
|
||||||
|
final double pitch = _parallelismData!.pitchDeviation;
|
||||||
|
final double roll = _parallelismData!.rollDeviation;
|
||||||
|
|
||||||
if (pitch.abs() >= roll.abs()) {
|
if (pitch.abs() >= roll.abs()) {
|
||||||
return pitch > 0
|
return pitch > 0
|
||||||
@@ -776,15 +782,25 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
Text(
|
||||||
|
data.pose == TargetPose.ground ? 'CIBLE AU SOL' : 'CIBLE AU MUR',
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
_buildAngleRow(
|
_buildAngleRow(
|
||||||
label: 'Pitch',
|
label: 'Pitch',
|
||||||
value: data.pitchDegrees,
|
value: data.pitchDeviation,
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
_buildAngleRow(
|
_buildAngleRow(
|
||||||
label: 'Roll ',
|
label: 'Roll ',
|
||||||
value: data.rollDegrees,
|
value: data.rollDeviation,
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -463,7 +463,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
const Text(
|
const Text(
|
||||||
'📤 Ce qui est envoyé : la photo de la cible, la position des '
|
'📤 Ce qui est envoyé : la photo de la cible, la position des '
|
||||||
'impacts que vous avez placés, la distance de tir et le calibre.',
|
'impacts que vous avez placés, la distance de tir, le calibre et '
|
||||||
|
'le nombre de coups prévus.',
|
||||||
style: TextStyle(fontSize: 13),
|
style: TextStyle(fontSize: 13),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ class AiExportService {
|
|||||||
required List<Shot> shots,
|
required List<Shot> shots,
|
||||||
int distanceMeters = 25,
|
int distanceMeters = 25,
|
||||||
String caliber = 'unknown',
|
String caliber = 'unknown',
|
||||||
|
int? expectedShots,
|
||||||
String? apiUrl,
|
String? apiUrl,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
@@ -172,6 +173,11 @@ class AiExportService {
|
|||||||
"type": targetType.name,
|
"type": targetType.name,
|
||||||
"distance_meters": distanceMeters,
|
"distance_meters": distanceMeters,
|
||||||
"caliber": caliber,
|
"caliber": caliber,
|
||||||
|
// Nombre de coups prevus pour cette cible, null hors session.
|
||||||
|
// Un ecart avec le nombre d'impacts ne signifie pas que le
|
||||||
|
// marquage est faux (un coup peut etre parti hors papier) : c'est
|
||||||
|
// au tri du dataset d'en decider, pas au client.
|
||||||
|
"expected_shots": expectedShots,
|
||||||
},
|
},
|
||||||
"plotting": {
|
"plotting": {
|
||||||
"target_corners": corners,
|
"target_corners": corners,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:sensors_plus/sensors_plus.dart';
|
import 'package:sensors_plus/sensors_plus.dart';
|
||||||
|
|
||||||
/// Statut de parallélisme retourné en temps réel.
|
/// Statut de parallélisme retourné en temps réel.
|
||||||
@@ -14,31 +15,67 @@ enum ParallelismStatus {
|
|||||||
misaligned,
|
misaligned,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pose de prise de vue déduite de l'inclinaison de l'appareil.
|
||||||
|
enum TargetPose {
|
||||||
|
/// Cible accrochée verticalement : l'appareil est tenu droit.
|
||||||
|
wall,
|
||||||
|
|
||||||
|
/// Cible posée au sol : l'appareil est à plat, caméra vers le bas.
|
||||||
|
ground,
|
||||||
|
}
|
||||||
|
|
||||||
/// Données de parallélisme calculées à chaque frame capteur.
|
/// Données de parallélisme calculées à chaque frame capteur.
|
||||||
class ParallelismData {
|
class ParallelismData {
|
||||||
final ParallelismStatus status;
|
final ParallelismStatus status;
|
||||||
|
|
||||||
/// Inclinaison avant/arrière en degrés (0° = parfaitement vertical).
|
/// Pose détectée automatiquement, qui sert de référence aux écarts.
|
||||||
|
final TargetPose pose;
|
||||||
|
|
||||||
|
/// Inclinaison avant/arrière brute en degrés (0° = appareil vertical,
|
||||||
|
/// +90° = appareil à plat, caméra vers le sol).
|
||||||
final double pitchDegrees;
|
final double pitchDegrees;
|
||||||
|
|
||||||
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
|
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
|
||||||
final double rollDegrees;
|
final double rollDegrees;
|
||||||
|
|
||||||
|
/// Écart de tangage par rapport à la pose détectée.
|
||||||
|
///
|
||||||
|
/// En pose [TargetPose.wall] il vaut exactement [pitchDegrees] ; en pose
|
||||||
|
/// [TargetPose.ground] il mesure l'écart aux +90° de l'appareil à plat.
|
||||||
|
/// C'est cette valeur qu'il faut afficher : le tangage brut vaudrait -90°
|
||||||
|
/// alors que le cadrage est parfait.
|
||||||
|
final double pitchDeviation;
|
||||||
|
|
||||||
const ParallelismData({
|
const ParallelismData({
|
||||||
required this.status,
|
required this.status,
|
||||||
|
required this.pose,
|
||||||
required this.pitchDegrees,
|
required this.pitchDegrees,
|
||||||
required this.rollDegrees,
|
required this.rollDegrees,
|
||||||
|
required this.pitchDeviation,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isAligned => status == ParallelismStatus.aligned;
|
bool get isAligned => status == ParallelismStatus.aligned;
|
||||||
|
|
||||||
|
/// Écart latéral. Le roulis se mesure de la même façon dans les deux poses.
|
||||||
|
double get rollDeviation => rollDegrees;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
|
'ParallelismData(status: $status, pose: $pose, pitch: ${pitchDegrees.toStringAsFixed(1)}°, '
|
||||||
|
'écart: ${pitchDeviation.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service de détection du parallélisme par accéléromètre.
|
/// Service de détection du parallélisme par accéléromètre.
|
||||||
///
|
///
|
||||||
|
/// Deux poses de prise de vue sont reconnues, et celle dont l'appareil est le
|
||||||
|
/// plus proche est retenue automatiquement :
|
||||||
|
///
|
||||||
|
/// [TargetPose.wall] — cible au mur, appareil vertical (tangage ≈ 0°)
|
||||||
|
/// [TargetPose.ground] — cible au sol, appareil à plat (tangage ≈ +90°)
|
||||||
|
///
|
||||||
|
/// Seul le tangage positif vaut pour la pose au sol : à plat écran vers le
|
||||||
|
/// haut, la caméra vise le plafond et le vert n'aurait aucun sens.
|
||||||
|
///
|
||||||
/// Implémente une hystérésis à deux seuils pour éviter le clignotement :
|
/// Implémente une hystérésis à deux seuils pour éviter le clignotement :
|
||||||
///
|
///
|
||||||
/// État actuel = misaligned → passe à aligned si angle < [alignThreshold]
|
/// État actuel = misaligned → passe à aligned si angle < [alignThreshold]
|
||||||
@@ -58,6 +95,13 @@ class ParallelismService {
|
|||||||
/// Doit être > alignThreshold pour créer la zone d'hystérésis.
|
/// Doit être > alignThreshold pour créer la zone d'hystérésis.
|
||||||
final double misalignThreshold;
|
final double misalignThreshold;
|
||||||
|
|
||||||
|
/// Écart minimum en faveur de l'autre pose pour basculer.
|
||||||
|
///
|
||||||
|
/// Les deux poses sont séparées de 90°, donc la bascule se joue vers 45° —
|
||||||
|
/// très loin des deux zones vertes. Cette marge évite seulement que
|
||||||
|
/// l'étiquette de pose clignote pile à la frontière.
|
||||||
|
static const double poseSwitchMargin = 5.0;
|
||||||
|
|
||||||
StreamSubscription<AccelerometerEvent>? _subscription;
|
StreamSubscription<AccelerometerEvent>? _subscription;
|
||||||
final StreamController<ParallelismData> _controller =
|
final StreamController<ParallelismData> _controller =
|
||||||
StreamController<ParallelismData>.broadcast();
|
StreamController<ParallelismData>.broadcast();
|
||||||
@@ -65,6 +109,9 @@ class ParallelismService {
|
|||||||
/// État interne mémorisé entre deux frames (cœur de l'hystérésis).
|
/// État interne mémorisé entre deux frames (cœur de l'hystérésis).
|
||||||
ParallelismStatus _currentStatus = ParallelismStatus.unknown;
|
ParallelismStatus _currentStatus = ParallelismStatus.unknown;
|
||||||
|
|
||||||
|
/// Pose retenue à la frame précédente, pour l'hystérésis de pose.
|
||||||
|
TargetPose? _currentPose;
|
||||||
|
|
||||||
ParallelismService({
|
ParallelismService({
|
||||||
this.alignThreshold = 25.0,
|
this.alignThreshold = 25.0,
|
||||||
this.misalignThreshold = 32.0,
|
this.misalignThreshold = 32.0,
|
||||||
@@ -86,10 +133,13 @@ class ParallelismService {
|
|||||||
// Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI
|
// Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI
|
||||||
if (!_controller.isClosed) {
|
if (!_controller.isClosed) {
|
||||||
_currentStatus = ParallelismStatus.unknown;
|
_currentStatus = ParallelismStatus.unknown;
|
||||||
|
_currentPose = null;
|
||||||
_controller.add(const ParallelismData(
|
_controller.add(const ParallelismData(
|
||||||
status: ParallelismStatus.unknown,
|
status: ParallelismStatus.unknown,
|
||||||
|
pose: TargetPose.wall,
|
||||||
pitchDegrees: 0,
|
pitchDegrees: 0,
|
||||||
rollDegrees: 0,
|
rollDegrees: 0,
|
||||||
|
pitchDeviation: 0,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -100,6 +150,7 @@ class ParallelismService {
|
|||||||
_subscription?.cancel();
|
_subscription?.cancel();
|
||||||
_subscription = null;
|
_subscription = null;
|
||||||
_currentStatus = ParallelismStatus.unknown;
|
_currentStatus = ParallelismStatus.unknown;
|
||||||
|
_currentPose = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -110,12 +161,18 @@ class ParallelismService {
|
|||||||
void _onAccelerometerEvent(AccelerometerEvent event) {
|
void _onAccelerometerEvent(AccelerometerEvent event) {
|
||||||
if (_controller.isClosed) return;
|
if (_controller.isClosed) return;
|
||||||
|
|
||||||
final double gx = event.x;
|
final data = evaluate(event.x, event.y, event.z);
|
||||||
final double gy = event.y;
|
if (data != null) _controller.add(data);
|
||||||
final double gz = event.z;
|
}
|
||||||
|
|
||||||
|
/// Calcule la pose et le statut depuis une mesure d'accéléromètre.
|
||||||
|
///
|
||||||
|
/// Met à jour l'état d'hystérésis, donc l'ordre des appels compte.
|
||||||
|
/// Retourne null sur une mesure aberrante, qu'il faut alors ignorer.
|
||||||
|
@visibleForTesting
|
||||||
|
ParallelismData? evaluate(double gx, double gy, double gz) {
|
||||||
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
|
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
|
||||||
if (magnitude < 1.0) return; // Données aberrantes
|
if (magnitude < 1.0) return null; // Données aberrantes
|
||||||
|
|
||||||
// Normalisation par la magnitude réelle (indépendant de g exact)
|
// Normalisation par la magnitude réelle (indépendant de g exact)
|
||||||
final double nx = gx / magnitude;
|
final double nx = gx / magnitude;
|
||||||
@@ -125,10 +182,39 @@ class ParallelismService {
|
|||||||
final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi);
|
final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi);
|
||||||
final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi);
|
final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi);
|
||||||
|
|
||||||
// Le critère de couleur = le PIRE des deux angles affichés à l'écran.
|
// ── Choix de la pose ────────────────────────────────────────────────────
|
||||||
// Ainsi ce que voit l'utilisateur (Pitch / Roll) correspond exactement
|
// Cible au mur : le tangage idéal est 0°. Formule d'origine, inchangée.
|
||||||
// à la décision vert/orange : à 2° d'écart, on est largement dans le vert.
|
final double wallPitchDeviation = pitchDeg;
|
||||||
final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs());
|
final double wallWorst = math.max(pitchDeg.abs(), rollDeg.abs());
|
||||||
|
|
||||||
|
// Cible au sol : le tangage idéal est +90° (caméra vers le bas). Écran vers
|
||||||
|
// le haut, le tangage vaut -90° et l'écart atteint 180° : la pose au sol ne
|
||||||
|
// peut alors jamais gagner, ce qui est exactement le comportement voulu.
|
||||||
|
final double groundPitchDeviation = pitchDeg - 90.0;
|
||||||
|
final double groundWorst =
|
||||||
|
math.max(groundPitchDeviation.abs(), rollDeg.abs());
|
||||||
|
|
||||||
|
if (_currentPose == null) {
|
||||||
|
_currentPose =
|
||||||
|
groundWorst < wallWorst ? TargetPose.ground : TargetPose.wall;
|
||||||
|
} else if (_currentPose == TargetPose.wall) {
|
||||||
|
if (groundWorst + poseSwitchMargin < wallWorst) {
|
||||||
|
_currentPose = TargetPose.ground;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (wallWorst + poseSwitchMargin < groundWorst) {
|
||||||
|
_currentPose = TargetPose.wall;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool isGround = _currentPose == TargetPose.ground;
|
||||||
|
final double pitchDeviation =
|
||||||
|
isGround ? groundPitchDeviation : wallPitchDeviation;
|
||||||
|
|
||||||
|
// Le critère de couleur = le PIRE des deux écarts affichés à l'écran.
|
||||||
|
// Ainsi ce que voit l'utilisateur correspond exactement à la décision
|
||||||
|
// vert/orange : à 2° d'écart, on est largement dans le vert.
|
||||||
|
final double worstAngle = isGround ? groundWorst : wallWorst;
|
||||||
|
|
||||||
// ── Hystérésis ──────────────────────────────────────────────────────────
|
// ── Hystérésis ──────────────────────────────────────────────────────────
|
||||||
// Premier appel : on décide selon alignThreshold uniquement
|
// Premier appel : on décide selon alignThreshold uniquement
|
||||||
@@ -152,10 +238,12 @@ class ParallelismService {
|
|||||||
}
|
}
|
||||||
// ────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_controller.add(ParallelismData(
|
return ParallelismData(
|
||||||
status: _currentStatus,
|
status: _currentStatus,
|
||||||
|
pose: _currentPose!,
|
||||||
pitchDegrees: pitchDeg,
|
pitchDegrees: pitchDeg,
|
||||||
rollDegrees: rollDeg,
|
rollDegrees: rollDeg,
|
||||||
));
|
pitchDeviation: pitchDeviation,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:bully/services/parallelism_service.dart';
|
||||||
|
|
||||||
|
/// Vecteur de gravité mesuré par l'accéléromètre pour une pose donnée.
|
||||||
|
///
|
||||||
|
/// [pitch] et [roll] sont exprimés comme dans le service : le tangage vaut 0°
|
||||||
|
/// appareil vertical et +90° appareil à plat caméra vers le sol.
|
||||||
|
({double x, double y, double z}) gravity({
|
||||||
|
double pitch = 0,
|
||||||
|
double roll = 0,
|
||||||
|
}) {
|
||||||
|
const double g = 9.81;
|
||||||
|
final double p = pitch * math.pi / 180.0;
|
||||||
|
final double r = roll * math.pi / 180.0;
|
||||||
|
|
||||||
|
final double nz = math.sin(p);
|
||||||
|
final double nx = math.sin(r);
|
||||||
|
// Ce qui reste va sur y, l'axe vertical de l'écran.
|
||||||
|
final double ny = math.sqrt(math.max(0.0, 1.0 - nz * nz - nx * nx));
|
||||||
|
|
||||||
|
return (x: nx * g, y: ny * g, z: nz * g);
|
||||||
|
}
|
||||||
|
|
||||||
|
ParallelismData evaluatePose(
|
||||||
|
ParallelismService service, {
|
||||||
|
double pitch = 0,
|
||||||
|
double roll = 0,
|
||||||
|
}) {
|
||||||
|
final v = gravity(pitch: pitch, roll: roll);
|
||||||
|
final data = service.evaluate(v.x, v.y, v.z);
|
||||||
|
expect(data, isNotNull, reason: 'mesure jugée aberrante à tort');
|
||||||
|
return data!;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('pose cible au mur (comportement existant)', () {
|
||||||
|
test('appareil vertical → aligné', () {
|
||||||
|
final data = evaluatePose(ParallelismService());
|
||||||
|
|
||||||
|
expect(data.pose, TargetPose.wall);
|
||||||
|
expect(data.status, ParallelismStatus.aligned);
|
||||||
|
expect(data.pitchDeviation, closeTo(0, 0.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('l\'écart de tangage reste le tangage brut', () {
|
||||||
|
final data = evaluatePose(ParallelismService(), pitch: 10);
|
||||||
|
|
||||||
|
expect(data.pose, TargetPose.wall);
|
||||||
|
expect(data.pitchDeviation, closeTo(data.pitchDegrees, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inclinaison au-delà du seuil → désaligné', () {
|
||||||
|
final data = evaluatePose(ParallelismService(), pitch: 40);
|
||||||
|
|
||||||
|
expect(data.pose, TargetPose.wall);
|
||||||
|
expect(data.status, ParallelismStatus.misaligned);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roulis excessif → désaligné même si le tangage est bon', () {
|
||||||
|
final data = evaluatePose(ParallelismService(), roll: 35);
|
||||||
|
|
||||||
|
expect(data.status, ParallelismStatus.misaligned);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('pose cible au sol', () {
|
||||||
|
test('appareil à plat caméra vers le bas → aligné', () {
|
||||||
|
final data = evaluatePose(ParallelismService(), pitch: 90);
|
||||||
|
|
||||||
|
expect(data.pose, TargetPose.ground);
|
||||||
|
expect(data.status, ParallelismStatus.aligned);
|
||||||
|
expect(data.pitchDeviation, closeTo(0, 0.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('l\'écart se mesure par rapport à +90°, pas à 0°', () {
|
||||||
|
final data = evaluatePose(ParallelismService(), pitch: 80);
|
||||||
|
|
||||||
|
expect(data.pose, TargetPose.ground);
|
||||||
|
expect(data.pitchDeviation, closeTo(-10, 0.5));
|
||||||
|
expect(data.status, ParallelismStatus.aligned);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('à plat mais trop incliné → désaligné', () {
|
||||||
|
final data = evaluatePose(ParallelismService(), pitch: 50);
|
||||||
|
|
||||||
|
expect(data.status, ParallelismStatus.misaligned);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('à plat écran vers le haut → jamais aligné', () {
|
||||||
|
// La caméra vise le plafond : le vert n'aurait aucun sens.
|
||||||
|
final data = evaluatePose(ParallelismService(), pitch: -90);
|
||||||
|
|
||||||
|
expect(data.pose, TargetPose.wall);
|
||||||
|
expect(data.status, ParallelismStatus.misaligned);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('hystérésis', () {
|
||||||
|
test('le vert survit à un léger tremblement entre les deux seuils', () {
|
||||||
|
final service = ParallelismService();
|
||||||
|
|
||||||
|
expect(evaluatePose(service).status, ParallelismStatus.aligned);
|
||||||
|
// 28° est au-dessus du seuil d'entrée (25°) mais sous celui de sortie (32°).
|
||||||
|
expect(evaluatePose(service, pitch: 28).status, ParallelismStatus.aligned);
|
||||||
|
expect(evaluatePose(service, pitch: 40).status, ParallelismStatus.misaligned);
|
||||||
|
// Repasser sous 32° ne suffit pas : il faut redescendre sous 25°.
|
||||||
|
expect(evaluatePose(service, pitch: 28).status, ParallelismStatus.misaligned);
|
||||||
|
expect(evaluatePose(service, pitch: 20).status, ParallelismStatus.aligned);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la pose ne bascule pas pour un écart négligeable', () {
|
||||||
|
final service = ParallelismService();
|
||||||
|
|
||||||
|
expect(evaluatePose(service).pose, TargetPose.wall);
|
||||||
|
// Pile à la frontière des deux poses : on garde celle déjà retenue.
|
||||||
|
expect(evaluatePose(service, pitch: 45).pose, TargetPose.wall);
|
||||||
|
// Franchement du côté du sol : on bascule.
|
||||||
|
expect(evaluatePose(service, pitch: 70).pose, TargetPose.ground);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop() remet la pose et le statut à zéro', () {
|
||||||
|
final service = ParallelismService();
|
||||||
|
|
||||||
|
expect(evaluatePose(service, pitch: 90).pose, TargetPose.ground);
|
||||||
|
service.stop();
|
||||||
|
expect(evaluatePose(service).pose, TargetPose.wall);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une mesure aberrante est ignorée', () {
|
||||||
|
expect(ParallelismService().evaluate(0, 0, 0), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user