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

215 lines
8.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
/// Colonnes de la grille bento. Au-delà on passe à une rangée de plus plutôt que
/// d'encercler le visiteur : 6 × 16° font 80° d'ouverture, dans les ±60° de confort
/// une fois compté que le regard balaie. Doit rester aligné sur `kVrMenuColumns`
/// côté manager-app, qui dessine l'aperçu de cette même grille.
/// </summary>
const int Columns = 6;
/// <summary>Écart vertical entre deux rangées.</summary>
const float RowGapMeters = 0.06f;
/// <summary>
/// Écart horizontal, déduit de l'arc : un pas de <see cref="StepDegrees"/> à
/// <see cref="RadiusMeters"/> mesure plus qu'une cellule, la différence est le gap.
/// Le calculer évite qu'un panneau double déborde sur son voisin.
/// </summary>
static float ColumnGapMeters =>
RadiusMeters * StepDegrees * Mathf.Deg2Rad - MenuItemPanel.WidthMeters;
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();
var items = new List<BentoLayout.Item>(sections.Count);
foreach (var section in sections)
{
items.Add(new BentoLayout.Item(
section.Id,
section.GridColSpan ?? 1,
section.GridRowSpan ?? 1));
}
var layout = BentoLayout.Compute(items, Columns);
// Un placement par item, dans l'ordre des items — donc dans celui des
// sections. C'est ce qui garde `_panels[i]` apparié à `sections[i]`, dont
// LoadImagesAsync dépend.
var placements = layout.Placements;
// La cascade doit suivre l'ordre de lecture de la grille, pas celui de la
// liste : avec des spans, une section placée plus loin dans la liste peut
// occuper une cellule plus haut à gauche, et les deux ordres divergent.
var readingRank = new int[placements.Count];
var byReading = new List<int>(placements.Count);
for (var i = 0; i < placements.Count; i++) byReading.Add(i);
byReading.Sort((a, b) => placements[a].Row != placements[b].Row
? placements[a].Row.CompareTo(placements[b].Row)
: placements[a].Col.CompareTo(placements[b].Col));
for (var r = 0; r < byReading.Count; r++) readingRank[byReading[r]] = r;
for (var i = 0; i < sections.Count && i < placements.Count; i++)
{
var title = ConfigurationExport.Translate(sections[i].Title, language)
?? sections[i].Label;
var placement = placements[i];
var panel = MenuItemPanel.Create(
sections[i].Id,
title,
transform,
CellsWidth(placement.ColSpan),
CellsHeight(placement.RowSpan));
Place(panel.transform, placement);
// 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(readingRank[i] * 0.04f);
_panels.Add(panel);
}
}
static float CellsWidth(int colSpan) =>
colSpan * MenuItemPanel.WidthMeters + (colSpan - 1) * ColumnGapMeters;
static float CellsHeight(int rowSpan) =>
rowSpan * MenuItemPanel.HeightMeters + (rowSpan - 1) * RowGapMeters;
/// <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);
}
}
/// <summary>
/// Mappe une cellule de la grille sur l'arc : la colonne devient un angle, la
/// rangée une hauteur. La grille est centrée sur <see cref="Columns"/>, pas sur le
/// nombre d'items — une rangée incomplète reste alignée sur celle du dessus, comme
/// dans l'aperçu du manager.
/// </summary>
void Place(Transform panel, BentoLayout.Placement placement)
{
var angle = (placement.Col + placement.ColSpan / 2f - Columns / 2f) * StepDegrees;
var radians = angle * Mathf.Deg2Rad;
var rowStep = MenuItemPanel.HeightMeters + RowGapMeters;
// Le panneau est centré sur les rangées qu'il couvre, sinon un panneau double
// déborderait vers le haut au lieu de descendre.
var height = HeightMeters
- placement.Row * rowStep
- (placement.RowSpan - 1) * rowStep / 2f;
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);
}
}
}