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

325 lines
12 KiB
C#

using System.Threading.Tasks;
using MyInfoMate.Vr.Menu;
using MyInfoMate.Vr.Net;
using MyInfoMate.Vr.Scene;
using UnityEngine;
namespace MyInfoMate.Vr.Boot
{
/// <summary>
/// L'app du canal VR, telle qu'elle tient aujourd'hui : appairage (E2), contenu
/// servi depuis le cache (E3-E4), <b>menu flottant</b> (E5) et télémétrie (E9).
///
/// C'est le successeur de <see cref="PairingBootstrap"/>, qui reste à côté : celui-ci
/// prouve la chaîne réseau en une phrase, celui-là est l'app. Tant que rien n'a tourné
/// sur un vrai casque, pouvoir revenir au plus simple en un clic évite de confondre
/// un problème d'appairage avec un problème de rendu.
///
/// Les quatre types retenus au §8 s'ouvrent : <b>Slider</b>, <b>Map</b> et <b>Event</b>
/// (E8), le <b>Parcours</b> rendu comme une Map, et la <b>Video 360</b> — celle-ci
/// n'est pas un type de section mais une section Video dont le média est une ressource
/// 360, livrée le 2026-09-12. S'y ajoute la <b>Scène 3D</b> (E7). Choisir une section
/// non gérée affiche son titre et le dit — un menu qui ne réagit pas se lit comme une
/// panne.
///
/// La session de borne (E10) vit ici aussi : elle décide quand la visite est finie.
/// </summary>
public class MenuBootstrap : MonoBehaviour
{
[Header("Serveur")]
[SerializeField] string baseUrl = "https://api.mymuseum.be";
[Header("Appairage — code PIN de l'instance (première fois seulement)")]
[SerializeField] string pinCode = "";
[Header("Nom affiché dans l'onglet XR du manager")]
[SerializeField] string headsetName = "Quest";
[SerializeField] string language = "FR";
[SerializeField] bool forgetPairing;
Transform _head;
FloatingMenu _menu;
Telemetry _telemetry;
ConfigurationExport _export;
SceneMessage _message;
async void Start()
{
_head = Camera.main != null ? Camera.main.transform : null;
if (_head == null)
Debug.LogError("[Menu] Aucune caméra active : le rig VR n'est pas dans la " +
"scène. Meta → Tools → Building Blocks → Camera Rig.");
if (forgetPairing) PairingService.Forget();
var pairing = PairingService.Restore();
if (pairing == null)
{
if (string.IsNullOrEmpty(pinCode))
{
Show("Ce casque n'est pas appairé. Renseignez le code PIN du lieu " +
"dans l'inspecteur, puis relancez.");
return;
}
var paired = await new PairingService().PairAsync(baseUrl, pinCode, headsetName);
if (!paired.Ok)
{
Show(paired.Error);
return;
}
pairing = paired.Value;
}
_client = new ApiClient(pairing.BaseUrl, pairing.ApiKey);
var client = _client;
var content = new ContentService(client, pairing.ConfigurationId, pairing.DeviceId);
var loaded = await content.LoadAsync();
if (!loaded.Ok)
{
Show(loaded.Error);
return;
}
_telemetry = new Telemetry(client, pairing.InstanceId, pairing.ConfigurationId, language);
// XR-5 : sans ce battement, l'onglet XR du manager affiche « — » pour la
// batterie et la version, et ne sait jamais si un casque est tombé.
var fleet = Kiosk.FleetReporter.Attach(
gameObject, client, pairing.DeviceId, pairing.ConfigurationId);
if (fleet != null) fleet.ConfigurationChanged += OnConfigurationChanged;
BuildMenu(loaded.Export);
await _menu.LoadImagesAsync(loaded.Export, client);
await ApplyBackdrop(loaded.Export, client);
// E4 — le reste de la visite part sur le disque pendant que le visiteur
// regarde le menu. Sans await : c'est long, et personne ne doit l'attendre.
_ = ContentPreloader.RunAsync(loaded.Export, client);
if (!loaded.FromCache) return;
// Le menu est déjà utilisable : le rafraîchissement ne fait attendre personne,
// et ne se voit que s'il apporte quelque chose.
var fresh = await content.RefreshAsync();
if (fresh == null || _menu == null) return;
Debug.Log("[Menu] Contenu mis à jour, le menu se reconstruit.");
BuildMenu(fresh);
await _menu.LoadImagesAsync(fresh, client);
await ApplyBackdrop(fresh, client);
_ = ContentPreloader.RunAsync(fresh, client);
}
void BuildMenu(ConfigurationExport export)
{
_export = export;
if (_menu == null)
{
_menu = FloatingMenu.Create(_head);
_menu.SectionChosen += OnSectionChosen;
_kiosk = Kiosk.KioskSession.Attach(gameObject, _head);
_kiosk.Ended += StartNewVisit;
}
_menu.Build(export, language);
}
/// <summary>
/// Fin de visite : casque reposé, ou plus rien depuis un long moment. On revient
/// au menu, on le repose devant le visiteur, et on ouvre une nouvelle session de
/// statistiques — sinon une journée de borne compterait pour un seul visiteur.
/// </summary>
void StartNewVisit()
{
CloseSection();
_menu?.Recenter();
_telemetry?.StartNewSession();
Debug.Log("[Kiosk] Nouvelle visite.");
}
async void OnSectionChosen(string sectionId)
{
_kiosk?.NotifyActivity();
_telemetry?.MenuItemTap(sectionId);
_telemetry?.SectionView(sectionId);
var section = _export?.Sections?.Find(s => s.Id == sectionId);
var title = section != null
? ConfigurationExport.Translate(section.Title, language) ?? section.Label
: sectionId;
Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})");
if (section == null) return;
// Une section Video dont le média est une ressource 360 est le seul cas qui
// ne se feuillette pas : elle devient le ciel.
var immersive = ImmersiveResourceOf(section);
if (immersive != null)
{
OpenSection(sectionId);
_skybox = SkyboxView.Create(_head);
_skybox.Closed += CloseSection;
_skybox.Interacted += () => _kiosk?.NotifyActivity();
await _skybox.LoadAsync(immersive, _client);
return;
}
// Une maquette 3D n'est pas une page : elle se regarde en volume, avec ses
// points posés dessus.
if (section.Type == ConfigurationExport.SectionType.Scene3D)
{
OpenSection(sectionId);
_model = Scene3DView.Create(_head);
_model.Closed += CloseSection;
_model.Interacted += () => _kiosk?.NotifyActivity();
if (!await _model.LoadAsync(section, _export, language))
{
CloseSection();
Show($"{title}\n\nCette scène 3D n'a pas pu être chargée.");
}
return;
}
if (!SectionPages.CanRender(section.Type))
{
// Les types écartés au §8 (Quiz, Game, Web, PDF…) ne seront pas portés.
// Le dire vaut mieux que ne rien faire : un menu qui ne réagit pas se lit
// comme une panne.
Show($"{title}\n\nCe type de contenu n'est pas encore affichable dans le casque.");
return;
}
OpenSection(sectionId);
_view = PagedView.Create(_head);
_view.Closed += CloseSection;
_view.Interacted += () => _kiosk?.NotifyActivity();
await SectionPages.FillAsync(_view, section, _client, language);
}
/// <summary>
/// Retour au menu. La durée passée dans la section part avec le
/// <c>SectionLeave</c> : c'est elle qui dit si un contenu retient, et sans elle
/// les stats ne comptent que des ouvertures.
/// </summary>
/// <summary>
/// Le manager a assigné une autre configuration à ce casque : on la charge et on
/// repart du menu, sans redémarrer l'app.
///
/// ⚠️ On ferme ce qui est ouvert d'abord. Un visiteur qui regardait une galerie
/// au moment du changement doit revenir au menu — pas rester dans un contenu qui
/// n'appartient plus à sa visite.
/// </summary>
async void OnConfigurationChanged(string configurationId)
{
CloseSection();
var content = new ContentService(_client, configurationId);
var loaded = await content.LoadAsync();
if (!loaded.Ok)
{
Debug.LogWarning($"[Menu] Nouvelle configuration illisible : {loaded.Error}");
return;
}
BuildMenu(loaded.Export);
_menu?.Recenter();
_telemetry?.StartNewSession();
await _menu.LoadImagesAsync(loaded.Export, _client);
await ApplyBackdrop(loaded.Export, _client);
_ = ContentPreloader.RunAsync(loaded.Export, _client);
}
/// <summary>
/// Pose le décor du lieu derrière le menu. Détruit le précédent d'abord : un
/// changement de configuration ne doit pas laisser le panorama de l'ancienne
/// visite, et deux fonds superposés font deux vidéos qui tournent.
/// </summary>
async Task ApplyBackdrop(ConfigurationExport export, ApiClient client)
{
if (_backdrop != null) Destroy(_backdrop.gameObject);
_backdrop = await Menu.ImmersiveBackdrop.ApplyAsync(export, client);
}
void OpenSection(string sectionId)
{
_openedSectionId = sectionId;
_openedAt = Time.time;
_menu.gameObject.SetActive(false);
}
/// <summary>
/// La ressource 360 d'une section, ou null. <c>Source</c> porte soit un id de
/// ressource, soit une URL externe (YouTube) — cette dernière n'est pas une 360
/// téléchargeable, donc pas un cas immersif.
/// </summary>
ConfigurationExport.Resource ImmersiveResourceOf(ConfigurationExport.SectionSummary section)
{
if (section.SourceIsUrl) return null;
var resource = _export?.FindResource(section.Source);
return resource != null && resource.IsImmersive ? resource : null;
}
void CloseSection()
{
if (_view != null) Destroy(_view.gameObject);
_view = null;
if (_skybox != null) Destroy(_skybox.gameObject);
_skybox = null;
// Une scène 3D laissée derrière soi reste plantée dans le décor du menu — et
// son GLB continue d'occuper la mémoire du casque.
if (_model != null) Destroy(_model.gameObject);
_model = null;
// Le message d'une section non gérée resterait affiché par-dessus le menu.
if (_message != null) Destroy(_message.gameObject);
_message = null;
if (_openedSectionId != null)
{
_telemetry?.SectionLeave(_openedSectionId, Mathf.RoundToInt(Time.time - _openedAt));
_openedSectionId = null;
}
if (_menu != null) _menu.gameObject.SetActive(true);
}
PagedView _view;
SkyboxView _skybox;
ImmersiveBackdrop _backdrop;
Scene3DView _model;
ApiClient _client;
Kiosk.KioskSession _kiosk;
string _openedSectionId;
float _openedAt;
void Show(string message)
{
if (_message != null) Destroy(_message.gameObject);
_message = SceneMessage.Show(message, _head);
}
}
}