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

141 lines
5.7 KiB
C#

using System;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using UnityEngine;
using UnityEngine.Networking;
namespace MyInfoMate.Vr.Manifest
{
/// <summary>
/// Lecture et <b>validation</b> d'un manifeste de scène.
///
/// La validation n'est pas de la défense de principe : le manifeste est écrit
/// par le serveur, mais il est lu par une app qui tourne hors ligne, sur site,
/// sans personne pour regarder une console. Un manifeste qu'on ne sait pas lire
/// doit produire <b>une phrase lisible dans le casque</b>, jamais une scène vide
/// (§2.3, et le livrable explicite de S2).
/// </summary>
public static class ManifestReader
{
static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
// Le manifeste est en camelCase (§2.2), les champs C# en PascalCase.
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new CamelCaseNamingStrategy()
},
MissingMemberHandling = MissingMemberHandling.Ignore,
// Un champ absent doit garder la valeur par défaut du champ C#, pas
// devenir null : c'est ce qui rend les sections optionnelles réellement
// optionnelles.
NullValueHandling = NullValueHandling.Ignore
};
public class Result
{
public SceneManifest Manifest;
/// <summary>Null si tout va bien ; sinon une phrase destinée au visiteur.</summary>
public string Error;
public bool Ok => Error == null;
}
public static Result Parse(string json)
{
if (string.IsNullOrWhiteSpace(json))
return Fail("Le manifeste de scène est vide.");
SceneManifest manifest;
try
{
manifest = JsonConvert.DeserializeObject<SceneManifest>(json, Settings);
}
catch (JsonException e)
{
Debug.LogError($"[Manifest] JSON illisible : {e.Message}");
return Fail("Le manifeste de scène est illisible.");
}
if (manifest == null)
return Fail("Le manifeste de scène est illisible.");
return Validate(manifest);
}
static Result Validate(SceneManifest manifest)
{
if (manifest.ManifestVersion != SceneManifest.SupportedManifestVersion)
return Fail($"Cette scène utilise un format de manifeste " +
$"(version {manifest.ManifestVersion}) que cette version de " +
$"l'application ne sait pas lire. Mettez l'application à jour.");
// Vérifié comme un littéral, volontairement : c'est le seul garde-fou
// contre un manifeste produit dans la convention Unity par erreur, et
// un miroir d'axes est invisible sur une scène symétrique (§2.1).
if (manifest.CoordinateSystem != SceneManifest.ExpectedCoordinateSystem)
return Fail($"Le repère de cette scène est inattendu " +
$"({manifest.CoordinateSystem ?? "absent"}). " +
$"Attendu : {SceneManifest.ExpectedCoordinateSystem}.");
if (manifest.World == null || string.IsNullOrEmpty(manifest.World.AssetId))
return Fail("Cette scène n'a pas de décor.");
switch (manifest.World.ParsedKind)
{
case World3DKind.Mesh:
break;
case World3DKind.Splat:
return Fail("Ce décor est un nuage de splats gaussiens, que cette " +
"version de l'application ne sait pas afficher.");
case World3DKind.Panorama:
return Fail("Ce décor est un panorama 360°, que cette version de " +
"l'application ne sait pas afficher.");
default:
return Fail($"Type de décor inconnu : « {manifest.World.Kind} ».");
}
if (manifest.FindAsset(manifest.World.AssetId) == null)
return Fail("Le fichier du décor est introuvable dans cette scène.");
if (manifest.Navigation.RadiusMeters > NavigationManifest.MaxRadiusMeters)
Debug.LogWarning(
$"[Manifest] Rayon de navigation {manifest.Navigation.RadiusMeters} m " +
$"au-delà du plafond de {NavigationManifest.MaxRadiusMeters} m : ramené au " +
"plafond. Le serveur aurait dû le refuser.");
return new Result { Manifest = manifest };
}
/// <summary>
/// Sur Android, un fichier de StreamingAssets est <b>dans l'APK</b> : seule
/// UnityWebRequest sait le lire. Même raison que
/// <see cref="Scene.GltfLoader.StreamingAssetsUrl"/>.
/// </summary>
public static async Task<Result> LoadAsync(string url)
{
using var request = UnityWebRequest.Get(url);
var operation = request.SendWebRequest();
while (!operation.isDone)
await Task.Yield();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"[Manifest] {url} : {request.error}");
return Fail("Le manifeste de scène est introuvable.");
}
return Parse(request.downloadHandler.text);
}
static Result Fail(string message)
{
Debug.LogError($"[Manifest] {message}");
return new Result { Error = message };
}
}
}