vr-app/docs/04-phase3-plan.md
Thomas Fransolet 0310d28b5e Canal VR Meta Quest : projet Unity, viewer web et documentation
Premier commit du cinquième front. Trois parties :

- `unity/MyInfoMateVR/` : le projet Unity (6000.0.83f1, URP, Meta XR SDK 205),
  un APK unique pour tous les clients. Menu flottant à sélection au regard,
  appairage, chargement de scène GLB, POI, cache de contenu, télémétrie.
- `unity-overlay/` : les mêmes scripts à recopier sur un projet Unity neuf, avec
  les pièges rencontrés consignés dans son README.
- `viewer/` : viewer et éditeur de scène web autonome (Vite, TypeScript,
  three.js), partagé avec les autres fronts.
- `docs/` : état des lieux, setup Unity, décisions d'architecture et plan
  d'exécution en 9 étapes.

La scène est décrite par un `scene.json` poussé par `adb push` : l'app le
préfère à celui embarqué dans l'APK.

Les binaires (GLB, textures de l'échantillon Sponza, DLL Meta XR) passent par
Git LFS dès ce premier commit — les y faire entrer après coup demanderait de
réécrire l'historique. Les artefacts régénérés par l'éditeur et par CMake
(`Library/`, `.utmp/`, Burst debug) sont ignorés.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:26:07 +02:00

320 lines
19 KiB
Markdown

# 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.