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

251 lines
7.9 KiB
C#

using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEngine;
namespace MyInfoMate.Vr.Manifest
{
/// <summary>
/// Miroir exact du schéma §2.2 de la conception. <b>Ce fichier et
/// <c>viewer/src/manifest.ts</c> décrivent la même chose</b> : modifier l'un
/// sans l'autre casse le test croisé de S3.
///
/// Les coordonnées sont ici <b>telles qu'écrites dans le manifeste</b>, donc en
/// convention glTF. Elles ne deviennent des coordonnées Unity qu'en passant par
/// <see cref="Scene.GltfSpace"/> — nulle part ailleurs.
/// </summary>
public class SceneManifest
{
public const int SupportedManifestVersion = 1;
public const string ExpectedCoordinateSystem = "gltf/y-up/right-handed/meters";
/// <summary>Version du <b>format</b>. Distincte de <see cref="Version"/>.</summary>
public int ManifestVersion = 1;
public string SceneId;
public string InstanceId;
public string ConfigurationId;
/// <summary>Version du <b>contenu</b> : +1 à chaque publication. C'est elle que le
/// cache compare pour savoir qu'il y a du nouveau.</summary>
public int Version;
public string PublishedAt;
public List<string> Languages = new List<string>();
public string DefaultLanguage;
public string CoordinateSystem;
public NavigationManifest Navigation = new NavigationManifest();
public EnvironmentManifest Environment = new EnvironmentManifest();
public WorldManifest World;
public List<SceneObject> Objects = new List<SceneObject>();
public List<ScenePersona> Personas = new List<ScenePersona>();
public List<SceneHotspot> Hotspots = new List<SceneHotspot>();
public List<SceneAsset> Assets = new List<SceneAsset>();
public BudgetManifest Budget;
public ProvenanceManifest Provenance;
[JsonIgnore] Dictionary<string, SceneAsset> _assetsById;
/// <summary>
/// <c>assets[]</c> est la seule liste que le téléchargeur parcourt (§2.3) : tout
/// le reste ne porte que des identifiants. D'où cet index.
/// </summary>
public SceneAsset FindAsset(string assetId)
{
if (string.IsNullOrEmpty(assetId)) return null;
if (_assetsById == null)
{
_assetsById = new Dictionary<string, SceneAsset>();
foreach (var asset in Assets)
if (asset != null && !string.IsNullOrEmpty(asset.Id))
_assetsById[asset.Id] = asset;
}
return _assetsById.TryGetValue(assetId, out var found) ? found : null;
}
}
/// <summary>
/// Position + rotation + échelle en convention glTF. Les accesseurs Unity passent
/// tous par <see cref="Scene.GltfSpace"/> : c'est ce qui garantit qu'il n'existe
/// qu'une conversion dans le projet.
/// </summary>
public class TransformManifest
{
public float[] Position = { 0f, 0f, 0f };
public float[] Rotation = { 0f, 0f, 0f, 1f };
public float[] Scale = { 1f, 1f, 1f };
public Vector3 UnityPosition => Scene.GltfSpace.Position(Position ?? new[] { 0f, 0f, 0f });
public Quaternion UnityRotation =>
Scene.GltfSpace.Rotation(Rotation ?? new[] { 0f, 0f, 0f, 1f });
public Vector3 UnityScale => Scene.GltfSpace.Scale(Scale ?? new[] { 1f, 1f, 1f });
public void ApplyTo(Transform target)
{
target.localPosition = UnityPosition;
target.localRotation = UnityRotation;
target.localScale = UnityScale;
}
}
public class NavigationManifest
{
/// <summary>Le plafond dur du §2.2, appliqué serveur <b>et</b> client.</summary>
public const float MaxRadiusMeters = 3f;
public TransformManifest Spawn = new TransformManifest();
public float RadiusMeters = MaxRadiusMeters;
public bool ShowBoundary = true;
public float ClampedRadiusMeters => Mathf.Clamp(RadiusMeters, 0.5f, MaxRadiusMeters);
}
public class EnvironmentManifest
{
public string LightingPreset = "neutral-indoor";
public string HdriAssetId;
public float Exposure = 1f;
}
/// <summary>
/// <c>kind</c> est la couture V2 : la V1 ne lit que <see cref="World3DKind.Mesh"/> et
/// refuse le reste <b>explicitement</b>. Un refus lisible vaut mieux qu'une scène
/// vide (§2.3).
/// </summary>
public class WorldManifest
{
public string Kind = "mesh";
public string AssetId;
public TransformManifest Transform = new TransformManifest();
public World3DKind ParsedKind =>
Kind switch
{
"mesh" => World3DKind.Mesh,
"splat" => World3DKind.Splat,
"panorama" => World3DKind.Panorama,
_ => World3DKind.Unknown
};
}
public enum World3DKind
{
Mesh,
Splat,
Panorama,
Unknown
}
public class SceneObject
{
public string Id;
public string Label;
public string AssetId;
public TransformManifest Transform = new TransformManifest();
}
public class ScenePersona
{
public string Id;
/// <summary>Null en V1 : la couture vers l'entité <c>Persona</c> du lot 7 Studio.</summary>
public string PersonaId;
public string Name;
public string AssetId;
public TransformManifest Transform = new TransformManifest();
public AnimationManifest Animation = new AnimationManifest();
public bool GazeAtVisitor = true;
public List<LocalizedAsset> Audio = new List<LocalizedAsset>();
public List<LocalizedText> Script = new List<LocalizedText>();
}
public class AnimationManifest
{
public string IdleClip = "Idle";
public bool Loop = true;
}
public class SceneHotspot
{
public string Id;
/// <summary>Un hotspot <b>est</b> un <c>GeoPoint</c> côté serveur (§3.2).</summary>
public int GeoPointId;
public TransformManifest Transform = new TransformManifest();
public List<LocalizedText> Title = new List<LocalizedText>();
public List<LocalizedText> Description = new List<LocalizedText>();
public List<HotspotContent> Contents = new List<HotspotContent>();
}
public class HotspotContent
{
public int Order;
public List<LocalizedText> Title = new List<LocalizedText>();
public List<LocalizedText> Description = new List<LocalizedText>();
public string AssetId;
}
/// <summary>
/// Toutes les langues arrivent d'un coup : un casque en borne change de langue à
/// chaud, l'export mono-langue existant ne suffit pas (§2.3).
/// </summary>
public class LocalizedText
{
public string Language;
public string Value;
}
public class LocalizedAsset
{
public string Language;
public string AssetId;
}
public class SceneAsset
{
public string Id;
public string ResourceId;
public string Url;
public string MimeType;
public long SizeBytes;
/// <summary>Le seul moyen d'un delta réel côté casque (§2.4).</summary>
public string Sha256;
/// <summary>KTX2 / Meshopt viendront ici, sans casser le contrat.</summary>
public List<AssetVariant> Variants = new List<AssetVariant>();
}
public class AssetVariant
{
public string Kind;
public string Url;
public string MimeType;
public long SizeBytes;
public string Sha256;
}
public class BudgetManifest
{
public long TotalBytes;
public long LimitBytes;
}
public class ProvenanceManifest
{
public bool AiGenerated;
public string RightsHolder;
public List<string> Sources = new List<string>();
}
}