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 };
}
}
}