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 entre deux tuiles, dans les deux sens. Serré, et c'est voulu : la /// grille du menu doit se lire comme celle de l'app mobile — des tuiles qui se /// touchent presque, donc une planche. Espacées, les mêmes tuiles se lisent comme /// des panneaux flottants sans rapport les uns avec les autres. /// const float GapMeters = 0.03f; /// /// Écart angulaire entre deux colonnes, déduit de la largeur d'une cellule /// et du rayon de l'arc : c'est le pas qui donne exactement /// entre deux tuiles voisines. Le poser en dur ferait dépendre l'allure de la /// grille d'un réglage sans rapport avec elle. /// static float StepDegrees => (MenuItemPanel.WidthMeters + GapMeters) / RadiusMeters * Mathf.Rad2Deg; /// /// Colonnes de la grille bento. Au-delà on passe à une rangée de plus plutôt que /// d'encercler le visiteur : six colonnes font environ 83° 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; 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) * GapMeters; static float CellsHeight(int rowSpan) => rowSpan * MenuItemPanel.HeightMeters + (rowSpan - 1) * GapMeters; /// /// 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); // La vignette est composée dans la texture du panneau : la source ne sert // plus, et une photo de plein format gardée par tuile finirait par tenir // plus de place que tout le reste du menu. Destroy(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 + GapMeters; // 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); } } }