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>
142 lines
5.7 KiB
C#
142 lines
5.7 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading.Tasks;
|
|
using UnityEngine;
|
|
|
|
namespace MyInfoMate.Vr.Net
|
|
{
|
|
/// <summary>
|
|
/// Préchargement des médias d'une visite — la seconde moitié de l'item <b>E4</b>.
|
|
///
|
|
/// <b>Le cache disque seul ne suffit pas.</b> Il se remplissait à la demande : une
|
|
/// borne branchée dans une salle sans wifi avait son JSON, ses titres, son menu — et
|
|
/// pas une image. Le contenu n'arrivait que si le visiteur ouvrait la section
|
|
/// pendant que le réseau était là, ce qui est exactement l'inverse de ce qu'on
|
|
/// promet. C'est l'argument n°1 qui a fait choisir Unity contre WebXR (§10 du plan) ;
|
|
/// sans cette classe, il n'était pas tenu.
|
|
///
|
|
/// Trois règles, toutes dictées par l'exploitation :
|
|
/// <list type="bullet">
|
|
/// <item><b>Après le menu, jamais avant.</b> Le visiteur voit son menu en une
|
|
/// seconde ; le téléchargement se fait derrière lui. Bloquer le démarrage sur
|
|
/// des gigaoctets de vidéo 360 ferait d'une borne un écran d'attente.</item>
|
|
/// <item><b>Un par un.</b> Dix téléchargements parallèles sur le wifi d'un musée se
|
|
/// gênent, et une 360 de 2 Go n'a rien à gagner à partager la bande passante
|
|
/// avec neuf vignettes.</item>
|
|
/// <item><b>Un échec n'arrête rien.</b> Chaque média manquant sera retenté au
|
|
/// prochain démarrage ; les autres sont déjà là.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
public static class ContentPreloader
|
|
{
|
|
public struct Progress
|
|
{
|
|
public int Done;
|
|
public int Total;
|
|
public int Failed;
|
|
|
|
/// <summary>Médias déjà sur le disque au démarrage — jamais retéléchargés.</summary>
|
|
public int AlreadyCached;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Télécharge tout ce que le casque saura afficher hors ligne. Rend le bilan, qui
|
|
/// n'a d'autre usage que le journal : rien de tout ceci ne doit se voir.
|
|
/// </summary>
|
|
public static async Task<Progress> RunAsync(
|
|
ConfigurationExport export, ApiClient client, Action<Progress> advanced = null)
|
|
{
|
|
var urls = MediaUrlsOf(export);
|
|
var progress = new Progress { Total = urls.Count };
|
|
|
|
if (urls.Count == 0) return progress;
|
|
|
|
Debug.Log($"[Preload] {urls.Count} médias à mettre en cache.");
|
|
|
|
foreach (var url in urls)
|
|
{
|
|
if (ContentCache.CachedPath(url) != null)
|
|
{
|
|
progress.AlreadyCached++;
|
|
progress.Done++;
|
|
}
|
|
else if (await ContentCache.MediaPathAsync(client, url) != null)
|
|
{
|
|
progress.Done++;
|
|
}
|
|
else
|
|
{
|
|
// Volontairement silencieux côté visiteur : ContentCache a déjà
|
|
// journalisé la cause, et un média manquant n'invalide pas la visite.
|
|
progress.Failed++;
|
|
}
|
|
|
|
advanced?.Invoke(progress);
|
|
}
|
|
|
|
Debug.Log($"[Preload] Terminé — {progress.Done}/{progress.Total} disponibles " +
|
|
$"hors ligne ({progress.AlreadyCached} déjà en cache, " +
|
|
$"{progress.Failed} en échec), {ContentCache.SizeBytes() / 1048576} Mo sur le disque.");
|
|
|
|
return progress;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Toutes les URL téléchargeables de la configuration, sans doublon et dans un
|
|
/// ordre utile : <b>les vignettes du menu d'abord</b>, parce que ce sont elles
|
|
/// qu'on voit en premier, et les gros médias ensuite.
|
|
///
|
|
/// ⚠️ Les types <c>ImageUrl</c> / <c>VideoUrl</c> sont <b>écartés</b> : ce sont des
|
|
/// liens externes (YouTube, Vimeo), qui ne se mettent pas en cache et ne
|
|
/// s'affichent pas hors ligne. Les PDF et les JSON aussi — le casque ne les rend
|
|
/// pas (§8 du plan), les télécharger ne ferait que remplir le disque.
|
|
/// </summary>
|
|
public static List<string> MediaUrlsOf(ConfigurationExport export)
|
|
{
|
|
var urls = new List<string>();
|
|
var seen = new HashSet<string>();
|
|
|
|
void Add(string url)
|
|
{
|
|
if (string.IsNullOrEmpty(url)) return;
|
|
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return;
|
|
if (seen.Add(url)) urls.Add(url);
|
|
}
|
|
|
|
if (export?.Sections != null)
|
|
foreach (var section in export.Sections)
|
|
{
|
|
Add(section.ImageSource);
|
|
Add(section.Model3DSource);
|
|
|
|
if (section.Points != null)
|
|
foreach (var point in section.Points)
|
|
Add(point.ImageUrl);
|
|
}
|
|
|
|
if (export?.Resources != null)
|
|
foreach (var resource in export.Resources)
|
|
if (IsDownloadable(resource.Type))
|
|
Add(resource.Url);
|
|
|
|
return urls;
|
|
}
|
|
|
|
static bool IsDownloadable(ConfigurationExport.ResourceKind kind)
|
|
{
|
|
switch (kind)
|
|
{
|
|
case ConfigurationExport.ResourceKind.Image:
|
|
case ConfigurationExport.ResourceKind.Video:
|
|
case ConfigurationExport.ResourceKind.Audio:
|
|
case ConfigurationExport.ResourceKind.Image360:
|
|
case ConfigurationExport.ResourceKind.Video360:
|
|
case ConfigurationExport.ResourceKind.Model3D:
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|