using System; using System.Collections.Generic; using System.Threading.Tasks; using Newtonsoft.Json; using UnityEngine; namespace MyInfoMate.Vr.Net { /// /// Lecture de l'export de configuration — item E3 du lot XR-4. /// /// Un seul appel pour tout le contenu : c'est l'arbitrage du lot XR-3, et la /// raison pour laquelle Unity n'a pas besoin du client généré. ExportConfigurationDTO /// est le DTO de l'import/export du back-office, déjà stable, et c'est aussi celui que /// mymuseum-visitapp télécharge pour une visite hors ligne. /// /// ⚠️ L'export ne rend qu'une langue à la fois. language traverse jusqu'à /// Section.GetReferencedResourceIds(language) côté serveur : changer de langue /// veut dire refaire l'appel, et un casque en borne change de langue à chaud. C'est la /// question ouverte du lot XR-3 ; en attendant, on recharge. /// public class ConfigurationExport { public string Id; public string Label; public List Sections = new List(); /// /// Tous les médias référencés par la configuration, dans un seul appel. C'est ce /// qui permet de résoudre la ressource d'une section sans repasser par le réseau. /// public List Resources = new List(); /// /// Valeurs de ResourceType côté serveur, persistées en int. Seules /// celles dont le casque a besoin sont nommées ici ; les autres passent en nombre /// sans rien casser. /// public enum ResourceKind { Image = 0, Video = 1, ImageUrl = 2, VideoUrl = 3, Audio = 4, PDF = 5, JSON = 6, JSONUrl = 7, Word = 8, PowerPoint = 9, Text = 10, Image360 = 11, Video360 = 12, Model3D = 13 } /// La ressource d'un id, ou null si la configuration ne la porte pas. public Resource FindResource(string id) { if (string.IsNullOrEmpty(id)) return null; foreach (var resource in Resources) if (resource.Id == id) return resource; return null; } /// /// Les 13 types de SectionDTO, persistés en int. On n'en rend que quatre /// (§8 du plan) — mais on doit tous les lire sans casser, sinon un contenu /// qui n'est pas pour nous ferait échouer la visite entière. /// public enum SectionType { Map = 0, Slider = 1, Video = 2, Web = 3, Menu = 4, Quiz = 5, Article = 6, PDF = 7, Game = 8, Agenda = 9, Weather = 10, Event = 11, Parcours = 12, /// Scène 3D avec points d'intérêt (E5/E7), ajoutée le 2026-09-12. Scene3D = 13 } /// /// Ce que le visiteur fait de la scène, et c'est une opposition franche (§4bis du /// plan de frontière) : soit il manipule un objet posé devant lui — l'épée /// du roi, caméra orbitale, les points tournent avec —, soit il est dedans /// et regarde autour — un décor, caméra fixe, les points restent où ils sont. /// /// Même GLB, même modèle de point, même éditeur. Ce qui change, c'est où l'on met /// le visiteur — et ça, aucun fichier ne peut le deviner. /// public enum Scene3DMode { Asset = 0, Scene = 1 } /// /// Le fond du lieu — §4 du plan de frontière. Null quand la visite n'en a pas, /// ce qui est le cas de toutes celles écrites avant le 2026-09-12. /// public Backdrop ImmersiveBackground; public enum ImmersiveBackgroundKind { Pano = 0, Video360 = 1, Scene3D = 2 } /// /// Nommée Backdrop et non ImmersiveBackground : en C# un champ ne /// peut pas porter le nom d'un type imbriqué de la même classe, et c'est le champ /// qui doit garder le nom du JSON. /// public class Backdrop { public string ResourceId; public ImmersiveBackgroundKind Kind; /// /// URL posée par le serveur. Le casque n'a pas de client généré et démarre /// souvent sans réseau : résoudre l'id ici serait un appel qu'il ne peut pas /// passer. /// public string ResourceUrl; /// Image plate, pour les canaux qui ne rendent pas l'immersif. public string FallbackResourceId; public string FallbackUrl; } public class SectionSummary { public string Id; public string Label; public SectionType Type; public bool IsActive; public bool IsSubSection; public string ParentId; public int? Order; public string ImageSource; public List Title = new List(); public List Description = new List(); /// /// Les médias d'un Slider. Le champ n'existe que sur les types qui en ont — /// l'export sérialise le sous-type réel de chaque section, pas un `SectionDTO` /// nu — et reste vide partout ailleurs. /// public List Contents = new List(); /// Les points d'intérêt d'une Map. public List Points = new List(); /// Le programme d'un Event. public List Programme = new List(); /// Vrai si cette Map porte des parcours guidés plutôt que des POI libres. public bool IsParcours; /// /// Média d'une section Video : soit un id de ressource, soit une URL /// (YouTube, Vimeo) — c'est le même champ côté serveur, et seul le premier cas /// est téléchargeable hors ligne. /// public string Source; public bool SourceIsUrl => !string.IsNullOrEmpty(Source) && Source.StartsWith("http", StringComparison.OrdinalIgnoreCase); /// Ressource GLB d'une section scène 3D. public string Model3DResourceId; /// URL du modèle, remplie par le serveur comme ImageSource. public string Model3DSource; /// Objet manipulé ou décor habité. Voir . public Scene3DMode Scene3DMode; /// Les médias, dans l'ordre voulu par le gestionnaire. public List OrderedContents() { var ordered = new List(Contents); ordered.Sort((a, b) => (a.Order ?? int.MaxValue).CompareTo(b.Order ?? int.MaxValue)); return ordered; } } public class Content { public int? Order; public string ResourceId; public Resource Resource; public List Title = new List(); public List Description = new List(); } /// /// Un point d'intérêt. Le même objet porte déjà titre, description, image et /// contenus multilingues — c'est ce qui rendra les POI sur modèle 3D presque /// gratuits le jour où `SectionScene3D` existera (§9 du plan). /// public class GeoPoint { public int? Id; public string ImageUrl; /// /// Position sur une maquette 3D, nulle sur un point de carte. Exprimée dans /// la convention glTF du manifeste : la conversion vers Unity se fait /// dans GltfSpace, et nulle part ailleurs. /// public Position3D LocalTransform; public List Title = new List(); public List Description = new List(); public List Schedules = new List(); public List Contents = new List(); } public class Position3D { public float X; public float Y; public float Z; public float? RotationY; } public class ProgrammeBlock { public string Id; public DateTime? StartTime; public DateTime? EndTime; public List Title = new List(); public List Description = new List(); } public class Resource { public string Id; public string Label; public ResourceKind Type; /// L'URL du fichier. C'est elle qui alimente le cache disque. public string Url; public int? Width; public int? Height; /// Une ressource que le casque sait afficher en immersif. public bool IsImmersive => Type == ResourceKind.Image360 || Type == ResourceKind.Video360; } public class Translation { public string Language; public string Value; } /// Le texte dans la langue demandée, ou la première traduction disponible. public static string Translate(List translations, string language) { if (translations == null || translations.Count == 0) return null; foreach (var t in translations) if (string.Equals(t.Language, language, System.StringComparison.OrdinalIgnoreCase)) return PlainText(t.Value); return PlainText(translations[0].Value); } /// /// Les textes du manager sont saisis dans un éditeur riche : un titre de section /// arrive en <p>Quiz test</p>. Un TextMesh ne connaît pas /// le HTML et affiche les balises telles quelles. /// /// C'est ici et nulle part ailleurs, parce que est le seul /// chemin par lequel un texte de l'export atteint l'écran — menu, pages, hotspots. /// static string PlainText(string html) { if (string.IsNullOrEmpty(html)) return html; var text = System.Text.RegularExpressions.Regex.Replace( html, "|

||", "\n"); text = System.Text.RegularExpressions.Regex.Replace(text, "<[^>]+>", ""); text = text.Replace(" ", " ").Replace("&", "&") .Replace("<", "<").Replace(">", ">") .Replace(""", "\"").Replace("'", "'"); return text.Trim(); } /// Les sections de premier niveau, dans l'ordre du manager. public List RootSections() { var roots = new List(); foreach (var s in Sections) if (!s.IsSubSection && s.IsActive) roots.Add(s); roots.Sort((a, b) => (a.Order ?? int.MaxValue).CompareTo(b.Order ?? int.MaxValue)); return roots; } /// /// ⚠️ Volontairement sans paramètre de langue. C'était la question ouverte /// du lot XR-3 — « l'export doit-il rendre toutes les langues d'un coup pour un /// casque en borne ? » — et la réponse était déjà dans le code : language /// ne filtre que les ressources (les audios d'une langue), les textes étant /// toujours rendus dans toutes leurs traductions. Omettre le paramètre rend donc /// tout : GetReferencedResourceIds(null) renvoie les médias de toutes les /// langues. /// /// Conséquence concrète : changer de langue à chaud ne demande aucun appel, /// et le cache disque en garde une copie au lieu d'une par langue. /// public static async Task> FetchAsync( ApiClient client, string configurationId) { var raw = await client.GetRawAsync( $"/api/configuration/{configurationId}/export"); if (!raw.Ok) return new ApiClient.Result { Error = raw.Error }; return Parse(raw.Value); } public static ApiClient.Result Parse(string json) { if (string.IsNullOrWhiteSpace(json)) return Fail("Le contenu de ce lieu est vide."); try { var export = JsonConvert.DeserializeObject(json, new JsonSerializerSettings { // L'export est en camelCase, et un type de section inconnu d'une // version future ne doit pas faire échouer la lecture. ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver { NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy() }, MissingMemberHandling = MissingMemberHandling.Ignore, NullValueHandling = NullValueHandling.Ignore }); if (export == null) return Fail("Le contenu de ce lieu est illisible."); return new ApiClient.Result { Value = export }; } catch (JsonException e) { Debug.LogError($"[Export] JSON illisible : {e.Message}"); return Fail("Le contenu de ce lieu est illisible."); } } static ApiClient.Result Fail(string error) { Debug.LogError($"[Export] {error}"); return new ApiClient.Result { Error = error }; } } }