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>
This commit is contained in:
Thomas Fransolet 2026-09-16 15:26:07 +02:00
commit 0310d28b5e
494 changed files with 101967 additions and 0 deletions

10
.gitattributes vendored Normal file
View File

@ -0,0 +1,10 @@
*.so filter=lfs diff=lfs merge=lfs -text
*.fbx filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
unity/MyInfoMateVR/Assets/StreamingAssets/**/*.png filter=lfs diff=lfs merge=lfs -text
unity/MyInfoMateVR/Assets/StreamingAssets/**/*.jpg filter=lfs diff=lfs merge=lfs -text
*.glb filter=lfs diff=lfs merge=lfs -text
*.bin filter=lfs diff=lfs merge=lfs -text
*.dll filter=lfs diff=lfs merge=lfs -text
*.hdr filter=lfs diff=lfs merge=lfs -text
*.exr filter=lfs diff=lfs merge=lfs -text

58
.gitignore vendored Normal file
View File

@ -0,0 +1,58 @@
# Unity — dossiers générés, jamais versionnés
unity/*/[Ll]ibrary/
unity/*/[Tt]emp/
unity/*/[Oo]bj/
unity/*/[Bb]uild/
unity/*/[Bb]uilds/
unity/*/[Ll]ogs/
unity/*/[Uu]serSettings/
unity/*/[Mm]emoryCaptures/
unity/*/[Rr]ecordings/
# Projets et solutions régénérés par l'éditeur
*.csproj
*.unityproj
*.sln
*.suo
*.user
*.userprefs
*.pidb
*.booproj
*.svd
*.pdb
*.mdb
*.opendb
*.VC.db
# Artefacts de build Android
*.apk
*.aab
*.unitypackage
*.app
# Crashlytics / logs Unity
sysinfo.txt
crashlytics-build.properties
# OS
.DS_Store
Thumbs.db
# Assets binaires lourds : passer par Git LFS AVANT le premier commit d'un binaire.
# Décommenter et faire `git lfs install && git lfs track "*.glb"` plutôt que d'ignorer.
# *.glb
# *.fbx
# *.mp4
# Viewer web — dependances et sorties de build
viewer/node_modules/
viewer/dist/
# Decodeurs Draco/KTX2 : copies de node_modules par `npm run libs`
viewer/public/libs/
# Echantillon de scene servi par URL : copies de StreamingAssets, binaires lourds
viewer/public/sample/
# Artefacts de build Unity/CMake regeneres a chaque build
unity/*/.utmp/
unity/*_BurstDebugInformation_DoNotShip/
unity/*/*_BurstDebugInformation_DoNotShip/

80
README.md Normal file
View File

@ -0,0 +1,80 @@
# vr-app — canal VR MyInfoMate (Meta Quest)
Repo du **cinquième front** de l'écosystème : l'application Unity qui tourne sur casque Meta Quest,
et toute la documentation du module VR (back-office compris).
Nommage aligné sur les autres repos : `manager-app`, `tablet-app`, `visitapp-web`, `vr-app`.
## Contenu
| Chemin | Rôle |
|---|---|
| `docs/00-phase1-etat-des-lieux.md` | **À lire en premier.** Relevé du code existant des 4 repos, ce qui est réutilisable, ce qui manque, points de friction |
| `docs/01-phase0-setup-unity.md` | Guide d'installation Unity + Meta XR, pas à pas, pour quelqu'un qui n'a jamais ouvert Unity |
| `docs/02-decisions.md` | Les 6 arbitrages d'architecture du module (modèle de contenu, placement, viewer, kiosk, périmètre V1, personnages) |
| `docs/03-phase2-conception.md` | Manifeste de scène, modèle de données, contrat d'API, flux de génération, structure Unity et viewer web |
| `docs/04-phase3-plan.md` | **Le plan d'exécution.** 9 étapes, chacune avec ce que je génère / ce que tu fais / le critère de validation |
| `unity-overlay/` | **Les fichiers Unity que je génère**, à recopier sur le projet une fois créé par Hub. Voir son README |
| `tools/` | Générateur du repère de calibration (`make_calibration_glb.py`) |
| `unity/` | Le projet Unity lui-même — l'APK unique, tous clients |
| `viewer/` | **Le viewer et l'éditeur de scène web** (étape S3), autonome. Voir son README |
## Où vit le reste
- Décisions déjà arrêtées sur le canal VR (pricing, moteur, types de section, POI sur GLB) :
`../DOCS/v2/vr-quest-unity-plan.md`
- Infrastructure dont ce module dépend (crédits, jobs, fournisseurs IA, personnages) :
`../DOCS/v2/studio-plan.md`
- Kanban : `../DOCS/kanban/cards/5-planifie/320-*` et `330-*`
## Statut
**S0 terminé. S1 quasiment bouclé — 11/09/2026.** Le projet Unity existe (`unity/MyInfoMateVR/`,
Unity 6000.0.83f1, URP, Meta XR SDK 205), l'APK se construit, s'installe et tourne sur un Quest 2
en Horizon OS v207.
| Critère S1 | État |
|---|---|
| 1. Le monde s'affiche, on tourne la tête dedans | ✅ |
| 2. Le marqueur par coordonnées coïncide avec le repère du GLB | ✅ **aucun miroir — `GltfSpace.Axis = NegateX` confirmé** |
| 3. Personnage debout, à la bonne taille, animé, qui regarde le visiteur | ✅ |
| 4. Cercle de navigation infranchissable | ❌ **on franchit toujours le cercle** — mesure tête/rig corrigée, contrainte toujours sans effet. Non bloquant pour S2, à reprendre |
### Les nombres que S1 devait produire
Mesurés sur **Sponza** (50 Mo, glTF multi-fichiers), pas sur un monde Marble :
| Mesure | Valeur |
|---|---|
| Chargement du décor | **1,4 à 1,8 s** — l'écran de progression est un détail, pas un sujet |
| Chargement du repère / du personnage | 33-248 ms / 54-96 ms |
| Images par seconde | **72/72 tenues**, mais `App=12,4 ms` sur 13,9 disponibles et **GPU au niveau maximum** |
| Poids réel d'un monde Marble | ❌ **toujours inconnu** — crédits Marble insuffisants |
➡️ Lecture : un décor de 50 Mo bien texturé **sature déjà un Quest 2**. Le budget de scène devra
être défini, et la question KTX2 côté serveur reste ouverte tant que le poids d'un vrai monde
Marble n'est pas connu.
Les assets de test sont des GLB libres (Khronos glTF Sample Assets : Sponza, CesiumMan) déposés
dans `StreamingAssets/`**non commités**, jetables, et sans valeur pour la mesure de poids.
Les pièges rencontrés et leurs corrections sont consignés dans
[unity-overlay/README.md](unity-overlay/README.md).
### S2 — validé le 11/09/2026
| Livrable S2 | État |
|---|---|
| La scène est construite depuis `scene.json`, plus depuis du code | ✅ |
| Modifier le JSON et relancer **sans recompiler** déplace l'objet | ✅ manifeste poussé par `adb push`, lu en priorité sur celui de l'APK |
| Un `world.kind` non supporté affiche une phrase lisible, pas une scène vide | ✅ testé avec `"kind": "panorama"` |
| Un hotspot apparaît et joue son audio au raycast | ⏳ le `scene.json` d'exemple n'a pas d'audio |
Le manifeste se pousse ainsi, l'app le préfère à celui embarqué :
```
adb push scene.json /sdcard/Android/data/com.unov.myinfomatevr/files/scene.json
```
Prochaine étape : **S3** de [04-phase3-plan.md](docs/04-phase3-plan.md) — le viewer web et son
éditeur. C'est le jalon « démo vendable », et le plan y place un go/no-go explicite.

View File

@ -0,0 +1,448 @@
# Phase 1 — État des lieux du code existant
> Relevé le 2026-09-03 **dans le code**, pas dans la doc. Chaque affirmation « existe / n'existe pas »
> a été vérifiée fichier par fichier sur `manager-service`, `manager-app`, `visitapp-web`,
> `mymuseum-visitapp`.
>
> Ce document ne refait pas l'analyse de `../../DOCS/v2/vr-quest-unity-plan.md` (canal VR : device,
> onglet flotte, pricing, arbitrage moteur) — il la **complète** sur ce que le nouveau périmètre
> ajoute : génération de mondes, manifeste de scène, personnages 3D, pipeline d'assets, preview web.
---
## 0. La conclusion, d'abord
**Trois quarts de ce que le module VR demande ne sont pas codés — mais presque tout est déjà
*spécifié*, dans le plan V2 (Studio, Personnages, TTS, 3D). Le module VR n'est pas un chantier
isolé : c'est le premier gros consommateur de ce plan, et il en hérite gratuitement s'il se branche
dessus au lieu de se construire à côté.** La table de dépendances est en §9 / F1.
| Brique demandée | Existe en code | Existe en spec | Où |
|---|---|---|---|
| `AppType.VR`, `Instance.IsVR`, sous-menu, stats par canal | ✅ | — | §1 |
| Un appareil non-tablette (`Device.AppType`) | ❌ | ✅ | `vr-quest-unity-plan.md` lot V-1 |
| Contrat de contenu pour un client non-Flutter | ✅ (`/export`) | ✅ | §5 |
| Jobs asynchrones (Hangfire) | ✅ | — | §6 |
| Crédits, plafonds, coût estimé, journal | ❌ | ✅ | `studio-plan.md` §3.5 |
| Couche fournisseurs IA (→ World Labs Marble) | ❌ | ✅ | `studio-plan.md` §3.6 |
| Traçabilité IA, mention, droits au client | ❌ | ✅ | `studio-plan.md` §3.11 |
| Entité `Persona` | ❌ | ✅ | `studio-plan.md` §3.8 |
| Upload serveur dans le bucket | ❌ | ✅ | `studio-plan.md` lot 0 |
| `ResourceType.Model3D` / `Panorama360` | ❌ | ✅ | §2 |
| Manifeste de scène versionné | ❌ | ❌ | **à concevoir — phase 2** |
| Viewer 3D web (THREE.js) | ❌ | ❌ | **greenfield**, §7 |
| Zone de navigation, hotspots 3D, menu VR | ❌ | ❌ | **greenfield** |
➡️ **Et l'inverse est vrai aussi** : le module VR ne rallonge pas le backlog V2, il en **réordonne**
le chemin critique. Trois chantiers qui pouvaient s'attendre (socle de stockage, personnages, TTS)
deviennent bloquants d'un quatrième.
---
## 1. Types d'application (`appType`)
### Ce qui existe
```csharp
// manager-service/ManagerService/Data/ApplicationInstance.cs (fin de fichier)
public enum AppType { Mobile, Tablet, Web, VR, Voice }
```
`VR` **vaut 3 et est persisté en base**. Autour :
| Brique | Emplacement vérifié |
|---|---|
| `Instance.IsVR` | `Data/Instance.cs:35` |
| `ApplicationInstance` unique par `(InstanceId, AppType)` | `Data/ApplicationInstance.cs` — index unique composite |
| Sous-menu « VR » dans le back-office | `manager-app/lib/Screens/Main/main_screen.dart:65` |
| L'écran derrière le sous-menu | `main_screen.dart:704`**`Text("TODO vr")`** |
| Télémétrie par canal | `Data/VisitEvent.cs` porte `AppType` — un event VR se stocke sans migration |
| Assistant IA par canal | `POST /api/Ai/chat` prend un `AppType` et vérifie `IsAssistant` sur l'`ApplicationInstance` correspondante |
### Ce qui manque
1. **`Device.AppType`.** `Data/Device.cs` n'a pas de type de canal, et
`Controllers/DeviceController.cs:155` résout l'`ApplicationInstance` **en dur** sur
`ai.AppType == AppType.Tablet`. Un casque enregistré aujourd'hui atterrit dans l'onglet Kiosk.
2. **`ApiKeyAppType`** ne connaît que `{ VisitApp, TabletApp, Other }`
(`Data/ApiKeyAppType.cs`, 3 lignes). Il faut `VrApp` **en fin d'enum** — persisté en int.
### Ce que ça implique
Le canal existe déjà comme *dimension*. Ce qui n'existe pas, c'est **le contenu VR** : aujourd'hui
une `ApplicationInstance` VR pointerait vers des `Configuration` composées des 13 types de section
2D. Le nouveau périmètre (mondes, personnages placés, hotspots 3D) est un **type de contenu neuf**.
👉 Question d'architecture n°1, traitée en §9 / F2.
---
## 2. Médiathèque
### Modèle
```csharp
// Data/Resource.cs
Resource { Id, Type(ResourceType, int), Label, InstanceId, Url, StoragePath, FileName,
SizeBytes, Width?, Height?, DateCreation, DateUpdate,
IncludeInAiKnowledge, AiIndexStatus, AiIndexMessage, AiIndexedAt, AiChunkCount }
enum ResourceType { Image, Video, ImageUrl, VideoUrl, Audio, PDF, JSON, JSONUrl,
Word, PowerPoint, Text } // 0..10 — commentaire explicite : jamais au milieu
```
### Les cinq faits qui décident du pipeline d'assets
1. **Aucune gestion de GLB, aucune gestion de 360.** Ni `Model3D`, ni `Panorama360`. La carte
kanban « Ressource 360° » (`5-planifie/250-*`) est encore en planifié ; `Model3D` est prévu au
lot 11 du Studio.
2. **L'upload est fait par le navigateur, en direct vers Firebase.** Le backend ne sait que
*supprimer* : `IResourceBlobService` n'expose que `IsConfigured` et `DeleteAsync`
(`Services/ResourceBlobService.cs:32-36`). ⚠️ **Conséquence directe** : un monde généré par
Marble, qui arrive par un webhook côté serveur, **n'a aujourd'hui aucun chemin pour entrer dans
le bucket**. C'est le lot 0 du Studio, prérequis dur.
3. **Un `.glb` déposé aujourd'hui produit une ressource cassée.** Le `switch` d'upload
(`manager-app/lib/Screens/Resources/resources_screen.dart:400-431`) n'a **pas de `default`** :
l'extension inconnue laisse `resourceDTO.type` à `null` et le mimeType à chaîne vide.
4. **Pas de versionnement, pas de hash, pas de variantes, pas de CDN, pas de miniatures.** Une
ressource est un fichier et une URL à jeton. Le manifeste VR aura besoin d'un **hash et d'un
poids par asset** — `SizeBytes` existe, le hash non.
5. **Le quota stockage est un `SUM(SizeBytes)` par instance**, contrôlé à l'upload avec un `413`.
Aucune notion d'enveloppe par add-on. Un monde Marble + textures pèse des centaines de Mo ;
une vidéo 360 pèse des Go.
### État du chantier médiathèque
La refonte V1 (facettes, index inverse « utilisée dans », panneau de détail) est **codée**, en
attente de test : `DOCS/kanban/cards/4-a-tester/100-mediatheque-codee-de-bout-en-bout*`. Le service
`ResourceUsageService.cs` existe côté serveur. Bon signe : le module VR arrive sur une médiathèque
fraîchement remise à plat.
---
## 3. Personas
### La bonne lecture : c'est une dépendance, pas une contradiction
> « **Pas de nouveau catalogue à produire.** Réutilise les personas déjà définis dans le manager. »
Les personas **sont définis** — dans `../../DOCS/v2/studio-plan.md` §3.8, arrêté le 2026-09-01.
Ils ne sont simplement **pas encore codés** : c'est le lot 7 du Studio, en V2
(kanban `5-planifie/310-personnages-un-guide-adressable-n-narrateurs`). Le module VR ne doit donc
rien réinventer — il doit **attendre ou tirer** ce lot. Voir la table de dépendances en §9 / F1.
Ce qui existe en base aujourd'hui, c'est **un** guide au singulier, en colonnes plates sur
l'`Instance` :
```
Data/Instance.cs
GuideName (l.46)
GuidePersonaPrompt (l.52) le comportement pour le LLM
GuideVoiceId (l.55) un nom de voix Gemini, en texte libre
GuideFallbackMessages (l.77) List<TranslationDTO>
```
Pas de table `Personas`, pas de `PersonaView`, pas de `TtsVoice` — vérifié : aucun résultat pour
`class Persona|GenerationJob|CreditLedger|VisualIdentity` dans les `.cs`, et aucun `DbSet`
correspondant dans `Data/MyInfoMateDbContext.cs`.
Deux codages en dur font foi aujourd'hui :
- les 2 voix : `manager-app/lib/Screens/GuideIa/guide_ia_screen.dart:19-20`
- l'intonation : `mymuseum-visitapp/lib/constants.dart:27` (`kGeminiTtsPrompt`) — **une constante de
build**, donc tous les personnages sonneraient pareil.
### Ce qui est spécifié, et qui est exactement ce qu'il faut
`../../DOCS/v2/studio-plan.md` §3.8 (arrêté le 2026-09-01) définit l'entité `Persona` : un seul
objet, trois facettes (`VoiceId` → il narre, `SystemPrompt` → il peut être guide, `WakewordId` → il
est adressable), plus `PersonaView` (canon en **jeu de vues** : Portrait / Profil / PlanLarge / Dos)
et une table `TtsVoice`. C'est le lot 7 du Studio — **0 % codé**.
### Ce qui manque pour porter un persona en 3D
`Persona` tel que spécifié est un personnage **2D** : un jeu d'images de référence pour un modèle
de génération d'images, plus une voix. **Aucun** champ d'incarnation 3D.
| À ajouter | Pourquoi |
|---|---|
| `Persona.Model3DResourceId?` | le GLB du personnage |
| `Persona.IdleAnimationClip?` | nom du clip dans le GLB, ou clip par défaut |
| `Persona.ScaleMeters?` | un GLB de studio n'arrive jamais à l'échelle |
| Hauteur des yeux / ancrage du regard | pour qu'il regarde le visiteur sans lip sync |
⚠️ **Et une question sans réponse dans le code** : rien ne produit un GLB de personnage. Le lot 11
du Studio prévoit Meshy 6 / Tripo pour des **objets isolés** — pas des humanoïdes rigés avec un
idle. Voir F8.
---
## 4. Modèle multilingue et audio
### Multilingue
Un seul motif, partout : `List<TranslationDTO> { language, value }`, stocké en **jsonb**.
10 langues supportées (`FR NL EN DE IT ES PL CN AR UK`). **`LB` est absent des 4 repos** — bloquant
pour le projet luxembourgeois, prévu au lot 0 du Studio.
Traduction assistée existante : `POST /api/Ai/translate`.
➡️ Le manifeste de scène doit porter ses textes dans ce format exact. Aucune raison d'en inventer un.
### Audio / TTS
**Le TTS n'existe pas côté serveur.** Les seules occurrences de `Tts`/`TTS` dans les `.cs` sont
`Data/Instance.cs` (le nom de voix) et `DTOs/AiChatDTO.cs`. La synthèse est faite **par l'app
cliente**, en Dart (`GeminiTtsEngine`, `gemini-2.5-flash-preview-tts`).
Conséquences :
- « Audio TTS pré-généré et validé » suppose `../../DOCS/v2/tts-pregenerated-plan.md`, qui est
**planifié, pas codé** (kanban `5-planifie/160-*`) et attend lui aussi le lot 0.
- Le pattern cible existe déjà : `SectionArticle.ArticleAudioIds` est un `List<TranslationDTO>`
d'**ids de `Resource`**. Un hotspot VR doit copier ce pattern, pas en inventer un.
- **Chemin de repli sans dette** : si le TTS n'est pas prêt, la V1 VR se contente d'**audio uploadé
à la main** (`.mp3`, `ResourceType.Audio`, supporté de bout en bout aujourd'hui). Le champ ne
change pas quand le TTS arrive — c'est le même id de ressource.
---
## 5. Publication, versionnement, récupération du contenu
### Le contrat existant
```
GET /api/Configuration/{id}/export?language=fr
[Authorize(Policy = AppReadAccess)] ← ConfigurationController.cs:383
→ ExportConfigurationDTO (configuration + sections + ressources, un seul appel)
```
**Bonne nouvelle, et elle ferme une question ouverte du plan du 2026-08-31** : l'endpoint est décoré
`AppReadAccess`, donc **accessible par clé d'API d'app**, pas seulement depuis le back-office
authentifié. Unity peut l'appeler tel quel.
Les ressources sont collectées par `section.GetReferencedResourceIds(language)`, implémenté sur les
13 sous-types. C'est aussi ce qui alimente le mode offline de `mymuseum-visitapp`.
### Les trois trous, et ils comptent tous les trois
1. **Il n'y a pas d'état « publié ».** `Data/Configuration.cs` n'a ni `IsPublished`, ni `Status`, ni
`Version` — 17 colonnes, aucune de cycle de vie. Une modification dans le back-office est
**immédiatement visible** par les clients. Le « appliquer au retour au menu, jamais à chaud » du
cahier des charges **n'a rien sur quoi s'appuyer**. `studio-plan.md` §7 le dit sans détour :
*« `Publié` est dérivé, pas stocké »*.
2. **Il n'y a pas de version incrémentée.** Le seul signal est `DateUpdate` (par entité), et
l'invalidation offline se fait par comparaison de dates. Un `SceneManifest.version` monotone est
**à créer**, il n'existe nulle part.
3. **L'export est mono-langue** (`?language=`). Un casque en borne change de langue à chaud et doit
avoir les 10 langues en mémoire. Soit N appels, soit un mode « toutes langues » à ajouter.
### Push de rafraîchissement
`DeviceController.cs:303` publie déjà sur le topic MQTT `player/{device.Id}` quand la config d'un
appareil change. Le mécanisme « le casque apprend qu'il y a du neuf » **existe côté serveur** ; il
n'a jamais eu d'autre client que `tablet-app`.
---
## 6. Jobs asynchrones
**Hangfire est installé et réellement utilisé** — la meilleure surprise pour le pipeline Marble.
| Fait | Où |
|---|---|
| `Hangfire.AspNetCore` 1.8.17 + `Hangfire.PostgreSql` 1.20.10 | `ManagerService.csproj:26-27` |
| Dashboard avec filtre d'autorisation | `Security/HangfireDashboardAuthorizationFilter.cs` |
| Convention établie : **un job prend un id, jamais un objet** | `Services/IIngestionService.cs` |
| Déclenchement automatique à l'écriture d'une entité | `Data/SectionIndexingInterceptor.cs` (réindexation RAG) |
| Idempotence pensée (rejeu au redémarrage de conteneur) | commentaire `Data/MyInfoMateDbContext.cs:304` |
| Jobs planifiés / annulables | `Controllers/NotificationController.cs:98,157,202``HangfireJobId` stocké, `BackgroundJob.Delete` |
| Retry maîtrisé plutôt que le défaut à 10 | `Services/IngestionService.cs:18` |
**Webhooks entrants** : un seul précédent, `Controllers/StripeWebhookController.cs`, avec
vérification de signature. C'est le patron à copier pour le webhook World Labs.
**Ce qui manque** : aucune table de suivi de job métier (statut, progression, résultat) exposée au
front. `studio-plan.md` §3.7 la spécifie (`GenerationJob` + polling front à 2 s, pas de SSE — et
l'argument tient : aucun précédent SSE dans le backend).
---
## 7. Preview web et rendu 3D côté clients
**Il n'y a pas une ligne de 3D dans l'écosystème.** Vérifié :
- `visitapp-web/package.json` : ni `three`, ni `@react-three/*`, ni `model-viewer`. Les seules
dépendances de rendu sont `leaflet` et `react-pdf`.
- La démo « villa d'Echternach » (`visitapp-web/src/app/demo/villa-echternach/scene.ts`) est de la
**géométrie procédurale rendue en Canvas 2D**, sans aucune dépendance — son propre en-tête le dit :
*« pas à charger un vrai modèle glTF — celui-là viendra d'un studio 3D »*.
- `manager-app` est en **Flutter Web** : aucun viewer 3D natif utilisable. Le viewer THREE.js devra
être une **page web embarquée en iframe** (`HtmlElementView`), pas un widget Flutter. À ne pas
découvrir en cours de route.
➡️ Le viewer de preview est **entièrement greenfield** — et le fait qu'il doive être une app web
autonome nourrie par une URL de manifeste le rend, par construction, réutilisable dans
`visitapp-web`. Voir question Q2.
---
## 8. Quotas, facturation, organisation
| Sujet | Réalité |
|---|---|
| Organisation | `Instance` — c'est le tenant. `SubscriptionPlan` n'est qu'un **gabarit** : l'`Instance` **recopie localement** ses valeurs (`Data/Instance.cs:92-100` : `StorageQuotaBytes`, `AiTokensPerMonth`, `HasStats`, `StatsHistoryDays`, `HasAdvancedStats`) |
| Add-on | Le patron existe : `Instance.IsAssistant` (`Instance.cs:37`). Un add-on = un booléen sur l'instance + un relèvement de quota. **~1 jour** |
| Compteur d'usage IA | `AiTokensThisMonth` + `AiUsageMonthKey`, reset par comparaison de clé, `429` au dépassement, **incrément après l'appel** |
| Journal ligne à ligne | ❌ inexistant. Deux compteurs cumulés, aucune traçabilité par génération |
| Coût estimé avant lancement | ❌ inexistant |
| Plafond dur configurable | ❌ inexistant |
| Stripe | `StripeService`, `StripeWebhookController` — ⚠️ **2 événements seulement** (`checkout.session.completed`, `invoice.payment_failed`). Facturer un add-on automatiquement demande du travail ; ligne manuelle pour les pilotes |
| Audit | `AuditLog { EntityType, EntityId, Action, UserId, InstanceId, Timestamp, OldValues, NewValues }` — utilisable tel quel pour « qui a lancé quelle génération » |
⚠️ **Le compteur actuel ne tient pas pour Marble.** `CheckQuota` avant / incrément après suffit à un
appel LLM bloquant de 2 s. Une génération de monde dure **5 min à 1 h** : dix lancements parallèles
passeraient les dix contrôles avant le premier débit. Il faut la **réservation en deux temps**
(`Reserve` à l'enqueue / `Charge` à la complétion / `Refund` à l'échec) déjà spécifiée dans
`studio-plan.md` §3.5. Ce n'est pas un raffinement : à **3 500 crédits (≈ 2,80 $) l'export mesh HQ**
et 1 500 le monde standard, c'est de l'argent réel et immédiat.
---
## 9. Points de friction
### F1. Le module VR est un consommateur du plan V2 — la table de dépendances
**Le** point de séquencement, et il ne dit pas « ça n'existe pas » mais **« ça existe en spec, en
V2, et le module VR en dépend »**. Référence de haut niveau : `../../DOCS/STATUS.md` §5, qui
énumère déjà la V2 (Studio, Personnages, TTS pré-généré, kiosk web, VR) et pose le même prérequis
dur — *« le backend ne sait pas écrire dans le bucket »*.
| Ce dont la VR a besoin | Spec | Kanban (`DOCS/kanban/cards/5-planifie/`) | Codé |
|---|---|---|---|
| Écrire un fichier dans le bucket côté serveur | `studio-plan.md` lot 0 | `300-socle-de-stockage-serveur-le-backend-ne-sait` | ❌ **prérequis dur** |
| Crédits, plafond, coût estimé, journal | `studio-plan.md` §3.5, lot 1 | `280-module-studio-generation-d-images` | ❌ |
| `IGenerationProvider` + catalogue de modèles en base | `studio-plan.md` §3.6, lot 3 | `280-*` | ❌ |
| Provenance IA / mention / droits au client | `studio-plan.md` §3.11, lot 6 | `280-*` | ❌ |
| `Persona` (+ `PersonaView`, `TtsVoice`) | `studio-plan.md` §3.8, lot 7 | `310-personnages-un-guide-adressable-n-narrateurs` | ❌ |
| `ResourceType.Model3D` + GLB en médiathèque | `studio-plan.md` §5, lot 11 | `280-*` (lot 11) | ❌ |
| Audio TTS pré-généré multilingue | `tts-pregenerated-plan.md` | `160-tts-pre-genere-audioguide-multilingue` | ❌ |
| Ressource 360° (`Panorama360`) | `todo-features.md` | `250-ressource-360-images-panoramiques` | ❌ |
⚠️ **Le lot 0 est le goulot commun** : le Studio, le TTS pré-généré **et** la génération de mondes
Marble attendent tous la même chose — que `IResourceBlobService` sache écrire. ~1 jour.
**Le module VR n'ajoute donc pas ces briques au backlog : il les rend urgentes.** Trois chantiers
V2 qui pouvaient s'attendre les uns les autres deviennent le chemin critique d'un quatrième.
> ⚠️ **Nuancé par la décision D5** ([`02-decisions.md`](02-decisions.md)) : en **V1**, le monde et
> les personnages sont des GLB **uploadés**, pas générés. Aucune de ces dépendances n'est bloquante
> pour la V1 — la table ci-dessus décrit ce qui bloque la **V1.5** (génération intégrée). C'est
> précisément ce que D5 achète : découpler la livraison de l'app Quest du backlog V2.
👉 **World Labs Marble n'est pas un fournisseur à part : c'est une implémentation de plus de
`IGenerationProvider`**, avec un `GenerationKind.World` et un `GenerationModel` en base
(`ProviderKey = "worldlabs"`, `ProviderModelId`, `CreditCost`). Le câbler à côté du Studio recréerait
crédits, jobs, webhooks et traçabilité en double — et contredirait la contrainte « ne jamais coder en
dur les noms de modèles ni les fournisseurs », que le catalogue en base résout déjà.
**Mais** — et c'est ce qui sauve le planning — **la chaîne technique Unity ne dépend de rien de tout
ça.** Générer un monde à la main sur le site de Marble, télécharger le GLB, le charger dans Unity,
le voir dans le casque : **zéro backend**. Faisable cette semaine, et c'est l'étape 1 obligatoire.
### F2. Deux modèles de contenu qui ne se sont pas encore rencontrés
Le plan du 2026-08-31 dit : *« Unity appelle `GET /api/configuration/{id}/export` »* — donc la VR
rend **les 13 types de section existants**, en immersif.
Le nouveau cahier des charges décrit un **manifeste de scène** : monde de fond, objets GLB, personas
placés, hotspots, spawn, rayon de navigation.
**Ce sont deux produits différents.** Les faire cohabiter sans arbitrage donnerait deux pipelines de
publication, deux caches offline, deux formats.
| Option | Ce que ça donne | Coût |
|---|---|---|
| **A — Type de section neuf `SectionScene3D`** dans une `Configuration` existante | Le manifeste devient une **projection** de l'export existant. Une seule publication, un seul offline, stats gratuites. Et la scène devient affichable en web/mobile — le « décorrélé du casque » sur lequel repose le pricing de l'add-on | Le plus faible. **Recommandé** |
| B — Entité `VrScene` parallèle, manifeste autonome | Modèle propre, taillé 3D, sans compromis avec le CMS 2D | Publication, versionnement, offline, quotas, stats **à refaire** |
| C — Les deux | — | Non |
➡️ Arbitrage proposé : **A**, avec le manifeste **généré** à partir de la section, jamais saisi.
Corollaire : `GeoPoint` devient l'objet POI unique — `vr-quest-unity-plan.md` §9 l'avait vu, il porte
déjà `Title`, `Description`, `ImageResource` et surtout `Contents` (titre + description + `Resource`
audio, multilingue). Il ne lui manque qu'un `(x, y, z)`.
### F3. Le versionnement du manifeste n'a aucune fondation
Pas d'état publié, pas de numéro de version, pas de hash d'asset (§5). Trois choses à créer — et
elles ne sont **pas** propres à la VR : un `Configuration.PublishedVersion` profiterait aussi à
l'offline de `mymuseum-visitapp`, qui invalide aujourd'hui à la date.
### F4. Le serveur ne sait pas écrire dans son propre bucket
Répété parce que c'est le blocage le plus bête et le plus dur : **le webhook Marble n'aura nulle part
où poser le GLB qu'il télécharge**. Lot 0 du Studio, ~1 jour, prérequis absolu de toute génération
serveur — y compris du TTS pré-généré, qui attend la même chose.
### F5. Le pipeline KTX2 / Meshopt n'a pas d'exécutant
Le cahier des charges demande KTX2 + Meshopt « dès le départ ». Or :
- la compression existante (`ImageCompressor`) tourne **dans le navigateur**, en Dart, sur des JPEG ;
- le serveur n'a aucun outil de traitement d'asset — `ImageHelper` en `System.Drawing` est du **code
mort**, il ne tourne pas sur `aspnet:8.0` Linux ;
- transcoder en KTX2 et compresser en Meshopt, c'est appeler des **binaires natifs**
(`toktx`/`basisu`, `gltf-transform`) : soit les embarquer dans l'image Docker, soit un service à part.
👉 Recommandation pour un dev solo : **servir le GLB Marble tel quel en V1**, mesurer, et n'optimiser
qu'ensuite — **mais** poser dès maintenant dans le manifeste un bloc `variants` par asset, pour que
l'ajout du KTX2 ne casse pas le contrat. La contrainte du cahier des charges (« à câbler dès le
départ ») est honorée au niveau du **format**, pas de l'implémentation.
### F6. Le viewer de preview ne peut pas être un widget Flutter
Voir §7. Conséquence de conception : **le viewer doit être une app web autonome nourrie par une URL
de manifeste**, pas un composant. Ce qui le rend réutilisable dans `visitapp-web` — question Q2.
### F7. Un `.glb` cassé à l'upload
`resources_screen.dart:400-431` n'a pas de `default`. Petit bug, mais c'est le tout premier geste du
client dans le module (« j'uploade mon personnage »).
### F8. Le personnage 3D n'a pas de source
Ni catalogue existant (il n'existe pas), ni génération (Meshy/Tripo font des objets isolés, pas des
humanoïdes rigés avec animation). En V1 : bibliothèque externe ou studio 3D, sous licence explicite —
et c'est un sujet **droits** pour des institutions publiques, pas seulement un sujet technique.
### F9. Traçabilité IA — l'exigence institutionnelle tombe pile
`studio-plan.md` §3.11 spécifie déjà exactement ce que demande le §5 du cahier des charges :
`Resource.AiProvenance` jsonb avec `modelKey`, `providerModelId`, `providerRequestId`,
`effectivePrompt`, `referenceResourceIds`, `generatedAt`, `generatedByUserId`, `validatedByUserId`,
et `rightsHolder` **par défaut au nom de l'instance** — les droits au client, écrits dans la donnée,
pas seulement dans les CGU. Un monde généré doit y entrer, avec en plus ses **sources de capture**
(la vidéo / le panorama d'origine, eux-mêmes des `Resource`).
---
## 10. Ce qui est réutilisable tel quel — la liste courte
À garder sous les yeux en phase 2, pour ne rien réécrire :
| Réutiliser | Au lieu de |
|---|---|
| `List<TranslationDTO>` jsonb | inventer un format i18n de manifeste |
| `GeoPoint` + `Contents` + `Resource` audio | inventer un objet « hotspot » |
| `ArticleAudioIds: List<TranslationDTO>` d'ids de ressource | inventer un stockage audio |
| `GET /configuration/{id}/export` + `GetReferencedResourceIds` | écrire N endpoints pour Unity |
| Hangfire + convention « un job prend un id » | une file maison |
| `StripeWebhookController` comme patron de webhook signé | improviser le webhook Marble |
| `Kiosk_devices/` (4 fichiers, 769 l.) | écrire l'écran de flotte de casques |
| `AppConfigurationLink.DeviceId` | inventer « une config par casque » |
| Le publish MQTT `player/{deviceId}` | inventer le push de rafraîchissement |
| `AuditLog` | inventer un journal d'action |
| `Instance.IsAssistant` comme patron d'add-on | une table de jointure de features |
| Le flux d'appairage par pincode de `tablet-app` | inventer le provisioning du casque |
---
## 11. Ce qu'il reste à trancher avant la phase 2
Cinq questions, posées en restitution. Les deux bloquantes sont **F2** (type de section vs entité
parallèle — ça détermine tout le modèle de données) et le **mécanisme de placement des personas**.

View File

@ -0,0 +1,271 @@
# Phase 0 — Setup Unity pour Meta Quest, à partir de zéro
> Écrit pour quelqu'un qui n'a **jamais ouvert Unity**. Chaque chemin de menu est donné en entier.
>
> ⚠️ **Unity n'est pas installé sur cette machine** (vérifié le 2026-08-31, toujours vrai). Je ne
> peux donc rien compiler ni tester : ce guide est vérifié sur la doc et l'expérience du SDK, pas
> par une exécution. **Compte 2 à 4 allers-retours** sur les versions de packages Meta, qui bougent
> vite d'une release à l'autre.
>
> ⚠️ Les numéros de version ci-dessous sont ceux que je connais. **Avant d'installer, ouvre la page
> de compatibilité Meta** (`developers.meta.com` → Unity → *Requirements*) et prends la version
> d'Unity qui y est marquée recommandée. Si elle diffère, c'est elle qui gagne, pas ce document.
---
## Ce que je peux produire, et ce que tu dois cliquer
| Je peux générer en fichier | Tu dois le faire à la main dans l'éditeur |
|---|---|
| `Packages/manifest.json` (la liste des packages) | Installer Unity Hub et Unity |
| Tous les scripts C# (`Assets/Scripts/**`) | Créer le projet (le template) |
| Un script d'éditeur qui **construit la scène par code** | Le premier clic sur *Build And Run* |
| `.gitignore` Unity | Activer le mode développeur sur le casque |
| Les fichiers de configuration texte lisibles (`.asmdef`, JSON) | Les cases de **Project Settings** (voir §5) |
| — | Les cases de **XR Plug-in Management** (§6) |
**Pourquoi je ne génère pas les `ProjectSettings/*.asset` ni les `.unity`** : c'est du YAML Unity
avec des GUID et des `fileID` internes. Écrit à la main, ça produit un projet qui *s'ouvre* mais
dont un réglage sur deux est silencieusement faux — le pire des cas, parce que ça ne plante pas.
Les réglages se font à l'écran, une fois, en 15 minutes. La **scène**, en revanche, je peux la
construire par code via un script d'éditeur (`[MenuItem]`) : c'est lisible, versionnable et
rejouable.
---
## 1. Installer Unity Hub et Unity
1. Télécharge **Unity Hub** : `unity.com/download`. C'est le lanceur ; Unity lui-même s'installe
depuis lui.
2. Crée un compte Unity (gratuit) et prends la licence **Personal** dans le Hub :
*Hub → icône compte (haut droite) → Manage licenses → Add → Get a free personal license*.
3. Dans le Hub : **Installs → Install Editor**.
4. Choisis une version **LTS** — au moment d'écrire, la ligne recommandée par Meta est **Unity 6
LTS (6000.0.x)**. Prends la dernière `6000.0.xxf1` de la liste LTS.
> Pourquoi LTS : support 2 ans, et surtout c'est la seule ligne que Meta teste. Une version
> `.a`/`.b` (alpha/beta) te fera perdre des journées sur des bugs qui ne sont pas les tiens.
### Modules à cocher — c'est l'écran à ne pas rater
À l'écran *Add modules*, coche :
- ☑️ **Android Build Support** — et **déplie-le**, les deux sous-modules ne sont pas cochés par
défaut selon les versions :
- ☑️ **OpenJDK**
- ☑️ **Android SDK & NDK Tools**
- ☑️ *Documentation* (optionnel, pratique hors ligne)
Ne coche pas iOS, WebGL, Linux : c'est des Go pour rien.
> **Si tu oublies un sous-module** : *Hub → Installs → ⚙️ sur la version → Add modules*. Rien
> n'est perdu, mais Unity te dira « No Android module loaded » sans expliquer lequel manque.
---
## 2. Créer le projet
1. **Hub → Projects → New project**.
2. Template : **3D (Built-In Render Pipeline)** ou **Universal 3D (URP)** ?
**URP**. Meta recommande URP pour Quest : le *Forward+* et le rendu mono-passe stéréo y sont
optimisés, et l'essentiel de la doc Meta récente le suppose. Prends **Universal 3D**.
3. Nom du projet : `MyInfoMateVR`.
4. Emplacement : `…/GITEA/vr-app/unity/`.
5. **Create project.** Premier lancement : 2 à 5 minutes.
> Tu obtiens un dossier avec `Assets/`, `Packages/`, `ProjectSettings/`, `Library/`.
> **`Library/` ne se versionne jamais** — c'est du cache, plusieurs Go. Le `.gitignore` s'en charge.
---
## 3. Le `.gitignore` (je le génère)
Fichier à créer à la racine de `vr-app/` — voir `../.gitignore` une fois généré. L'essentiel :
`Library/`, `Temp/`, `Obj/`, `Build/`, `Logs/`, `UserSettings/`, `*.csproj`, `*.sln`.
⚠️ **Git LFS** : les `.glb`, `.fbx`, `.png` et `.mp4` sont des binaires lourds. Si le repo doit un
jour contenir des assets, active LFS **avant** le premier commit d'un binaire — après, c'est une
réécriture d'historique.
---
## 4. Les packages (je génère `Packages/manifest.json`)
Trois familles :
### 4.1 XR — installés par l'éditeur
- `com.unity.xr.openxr`**OpenXR Plugin**, la couche standard.
- `com.unity.xr.interaction.toolkit` — optionnel si tu utilises l'Interaction SDK de Meta.
*Window → Package Manager → Unity Registry → chercher « OpenXR Plugin » → Install.*
### 4.2 Meta XR SDK — le point qui bouge le plus
Meta distribue son SDK de deux façons selon les versions : par l'**Asset Store** (« Meta XR
All-in-One SDK ») ou par un **registre npm** déclaré dans `manifest.json`. Les packages qui comptent :
| Package | Rôle |
|---|---|
| `com.meta.xr.sdk.core` | **Meta XR Core SDK** — obligatoire : caméra, tracking, passthrough |
| `com.meta.xr.sdk.interaction.ovr` | **Interaction SDK** — raycast contrôleur *et* hand tracking |
| `com.meta.xr.sdk.all` | le méta-paquet qui tire tout (plus lourd) |
**Ce que tu dois faire à la main la première fois** :
*Window → Asset Store → chercher « Meta XR All-in-One SDK » → Add to My Assets → Open in Unity →
Package Manager → My Assets → Download → Import.*
Puis Unity affiche un assistant **Meta XR Project Setup Tool** (*Edit → Project Settings → Meta XR*)
qui liste les réglages non conformes avec un bouton **Fix** / **Fix All**. ➡️ **Utilise-le.** Il
applique la moitié du §5 tout seul, et il est à jour du SDK installé — donc plus fiable que ce
document.
### 4.3 glTF — le cœur du pipeline
Ajoutés par *Window → Package Manager → **+** (haut gauche) → Add package by name…* :
| Nom exact à coller | Rôle |
|---|---|
| `com.unity.cloud.gltfast` | **glTFast** — chargement de `.glb` **au runtime** |
| `com.unity.cloud.ktx` | support **KTX2 / Basis Universal** des textures |
| `com.unity.cloud.draco` | géométrie compressée **Draco** |
| `com.unity.meshopt.decompress` | géométrie compressée **Meshopt** |
> Les trois derniers sont **des dépendances optionnelles de glTFast** : sans eux, un GLB compressé
> se charge en erreur silencieuse (mesh vide, texture rose). C'est exactement le genre de panne qui
> coûte une demi-journée — installe-les tout de suite, même si les premiers GLB ne sont pas compressés.
---
## 5. Configurer le build Android pour Quest — écran par écran
> Fais d'abord tourner le **Meta XR Project Setup Tool** (§4.2), puis vérifie ce qui suit. Ce que
> l'outil corrige, tu n'as pas à le refaire.
### 5.1 Passer la plateforme en Android
*File → Build Profiles* (Unity 6 ; **File → Build Settings** avant Unity 6)
→ sélectionne **Android****Switch Platform**.
⏱️ Le premier switch réimporte tous les assets : **5 à 20 minutes**. C'est normal, ne l'interromps pas.
Dans le même écran : **Texture Compression → ASTC**.
### 5.2 Player Settings
*Edit → Project Settings → Player*, onglet Android (l'icône robot) :
| Section | Réglage | Valeur |
|---|---|---|
| *Other Settings → Rendering* | **Color Space** | **Linear** |
| | **Auto Graphics API** | ❌ décoché |
| | **Graphics APIs** | **Vulkan uniquement** — supprime `OpenGLES3` de la liste |
| | **Multithreaded Rendering** | ☑️ |
| *Other Settings → Identification* | **Package Name** | `be.unov.myinfomate.vr` (jamais le `com.DefaultCompany` par défaut) |
| | **Minimum API Level** | **32** (Android 12L) — exigé par Horizon OS récent |
| | **Target API Level** | la plus haute proposée (34+) |
| *Other Settings → Configuration* | **Scripting Backend** | **IL2CPP** (Mono ne produit pas d'APK Quest valide) |
| | **Target Architectures** | ☑️ **ARM64** seulement — décoche ARMv7 |
| | **Active Input Handling** | **Both** — le plus sûr : l'`OVRInput` de Meta est en legacy, le reste de l'écosystème en new Input System |
| *Resolution and Presentation* | **Default Orientation** | **Landscape Left** |
| *Publishing Settings* | **Custom Main Manifest** | ☑️ si tu dois ajouter des permissions à la main |
### 5.3 Quality
*Edit → Project Settings → Quality* : garde **un seul** niveau pour Android, et mets-y :
**Anti Aliasing = 4x Multi Sampling** (le MSAA est quasi gratuit sur le GPU mobile du Quest, et
c'est ce qui fait la différence entre « propre » et « ça scintille »), **Shadows = Hard Shadows Only**
ou aucune.
---
## 6. XR Plug-in Management
*Edit → Project Settings → **XR Plug-in Management*** :
1. S'il propose **Install XR Plug-in Management**, clique.
2. Choisis l'onglet **Android** (l'icône robot) — ⚠️ **pas** l'onglet PC. C'est l'erreur n°1 :
tout configurer sur PC et obtenir un APK sans VR.
3. ☑️ **OpenXR**
4. Sous **OpenXR** (sous-entrée du menu de gauche), onglet Android :
- **Enabled Interaction Profiles****+** → **Oculus Touch Controller Profile**
- **OpenXR Feature Groups** → ☑️ **Meta Quest** (ou *Meta Quest Support* selon la version)
5. Un triangle jaune ⚠️ à côté d'un réglage est **cliquable** : il ouvre un correcteur automatique.
Aucun triangle ne doit rester avant de builder.
---
## 7. Activer le mode développeur sur le casque
À faire une seule fois, mais dans cet ordre — l'étape 1 est celle qu'on oublie :
1. **Crée une organisation développeur** : `developers.meta.com` → connecte-toi avec le compte Meta
**du casque***Create organization*. Sans organisation, le toggle de l'étape 3 n'apparaît pas.
2. Application **Meta Horizon** sur le téléphone → **Devices** (ou *Appareils*) → sélectionne le
casque → assure-toi qu'il est **connecté**.
3. → **Headset settings****Developer mode** → ☑️.
4. **Redémarre le casque.**
5. Branche le casque en USB-C au PC. **Mets le casque sur la tête** : une popup *« Autoriser le
débogage USB ? »* apparaît **dans le casque**, pas sur le PC. Coche *Toujours autoriser* et
accepte. Tant que ce n'est pas fait, `adb devices` affiche `unauthorized`.
6. Vérifie côté PC :
```
adb devices
```
`adb` se trouve dans le SDK Android installé par Unity :
`C:\Program Files\Unity\Hub\Editor\<version>\Editor\Data\PlaybackEngines\AndroidPlayer\SDK\platform-tools\`
Ajoute ce dossier au `PATH`, tu t'en serviras tous les jours.
> **Alternative confortable** : **Meta Quest Developer Hub (MQDH)**, l'outil desktop de Meta. Il
> gère l'appairage, l'installation d'APK par glisser-déposer, la capture vidéo de ce que voit le
> casque (précieux pour montrer une démo à un client) et les logs. Recommandé.
---
## 8. Hello World VR — le seul test qui compte
**But** : voir une scène en stéréo dans le casque et bouger la tête. Rien d'autre. Tant que ça ne
marche pas, tout le reste est théorique.
### Ce que tu fais à la main
1. *File → New Scene → Basic (URP)* → sauvegarde en `Assets/Scenes/Hello.unity`.
2. Supprime la **Main Camera** de la hiérarchie (le rig VR apporte la sienne).
3. Ajoute le rig : *menu **Meta** → Tools → **Building Blocks*** → glisse **Camera Rig** dans la
scène. (Si le menu Meta n'existe pas, le SDK n'est pas importé — retour au §4.2.)
4. Clic droit dans la hiérarchie → *3D Object → Cube*, place-le à `Position (0, 1.5, 2)`,
`Scale (0.3, 0.3, 0.3)`. C'est ton repère visuel.
5. *File → Build Profiles* → vérifie que `Assets/Scenes/Hello.unity` est **dans la liste des scènes**
et cochée (bouton *Add Open Scenes* si elle n'y est pas).
6. Casque branché et autorisé → **Build And Run**.
7. Premier build : **10 à 30 minutes** (IL2CPP compile tout le code natif). Les suivants : 1 à 3 min.
### Critère de validation
Le cube est devant toi, en relief, et il reste **immobile dans l'espace** quand tu bouges la tête
(pas collé au regard). Si le cube suit ta tête, le rig n'est pas correctement en place.
### Panne la plus fréquente
| Symptôme | Cause |
|---|---|
| L'app s'ouvre en écran plat flottant, pas en VR | XR Plug-in Management configuré sur l'onglet **PC** au lieu d'**Android** (§6.2) |
| `adb: device unauthorized` | La popup d'autorisation n'a pas été acceptée **dans le casque** (§7.5) |
| Build échoue sur « No Android module » | Sous-modules OpenJDK / SDK-NDK non cochés (§1) |
| Écran noir au lancement | Graphics API : OpenGLES3 encore présent avant Vulkan (§5.2) |
| Tout est rose | Shader non compatible URP — l'objet vient d'un import Built-In |
---
## 9. Étape suivante immédiate
Une fois le cube visible, **la vraie étape 1 du projet** (cahier des charges §PHASE 3) :
1. Un monde Marble généré **à la main** sur le site de World Labs, exporté en **mesh GLB**.
2. Ce GLB posé dans `Assets/StreamingAssets/` et chargé **au runtime par glTFast** — pas glissé dans
la scène comme un asset d'éditeur. C'est toute la différence : le chargement runtime est ce qui
permettra plus tard de télécharger un monde depuis le manager sans rebuild.
3. Un second GLB de personnage posé dedans, à l'échelle, avec un idle.
4. Vu dans le casque.
Aucun backend n'est nécessaire pour ces quatre points — c'est ce qui les rend faisables tout de
suite, en parallèle de tout le reste. Les scripts C# de chargement, je les génère quand tu me dis
que le cube est passé.

192
docs/02-decisions.md Normal file
View File

@ -0,0 +1,192 @@
# Décisions d'architecture du module VR
> Arbitrées le 2026-09-03, à partir du relevé de code de
> [`00-phase1-etat-des-lieux.md`](00-phase1-etat-des-lieux.md). **Ne pas rouvrir sans raison
> technique forte.** Les décisions antérieures (Unity vs WebXR, pricing add-on, MDM, types de
> section) restent dans `../../DOCS/v2/vr-quest-unity-plan.md` §6 à §10.
---
## D1 — Le contenu VR est un type de section, pas une entité parallèle
**Retenu : `SectionScene3D`, dans une `Configuration` existante.**
Le manifeste de scène est une **projection** de la section, générée par le serveur, jamais saisie à
la main. Conséquences, toutes acquises sans code neuf :
| On hérite de | Parce que |
|---|---|
| La publication | c'est une section, elle part dans `/configuration/{id}/export` |
| L'offline | `GetReferencedResourceIds(language)` est implémenté par sous-type — on l'implémente une fois |
| Les stats par canal | `VisitEvent` porte déjà `AppType` |
| Le multilingue | `List<TranslationDTO>` jsonb, comme le reste |
| Le quota stockage | `SUM(SizeBytes)` sur les `Resource` référencées |
| L'affichage web / mobile | une section est rendue par les 3 clients visiteurs — c'est ce qui rend l'add-on « contenu immersif » vendable **sans casque** |
**Corollaire : `GeoPoint` est l'objet hotspot unique.** Il porte déjà `Title`, `Description`,
`ImageResource` et `Contents` (titre + description + `Resource` audio, multilingue). Il lui manque
une position locale `(x, y, z)` + rotation à côté de son `Geometry` PostGIS. Pas de nouvel objet POI.
**Écarté** : une entité `VrScene` autonome — elle aurait obligé à réécrire publication,
versionnement, cache offline, quotas et stats.
**Écarté aussi, pour la V1** : rendre les 13 types de section en immersif. Le §8 du plan du 31/08
garde sa validité comme cap (Menu / Video 360 / Slider / Map gagnent vraiment en VR), mais ce n'est
pas le périmètre de la V1 : la V1 rend **une scène**, pas un CMS flottant.
---
## D2 — Le placement se fait dans le viewer, au clic
**Retenu : le viewer THREE.js est aussi l'éditeur de placement.**
Clic dans la scène → raycast sur le sol → position. Poignée de rotation sur l'objet sélectionné,
panneau numérique à côté pour ajuster au centimètre. Pas d'éditeur 3D complet, pas de gizmo de
translation sur 3 axes.
Coût marginal quasi nul : le viewer doit exister de toute façon pour la preview (§3.6 du cahier des
charges). C'est le seul mécanisme où le client **voit ce qu'il compose**.
⚠️ **Ce que ça engage** : le viewer cesse d'être en lecture seule. Il lui faut un état d'édition,
un undo, et un enregistrement explicite. À ne pas sous-estimer — c'est la moitié de son coût.
**Écartés** : la mini-carte 2D vue de dessus (il aurait fallu produire un rendu orthographique du
monde, et le client place à l'aveugle sur la hauteur) ; les ancrages prédéfinis (composition pauvre,
sensation de gabarit — mauvais en démo commerciale).
---
## D3 — Un seul viewer, app web autonome, partagé par les quatre fronts
**Retenu : le viewer est une application web autonome nourrie par une URL de manifeste.**
Précisé le 2026-09-03 : il ne sert pas deux fronts mais **quatre**.
| Front | Comment il l'héberge | Mode |
|---|---|---|
| `manager-app` (Flutter Web) | **iframe** (`HtmlElementView`) + `postMessage` | édition |
| `visitapp-web` (Next.js) | composant, ou iframe si plus simple | lecture |
| `mymuseum-visitapp` (Flutter mobile) | **WebView** servant le viewer **empaqueté dans l'app** | lecture, **hors ligne** |
| `vr-app` (Unity/Quest) | ❌ pas concerné — Unity a son propre rendu natif | — |
**Un seul moteur de rendu 3D pour tout l'écosystème.** C'est ce qui attaque de front le risque
n°2 du plan d'août (« nouvelle stack, zéro mutualisation ») : le rendu 3D n'est pas un quatrième
front, c'est **un composant partagé**.
> Sur Flutter mobile, il n'y a pas d'alternative sérieuse : `model_viewer_plus`, la solution
> habituelle, **est elle-même un WebView** autour de `<model-viewer>` de Google. Autant que ce soit
> notre viewer dans ce WebView, avec nos hotspots et notre manifeste, qu'un composant tiers qu'on
> ne contrôle pas.
### L'offline mobile : souhaitable, pas bloquant — arbitré le 2026-09-03
`mymuseum-visitapp` est offline-first, et un WebView pointant sur une URL distante casserait ça.
**Thomas a tranché : si la 3D hors ligne s'avère trop coûteuse, on l'assume et on le dit au client
dans manager-app.** C'est acceptable, et surtout **c'est déjà la convention de la maison** :
- `SectionVideo.GetReferencedResourceIds` (`SectionVideo.cs:25-27`) **exclut explicitement** une
vidéo dont la source est une URL `http` — elle ne part pas hors ligne ;
- `SectionWeb` ne collecte que sa vignette.
Une scène 3D non disponible hors ligne serait donc **le troisième cas d'une règle existante**, pas
une exception. Rien à inventer, juste à signaler dans l'éditeur.
**Ceci dit, c'est probablement faisable**, et le coût n'est pas dans le rendu mais dans la plomberie :
viewer empaqueté dans les assets Flutter, servi depuis une origine locale, GLB téléchargés dans le
dossier documents, URL du manifeste réécrites en chemins locaux. Le pipeline offline fait **déjà**
tout ça pour les médias 2D. À évaluer au moment du portage mobile, pas maintenant.
### ⚠️ Les trois règles à poser dès la V1 — indépendamment de l'offline
Elles ne coûtent rien maintenant, elles coûtent une réécriture plus tard, et **elles sont de toute
façon du bon design** : un viewer qui ne sait pas d'où viennent ses données est un viewer réutilisable.
1. **Le viewer est un jeu de fichiers statiques**, embarquable dans les assets d'une app Flutter et
servable depuis une origine locale. Pas de build qui suppose un serveur.
2. **Aucune URL d'API en dur dans le viewer.** Il ne connaît que le manifeste qu'on lui donne —
toutes les URL d'assets viennent de `assets[].url`. C'est ce qui permet de lui passer un
manifeste dont les URL ont été **réécrites en chemins locaux** par l'app, exactement comme le
fait déjà le pipeline offline pour les médias.
3. **Le manifeste est une entrée, pas un fetch.** Le viewer accepte un manifeste **injecté**
(`postMessage` / paramètre), pas seulement une URL à charger.
### Ce que ça débloque au passage
Le 360 et le GLB s'affichent alors **en web et en mobile**, pas seulement au casque — c'est
exactement la part de l'add-on « contenu immersif » qui se vend **sans casque**, et sur laquelle
repose son pricing (`vr-quest-unity-plan.md` §6).
⚠️ **Rappel de contrat** : le viewer est un **aperçu indicatif** — placement, échelle, composition.
Pas un rendu final. Alignement à tenir : même tone mapping, même HDRI d'environnement, PBR core glTF
uniquement. La validation réelle passe par une publication sur canal de test et un essai au casque.
---
## D4 — La borne se réinitialise à la repose du casque
**Retenu : détection du retrait (capteur de proximité) → fondu au noir, reset de la scène, retour au
menu de sélection dans la langue par défaut.**
Précédent direct dans l'écosystème : le retour à l'accueil de la borne tablette après 5 min
(`DOCS/kanban/done/140-*`). Un agent n'a rien à faire, ce qui est cohérent avec l'argument qui avait
fait retenir Unity — l'exploitation sans surveillance.
Le timeout d'inactivité reste utile en **filet secondaire** (casque posé sans être retiré
proprement), pas comme mécanisme principal.
---
## D5 — La V1 consomme des mondes uploadés ; la génération vient juste après
**Retenu : upload manuel d'abord, génération intégrée ensuite.**
En V1, le monde est un **GLB uploadé dans la médiathèque** — produit hors ligne, sur le site de
World Labs, par nous, pour les clients pilotes. Le module VR devient alors **indépendant du socle
Studio** et livrable seul, sans attendre les lots 0 / 1 / 3.
La génération in-app (le bouton « Générer un monde » du §3.1 du cahier des charges) arrive quand ces
lots existent, **sans rien casser** : au bout du pipeline, c'est la même `Resource` GLB référencée
par la même section. La seule chose à faire dès maintenant, c'est de ne rien coder qui empêche cette
greffe — donc :
- le monde est **toujours** un `ResourceId`, jamais un chemin ou une URL en dur ;
- `Resource.AiProvenance` est prévu dans le manifeste **dès la V1**, même vide ;
- Marble n'est jamais appelé depuis le code VR : le jour venu, ce sera un `IGenerationProvider`
de plus (`ProviderKey = "worldlabs"`, `GenerationKind.World`), pas un client HTTP maison.
**Écarté** : la génération dès la V1 (elle mettait plusieurs semaines de backend V2 devant la
première scène visible) et le compromis « lot 0 seul + appel Marble en dur » (il recrée exactement
la dette que `studio-plan.md` cherche à éviter : modèle codé en dur, pas de journal, pas de
réservation de crédits).
---
## D6 — Personnages 3D : catalogue externe sous licence, mais branché sur la médiathèque dès le jour 1
**Retenu : une petite bibliothèque de personnages stylisés sous licence** (modèles type
Quaternius / Kenney ou un pack payant, animations idle type Mixamo), livrée comme catalogue de
départ. Le client choisit, il ne modélise pas.
⚠️ **Condition posée par Thomas, et elle est structurante** : *« il faut quand même prévoir vite
d'intégrer ce qu'on génère dans le Studio »*. Donc **le catalogue n'est pas une liste en dur dans
l'APK**. Dès la V1 :
- un personnage 3D est un **`Resource` de type `Model3D` dans la médiathèque**, comme n'importe quel
autre asset — le catalogue de départ est juste un **jeu de ressources pré-chargées**, marquées
comme telles ;
- `Persona.Model3DResourceId` pointe dessus. Le jour où le Studio produit un GLB, il **remplace la
valeur du champ**, il ne demande aucune refonte ;
- la licence de chaque modèle du catalogue de départ est tracée dans `AiProvenance.rightsHolder`
(ou son équivalent pour un asset non généré) — sujet **droits**, pas seulement technique, pour des
institutions publiques.
**Écartés** : le studio 3D sur devis en V1 (aucun pilote ne pourrait composer une scène sans payer
d'abord une prestation) et l'absence de personnage en V1 (c'est l'élément le plus démonstratif).
---
## Ce qui reste ouvert
| Sujet | Où c'est décrit | Ce qui manque pour trancher |
|---|---|---|
| **KTX2 / Meshopt : où s'exécute la transformation ?** | état des lieux F5 | Binaires natifs dans l'image Docker vs GLB servi tel quel en V1. **Défaut retenu faute d'objection : tel quel en V1**, avec le bloc `variants` par asset posé dans le manifeste dès le départ pour que l'ajout n'y touche pas. À rouvrir dès qu'on aura un vrai poids de monde Marble mesuré |
| **Go/no-go client pilote** | `vr-quest-unity-plan.md` §5 | Décision produit, inchangée : ne pas engager l'app Unity **complète** sans un lieu pilote identifié. D5 réduit l'enjeu — la chaîne technique se prouve sans client |

View File

@ -0,0 +1,644 @@
# Phase 2 — Conception
> Écrit le 2026-09-03, à partir de l'état des lieux ([00](00-phase1-etat-des-lieux.md)) et des
> décisions D1 à D6 ([02](02-decisions.md)).
>
> **Ce document ne modifie aucun code.** Les schémas, colonnes et signatures ci-dessous sont des
> propositions à lire et à critiquer. Aucune migration EF n'est générée, aucune classe n'est créée.
---
## 1. Le principe, et sa conséquence unique
**Le client capture, compose, publie. Il ne modélise rien, ne code rien, ne rebuild rien.**
Traduit en règle d'architecture, une seule ligne à laquelle tout le reste obéit :
> **Un rebuild d'APK n'est nécessaire que si j'ajoute une fonctionnalité. Jamais si un client change
> son contenu.**
Test de validation permanent : *si la réponse à « il faut rebuilder ? » est oui pour un changement
de contenu, la conception est fausse.* Concrètement, ça interdit trois choses dans le projet Unity :
| Interdit | Pourquoi |
|---|---|
| Un asset de contenu dans `Assets/` | il serait dans l'APK, donc figé au build |
| Une scène `.unity` par client ou par monde | idem, et ça multiplie les builds |
| Une liste en dur de personnages, de mondes, de langues | c'est de la donnée, elle vient du manifeste |
Tout le contenu passe par **une seule porte : le manifeste de scène**, et tout ce qu'il référence est
téléchargé au runtime.
---
## 2. Le manifeste de scène — le contrat central
C'est la pièce dont dépendent les trois consommateurs : l'app Quest, le viewer web, le back-office.
Il est **généré par le serveur**, jamais saisi à la main.
### 2.0 Le format est déjà commun aux deux stacks — ne pas confondre avec la convention
**glTF / GLB *est* le format qui marche des deux côtés.** Le même `.glb`, octet pour octet, est lu
par three.js (`GLTFLoader`) et par Unity (`glTFast`). Il n'y a **ni conversion de fichier, ni export
parallèle, ni format Unity séparé**. C'est précisément pour ça qu'il a été retenu comme format pivot.
Et ça vaut aussi pour les optimisations, parce que ce sont des **extensions glTF standard**, pas des
formats concurrents :
| Extension | three.js | Unity |
|---|---|---|
| `KHR_texture_basisu` (KTX2) | `KTX2Loader` | `com.unity.cloud.ktx` |
| `KHR_draco_mesh_compression` | `DRACOLoader` | `com.unity.cloud.draco` |
| `EXT_meshopt_compression` | `MeshoptDecoder` | `com.unity.meshopt.decompress` |
| `KHR_materials_*` (PBR core) | natif | natif |
➡️ Le pipeline est **un seul fichier, deux lecteurs**. Un monde généré est vu dans le viewer du
back-office, puis publié sur le canal VR, sans transformation entre les deux.
**La seule chose qui n'a pas de format commun, ce sont les splats gaussiens** — c'est exactement
pourquoi `world.kind` existe (§2.2) : la V1 ne lit que `"mesh"` et refuse le reste explicitement.
> ⚠️ **Ne pas confondre avec le §2.1 qui suit.** Le format des *assets* est réglé et partagé. Le
> §2.1 traite d'autre chose, beaucoup plus petit : la convention d'axes de **mes propres
> coordonnées de placement** dans le manifeste — de la métadonnée, pas de la géométrie. Une
> vingtaine de lignes de code, pas un choix de pipeline.
### 2.1 Convention d'axes du manifeste — la partie qu'on ne peut pas se permettre de rater
| Règle | Valeur |
|---|---|
| Système de coordonnées | **glTF : main droite, Y-up, unités en mètres** |
| Origine | le **point de spawn** du visiteur, au sol (y = 0) |
| Rotation | **quaternion `[x, y, z, w]`** — non ambigu, pas d'ordre d'axes à deviner |
| Échelle | `[x, y, z]`, défaut `[1, 1, 1]` |
| Angles | jamais de degrés dans le manifeste |
**Pourquoi glTF et pas Unity.** C'est la convention des assets eux-mêmes, **et** celle de three.js —
qui est nativement main droite Y-up. Donc :
- le **viewer web ne convertit rien** : il édite et affiche dans l'espace du manifeste ;
- le **placement produit par le client est déjà dans le bon repère**, puisqu'il est produit dans le
viewer (décision D2) ;
- **Unity est le seul convertisseur**, à un seul endroit du code.
C'est exactement la contrainte posée au départ : *« conversion à l'entrée d'Unity uniquement »*.
### L'argument décisif — et ce n'est pas celui auquel on pense
La tentation, c'est de stocker en convention **Unity** pour épargner la conversion au client le plus
contraint. **Ça ne supprime rien** : le GLB du monde est de toute façon converti par l'importeur.
On déplacerait donc juste le désaccord — coordonnées brutes contre géométrie convertie, même risque
de miroir, dans l'autre sens.
Ce qui départage les deux options n'est donc pas le nombre de conversions, mais **où une erreur de
signe fait mal** :
| Convention du manifeste | Où vit la conversion | Conséquence d'une erreur de signe |
|---|---|---|
| **glTF** (retenu) | dans Unity, en lecture | **bug d'affichage.** Une ligne à corriger, aucune donnée à reprendre |
| Unity | dans le viewer, en **lecture et écriture** | **corruption de données.** Le client a placé 40 objets, ils sont stockés faux, et la correction demande une migration |
Le viewer est l'endroit où la donnée est **écrite**. On n'y met pas la conversion.
Et il y a un bénéfice pratique qui achève la question : dans le viewer, le monde est chargé par
three.js **sans conversion**. Le client place donc ses objets **dans le même espace que celui où il
voit le monde** — la cohérence n'est pas garantie par du code, elle est vraie par construction.
⚠️ **Le piège, et il est réel.** Unity est **main gauche**. glTFast applique une conversion à la
géométrie qu'il importe ; mes coordonnées de manifeste, elles, **ne passent par aucun importeur**.
Si ma conversion et celle de glTFast ne sont pas la même, les personnages sont en miroir par rapport
au monde — et le bug est **invisible sur une scène symétrique**, ce qui le rend cher.
➡️ **Une seule fonction dans tout le projet Unity**, `GltfSpace.ToUnity(Vector3)` /
`ToUnity(Quaternion)`, et **son signe est confirmé empiriquement, pas supposé** : un GLB de
calibration en forme de **L asymétrique** (branche longue vers +X, branche courte vers +Z, une
marque colorée sur une seule face), chargé à côté d'un marqueur placé par manifeste aux mêmes
coordonnées. Les deux se superposent, ou la conversion est fausse. Ce GLB de calibration est un
livrable de l'étape 1, pas un raffinement.
### 2.2 Schéma
```jsonc
{
"manifestVersion": 1, // version du FORMAT — change si le schéma change
"sceneId": "sec_ab12…",
"instanceId": "inst_…",
"configurationId": "cfg_…",
"version": 42, // version du CONTENU — monotone, +1 à chaque publication
"publishedAt": "2026-09-03T10:22:41Z",
"languages": ["fr", "nl", "en", "de"],
"defaultLanguage": "fr",
"coordinateSystem": "gltf/y-up/right-handed/meters", // littéral, vérifié par le client
"navigation": {
"spawn": { "position": [0,0,0], "rotation": [0,0,0,1] },
"radiusMeters": 3.0, // plafond dur 3.0, appliqué serveur ET client
"showBoundary": true
},
"environment": {
"lightingPreset": "neutral-indoor",
"hdriAssetId": null,
"exposure": 1.0
},
"world": {
"kind": "mesh", // "mesh" | "splat" | "panorama" ← la couture V2
"assetId": "a_world_01",
"transform": { "position": [0,0,0], "rotation": [0,0,0,1], "scale": [1,1,1] }
},
"objects": [
{ "id": "o_1", "label": "Amphore",
"assetId": "a_amphora",
"transform": { "position": [1.2,0,-0.8], "rotation": [0,0.707,0,0.707], "scale": [1,1,1] } }
],
"personas": [
{ "id": "p_1",
"personaId": null, // null en V1 (pas d'entité Persona) — la couture
"name": "Le gouverneur",
"assetId": "a_char_governor",
"transform": { "position": [-1.5,0,-1.0], "rotation": [0,1,0,0], "scale": [1,1,1] },
"animation": { "idleClip": "Idle", "loop": true },
"gazeAtVisitor": true,
"audio": [ { "language": "fr", "assetId": "a_audio_gov_fr" } ],
"script": [ { "language": "fr", "value": "Bienvenue dans…" } ] }
],
"hotspots": [
{ "id": "h_1", "geoPointId": 128,
"transform": { "position": [0.4,1.4,-2.1], "rotation": [0,0,0,1] },
"title": [ { "language": "fr", "value": "La mosaïque" } ],
"description": [ { "language": "fr", "value": "…" } ],
"contents": [
{ "order": 0,
"title": [ { "language": "fr", "value": "…" } ],
"description": [ { "language": "fr", "value": "…" } ],
"assetId": "a_audio_mosaic_fr" } ] }
],
"assets": [
{ "id": "a_world_01",
"resourceId": "res_…",
"url": "https://…/o/pictures%2Finst_…%2Fres_…?alt=media&token=…",
"mimeType": "model/gltf-binary",
"sizeBytes": 184320000,
"sha256": "9f2c…",
"variants": [] } // ← KTX2 / Meshopt viendront ici, sans casser le contrat
],
"budget": { "totalBytes": 214000000, "limitBytes": 1073741824 },
"provenance": {
"aiGenerated": false,
"rightsHolder": "Musée de …",
"sources": []
}
}
```
### 2.3 Les cinq choix de structure, et leur raison
| Choix | Raison |
|---|---|
| **`assets[]` séparé des références** | Dédoublonnage, et surtout : c'est la **seule** liste que le téléchargeur parcourt. Le budget et les hash s'y calculent une fois. C'est le motif déjà utilisé par `ExportConfigurationDTO` (`sections` + `resources` à plat) |
| **Toutes les langues d'un coup** | Un casque en borne change de langue à chaud. L'export existant est mono-langue (`?language=`) — c'est le seul point où le manifeste s'écarte du contrat existant, et il le doit |
| **`version` séparé de `manifestVersion`** | L'un dit « le contenu a changé », l'autre « le schéma a changé ». Les confondre rend impossible de livrer une app qui lit les deux |
| **`world.kind`** | La couture pour les splats gaussiens. En V1 le client ne lit que `"mesh"` et **refuse explicitement** les autres — un refus lisible vaut mieux qu'un rendu vide |
| **`personaId` nullable** | La couture pour l'entité `Persona` du lot 7 Studio. En V1 il est `null` et `name`/`assetId` portent tout. Quand `Persona` existe, il se remplit et le reste devient dérivé |
### 2.4 Le hash, et ce qu'il achète
`sha256` par asset est la seule chose qui permet un **delta réel** : le casque compare, ne
retélécharge que ce qui a changé, et détecte un fichier corrompu. Sans lui, un changement de version
force le retéléchargement de tout — et à plusieurs centaines de Mo par monde, sur le wifi d'un
musée, ça se compte en heures.
⚠️ Il n'existe nulle part aujourd'hui (§2 de l'état des lieux). Il se calcule **à la publication**,
pas à l'upload : c'est un job Hangfire qui lit le blob et écrit le hash.
---
## 3. Modèle de données
**Tout est additif. Aucune donnée existante n'est touchée. Aucune valeur d'enum n'est réordonnée.**
### 3.1 Le nouveau type de section
L'héritage des sections est en **TPH avec un discriminant `string`**
(`MyInfoMateDbContext.cs:329-348`). Ajouter un sous-type, c'est donc **une ligne de mapping et des
colonnes nullables** — pas de nouvelle table, pas de recopie.
```csharp
// Data/SubSection/SectionScene3D.cs
public class SectionScene3D : Section
{
public string? WorldResourceId { get; set; }
public Resource? WorldResource { get; set; }
public World3DKind WorldKind { get; set; } = World3DKind.Mesh;
[Column(TypeName = "jsonb")] public TransformDTO? SpawnTransform { get; set; }
public double NavigationRadiusMeters { get; set; } = 3.0; // plafond 3.0, validé serveur
public bool ShowBoundary { get; set; } = true;
public string? LightingPreset { get; set; }
public string? HdriResourceId { get; set; }
public double Exposure { get; set; } = 1.0;
[Column(TypeName = "jsonb")] public List<Scene3DObjectDTO> Objects { get; set; }
[Column(TypeName = "jsonb")] public List<Scene3DPersonaDTO> Personas { get; set; }
public List<GeoPoint> Hotspots { get; set; } // ← relation, pas jsonb : voir 3.2
public int PublishedVersion { get; set; } = 0;
public DateTime? PublishedAt { get; set; }
public override string GetEmbeddableText(string language) => …
public override IEnumerable<string> GetReferencedResourceIds(string language = null) => …
}
public enum World3DKind { Mesh, Splat, Panorama } // valeurs 0,1,2 — ajouts EN FIN
```
**`Objects` et `Personas` en jsonb, `Hotspots` en relation.** Ce n'est pas une incohérence :
- un objet ou un personnage placé est **une position et un id d'asset**, jamais interrogé seul,
jamais réutilisé ailleurs → jsonb, exactement comme `SectionMap.MapCategories` ;
- un hotspot porte **du contenu éditorial multilingue avec de l'audio**, doit être indexable par le
guide IA, et réutilise l'éditeur de POI existant → c'est un `GeoPoint`, entité à part entière.
⚠️ **`GetReferencedResourceIds` est `abstract`, pas `virtual`** — le commentaire de `Section.cs` dit
pourquoi : *« la collecte des ressources vivait dans un switch centralisé […] il est devenu faux en
silence »*. Le compilateur réclamera l'implémentation. Elle doit rendre : le monde, l'HDRI, chaque
asset d'objet, chaque asset et audio de persona, et les ressources de chaque hotspot. **Un oubli ici
n'est pas une erreur d'affichage : c'est un asset manquant sur un casque hors ligne, sur site.**
### 3.2 Le hotspot est un `GeoPoint`
`GeoPoint` porte **déjà** deux clés étrangères nullables (`SectionMapId`, `SectionEventId`). On en
ajoute une troisième — c'est littéralement le motif existant :
```csharp
public string? SectionScene3DId { get; set; }
[ForeignKey(nameof(SectionScene3DId))] public SectionScene3D? SectionScene3D { get; set; }
[Column(TypeName = "jsonb")] public TransformDTO? LocalTransform { get; set; }
```
`LocalTransform` cohabite avec le `Geometry` PostGIS sans conflit : l'un est une lat/lon sur Terre,
l'autre un `(x, y, z)` local à une scène. Un `GeoPoint` a l'un **ou** l'autre.
➡️ Ce que ça donne gratuitement : `Title`, `Description`, `Contents` (titre + description +
`Resource` audio, multilingue), `ImageResourceId`, et `GetEmbeddableText` — donc **le guide IA sait
déjà lire un hotspot 3D**, sans une ligne de plus.
### 3.3 Les enums à étendre — toutes en fin, toutes persistées en int
```
SectionType += Scene3D // valeur 13
ResourceType += Model3D // valeur 11
+= Panorama360 // valeur 12 (carte kanban 250, arrive avec)
ApiKeyAppType += VrApp // valeur 3
```
Et le discriminant TPH : `.HasValue<SectionScene3D>("Scene3D")`.
### 3.4 Le device devient multi-canal — lot V-1, inchangé
Repris tel quel de `vr-quest-unity-plan.md` §2 :
```
Device += AppType (int, défaut Tablet = 1)
DeviceController.Create : résoudre l'ApplicationInstance sur newDevice.appType,
au lieu du AppType.Tablet en dur (DeviceController.cs:155)
DeviceController.GetAll : paramètre de requête appType optionnel
```
⚠️ `Device.ConfigurationId` est `[Required]` alors que son propre commentaire dit
« OLD WAY → AppConfigurationLink ». Le casque passe par `AppConfigurationLink` ; on remplit quand
même le champ pour ne pas violer la contrainte.
### 3.5 La publication — le choix étroit, assumé
L'état des lieux (§5) a montré qu'il **n'existe aucun état publié** dans tout le CMS : une
modification est immédiatement visible par tous les clients.
**Décision : on ne refond pas la publication globale pour la V1.** `PublishedVersion` et
`PublishedAt` vivent **sur `SectionScene3D` seulement**. Le manifeste servi à l'app est celui de la
dernière publication ; le viewer du back-office lit le brouillon.
| Pourquoi pas la refonte globale | |
|---|---|
| Coût | un cycle de vie brouillon/publié sur `Configuration` touche les 13 types, les 3 clients visiteurs et le mode offline |
| Risque | c'est le genre de chantier qui casse ce qui marchait |
| Bénéfice V1 | nul : seule la scène 3D a besoin d'un « ne change pas sous les pieds du visiteur » |
⚠️ **Dette assumée, à écrire dans le suivi** : le jour où la publication devient globale,
`SectionScene3D.PublishedVersion` doit y être absorbé, pas coexister. C'est une dette **connue et
datée**, pas un oubli.
### 3.6 Ce que la migration contient
Une seule migration, additive :
- colonnes nullables de `SectionScene3D` sur la table `Sections` (TPH) ;
- `GeoPoints += SectionScene3DId, LocalTransform` ;
- `Devices += AppType` (défaut `1`) ;
- `Resources += Sha256` (nullable) ;
- rien à supprimer, rien à recopier, aucune donnée existante modifiée.
⚠️ **Contexte à ne pas perdre** : la bascule Postgres n'est pas faite et le schéma est gelé
(`STATUS.md` §1ter, lot B). Cette migration est **purement additive**, donc elle ne concurrence pas
la bascule — mais elle doit être posée en connaissance de cause, pas en parallèle sans le dire.
---
## 4. Contrat d'API
### 4.1 Ce qui ne change pas
La création, la lecture et la mise à jour d'une `SectionScene3D` passent par le
**`SectionController` existant**, générique sur `SectionDTO` + `SectionFactory`. Aucun endpoint
CRUD nouveau. Les points de couture à toucher sont connus et se comptent :
| Fichier | Ce qu'il faut y ajouter |
|---|---|
| `DTOs/SectionType.cs` | la valeur `Scene3D` |
| `Services/SectionFactory.cs` | 4 emplacements : `CreateEmpty`, le `switch` de désérialisation, le `switch` de construction, `ToDTO` |
| `Controllers/SectionController.cs` | 1 `switch` |
| `Services/IngestionService.cs` | 1 `switch` (indexation RAG) |
| `Data/MyInfoMateDbContext.cs` | 1 ligne de discriminant |
### 4.2 Ce qui s'ajoute — côté back-office
```
GET /api/Scene3D/{sectionId}/manifest?draft=true
[Authorize ContentEditor]
→ SceneManifestDTO (brouillon, toutes langues, urls signées)
Alimente le viewer web du back-office.
POST /api/Scene3D/{sectionId}/publish
[Authorize ContentEditor]
→ { version, publishedAt, totalBytes }
Valide (rayon ≤ 3 m, assets présents, budget), incrémente PublishedVersion,
enqueue le calcul des hash manquants, journalise dans AuditLog.
GET /api/Scene3D/{sectionId}/budget
[Authorize Viewer]
→ { totalBytes, limitBytes, perAsset[] }
Affiché en continu dans l'éditeur — pas seulement à la publication.
```
**Le budget est calculé et affiché en continu, pas au moment de publier.** C'est la demande
explicite du cahier des charges (« sinon le stockage du casque explose et je le découvre sur site »),
et l'expérience de la jauge de stockage du menu latéral — qui ne se rafraîchit pas et affiche
« 0 KB » en même temps qu'un refus 413 (bug ouvert, `STATUS.md` §1) — dit exactement ce qu'il ne faut
pas refaire : **un chiffre affiché doit être le chiffre qui bloque.**
### 4.3 Ce qui s'ajoute — côté app Quest
```
GET /api/instance/app-key?pinCode={pin}&appType=VrApp [AllowAnonymous]
→ { key, instanceId }
⚠️ La route réelle est bien "app-key" (InstanceController.cs, HttpGet("app-key")),
pas "appKeyByPin" comme l'écrivait le plan du 31/08.
POST /api/device { identifier, name, instanceId, appType: VR }
→ enregistre le casque, le rattache à l'ApplicationInstance VR
GET /api/Scene3D/{sectionId}/manifest [X-Api-Key, AppReadAccess]
→ SceneManifestDTO publié, toutes langues
GET {asset.url} téléchargement direct du blob
POST /api/stats/visit-event { appType: VR, … } télémétrie
MQTT player/{deviceId} notification « du neuf est publié »
```
**Aucun endpoint de contenu spécifique à Unity au-delà du manifeste.** C'est la leçon du lot V-3 du
plan d'août, et elle tient : un client non-Flutter ne doit avoir qu'**un** point d'entrée de contenu.
### 4.4 Authentification et fraîcheur
Le casque garde sa clé d'API et son `instanceId` en stockage local après appairage. Au démarrage :
1. lecture du **cache local** → l'app est utilisable immédiatement, même sans réseau ;
2. en tâche de fond, `GET manifest` → comparaison de `version` ;
3. si `version` a changé : téléchargement des assets dont le `sha256` diffère ;
4. **application au retour au menu**, jamais pendant qu'un visiteur est immergé ;
5. si le manifeste ne se charge pas : **la dernière version valide reste active**, silencieusement.
Le point 5 n'est pas un détail de robustesse, c'est la promesse produit : *une borne ne tombe jamais
en panne parce que le wifi du musée a bougé.*
---
## 5. Flux asynchrone de génération de mondes — V1.5
> Rappel de la décision **D5** : **hors V1**. En V1 le monde est un GLB uploadé. Cette section décrit
> la greffe, pour qu'on ne code rien aujourd'hui qui l'empêche.
Marble **n'est pas un chantier séparé** : c'est une implémentation de plus de l'interface
`IGenerationProvider` déjà spécifiée (`studio-plan.md` §3.6).
```
GenerationModel (en base, pas dans le code)
Key = "world-default"
ProviderKey = "worldlabs"
ProviderModelId = "marble-…" ← la seule chaîne qui change quand le marché bouge
Kind = World
CreditCost = 1500 ← monde standard
Params jsonb = { draftCost: 150, meshExportCost: 3500, … }
```
```
POST /api/Studio/generate { kind: World, inputResourceIds[], … }
→ validations, Reserve(crédits), GenerationJob(Queued), 202 { jobId, estimatedCredits }
Hangfire worker
→ provider.SubmitAsync → ProviderRequestId, Status=Running
→ BackgroundJob.Schedule(PollFallback, +5 min) ← filet, la génération dure 5 min à 1 h
POST /api/StudioWebhook/worldlabs (HMAC vérifié — patron : StripeWebhookController)
→ télécharge le GLB, écrit dans le bucket (lot 0), crée la Resource(Model3D),
remplit AiProvenance, Charge(crédits réels), Status=Succeeded
GET /api/Studio/jobs/{id} polling front à 2 s tant qu'un job est ouvert à l'écran
```
**Trois points propres à Marble** que le Studio générique ne couvre pas :
1. **L'export mesh GLB HQ est facturé une seule fois par monde**, résultat mis en cache ensuite. Le
`CreditLedger` doit donc distinguer *générer le monde* et *en exporter le mesh* : deux `Charge`
sur le même job, la seconde conditionnelle.
2. **Une génération depuis un input non-panoramique peut ajouter un événement de facturation
« pano generation »** — donc le coût réel peut **dépasser** l'estimation affichée. Conséquence de
conception : `Reserve` prend une marge, et `Charge` ajuste au réel. Ne jamais afficher l'estimation
comme un prix ferme.
3. **Les sources de capture sont des `Resource`** (la vidéo, le panorama). Elles entrent dans
`AiProvenance.sources` — c'est ce que demande la traçabilité institutionnelle : modèle, prompt,
date, auteur, **sources**.
### Ce qu'on ne doit pas coder aujourd'hui
| Interdit en V1 | Parce que |
|---|---|
| Un `WorldLabsService` appelé depuis le module VR | il court-circuiterait crédits, journal, provenance |
| Un nom de modèle Marble dans le code ou l'`appsettings.json` | contrainte explicite : configuration serveur versionnable, catalogue en base |
| La clé d'API dans `appsettings.json` | le fichier versionné contient déjà 4 secrets en clair — variable d'environnement, volume `/etc/managerservice` |
---
## 6. Le projet Unity
### 6.1 Structure
```
vr-app/unity/MyInfoMateVR/
Assets/
Scenes/
Boot.unity ← LA scène. Une seule, pour tous les clients, tous les mondes.
Scripts/
Boot/
AppBootstrap.cs orchestre : provisioning → cache → manifeste → scène → menu
AppState.cs machine à états : Provisioning | Syncing | Menu | InScene | Error
Provisioning/
PinCodeFlow.cs saisie du code d'instance, GET app-key, POST device
CredentialStore.cs clé d'API + instanceId, persistés
Content/
SceneManifest.cs les DTO du manifeste — miroir exact du §2.2
ManifestClient.cs GET manifeste, comparaison de version
AssetCache.cs disque, indexé par sha256, éviction, fallback dernière version valide
AssetDownloader.cs file de téléchargement, reprise, progression
Scene/
GltfSpace.cs ⚠️ LA conversion glTF → Unity. Le seul endroit.
SceneBuilder.cs instancie monde, objets, personas, hotspots depuis le manifeste
WorldLoader.cs glTFast, refuse explicitement kind != "mesh" en V1
PersonaInstance.cs idle, orientation vers le visiteur, audio spatialisé
HotspotInstance.cs raycast, surbrillance, lecture audio
NavigationBounds.cs zone circulaire, rendu type garde-fou, plafond 3 m
UI/
FloatingMenu.cs sélection de scène, langue, relance audio, sortie
KioskSession.cs détection de retrait du casque → reset (décision D4)
Telemetry/
VisitEventReporter.cs POST des events avec appType = VR
StreamingAssets/
calibration.glb le L asymétrique du §2.1
Packages/manifest.json
ProjectSettings/
```
**Une seule scène Unity.** `Boot.unity` contient le rig VR, le menu et rien d'autre ; tout le reste
est instancié au runtime depuis le manifeste. C'est la traduction directe de la règle du §1.
### 6.2 Les quatre scripts qui portent le risque
| Script | Le risque |
|---|---|
| `GltfSpace.cs` | conversion miroir invisible sur une scène symétrique (§2.1). **Validé par la calibration, pas par la relecture** |
| `AssetCache.cs` | plusieurs centaines de Mo, coupure réseau, disque plein, fichier corrompu. C'est ici que se joue « la borne ne tombe pas en panne » |
| `KioskSession.cs` | un état résiduel entre deux visiteurs (langue changée, audio en cours, position). Le reset doit être **total**, pas un retour de caméra |
| `WorldLoader.cs` | un GLB de 200 Mo chargé sur le thread principal fige le casque et donne la nausée. Chargement asynchrone obligatoire, écran de progression diégétique |
### 6.3 Ce que je génère, ce que tu fais
| Je génère | Tu fais |
|---|---|
| Tous les `.cs` ci-dessus | Les réglages de `ProjectSettings` (guide phase 0 §5-6) |
| `Packages/manifest.json` | L'import du SDK Meta depuis l'Asset Store |
| Un script d'éditeur `[MenuItem]` qui **construit `Boot.unity` par code** | Le premier *Build And Run* |
| Le `.gitignore` (fait) | L'appairage du casque |
**Pourquoi la scène par code plutôt qu'un `.unity` écrit à la main** : le YAML Unity est plein de
GUID et de `fileID` internes. Écrit à la main, il produit un projet qui s'ouvre mais dont un réglage
sur deux est faux **en silence** — le pire des cas. Un script d'éditeur est lisible, versionnable,
rejouable et diffable.
---
## 7. Le viewer web
**Une application web autonome** (décision D3), une seule, **partagée par les quatre fronts** :
`manager-app` en édition, `visitapp-web` et `mymuseum-visitapp` en lecture, Unity exclu (rendu natif).
```
vr-app/viewer/ autonome, déployé à part — tranché le 2026-09-03
src/
manifest.ts les mêmes types que SceneManifest.cs, côté TS
Viewer.ts THREE.js : scène, caméra orbitale, chargement glTF
Editor.ts sélection, raycast au sol, poignée de rotation, undo
Bounds.ts le cercle de navigation, à l'échelle
Panel.ts valeurs numériques, budget, liste des objets
```
### Les trois règles à poser dès la V1
Elles gardent la porte ouverte au portage Flutter mobile et à l'offline (D3), sans l'imposer comme
périmètre V1 — **si la 3D hors ligne s'avère trop chère, on l'assume et on le signale dans
l'éditeur**, comme le fait déjà `SectionVideo` pour une vidéo distante (`SectionVideo.cs:25-27`).
Elles ne coûtent rien maintenant, et c'est de toute façon du bon design :
1. **Fichiers statiques**, embarquables dans les assets d'une app Flutter et servables depuis une
origine locale. Pas de build qui suppose un serveur.
2. **Aucune URL d'API en dur.** Le viewer ne connaît que le manifeste qu'on lui donne ; toutes les
URL d'assets viennent de `assets[].url`. C'est ce qui permet de lui passer un manifeste dont les
URL ont été **réécrites en chemins locaux** — exactement ce que fait déjà le pipeline offline.
3. **Le manifeste est une entrée, pas un fetch.** Il s'injecte (`postMessage` / paramètre), pas
seulement par URL. Sans ça, pas d'offline.
Ces trois règles ne coûtent rien maintenant. Elles coûtent une réécriture si on les découvre au
moment du portage.
### Trois exigences non négociables
1. **Aucune conversion de coordonnées.** three.js est nativement main droite Y-up : le viewer lit et
écrit dans l'espace du manifeste, point. C'est ce qui fait que le placement produit ici est
directement juste dans Unity.
2. **Aperçu indicatif, annoncé comme tel.** Alignement à tenir : même tone mapping, même HDRI
d'environnement, **PBR core glTF uniquement** (pas d'extension de matériau exotique). Le mot
« aperçu » doit être dans l'interface, pas seulement dans la doc — sinon le client validera une
couleur qu'il ne retrouvera pas au casque.
3. **Il est aussi l'éditeur** (décision D2). Donc : état d'édition, undo, enregistrement explicite.
C'est la moitié de son coût, et c'est la partie qu'on sous-estime.
### L'intégration dans `manager-app`
`manager-app` est en **Flutter Web** : le viewer y entre en **iframe** (`HtmlElementView`), avec un
`postMessage` dans les deux sens :
```
manager-app → viewer : { type: "load", manifestUrl, mode: "edit" }
viewer → manager-app : { type: "changed", objects, personas, hotspots }
{ type: "dirty", isDirty: true }
```
Ce n'est pas un contournement : Flutter Web ne peut pas héberger THREE.js autrement. Et le bénéfice
est réel — le même viewer se monte comme composant React dans `visitapp-web`, en mode lecture seule.
---
## 8. Ce qui n'est pas dans cette conception, volontairement
| Hors périmètre | Où ça va |
|---|---|
| Les 13 types de section rendus en immersif | Décision D1. `vr-quest-unity-plan.md` §8 garde le cap pour plus tard |
| Splats gaussiens | `world.kind = "splat"`, refusé par le client V1. Prototype séparé |
| Assistant IA dans le casque | Zéro backend nécessaire (`AiController` prend déjà un `AppType`), mais le TTS est côté client et à réimplémenter en C#. Après la V1 |
| Lip sync | Écarté au niveau produit, définitivement |
| MDM / flotte de casques | Inutile sous 10 casques. Lot V-5 |
| Éditeur 3D complet (gizmos, hiérarchie, matériaux) | Jamais. Le client compose, il ne modélise pas |
| KTX2 / Meshopt exécutés serveur | Défaut retenu : GLB servi tel quel en V1, `variants[]` posé dans le contrat |
---
## 9. Les questions soulevées — état au 2026-09-03
1. ~~**Où vit le viewer web ?**~~ **Tranché : `vr-app/viewer/`, autonome.** Et le périmètre s'élargit —
il sert **quatre** fronts, `mymuseum-visitapp` compris (WebView, hors ligne). Voir D3 et §7.
2. **Combien de scènes par expérience ?** Une `SectionScene3D` = une scène ; plusieurs scènes =
plusieurs sections dans la même `Configuration`, listées par un `SectionMenu` — ce qui marche
déjà. **Retenu à titre provisoire**, à confirmer sur un cas concret : c'est le genre de choix qui
se valide en composant une vraie expérience, pas sur le papier.

319
docs/04-phase3-plan.md Normal file
View File

@ -0,0 +1,319 @@
# Piste Scène 3D — Plan d'implémentation séquencé (S0 → S8)
> ⚠️ **Numérotation, lis ceci d'abord.** Les étapes de ce plan s'appellent **S0 à S8** depuis le
> 2026-09-11. Elles s'appelaient E0 à E8, ce qui les confondait avec les items **E0 à E11 du lot
> XR-4** de [`../../DOCS/v2/vr-quest-unity-plan.md`](../../DOCS/v2/vr-quest-unity-plan.md), qui
> sont autre chose.
>
> **Le plan unifié gouverne.** Ce document est le détail d'ingénierie d'une **piste**, pas le plan
> du canal : la scène 3D composable (`SectionScene3D`, manifeste, éditeur web), qui alimente
> **XR-4/E7** (viewer GLB + POI). Le périmètre du POC, lui, est `XR-4 : E0 → E2 → E3 → E5 → E6`
> menu immersif et vidéo 360 — et il passe avant.
>
> En cas de contradiction entre les deux documents, **le plus récemment mis à jour tranche**.
>
> **Fait à ce jour (2026-09-12)** : S0 ✅, S1 ✅ — **le cercle de navigation est là** aussi
> (`NavigationBounds.cs`, 3 m, repousse sans téléporter), S2 ✅ — **l'audio
> d'un hotspot est là** (`HotspotInstance.cs`, spatialisé, ⚠️ lu **depuis l'URL et non depuis le
> cache** : muet hors ligne), S3 écrit mais jamais essayé.
>
> ⚠️ **S4 à S8 ont été absorbés par la piste E le 2026-09-12** et n'existent plus comme étapes
> propres : `SectionScene3D` + son contrôleur (S4), l'écran manager-app (S5), réseau/cache/sync
> (S6), menu/langues/kiosque/télémétrie (S7) et la flotte (S8) sont écrits. **Un écart assumé** :
> il n'y a **pas** de route `GET /api/Scene3D/{id}/manifest` — le manifeste est construit côté
> client, dans `Scene3DView` comme dans le viewer web, à partir de l'export. Le contrat de S2
> reste le contrat ; seul son lieu de fabrication a changé.
>
> Détail dans [`../README.md`](../README.md).
> Écrit le 2026-09-03, à partir de [00 (état des lieux)](00-phase1-etat-des-lieux.md),
> [02 (décisions D1-D6)](02-decisions.md) et [03 (conception)](03-phase2-conception.md).
>
> Format imposé, tenu à chaque étape : **ce que je génère** · **ce que tu fais à la main** ·
> **le critère de validation**. Un critère de validation est une chose qu'on *voit*, pas une case
> qu'on coche.
---
## 0. La règle de séquencement
Trois principes ont dicté l'ordre, et aucun n'est esthétique :
1. **Le risque technique se lève avant le confort.** Ce qui peut échouer bêtement (conversion d'axes,
poids d'un monde, perf sur casque) passe en premier, sur un cas minimal.
2. **Aucun backend tant qu'il n'est pas nécessaire.** Grâce à la décision **D5** (mondes uploadés,
pas générés), les trois premières étapes n'écrivent **pas une ligne** dans `manager-service`.
C'est ce qui permet de démarrer aujourd'hui sans toucher au backlog V1/Postgres.
3. **Le manifeste avant les deux clients.** Il est le contrat ; l'écrire après l'un des deux, c'est
le tailler pour lui.
### Vue d'ensemble
```
S0 Setup Unity ──► S1 Chaîne bout en bout ──► S2 Manifeste local
(0,5-1 j) (3-5 j) ⚠️ OBLIGATOIRE (2-3 j)
┌───────────────────────┴──────────────┐
▼ ▼
S3 Viewer + éditeur web S4 Backend SectionScene3D
(8-12 j) (4-6 j)
│ │
└──────────────┬───────────────────────┘
S5 Écran manager-app (5-8 j)
S6 Unity : réseau, cache, sync (5-8 j)
S7 Menu, langues, kiosk, télémétrie (4-6 j)
S8 Flotte de casques (6-8 j)
```
**Total V1 : 8 à 10 semaines** d'un dev solo. Cohérent avec l'estimation d'août (POC 3-4 sem.,
couverture 6-10 sem.) — la conception a déplacé de l'effort de l'app vers le viewer, qui sert
maintenant quatre fronts.
### Les deux jalons qui comptent
| Jalon | Après | Ce qu'il prouve |
|---|---|---|
| 🔬 **Preuve technique** | S1 | La chaîne marche. Tant que tu ne l'as pas vue de tes yeux, tout le reste est théorique |
| 💼 **Démo vendable** | S3 | On compose dans un navigateur, on voit dans le casque. C'est ce qu'on montre à un client pilote — **avant** d'engager S4 à S8 |
⚠️ **Le go/no-go se place ici, après S3**, pas au début. À ce stade on a dépensé ~4 semaines et on a
quelque chose à montrer ; S4-S8 (~5 semaines) ne s'engagent qu'avec un pilote identifié.
---
# S0 — Setup Unity
**0,5 à 1 jour.** Détail complet : [01-phase0-setup-unity.md](01-phase0-setup-unity.md).
| | |
|---|---|
| **Je génère** | `Packages/manifest.json`, `.gitignore` (fait) |
| **Tu fais** | Unity Hub + Unity 6 LTS avec Android Build Support (**OpenJDK et SDK & NDK Tools cochés**), création du projet URP, Meta XR SDK depuis l'Asset Store, réglages Player/XR (guide §5-6), mode développeur casque |
| **✅ Validation** | Un cube devant toi, en relief, qui **reste immobile quand tu bouges la tête**. S'il suit ton regard, le rig n'est pas en place |
⚠️ Le premier *Build And Run* prend 10-30 min (IL2CPP). Les suivants, 1-3 min. Ne pas conclure à un
plantage.
---
# S1 — La chaîne technique de bout en bout ⚠️ OBLIGATOIRE
**3 à 5 jours. Aucun backend. Aucune ligne dans `manager-service`.**
C'est l'étape que le cahier des charges impose avant tout le reste, et elle le mérite : elle lève
les trois risques qui coûteraient le plus cher découverts tard.
### Ce que tu fais à la main
1. **Générer un monde sur Marble**, à partir d'une vraie capture (une vidéo d'une pièce te suffit).
Draft d'abord (~20 s, 150 crédits) pour valider le cadrage, puis monde complet (~5 min,
1 500 crédits), puis **export mesh GLB HQ** (jusqu'à 1 h, 3 500 crédits, facturé une seule fois).
2. Déposer le `.glb` dans `Assets/StreamingAssets/world.glb`.
3. Trouver **un personnage stylisé GLB** avec une animation idle (décision D6 : bibliothèque externe
sous licence — Mixamo pour l'animation, un pack de modèles stylisés pour le corps). **Note la
licence**, elle devra être tracée.
4. *Build And Run*.
### Ce que je génère
| Script | Rôle |
|---|---|
| `GltfSpace.cs` | La conversion glTF → Unity. **Le seul endroit du projet où elle existe** |
| `WorldLoader.cs` | Chargement **runtime** par glTFast depuis `StreamingAssets` — pas un glisser-déposer d'éditeur |
| `CalibrationCheck.cs` | Place un marqueur par coordonnées à côté du L de calibration |
| `NavigationBounds.cs` | Cercle de 3 m, rendu type garde-fou |
| `PersonaInstance.cs` | Instancie le personnage, joue l'idle, l'oriente vers le visiteur |
| Un GLB de calibration | L asymétrique : branche longue vers +X, courte vers +Z, marque colorée sur **une seule** face |
### ✅ Critères de validation — quatre, tous visuels
1. Le monde s'affiche dans le casque et tu peux **tourner la tête dedans**.
2. **Le marqueur placé par coordonnées se superpose exactement au repère du GLB de calibration.**
S'il est du mauvais côté, la conversion est en miroir — et c'est le bug qu'on paie le plus cher
plus tard, parce qu'il est invisible sur une scène symétrique.
3. Le personnage est **debout, à la bonne taille** (~1,7 m contre le décor), animé, et il te regarde.
4. Le cercle de navigation est visible et tu **ne peux pas en sortir**.
### Ce que cette étape mesure, et qu'aucune doc ne peut donner
- **Le poids réel d'un monde Marble** exporté en mesh. C'est le chiffre qui décide de F5 (KTX2 dans
le conteneur, ou GLB servi tel quel) — et on ne peut pas le décider avant de l'avoir.
- **Les FPS sur casque** avec ce monde. Le budget de scène en découle, pas l'inverse.
- **Le temps de chargement** d'un GLB de cette taille, donc si l'écran de progression est un détail
ou un sujet.
➡️ **Rapporte-moi ces trois nombres.** Ils réécrivent une partie du §7 de la conception.
---
# S2 — Le manifeste, lu localement
**2 à 3 jours. Toujours aucun backend.**
La scène cesse d'être codée en dur : elle est **construite à partir d'un JSON**. C'est ici que la
règle du §1 de la conception (« un rebuild seulement pour une fonctionnalité ») devient vraie.
| | |
|---|---|
| **Je génère** | `SceneManifest.cs` (miroir exact du schéma §2.2), `SceneBuilder.cs`, `HotspotInstance.cs`, et un `scene.json` d'exemple complet |
| **Tu fais** | Poser le `scene.json` sur le casque (`adb push`, ou MQDH), déplacer des valeurs à la main, relancer l'app |
| **✅ Validation** | **Tu changes une position dans le JSON, tu relances l'app — sans recompiler — et l'objet a bougé.** Tu ajoutes un hotspot, il apparaît et joue son audio au raycast |
⚠️ Le refus explicite est un livrable, pas un oubli : un `world.kind` autre que `"mesh"` doit
afficher un message lisible, pas une scène vide.
---
# S3 — Le viewer web et son éditeur
**8 à 12 jours.** L'étape la plus lourde, et la plus rentable : ce viewer sert **quatre fronts**
(décision D3).
| | |
|---|---|
| **Je génère** | Le projet `vr-app/viewer/` complet : `manifest.ts` (mêmes types qu'en C#), `Viewer.ts` (three.js, chargement glTF, tone mapping et HDRI **alignés sur Unity**), `Editor.ts` (sélection, raycast au sol, poignée de rotation, undo), `Bounds.ts`, `Panel.ts` (valeurs numériques + budget), le protocole `postMessage` |
| **Tu fais** | `npm install && npm run dev`, charger le `scene.json` de S2, placer des objets à la souris |
| **✅ Validation** | **Le test croisé, et c'est le vrai but de l'étape** : tu places trois objets dans le viewer, tu exportes le JSON, tu le pousses sur le casque — **les trois objets sont exactement là où tu les as mis**. C'est la seule preuve valable de la convention d'axes (§2.1 de la conception) |
### Les trois règles à tenir dès maintenant
Elles gardent la porte ouverte au portage Flutter mobile sans l'imposer (D3) :
fichiers **statiques** servables depuis une origine locale · **aucune URL d'API en dur** (tout vient
de `assets[].url`) · le manifeste **s'injecte**, pas seulement par URL.
### Ce qu'on sous-estime toujours
L'éditeur, ce n'est pas le viewer plus un clic. C'est **un état d'édition, un undo, un enregistrement
explicite, et la gestion du « tu as des modifications non enregistrées »**. Compte la moitié de
l'étape là-dessus.
> 💼 **C'est ici que se place la démo client.** Composer dans un navigateur, voir dans le casque :
> à ce stade on a de quoi aller chercher un pilote, avant d'engager S4 à S8.
---
# S4 — Backend : le type de section et le manifeste servi
**4 à 6 jours.** Première ligne écrite dans `manager-service`.
| | |
|---|---|
| **Je génère** | `SectionScene3D.cs`, `Scene3DDTO`, `TransformDTO`, `Scene3DObjectDTO`, `Scene3DPersonaDTO` · les 4 emplacements de `SectionFactory` · les `switch` de `SectionController` et `IngestionService` · la ligne de discriminant TPH · `GeoPoint += SectionScene3DId, LocalTransform` · les enums (`SectionType.Scene3D`, `ResourceType.Model3D` + `Panorama360`, `ApiKeyAppType.VrApp`) · `Scene3DController` (`manifest`, `publish`, `budget`) · le job Hangfire de calcul des `sha256` · les tests |
| **Tu fais** | Relire la migration **avant** de l'appliquer, lancer `dotnet ef migrations add`, `dotnet test`, corriger l'upload `.glb` de `resources_screen.dart` (le `switch` sans `default`, F7) |
| **✅ Validation** | Un `curl` sur `GET /api/Scene3D/{id}/manifest` avec une clé d'API rend un manifeste que **le viewer de S3 affiche sans modification**, et que **l'app de S2 charge sans modification** |
### Trois points de vigilance, tous vérifiés dans le code
1. **`GetReferencedResourceIds` est `abstract`, pas `virtual`** — le compilateur réclamera
l'implémentation, et c'est voulu (`Section.cs`). Un oubli ici n'est pas un bug d'affichage : c'est
**un asset manquant sur un casque hors ligne, sur site**.
2. **Toutes les valeurs d'enum s'ajoutent EN FIN.** Elles sont persistées en int, le commentaire de
`ResourceType` le dit en majuscules. Insérer au milieu transformerait les PDF en JSON.
3. **La migration est purement additive** (colonnes nullables sur la table TPH `Sections`). Elle ne
concurrence pas le gel de schéma du lot B, mais elle se pose **en le sachant** — à annoncer dans
`STATUS.md`, pas à glisser.
---
# S5 — L'écran manager-app
**5 à 8 jours.** C'est l'étape qui rend le module utilisable par un conservateur.
| | |
|---|---|
| **Je génère** | L'écran d'édition de `SectionScene3D` (sélection du monde et des assets via le sélecteur de ressource existant, jauge de budget, liste des objets/personas/hotspots), l'intégration du viewer en `HtmlElementView` + `postMessage`, le bouton Publier, les DTO à ajouter **à la main** dans `manager_api_new`, l'i18n FR/EN/NL/DE |
| **Tu fais** | `flutter build web` (pas `flutter analyze` — voir la leçon de `STATUS.md` §1bis), et **composer une vraie scène de bout en bout** |
| **✅ Validation** | Tu crées une scène, tu poses un monde, deux objets, un persona et trois hotspots avec audio, tu publies — **et le casque affiche la scène après un retour au menu**. Sans qu'aucun développeur n'intervienne |
⚠️ **`manager_api_new` ne se régénère jamais** : les DTO s'ajoutent à la main. C'est une règle du
repo, pas une préférence.
⚠️ **La jauge de budget doit afficher le chiffre qui bloque.** Le bug ouvert de la jauge de stockage
(`STATUS.md` §1 : « 0 KB » affiché en même temps qu'un refus 413) est exactement ce qu'il ne faut
pas refaire.
---
# S6 — Unity : appairage, cache, synchronisation
**5 à 8 jours.** L'app cesse d'être une démo et devient une borne.
| | |
|---|---|
| **Je génère** | `PinCodeFlow.cs` (saisie du code, `GET /api/instance/app-key?appType=VrApp`, `POST /api/device` avec `appType = VR`), `CredentialStore.cs`, `ManifestClient.cs`, `AssetCache.cs` (disque, indexé par `sha256`, éviction, **fallback sur la dernière version valide**), `AssetDownloader.cs` (file, reprise, progression) |
| **Tu fais** | Appairer un vrai casque avec un vrai pincode, puis **débrancher le wifi et relancer** |
| **✅ Validation** | Trois choses, dans cet ordre : (1) l'app démarre **sur le cache**, sans réseau ; (2) après une publication, seuls les assets dont le `sha256` a changé sont retéléchargés ; (3) **un manifeste illisible ou un serveur injoignable laisse la dernière version valide active**, sans message d'erreur au visiteur |
⚠️ Le point (3) n'est pas de la robustesse défensive, c'est la promesse produit : *une borne ne
tombe pas en panne parce que le wifi du musée a bougé.* À tester en débranchant, pas en relisant.
⚠️ **La sync s'applique au retour au menu, jamais à chaud.** Un monde qui se recharge pendant qu'un
visiteur est immergé, c'est au mieux une coupure, au pire une nausée.
---
# S7 — Menu flottant, langues, kiosk, télémétrie
**4 à 6 jours.**
| | |
|---|---|
| **Je génère** | `FloatingMenu.cs` (sélection de scène, langue, relance audio, sortie), `KioskSession.cs` (détection de retrait du casque → reset, décision D4), `VisitEventReporter.cs` (`appType = VR`) |
| **Tu fais** | **Faire essayer à quelqu'un qui n'a jamais tenu une manette Quest.** Sans l'aider |
| **✅ Validation** | Deux critères, et le premier est le vrai : (1) cette personne lance une scène et change de langue **sans qu'on lui explique** ; (2) le casque reposé puis repris repart **à l'état initial** — langue par défaut, audio arrêté, position de départ |
Le reset doit être **total**, pas un retour de caméra. Un état résiduel entre deux visiteurs
(audio en cours, langue changée, hotspot ouvert) est le défaut le plus visible en exploitation
réelle — et le plus facile à laisser passer en test solo.
**La télémétrie ne demande aucun travail serveur** : `VisitEvent` porte déjà `AppType`, et le filtre
de l'écran de statistiques est générique sur `AppType.values` avec l'i18n `statsChannelVR` déjà
présente. Le canal VR apparaîtra tout seul au premier event.
---
# S8 — La flotte de casques
**6 à 8 jours.** Ce sont les lots V-1 et V-2 du plan d'août, inchangés.
| | |
|---|---|
| **Je génère** | `Device.AppType` + migration, `DeviceController.Create` résolvant l'`ApplicationInstance` sur `newDevice.appType` (au lieu du `AppType.Tablet` en dur, `DeviceController.cs:155`), `GetAll` avec un paramètre `appType` optionnel · `lib/Screens/Vr_devices/vr_screen.dart` **cloné de `Kiosk_devices/`** (4 fichiers, 769 l.) · le branchement de `main_screen.dart:704` à la place du `Text("TODO vr")` · batterie / `AppVersion` / `LastSeen` sur la carte casque |
| **Tu fais** | Vérifier qu'un casque enregistré **n'apparaît pas** dans l'onglet Kiosk |
| **✅ Validation** | Deux casques appairés, visibles dans l'onglet VR avec leur batterie et leur version, chacun avec **une configuration différente assignée** |
`Device.AppType` a pour **défaut `Tablet` (1)** : les lignes existantes ne bougent pas.
`ApiKeyAppType.VrApp` s'ajoute **en fin d'enum**.
---
## Ce qui n'est pas dans ce plan
| Hors V1 | Quand |
|---|---|
| Génération de mondes Marble depuis le manager | V1.5 — décision D5. Dépend des lots 0/1/3 du Studio (§5 de la conception) |
| Entité `Persona` et personnages 3D générés | Lot 7 puis 11 du Studio. La couture `personaId` est déjà dans le manifeste |
| TTS pré-généré | Plan dédié. En V1, audio `.mp3` uploadé — même champ, aucune reprise |
| Splats gaussiens | `world.kind = "splat"`, refusé explicitement par le client V1 |
| Assistant IA dans le casque | Zéro backend (`AiController` prend déjà un `AppType`), mais le TTS est à réimplémenter en C# |
| Viewer 3D dans `visitapp-web` et `mymuseum-visitapp` | Le viewer est conçu pour, les trois règles sont posées. Le portage est un chantier à part |
| KTX2 / Meshopt exécutés côté serveur | **À rouvrir avec les chiffres de S1**, pas avant |
| MQTT dans Unity, MDM | Lot V-5. Inutile sous une dizaine de casques |
---
## Les trois choses qui peuvent faire dérailler ce plan
1. **Le poids d'un monde Marble.** Si un monde pèse 500 Mo, le budget de scène et le pipeline KTX2
remontent en priorité, et S1 doit le dire. C'est le seul inconnu vraiment structurant.
2. **L'éditeur de S3.** C'est l'étape la plus longue et la seule dont l'estimation repose sur peu de
précédent dans le repo — il n'y a aucune 3D nulle part aujourd'hui.
3. **Le go/no-go pilote.** Il est placé après S3 pour une raison : au-delà, on engage ~5 semaines sur
un canal dont le risque est **produit**, pas technique.

View File

@ -0,0 +1,135 @@
"""Génère calibration.glb — le repère de calibration de l'étape E1.
Trois branches de longueurs DIFFÉRENTES, aux couleurs de la convention habituelle :
+X rouge (1.00 m) +Y vert (0.60 m) +Z bleu (0.30 m)
Pourquoi trois longueurs différentes : un repère symétrique ne permet pas de voir
une inversion d'axe. Ici, si la conversion glTF -> Unity se trompe de signe, la
branche rouge part du mauvais côté par rapport à la bleue, et ça se voit sans
mesurer.
Le modèle est écrit en coordonnées glTF (main droite, Y-up, mètres) c'est-à-dire
exactement la convention du manifeste. C'est ce qui en fait un test valable :
l'app place un marqueur AUX MÊMES coordonnées, sans passer par l'importeur.
Usage : python3 make_calibration_glb.py [chemin/de/sortie.glb]
"""
import base64
import json
import struct
import sys
from pathlib import Path
AXES = [
# (nom, dimensions du pavé, couleur RGBA)
("AxisX", (1.00, 0.04, 0.04), (0.85, 0.15, 0.15, 1.0)),
("AxisY", (0.04, 0.60, 0.04), (0.15, 0.75, 0.20, 1.0)),
("AxisZ", (0.04, 0.04, 0.30), (0.15, 0.35, 0.90, 1.0)),
]
FACES = [
((0, 0, 1), [(0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)]),
((0, 0, -1), [(1, 0, 0), (0, 0, 0), (0, 1, 0), (1, 1, 0)]),
((1, 0, 0), [(1, 0, 1), (1, 0, 0), (1, 1, 0), (1, 1, 1)]),
((-1, 0, 0), [(0, 0, 0), (0, 0, 1), (0, 1, 1), (0, 1, 0)]),
((0, 1, 0), [(0, 1, 1), (1, 1, 1), (1, 1, 0), (0, 1, 0)]),
((0, -1, 0), [(0, 0, 0), (1, 0, 0), (1, 0, 1), (0, 0, 1)]),
]
def box(size):
"""Pavé dont un coin est à l'origine et qui s'étend vers +X, +Y, +Z."""
positions, normals, indices = [], [], []
for normal, corners in FACES:
base = len(positions)
for cx, cy, cz in corners:
positions.append((cx * size[0], cy * size[1], cz * size[2]))
normals.append(normal)
indices += [base, base + 1, base + 2, base, base + 2, base + 3]
return positions, normals, indices
def main(out_path):
buffer = bytearray()
buffer_views, accessors, meshes, materials, nodes = [], [], [], [], []
def add_view(data, target):
while len(buffer) % 4:
buffer.append(0)
offset = len(buffer)
buffer.extend(data)
buffer_views.append(
{"buffer": 0, "byteOffset": offset, "byteLength": len(data), "target": target}
)
return len(buffer_views) - 1
for name, size, color in AXES:
positions, normals, indices = box(size)
pos_view = add_view(b"".join(struct.pack("<3f", *p) for p in positions), 34962)
nrm_view = add_view(b"".join(struct.pack("<3f", *n) for n in normals), 34962)
idx_view = add_view(b"".join(struct.pack("<H", i) for i in indices), 34963)
accessors.append({
"bufferView": pos_view, "componentType": 5126, "count": len(positions),
"type": "VEC3",
"min": [min(p[i] for p in positions) for i in range(3)],
"max": [max(p[i] for p in positions) for i in range(3)],
})
accessors.append({
"bufferView": nrm_view, "componentType": 5126, "count": len(normals),
"type": "VEC3",
})
accessors.append({
"bufferView": idx_view, "componentType": 5123, "count": len(indices),
"type": "SCALAR",
})
base = len(accessors) - 3
materials.append({
"name": name + "Mat",
"pbrMetallicRoughness": {
"baseColorFactor": list(color), "metallicFactor": 0.0, "roughnessFactor": 0.8,
},
})
meshes.append({
"name": name,
"primitives": [{
"attributes": {"POSITION": base, "NORMAL": base + 1},
"indices": base + 2,
"material": len(materials) - 1,
}],
})
nodes.append({"name": name, "mesh": len(meshes) - 1})
gltf = {
"asset": {
"version": "2.0",
"generator": "MyInfoMate VR — make_calibration_glb.py",
},
"scene": 0,
"scenes": [{"name": "Calibration", "nodes": list(range(len(nodes)))}],
"nodes": nodes,
"meshes": meshes,
"materials": materials,
"accessors": accessors,
"bufferViews": buffer_views,
"buffers": [{"byteLength": len(buffer)}],
}
json_chunk = json.dumps(gltf, separators=(",", ":")).encode("utf-8")
json_chunk += b" " * (-len(json_chunk) % 4)
bin_chunk = bytes(buffer) + b"\0" * (-len(buffer) % 4)
glb = struct.pack("<III", 0x46546C67, 2, 12 + 8 + len(json_chunk) + 8 + len(bin_chunk))
glb += struct.pack("<II", len(json_chunk), 0x4E4F534A) + json_chunk
glb += struct.pack("<II", len(bin_chunk), 0x004E4942) + bin_chunk
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
Path(out_path).write_bytes(glb)
print(f"{out_path}{len(glb)} octets, {len(nodes)} branches")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "calibration.glb")

View File

@ -0,0 +1,324 @@
using System.Threading.Tasks;
using MyInfoMate.Vr.Menu;
using MyInfoMate.Vr.Net;
using MyInfoMate.Vr.Scene;
using UnityEngine;
namespace MyInfoMate.Vr.Boot
{
/// <summary>
/// L'app du canal VR, telle qu'elle tient aujourd'hui : appairage (E2), contenu
/// servi depuis le cache (E3-E4), <b>menu flottant</b> (E5) et télémétrie (E9).
///
/// C'est le successeur de <see cref="PairingBootstrap"/>, qui reste à côté : celui-ci
/// prouve la chaîne réseau en une phrase, celui-là est l'app. Tant que rien n'a tourné
/// sur un vrai casque, pouvoir revenir au plus simple en un clic évite de confondre
/// un problème d'appairage avec un problème de rendu.
///
/// Les quatre types retenus au §8 s'ouvrent : <b>Slider</b>, <b>Map</b> et <b>Event</b>
/// (E8), le <b>Parcours</b> rendu comme une Map, et la <b>Video 360</b> — celle-ci
/// n'est pas un type de section mais une section Video dont le média est une ressource
/// 360, livrée le 2026-09-12. S'y ajoute la <b>Scène 3D</b> (E7). Choisir une section
/// non gérée affiche son titre et le dit — un menu qui ne réagit pas se lit comme une
/// panne.
///
/// La session de borne (E10) vit ici aussi : elle décide quand la visite est finie.
/// </summary>
public class MenuBootstrap : MonoBehaviour
{
[Header("Serveur")]
[SerializeField] string baseUrl = "https://api.mymuseum.be";
[Header("Appairage — code PIN de l'instance (première fois seulement)")]
[SerializeField] string pinCode = "";
[Header("Nom affiché dans l'onglet XR du manager")]
[SerializeField] string headsetName = "Quest";
[SerializeField] string language = "FR";
[SerializeField] bool forgetPairing;
Transform _head;
FloatingMenu _menu;
Telemetry _telemetry;
ConfigurationExport _export;
SceneMessage _message;
async void Start()
{
_head = Camera.main != null ? Camera.main.transform : null;
if (_head == null)
Debug.LogError("[Menu] Aucune caméra active : le rig VR n'est pas dans la " +
"scène. Meta → Tools → Building Blocks → Camera Rig.");
if (forgetPairing) PairingService.Forget();
var pairing = PairingService.Restore();
if (pairing == null)
{
if (string.IsNullOrEmpty(pinCode))
{
Show("Ce casque n'est pas appairé. Renseignez le code PIN du lieu " +
"dans l'inspecteur, puis relancez.");
return;
}
var paired = await new PairingService().PairAsync(baseUrl, pinCode, headsetName);
if (!paired.Ok)
{
Show(paired.Error);
return;
}
pairing = paired.Value;
}
_client = new ApiClient(pairing.BaseUrl, pairing.ApiKey);
var client = _client;
var content = new ContentService(client, pairing.ConfigurationId);
var loaded = await content.LoadAsync();
if (!loaded.Ok)
{
Show(loaded.Error);
return;
}
_telemetry = new Telemetry(client, pairing.InstanceId, pairing.ConfigurationId, language);
// XR-5 : sans ce battement, l'onglet XR du manager affiche « — » pour la
// batterie et la version, et ne sait jamais si un casque est tombé.
var fleet = Kiosk.FleetReporter.Attach(
gameObject, client, pairing.DeviceId, pairing.ConfigurationId);
if (fleet != null) fleet.ConfigurationChanged += OnConfigurationChanged;
BuildMenu(loaded.Export);
await _menu.LoadImagesAsync(loaded.Export, client);
await ApplyBackdrop(loaded.Export, client);
// E4 — le reste de la visite part sur le disque pendant que le visiteur
// regarde le menu. Sans await : c'est long, et personne ne doit l'attendre.
_ = ContentPreloader.RunAsync(loaded.Export, client);
if (!loaded.FromCache) return;
// Le menu est déjà utilisable : le rafraîchissement ne fait attendre personne,
// et ne se voit que s'il apporte quelque chose.
var fresh = await content.RefreshAsync();
if (fresh == null || _menu == null) return;
Debug.Log("[Menu] Contenu mis à jour, le menu se reconstruit.");
BuildMenu(fresh);
await _menu.LoadImagesAsync(fresh, client);
await ApplyBackdrop(fresh, client);
_ = ContentPreloader.RunAsync(fresh, client);
}
void BuildMenu(ConfigurationExport export)
{
_export = export;
if (_menu == null)
{
_menu = FloatingMenu.Create(_head);
_menu.SectionChosen += OnSectionChosen;
_kiosk = Kiosk.KioskSession.Attach(gameObject, _head);
_kiosk.Ended += StartNewVisit;
}
_menu.Build(export, language);
}
/// <summary>
/// Fin de visite : casque reposé, ou plus rien depuis un long moment. On revient
/// au menu, on le repose devant le visiteur, et on ouvre une nouvelle session de
/// statistiques — sinon une journée de borne compterait pour un seul visiteur.
/// </summary>
void StartNewVisit()
{
CloseSection();
_menu?.Recenter();
_telemetry?.StartNewSession();
Debug.Log("[Kiosk] Nouvelle visite.");
}
async void OnSectionChosen(string sectionId)
{
_kiosk?.NotifyActivity();
_telemetry?.MenuItemTap(sectionId);
_telemetry?.SectionView(sectionId);
var section = _export?.Sections?.Find(s => s.Id == sectionId);
var title = section != null
? ConfigurationExport.Translate(section.Title, language) ?? section.Label
: sectionId;
Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})");
if (section == null) return;
// Une section Video dont le média est une ressource 360 est le seul cas qui
// ne se feuillette pas : elle devient le ciel.
var immersive = ImmersiveResourceOf(section);
if (immersive != null)
{
OpenSection(sectionId);
_skybox = SkyboxView.Create(_head);
_skybox.Closed += CloseSection;
_skybox.Interacted += () => _kiosk?.NotifyActivity();
await _skybox.LoadAsync(immersive, _client);
return;
}
// Une maquette 3D n'est pas une page : elle se regarde en volume, avec ses
// points posés dessus.
if (section.Type == ConfigurationExport.SectionType.Scene3D)
{
OpenSection(sectionId);
_model = Scene3DView.Create(_head);
_model.Closed += CloseSection;
_model.Interacted += () => _kiosk?.NotifyActivity();
if (!await _model.LoadAsync(section, _export, language))
{
CloseSection();
Show($"{title}\n\nCette scène 3D n'a pas pu être chargée.");
}
return;
}
if (!SectionPages.CanRender(section.Type))
{
// Les types écartés au §8 (Quiz, Game, Web, PDF…) ne seront pas portés.
// Le dire vaut mieux que ne rien faire : un menu qui ne réagit pas se lit
// comme une panne.
Show($"{title}\n\nCe type de contenu n'est pas encore affichable dans le casque.");
return;
}
OpenSection(sectionId);
_view = PagedView.Create(_head);
_view.Closed += CloseSection;
_view.Interacted += () => _kiosk?.NotifyActivity();
await SectionPages.FillAsync(_view, section, _client, language);
}
/// <summary>
/// Retour au menu. La durée passée dans la section part avec le
/// <c>SectionLeave</c> : c'est elle qui dit si un contenu retient, et sans elle
/// les stats ne comptent que des ouvertures.
/// </summary>
/// <summary>
/// Le manager a assigné une autre configuration à ce casque : on la charge et on
/// repart du menu, sans redémarrer l'app.
///
/// ⚠️ On ferme ce qui est ouvert d'abord. Un visiteur qui regardait une galerie
/// au moment du changement doit revenir au menu — pas rester dans un contenu qui
/// n'appartient plus à sa visite.
/// </summary>
async void OnConfigurationChanged(string configurationId)
{
CloseSection();
var content = new ContentService(_client, configurationId);
var loaded = await content.LoadAsync();
if (!loaded.Ok)
{
Debug.LogWarning($"[Menu] Nouvelle configuration illisible : {loaded.Error}");
return;
}
BuildMenu(loaded.Export);
_menu?.Recenter();
_telemetry?.StartNewSession();
await _menu.LoadImagesAsync(loaded.Export, _client);
await ApplyBackdrop(loaded.Export, _client);
_ = ContentPreloader.RunAsync(loaded.Export, _client);
}
/// <summary>
/// Pose le décor du lieu derrière le menu. Détruit le précédent d'abord : un
/// changement de configuration ne doit pas laisser le panorama de l'ancienne
/// visite, et deux fonds superposés font deux vidéos qui tournent.
/// </summary>
async Task ApplyBackdrop(ConfigurationExport export, ApiClient client)
{
if (_backdrop != null) Destroy(_backdrop.gameObject);
_backdrop = await Menu.ImmersiveBackdrop.ApplyAsync(export, client);
}
void OpenSection(string sectionId)
{
_openedSectionId = sectionId;
_openedAt = Time.time;
_menu.gameObject.SetActive(false);
}
/// <summary>
/// La ressource 360 d'une section, ou null. <c>Source</c> porte soit un id de
/// ressource, soit une URL externe (YouTube) — cette dernière n'est pas une 360
/// téléchargeable, donc pas un cas immersif.
/// </summary>
ConfigurationExport.Resource ImmersiveResourceOf(ConfigurationExport.SectionSummary section)
{
if (section.SourceIsUrl) return null;
var resource = _export?.FindResource(section.Source);
return resource != null && resource.IsImmersive ? resource : null;
}
void CloseSection()
{
if (_view != null) Destroy(_view.gameObject);
_view = null;
if (_skybox != null) Destroy(_skybox.gameObject);
_skybox = null;
// Une scène 3D laissée derrière soi reste plantée dans le décor du menu — et
// son GLB continue d'occuper la mémoire du casque.
if (_model != null) Destroy(_model.gameObject);
_model = null;
// Le message d'une section non gérée resterait affiché par-dessus le menu.
if (_message != null) Destroy(_message.gameObject);
_message = null;
if (_openedSectionId != null)
{
_telemetry?.SectionLeave(_openedSectionId, Mathf.RoundToInt(Time.time - _openedAt));
_openedSectionId = null;
}
if (_menu != null) _menu.gameObject.SetActive(true);
}
PagedView _view;
SkyboxView _skybox;
ImmersiveBackdrop _backdrop;
Scene3DView _model;
ApiClient _client;
Kiosk.KioskSession _kiosk;
string _openedSectionId;
float _openedAt;
void Show(string message)
{
if (_message != null) Destroy(_message.gameObject);
_message = SceneMessage.Show(message, _head);
}
}
}

View File

@ -0,0 +1,123 @@
using MyInfoMate.Vr.Net;
using MyInfoMate.Vr.Scene;
using UnityEngine;
namespace MyInfoMate.Vr.Boot
{
/// <summary>
/// Items <b>E2 et E3</b> du lot XR-4 : le casque s'appaire, puis lit le contenu
/// de sa configuration. Rien d'immersif ici — c'est la preuve que la chaîne
/// réseau tient, affichée en une phrase dans le casque.
///
/// ⚠️ <b>Ne pas confondre avec <see cref="S1Bootstrap"/> et <see cref="S2Bootstrap"/></b> :
/// ceux-là sont la piste Scène 3D (S0-S8), celui-ci le canal VR (E0-E11). Deux
/// numérotations, deux plans.
///
/// <b>Le code PIN ne se saisit pas encore</b> : sans l'Interaction SDK (item E1),
/// il n'y a ni pointeur ni clavier. Il se pose donc dans l'inspecteur avant le
/// build ; l'écran d'appairage arrive avec E5.
/// </summary>
public class PairingBootstrap : MonoBehaviour
{
[Header("Serveur")]
[SerializeField] string baseUrl = "https://api.mymuseum.be";
[Header("Appairage — code PIN de l'instance")]
[SerializeField] string pinCode = "";
[Header("Nom affiché dans l'onglet XR du manager")]
[SerializeField] string headsetName = "Quest";
[Header("Langue d'affichage — l'export les porte toutes")]
[SerializeField] string language = "FR";
[Header("Réappairer même si ce casque l'est déjà")]
[SerializeField] bool forgetPairing;
async void Start()
{
var head = Camera.main != null ? Camera.main.transform : null;
if (head == null)
Debug.LogError("[Pairing] Aucune caméra active : le rig VR n'est pas dans la " +
"scène. Meta → Tools → Building Blocks → Camera Rig.");
if (forgetPairing) PairingService.Forget();
var pairing = PairingService.Restore();
if (pairing == null)
{
if (string.IsNullOrEmpty(pinCode))
{
SceneMessage.Show("Ce casque n'est pas appairé. Renseignez le code PIN " +
"du lieu dans l'inspecteur, puis relancez.", head);
return;
}
var result = await new PairingService()
.PairAsync(baseUrl, pinCode, headsetName);
if (!result.Ok)
{
SceneMessage.Show(result.Error, head);
return;
}
pairing = result.Value;
Debug.Log($"[Pairing] Casque appairé — device {pairing.DeviceId}, " +
$"instance {pairing.InstanceId}");
}
var client = new ApiClient(pairing.BaseUrl, pairing.ApiKey);
var content = new ContentService(client, pairing.ConfigurationId);
var loaded = await content.LoadAsync();
if (!loaded.Ok)
{
SceneMessage.Show(loaded.Error, head);
return;
}
Show(loaded.Export, head, loaded.FromCache ? loaded.CachedAt : null);
// E4 : le cache a déjà été affiché, le réseau ne fait plus attendre personne.
// Un échec ici ne se voit pas — c'est le comportement voulu pour une borne.
if (loaded.FromCache)
{
var fresh = await content.RefreshAsync();
if (fresh != null)
{
Debug.Log("[Content] Contenu mis à jour depuis le serveur.");
Show(fresh, head, null);
}
}
}
SceneMessage _message;
void Show(ConfigurationExport export, Transform head, System.DateTime? cachedAt)
{
// Le rafraîchissement de fond réaffiche : sans ça, deux panneaux se
// superposent et le texte devient illisible.
if (_message != null) Destroy(_message.gameObject);
var roots = export.RootSections();
var lines = $"{export.Label} — {roots.Count} sections";
if (cachedAt.HasValue)
lines += $"\n(hors ligne — contenu du {cachedAt.Value.ToLocalTime():dd/MM HH:mm})";
for (var i = 0; i < roots.Count; i++)
{
var title = ConfigurationExport.Translate(roots[i].Title, language) ?? roots[i].Label;
Debug.Log($"[Export] {roots[i].Type} — {title}");
// Le message flottant reste lisible à cinq lignes ; la console a le reste.
if (i < 5) lines += $"\n{title} ({roots[i].Type})";
}
_message = SceneMessage.Show(lines, head);
}
}
}

View File

@ -0,0 +1,82 @@
using System.Threading.Tasks;
using MyInfoMate.Vr.Scene;
using UnityEngine;
namespace MyInfoMate.Vr.Boot
{
/// <summary>
/// Orchestration de l'étape S1 : décor, calibration, personnage, zone de navigation.
///
/// <b>Provisoire par construction.</b> Les chemins sont ici en dur, parce que S1
/// n'a ni manifeste ni réseau — c'est exactement son intérêt : prouver la chaîne
/// technique sans backend. S2 remplace ce script par <c>SceneBuilder</c>, qui lit
/// les mêmes assets depuis un manifeste. Ne rien construire au-dessus de celui-ci.
/// </summary>
public class S1Bootstrap : MonoBehaviour
{
[Header("Fichiers dans Assets/StreamingAssets")]
[SerializeField] string worldFile = "world.glb";
[SerializeField] string personaFile = "persona.glb";
[Header("Placement du personnage — coordonnées glTF, en mètres")]
[SerializeField] float[] personaPosition = { -1.5f, 0f, -1f };
[Header("Navigation")]
[SerializeField] float navigationRadiusMeters = 3f;
[SerializeField] bool showBoundary = true;
[SerializeField] bool runCalibration = true;
Transform _sceneRoot;
async void Start()
{
_sceneRoot = new GameObject("SceneRoot").transform;
_sceneRoot.SetParent(transform, false);
var head = Camera.main != null ? Camera.main.transform : null;
if (head == null)
Debug.LogError("[S1] Aucune caméra active : le rig VR n'est pas dans la scène. " +
"Meta → Tools → Building Blocks → Camera Rig.");
await LoadWorld();
if (runCalibration)
await gameObject.AddComponent<CalibrationCheck>().RunAsync(_sceneRoot);
await LoadPersona(head);
SetUpBounds(head);
}
async Task LoadWorld()
{
var world = await GltfLoader.LoadAsync(
GltfLoader.StreamingAssetsUrl(worldFile), _sceneRoot, "World");
if (world == null)
Debug.LogError($"[S1] {worldFile} absent de StreamingAssets — " +
"exporte un monde Marble en mesh GLB et dépose-le là.");
}
async Task LoadPersona(Transform head)
{
var holder = new GameObject("Persona");
holder.transform.SetParent(_sceneRoot, false);
holder.transform.localPosition = GltfSpace.Position(personaPosition);
var persona = holder.AddComponent<PersonaInstance>();
if (!await persona.LoadAsync(GltfLoader.StreamingAssetsUrl(personaFile), head))
Debug.LogError($"[S1] {personaFile} absent de StreamingAssets.");
}
void SetUpBounds(Transform head)
{
// La zone suit le rig, pas la tête : c'est la position au sol du visiteur
// qui compte, et la tête bouge de 60 cm quand on se penche.
var rig = head != null ? head.root : null;
var bounds = new GameObject("NavigationBounds").AddComponent<NavigationBounds>();
bounds.transform.SetParent(_sceneRoot, false);
bounds.Configure(rig, head, navigationRadiusMeters, showBoundary);
}
}
}

View File

@ -0,0 +1,76 @@
using MyInfoMate.Vr.Manifest;
using MyInfoMate.Vr.Scene;
using UnityEngine;
namespace MyInfoMate.Vr.Boot
{
/// <summary>
/// Point d'entrée de l'étape S2 : la scène n'est plus codée en dur, elle est
/// <b>construite à partir d'un JSON</b>.
///
/// Remplace <see cref="S1Bootstrap"/>, à garder côte à côte le temps de valider
/// S1 : l'un prouve la chaîne technique, l'autre prouve le contrat de données.
///
/// Le manifeste est ici un fichier de StreamingAssets, poussé sur le casque à la
/// main (<c>adb push</c> ou MQDH). En S6 il viendra du réseau et du cache disque
/// — et <see cref="SceneBuilder"/> ne changera pas d'une ligne, c'est le but.
/// </summary>
public class S2Bootstrap : MonoBehaviour
{
[Header("Manifeste dans Assets/StreamingAssets")]
[SerializeField] string manifestFile = "scene.json";
[Header("Langue de démarrage — vide = langue par défaut du manifeste")]
[SerializeField] string language = "";
SceneBuilder _builder;
async void Start()
{
var head = Camera.main != null ? Camera.main.transform : null;
if (head == null)
Debug.LogError("[S2] Aucune caméra active : le rig VR n'est pas dans la scène. " +
"Meta → Tools → Building Blocks → Camera Rig.");
var rig = head != null ? head.root : null;
var url = ManifestUrl();
var result = await ManifestReader.LoadAsync(url);
if (!result.Ok)
{
// Le refus est le livrable : on affiche la phrase, on ne charge rien,
// et on ne laisse pas une scène vide faire croire à une panne.
SceneMessage.Show(result.Error, head);
return;
}
_builder = gameObject.AddComponent<SceneBuilder>();
var chosen = string.IsNullOrEmpty(language) ? result.Manifest.DefaultLanguage : language;
if (!await _builder.BuildAsync(result.Manifest, head, rig, chosen))
SceneMessage.Show("Le décor de cette scène n'a pas pu être chargé.", head);
}
/// <summary>
/// Un manifeste poussé sur le casque l'emporte sur celui de l'APK :
/// <c>adb push scene.json /sdcard/Android/data/&lt;package&gt;/files/</c>.
///
/// C'est ce qui rend S2 vérifiable. Lu depuis StreamingAssets, le manifeste
/// est <b>dans</b> l'APK : changer une valeur demanderait un rebuild, et le
/// critère « on modifie le JSON, on relance, sans recompiler » n'aurait plus
/// d'objet. En S6 la même priorité vaudra pour le cache disque.
/// </summary>
string ManifestUrl()
{
var pushed = System.IO.Path.Combine(Application.persistentDataPath, manifestFile);
if (!System.IO.File.Exists(pushed))
return GltfLoader.StreamingAssetsUrl(manifestFile);
Debug.Log($"[S2] Manifeste poussé : {pushed}");
return new System.Uri(pushed).AbsoluteUri;
}
}
}

View File

@ -0,0 +1,87 @@
using MyInfoMate.Vr.Boot;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace MyInfoMate.Vr.EditorTools
{
/// <summary>
/// Construit Boot.unity <b>par code</b> plutôt que de versionner un .unity écrit à
/// la main. Le YAML d'une scène Unity est plein de GUID et de fileID internes :
/// écrit à la main il produit une scène qui s'ouvre mais dont un réglage sur deux
/// est faux <i>en silence</i>. Ceci est lisible, rejouable et diffable.
///
/// Ce que ce script ne fait pas : ajouter le rig VR. Le Camera Rig vient du SDK
/// Meta et se pose par les Building Blocks — le référencer ici créerait une
/// dépendance de compilation sur un package importé à la main.
///
/// Deux variantes, et elles coexistent volontairement le temps de la validation :
/// <b>S1</b> prouve la chaîne technique avec des chemins en dur, <b>S2</b> prouve
/// le contrat de données en lisant <c>scene.json</c>. Tant que S1 n'est pas validé
/// au casque, pouvoir revenir à la scène minimale en un clic évite de confondre
/// un bug de rendu avec un bug de manifeste.
/// </summary>
public static class BuildBootScene
{
const string ScenePath = "Assets/Scenes/Boot.unity";
[MenuItem("MyInfoMate/Construire la scène Boot (S1 — chemins en dur)")]
public static void BuildS1() =>
Build(() => new GameObject("Bootstrap").AddComponent<S1Bootstrap>(),
"S1 (chemins en dur)");
[MenuItem("MyInfoMate/Construire la scène Boot (S2 — depuis scene.json)")]
public static void BuildS2() =>
Build(() => new GameObject("Bootstrap").AddComponent<S2Bootstrap>(),
"S2 (manifeste scene.json)");
/// <summary>
/// La troisième variante appartient à l'autre plan : le canal VR (E2/E3), pas la
/// piste Scène 3D. Elle ne charge aucun décor — elle appaire le casque et affiche
/// ce que le serveur renvoie. Le code PIN se pose dans l'inspecteur du Bootstrap
/// avant le build, tant que E1 n'a pas donné de pointeur.
/// </summary>
[MenuItem("MyInfoMate/Construire la scène Boot (E2-E3 — appairage et export)")]
public static void BuildPairing() =>
Build(() => new GameObject("Bootstrap").AddComponent<PairingBootstrap>(),
"E2-E3 (appairage et export)");
/// <summary>
/// L'app du canal VR telle qu'elle tient : appairage, cache, menu flottant au
/// regard, télémétrie. La variante précédente reste là pour isoler un problème
/// de réseau d'un problème de rendu.
/// </summary>
[MenuItem("MyInfoMate/Construire la scène Boot (E5 — menu flottant)")]
public static void BuildMenu() =>
Build(() => new GameObject("Bootstrap").AddComponent<MenuBootstrap>(),
"E5 (menu flottant)");
static void Build(System.Action addBootstrap, string label)
{
var scene = EditorSceneManager.NewScene(
NewSceneSetup.EmptyScene, NewSceneMode.Single);
var light = new GameObject("Directional Light").AddComponent<Light>();
light.type = LightType.Directional;
light.transform.rotation = Quaternion.Euler(50f, -30f, 0f);
light.intensity = 1f;
addBootstrap();
System.IO.Directory.CreateDirectory("Assets/Scenes");
EditorSceneManager.SaveScene(scene, ScenePath);
EditorBuildSettings.scenes = new[] { new EditorBuildSettingsScene(ScenePath, true) };
// Enchaîné ici plutôt que laissé à ta mémoire : une scène reconstruite
// puis buildée sans ses shaders embarqués donne un casque magenta, et
// l'erreur se diagnostique mal parce que l'éditeur, lui, va bien.
IncludeRuntimeShaders.Include();
Debug.Log($"Boot.unity construite sur {label}. Reste à faire à la main : " +
"Meta → Tools → Building Blocks → Camera Rig, puis Hand Tracking " +
"si on veut le pincement (AimSelector le détecte tout seul, et " +
"retombe sur la visée à la tête s'il est absent).");
}
}
}

View File

@ -0,0 +1,129 @@
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace MyInfoMate.Vr.EditorTools
{
/// <summary>
/// Déclare dans <b>Always Included Shaders</b> les shaders que ce projet ne
/// charge que par code.
///
/// <b>Le problème que ça règle.</b> Unity n'embarque dans un build que les
/// shaders référencés par un matériau présent dans une scène ou dans
/// <c>Resources</c>. Un shader obtenu par <c>Shader.Find</c> au runtime n'est
/// référencé nulle part au moment du build : il est retiré. Dans l'éditeur tout
/// est disponible, donc <b>tout est correct en Play Mode et magenta sur le
/// casque</b> — la combinaison la plus coûteuse à diagnostiquer.
///
/// C'est le cas de tous les shaders listés ici :
/// <list type="bullet">
/// <item>glTFast résout ses shaders par <c>Shader.Find("Shader Graphs/…")</c>
/// (<c>ShaderGraphMaterialGenerator.cs:594</c>) — donc tout GLB chargé au
/// runtime, c'est-à-dire <i>tout le contenu de l'app</i> ;</item>
/// <item><c>NavigationBounds</c> et <c>HotspotInstance</c> font le même appel
/// pour URP/Unlit ;</item>
/// <item><c>SceneMessage</c> utilise un <c>TextMesh</c>, dont le matériau par
/// défaut pointe sur <c>GUI/Text Shader</c>. Sans lui, le message d'erreur
/// destiné au visiteur serait lui-même illisible.</item>
/// </list>
///
/// Fait par script et pas à la main pour la même raison que
/// <see cref="BuildBootScene"/> : c'est versionné, rejouable, et ça survit à une
/// réimportation du projet.
/// </summary>
public static class IncludeRuntimeShaders
{
/// <summary>
/// Les noms sont ceux que le code résout au runtime, relevés dans les
/// sources — pas devinés. Un nom faux ici est silencieux : le script dira
/// « introuvable » au lieu d'ajouter le shader.
/// </summary>
static readonly string[] RequiredShaders =
{
// glTFast en URP — le rendu de tout GLB chargé au runtime
"Shader Graphs/glTF-pbrMetallicRoughness",
"Shader Graphs/glTF-unlit",
"Shader Graphs/glTF-pbrSpecularGlossiness",
// Repères et garde-fous dessinés par code
"Universal Render Pipeline/Unlit",
// ⚠️ Surtout pas "Universal Render Pipeline/Lit" : 2 359 296 variantes,
// au-delà de ce qu'Unity accepte d'embarquer — le build échoue sur
// « has too many Shader variants » après 35 minutes de compilation.
// Aucun Shader.Find du projet ne le demande : tout ce qui est dessiné
// par code est en Unlit, et les GLB passent par les graphes glTFast.
// La 360° (E6) — même piège que les autres : magenta sur le casque,
// correct dans l'éditeur
"Skybox/Panoramic"
// ⚠️ Pas de "GUI/Text Shader" non plus, et pour une raison différente :
// il vit dans « Library/unity default resources », marqué
// HideFlags.DontSave. Le mettre dans cette liste fait échouer le build
// sur « An asset is marked with HideFlags.DontSave but is included in
// the build » puis « Failed to write file: …/unity_builtin_extra ».
// Le TextMesh de SceneMessage n'en a pas besoin : son matériau vient de
// la police référencée par le composant, qui est embarquée normalement.
};
[MenuItem("MyInfoMate/Embarquer les shaders du runtime")]
public static void Include()
{
var settings = AssetDatabase
.LoadAllAssetsAtPath("ProjectSettings/GraphicsSettings.asset")
.FirstOrDefault();
if (settings == null)
{
Debug.LogError("[Shaders] GraphicsSettings.asset introuvable.");
return;
}
var serialized = new SerializedObject(settings);
var list = serialized.FindProperty("m_AlwaysIncludedShaders");
var already = new HashSet<Shader>();
for (var i = 0; i < list.arraySize; i++)
{
if (list.GetArrayElementAtIndex(i).objectReferenceValue is Shader shader)
already.Add(shader);
}
var added = 0;
var missing = new List<string>();
foreach (var name in RequiredShaders)
{
var shader = Shader.Find(name);
if (shader == null)
{
// Un shader introuvable dans l'éditeur signale soit un package
// absent, soit un renommage en amont. Dans les deux cas, se
// taire reviendrait à livrer le magenta.
missing.Add(name);
continue;
}
if (!already.Add(shader)) continue;
list.InsertArrayElementAtIndex(list.arraySize);
list.GetArrayElementAtIndex(list.arraySize - 1).objectReferenceValue = shader;
added++;
}
serialized.ApplyModifiedProperties();
AssetDatabase.SaveAssets();
Debug.Log($"[Shaders] {added} shader(s) ajouté(s), " +
$"{already.Count} au total dans Always Included Shaders.");
if (missing.Count > 0)
Debug.LogError("[Shaders] Introuvables, donc toujours magenta au build : " +
string.Join(", ", missing) +
". Vérifier que le package correspondant est bien importé.");
}
}
}

View File

@ -0,0 +1,117 @@
using System;
using System.Collections;
using MyInfoMate.Vr.Net;
using UnityEngine;
namespace MyInfoMate.Vr.Kiosk
{
/// <summary>
/// Ce que le casque dit de lui-même au manager — lot <b>XR-5</b>.
///
/// L'onglet XR affiche batterie, version et dernier vu depuis le 12/09, mais rien ne
/// les alimentait : c'est ce trou-là que ce composant comble. <b>Un exploitant qui
/// voit « vu il y a 3 minutes, 12 % » sait quoi faire ; « — » ne lui apprend rien.</b>
///
/// <b>Pourquoi pas MQTT.</b> Le plan prévoit MQTTnet à terme, et le serveur publie
/// déjà sur <c>player/{deviceId}</c>. Mais à un à trois casques, un battement HTTP
/// périodique fait le même travail pour dix fois moins de code, sans connexion
/// permanente à maintenir ni bibliothèque à embarquer. MQTT redeviendra utile le jour
/// où il faudra <b>pousser</b> vers le casque — recharger une config à distance —
/// pas pour remonter trois chiffres.
///
/// L'échec est silencieux, comme tout le reste de la borne : une statistique perdue
/// ne vaut pas un message au visiteur.
/// </summary>
public class FleetReporter : MonoBehaviour
{
/// <summary>
/// 3 min : assez fréquent pour qu'un exploitant voie un casque tomber avant la
/// fin d'une visite, assez rare pour ne rien coûter en batterie ni en réseau.
/// </summary>
const float IntervalSeconds = 180f;
ApiClient _client;
string _deviceId;
string _configurationId;
/// <summary>
/// Le manager a assigné une <b>autre configuration</b> à ce casque. C'est le
/// « pousser vers le casque » du lot XR-5, obtenu sans MQTT : la réponse au
/// battement porte déjà la configuration courante de l'appareil, il suffit de la
/// comparer.
///
/// <b>Ce que ça coûte</b> : la latence du battement, trois minutes au pire.
/// <b>Ce que ça évite</b> : une connexion permanente à maintenir sur une borne,
/// une bibliothèque MQTT embarquée dans le build, et un client de plus à
/// reconnecter quand le wifi du musée tombe. Le serveur publie déjà sur
/// <c>player/{deviceId}</c> (<c>DeviceController.cs:413</c>) : le jour où trois
/// minutes seront trop, tout est prêt côté serveur.
/// </summary>
public event Action<string> ConfigurationChanged;
public static FleetReporter Attach(GameObject host, ApiClient client,
string deviceId, string configurationId)
{
if (string.IsNullOrEmpty(deviceId)) return null;
var reporter = host.AddComponent<FleetReporter>();
reporter._client = client;
reporter._deviceId = deviceId;
reporter._configurationId = configurationId;
reporter.StartCoroutine(reporter.Loop());
return reporter;
}
class DeviceResponse
{
public string ConfigurationId;
}
IEnumerator Loop()
{
// Un premier battement tout de suite : le casque doit apparaître en ligne
// dès qu'il démarre, pas trois minutes plus tard.
Send();
while (true)
{
yield return new WaitForSeconds(IntervalSeconds);
Send();
}
}
async void Send()
{
var result = await _client.PutAsync<DeviceResponse>(
$"/api/device/{_deviceId}/heartbeat", new
{
batteryLevel = BatteryPercent(),
appVersion = Application.version
});
if (!result.Ok)
{
Debug.Log($"[Fleet] Battement non envoyé : {result.Error}");
return;
}
var assigned = result.Value?.ConfigurationId;
if (string.IsNullOrEmpty(assigned) || assigned == _configurationId) return;
Debug.Log($"[Fleet] Nouvelle configuration assignée : {assigned}");
_configurationId = assigned;
ConfigurationChanged?.Invoke(assigned);
}
/// <summary>
/// Le niveau en pourcentage, ou null si le casque ne le donne pas —
/// <c>SystemInfo.batteryLevel</c> renvoie -1 là où l'information n'existe pas,
/// et envoyer « -100 % » serait pire que ne rien envoyer.
/// </summary>
static string BatteryPercent()
{
var level = SystemInfo.batteryLevel;
return level < 0f ? null : Mathf.RoundToInt(level * 100f).ToString();
}
}
}

View File

@ -0,0 +1,117 @@
using System;
using UnityEngine;
using UnityEngine.XR;
namespace MyInfoMate.Vr.Kiosk
{
/// <summary>
/// Le mode borne — item <b>E10</b> du lot XR-4.
///
/// <b>Ce qui sépare une démo d'une borne</b> : une démo, quelqu'un la lance et la
/// range ; une borne tourne 8 h par jour, passe de main en main, et personne ne la
/// surveille. Le seul état acceptable pour le visiteur suivant est <b>le menu, devant
/// lui, comme si l'app venait de démarrer</b> — pas la galerie que le précédent avait
/// laissée ouverte, pas un menu dans son dos.
///
/// Deux déclencheurs, et le second est le vrai :
/// <list type="bullet">
/// <item><b>Le casque est reposé</b> — le signal le plus fiable qui existe : la
/// visite est finie, tout de suite, sans attendre.</item>
/// <item><b>Plus rien ne bouge</b> pendant un moment — le repli, pour le casque
/// posé sur une table sans que personne ne l'ait retiré de la tête.</item>
/// </list>
///
/// <b>Sans SDK Meta.</b> La présence du porteur se lit par <c>CommonUsages.userPresence</c>
/// d'Unity XR, alimenté par OpenXR : pas besoin d'un <c>OVRManager</c> dans la scène,
/// donc rien à configurer et rien à oublier de configurer.
/// </summary>
public class KioskSession : MonoBehaviour
{
/// <summary>
/// 90 s : assez long pour qu'un visiteur qui lit un panneau ne soit pas renvoyé
/// au menu, assez court pour que le suivant ne trouve pas l'écran du précédent.
/// </summary>
[SerializeField] float idleSeconds = 90f;
/// <summary>Au-delà, la tête a bougé : quelqu'un est là.</summary>
const float MovementThresholdDegrees = 3f;
Transform _head;
Quaternion _lastRotation;
float _idle;
bool _wasWorn = true;
/// <summary>La visite est finie — revenir au menu et repartir de zéro.</summary>
public event Action Ended;
public static KioskSession Attach(GameObject host, Transform head)
{
var session = host.AddComponent<KioskSession>();
session._head = head;
session._lastRotation = head != null ? head.rotation : Quaternion.identity;
// Une borne ne doit pas s'éteindre toute seule pendant qu'un visiteur lit.
Screen.sleepTimeout = SleepTimeout.NeverSleep;
return session;
}
/// <summary>
/// À appeler sur toute action délibérée. Le mouvement de tête suffit à détecter
/// une présence, mais pas à prouver l'attention : quelqu'un qui vise et choisit
/// est actif même s'il bouge à peine.
/// </summary>
public void NotifyActivity() => _idle = 0f;
void Update()
{
if (IsWorn())
{
if (!_wasWorn)
{
// Le casque vient d'être remis : c'est un nouveau visiteur, même si
// le précédent l'a reposé il y a dix secondes.
_wasWorn = true;
_idle = 0f;
Ended?.Invoke();
}
}
else if (_wasWorn)
{
_wasWorn = false;
Ended?.Invoke();
return;
}
if (!_wasWorn) return;
if (_head != null)
{
if (Quaternion.Angle(_head.rotation, _lastRotation) > MovementThresholdDegrees)
{
_lastRotation = _head.rotation;
_idle = 0f;
}
}
_idle += Time.deltaTime;
if (_idle < idleSeconds) return;
_idle = 0f;
Ended?.Invoke();
}
/// <summary>
/// Vrai quand le casque est porté. Un runtime qui ne sait pas répondre — le
/// simulateur, l'éditeur — renvoie « porté » : sur un poste de développement, une
/// borne qui se réinitialise toutes les 90 s serait intenable.
/// </summary>
static bool IsWorn()
{
var device = InputDevices.GetDeviceAtXRNode(XRNode.Head);
if (!device.isValid) return true;
return !device.TryGetFeatureValue(CommonUsages.userPresence, out var present) || present;
}
}
}

View File

@ -0,0 +1,140 @@
using System;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using UnityEngine;
using UnityEngine.Networking;
namespace MyInfoMate.Vr.Manifest
{
/// <summary>
/// Lecture et <b>validation</b> d'un manifeste de scène.
///
/// La validation n'est pas de la défense de principe : le manifeste est écrit
/// par le serveur, mais il est lu par une app qui tourne hors ligne, sur site,
/// sans personne pour regarder une console. Un manifeste qu'on ne sait pas lire
/// doit produire <b>une phrase lisible dans le casque</b>, jamais une scène vide
/// (§2.3, et le livrable explicite de S2).
/// </summary>
public static class ManifestReader
{
static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
// Le manifeste est en camelCase (§2.2), les champs C# en PascalCase.
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new CamelCaseNamingStrategy()
},
MissingMemberHandling = MissingMemberHandling.Ignore,
// Un champ absent doit garder la valeur par défaut du champ C#, pas
// devenir null : c'est ce qui rend les sections optionnelles réellement
// optionnelles.
NullValueHandling = NullValueHandling.Ignore
};
public class Result
{
public SceneManifest Manifest;
/// <summary>Null si tout va bien ; sinon une phrase destinée au visiteur.</summary>
public string Error;
public bool Ok => Error == null;
}
public static Result Parse(string json)
{
if (string.IsNullOrWhiteSpace(json))
return Fail("Le manifeste de scène est vide.");
SceneManifest manifest;
try
{
manifest = JsonConvert.DeserializeObject<SceneManifest>(json, Settings);
}
catch (JsonException e)
{
Debug.LogError($"[Manifest] JSON illisible : {e.Message}");
return Fail("Le manifeste de scène est illisible.");
}
if (manifest == null)
return Fail("Le manifeste de scène est illisible.");
return Validate(manifest);
}
static Result Validate(SceneManifest manifest)
{
if (manifest.ManifestVersion != SceneManifest.SupportedManifestVersion)
return Fail($"Cette scène utilise un format de manifeste " +
$"(version {manifest.ManifestVersion}) que cette version de " +
$"l'application ne sait pas lire. Mettez l'application à jour.");
// Vérifié comme un littéral, volontairement : c'est le seul garde-fou
// contre un manifeste produit dans la convention Unity par erreur, et
// un miroir d'axes est invisible sur une scène symétrique (§2.1).
if (manifest.CoordinateSystem != SceneManifest.ExpectedCoordinateSystem)
return Fail($"Le repère de cette scène est inattendu " +
$"({manifest.CoordinateSystem ?? "absent"}). " +
$"Attendu : {SceneManifest.ExpectedCoordinateSystem}.");
if (manifest.World == null || string.IsNullOrEmpty(manifest.World.AssetId))
return Fail("Cette scène n'a pas de décor.");
switch (manifest.World.ParsedKind)
{
case World3DKind.Mesh:
break;
case World3DKind.Splat:
return Fail("Ce décor est un nuage de splats gaussiens, que cette " +
"version de l'application ne sait pas afficher.");
case World3DKind.Panorama:
return Fail("Ce décor est un panorama 360°, que cette version de " +
"l'application ne sait pas afficher.");
default:
return Fail($"Type de décor inconnu : « {manifest.World.Kind} ».");
}
if (manifest.FindAsset(manifest.World.AssetId) == null)
return Fail("Le fichier du décor est introuvable dans cette scène.");
if (manifest.Navigation.RadiusMeters > NavigationManifest.MaxRadiusMeters)
Debug.LogWarning(
$"[Manifest] Rayon de navigation {manifest.Navigation.RadiusMeters} m " +
$"au-delà du plafond de {NavigationManifest.MaxRadiusMeters} m : ramené au " +
"plafond. Le serveur aurait dû le refuser.");
return new Result { Manifest = manifest };
}
/// <summary>
/// Sur Android, un fichier de StreamingAssets est <b>dans l'APK</b> : seule
/// UnityWebRequest sait le lire. Même raison que
/// <see cref="Scene.GltfLoader.StreamingAssetsUrl"/>.
/// </summary>
public static async Task<Result> LoadAsync(string url)
{
using var request = UnityWebRequest.Get(url);
var operation = request.SendWebRequest();
while (!operation.isDone)
await Task.Yield();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"[Manifest] {url} : {request.error}");
return Fail("Le manifeste de scène est introuvable.");
}
return Parse(request.downloadHandler.text);
}
static Result Fail(string message)
{
Debug.LogError($"[Manifest] {message}");
return new Result { Error = message };
}
}
}

View File

@ -0,0 +1,250 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEngine;
namespace MyInfoMate.Vr.Manifest
{
/// <summary>
/// Miroir exact du schéma §2.2 de la conception. <b>Ce fichier et
/// <c>viewer/src/manifest.ts</c> décrivent la même chose</b> : modifier l'un
/// sans l'autre casse le test croisé de S3.
///
/// Les coordonnées sont ici <b>telles qu'écrites dans le manifeste</b>, donc en
/// convention glTF. Elles ne deviennent des coordonnées Unity qu'en passant par
/// <see cref="Scene.GltfSpace"/> — nulle part ailleurs.
/// </summary>
public class SceneManifest
{
public const int SupportedManifestVersion = 1;
public const string ExpectedCoordinateSystem = "gltf/y-up/right-handed/meters";
/// <summary>Version du <b>format</b>. Distincte de <see cref="Version"/>.</summary>
public int ManifestVersion = 1;
public string SceneId;
public string InstanceId;
public string ConfigurationId;
/// <summary>Version du <b>contenu</b> : +1 à chaque publication. C'est elle que le
/// cache compare pour savoir qu'il y a du nouveau.</summary>
public int Version;
public string PublishedAt;
public List<string> Languages = new List<string>();
public string DefaultLanguage;
public string CoordinateSystem;
public NavigationManifest Navigation = new NavigationManifest();
public EnvironmentManifest Environment = new EnvironmentManifest();
public WorldManifest World;
public List<SceneObject> Objects = new List<SceneObject>();
public List<ScenePersona> Personas = new List<ScenePersona>();
public List<SceneHotspot> Hotspots = new List<SceneHotspot>();
public List<SceneAsset> Assets = new List<SceneAsset>();
public BudgetManifest Budget;
public ProvenanceManifest Provenance;
[JsonIgnore] Dictionary<string, SceneAsset> _assetsById;
/// <summary>
/// <c>assets[]</c> est la seule liste que le téléchargeur parcourt (§2.3) : tout
/// le reste ne porte que des identifiants. D'où cet index.
/// </summary>
public SceneAsset FindAsset(string assetId)
{
if (string.IsNullOrEmpty(assetId)) return null;
if (_assetsById == null)
{
_assetsById = new Dictionary<string, SceneAsset>();
foreach (var asset in Assets)
if (asset != null && !string.IsNullOrEmpty(asset.Id))
_assetsById[asset.Id] = asset;
}
return _assetsById.TryGetValue(assetId, out var found) ? found : null;
}
}
/// <summary>
/// Position + rotation + échelle en convention glTF. Les accesseurs Unity passent
/// tous par <see cref="Scene.GltfSpace"/> : c'est ce qui garantit qu'il n'existe
/// qu'une conversion dans le projet.
/// </summary>
public class TransformManifest
{
public float[] Position = { 0f, 0f, 0f };
public float[] Rotation = { 0f, 0f, 0f, 1f };
public float[] Scale = { 1f, 1f, 1f };
public Vector3 UnityPosition => Scene.GltfSpace.Position(Position ?? new[] { 0f, 0f, 0f });
public Quaternion UnityRotation =>
Scene.GltfSpace.Rotation(Rotation ?? new[] { 0f, 0f, 0f, 1f });
public Vector3 UnityScale => Scene.GltfSpace.Scale(Scale ?? new[] { 1f, 1f, 1f });
public void ApplyTo(Transform target)
{
target.localPosition = UnityPosition;
target.localRotation = UnityRotation;
target.localScale = UnityScale;
}
}
public class NavigationManifest
{
/// <summary>Le plafond dur du §2.2, appliqué serveur <b>et</b> client.</summary>
public const float MaxRadiusMeters = 3f;
public TransformManifest Spawn = new TransformManifest();
public float RadiusMeters = MaxRadiusMeters;
public bool ShowBoundary = true;
public float ClampedRadiusMeters => Mathf.Clamp(RadiusMeters, 0.5f, MaxRadiusMeters);
}
public class EnvironmentManifest
{
public string LightingPreset = "neutral-indoor";
public string HdriAssetId;
public float Exposure = 1f;
}
/// <summary>
/// <c>kind</c> est la couture V2 : la V1 ne lit que <see cref="World3DKind.Mesh"/> et
/// refuse le reste <b>explicitement</b>. Un refus lisible vaut mieux qu'une scène
/// vide (§2.3).
/// </summary>
public class WorldManifest
{
public string Kind = "mesh";
public string AssetId;
public TransformManifest Transform = new TransformManifest();
public World3DKind ParsedKind =>
Kind switch
{
"mesh" => World3DKind.Mesh,
"splat" => World3DKind.Splat,
"panorama" => World3DKind.Panorama,
_ => World3DKind.Unknown
};
}
public enum World3DKind
{
Mesh,
Splat,
Panorama,
Unknown
}
public class SceneObject
{
public string Id;
public string Label;
public string AssetId;
public TransformManifest Transform = new TransformManifest();
}
public class ScenePersona
{
public string Id;
/// <summary>Null en V1 : la couture vers l'entité <c>Persona</c> du lot 7 Studio.</summary>
public string PersonaId;
public string Name;
public string AssetId;
public TransformManifest Transform = new TransformManifest();
public AnimationManifest Animation = new AnimationManifest();
public bool GazeAtVisitor = true;
public List<LocalizedAsset> Audio = new List<LocalizedAsset>();
public List<LocalizedText> Script = new List<LocalizedText>();
}
public class AnimationManifest
{
public string IdleClip = "Idle";
public bool Loop = true;
}
public class SceneHotspot
{
public string Id;
/// <summary>Un hotspot <b>est</b> un <c>GeoPoint</c> côté serveur (§3.2).</summary>
public int GeoPointId;
public TransformManifest Transform = new TransformManifest();
public List<LocalizedText> Title = new List<LocalizedText>();
public List<LocalizedText> Description = new List<LocalizedText>();
public List<HotspotContent> Contents = new List<HotspotContent>();
}
public class HotspotContent
{
public int Order;
public List<LocalizedText> Title = new List<LocalizedText>();
public List<LocalizedText> Description = new List<LocalizedText>();
public string AssetId;
}
/// <summary>
/// Toutes les langues arrivent d'un coup : un casque en borne change de langue à
/// chaud, l'export mono-langue existant ne suffit pas (§2.3).
/// </summary>
public class LocalizedText
{
public string Language;
public string Value;
}
public class LocalizedAsset
{
public string Language;
public string AssetId;
}
public class SceneAsset
{
public string Id;
public string ResourceId;
public string Url;
public string MimeType;
public long SizeBytes;
/// <summary>Le seul moyen d'un delta réel côté casque (§2.4).</summary>
public string Sha256;
/// <summary>KTX2 / Meshopt viendront ici, sans casser le contrat.</summary>
public List<AssetVariant> Variants = new List<AssetVariant>();
}
public class AssetVariant
{
public string Kind;
public string Url;
public string MimeType;
public long SizeBytes;
public string Sha256;
}
public class BudgetManifest
{
public long TotalBytes;
public long LimitBytes;
}
public class ProvenanceManifest
{
public bool AiGenerated;
public string RightsHolder;
public List<string> Sources = new List<string>();
}
}

View File

@ -0,0 +1,225 @@
using System;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Visée et sélection — item <b>E5</b> du lot XR-4. Trois moyens de désigner un
/// panneau, dans cet ordre de priorité : <b>manette</b>, <b>main</b>, <b>tête</b>.
///
/// ⚠️ <b>« Au regard » ne veut pas dire eye tracking.</b> Le Quest 2 n'a pas de suivi
/// oculaire — seul le Quest Pro en a. Ce qu'on suit ici est la <b>direction de la
/// tête</b> (head gaze) : le visiteur vise avec son nez, pas avec ses yeux. C'est le
/// seul mode qui marche sur <i>tous</i> les casques, donc le repli garanti.
///
/// <b>Pourquoi les trois.</b> Une borne publique ne distribue pas de manettes — le
/// visiteur met le casque et c'est tout : il faut que ça marche sans rien dans les
/// mains. Mais en démo ou en installation surveillée, la manette est plus rapide et
/// les mains plus naturelles. Aucun des trois ne suffit seul.
///
/// <b>La temporisation ne s'applique qu'à la tête</b>, où il n'y a rien pour valider :
/// c'est le seul garde-fou contre le « Midas touch », où tout ce qu'on regarde se
/// déclenche, y compris ce qu'on ne fait que lire. La manette (gâchette) et la main
/// (pincement) ont un geste explicite, donc pas d'attente : imposer 1,2 s à quelqu'un
/// qui vient d'appuyer serait absurde.
///
/// <b>Dépendance</b> : <c>OVRInput</c> et <c>OVRHand</c> viennent du <i>Meta XR Core
/// SDK</i>, déjà installé (E1) — pas de l'Interaction SDK, qui lui n'est pas configuré.
/// Les mains ne sont détectées que si un <c>OVRHand</c> est présent dans la scène
/// (building block <i>Hand Tracking</i>) ; sinon on retombe sur les deux autres, sans
/// erreur.
/// </summary>
public class AimSelector : MonoBehaviour
{
/// <summary>
/// 1,2 s à la tête : assez long pour qu'un regard qui balaie ne déclenche rien,
/// assez court pour qu'on n'ait pas l'impression d'attendre. C'est le réglage qui
/// décide si le menu est agréable ou pénible — à confirmer au premier essai réel.
/// </summary>
[SerializeField] float dwellSeconds = 1.2f;
[SerializeField] float maxDistanceMeters = 10f;
/// <summary>Sous ce seuil, un pincement n'en est pas un.</summary>
const float PinchThreshold = 0.7f;
public enum AimSource { Head, Controller, Hand }
Transform _head;
IAimTarget _current;
float _dwell;
LineRenderer _ray;
OVRHand[] _hands = Array.Empty<OVRHand>();
public AimSource Source { get; private set; } = AimSource.Head;
public event Action<IAimTarget> Selected;
public void Configure(Transform head)
{
_head = head;
// Le hand tracking n'est pas toujours dans la scène : son absence est un cas
// nominal, pas une erreur.
_hands = FindObjectsByType<OVRHand>(FindObjectsSortMode.None);
BuildRay();
}
void Update()
{
if (_head == null) return;
if (!TryAim(out var ray, out var triggered))
{
Clear();
return;
}
DrawRay(ray);
var target = Probe(ray);
if (target != _current)
{
_current?.OnAimExit();
_current = target;
_dwell = 0f;
_current?.OnAimEnter();
}
if (_current == null) return;
// Un geste explicite déclenche tout de suite ; sans geste, c'est le temps
// passé sur la cible qui vaut validation.
if (triggered)
{
Fire();
return;
}
if (Source != AimSource.Head) return;
_dwell += Time.deltaTime;
_current.OnAimProgress(Mathf.Clamp01(_dwell / dwellSeconds));
if (_dwell >= dwellSeconds) Fire();
}
/// <summary>
/// La source active. L'ordre n'est pas un goût : une manette en main est un choix
/// délibéré du visiteur, une main levée aussi ; la tête ne veut rien dire par
/// elle-même, donc elle passe en dernier.
/// </summary>
bool TryAim(out Ray ray, out bool triggered)
{
var controller = OVRInput.GetActiveController();
if (controller == OVRInput.Controller.RTouch || controller == OVRInput.Controller.LTouch)
{
var position = OVRInput.GetLocalControllerPosition(controller);
var rotation = OVRInput.GetLocalControllerRotation(controller);
// Les poses des manettes sont locales au rig ; la tête vit dans le même
// repère, donc son parent donne la transformation à appliquer.
var rig = _head.parent != null ? _head.parent : _head;
Source = AimSource.Controller;
ray = new Ray(rig.TransformPoint(position), rig.rotation * rotation * Vector3.forward);
triggered = OVRInput.GetDown(OVRInput.Button.PrimaryIndexTrigger, controller);
return true;
}
foreach (var hand in _hands)
{
if (hand == null || !hand.IsTracked || !hand.IsPointerPoseValid) continue;
if (hand.IsSystemGestureInProgress) continue;
Source = AimSource.Hand;
ray = new Ray(hand.PointerPose.position, hand.PointerPose.forward);
// Le front montant, pas l'état : un pincement maintenu ne doit pas
// déclencher à chaque image.
triggered = hand.GetFingerPinchStrength(OVRHand.HandFinger.Index) > PinchThreshold
&& !_wasPinching;
_wasPinching = hand.GetFingerPinchStrength(OVRHand.HandFinger.Index) > PinchThreshold;
return true;
}
_wasPinching = false;
Source = AimSource.Head;
ray = new Ray(_head.position, _head.forward);
triggered = false;
return true;
}
bool _wasPinching;
void Fire()
{
var selected = _current;
_current = null;
_dwell = 0f;
selected.OnAimExit();
MenuFeedback.Select();
Selected?.Invoke(selected);
}
void Clear()
{
_current?.OnAimExit();
_current = null;
_dwell = 0f;
if (_ray != null) _ray.enabled = false;
}
IAimTarget Probe(Ray ray) =>
Physics.Raycast(ray, out var hit, maxDistanceMeters)
? hit.collider.GetComponentInParent<IAimTarget>()
: null;
void BuildRay()
{
var line = new GameObject("AimRay");
line.transform.SetParent(transform, false);
_ray = line.AddComponent<LineRenderer>();
_ray.useWorldSpace = true;
_ray.positionCount = 2;
_ray.widthMultiplier = 0.004f;
_ray.material = new Material(Shader.Find("Universal Render Pipeline/Unlit"));
_ray.material.color = new Color(0.4f, 0.8f, 1f, 0.5f);
_ray.enabled = false;
}
/// <summary>
/// Le rayon ne se dessine que pour la manette et la main. À la tête, un trait
/// parti du milieu du visage est fixe au centre du champ de vision : il gêne en
/// permanence sans rien apprendre, puisque l'anneau du panneau dit déjà où on vise.
/// </summary>
void DrawRay(Ray ray)
{
if (_ray == null) return;
if (Source == AimSource.Head)
{
_ray.enabled = false;
return;
}
_ray.enabled = true;
_ray.SetPosition(0, ray.origin);
_ray.SetPosition(1, ray.origin + ray.direction * 2.5f);
}
}
/// <summary>Ce qu'un objet doit savoir faire pour être visable.</summary>
public interface IAimTarget
{
void OnAimEnter();
void OnAimProgress(float ratio);
void OnAimExit();
}
}

View File

@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using MyInfoMate.Vr.Net;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Le menu flottant — <b>le hub de l'app</b>, item <b>E5</b> du lot XR-4.
///
/// C'est le rendu de la <c>SectionMenu</c> du CMS, et le seul type de section dont
/// le plan dit qu'il est « nécessaire » : tout le reste s'atteint depuis lui.
///
/// <b>Disposition en arc, pas en grille plate.</b> Une grille plate oblige à tourner
/// les yeux vers ses bords, qui sont alors vus de biais ; un arc centré sur le
/// visiteur présente chaque panneau de face, à distance constante. C'est le même
/// principe qu'un pupitre incurvé.
/// </summary>
public class FloatingMenu : MonoBehaviour
{
/// <summary>2,2 m : au-delà le texte devient petit, en deçà l'œil doit converger.</summary>
const float RadiusMeters = 2.2f;
const float HeightMeters = 1.45f;
/// <summary>Écart angulaire entre deux panneaux, assez large pour que le regard tranche.</summary>
const float StepDegrees = 16f;
/// <summary>Au-delà, on passe à une seconde rangée plutôt que d'encercler le visiteur.</summary>
const int MaxPerRow = 5;
readonly List<MenuItemPanel> _panels = new List<MenuItemPanel>();
AimSelector _selector;
/// <summary>L'id de la section choisie.</summary>
public event Action<string> SectionChosen;
public static FloatingMenu Create(Transform head)
{
var root = new GameObject("FloatingMenu");
var menu = root.AddComponent<FloatingMenu>();
menu._selector = root.AddComponent<AimSelector>();
menu._selector.Configure(head);
menu._selector.Selected += menu.OnSelected;
menu._head = head;
menu.Recenter();
return menu;
}
Transform _head;
/// <summary>
/// Pose le menu devant le visiteur. Appelé au démarrage et à chaque nouvelle
/// visite (mode borne) : le visiteur suivant n'arrive pas forcément orienté comme
/// le précédent, et un menu resté dans son dos est un menu introuvable.
///
/// Entre deux recentrages, le menu <b>ne suit pas la tête</b> : un menu qui suit
/// est impossible à viser, et donne le mal de cœur.
/// </summary>
public void Recenter()
{
if (_head == null) return;
var forward = _head.forward;
forward.y = 0f;
if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward;
transform.position = new Vector3(_head.position.x, 0f, _head.position.z);
transform.rotation = Quaternion.LookRotation(forward.normalized);
}
public void Build(ConfigurationExport export, string language)
{
foreach (var panel in _panels) Destroy(panel.gameObject);
_panels.Clear();
var sections = export.RootSections();
for (var i = 0; i < sections.Count; i++)
{
var title = ConfigurationExport.Translate(sections[i].Title, language)
?? sections[i].Label;
var panel = MenuItemPanel.Create(sections[i].Id, title, transform);
Place(panel.transform, i, sections.Count);
// La cascade : 40 ms d'écart entre deux panneaux. Assez pour qu'on voie
// le menu se construire, assez peu pour que le dernier soit là avant
// qu'on ait fini de tourner la tête.
panel.PlayAppear(i * 0.04f);
_panels.Add(panel);
}
}
/// <summary>
/// Les vignettes arrivent après coup : le menu doit être utilisable avant que
/// le premier octet d'image ne soit téléchargé.
/// </summary>
public async Task LoadImagesAsync(ConfigurationExport export, ApiClient client)
{
var sections = export.RootSections();
for (var i = 0; i < sections.Count && i < _panels.Count; i++)
{
var source = sections[i].ImageSource;
if (string.IsNullOrEmpty(source)) continue;
var path = await ContentCache.MediaPathAsync(client, source);
if (path == null || _panels[i] == null) continue;
var texture = new Texture2D(2, 2);
if (texture.LoadImage(File.ReadAllBytes(path)))
_panels[i].SetImage(texture);
}
}
void Place(Transform panel, int index, int total)
{
var row = index / MaxPerRow;
var inRow = index % MaxPerRow;
var countInRow = Mathf.Min(total - row * MaxPerRow, MaxPerRow);
var angle = (inRow - (countInRow - 1) / 2f) * StepDegrees;
var radians = angle * Mathf.Deg2Rad;
var height = HeightMeters - row * (MenuItemPanel.HeightMeters + 0.06f);
panel.localPosition = new Vector3(
Mathf.Sin(radians) * RadiusMeters,
height,
Mathf.Cos(radians) * RadiusMeters);
// Chaque panneau pivote vers le centre de l'arc — donc vers le visiteur.
panel.localRotation = Quaternion.Euler(0f, angle, 0f);
}
void OnSelected(IAimTarget target)
{
if (target is MenuItemPanel panel) SectionChosen?.Invoke(panel.Id);
}
}
}

View File

@ -0,0 +1,134 @@
using System;
using System.Threading.Tasks;
using MyInfoMate.Vr.Net;
using UnityEngine;
using UnityEngine.Video;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Le fond immersif d'une visite — le reste de l'item <b>E5</b>, spécifié au §4 de
/// <c>DOCS/v2/immersif-frontiere-plan.md</c>.
///
/// <b>Sans lui, le menu flotte dans le noir.</b> C'est le défaut d'Unity : une scène
/// vide est un vide, et un visiteur qui met le casque se retrouve devant six panneaux
/// suspendus dans le néant. Avec le panorama du lieu derrière, il est <i>dans</i> le
/// musée avant d'avoir choisi quoi que ce soit.
///
/// Trois différences avec <see cref="SkyboxView"/>, et elles tiennent toutes à la même
/// chose — <b>un fond n'est pas un contenu</b> :
/// <list type="bullet">
/// <item>on ne le choisit pas et on n'en sort pas : aucun panneau de retour ;</item>
/// <item>il reste en place tant que la visite dure, y compris entre deux sections ;</item>
/// <item>il s'efface devant une 360 ouverte, puis revient — <c>SkyboxView</c> restaure
/// le ciel précédent en se détruisant, et le ciel précédent, c'est celui-ci.</item>
/// </list>
///
/// Son absence est un cas nominal : un lieu sans fond garde le noir, sans un mot.
/// </summary>
public class ImmersiveBackdrop : MonoBehaviour
{
const int VideoWidth = 4096;
const int VideoHeight = 2048;
Material _sky;
Texture2D _image;
VideoPlayer _player;
RenderTexture _target;
/// <summary>
/// Pose le fond de cette visite, s'il y en a un. Rend le composant créé, ou null —
/// pas de fond n'est pas une erreur.
/// </summary>
public static async Task<ImmersiveBackdrop> ApplyAsync(
ConfigurationExport export, ApiClient client)
{
var background = export?.ImmersiveBackground;
if (background == null || string.IsNullOrEmpty(background.ResourceUrl)) return null;
// Une scène 3D en fond est du ressort du viewer de scène, pas du ciel : la
// rendre ici donnerait un décor plaqué sur une sphère. Le §4 la prévoit, E7
// sait la charger — le jour où on les branche, c'est ici que ça se décide.
if (background.Kind == ConfigurationExport.ImmersiveBackgroundKind.Scene3D)
{
Debug.Log("[Backdrop] Fond de type Scène 3D : pas encore rendu en fond, " +
"le menu reste sur le ciel par défaut.");
return null;
}
var root = new GameObject("ImmersiveBackdrop");
var backdrop = root.AddComponent<ImmersiveBackdrop>();
await backdrop.LoadAsync(background, client);
return backdrop;
}
async Task LoadAsync(ConfigurationExport.Backdrop background, ApiClient client)
{
_sky = PanoramicSky.CreateMaterial();
if (_sky == null) return;
if (background.Kind == ConfigurationExport.ImmersiveBackgroundKind.Video360)
PlayVideo(background, client);
else
await ShowImage(background, client);
}
async Task ShowImage(ConfigurationExport.Backdrop background, ApiClient client)
{
var texture = await PanoramicSky.LoadEquirectangularAsync(client, background.ResourceUrl);
if (texture == null || this == null) return;
_image = texture;
_sky.SetTexture("_MainTex", texture);
RenderSettings.skybox = _sky;
}
/// <summary>
/// ⚠️ <b>Une vidéo en fond tourne en permanence.</b> Sur une borne qui fonctionne
/// huit heures, c'est un décodeur matériel occupé toute la journée et une batterie
/// qui descend plus vite — à mesurer avant de la vendre comme le cas normal. Le son
/// est coupé : un fond qui parle par-dessus le commentaire d'une section serait
/// intenable.
/// </summary>
void PlayVideo(ConfigurationExport.Backdrop background, ApiClient client)
{
_target = new RenderTexture(VideoWidth, VideoHeight, 0);
_player = gameObject.AddComponent<VideoPlayer>();
_player.source = VideoSource.Url;
_player.renderMode = VideoRenderMode.RenderTexture;
_player.targetTexture = _target;
_player.isLooping = true;
_player.audioOutputMode = VideoAudioOutputMode.None;
_player.errorReceived += (_, message) =>
Debug.LogError($"[Backdrop] Lecture vidéo impossible : {message}");
_sky.SetTexture("_MainTex", _target);
RenderSettings.skybox = _sky;
StartPlayback(background.ResourceUrl, client);
}
async void StartPlayback(string url, ApiClient client)
{
var path = await ContentCache.MediaPathAsync(client, url);
if (path == null || this == null || _player == null) return;
_player.url = new Uri(path).AbsoluteUri;
_player.Play();
}
void OnDestroy()
{
// Le ciel est un réglage global. On ne le remet à null que s'il est encore le
// nôtre : une 360 ouverte par-dessus a déjà pris la place, et lui arracher son
// ciel en repassant par ici la laisserait dans le noir.
if (RenderSettings.skybox == _sky) RenderSettings.skybox = null;
if (_player != null) _player.Stop();
if (_target != null) _target.Release();
if (_image != null) Destroy(_image);
}
}
}

View File

@ -0,0 +1,112 @@
using System.Collections;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Le retour au survol et à la sélection : un son court, et une vibration quand il y
/// a une manette pour la sentir.
///
/// <b>Ce que ça règle.</b> En visée à la tête, rien ne confirme qu'on a bien accroché
/// un panneau avant que l'anneau ne commence à se remplir — et l'anneau est en
/// périphérie du regard au moment où on l'attend. Un son de 40 ms dit « tu es
/// dessus » avant même que l'œil ne l'ait vu.
///
/// <b>Les sons sont synthétisés</b>, pas chargés. Deux raisons : aucun fichier à
/// gérer dans un repo qui n'a pas d'assets, et un clic généré est identique sur tous
/// les casques. Ils sont volontairement discrets — une borne de musée qui claque à
/// chaque regard devient insupportable en une heure.
/// </summary>
public class MenuFeedback : MonoBehaviour
{
const int SampleRate = 44100;
static MenuFeedback _instance;
AudioSource _source;
AudioClip _hover;
AudioClip _select;
static MenuFeedback Instance
{
get
{
if (_instance != null) return _instance;
var root = new GameObject("MenuFeedback");
DontDestroyOnLoad(root);
_instance = root.AddComponent<MenuFeedback>();
_instance.Build();
return _instance;
}
}
void Build()
{
_source = gameObject.AddComponent<AudioSource>();
_source.playOnAwake = false;
// 2D : le retour n'appartient pas à un endroit de la pièce, il appartient au
// geste. Le spatialiser le ferait venir d'un panneau qu'on est en train de
// quitter.
_source.spatialBlend = 0f;
_hover = Tone("hover", 880f, 0.045f, 0.18f);
_select = Tone("select", 1320f, 0.09f, 0.32f, 660f);
}
/// <summary>
/// Une sinusoïde avec une enveloppe qui décroît. <paramref name="startFrequency"/>
/// non nul fait un glissando : c'est ce qui distingue « choisi » de « survolé »
/// sans avoir à monter le volume.
/// </summary>
static AudioClip Tone(string name, float frequency, float seconds, float volume,
float startFrequency = 0f)
{
var samples = Mathf.RoundToInt(SampleRate * seconds);
var data = new float[samples];
var phase = 0f;
for (var i = 0; i < samples; i++)
{
var t = i / (float)samples;
var f = startFrequency > 0f ? Mathf.Lerp(startFrequency, frequency, t) : frequency;
phase += 2f * Mathf.PI * f / SampleRate;
// Attaque très courte pour éviter le claquement d'un début abrupt, puis
// décroissance exponentielle.
var envelope = Mathf.Min(1f, t * 40f) * Mathf.Exp(-5f * t);
data[i] = Mathf.Sin(phase) * envelope * volume;
}
var clip = AudioClip.Create(name, samples, 1, SampleRate, false);
clip.SetData(data, 0);
return clip;
}
public static void Hover() => Instance.Play(Instance._hover, 0.12f, 0.05f);
public static void Select() => Instance.Play(Instance._select, 0.5f, 0.12f);
void Play(AudioClip clip, float vibrationAmplitude, float vibrationSeconds)
{
if (clip != null) _source.PlayOneShot(clip);
var controller = OVRInput.GetActiveController();
if (controller != OVRInput.Controller.RTouch && controller != OVRInput.Controller.LTouch)
return;
StopAllCoroutines();
StartCoroutine(Vibrate(controller, vibrationAmplitude, vibrationSeconds));
}
IEnumerator Vibrate(OVRInput.Controller controller, float amplitude, float seconds)
{
OVRInput.SetControllerVibration(0.4f, amplitude, controller);
yield return new WaitForSeconds(seconds);
OVRInput.SetControllerVibration(0f, 0f, controller);
}
}
}

View File

@ -0,0 +1,330 @@
using MyInfoMate.Vr.Ui;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Un panneau du menu flottant : une image, un titre, et l'anneau de progression de
/// la visée — item <b>E5</b> du lot XR-4.
///
/// L'anneau ne se remplit qu'en visée à la tête : la manette et la main ont une
/// gâchette et un pincement, donc rien à attendre (voir <see cref="AimSelector"/>).
///
/// Tout est construit par code, avec le <b>même shader</b> que
/// <see cref="Scene.NavigationBounds"/> (<c>Universal Render Pipeline/Unlit</c>).
/// Ce n'est pas un détail de style : ce shader est déjà dans <i>Always Included
/// Shaders</i>, et tout shader supplémentaire embarqué coûte des milliers de
/// variantes et des dizaines de minutes de build (voir le README de l'overlay).
///
/// <b>Les coins arrondis et le liseré ne viennent donc pas d'un shader</b>, mais
/// d'une texture générée une fois et partagée par tous les panneaux : un rectangle
/// arrondi en alpha, avec un bord plus clair. C'est la seule façon d'avoir une
/// matière sans payer une deuxième compilation de variantes.
/// </summary>
public class MenuItemPanel : MonoBehaviour, IAimTarget
{
public const float WidthMeters = 0.5f;
public const float HeightMeters = 0.34f;
static readonly Color Idle = new Color(0.10f, 0.13f, 0.17f, 0.92f);
static readonly Color Hover = new Color(0.20f, 0.42f, 0.60f, 0.98f);
/// <summary>Le panneau visé avance vers le visiteur : c'est ce qui se lit de plus loin.</summary>
const float HoverScale = 1.07f;
const float TransitionSpeed = 12f;
/// <summary>Assez court pour ne pas faire attendre, assez long pour se voir.</summary>
const float AppearSeconds = 0.35f;
/// <summary>
/// Ce que le panneau désigne : un id de section dans le menu, une commande
/// (« suivant », « retour ») ailleurs. Le panneau ne sait pas ce que ça veut
/// dire, et c'est ce qui le rend réutilisable.
/// </summary>
public string Id { get; private set; }
Material _background;
LineRenderer _progress;
Transform _image;
Vector3 _baseScale = Vector3.one;
bool _baseScaleKnown;
bool _hovered;
float _hover;
float _appear = 1f;
float _appearElapsed;
bool _appearing;
public static MenuItemPanel Create(string id, string title, Transform parent)
{
var root = new GameObject($"Menu — {title}");
root.transform.SetParent(parent, false);
var panel = root.AddComponent<MenuItemPanel>();
panel.Build(id, title);
return panel;
}
void Build(string id, string title)
{
Id = id;
var background = GameObject.CreatePrimitive(PrimitiveType.Quad);
background.name = "Background";
background.transform.SetParent(transform, false);
background.transform.localScale = new Vector3(WidthMeters, HeightMeters, 1f);
_background = background.GetComponent<Renderer>().material =
new Material(Shader.Find("Universal Render Pipeline/Unlit"));
_background.mainTexture = PanelTexture;
Transparent(_background);
_background.color = Idle;
// Double face. La normale d'un Quad et l'orientation lisible d'un texte ne
// pointent pas du même côté : avec le culling par défaut, l'un des deux
// disparaît, et on ne le découvre que dans le casque.
NoCulling(_background);
// Le collider est ce que la visée touche. Celui d'un Quad est un
// MeshCollider, qu'on ne peut pas passer en trigger sans qu'il soit convexe :
// une boîte fine fait le même travail, couvre tout le panneau — texte
// compris — et ne pose aucune question à la physique.
Destroy(background.GetComponent<MeshCollider>());
var box = background.AddComponent<BoxCollider>();
box.size = new Vector3(1f, 1f, 0.02f);
var label = Label.Create(transform, "Title", Label.Anchor.TopCenter, 0.032f, Color.white);
label.transform.localPosition = new Vector3(0f, -HeightMeters * 0.30f, -0.01f);
label.Text = Label.Wrap(title, 22);
BuildProgressRing();
}
void Start()
{
// Après le Create : PagedView réduit ses commandes à 0,55 juste après. Capter
// l'échelle ici évite que le survol et l'apparition l'écrasent.
_baseScale = transform.localScale;
_baseScaleKnown = true;
}
/// <summary>
/// Fait entrer le panneau. <paramref name="delaySeconds"/> décale son arrivée :
/// c'est ce décalage, et lui seul, qui fait la cascade quand le menu se pose.
/// </summary>
public void PlayAppear(float delaySeconds)
{
_appearing = true;
_appearElapsed = -delaySeconds;
_appear = 0f;
ApplyScale();
}
void Update()
{
_hover = Mathf.MoveTowards(
_hover, _hovered ? 1f : 0f, Time.deltaTime * TransitionSpeed);
var color = Color.Lerp(Idle, Hover, _hover);
if (_appearing)
{
_appearElapsed += Time.deltaTime;
var t = Mathf.Clamp01(_appearElapsed / AppearSeconds);
// Un léger dépassement : le panneau arrive, dépasse sa taille d'un cheveu
// et se pose. Sans ça l'apparition est linéaire, donc mécanique.
_appear = _appearElapsed <= 0f ? 0f : Overshoot(t);
// La couleur est toujours recalculée depuis Idle/Hover, jamais modifiée
// sur place : une opacité multipliée image après image tomberait à zéro.
color.a *= Mathf.Clamp01(_appearElapsed / (AppearSeconds * 0.6f));
if (t >= 1f)
{
_appearing = false;
_appear = 1f;
}
}
_background.color = color;
ApplyScale();
}
static float Overshoot(float t)
{
const float tension = 1.7f;
var u = t - 1f;
return 1f + u * u * ((tension + 1f) * u + tension);
}
void ApplyScale()
{
if (!_baseScaleKnown) return;
transform.localScale = _baseScale
* Mathf.Lerp(1f, HoverScale, _hover)
* Mathf.Lerp(0.7f, 1f, _appear);
}
/// <summary>
/// L'image de la section, une fois téléchargée. Un panneau sans image reste
/// lisible : le titre suffit, et une borne hors ligne au premier démarrage n'a
/// encore aucun média en cache.
/// </summary>
public void SetImage(Texture2D texture)
{
if (texture == null) return;
if (_image == null)
{
var quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
quad.name = "Image";
quad.transform.SetParent(transform, false);
quad.transform.localPosition = new Vector3(0f, HeightMeters * 0.16f, -0.005f);
quad.transform.localScale = new Vector3(WidthMeters * 0.86f, HeightMeters * 0.5f, 1f);
// L'image ne doit pas intercepter le regard : c'est le panneau qui est
// la cible, sinon le survol s'éteint dès qu'on regarde la vignette.
Destroy(quad.GetComponent<Collider>());
_image = quad.transform;
}
var material = new Material(Shader.Find("Universal Render Pipeline/Unlit"))
{
mainTexture = texture
};
NoCulling(material);
_image.GetComponent<Renderer>().material = material;
}
static void NoCulling(Material material) => material.SetFloat("_Cull", 0f);
/// <summary>
/// URP/Unlit est opaque par défaut : sans ces cinq lignes, l'alpha d'une couleur
/// est simplement ignoré et le panneau est un rectangle plein aux coins carrés,
/// texture arrondie ou pas.
/// </summary>
static void Transparent(Material material)
{
material.SetFloat("_Surface", 1f);
material.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha);
material.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
material.SetFloat("_ZWrite", 0f);
material.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
}
static Texture2D _panelTexture;
/// <summary>
/// Le rectangle arrondi, généré une fois pour tous les panneaux. L'alpha porte la
/// forme et l'anticrénelage du bord ; le RGB porte le liseré, plus clair que
/// l'intérieur — le matériau le teinte ensuite, donc un seul dessin sert au repos
/// comme au survol.
/// </summary>
static Texture2D PanelTexture
{
get
{
if (_panelTexture != null) return _panelTexture;
const int width = 256;
const int height = 176;
const float radius = 30f;
const float border = 3f;
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
{
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear
};
var pixels = new Color32[width * height];
for (var y = 0; y < height; y++)
for (var x = 0; x < width; x++)
{
var distance = RoundedRectDistance(x + 0.5f, y + 0.5f, width, height, radius);
// distance < 0 dedans, > 0 dehors. 1,2 px de dégradé suffisent à
// effacer l'escalier du bord à 2,20 m.
var alpha = Mathf.Clamp01(0.5f - distance / 1.2f);
var lit = distance > -border ? 1f : 0.72f;
pixels[y * width + x] = new Color(lit, lit, lit, alpha);
}
texture.SetPixels32(pixels);
texture.Apply();
_panelTexture = texture;
return _panelTexture;
}
}
/// <summary>Distance signée au rectangle arrondi, en pixels.</summary>
static float RoundedRectDistance(float x, float y, float width, float height, float radius)
{
var dx = Mathf.Abs(x - width / 2f) - (width / 2f - radius);
var dy = Mathf.Abs(y - height / 2f) - (height / 2f - radius);
var outside = new Vector2(Mathf.Max(dx, 0f), Mathf.Max(dy, 0f)).magnitude;
return outside + Mathf.Min(Mathf.Max(dx, dy), 0f) - radius;
}
void BuildProgressRing()
{
const int segments = 48;
var ring = new GameObject("Progress");
ring.transform.SetParent(transform, false);
ring.transform.localPosition = new Vector3(0f, 0f, -0.02f);
_progress = ring.AddComponent<LineRenderer>();
_progress.useWorldSpace = false;
_progress.loop = false;
_progress.positionCount = 0;
_progress.widthMultiplier = 0.006f;
_progress.material = new Material(Shader.Find("Universal Render Pipeline/Unlit"));
_progress.material.color = new Color(0.4f, 0.8f, 1f, 0.9f);
_ringPoints = new Vector3[segments];
const float radius = 0.055f;
for (var i = 0; i < segments; i++)
{
// Départ en haut, sens horaire : c'est le sens d'une minuterie, et
// n'importe qui le lit sans explication.
var angle = Mathf.PI / 2f - i * Mathf.PI * 2f / (segments - 1);
_ringPoints[i] = new Vector3(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, 0f);
}
}
Vector3[] _ringPoints;
public void OnAimEnter()
{
_hovered = true;
MenuFeedback.Hover();
}
public void OnAimProgress(float ratio)
{
var count = Mathf.RoundToInt(ratio * _ringPoints.Length);
_progress.positionCount = count;
for (var i = 0; i < count; i++)
_progress.SetPosition(i, _ringPoints[i]);
}
public void OnAimExit()
{
_hovered = false;
_progress.positionCount = 0;
}
}
}

View File

@ -0,0 +1,226 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Une pile de pages qu'on feuillette — la vue commune du <b>Slider</b>, de la
/// <b>Map</b> et de l'<b>Event</b>, item <b>E8</b> du lot XR-4.
///
/// <b>Pourquoi une seule vue pour trois types.</b> Une galerie de photos, une liste de
/// points d'intérêt et un programme de la journée sont trois contenus différents dans
/// le CMS, mais dans le casque ce sont les mêmes gestes : une chose à la fois, grande,
/// devant soi, et de quoi passer à la suivante. Les trois renderers ne se distinguent
/// que par <b>ce qu'ils mettent dans les pages</b> — c'est le rôle de
/// <see cref="SectionPages"/>.
///
/// Les pages s'ajoutent au fil du chargement, et la première s'affiche dès qu'elle
/// arrive : sur vingt photos, attendre la vingtième ferait passer le chargement pour
/// une panne.
/// </summary>
public class PagedView : MonoBehaviour
{
const string CommandPrevious = "__previous";
const string CommandNext = "__next";
const string CommandBack = "__back";
const float DistanceMeters = 2.5f;
const float ImageWidthMeters = 1.6f;
const float ImageHeightMeters = 0.9f;
const float CenterHeight = 1.5f;
public class Page
{
/// <summary>Le titre de la page. Toujours présent.</summary>
public string Heading;
/// <summary>Description, horaire, adresse… Peut être vide.</summary>
public string Body;
/// <summary>Nulle sur une page sans média — le texte tient tout seul.</summary>
public Texture2D Image;
}
readonly List<Page> _pages = new List<Page>();
Material _imageMaterial;
Transform _imageQuad;
Ui.Label _text;
MenuItemPanel _previous;
MenuItemPanel _next;
int _index;
/// <summary>Le visiteur veut revenir au menu.</summary>
public event Action Closed;
/// <summary>
/// N'importe quelle action du visiteur. Sert au mode borne : feuilleter est une
/// activité, même si la tête ne bouge presque pas.
/// </summary>
public event Action Interacted;
public int PageCount => _pages.Count;
public static PagedView Create(Transform head)
{
var root = new GameObject("PagedView");
var view = root.AddComponent<PagedView>();
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.Build();
return view;
}
void Build()
{
var quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
quad.name = "Image";
quad.transform.SetParent(transform, false);
quad.transform.localPosition = new Vector3(0f, CenterHeight, DistanceMeters);
quad.transform.localScale = new Vector3(ImageWidthMeters, ImageHeightMeters, 1f);
// L'image n'est pas une cible de visée : ce sont les trois commandes qui le
// sont, et un collider plein cadre les masquerait.
Destroy(quad.GetComponent<Collider>());
_imageMaterial = new Material(Shader.Find("Universal Render Pipeline/Unlit"));
_imageMaterial.SetFloat("_Cull", 0f);
_imageMaterial.color = new Color(0.08f, 0.10f, 0.13f, 1f);
quad.GetComponent<Renderer>().material = _imageMaterial;
_imageQuad = quad.transform;
_text = Ui.Label.Create(transform, "Text", Ui.Label.Anchor.TopCenter, 0.048f, Color.white);
_text.transform.localPosition = new Vector3(
0f, CenterHeight - ImageHeightMeters * 0.62f, DistanceMeters);
_previous = Command(CommandPrevious, "◀", -ImageWidthMeters * 0.62f, CenterHeight);
_next = Command(CommandNext, "▶", ImageWidthMeters * 0.62f, CenterHeight);
Command(CommandBack, "Retour au menu", 0f, CenterHeight - ImageHeightMeters * 0.95f);
UpdateCommands();
}
MenuItemPanel Command(string id, string label, float x, float y)
{
var panel = MenuItemPanel.Create(id, label, transform);
panel.transform.localPosition = new Vector3(x, y, DistanceMeters - 0.02f);
panel.transform.localScale = Vector3.one * 0.55f;
return panel;
}
public void AddPage(Page page)
{
_pages.Add(page);
if (_pages.Count == 1) Display(0);
else UpdateCommands();
}
/// <summary>Quand il n'y a rien à montrer, le dire — pas laisser un cadre vide.</summary>
public void ShowEmpty(string message)
{
_text.Text = Wrap(message, 46);
UpdateCommands();
}
void Display(int index)
{
if (index < 0 || index >= _pages.Count) return;
_index = index;
var page = _pages[index];
if (page.Image != null)
{
_imageMaterial.mainTexture = page.Image;
_imageMaterial.color = Color.white;
_imageQuad.gameObject.SetActive(true);
}
else
{
// Sans image, le cadre reste — mais sombre : il porte le texte, qui
// devient alors le contenu principal.
_imageMaterial.mainTexture = null;
_imageMaterial.color = new Color(0.08f, 0.10f, 0.13f, 1f);
}
var counter = _pages.Count > 1 ? $"\n{index + 1} / {_pages.Count}" : "";
var body = string.IsNullOrEmpty(page.Body) ? "" : $"\n{page.Body}";
_text.Text = Wrap(page.Heading, 46) + Wrap(body, 46) + counter;
UpdateCommands();
}
/// <summary>
/// Les flèches disparaissent aux extrémités plutôt que de ne rien faire : un
/// bouton présent qui ne réagit pas se lit comme une panne.
/// </summary>
void UpdateCommands()
{
if (_previous != null) _previous.gameObject.SetActive(_index > 0);
if (_next != null) _next.gameObject.SetActive(_index < _pages.Count - 1);
}
void OnSelected(IAimTarget target)
{
if (!(target is MenuItemPanel panel)) return;
Interacted?.Invoke();
switch (panel.Id)
{
case CommandPrevious: Display(_index - 1); break;
case CommandNext: Display(_index + 1); break;
case CommandBack: Closed?.Invoke(); break;
}
}
static string Wrap(string text, int columns)
{
if (string.IsNullOrEmpty(text)) return "";
var wrapped = new System.Text.StringBuilder();
// Les retours à la ligne du contenu sont respectés : c'est la mise en forme
// voulue par le gestionnaire, on ne recolle pas ses paragraphes.
foreach (var paragraph in text.Split('\n'))
{
if (wrapped.Length > 0) wrapped.Append('\n');
var line = 0;
foreach (var word in paragraph.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();
}
}
}

View File

@ -0,0 +1,81 @@
using System.IO;
using System.Threading.Tasks;
using MyInfoMate.Vr.Net;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Ce qu'il faut savoir pour faire d'une équirectangulaire un ciel — mis en commun
/// entre la lecture 360 d'une section (<see cref="SkyboxView"/>) et le fond immersif
/// d'une visite (<see cref="ImmersiveBackdrop"/>).
///
/// <b>Deux pièges vivent ici, et une seule fois.</b> Le shader absent du build (ciel
/// magenta sur le casque, correct dans l'éditeur) et la texture décodée en RGBA32
/// (134 Mo pour une 8192×4096) sont exactement le genre de choses qu'on corrige d'un
/// côté en oubliant l'autre.
/// </summary>
public static class PanoramicSky
{
const string PanoramicShader = "Skybox/Panoramic";
/// <summary>
/// Le matériau de ciel, ou null si le shader n'a pas survécu au build.
///
/// ⚠️ <c>Skybox/Panoramic</c> est construit au runtime : aucune scène ne le
/// référence, donc le build l'élimine s'il n'est pas dans <i>Project Settings →
/// Graphics → Always Included Shaders</i>. Le symptôme est un ciel magenta sur le
/// casque, et rien du tout dans l'éditeur — c'est le piège déjà payé avec glTFast.
/// </summary>
public static Material CreateMaterial()
{
var shader = Shader.Find(PanoramicShader);
if (shader == null)
{
Debug.LogError($"[Sky] Shader « {PanoramicShader} » absent du build. " +
"Project Settings → Graphics → Always Included Shaders.");
return null;
}
var material = new Material(shader);
material.SetFloat("_Mapping", 1f); // Latitude-longitude (équirectangulaire)
material.SetFloat("_ImageType", 0f); // 360°, pas 180°
return material;
}
/// <summary>
/// La texture d'une image équirectangulaire mise en cache, ou null.
///
/// ⚠️ <b>Mémoire.</b> <c>LoadImage</c> décode en RGBA32 : une 8192×4096 occupe
/// <b>134 Mo</b> de mémoire graphique, pour une seule image, sur un casque qui en
/// a quelques Go partagés avec le système. La compression matérielle (ASTC sur
/// Quest) divise ça par six à huit, au prix d'une seconde au chargement et d'une
/// perte invisible sur une photo. C'est elle qui décide si on peut enchaîner
/// plusieurs 360 sans que l'app se fasse tuer.
/// </summary>
public static async Task<Texture2D> LoadEquirectangularAsync(ApiClient client, string url)
{
var path = await ContentCache.MediaPathAsync(client, url);
if (path == null) return null;
// Pas de mipmaps : une équirectangulaire est vue à taille réelle, et les
// niveaux intermédiaires coûteraient un tiers de mémoire pour rien.
var texture = new Texture2D(2, 2, TextureFormat.RGBA32, mipChain: false);
if (!texture.LoadImage(File.ReadAllBytes(path)))
{
Debug.LogError($"[Sky] Image illisible : {url}");
Object.Destroy(texture);
return null;
}
// Sans cela, la couture verticale du panorama apparaît comme un trait net.
texture.wrapModeU = TextureWrapMode.Repeat;
texture.wrapModeV = TextureWrapMode.Clamp;
texture.Compress(highQuality: true);
texture.Apply(updateMipmaps: false, makeNoLongerReadable: true);
return texture;
}
}
}

View File

@ -0,0 +1,228 @@
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();
}
}
}

View File

@ -0,0 +1,172 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using MyInfoMate.Vr.Net;
using UnityEngine;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Remplit une <see cref="PagedView"/> à partir d'une section — item <b>E8</b> du
/// lot XR-4. C'est ici, et nulle part ailleurs, que vit ce qui distingue un Slider
/// d'une Map ou d'un Event.
///
/// <b>Trois types sur les quatre retenus au §8.</b> Le quatrième, la Video 360, attend
/// la ressource 360° côté backend. Le Parcours est volontairement rendu comme une Map :
/// « chaque étape = un lieu autour de soi » demande de la 3D, donc `SectionScene3D`.
/// </summary>
public static class SectionPages
{
/// <summary>Les types qu'on sait ouvrir aujourd'hui.</summary>
public static bool CanRender(ConfigurationExport.SectionType type) =>
type == ConfigurationExport.SectionType.Slider
|| type == ConfigurationExport.SectionType.Map
|| type == ConfigurationExport.SectionType.Parcours
|| type == ConfigurationExport.SectionType.Event;
public static async Task FillAsync(PagedView view,
ConfigurationExport.SectionSummary section,
ApiClient client,
string language)
{
switch (section.Type)
{
case ConfigurationExport.SectionType.Slider:
await FillSlider(view, section, client, language);
break;
case ConfigurationExport.SectionType.Map:
case ConfigurationExport.SectionType.Parcours:
await FillMap(view, section, client, language);
break;
case ConfigurationExport.SectionType.Event:
FillEvent(view, section, language);
break;
}
if (view != null && view.PageCount == 0)
view.ShowEmpty("Ce contenu est vide.");
}
static async Task FillSlider(PagedView view,
ConfigurationExport.SectionSummary section,
ApiClient client,
string language)
{
foreach (var content in section.OrderedContents())
{
var texture = await LoadImage(client, content.Resource?.Url);
if (view == null) return;
if (texture == null) continue;
view.AddPage(new PagedView.Page
{
Heading = ConfigurationExport.Translate(content.Title, language) ?? "",
Body = ConfigurationExport.Translate(content.Description, language),
Image = texture
});
}
}
/// <summary>
/// Une Map devient une <b>liste de points d'intérêt</b>, un par page.
///
/// ⚠️ Ce n'est pas la Map que le plan promet. Sa valeur en VR, c'est « le plan posé
/// devant soi comme une maquette » — et ça demande un modèle 3D, donc
/// `SectionScene3D` (§9). Une carte Google affichée sur un panneau flottant serait
/// <i>moins</i> bonne qu'un téléphone : autant montrer les lieux eux-mêmes, avec
/// leur photo et leurs horaires, ce que le `GeoPoint` porte déjà.
/// </summary>
static async Task FillMap(PagedView view,
ConfigurationExport.SectionSummary section,
ApiClient client,
string language)
{
foreach (var point in section.Points)
{
var texture = await LoadImage(client, point.ImageUrl);
if (view == null) return;
var parts = new List<string>();
var description = ConfigurationExport.Translate(point.Description, language);
if (!string.IsNullOrEmpty(description)) parts.Add(description);
var schedules = ConfigurationExport.Translate(point.Schedules, language);
if (!string.IsNullOrEmpty(schedules)) parts.Add(schedules);
view.AddPage(new PagedView.Page
{
Heading = ConfigurationExport.Translate(point.Title, language) ?? "",
Body = string.Join("\n", parts),
Image = texture
});
}
}
/// <summary>
/// Un Event devient le programme, une page par bloc.
///
/// <b>« Ce qui se passe aujourd'hui »</b> est l'usage visé (§8) : un casque en
/// écran d'attente dans un office de tourisme. On ne montre donc que la journée en
/// cours — et seulement s'il s'y passe quelque chose : un programme vide le jour
/// même vaut mieux rempli par les dates suivantes que pas rempli du tout.
/// </summary>
static void FillEvent(PagedView view,
ConfigurationExport.SectionSummary section,
string language)
{
var blocks = new List<ConfigurationExport.ProgrammeBlock>(section.Programme);
blocks.Sort((a, b) => Nullable.Compare(a.StartTime, b.StartTime));
var today = new List<ConfigurationExport.ProgrammeBlock>();
var upcoming = new List<ConfigurationExport.ProgrammeBlock>();
foreach (var block in blocks)
{
if (block.StartTime == null) continue;
var start = block.StartTime.Value.ToLocalTime();
if (start.Date == DateTime.Now.Date) today.Add(block);
else if (start > DateTime.Now) upcoming.Add(block);
}
var shown = today.Count > 0 ? today : upcoming;
foreach (var block in shown)
{
var start = block.StartTime?.ToLocalTime();
var end = block.EndTime?.ToLocalTime();
var when = start == null ? "" :
today.Count > 0
? (end == null ? $"{start:HH:mm}" : $"{start:HH:mm} {end:HH:mm}")
: $"{start:dd/MM} · {start:HH:mm}";
var description = ConfigurationExport.Translate(block.Description, language);
view.AddPage(new PagedView.Page
{
Heading = ConfigurationExport.Translate(block.Title, language) ?? "",
Body = string.IsNullOrEmpty(description) ? when : $"{when}\n{description}"
});
}
if (view.PageCount == 0)
view.ShowEmpty("Rien au programme aujourd'hui.");
}
static async Task<Texture2D> LoadImage(ApiClient client, string url)
{
if (string.IsNullOrEmpty(url)) return null;
var path = await ContentCache.MediaPathAsync(client, url);
if (path == null) return null;
var texture = new Texture2D(2, 2);
return texture.LoadImage(File.ReadAllBytes(path)) ? texture : null;
}
}
}

View File

@ -0,0 +1,220 @@
using System;
using System.IO;
using System.Threading.Tasks;
using MyInfoMate.Vr.Net;
using UnityEngine;
using UnityEngine.Video;
namespace MyInfoMate.Vr.Menu
{
/// <summary>
/// Lecture 360° — item <b>E6</b> du lot XR-4, et <b>la seule chose que le casque
/// fait mieux que tout le reste</b>.
///
/// Une photo ou une vidéo équirectangulaire devient le ciel : le visiteur est
/// <i>dedans</i>, il tourne la tête et le monde suit. C'est ce qu'aucune tablette ne
/// sait faire, et c'est pour ça que le plan (§8) dit que la Video 360 « gagne
/// vraiment » là où un Article flottant est <i>moins</i> bon qu'un écran.
///
/// Les deux cas passent par le même shader de skybox, donc le même rendu : une image
/// devient une texture, une vidéo devient une <c>RenderTexture</c> qu'un
/// <c>VideoPlayer</c> alimente image par image.
///
/// ⚠️ <b>Ce shader doit être dans <i>Always Included Shaders</i></b>, comme
/// <c>Universal Render Pipeline/Unlit</c> : construit au runtime, il n'est référencé
/// par aucune scène et le build l'élimine en silence. Le ciel sort alors magenta sur
/// le casque et correct dans l'éditeur — le piège déjà payé une fois avec glTFast
/// (voir le README de l'overlay).
/// </summary>
public class SkyboxView : MonoBehaviour
{
/// <summary>Résolution de la cible vidéo. 4096×2048 tient sur un Quest 2.</summary>
const int VideoWidth = 4096;
const int VideoHeight = 2048;
Material _skybox;
Material _previousSkybox;
VideoPlayer _player;
RenderTexture _target;
Texture2D _image;
MenuItemPanel _back;
Transform _head;
public event Action Closed;
public event Action Interacted;
public static SkyboxView Create(Transform head)
{
var root = new GameObject("SkyboxView");
var view = root.AddComponent<SkyboxView>();
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.Build();
return view;
}
void Build()
{
_previousSkybox = RenderSettings.skybox;
_skybox = PanoramicSky.CreateMaterial();
if (_skybox == null) return;
// Le seul élément posé dans la scène : de quoi ressortir.
_back = MenuItemPanel.Create("__back", "Regardez ici pour revenir", transform);
_back.transform.localScale = Vector3.one * 0.6f;
PlaceBack();
}
/// <summary>
/// <b>Le retour ne reste pas affiché.</b> Toute la valeur d'une 360 est d'y être :
/// un panneau planté en permanence dans le décor rappelle qu'on regarde une image,
/// et c'est précisément ce qu'on cherchait à faire oublier.
///
/// Il se montre <see cref="VisibleSeconds"/> secondes au début — assez pour qu'on
/// sache qu'il existe et où le retrouver — puis s'efface. Il revient dès que le
/// visiteur <b>baisse les yeux</b>, le geste qu'on fait naturellement pour chercher
/// une commande, ou dès qu'il touche une manette. Sans ce rappel, quelqu'un en
/// borne resterait coincé dans la photo.
/// </summary>
const float VisibleSeconds = 4f;
/// <summary>Sous cet angle, le visiteur cherche quelque chose plutôt qu'il ne regarde.</summary>
const float LookDownDegrees = 25f;
float _hideAt;
void Update()
{
if (_back == null || _head == null) return;
var wants = LookingDown() || ControllerInUse();
if (wants)
{
if (!_back.gameObject.activeSelf) PlaceBack();
_hideAt = Time.time + VisibleSeconds;
}
_back.gameObject.SetActive(Time.time < _hideAt);
}
bool LookingDown() => Vector3.Angle(_head.forward, Vector3.down) < 90f - LookDownDegrees;
static bool ControllerInUse()
{
var controller = OVRInput.GetActiveController();
return controller == OVRInput.Controller.RTouch
|| controller == OVRInput.Controller.LTouch;
}
/// <summary>
/// Reposé sous le regard courant à chaque réapparition : en 360 le visiteur tourne
/// sur lui-même, et un panneau laissé à sa position de départ serait dans son dos
/// au moment où il le cherche.
/// </summary>
void PlaceBack()
{
if (_back == null) return;
var forward = _head != null ? _head.forward : Vector3.forward;
forward.y = 0f;
if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward;
forward.Normalize();
_back.transform.position =
(_head != null ? _head.position : Vector3.zero) + forward * 2.2f + Vector3.down * 0.8f;
_back.transform.rotation = Quaternion.LookRotation(forward);
_back.gameObject.SetActive(true);
}
public async Task LoadAsync(ConfigurationExport.Resource resource, ApiClient client)
{
if (_skybox == null) return;
if (resource.Type == ConfigurationExport.ResourceKind.Video360)
await PlayVideo(resource, client);
else
await ShowImage(resource, client);
}
async Task ShowImage(ConfigurationExport.Resource resource, ApiClient client)
{
var texture = await PanoramicSky.LoadEquirectangularAsync(client, resource.Url);
if (texture == null || this == null) return;
_image = texture;
_skybox.SetTexture("_MainTex", texture);
RenderSettings.skybox = _skybox;
// Le panneau de retour se montre au début, puis s'efface.
_hideAt = Time.time + VisibleSeconds;
}
async Task PlayVideo(ConfigurationExport.Resource resource, ApiClient client)
{
// Une vidéo 360 pèse des centaines de Mo : elle est lue depuis le cache
// disque, pas gardée en mémoire.
var path = await ContentCache.MediaPathAsync(client, resource.Url);
if (path == null || this == null) return;
_target = new RenderTexture(VideoWidth, VideoHeight, 0);
_player = gameObject.AddComponent<VideoPlayer>();
_player.source = VideoSource.Url;
_player.url = new Uri(path).AbsoluteUri;
_player.renderMode = VideoRenderMode.RenderTexture;
_player.targetTexture = _target;
_player.isLooping = true;
_player.audioOutputMode = VideoAudioOutputMode.Direct;
_player.errorReceived += (_, message) =>
Debug.LogError($"[Skybox] Lecture vidéo impossible : {message}");
_skybox.SetTexture("_MainTex", _target);
RenderSettings.skybox = _skybox;
_hideAt = Time.time + VisibleSeconds;
_player.Play();
}
void OnSelected(IAimTarget target)
{
if (!(target is MenuItemPanel panel) || panel.Id != "__back") return;
Interacted?.Invoke();
Closed?.Invoke();
}
/// <summary>
/// Le ciel est un réglage <b>global</b> : sans cette remise en état, le menu
/// s'afficherait par-dessus la dernière image 360 vue, et le visiteur suivant
/// hériterait du décor du précédent.
/// </summary>
void OnDestroy()
{
RenderSettings.skybox = _previousSkybox;
if (_player != null) _player.Stop();
if (_target != null) _target.Release();
// Une équirectangulaire pèse des dizaines de Mo même compressée : laissée
// derrière, elle s'accumulerait à chaque 360 ouverte jusqu'à ce que le
// système tue l'app — au bout de plusieurs visites, donc jamais en test court.
if (_image != null) Destroy(_image);
}
}
}

View File

@ -0,0 +1,160 @@
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using UnityEngine;
using UnityEngine.Networking;
namespace MyInfoMate.Vr.Net
{
/// <summary>
/// Le seul endroit du projet qui parle à manager-service.
///
/// Trois règles tenues ici, parce qu'elles se paient cher ailleurs :
/// la clé d'API part en en-tête <c>X-Api-Key</c> et jamais dans l'URL ;
/// un échec produit une <b>phrase lisible</b> comme <see cref="Manifest.ManifestReader"/>,
/// jamais un <c>null</c> silencieux ; et le JSON est en camelCase côté serveur,
/// PascalCase côté C#.
/// </summary>
public class ApiClient
{
public const string ApiKeyHeader = "X-Api-Key";
/// <summary>Une borne sur un wifi de musée : ni instantané, ni infini.</summary>
const int TimeoutSeconds = 20;
static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new CamelCaseNamingStrategy()
},
MissingMemberHandling = MissingMemberHandling.Ignore,
NullValueHandling = NullValueHandling.Ignore
};
public string BaseUrl { get; }
/// <summary>Null tant que l'appairage n'a pas eu lieu.</summary>
public string ApiKey { get; set; }
public ApiClient(string baseUrl, string apiKey = null)
{
// Une base d'URL avec un slash final produit "//api/..." — accepté par ASP.NET,
// mais pas par tous les proxies devant.
BaseUrl = baseUrl?.TrimEnd('/');
ApiKey = apiKey;
}
public class Result<T>
{
public T Value;
/// <summary>Null si tout va bien ; sinon une phrase destinée au visiteur.</summary>
public string Error;
public bool Ok => Error == null;
}
public Task<Result<T>> GetAsync<T>(string path) =>
SendAsync<T>(UnityWebRequest.Get(BaseUrl + path), path);
public Task<Result<T>> PostAsync<T>(string path, object body) =>
SendBody<T>(path, body, UnityWebRequest.kHttpVerbPOST);
public Task<Result<T>> PutAsync<T>(string path, object body) =>
SendBody<T>(path, body, UnityWebRequest.kHttpVerbPUT);
Task<Result<T>> SendBody<T>(string path, object body, string verb)
{
var json = JsonConvert.SerializeObject(body, Settings);
var request = new UnityWebRequest(BaseUrl + path, verb)
{
uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json)),
downloadHandler = new DownloadHandlerBuffer()
};
request.SetRequestHeader("Content-Type", "application/json");
return SendAsync<T>(request, path);
}
/// <summary>
/// Le texte brut, pour l'export de configuration : c'est un
/// <c>FileContentResult</c> côté serveur, et on veut pouvoir l'écrire tel quel
/// dans le cache disque avant de le désérialiser.
/// </summary>
public async Task<Result<string>> GetRawAsync(string path)
{
using var request = UnityWebRequest.Get(BaseUrl + path);
var sent = await Send(request, path);
return sent != null
? new Result<string> { Error = sent }
: new Result<string> { Value = request.downloadHandler.text };
}
async Task<Result<T>> SendAsync<T>(UnityWebRequest request, string path)
{
using (request)
{
var sent = await Send(request, path);
if (sent != null) return new Result<T> { Error = sent };
// Un 204 n'a pas de corps — c'est le cas nominal de la télémétrie, pas
// une réponse illisible.
if (string.IsNullOrWhiteSpace(request.downloadHandler?.text))
return new Result<T>();
try
{
return new Result<T>
{
Value = JsonConvert.DeserializeObject<T>(
request.downloadHandler.text, Settings)
};
}
catch (JsonException e)
{
Debug.LogError($"[Api] {path} : réponse illisible — {e.Message}");
return new Result<T> { Error = "Le serveur a répondu quelque chose d'inattendu." };
}
}
}
/// <summary>Null si la requête est passée ; sinon la phrase à afficher.</summary>
async Task<string> Send(UnityWebRequest request, string path)
{
request.timeout = TimeoutSeconds;
if (!string.IsNullOrEmpty(ApiKey)) request.SetRequestHeader(ApiKeyHeader, ApiKey);
UnityWebRequestAsyncOperation operation;
try
{
operation = request.SendWebRequest();
}
catch (System.InvalidOperationException e)
{
// Le cas connu : « Insecure connection not allowed », quand le Player
// interdit le HTTP en clair et que le serveur est en http://. Ça lève
// au lieu de rendre un code, donc sans ce catch l'await remonte
// l'exception et le casque n'affiche rien du tout.
Debug.LogError($"[Api] {path} : requête refusée avant l'envoi — {e.Message}");
return "Le serveur est injoignable. Vérifiez la connexion du casque.";
}
while (!operation.isDone) await Task.Yield();
if (request.result == UnityWebRequest.Result.Success) return null;
Debug.LogError($"[Api] {path} : {(long)request.responseCode} {request.error}");
// Ces trois codes-là ne sont pas des pannes réseau, ce sont des erreurs
// d'appairage — et c'est ce que la personne devant le casque doit lire.
return request.responseCode switch
{
401 => "Ce casque n'est plus autorisé. Refaites l'appairage.",
403 => "Ce casque n'a pas accès à ce contenu.",
404 => "Ce contenu n'existe plus sur le serveur.",
_ => "Le serveur est injoignable. Vérifiez la connexion du casque."
};
}
}
}

View File

@ -0,0 +1,359 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Newtonsoft.Json;
using UnityEngine;
namespace MyInfoMate.Vr.Net
{
/// <summary>
/// Lecture de l'export de configuration — item <b>E3</b> du lot XR-4.
///
/// <b>Un seul appel pour tout le contenu</b> : c'est l'arbitrage du lot XR-3, et la
/// raison pour laquelle Unity n'a pas besoin du client généré. <c>ExportConfigurationDTO</c>
/// est le DTO de l'import/export du back-office, déjà stable, et c'est aussi celui que
/// mymuseum-visitapp télécharge pour une visite hors ligne.
///
/// ⚠️ <b>L'export ne rend qu'une langue à la fois.</b> <c>language</c> traverse jusqu'à
/// <c>Section.GetReferencedResourceIds(language)</c> côté serveur : changer de langue
/// veut dire refaire l'appel, et un casque en borne change de langue à chaud. C'est la
/// question ouverte du lot XR-3 ; en attendant, on recharge.
/// </summary>
public class ConfigurationExport
{
public string Id;
public string Label;
public List<SectionSummary> Sections = new List<SectionSummary>();
/// <summary>
/// Tous les médias référencés par la configuration, dans un seul appel. C'est ce
/// qui permet de résoudre la ressource d'une section sans repasser par le réseau.
/// </summary>
public List<Resource> Resources = new List<Resource>();
/// <summary>
/// Valeurs de <c>ResourceType</c> côté serveur, <b>persistées en int</b>. Seules
/// celles dont le casque a besoin sont nommées ici ; les autres passent en nombre
/// sans rien casser.
/// </summary>
public enum ResourceKind
{
Image = 0, Video = 1, ImageUrl = 2, VideoUrl = 3, Audio = 4, PDF = 5,
JSON = 6, JSONUrl = 7, Word = 8, PowerPoint = 9, Text = 10,
Image360 = 11, Video360 = 12, Model3D = 13
}
/// <summary>La ressource d'un id, ou null si la configuration ne la porte pas.</summary>
public Resource FindResource(string id)
{
if (string.IsNullOrEmpty(id)) return null;
foreach (var resource in Resources)
if (resource.Id == id) return resource;
return null;
}
/// <summary>
/// Les 13 types de <c>SectionDTO</c>, persistés en int. On n'en rend que quatre
/// (§8 du plan) — mais on doit tous les <b>lire</b> sans casser, sinon un contenu
/// qui n'est pas pour nous ferait échouer la visite entière.
/// </summary>
public enum SectionType
{
Map = 0, Slider = 1, Video = 2, Web = 3, Menu = 4, Quiz = 5,
Article = 6, PDF = 7, Game = 8, Agenda = 9, Weather = 10,
Event = 11, Parcours = 12,
/// <summary>Scène 3D avec points d'intérêt (E5/E7), ajoutée le 2026-09-12.</summary>
Scene3D = 13
}
/// <summary>
/// Ce que le visiteur fait de la scène, et c'est une opposition franche (§4bis du
/// plan de frontière) : soit il <b>manipule un objet</b> posé devant lui — l'épée
/// du roi, caméra orbitale, les points tournent avec —, soit il <b>est dedans</b>
/// et regarde autour — un décor, caméra fixe, les points restent où ils sont.
///
/// Même GLB, même modèle de point, même éditeur. Ce qui change, c'est où l'on met
/// le visiteur — et ça, aucun fichier ne peut le deviner.
/// </summary>
public enum Scene3DMode
{
Asset = 0,
Scene = 1
}
/// <summary>
/// Le fond du lieu — §4 du plan de frontière. Null quand la visite n'en a pas,
/// ce qui est le cas de toutes celles écrites avant le 2026-09-12.
/// </summary>
public Backdrop ImmersiveBackground;
public enum ImmersiveBackgroundKind
{
Pano = 0,
Video360 = 1,
Scene3D = 2
}
/// <summary>
/// Nommée <c>Backdrop</c> et non <c>ImmersiveBackground</c> : en C# un champ ne
/// peut pas porter le nom d'un type imbriqué de la même classe, et c'est le champ
/// qui doit garder le nom du JSON.
/// </summary>
public class Backdrop
{
public string ResourceId;
public ImmersiveBackgroundKind Kind;
/// <summary>
/// URL posée par le serveur. Le casque n'a pas de client généré et démarre
/// souvent sans réseau : résoudre l'id ici serait un appel qu'il ne peut pas
/// passer.
/// </summary>
public string ResourceUrl;
/// <summary>Image plate, pour les canaux qui ne rendent pas l'immersif.</summary>
public string FallbackResourceId;
public string FallbackUrl;
}
public class SectionSummary
{
public string Id;
public string Label;
public SectionType Type;
public bool IsActive;
public bool IsSubSection;
public string ParentId;
public int? Order;
public string ImageSource;
public List<Translation> Title = new List<Translation>();
public List<Translation> Description = new List<Translation>();
/// <summary>
/// Les médias d'un Slider. Le champ n'existe que sur les types qui en ont —
/// l'export sérialise le sous-type réel de chaque section, pas un `SectionDTO`
/// nu — et reste vide partout ailleurs.
/// </summary>
public List<Content> Contents = new List<Content>();
/// <summary>Les points d'intérêt d'une Map.</summary>
public List<GeoPoint> Points = new List<GeoPoint>();
/// <summary>Le programme d'un Event.</summary>
public List<ProgrammeBlock> Programme = new List<ProgrammeBlock>();
/// <summary>Vrai si cette Map porte des parcours guidés plutôt que des POI libres.</summary>
public bool IsParcours;
/// <summary>
/// Média d'une section Video : <b>soit un id de ressource, soit une URL</b>
/// (YouTube, Vimeo) — c'est le même champ côté serveur, et seul le premier cas
/// est téléchargeable hors ligne.
/// </summary>
public string Source;
public bool SourceIsUrl =>
!string.IsNullOrEmpty(Source)
&& Source.StartsWith("http", StringComparison.OrdinalIgnoreCase);
/// <summary>Ressource GLB d'une section scène 3D.</summary>
public string Model3DResourceId;
/// <summary>URL du modèle, remplie par le serveur comme <c>ImageSource</c>.</summary>
public string Model3DSource;
/// <summary>Objet manipulé ou décor habité. Voir <see cref="Scene3DMode"/>.</summary>
public Scene3DMode Scene3DMode;
/// <summary>Les médias, dans l'ordre voulu par le gestionnaire.</summary>
public List<Content> OrderedContents()
{
var ordered = new List<Content>(Contents);
ordered.Sort((a, b) => (a.Order ?? int.MaxValue).CompareTo(b.Order ?? int.MaxValue));
return ordered;
}
}
public class Content
{
public int? Order;
public string ResourceId;
public Resource Resource;
public List<Translation> Title = new List<Translation>();
public List<Translation> Description = new List<Translation>();
}
/// <summary>
/// Un point d'intérêt. Le même objet porte déjà titre, description, image et
/// contenus multilingues — c'est ce qui rendra les POI sur modèle 3D presque
/// gratuits le jour où `SectionScene3D` existera (§9 du plan).
/// </summary>
public class GeoPoint
{
public int? Id;
public string ImageUrl;
/// <summary>
/// Position sur une maquette 3D, nulle sur un point de carte. Exprimée dans
/// la convention <b>glTF</b> du manifeste : la conversion vers Unity se fait
/// dans <c>GltfSpace</c>, et nulle part ailleurs.
/// </summary>
public Position3D LocalTransform;
public List<Translation> Title = new List<Translation>();
public List<Translation> Description = new List<Translation>();
public List<Translation> Schedules = new List<Translation>();
public List<Content> Contents = new List<Content>();
}
public class Position3D
{
public float X;
public float Y;
public float Z;
public float? RotationY;
}
public class ProgrammeBlock
{
public string Id;
public DateTime? StartTime;
public DateTime? EndTime;
public List<Translation> Title = new List<Translation>();
public List<Translation> Description = new List<Translation>();
}
public class Resource
{
public string Id;
public string Label;
public ResourceKind Type;
/// <summary>L'URL du fichier. C'est elle qui alimente le cache disque.</summary>
public string Url;
public int? Width;
public int? Height;
/// <summary>Une ressource que le casque sait afficher en immersif.</summary>
public bool IsImmersive =>
Type == ResourceKind.Image360 || Type == ResourceKind.Video360;
}
public class Translation
{
public string Language;
public string Value;
}
/// <summary>Le texte dans la langue demandée, ou la première traduction disponible.</summary>
public static string Translate(List<Translation> translations, string language)
{
if (translations == null || translations.Count == 0) return null;
foreach (var t in translations)
if (string.Equals(t.Language, language, System.StringComparison.OrdinalIgnoreCase))
return PlainText(t.Value);
return PlainText(translations[0].Value);
}
/// <summary>
/// Les textes du manager sont saisis dans un éditeur riche : un titre de section
/// arrive en <c>&lt;p&gt;Quiz test&lt;/p&gt;</c>. Un <c>TextMesh</c> ne connaît pas
/// le HTML et affiche les balises telles quelles.
///
/// C'est ici et nulle part ailleurs, parce que <see cref="Translate"/> est le seul
/// chemin par lequel un texte de l'export atteint l'écran — menu, pages, hotspots.
/// </summary>
static string PlainText(string html)
{
if (string.IsNullOrEmpty(html)) return html;
var text = System.Text.RegularExpressions.Regex.Replace(
html, "<br\\s*/?>|</p>|</div>|</li>", "\n");
text = System.Text.RegularExpressions.Regex.Replace(text, "<[^>]+>", "");
text = text.Replace("&nbsp;", " ").Replace("&amp;", "&")
.Replace("&lt;", "<").Replace("&gt;", ">")
.Replace("&quot;", "\"").Replace("&#39;", "'");
return text.Trim();
}
/// <summary>Les sections de premier niveau, dans l'ordre du manager.</summary>
public List<SectionSummary> RootSections()
{
var roots = new List<SectionSummary>();
foreach (var s in Sections)
if (!s.IsSubSection && s.IsActive) roots.Add(s);
roots.Sort((a, b) => (a.Order ?? int.MaxValue).CompareTo(b.Order ?? int.MaxValue));
return roots;
}
/// <summary>
/// ⚠️ <b>Volontairement sans paramètre de langue.</b> C'était la question ouverte
/// du lot XR-3 — « l'export doit-il rendre toutes les langues d'un coup pour un
/// casque en borne ? » — et la réponse était déjà dans le code : <c>language</c>
/// ne filtre que les <b>ressources</b> (les audios d'une langue), les textes étant
/// toujours rendus dans toutes leurs traductions. Omettre le paramètre rend donc
/// tout : <c>GetReferencedResourceIds(null)</c> renvoie les médias de toutes les
/// langues.
///
/// Conséquence concrète : <b>changer de langue à chaud ne demande aucun appel</b>,
/// et le cache disque en garde une copie au lieu d'une par langue.
/// </summary>
public static async Task<ApiClient.Result<ConfigurationExport>> FetchAsync(
ApiClient client, string configurationId)
{
var raw = await client.GetRawAsync(
$"/api/configuration/{configurationId}/export");
if (!raw.Ok) return new ApiClient.Result<ConfigurationExport> { Error = raw.Error };
return Parse(raw.Value);
}
public static ApiClient.Result<ConfigurationExport> Parse(string json)
{
if (string.IsNullOrWhiteSpace(json))
return Fail("Le contenu de ce lieu est vide.");
try
{
var export = JsonConvert.DeserializeObject<ConfigurationExport>(json,
new JsonSerializerSettings
{
// L'export est en camelCase, et un type de section inconnu d'une
// version future ne doit pas faire échouer la lecture.
ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver
{
NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy()
},
MissingMemberHandling = MissingMemberHandling.Ignore,
NullValueHandling = NullValueHandling.Ignore
});
if (export == null) return Fail("Le contenu de ce lieu est illisible.");
return new ApiClient.Result<ConfigurationExport> { Value = export };
}
catch (JsonException e)
{
Debug.LogError($"[Export] JSON illisible : {e.Message}");
return Fail("Le contenu de ce lieu est illisible.");
}
}
static ApiClient.Result<ConfigurationExport> Fail(string error)
{
Debug.LogError($"[Export] {error}");
return new ApiClient.Result<ConfigurationExport> { Error = error };
}
}
}

View File

@ -0,0 +1,180 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;
namespace MyInfoMate.Vr.Net
{
/// <summary>
/// Cache disque du contenu — item <b>E4</b> du lot XR-4.
///
/// <b>C'est l'item qui justifie Unity plutôt que WebXR</b> (§10 du plan) : une borne
/// d'accueil tourne 8 h par jour sans personne, et le wifi d'un musée tombe. En natif
/// on écrit sur le disque, sans quota de navigateur.
///
/// La règle est <b>cache d'abord</b>, jamais réseau d'abord : l'app démarre sur ce
/// qu'elle a, et se rafraîchit ensuite. Un serveur lent ou absent ne doit pas
/// retarder d'une seconde l'affichage d'un contenu déjà téléchargé.
///
/// Rien n'est jamais servi à moitié : le JSON est écrit dans un fichier temporaire
/// puis déplacé. Une coupure de courant pendant une écriture — le cas nominal pour
/// une borne qu'on débranche le soir — laisse l'ancienne version intacte, jamais un
/// fichier tronqué qui ne se relit pas.
/// </summary>
public static class ContentCache
{
static string Root => Path.Combine(Application.persistentDataPath, "content");
// Un seul fichier par configuration, pas un par langue : l'export les porte
// toutes (voir ConfigurationExport.FetchAsync).
static string ExportPath(string configurationId) =>
Path.Combine(Root, $"{configurationId}.json");
static string MediaDirectory => Path.Combine(Root, "media");
/// <summary>Le contenu en cache, ou null si ce casque n'a jamais rien téléchargé.</summary>
public static string ReadExport(string configurationId)
{
var path = ExportPath(configurationId);
try
{
return File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : null;
}
catch (IOException e)
{
Debug.LogError($"[Cache] Lecture de {path} impossible : {e.Message}");
return null;
}
}
public static DateTime? ExportDate(string configurationId)
{
var path = ExportPath(configurationId);
return File.Exists(path) ? File.GetLastWriteTimeUtc(path) : (DateTime?)null;
}
public static void WriteExport(string configurationId, string json)
{
var path = ExportPath(configurationId);
var temporary = path + ".tmp";
try
{
Directory.CreateDirectory(Root);
File.WriteAllText(temporary, json, Encoding.UTF8);
// File.Move ne remplace pas sur toutes les plateformes ; le couple
// delete + move est la seule forme qui marche partout, et la fenêtre
// entre les deux est couverte par le .tmp qui reste lisible.
if (File.Exists(path)) File.Delete(path);
File.Move(temporary, path);
}
catch (IOException e)
{
Debug.LogError($"[Cache] Écriture de {path} impossible : {e.Message}");
}
}
/// <summary>
/// Le fichier local d'un média <b>déjà téléchargé</b>, ou null. Sans réseau et
/// sans attente : c'est ce qui permet à un appelant synchrone — une coroutine,
/// typiquement — de jouer un son depuis le disque au lieu de le streamer.
/// </summary>
public static string CachedPath(string url)
{
if (string.IsNullOrEmpty(url)) return null;
var path = LocalPathOf(url);
return File.Exists(path) ? path : null;
}
static string LocalPathOf(string url) =>
Path.Combine(MediaDirectory, HashOf(url) + ExtensionOf(url));
/// <summary>
/// Le fichier local d'un média, téléchargé si absent. Le nom vient d'un hachage
/// de l'URL : les sources du CMS ne sont pas des noms de fichiers sûrs, et deux
/// ressources peuvent porter le même nom d'affichage.
/// </summary>
public static async Task<string> MediaPathAsync(ApiClient client, string url)
{
if (string.IsNullOrEmpty(url)) return null;
var path = LocalPathOf(url);
if (File.Exists(path)) return path;
using var request = UnityWebRequest.Get(url);
var operation = request.SendWebRequest();
while (!operation.isDone) await Task.Yield();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"[Cache] {url} : {request.error}");
return null;
}
try
{
Directory.CreateDirectory(MediaDirectory);
var temporary = path + ".tmp";
File.WriteAllBytes(temporary, request.downloadHandler.data);
if (File.Exists(path)) File.Delete(path);
File.Move(temporary, path);
return path;
}
catch (IOException e)
{
Debug.LogError($"[Cache] Écriture de {path} impossible : {e.Message}");
return null;
}
}
/// <summary>Octets occupés par le cache. À afficher un jour dans le manager.</summary>
public static long SizeBytes()
{
if (!Directory.Exists(Root)) return 0;
long total = 0;
foreach (var file in Directory.GetFiles(Root, "*", SearchOption.AllDirectories))
total += new FileInfo(file).Length;
return total;
}
public static void Clear()
{
try
{
if (Directory.Exists(Root)) Directory.Delete(Root, true);
}
catch (IOException e)
{
Debug.LogError($"[Cache] Purge impossible : {e.Message}");
}
}
static string HashOf(string value)
{
using var sha = SHA1.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(value));
var hex = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes) hex.Append(b.ToString("x2"));
return hex.ToString();
}
/// <summary>
/// L'extension compte : Unity choisit son décodeur vidéo et son importeur de
/// texture dessus. Une URL avec une query string ne doit pas la faire perdre.
/// </summary>
static string ExtensionOf(string url)
{
var withoutQuery = url.Split('?')[0];
var extension = Path.GetExtension(withoutQuery);
return string.IsNullOrEmpty(extension) || extension.Length > 6 ? "" : extension;
}
}
}

View File

@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
namespace MyInfoMate.Vr.Net
{
/// <summary>
/// Préchargement des médias d'une visite — la seconde moitié de l'item <b>E4</b>.
///
/// <b>Le cache disque seul ne suffit pas.</b> Il se remplissait à la demande : une
/// borne branchée dans une salle sans wifi avait son JSON, ses titres, son menu — et
/// pas une image. Le contenu n'arrivait que si le visiteur ouvrait la section
/// pendant que le réseau était là, ce qui est exactement l'inverse de ce qu'on
/// promet. C'est l'argument n°1 qui a fait choisir Unity contre WebXR (§10 du plan) ;
/// sans cette classe, il n'était pas tenu.
///
/// Trois règles, toutes dictées par l'exploitation :
/// <list type="bullet">
/// <item><b>Après le menu, jamais avant.</b> Le visiteur voit son menu en une
/// seconde ; le téléchargement se fait derrière lui. Bloquer le démarrage sur
/// des gigaoctets de vidéo 360 ferait d'une borne un écran d'attente.</item>
/// <item><b>Un par un.</b> Dix téléchargements parallèles sur le wifi d'un musée se
/// gênent, et une 360 de 2 Go n'a rien à gagner à partager la bande passante
/// avec neuf vignettes.</item>
/// <item><b>Un échec n'arrête rien.</b> Chaque média manquant sera retenté au
/// prochain démarrage ; les autres sont déjà là.</item>
/// </list>
/// </summary>
public static class ContentPreloader
{
public struct Progress
{
public int Done;
public int Total;
public int Failed;
/// <summary>Médias déjà sur le disque au démarrage — jamais retéléchargés.</summary>
public int AlreadyCached;
}
/// <summary>
/// Télécharge tout ce que le casque saura afficher hors ligne. Rend le bilan, qui
/// n'a d'autre usage que le journal : rien de tout ceci ne doit se voir.
/// </summary>
public static async Task<Progress> RunAsync(
ConfigurationExport export, ApiClient client, Action<Progress> advanced = null)
{
var urls = MediaUrlsOf(export);
var progress = new Progress { Total = urls.Count };
if (urls.Count == 0) return progress;
Debug.Log($"[Preload] {urls.Count} médias à mettre en cache.");
foreach (var url in urls)
{
if (ContentCache.CachedPath(url) != null)
{
progress.AlreadyCached++;
progress.Done++;
}
else if (await ContentCache.MediaPathAsync(client, url) != null)
{
progress.Done++;
}
else
{
// Volontairement silencieux côté visiteur : ContentCache a déjà
// journalisé la cause, et un média manquant n'invalide pas la visite.
progress.Failed++;
}
advanced?.Invoke(progress);
}
Debug.Log($"[Preload] Terminé — {progress.Done}/{progress.Total} disponibles " +
$"hors ligne ({progress.AlreadyCached} déjà en cache, " +
$"{progress.Failed} en échec), {ContentCache.SizeBytes() / 1048576} Mo sur le disque.");
return progress;
}
/// <summary>
/// Toutes les URL téléchargeables de la configuration, sans doublon et dans un
/// ordre utile : <b>les vignettes du menu d'abord</b>, parce que ce sont elles
/// qu'on voit en premier, et les gros médias ensuite.
///
/// ⚠️ Les types <c>ImageUrl</c> / <c>VideoUrl</c> sont <b>écartés</b> : ce sont des
/// liens externes (YouTube, Vimeo), qui ne se mettent pas en cache et ne
/// s'affichent pas hors ligne. Les PDF et les JSON aussi — le casque ne les rend
/// pas (§8 du plan), les télécharger ne ferait que remplir le disque.
/// </summary>
public static List<string> MediaUrlsOf(ConfigurationExport export)
{
var urls = new List<string>();
var seen = new HashSet<string>();
void Add(string url)
{
if (string.IsNullOrEmpty(url)) return;
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return;
if (seen.Add(url)) urls.Add(url);
}
if (export?.Sections != null)
foreach (var section in export.Sections)
{
Add(section.ImageSource);
Add(section.Model3DSource);
if (section.Points != null)
foreach (var point in section.Points)
Add(point.ImageUrl);
}
if (export?.Resources != null)
foreach (var resource in export.Resources)
if (IsDownloadable(resource.Type))
Add(resource.Url);
return urls;
}
static bool IsDownloadable(ConfigurationExport.ResourceKind kind)
{
switch (kind)
{
case ConfigurationExport.ResourceKind.Image:
case ConfigurationExport.ResourceKind.Video:
case ConfigurationExport.ResourceKind.Audio:
case ConfigurationExport.ResourceKind.Image360:
case ConfigurationExport.ResourceKind.Video360:
case ConfigurationExport.ResourceKind.Model3D:
return true;
default:
return false;
}
}
}
}

View File

@ -0,0 +1,112 @@
using System;
using System.Threading.Tasks;
using UnityEngine;
namespace MyInfoMate.Vr.Net
{
/// <summary>
/// Le contenu d'un lieu, servi <b>cache d'abord</b> — item <b>E4</b> du lot XR-4.
///
/// Deux chemins, jamais mélangés :
/// <list type="bullet">
/// <item><b>Au démarrage</b> — le cache s'il existe, affiché tout de suite ;
/// le réseau seulement s'il n'y a rien en cache.</item>
/// <item><b>Ensuite</b> — un rafraîchissement en tâche de fond, dont l'échec est
/// silencieux : une borne hors ligne affiche son contenu de la veille, et
/// ce n'est pas une erreur à montrer au visiteur.</item>
/// </list>
///
/// C'est aussi ce qui rendra la reprise après coupure gratuite : au redémarrage, le
/// cache est déjà là.
/// </summary>
public class ContentService
{
readonly ApiClient _client;
readonly string _configurationId;
public ContentService(ApiClient client, string configurationId)
{
_client = client;
_configurationId = configurationId;
}
public class Loaded
{
public ConfigurationExport Export;
/// <summary>Vrai si le contenu vient du disque et non du serveur.</summary>
public bool FromCache;
/// <summary>Date du cache servi, pour l'afficher en mode dégradé.</summary>
public DateTime? CachedAt;
public string Error;
public bool Ok => Error == null;
}
public async Task<Loaded> LoadAsync()
{
var cached = ContentCache.ReadExport(_configurationId);
if (cached != null)
{
var parsed = ConfigurationExport.Parse(cached);
if (parsed.Ok)
return new Loaded
{
Export = parsed.Value,
FromCache = true,
CachedAt = ContentCache.ExportDate(_configurationId)
};
// Un cache illisible est un cache mort : on ne le garde pas pour
// rejouer le même échec au prochain démarrage.
Debug.LogWarning("[Content] Cache illisible, il est écarté.");
}
return await FetchAsync();
}
/// <summary>
/// Rafraîchit le cache sans bloquer l'affichage. Renvoie le contenu neuf, ou null
/// si rien n'a changé ou si le serveur n'a pas répondu — dans les deux cas
/// l'appelant garde ce qu'il montre.
/// </summary>
public async Task<ConfigurationExport> RefreshAsync()
{
var before = ContentCache.ReadExport(_configurationId);
var raw = await _client.GetRawAsync(
$"/api/configuration/{_configurationId}/export");
if (!raw.Ok)
{
Debug.Log($"[Content] Rafraîchissement impossible ({raw.Error}) — " +
"le contenu en cache reste affiché.");
return null;
}
if (raw.Value == before) return null;
var parsed = ConfigurationExport.Parse(raw.Value);
if (!parsed.Ok) return null;
ContentCache.WriteExport(_configurationId, raw.Value);
return parsed.Value;
}
async Task<Loaded> FetchAsync()
{
var raw = await _client.GetRawAsync(
$"/api/configuration/{_configurationId}/export");
if (!raw.Ok) return new Loaded { Error = raw.Error };
var parsed = ConfigurationExport.Parse(raw.Value);
if (!parsed.Ok) return new Loaded { Error = parsed.Error };
ContentCache.WriteExport(_configurationId, raw.Value);
return new Loaded { Export = parsed.Value };
}
}
}

View File

@ -0,0 +1,179 @@
using System.Threading.Tasks;
using UnityEngine;
namespace MyInfoMate.Vr.Net
{
/// <summary>
/// Appairage du casque — item <b>E2</b> du lot XR-4.
///
/// Le flux est celui de la tablette, à un paramètre près
/// (<c>tablet-app/lib/Screens/Configuration/config_view.dart:274</c> et son
/// <c>_fetchAppKey</c>) :
/// <list type="number">
/// <item>code PIN → <c>GET /api/instance/app-key</c> → clé d'API + id d'instance</item>
/// <item>la clé → <c>GET /api/configuration</c> → les configurations de l'instance</item>
/// <item><c>POST /api/device</c> avec <c>appType = VR</c> → le casque existe dans la flotte</item>
/// </list>
///
/// ⚠️ <b>Le paramètre qui change tout, c'est <c>appType</c>.</b> Sans lui, le serveur
/// crée une tablette (<c>DeviceController.Create</c>, défaut <c>Tablet</c>) : le casque
/// atterrirait dans l'onglet Kiosk du manager. Avec <c>VR</c>, il est rattaché à
/// l'<c>ApplicationInstance</c> VR — et un 404 signifie que le canal VR n'est pas
/// activé sur cette instance.
/// </summary>
public class PairingService
{
/// <summary>Valeur 3 de l'enum <c>AppType</c> côté serveur — persistée en int.</summary>
public const int AppTypeVr = 3;
/// <summary>Nom de l'enum <c>ApiKeyAppType</c>, envoyé tel quel en query.</summary>
const string ApiKeyAppTypeVr = "VrApp";
const string PrefsBaseUrl = "myinfomate.baseUrl";
const string PrefsApiKey = "myinfomate.apiKey";
const string PrefsInstanceId = "myinfomate.instanceId";
const string PrefsDeviceId = "myinfomate.deviceId";
const string PrefsConfigurationId = "myinfomate.configurationId";
public class Pairing
{
public string BaseUrl;
public string ApiKey;
public string InstanceId;
public string DeviceId;
public string ConfigurationId;
}
class AppKeyResponse
{
public string Key;
public string InstanceId;
}
class ConfigurationSummary
{
public string Id;
public string Label;
}
class DeviceResponse
{
public string Id;
public string Identifier;
}
/// <summary>
/// L'identifiant matériel du casque. <c>SystemInfo.deviceUniqueIdentifier</c> est
/// stable pour une installation donnée — c'est lui que <c>DeviceController.Create</c>
/// utilise pour reconnaître un appareil déjà enregistré au lieu d'en créer un second.
/// </summary>
public static string HeadsetIdentifier => SystemInfo.deviceUniqueIdentifier;
/// <summary>L'appairage précédent, ou null si ce casque n'a jamais été appairé.</summary>
public static Pairing Restore()
{
var key = PlayerPrefs.GetString(PrefsApiKey, null);
if (string.IsNullOrEmpty(key)) return null;
return new Pairing
{
BaseUrl = PlayerPrefs.GetString(PrefsBaseUrl, null),
ApiKey = key,
InstanceId = PlayerPrefs.GetString(PrefsInstanceId, null),
DeviceId = PlayerPrefs.GetString(PrefsDeviceId, null),
ConfigurationId = PlayerPrefs.GetString(PrefsConfigurationId, null)
};
}
public static void Forget()
{
foreach (var k in new[] { PrefsBaseUrl, PrefsApiKey, PrefsInstanceId,
PrefsDeviceId, PrefsConfigurationId })
PlayerPrefs.DeleteKey(k);
PlayerPrefs.Save();
}
/// <summary>
/// Appaire ce casque. <paramref name="configurationId"/> vide = la première
/// configuration de l'instance, ce qui suffit à une borne qui n'en a qu'une.
/// </summary>
public async Task<ApiClient.Result<Pairing>> PairAsync(
string baseUrl, string pinCode, string headsetName, string configurationId = null)
{
var client = new ApiClient(baseUrl);
var keyResult = await client.GetAsync<AppKeyResponse>(
$"/api/instance/app-key?pinCode={UnityWebRequestEscape(pinCode)}&appType={ApiKeyAppTypeVr}");
if (!keyResult.Ok) return Fail(keyResult.Error);
if (keyResult.Value == null || string.IsNullOrEmpty(keyResult.Value.Key))
return Fail("Ce code PIN ne correspond à aucun lieu.");
client.ApiKey = keyResult.Value.Key;
var instanceId = keyResult.Value.InstanceId;
if (string.IsNullOrEmpty(configurationId))
{
var configs = await client.GetAsync<ConfigurationSummary[]>(
$"/api/configuration?instanceId={instanceId}");
if (!configs.Ok) return Fail(configs.Error);
if (configs.Value == null || configs.Value.Length == 0)
return Fail("Ce lieu n'a encore aucun contenu publié.");
configurationId = configs.Value[0].Id;
}
// Le serveur crée lui-même l'AppConfigurationLink porteur de ce DeviceId :
// c'est ce lien que l'onglet XR du manager affiche comme carte de casque.
var device = await client.PostAsync<DeviceResponse>("/api/device", new
{
identifier = HeadsetIdentifier,
name = headsetName,
instanceId,
configurationId,
appType = AppTypeVr,
connected = true
});
if (!device.Ok)
{
// 404 sur cette route ne veut pas dire « introuvable » au sens courant :
// il n'y a pas d'ApplicationInstance VR sur cette instance.
return Fail(device.Error == "Ce contenu n'existe plus sur le serveur."
? "Le canal VR n'est pas activé pour ce lieu."
: device.Error);
}
var pairing = new Pairing
{
BaseUrl = client.BaseUrl,
ApiKey = client.ApiKey,
InstanceId = instanceId,
DeviceId = device.Value?.Id,
ConfigurationId = configurationId
};
Save(pairing);
return new ApiClient.Result<Pairing> { Value = pairing };
}
static void Save(Pairing pairing)
{
PlayerPrefs.SetString(PrefsBaseUrl, pairing.BaseUrl);
PlayerPrefs.SetString(PrefsApiKey, pairing.ApiKey);
PlayerPrefs.SetString(PrefsInstanceId, pairing.InstanceId);
PlayerPrefs.SetString(PrefsDeviceId, pairing.DeviceId);
PlayerPrefs.SetString(PrefsConfigurationId, pairing.ConfigurationId);
PlayerPrefs.Save();
}
static ApiClient.Result<Pairing> Fail(string error) =>
new ApiClient.Result<Pairing> { Error = error };
static string UnityWebRequestEscape(string value) =>
UnityEngine.Networking.UnityWebRequest.EscapeURL(value);
}
}

View File

@ -0,0 +1,88 @@
using System;
using UnityEngine;
namespace MyInfoMate.Vr.Net
{
/// <summary>
/// Télémétrie de visite — item <b>E9</b> du lot XR-4.
///
/// <b>Les stats du manager fonctionnent sans une ligne de plus côté serveur</b> :
/// <c>VisitEvent</c> porte déjà un <c>AppType</c>, et l'écran Statistiques filtre
/// génériquement sur <c>AppType.values</c> — le canal VR y apparaîtra tout seul au
/// premier événement reçu.
///
/// ⚠️ Deux différences avec le reste de l'API, vérifiées dans <c>StatsController</c> :
/// la route est <c>POST /api/stats/event</c> (pas <c>/api/visitevent</c>), et
/// <c>appType</c> comme <c>eventType</c> partent en <b>chaîne</b>, parsés par nom
/// côté serveur. Un nom inconnu d'<c>eventType</c> est un 400 ; un <c>appType</c>
/// inconnu retombe <b>silencieusement sur Mobile</b> — c'est-à-dire qu'une faute de
/// frappe ici ferait compter les visites du casque dans le canal mobile.
///
/// L'envoi est « tire et oublie » : une borne ne doit jamais attendre une statistique,
/// et un événement perdu ne vaut pas un message d'erreur au visiteur.
/// </summary>
public class Telemetry
{
/// <summary>Le nom, pas la valeur : le serveur parse par nom (<c>Enum.TryParse</c>).</summary>
const string AppTypeVr = "VR";
readonly ApiClient _client;
readonly string _instanceId;
readonly string _configurationId;
readonly string _language;
/// <summary>
/// Une session = une visite. Sur une borne, elle se renouvelle à chaque retour au
/// menu ou à la repose du casque (E10), pas au lancement de l'app : sinon une
/// journée entière de borne compterait pour un seul visiteur.
/// </summary>
public string SessionId { get; private set; } = NewSessionId();
public Telemetry(ApiClient client, string instanceId, string configurationId, string language)
{
_client = client;
_instanceId = instanceId;
_configurationId = configurationId;
_language = language;
}
public void StartNewSession() => SessionId = NewSessionId();
public void SectionView(string sectionId) => Send("SectionView", sectionId);
public void SectionLeave(string sectionId, int seconds) =>
Send("SectionLeave", sectionId, seconds);
public void MenuItemTap(string sectionId) => Send("MenuItemTap", sectionId);
public void MapPoiTap(string sectionId, string poiTitle) =>
Send("MapPoiTap", sectionId, metadata: $"{{\"poi\":\"{Escape(poiTitle)}\"}}");
async void Send(string eventType, string sectionId,
int? durationSeconds = null, string metadata = null)
{
var result = await _client.PostAsync<object>("/api/stats/event", new
{
instanceId = _instanceId,
configurationId = _configurationId,
sectionId,
sessionId = SessionId,
eventType,
appType = AppTypeVr,
language = _language,
durationSeconds,
metadata,
timestamp = DateTime.UtcNow
});
// La réponse est un 204 sans corps : la désérialisation ne dit rien d'utile,
// seul un échec réseau mérite une trace — et rien ne remonte au visiteur.
if (!result.Ok) Debug.Log($"[Stats] {eventType} non envoyé : {result.Error}");
}
static string NewSessionId() => Guid.NewGuid().ToString("N");
static string Escape(string value) =>
string.IsNullOrEmpty(value) ? "" : value.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
}

View File

@ -0,0 +1,57 @@
using System.Threading.Tasks;
using UnityEngine;
namespace MyInfoMate.Vr.Scene
{
/// <summary>
/// Le test qui valide <see cref="GltfSpace"/>, et le critère de validation n°2 de
/// l'étape S1.
///
/// Charge calibration.glb — trois branches de longueurs différentes, rouge sur +X,
/// verte sur +Y, bleue sur +Z, exprimées en coordonnées glTF — puis place trois
/// sphères <b>aux mêmes coordonnées</b>, en passant par la conversion du manifeste.
///
/// Chaque sphère doit coiffer le bout de sa branche. Si l'une part du côté opposé,
/// la conversion est en miroir : basculer <see cref="GltfSpace.Axis"/> sur
/// <c>NegateZ</c> et relancer. Trois longueurs différentes, parce qu'un repère
/// symétrique ne permet pas de voir une inversion.
/// </summary>
public class CalibrationCheck : MonoBehaviour
{
static readonly (float[] position, Color color)[] Markers =
{
(new[] { 1.00f, 0f, 0f }, new Color(0.85f, 0.15f, 0.15f)),
(new[] { 0f, 0.60f, 0f }, new Color(0.15f, 0.75f, 0.20f)),
(new[] { 0f, 0f, 0.30f }, new Color(0.15f, 0.35f, 0.90f)),
};
public async Task RunAsync(Transform parent)
{
var model = await GltfLoader.LoadAsync(
GltfLoader.StreamingAssetsUrl("calibration.glb"), parent, "Calibration");
if (model == null)
{
Debug.LogError("[Calibration] calibration.glb introuvable dans StreamingAssets");
return;
}
foreach (var (position, color) in Markers)
{
var marker = GameObject.CreatePrimitive(PrimitiveType.Sphere);
marker.name = "Marker";
marker.transform.SetParent(parent, false);
marker.transform.localPosition = GltfSpace.Position(position);
marker.transform.localScale = Vector3.one * 0.09f;
// CreatePrimitive pose le matériau du pipeline historique : magenta sous
// URP, et dessiné sans décalage d'oeil, donc dédoublé en stéréo.
marker.GetComponent<Renderer>().material =
new Material(Shader.Find("Universal Render Pipeline/Unlit")) { color = color };
Destroy(marker.GetComponent<Collider>());
}
Debug.Log($"[Calibration] Convention active : {GltfSpace.Axis}. " +
"Chaque sphère doit coiffer le bout de la branche de sa couleur.");
}
}
}

View File

@ -0,0 +1,53 @@
using System;
using System.Threading.Tasks;
using GLTFast;
using UnityEngine;
namespace MyInfoMate.Vr.Scene
{
/// <summary>
/// Chargement d'un GLB <b>au runtime</b>, jamais par glisser-déposer dans l'éditeur.
/// C'est toute la différence : un asset posé dans la scène part dans l'APK et fige
/// le contenu au build. Ici, le fichier peut venir de StreamingAssets aujourd'hui
/// et du cache disque demain, sans changer une ligne d'appelant.
/// </summary>
public static class GltfLoader
{
public static async Task<GameObject> LoadAsync(string url, Transform parent, string name)
{
var import = new GltfImport();
var watch = System.Diagnostics.Stopwatch.StartNew();
if (!await import.Load(url))
{
Debug.LogError($"[GltfLoader] Chargement impossible : {url}");
return null;
}
var root = new GameObject(name);
root.transform.SetParent(parent, false);
if (!await import.InstantiateMainSceneAsync(root.transform))
{
Debug.LogError($"[GltfLoader] Instanciation impossible : {url}");
UnityEngine.Object.Destroy(root);
return null;
}
Debug.Log($"[GltfLoader] {name} chargé en {watch.ElapsedMilliseconds} ms");
return root;
}
/// <summary>
/// Sur Android, StreamingAssets est <b>à l'intérieur de l'APK</b> : le chemin
/// est une URI <c>jar:file://…!/assets/…</c> que seul UnityWebRequest sait lire.
/// Sur desktop c'est un chemin de fichier ordinaire. glTFast gère les deux, à
/// condition qu'on lui passe une URI et pas un chemin concaténé à la main.
/// </summary>
public static string StreamingAssetsUrl(string fileName)
{
var path = System.IO.Path.Combine(Application.streamingAssetsPath, fileName);
return path.Contains("://") ? path : new Uri(path).AbsoluteUri;
}
}
}

View File

@ -0,0 +1,55 @@
using UnityEngine;
namespace MyInfoMate.Vr.Scene
{
/// <summary>
/// Conversion glTF → Unity. <b>Le seul endroit du projet où elle existe.</b>
///
/// glTF est en main droite, Y-up, unités en mètres. Unity est en main
/// <b>gauche</b>. Passer de l'un à l'autre demande d'inverser un axe horizontal.
///
/// Le décor, lui, est converti par glTFast à l'import. Les coordonnées du
/// manifeste, elles, ne passent par aucun importeur. Si les deux conversions
/// ne sont pas la même, les objets placés sont en miroir par rapport au décor
/// — et c'est <b>invisible sur une scène symétrique</b>, donc découvert tard.
///
/// D'où <see cref="Axis"/> : la valeur par défaut est celle que documente
/// glTFast, mais elle se <b>vérifie</b> avec calibration.glb (voir
/// <see cref="CalibrationCheck"/>), elle ne se suppose pas. Si le test montre
/// un miroir, c'est cette seule ligne qui change.
/// </summary>
public static class GltfSpace
{
public enum Convention
{
NegateX,
NegateZ
}
public static Convention Axis = Convention.NegateX;
public static Vector3 ToUnity(Vector3 p) =>
Axis == Convention.NegateX
? new Vector3(-p.x, p.y, p.z)
: new Vector3(p.x, p.y, -p.z);
/// <summary>
/// Une réflexion inverse le sens des rotations : l'axe est réfléchi <i>et</i>
/// l'angle change de signe. Sur un quaternion, ça revient à inverser le signe
/// des deux composantes que la réflexion ne touche pas.
/// </summary>
public static Quaternion ToUnity(Quaternion q) =>
Axis == Convention.NegateX
? new Quaternion(q.x, -q.y, -q.z, q.w)
: new Quaternion(-q.x, -q.y, q.z, q.w);
/// <summary>Les coordonnées du manifeste arrivent en tableaux JSON.</summary>
public static Vector3 Position(float[] v) => ToUnity(new Vector3(v[0], v[1], v[2]));
public static Quaternion Rotation(float[] q) =>
ToUnity(new Quaternion(q[0], q[1], q[2], q[3]));
/// <summary>L'échelle est invariante par réflexion — pas de conversion.</summary>
public static Vector3 Scale(float[] s) => new Vector3(s[0], s[1], s[2]);
}
}

View File

@ -0,0 +1,210 @@
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
using MyInfoMate.Vr.Manifest;
namespace MyInfoMate.Vr.Scene
{
/// <summary>
/// Un point d'intérêt posé dans la scène : un repère visible, visé au regard, qui
/// joue son commentaire audio dans la langue courante.
///
/// <b>Le déclenchement se fait au regard soutenu, pas à la gâchette.</b> Deux
/// raisons, et la seconde est la vraie : le projet vise deux plugins XR (Meta XR
/// SDK et OpenXR) dont les API de manettes diffèrent, et un visiteur de musée qui
/// n'a jamais tenu une manette Quest ne sait pas laquelle presser. S7 pourra
/// ajouter la gâchette en raccourci — pas la remplacer.
/// </summary>
public class HotspotInstance : MonoBehaviour
{
public const float DwellSeconds = 1.2f;
const float MarkerRadius = 0.09f;
SceneHotspot _hotspot;
SceneManifest _manifest;
Transform _visitorHead;
AudioSource _audio;
Renderer _marker;
float _gazeSeconds;
string _language;
AudioClip _clip;
string _loadedClipAssetId;
public SceneHotspot Hotspot => _hotspot;
public void Configure(SceneHotspot hotspot, SceneManifest manifest,
Transform visitorHead, string language)
{
_hotspot = hotspot;
_manifest = manifest;
_visitorHead = visitorHead;
_language = language;
hotspot.Transform.ApplyTo(transform);
BuildMarker();
_audio = gameObject.AddComponent<AudioSource>();
// Spatialisé : un commentaire qui sort de l'objet dont il parle situe le
// point sans qu'on ait à l'indiquer. Un son plat le laisse invisible.
_audio.spatialBlend = 1f;
_audio.rolloffMode = AudioRolloffMode.Linear;
_audio.maxDistance = 8f;
_audio.playOnAwake = false;
}
void BuildMarker()
{
var sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere);
sphere.name = "Marker";
sphere.transform.SetParent(transform, false);
sphere.transform.localScale = Vector3.one * (MarkerRadius * 2f);
_marker = sphere.GetComponent<Renderer>();
_marker.material = UnlitMaterial(IdleColor);
// Un collider plus large que le repère : viser une bille de 9 cm à trois
// mètres au regard est nettement plus dur que ça n'en a l'air.
var collider = sphere.GetComponent<SphereCollider>();
collider.radius = 1.6f;
}
static readonly Color IdleColor = new Color(1f, 0.85f, 0.3f, 1f);
static readonly Color GazeColor = new Color(1f, 1f, 1f, 1f);
/// <summary>
/// ⚠️ <c>Shader.Find</c> ne trouve un shader dans un build que s'il y est
/// embarqué. Les shaders URP utilisés uniquement par du code — jamais par un
/// matériau posé dans une scène — sont <b>retirés au build</b> et le rendu
/// devient magenta sur le casque alors qu'il est correct dans l'éditeur.
/// D'où <c>Always Included Shaders</c> dans Project Settings → Graphics.
/// </summary>
static Material UnlitMaterial(Color color)
{
var shader = Shader.Find("Universal Render Pipeline/Unlit");
if (shader == null)
{
Debug.LogError("[Hotspot] Shader URP/Unlit introuvable : il a été retiré " +
"du build. Project Settings → Graphics → Always Included Shaders.");
shader = Shader.Find("Sprites/Default");
}
return new Material(shader) { color = color };
}
void Update()
{
if (_visitorHead == null) return;
var looking = IsGazedAt();
_gazeSeconds = looking ? _gazeSeconds + Time.deltaTime : 0f;
if (_marker != null)
_marker.material.color = Color.Lerp(
IdleColor, GazeColor, Mathf.Clamp01(_gazeSeconds / DwellSeconds));
if (looking && _gazeSeconds >= DwellSeconds && !_audio.isPlaying)
{
_gazeSeconds = 0f;
StartCoroutine(PlayCurrentLanguage());
}
}
bool IsGazedAt()
{
var ray = new Ray(_visitorHead.position, _visitorHead.forward);
return Physics.Raycast(ray, out var hit, 12f)
&& hit.collider.transform.IsChildOf(transform);
}
public void SetLanguage(string language)
{
if (_language == language) return;
_language = language;
// Un changement de langue pendant la lecture doit couper : laisser finir
// la phrase dans l'ancienne langue est le genre de détail qui fait dire
// « ça ne marche pas ».
if (_audio != null && _audio.isPlaying) _audio.Stop();
}
public void StopAudio()
{
if (_audio != null && _audio.isPlaying) _audio.Stop();
}
IEnumerator PlayCurrentLanguage()
{
var assetId = AudioAssetIdFor(_language);
if (string.IsNullOrEmpty(assetId))
{
Debug.Log($"[Hotspot] {_hotspot.Id} : aucun audio en « {_language} »");
yield break;
}
if (_loadedClipAssetId != assetId)
{
yield return LoadClip(assetId);
_loadedClipAssetId = assetId;
}
if (_clip == null) yield break;
_audio.clip = _clip;
_audio.Play();
}
/// <summary>
/// Le premier contenu du hotspot qui porte un audio dans cette langue. La
/// lecture de la suite de <c>contents</c> viendra avec le panneau de S7 ; en
/// S2 on valide qu'un son sort du bon endroit.
/// </summary>
string AudioAssetIdFor(string language)
{
if (_hotspot.Contents == null) return null;
foreach (var content in _hotspot.Contents)
{
if (string.IsNullOrEmpty(content.AssetId)) continue;
var hasText = content.Description != null
&& content.Description.Exists(t => t.Language == language);
if (hasText || _hotspot.Contents.Count == 1) return content.AssetId;
}
return null;
}
IEnumerator LoadClip(string assetId)
{
var url = SceneBuilder.ResolveAssetUrl(_manifest, assetId);
if (url == null)
{
Debug.LogWarning($"[Hotspot] {_hotspot.Id} : asset {assetId} absent du manifeste");
yield break;
}
// Le disque d'abord. Le préchargement (E4) a normalement déjà déposé ce
// fichier ; sans ce détour, un hotspot restait muet dès que le wifi tombait,
// alors que le son était sur le casque.
var cached = Net.ContentCache.CachedPath(url);
var source = cached != null ? "file://" + cached : url;
using var request = UnityWebRequestMultimedia.GetAudioClip(source, AudioType.MPEG);
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogWarning($"[Hotspot] audio {assetId} illisible : {request.error}");
yield break;
}
_clip = DownloadHandlerAudioClip.GetContent(request);
}
}
}

View File

@ -0,0 +1,83 @@
using UnityEngine;
namespace MyInfoMate.Vr.Scene
{
/// <summary>
/// Zone de navigation circulaire, plafonnée à 3 m de rayon (§3.2 du cahier des
/// charges). Le plafond est appliqué ici <i>et</i> côté serveur : un client qui
/// fait confiance à la donnée est un client qu'on casse avec une donnée fausse.
///
/// Deux rôles : matérialiser la limite comme le garde-fou natif du Quest, et
/// ramener le visiteur à l'intérieur s'il en sort. On ne téléporte pas — on
/// repousse : une téléportation surprise dans un casque donne la nausée.
/// </summary>
public class NavigationBounds : MonoBehaviour
{
public const float MaxRadiusMeters = 3f;
[SerializeField] float radiusMeters = 3f;
[SerializeField] Transform visitor;
[SerializeField] Transform head;
[SerializeField] float fadeStartMeters = 0.4f;
LineRenderer _ring;
public void Configure(Transform visitorRig, Transform visitorHead, float radius, bool showBoundary)
{
visitor = visitorRig;
head = visitorHead;
radiusMeters = Mathf.Min(radius, MaxRadiusMeters);
if (showBoundary) DrawRing();
}
void DrawRing()
{
const int segments = 96;
_ring = gameObject.AddComponent<LineRenderer>();
_ring.useWorldSpace = false;
_ring.loop = true;
_ring.positionCount = segments;
_ring.widthMultiplier = 0.015f;
_ring.material = new Material(Shader.Find("Universal Render Pipeline/Unlit"));
_ring.material.color = new Color(0.4f, 0.8f, 1f, 0.6f);
for (var i = 0; i < segments; i++)
{
var angle = i * Mathf.PI * 2f / segments;
_ring.SetPosition(i, new Vector3(
Mathf.Cos(angle) * radiusMeters, 0.01f, Mathf.Sin(angle) * radiusMeters));
}
}
/// <summary>
/// On mesure la <b>tête</b> et on déplace le <b>rig</b>. En VR le rig ne bouge
/// pas quand le visiteur marche : le tracking applique le déplacement à la
/// caméra, à l'intérieur du rig. Mesurer le rig, c'est ne jamais rien mesurer —
/// la contrainte ne se déclenche alors pour aucune distance parcourue.
///
/// On retranche l'excédent au rig plutôt que de poser la tête sur le cercle :
/// la tête suit le corps, on ne peut que déplacer le repère sous elle.
/// </summary>
void LateUpdate()
{
if (visitor == null || head == null) return;
var local = transform.InverseTransformPoint(head.position);
var flat = new Vector2(local.x, local.z);
if (flat.magnitude <= radiusMeters) return;
var excess = flat - flat.normalized * radiusMeters;
visitor.position -= transform.TransformVector(new Vector3(excess.x, 0f, excess.y));
}
/// <summary>Distance à la limite, pour l'assombrissement progressif du bord.</summary>
public float ProximityToEdge()
{
if (head == null) return 0f;
var local = transform.InverseTransformPoint(head.position);
var distance = new Vector2(local.x, local.z).magnitude;
return Mathf.InverseLerp(radiusMeters - fadeStartMeters, radiusMeters, distance);
}
}
}

View File

@ -0,0 +1,84 @@
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
namespace MyInfoMate.Vr.Scene
{
/// <summary>
/// Un personnage posé dans la scène : GLB chargé au runtime, animation idle en
/// boucle, orientation vers le visiteur.
///
/// Pas de lip sync, jamais — écarté au niveau produit. Un personnage stylisé qui
/// respire et regarde dans la bonne direction porte l'essentiel de la présence ;
/// une bouche mal synchronisée la détruit.
/// </summary>
public class PersonaInstance : MonoBehaviour
{
[SerializeField] Transform lookAtTarget;
[SerializeField] bool gazeAtVisitor = true;
[SerializeField] float gazeDegreesPerSecond = 90f;
Quaternion _restRotation;
public async Task<bool> LoadAsync(string url, Transform visitorHead)
{
lookAtTarget = visitorHead;
_restRotation = transform.localRotation;
var model = await GltfLoader.LoadAsync(url, transform, "Model");
if (model == null) return false;
PlayIdle(model);
return true;
}
/// <summary>
/// ⚠️ glTFast expose les animations de deux façons selon
/// <c>GltfImportSettings.AnimationMethod</c> : <c>Legacy</c> (composant
/// <see cref="Animation"/>, le défaut) ou <c>Mecanim</c> (un <c>Animator</c> et
/// son controller). Ce code suit le défaut ; si le personnage reste figé alors
/// que le GLB contient bien des clips, c'est le premier endroit à regarder.
///
/// On joue le clip nommé
/// « idle » s'il existe, sinon le premier — un pack d'animations tiers ne
/// respecte aucune convention de nommage, et refuser de jouer parce que le
/// nom ne colle pas donnerait un personnage figé sans message d'erreur.
/// </summary>
void PlayIdle(GameObject model)
{
var animation = model.GetComponentInChildren<Animation>();
if (animation == null) return;
var clip = animation.Cast<AnimationState>()
.FirstOrDefault(s => s.name.ToLowerInvariant().Contains("idle"))
?? animation.Cast<AnimationState>().FirstOrDefault();
if (clip == null)
{
Debug.LogWarning($"[Persona] {name} : aucune animation dans le GLB");
return;
}
clip.wrapMode = WrapMode.Loop;
animation.Play(clip.name);
}
void Update()
{
if (!gazeAtVisitor || lookAtTarget == null) return;
// Rotation sur Y seulement : un personnage qui bascule la tête vers un
// visiteur assis ou grand donne un effet de pantin.
var direction = lookAtTarget.position - transform.position;
direction.y = 0f;
if (direction.sqrMagnitude < 0.01f) return;
transform.rotation = Quaternion.RotateTowards(
transform.rotation,
Quaternion.LookRotation(direction),
gazeDegreesPerSecond * Time.deltaTime);
}
public void ReturnToRest() => transform.localRotation = _restRotation;
}
}

View File

@ -0,0 +1,207 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using MyInfoMate.Vr.Manifest;
using UnityEngine;
namespace MyInfoMate.Vr.Scene
{
/// <summary>
/// Construit une scène <b>à partir d'un manifeste</b>, et remplace à ce titre
/// <c>S1Bootstrap</c> dont les chemins étaient en dur.
///
/// C'est ici que la règle du §1 de la conception devient vraie : <b>un rebuild
/// seulement pour une fonctionnalité</b>. Changer une position, ajouter un objet,
/// déplacer un hotspot — tout ça se fait dans le JSON, sans recompiler.
///
/// Le builder ne lit ni ne valide le manifeste : <see cref="ManifestReader"/> le
/// fait avant, et un manifeste refusé n'arrive jamais jusqu'ici.
/// </summary>
public class SceneBuilder : MonoBehaviour
{
public Transform Root { get; private set; }
public NavigationBounds Bounds { get; private set; }
readonly List<HotspotInstance> _hotspots = new List<HotspotInstance>();
readonly List<PersonaInstance> _personas = new List<PersonaInstance>();
SceneManifest _manifest;
string _language;
public IReadOnlyList<HotspotInstance> Hotspots => _hotspots;
public async Task<bool> BuildAsync(SceneManifest manifest, Transform visitorHead,
Transform visitorRig, string language)
{
_manifest = manifest;
_language = language ?? manifest.DefaultLanguage;
var watch = System.Diagnostics.Stopwatch.StartNew();
Root = new GameObject($"Scene:{manifest.SceneId}").transform;
Root.SetParent(transform, false);
var worldLoaded = await LoadWorld();
await LoadObjects();
await LoadPersonas(visitorHead);
BuildHotspots(visitorHead);
SetUpBounds(visitorHead, visitorRig);
PlaceVisitor(visitorRig);
// Le chiffre que S1 doit rapporter (§« ce que cette étape mesure »), mais
// mesuré ici sur la scène réelle plutôt que sur un GLB isolé : c'est
// celui-là qui décide si l'écran de progression est un détail ou un sujet.
Debug.Log($"[SceneBuilder] Scène {manifest.SceneId} v{manifest.Version} " +
$"construite en {watch.ElapsedMilliseconds} ms " +
$"({_manifest.Objects.Count} objets, {_personas.Count} personnages, " +
$"{_hotspots.Count} hotspots, budget " +
$"{manifest.Budget?.TotalBytes ?? 0} octets)");
return worldLoaded;
}
async Task<bool> LoadWorld()
{
var url = ResolveAssetUrl(_manifest, _manifest.World.AssetId);
if (url == null)
{
Debug.LogError($"[SceneBuilder] décor {_manifest.World.AssetId} absent du manifeste");
return false;
}
var holder = new GameObject("World");
holder.transform.SetParent(Root, false);
_manifest.World.Transform.ApplyTo(holder.transform);
return await GltfLoader.LoadAsync(url, holder.transform, "Model") != null;
}
async Task LoadObjects()
{
var objectsRoot = new GameObject("Objects").transform;
objectsRoot.SetParent(Root, false);
foreach (var sceneObject in _manifest.Objects)
{
var url = ResolveAssetUrl(_manifest, sceneObject.AssetId);
if (url == null)
{
// Un asset manquant n'interrompt pas la scène : sur site, une
// amphore absente vaut mieux qu'une salle noire.
Debug.LogWarning($"[SceneBuilder] objet {sceneObject.Id} : " +
$"asset {sceneObject.AssetId} absent du manifeste");
continue;
}
var holder = new GameObject($"Object:{sceneObject.Id}");
holder.transform.SetParent(objectsRoot, false);
sceneObject.Transform.ApplyTo(holder.transform);
await GltfLoader.LoadAsync(url, holder.transform, "Model");
}
}
async Task LoadPersonas(Transform visitorHead)
{
var personasRoot = new GameObject("Personas").transform;
personasRoot.SetParent(Root, false);
foreach (var persona in _manifest.Personas)
{
var url = ResolveAssetUrl(_manifest, persona.AssetId);
if (url == null)
{
Debug.LogWarning($"[SceneBuilder] persona {persona.Id} : " +
$"asset {persona.AssetId} absent du manifeste");
continue;
}
var holder = new GameObject($"Persona:{persona.Id}");
holder.transform.SetParent(personasRoot, false);
persona.Transform.ApplyTo(holder.transform);
var instance = holder.AddComponent<PersonaInstance>();
// L'échelle du manifeste s'applique au porteur ; le regard, lui, ne
// vise que si le personnage a été demandé comme tel.
if (!await instance.LoadAsync(url, persona.GazeAtVisitor ? visitorHead : null))
continue;
_personas.Add(instance);
}
}
void BuildHotspots(Transform visitorHead)
{
var hotspotsRoot = new GameObject("Hotspots").transform;
hotspotsRoot.SetParent(Root, false);
foreach (var hotspot in _manifest.Hotspots)
{
var holder = new GameObject($"Hotspot:{hotspot.Id}");
holder.transform.SetParent(hotspotsRoot, false);
var instance = holder.AddComponent<HotspotInstance>();
instance.Configure(hotspot, _manifest, visitorHead, _language);
_hotspots.Add(instance);
}
}
void SetUpBounds(Transform visitorHead, Transform visitorRig)
{
Bounds = new GameObject("NavigationBounds").AddComponent<NavigationBounds>();
Bounds.transform.SetParent(Root, false);
Bounds.Configure(visitorRig,
visitorHead,
_manifest.Navigation.ClampedRadiusMeters,
_manifest.Navigation.ShowBoundary);
}
/// <summary>
/// L'origine du manifeste <b>est</b> le point de spawn, au sol (§2.1). Un
/// <c>spawn</c> non identitaire est donc l'exception, pas la règle — mais il
/// existe, et il doit déplacer le visiteur, pas le décor : déplacer le décor
/// désaligne le garde-fou natif du casque.
/// </summary>
void PlaceVisitor(Transform visitorRig)
{
if (visitorRig == null) return;
visitorRig.localPosition = _manifest.Navigation.Spawn.UnityPosition;
visitorRig.localRotation = _manifest.Navigation.Spawn.UnityRotation;
}
public void SetLanguage(string language)
{
_language = language;
foreach (var hotspot in _hotspots) hotspot.SetLanguage(language);
}
public void StopAllAudio()
{
foreach (var hotspot in _hotspots) hotspot.StopAudio();
}
/// <summary>
/// Résolution de l'URL d'un asset, et <b>le seul endroit</b> qui la fait.
///
/// Le manifeste servi par le serveur porte des URL absolues. Mais la règle 2
/// de la décision D3 impose qu'un manifeste dont les URL ont été réécrites en
/// chemins locaux soit lisible tel quel — c'est ce qui rend l'offline
/// possible, et c'est ce qu'utilise le <c>scene.json</c> de S2 avec ses noms
/// de fichiers nus. Une URL sans schéma est donc un fichier de
/// StreamingAssets aujourd'hui, du cache disque en S6, sans changer d'appelant.
/// </summary>
public static string ResolveAssetUrl(SceneManifest manifest, string assetId)
{
var asset = manifest.FindAsset(assetId);
if (asset == null || string.IsNullOrEmpty(asset.Url)) return null;
return asset.Url.Contains("://")
? asset.Url
: GltfLoader.StreamingAssetsUrl(asset.Url);
}
}
}

View File

@ -0,0 +1,133 @@
using UnityEngine;
namespace MyInfoMate.Vr.Scene
{
/// <summary>
/// Une phrase affichée dans le casque, lisible, à hauteur d'yeux.
///
/// <b>C'est un livrable, pas un raffinement.</b> Une scène qu'on ne sait pas
/// charger — décor en splats, manifeste d'une version inconnue, asset absent —
/// doit le dire. Sur site, personne ne lit une console : un écran noir devient
/// « le casque est cassé », et un message devient un appel au support qui sait
/// quoi corriger.
/// </summary>
public class SceneMessage : MonoBehaviour
{
// 3,50 m : à 2 m un texte de cette taille occupe trop de champ pour être lu
// confortablement, et l'oeil doit converger de près. Plus loin, il se lit
// d'un seul regard.
const float DistanceMeters = 3.5f;
const float HeightMeters = 1.5f;
/// <summary>Au-delà de cet écart, le visiteur ne regarde plus le message.</summary>
const float ReleaseAngleDegrees = 45f;
const float RecentringSpeed = 3f;
Transform _head;
bool _recentring;
public static SceneMessage Show(string message, Transform visitorHead)
{
var instance = new GameObject("SceneMessage").AddComponent<SceneMessage>();
instance.Build(message, visitorHead);
return instance;
}
void Build(string message, Transform visitorHead)
{
_head = visitorHead;
var label = Ui.Label.Create(
transform, "Text", Ui.Label.Anchor.MiddleCenter, 0.08f, Color.white);
label.Text = Wrap(message, 40);
if (_head != null) transform.position = TargetPosition();
FaceVisitor();
}
/// <summary>
/// Le message <b>reste posé</b> tant que le visiteur le regarde, et ne se
/// recentre que s'il lui tourne le dos. Le suivre à chaque image le rend
/// illisible — on ne peut pas fixer un texte qui fuit ; ne jamais le suivre
/// le laisserait dans le dos du visiteur, donc jamais lu.
/// </summary>
void LateUpdate()
{
if (_head == null) return;
if (LookingAway()) _recentring = true;
if (_recentring)
{
var target = TargetPosition();
transform.position = Vector3.Lerp(
transform.position, target, Time.deltaTime * RecentringSpeed);
if ((transform.position - target).sqrMagnitude < 0.0004f) _recentring = false;
}
FaceVisitor();
}
bool LookingAway()
{
var toMessage = transform.position - _head.position;
toMessage.y = 0f;
if (toMessage.sqrMagnitude < 0.01f) return true;
return Vector3.Angle(HeadForward(), toMessage.normalized) > ReleaseAngleDegrees;
}
Vector3 TargetPosition()
{
return new Vector3(_head.position.x, HeightMeters, _head.position.z)
+ HeadForward() * DistanceMeters;
}
/// <summary>Le texte fait face au visiteur, sans jamais s'incliner.</summary>
void FaceVisitor()
{
if (_head == null) return;
var away = transform.position - _head.position;
away.y = 0f;
if (away.sqrMagnitude < 0.01f) return;
transform.rotation = Quaternion.LookRotation(away.normalized);
}
Vector3 HeadForward()
{
var forward = _head.forward;
forward.y = 0f;
return forward.sqrMagnitude < 0.01f ? Vector3.forward : forward.normalized;
}
static string Wrap(string message, int columns)
{
var words = message.Split(' ');
var line = 0;
var wrapped = new System.Text.StringBuilder();
foreach (var word in words)
{
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();
}
}
}

View File

@ -0,0 +1,151 @@
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();
}
}
}

BIN
unity-overlay/Assets/StreamingAssets/calibration.glb (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,110 @@
{
"manifestVersion": 1,
"sceneId": "sec_e2_local",
"instanceId": "inst_local",
"configurationId": "cfg_local",
"version": 1,
"publishedAt": "2026-09-11T00:00:00Z",
"languages": ["fr", "nl", "en", "de"],
"defaultLanguage": "fr",
"coordinateSystem": "gltf/y-up/right-handed/meters",
"navigation": {
"spawn": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1] },
"radiusMeters": 3.0,
"showBoundary": true
},
"environment": {
"lightingPreset": "neutral-indoor",
"hdriAssetId": null,
"exposure": 1.0
},
"world": {
"kind": "mesh",
"assetId": "a_world",
"transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] }
},
"objects": [
{
"id": "o_calibration",
"label": "Repere de calibration",
"assetId": "a_calibration",
"transform": { "position": [1.0, 0, -1.0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] }
}
],
"personas": [
{
"id": "p_1",
"personaId": null,
"name": "Personnage de test",
"assetId": "a_persona",
"transform": { "position": [-1.5, 0, -1.0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
"animation": { "idleClip": "Idle", "loop": true },
"gazeAtVisitor": true,
"audio": [],
"script": [{ "language": "fr", "value": "Bienvenue." }]
}
],
"hotspots": [
{
"id": "h_1",
"geoPointId": 0,
"transform": { "position": [0.4, 1.4, -2.1], "rotation": [0, 0, 0, 1] },
"title": [{ "language": "fr", "value": "Point de test" }],
"description": [{ "language": "fr", "value": "Vise ce point avec la manette." }],
"contents": [
{
"order": 0,
"title": [{ "language": "fr", "value": "Commentaire" }],
"description": [{ "language": "fr", "value": "" }],
"assetId": null
}
]
}
],
"assets": [
{
"id": "a_world",
"resourceId": "res_local_world",
"url": "world.glb",
"mimeType": "model/gltf-binary",
"sizeBytes": 3085416,
"sha256": "",
"variants": []
},
{
"id": "a_calibration",
"resourceId": "res_local_calibration",
"url": "calibration.glb",
"mimeType": "model/gltf-binary",
"sizeBytes": 4224,
"sha256": "",
"variants": []
},
{
"id": "a_persona",
"resourceId": "res_local_persona",
"url": "persona.glb",
"mimeType": "model/gltf-binary",
"sizeBytes": 438044,
"sha256": "",
"variants": []
}
],
"budget": { "totalBytes": 3527684, "limitBytes": 1073741824 },
"provenance": {
"aiGenerated": false,
"rightsHolder": "Unov - scene de test E2",
"sources": []
}
}

View File

@ -0,0 +1,55 @@
{
"dependencies": {
"com.meta.xr.sdk.all": "205.0.0",
"com.unity.ai.navigation": "2.0.14",
"com.unity.cloud.draco": "5.1.7",
"com.unity.cloud.gltfast": "6.10.1",
"com.unity.cloud.ktx": "3.4.2",
"com.unity.collab-proxy": "2.13.6",
"com.unity.ide.rider": "3.0.39",
"com.unity.ide.visualstudio": "2.0.27",
"com.unity.inputsystem": "1.19.0",
"com.unity.multiplayer.center": "1.0.0",
"com.unity.nuget.newtonsoft-json": "3.2.1",
"com.unity.pipeline": "0.6.0-exp.1",
"com.unity.render-pipelines.universal": "17.0.4",
"com.unity.test-framework": "1.6.0",
"com.unity.timeline": "1.8.13",
"com.unity.ugui": "2.0.0",
"com.unity.visualscripting": "1.9.12",
"com.unity.xr.management": "4.5.0",
"com.unity.xr.openxr": "1.17.1",
"com.unity.modules.accessibility": "1.0.0",
"com.unity.modules.ai": "1.0.0",
"com.unity.modules.androidjni": "1.0.0",
"com.unity.modules.animation": "1.0.0",
"com.unity.modules.assetbundle": "1.0.0",
"com.unity.modules.audio": "1.0.0",
"com.unity.modules.cloth": "1.0.0",
"com.unity.modules.director": "1.0.0",
"com.unity.modules.imageconversion": "1.0.0",
"com.unity.modules.imgui": "1.0.0",
"com.unity.modules.jsonserialize": "1.0.0",
"com.unity.modules.particlesystem": "1.0.0",
"com.unity.modules.physics": "1.0.0",
"com.unity.modules.physics2d": "1.0.0",
"com.unity.modules.screencapture": "1.0.0",
"com.unity.modules.terrain": "1.0.0",
"com.unity.modules.terrainphysics": "1.0.0",
"com.unity.modules.tilemap": "1.0.0",
"com.unity.modules.ui": "1.0.0",
"com.unity.modules.uielements": "1.0.0",
"com.unity.modules.umbra": "1.0.0",
"com.unity.modules.unityanalytics": "1.0.0",
"com.unity.modules.unitywebrequest": "1.0.0",
"com.unity.modules.unitywebrequestassetbundle": "1.0.0",
"com.unity.modules.unitywebrequestaudio": "1.0.0",
"com.unity.modules.unitywebrequesttexture": "1.0.0",
"com.unity.modules.unitywebrequestwww": "1.0.0",
"com.unity.modules.vehicles": "1.0.0",
"com.unity.modules.video": "1.0.0",
"com.unity.modules.vr": "1.0.0",
"com.unity.modules.wind": "1.0.0",
"com.unity.modules.xr": "1.0.0"
}
}

253
unity-overlay/README.md Normal file
View File

@ -0,0 +1,253 @@
# unity-overlay — les fichiers à déposer sur le projet Unity
Ce dossier **n'est pas un projet Unity**. C'est l'arborescence des fichiers que je génère,
calquée sur celle du projet, à recopier par-dessus une fois le projet créé par Unity Hub.
**Pourquoi un dossier séparé** : Unity Hub refuse de créer un projet dans un dossier non vide.
Il faut donc que le projet naisse d'abord, et que mes fichiers arrivent ensuite. Ça a un
avantage secondaire — à chaque étape je remets à jour cet overlay, tu recopies, rien ne se perd.
## Ordre des opérations
1. **Créer le projet** — Unity Hub → New project → **Universal 3D (URP)** → nom `MyInfoMateVR`,
emplacement `vr-app/unity/`. Détail : [../docs/01-phase0-setup-unity.md](../docs/01-phase0-setup-unity.md).
2. **Importer le Meta XR SDK** (Asset Store → *Meta XR All-in-One SDK*) et lancer
*Edit → Project Settings → Meta XR***Fix All**.
3. **Recopier cet overlay** :
```powershell
Copy-Item -Recurse -Force vr-app\unity-overlay\* vr-app\unity\MyInfoMateVR\
```
⚠️ `Packages/manifest.json` **écrase** celui du projet. C'est voulu — il porte glTFast, KTX2,
Draco et OpenXR. Si tu as déjà importé le SDK Meta, ses lignes `com.meta.xr.*` sont
dans le fichier du projet et seront perdues : dans ce cas, fusionne les deux blocs
`dependencies` à la main plutôt que d'écraser. Unity recompile tout seul au retour dans l'éditeur.
4. **Déposer les deux GLB** dans `Assets/StreamingAssets/` :
- `world.glb` — ton monde Marble exporté en mesh
- `persona.glb` — un personnage stylisé avec une animation idle
`calibration.glb` y est déjà, il vient de cet overlay.
5. **MyInfoMate → Construire la scène Boot** (menu ajouté par `BuildBootScene.cs`).
6. **Meta → Tools → Building Blocks → Camera Rig**, à glisser dans la scène. C'est la seule
chose que je ne peux pas générer : référencer le rig depuis un script d'éditeur créerait une
dépendance de compilation sur un package importé à la main.
7. **File → Build Profiles → Build And Run.**
## Regénérer le repère de calibration
```bash
python3 vr-app/tools/make_calibration_glb.py vr-app/unity-overlay/Assets/StreamingAssets/calibration.glb
```
## ⚠️ Deux numérotations, et elles ne se mélangent pas
| Préfixe | Plan | Ce que ça couvre |
|---|---|---|
| **S0-S8** | [`../docs/04-phase3-plan.md`](../docs/04-phase3-plan.md) | La piste **Scène 3D** : monde GLB, manifeste, viewer web |
| **E0-E11** | [`../../DOCS/v2/vr-quest-unity-plan.md`](../../DOCS/v2/vr-quest-unity-plan.md) §XR-4 | L'**app Unity du canal VR** : appairage, export, menu, 360° |
Les scripts de bootstrap parlaient `E1`/`E2` en voulant dire `S1`/`S2`**renommés le 12/09**,
alors que la doc l'était depuis le 11/09. Si tu croises un `E1Bootstrap` quelque part, il est périmé.
## Ce que contient l'overlay aujourd'hui (S1-S2 · E2-E5, E8-E10)
| Fichier | Rôle |
|---|---|
| `Packages/manifest.json` | glTFast + KTX2 + Draco + OpenXR |
| `Assets/Scripts/Scene/GltfSpace.cs` | **La** conversion glTF → Unity. Le seul endroit |
| `Assets/Scripts/Scene/GltfLoader.cs` | Chargement GLB au runtime, URI StreamingAssets Android comprise |
| `Assets/Scripts/Scene/CalibrationCheck.cs` | Le test de la conversion — critère de validation n°2 de S1 |
| `Assets/Scripts/Scene/NavigationBounds.cs` | Zone circulaire, plafond 3 m, repousse sans téléporter |
| `Assets/Scripts/Scene/PersonaInstance.cs` | GLB + idle en boucle + regard vers le visiteur |
| `Assets/Scripts/Boot/S1Bootstrap.cs` | Orchestration de S1 — **provisoire**, remplacé par `SceneBuilder` en S2 |
| `Assets/Scripts/Boot/S2Bootstrap.cs` | S2 — la scène vient de `scene.json` |
| `Assets/Scripts/Net/ApiClient.cs` | **E2-E3** — le seul endroit qui parle à manager-service (`X-Api-Key`, erreurs en phrases) |
| `Assets/Scripts/Net/PairingService.cs` | **E2** — code PIN → clé d'API → `POST /api/device` avec `appType = 3` |
| `Assets/Scripts/Net/ConfigurationExport.cs` | **E3**`GET /api/configuration/{id}/export`, un appel pour tout le contenu |
| `Assets/Scripts/Net/ContentCache.cs` | **E4** — cache disque, écriture atomique, médias nommés par hachage d'URL |
| `Assets/Scripts/Net/ContentService.cs` | **E4** — cache d'abord, rafraîchissement de fond silencieux |
| `Assets/Scripts/Net/Telemetry.cs` | **E9**`POST /api/stats/event` en « tire et oublie », `appType = "VR"` |
| `Assets/Scripts/Boot/PairingBootstrap.cs` | **E2-E4** — appaire, sert le cache, rafraîchit, affiche les sections dans le casque |
| `Assets/Scripts/Ui/Label.cs` | **Tout le texte du casque.** TextMeshPro, avec repli sur `TextMesh` si les ressources TMP manquent |
| `Assets/Scripts/Menu/MenuFeedback.cs` | Son de survol et de sélection (**synthétisés**, aucun fichier) + vibration de la manette |
| `Assets/Scripts/Menu/AimSelector.cs` | **E5** — viser à la **manette**, à la **main** ou à la **tête** ; temporisation 1,2 s à la tête seulement |
| `Assets/Scripts/Menu/MenuItemPanel.cs` | **E5** — un panneau : vignette, titre, anneau de progression |
| `Assets/Scripts/Menu/FloatingMenu.cs` | **E5** — l'arc de panneaux, posé une fois, qui ne suit pas la tête |
| `Assets/Scripts/Menu/PagedView.cs` | **E8** — la vue paginée commune : image de 1,6 m, texte, flèches, retour |
| `Assets/Scripts/Menu/SectionPages.cs` | **E8** — ce qui distingue Slider, Map/Parcours et Event |
| `Assets/Scripts/Menu/SkyboxView.cs` | **E6** — image et vidéo 360° en skybox équirectangulaire |
| `Assets/Scripts/Kiosk/KioskSession.cs` | **E10** — fin de visite à la repose du casque ou après 90 s, menu recentré |
| `Assets/Scripts/Boot/MenuBootstrap.cs` | **E5** — l'app : appairage, cache, menu, sections, borne, télémétrie |
| `Assets/Scripts/Editor/BuildBootScene.cs` | Construit `Boot.unity` par code — quatre variantes (S1, S2, E2-E3, E5) |
| `Assets/StreamingAssets/calibration.glb` | Repère RGB asymétrique (X 1,00 m · Y 0,60 m · Z 0,30 m) |
### Essayer le menu (E5) — c'est l'app
⚠️ **Une fois pour le projet : _Window → TextMeshPro → Import TMP Essential Resources_.** Tout le
texte du casque passe par TMP depuis le 14/09 (`Ui/Label.cs`). Sans ces ressources, `TMP_Settings`
n'a pas de police par défaut : `Label` le dit dans la console et retombe sur `TextMesh`, donc le
texte reste lisible — mais flou, ce qu'on voulait justement corriger.
1. **MyInfoMate → Construire la scène Boot (E5 — menu flottant)**.
2. Renseigner `baseUrl`, le **code PIN**, `headsetName`, la langue sur le `Bootstrap`.
3. Build And Run. Un arc de panneaux apparaît à 2,2 m. Trois façons d'en choisir un :
| Moyen | Geste | Remarque |
|---|---|---|
| **Manette** | Gâchette d'index | Prioritaire dès qu'une manette est active |
| **Main** | Pincement pouce-index | Demande le building block *Hand Tracking* dans la scène |
| **Tête** | Viser **1,2 s**, l'anneau se remplit | Le repli universel, et le mode d'une borne publique |
⚠️ **« À la tête », pas « aux yeux ».** Le Quest 2 n'a pas d'eye tracking (seul le Quest Pro en a) :
on suit la direction de la tête, ce qui marche sur tous les casques et ne demande aucun SDK.
⚠️ **La visée a besoin d'un collider et d'un rig.** Si rien ne réagit : vérifier que le Camera Rig
des Building Blocks est dans la scène (sans lui `Camera.main` est nul) et que le module Physics
n'a pas été exclu du build. La manette et la main passent par `OVRInput` / `OVRHand`, du **Core
SDK** — pas de l'Interaction SDK, qui reste non configuré.
**Quatre types de section s'ouvrent** : Slider (galerie), Map et Parcours (liste de POI), Event
(programme du jour). Les autres affichent leur titre et le disent. ⚠️ La Map n'est **pas** la
maquette 3D promise par le plan — ça demande `SectionModel3D` ; une carte sur panneau flottant
serait moins bonne qu'un téléphone, donc on montre les lieux plutôt que le plan.
**La borne se réinitialise seule** : casque reposé, ou 90 s sans rien → retour au menu, recentré
devant le visiteur suivant, nouvelle session de stats. Dans l'éditeur et le simulateur, le casque
est considéré porté — sinon la scène se réinitialiserait toutes les 90 s pendant qu'on travaille.
### Essayer l'appairage seul (E2-E3)
La variante réseau existe encore, et elle sert : elle isole un problème d'appairage d'un problème
de rendu, ce qu'on ne peut plus faire une fois que tout est dans le menu.
1. **MyInfoMate → Construire la scène Boot (E2-E3 — appairage et export)**.
2. Sur le `Bootstrap` de la scène : renseigner `baseUrl`, le **code PIN** de l'instance, la langue.
Le PIN se saisit dans l'inspecteur tant que l'Interaction SDK (E1) n'a pas donné de pointeur.
3. Build And Run. Le casque doit afficher le nom de la configuration et ses sections.
⚠️ **Le canal VR doit être activé sur l'instance** : `isVR` **et** une `ApplicationInstance` de
type VR, sinon le `POST /api/device` répond 404 et le casque affiche « Le canal VR n'est pas activé
pour ce lieu ». Il n'y a pas encore d'écran pour ça — c'est deux appels d'API, décrits au §24.0 de
[`../../DOCS/test-plan.md`](../../DOCS/test-plan.md).
L'appairage est **persistant** (`PlayerPrefs`) : au deuxième lancement le PIN n'est plus lu. Pour
réappairer, cocher `forgetPairing`.
## Le seul résultat qui compte
Les **trois sphères doivent coiffer le bout de la branche de leur couleur**.
Si l'une part du côté opposé, la conversion est en miroir : dans `GltfSpace.cs`, passer
`Axis` à `Convention.NegateZ` et relancer. C'est la seule ligne à changer, et c'est pour ça
qu'elle est seule.
---
## Pièges rencontrés au premier build (11/09/2026, Quest 2)
Tous constatés sur le vrai matériel, tous coûteux à rechercher. Dans l'ordre où ils tombent.
**`com.unity.meshopt.decompress` n'existe pas au registre.** Il figurait dans le manifest de
l'overlay et faisait échouer la résolution entière. Retiré : glTFast gère Draco et KTX2, la
décompression meshopt n'est pas nécessaire pour nos GLB. Ne pas le remettre.
**OpenXR n'est activé que pour Standalone après le Fix All.** Les réglages XR sont *par
plateforme*. Tant que la plateforme active est Windows, le Fix All de Meta ne touche pas la
colonne Android, et `XRGeneralSettingsPerBuildTarget.asset` ne contient qu'une entrée.
Ordre correct : **File → Build Profiles → Android → Switch Platform**, *puis* Project Settings
→ Meta XR → **Fix All**, *puis* vérifier XR Plug-in Management → onglet Android → OpenXR coché
+ groupe de features **Meta Quest**.
**`INSTALL_FAILED_MISSING_SHARED_LIBRARY` — le build réussit, l'install échoue.**
```
Supplement horizonos-supplement-hzplatformclientcore does not exist.
```
Le manifeste généré par le SDK Meta déclare `horizonos:targetSdkVersion="205"`. Un casque sur
une version d'Horizon OS plus ancienne (v74 dans notre cas) n'a pas le composant système
correspondant et refuse le paquet. Deux sorties : mettre le casque à jour (la bonne), ou
abaisser `targetSdkVersion` dans `Assets/Plugins/Android/AndroidManifest.xml` pour débloquer
(contournement — à remettre à 205 une fois le casque à jour). Cette déclaration ne sert qu'au
Platform SDK Meta, que nos scripts n'utilisent pas.
**Deux appareils branchés = Unity déploie au hasard.** Un téléphone Android connecté en USB
apparaît dans `adb devices` au même titre que le casque. Fixer **Run Device** sur le Quest dans
Build Profiles, ou débrancher le téléphone.
**Le premier build prend 20 minutes, les suivants 1 à 3.** Mesuré : 1245 s pour un APK de
66 Mo. C'est IL2CPP. Ne pas conclure à un plantage.
**Package name et Company Name restent ceux du template URP** si on ne les change pas
(`com.UnityTechnologies.com.unity.template.urpblank`). À corriger avant le premier sideload :
changer l'identifiant plus tard fait apparaître une *deuxième* app dans le casque.
## Tester sans casque — le simulateur
Le **Meta XR Simulator** (panneau Meta XR Tools) lance la scène en mode Play dans l'éditeur,
sans casque. `E1Bootstrap` tolère l'absence de `world.glb` et `persona.glb` — il se contente de
logger une erreur — donc **le critère de validation n°2 de S1, le test de miroir de
`GltfSpace`, est jouable dès maintenant** avec le seul `calibration.glb`, sans aucun asset
Marble. C'est le bug le plus cher du lot : autant le lever tôt.
Quand le simulateur est activé, la barre de statut l'affiche et ▶ ne va plus au casque. La
bascule est dans **Meta → Tools → Meta XR Simulator**.
## Shaders glTFast : magenta sur le casque, correct dans l'éditeur
Le piège le plus coûteux du lot, et il ne se voit **que** sur le device.
glTFast construit ses matériaux au runtime et trouve ses shaders par
`Shader.Find("Shader Graphs/glTF-pbrMetallicRoughness")`. Dans l'éditeur, tous les assets du
projet sont disponibles, donc ça marche toujours. Dans un build, `Shader.Find` ne voit que ce qui
a été explicitement embarqué — et un shader vivant dans un package n'est référencé par aucune
scène, donc il est éliminé. Tout ce qui vient d'un glTF sort en magenta, et
`new Material(null)` lève `ArgumentNullException: Parameter name: shader`.
**Un ShaderVariantCollection dans *Preloaded Shaders* ne suffit pas.** Vérifié sur le casque : la
collection capturée contenait bien `Universal Render Pipeline/Unlit`, et `Shader.Find` renvoyait
quand même `null`. Cette liste précharge les variantes de shaders **déjà embarqués** ; elle
n'embarque rien. Seul *Always Included Shaders* fait entrer un shader dans le build.
Le minimum qui marche, dans Project Settings → Graphics → **Always Included Shaders** :
| Shader | Référence YAML |
|---|---|
| `Universal Render Pipeline/Unlit` (l'anneau de `NavigationBounds`, les panneaux du menu) | `{fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3}` |
| `glTF-pbrMetallicRoughness` | `{fileID: -6465566751694194690, guid: b9d29dfa1474148e792ac720cbd45122, type: 3}` |
| **`Skybox/Panoramic`** — la 360° (E6). Même piège, même conséquence : sans lui le ciel sort **magenta sur le casque et correct dans l'éditeur** | ajouté par `MyInfoMate → Embarquer les shaders du runtime` |
⚠️ **`Universal Render Pipeline/Lit` ne doit PAS être dans cette liste.** Il pèse **2 359 296
variantes**, plus que ce qu'Unity accepte d'embarquer : le build échoue sur *« has too many Shader
variants »* — et il échoue **à la fin**, après 35 minutes de compilation. Constaté le 14/09. Aucun
`Shader.Find` du projet ne le demande : tout ce qui est dessiné par code est en Unlit, et les GLB
passent par les graphes glTFast. `IncludeRuntimeShaders.cs` ne l'ajoute plus ; s'il est déjà dans
la liste, **le retirer à la main** — le script ajoute, il ne retire jamais.
⚠️ **`GUI/Text Shader` non plus**, et pour une autre raison. Il vit dans
`Library/unity default resources`, marqué `HideFlags.DontSave` : dans la liste, il fait échouer le
build sur *« An asset is marked with HideFlags.DontSave but is included in the build »*, suivi d'une
assertion `m_LockCount == 0` et de *« Failed to write file:
Library/PlayerDataCache/Android/Data/Resources/unity_builtin_extra »*. Constaté le 14/09, juste
après avoir réglé le cas Lit. Le `TextMesh` de `SceneMessage` n'en a pas besoin : son matériau vient
de la police référencée par le composant, embarquée par le chemin normal.
⚠️ **N'ajoute que les shaders réellement utilisés.** Un Shader Graph dans cette liste fait compiler
**toutes** ses combinaisons de mots-clés : les trois graphes glTFast d'un coup, c'est 16 384
variantes et ~50 minutes de build. Pour savoir lesquels comptent, joue la scène dans l'éditeur puis
lis la capture (Graphics → Shader Loading → *Save to asset…*) : les shaders qu'elle liste sont
exactement ceux dont ton contenu a besoin. Le cache de shaders est conservé, la facture ne se paie
qu'une fois.
## NavigationBounds : mesurer la tête, déplacer le rig
En VR le rig **ne bouge pas** quand le visiteur marche — le tracking applique le déplacement à la
caméra, à l'intérieur du rig. Une contrainte de zone qui teste `rig.position` ne se déclenche donc
jamais, quelle que soit la distance parcourue : on sort du cercle sans rien sentir. Il faut mesurer
`Camera.main.transform` et retrancher l'excédent au rig.

View File

@ -0,0 +1,119 @@
---
name: hz-meta-xr-operator-coordinates
description: Converts between Unity, OpenXR, and tracking-origin coordinates so AI agents can position the head and controllers correctly in Meta Quest and Horizon OS apps, covering tracking origin matching, Unity-to-OpenXR conversion, controller positioning, and UI interaction via aim pose.
allowed-tools:
- Bash(hzdb:*)
tags:
- agentic-xr
- openxr
- unity
- camera
- controller
- movement
- coordinates
- aiming
- aim-pose
- ui
---
# Meta XR Operator Coordinates & Movement
Coordinate math for moving the VR head/camera, positioning controllers, and aiming at objects at runtime. The Unity app must have an OVRCameraRig in the scene.
For aiming **grabbed objects** (guns, tools, etc.), see the **hz-meta-xr-operator-grabbed-objects** skill, which covers offset calibration and precise aiming.
## Key Concept: Tracking Origin
`unity_get_world_pose` returns a `tracking_origin` field that tells you which OpenXR reference space matches Unity's coordinate system. **Always use `tracking_origin.OpenXR` as the `base_space` parameter** when setting poses — this eliminates manual Y-offset hacks.
| OVR Setting | OpenXR base_space | Description |
|---|---|---|
| EyeLevel | `local` | Origin at headset eye level |
| FloorLevel | `local_floor` | Origin at floor level |
| Stage | `stage` | Origin at stage/room center |
Re-query `unity_get_world_pose` if the scene or project changes — the tracking origin may differ.
## World-to-OpenXR Position Formula
```
eye = unity_get_world_pose("OVRCameraRig/TrackingSpace/CenterEyeAnchor")
openxr_x = (target_world_x - eye_x)
openxr_y = (target_world_y - eye_y)
openxr_z = -( target_world_z - eye_z)
```
Use `tracking_origin.OpenXR` as `base_space`. No hardcoded offset needed — the correct reference space handles alignment.
## Coordinate Conversion (Unity ↔ OpenXR)
| Component | Conversion |
|-----------|-----------|
| Position X | Same |
| Position Y | Same |
| Position Z | **Negate** |
| Quaternion X | **Negate** |
| Quaternion Y | **Negate** |
| Quaternion Z | Same |
| Quaternion W | Same |
## Steps: Face an Object (Head)
1. **Get positions** (parallel): `unity_get_world_pose("TargetObject")` + `unity_get_world_pose("CenterEyeAnchor")`
2. **Direction in Unity space**: `math_sub(target_pos, eye_pos)`
3. **Convert to OpenXR**: negate Z component
4. **Build quaternion**: `math_build_quat(front_direction: [dir.x, dir.y, dir.z])` — include Y to look up/down, or zero Y for horizontal view
5. **Set head pose**: `openxr_set_head_pose(orientation, base_space: tracking_origin.OpenXR)`
6. **Verify**: `openxr_capture_composited_image()`
## Steps: Position a Controller at a Target
**IMPORTANT: Head movement shifts controller world positions.** Always: face the target **first**, then requery all positions before computing the controller pose. Using stale pre-head-movement positions will place the controller in the wrong location.
1. **Face the target** using the head steps above — the target must be visible to the camera, just as a real user would look at what they're interacting with
2. **Requery positions** (parallel): `unity_get_world_pose("TargetObject")` + `unity_get_world_pose("CenterEyeAnchor")`
3. **Apply the formula** above to compute the controller position in OpenXR
4. **Set controller pose**: `openxr_set_controller_pose(hand, position, base_space: tracking_origin.OpenXR)`
5. **Verify**: `unity_get_world_pose("OVRCameraRig/TrackingSpace/RightHandAnchor")` to confirm placement
## Grip vs Aim Pose
- **Grip pose** (`pose_type: grip`, default) — where the hand holds the controller. Use for positioning and grabbing.
- **Aim pose** (`pose_type: aim`) — pointing direction. Use for shooting, pointing, and UI interaction.
- **Typical workflow**: position with grip → grab → switch to aim pose for orientation control.
- **Grip-to-aim pitch offset is ~60°** on Quest controllers. To aim horizontally with grip pose, tilt grip up ~60°. Prefer using aim pose directly to avoid this complexity.
## Interacting with UI Elements
Use the aim pose to point at and click UI elements. Position the controller **within 1 unit of the UI target** (if possible) for realistic interaction.
1. **Discover UI**: `unity_find_canvases()``unity_find_interactables("CanvasPath")`
2. **Get target pose**: `unity_get_world_pose("Canvas/Panel/Button")` → note `tracking_origin.OpenXR`
3. **Face the target** — turn head toward the UI element first
4. **Requery positions** after head movement
5. **Position controller** ~0.50.7m from the element along the line from the eye:
```
target_openxr = convert(target_unity - eye_unity)
controller_pos = target_openxr - 0.7 * normalize(target_openxr)
direction = target_openxr - controller_pos
orientation = math_build_quat(direction)
```
6. **Set aim pose**: `openxr_set_controller_pose(hand: right, pose_type: aim, position, orientation, base_space: tracking_origin.OpenXR)`
7. **Click**: `openxr_set_controller_input(Trigger, 1)` then `(Trigger, 0)`
8. **Verify**: `openxr_capture_composited_image()`
## Tips
- **Smooth movement**: `duration_seconds: 1` for rotations, `1.5-2` for position+rotation, `0` for instant.
- **Never guess positions**: always use `unity_get_world_pose` for both target and eye.
- **Hold inputs across frames** — setting an input to 1 and immediately to 0 may be missed by `Update()`.
## Common Pitfalls
1. **Aiming at targets not in view** — always turn the head to face the target first. Aiming at something behind the camera produces unrealistic and unreliable results.
2. **Using stale positions after head movement** — see the controller positioning section above.
3. **Using wrong base_space** — must match `tracking_origin.OpenXR` from `unity_get_world_pose`. Using a mismatched space produces incorrect Y offsets.
4. **Forgetting to negate Z** — Unity Z forward vs OpenXR -Z forward.
5. **Using world position directly as OpenXR position** — must subtract eye/rig position first.
6. **Not verifying with unity_get_world_pose** — always read back the anchor's world position to confirm.

View File

@ -0,0 +1,105 @@
---
name: hz-meta-xr-operator-grabbed-objects
description: Aims and positions objects currently held by simulated controllers in Meta Quest and Horizon OS XR apps, including pose-offset calibration and quaternion math for grip and aim poses.
allowed-tools:
- Bash(hzdb:*)
tags:
- agentic-xr
- openxr
- unity
- aiming
- grip
- aim
- grabbed
- orientation
- position
- offset
---
# Meta XR Operator Grabbed Object Control
How to precisely aim grabbed objects (guns, tools, pointers) so a reference point (muzzle, tip) points at a target.
## Core Concept: Pose Offset
When grabbed, the object's reference point doesn't align with the controller pose. There are two offsets — **rotation** and **position** — that are constant after a grab. Measure them once, then apply their inverses to aim or position the object.
Offsets depend on which **pose type** (aim vs grip) you use. **Always calibrate — never skip based on assumptions about the grab code.** The OpenXR-to-object mapping always has a residual offset.
## Step 1: Grab the Object
Position the controller at the object and press grip (see coordinates skill for world-to-OpenXR conversion).
## Step 2: Calibrate Offsets (once per grab)
Set the aim pose to identity, then read the reference point's pose:
```
set_controller_pose(aim, position: [0,0,0], orientation: [0,0,0,1])
get_world_pose("Controller/Anchor") → anchor_pos
get_world_pose("Object/RefPoint") → ref_pos, rotation (rx, ry, rz)
```
**Rotation offset** — the reference point's yaw at identity reveals the offset:
```
forward_openxr = (sin(ry°), 0, -cos(ry°)) // for yaw-only offset
Q_offset = math_build_quat(forward_openxr)
Q_offset_inv = [-Q.x, -Q.y, -Q.z, Q.w] // quaternion conjugate
```
**Position offset** — vector from anchor to reference point in Unity world space:
```
P_offset = ref_pos - anchor_pos
```
**Note:** Recalibrate the position offset if the object's scale or model changes. The rotation offset is stable across those changes.
After computing the offsets, the controller and grabbed object should be **moved to a default position in view** (unless specified otherwise by the user or game).
Keep held objects far enough from the camera so they don't fill the screen. In `local` (EyeLevel) space, recommended ranges from the camera are:
- **Z** (forward): minimum -0.4, closer makes objects too large
- **Y** (below eyes): -0.2 to -0.4
- **X** (lateral): ±0.1 to ±0.2 (positive = right hand, negative = left hand)
## Step 3: Aim at a Target
1. **Turn head** to face the target (see coordinates skill).
2. **Requery positions** — head movement **shifts** the controller in world space, so it is **critical** to requery positions of objects after head movement:
```
get_world_pose("Object/RefPoint") → ref_pos
get_world_pose("TargetObject") → target_pos
```
3. **Compute aim orientation** and apply the inverse offset:
```
dir = target_pos - ref_pos
Q_desired = math_build_quat([dir.x, dir.y, -dir.z])
Q_aim = math_multiply_quat(Q_desired, Q_offset_inv)
set_controller_pose(aim, orientation: Q_aim)
```
4. **Refine** — the rotation shifts the reference point slightly. Requery ref_pos, recompute, and re-set once for better accuracy.
5. **Act and verify**:
```
set_controller_input(Trigger, 1)
openxr_capture_composited_image() // visual check
get_world_pose("TargetObject") // "not found" = hit
```
## Positioning the Reference Point
To place the reference point at an exact world position, subtract P_offset before converting to OpenXR:
```
anchor_pos = desired_pos - P_offset
openxr_pos = world_to_openxr(anchor_pos) // see coordinates skill
set_controller_pose(aim, position: openxr_pos)
```
To position AND orient in one call, combine with the aim quaternion from Step 3.
## Common Pitfalls
1. **Not calibrating after grab** — measure offsets after each grab.
2. **Using stale positions** — always requery after any head or controller movement.
3. **Not turning head first** — head rotation moves the controller. Turn head → requery → compute.
4. **Mixing pose types** — calibrate and aim with the same pose type (both aim or both grip).
5. **Forgetting the inverse**`Q_aim = Q_desired * Q_offset_inv`, not `Q_desired * Q_offset`.
6. **Aiming from object origin instead of fire point** — use the actual muzzle/fire point child, not the object's transform origin.

View File

@ -0,0 +1,93 @@
---
name: hz-meta-xr-operator-interaction-grab
description: "How to grab and manipulate Meta XR Interaction SDK grabbable interactables in Unity using Meta XR Operator. Covers identifying grabbable types (close-range HandGrab/GrabInteractable, distance grab, hinged interactables like lids/doors/drawers), performing the grab via aim pose, choosing controller motions that move the target as intended, and identifying which of the three movement providers (relative-to-hand, manipulate-in-place, pull-to-hand) an interactable uses."
allowed-tools:
- Bash(hzdb:*)
tags:
- agentic-xr
- openxr
- unity
- meta
- interaction-sdk
- grab
- hand-grab
- distance-grab
- movement
- interactable
---
# Meta XR Operator Interaction Grab
How to grab and manipulate Meta XR Interaction SDK grabbables — covers close-range hand/controller grabs, distance grabs, and constrained interactables (lids, doors, drawers). See **meta-xr-operator-grabbed-objects** for post-grab aiming, **meta-xr-operator-coordinates** for coordinate conversion.
## Identifying Grabbables
**Identify by components, never by GameObject name.** Use Unity MCP to enumerate components:
| Component | Meaning |
|---|---|
| `HandGrabInteractable` / `GrabInteractable` | Close-range hand or controller grab (most common). |
| `DistanceHandGrabInteractable` | Adds distance-grab capability to the same interactable. |
| `Grabbable` | Base grab target the interactable drives. |
| `IMovementProvider` impl | How the object moves while held: `MoveFromTargetProvider` → relative-to-hand, `MoveAtSourceProvider` → manipulate-in-place, `MoveTowardsTargetProvider` → pull-to-hand snap. |
The interactor lives under the camera rig — find it by `ControllerDistanceGrabInteractor` (or `DistanceHandGrabInteractor` for hand tracking), not a hard-coded path. Its selection volume is a `SelectionFrustum` cone (~1015° half-angle), so aim only needs to be approximately on-target. If component inspection isn't available, fall back to the empirical test below — and say so.
## Grab Sequence
**Always use aim pose for both position and orientation.** Do not set grip pose — the Meta interaction system reads aim. (Setting aim also moves the derived grip pose at a ~58 cm offset, which is why aim-pose-only also works for close-range grabs.)
1. `set_controller_pose(hand, aim, position, orientation, base_space)` aimed at the target. For predictable downstream math, prefer **identity orientation** (`[0,0,0,1]`) from in front of the target — this leaves source_rotation ≈ identity so subsequent translations move the target 1:1.
2. `set_controller_input(hand, Grip, 1)`. Hold across frames; an immediate `0` may be missed. The grab latches on the frame grip is processed, only if a grabbable is hovered.
3. Move/rotate the **aim pose** (not grip) to manipulate.
4. `set_controller_input(hand, Grip, 0)` to release. Released objects often hover (no gravity in many sample scenes) — plan where to drop.
Note: the visible controller mesh follows grip pose, so it may look stationary or odd after aim-only updates — cosmetic, the grab system still reads aim.
### Close-range grab (HandGrab / GrabInteractable)
The most common case. Same aim-pose-at-target-with-identity-orientation pattern works. If the collider is small and the aim-to-grip offset misses, fall back to setting grip pose directly at the target.
### Distance grab (DistanceHandGrabInteractable)
Same sequence; the SelectionFrustum cone reaches the target from afar. Aim approximately at the object — exact alignment isn't required.
### Constrained interactables (lids, doors, drawers)
Grab anywhere on the movable surface (the far end from the pivot has the largest collider). Move the controller's aim pose roughly in the direction the object should travel — the constraint guides the path, so it doesn't have to be exact. Reverse direction to close. If a small move produces no change, you grabbed a non-movable part — re-grab on the movable surface.
## The Three Movement Providers
- **`MoveFromTargetProvider` (relative-to-hand)** — target tracks controller 1:1. Rotating the controller pivots the target *around the controller*, so distant targets swing through wide arcs.
- **`MoveAtSourceProvider` (manipulate-in-place)** — target stays anchored at grab-time position. Rotating the controller rotates the target *around itself*. Translating the controller still translates the target (rotated by the original grab orientation).
- **Pull-to-hand snap (`MoveTowardsTargetProvider` and similar)** — target snaps to the controller on grab, then follows it.
### Identifying the provider empirically (when components aren't readable)
Translation alone does NOT distinguish relative-to-hand from manipulate-in-place. Procedure:
1. **Grab and check pose immediately.** Position jumped to controller → **pull-to-hand**. Position unchanged but rotation matches controller's grab orientation → **manipulate-in-place**. Nothing changed → ambiguous, do step 2.
2. **Rotate controller in place** (no translation) by ~90° yaw and re-query target pose. Position barely moves → manipulate-in-place. Position swings dramatically (~controller-to-target distance × √2) → relative-to-hand.
Visual fallback for duplicate-named objects (where `unity_get_world_pose` only resolves the first): pull-to-hand makes the target jump to the controller; relative-to-hand swings it off-screen on rotate; manipulate-in-place keeps it roughly in place.
## Placing a Target at a Destination
| Provider | Strategy |
|---|---|
| Pull-to-hand | Move controller aim to destination; target follows within ~cm. |
| Relative-to-hand | `controller_target = destination grab_offset` where `grab_offset = target_grab_pos controller_grab_pos`. Keep orientation identity to avoid swing. |
| Manipulate-in-place | With identity-orientation grab: `controller_target = controller_grab_pos + (destination target_grab_pos)`. Rotate aim in place to rotate without moving. |
For orientation: pull-to-hand and relative-to-hand rotate with the controller (relative-to-hand swings at distance); manipulate-in-place is safest for precise in-place rotation.
## Common Pitfalls
1. **Setting grip pose** — don't. Always `pose_type: aim`.
2. **Pressing grip without aiming first** — the SelectionFrustum must be hovering a grabbable on the grip-press frame. Set aim, take a screenshot (gives the engine a frame), then press grip.
3. **Not holding grip across frames**`Grip=1` immediately followed by `Grip=0` is often missed.
4. **"Nothing happened, grab failed"** — relative-to-hand and close-range HandGrab produce *no visible change* until the controller moves. Don't release and retry; nudge aim by ~0.1 m and re-query target pose. If it moved with you, the grab worked.
5. **Aim line passes through another grabbable** — the closer one wins. If a previously-grabbed (floating) object is between you and the target, approach from a different angle.
6. **Manipulate-in-place rotates target on grab** — non-identity grab orientation snaps the target to that orientation. Use identity to preserve original rotation.
7. **Translation-only provider test** — ambiguous between relative-to-hand and manipulate-in-place. Always do the rotation-only test.
8. **Duplicate-named root objects**`unity_get_world_pose("Name")` returns only the first sibling. Use screenshots or instance-ID enumeration for the others.

View File

@ -0,0 +1,122 @@
---
name: hz-meta-xr-operator-interaction-poke
description: "How to poke Meta XR Interaction SDK poke interactables in Unity using Meta XR Operator — physical 3D buttons, tilted touchpad/keypad surfaces, UI buttons, and any other PokeInteractable. Covers locating the press point, computing the surface normal for any tilt, positioning the controller via aim pose along that normal, performing the press motion, and verifying via the appropriate signal (transform depression for physical buttons; UI state change for canvas buttons)."
allowed-tools:
- Bash(hzdb:*)
tags:
- agentic-xr
- openxr
- unity
- meta
- interaction-sdk
- poke
- button
- interactable
---
# Meta XR Operator Interaction Poke
How to poke Meta XR Interaction SDK pokeables. See **meta-xr-operator-interaction-grab** for grab interactions, **meta-xr-operator-coordinates** for coordinate conversion.
## Step 1 — Identify the pokeable type
**You MUST classify the pokeable before designing the test.** Each type has a different verification signal AND a different test-design protocol. Skipping this step leads to picking the wrong signal and getting an ambiguous result.
Classify by the interactable's components, not its GameObject name:
| Type | Tell-tale | Verification signal | Test-design rules |
|---|---|---|---|
| **Physical 3D button** | `PokeInteractable` + a child `…/Visuals/ButtonVisual` whose transform moves under press | Re-query the depressing visual's world pose | Standard press, any camera angle |
| **Material-only** (glow / color / opacity) | `PokeInteractable` but the visual's transform does NOT move under contact | Screenshot comparison only | **See § Material-only test design — non-negotiable** |
| **UI Button / Toggle** | UI `Button` or `Toggle` on a `Canvas` with `PointableCanvasModule` | `unity_get_toggle_state` or side-effect query (NOT screenshot, NOT mid-push) | Always lift before checking state |
If you cannot tell physical vs material-only by inspection, do a probe press, re-query the visual transform, and decide from the delta.
## Step 2 — Pre-press checklist
Before sending the first `set_controller_pose`, confirm each line:
- [ ] Pokeable type identified (physical / material-only / UI)
- [ ] Found the **depressing visual** (typically `…/Visuals/ButtonVisual`) and its world pose
- [ ] Computed the press **normal** in OpenXR coords
- [ ] Decided which **verification signal** matches the type
- [ ] **For material-only:** camera staged per § Material-only test design, side button chosen, baseline captured
## Step 3 — Press mechanics (universal)
**Two non-negotiable rules:**
1. **Use the aim pose, not grip.** The PokeInteractor tip rides on aim; grip is offset by several cm and the press misses.
2. **Start outside the surface and push in.** The SDK detects presses from the entry transition (outside → inside). Setting the initial aim at or past the target skips that transition; touch-state pokeables won't fire at all.
Sequence:
1. **Find the press point**`unity_get_world_pose` on the depressing visual.
2. **Compute the press normal in OpenXR** (see § Surface normal). Face-up button: `(0, 1, 0)`.
3. **Convert target to OpenXR** — negate Z; X and Y unchanged.
4. **Position aim ~1020 cm out along +normal**, oriented so controller forward (-Z OpenXR) aligns with -normal.
5. **Push aim ~35 cm past the surface along -normal** with `duration_seconds: 0.51.0`. Instant teleport (`0`) can skip the SDK's entry frames.
6. **Lift aim back along +normal** to release. Some interactables (UI Buttons, Toggles) only fire on release.
No grip/trigger input — poke is purely positional.
## Surface normal & orientation
The press normal is the parent's local +Z (Unity convention) rotated into world space, then Z-negated for OpenXR.
- **Face-up** (parent rotation 0): normal = `(0, 1, 0)` OpenXR.
- **X-tilted by `θ`**: normal = `(0, sin θ, cos θ)` OpenXR. For 30°: `(0, 0.5, 0.866)`.
- **Empirical check**: query the visual's offset from its parent — that direction *is* the local face direction (the visual sits proud of the parent in -press direction).
For controller orientation along normal `n`: rotate so forward (-Z OpenXR) aligns with -n. For X-tilt `θ`: quaternion `[-sin(θ/2), 0, 0, cos(θ/2)]`. Re-query aim pose afterward to sanity-check.
## Verification by type
### Physical 3D buttons
**Re-query the depressing visual's world pose.** It moves along -normal by ~13 cm while held and springs back on release. Authoritative — works even when the button is dim, off-screen, or the camera angle hides it. On tilted surfaces the depression appears in multiple axes; check the delta direction matches -normal.
Don't rely on screenshots: many sample scenes render pokeables in a "ghost" state until directly hovered, so they look unchanged mid-press.
### Material-only test design — non-negotiable
Material-only pokeables have no transform feedback — only a material parameter changes. **Screenshots are your only signal**, and the signal is subtle. Three rules govern whether you'll see it:
1. **Pick a side button, never the middle one.** When several pokeables sit side-by-side, the middle one frequently renders with a baseline rendering offset (z-fighting, staggered z-offsets, ghost states). Testing the middle button means your "did it change?" comparison fights an existing asymmetry. A side button gives you two clean unlit neighbors as in-frame baselines.
2. **Lock head pose close.** Head along -n at ~3060 cm so each button fills a meaningful portion of the frame. Standing-eye distance hides glow deltas in pixel noise. Offset slightly off-axis (or use a top-down angle for face-up panels) so the controller body doesn't sit between camera and target.
3. **Capture a same-pose baseline before you touch anything.** Both controllers off-camera. Then move controller into test pose and re-screenshot at the *same* head pose. Compare same-button before/after AND target vs. neighbors in the same frame.
If you find yourself hedging with phrases like "subtle but visible" — stop. Reset to a side button at close range and try again. Ambiguity is a redesign signal, not a result.
### Hover vs touch reactivity
To tell whether a pokeable reacts on proximity or only on contact, stage the controller in three phases and verify after each:
1. **Far** — both controllers off-camera. Baseline.
2. **Hover** — ~510 cm outside the surface along +n. Proximity-reactive pokeables fire here; touch-only ones don't.
3. **Contact** — ~5 cm past the surface along -n. Touch-only pokeables fire here.
When several adjacent pokeables of unknown type sit side by side, test one at a time — the others act as in-frame baselines.
### UI buttons on a canvas (PointableCanvasModule + UI Button/Toggle)
The transform check doesn't apply — UI elements don't physically move.
- **The click fires on release, not push.** `onClick` / `onValueChanged` fires when you lift the poke point back out. Querying state mid-push returns the pre-press value. **Always lift before checking.**
- **Verify via state.** `unity_get_toggle_state(path)` for `Toggle`; for `Button`, query the side effect (dialog opening, content change). Screenshots are a useful supplement but state is authoritative.
- **Radio-style toggle groups** (dropdown lists): selecting one flips others off. Check both your target and the previously-selected sibling to confirm a real swap.
## When verification is ambiguous
1. Re-query the aim pose — interpolation from a prior `set_controller_pose` may not have completed. Re-set with `duration_seconds: 0` and re-query.
2. Re-confirm the surface normal from the visual's offset relative to its parent.
3. Push deeper and slower.
4. **For material-only:** if you didn't follow the test-design rules above, that's the problem. Reset and follow them.
## Common Pitfalls
1. **Confusing the info panel with the interactable** — sample scenes often render a "Poke" info card next to the button. The real interactable is at the position returned by `unity_get_world_pose`, not where your eye is drawn.
2. **Targeting the wrong canvas** — multiple canvases can have similarly-named toggles (`DropDownListButton_…_Toggle (20)` vs `(0)` on a different canvas). Check parent positions. A horizontal row of toggles all sharing the same Y is a button bar (e.g. a scrubber), not the scrollable list.
3. **Assuming all pokeables physically depress** — material-only ones don't move the transform. The transform-delta check returns "nothing changed" mid-press. Switch to the screenshot comparison protocol.
4. **Testing the middle button of a row** — see § Material-only test design.
5. **Batching multiple pokeable tests in one camera frame** — efficient-feeling, but mixes states across screenshots and creates ambiguity. Reset to baseline between tests.

View File

@ -0,0 +1,66 @@
---
name: hz-meta-xr-operator-unity-meta-quest
description: Quest-specific gotchas for using Meta XR Operator to test a Unity app on a Meta Quest headset — the required OpenXR controller interaction profile, Development-build requirement, the OVRRaycaster ray aim offset, head-pose limits, capture consent, the device sysprops to set, and verbose input tracing for debugging clicks.
allowed-tools:
- Bash(adb:*)
---
# Meta XR Operator on Meta Quest (Unity)
Things to know when using Meta XR Operator to drive and validate **your own Unity app** (built with the Meta XR Core SDK) **on a Meta Quest headset**. Follow **hz-meta-xr-operator** / **hz-meta-xr-operator-unity-workflow** for general runtime interaction, and **hz-meta-xr-operator-coordinates** for coordinate math; this skill is the Quest-on-device delta.
## 1. Enable a controller interaction profile for the Android build (REQUIRED)
The most common cause of "the simulated controller does nothing." Unity's OpenXR settings frequently enable controller interaction profiles **only for Standalone (PC), not for Android**. On a Quest build with **no** controller profile, the runtime binds none, so **neither a physical nor a simulated controller's input reaches your actions**. The pose, ray, and hover still work (the Meta XR Operator layer supplies the simulated pose), which **masks** the problem — only *clicks/button presses* silently do nothing.
- **Fix:** Project Settings → XR Plug-in Management → OpenXR → **Android** tab → *Interaction Profiles* → add a Touch controller profile (e.g. **Oculus Touch Controller Profile**; the Meta XR plugin surfaces it to the runtime as `/interaction_profiles/meta/touch_controller_plus`). Enable the same profile(s) you already have under Standalone.
- **Verify at runtime:** `openxr_get_active_interaction_profile` must return a controller profile (non-`null`) once a controller — real or simulated — is active. `null` means no profile is enabled/bound and input cannot register.
## 2. Build must be a Development build
The Meta XR Operator API layer is bundled **only in Development builds**. A Release build strips it and the `openxr_*` / `unity_*` MCP tools will not be available. Enable *Development Build* before building the APK.
## 3. Aim: the OVRRaycaster ray offset
If the UI uses **OVRRaycaster / OVRInputModule**, the rendered ray emerges noticeably **below** the controller pose's forward (observed roughly **5055°** in testing). Pointing the aim pose straight at a UI element therefore lands the reticle well below it and the click misses — even though the aim "looks" correct.
- Pitch the pose **up** from the straight-line direction to the target (≈5055° for OVRRaycaster), and set **both** the `grip` and `aim` poses (the ray rides the device/grip pose; setting only `aim` leaves a stale grip pose).
- **Verify the reticle is on the target with `openxr_capture_composited_image` immediately before clicking.** The raycast is from the controller pose and is independent of head orientation.
- XRI / `XRUIInputModule` ray interactors may use a different offset — always confirm the reticle visually rather than assuming.
## 4. Head pose cannot be simulated on the headset
`openxr_set_head_pose` is not available on a Quest headset — move the **physical** headset to change the view. The simulated controller's raycast is world-anchored, so head movement does not change where its ray lands (you can click an off-screen element); use captures to confirm the reticle when the panel is in view.
## 5. Screen capture needs one-time consent
`openxr_capture_composited_image` uses Android MediaProjection, which requires a one-time, in-headset consent dialog. Front-load it so it doesn't interrupt an action later:
`adb shell setprop debug.meta_xr_operator.request_capture_permission 1` (then approve the dialog in-headset).
## 6. Device sysprops to set each session
`debug.` props are not persisted across reboot — re-set them per session:
```bash
adb shell setprop debug.oculus.experimentalEnabled 1 # enable the agentic path
adb shell setprop debug.meta_xr_operator.request_capture_permission 1 # front-load capture consent
adb forward tcp:8720 tcp:8720 # reach the MCP server
# optional, for input debugging (see section 8):
adb shell setprop debug.meta_xr_operator.verbose 1
```
If the MCP server is unreachable, re-run `adb forward tcp:8720` and make sure the app is foregrounded (session must reach `FOCUSED`).
## 7. Simulating a controller suppresses the physical controllers
While the agent drives the simulated controller (conformance automation), the runtime **suppresses physical controller tracking** for that session — relaunch the app to hand control back to physical controllers. If the headset is stationary/off-head and loses positional tracking, Horizon OS shows a **"Finding position in room"** dialog that intercepts XR input; wear the headset or give its cameras a textured view to clear it.
## 8. Debug clicks with verbose input tracing
Set `adb shell setprop debug.meta_xr_operator.verbose 1` **before launch** to raise the layer to DEBUG and trace per-action input reads (zero overhead when off). During a simulated trigger, `adb logcat -s AgenticXR` shows:
```
[DEBUG] [inputdiag] float action=0x.. cur=1 active=1 # injected value reached the app's action
```
Map an action handle to its component via the `[inputdiag] suggest action=0x.. path=...` lines. This tells you whether a failed click is **aim** (the trigger action reads `cur=1` — input is fine, re-aim) or **input routing** (`cur=0` throughout — e.g. the missing interaction profile in section 1).

View File

@ -0,0 +1,58 @@
---
name: hz-meta-xr-operator-unity-test-mechanics
description: Runs AI-driven test attempts in Unity Meta Quest and Horizon OS projects using a bounded 3-attempt retry policy with an explicit understand → set up → execute → evaluate → report flow.
allowed-tools:
- Bash(hzdb:*)
tags:
- agentic-xr
- unity
- openxr
- mcp
- testing
- retry
- vr
---
# Unity Test Mechanics
Test Unity game mechanics using Meta XR Operator and reach a clear pass/fail conclusion quickly without excessive iteration.
## Retry Policy
Do NOT spend excessive time iterating on a behavior that may be broken. Your goal is to reach a clear conclusion quickly:
- **Attempt 1**: Follow the user's instructions directly.
- **Attempt 2** (if needed): Adjust approach — different trigger method, timing, or parameters.
- **Attempt 3** (absolute maximum): Meaningfully different strategy — isolate the mechanic, try alternative verification.
**Stop early** if an attempt reveals an obvious root cause (missing component, compilation error, null reference) - 3 tries is the **maximum**, not the requirement. The user needs a fast answer, not an exhaustive search.
## Workflow
1. **Understand**: Identify the behavior to test, expected outcome, and how to trigger it. Clarify with the user if needed.
2. **Set up**: Verify preconditions (correct scene, editor state, required objects/scripts present). Capture baseline state (i.e. with a screenshot) if useful.
3. **Execute**: Enter Unity play mode (if in editor), then trigger the necessary behavior and actions to test behaviors. Observe via screenshots, console output, object state (inspect GameObjects, components, transforms), and OpenXR tools (controller poses, inputs).
4. **Evaluate**: Compare observed result to expected outcome. On pass, report with evidence. On fail, retry or conclude per the policy above.
5. **Report** (pass or fail): What you tested, what you expected, what happened, any console errors, and your assessment of why (if failing).
## Example
```
User: "Test if the player takes damage when touching the lava"
Attempt 1: Move player to lava, check health → unchanged. FAIL.
Attempt 2: Check console → "LavaDamage script missing collider". Lava has no Collider. Root cause found.
Conclusion: Lava damage broken — missing Collider (trigger) on lava GameObject, so OnTriggerEnter never fires.
```
## Additional Rules
- Follow the user's instructions as closely as possible on the first attempt.
- Use the most direct verification method available.
- Check the console for errors after each attempt.
- Never fix issues without telling the user (unless they asked you to fix it).
- Do not make project changes during testing unless necessary for test setup.
- Each retry **must** use a different approach — never repeat the same method, and never exceed 3 total attempts.
- Report what you observe (not just pass/fail), and include evidence (screenshots, logs, object state) in your report.
- Load additional Meta XR Operator skills as needed for critical context on specific mechanics and features.

View File

@ -0,0 +1,72 @@
---
name: hz-meta-xr-operator-unity-workflow
description: Iterates on Unity projects targeting Meta Quest and Horizon OS via the Meta XR Operator MCP server, covering Play Mode gating, scene editing safety, scene hierarchy navigation, UI interaction, and HZDB documentation lookup.
allowed-tools:
- Bash(hzdb:*)
tags:
- agentic-xr
- unity
- openxr
- mcp
- vr
- development
- iteration
---
# Meta XR Operator + Unity Development
Using Meta XR Operator within a **Unity + XR Simulator** development workflow. Follow the **hz-meta-xr-operator** skill for general runtime interaction guidance.
## The Iteration Loop
1. Plan feature/fix (use HZDB for Oculus VR docs if available)
2. Modify Unity project (use Unity MCP to edit scripts, scenes, etc.)
3. Validate scene in Editor (take editor screenshot for layout understanding)
4. Enter Play Mode (app starts, Meta XR Operator becomes available)
5. Interact & verify at runtime (use Meta XR Operator MCP tools)
6. Exit Play Mode (Meta XR Operator disconnects)
7. Repeat from step 2
## Critical Rules
- Unity MUST be in **Play Mode** for Meta XR Operator tools to work. They return errors otherwise.
- **Exit Play Mode** before making persistent changes to scripts, scenes, or project settings via Unity MCP.
- If Unity MCP becomes unresponsive, Unity is likely **compiling code** — wait and retry.
## Before Entering Play Mode
- **Take an editor screenshot** to understand the initial layout.
- **Query the scene hierarchy** via Unity MCP to understand what GameObjects and components exist.
## Navigating the Scene Hierarchy
1. `get_scene_root_objects` — see top-level objects
2. `get_children("path")` — drill down (e.g. `"Canvas/Panel/StartButton"`)
3. `get_world_pose("path")` — get coordinates, then convert to OpenXR (see coordinates skill)
## Interacting with UI
1. `find_canvases` — locate UI canvases
2. `find_interactables("CanvasPath")` — discover buttons, sliders, etc.
3. `get_world_pose` on the interactable → convert to OpenXR coordinates
4. `openxr_set_controller_pose` — move controller to that position
5. `openxr_set_controller_input(Trigger, 1)` then `(Trigger, 0)` — simulate click
6. `openxr_capture_composited_image` — verify
## General Unity MCP Tips
- **Script types aren't available immediately** — after creating C# scripts, Unity needs to compile. `AssetDatabase.Refresh()` triggers compilation but disconnects MCP. Wait, then use `Type.GetType("ClassName, Assembly-CSharp")` to add components.
- **RunCommand can't reference Assembly-CSharp types directly** — use `Type.GetType()` + `AddComponent(type)` and `SerializedObject` for wiring references.
- **Update serialized values in the editor, not just code defaults** — public fields are serialized in the scene. Use `Unity_RunCommand` with `EditorUtility.SetDirty()` to update live values.
- **Reimport shaders/scripts after editing**`AssetDatabase.ImportAsset(..., ImportAssetOptions.ForceUpdate)` and `SceneView.RepaintAll()`. `Shader.Find()` returns null for unimported shaders.
- **Reassign shader to material** after adding/removing properties to avoid stale bindings.
### Colliders & Physics
- **Check `isTrigger`**`OnCollisionEnter` only fires on non-trigger colliders; triggers require `OnTriggerEnter`. When in doubt, implement both.
- **Use `ContinuousDynamic` for fast projectiles** — default `Discrete` mode lets fast objects tunnel through colliders.
- **Never spawn GameObjects from `OnDestroy`** — Unity calls it during scene teardown. Move spawn logic to the caller.
## Using HZDB (if available)
Query HZDB for Oculus VR documentation on VR-specific features (hand tracking, passthrough, spatial anchors, etc.). If unavailable, proceed with general VR knowledge.

View File

@ -0,0 +1,59 @@
---
name: hz-meta-xr-operator
description: Drives Meta Quest and Horizon OS XR apps from an AI agent via the Meta XR Operator OpenXR API layer and MCP tools, including setup, head-pose control, controller input, and runtime verification.
allowed-tools:
- Bash(hzdb:*)
tags:
- agentic-xr
- openxr
- mcp
- vr
- ar
- runtime
---
# Meta XR Operator Usage
Meta XR Operator is an OpenXR API Layer that gives AI agents the ability to perceive, understand, and manipulate VR/AR applications at runtime via MCP tools.
**Key constraint:** Meta XR Operator tools are ONLY available when the VR/XR application is actively running. Tools will return errors if the app is not active.
## Setup
Meta XR Operator is available as an MCP server. To setup:
- **If HZDB v1.3.0+ is installed as an MCP server but the Meta XR Operator tools (e.g., `openxr_*`) are not available**, run the **"install meta xr operator mcp proxy"** tool exposed by HZDB.
- Otherwise, prompt the user to follow steps within the Meta XR Core SDK's AI Tools window in Unity to set up Meta XR Operator.
## When to Use
Use Meta XR Operator to **interact with a running XR application at runtime**:
- **Iteration** — Launch the app, verify features, interact, then exit to make changes.
- **Debugging** — Reproduce and investigate runtime bugs by inspecting poses, scene state, and visuals.
- **Test Automation** — Programmatically verify features. See the **hz-meta-xr-operator-unity-test-mechanics** skill for more details.
## Orientation & Navigation
- **Start by orienting yourself.** Use `openxr_get_head_pose` + `openxr_capture_composited_image` to see where you are.
- **Use data before vision.** Query scene data (`get_scene_root_objects`, `get_children`, `get_world_pose`) to understand layout via coordinates, then confirm visually with screenshots.
- **Don't guess positions** — always use `get_world_pose` for both target and rig. Check positions relative to the camera rig, not world origin.
- **If you can't find something visually**, step back to get a wider field of view. If still not found after 2-3 screenshots, use data tools to get coordinates and navigate there directly.
For detailed coordinate math and controller positioning, see **hz-meta-xr-operator-coordinates**. For aiming grabbed objects (guns, tools), see **hz-meta-xr-operator-grabbed-objects**.
## Verification
- **Verify both via data AND visually.** Confirm results using coordinate/scene-data tools AND compositor screenshots.
- **Take screenshots after key interactions** to verify visual state matches expectations.
- **Check for visual artifacts** (aliasing, noise) by capturing screenshots from multiple angles.
## Input Simulation
- **Smooth movements:** Use the `duration` parameter on `openxr_set_head_pose` and `openxr_set_controller_pose` for realistic motion.
- **Controller input values:** Buttons are 0/1, Trigger/Grip are 0.01.0, Thumbstick is -1.0 to 1.0 on each axis.
- **Hold inputs across frames** — setting an input to 1 and immediately to 0 may be missed by `Update()`.
## Common Pitfalls
- **Tools only work when the app is running.** Connection errors mean the app may have stopped.
- **Coordinate systems matter.** OpenXR is right-handed (-Z forward). Unity is left-handed (Z forward). See the **hz-meta-xr-operator-coordinates** skill for conversion.

View File

@ -0,0 +1,6 @@
{
"version": "1.0",
"components": [
"Microsoft.VisualStudio.Workload.ManagedGame"
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,14 @@
fileFormatVersion: 2
guid: 052faaac586de48259a63d0c4782560b
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 11500000, guid: 8404be70184654265930450def6a9037, type: 3}
generateWrapperCode: 0
wrapperCodePath:
wrapperClassName:
wrapperCodeNamespace:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3a75f63426af7ac4fbe696ac344e41e3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,49 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 05d394ae2a81edd4cbc3c51917e766e3, type: 3}
m_Name: OculusProjectConfig
m_EditorClassIdentifier:
targetDeviceTypes: 02000000030000000400000005000000
allowOptional3DofHeadTracking: 0
handTrackingSupport: 1
handTrackingFrequency: 0
anchorSupport: 1
sharedAnchorSupport: 0
renderModelSupport: 0
trackedKeyboardSupport: 0
bodyTrackingSupport: 0
faceTrackingSupport: 0
eyeTrackingSupport: 0
colocationSessionSupport: 0
sceneSupport: 2
boundaryVisibilitySupport: 0
disableBackups: 1
enableNSCConfig: 1
securityXmlPath:
horizonOsSdkDisabled: 0
minHorizonOsSdkVersion: 60
targetHorizonOsSdkVersion: 205
skipUnneededShaders: 0
enableIL2CPPLTO: 0
removeGradleManifest: 1
metaXrFeaturePromptDeclined: 0
useOpenXRPromptDeclined: 0
focusAware: 1
requiresSystemKeyboard: 0
experimentalFeaturesEnabled: 0
insightPassthroughEnabled: 0
_insightPassthroughSupport: 0
isPassthroughCameraAccessEnabled: 0
_processorFavor: 0
systemSplashScreen: {fileID: 0}
systemSplashScreenType: 0
_systemLoadingScreenBackground: 0

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 66f844081470f8c4a823e55c54cf77c9
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 700e39862a1e72b47a1e5ccfbcf6fb13
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7ab3270d029a44f4f8ec733b4148d28e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:installLocation="auto" xmlns:horizonos="http://schemas.horizonos/sdk">
<application android:label="@string/app_name" android:icon="@mipmap/app_icon" android:allowBackup="false">
<activity android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" android:configChanges="locale|fontScale|keyboard|keyboardHidden|mcc|mnc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode" android:launchMode="singleTask" android:name="com.unity3d.player.UnityPlayerGameActivity" android:excludeFromRecents="true" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="com.oculus.intent.category.VR" />
</intent-filter>
<meta-data android:name="com.oculus.vr.focusaware" android:value="true" />
</activity>
<meta-data android:name="unityplayer.SkipPermissionsDialog" android:value="false" />
<meta-data android:name="com.oculus.handtracking.frequency" android:value="LOW" />
<meta-data android:name="com.oculus.ossplash.background" android:value="black" />
<meta-data android:name="com.oculus.telemetry.project_guid" android:value="dc209ffb-2bcd-461c-b94e-d02f3e6251a4" />
<meta-data android:name="com.oculus.supportedDevices" android:value="quest|quest2|questpro|quest3|quest3s" tools:replace="android:value" />
</application>
<uses-feature android:name="android.hardware.vr.headtracking" android:version="1" android:required="true" />
<uses-feature android:name="oculus.software.handtracking" android:required="false" />
<uses-permission android:name="com.oculus.permission.HAND_TRACKING" />
<uses-permission android:name="com.oculus.permission.USE_ANCHOR_API" />
<uses-permission android:name="com.oculus.permission.USE_SCENE" />
<horizonos:uses-horizonos-sdk horizonos:minSdkVersion="60" horizonos:targetSdkVersion="205" />
</manifest>

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 92d10fad75a606e4faa7146de3ef0f5a
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,34 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fcf7219bab7fe46a1ad266029b2fee19, type: 3}
m_Name: Readme
m_EditorClassIdentifier:
icon: {fileID: 2800000, guid: 727a75301c3d24613a3ebcec4a24c2c8, type: 3}
title: URP Empty Template
sections:
- heading: Welcome to the Universal Render Pipeline
text: This template includes the settings and assets you need to start creating with the Universal Render Pipeline.
linkText:
url:
- heading: URP Documentation
text:
linkText: Read more about URP
url: https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@latest
- heading: Forums
text:
linkText: Get answers and support
url: https://forum.unity.com/forums/universal-render-pipeline.383/
- heading: Report bugs
text:
linkText: Submit a report
url: https://unity3d.com/unity/qa/bug-reporting
loadedLayout: 1

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8105016687592461f977c054a80ce2f2
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 534d7f790c9a07246997e73fc348b13f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,25 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6b414b497f4292d4ab7fc53a67464cb7, type: 3}
m_Name: DevAgentSettings
m_EditorClassIdentifier:
enabled: 0
serverAddress: 192.168.31.228
serverPort: 48735
mcpServerPort: 48736
witConfiguration: {fileID: 0}
witClientAccessToken:
pushToTalkButton: 1
handPushToTalkGesture: 0
enableDelayedRelease: 1
releaseDelay: 0.8
accessToken: cb7abe06675f48aeb8f2b77d5ca62823

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1c82aeee5494b6e4b976d2c4c435b9a8
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,44 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a7d75bea1662418ab5f9e0c22110bc09, type: 3}
m_Name: ImmersiveDebuggerSettings
m_EditorClassIdentifier:
debugTypes: []
immersiveDebuggerEnabled: 0
immersiveDebuggerDisplayAtStartup: 0
enableOnlyInDebugBuild: 0
showInspectors: 0
showConsole: 0
followOverride: 1
rotateOverride: 0
recenterOnToggle: 1
showInfoLog: 0
showWarningLog: 1
showErrorLog: 1
collapsedIdenticalLogEntries: 0
maximumNumberOfLogEntries: 1000
panelDistance: 1
createEventSystem: 1
automaticLayerCullingUpdate: 1
panelLayer: 20
meshRendererLayer: 21
overlayDepth: 10
useOverlay: 1
inspectedDataEnabled:
inspectedDataAssets: []
useCustomIntegrationConfig: 0
customIntegrationConfigClassName:
hierarchyViewShowsPrivateMembers: 0
clickButton: 8193
toggleFollowTranslationButton: 0
toggleFollowRotationButton: 0
immersiveDebuggerToggleDisplayButton: 2

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9a3db69b557ef6142bb753525efa3135
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 8922a6ca86889d84f8371a29d37b6dc8, type: 3}
m_Name: InputActions
m_EditorClassIdentifier:
InputActionDefinitions: []
InputActionSets: []

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5b9e3f22134397c4ea4e057923a789dc
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 60e5406209e5ed147bb02ed71f9c40f3, type: 3}
m_Name: MetaXRAcousticMaterialMapping
m_EditorClassIdentifier:
mapping: []
fallbackMaterial: {fileID: 0}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ccc9c114a8f5dda4e892a52fc748e4a0
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,18 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 2b76bcf034ab49e4a8cd30239a716460, type: 3}
m_Name: MetaXRAcousticSettings
m_EditorClassIdentifier:
acousticModel: -1
diffractionEnabled: 1
excludeTags: []
mapBakeWriteGeo: 1

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 40cc63d50ee6af84eac314e60e82b79e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,15 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f3fe6e38ac2d4c22b04340d6eda2a47e, type: 3}
m_Name: MetaXRAudioSettings
m_EditorClassIdentifier:
voiceLimit: 64

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 175555f25bcebb34aa28ed88b6ef4d2c
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,14 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 20553fac56ec59645857c0732b787431, type: 3}
m_Name: OVRBuildConfig
m_EditorClassIdentifier:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c89ccef335fe12447aec5971b8347702
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,22 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: cd7bb81df5b74b34dadbf531f381a26b, type: 3}
m_Name: OVRPlatformToolSettings
m_EditorClassIdentifier:
riftRedistPackages: []
languagePackDirectory:
assetConfigs:
- configList: []
- configList: []
- configList: []
targetPlatform: 3
runProjectSetupTool: 1

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1dd48e4e52953904f83c3afb628aaeda
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,24 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3863570e7e6387a40ae4f323d83291e5, type: 3}
m_Name: OculusRuntimeSettings
m_EditorClassIdentifier:
handSkeletonVersion: 1
colorSpace: 7
requestsVisualFaceTracking: 1
requestsAudioFaceTracking: 1
enableFaceTrackingVisemesOutput: 0
telemetryProjectGuid: dc209ffb-2bcd-461c-b94e-d02f3e6251a4
bodyTrackingFidelity: 1
bodyTrackingJointSet: 0
allowVisibilityMesh: 1
QuestVisibilityMeshOverriden: 0

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: eae441885ffab0645a48080c4974832a
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: dcde72d6cd79b07409f73fd596acf15b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 7e4c07418c4ff3a4b84c1862f43df188
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 99c9720ab356a0642a771bea13969a05
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 805dcdbca14935f478a2d1b1b14c063a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 388e1e431b8eb1b4aa05e88fba3b43e2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,324 @@
using System.Threading.Tasks;
using MyInfoMate.Vr.Menu;
using MyInfoMate.Vr.Net;
using MyInfoMate.Vr.Scene;
using UnityEngine;
namespace MyInfoMate.Vr.Boot
{
/// <summary>
/// L'app du canal VR, telle qu'elle tient aujourd'hui : appairage (E2), contenu
/// servi depuis le cache (E3-E4), <b>menu flottant</b> (E5) et télémétrie (E9).
///
/// C'est le successeur de <see cref="PairingBootstrap"/>, qui reste à côté : celui-ci
/// prouve la chaîne réseau en une phrase, celui-là est l'app. Tant que rien n'a tourné
/// sur un vrai casque, pouvoir revenir au plus simple en un clic évite de confondre
/// un problème d'appairage avec un problème de rendu.
///
/// Les quatre types retenus au §8 s'ouvrent : <b>Slider</b>, <b>Map</b> et <b>Event</b>
/// (E8), le <b>Parcours</b> rendu comme une Map, et la <b>Video 360</b> — celle-ci
/// n'est pas un type de section mais une section Video dont le média est une ressource
/// 360, livrée le 2026-09-12. S'y ajoute la <b>Scène 3D</b> (E7). Choisir une section
/// non gérée affiche son titre et le dit — un menu qui ne réagit pas se lit comme une
/// panne.
///
/// La session de borne (E10) vit ici aussi : elle décide quand la visite est finie.
/// </summary>
public class MenuBootstrap : MonoBehaviour
{
[Header("Serveur")]
[SerializeField] string baseUrl = "https://api.mymuseum.be";
[Header("Appairage — code PIN de l'instance (première fois seulement)")]
[SerializeField] string pinCode = "";
[Header("Nom affiché dans l'onglet XR du manager")]
[SerializeField] string headsetName = "Quest";
[SerializeField] string language = "FR";
[SerializeField] bool forgetPairing;
Transform _head;
FloatingMenu _menu;
Telemetry _telemetry;
ConfigurationExport _export;
SceneMessage _message;
async void Start()
{
_head = Camera.main != null ? Camera.main.transform : null;
if (_head == null)
Debug.LogError("[Menu] Aucune caméra active : le rig VR n'est pas dans la " +
"scène. Meta → Tools → Building Blocks → Camera Rig.");
if (forgetPairing) PairingService.Forget();
var pairing = PairingService.Restore();
if (pairing == null)
{
if (string.IsNullOrEmpty(pinCode))
{
Show("Ce casque n'est pas appairé. Renseignez le code PIN du lieu " +
"dans l'inspecteur, puis relancez.");
return;
}
var paired = await new PairingService().PairAsync(baseUrl, pinCode, headsetName);
if (!paired.Ok)
{
Show(paired.Error);
return;
}
pairing = paired.Value;
}
_client = new ApiClient(pairing.BaseUrl, pairing.ApiKey);
var client = _client;
var content = new ContentService(client, pairing.ConfigurationId, pairing.DeviceId);
var loaded = await content.LoadAsync();
if (!loaded.Ok)
{
Show(loaded.Error);
return;
}
_telemetry = new Telemetry(client, pairing.InstanceId, pairing.ConfigurationId, language);
// XR-5 : sans ce battement, l'onglet XR du manager affiche « — » pour la
// batterie et la version, et ne sait jamais si un casque est tombé.
var fleet = Kiosk.FleetReporter.Attach(
gameObject, client, pairing.DeviceId, pairing.ConfigurationId);
if (fleet != null) fleet.ConfigurationChanged += OnConfigurationChanged;
BuildMenu(loaded.Export);
await _menu.LoadImagesAsync(loaded.Export, client);
await ApplyBackdrop(loaded.Export, client);
// E4 — le reste de la visite part sur le disque pendant que le visiteur
// regarde le menu. Sans await : c'est long, et personne ne doit l'attendre.
_ = ContentPreloader.RunAsync(loaded.Export, client);
if (!loaded.FromCache) return;
// Le menu est déjà utilisable : le rafraîchissement ne fait attendre personne,
// et ne se voit que s'il apporte quelque chose.
var fresh = await content.RefreshAsync();
if (fresh == null || _menu == null) return;
Debug.Log("[Menu] Contenu mis à jour, le menu se reconstruit.");
BuildMenu(fresh);
await _menu.LoadImagesAsync(fresh, client);
await ApplyBackdrop(fresh, client);
_ = ContentPreloader.RunAsync(fresh, client);
}
void BuildMenu(ConfigurationExport export)
{
_export = export;
if (_menu == null)
{
_menu = FloatingMenu.Create(_head);
_menu.SectionChosen += OnSectionChosen;
_kiosk = Kiosk.KioskSession.Attach(gameObject, _head);
_kiosk.Ended += StartNewVisit;
}
_menu.Build(export, language);
}
/// <summary>
/// Fin de visite : casque reposé, ou plus rien depuis un long moment. On revient
/// au menu, on le repose devant le visiteur, et on ouvre une nouvelle session de
/// statistiques — sinon une journée de borne compterait pour un seul visiteur.
/// </summary>
void StartNewVisit()
{
CloseSection();
_menu?.Recenter();
_telemetry?.StartNewSession();
Debug.Log("[Kiosk] Nouvelle visite.");
}
async void OnSectionChosen(string sectionId)
{
_kiosk?.NotifyActivity();
_telemetry?.MenuItemTap(sectionId);
_telemetry?.SectionView(sectionId);
var section = _export?.Sections?.Find(s => s.Id == sectionId);
var title = section != null
? ConfigurationExport.Translate(section.Title, language) ?? section.Label
: sectionId;
Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})");
if (section == null) return;
// Une section Video dont le média est une ressource 360 est le seul cas qui
// ne se feuillette pas : elle devient le ciel.
var immersive = ImmersiveResourceOf(section);
if (immersive != null)
{
OpenSection(sectionId);
_skybox = SkyboxView.Create(_head);
_skybox.Closed += CloseSection;
_skybox.Interacted += () => _kiosk?.NotifyActivity();
await _skybox.LoadAsync(immersive, _client);
return;
}
// Une maquette 3D n'est pas une page : elle se regarde en volume, avec ses
// points posés dessus.
if (section.Type == ConfigurationExport.SectionType.Scene3D)
{
OpenSection(sectionId);
_model = Scene3DView.Create(_head);
_model.Closed += CloseSection;
_model.Interacted += () => _kiosk?.NotifyActivity();
if (!await _model.LoadAsync(section, _export, language))
{
CloseSection();
Show($"{title}\n\nCette scène 3D n'a pas pu être chargée.");
}
return;
}
if (!SectionPages.CanRender(section.Type))
{
// Les types écartés au §8 (Quiz, Game, Web, PDF…) ne seront pas portés.
// Le dire vaut mieux que ne rien faire : un menu qui ne réagit pas se lit
// comme une panne.
Show($"{title}\n\nCe type de contenu n'est pas encore affichable dans le casque.");
return;
}
OpenSection(sectionId);
_view = PagedView.Create(_head);
_view.Closed += CloseSection;
_view.Interacted += () => _kiosk?.NotifyActivity();
await SectionPages.FillAsync(_view, section, _client, language);
}
/// <summary>
/// Retour au menu. La durée passée dans la section part avec le
/// <c>SectionLeave</c> : c'est elle qui dit si un contenu retient, et sans elle
/// les stats ne comptent que des ouvertures.
/// </summary>
/// <summary>
/// Le manager a assigné une autre configuration à ce casque : on la charge et on
/// repart du menu, sans redémarrer l'app.
///
/// ⚠️ On ferme ce qui est ouvert d'abord. Un visiteur qui regardait une galerie
/// au moment du changement doit revenir au menu — pas rester dans un contenu qui
/// n'appartient plus à sa visite.
/// </summary>
async void OnConfigurationChanged(string configurationId)
{
CloseSection();
var content = new ContentService(_client, configurationId);
var loaded = await content.LoadAsync();
if (!loaded.Ok)
{
Debug.LogWarning($"[Menu] Nouvelle configuration illisible : {loaded.Error}");
return;
}
BuildMenu(loaded.Export);
_menu?.Recenter();
_telemetry?.StartNewSession();
await _menu.LoadImagesAsync(loaded.Export, _client);
await ApplyBackdrop(loaded.Export, _client);
_ = ContentPreloader.RunAsync(loaded.Export, _client);
}
/// <summary>
/// Pose le décor du lieu derrière le menu. Détruit le précédent d'abord : un
/// changement de configuration ne doit pas laisser le panorama de l'ancienne
/// visite, et deux fonds superposés font deux vidéos qui tournent.
/// </summary>
async Task ApplyBackdrop(ConfigurationExport export, ApiClient client)
{
if (_backdrop != null) Destroy(_backdrop.gameObject);
_backdrop = await Menu.ImmersiveBackdrop.ApplyAsync(export, client);
}
void OpenSection(string sectionId)
{
_openedSectionId = sectionId;
_openedAt = Time.time;
_menu.gameObject.SetActive(false);
}
/// <summary>
/// La ressource 360 d'une section, ou null. <c>Source</c> porte soit un id de
/// ressource, soit une URL externe (YouTube) — cette dernière n'est pas une 360
/// téléchargeable, donc pas un cas immersif.
/// </summary>
ConfigurationExport.Resource ImmersiveResourceOf(ConfigurationExport.SectionSummary section)
{
if (section.SourceIsUrl) return null;
var resource = _export?.FindResource(section.Source);
return resource != null && resource.IsImmersive ? resource : null;
}
void CloseSection()
{
if (_view != null) Destroy(_view.gameObject);
_view = null;
if (_skybox != null) Destroy(_skybox.gameObject);
_skybox = null;
// Une scène 3D laissée derrière soi reste plantée dans le décor du menu — et
// son GLB continue d'occuper la mémoire du casque.
if (_model != null) Destroy(_model.gameObject);
_model = null;
// Le message d'une section non gérée resterait affiché par-dessus le menu.
if (_message != null) Destroy(_message.gameObject);
_message = null;
if (_openedSectionId != null)
{
_telemetry?.SectionLeave(_openedSectionId, Mathf.RoundToInt(Time.time - _openedAt));
_openedSectionId = null;
}
if (_menu != null) _menu.gameObject.SetActive(true);
}
PagedView _view;
SkyboxView _skybox;
ImmersiveBackdrop _backdrop;
Scene3DView _model;
ApiClient _client;
Kiosk.KioskSession _kiosk;
string _openedSectionId;
float _openedAt;
void Show(string message)
{
if (_message != null) Destroy(_message.gameObject);
_message = SceneMessage.Show(message, _head);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f40a46377df94b249bd444e525406a30

View File

@ -0,0 +1,123 @@
using MyInfoMate.Vr.Net;
using MyInfoMate.Vr.Scene;
using UnityEngine;
namespace MyInfoMate.Vr.Boot
{
/// <summary>
/// Items <b>E2 et E3</b> du lot XR-4 : le casque s'appaire, puis lit le contenu
/// de sa configuration. Rien d'immersif ici — c'est la preuve que la chaîne
/// réseau tient, affichée en une phrase dans le casque.
///
/// ⚠️ <b>Ne pas confondre avec <see cref="S1Bootstrap"/> et <see cref="S2Bootstrap"/></b> :
/// ceux-là sont la piste Scène 3D (S0-S8), celui-ci le canal VR (E0-E11). Deux
/// numérotations, deux plans.
///
/// <b>Le code PIN ne se saisit pas encore</b> : sans l'Interaction SDK (item E1),
/// il n'y a ni pointeur ni clavier. Il se pose donc dans l'inspecteur avant le
/// build ; l'écran d'appairage arrive avec E5.
/// </summary>
public class PairingBootstrap : MonoBehaviour
{
[Header("Serveur")]
[SerializeField] string baseUrl = "https://api.mymuseum.be";
[Header("Appairage — code PIN de l'instance")]
[SerializeField] string pinCode = "";
[Header("Nom affiché dans l'onglet XR du manager")]
[SerializeField] string headsetName = "Quest";
[Header("Langue d'affichage — l'export les porte toutes")]
[SerializeField] string language = "FR";
[Header("Réappairer même si ce casque l'est déjà")]
[SerializeField] bool forgetPairing;
async void Start()
{
var head = Camera.main != null ? Camera.main.transform : null;
if (head == null)
Debug.LogError("[Pairing] Aucune caméra active : le rig VR n'est pas dans la " +
"scène. Meta → Tools → Building Blocks → Camera Rig.");
if (forgetPairing) PairingService.Forget();
var pairing = PairingService.Restore();
if (pairing == null)
{
if (string.IsNullOrEmpty(pinCode))
{
SceneMessage.Show("Ce casque n'est pas appairé. Renseignez le code PIN " +
"du lieu dans l'inspecteur, puis relancez.", head);
return;
}
var result = await new PairingService()
.PairAsync(baseUrl, pinCode, headsetName);
if (!result.Ok)
{
SceneMessage.Show(result.Error, head);
return;
}
pairing = result.Value;
Debug.Log($"[Pairing] Casque appairé — device {pairing.DeviceId}, " +
$"instance {pairing.InstanceId}");
}
var client = new ApiClient(pairing.BaseUrl, pairing.ApiKey);
var content = new ContentService(client, pairing.ConfigurationId);
var loaded = await content.LoadAsync();
if (!loaded.Ok)
{
SceneMessage.Show(loaded.Error, head);
return;
}
Show(loaded.Export, head, loaded.FromCache ? loaded.CachedAt : null);
// E4 : le cache a déjà été affiché, le réseau ne fait plus attendre personne.
// Un échec ici ne se voit pas — c'est le comportement voulu pour une borne.
if (loaded.FromCache)
{
var fresh = await content.RefreshAsync();
if (fresh != null)
{
Debug.Log("[Content] Contenu mis à jour depuis le serveur.");
Show(fresh, head, null);
}
}
}
SceneMessage _message;
void Show(ConfigurationExport export, Transform head, System.DateTime? cachedAt)
{
// Le rafraîchissement de fond réaffiche : sans ça, deux panneaux se
// superposent et le texte devient illisible.
if (_message != null) Destroy(_message.gameObject);
var roots = export.RootSections();
var lines = $"{export.Label} — {roots.Count} sections";
if (cachedAt.HasValue)
lines += $"\n(hors ligne — contenu du {cachedAt.Value.ToLocalTime():dd/MM HH:mm})";
for (var i = 0; i < roots.Count; i++)
{
var title = ConfigurationExport.Translate(roots[i].Title, language) ?? roots[i].Label;
Debug.Log($"[Export] {roots[i].Type} — {title}");
// Le message flottant reste lisible à cinq lignes ; la console a le reste.
if (i < 5) lines += $"\n{title} ({roots[i].Type})";
}
_message = SceneMessage.Show(lines, head);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 227c97670fb4e714da8d95c24e21b088

View File

@ -0,0 +1,82 @@
using System.Threading.Tasks;
using MyInfoMate.Vr.Scene;
using UnityEngine;
namespace MyInfoMate.Vr.Boot
{
/// <summary>
/// Orchestration de l'étape S1 : décor, calibration, personnage, zone de navigation.
///
/// <b>Provisoire par construction.</b> Les chemins sont ici en dur, parce que S1
/// n'a ni manifeste ni réseau — c'est exactement son intérêt : prouver la chaîne
/// technique sans backend. S2 remplace ce script par <c>SceneBuilder</c>, qui lit
/// les mêmes assets depuis un manifeste. Ne rien construire au-dessus de celui-ci.
/// </summary>
public class S1Bootstrap : MonoBehaviour
{
[Header("Fichiers dans Assets/StreamingAssets")]
[SerializeField] string worldFile = "world.glb";
[SerializeField] string personaFile = "persona.glb";
[Header("Placement du personnage — coordonnées glTF, en mètres")]
[SerializeField] float[] personaPosition = { -1.5f, 0f, -1f };
[Header("Navigation")]
[SerializeField] float navigationRadiusMeters = 3f;
[SerializeField] bool showBoundary = true;
[SerializeField] bool runCalibration = true;
Transform _sceneRoot;
async void Start()
{
_sceneRoot = new GameObject("SceneRoot").transform;
_sceneRoot.SetParent(transform, false);
var head = Camera.main != null ? Camera.main.transform : null;
if (head == null)
Debug.LogError("[S1] Aucune caméra active : le rig VR n'est pas dans la scène. " +
"Meta → Tools → Building Blocks → Camera Rig.");
await LoadWorld();
if (runCalibration)
await gameObject.AddComponent<CalibrationCheck>().RunAsync(_sceneRoot);
await LoadPersona(head);
SetUpBounds(head);
}
async Task LoadWorld()
{
var world = await GltfLoader.LoadAsync(
GltfLoader.StreamingAssetsUrl(worldFile), _sceneRoot, "World");
if (world == null)
Debug.LogError($"[S1] {worldFile} absent de StreamingAssets — " +
"exporte un monde Marble en mesh GLB et dépose-le là.");
}
async Task LoadPersona(Transform head)
{
var holder = new GameObject("Persona");
holder.transform.SetParent(_sceneRoot, false);
holder.transform.localPosition = GltfSpace.Position(personaPosition);
var persona = holder.AddComponent<PersonaInstance>();
if (!await persona.LoadAsync(GltfLoader.StreamingAssetsUrl(personaFile), head))
Debug.LogError($"[S1] {personaFile} absent de StreamingAssets.");
}
void SetUpBounds(Transform head)
{
// La zone suit le rig, pas la tête : c'est la position au sol du visiteur
// qui compte, et la tête bouge de 60 cm quand on se penche.
var rig = head != null ? head.root : null;
var bounds = new GameObject("NavigationBounds").AddComponent<NavigationBounds>();
bounds.transform.SetParent(_sceneRoot, false);
bounds.Configure(rig, head, navigationRadiusMeters, showBoundary);
}
}
}

Some files were not shown because too many files have changed in this diff Show More