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 { /// /// Une maquette 3D et ses points d'intérêt — item E7 du lot XR-4. /// /// Ce fichier ne dessine rien. Le moteur existe déjà : /// 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 /// source de données : 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 SceneHotspot porte déjà un GeoPointId, et /// GeoPoint.LocalTransform est dans la convention du manifeste — pas celle /// d'Unity. La conversion d'axes reste donc au seul endroit qui la connaît. /// 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(); 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(); 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 LoadAsync(ConfigurationExport.SectionSummary section, ConfigurationExport export, string language) { var manifest = BuildManifest(section, export, language); if (manifest == null) return false; _builder = gameObject.AddComponent(); // ⚠️ 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); } /// /// Où poser le modèle. Un objet se présente devant le visiteur, à portée de /// regard ; un décor l'entoure, donc il reste à l'origine et c'est le visiteur qui /// est dedans. /// 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 } }; } /// /// Le manifeste que le moteur attend, construit depuis la section. Le modèle /// devient le décor : c'est bien ce qu'il est ici, la scène entière. /// 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 { new SceneAsset { Id = ModelAssetId, ResourceId = section.Model3DResourceId, Url = url, MimeType = "model/gltf-binary" } }, Hotspots = new List() }; 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 Localized(List texts) { var result = new List(); foreach (var text in texts ?? new List()) result.Add(new LocalizedText { Language = text.Language, Value = text.Value }); return result; } /// /// Les contenus d'un point portent leurs médias — c'est de là que vient le /// commentaire audio. Chaque ressource entre aussi dans Assets : le /// hotspot ne connaît qu'un identifiant, le manifeste seul porte les URL. /// static List ContentsOf(ConfigurationExport.GeoPoint point, SceneManifest manifest) { var contents = new List(); foreach (var content in point.Contents ?? new List()) { 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(); } } }