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>
150 lines
5.5 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|