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

480 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](../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-1`…`V-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](../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.cs``enum 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:27``public 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:65``if (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-705``case '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](../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`.
- [x] `Device.AppType` (colonne `int`, **défaut `Tablet` = 1** pour que les lignes existantes ne bougent pas) + migration EF
- [x] `DeviceDTO.appType` / `DeviceDetailDTO`, mappé dans `ToDTO` / `ToDetailDTO` / `FromDTO`
`DeviceDetailDTO` expose au passage `appVersion` et `lastSeen`, colonnes en base jamais exposées
- [x] `DeviceController.Create` : résoudre l'`ApplicationInstance` sur `newDevice.appType` au lieu de `AppType.Tablet`
- [x] `DeviceController.GetAll` : paramètre de requête `appType` optionnel (sans lui, comportement actuel)
- [x] `ApiKeyAppType` : ajouter `VrApp` **à la fin** de l'enum (`{ VisitApp, TabletApp, Other, VrApp }`) — l'enum est persisté en int, ne jamais insérer au milieu
- [x] `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`
- [x] `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.
- [x] `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 »
- [x] 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
- [x] 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
- [x] Réutiliser tel quel `change_device_info_modal.dart` et `dropDown_configuration.dart`
`device_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)
- [x] Brancher `main_screen.dart:740` — le placeholder n'était plus `Text("TODO vr")` mais
`Text(vrAppNotConfigured)`, affiché même quand l'`ApplicationInstance` VR existait
- [x] Carte casque : batterie + `appVersion` + `lastSeen`
- [x] `manager_api_new` : `appType` sur `deviceGet` / `deviceGetWithHttpInfo`**à la main**,
la génération OpenAPI ne se relance pas sur ce repo
- [x] 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** |
- [x] ~~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`
- [x] ~~Décider si l'export multilingue (aujourd'hui `?language=`) doit renvoyer toutes les langues d'un coup pour la VR~~**tranché 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 tout**`GetReferencedResourceIds(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
- [x] 🔴 **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**
- [x] **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/`](../../vr-app/), 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`](../../vr-app/docs/04-phase3-plan.md). Ne pas confondre ses S<n>
> avec les E<n> 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`](../../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épend**~~**faux, 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=VrApp``POST /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](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és**`Menu/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ée**`Kiosk/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**
- [x] **Battement HTTP plutôt que MQTT**`PUT /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
- [x] **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
- [x] 🔴 **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
- [x] **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
- [x] **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-12**`ResourceType.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](../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`**.
- [x] `Instance.HasImmersiveContent` (bool) + migration — **fait le 2026-09-12** (`Data/Instance.cs:52`, migration `AddScene3DSectionAndImmersiveAddon`)
- [x] 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.PointerPose`**Core** 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**.
- [x] `GeoPoint.Model3DPosition` nullable (x,y,z + rotation Y) en jsonb, à côté du `Geometry` PostGIS existant
- [x] **`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
- [x] ⚠️ **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**
- [x] **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
- [x] `ResourceType.Model3D` — en fin d'enum (fait avec la ressource 360°)
- [x] ~~Éditeur de placement de POI en 3D dans manager-app — **le seul morceau non trivial**~~**il 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 **iframe**`manager-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`](../../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.