Thomas Fransolet 0310d28b5e Canal VR Meta Quest : projet Unity, viewer web et documentation
Premier commit du cinquième front. Trois parties :

- `unity/MyInfoMateVR/` : le projet Unity (6000.0.83f1, URP, Meta XR SDK 205),
  un APK unique pour tous les clients. Menu flottant à sélection au regard,
  appairage, chargement de scène GLB, POI, cache de contenu, télémétrie.
- `unity-overlay/` : les mêmes scripts à recopier sur un projet Unity neuf, avec
  les pièges rencontrés consignés dans son README.
- `viewer/` : viewer et éditeur de scène web autonome (Vite, TypeScript,
  three.js), partagé avec les autres fronts.
- `docs/` : état des lieux, setup Unity, décisions d'architecture et plan
  d'exécution en 9 étapes.

La scène est décrite par un `scene.json` poussé par `adb push` : l'app le
préfère à celui embarqué dans l'APK.

Les binaires (GLB, textures de l'échantillon Sponza, DLL Meta XR) passent par
Git LFS dès ce premier commit — les y faire entrer après coup demanderait de
réécrire l'historique. Les artefacts régénérés par l'éditeur et par CMake
(`Library/`, `.utmp/`, Burst debug) sont ignorés.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:26:07 +02:00

226 lines
8.1 KiB
C#

using System;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Visée et sélection — item <b>E5</b> du lot XR-4. Trois moyens de désigner un
/// panneau, dans cet ordre de priorité : <b>manette</b>, <b>main</b>, <b>tête</b>.
///
/// ⚠️ <b>« Au regard » ne veut pas dire eye tracking.</b> Le Quest 2 n'a pas de suivi
/// oculaire — seul le Quest Pro en a. Ce qu'on suit ici est la <b>direction de la
/// tête</b> (head gaze) : le visiteur vise avec son nez, pas avec ses yeux. C'est le
/// seul mode qui marche sur <i>tous</i> les casques, donc le repli garanti.
///
/// <b>Pourquoi les trois.</b> Une borne publique ne distribue pas de manettes — le
/// visiteur met le casque et c'est tout : il faut que ça marche sans rien dans les
/// mains. Mais en démo ou en installation surveillée, la manette est plus rapide et
/// les mains plus naturelles. Aucun des trois ne suffit seul.
///
/// <b>La temporisation ne s'applique qu'à la tête</b>, où il n'y a rien pour valider :
/// c'est le seul garde-fou contre le « Midas touch », où tout ce qu'on regarde se
/// déclenche, y compris ce qu'on ne fait que lire. La manette (gâchette) et la main
/// (pincement) ont un geste explicite, donc pas d'attente : imposer 1,2 s à quelqu'un
/// qui vient d'appuyer serait absurde.
///
/// <b>Dépendance</b> : <c>OVRInput</c> et <c>OVRHand</c> viennent du <i>Meta XR Core
/// SDK</i>, déjà installé (E1) — pas de l'Interaction SDK, qui lui n'est pas configuré.
/// Les mains ne sont détectées que si un <c>OVRHand</c> est présent dans la scène
/// (building block <i>Hand Tracking</i>) ; sinon on retombe sur les deux autres, sans
/// erreur.
/// </summary>
public class AimSelector : MonoBehaviour
{
/// <summary>
/// 1,2 s à la tête : assez long pour qu'un regard qui balaie ne déclenche rien,
/// assez court pour qu'on n'ait pas l'impression d'attendre. C'est le réglage qui
/// décide si le menu est agréable ou pénible — à confirmer au premier essai réel.
/// </summary>
[SerializeField] float dwellSeconds = 1.2f;
[SerializeField] float maxDistanceMeters = 10f;
/// <summary>Sous ce seuil, un pincement n'en est pas un.</summary>
const float PinchThreshold = 0.7f;
public enum AimSource { Head, Controller, Hand }
Transform _head;
IAimTarget _current;
float _dwell;
LineRenderer _ray;
OVRHand[] _hands = Array.Empty<OVRHand>();
public AimSource Source { get; private set; } = AimSource.Head;
public event Action<IAimTarget> Selected;
public void Configure(Transform head)
{
_head = head;
// Le hand tracking n'est pas toujours dans la scène : son absence est un cas
// nominal, pas une erreur.
_hands = FindObjectsByType<OVRHand>(FindObjectsSortMode.None);
BuildRay();
}
void Update()
{
if (_head == null) return;
if (!TryAim(out var ray, out var triggered))
{
Clear();
return;
}
DrawRay(ray);
var target = Probe(ray);
if (target != _current)
{
_current?.OnAimExit();
_current = target;
_dwell = 0f;
_current?.OnAimEnter();
}
if (_current == null) return;
// Un geste explicite déclenche tout de suite ; sans geste, c'est le temps
// passé sur la cible qui vaut validation.
if (triggered)
{
Fire();
return;
}
if (Source != AimSource.Head) return;
_dwell += Time.deltaTime;
_current.OnAimProgress(Mathf.Clamp01(_dwell / dwellSeconds));
if (_dwell >= dwellSeconds) Fire();
}
/// <summary>
/// La source active. L'ordre n'est pas un goût : une manette en main est un choix
/// délibéré du visiteur, une main levée aussi ; la tête ne veut rien dire par
/// elle-même, donc elle passe en dernier.
/// </summary>
bool TryAim(out Ray ray, out bool triggered)
{
var controller = OVRInput.GetActiveController();
if (controller == OVRInput.Controller.RTouch || controller == OVRInput.Controller.LTouch)
{
var position = OVRInput.GetLocalControllerPosition(controller);
var rotation = OVRInput.GetLocalControllerRotation(controller);
// Les poses des manettes sont locales au rig ; la tête vit dans le même
// repère, donc son parent donne la transformation à appliquer.
var rig = _head.parent != null ? _head.parent : _head;
Source = AimSource.Controller;
ray = new Ray(rig.TransformPoint(position), rig.rotation * rotation * Vector3.forward);
triggered = OVRInput.GetDown(OVRInput.Button.PrimaryIndexTrigger, controller);
return true;
}
foreach (var hand in _hands)
{
if (hand == null || !hand.IsTracked || !hand.IsPointerPoseValid) continue;
if (hand.IsSystemGestureInProgress) continue;
Source = AimSource.Hand;
ray = new Ray(hand.PointerPose.position, hand.PointerPose.forward);
// Le front montant, pas l'état : un pincement maintenu ne doit pas
// déclencher à chaque image.
triggered = hand.GetFingerPinchStrength(OVRHand.HandFinger.Index) > PinchThreshold
&& !_wasPinching;
_wasPinching = hand.GetFingerPinchStrength(OVRHand.HandFinger.Index) > PinchThreshold;
return true;
}
_wasPinching = false;
Source = AimSource.Head;
ray = new Ray(_head.position, _head.forward);
triggered = false;
return true;
}
bool _wasPinching;
void Fire()
{
var selected = _current;
_current = null;
_dwell = 0f;
selected.OnAimExit();
MenuFeedback.Select();
Selected?.Invoke(selected);
}
void Clear()
{
_current?.OnAimExit();
_current = null;
_dwell = 0f;
if (_ray != null) _ray.enabled = false;
}
IAimTarget Probe(Ray ray) =>
Physics.Raycast(ray, out var hit, maxDistanceMeters)
? hit.collider.GetComponentInParent<IAimTarget>()
: null;
void BuildRay()
{
var line = new GameObject("AimRay");
line.transform.SetParent(transform, false);
_ray = line.AddComponent<LineRenderer>();
_ray.useWorldSpace = true;
_ray.positionCount = 2;
_ray.widthMultiplier = 0.004f;
_ray.material = new Material(Shader.Find("Universal Render Pipeline/Unlit"));
_ray.material.color = new Color(0.4f, 0.8f, 1f, 0.5f);
_ray.enabled = false;
}
/// <summary>
/// Le rayon ne se dessine que pour la manette et la main. À la tête, un trait
/// parti du milieu du visage est fixe au centre du champ de vision : il gêne en
/// permanence sans rien apprendre, puisque l'anneau du panneau dit déjà où on vise.
/// </summary>
void DrawRay(Ray ray)
{
if (_ray == null) return;
if (Source == AimSource.Head)
{
_ray.enabled = false;
return;
}
_ray.enabled = true;
_ray.SetPosition(0, ray.origin);
_ray.SetPosition(1, ray.origin + ray.direction * 2.5f);
}
}
/// <summary>Ce qu'un objet doit savoir faire pour être visable.</summary>
public interface IAimTarget
{
void OnAimEnter();
void OnAimProgress(float ratio);
void OnAimExit();
}
}