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