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

152 lines
5.2 KiB
C#

using TMPro;
using UnityEngine;
namespace MyInfoMate.Vr.Ui
{
/// <summary>
/// Tout le texte affiché dans le casque passe par ici.
///
/// <b>Pourquoi TextMeshPro.</b> <c>TextMesh</c> rend une police bitmap : à 2,20 m
/// dans un casque, chaque titre bave dès qu'on s'en approche, et c'est le premier
/// truc qui fait « prototype ». TMP rend par champ de distance signée — net à
/// n'importe quelle distance, pour le même coût de rendu.
///
/// ⚠️ <b>TMP a besoin de ses ressources.</b> <i>Window → TextMeshPro → Import TMP
/// Essential Resources</i>, une fois pour le projet. Sans elles, <c>TMP_Settings</c>
/// n'a pas de police par défaut et un <c>TextMeshPro</c> n'affiche <b>rien</b> —
/// silencieusement, dans un build. D'où le repli sur <c>TextMesh</c> plus bas : un
/// texte laid reste lisible, un texte absent transforme une borne en casque cassé.
/// </summary>
public class Label : MonoBehaviour
{
public enum Anchor { TopCenter, MiddleCenter }
/// <summary>
/// TMP exprime sa taille en points, à raison de 10 points par unité de monde pour
/// un objet à l'échelle 1. <c>TextMesh</c>, lui, multiplie <c>fontSize</c> par
/// <c>characterSize</c> puis par 0,1. Les deux constantes ci-dessous n'existent
/// que pour que les appelants parlent en <b>mètres</b>, une seule fois.
/// </summary>
const float TmpPointsPerMeter = 10f;
const int LegacyFontSize = 96;
TMP_Text _tmp;
TextMesh _legacy;
public static Label Create(Transform parent, string name, Anchor anchor,
float lineHeightMeters, Color color)
{
var root = new GameObject(name);
root.transform.SetParent(parent, false);
var label = root.AddComponent<Label>();
label.Build(anchor, lineHeightMeters, color);
return label;
}
void Build(Anchor anchor, float lineHeightMeters, Color color)
{
if (TextMeshProAvailable)
{
var tmp = gameObject.AddComponent<TextMeshPro>();
tmp.fontSize = lineHeightMeters * TmpPointsPerMeter;
tmp.color = color;
tmp.alignment = anchor == Anchor.TopCenter
? TextAlignmentOptions.Top
: TextAlignmentOptions.Center;
// Le retour à la ligne est fait par Wrap, en amont : laisser TMP couper
// en plus donnerait des lignes coupées deux fois.
tmp.textWrappingMode = TextWrappingModes.NoWrap;
// Sans rect assez large, TMP tronque au lieu d'afficher.
tmp.rectTransform.sizeDelta = new Vector2(4f, 2f);
_tmp = tmp;
return;
}
var text = gameObject.AddComponent<TextMesh>();
text.fontSize = LegacyFontSize;
text.characterSize = lineHeightMeters / (LegacyFontSize * 0.1f);
text.color = color;
text.anchor = anchor == Anchor.TopCenter
? TextAnchor.UpperCenter
: TextAnchor.MiddleCenter;
text.alignment = TextAlignment.Center;
_legacy = text;
}
static bool? _available;
static bool TextMeshProAvailable
{
get
{
if (_available.HasValue) return _available.Value;
_available = TMP_Settings.instance != null
&& TMP_Settings.defaultFontAsset != null;
if (!_available.Value)
Debug.LogError(
"[Ui] Ressources TextMeshPro absentes — repli sur TextMesh, le texte " +
"sera flou. Window → TextMeshPro → Import TMP Essential Resources.");
return _available.Value;
}
}
public string Text
{
set
{
if (_tmp != null) _tmp.text = value;
else if (_legacy != null) _legacy.text = value;
}
}
public Color Color
{
set
{
if (_tmp != null) _tmp.color = value;
else if (_legacy != null) _legacy.color = value;
}
}
/// <summary>
/// Coupe à la largeur donnée, en mots entiers. Était recopié dans trois écrans ;
/// un seul texte mal coupé suffisait à ce qu'ils divergent.
/// </summary>
public static string Wrap(string text, int columns)
{
if (string.IsNullOrEmpty(text)) return "";
var wrapped = new System.Text.StringBuilder();
var line = 0;
foreach (var word in text.Split(' '))
{
if (line + word.Length > columns)
{
wrapped.Append('\n');
line = 0;
}
else if (line > 0)
{
wrapped.Append(' ');
line++;
}
wrapped.Append(word);
line += word.Length;
}
return wrapped.ToString();
}
}
}