using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using MyInfoMate.Vr.Net; using UnityEngine; namespace MyInfoMate.Vr.Menu { /// /// Le menu flottant — le hub de l'app, item E5 du lot XR-4. /// /// C'est le rendu de la SectionMenu du CMS, et le seul type de section dont /// le plan dit qu'il est « nécessaire » : tout le reste s'atteint depuis lui. /// /// Disposition en arc, pas en grille plate. 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é. /// public class FloatingMenu : MonoBehaviour { /// 2,2 m : au-delà le texte devient petit, en deçà l'œil doit converger. const float RadiusMeters = 2.2f; const float HeightMeters = 1.45f; /// Écart angulaire entre deux panneaux, assez large pour que le regard tranche. const float StepDegrees = 16f; /// /// 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. /// const int Columns = 6; /// Écart vertical entre deux rangées. const float RowGapMeters = 0.06f; /// /// Écart horizontal, déduit de l'arc : un pas de à /// mesure plus qu'une cellule, la différence est le gap. /// Le calculer évite qu'un panneau double déborde sur son voisin. /// static float ColumnGapMeters => RadiusMeters * StepDegrees * Mathf.Deg2Rad - MenuItemPanel.WidthMeters; readonly List _panels = new List(); AimSelector _selector; /// L'id de la section choisie. public event Action SectionChosen; public static FloatingMenu Create(Transform head) { var root = new GameObject("FloatingMenu"); var menu = root.AddComponent(); menu._selector = root.AddComponent(); menu._selector.Configure(head); menu._selector.Selected += menu.OnSelected; menu._head = head; menu.Recenter(); return menu; } Transform _head; /// /// 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 ne suit pas la tête : un menu qui suit /// est impossible à viser, et donne le mal de cœur. /// 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(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(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; /// /// Les vignettes arrivent après coup : le menu doit être utilisable avant que /// le premier octet d'image ne soit téléchargé. /// 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); } } /// /// 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 , 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. /// 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); } } }