Premier commit du cinquième front. Trois parties : - `unity/MyInfoMateVR/` : le projet Unity (6000.0.83f1, URP, Meta XR SDK 205), un APK unique pour tous les clients. Menu flottant à sélection au regard, appairage, chargement de scène GLB, POI, cache de contenu, télémétrie. - `unity-overlay/` : les mêmes scripts à recopier sur un projet Unity neuf, avec les pièges rencontrés consignés dans son README. - `viewer/` : viewer et éditeur de scène web autonome (Vite, TypeScript, three.js), partagé avec les autres fronts. - `docs/` : état des lieux, setup Unity, décisions d'architecture et plan d'exécution en 9 étapes. La scène est décrite par un `scene.json` poussé par `adb push` : l'app le préfère à celui embarqué dans l'APK. Les binaires (GLB, textures de l'échantillon Sponza, DLL Meta XR) passent par Git LFS dès ce premier commit — les y faire entrer après coup demanderait de réécrire l'historique. Les artefacts régénérés par l'éditeur et par CMake (`Library/`, `.utmp/`, Burst debug) sont ignorés. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
360 lines
15 KiB
C#
360 lines
15 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 ne rend qu'une langue à la fois.</b> <c>language</c> traverse jusqu'à
|
|
/// <c>Section.GetReferencedResourceIds(language)</c> 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.
|
|
/// </summary>
|
|
public class ConfigurationExport
|
|
{
|
|
public string Id;
|
|
public string Label;
|
|
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;
|
|
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 demandée, ou la première traduction disponible.</summary>
|
|
public static string Translate(List<Translation> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Les textes du manager sont saisis dans un éditeur riche : un titre de section
|
|
/// arrive en <c><p>Quiz test</p></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(" ", " ").Replace("&", "&")
|
|
.Replace("<", "<").Replace(">", ">")
|
|
.Replace(""", "\"").Replace("'", "'");
|
|
|
|
return text.Trim();
|
|
}
|
|
|
|
/// <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 };
|
|
}
|
|
}
|
|
}
|