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>
173 lines
7.1 KiB
C#
173 lines
7.1 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Threading.Tasks;
|
||
using MyInfoMate.Vr.Net;
|
||
using UnityEngine;
|
||
|
||
namespace MyInfoMate.Vr.Menu
|
||
{
|
||
/// <summary>
|
||
/// Remplit une <see cref="PagedView"/> à partir d'une section — item <b>E8</b> du
|
||
/// lot XR-4. C'est ici, et nulle part ailleurs, que vit ce qui distingue un Slider
|
||
/// d'une Map ou d'un Event.
|
||
///
|
||
/// <b>Trois types sur les quatre retenus au §8.</b> Le quatrième, la Video 360, attend
|
||
/// la ressource 360° côté backend. Le Parcours est volontairement rendu comme une Map :
|
||
/// « chaque étape = un lieu autour de soi » demande de la 3D, donc `SectionScene3D`.
|
||
/// </summary>
|
||
public static class SectionPages
|
||
{
|
||
/// <summary>Les types qu'on sait ouvrir aujourd'hui.</summary>
|
||
public static bool CanRender(ConfigurationExport.SectionType type) =>
|
||
type == ConfigurationExport.SectionType.Slider
|
||
|| type == ConfigurationExport.SectionType.Map
|
||
|| type == ConfigurationExport.SectionType.Parcours
|
||
|| type == ConfigurationExport.SectionType.Event;
|
||
|
||
public static async Task FillAsync(PagedView view,
|
||
ConfigurationExport.SectionSummary section,
|
||
ApiClient client,
|
||
string language)
|
||
{
|
||
switch (section.Type)
|
||
{
|
||
case ConfigurationExport.SectionType.Slider:
|
||
await FillSlider(view, section, client, language);
|
||
break;
|
||
|
||
case ConfigurationExport.SectionType.Map:
|
||
case ConfigurationExport.SectionType.Parcours:
|
||
await FillMap(view, section, client, language);
|
||
break;
|
||
|
||
case ConfigurationExport.SectionType.Event:
|
||
FillEvent(view, section, language);
|
||
break;
|
||
}
|
||
|
||
if (view != null && view.PageCount == 0)
|
||
view.ShowEmpty("Ce contenu est vide.");
|
||
}
|
||
|
||
static async Task FillSlider(PagedView view,
|
||
ConfigurationExport.SectionSummary section,
|
||
ApiClient client,
|
||
string language)
|
||
{
|
||
foreach (var content in section.OrderedContents())
|
||
{
|
||
var texture = await LoadImage(client, content.Resource?.Url);
|
||
if (view == null) return;
|
||
if (texture == null) continue;
|
||
|
||
view.AddPage(new PagedView.Page
|
||
{
|
||
Heading = ConfigurationExport.Translate(content.Title, language) ?? "",
|
||
Body = ConfigurationExport.Translate(content.Description, language),
|
||
Image = texture
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Une Map devient une <b>liste de points d'intérêt</b>, un par page.
|
||
///
|
||
/// ⚠️ Ce n'est pas la Map que le plan promet. Sa valeur en VR, c'est « le plan posé
|
||
/// devant soi comme une maquette » — et ça demande un modèle 3D, donc
|
||
/// `SectionScene3D` (§9). Une carte Google affichée sur un panneau flottant serait
|
||
/// <i>moins</i> bonne qu'un téléphone : autant montrer les lieux eux-mêmes, avec
|
||
/// leur photo et leurs horaires, ce que le `GeoPoint` porte déjà.
|
||
/// </summary>
|
||
static async Task FillMap(PagedView view,
|
||
ConfigurationExport.SectionSummary section,
|
||
ApiClient client,
|
||
string language)
|
||
{
|
||
foreach (var point in section.Points)
|
||
{
|
||
var texture = await LoadImage(client, point.ImageUrl);
|
||
if (view == null) return;
|
||
|
||
var parts = new List<string>();
|
||
|
||
var description = ConfigurationExport.Translate(point.Description, language);
|
||
if (!string.IsNullOrEmpty(description)) parts.Add(description);
|
||
|
||
var schedules = ConfigurationExport.Translate(point.Schedules, language);
|
||
if (!string.IsNullOrEmpty(schedules)) parts.Add(schedules);
|
||
|
||
view.AddPage(new PagedView.Page
|
||
{
|
||
Heading = ConfigurationExport.Translate(point.Title, language) ?? "",
|
||
Body = string.Join("\n", parts),
|
||
Image = texture
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Un Event devient le programme, une page par bloc.
|
||
///
|
||
/// <b>« Ce qui se passe aujourd'hui »</b> est l'usage visé (§8) : un casque en
|
||
/// écran d'attente dans un office de tourisme. On ne montre donc que la journée en
|
||
/// cours — et seulement s'il s'y passe quelque chose : un programme vide le jour
|
||
/// même vaut mieux rempli par les dates suivantes que pas rempli du tout.
|
||
/// </summary>
|
||
static void FillEvent(PagedView view,
|
||
ConfigurationExport.SectionSummary section,
|
||
string language)
|
||
{
|
||
var blocks = new List<ConfigurationExport.ProgrammeBlock>(section.Programme);
|
||
blocks.Sort((a, b) => Nullable.Compare(a.StartTime, b.StartTime));
|
||
|
||
var today = new List<ConfigurationExport.ProgrammeBlock>();
|
||
var upcoming = new List<ConfigurationExport.ProgrammeBlock>();
|
||
|
||
foreach (var block in blocks)
|
||
{
|
||
if (block.StartTime == null) continue;
|
||
|
||
var start = block.StartTime.Value.ToLocalTime();
|
||
if (start.Date == DateTime.Now.Date) today.Add(block);
|
||
else if (start > DateTime.Now) upcoming.Add(block);
|
||
}
|
||
|
||
var shown = today.Count > 0 ? today : upcoming;
|
||
|
||
foreach (var block in shown)
|
||
{
|
||
var start = block.StartTime?.ToLocalTime();
|
||
var end = block.EndTime?.ToLocalTime();
|
||
|
||
var when = start == null ? "" :
|
||
today.Count > 0
|
||
? (end == null ? $"{start:HH:mm}" : $"{start:HH:mm} – {end:HH:mm}")
|
||
: $"{start:dd/MM} · {start:HH:mm}";
|
||
|
||
var description = ConfigurationExport.Translate(block.Description, language);
|
||
|
||
view.AddPage(new PagedView.Page
|
||
{
|
||
Heading = ConfigurationExport.Translate(block.Title, language) ?? "",
|
||
Body = string.IsNullOrEmpty(description) ? when : $"{when}\n{description}"
|
||
});
|
||
}
|
||
|
||
if (view.PageCount == 0)
|
||
view.ShowEmpty("Rien au programme aujourd'hui.");
|
||
}
|
||
|
||
static async Task<Texture2D> LoadImage(ApiClient client, string url)
|
||
{
|
||
if (string.IsNullOrEmpty(url)) return null;
|
||
|
||
var path = await ContentCache.MediaPathAsync(client, url);
|
||
if (path == null) return null;
|
||
|
||
var texture = new Texture2D(2, 2);
|
||
return texture.LoadImage(File.ReadAllBytes(path)) ? texture : null;
|
||
}
|
||
}
|
||
}
|