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>
229 lines
8.7 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|