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>
226 lines
8.1 KiB
C#
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();
|
|
}
|
|
}
|