DOCS/v2/vr-quest-unity-plan.md
Thomas Fransolet 6221978526 Docs en attente : canal VR et contenu immersif livrés, SectionForm, roadmap
Cartes 600, 610 et 620 closes (back-office XR, ressource 360, appairage tablette),
250 et 320 retirées du planifié, 015 et 330 à jour ; STATUS, roadmap, test-plan,
todo-features et plans VR / frontière immersif alignés ; kanban.html regénéré.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VxSQeGQYUvPmSEoGdnidA
2026-09-15 20:06:34 +02:00

44 KiB
Raw Permalink Blame History

VR Meta Quest — app Unity + onglet XR dans manager-app — 📦 V2

Relevé le 2026-08-31. Toutes les affirmations « existe / n'existe pas » ci-dessous ont été vérifiées dans le code, pas dans la doc. Ce fichier remplace la section VR de ../roadmap.md comme référence technique — la roadmap garde le volet commercial.

⚠️ Rien de ce document n'est V1. La bascule Postgres et le backlog V1 passent avant.

⚠️ Nommage — les lots s'appellent XR-1 à XR-5 (renommés le 2026-09-11 ; ils s'appelaient V-1V-5, ce qui se confondait avec « V1 », la version). Rien à voir avec les lots du backlog V1, qui sont lettrés A à J dans ../v1-plan.md avec des items A0, C6, I3… Deux numérotations, deux documents, deux horizons.


0. Correction d'une affirmation de STATUS.md

STATUS.md §5 disait jusqu'ici : VR Meta Quest = « idée, zéro ligne de code dans tous les repos ». C'est faux depuis au moins juillet 2026. Le canal VR est déjà un citoyen de première classe du modèle de données et de la navigation du back-office. Ce qui manque, c'est l'app et l'écran de flotte — pas les fondations.


1. Ce qui existe déjà

1.1 Backend (manager-service)

Brique Emplacement État
AppType.VR Data/ApplicationInstance.csenum AppType { Mobile, Tablet, Web, VR, Voice } Valeur 3, persistée en base
Instance.IsVR Data/Instance.cs:35, DTOs/InstanceDTO.cs:16, InstanceController.cs:212 Activable par instance, exposé au DTO, modifiable par l'API
Migration 20250709151627_AddedApplicationInstanceAndMisc Colonne IsVR en base depuis juillet 2025
ApplicationInstance VR Controllers/ApplicationInstanceController.cs — CRUD complet + application-link Une instance VR se crée déjà par l'API, sans une ligne de code neuve
AppConfigurationLink Data/AppConfigurationLink.cs Porte déjà DeviceId (nullable) — c'est exactement le mécanisme « une config par appareil » du kiosk
Entité Device Data/Device.cs Identifier, Name, Connected, BatteryLevel, ConnectionLevel, AppVersion, LastSeen, InstanceId, ConfigurationId
DeviceController 7 endpoints (list, detail, create, update, mainInfos, delete) Multi-canal depuis le 2026-09-11 — résout l'ApplicationInstance sur appType, et Get filtre par canal
Push MQTT « config changée » DeviceController.cs:303 → topic player/{device.Id} Le mécanisme de rafraîchissement à distance est déjà là
Télémétrie Data/VisitEvent.cs:27public AppType AppType Un event VR se stocke sans aucun changement de schéma
Export de contenu GET /api/configuration/{id}/export (ConfigurationController.cs:388) Configuration + sections + ressources en un seul appel — le contrat idéal pour un renderer tiers
Auth app cliente GET /api/instance/appKeyByPin (InstanceController.cs:347) ApiKeyAppType.VrApp ajouté en fin d'enum le 2026-09-11

1.2 manager-app

