vr-app/unity-overlay/Assets/Scripts/Boot/PairingBootstrap.cs
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

124 lines
4.6 KiB
C#

using MyInfoMate.Vr.Net;
using MyInfoMate.Vr.Scene;
using UnityEngine;
namespace MyInfoMate.Vr.Boot
{
/// <summary>
/// Items <b>E2 et E3</b> du lot XR-4 : le casque s'appaire, puis lit le contenu
/// de sa configuration. Rien d'immersif ici — c'est la preuve que la chaîne
/// réseau tient, affichée en une phrase dans le casque.
///
/// ⚠️ <b>Ne pas confondre avec <see cref="S1Bootstrap"/> et <see cref="S2Bootstrap"/></b> :
/// ceux-là sont la piste Scène 3D (S0-S8), celui-ci le canal VR (E0-E11). Deux
/// numérotations, deux plans.
///
/// <b>Le code PIN ne se saisit pas encore</b> : sans l'Interaction SDK (item E1),
/// il n'y a ni pointeur ni clavier. Il se pose donc dans l'inspecteur avant le
/// build ; l'écran d'appairage arrive avec E5.
/// </summary>
public class PairingBootstrap : MonoBehaviour
{
[Header("Serveur")]
[SerializeField] string baseUrl = "https://api.mymuseum.be";
[Header("Appairage — code PIN de l'instance")]
[SerializeField] string pinCode = "";
[Header("Nom affiché dans l'onglet XR du manager")]
[SerializeField] string headsetName = "Quest";
[Header("Langue d'affichage — l'export les porte toutes")]
[SerializeField] string language = "FR";
[Header("Réappairer même si ce casque l'est déjà")]
[SerializeField] bool forgetPairing;
async void Start()
{
var head = Camera.main != null ? Camera.main.transform : null;
if (head == null)
Debug.LogError("[Pairing] 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))
{
SceneMessage.Show("Ce casque n'est pas appairé. Renseignez le code PIN " +
"du lieu dans l'inspecteur, puis relancez.", head);
return;
}
var result = await new PairingService()
.PairAsync(baseUrl, pinCode, headsetName);
if (!result.Ok)
{
SceneMessage.Show(result.Error, head);
return;
}
pairing = result.Value;
Debug.Log($"[Pairing] Casque appairé — device {pairing.DeviceId}, " +
$"instance {pairing.InstanceId}");
}
var client = new ApiClient(pairing.BaseUrl, pairing.ApiKey);
var content = new ContentService(client, pairing.ConfigurationId);
var loaded = await content.LoadAsync();
if (!loaded.Ok)
{
SceneMessage.Show(loaded.Error, head);
return;
}
Show(loaded.Export, head, loaded.FromCache ? loaded.CachedAt : null);
// E4 : le cache a déjà été affiché, le réseau ne fait plus attendre personne.
// Un échec ici ne se voit pas — c'est le comportement voulu pour une borne.
if (loaded.FromCache)
{
var fresh = await content.RefreshAsync();
if (fresh != null)
{
Debug.Log("[Content] Contenu mis à jour depuis le serveur.");
Show(fresh, head, null);
}
}
}
SceneMessage _message;
void Show(ConfigurationExport export, Transform head, System.DateTime? cachedAt)
{
// Le rafraîchissement de fond réaffiche : sans ça, deux panneaux se
// superposent et le texte devient illisible.
if (_message != null) Destroy(_message.gameObject);
var roots = export.RootSections();
var lines = $"{export.Label} — {roots.Count} sections";
if (cachedAt.HasValue)
lines += $"\n(hors ligne — contenu du {cachedAt.Value.ToLocalTime():dd/MM HH:mm})";
for (var i = 0; i < roots.Count; i++)
{
var title = ConfigurationExport.Translate(roots[i].Title, language) ?? roots[i].Label;
Debug.Log($"[Export] {roots[i].Type} — {title}");
// Le message flottant reste lisible à cinq lignes ; la console a le reste.
if (i < 5) lines += $"\n{title} ({roots[i].Type})";
}
_message = SceneMessage.Show(lines, head);
}
}
}