Thomas Fransolet 0310d28b5e Canal VR Meta Quest : projet Unity, viewer web et documentation
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>
2026-09-16 15:26:07 +02:00

173 lines
7.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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