Brique Emplacement État
AppType.VR côté client manager_api_new/lib/model/app_type.dart + app_type_extensions.dart Valeur 3 présente et nommée « VR »
Sous-menu « VR » lib/Screens/Main/main_screen.dart:65if (instance.isVR == true) L'entrée de menu apparaît déjà si l'instance a le canal activé
Écran VR main_screen.dart:698-705case 'vr' Text("TODO vr") — c'est littéralement le seul trou de la navigation
Écran de flotte à cloner lib/Screens/Kiosk_devices/ — 4 fichiers, 769 lignes kiosk_screen.dart (grille d'appareils), device_element.dart, dropDown_configuration.dart (assignation de config), change_device_info_modal.dart
Filtre stats par canal lib/Screens/Statistics/statistics_screen.dart:81, 177 Générique sur AppType.values, et statsChannelVR existe déjà en i18n — le canal VR apparaîtra tout seul dès le premier VisitEvent

1.3 Ce qui n'existe nulle part

  • Aucun projet Unity, aucun .unity, aucun package Meta XR dans les repos.
  • Aucun rendu 360° : pas de ResourceType.Panorama360 (planifié en V2, voir ../todo-features.md § Ressource 360°), pas de flag « vidéo equirectangulaire ».
  • Aucun Device non-tablette : la table est peuplée exclusivement par tablet-app.

2. Ce qu'il reste à faire

Lot XR-1 — Le Device devient multi-canal (backend) — 2-3 j FAIT le 2026-09-11

Aujourd'hui un appareil est une tablette : DeviceController.Create va chercher ApplicationInstances.FirstOrDefault(ai => ai.AppType == AppType.Tablet) en dur. Un casque enregistré par ce chemin serait rattaché à l'instance kiosk et apparaîtrait dans l'onglet Kiosk.

Fait le 2026-09-11. Migration 20260911135527_AddAppTypeToDevice, 224 tests au vert. ⚠️ Piège rencontré : dotnet ef génère defaultValue: 0 (= Mobile), pas 1. Laissé tel quel, le backfill sortait toutes les tablettes existantes de l'onglet Kiosk, qui filtre désormais sur AppType. Corrigé à la main dans la migration. Et le défaut n'est pas déclaré dans le modèle : HasDefaultValue rendrait la colonne ValueGeneratedOnAdd, donc read-only après insert — piège déjà documenté dans MyInfoMateDbContext pour les colonnes de quota de l'Instance, et qui aurait fait créer tous les casques en Tablet.

  • Device.AppType (colonne int, défaut Tablet = 1 pour que les lignes existantes ne bougent pas) + migration EF
  • DeviceDTO.appType / DeviceDetailDTO, mappé dans ToDTO / ToDetailDTO / FromDTODeviceDetailDTO expose au passage appVersion et lastSeen, colonnes en base jamais exposées
  • DeviceController.Create : résoudre l'ApplicationInstance sur newDevice.appType au lieu de AppType.Tablet
  • DeviceController.GetAll : paramètre de requête appType optionnel (sans lui, comportement actuel)
  • ApiKeyAppType : ajouter VrApp à la fin de l'enum ({ VisitApp, TabletApp, Other, VrApp }) — l'enum est persisté en int, ne jamais insérer au milieu
  • DeviceControllerTests : un cas « création d'un device VR » qui vérifie le rattachement à la bonne ApplicationInstance — plus le défaut Tablet, le 404 sans ApplicationInstance VR, et le filtre de Get
  • manager_api_new : appType dans device_dto.dart / device_detail_dto.dart, number3 (VrApp) dans api_key_app_type.dartédités à la main, la génération ne se relance pas sur ce repo

⚠️ Le champ Device.ConfigurationId est marqué [Required] et le commentaire du code le dit « OLD WAY → AppConfigurationLink ». Le casque doit passer par AppConfigurationLink, pas par ce champ ; on le remplit quand même pour ne pas casser la contrainte.

Lot XR-2 — Onglet XR dans manager-app — 4-5 j FAIT le 2026-09-12

C'est le lot le moins cher : l'écran kiosk est un patron directement réutilisable.

⚠️ Deux affirmations de ce lot étaient fausses, vérifiées dans le code le 2026-09-12 :

  1. « la seule différence de fond est l'AppType filtré » — non. La grille kiosk ne liste pas des Device : kiosk_screen.dart:57 appelle applicationInstanceGetAllApplicationLinkFromApplicationInstance, donc des AppConfigurationLink. Le filtre par canal est déjà implicite (l'ApplicationInstance VR ne porte que des liens VR) et le paramètre appType ajouté à /api/device par XR-1 ne sert pas à cet écran.
  2. Batterie / version / dernier vu ne sont pas dans DeviceDTO, seulement dans DeviceDetailDTO — donc un GET /api/device/{id}/detail par casque. Assumé : une flotte de bornes se compte en unités. À revoir si la flotte grossit (exposer les 3 champs dans ToDTO).

Et device_api.dart n'exposait pas appType : XR-1 n'avait mis à jour que les DTO et l'enum, pas l'API. Ajouté à la main.

  • lib/Screens/Vr_devices/vr_screen.dart — coquille à deux sous-onglets (patron _tabButton de guide_ia_screen.dart, pas un TabBar Material) : « Configuration » et « Casques »
  • Onglet Configuration : AppConfigurationLinkScreen réutilisé tel quel — il est déjà générique sur l'appType, c'est ce que font Mobile et Web. Zéro code neuf
  • Onglet Casques : vr_devices_tab.dart — pincode d'appairage en tête, grille, état connecté/déconnecté, carte « aucun casque appairé » pour un lien sans appareil
  • Réutiliser tel quel change_device_info_modal.dart et dropDown_configuration.dartdevice_element.dart non : sa carte est un Stack plein cadre où les trois lignes d'info du casque n'entrent pas. Carte dédiée, modal partagé (c'est lui, le gros morceau)
  • Brancher main_screen.dart:740 — le placeholder n'était plus Text("TODO vr") mais Text(vrAppNotConfigured), affiché même quand l'ApplicationInstance VR existait
  • Carte casque : batterie + appVersion + lastSeen
  • manager_api_new : appType sur deviceGet / deviceGetWithHttpInfoà la main, la génération OpenAPI ne se relance pas sur ce repo
  • i18n FR/EN/NL — DE n'existe pas dans manager-app/lib/l10n/ (3 langues, pas 4)

Rien à faire pour le sous-menu ni pour les stats : les deux sont déjà branchés sur isVR / AppType.VR.

Lot XR-3 — Contrat de contenu pour un client non-Flutter — 1-2 j

Unity ne peut pas consommer le client Dart généré. Deux options :

Option Coût Verdict
Unity appelle les endpoints REST un par un, DTO C# écrits à la main Élevé et récurrent — chaque évolution de section se répercute
Unity appelle GET /api/configuration/{id}/export (un appel, tout le contenu) et désérialise ExportConfigurationDTO Faible — le DTO d'export est déjà stable, c'est celui de l'import/export back-office retenu
  • Vérifier que Export répond bien à une clé d'API d'app (aujourd'hui utilisé depuis le back-office authentifié) Déjà fait, le plan était en retard sur le code (vérifié le 2026-09-11) : commit 9cc45c5 « Ouvrir l'export de configuration aux apps visiteur ». ConfigurationController.cs:396 est [AllowAnonymous] avec authentification X-Api-Key explicite et contrôle que la clé donne accès à cette instance (403 sinon). Le commentaire du code explique pourquoi [Authorize(AppReadAccess)] ne suffisait pas : ASP.NET combine les [Authorize] de la classe et de l'action, et le contrôleur exige ContentEditor
  • Décider si l'export multilingue (aujourd'hui ?language=) doit renvoyer toutes les langues d'un coup pour la VRtranché le 2026-09-12, et la réponse était déjà dans le code : language ne filtre que les ressources (les audios d'une langue), les textes partant toujours dans toutes leurs traductions. Omettre le paramètre rend donc toutGetReferencedResourceIds(null) renvoie les médias de toutes les langues. Le casque l'omet désormais : changer de langue à chaud ne demande aucun appel, et le cache disque en garde une copie au lieu d'une par langue
  • 🔴 Trou trouvé en vérifiant, corrigé le 2026-09-12 : l'export ne portait aucun champ spécifique de section. Section.ToDTO() n'est pas virtuelle, et l'export l'appelait sur une variable de type Section — donc une galerie sans ses images, une carte sans ses points, une maquette sans son modèle. Corrigé par SectionFactory.ToDTO + le pré-chargement des collections, et verrouillé par six tests. ⚠️ Ça ne concernait pas que la VR : l'export est ce qui part en visite hors ligne
  • Contrat d'export versionné — fait le 2026-09-12. exportVersion: 1 et generatedAt dans le JSON, et les règles de compatibilité écrites sur le DTO lui-même : on ajoute des champs, on n'en retire ni n'en renomme ; les valeurs d'enum vont en fin ; un champ absent vaut « pas de valeur ». Trois consommateurs en dépendent — la visite hors ligne, l'import/export du back-office, et l'app Unity qui lit ce JSON sans client généré. Un export sans numéro est antérieur au 12/09 et se lit comme une version 1

