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

150 lines
5.5 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using MyInfoMate.Vr.Net;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Le menu flottant — <b>le hub de l'app</b>, item <b>E5</b> du lot XR-4.
///
/// C'est le rendu de la <c>SectionMenu</c> du CMS, et le seul type de section dont
/// le plan dit qu'il est « nécessaire » : tout le reste s'atteint depuis lui.
///
/// <b>Disposition en arc, pas en grille plate.</b> Une grille plate oblige à tourner
/// les yeux vers ses bords, qui sont alors vus de biais ; un arc centré sur le
/// visiteur présente chaque panneau de face, à distance constante. C'est le même
/// principe qu'un pupitre incurvé.
/// </summary>
public class FloatingMenu : MonoBehaviour
{
/// <summary>2,2 m : au-delà le texte devient petit, en deçà l'œil doit converger.</summary>
const float RadiusMeters = 2.2f;
const float HeightMeters = 1.45f;
/// <summary>Écart angulaire entre deux panneaux, assez large pour que le regard tranche.</summary>
const float StepDegrees = 16f;
/// <summary>Au-delà, on passe à une seconde rangée plutôt que d'encercler le visiteur.</summary>
const int MaxPerRow = 5;
readonly List<MenuItemPanel> _panels = new List<MenuItemPanel>();
AimSelector _selector;
/// <summary>L'id de la section choisie.</summary>
public event Action<string> SectionChosen;
public static FloatingMenu Create(Transform head)
{
var root = new GameObject("FloatingMenu");
var menu = root.AddComponent<FloatingMenu>();
menu._selector = root.AddComponent<AimSelector>();
menu._selector.Configure(head);
menu._selector.Selected += menu.OnSelected;
menu._head = head;
menu.Recenter();
return menu;
}
Transform _head;
/// <summary>
/// Pose le menu devant le visiteur. Appelé au démarrage et à chaque nouvelle
/// visite (mode borne) : le visiteur suivant n'arrive pas forcément orienté comme
/// le précédent, et un menu resté dans son dos est un menu introuvable.
///
/// Entre deux recentrages, le menu <b>ne suit pas la tête</b> : un menu qui suit
/// est impossible à viser, et donne le mal de cœur.
/// </summary>
public void Recenter()
{
if (_head == null) return;
var forward = _head.forward;
forward.y = 0f;
if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward;
transform.position = new Vector3(_head.position.x, 0f, _head.position.z);
transform.rotation = Quaternion.LookRotation(forward.normalized);
}
public void Build(ConfigurationExport export, string language)
{
foreach (var panel in _panels) Destroy(panel.gameObject);
_panels.Clear();
var sections = export.RootSections();
for (var i = 0; i < sections.Count; i++)
{
var title = ConfigurationExport.Translate(sections[i].Title, language)
?? sections[i].Label;
var panel = MenuItemPanel.Create(sections[i].Id, title, transform);
Place(panel.transform, i, sections.Count);
// La cascade : 40 ms d'écart entre deux panneaux. Assez pour qu'on voie
// le menu se construire, assez peu pour que le dernier soit là avant
// qu'on ait fini de tourner la tête.
panel.PlayAppear(i * 0.04f);
_panels.Add(panel);
}
}
/// <summary>
/// Les vignettes arrivent après coup : le menu doit être utilisable avant que
/// le premier octet d'image ne soit téléchargé.
/// </summary>
public async Task LoadImagesAsync(ConfigurationExport export, ApiClient client)
{
var sections = export.RootSections();
for (var i = 0; i < sections.Count && i < _panels.Count; i++)
{
var source = sections[i].ImageSource;
if (string.IsNullOrEmpty(source)) continue;
var path = await ContentCache.MediaPathAsync(client, source);
if (path == null || _panels[i] == null) continue;
var texture = new Texture2D(2, 2);
if (texture.LoadImage(File.ReadAllBytes(path)))
_panels[i].SetImage(texture);
}
}
void Place(Transform panel, int index, int total)
{
var row = index / MaxPerRow;
var inRow = index % MaxPerRow;
var countInRow = Mathf.Min(total - row * MaxPerRow, MaxPerRow);
var angle = (inRow - (countInRow - 1) / 2f) * StepDegrees;
var radians = angle * Mathf.Deg2Rad;
var height = HeightMeters - row * (MenuItemPanel.HeightMeters + 0.06f);
panel.localPosition = new Vector3(
Mathf.Sin(radians) * RadiusMeters,
height,
Mathf.Cos(radians) * RadiusMeters);
// Chaque panneau pivote vers le centre de l'arc — donc vers le visiteur.
panel.localRotation = Quaternion.Euler(0f, angle, 0f);
}
void OnSelected(IAimTarget target)
{
if (target is MenuItemPanel panel) SectionChosen?.Invoke(panel.Id);
}
}
}