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

229 lines
8.7 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MyInfoMate.Vr.Manifest;
using MyInfoMate.Vr.Net;
using MyInfoMate.Vr.Scene;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Une maquette 3D et ses points d'intérêt — item <b>E7</b> du lot XR-4.
///
/// <b>Ce fichier ne dessine rien.</b> Le moteur existe déjà : <see cref="SceneBuilder"/>
/// charge un GLB, place les hotspots, gère leur visée et leur audio spatialisé —
/// c'est tout le travail de la piste Scène 3D. Ce qui manquait, c'est la
/// <b>source de données</b> : le manifeste venait d'un fichier poussé à la main,
/// il vient maintenant de la section.
///
/// La traduction tient en trois lignes parce que les deux modèles ont été pensés
/// l'un pour l'autre : un <c>SceneHotspot</c> porte déjà un <c>GeoPointId</c>, et
/// <c>GeoPoint.LocalTransform</c> est dans la convention du manifeste — pas celle
/// d'Unity. La conversion d'axes reste donc au seul endroit qui la connaît.
/// </summary>
public class Scene3DView : MonoBehaviour
{
const string ModelAssetId = "model";
SceneBuilder _builder;
MenuItemPanel _back;
Transform _head;
public event Action Closed;
public event Action Interacted;
public static Scene3DView Create(Transform head)
{
var root = new GameObject("Scene3DView");
var view = root.AddComponent<Scene3DView>();
view._head = head;
if (head != null)
{
var forward = head.forward;
forward.y = 0f;
if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward;
root.transform.position = new Vector3(head.position.x, 0f, head.position.z);
root.transform.rotation = Quaternion.LookRotation(forward.normalized);
}
var selector = root.AddComponent<AimSelector>();
selector.Configure(head);
selector.Selected += view.OnSelected;
view._back = MenuItemPanel.Create("__back", "Retour au menu", root.transform);
view._back.transform.localPosition = new Vector3(0f, 0.9f, 2.2f);
view._back.transform.localScale = Vector3.one * 0.6f;
return view;
}
public async Task<bool> LoadAsync(ConfigurationExport.SectionSummary section,
ConfigurationExport export,
string language)
{
var manifest = BuildManifest(section, export, language);
if (manifest == null) return false;
_builder = gameObject.AddComponent<SceneBuilder>();
// ⚠️ Le rig n'est passé qu'en mode **décor**. En mode objet, on regarde la
// chose de l'extérieur : lui donner une zone de navigation la ferait parcourir
// comme un lieu, et le visiteur se retrouverait *dans* l'épée.
var rig = section.Scene3DMode == ConfigurationExport.Scene3DMode.Scene
? (_head != null ? _head.root : null)
: null;
return await _builder.BuildAsync(manifest, _head, rig, language);
}
/// <summary>
/// Où poser le modèle. Un objet se présente <b>devant</b> le visiteur, à portée de
/// regard ; un décor l'entoure, donc il reste à l'origine et c'est le visiteur qui
/// est dedans.
/// </summary>
static TransformManifest WorldTransformFor(ConfigurationExport.Scene3DMode mode)
{
if (mode == ConfigurationExport.Scene3DMode.Scene) return new TransformManifest();
// 1,20 m de haut, 1,50 m devant : la hauteur d'un objet posé sur un socle,
// à la distance où l'on tend la main sans avancer.
return new TransformManifest
{
Position = new[] { 0f, 1.2f, 1.5f },
Rotation = new[] { 0f, 0f, 0f, 1f },
Scale = new[] { 1f, 1f, 1f }
};
}
/// <summary>
/// Le manifeste que le moteur attend, construit depuis la section. Le modèle
/// devient le <b>décor</b> : c'est bien ce qu'il est ici, la scène entière.
/// </summary>
SceneManifest BuildManifest(ConfigurationExport.SectionSummary section,
ConfigurationExport export,
string language)
{
var model = export?.FindResource(section.Model3DResourceId);
var url = model?.Url ?? section.Model3DSource;
if (string.IsNullOrEmpty(url))
{
Debug.LogError($"[Model3D] {section.Id} : aucun modèle à afficher.");
return null;
}
var manifest = new SceneManifest
{
ManifestVersion = SceneManifest.SupportedManifestVersion,
SceneId = section.Id,
CoordinateSystem = SceneManifest.ExpectedCoordinateSystem,
DefaultLanguage = language,
World = new WorldManifest
{
Kind = "mesh",
AssetId = ModelAssetId,
Transform = WorldTransformFor(section.Scene3DMode)
},
Assets = new List<SceneAsset>
{
new SceneAsset
{
Id = ModelAssetId,
ResourceId = section.Model3DResourceId,
Url = url,
MimeType = "model/gltf-binary"
}
},
Hotspots = new List<SceneHotspot>()
};
foreach (var point in section.Points)
{
if (point.LocalTransform == null) continue;
manifest.Hotspots.Add(new SceneHotspot
{
Id = $"poi-{point.Id}",
GeoPointId = point.Id ?? 0,
Transform = TransformOf(point.LocalTransform),
Title = Localized(point.Title),
Description = Localized(point.Description),
Contents = ContentsOf(point, manifest)
});
}
return manifest;
}
static TransformManifest TransformOf(ConfigurationExport.Position3D position)
{
var yaw = (position.RotationY ?? 0f) * Mathf.Deg2Rad / 2f;
return new TransformManifest
{
Position = new[] { position.X, position.Y, position.Z },
Rotation = new[] { 0f, Mathf.Sin(yaw), 0f, Mathf.Cos(yaw) },
Scale = new[] { 1f, 1f, 1f }
};
}
static List<LocalizedText> Localized(List<ConfigurationExport.Translation> texts)
{
var result = new List<LocalizedText>();
foreach (var text in texts ?? new List<ConfigurationExport.Translation>())
result.Add(new LocalizedText { Language = text.Language, Value = text.Value });
return result;
}
/// <summary>
/// Les contenus d'un point portent leurs médias — c'est de là que vient le
/// commentaire audio. Chaque ressource entre aussi dans <c>Assets</c> : le
/// hotspot ne connaît qu'un identifiant, le manifeste seul porte les URL.
/// </summary>
static List<HotspotContent> ContentsOf(ConfigurationExport.GeoPoint point,
SceneManifest manifest)
{
var contents = new List<HotspotContent>();
foreach (var content in point.Contents ?? new List<ConfigurationExport.Content>())
{
var resource = content.Resource;
string assetId = null;
if (!string.IsNullOrEmpty(resource?.Url))
{
assetId = $"asset-{resource.Id}";
manifest.Assets.Add(new SceneAsset
{
Id = assetId,
ResourceId = resource.Id,
Url = resource.Url
});
}
contents.Add(new HotspotContent
{
Order = content.Order ?? 0,
Title = Localized(content.Title),
Description = Localized(content.Description),
AssetId = assetId
});
}
return contents;
}
void OnSelected(IAimTarget target)
{
if (!(target is MenuItemPanel panel) || panel.Id != "__back") return;
Interacted?.Invoke();
Closed?.Invoke();
}
}
}