vr-app/unity-overlay/Assets/Scripts/Net/ConfigurationExport.cs
Thomas Fransolet c70c572aed Menu du casque : la grille bento, et le choix de la langue
Les tuiles suivent maintenant les mêmes colSpan × rowSpan que les autres
canaux, sur six colonnes — environ 83° d'ouverture, dans le confort du
regard — avec un écart serré assumé : espacées, les tuiles se lisent comme
des panneaux sans rapport plutôt que comme une planche.

Le pas angulaire se déduit de la largeur de cellule et du rayon de l'arc,
plutôt que d'être posé en dur.

S'y ajoute le sélecteur de langue avec ses drapeaux, et les libellés de
l'interface sortis du code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:37:06 +02:00

443 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Newtonsoft.Json;
using UnityEngine;
namespace MyInfoMate.Vr.Net
{
/// <summary>
/// Lecture de l'export de configuration — item <b>E3</b> du lot XR-4.
///
/// <b>Un seul appel pour tout le contenu</b> : c'est l'arbitrage du lot XR-3, et la
/// raison pour laquelle Unity n'a pas besoin du client généré. <c>ExportConfigurationDTO</c>
/// 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.
///
/// <b>L'export porte toutes les langues à la fois</b>, et c'est ce qui permet au
/// visiteur de choisir la sienne au début de sa visite sans aucun appel réseau : le
/// paramètre <c>language</c> du serveur ne filtre que les <b>ressources</b>, jamais
/// les textes. Le détail de l'arbitrage est sur <see cref="FetchAsync"/>.
/// </summary>
public class ConfigurationExport
{
public string Id;
public string Label;
/// <summary>
/// Les langues dans lesquelles cette visite est proposée, telles que le
/// gestionnaire les a cochées. Le champ vient de <c>ConfigurationDTO</c>, dont
/// l'export hérite ; il peut manquer sur un contenu ancien — d'où le repli de
/// <see cref="AvailableLanguages"/>.
/// </summary>
public List<string> Languages = new List<string>();
public List<SectionSummary> Sections = new List<SectionSummary>();
/// <summary>
/// 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.
/// </summary>
public List<Resource> Resources = new List<Resource>();
/// <summary>
/// Valeurs de <c>ResourceType</c> côté serveur, <b>persistées en int</b>. Seules
/// celles dont le casque a besoin sont nommées ici ; les autres passent en nombre
/// sans rien casser.
/// </summary>
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
}
/// <summary>La ressource d'un id, ou null si la configuration ne la porte pas.</summary>
public Resource FindResource(string id)
{
if (string.IsNullOrEmpty(id)) return null;
foreach (var resource in Resources)
if (resource.Id == id) return resource;
return null;
}
/// <summary>
/// Les 13 types de <c>SectionDTO</c>, persistés en int. On n'en rend que quatre
/// (§8 du plan) — mais on doit tous les <b>lire</b> sans casser, sinon un contenu
/// qui n'est pas pour nous ferait échouer la visite entière.
/// </summary>
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,
/// <summary>Scène 3D avec points d'intérêt (E5/E7), ajoutée le 2026-09-12.</summary>
Scene3D = 13
}
/// <summary>
/// Ce que le visiteur fait de la scène, et c'est une opposition franche (§4bis du
/// plan de frontière) : soit il <b>manipule un objet</b> posé devant lui — l'épée
/// du roi, caméra orbitale, les points tournent avec —, soit il <b>est dedans</b>
/// 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.
/// </summary>
public enum Scene3DMode
{
Asset = 0,
Scene = 1
}
/// <summary>
/// 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.
/// </summary>
public Backdrop ImmersiveBackground;
public enum ImmersiveBackgroundKind
{
Pano = 0,
Video360 = 1,
Scene3D = 2
}
/// <summary>
/// Nommée <c>Backdrop</c> et non <c>ImmersiveBackground</c> : 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.
/// </summary>
public class Backdrop
{
public string ResourceId;
public ImmersiveBackgroundKind Kind;
/// <summary>
/// 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.
/// </summary>
public string ResourceUrl;
/// <summary>Image plate, pour les canaux qui ne rendent pas l'immersif.</summary>
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;
/// <summary>
/// Taille de la section dans la grille du menu, en cellules. null vaut 1 —
/// un contenu créé avant le bento VR reste une cellule simple.
/// </summary>
public int? GridColSpan;
public int? GridRowSpan;
public string ImageSource;
public List<Translation> Title = new List<Translation>();
public List<Translation> Description = new List<Translation>();
/// <summary>
/// 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.
/// </summary>
public List<Content> Contents = new List<Content>();
/// <summary>Les points d'intérêt d'une Map.</summary>
public List<GeoPoint> Points = new List<GeoPoint>();
/// <summary>Le programme d'un Event.</summary>
public List<ProgrammeBlock> Programme = new List<ProgrammeBlock>();
/// <summary>Vrai si cette Map porte des parcours guidés plutôt que des POI libres.</summary>
public bool IsParcours;
/// <summary>
/// Média d'une section Video : <b>soit un id de ressource, soit une URL</b>
/// (YouTube, Vimeo) — c'est le même champ côté serveur, et seul le premier cas
/// est téléchargeable hors ligne.
/// </summary>
public string Source;
public bool SourceIsUrl =>
!string.IsNullOrEmpty(Source)
&& Source.StartsWith("http", StringComparison.OrdinalIgnoreCase);
/// <summary>Ressource GLB d'une section scène 3D.</summary>
public string Model3DResourceId;
/// <summary>URL du modèle, remplie par le serveur comme <c>ImageSource</c>.</summary>
public string Model3DSource;
/// <summary>Objet manipulé ou décor habité. Voir <see cref="Scene3DMode"/>.</summary>
public Scene3DMode Scene3DMode;
/// <summary>Les médias, dans l'ordre voulu par le gestionnaire.</summary>
public List<Content> OrderedContents()
{
var ordered = new List<Content>(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<Translation> Title = new List<Translation>();
public List<Translation> Description = new List<Translation>();
}
/// <summary>
/// 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).
/// </summary>
public class GeoPoint
{
public int? Id;
public string ImageUrl;
/// <summary>
/// Position sur une maquette 3D, nulle sur un point de carte. Exprimée dans
/// la convention <b>glTF</b> du manifeste : la conversion vers Unity se fait
/// dans <c>GltfSpace</c>, et nulle part ailleurs.
/// </summary>
public Position3D LocalTransform;
public List<Translation> Title = new List<Translation>();
public List<Translation> Description = new List<Translation>();
public List<Translation> Schedules = new List<Translation>();
public List<Content> Contents = new List<Content>();
}
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<Translation> Title = new List<Translation>();
public List<Translation> Description = new List<Translation>();
}
public class Resource
{
public string Id;
public string Label;
public ResourceKind Type;
/// <summary>L'URL du fichier. C'est elle qui alimente le cache disque.</summary>
public string Url;
public int? Width;
public int? Height;
/// <summary>Une ressource que le casque sait afficher en immersif.</summary>
public bool IsImmersive =>
Type == ResourceKind.Image360 || Type == ResourceKind.Video360;
}
public class Translation
{
public string Language;
public string Value;
}
/// <summary>
/// Le texte dans la langue du visiteur.
///
/// <b>Trois essais, dans cet ordre</b> : la langue demandée, le français, puis la
/// première traduction qui porte quelque chose. C'est <i>exactement</i> la chaîne
/// de <c>t()</c> côté visitapp-web et de son équivalent Flutter — un gestionnaire
/// qui coche une langue sans la traduire doit voir la même chose sur les quatre
/// canaux, sinon on lui fait chercher un bug qui n'existe pas.
///
/// Une traduction vide ne compte pas : le manager en crée une par langue cochée,
/// remplie ou non, et s'arrêter dessus rendrait une tuile sans nom.
/// </summary>
public static string Translate(List<Translation> translations, string language)
{
if (translations == null || translations.Count == 0) return null;
return Find(translations, language)
?? Find(translations, Ui.Strings.Fallback)
?? First(translations);
}
static string Find(List<Translation> translations, string language)
{
foreach (var translation in translations)
{
if (!string.Equals(translation.Language, language,
System.StringComparison.OrdinalIgnoreCase)) continue;
var text = PlainText(translation.Value);
if (!string.IsNullOrWhiteSpace(text)) return text;
}
return null;
}
static string First(List<Translation> translations)
{
foreach (var translation in translations)
{
var text = PlainText(translation.Value);
if (!string.IsNullOrWhiteSpace(text)) return text;
}
return null;
}
/// <summary>
/// Les textes du manager sont saisis dans un éditeur riche : un titre de section
/// arrive en <c>&lt;p&gt;Quiz test&lt;/p&gt;</c>. Un <c>TextMesh</c> ne connaît pas
/// le HTML et affiche les balises telles quelles.
///
/// C'est ici et nulle part ailleurs, parce que <see cref="Translate"/> est le seul
/// chemin par lequel un texte de l'export atteint l'écran — menu, pages, hotspots.
/// </summary>
static string PlainText(string html)
{
if (string.IsNullOrEmpty(html)) return html;
var text = System.Text.RegularExpressions.Regex.Replace(
html, "<br\\s*/?>|</p>|</div>|</li>", "\n");
text = System.Text.RegularExpressions.Regex.Replace(text, "<[^>]+>", "");
text = text.Replace("&nbsp;", " ").Replace("&amp;", "&")
.Replace("&lt;", "<").Replace("&gt;", ">")
.Replace("&quot;", "\"").Replace("&#39;", "'");
return text.Trim();
}
/// <summary>
/// Ce qu'on peut proposer au visiteur, normalisé en majuscules et sans doublon.
///
/// Le champ <c>languages</c> de la configuration fait foi : c'est la liste que le
/// gestionnaire a choisie, et elle inclut une langue même quand une section n'y est
/// pas encore traduite. À défaut — contenu antérieur au champ — on retombe sur les
/// langues réellement présentes dans les titres, ce qui donne au moins de quoi
/// afficher un choix juste.
/// </summary>
public List<string> AvailableLanguages()
{
var codes = new List<string>();
void Add(string language)
{
if (string.IsNullOrWhiteSpace(language)) return;
var code = Ui.Strings.Normalize(language);
if (!codes.Contains(code)) codes.Add(code);
}
foreach (var language in Languages) Add(language);
if (codes.Count > 0) return codes;
foreach (var section in Sections)
foreach (var title in section.Title)
Add(title.Language);
return codes;
}
/// <summary>Les sections de premier niveau, dans l'ordre du manager.</summary>
public List<SectionSummary> RootSections()
{
var roots = new List<SectionSummary>();
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;
}
/// <summary>
/// ⚠️ <b>Volontairement sans paramètre de langue.</b> 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 : <c>language</c>
/// ne filtre que les <b>ressources</b> (les audios d'une langue), les textes étant
/// toujours rendus dans toutes leurs traductions. Omettre le paramètre rend donc
/// tout : <c>GetReferencedResourceIds(null)</c> renvoie les médias de toutes les
/// langues.
///
/// Conséquence concrète : <b>changer de langue à chaud ne demande aucun appel</b>,
/// et le cache disque en garde une copie au lieu d'une par langue.
/// </summary>
public static async Task<ApiClient.Result<ConfigurationExport>> FetchAsync(
ApiClient client, string configurationId)
{
var raw = await client.GetRawAsync(
$"/api/configuration/{configurationId}/export");
if (!raw.Ok) return new ApiClient.Result<ConfigurationExport> { Error = raw.Error };
return Parse(raw.Value);
}
public static ApiClient.Result<ConfigurationExport> Parse(string json)
{
if (string.IsNullOrWhiteSpace(json))
return Fail("Le contenu de ce lieu est vide.");
try
{
var export = JsonConvert.DeserializeObject<ConfigurationExport>(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<ConfigurationExport> { Value = export };
}
catch (JsonException e)
{
Debug.LogError($"[Export] JSON illisible : {e.Message}");
return Fail("Le contenu de ce lieu est illisible.");
}
}
static ApiClient.Result<ConfigurationExport> Fail(string error)
{
Debug.LogError($"[Export] {error}");
return new ApiClient.Result<ConfigurationExport> { Error = error };
}
}
}