Lot XR-4 — L'app Unity + Meta XR SDK — 6-10 semaines (POC utile : 3-4 semaines)

Découpé en items le 2026-09-11. Le lot n'avait que des puces, donc rien d'assignable ni de chiffrable.

Le verrou est levé depuis le 2026-09-11. Unity 6000.0.83f1 est installé, le projet vit dans vr-app/unity/MyInfoMateVR/, et un APK signé tourne sur un Quest 2 en Horizon OS v207. E0 est fait, E1 l'est aussi à l'exception de l'Interaction SDK — ce qui suit n'est donc plus de l'estimation sur du code non compilé.

La piste Scène 3D (manifeste de scène, SectionScene3D, éditeur web), qui alimente E7, a son propre plan et sa propre numérotation S0-S8 : vr-app/docs/04-phase3-plan.md. Ne pas confondre ses S avec les E ci-dessous.

⚠️ Les scripts Unity, eux, parlaient encore E1/E2 en voulant dire S1/S2 — la doc avait été renommée le 11/09, le code non. Corrigé le 2026-09-12 : S1Bootstrap / S2Bootstrap (piste Scène 3D) et PairingBootstrap (E2-E3, canal VR).

# Item Dépend de Note
E0 FAIT le 2026-09-11. Unity 6000.0.83f1, projet URP MyInfoMateVR, APK sideloadé et lancé sur Quest 2 (Horizon OS v207) Premier build IL2CPP : 21 min, les suivants 2-3. Pièges rencontrés et leurs corrections : vr-app/unity-overlay/README.md
E1 🟡 Socle : URP , XR Plugin Management → OpenXR + Meta XR feature group , Meta XR Core SDK 205 reste l'Interaction SDK. ⚠️ Ce reste n'est pas du code : le raycast manette et le pincement de la main sont écrits et compilés (Menu/AimSelector.cs, via OVRInput et OVRHand du Core SDK, pas de l'Interaction SDK). Ce qui manque est un geste dans l'éditeur — importer le package et poser le building block Hand Tracking dans la scène ; sans lui le code retombe proprement sur la visée à la tête E0 ⚠️ Les API Meta bougent : prévoir des allers-retours. E5 en dépendfaux, levé le 2026-09-12 : le menu se pilote au regard, sans aucun SDK d'interaction. E1 n'est plus sur le chemin critique du POC
E2 🟡 Couche réseau C# — écrite le 2026-09-12, jamais essayée contre un vrai serveur. Net/ApiClient.cs (le seul endroit qui parle au backend, X-Api-Key en en-tête, erreurs rendues en phrases lisibles), Net/PairingService.cs (pincode → GET /api/instance/app-key?appType=VrAppPOST /api/device avec appType = 3), appairage persistant en PlayerPrefs. Compile contre les DLL Unity + Newtonsoft E0, XR-1 Le flux est celui de tablet-app/.../config_view.dart:274 + _fetchAppKey. ⚠️ Un 404 sur POST /api/device ne veut pas dire « introuvable » : il n'y a pas d'ApplicationInstance VR sur l'instance
E3 🟡 Lecture de l'export — écrite le 2026-09-12, jamais essayée. Net/ConfigurationExport.cs : un appel, désérialisation, sections racines triées, traduction par langue. Boot/PairingBootstrap.cs affiche le résultat dans le casque E2, XR-3 ⚠️ Confirmé dans le code : language traverse jusqu'à Section.GetReferencedResourceIds(language) — l'export ne rend qu'une langue, donc changer de langue à chaud = refaire l'appel. C'est la question ouverte de XR-3
E4 🟡 Cache disque + offline-first — écrit le 2026-09-12, jamais essayé. Net/ContentCache.cs (écriture atomique par fichier temporaire puis déplacement — une borne qu'on débranche le soir ne doit pas laisser un JSON tronqué ; médias nommés par hachage de l'URL, l'extension préservée pour le décodeur), Net/ContentService.cs (cache d'abord, rafraîchissement de fond dont l'échec est silencieux) E3 C'est l'argument n°1 d'Unity contre WebXR (§10) — donc l'item qui justifie le choix de stack. Complété le 2026-09-12 : Net/ContentPreloader.cs télécharge toute la visite en tâche de fond dès que le menu est affiché, un média à la fois, sans jamais faire attendre le visiteur. Le cache ne se remplissait qu'à la demande — une borne branchée hors ligne avait ses titres et pas une image, ce qui vidait l'argument de sa substance. Les liens externes (YouTube), les PDF et les JSON sont écartés : le casque ne les rend pas. L'audio d'un hotspot passe désormais par le cache lui aussi (HotspotInstance.cs), il était streamé depuis l'URL
E5 🟡 Menu flottant — écrit le 2026-09-12, jamais essayé. Menu/AimSelector.cs (trois moyens de viser : manette, main, tête), Menu/MenuItemPanel.cs (panneau + vignette + anneau de progression), Menu/FloatingMenu.cs (arc à 2,2 m, posé une fois et qui ne suit pas la tête), Boot/MenuBootstrap.cs (l'app : appairage → cache → menu → télémétrie) E1, E3 ⚠️ E1 n'est plus une dépendance — voir le §8bis. Le fond immersif est livré le 2026-09-12 : type ImmersiveBackground porté par la Configuration et par l'ApplicationInstance VR comme le prévoit le §4 de immersif-frontiere-plan.md, migration AddImmersiveBackground, sélecteur dans manager-app (le type est déduit de la ressource, jamais demandé), et Menu/ImmersiveBackdrop.cs côté casque. Sans lui le menu flottait dans le noir. ⚠️ Le repli 2D n'est pas décoratif : web, mobile et kiosk n'affichent aucun fond sans lui. Un fond de type Scene3D est accepté par le contrat mais pas encore rendu — il retombe sur le ciel par défaut, et le dit dans le journal
E6 🟡 Lecture 360° — écrite le 2026-09-12, jamais essayée. Menu/SkyboxView.cs : image et vidéo équirectangulaires dans le même shader Skybox/Panoramic, la vidéo via une RenderTexture 4096×2048 alimentée par un VideoPlayer qui lit depuis le cache disque. Le ciel précédent est restauré à la fermeture — c'est un réglage global, sinon le visiteur suivant hérite du décor du précédent. Le retour ne reste pas affiché : 4 s au début, puis il s'efface et revient quand le visiteur baisse les yeux ou prend une manette — un panneau planté en permanence rappellerait qu'on regarde une image, ce qu'on cherchait à faire oublier. ⚠️ Mémoire : LoadImage décode en RGBA32, soit 134 Mo pour une 8192×4096 — compressée en ASTC au chargement et libérée à la sortie, sans quoi l'app se fait tuer au bout de quelques 360 E5, ressource 360° ⚠️ Skybox/Panoramic doit être dans Always Included Shaders : construit au runtime, il n'est référencé par aucune scène, le build l'élimine, et le ciel sort magenta sur le casque et correct dans l'éditeur — exactement le piège déjà payé avec glTFast
E7 🟡 Maquette 3D et ses POI — écrit le 2026-09-12, jamais essayé. SectionModel3D côté backend (+ GeoPoint.Model3DPosition, migration), Model3DConfig dans manager-app (le viewer web en iframe, protocole postMessage déjà défini par bridge.ts), Menu/Model3DView.cs côté casque E5, SectionModel3D Fondations faites le 2026-09-11 dans la piste S : chargement GLB au runtime (glTFast, 50 Mo en 1,4 s sur Quest 2), conversion d'axes glTF→Unity prouvée au repère de calibration, hotspots écrits. ➡️ E7 n'a donc rien dessiné : il a branché la source de données sur un moteur qui existait. Model3DView construit un SceneManifest depuis la section et appelle SceneBuilder — les deux modèles avaient été pensés l'un pour l'autre, un SceneHotspot porte déjà un GeoPointId. Voir la frontière §4bis
E8 🟡 Slider, Map, Parcours et Event écrits le 2026-09-12, jamais essayésMenu/PagedView.cs (la vue commune : une chose à la fois, grande, devant soi, image de 1,6 m à 2,5 m, flèches, retour) et Menu/SectionPages.cs (ce qui distingue les trois types). Reste la Video 360, bloquée par la ressource 360° E5 Arbitrage des 13 types tranché au §8. ⚠️ Deux écarts assumés : la Map est rendue en liste de POI, pas en maquette — « le plan posé devant soi » demande SectionModel3D (§9), et une carte Google sur un panneau flottant serait moins bonne qu'un téléphone. Le Parcours est rendu comme une Map, pour la même raison. L'Event ne montre que la journée en cours, et retombe sur les dates suivantes si elle est vide — c'est l'usage « écran d'attente » du §8
E9 🟡 Télémétrie — écrite le 2026-09-12, jamais essayée. Net/Telemetry.cs, envoi « tire et oublie » E3 Les stats manager-app fonctionnent sans une ligne de plus : le filtre est déjà générique sur AppType.values. ⚠️ Deux pièges vérifiés dans StatsController : la route est POST /api/stats/event (pas /api/visitevent), et appType part en chaîne parsée par nom — une faute de frappe ne lève pas d'erreur, elle fait retomber sur Mobile et compte les visites du casque dans le canal mobile
E10 🟡 Session de borne écrite le 2026-09-12, jamais essayéeKiosk/KioskSession.cs : fin de visite à la repose du casque (le signal le plus fiable) ou après 90 s sans rien, retour au menu recentré devant le visiteur suivant, nouvelle session de statistiques, écran qui ne s'endort pas. ⚠️ Sans SDK Meta : la présence se lit par CommonUsages.userPresence d'Unity XR, donc rien à configurer dans la scène. Reste le verrouillage de l'app sur le casque, qui n'est pas du code — c'est le mode appareil de Meta E5 MDM inutile à 1-3 casques (risque n°3 rétrogradé) — sideload + mode appareil suffisent. ⚠️ Le recentrage du menu n'est pas un détail : le visiteur suivant n'arrive pas orienté comme le précédent, et un menu resté dans son dos est introuvable
E11 Distribution : Meta Horizon Store (review) ou canal privé / sideload E10 ⚠️ Les programmes business de Meta bougent vite : à revérifier avant de l'écrire dans une offre

Périmètre du POC (3-4 semaines) : E0 → E5 → E6, plus E2/E3 dont ils dépendent. Soit menu immersif + vidéo 360, et c'est exactement la démo. E7 et E8 sont la couverture, pas le POC.

Lot XR-5 — Supervision de la flotte — 🟡 l'essentiel écrit le 2026-09-12

  • Battement HTTP plutôt que MQTTPUT /api/device/{id}/heartbeat côté serveur (batterie, version ; le serveur pose lui-même Connected et LastSeen, puisque recevoir le battement est la preuve), Kiosk/FleetReporter.cs côté casque, toutes les 3 min et une fois au démarrage. À 1-3 casques, MQTT coûterait dix fois plus de code pour remonter trois chiffres ; il redeviendra utile pour pousser vers le casque (recharger une config à distance), pas pour remonter
  • Remontée batterie / version / dernier vu — c'est ce qui remplit l'onglet XR livré en XR-2, qui affichait « — » faute d'alimentation. ⚠️ Update n'écrivait ni AppVersion ni LastSeen : ces deux colonnes n'avaient aucun chemin d'écriture
  • 🔴 Bug de production trouvé au passage : POST /api/device répondait 403 à toute tablette depuis le 13/03/2026 (a452f4a, « need to be tested ») — la policy InstanceAdmin de la classe contre une clé d'API qui ne porte que AppRead/Viewer. Une tablette déjà appairée ne rappelle jamais cette route, donc seul un nouvel appareil échouait. Corrigé, carte kanban dédiée
  • Pousser une configuration vers le casque — fait le 2026-09-12, sans MQTT. La réponse au battement porte déjà la configuration assignée à l'appareil : le casque la compare, et si elle a changé il recharge et revient au menu, sans redémarrage. Coût : la latence du battement, trois minutes au pire. Ce que ça évite : une connexion permanente à tenir sur une borne, une bibliothèque MQTT dans le build IL2CPP, et un client de plus à reconnecter quand le wifi du musée tombe
  • Client MQTT (MQTTnet) et abonnement à player/{deviceId}le publish serveur existe déjà (DeviceController.cs:413). N'a de sens que le jour où trois minutes seront trop : tout est prêt côté serveur
  • Alerte « casque muet » — faite le 2026-09-12. Bandeau rouge sur la carte du casque après une heure sans battement, soit vingt battements manqués. ⚠️ Elle corrige un mensonge : la pastille « connecté » vient du dernier battement reçu, elle ne sait pas qu'il date d'hier

3. Estimation

Lot Charge Dépendances
XR-1 — Device multi-canal (backend) fait 11/09 2-3 j
XR-2 — Onglet XR manager-app fait 12/09 4-5 j XR-1
XR-3 — Contrat de contenu 1-2 j
Sous-total « back-office XR prêt » quasi clos — reste les 2 puces de XR-3
XR-4 — App Unity (POC : menu + vidéo 360 + une section) 3-4 sem. XR-1, XR-3, Ressource 360°
XR-4 — App Unity (couverture complète) 6-10 sem. idem
XR-5 — Supervision flotte 1-2 sem. XR-4
Total avec app complète ~3-4 mois d'un dev

Le découpage est volontaire : les 8-12 jours du back-office XR se tiennent debout tout seuls. Ils rendent le canal VR administrable et démontrable (flotte, assignation de config, stats) avant d'engager le vrai coût, qui est l'app Unity.


4. Risques

  1. Le contenu du CMS est 2D. C'est le risque produit majeur, pas un risque technique. Un Article affiché sur un panneau flottant en VR est moins bon qu'une tablette. La valeur du canal vient du contenu 360°, que très peu de clients possèdent. Sans un client pilote qui a déjà des vidéos 360°, le POC démontrera une régression.
  2. Nouvelle stack, zéro mutualisation. Unity/C# XR ne réutilise ni Flutter, ni le client API généré, ni myinfomate_layout. C'est un quatrième front à maintenir, pas une déclinaison.
  3. Économie du MDM. Rétrogradé le 2026-08-31. Un MDM XR (~15-20 €/casque/mois) n'est nécessaire qu'à l'échelle d'une flotte. À 1-3 casques, sideload adb + le mode appareil intégré suffisent. Ne redevient un sujet qu'au-delà d'une dizaine de casques. ⚠️ Les noms et l'état des programmes business de Meta bougent vite — à revérifier avant de l'écrire dans une offre.
  4. Exploitation en borne publique : hygiène des casques, sessions courtes, présence de personnel. C'est un frein opérationnel réel côté client, indépendant de la qualité de l'app.
  5. La landing promet déjà « Meta Quest — Bientôt » en 4 langues (myinfomate-landing, translations.ts, clé mode4Desc). L'écart annonce/réalité existe depuis un moment ; il se creuse tant que rien n'est livré.

5. Recommandation de séquencement

  1. ~XR-1 + XR-2 + XR-3 (2 semaines) — peu cher, sans risque, rend le canal administrable, ferme le TODO vr. XR-1 fait le 11/09, XR-2 fait le 12/09, XR-3 était déjà fait (l'export est ouvert aux apps depuis 9cc45c5). ➡️ Le canal VR est administrable. Ne restent que les deux puces documentaires de XR-3 (contrat d'export versionné, décision multilingue).
  2. Ressource 360° fait le 2026-09-12ResourceType.Image360 (11), Video360 (12) et Model3D (13) en fin d'enum, sans migration (la colonne est déjà integer), verrouillées par un test valeur par valeur. Côté manager : la pastille de type du sélecteur de médias devient cliquable pour déclarer une 360 — aucune extension ne le dit, un panorama est un .jpg comme un autre — le .glb est accepté et déduit tout seul, et i18n FR/EN/NL. ⚠️ Le vrai piège n'était pas l'enum, c'était la compression : ImageCompressor ramène toute image à 2560 px de côté long. Une équirectangulaire de 8192×4096 y perd les trois quarts de sa définition — invisible sur un écran, une bouillie dans un casque où elle couvre tout le champ de vision. Les trois types en sont exclus, sur les deux chemins d'upload. Et le prérequis bucket ne s'applique pas : manager-app pousse directement dans Firebase Storage puis enregistre l'URL, donc la 360 n'attend pas le chantier « le backend ne sait pas écrire dans le bucket ». L'ancienne route serveur /upload plafonne à 1,5 Mo, mais elle n'est plus le chemin utilisé.
  3. Décision go/no-go conditionnée à un client pilote avec du contenu 360° existant. Sans lui, ne pas démarrer XR-4.
  4. POC Unity (3-4 semaines) — menu immersif + vidéo 360 + une section riche.
  5. XR-4 complet et XR-5 seulement après validation du POC en conditions réelles.

§6 à §10 ajoutés le 2026-08-31, seconde passe. Tout ce qui suit est vérifié dans le code — c'est de l'analyse déjà faite, à ne pas refaire.


6. Pricing — add-on « Contenu immersif », à partir de 70 €/mois

Arbitré par Thomas le 2026-08-31. Les chiffres encore inscrits dans ../roadmap.md+30-50€/mois (L113) et +40€/mois dans le bundle (L148-149) — sont périmés et à réécrire.

Historique de la décision : l'assistant avait proposé un palier « Immersif » à 299 €/mois en remplacement de l'add-on. Arbitrage retenu : on garde un add-on, revalorisé de 40 à 70 €/mois et plus. Le raisonnement du palier est conservé ci-dessous parce que son principe structurant, lui, est validé — c'est le montant et le véhicule qui changent.

Le principe qui reste vrai : décorréler du casque

Un add-on « VR » ne se vend que le jour où l'app Unity existe et où le client achète un casque. Un add-on « Contenu immersif » vend le contenu — image 360, vidéo 360, modèles GLB — qui s'affiche déjà dans le web, le mobile et le kiosk. Le casque devient le haut de gamme de l'add-on, pas sa condition d'existence. Un client souscrit et en a pour son argent sans casque.

Pourquoi l'add-on bat le palier — le point que l'assistant avait manqué

Un add-on s'accroche à n'importe quel palier. Le palier à 299 € forçait tout le monde à passer par Premium ; l'add-on permet à un client Pro à 99 € de prendre du 360 sans payer l'assistant IA dont il ne veut pas → 169 € au lieu de 299 €. Marché adressable nettement plus large.

Sur le total, les deux modèles convergeaient de toute façon : Premium 179 + 70 = 249 €, soit exactement le plancher de négociation cité pour le palier. Le désaccord portait sur la structure, pas sur le montant.

Paliers de référence (source de vérité : myinfomate-landing, HomeClient.tsx:534,583 + segments.ts) : Essentiel 39 / Pro 99 / Premium 179 / Enterprise sur devis.

Ce que l'add-on débloque

Ressources image 360, vidéo 360, modèles GLB avec POI cliquables texte/audio — et l'app Quest quand elle sortira. L'assistant IA n'est pas dedans : il reste attaché à son propre add-on / au palier Premium.

⚠️ Le stockage — plus aigu en add-on qu'en palier

C'est le seul point de vigilance qui s'aggrave avec ce modèle. Un client Pro à 99 € a un quota dimensionné pour du Pro, et une vidéo 360 de 5 min en qualité correcte pèse des Go.

➡️ L'add-on doit porter sa propre enveloppe de stockage, explicite et finie, avec facturation du dépassement. Sans ça, le premier client 360 sur un petit palier fait sauter la marge.

Ce qui reste HORS de l'add-on

Exclusion Pourquoi
Modélisation 3D Coût studio, 3 à 15 k€, variable — reste sur devis
Hardware casque Achat client
Stockage au-delà de l'enveloppe Voir ci-dessus — plafond dur + facturation du dépassement

Onboarding one-shot : 1200-1500 € quand il y a des casques (provisioning, config borne, formation, journée sur site). Sans casque, l'add-on ne justifie pas de frais de mise en place particuliers.

Implémentation — une journée de backend

Vérifié dans le code, et c'est la bonne surprise. L'Instance recopie localement les valeurs du plan au lieu de les lire à travers : StorageQuotaBytes, AiTokensPerMonth, HasStats, StatsHistoryDays, HasAdvancedStats sont tous des colonnes de Instance (Data/Instance.cs:87-98). Le SubscriptionPlan n'est qu'un gabarit.

Et IsAssistant (Data/Instance.cs:37) est déjà exactement ce patron : un add-on = un bool sur l'Instance.

  • Instance.HasImmersiveContent (bool) + migration — fait le 2026-09-12 (Data/Instance.cs:52, migration AddScene3DSectionAndImmersiveAddon)
  • Exposition dans InstanceDTO + endpoint de mise à jour — fait le 2026-09-12 (InstanceController.cs:254, 3 tests dans InstanceAddonTests)
  • L'écran SuperAdmin — seul reste du lot. Aujourd'hui on active l'add-on en base à la main ; l'API sait le faire, personne ne peut le cliquer
  • Relèvement de StorageQuotaBytes à l'activation — pas fait, et c'est le point de marge du §« stockage » ci-dessus : activer l'add-on ne relève rien pour l'instant

➡️ Pas de nouvelle ligne de plan, pas de table de jointure, pas de refonte de la facturation.

Et ça tombe sur une carte déjà planifiée : « Écran SuperAdmin — add-on IA & canaux d'une instance » (kanban, colonne Planifié), qui dit explicitement « à faire d'un bloc avec l'endpoint de mise à jour d'ApplicationInstance : c'est le même écran ». L'add-on immersif y prend sa place sans rien ajouter au backlog.

⚠️ Réserve Stripe : le webhook ne gère que checkout.session.completed et invoice.payment_failed (Controllers/StripeWebhookController.cs:62-65), rien sur les lignes d'abonnement. Facturer l'add-on automatiquement demandera du travail. Pour 1-3 clients pilotes, une ligne ajoutée à la main dans Stripe suffit.


7. Assistant IA dans le casque — possible, et quasi gratuit

Vérifié dans le code. POST /api/Ai/chat (Controllers/AiController.cs:367) prend déjà un AppType dans sa requête (DTOs/AiChatDTO.cs:10) et se contente de vérifier que l'ApplicationInstance de cet AppType a IsAssistant = true (AiController.cs:387).

➡️ Créer l'ApplicationInstance VR avec le flag suffit. Zéro ligne de backend.

Deux nuances :

  • Pas de fallback pour VR. Le fallback sur Mobile n'existe que pour AppType.Voice (commentaire AiController.cs:387). Sans ApplicationInstance VR, c'est un 403.
  • Le TTS est côté client. L'instance ne stocke qu'un nom de voix Gemini (Data/Instance.cs:54), la synthèse est faite par l'app. Unity devra appeler Gemini TTS lui-même et gérer le micro pour le STT. L'architecture est prouvée par le POC Ray-Ban (11 implémentations de moteurs, branche Meta-Rayban-Test) mais c'est du Dart : réimplémentation en C#, pas un portage.

8. Types de section en VR — arbitrage tranché

Sur les 13 types existants :

Verdict Types Pourquoi
Nécessaire Menu C'est le hub flottant lui-même
Gagne vraiment Video (360 en skybox), Slider (galerie immersive), Map (le plan posé devant soi comme une maquette — c'est là qu'arrive le GLB), Parcours (chaque étape = un lieu autour de soi) La VR ajoute une dimension que l'écran n'a pas
Utile en borne Event / Agenda « Ce qui se passe aujourd'hui » en écran d'attente d'office de tourisme — c'est même l'usage principal
Correct Quiz Jouable au regard ou au contrôleur, bon pour l'engagement
À ne pas porter Article, PDF, Web, Weather, Game Un mur de texte flottant est moins bon qu'une tablette. Une WebView en VR est frustrante

Scope du POC : Menu + Video 360 + Slider + Map/GLB, avec Event en écran d'attente. Quatre types, et c'est exactement la démo.


8bis. Comment le visiteur désigne quelque chose — tranché le 2026-09-12

⚠️ « Au regard » ne veut pas dire eye tracking. Le Quest 2 n'a pas de suivi oculaire — seul le Quest Pro en a, et le Quest 3 ne l'a pas non plus. Ce que suit AimSelector, c'est la direction de la tête (head gaze) : le visiteur vise avec son nez. C'est la seule visée qui marche sur tous les casques, donc le repli garanti — et elle ne demande aucun SDK, juste la transformation de la caméra.

Les trois modes coexistent, par ordre de priorité :

Mode Déclenchement D'où vient l'API Pourquoi il est là
Manette Gâchette d'index OVRInput — Meta XR Core SDK, déjà installé Le plus rapide et le plus précis. Le mode de la démo et de l'installation surveillée
Main Pincement pouce-index OVRHand.PointerPoseCore SDK aussi Le plus naturel. Demande le building block Hand Tracking dans la scène ; absent, on retombe sans erreur
Tête Temporisation de 1,2 s Aucune — Camera.main Le repli universel. C'est le mode d'une borne publique : le visiteur met le casque et ça marche, sans rien dans les mains, sans rien à distribuer ni à recharger

La temporisation ne s'applique qu'à la tête. La manette et la main ont un geste explicite ; imposer 1,2 s à quelqu'un qui vient d'appuyer serait absurde. À la tête il n'y a rien pour valider, et sans attente tout ce que le visiteur regarde se déclenche, y compris ce qu'il ne fait que lire — c'est le « Midas touch », et l'anneau qui se remplit est ce qui rend l'attente supportable.

➡️ Conséquence sur le planning : la dépendance E5 → E1 du tableau était fausse. L'Interaction SDK apporte le confort (rayons stylés, poignées, poke), pas la capacité de sélectionner. E1 sort du chemin critique du POC.


9. POI sur modèle GLB — le modèle de données existe déjà à 80 %

La plus grosse économie du chantier, repérée le 2026-08-31.

GeoPoint (Data/SubSection/SectionMap.cs:85) porte déjà Title, Description, ImageResource, et surtout Contents — une List<Content> où chaque Content (Data/SubSection/Content.cs) est titre + description + Resource, donc audio. Le tout multilingue via TranslationDTO.

➡️ Un POI sur GLB, c'est le même objet avec une position locale (x,y,z) au lieu d'une lat/lon.

  • GeoPoint.Model3DPosition nullable (x,y,z + rotation Y) en jsonb, à côté du Geometry PostGIS existant
  • SectionScene3D référençant une ressource GLB — un vrai type de section, pas une Map augmentée. Une scène n'est pas une carte : elle n'a ni fond cartographique, ni zoom, ni fournisseur, ni coordonnées terrestres. Ce qu'on réutilise, c'est le GeoPoint — il portait déjà SectionMapId et SectionEventId, un troisième rattachement suit le patron
  • ⚠️ Nommé Scene3D et pas Model3D, décidé le 2026-09-12. Le discriminateur TPH est une chaîne persistée : renommer tant que rien n'est en production coûte zéro, renommer après la première maquette livrée coûte une migration de données. Les objets posés, les personnages et la navigation du §S4 viendront ici en colonnes nullables — pas dans un second type à faire cohabiter
  • Champ Mode : Asset ou Scene — l'opposition du §4bis, désormais dans la donnée. Asset : un objet posé devant le visiteur, qu'il manipule, les points tournent avec lui. Scene : un décor dans lequel il est, les points restent fixes. Même GLB, même modèle de point, même éditeur : ce qui change, c'est où l'on met le visiteur, et aucun fichier ne peut le deviner. Côté casque, le mode décide si le rig est passé au SceneBuilder (sans quoi le visiteur se retrouverait dans l'épée) et où le modèle est posé — 1,20 m de haut, 1,50 m devant, en mode objet
  • ResourceType.Model3D — en fin d'enum (fait avec la ressource 360°)
  • Éditeur de placement de POI en 3D dans manager-app — le seul morceau non trivialil n'était pas à écrire. vr-app/viewer sait déjà charger un GLB et déplacer des objets à la souris (étape S3), et son protocole postMessage existait déjà (bridge.ts), pensé pour ce cas : il parle en manifeste de scène, le même objet que lit le casque. Model3DConfig le monte en iframemanager-app le fait déjà trois fois (policy_screen, web_view, pdf_web_viewer) — lui envoie un manifeste et relit les positions
  • ⚠️ Le viewer n'a jamais tourné (S3 écrit, jamais essayé) et doit être servi sous le même domaine que le manager, ou passé par --dart-define=VIEWER_URL=… en développement

Le back-office réutilise l'éditeur de POI existant ; le multilingue et l'audio suivent gratuitement.


10. Unity ou WebXR — la question tranchée

Unity n'est PAS une nécessité technique

La distinction qui compte : le mode kiosk ne vient pas du moteur, il vient de la gestion d'appareil. Un MDM ou le mode appareil de Meta verrouille le casque sur n'importe quelle app installée — build Unity, APK Godot, ou navigateur épinglé sur une URL. Le choix du moteur est orthogonal au verrouillage kiosk.

Routes possibles vers un APK installable sur Quest : Unity + Meta XR SDK, Unreal, Godot 4 + OpenXR, ou du WebXR empaqueté (Meta supporte l'installation de PWA sur Horizon OS). ⚠️ Sur ce dernier point : hors date de connaissance de l'assistant, à revérifier avant tout engagement.

Ce qui plaide pour Unity — l'exploitation, pas la technique

  • Média offline : plusieurs Go de vidéo 360 en cache navigateur se heurtent aux quotas de stockage. En natif on écrit sur le disque.
  • Décodage vidéo : une 360 en 8K veut le décodeur matériel et le contrôle dessus.
  • Prévisibilitél'argument le plus fort : une borne tourne 8 h/jour sans personne. Un build Unity est figé ; le navigateur du casque se met à jour tout seul et peut casser la borne un mardi matin.
  • Accessoirement : MQTT en tâche de fond, hand tracking riche, porte du Horizon Store.

Ce qui plaide pour WebXR — à ne pas balayer

visitapp-web rend déjà les 13 types de section en React 19 / Next 16 (src/components/sections/, 13 fichiers, aucune dépendance 3D pour l'instant). Une couche WebXR par-dessus attaque frontalement le risque n°2 (« nouvelle stack, zéro mutualisation ») : c'est potentiellement un quatrième front en moins à maintenir.

Position retenue

WebXR pour la maquette et la spec visuelle, Unity pour la borne en production. Pas parce que le web ne peut pas, mais parce qu'une borne publique non surveillée pardonne mal. ➡️ À rouvrir si l'usage devient « le visiteur met le casque 5 minutes avec un agent à côté » — là, le web redevient le bon choix.

Ce que l'assistant peut et ne peut pas produire

Peut : le projet Unity complet — scripts C#, client API, script d'éditeur qui construit la scène par code (plutôt que du YAML .unity écrit à la main, illisible et cassant), manifest.json avec les packages Meta XR, menu flottant, swap de skybox 360, chargement GLB, raycast sur POI avec audio.

Ne peut plus servir d'excuse : Unity est installé depuis le 2026-09-11 (6000.0.83f1 + Hub), avec le module Android, un Quest 2 en mode développeur et un APK qui tourne. Compilation, build et rendu sur casque sont vérifiables. Ce paragraphe disait l'inverse jusqu'au 2026-09-11, sur une vérification du 2026-08-31. Reste vrai : les API du SDK Meta bougent entre versions. Les allers-retours annoncés ont bien eu lieu — résolution de packages, shaders éliminés au build, targetSdkVersion Horizon OS. Ils sont consignés dans vr-app/unity-overlay/README.md plutôt que redécouverts.

Pour voir le rendu, la maquette WebXR/three.js est le chemin court : visible sur écran ou dans le navigateur du Quest en immersif, itérable en secondes. Précédent dans le repo : la démo villa d'Echternach (visitapp-web/src/app/demo/villa-echternach/scene.ts), rendu procédural fait exactement pour montrer au client.