Thomas Fransolet c70c572aed Menu du casque : la grille bento, et le choix de la langue
Les tuiles suivent maintenant les mêmes colSpan × rowSpan que les autres
canaux, sur six colonnes — environ 83° d'ouverture, dans le confort du
regard — avec un écart serré assumé : espacées, les tuiles se lisent comme
des panneaux sans rapport plutôt que comme une planche.

Le pas angulaire se déduit de la largeur de cellule et du rayon de l'arc,
plutôt que d'être posé en dur.

S'y ajoute le sélecteur de langue avec ses drapeaux, et les libellés de
l'interface sortis du code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:37:06 +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, string language)
{
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.CreateAction(
"__back", Ui.Strings.Get(Ui.Strings.BackToMenu, language), root.transform);
view._back.transform.localPosition = new Vector3(0f, 0.85f, 2.2f);
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();
}
}
}