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>
215 lines
8.7 KiB
C#
215 lines
8.7 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>
|
||
/// 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);
|
||
}
|
||
}
|
||
}
|