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>
141 lines
5.7 KiB
C#
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 };
|
|
}
|
|
}
|
|
}
|