commit 0310d28b5e2898c51c46f40bab23b7dba3464867 Author: Thomas Fransolet Date: Wed Sep 16 15:26:07 2026 +0200 Canal VR Meta Quest : projet Unity, viewer web et documentation Premier commit du cinquième front. Trois parties : - `unity/MyInfoMateVR/` : le projet Unity (6000.0.83f1, URP, Meta XR SDK 205), un APK unique pour tous les clients. Menu flottant à sélection au regard, appairage, chargement de scène GLB, POI, cache de contenu, télémétrie. - `unity-overlay/` : les mêmes scripts à recopier sur un projet Unity neuf, avec les pièges rencontrés consignés dans son README. - `viewer/` : viewer et éditeur de scène web autonome (Vite, TypeScript, three.js), partagé avec les autres fronts. - `docs/` : état des lieux, setup Unity, décisions d'architecture et plan d'exécution en 9 étapes. La scène est décrite par un `scene.json` poussé par `adb push` : l'app le préfère à celui embarqué dans l'APK. Les binaires (GLB, textures de l'échantillon Sponza, DLL Meta XR) passent par Git LFS dès ce premier commit — les y faire entrer après coup demanderait de réécrire l'historique. Les artefacts régénérés par l'éditeur et par CMake (`Library/`, `.utmp/`, Burst debug) sont ignorés. Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b2f6822 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +*.so filter=lfs diff=lfs merge=lfs -text +*.fbx filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text +unity/MyInfoMateVR/Assets/StreamingAssets/**/*.png filter=lfs diff=lfs merge=lfs -text +unity/MyInfoMateVR/Assets/StreamingAssets/**/*.jpg filter=lfs diff=lfs merge=lfs -text +*.glb filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.dll filter=lfs diff=lfs merge=lfs -text +*.hdr filter=lfs diff=lfs merge=lfs -text +*.exr filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..240789a --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Unity — dossiers générés, jamais versionnés +unity/*/[Ll]ibrary/ +unity/*/[Tt]emp/ +unity/*/[Oo]bj/ +unity/*/[Bb]uild/ +unity/*/[Bb]uilds/ +unity/*/[Ll]ogs/ +unity/*/[Uu]serSettings/ +unity/*/[Mm]emoryCaptures/ +unity/*/[Rr]ecordings/ + +# Projets et solutions régénérés par l'éditeur +*.csproj +*.unityproj +*.sln +*.suo +*.user +*.userprefs +*.pidb +*.booproj +*.svd +*.pdb +*.mdb +*.opendb +*.VC.db + +# Artefacts de build Android +*.apk +*.aab +*.unitypackage +*.app + +# Crashlytics / logs Unity +sysinfo.txt +crashlytics-build.properties + +# OS +.DS_Store +Thumbs.db + +# Assets binaires lourds : passer par Git LFS AVANT le premier commit d'un binaire. +# Décommenter et faire `git lfs install && git lfs track "*.glb"` plutôt que d'ignorer. +# *.glb +# *.fbx +# *.mp4 + +# Viewer web — dependances et sorties de build +viewer/node_modules/ +viewer/dist/ +# Decodeurs Draco/KTX2 : copies de node_modules par `npm run libs` +viewer/public/libs/ +# Echantillon de scene servi par URL : copies de StreamingAssets, binaires lourds +viewer/public/sample/ + +# Artefacts de build Unity/CMake regeneres a chaque build +unity/*/.utmp/ +unity/*_BurstDebugInformation_DoNotShip/ +unity/*/*_BurstDebugInformation_DoNotShip/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..aefa27c --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# vr-app — canal VR MyInfoMate (Meta Quest) + +Repo du **cinquième front** de l'écosystème : l'application Unity qui tourne sur casque Meta Quest, +et toute la documentation du module VR (back-office compris). + +Nommage aligné sur les autres repos : `manager-app`, `tablet-app`, `visitapp-web`, `vr-app`. + +## Contenu + +| Chemin | Rôle | +|---|---| +| `docs/00-phase1-etat-des-lieux.md` | **À lire en premier.** Relevé du code existant des 4 repos, ce qui est réutilisable, ce qui manque, points de friction | +| `docs/01-phase0-setup-unity.md` | Guide d'installation Unity + Meta XR, pas à pas, pour quelqu'un qui n'a jamais ouvert Unity | +| `docs/02-decisions.md` | Les 6 arbitrages d'architecture du module (modèle de contenu, placement, viewer, kiosk, périmètre V1, personnages) | +| `docs/03-phase2-conception.md` | Manifeste de scène, modèle de données, contrat d'API, flux de génération, structure Unity et viewer web | +| `docs/04-phase3-plan.md` | **Le plan d'exécution.** 9 étapes, chacune avec ce que je génère / ce que tu fais / le critère de validation | +| `unity-overlay/` | **Les fichiers Unity que je génère**, à recopier sur le projet une fois créé par Hub. Voir son README | +| `tools/` | Générateur du repère de calibration (`make_calibration_glb.py`) | +| `unity/` | Le projet Unity lui-même — l'APK unique, tous clients | +| `viewer/` | **Le viewer et l'éditeur de scène web** (étape S3), autonome. Voir son README | + +## Où vit le reste + +- Décisions déjà arrêtées sur le canal VR (pricing, moteur, types de section, POI sur GLB) : + `../DOCS/v2/vr-quest-unity-plan.md` +- Infrastructure dont ce module dépend (crédits, jobs, fournisseurs IA, personnages) : + `../DOCS/v2/studio-plan.md` +- Kanban : `../DOCS/kanban/cards/5-planifie/320-*` et `330-*` + +## Statut + +**S0 terminé. S1 quasiment bouclé — 11/09/2026.** Le projet Unity existe (`unity/MyInfoMateVR/`, +Unity 6000.0.83f1, URP, Meta XR SDK 205), l'APK se construit, s'installe et tourne sur un Quest 2 +en Horizon OS v207. + +| Critère S1 | État | +|---|---| +| 1. Le monde s'affiche, on tourne la tête dedans | ✅ | +| 2. Le marqueur par coordonnées coïncide avec le repère du GLB | ✅ **aucun miroir — `GltfSpace.Axis = NegateX` confirmé** | +| 3. Personnage debout, à la bonne taille, animé, qui regarde le visiteur | ✅ | +| 4. Cercle de navigation infranchissable | ❌ **on franchit toujours le cercle** — mesure tête/rig corrigée, contrainte toujours sans effet. Non bloquant pour S2, à reprendre | + +### Les nombres que S1 devait produire + +Mesurés sur **Sponza** (50 Mo, glTF multi-fichiers), pas sur un monde Marble : + +| Mesure | Valeur | +|---|---| +| Chargement du décor | **1,4 à 1,8 s** — l'écran de progression est un détail, pas un sujet | +| Chargement du repère / du personnage | 33-248 ms / 54-96 ms | +| Images par seconde | **72/72 tenues**, mais `App=12,4 ms` sur 13,9 disponibles et **GPU au niveau maximum** | +| Poids réel d'un monde Marble | ❌ **toujours inconnu** — crédits Marble insuffisants | + +➡️ Lecture : un décor de 50 Mo bien texturé **sature déjà un Quest 2**. Le budget de scène devra +être défini, et la question KTX2 côté serveur reste ouverte tant que le poids d'un vrai monde +Marble n'est pas connu. + +Les assets de test sont des GLB libres (Khronos glTF Sample Assets : Sponza, CesiumMan) déposés +dans `StreamingAssets/` — **non commités**, jetables, et sans valeur pour la mesure de poids. + +Les pièges rencontrés et leurs corrections sont consignés dans +[unity-overlay/README.md](unity-overlay/README.md). + +### S2 — validé le 11/09/2026 + +| Livrable S2 | État | +|---|---| +| La scène est construite depuis `scene.json`, plus depuis du code | ✅ | +| Modifier le JSON et relancer **sans recompiler** déplace l'objet | ✅ manifeste poussé par `adb push`, lu en priorité sur celui de l'APK | +| Un `world.kind` non supporté affiche une phrase lisible, pas une scène vide | ✅ testé avec `"kind": "panorama"` | +| Un hotspot apparaît et joue son audio au raycast | ⏳ le `scene.json` d'exemple n'a pas d'audio | + +Le manifeste se pousse ainsi, l'app le préfère à celui embarqué : + +``` +adb push scene.json /sdcard/Android/data/com.unov.myinfomatevr/files/scene.json +``` + +Prochaine étape : **S3** de [04-phase3-plan.md](docs/04-phase3-plan.md) — le viewer web et son +éditeur. C'est le jalon « démo vendable », et le plan y place un go/no-go explicite. diff --git a/docs/00-phase1-etat-des-lieux.md b/docs/00-phase1-etat-des-lieux.md new file mode 100644 index 0000000..97cfa46 --- /dev/null +++ b/docs/00-phase1-etat-des-lieux.md @@ -0,0 +1,448 @@ +# Phase 1 — État des lieux du code existant + +> Relevé le 2026-09-03 **dans le code**, pas dans la doc. Chaque affirmation « existe / n'existe pas » +> a été vérifiée fichier par fichier sur `manager-service`, `manager-app`, `visitapp-web`, +> `mymuseum-visitapp`. +> +> Ce document ne refait pas l'analyse de `../../DOCS/v2/vr-quest-unity-plan.md` (canal VR : device, +> onglet flotte, pricing, arbitrage moteur) — il la **complète** sur ce que le nouveau périmètre +> ajoute : génération de mondes, manifeste de scène, personnages 3D, pipeline d'assets, preview web. + +--- + +## 0. La conclusion, d'abord + +**Trois quarts de ce que le module VR demande ne sont pas codés — mais presque tout est déjà +*spécifié*, dans le plan V2 (Studio, Personnages, TTS, 3D). Le module VR n'est pas un chantier +isolé : c'est le premier gros consommateur de ce plan, et il en hérite gratuitement s'il se branche +dessus au lieu de se construire à côté.** La table de dépendances est en §9 / F1. + +| Brique demandée | Existe en code | Existe en spec | Où | +|---|---|---|---| +| `AppType.VR`, `Instance.IsVR`, sous-menu, stats par canal | ✅ | — | §1 | +| Un appareil non-tablette (`Device.AppType`) | ❌ | ✅ | `vr-quest-unity-plan.md` lot V-1 | +| Contrat de contenu pour un client non-Flutter | ✅ (`/export`) | ✅ | §5 | +| Jobs asynchrones (Hangfire) | ✅ | — | §6 | +| Crédits, plafonds, coût estimé, journal | ❌ | ✅ | `studio-plan.md` §3.5 | +| Couche fournisseurs IA (→ World Labs Marble) | ❌ | ✅ | `studio-plan.md` §3.6 | +| Traçabilité IA, mention, droits au client | ❌ | ✅ | `studio-plan.md` §3.11 | +| Entité `Persona` | ❌ | ✅ | `studio-plan.md` §3.8 | +| Upload serveur dans le bucket | ❌ | ✅ | `studio-plan.md` lot 0 | +| `ResourceType.Model3D` / `Panorama360` | ❌ | ✅ | §2 | +| Manifeste de scène versionné | ❌ | ❌ | **à concevoir — phase 2** | +| Viewer 3D web (THREE.js) | ❌ | ❌ | **greenfield**, §7 | +| Zone de navigation, hotspots 3D, menu VR | ❌ | ❌ | **greenfield** | + +➡️ **Et l'inverse est vrai aussi** : le module VR ne rallonge pas le backlog V2, il en **réordonne** +le chemin critique. Trois chantiers qui pouvaient s'attendre (socle de stockage, personnages, TTS) +deviennent bloquants d'un quatrième. + +--- + +## 1. Types d'application (`appType`) + +### Ce qui existe + +```csharp +// manager-service/ManagerService/Data/ApplicationInstance.cs (fin de fichier) +public enum AppType { Mobile, Tablet, Web, VR, Voice } +``` + +`VR` **vaut 3 et est persisté en base**. Autour : + +| Brique | Emplacement vérifié | +|---|---| +| `Instance.IsVR` | `Data/Instance.cs:35` | +| `ApplicationInstance` unique par `(InstanceId, AppType)` | `Data/ApplicationInstance.cs` — index unique composite | +| Sous-menu « VR » dans le back-office | `manager-app/lib/Screens/Main/main_screen.dart:65` | +| L'écran derrière le sous-menu | `main_screen.dart:704` → **`Text("TODO vr")`** | +| Télémétrie par canal | `Data/VisitEvent.cs` porte `AppType` — un event VR se stocke sans migration | +| Assistant IA par canal | `POST /api/Ai/chat` prend un `AppType` et vérifie `IsAssistant` sur l'`ApplicationInstance` correspondante | + +### Ce qui manque + +1. **`Device.AppType`.** `Data/Device.cs` n'a pas de type de canal, et + `Controllers/DeviceController.cs:155` résout l'`ApplicationInstance` **en dur** sur + `ai.AppType == AppType.Tablet`. Un casque enregistré aujourd'hui atterrit dans l'onglet Kiosk. +2. **`ApiKeyAppType`** ne connaît que `{ VisitApp, TabletApp, Other }` + (`Data/ApiKeyAppType.cs`, 3 lignes). Il faut `VrApp` **en fin d'enum** — persisté en int. + +### Ce que ça implique + +Le canal existe déjà comme *dimension*. Ce qui n'existe pas, c'est **le contenu VR** : aujourd'hui +une `ApplicationInstance` VR pointerait vers des `Configuration` composées des 13 types de section +2D. Le nouveau périmètre (mondes, personnages placés, hotspots 3D) est un **type de contenu neuf**. +👉 Question d'architecture n°1, traitée en §9 / F2. + +--- + +## 2. Médiathèque + +### Modèle + +```csharp +// Data/Resource.cs +Resource { Id, Type(ResourceType, int), Label, InstanceId, Url, StoragePath, FileName, + SizeBytes, Width?, Height?, DateCreation, DateUpdate, + IncludeInAiKnowledge, AiIndexStatus, AiIndexMessage, AiIndexedAt, AiChunkCount } + +enum ResourceType { Image, Video, ImageUrl, VideoUrl, Audio, PDF, JSON, JSONUrl, + Word, PowerPoint, Text } // 0..10 — commentaire explicite : jamais au milieu +``` + +### Les cinq faits qui décident du pipeline d'assets + +1. **Aucune gestion de GLB, aucune gestion de 360.** Ni `Model3D`, ni `Panorama360`. La carte + kanban « Ressource 360° » (`5-planifie/250-*`) est encore en planifié ; `Model3D` est prévu au + lot 11 du Studio. +2. **L'upload est fait par le navigateur, en direct vers Firebase.** Le backend ne sait que + *supprimer* : `IResourceBlobService` n'expose que `IsConfigured` et `DeleteAsync` + (`Services/ResourceBlobService.cs:32-36`). ⚠️ **Conséquence directe** : un monde généré par + Marble, qui arrive par un webhook côté serveur, **n'a aujourd'hui aucun chemin pour entrer dans + le bucket**. C'est le lot 0 du Studio, prérequis dur. +3. **Un `.glb` déposé aujourd'hui produit une ressource cassée.** Le `switch` d'upload + (`manager-app/lib/Screens/Resources/resources_screen.dart:400-431`) n'a **pas de `default`** : + l'extension inconnue laisse `resourceDTO.type` à `null` et le mimeType à chaîne vide. +4. **Pas de versionnement, pas de hash, pas de variantes, pas de CDN, pas de miniatures.** Une + ressource est un fichier et une URL à jeton. Le manifeste VR aura besoin d'un **hash et d'un + poids par asset** — `SizeBytes` existe, le hash non. +5. **Le quota stockage est un `SUM(SizeBytes)` par instance**, contrôlé à l'upload avec un `413`. + Aucune notion d'enveloppe par add-on. Un monde Marble + textures pèse des centaines de Mo ; + une vidéo 360 pèse des Go. + +### État du chantier médiathèque + +La refonte V1 (facettes, index inverse « utilisée dans », panneau de détail) est **codée**, en +attente de test : `DOCS/kanban/cards/4-a-tester/100-mediatheque-codee-de-bout-en-bout*`. Le service +`ResourceUsageService.cs` existe côté serveur. Bon signe : le module VR arrive sur une médiathèque +fraîchement remise à plat. + +--- + +## 3. Personas + +### La bonne lecture : c'est une dépendance, pas une contradiction + +> « **Pas de nouveau catalogue à produire.** Réutilise les personas déjà définis dans le manager. » + +Les personas **sont définis** — dans `../../DOCS/v2/studio-plan.md` §3.8, arrêté le 2026-09-01. +Ils ne sont simplement **pas encore codés** : c'est le lot 7 du Studio, en V2 +(kanban `5-planifie/310-personnages-un-guide-adressable-n-narrateurs`). Le module VR ne doit donc +rien réinventer — il doit **attendre ou tirer** ce lot. Voir la table de dépendances en §9 / F1. + +Ce qui existe en base aujourd'hui, c'est **un** guide au singulier, en colonnes plates sur +l'`Instance` : + +``` +Data/Instance.cs + GuideName (l.46) + GuidePersonaPrompt (l.52) le comportement pour le LLM + GuideVoiceId (l.55) un nom de voix Gemini, en texte libre + GuideFallbackMessages (l.77) List +``` + +Pas de table `Personas`, pas de `PersonaView`, pas de `TtsVoice` — vérifié : aucun résultat pour +`class Persona|GenerationJob|CreditLedger|VisualIdentity` dans les `.cs`, et aucun `DbSet` +correspondant dans `Data/MyInfoMateDbContext.cs`. + +Deux codages en dur font foi aujourd'hui : +- les 2 voix : `manager-app/lib/Screens/GuideIa/guide_ia_screen.dart:19-20` +- l'intonation : `mymuseum-visitapp/lib/constants.dart:27` (`kGeminiTtsPrompt`) — **une constante de + build**, donc tous les personnages sonneraient pareil. + +### Ce qui est spécifié, et qui est exactement ce qu'il faut + +`../../DOCS/v2/studio-plan.md` §3.8 (arrêté le 2026-09-01) définit l'entité `Persona` : un seul +objet, trois facettes (`VoiceId` → il narre, `SystemPrompt` → il peut être guide, `WakewordId` → il +est adressable), plus `PersonaView` (canon en **jeu de vues** : Portrait / Profil / PlanLarge / Dos) +et une table `TtsVoice`. C'est le lot 7 du Studio — **0 % codé**. + +### Ce qui manque pour porter un persona en 3D + +`Persona` tel que spécifié est un personnage **2D** : un jeu d'images de référence pour un modèle +de génération d'images, plus une voix. **Aucun** champ d'incarnation 3D. + +| À ajouter | Pourquoi | +|---|---| +| `Persona.Model3DResourceId?` | le GLB du personnage | +| `Persona.IdleAnimationClip?` | nom du clip dans le GLB, ou clip par défaut | +| `Persona.ScaleMeters?` | un GLB de studio n'arrive jamais à l'échelle | +| Hauteur des yeux / ancrage du regard | pour qu'il regarde le visiteur sans lip sync | + +⚠️ **Et une question sans réponse dans le code** : rien ne produit un GLB de personnage. Le lot 11 +du Studio prévoit Meshy 6 / Tripo pour des **objets isolés** — pas des humanoïdes rigés avec un +idle. Voir F8. + +--- + +## 4. Modèle multilingue et audio + +### Multilingue + +Un seul motif, partout : `List { language, value }`, stocké en **jsonb**. +10 langues supportées (`FR NL EN DE IT ES PL CN AR UK`). **`LB` est absent des 4 repos** — bloquant +pour le projet luxembourgeois, prévu au lot 0 du Studio. + +Traduction assistée existante : `POST /api/Ai/translate`. + +➡️ Le manifeste de scène doit porter ses textes dans ce format exact. Aucune raison d'en inventer un. + +### Audio / TTS + +**Le TTS n'existe pas côté serveur.** Les seules occurrences de `Tts`/`TTS` dans les `.cs` sont +`Data/Instance.cs` (le nom de voix) et `DTOs/AiChatDTO.cs`. La synthèse est faite **par l'app +cliente**, en Dart (`GeminiTtsEngine`, `gemini-2.5-flash-preview-tts`). + +Conséquences : +- « Audio TTS pré-généré et validé » suppose `../../DOCS/v2/tts-pregenerated-plan.md`, qui est + **planifié, pas codé** (kanban `5-planifie/160-*`) et attend lui aussi le lot 0. +- Le pattern cible existe déjà : `SectionArticle.ArticleAudioIds` est un `List` + d'**ids de `Resource`**. Un hotspot VR doit copier ce pattern, pas en inventer un. +- **Chemin de repli sans dette** : si le TTS n'est pas prêt, la V1 VR se contente d'**audio uploadé + à la main** (`.mp3`, `ResourceType.Audio`, supporté de bout en bout aujourd'hui). Le champ ne + change pas quand le TTS arrive — c'est le même id de ressource. + +--- + +## 5. Publication, versionnement, récupération du contenu + +### Le contrat existant + +``` +GET /api/Configuration/{id}/export?language=fr + [Authorize(Policy = AppReadAccess)] ← ConfigurationController.cs:383 + → ExportConfigurationDTO (configuration + sections + ressources, un seul appel) +``` + +**Bonne nouvelle, et elle ferme une question ouverte du plan du 2026-08-31** : l'endpoint est décoré +`AppReadAccess`, donc **accessible par clé d'API d'app**, pas seulement depuis le back-office +authentifié. Unity peut l'appeler tel quel. + +Les ressources sont collectées par `section.GetReferencedResourceIds(language)`, implémenté sur les +13 sous-types. C'est aussi ce qui alimente le mode offline de `mymuseum-visitapp`. + +### Les trois trous, et ils comptent tous les trois + +1. **Il n'y a pas d'état « publié ».** `Data/Configuration.cs` n'a ni `IsPublished`, ni `Status`, ni + `Version` — 17 colonnes, aucune de cycle de vie. Une modification dans le back-office est + **immédiatement visible** par les clients. Le « appliquer au retour au menu, jamais à chaud » du + cahier des charges **n'a rien sur quoi s'appuyer**. `studio-plan.md` §7 le dit sans détour : + *« `Publié` est dérivé, pas stocké »*. +2. **Il n'y a pas de version incrémentée.** Le seul signal est `DateUpdate` (par entité), et + l'invalidation offline se fait par comparaison de dates. Un `SceneManifest.version` monotone est + **à créer**, il n'existe nulle part. +3. **L'export est mono-langue** (`?language=`). Un casque en borne change de langue à chaud et doit + avoir les 10 langues en mémoire. Soit N appels, soit un mode « toutes langues » à ajouter. + +### Push de rafraîchissement + +`DeviceController.cs:303` publie déjà sur le topic MQTT `player/{device.Id}` quand la config d'un +appareil change. Le mécanisme « le casque apprend qu'il y a du neuf » **existe côté serveur** ; il +n'a jamais eu d'autre client que `tablet-app`. + +--- + +## 6. Jobs asynchrones + +**Hangfire est installé et réellement utilisé** — la meilleure surprise pour le pipeline Marble. + +| Fait | Où | +|---|---| +| `Hangfire.AspNetCore` 1.8.17 + `Hangfire.PostgreSql` 1.20.10 | `ManagerService.csproj:26-27` | +| Dashboard avec filtre d'autorisation | `Security/HangfireDashboardAuthorizationFilter.cs` | +| Convention établie : **un job prend un id, jamais un objet** | `Services/IIngestionService.cs` | +| Déclenchement automatique à l'écriture d'une entité | `Data/SectionIndexingInterceptor.cs` (réindexation RAG) | +| Idempotence pensée (rejeu au redémarrage de conteneur) | commentaire `Data/MyInfoMateDbContext.cs:304` | +| Jobs planifiés / annulables | `Controllers/NotificationController.cs:98,157,202` — `HangfireJobId` stocké, `BackgroundJob.Delete` | +| Retry maîtrisé plutôt que le défaut à 10 | `Services/IngestionService.cs:18` | + +**Webhooks entrants** : un seul précédent, `Controllers/StripeWebhookController.cs`, avec +vérification de signature. C'est le patron à copier pour le webhook World Labs. + +**Ce qui manque** : aucune table de suivi de job métier (statut, progression, résultat) exposée au +front. `studio-plan.md` §3.7 la spécifie (`GenerationJob` + polling front à 2 s, pas de SSE — et +l'argument tient : aucun précédent SSE dans le backend). + +--- + +## 7. Preview web et rendu 3D côté clients + +**Il n'y a pas une ligne de 3D dans l'écosystème.** Vérifié : + +- `visitapp-web/package.json` : ni `three`, ni `@react-three/*`, ni `model-viewer`. Les seules + dépendances de rendu sont `leaflet` et `react-pdf`. +- La démo « villa d'Echternach » (`visitapp-web/src/app/demo/villa-echternach/scene.ts`) est de la + **géométrie procédurale rendue en Canvas 2D**, sans aucune dépendance — son propre en-tête le dit : + *« pas à charger un vrai modèle glTF — celui-là viendra d'un studio 3D »*. +- `manager-app` est en **Flutter Web** : aucun viewer 3D natif utilisable. Le viewer THREE.js devra + être une **page web embarquée en iframe** (`HtmlElementView`), pas un widget Flutter. À ne pas + découvrir en cours de route. + +➡️ Le viewer de preview est **entièrement greenfield** — et le fait qu'il doive être une app web +autonome nourrie par une URL de manifeste le rend, par construction, réutilisable dans +`visitapp-web`. Voir question Q2. + +--- + +## 8. Quotas, facturation, organisation + +| Sujet | Réalité | +|---|---| +| Organisation | `Instance` — c'est le tenant. `SubscriptionPlan` n'est qu'un **gabarit** : l'`Instance` **recopie localement** ses valeurs (`Data/Instance.cs:92-100` : `StorageQuotaBytes`, `AiTokensPerMonth`, `HasStats`, `StatsHistoryDays`, `HasAdvancedStats`) | +| Add-on | Le patron existe : `Instance.IsAssistant` (`Instance.cs:37`). Un add-on = un booléen sur l'instance + un relèvement de quota. **~1 jour** | +| Compteur d'usage IA | `AiTokensThisMonth` + `AiUsageMonthKey`, reset par comparaison de clé, `429` au dépassement, **incrément après l'appel** | +| Journal ligne à ligne | ❌ inexistant. Deux compteurs cumulés, aucune traçabilité par génération | +| Coût estimé avant lancement | ❌ inexistant | +| Plafond dur configurable | ❌ inexistant | +| Stripe | `StripeService`, `StripeWebhookController` — ⚠️ **2 événements seulement** (`checkout.session.completed`, `invoice.payment_failed`). Facturer un add-on automatiquement demande du travail ; ligne manuelle pour les pilotes | +| Audit | `AuditLog { EntityType, EntityId, Action, UserId, InstanceId, Timestamp, OldValues, NewValues }` — utilisable tel quel pour « qui a lancé quelle génération » | + +⚠️ **Le compteur actuel ne tient pas pour Marble.** `CheckQuota` avant / incrément après suffit à un +appel LLM bloquant de 2 s. Une génération de monde dure **5 min à 1 h** : dix lancements parallèles +passeraient les dix contrôles avant le premier débit. Il faut la **réservation en deux temps** +(`Reserve` à l'enqueue / `Charge` à la complétion / `Refund` à l'échec) déjà spécifiée dans +`studio-plan.md` §3.5. Ce n'est pas un raffinement : à **3 500 crédits (≈ 2,80 $) l'export mesh HQ** +et 1 500 le monde standard, c'est de l'argent réel et immédiat. + +--- + +## 9. Points de friction + +### F1. Le module VR est un consommateur du plan V2 — la table de dépendances + +**Le** point de séquencement, et il ne dit pas « ça n'existe pas » mais **« ça existe en spec, en +V2, et le module VR en dépend »**. Référence de haut niveau : `../../DOCS/STATUS.md` §5, qui +énumère déjà la V2 (Studio, Personnages, TTS pré-généré, kiosk web, VR) et pose le même prérequis +dur — *« le backend ne sait pas écrire dans le bucket »*. + +| Ce dont la VR a besoin | Spec | Kanban (`DOCS/kanban/cards/5-planifie/`) | Codé | +|---|---|---|---| +| Écrire un fichier dans le bucket côté serveur | `studio-plan.md` lot 0 | `300-socle-de-stockage-serveur-le-backend-ne-sait` | ❌ **prérequis dur** | +| Crédits, plafond, coût estimé, journal | `studio-plan.md` §3.5, lot 1 | `280-module-studio-generation-d-images` | ❌ | +| `IGenerationProvider` + catalogue de modèles en base | `studio-plan.md` §3.6, lot 3 | `280-*` | ❌ | +| Provenance IA / mention / droits au client | `studio-plan.md` §3.11, lot 6 | `280-*` | ❌ | +| `Persona` (+ `PersonaView`, `TtsVoice`) | `studio-plan.md` §3.8, lot 7 | `310-personnages-un-guide-adressable-n-narrateurs` | ❌ | +| `ResourceType.Model3D` + GLB en médiathèque | `studio-plan.md` §5, lot 11 | `280-*` (lot 11) | ❌ | +| Audio TTS pré-généré multilingue | `tts-pregenerated-plan.md` | `160-tts-pre-genere-audioguide-multilingue` | ❌ | +| Ressource 360° (`Panorama360`) | `todo-features.md` | `250-ressource-360-images-panoramiques` | ❌ | + +⚠️ **Le lot 0 est le goulot commun** : le Studio, le TTS pré-généré **et** la génération de mondes +Marble attendent tous la même chose — que `IResourceBlobService` sache écrire. ~1 jour. + +**Le module VR n'ajoute donc pas ces briques au backlog : il les rend urgentes.** Trois chantiers +V2 qui pouvaient s'attendre les uns les autres deviennent le chemin critique d'un quatrième. + +> ⚠️ **Nuancé par la décision D5** ([`02-decisions.md`](02-decisions.md)) : en **V1**, le monde et +> les personnages sont des GLB **uploadés**, pas générés. Aucune de ces dépendances n'est bloquante +> pour la V1 — la table ci-dessus décrit ce qui bloque la **V1.5** (génération intégrée). C'est +> précisément ce que D5 achète : découpler la livraison de l'app Quest du backlog V2. + +👉 **World Labs Marble n'est pas un fournisseur à part : c'est une implémentation de plus de +`IGenerationProvider`**, avec un `GenerationKind.World` et un `GenerationModel` en base +(`ProviderKey = "worldlabs"`, `ProviderModelId`, `CreditCost`). Le câbler à côté du Studio recréerait +crédits, jobs, webhooks et traçabilité en double — et contredirait la contrainte « ne jamais coder en +dur les noms de modèles ni les fournisseurs », que le catalogue en base résout déjà. + +**Mais** — et c'est ce qui sauve le planning — **la chaîne technique Unity ne dépend de rien de tout +ça.** Générer un monde à la main sur le site de Marble, télécharger le GLB, le charger dans Unity, +le voir dans le casque : **zéro backend**. Faisable cette semaine, et c'est l'étape 1 obligatoire. + +### F2. Deux modèles de contenu qui ne se sont pas encore rencontrés + +Le plan du 2026-08-31 dit : *« Unity appelle `GET /api/configuration/{id}/export` »* — donc la VR +rend **les 13 types de section existants**, en immersif. +Le nouveau cahier des charges décrit un **manifeste de scène** : monde de fond, objets GLB, personas +placés, hotspots, spawn, rayon de navigation. + +**Ce sont deux produits différents.** Les faire cohabiter sans arbitrage donnerait deux pipelines de +publication, deux caches offline, deux formats. + +| Option | Ce que ça donne | Coût | +|---|---|---| +| **A — Type de section neuf `SectionScene3D`** dans une `Configuration` existante | Le manifeste devient une **projection** de l'export existant. Une seule publication, un seul offline, stats gratuites. Et la scène devient affichable en web/mobile — le « décorrélé du casque » sur lequel repose le pricing de l'add-on | Le plus faible. **Recommandé** | +| B — Entité `VrScene` parallèle, manifeste autonome | Modèle propre, taillé 3D, sans compromis avec le CMS 2D | Publication, versionnement, offline, quotas, stats **à refaire** | +| C — Les deux | — | Non | + +➡️ Arbitrage proposé : **A**, avec le manifeste **généré** à partir de la section, jamais saisi. +Corollaire : `GeoPoint` devient l'objet POI unique — `vr-quest-unity-plan.md` §9 l'avait vu, il porte +déjà `Title`, `Description`, `ImageResource` et surtout `Contents` (titre + description + `Resource` +audio, multilingue). Il ne lui manque qu'un `(x, y, z)`. + +### F3. Le versionnement du manifeste n'a aucune fondation + +Pas d'état publié, pas de numéro de version, pas de hash d'asset (§5). Trois choses à créer — et +elles ne sont **pas** propres à la VR : un `Configuration.PublishedVersion` profiterait aussi à +l'offline de `mymuseum-visitapp`, qui invalide aujourd'hui à la date. + +### F4. Le serveur ne sait pas écrire dans son propre bucket + +Répété parce que c'est le blocage le plus bête et le plus dur : **le webhook Marble n'aura nulle part +où poser le GLB qu'il télécharge**. Lot 0 du Studio, ~1 jour, prérequis absolu de toute génération +serveur — y compris du TTS pré-généré, qui attend la même chose. + +### F5. Le pipeline KTX2 / Meshopt n'a pas d'exécutant + +Le cahier des charges demande KTX2 + Meshopt « dès le départ ». Or : +- la compression existante (`ImageCompressor`) tourne **dans le navigateur**, en Dart, sur des JPEG ; +- le serveur n'a aucun outil de traitement d'asset — `ImageHelper` en `System.Drawing` est du **code + mort**, il ne tourne pas sur `aspnet:8.0` Linux ; +- transcoder en KTX2 et compresser en Meshopt, c'est appeler des **binaires natifs** + (`toktx`/`basisu`, `gltf-transform`) : soit les embarquer dans l'image Docker, soit un service à part. + +👉 Recommandation pour un dev solo : **servir le GLB Marble tel quel en V1**, mesurer, et n'optimiser +qu'ensuite — **mais** poser dès maintenant dans le manifeste un bloc `variants` par asset, pour que +l'ajout du KTX2 ne casse pas le contrat. La contrainte du cahier des charges (« à câbler dès le +départ ») est honorée au niveau du **format**, pas de l'implémentation. + +### F6. Le viewer de preview ne peut pas être un widget Flutter + +Voir §7. Conséquence de conception : **le viewer doit être une app web autonome nourrie par une URL +de manifeste**, pas un composant. Ce qui le rend réutilisable dans `visitapp-web` — question Q2. + +### F7. Un `.glb` cassé à l'upload + +`resources_screen.dart:400-431` n'a pas de `default`. Petit bug, mais c'est le tout premier geste du +client dans le module (« j'uploade mon personnage »). + +### F8. Le personnage 3D n'a pas de source + +Ni catalogue existant (il n'existe pas), ni génération (Meshy/Tripo font des objets isolés, pas des +humanoïdes rigés avec animation). En V1 : bibliothèque externe ou studio 3D, sous licence explicite — +et c'est un sujet **droits** pour des institutions publiques, pas seulement un sujet technique. + +### F9. Traçabilité IA — l'exigence institutionnelle tombe pile + +`studio-plan.md` §3.11 spécifie déjà exactement ce que demande le §5 du cahier des charges : +`Resource.AiProvenance` jsonb avec `modelKey`, `providerModelId`, `providerRequestId`, +`effectivePrompt`, `referenceResourceIds`, `generatedAt`, `generatedByUserId`, `validatedByUserId`, +et `rightsHolder` **par défaut au nom de l'instance** — les droits au client, écrits dans la donnée, +pas seulement dans les CGU. Un monde généré doit y entrer, avec en plus ses **sources de capture** +(la vidéo / le panorama d'origine, eux-mêmes des `Resource`). + +--- + +## 10. Ce qui est réutilisable tel quel — la liste courte + +À garder sous les yeux en phase 2, pour ne rien réécrire : + +| Réutiliser | Au lieu de | +|---|---| +| `List` jsonb | inventer un format i18n de manifeste | +| `GeoPoint` + `Contents` + `Resource` audio | inventer un objet « hotspot » | +| `ArticleAudioIds: List` d'ids de ressource | inventer un stockage audio | +| `GET /configuration/{id}/export` + `GetReferencedResourceIds` | écrire N endpoints pour Unity | +| Hangfire + convention « un job prend un id » | une file maison | +| `StripeWebhookController` comme patron de webhook signé | improviser le webhook Marble | +| `Kiosk_devices/` (4 fichiers, 769 l.) | écrire l'écran de flotte de casques | +| `AppConfigurationLink.DeviceId` | inventer « une config par casque » | +| Le publish MQTT `player/{deviceId}` | inventer le push de rafraîchissement | +| `AuditLog` | inventer un journal d'action | +| `Instance.IsAssistant` comme patron d'add-on | une table de jointure de features | +| Le flux d'appairage par pincode de `tablet-app` | inventer le provisioning du casque | + +--- + +## 11. Ce qu'il reste à trancher avant la phase 2 + +Cinq questions, posées en restitution. Les deux bloquantes sont **F2** (type de section vs entité +parallèle — ça détermine tout le modèle de données) et le **mécanisme de placement des personas**. diff --git a/docs/01-phase0-setup-unity.md b/docs/01-phase0-setup-unity.md new file mode 100644 index 0000000..241a57a --- /dev/null +++ b/docs/01-phase0-setup-unity.md @@ -0,0 +1,271 @@ +# Phase 0 — Setup Unity pour Meta Quest, à partir de zéro + +> Écrit pour quelqu'un qui n'a **jamais ouvert Unity**. Chaque chemin de menu est donné en entier. +> +> ⚠️ **Unity n'est pas installé sur cette machine** (vérifié le 2026-08-31, toujours vrai). Je ne +> peux donc rien compiler ni tester : ce guide est vérifié sur la doc et l'expérience du SDK, pas +> par une exécution. **Compte 2 à 4 allers-retours** sur les versions de packages Meta, qui bougent +> vite d'une release à l'autre. +> +> ⚠️ Les numéros de version ci-dessous sont ceux que je connais. **Avant d'installer, ouvre la page +> de compatibilité Meta** (`developers.meta.com` → Unity → *Requirements*) et prends la version +> d'Unity qui y est marquée recommandée. Si elle diffère, c'est elle qui gagne, pas ce document. + +--- + +## Ce que je peux produire, et ce que tu dois cliquer + +| Je peux générer en fichier | Tu dois le faire à la main dans l'éditeur | +|---|---| +| `Packages/manifest.json` (la liste des packages) | Installer Unity Hub et Unity | +| Tous les scripts C# (`Assets/Scripts/**`) | Créer le projet (le template) | +| Un script d'éditeur qui **construit la scène par code** | Le premier clic sur *Build And Run* | +| `.gitignore` Unity | Activer le mode développeur sur le casque | +| Les fichiers de configuration texte lisibles (`.asmdef`, JSON) | Les cases de **Project Settings** (voir §5) | +| — | Les cases de **XR Plug-in Management** (§6) | + +**Pourquoi je ne génère pas les `ProjectSettings/*.asset` ni les `.unity`** : c'est du YAML Unity +avec des GUID et des `fileID` internes. Écrit à la main, ça produit un projet qui *s'ouvre* mais +dont un réglage sur deux est silencieusement faux — le pire des cas, parce que ça ne plante pas. +Les réglages se font à l'écran, une fois, en 15 minutes. La **scène**, en revanche, je peux la +construire par code via un script d'éditeur (`[MenuItem]`) : c'est lisible, versionnable et +rejouable. + +--- + +## 1. Installer Unity Hub et Unity + +1. Télécharge **Unity Hub** : `unity.com/download`. C'est le lanceur ; Unity lui-même s'installe + depuis lui. +2. Crée un compte Unity (gratuit) et prends la licence **Personal** dans le Hub : + *Hub → icône compte (haut droite) → Manage licenses → Add → Get a free personal license*. +3. Dans le Hub : **Installs → Install Editor**. +4. Choisis une version **LTS** — au moment d'écrire, la ligne recommandée par Meta est **Unity 6 + LTS (6000.0.x)**. Prends la dernière `6000.0.xxf1` de la liste LTS. + > Pourquoi LTS : support 2 ans, et surtout c'est la seule ligne que Meta teste. Une version + > `.a`/`.b` (alpha/beta) te fera perdre des journées sur des bugs qui ne sont pas les tiens. + +### Modules à cocher — c'est l'écran à ne pas rater + +À l'écran *Add modules*, coche : + +- ☑️ **Android Build Support** — et **déplie-le**, les deux sous-modules ne sont pas cochés par + défaut selon les versions : + - ☑️ **OpenJDK** + - ☑️ **Android SDK & NDK Tools** +- ☑️ *Documentation* (optionnel, pratique hors ligne) + +Ne coche pas iOS, WebGL, Linux : c'est des Go pour rien. + +> **Si tu oublies un sous-module** : *Hub → Installs → ⚙️ sur la version → Add modules*. Rien +> n'est perdu, mais Unity te dira « No Android module loaded » sans expliquer lequel manque. + +--- + +## 2. Créer le projet + +1. **Hub → Projects → New project**. +2. Template : **3D (Built-In Render Pipeline)** ou **Universal 3D (URP)** ? + → **URP**. Meta recommande URP pour Quest : le *Forward+* et le rendu mono-passe stéréo y sont + optimisés, et l'essentiel de la doc Meta récente le suppose. Prends **Universal 3D**. +3. Nom du projet : `MyInfoMateVR`. +4. Emplacement : `…/GITEA/vr-app/unity/`. +5. **Create project.** Premier lancement : 2 à 5 minutes. + +> Tu obtiens un dossier avec `Assets/`, `Packages/`, `ProjectSettings/`, `Library/`. +> **`Library/` ne se versionne jamais** — c'est du cache, plusieurs Go. Le `.gitignore` s'en charge. + +--- + +## 3. Le `.gitignore` (je le génère) + +Fichier à créer à la racine de `vr-app/` — voir `../.gitignore` une fois généré. L'essentiel : +`Library/`, `Temp/`, `Obj/`, `Build/`, `Logs/`, `UserSettings/`, `*.csproj`, `*.sln`. + +⚠️ **Git LFS** : les `.glb`, `.fbx`, `.png` et `.mp4` sont des binaires lourds. Si le repo doit un +jour contenir des assets, active LFS **avant** le premier commit d'un binaire — après, c'est une +réécriture d'historique. + +--- + +## 4. Les packages (je génère `Packages/manifest.json`) + +Trois familles : + +### 4.1 XR — installés par l'éditeur + +- `com.unity.xr.openxr` — **OpenXR Plugin**, la couche standard. +- `com.unity.xr.interaction.toolkit` — optionnel si tu utilises l'Interaction SDK de Meta. + +*Window → Package Manager → Unity Registry → chercher « OpenXR Plugin » → Install.* + +### 4.2 Meta XR SDK — le point qui bouge le plus + +Meta distribue son SDK de deux façons selon les versions : par l'**Asset Store** (« Meta XR +All-in-One SDK ») ou par un **registre npm** déclaré dans `manifest.json`. Les packages qui comptent : + +| Package | Rôle | +|---|---| +| `com.meta.xr.sdk.core` | **Meta XR Core SDK** — obligatoire : caméra, tracking, passthrough | +| `com.meta.xr.sdk.interaction.ovr` | **Interaction SDK** — raycast contrôleur *et* hand tracking | +| `com.meta.xr.sdk.all` | le méta-paquet qui tire tout (plus lourd) | + +**Ce que tu dois faire à la main la première fois** : +*Window → Asset Store → chercher « Meta XR All-in-One SDK » → Add to My Assets → Open in Unity → +Package Manager → My Assets → Download → Import.* + +Puis Unity affiche un assistant **Meta XR Project Setup Tool** (*Edit → Project Settings → Meta XR*) +qui liste les réglages non conformes avec un bouton **Fix** / **Fix All**. ➡️ **Utilise-le.** Il +applique la moitié du §5 tout seul, et il est à jour du SDK installé — donc plus fiable que ce +document. + +### 4.3 glTF — le cœur du pipeline + +Ajoutés par *Window → Package Manager → **+** (haut gauche) → Add package by name…* : + +| Nom exact à coller | Rôle | +|---|---| +| `com.unity.cloud.gltfast` | **glTFast** — chargement de `.glb` **au runtime** | +| `com.unity.cloud.ktx` | support **KTX2 / Basis Universal** des textures | +| `com.unity.cloud.draco` | géométrie compressée **Draco** | +| `com.unity.meshopt.decompress` | géométrie compressée **Meshopt** | + +> Les trois derniers sont **des dépendances optionnelles de glTFast** : sans eux, un GLB compressé +> se charge en erreur silencieuse (mesh vide, texture rose). C'est exactement le genre de panne qui +> coûte une demi-journée — installe-les tout de suite, même si les premiers GLB ne sont pas compressés. + +--- + +## 5. Configurer le build Android pour Quest — écran par écran + +> Fais d'abord tourner le **Meta XR Project Setup Tool** (§4.2), puis vérifie ce qui suit. Ce que +> l'outil corrige, tu n'as pas à le refaire. + +### 5.1 Passer la plateforme en Android + +*File → Build Profiles* (Unity 6 ; **File → Build Settings** avant Unity 6) +→ sélectionne **Android** → **Switch Platform**. +⏱️ Le premier switch réimporte tous les assets : **5 à 20 minutes**. C'est normal, ne l'interromps pas. + +Dans le même écran : **Texture Compression → ASTC**. + +### 5.2 Player Settings + +*Edit → Project Settings → Player*, onglet Android (l'icône robot) : + +| Section | Réglage | Valeur | +|---|---|---| +| *Other Settings → Rendering* | **Color Space** | **Linear** | +| | **Auto Graphics API** | ❌ décoché | +| | **Graphics APIs** | **Vulkan uniquement** — supprime `OpenGLES3` de la liste | +| | **Multithreaded Rendering** | ☑️ | +| *Other Settings → Identification* | **Package Name** | `be.unov.myinfomate.vr` (jamais le `com.DefaultCompany` par défaut) | +| | **Minimum API Level** | **32** (Android 12L) — exigé par Horizon OS récent | +| | **Target API Level** | la plus haute proposée (34+) | +| *Other Settings → Configuration* | **Scripting Backend** | **IL2CPP** (Mono ne produit pas d'APK Quest valide) | +| | **Target Architectures** | ☑️ **ARM64** seulement — décoche ARMv7 | +| | **Active Input Handling** | **Both** — le plus sûr : l'`OVRInput` de Meta est en legacy, le reste de l'écosystème en new Input System | +| *Resolution and Presentation* | **Default Orientation** | **Landscape Left** | +| *Publishing Settings* | **Custom Main Manifest** | ☑️ si tu dois ajouter des permissions à la main | + +### 5.3 Quality + +*Edit → Project Settings → Quality* : garde **un seul** niveau pour Android, et mets-y : +**Anti Aliasing = 4x Multi Sampling** (le MSAA est quasi gratuit sur le GPU mobile du Quest, et +c'est ce qui fait la différence entre « propre » et « ça scintille »), **Shadows = Hard Shadows Only** +ou aucune. + +--- + +## 6. XR Plug-in Management + +*Edit → Project Settings → **XR Plug-in Management*** : + +1. S'il propose **Install XR Plug-in Management**, clique. +2. Choisis l'onglet **Android** (l'icône robot) — ⚠️ **pas** l'onglet PC. C'est l'erreur n°1 : + tout configurer sur PC et obtenir un APK sans VR. +3. ☑️ **OpenXR** +4. Sous **OpenXR** (sous-entrée du menu de gauche), onglet Android : + - **Enabled Interaction Profiles** → **+** → **Oculus Touch Controller Profile** + - **OpenXR Feature Groups** → ☑️ **Meta Quest** (ou *Meta Quest Support* selon la version) +5. Un triangle jaune ⚠️ à côté d'un réglage est **cliquable** : il ouvre un correcteur automatique. + Aucun triangle ne doit rester avant de builder. + +--- + +## 7. Activer le mode développeur sur le casque + +À faire une seule fois, mais dans cet ordre — l'étape 1 est celle qu'on oublie : + +1. **Crée une organisation développeur** : `developers.meta.com` → connecte-toi avec le compte Meta + **du casque** → *Create organization*. Sans organisation, le toggle de l'étape 3 n'apparaît pas. +2. Application **Meta Horizon** sur le téléphone → **Devices** (ou *Appareils*) → sélectionne le + casque → assure-toi qu'il est **connecté**. +3. → **Headset settings** → **Developer mode** → ☑️. +4. **Redémarre le casque.** +5. Branche le casque en USB-C au PC. **Mets le casque sur la tête** : une popup *« Autoriser le + débogage USB ? »* apparaît **dans le casque**, pas sur le PC. Coche *Toujours autoriser* et + accepte. Tant que ce n'est pas fait, `adb devices` affiche `unauthorized`. +6. Vérifie côté PC : + ``` + adb devices + ``` + `adb` se trouve dans le SDK Android installé par Unity : + `C:\Program Files\Unity\Hub\Editor\\Editor\Data\PlaybackEngines\AndroidPlayer\SDK\platform-tools\` + Ajoute ce dossier au `PATH`, tu t'en serviras tous les jours. + +> **Alternative confortable** : **Meta Quest Developer Hub (MQDH)**, l'outil desktop de Meta. Il +> gère l'appairage, l'installation d'APK par glisser-déposer, la capture vidéo de ce que voit le +> casque (précieux pour montrer une démo à un client) et les logs. Recommandé. + +--- + +## 8. Hello World VR — le seul test qui compte + +**But** : voir une scène en stéréo dans le casque et bouger la tête. Rien d'autre. Tant que ça ne +marche pas, tout le reste est théorique. + +### Ce que tu fais à la main + +1. *File → New Scene → Basic (URP)* → sauvegarde en `Assets/Scenes/Hello.unity`. +2. Supprime la **Main Camera** de la hiérarchie (le rig VR apporte la sienne). +3. Ajoute le rig : *menu **Meta** → Tools → **Building Blocks*** → glisse **Camera Rig** dans la + scène. (Si le menu Meta n'existe pas, le SDK n'est pas importé — retour au §4.2.) +4. Clic droit dans la hiérarchie → *3D Object → Cube*, place-le à `Position (0, 1.5, 2)`, + `Scale (0.3, 0.3, 0.3)`. C'est ton repère visuel. +5. *File → Build Profiles* → vérifie que `Assets/Scenes/Hello.unity` est **dans la liste des scènes** + et cochée (bouton *Add Open Scenes* si elle n'y est pas). +6. Casque branché et autorisé → **Build And Run**. +7. Premier build : **10 à 30 minutes** (IL2CPP compile tout le code natif). Les suivants : 1 à 3 min. + +### Critère de validation + +Le cube est devant toi, en relief, et il reste **immobile dans l'espace** quand tu bouges la tête +(pas collé au regard). Si le cube suit ta tête, le rig n'est pas correctement en place. + +### Panne la plus fréquente + +| Symptôme | Cause | +|---|---| +| L'app s'ouvre en écran plat flottant, pas en VR | XR Plug-in Management configuré sur l'onglet **PC** au lieu d'**Android** (§6.2) | +| `adb: device unauthorized` | La popup d'autorisation n'a pas été acceptée **dans le casque** (§7.5) | +| Build échoue sur « No Android module » | Sous-modules OpenJDK / SDK-NDK non cochés (§1) | +| Écran noir au lancement | Graphics API : OpenGLES3 encore présent avant Vulkan (§5.2) | +| Tout est rose | Shader non compatible URP — l'objet vient d'un import Built-In | + +--- + +## 9. Étape suivante immédiate + +Une fois le cube visible, **la vraie étape 1 du projet** (cahier des charges §PHASE 3) : + +1. Un monde Marble généré **à la main** sur le site de World Labs, exporté en **mesh GLB**. +2. Ce GLB posé dans `Assets/StreamingAssets/` et chargé **au runtime par glTFast** — pas glissé dans + la scène comme un asset d'éditeur. C'est toute la différence : le chargement runtime est ce qui + permettra plus tard de télécharger un monde depuis le manager sans rebuild. +3. Un second GLB de personnage posé dedans, à l'échelle, avec un idle. +4. Vu dans le casque. + +Aucun backend n'est nécessaire pour ces quatre points — c'est ce qui les rend faisables tout de +suite, en parallèle de tout le reste. Les scripts C# de chargement, je les génère quand tu me dis +que le cube est passé. diff --git a/docs/02-decisions.md b/docs/02-decisions.md new file mode 100644 index 0000000..648b5a8 --- /dev/null +++ b/docs/02-decisions.md @@ -0,0 +1,192 @@ +# Décisions d'architecture du module VR + +> Arbitrées le 2026-09-03, à partir du relevé de code de +> [`00-phase1-etat-des-lieux.md`](00-phase1-etat-des-lieux.md). **Ne pas rouvrir sans raison +> technique forte.** Les décisions antérieures (Unity vs WebXR, pricing add-on, MDM, types de +> section) restent dans `../../DOCS/v2/vr-quest-unity-plan.md` §6 à §10. + +--- + +## D1 — Le contenu VR est un type de section, pas une entité parallèle + +**Retenu : `SectionScene3D`, dans une `Configuration` existante.** + +Le manifeste de scène est une **projection** de la section, générée par le serveur, jamais saisie à +la main. Conséquences, toutes acquises sans code neuf : + +| On hérite de | Parce que | +|---|---| +| La publication | c'est une section, elle part dans `/configuration/{id}/export` | +| L'offline | `GetReferencedResourceIds(language)` est implémenté par sous-type — on l'implémente une fois | +| Les stats par canal | `VisitEvent` porte déjà `AppType` | +| Le multilingue | `List` jsonb, comme le reste | +| Le quota stockage | `SUM(SizeBytes)` sur les `Resource` référencées | +| L'affichage web / mobile | une section est rendue par les 3 clients visiteurs — c'est ce qui rend l'add-on « contenu immersif » vendable **sans casque** | + +**Corollaire : `GeoPoint` est l'objet hotspot unique.** Il porte déjà `Title`, `Description`, +`ImageResource` et `Contents` (titre + description + `Resource` audio, multilingue). Il lui manque +une position locale `(x, y, z)` + rotation à côté de son `Geometry` PostGIS. Pas de nouvel objet POI. + +**Écarté** : une entité `VrScene` autonome — elle aurait obligé à réécrire publication, +versionnement, cache offline, quotas et stats. + +**Écarté aussi, pour la V1** : rendre les 13 types de section en immersif. Le §8 du plan du 31/08 +garde sa validité comme cap (Menu / Video 360 / Slider / Map gagnent vraiment en VR), mais ce n'est +pas le périmètre de la V1 : la V1 rend **une scène**, pas un CMS flottant. + +--- + +## D2 — Le placement se fait dans le viewer, au clic + +**Retenu : le viewer THREE.js est aussi l'éditeur de placement.** + +Clic dans la scène → raycast sur le sol → position. Poignée de rotation sur l'objet sélectionné, +panneau numérique à côté pour ajuster au centimètre. Pas d'éditeur 3D complet, pas de gizmo de +translation sur 3 axes. + +Coût marginal quasi nul : le viewer doit exister de toute façon pour la preview (§3.6 du cahier des +charges). C'est le seul mécanisme où le client **voit ce qu'il compose**. + +⚠️ **Ce que ça engage** : le viewer cesse d'être en lecture seule. Il lui faut un état d'édition, +un undo, et un enregistrement explicite. À ne pas sous-estimer — c'est la moitié de son coût. + +**Écartés** : la mini-carte 2D vue de dessus (il aurait fallu produire un rendu orthographique du +monde, et le client place à l'aveugle sur la hauteur) ; les ancrages prédéfinis (composition pauvre, +sensation de gabarit — mauvais en démo commerciale). + +--- + +## D3 — Un seul viewer, app web autonome, partagé par les quatre fronts + +**Retenu : le viewer est une application web autonome nourrie par une URL de manifeste.** +Précisé le 2026-09-03 : il ne sert pas deux fronts mais **quatre**. + +| Front | Comment il l'héberge | Mode | +|---|---|---| +| `manager-app` (Flutter Web) | **iframe** (`HtmlElementView`) + `postMessage` | édition | +| `visitapp-web` (Next.js) | composant, ou iframe si plus simple | lecture | +| `mymuseum-visitapp` (Flutter mobile) | **WebView** servant le viewer **empaqueté dans l'app** | lecture, **hors ligne** | +| `vr-app` (Unity/Quest) | ❌ pas concerné — Unity a son propre rendu natif | — | + +**Un seul moteur de rendu 3D pour tout l'écosystème.** C'est ce qui attaque de front le risque +n°2 du plan d'août (« nouvelle stack, zéro mutualisation ») : le rendu 3D n'est pas un quatrième +front, c'est **un composant partagé**. + +> Sur Flutter mobile, il n'y a pas d'alternative sérieuse : `model_viewer_plus`, la solution +> habituelle, **est elle-même un WebView** autour de `` de Google. Autant que ce soit +> notre viewer dans ce WebView, avec nos hotspots et notre manifeste, qu'un composant tiers qu'on +> ne contrôle pas. + +### L'offline mobile : souhaitable, pas bloquant — arbitré le 2026-09-03 + +`mymuseum-visitapp` est offline-first, et un WebView pointant sur une URL distante casserait ça. +**Thomas a tranché : si la 3D hors ligne s'avère trop coûteuse, on l'assume et on le dit au client +dans manager-app.** C'est acceptable, et surtout **c'est déjà la convention de la maison** : + +- `SectionVideo.GetReferencedResourceIds` (`SectionVideo.cs:25-27`) **exclut explicitement** une + vidéo dont la source est une URL `http` — elle ne part pas hors ligne ; +- `SectionWeb` ne collecte que sa vignette. + +Une scène 3D non disponible hors ligne serait donc **le troisième cas d'une règle existante**, pas +une exception. Rien à inventer, juste à signaler dans l'éditeur. + +**Ceci dit, c'est probablement faisable**, et le coût n'est pas dans le rendu mais dans la plomberie : +viewer empaqueté dans les assets Flutter, servi depuis une origine locale, GLB téléchargés dans le +dossier documents, URL du manifeste réécrites en chemins locaux. Le pipeline offline fait **déjà** +tout ça pour les médias 2D. À évaluer au moment du portage mobile, pas maintenant. + +### ⚠️ Les trois règles à poser dès la V1 — indépendamment de l'offline + +Elles ne coûtent rien maintenant, elles coûtent une réécriture plus tard, et **elles sont de toute +façon du bon design** : un viewer qui ne sait pas d'où viennent ses données est un viewer réutilisable. + +1. **Le viewer est un jeu de fichiers statiques**, embarquable dans les assets d'une app Flutter et + servable depuis une origine locale. Pas de build qui suppose un serveur. +2. **Aucune URL d'API en dur dans le viewer.** Il ne connaît que le manifeste qu'on lui donne — + toutes les URL d'assets viennent de `assets[].url`. C'est ce qui permet de lui passer un + manifeste dont les URL ont été **réécrites en chemins locaux** par l'app, exactement comme le + fait déjà le pipeline offline pour les médias. +3. **Le manifeste est une entrée, pas un fetch.** Le viewer accepte un manifeste **injecté** + (`postMessage` / paramètre), pas seulement une URL à charger. + +### Ce que ça débloque au passage + +Le 360 et le GLB s'affichent alors **en web et en mobile**, pas seulement au casque — c'est +exactement la part de l'add-on « contenu immersif » qui se vend **sans casque**, et sur laquelle +repose son pricing (`vr-quest-unity-plan.md` §6). + +⚠️ **Rappel de contrat** : le viewer est un **aperçu indicatif** — placement, échelle, composition. +Pas un rendu final. Alignement à tenir : même tone mapping, même HDRI d'environnement, PBR core glTF +uniquement. La validation réelle passe par une publication sur canal de test et un essai au casque. + +--- + +## D4 — La borne se réinitialise à la repose du casque + +**Retenu : détection du retrait (capteur de proximité) → fondu au noir, reset de la scène, retour au +menu de sélection dans la langue par défaut.** + +Précédent direct dans l'écosystème : le retour à l'accueil de la borne tablette après 5 min +(`DOCS/kanban/done/140-*`). Un agent n'a rien à faire, ce qui est cohérent avec l'argument qui avait +fait retenir Unity — l'exploitation sans surveillance. + +Le timeout d'inactivité reste utile en **filet secondaire** (casque posé sans être retiré +proprement), pas comme mécanisme principal. + +--- + +## D5 — La V1 consomme des mondes uploadés ; la génération vient juste après + +**Retenu : upload manuel d'abord, génération intégrée ensuite.** + +En V1, le monde est un **GLB uploadé dans la médiathèque** — produit hors ligne, sur le site de +World Labs, par nous, pour les clients pilotes. Le module VR devient alors **indépendant du socle +Studio** et livrable seul, sans attendre les lots 0 / 1 / 3. + +La génération in-app (le bouton « Générer un monde » du §3.1 du cahier des charges) arrive quand ces +lots existent, **sans rien casser** : au bout du pipeline, c'est la même `Resource` GLB référencée +par la même section. La seule chose à faire dès maintenant, c'est de ne rien coder qui empêche cette +greffe — donc : + +- le monde est **toujours** un `ResourceId`, jamais un chemin ou une URL en dur ; +- `Resource.AiProvenance` est prévu dans le manifeste **dès la V1**, même vide ; +- Marble n'est jamais appelé depuis le code VR : le jour venu, ce sera un `IGenerationProvider` + de plus (`ProviderKey = "worldlabs"`, `GenerationKind.World`), pas un client HTTP maison. + +**Écarté** : la génération dès la V1 (elle mettait plusieurs semaines de backend V2 devant la +première scène visible) et le compromis « lot 0 seul + appel Marble en dur » (il recrée exactement +la dette que `studio-plan.md` cherche à éviter : modèle codé en dur, pas de journal, pas de +réservation de crédits). + +--- + +## D6 — Personnages 3D : catalogue externe sous licence, mais branché sur la médiathèque dès le jour 1 + +**Retenu : une petite bibliothèque de personnages stylisés sous licence** (modèles type +Quaternius / Kenney ou un pack payant, animations idle type Mixamo), livrée comme catalogue de +départ. Le client choisit, il ne modélise pas. + +⚠️ **Condition posée par Thomas, et elle est structurante** : *« il faut quand même prévoir vite +d'intégrer ce qu'on génère dans le Studio »*. Donc **le catalogue n'est pas une liste en dur dans +l'APK**. Dès la V1 : + +- un personnage 3D est un **`Resource` de type `Model3D` dans la médiathèque**, comme n'importe quel + autre asset — le catalogue de départ est juste un **jeu de ressources pré-chargées**, marquées + comme telles ; +- `Persona.Model3DResourceId` pointe dessus. Le jour où le Studio produit un GLB, il **remplace la + valeur du champ**, il ne demande aucune refonte ; +- la licence de chaque modèle du catalogue de départ est tracée dans `AiProvenance.rightsHolder` + (ou son équivalent pour un asset non généré) — sujet **droits**, pas seulement technique, pour des + institutions publiques. + +**Écartés** : le studio 3D sur devis en V1 (aucun pilote ne pourrait composer une scène sans payer +d'abord une prestation) et l'absence de personnage en V1 (c'est l'élément le plus démonstratif). + +--- + +## Ce qui reste ouvert + +| Sujet | Où c'est décrit | Ce qui manque pour trancher | +|---|---|---| +| **KTX2 / Meshopt : où s'exécute la transformation ?** | état des lieux F5 | Binaires natifs dans l'image Docker vs GLB servi tel quel en V1. **Défaut retenu faute d'objection : tel quel en V1**, avec le bloc `variants` par asset posé dans le manifeste dès le départ pour que l'ajout n'y touche pas. À rouvrir dès qu'on aura un vrai poids de monde Marble mesuré | +| **Go/no-go client pilote** | `vr-quest-unity-plan.md` §5 | Décision produit, inchangée : ne pas engager l'app Unity **complète** sans un lieu pilote identifié. D5 réduit l'enjeu — la chaîne technique se prouve sans client | diff --git a/docs/03-phase2-conception.md b/docs/03-phase2-conception.md new file mode 100644 index 0000000..993604a --- /dev/null +++ b/docs/03-phase2-conception.md @@ -0,0 +1,644 @@ +# Phase 2 — Conception + +> Écrit le 2026-09-03, à partir de l'état des lieux ([00](00-phase1-etat-des-lieux.md)) et des +> décisions D1 à D6 ([02](02-decisions.md)). +> +> **Ce document ne modifie aucun code.** Les schémas, colonnes et signatures ci-dessous sont des +> propositions à lire et à critiquer. Aucune migration EF n'est générée, aucune classe n'est créée. + +--- + +## 1. Le principe, et sa conséquence unique + +**Le client capture, compose, publie. Il ne modélise rien, ne code rien, ne rebuild rien.** + +Traduit en règle d'architecture, une seule ligne à laquelle tout le reste obéit : + +> **Un rebuild d'APK n'est nécessaire que si j'ajoute une fonctionnalité. Jamais si un client change +> son contenu.** + +Test de validation permanent : *si la réponse à « il faut rebuilder ? » est oui pour un changement +de contenu, la conception est fausse.* Concrètement, ça interdit trois choses dans le projet Unity : + +| Interdit | Pourquoi | +|---|---| +| Un asset de contenu dans `Assets/` | il serait dans l'APK, donc figé au build | +| Une scène `.unity` par client ou par monde | idem, et ça multiplie les builds | +| Une liste en dur de personnages, de mondes, de langues | c'est de la donnée, elle vient du manifeste | + +Tout le contenu passe par **une seule porte : le manifeste de scène**, et tout ce qu'il référence est +téléchargé au runtime. + +--- + +## 2. Le manifeste de scène — le contrat central + +C'est la pièce dont dépendent les trois consommateurs : l'app Quest, le viewer web, le back-office. +Il est **généré par le serveur**, jamais saisi à la main. + +### 2.0 Le format est déjà commun aux deux stacks — ne pas confondre avec la convention + +**glTF / GLB *est* le format qui marche des deux côtés.** Le même `.glb`, octet pour octet, est lu +par three.js (`GLTFLoader`) et par Unity (`glTFast`). Il n'y a **ni conversion de fichier, ni export +parallèle, ni format Unity séparé**. C'est précisément pour ça qu'il a été retenu comme format pivot. + +Et ça vaut aussi pour les optimisations, parce que ce sont des **extensions glTF standard**, pas des +formats concurrents : + +| Extension | three.js | Unity | +|---|---|---| +| `KHR_texture_basisu` (KTX2) | `KTX2Loader` | `com.unity.cloud.ktx` | +| `KHR_draco_mesh_compression` | `DRACOLoader` | `com.unity.cloud.draco` | +| `EXT_meshopt_compression` | `MeshoptDecoder` | `com.unity.meshopt.decompress` | +| `KHR_materials_*` (PBR core) | natif | natif | + +➡️ Le pipeline est **un seul fichier, deux lecteurs**. Un monde généré est vu dans le viewer du +back-office, puis publié sur le canal VR, sans transformation entre les deux. + +**La seule chose qui n'a pas de format commun, ce sont les splats gaussiens** — c'est exactement +pourquoi `world.kind` existe (§2.2) : la V1 ne lit que `"mesh"` et refuse le reste explicitement. + +> ⚠️ **Ne pas confondre avec le §2.1 qui suit.** Le format des *assets* est réglé et partagé. Le +> §2.1 traite d'autre chose, beaucoup plus petit : la convention d'axes de **mes propres +> coordonnées de placement** dans le manifeste — de la métadonnée, pas de la géométrie. Une +> vingtaine de lignes de code, pas un choix de pipeline. + +### 2.1 Convention d'axes du manifeste — la partie qu'on ne peut pas se permettre de rater + +| Règle | Valeur | +|---|---| +| Système de coordonnées | **glTF : main droite, Y-up, unités en mètres** | +| Origine | le **point de spawn** du visiteur, au sol (y = 0) | +| Rotation | **quaternion `[x, y, z, w]`** — non ambigu, pas d'ordre d'axes à deviner | +| Échelle | `[x, y, z]`, défaut `[1, 1, 1]` | +| Angles | jamais de degrés dans le manifeste | + +**Pourquoi glTF et pas Unity.** C'est la convention des assets eux-mêmes, **et** celle de three.js — +qui est nativement main droite Y-up. Donc : + +- le **viewer web ne convertit rien** : il édite et affiche dans l'espace du manifeste ; +- le **placement produit par le client est déjà dans le bon repère**, puisqu'il est produit dans le + viewer (décision D2) ; +- **Unity est le seul convertisseur**, à un seul endroit du code. + +C'est exactement la contrainte posée au départ : *« conversion à l'entrée d'Unity uniquement »*. + +### L'argument décisif — et ce n'est pas celui auquel on pense + +La tentation, c'est de stocker en convention **Unity** pour épargner la conversion au client le plus +contraint. **Ça ne supprime rien** : le GLB du monde est de toute façon converti par l'importeur. +On déplacerait donc juste le désaccord — coordonnées brutes contre géométrie convertie, même risque +de miroir, dans l'autre sens. + +Ce qui départage les deux options n'est donc pas le nombre de conversions, mais **où une erreur de +signe fait mal** : + +| Convention du manifeste | Où vit la conversion | Conséquence d'une erreur de signe | +|---|---|---| +| **glTF** (retenu) | dans Unity, en lecture | **bug d'affichage.** Une ligne à corriger, aucune donnée à reprendre | +| Unity | dans le viewer, en **lecture et écriture** | **corruption de données.** Le client a placé 40 objets, ils sont stockés faux, et la correction demande une migration | + +Le viewer est l'endroit où la donnée est **écrite**. On n'y met pas la conversion. + +Et il y a un bénéfice pratique qui achève la question : dans le viewer, le monde est chargé par +three.js **sans conversion**. Le client place donc ses objets **dans le même espace que celui où il +voit le monde** — la cohérence n'est pas garantie par du code, elle est vraie par construction. + +⚠️ **Le piège, et il est réel.** Unity est **main gauche**. glTFast applique une conversion à la +géométrie qu'il importe ; mes coordonnées de manifeste, elles, **ne passent par aucun importeur**. +Si ma conversion et celle de glTFast ne sont pas la même, les personnages sont en miroir par rapport +au monde — et le bug est **invisible sur une scène symétrique**, ce qui le rend cher. + +➡️ **Une seule fonction dans tout le projet Unity**, `GltfSpace.ToUnity(Vector3)` / +`ToUnity(Quaternion)`, et **son signe est confirmé empiriquement, pas supposé** : un GLB de +calibration en forme de **L asymétrique** (branche longue vers +X, branche courte vers +Z, une +marque colorée sur une seule face), chargé à côté d'un marqueur placé par manifeste aux mêmes +coordonnées. Les deux se superposent, ou la conversion est fausse. Ce GLB de calibration est un +livrable de l'étape 1, pas un raffinement. + +### 2.2 Schéma + +```jsonc +{ + "manifestVersion": 1, // version du FORMAT — change si le schéma change + "sceneId": "sec_ab12…", + "instanceId": "inst_…", + "configurationId": "cfg_…", + "version": 42, // version du CONTENU — monotone, +1 à chaque publication + "publishedAt": "2026-09-03T10:22:41Z", + + "languages": ["fr", "nl", "en", "de"], + "defaultLanguage": "fr", + + "coordinateSystem": "gltf/y-up/right-handed/meters", // littéral, vérifié par le client + + "navigation": { + "spawn": { "position": [0,0,0], "rotation": [0,0,0,1] }, + "radiusMeters": 3.0, // plafond dur 3.0, appliqué serveur ET client + "showBoundary": true + }, + + "environment": { + "lightingPreset": "neutral-indoor", + "hdriAssetId": null, + "exposure": 1.0 + }, + + "world": { + "kind": "mesh", // "mesh" | "splat" | "panorama" ← la couture V2 + "assetId": "a_world_01", + "transform": { "position": [0,0,0], "rotation": [0,0,0,1], "scale": [1,1,1] } + }, + + "objects": [ + { "id": "o_1", "label": "Amphore", + "assetId": "a_amphora", + "transform": { "position": [1.2,0,-0.8], "rotation": [0,0.707,0,0.707], "scale": [1,1,1] } } + ], + + "personas": [ + { "id": "p_1", + "personaId": null, // null en V1 (pas d'entité Persona) — la couture + "name": "Le gouverneur", + "assetId": "a_char_governor", + "transform": { "position": [-1.5,0,-1.0], "rotation": [0,1,0,0], "scale": [1,1,1] }, + "animation": { "idleClip": "Idle", "loop": true }, + "gazeAtVisitor": true, + "audio": [ { "language": "fr", "assetId": "a_audio_gov_fr" } ], + "script": [ { "language": "fr", "value": "Bienvenue dans…" } ] } + ], + + "hotspots": [ + { "id": "h_1", "geoPointId": 128, + "transform": { "position": [0.4,1.4,-2.1], "rotation": [0,0,0,1] }, + "title": [ { "language": "fr", "value": "La mosaïque" } ], + "description": [ { "language": "fr", "value": "…" } ], + "contents": [ + { "order": 0, + "title": [ { "language": "fr", "value": "…" } ], + "description": [ { "language": "fr", "value": "…" } ], + "assetId": "a_audio_mosaic_fr" } ] } + ], + + "assets": [ + { "id": "a_world_01", + "resourceId": "res_…", + "url": "https://…/o/pictures%2Finst_…%2Fres_…?alt=media&token=…", + "mimeType": "model/gltf-binary", + "sizeBytes": 184320000, + "sha256": "9f2c…", + "variants": [] } // ← KTX2 / Meshopt viendront ici, sans casser le contrat + ], + + "budget": { "totalBytes": 214000000, "limitBytes": 1073741824 }, + + "provenance": { + "aiGenerated": false, + "rightsHolder": "Musée de …", + "sources": [] + } +} +``` + +### 2.3 Les cinq choix de structure, et leur raison + +| Choix | Raison | +|---|---| +| **`assets[]` séparé des références** | Dédoublonnage, et surtout : c'est la **seule** liste que le téléchargeur parcourt. Le budget et les hash s'y calculent une fois. C'est le motif déjà utilisé par `ExportConfigurationDTO` (`sections` + `resources` à plat) | +| **Toutes les langues d'un coup** | Un casque en borne change de langue à chaud. L'export existant est mono-langue (`?language=`) — c'est le seul point où le manifeste s'écarte du contrat existant, et il le doit | +| **`version` séparé de `manifestVersion`** | L'un dit « le contenu a changé », l'autre « le schéma a changé ». Les confondre rend impossible de livrer une app qui lit les deux | +| **`world.kind`** | La couture pour les splats gaussiens. En V1 le client ne lit que `"mesh"` et **refuse explicitement** les autres — un refus lisible vaut mieux qu'un rendu vide | +| **`personaId` nullable** | La couture pour l'entité `Persona` du lot 7 Studio. En V1 il est `null` et `name`/`assetId` portent tout. Quand `Persona` existe, il se remplit et le reste devient dérivé | + +### 2.4 Le hash, et ce qu'il achète + +`sha256` par asset est la seule chose qui permet un **delta réel** : le casque compare, ne +retélécharge que ce qui a changé, et détecte un fichier corrompu. Sans lui, un changement de version +force le retéléchargement de tout — et à plusieurs centaines de Mo par monde, sur le wifi d'un +musée, ça se compte en heures. + +⚠️ Il n'existe nulle part aujourd'hui (§2 de l'état des lieux). Il se calcule **à la publication**, +pas à l'upload : c'est un job Hangfire qui lit le blob et écrit le hash. + +--- + +## 3. Modèle de données + +**Tout est additif. Aucune donnée existante n'est touchée. Aucune valeur d'enum n'est réordonnée.** + +### 3.1 Le nouveau type de section + +L'héritage des sections est en **TPH avec un discriminant `string`** +(`MyInfoMateDbContext.cs:329-348`). Ajouter un sous-type, c'est donc **une ligne de mapping et des +colonnes nullables** — pas de nouvelle table, pas de recopie. + +```csharp +// Data/SubSection/SectionScene3D.cs +public class SectionScene3D : Section +{ + public string? WorldResourceId { get; set; } + public Resource? WorldResource { get; set; } + public World3DKind WorldKind { get; set; } = World3DKind.Mesh; + + [Column(TypeName = "jsonb")] public TransformDTO? SpawnTransform { get; set; } + public double NavigationRadiusMeters { get; set; } = 3.0; // plafond 3.0, validé serveur + public bool ShowBoundary { get; set; } = true; + + public string? LightingPreset { get; set; } + public string? HdriResourceId { get; set; } + public double Exposure { get; set; } = 1.0; + + [Column(TypeName = "jsonb")] public List Objects { get; set; } + [Column(TypeName = "jsonb")] public List Personas { get; set; } + + public List Hotspots { get; set; } // ← relation, pas jsonb : voir 3.2 + + public int PublishedVersion { get; set; } = 0; + public DateTime? PublishedAt { get; set; } + + public override string GetEmbeddableText(string language) => … + public override IEnumerable GetReferencedResourceIds(string language = null) => … +} + +public enum World3DKind { Mesh, Splat, Panorama } // valeurs 0,1,2 — ajouts EN FIN +``` + +**`Objects` et `Personas` en jsonb, `Hotspots` en relation.** Ce n'est pas une incohérence : + +- un objet ou un personnage placé est **une position et un id d'asset**, jamais interrogé seul, + jamais réutilisé ailleurs → jsonb, exactement comme `SectionMap.MapCategories` ; +- un hotspot porte **du contenu éditorial multilingue avec de l'audio**, doit être indexable par le + guide IA, et réutilise l'éditeur de POI existant → c'est un `GeoPoint`, entité à part entière. + +⚠️ **`GetReferencedResourceIds` est `abstract`, pas `virtual`** — le commentaire de `Section.cs` dit +pourquoi : *« la collecte des ressources vivait dans un switch centralisé […] il est devenu faux en +silence »*. Le compilateur réclamera l'implémentation. Elle doit rendre : le monde, l'HDRI, chaque +asset d'objet, chaque asset et audio de persona, et les ressources de chaque hotspot. **Un oubli ici +n'est pas une erreur d'affichage : c'est un asset manquant sur un casque hors ligne, sur site.** + +### 3.2 Le hotspot est un `GeoPoint` + +`GeoPoint` porte **déjà** deux clés étrangères nullables (`SectionMapId`, `SectionEventId`). On en +ajoute une troisième — c'est littéralement le motif existant : + +```csharp +public string? SectionScene3DId { get; set; } +[ForeignKey(nameof(SectionScene3DId))] public SectionScene3D? SectionScene3D { get; set; } + +[Column(TypeName = "jsonb")] public TransformDTO? LocalTransform { get; set; } +``` + +`LocalTransform` cohabite avec le `Geometry` PostGIS sans conflit : l'un est une lat/lon sur Terre, +l'autre un `(x, y, z)` local à une scène. Un `GeoPoint` a l'un **ou** l'autre. + +➡️ Ce que ça donne gratuitement : `Title`, `Description`, `Contents` (titre + description + +`Resource` audio, multilingue), `ImageResourceId`, et `GetEmbeddableText` — donc **le guide IA sait +déjà lire un hotspot 3D**, sans une ligne de plus. + +### 3.3 Les enums à étendre — toutes en fin, toutes persistées en int + +``` +SectionType += Scene3D // valeur 13 +ResourceType += Model3D // valeur 11 + += Panorama360 // valeur 12 (carte kanban 250, arrive avec) +ApiKeyAppType += VrApp // valeur 3 +``` + +Et le discriminant TPH : `.HasValue("Scene3D")`. + +### 3.4 Le device devient multi-canal — lot V-1, inchangé + +Repris tel quel de `vr-quest-unity-plan.md` §2 : + +``` +Device += AppType (int, défaut Tablet = 1) +DeviceController.Create : résoudre l'ApplicationInstance sur newDevice.appType, + au lieu du AppType.Tablet en dur (DeviceController.cs:155) +DeviceController.GetAll : paramètre de requête appType optionnel +``` + +⚠️ `Device.ConfigurationId` est `[Required]` alors que son propre commentaire dit +« OLD WAY → AppConfigurationLink ». Le casque passe par `AppConfigurationLink` ; on remplit quand +même le champ pour ne pas violer la contrainte. + +### 3.5 La publication — le choix étroit, assumé + +L'état des lieux (§5) a montré qu'il **n'existe aucun état publié** dans tout le CMS : une +modification est immédiatement visible par tous les clients. + +**Décision : on ne refond pas la publication globale pour la V1.** `PublishedVersion` et +`PublishedAt` vivent **sur `SectionScene3D` seulement**. Le manifeste servi à l'app est celui de la +dernière publication ; le viewer du back-office lit le brouillon. + +| Pourquoi pas la refonte globale | | +|---|---| +| Coût | un cycle de vie brouillon/publié sur `Configuration` touche les 13 types, les 3 clients visiteurs et le mode offline | +| Risque | c'est le genre de chantier qui casse ce qui marchait | +| Bénéfice V1 | nul : seule la scène 3D a besoin d'un « ne change pas sous les pieds du visiteur » | + +⚠️ **Dette assumée, à écrire dans le suivi** : le jour où la publication devient globale, +`SectionScene3D.PublishedVersion` doit y être absorbé, pas coexister. C'est une dette **connue et +datée**, pas un oubli. + +### 3.6 Ce que la migration contient + +Une seule migration, additive : + +- colonnes nullables de `SectionScene3D` sur la table `Sections` (TPH) ; +- `GeoPoints += SectionScene3DId, LocalTransform` ; +- `Devices += AppType` (défaut `1`) ; +- `Resources += Sha256` (nullable) ; +- rien à supprimer, rien à recopier, aucune donnée existante modifiée. + +⚠️ **Contexte à ne pas perdre** : la bascule Postgres n'est pas faite et le schéma est gelé +(`STATUS.md` §1ter, lot B). Cette migration est **purement additive**, donc elle ne concurrence pas +la bascule — mais elle doit être posée en connaissance de cause, pas en parallèle sans le dire. + +--- + +## 4. Contrat d'API + +### 4.1 Ce qui ne change pas + +La création, la lecture et la mise à jour d'une `SectionScene3D` passent par le +**`SectionController` existant**, générique sur `SectionDTO` + `SectionFactory`. Aucun endpoint +CRUD nouveau. Les points de couture à toucher sont connus et se comptent : + +| Fichier | Ce qu'il faut y ajouter | +|---|---| +| `DTOs/SectionType.cs` | la valeur `Scene3D` | +| `Services/SectionFactory.cs` | 4 emplacements : `CreateEmpty`, le `switch` de désérialisation, le `switch` de construction, `ToDTO` | +| `Controllers/SectionController.cs` | 1 `switch` | +| `Services/IngestionService.cs` | 1 `switch` (indexation RAG) | +| `Data/MyInfoMateDbContext.cs` | 1 ligne de discriminant | + +### 4.2 Ce qui s'ajoute — côté back-office + +``` +GET /api/Scene3D/{sectionId}/manifest?draft=true + [Authorize ContentEditor] + → SceneManifestDTO (brouillon, toutes langues, urls signées) + Alimente le viewer web du back-office. + +POST /api/Scene3D/{sectionId}/publish + [Authorize ContentEditor] + → { version, publishedAt, totalBytes } + Valide (rayon ≤ 3 m, assets présents, budget), incrémente PublishedVersion, + enqueue le calcul des hash manquants, journalise dans AuditLog. + +GET /api/Scene3D/{sectionId}/budget + [Authorize Viewer] + → { totalBytes, limitBytes, perAsset[] } + Affiché en continu dans l'éditeur — pas seulement à la publication. +``` + +**Le budget est calculé et affiché en continu, pas au moment de publier.** C'est la demande +explicite du cahier des charges (« sinon le stockage du casque explose et je le découvre sur site »), +et l'expérience de la jauge de stockage du menu latéral — qui ne se rafraîchit pas et affiche +« 0 KB » en même temps qu'un refus 413 (bug ouvert, `STATUS.md` §1) — dit exactement ce qu'il ne faut +pas refaire : **un chiffre affiché doit être le chiffre qui bloque.** + +### 4.3 Ce qui s'ajoute — côté app Quest + +``` +GET /api/instance/app-key?pinCode={pin}&appType=VrApp [AllowAnonymous] + → { key, instanceId } + ⚠️ La route réelle est bien "app-key" (InstanceController.cs, HttpGet("app-key")), + pas "appKeyByPin" comme l'écrivait le plan du 31/08. + +POST /api/device { identifier, name, instanceId, appType: VR } + → enregistre le casque, le rattache à l'ApplicationInstance VR + +GET /api/Scene3D/{sectionId}/manifest [X-Api-Key, AppReadAccess] + → SceneManifestDTO publié, toutes langues + +GET {asset.url} téléchargement direct du blob +POST /api/stats/visit-event { appType: VR, … } télémétrie +MQTT player/{deviceId} notification « du neuf est publié » +``` + +**Aucun endpoint de contenu spécifique à Unity au-delà du manifeste.** C'est la leçon du lot V-3 du +plan d'août, et elle tient : un client non-Flutter ne doit avoir qu'**un** point d'entrée de contenu. + +### 4.4 Authentification et fraîcheur + +Le casque garde sa clé d'API et son `instanceId` en stockage local après appairage. Au démarrage : + +1. lecture du **cache local** → l'app est utilisable immédiatement, même sans réseau ; +2. en tâche de fond, `GET manifest` → comparaison de `version` ; +3. si `version` a changé : téléchargement des assets dont le `sha256` diffère ; +4. **application au retour au menu**, jamais pendant qu'un visiteur est immergé ; +5. si le manifeste ne se charge pas : **la dernière version valide reste active**, silencieusement. + +Le point 5 n'est pas un détail de robustesse, c'est la promesse produit : *une borne ne tombe jamais +en panne parce que le wifi du musée a bougé.* + +--- + +## 5. Flux asynchrone de génération de mondes — V1.5 + +> Rappel de la décision **D5** : **hors V1**. En V1 le monde est un GLB uploadé. Cette section décrit +> la greffe, pour qu'on ne code rien aujourd'hui qui l'empêche. + +Marble **n'est pas un chantier séparé** : c'est une implémentation de plus de l'interface +`IGenerationProvider` déjà spécifiée (`studio-plan.md` §3.6). + +``` +GenerationModel (en base, pas dans le code) + Key = "world-default" + ProviderKey = "worldlabs" + ProviderModelId = "marble-…" ← la seule chaîne qui change quand le marché bouge + Kind = World + CreditCost = 1500 ← monde standard + Params jsonb = { draftCost: 150, meshExportCost: 3500, … } +``` + +``` +POST /api/Studio/generate { kind: World, inputResourceIds[], … } + → validations, Reserve(crédits), GenerationJob(Queued), 202 { jobId, estimatedCredits } + +Hangfire worker + → provider.SubmitAsync → ProviderRequestId, Status=Running + → BackgroundJob.Schedule(PollFallback, +5 min) ← filet, la génération dure 5 min à 1 h + +POST /api/StudioWebhook/worldlabs (HMAC vérifié — patron : StripeWebhookController) + → télécharge le GLB, écrit dans le bucket (lot 0), crée la Resource(Model3D), + remplit AiProvenance, Charge(crédits réels), Status=Succeeded + +GET /api/Studio/jobs/{id} polling front à 2 s tant qu'un job est ouvert à l'écran +``` + +**Trois points propres à Marble** que le Studio générique ne couvre pas : + +1. **L'export mesh GLB HQ est facturé une seule fois par monde**, résultat mis en cache ensuite. Le + `CreditLedger` doit donc distinguer *générer le monde* et *en exporter le mesh* : deux `Charge` + sur le même job, la seconde conditionnelle. +2. **Une génération depuis un input non-panoramique peut ajouter un événement de facturation + « pano generation »** — donc le coût réel peut **dépasser** l'estimation affichée. Conséquence de + conception : `Reserve` prend une marge, et `Charge` ajuste au réel. Ne jamais afficher l'estimation + comme un prix ferme. +3. **Les sources de capture sont des `Resource`** (la vidéo, le panorama). Elles entrent dans + `AiProvenance.sources` — c'est ce que demande la traçabilité institutionnelle : modèle, prompt, + date, auteur, **sources**. + +### Ce qu'on ne doit pas coder aujourd'hui + +| Interdit en V1 | Parce que | +|---|---| +| Un `WorldLabsService` appelé depuis le module VR | il court-circuiterait crédits, journal, provenance | +| Un nom de modèle Marble dans le code ou l'`appsettings.json` | contrainte explicite : configuration serveur versionnable, catalogue en base | +| La clé d'API dans `appsettings.json` | le fichier versionné contient déjà 4 secrets en clair — variable d'environnement, volume `/etc/managerservice` | + +--- + +## 6. Le projet Unity + +### 6.1 Structure + +``` +vr-app/unity/MyInfoMateVR/ + Assets/ + Scenes/ + Boot.unity ← LA scène. Une seule, pour tous les clients, tous les mondes. + Scripts/ + Boot/ + AppBootstrap.cs orchestre : provisioning → cache → manifeste → scène → menu + AppState.cs machine à états : Provisioning | Syncing | Menu | InScene | Error + Provisioning/ + PinCodeFlow.cs saisie du code d'instance, GET app-key, POST device + CredentialStore.cs clé d'API + instanceId, persistés + Content/ + SceneManifest.cs les DTO du manifeste — miroir exact du §2.2 + ManifestClient.cs GET manifeste, comparaison de version + AssetCache.cs disque, indexé par sha256, éviction, fallback dernière version valide + AssetDownloader.cs file de téléchargement, reprise, progression + Scene/ + GltfSpace.cs ⚠️ LA conversion glTF → Unity. Le seul endroit. + SceneBuilder.cs instancie monde, objets, personas, hotspots depuis le manifeste + WorldLoader.cs glTFast, refuse explicitement kind != "mesh" en V1 + PersonaInstance.cs idle, orientation vers le visiteur, audio spatialisé + HotspotInstance.cs raycast, surbrillance, lecture audio + NavigationBounds.cs zone circulaire, rendu type garde-fou, plafond 3 m + UI/ + FloatingMenu.cs sélection de scène, langue, relance audio, sortie + KioskSession.cs détection de retrait du casque → reset (décision D4) + Telemetry/ + VisitEventReporter.cs POST des events avec appType = VR + StreamingAssets/ + calibration.glb le L asymétrique du §2.1 + Packages/manifest.json + ProjectSettings/ +``` + +**Une seule scène Unity.** `Boot.unity` contient le rig VR, le menu et rien d'autre ; tout le reste +est instancié au runtime depuis le manifeste. C'est la traduction directe de la règle du §1. + +### 6.2 Les quatre scripts qui portent le risque + +| Script | Le risque | +|---|---| +| `GltfSpace.cs` | conversion miroir invisible sur une scène symétrique (§2.1). **Validé par la calibration, pas par la relecture** | +| `AssetCache.cs` | plusieurs centaines de Mo, coupure réseau, disque plein, fichier corrompu. C'est ici que se joue « la borne ne tombe pas en panne » | +| `KioskSession.cs` | un état résiduel entre deux visiteurs (langue changée, audio en cours, position). Le reset doit être **total**, pas un retour de caméra | +| `WorldLoader.cs` | un GLB de 200 Mo chargé sur le thread principal fige le casque et donne la nausée. Chargement asynchrone obligatoire, écran de progression diégétique | + +### 6.3 Ce que je génère, ce que tu fais + +| Je génère | Tu fais | +|---|---| +| Tous les `.cs` ci-dessus | Les réglages de `ProjectSettings` (guide phase 0 §5-6) | +| `Packages/manifest.json` | L'import du SDK Meta depuis l'Asset Store | +| Un script d'éditeur `[MenuItem]` qui **construit `Boot.unity` par code** | Le premier *Build And Run* | +| Le `.gitignore` (fait) | L'appairage du casque | + +**Pourquoi la scène par code plutôt qu'un `.unity` écrit à la main** : le YAML Unity est plein de +GUID et de `fileID` internes. Écrit à la main, il produit un projet qui s'ouvre mais dont un réglage +sur deux est faux **en silence** — le pire des cas. Un script d'éditeur est lisible, versionnable, +rejouable et diffable. + +--- + +## 7. Le viewer web + +**Une application web autonome** (décision D3), une seule, **partagée par les quatre fronts** : +`manager-app` en édition, `visitapp-web` et `mymuseum-visitapp` en lecture, Unity exclu (rendu natif). + +``` +vr-app/viewer/ autonome, déployé à part — tranché le 2026-09-03 + src/ + manifest.ts les mêmes types que SceneManifest.cs, côté TS + Viewer.ts THREE.js : scène, caméra orbitale, chargement glTF + Editor.ts sélection, raycast au sol, poignée de rotation, undo + Bounds.ts le cercle de navigation, à l'échelle + Panel.ts valeurs numériques, budget, liste des objets +``` + +### Les trois règles à poser dès la V1 + +Elles gardent la porte ouverte au portage Flutter mobile et à l'offline (D3), sans l'imposer comme +périmètre V1 — **si la 3D hors ligne s'avère trop chère, on l'assume et on le signale dans +l'éditeur**, comme le fait déjà `SectionVideo` pour une vidéo distante (`SectionVideo.cs:25-27`). +Elles ne coûtent rien maintenant, et c'est de toute façon du bon design : + +1. **Fichiers statiques**, embarquables dans les assets d'une app Flutter et servables depuis une + origine locale. Pas de build qui suppose un serveur. +2. **Aucune URL d'API en dur.** Le viewer ne connaît que le manifeste qu'on lui donne ; toutes les + URL d'assets viennent de `assets[].url`. C'est ce qui permet de lui passer un manifeste dont les + URL ont été **réécrites en chemins locaux** — exactement ce que fait déjà le pipeline offline. +3. **Le manifeste est une entrée, pas un fetch.** Il s'injecte (`postMessage` / paramètre), pas + seulement par URL. Sans ça, pas d'offline. + +Ces trois règles ne coûtent rien maintenant. Elles coûtent une réécriture si on les découvre au +moment du portage. + +### Trois exigences non négociables + +1. **Aucune conversion de coordonnées.** three.js est nativement main droite Y-up : le viewer lit et + écrit dans l'espace du manifeste, point. C'est ce qui fait que le placement produit ici est + directement juste dans Unity. +2. **Aperçu indicatif, annoncé comme tel.** Alignement à tenir : même tone mapping, même HDRI + d'environnement, **PBR core glTF uniquement** (pas d'extension de matériau exotique). Le mot + « aperçu » doit être dans l'interface, pas seulement dans la doc — sinon le client validera une + couleur qu'il ne retrouvera pas au casque. +3. **Il est aussi l'éditeur** (décision D2). Donc : état d'édition, undo, enregistrement explicite. + C'est la moitié de son coût, et c'est la partie qu'on sous-estime. + +### L'intégration dans `manager-app` + +`manager-app` est en **Flutter Web** : le viewer y entre en **iframe** (`HtmlElementView`), avec un +`postMessage` dans les deux sens : + +``` +manager-app → viewer : { type: "load", manifestUrl, mode: "edit" } +viewer → manager-app : { type: "changed", objects, personas, hotspots } + { type: "dirty", isDirty: true } +``` + +Ce n'est pas un contournement : Flutter Web ne peut pas héberger THREE.js autrement. Et le bénéfice +est réel — le même viewer se monte comme composant React dans `visitapp-web`, en mode lecture seule. + +--- + +## 8. Ce qui n'est pas dans cette conception, volontairement + +| Hors périmètre | Où ça va | +|---|---| +| Les 13 types de section rendus en immersif | Décision D1. `vr-quest-unity-plan.md` §8 garde le cap pour plus tard | +| Splats gaussiens | `world.kind = "splat"`, refusé par le client V1. Prototype séparé | +| Assistant IA dans le casque | Zéro backend nécessaire (`AiController` prend déjà un `AppType`), mais le TTS est côté client et à réimplémenter en C#. Après la V1 | +| Lip sync | Écarté au niveau produit, définitivement | +| MDM / flotte de casques | Inutile sous 10 casques. Lot V-5 | +| Éditeur 3D complet (gizmos, hiérarchie, matériaux) | Jamais. Le client compose, il ne modélise pas | +| KTX2 / Meshopt exécutés serveur | Défaut retenu : GLB servi tel quel en V1, `variants[]` posé dans le contrat | + +--- + +## 9. Les questions soulevées — état au 2026-09-03 + +1. ~~**Où vit le viewer web ?**~~ **Tranché : `vr-app/viewer/`, autonome.** Et le périmètre s'élargit — + il sert **quatre** fronts, `mymuseum-visitapp` compris (WebView, hors ligne). Voir D3 et §7. + +2. **Combien de scènes par expérience ?** Une `SectionScene3D` = une scène ; plusieurs scènes = + plusieurs sections dans la même `Configuration`, listées par un `SectionMenu` — ce qui marche + déjà. **Retenu à titre provisoire**, à confirmer sur un cas concret : c'est le genre de choix qui + se valide en composant une vraie expérience, pas sur le papier. diff --git a/docs/04-phase3-plan.md b/docs/04-phase3-plan.md new file mode 100644 index 0000000..762ebbc --- /dev/null +++ b/docs/04-phase3-plan.md @@ -0,0 +1,319 @@ +# Piste Scène 3D — Plan d'implémentation séquencé (S0 → S8) + +> ⚠️ **Numérotation, lis ceci d'abord.** Les étapes de ce plan s'appellent **S0 à S8** depuis le +> 2026-09-11. Elles s'appelaient E0 à E8, ce qui les confondait avec les items **E0 à E11 du lot +> XR-4** de [`../../DOCS/v2/vr-quest-unity-plan.md`](../../DOCS/v2/vr-quest-unity-plan.md), qui +> sont autre chose. +> +> **Le plan unifié gouverne.** Ce document est le détail d'ingénierie d'une **piste**, pas le plan +> du canal : la scène 3D composable (`SectionScene3D`, manifeste, éditeur web), qui alimente +> **XR-4/E7** (viewer GLB + POI). Le périmètre du POC, lui, est `XR-4 : E0 → E2 → E3 → E5 → E6` — +> menu immersif et vidéo 360 — et il passe avant. +> +> En cas de contradiction entre les deux documents, **le plus récemment mis à jour tranche**. +> +> **Fait à ce jour (2026-09-12)** : S0 ✅, S1 ✅ — **le cercle de navigation est là** aussi +> (`NavigationBounds.cs`, 3 m, repousse sans téléporter), S2 ✅ — **l'audio +> d'un hotspot est là** (`HotspotInstance.cs`, spatialisé, ⚠️ lu **depuis l'URL et non depuis le +> cache** : muet hors ligne), S3 écrit mais jamais essayé. +> +> ⚠️ **S4 à S8 ont été absorbés par la piste E le 2026-09-12** et n'existent plus comme étapes +> propres : `SectionScene3D` + son contrôleur (S4), l'écran manager-app (S5), réseau/cache/sync +> (S6), menu/langues/kiosque/télémétrie (S7) et la flotte (S8) sont écrits. **Un écart assumé** : +> il n'y a **pas** de route `GET /api/Scene3D/{id}/manifest` — le manifeste est construit côté +> client, dans `Scene3DView` comme dans le viewer web, à partir de l'export. Le contrat de S2 +> reste le contrat ; seul son lieu de fabrication a changé. +> +> Détail dans [`../README.md`](../README.md). + +> Écrit le 2026-09-03, à partir de [00 (état des lieux)](00-phase1-etat-des-lieux.md), +> [02 (décisions D1-D6)](02-decisions.md) et [03 (conception)](03-phase2-conception.md). +> +> Format imposé, tenu à chaque étape : **ce que je génère** · **ce que tu fais à la main** · +> **le critère de validation**. Un critère de validation est une chose qu'on *voit*, pas une case +> qu'on coche. + +--- + +## 0. La règle de séquencement + +Trois principes ont dicté l'ordre, et aucun n'est esthétique : + +1. **Le risque technique se lève avant le confort.** Ce qui peut échouer bêtement (conversion d'axes, + poids d'un monde, perf sur casque) passe en premier, sur un cas minimal. +2. **Aucun backend tant qu'il n'est pas nécessaire.** Grâce à la décision **D5** (mondes uploadés, + pas générés), les trois premières étapes n'écrivent **pas une ligne** dans `manager-service`. + C'est ce qui permet de démarrer aujourd'hui sans toucher au backlog V1/Postgres. +3. **Le manifeste avant les deux clients.** Il est le contrat ; l'écrire après l'un des deux, c'est + le tailler pour lui. + +### Vue d'ensemble + +``` +S0 Setup Unity ──► S1 Chaîne bout en bout ──► S2 Manifeste local + (0,5-1 j) (3-5 j) ⚠️ OBLIGATOIRE (2-3 j) + │ + ┌───────────────────────┴──────────────┐ + ▼ ▼ + S3 Viewer + éditeur web S4 Backend SectionScene3D + (8-12 j) (4-6 j) + │ │ + └──────────────┬───────────────────────┘ + ▼ + S5 Écran manager-app (5-8 j) + │ + ▼ + S6 Unity : réseau, cache, sync (5-8 j) + │ + ▼ + S7 Menu, langues, kiosk, télémétrie (4-6 j) + │ + ▼ + S8 Flotte de casques (6-8 j) +``` + +**Total V1 : 8 à 10 semaines** d'un dev solo. Cohérent avec l'estimation d'août (POC 3-4 sem., +couverture 6-10 sem.) — la conception a déplacé de l'effort de l'app vers le viewer, qui sert +maintenant quatre fronts. + +### Les deux jalons qui comptent + +| Jalon | Après | Ce qu'il prouve | +|---|---|---| +| 🔬 **Preuve technique** | S1 | La chaîne marche. Tant que tu ne l'as pas vue de tes yeux, tout le reste est théorique | +| 💼 **Démo vendable** | S3 | On compose dans un navigateur, on voit dans le casque. C'est ce qu'on montre à un client pilote — **avant** d'engager S4 à S8 | + +⚠️ **Le go/no-go se place ici, après S3**, pas au début. À ce stade on a dépensé ~4 semaines et on a +quelque chose à montrer ; S4-S8 (~5 semaines) ne s'engagent qu'avec un pilote identifié. + +--- + +# S0 — Setup Unity + +**0,5 à 1 jour.** Détail complet : [01-phase0-setup-unity.md](01-phase0-setup-unity.md). + +| | | +|---|---| +| **Je génère** | `Packages/manifest.json`, `.gitignore` (fait) | +| **Tu fais** | Unity Hub + Unity 6 LTS avec Android Build Support (**OpenJDK et SDK & NDK Tools cochés**), création du projet URP, Meta XR SDK depuis l'Asset Store, réglages Player/XR (guide §5-6), mode développeur casque | +| **✅ Validation** | Un cube devant toi, en relief, qui **reste immobile quand tu bouges la tête**. S'il suit ton regard, le rig n'est pas en place | + +⚠️ Le premier *Build And Run* prend 10-30 min (IL2CPP). Les suivants, 1-3 min. Ne pas conclure à un +plantage. + +--- + +# S1 — La chaîne technique de bout en bout ⚠️ OBLIGATOIRE + +**3 à 5 jours. Aucun backend. Aucune ligne dans `manager-service`.** + +C'est l'étape que le cahier des charges impose avant tout le reste, et elle le mérite : elle lève +les trois risques qui coûteraient le plus cher découverts tard. + +### Ce que tu fais à la main + +1. **Générer un monde sur Marble**, à partir d'une vraie capture (une vidéo d'une pièce te suffit). + Draft d'abord (~20 s, 150 crédits) pour valider le cadrage, puis monde complet (~5 min, + 1 500 crédits), puis **export mesh GLB HQ** (jusqu'à 1 h, 3 500 crédits, facturé une seule fois). +2. Déposer le `.glb` dans `Assets/StreamingAssets/world.glb`. +3. Trouver **un personnage stylisé GLB** avec une animation idle (décision D6 : bibliothèque externe + sous licence — Mixamo pour l'animation, un pack de modèles stylisés pour le corps). **Note la + licence**, elle devra être tracée. +4. *Build And Run*. + +### Ce que je génère + +| Script | Rôle | +|---|---| +| `GltfSpace.cs` | La conversion glTF → Unity. **Le seul endroit du projet où elle existe** | +| `WorldLoader.cs` | Chargement **runtime** par glTFast depuis `StreamingAssets` — pas un glisser-déposer d'éditeur | +| `CalibrationCheck.cs` | Place un marqueur par coordonnées à côté du L de calibration | +| `NavigationBounds.cs` | Cercle de 3 m, rendu type garde-fou | +| `PersonaInstance.cs` | Instancie le personnage, joue l'idle, l'oriente vers le visiteur | +| Un GLB de calibration | L asymétrique : branche longue vers +X, courte vers +Z, marque colorée sur **une seule** face | + +### ✅ Critères de validation — quatre, tous visuels + +1. Le monde s'affiche dans le casque et tu peux **tourner la tête dedans**. +2. **Le marqueur placé par coordonnées se superpose exactement au repère du GLB de calibration.** + S'il est du mauvais côté, la conversion est en miroir — et c'est le bug qu'on paie le plus cher + plus tard, parce qu'il est invisible sur une scène symétrique. +3. Le personnage est **debout, à la bonne taille** (~1,7 m contre le décor), animé, et il te regarde. +4. Le cercle de navigation est visible et tu **ne peux pas en sortir**. + +### Ce que cette étape mesure, et qu'aucune doc ne peut donner + +- **Le poids réel d'un monde Marble** exporté en mesh. C'est le chiffre qui décide de F5 (KTX2 dans + le conteneur, ou GLB servi tel quel) — et on ne peut pas le décider avant de l'avoir. +- **Les FPS sur casque** avec ce monde. Le budget de scène en découle, pas l'inverse. +- **Le temps de chargement** d'un GLB de cette taille, donc si l'écran de progression est un détail + ou un sujet. + +➡️ **Rapporte-moi ces trois nombres.** Ils réécrivent une partie du §7 de la conception. + +--- + +# S2 — Le manifeste, lu localement + +**2 à 3 jours. Toujours aucun backend.** + +La scène cesse d'être codée en dur : elle est **construite à partir d'un JSON**. C'est ici que la +règle du §1 de la conception (« un rebuild seulement pour une fonctionnalité ») devient vraie. + +| | | +|---|---| +| **Je génère** | `SceneManifest.cs` (miroir exact du schéma §2.2), `SceneBuilder.cs`, `HotspotInstance.cs`, et un `scene.json` d'exemple complet | +| **Tu fais** | Poser le `scene.json` sur le casque (`adb push`, ou MQDH), déplacer des valeurs à la main, relancer l'app | +| **✅ Validation** | **Tu changes une position dans le JSON, tu relances l'app — sans recompiler — et l'objet a bougé.** Tu ajoutes un hotspot, il apparaît et joue son audio au raycast | + +⚠️ Le refus explicite est un livrable, pas un oubli : un `world.kind` autre que `"mesh"` doit +afficher un message lisible, pas une scène vide. + +--- + +# S3 — Le viewer web et son éditeur + +**8 à 12 jours.** L'étape la plus lourde, et la plus rentable : ce viewer sert **quatre fronts** +(décision D3). + +| | | +|---|---| +| **Je génère** | Le projet `vr-app/viewer/` complet : `manifest.ts` (mêmes types qu'en C#), `Viewer.ts` (three.js, chargement glTF, tone mapping et HDRI **alignés sur Unity**), `Editor.ts` (sélection, raycast au sol, poignée de rotation, undo), `Bounds.ts`, `Panel.ts` (valeurs numériques + budget), le protocole `postMessage` | +| **Tu fais** | `npm install && npm run dev`, charger le `scene.json` de S2, placer des objets à la souris | +| **✅ Validation** | **Le test croisé, et c'est le vrai but de l'étape** : tu places trois objets dans le viewer, tu exportes le JSON, tu le pousses sur le casque — **les trois objets sont exactement là où tu les as mis**. C'est la seule preuve valable de la convention d'axes (§2.1 de la conception) | + +### Les trois règles à tenir dès maintenant + +Elles gardent la porte ouverte au portage Flutter mobile sans l'imposer (D3) : +fichiers **statiques** servables depuis une origine locale · **aucune URL d'API en dur** (tout vient +de `assets[].url`) · le manifeste **s'injecte**, pas seulement par URL. + +### Ce qu'on sous-estime toujours + +L'éditeur, ce n'est pas le viewer plus un clic. C'est **un état d'édition, un undo, un enregistrement +explicite, et la gestion du « tu as des modifications non enregistrées »**. Compte la moitié de +l'étape là-dessus. + +> 💼 **C'est ici que se place la démo client.** Composer dans un navigateur, voir dans le casque : +> à ce stade on a de quoi aller chercher un pilote, avant d'engager S4 à S8. + +--- + +# S4 — Backend : le type de section et le manifeste servi + +**4 à 6 jours.** Première ligne écrite dans `manager-service`. + +| | | +|---|---| +| **Je génère** | `SectionScene3D.cs`, `Scene3DDTO`, `TransformDTO`, `Scene3DObjectDTO`, `Scene3DPersonaDTO` · les 4 emplacements de `SectionFactory` · les `switch` de `SectionController` et `IngestionService` · la ligne de discriminant TPH · `GeoPoint += SectionScene3DId, LocalTransform` · les enums (`SectionType.Scene3D`, `ResourceType.Model3D` + `Panorama360`, `ApiKeyAppType.VrApp`) · `Scene3DController` (`manifest`, `publish`, `budget`) · le job Hangfire de calcul des `sha256` · les tests | +| **Tu fais** | Relire la migration **avant** de l'appliquer, lancer `dotnet ef migrations add`, `dotnet test`, corriger l'upload `.glb` de `resources_screen.dart` (le `switch` sans `default`, F7) | +| **✅ Validation** | Un `curl` sur `GET /api/Scene3D/{id}/manifest` avec une clé d'API rend un manifeste que **le viewer de S3 affiche sans modification**, et que **l'app de S2 charge sans modification** | + +### Trois points de vigilance, tous vérifiés dans le code + +1. **`GetReferencedResourceIds` est `abstract`, pas `virtual`** — le compilateur réclamera + l'implémentation, et c'est voulu (`Section.cs`). Un oubli ici n'est pas un bug d'affichage : c'est + **un asset manquant sur un casque hors ligne, sur site**. +2. **Toutes les valeurs d'enum s'ajoutent EN FIN.** Elles sont persistées en int, le commentaire de + `ResourceType` le dit en majuscules. Insérer au milieu transformerait les PDF en JSON. +3. **La migration est purement additive** (colonnes nullables sur la table TPH `Sections`). Elle ne + concurrence pas le gel de schéma du lot B, mais elle se pose **en le sachant** — à annoncer dans + `STATUS.md`, pas à glisser. + +--- + +# S5 — L'écran manager-app + +**5 à 8 jours.** C'est l'étape qui rend le module utilisable par un conservateur. + +| | | +|---|---| +| **Je génère** | L'écran d'édition de `SectionScene3D` (sélection du monde et des assets via le sélecteur de ressource existant, jauge de budget, liste des objets/personas/hotspots), l'intégration du viewer en `HtmlElementView` + `postMessage`, le bouton Publier, les DTO à ajouter **à la main** dans `manager_api_new`, l'i18n FR/EN/NL/DE | +| **Tu fais** | `flutter build web` (pas `flutter analyze` — voir la leçon de `STATUS.md` §1bis), et **composer une vraie scène de bout en bout** | +| **✅ Validation** | Tu crées une scène, tu poses un monde, deux objets, un persona et trois hotspots avec audio, tu publies — **et le casque affiche la scène après un retour au menu**. Sans qu'aucun développeur n'intervienne | + +⚠️ **`manager_api_new` ne se régénère jamais** : les DTO s'ajoutent à la main. C'est une règle du +repo, pas une préférence. + +⚠️ **La jauge de budget doit afficher le chiffre qui bloque.** Le bug ouvert de la jauge de stockage +(`STATUS.md` §1 : « 0 KB » affiché en même temps qu'un refus 413) est exactement ce qu'il ne faut +pas refaire. + +--- + +# S6 — Unity : appairage, cache, synchronisation + +**5 à 8 jours.** L'app cesse d'être une démo et devient une borne. + +| | | +|---|---| +| **Je génère** | `PinCodeFlow.cs` (saisie du code, `GET /api/instance/app-key?appType=VrApp`, `POST /api/device` avec `appType = VR`), `CredentialStore.cs`, `ManifestClient.cs`, `AssetCache.cs` (disque, indexé par `sha256`, éviction, **fallback sur la dernière version valide**), `AssetDownloader.cs` (file, reprise, progression) | +| **Tu fais** | Appairer un vrai casque avec un vrai pincode, puis **débrancher le wifi et relancer** | +| **✅ Validation** | Trois choses, dans cet ordre : (1) l'app démarre **sur le cache**, sans réseau ; (2) après une publication, seuls les assets dont le `sha256` a changé sont retéléchargés ; (3) **un manifeste illisible ou un serveur injoignable laisse la dernière version valide active**, sans message d'erreur au visiteur | + +⚠️ Le point (3) n'est pas de la robustesse défensive, c'est la promesse produit : *une borne ne +tombe pas en panne parce que le wifi du musée a bougé.* À tester en débranchant, pas en relisant. + +⚠️ **La sync s'applique au retour au menu, jamais à chaud.** Un monde qui se recharge pendant qu'un +visiteur est immergé, c'est au mieux une coupure, au pire une nausée. + +--- + +# S7 — Menu flottant, langues, kiosk, télémétrie + +**4 à 6 jours.** + +| | | +|---|---| +| **Je génère** | `FloatingMenu.cs` (sélection de scène, langue, relance audio, sortie), `KioskSession.cs` (détection de retrait du casque → reset, décision D4), `VisitEventReporter.cs` (`appType = VR`) | +| **Tu fais** | **Faire essayer à quelqu'un qui n'a jamais tenu une manette Quest.** Sans l'aider | +| **✅ Validation** | Deux critères, et le premier est le vrai : (1) cette personne lance une scène et change de langue **sans qu'on lui explique** ; (2) le casque reposé puis repris repart **à l'état initial** — langue par défaut, audio arrêté, position de départ | + +Le reset doit être **total**, pas un retour de caméra. Un état résiduel entre deux visiteurs +(audio en cours, langue changée, hotspot ouvert) est le défaut le plus visible en exploitation +réelle — et le plus facile à laisser passer en test solo. + +**La télémétrie ne demande aucun travail serveur** : `VisitEvent` porte déjà `AppType`, et le filtre +de l'écran de statistiques est générique sur `AppType.values` avec l'i18n `statsChannelVR` déjà +présente. Le canal VR apparaîtra tout seul au premier event. + +--- + +# S8 — La flotte de casques + +**6 à 8 jours.** Ce sont les lots V-1 et V-2 du plan d'août, inchangés. + +| | | +|---|---| +| **Je génère** | `Device.AppType` + migration, `DeviceController.Create` résolvant l'`ApplicationInstance` sur `newDevice.appType` (au lieu du `AppType.Tablet` en dur, `DeviceController.cs:155`), `GetAll` avec un paramètre `appType` optionnel · `lib/Screens/Vr_devices/vr_screen.dart` **cloné de `Kiosk_devices/`** (4 fichiers, 769 l.) · le branchement de `main_screen.dart:704` à la place du `Text("TODO vr")` · batterie / `AppVersion` / `LastSeen` sur la carte casque | +| **Tu fais** | Vérifier qu'un casque enregistré **n'apparaît pas** dans l'onglet Kiosk | +| **✅ Validation** | Deux casques appairés, visibles dans l'onglet VR avec leur batterie et leur version, chacun avec **une configuration différente assignée** | + +`Device.AppType` a pour **défaut `Tablet` (1)** : les lignes existantes ne bougent pas. +`ApiKeyAppType.VrApp` s'ajoute **en fin d'enum**. + +--- + +## Ce qui n'est pas dans ce plan + +| Hors V1 | Quand | +|---|---| +| Génération de mondes Marble depuis le manager | V1.5 — décision D5. Dépend des lots 0/1/3 du Studio (§5 de la conception) | +| Entité `Persona` et personnages 3D générés | Lot 7 puis 11 du Studio. La couture `personaId` est déjà dans le manifeste | +| TTS pré-généré | Plan dédié. En V1, audio `.mp3` uploadé — même champ, aucune reprise | +| Splats gaussiens | `world.kind = "splat"`, refusé explicitement par le client V1 | +| Assistant IA dans le casque | Zéro backend (`AiController` prend déjà un `AppType`), mais le TTS est à réimplémenter en C# | +| Viewer 3D dans `visitapp-web` et `mymuseum-visitapp` | Le viewer est conçu pour, les trois règles sont posées. Le portage est un chantier à part | +| KTX2 / Meshopt exécutés côté serveur | **À rouvrir avec les chiffres de S1**, pas avant | +| MQTT dans Unity, MDM | Lot V-5. Inutile sous une dizaine de casques | + +--- + +## Les trois choses qui peuvent faire dérailler ce plan + +1. **Le poids d'un monde Marble.** Si un monde pèse 500 Mo, le budget de scène et le pipeline KTX2 + remontent en priorité, et S1 doit le dire. C'est le seul inconnu vraiment structurant. +2. **L'éditeur de S3.** C'est l'étape la plus longue et la seule dont l'estimation repose sur peu de + précédent dans le repo — il n'y a aucune 3D nulle part aujourd'hui. +3. **Le go/no-go pilote.** Il est placé après S3 pour une raison : au-delà, on engage ~5 semaines sur + un canal dont le risque est **produit**, pas technique. diff --git a/tools/make_calibration_glb.py b/tools/make_calibration_glb.py new file mode 100644 index 0000000..b8d81a5 --- /dev/null +++ b/tools/make_calibration_glb.py @@ -0,0 +1,135 @@ +"""Génère calibration.glb — le repère de calibration de l'étape E1. + +Trois branches de longueurs DIFFÉRENTES, aux couleurs de la convention habituelle : + +X rouge (1.00 m) +Y vert (0.60 m) +Z bleu (0.30 m) + +Pourquoi trois longueurs différentes : un repère symétrique ne permet pas de voir +une inversion d'axe. Ici, si la conversion glTF -> Unity se trompe de signe, la +branche rouge part du mauvais côté par rapport à la bleue, et ça se voit sans +mesurer. + +Le modèle est écrit en coordonnées glTF (main droite, Y-up, mètres) — c'est-à-dire +exactement la convention du manifeste. C'est ce qui en fait un test valable : +l'app place un marqueur AUX MÊMES coordonnées, sans passer par l'importeur. + +Usage : python3 make_calibration_glb.py [chemin/de/sortie.glb] +""" + +import base64 +import json +import struct +import sys +from pathlib import Path + +AXES = [ + # (nom, dimensions du pavé, couleur RGBA) + ("AxisX", (1.00, 0.04, 0.04), (0.85, 0.15, 0.15, 1.0)), + ("AxisY", (0.04, 0.60, 0.04), (0.15, 0.75, 0.20, 1.0)), + ("AxisZ", (0.04, 0.04, 0.30), (0.15, 0.35, 0.90, 1.0)), +] + +FACES = [ + ((0, 0, 1), [(0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)]), + ((0, 0, -1), [(1, 0, 0), (0, 0, 0), (0, 1, 0), (1, 1, 0)]), + ((1, 0, 0), [(1, 0, 1), (1, 0, 0), (1, 1, 0), (1, 1, 1)]), + ((-1, 0, 0), [(0, 0, 0), (0, 0, 1), (0, 1, 1), (0, 1, 0)]), + ((0, 1, 0), [(0, 1, 1), (1, 1, 1), (1, 1, 0), (0, 1, 0)]), + ((0, -1, 0), [(0, 0, 0), (1, 0, 0), (1, 0, 1), (0, 0, 1)]), +] + + +def box(size): + """Pavé dont un coin est à l'origine et qui s'étend vers +X, +Y, +Z.""" + positions, normals, indices = [], [], [] + for normal, corners in FACES: + base = len(positions) + for cx, cy, cz in corners: + positions.append((cx * size[0], cy * size[1], cz * size[2])) + normals.append(normal) + indices += [base, base + 1, base + 2, base, base + 2, base + 3] + return positions, normals, indices + + +def main(out_path): + buffer = bytearray() + buffer_views, accessors, meshes, materials, nodes = [], [], [], [], [] + + def add_view(data, target): + while len(buffer) % 4: + buffer.append(0) + offset = len(buffer) + buffer.extend(data) + buffer_views.append( + {"buffer": 0, "byteOffset": offset, "byteLength": len(data), "target": target} + ) + return len(buffer_views) - 1 + + for name, size, color in AXES: + positions, normals, indices = box(size) + + pos_view = add_view(b"".join(struct.pack("<3f", *p) for p in positions), 34962) + nrm_view = add_view(b"".join(struct.pack("<3f", *n) for n in normals), 34962) + idx_view = add_view(b"".join(struct.pack(" 1 else "calibration.glb") diff --git a/unity-overlay/Assets/Scripts/Boot/MenuBootstrap.cs b/unity-overlay/Assets/Scripts/Boot/MenuBootstrap.cs new file mode 100644 index 0000000..0ceec3f --- /dev/null +++ b/unity-overlay/Assets/Scripts/Boot/MenuBootstrap.cs @@ -0,0 +1,324 @@ +using System.Threading.Tasks; +using MyInfoMate.Vr.Menu; +using MyInfoMate.Vr.Net; +using MyInfoMate.Vr.Scene; +using UnityEngine; + +namespace MyInfoMate.Vr.Boot +{ + /// + /// L'app du canal VR, telle qu'elle tient aujourd'hui : appairage (E2), contenu + /// servi depuis le cache (E3-E4), menu flottant (E5) et télémétrie (E9). + /// + /// C'est le successeur de , qui reste à côté : celui-ci + /// prouve la chaîne réseau en une phrase, celui-là est l'app. Tant que rien n'a tourné + /// sur un vrai casque, pouvoir revenir au plus simple en un clic évite de confondre + /// un problème d'appairage avec un problème de rendu. + /// + /// Les quatre types retenus au §8 s'ouvrent : Slider, Map et Event + /// (E8), le Parcours rendu comme une Map, et la Video 360 — celle-ci + /// n'est pas un type de section mais une section Video dont le média est une ressource + /// 360, livrée le 2026-09-12. S'y ajoute la Scène 3D (E7). Choisir une section + /// non gérée affiche son titre et le dit — un menu qui ne réagit pas se lit comme une + /// panne. + /// + /// La session de borne (E10) vit ici aussi : elle décide quand la visite est finie. + /// + public class MenuBootstrap : MonoBehaviour + { + [Header("Serveur")] + [SerializeField] string baseUrl = "https://api.mymuseum.be"; + + [Header("Appairage — code PIN de l'instance (première fois seulement)")] + [SerializeField] string pinCode = ""; + + [Header("Nom affiché dans l'onglet XR du manager")] + [SerializeField] string headsetName = "Quest"; + + [SerializeField] string language = "FR"; + + [SerializeField] bool forgetPairing; + + Transform _head; + FloatingMenu _menu; + Telemetry _telemetry; + ConfigurationExport _export; + SceneMessage _message; + + async void Start() + { + _head = Camera.main != null ? Camera.main.transform : null; + if (_head == null) + Debug.LogError("[Menu] Aucune caméra active : le rig VR n'est pas dans la " + + "scène. Meta → Tools → Building Blocks → Camera Rig."); + + if (forgetPairing) PairingService.Forget(); + + var pairing = PairingService.Restore(); + + if (pairing == null) + { + if (string.IsNullOrEmpty(pinCode)) + { + Show("Ce casque n'est pas appairé. Renseignez le code PIN du lieu " + + "dans l'inspecteur, puis relancez."); + return; + } + + var paired = await new PairingService().PairAsync(baseUrl, pinCode, headsetName); + if (!paired.Ok) + { + Show(paired.Error); + return; + } + + pairing = paired.Value; + } + + _client = new ApiClient(pairing.BaseUrl, pairing.ApiKey); + var client = _client; + var content = new ContentService(client, pairing.ConfigurationId); + + var loaded = await content.LoadAsync(); + if (!loaded.Ok) + { + Show(loaded.Error); + return; + } + + _telemetry = new Telemetry(client, pairing.InstanceId, pairing.ConfigurationId, language); + + // XR-5 : sans ce battement, l'onglet XR du manager affiche « — » pour la + // batterie et la version, et ne sait jamais si un casque est tombé. + var fleet = Kiosk.FleetReporter.Attach( + gameObject, client, pairing.DeviceId, pairing.ConfigurationId); + + if (fleet != null) fleet.ConfigurationChanged += OnConfigurationChanged; + + BuildMenu(loaded.Export); + await _menu.LoadImagesAsync(loaded.Export, client); + await ApplyBackdrop(loaded.Export, client); + + // E4 — le reste de la visite part sur le disque pendant que le visiteur + // regarde le menu. Sans await : c'est long, et personne ne doit l'attendre. + _ = ContentPreloader.RunAsync(loaded.Export, client); + + if (!loaded.FromCache) return; + + // Le menu est déjà utilisable : le rafraîchissement ne fait attendre personne, + // et ne se voit que s'il apporte quelque chose. + var fresh = await content.RefreshAsync(); + if (fresh == null || _menu == null) return; + + Debug.Log("[Menu] Contenu mis à jour, le menu se reconstruit."); + BuildMenu(fresh); + await _menu.LoadImagesAsync(fresh, client); + await ApplyBackdrop(fresh, client); + + _ = ContentPreloader.RunAsync(fresh, client); + } + + void BuildMenu(ConfigurationExport export) + { + _export = export; + + if (_menu == null) + { + _menu = FloatingMenu.Create(_head); + _menu.SectionChosen += OnSectionChosen; + + _kiosk = Kiosk.KioskSession.Attach(gameObject, _head); + _kiosk.Ended += StartNewVisit; + } + + _menu.Build(export, language); + } + + /// + /// Fin de visite : casque reposé, ou plus rien depuis un long moment. On revient + /// au menu, on le repose devant le visiteur, et on ouvre une nouvelle session de + /// statistiques — sinon une journée de borne compterait pour un seul visiteur. + /// + void StartNewVisit() + { + CloseSection(); + _menu?.Recenter(); + _telemetry?.StartNewSession(); + Debug.Log("[Kiosk] Nouvelle visite."); + } + + async void OnSectionChosen(string sectionId) + { + _kiosk?.NotifyActivity(); + _telemetry?.MenuItemTap(sectionId); + _telemetry?.SectionView(sectionId); + + var section = _export?.Sections?.Find(s => s.Id == sectionId); + var title = section != null + ? ConfigurationExport.Translate(section.Title, language) ?? section.Label + : sectionId; + + Debug.Log($"[Menu] Section choisie : {title} ({section?.Type})"); + + if (section == null) return; + + // Une section Video dont le média est une ressource 360 est le seul cas qui + // ne se feuillette pas : elle devient le ciel. + var immersive = ImmersiveResourceOf(section); + if (immersive != null) + { + OpenSection(sectionId); + + _skybox = SkyboxView.Create(_head); + _skybox.Closed += CloseSection; + _skybox.Interacted += () => _kiosk?.NotifyActivity(); + + await _skybox.LoadAsync(immersive, _client); + return; + } + + // Une maquette 3D n'est pas une page : elle se regarde en volume, avec ses + // points posés dessus. + if (section.Type == ConfigurationExport.SectionType.Scene3D) + { + OpenSection(sectionId); + + _model = Scene3DView.Create(_head); + _model.Closed += CloseSection; + _model.Interacted += () => _kiosk?.NotifyActivity(); + + if (!await _model.LoadAsync(section, _export, language)) + { + CloseSection(); + Show($"{title}\n\nCette scène 3D n'a pas pu être chargée."); + } + return; + } + + if (!SectionPages.CanRender(section.Type)) + { + // Les types écartés au §8 (Quiz, Game, Web, PDF…) ne seront pas portés. + // Le dire vaut mieux que ne rien faire : un menu qui ne réagit pas se lit + // comme une panne. + Show($"{title}\n\nCe type de contenu n'est pas encore affichable dans le casque."); + return; + } + + OpenSection(sectionId); + + _view = PagedView.Create(_head); + _view.Closed += CloseSection; + _view.Interacted += () => _kiosk?.NotifyActivity(); + + await SectionPages.FillAsync(_view, section, _client, language); + } + + /// + /// Retour au menu. La durée passée dans la section part avec le + /// SectionLeave : c'est elle qui dit si un contenu retient, et sans elle + /// les stats ne comptent que des ouvertures. + /// + /// + /// Le manager a assigné une autre configuration à ce casque : on la charge et on + /// repart du menu, sans redémarrer l'app. + /// + /// ⚠️ On ferme ce qui est ouvert d'abord. Un visiteur qui regardait une galerie + /// au moment du changement doit revenir au menu — pas rester dans un contenu qui + /// n'appartient plus à sa visite. + /// + async void OnConfigurationChanged(string configurationId) + { + CloseSection(); + + var content = new ContentService(_client, configurationId); + var loaded = await content.LoadAsync(); + + if (!loaded.Ok) + { + Debug.LogWarning($"[Menu] Nouvelle configuration illisible : {loaded.Error}"); + return; + } + + BuildMenu(loaded.Export); + _menu?.Recenter(); + _telemetry?.StartNewSession(); + + await _menu.LoadImagesAsync(loaded.Export, _client); + await ApplyBackdrop(loaded.Export, _client); + + _ = ContentPreloader.RunAsync(loaded.Export, _client); + } + + /// + /// Pose le décor du lieu derrière le menu. Détruit le précédent d'abord : un + /// changement de configuration ne doit pas laisser le panorama de l'ancienne + /// visite, et deux fonds superposés font deux vidéos qui tournent. + /// + async Task ApplyBackdrop(ConfigurationExport export, ApiClient client) + { + if (_backdrop != null) Destroy(_backdrop.gameObject); + _backdrop = await Menu.ImmersiveBackdrop.ApplyAsync(export, client); + } + + void OpenSection(string sectionId) + { + _openedSectionId = sectionId; + _openedAt = Time.time; + _menu.gameObject.SetActive(false); + } + + /// + /// La ressource 360 d'une section, ou null. Source porte soit un id de + /// ressource, soit une URL externe (YouTube) — cette dernière n'est pas une 360 + /// téléchargeable, donc pas un cas immersif. + /// + ConfigurationExport.Resource ImmersiveResourceOf(ConfigurationExport.SectionSummary section) + { + if (section.SourceIsUrl) return null; + + var resource = _export?.FindResource(section.Source); + return resource != null && resource.IsImmersive ? resource : null; + } + + void CloseSection() + { + if (_view != null) Destroy(_view.gameObject); + _view = null; + + if (_skybox != null) Destroy(_skybox.gameObject); + _skybox = null; + + // Une scène 3D laissée derrière soi reste plantée dans le décor du menu — et + // son GLB continue d'occuper la mémoire du casque. + if (_model != null) Destroy(_model.gameObject); + _model = null; + + // Le message d'une section non gérée resterait affiché par-dessus le menu. + if (_message != null) Destroy(_message.gameObject); + _message = null; + + if (_openedSectionId != null) + { + _telemetry?.SectionLeave(_openedSectionId, Mathf.RoundToInt(Time.time - _openedAt)); + _openedSectionId = null; + } + + if (_menu != null) _menu.gameObject.SetActive(true); + } + + PagedView _view; + SkyboxView _skybox; + ImmersiveBackdrop _backdrop; + Scene3DView _model; + ApiClient _client; + Kiosk.KioskSession _kiosk; + string _openedSectionId; + float _openedAt; + + void Show(string message) + { + if (_message != null) Destroy(_message.gameObject); + _message = SceneMessage.Show(message, _head); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Boot/PairingBootstrap.cs b/unity-overlay/Assets/Scripts/Boot/PairingBootstrap.cs new file mode 100644 index 0000000..0355532 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Boot/PairingBootstrap.cs @@ -0,0 +1,123 @@ +using MyInfoMate.Vr.Net; +using MyInfoMate.Vr.Scene; +using UnityEngine; + +namespace MyInfoMate.Vr.Boot +{ + /// + /// Items E2 et E3 du lot XR-4 : le casque s'appaire, puis lit le contenu + /// de sa configuration. Rien d'immersif ici — c'est la preuve que la chaîne + /// réseau tient, affichée en une phrase dans le casque. + /// + /// ⚠️ Ne pas confondre avec et : + /// ceux-là sont la piste Scène 3D (S0-S8), celui-ci le canal VR (E0-E11). Deux + /// numérotations, deux plans. + /// + /// Le code PIN ne se saisit pas encore : sans l'Interaction SDK (item E1), + /// il n'y a ni pointeur ni clavier. Il se pose donc dans l'inspecteur avant le + /// build ; l'écran d'appairage arrive avec E5. + /// + public class PairingBootstrap : MonoBehaviour + { + [Header("Serveur")] + [SerializeField] string baseUrl = "https://api.mymuseum.be"; + + [Header("Appairage — code PIN de l'instance")] + [SerializeField] string pinCode = ""; + + [Header("Nom affiché dans l'onglet XR du manager")] + [SerializeField] string headsetName = "Quest"; + + [Header("Langue d'affichage — l'export les porte toutes")] + [SerializeField] string language = "FR"; + + [Header("Réappairer même si ce casque l'est déjà")] + [SerializeField] bool forgetPairing; + + async void Start() + { + var head = Camera.main != null ? Camera.main.transform : null; + if (head == null) + Debug.LogError("[Pairing] Aucune caméra active : le rig VR n'est pas dans la " + + "scène. Meta → Tools → Building Blocks → Camera Rig."); + + if (forgetPairing) PairingService.Forget(); + + var pairing = PairingService.Restore(); + + if (pairing == null) + { + if (string.IsNullOrEmpty(pinCode)) + { + SceneMessage.Show("Ce casque n'est pas appairé. Renseignez le code PIN " + + "du lieu dans l'inspecteur, puis relancez.", head); + return; + } + + var result = await new PairingService() + .PairAsync(baseUrl, pinCode, headsetName); + + if (!result.Ok) + { + SceneMessage.Show(result.Error, head); + return; + } + + pairing = result.Value; + Debug.Log($"[Pairing] Casque appairé — device {pairing.DeviceId}, " + + $"instance {pairing.InstanceId}"); + } + + var client = new ApiClient(pairing.BaseUrl, pairing.ApiKey); + var content = new ContentService(client, pairing.ConfigurationId); + + var loaded = await content.LoadAsync(); + + if (!loaded.Ok) + { + SceneMessage.Show(loaded.Error, head); + return; + } + + Show(loaded.Export, head, loaded.FromCache ? loaded.CachedAt : null); + + // E4 : le cache a déjà été affiché, le réseau ne fait plus attendre personne. + // Un échec ici ne se voit pas — c'est le comportement voulu pour une borne. + if (loaded.FromCache) + { + var fresh = await content.RefreshAsync(); + if (fresh != null) + { + Debug.Log("[Content] Contenu mis à jour depuis le serveur."); + Show(fresh, head, null); + } + } + } + + SceneMessage _message; + + void Show(ConfigurationExport export, Transform head, System.DateTime? cachedAt) + { + // Le rafraîchissement de fond réaffiche : sans ça, deux panneaux se + // superposent et le texte devient illisible. + if (_message != null) Destroy(_message.gameObject); + + var roots = export.RootSections(); + var lines = $"{export.Label} — {roots.Count} sections"; + + if (cachedAt.HasValue) + lines += $"\n(hors ligne — contenu du {cachedAt.Value.ToLocalTime():dd/MM HH:mm})"; + + for (var i = 0; i < roots.Count; i++) + { + var title = ConfigurationExport.Translate(roots[i].Title, language) ?? roots[i].Label; + Debug.Log($"[Export] {roots[i].Type} — {title}"); + + // Le message flottant reste lisible à cinq lignes ; la console a le reste. + if (i < 5) lines += $"\n{title} ({roots[i].Type})"; + } + + _message = SceneMessage.Show(lines, head); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Boot/S1Bootstrap.cs b/unity-overlay/Assets/Scripts/Boot/S1Bootstrap.cs new file mode 100644 index 0000000..5d2293a --- /dev/null +++ b/unity-overlay/Assets/Scripts/Boot/S1Bootstrap.cs @@ -0,0 +1,82 @@ +using System.Threading.Tasks; +using MyInfoMate.Vr.Scene; +using UnityEngine; + +namespace MyInfoMate.Vr.Boot +{ + /// + /// Orchestration de l'étape S1 : décor, calibration, personnage, zone de navigation. + /// + /// Provisoire par construction. Les chemins sont ici en dur, parce que S1 + /// n'a ni manifeste ni réseau — c'est exactement son intérêt : prouver la chaîne + /// technique sans backend. S2 remplace ce script par SceneBuilder, qui lit + /// les mêmes assets depuis un manifeste. Ne rien construire au-dessus de celui-ci. + /// + public class S1Bootstrap : MonoBehaviour + { + [Header("Fichiers dans Assets/StreamingAssets")] + [SerializeField] string worldFile = "world.glb"; + [SerializeField] string personaFile = "persona.glb"; + + [Header("Placement du personnage — coordonnées glTF, en mètres")] + [SerializeField] float[] personaPosition = { -1.5f, 0f, -1f }; + + [Header("Navigation")] + [SerializeField] float navigationRadiusMeters = 3f; + [SerializeField] bool showBoundary = true; + [SerializeField] bool runCalibration = true; + + Transform _sceneRoot; + + async void Start() + { + _sceneRoot = new GameObject("SceneRoot").transform; + _sceneRoot.SetParent(transform, false); + + var head = Camera.main != null ? Camera.main.transform : null; + if (head == null) + Debug.LogError("[S1] Aucune caméra active : le rig VR n'est pas dans la scène. " + + "Meta → Tools → Building Blocks → Camera Rig."); + + await LoadWorld(); + + if (runCalibration) + await gameObject.AddComponent().RunAsync(_sceneRoot); + + await LoadPersona(head); + + SetUpBounds(head); + } + + async Task LoadWorld() + { + var world = await GltfLoader.LoadAsync( + GltfLoader.StreamingAssetsUrl(worldFile), _sceneRoot, "World"); + + if (world == null) + Debug.LogError($"[S1] {worldFile} absent de StreamingAssets — " + + "exporte un monde Marble en mesh GLB et dépose-le là."); + } + + async Task LoadPersona(Transform head) + { + var holder = new GameObject("Persona"); + holder.transform.SetParent(_sceneRoot, false); + holder.transform.localPosition = GltfSpace.Position(personaPosition); + + var persona = holder.AddComponent(); + if (!await persona.LoadAsync(GltfLoader.StreamingAssetsUrl(personaFile), head)) + Debug.LogError($"[S1] {personaFile} absent de StreamingAssets."); + } + + void SetUpBounds(Transform head) + { + // La zone suit le rig, pas la tête : c'est la position au sol du visiteur + // qui compte, et la tête bouge de 60 cm quand on se penche. + var rig = head != null ? head.root : null; + var bounds = new GameObject("NavigationBounds").AddComponent(); + bounds.transform.SetParent(_sceneRoot, false); + bounds.Configure(rig, head, navigationRadiusMeters, showBoundary); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Boot/S2Bootstrap.cs b/unity-overlay/Assets/Scripts/Boot/S2Bootstrap.cs new file mode 100644 index 0000000..e36c5db --- /dev/null +++ b/unity-overlay/Assets/Scripts/Boot/S2Bootstrap.cs @@ -0,0 +1,76 @@ +using MyInfoMate.Vr.Manifest; +using MyInfoMate.Vr.Scene; +using UnityEngine; + +namespace MyInfoMate.Vr.Boot +{ + /// + /// Point d'entrée de l'étape S2 : la scène n'est plus codée en dur, elle est + /// construite à partir d'un JSON. + /// + /// Remplace , à garder côte à côte le temps de valider + /// S1 : l'un prouve la chaîne technique, l'autre prouve le contrat de données. + /// + /// Le manifeste est ici un fichier de StreamingAssets, poussé sur le casque à la + /// main (adb push ou MQDH). En S6 il viendra du réseau et du cache disque + /// — et ne changera pas d'une ligne, c'est le but. + /// + public class S2Bootstrap : MonoBehaviour + { + [Header("Manifeste dans Assets/StreamingAssets")] + [SerializeField] string manifestFile = "scene.json"; + + [Header("Langue de démarrage — vide = langue par défaut du manifeste")] + [SerializeField] string language = ""; + + SceneBuilder _builder; + + async void Start() + { + var head = Camera.main != null ? Camera.main.transform : null; + if (head == null) + Debug.LogError("[S2] Aucune caméra active : le rig VR n'est pas dans la scène. " + + "Meta → Tools → Building Blocks → Camera Rig."); + + var rig = head != null ? head.root : null; + + var url = ManifestUrl(); + var result = await ManifestReader.LoadAsync(url); + + if (!result.Ok) + { + // Le refus est le livrable : on affiche la phrase, on ne charge rien, + // et on ne laisse pas une scène vide faire croire à une panne. + SceneMessage.Show(result.Error, head); + return; + } + + _builder = gameObject.AddComponent(); + + var chosen = string.IsNullOrEmpty(language) ? result.Manifest.DefaultLanguage : language; + + if (!await _builder.BuildAsync(result.Manifest, head, rig, chosen)) + SceneMessage.Show("Le décor de cette scène n'a pas pu être chargé.", head); + } + + /// + /// Un manifeste poussé sur le casque l'emporte sur celui de l'APK : + /// adb push scene.json /sdcard/Android/data/<package>/files/. + /// + /// C'est ce qui rend S2 vérifiable. Lu depuis StreamingAssets, le manifeste + /// est dans l'APK : changer une valeur demanderait un rebuild, et le + /// critère « on modifie le JSON, on relance, sans recompiler » n'aurait plus + /// d'objet. En S6 la même priorité vaudra pour le cache disque. + /// + string ManifestUrl() + { + var pushed = System.IO.Path.Combine(Application.persistentDataPath, manifestFile); + + if (!System.IO.File.Exists(pushed)) + return GltfLoader.StreamingAssetsUrl(manifestFile); + + Debug.Log($"[S2] Manifeste poussé : {pushed}"); + return new System.Uri(pushed).AbsoluteUri; + } + } +} diff --git a/unity-overlay/Assets/Scripts/Editor/BuildBootScene.cs b/unity-overlay/Assets/Scripts/Editor/BuildBootScene.cs new file mode 100644 index 0000000..209d7ac --- /dev/null +++ b/unity-overlay/Assets/Scripts/Editor/BuildBootScene.cs @@ -0,0 +1,87 @@ +using MyInfoMate.Vr.Boot; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace MyInfoMate.Vr.EditorTools +{ + /// + /// Construit Boot.unity par code plutôt que de versionner un .unity écrit à + /// la main. Le YAML d'une scène Unity est plein de GUID et de fileID internes : + /// écrit à la main il produit une scène qui s'ouvre mais dont un réglage sur deux + /// est faux en silence. Ceci est lisible, rejouable et diffable. + /// + /// Ce que ce script ne fait pas : ajouter le rig VR. Le Camera Rig vient du SDK + /// Meta et se pose par les Building Blocks — le référencer ici créerait une + /// dépendance de compilation sur un package importé à la main. + /// + /// Deux variantes, et elles coexistent volontairement le temps de la validation : + /// S1 prouve la chaîne technique avec des chemins en dur, S2 prouve + /// le contrat de données en lisant scene.json. Tant que S1 n'est pas validé + /// au casque, pouvoir revenir à la scène minimale en un clic évite de confondre + /// un bug de rendu avec un bug de manifeste. + /// + public static class BuildBootScene + { + const string ScenePath = "Assets/Scenes/Boot.unity"; + + [MenuItem("MyInfoMate/Construire la scène Boot (S1 — chemins en dur)")] + public static void BuildS1() => + Build(() => new GameObject("Bootstrap").AddComponent(), + "S1 (chemins en dur)"); + + [MenuItem("MyInfoMate/Construire la scène Boot (S2 — depuis scene.json)")] + public static void BuildS2() => + Build(() => new GameObject("Bootstrap").AddComponent(), + "S2 (manifeste scene.json)"); + + /// + /// La troisième variante appartient à l'autre plan : le canal VR (E2/E3), pas la + /// piste Scène 3D. Elle ne charge aucun décor — elle appaire le casque et affiche + /// ce que le serveur renvoie. Le code PIN se pose dans l'inspecteur du Bootstrap + /// avant le build, tant que E1 n'a pas donné de pointeur. + /// + [MenuItem("MyInfoMate/Construire la scène Boot (E2-E3 — appairage et export)")] + public static void BuildPairing() => + Build(() => new GameObject("Bootstrap").AddComponent(), + "E2-E3 (appairage et export)"); + + /// + /// L'app du canal VR telle qu'elle tient : appairage, cache, menu flottant au + /// regard, télémétrie. La variante précédente reste là pour isoler un problème + /// de réseau d'un problème de rendu. + /// + [MenuItem("MyInfoMate/Construire la scène Boot (E5 — menu flottant)")] + public static void BuildMenu() => + Build(() => new GameObject("Bootstrap").AddComponent(), + "E5 (menu flottant)"); + + static void Build(System.Action addBootstrap, string label) + { + var scene = EditorSceneManager.NewScene( + NewSceneSetup.EmptyScene, NewSceneMode.Single); + + var light = new GameObject("Directional Light").AddComponent(); + light.type = LightType.Directional; + light.transform.rotation = Quaternion.Euler(50f, -30f, 0f); + light.intensity = 1f; + + addBootstrap(); + + System.IO.Directory.CreateDirectory("Assets/Scenes"); + EditorSceneManager.SaveScene(scene, ScenePath); + + EditorBuildSettings.scenes = new[] { new EditorBuildSettingsScene(ScenePath, true) }; + + // Enchaîné ici plutôt que laissé à ta mémoire : une scène reconstruite + // puis buildée sans ses shaders embarqués donne un casque magenta, et + // l'erreur se diagnostique mal parce que l'éditeur, lui, va bien. + IncludeRuntimeShaders.Include(); + + Debug.Log($"Boot.unity construite sur {label}. Reste à faire à la main : " + + "Meta → Tools → Building Blocks → Camera Rig, puis Hand Tracking " + + "si on veut le pincement (AimSelector le détecte tout seul, et " + + "retombe sur la visée à la tête s'il est absent)."); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Editor/IncludeRuntimeShaders.cs b/unity-overlay/Assets/Scripts/Editor/IncludeRuntimeShaders.cs new file mode 100644 index 0000000..0689b34 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Editor/IncludeRuntimeShaders.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace MyInfoMate.Vr.EditorTools +{ + /// + /// Déclare dans Always Included Shaders les shaders que ce projet ne + /// charge que par code. + /// + /// Le problème que ça règle. Unity n'embarque dans un build que les + /// shaders référencés par un matériau présent dans une scène ou dans + /// Resources. Un shader obtenu par Shader.Find au runtime n'est + /// référencé nulle part au moment du build : il est retiré. Dans l'éditeur tout + /// est disponible, donc tout est correct en Play Mode et magenta sur le + /// casque — la combinaison la plus coûteuse à diagnostiquer. + /// + /// C'est le cas de tous les shaders listés ici : + /// + /// glTFast résout ses shaders par Shader.Find("Shader Graphs/…") + /// (ShaderGraphMaterialGenerator.cs:594) — donc tout GLB chargé au + /// runtime, c'est-à-dire tout le contenu de l'app ; + /// NavigationBounds et HotspotInstance font le même appel + /// pour URP/Unlit ; + /// SceneMessage utilise un TextMesh, dont le matériau par + /// défaut pointe sur GUI/Text Shader. Sans lui, le message d'erreur + /// destiné au visiteur serait lui-même illisible. + /// + /// + /// Fait par script et pas à la main pour la même raison que + /// : c'est versionné, rejouable, et ça survit à une + /// réimportation du projet. + /// + public static class IncludeRuntimeShaders + { + /// + /// Les noms sont ceux que le code résout au runtime, relevés dans les + /// sources — pas devinés. Un nom faux ici est silencieux : le script dira + /// « introuvable » au lieu d'ajouter le shader. + /// + static readonly string[] RequiredShaders = + { + // glTFast en URP — le rendu de tout GLB chargé au runtime + "Shader Graphs/glTF-pbrMetallicRoughness", + "Shader Graphs/glTF-unlit", + "Shader Graphs/glTF-pbrSpecularGlossiness", + + // Repères et garde-fous dessinés par code + "Universal Render Pipeline/Unlit", + + // ⚠️ Surtout pas "Universal Render Pipeline/Lit" : 2 359 296 variantes, + // au-delà de ce qu'Unity accepte d'embarquer — le build échoue sur + // « has too many Shader variants » après 35 minutes de compilation. + // Aucun Shader.Find du projet ne le demande : tout ce qui est dessiné + // par code est en Unlit, et les GLB passent par les graphes glTFast. + + // La 360° (E6) — même piège que les autres : magenta sur le casque, + // correct dans l'éditeur + "Skybox/Panoramic" + + // ⚠️ Pas de "GUI/Text Shader" non plus, et pour une raison différente : + // il vit dans « Library/unity default resources », marqué + // HideFlags.DontSave. Le mettre dans cette liste fait échouer le build + // sur « An asset is marked with HideFlags.DontSave but is included in + // the build » puis « Failed to write file: …/unity_builtin_extra ». + // Le TextMesh de SceneMessage n'en a pas besoin : son matériau vient de + // la police référencée par le composant, qui est embarquée normalement. + }; + + [MenuItem("MyInfoMate/Embarquer les shaders du runtime")] + public static void Include() + { + var settings = AssetDatabase + .LoadAllAssetsAtPath("ProjectSettings/GraphicsSettings.asset") + .FirstOrDefault(); + + if (settings == null) + { + Debug.LogError("[Shaders] GraphicsSettings.asset introuvable."); + return; + } + + var serialized = new SerializedObject(settings); + var list = serialized.FindProperty("m_AlwaysIncludedShaders"); + + var already = new HashSet(); + for (var i = 0; i < list.arraySize; i++) + { + if (list.GetArrayElementAtIndex(i).objectReferenceValue is Shader shader) + already.Add(shader); + } + + var added = 0; + var missing = new List(); + + foreach (var name in RequiredShaders) + { + var shader = Shader.Find(name); + + if (shader == null) + { + // Un shader introuvable dans l'éditeur signale soit un package + // absent, soit un renommage en amont. Dans les deux cas, se + // taire reviendrait à livrer le magenta. + missing.Add(name); + continue; + } + + if (!already.Add(shader)) continue; + + list.InsertArrayElementAtIndex(list.arraySize); + list.GetArrayElementAtIndex(list.arraySize - 1).objectReferenceValue = shader; + added++; + } + + serialized.ApplyModifiedProperties(); + AssetDatabase.SaveAssets(); + + Debug.Log($"[Shaders] {added} shader(s) ajouté(s), " + + $"{already.Count} au total dans Always Included Shaders."); + + if (missing.Count > 0) + Debug.LogError("[Shaders] Introuvables, donc toujours magenta au build : " + + string.Join(", ", missing) + + ". Vérifier que le package correspondant est bien importé."); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Kiosk/FleetReporter.cs b/unity-overlay/Assets/Scripts/Kiosk/FleetReporter.cs new file mode 100644 index 0000000..a85acb0 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Kiosk/FleetReporter.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections; +using MyInfoMate.Vr.Net; +using UnityEngine; + +namespace MyInfoMate.Vr.Kiosk +{ + /// + /// Ce que le casque dit de lui-même au manager — lot XR-5. + /// + /// L'onglet XR affiche batterie, version et dernier vu depuis le 12/09, mais rien ne + /// les alimentait : c'est ce trou-là que ce composant comble. Un exploitant qui + /// voit « vu il y a 3 minutes, 12 % » sait quoi faire ; « — » ne lui apprend rien. + /// + /// Pourquoi pas MQTT. Le plan prévoit MQTTnet à terme, et le serveur publie + /// déjà sur player/{deviceId}. Mais à un à trois casques, un battement HTTP + /// périodique fait le même travail pour dix fois moins de code, sans connexion + /// permanente à maintenir ni bibliothèque à embarquer. MQTT redeviendra utile le jour + /// où il faudra pousser vers le casque — recharger une config à distance — + /// pas pour remonter trois chiffres. + /// + /// L'échec est silencieux, comme tout le reste de la borne : une statistique perdue + /// ne vaut pas un message au visiteur. + /// + public class FleetReporter : MonoBehaviour + { + /// + /// 3 min : assez fréquent pour qu'un exploitant voie un casque tomber avant la + /// fin d'une visite, assez rare pour ne rien coûter en batterie ni en réseau. + /// + const float IntervalSeconds = 180f; + + ApiClient _client; + string _deviceId; + string _configurationId; + + /// + /// Le manager a assigné une autre configuration à ce casque. C'est le + /// « pousser vers le casque » du lot XR-5, obtenu sans MQTT : la réponse au + /// battement porte déjà la configuration courante de l'appareil, il suffit de la + /// comparer. + /// + /// Ce que ça coûte : la latence du battement, trois minutes au pire. + /// Ce que ça évite : une connexion permanente à maintenir sur une borne, + /// une bibliothèque MQTT embarquée dans le build, et un client de plus à + /// reconnecter quand le wifi du musée tombe. Le serveur publie déjà sur + /// player/{deviceId} (DeviceController.cs:413) : le jour où trois + /// minutes seront trop, tout est prêt côté serveur. + /// + public event Action ConfigurationChanged; + + public static FleetReporter Attach(GameObject host, ApiClient client, + string deviceId, string configurationId) + { + if (string.IsNullOrEmpty(deviceId)) return null; + + var reporter = host.AddComponent(); + reporter._client = client; + reporter._deviceId = deviceId; + reporter._configurationId = configurationId; + reporter.StartCoroutine(reporter.Loop()); + return reporter; + } + + class DeviceResponse + { + public string ConfigurationId; + } + + IEnumerator Loop() + { + // Un premier battement tout de suite : le casque doit apparaître en ligne + // dès qu'il démarre, pas trois minutes plus tard. + Send(); + + while (true) + { + yield return new WaitForSeconds(IntervalSeconds); + Send(); + } + } + + async void Send() + { + var result = await _client.PutAsync( + $"/api/device/{_deviceId}/heartbeat", new + { + batteryLevel = BatteryPercent(), + appVersion = Application.version + }); + + if (!result.Ok) + { + Debug.Log($"[Fleet] Battement non envoyé : {result.Error}"); + return; + } + + var assigned = result.Value?.ConfigurationId; + if (string.IsNullOrEmpty(assigned) || assigned == _configurationId) return; + + Debug.Log($"[Fleet] Nouvelle configuration assignée : {assigned}"); + _configurationId = assigned; + ConfigurationChanged?.Invoke(assigned); + } + + /// + /// Le niveau en pourcentage, ou null si le casque ne le donne pas — + /// SystemInfo.batteryLevel renvoie -1 là où l'information n'existe pas, + /// et envoyer « -100 % » serait pire que ne rien envoyer. + /// + static string BatteryPercent() + { + var level = SystemInfo.batteryLevel; + return level < 0f ? null : Mathf.RoundToInt(level * 100f).ToString(); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Kiosk/KioskSession.cs b/unity-overlay/Assets/Scripts/Kiosk/KioskSession.cs new file mode 100644 index 0000000..5da49b1 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Kiosk/KioskSession.cs @@ -0,0 +1,117 @@ +using System; +using UnityEngine; +using UnityEngine.XR; + +namespace MyInfoMate.Vr.Kiosk +{ + /// + /// Le mode borne — item E10 du lot XR-4. + /// + /// Ce qui sépare une démo d'une borne : une démo, quelqu'un la lance et la + /// range ; une borne tourne 8 h par jour, passe de main en main, et personne ne la + /// surveille. Le seul état acceptable pour le visiteur suivant est le menu, devant + /// lui, comme si l'app venait de démarrer — pas la galerie que le précédent avait + /// laissée ouverte, pas un menu dans son dos. + /// + /// Deux déclencheurs, et le second est le vrai : + /// + /// Le casque est reposé — le signal le plus fiable qui existe : la + /// visite est finie, tout de suite, sans attendre. + /// Plus rien ne bouge pendant un moment — le repli, pour le casque + /// posé sur une table sans que personne ne l'ait retiré de la tête. + /// + /// + /// Sans SDK Meta. La présence du porteur se lit par CommonUsages.userPresence + /// d'Unity XR, alimenté par OpenXR : pas besoin d'un OVRManager dans la scène, + /// donc rien à configurer et rien à oublier de configurer. + /// + public class KioskSession : MonoBehaviour + { + /// + /// 90 s : assez long pour qu'un visiteur qui lit un panneau ne soit pas renvoyé + /// au menu, assez court pour que le suivant ne trouve pas l'écran du précédent. + /// + [SerializeField] float idleSeconds = 90f; + + /// Au-delà, la tête a bougé : quelqu'un est là. + const float MovementThresholdDegrees = 3f; + + Transform _head; + Quaternion _lastRotation; + float _idle; + bool _wasWorn = true; + + /// La visite est finie — revenir au menu et repartir de zéro. + public event Action Ended; + + public static KioskSession Attach(GameObject host, Transform head) + { + var session = host.AddComponent(); + session._head = head; + session._lastRotation = head != null ? head.rotation : Quaternion.identity; + + // Une borne ne doit pas s'éteindre toute seule pendant qu'un visiteur lit. + Screen.sleepTimeout = SleepTimeout.NeverSleep; + + return session; + } + + /// + /// À appeler sur toute action délibérée. Le mouvement de tête suffit à détecter + /// une présence, mais pas à prouver l'attention : quelqu'un qui vise et choisit + /// est actif même s'il bouge à peine. + /// + public void NotifyActivity() => _idle = 0f; + + void Update() + { + if (IsWorn()) + { + if (!_wasWorn) + { + // Le casque vient d'être remis : c'est un nouveau visiteur, même si + // le précédent l'a reposé il y a dix secondes. + _wasWorn = true; + _idle = 0f; + Ended?.Invoke(); + } + } + else if (_wasWorn) + { + _wasWorn = false; + Ended?.Invoke(); + return; + } + + if (!_wasWorn) return; + + if (_head != null) + { + if (Quaternion.Angle(_head.rotation, _lastRotation) > MovementThresholdDegrees) + { + _lastRotation = _head.rotation; + _idle = 0f; + } + } + + _idle += Time.deltaTime; + if (_idle < idleSeconds) return; + + _idle = 0f; + Ended?.Invoke(); + } + + /// + /// Vrai quand le casque est porté. Un runtime qui ne sait pas répondre — le + /// simulateur, l'éditeur — renvoie « porté » : sur un poste de développement, une + /// borne qui se réinitialise toutes les 90 s serait intenable. + /// + static bool IsWorn() + { + var device = InputDevices.GetDeviceAtXRNode(XRNode.Head); + if (!device.isValid) return true; + + return !device.TryGetFeatureValue(CommonUsages.userPresence, out var present) || present; + } + } +} diff --git a/unity-overlay/Assets/Scripts/Manifest/ManifestReader.cs b/unity-overlay/Assets/Scripts/Manifest/ManifestReader.cs new file mode 100644 index 0000000..f4b829d --- /dev/null +++ b/unity-overlay/Assets/Scripts/Manifest/ManifestReader.cs @@ -0,0 +1,140 @@ +using System; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using UnityEngine; +using UnityEngine.Networking; + +namespace MyInfoMate.Vr.Manifest +{ + /// + /// Lecture et validation d'un manifeste de scène. + /// + /// La validation n'est pas de la défense de principe : le manifeste est écrit + /// par le serveur, mais il est lu par une app qui tourne hors ligne, sur site, + /// sans personne pour regarder une console. Un manifeste qu'on ne sait pas lire + /// doit produire une phrase lisible dans le casque, jamais une scène vide + /// (§2.3, et le livrable explicite de S2). + /// + public static class ManifestReader + { + static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + // Le manifeste est en camelCase (§2.2), les champs C# en PascalCase. + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() + }, + MissingMemberHandling = MissingMemberHandling.Ignore, + + // Un champ absent doit garder la valeur par défaut du champ C#, pas + // devenir null : c'est ce qui rend les sections optionnelles réellement + // optionnelles. + NullValueHandling = NullValueHandling.Ignore + }; + + public class Result + { + public SceneManifest Manifest; + + /// Null si tout va bien ; sinon une phrase destinée au visiteur. + public string Error; + + public bool Ok => Error == null; + } + + public static Result Parse(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return Fail("Le manifeste de scène est vide."); + + SceneManifest manifest; + try + { + manifest = JsonConvert.DeserializeObject(json, Settings); + } + catch (JsonException e) + { + Debug.LogError($"[Manifest] JSON illisible : {e.Message}"); + return Fail("Le manifeste de scène est illisible."); + } + + if (manifest == null) + return Fail("Le manifeste de scène est illisible."); + + return Validate(manifest); + } + + static Result Validate(SceneManifest manifest) + { + if (manifest.ManifestVersion != SceneManifest.SupportedManifestVersion) + return Fail($"Cette scène utilise un format de manifeste " + + $"(version {manifest.ManifestVersion}) que cette version de " + + $"l'application ne sait pas lire. Mettez l'application à jour."); + + // Vérifié comme un littéral, volontairement : c'est le seul garde-fou + // contre un manifeste produit dans la convention Unity par erreur, et + // un miroir d'axes est invisible sur une scène symétrique (§2.1). + if (manifest.CoordinateSystem != SceneManifest.ExpectedCoordinateSystem) + return Fail($"Le repère de cette scène est inattendu " + + $"({manifest.CoordinateSystem ?? "absent"}). " + + $"Attendu : {SceneManifest.ExpectedCoordinateSystem}."); + + if (manifest.World == null || string.IsNullOrEmpty(manifest.World.AssetId)) + return Fail("Cette scène n'a pas de décor."); + + switch (manifest.World.ParsedKind) + { + case World3DKind.Mesh: + break; + case World3DKind.Splat: + return Fail("Ce décor est un nuage de splats gaussiens, que cette " + + "version de l'application ne sait pas afficher."); + case World3DKind.Panorama: + return Fail("Ce décor est un panorama 360°, que cette version de " + + "l'application ne sait pas afficher."); + default: + return Fail($"Type de décor inconnu : « {manifest.World.Kind} »."); + } + + if (manifest.FindAsset(manifest.World.AssetId) == null) + return Fail("Le fichier du décor est introuvable dans cette scène."); + + if (manifest.Navigation.RadiusMeters > NavigationManifest.MaxRadiusMeters) + Debug.LogWarning( + $"[Manifest] Rayon de navigation {manifest.Navigation.RadiusMeters} m " + + $"au-delà du plafond de {NavigationManifest.MaxRadiusMeters} m : ramené au " + + "plafond. Le serveur aurait dû le refuser."); + + return new Result { Manifest = manifest }; + } + + /// + /// Sur Android, un fichier de StreamingAssets est dans l'APK : seule + /// UnityWebRequest sait le lire. Même raison que + /// . + /// + public static async Task LoadAsync(string url) + { + using var request = UnityWebRequest.Get(url); + var operation = request.SendWebRequest(); + + while (!operation.isDone) + await Task.Yield(); + + if (request.result != UnityWebRequest.Result.Success) + { + Debug.LogError($"[Manifest] {url} : {request.error}"); + return Fail("Le manifeste de scène est introuvable."); + } + + return Parse(request.downloadHandler.text); + } + + static Result Fail(string message) + { + Debug.LogError($"[Manifest] {message}"); + return new Result { Error = message }; + } + } +} diff --git a/unity-overlay/Assets/Scripts/Manifest/SceneManifest.cs b/unity-overlay/Assets/Scripts/Manifest/SceneManifest.cs new file mode 100644 index 0000000..1cf18f1 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Manifest/SceneManifest.cs @@ -0,0 +1,250 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using UnityEngine; + +namespace MyInfoMate.Vr.Manifest +{ + /// + /// Miroir exact du schéma §2.2 de la conception. Ce fichier et + /// viewer/src/manifest.ts décrivent la même chose : modifier l'un + /// sans l'autre casse le test croisé de S3. + /// + /// Les coordonnées sont ici telles qu'écrites dans le manifeste, donc en + /// convention glTF. Elles ne deviennent des coordonnées Unity qu'en passant par + /// — nulle part ailleurs. + /// + public class SceneManifest + { + public const int SupportedManifestVersion = 1; + public const string ExpectedCoordinateSystem = "gltf/y-up/right-handed/meters"; + + /// Version du format. Distincte de . + public int ManifestVersion = 1; + + public string SceneId; + public string InstanceId; + public string ConfigurationId; + + /// Version du contenu : +1 à chaque publication. C'est elle que le + /// cache compare pour savoir qu'il y a du nouveau. + public int Version; + + public string PublishedAt; + + public List Languages = new List(); + public string DefaultLanguage; + + public string CoordinateSystem; + + public NavigationManifest Navigation = new NavigationManifest(); + public EnvironmentManifest Environment = new EnvironmentManifest(); + public WorldManifest World; + + public List Objects = new List(); + public List Personas = new List(); + public List Hotspots = new List(); + public List Assets = new List(); + + public BudgetManifest Budget; + public ProvenanceManifest Provenance; + + [JsonIgnore] Dictionary _assetsById; + + /// + /// assets[] est la seule liste que le téléchargeur parcourt (§2.3) : tout + /// le reste ne porte que des identifiants. D'où cet index. + /// + public SceneAsset FindAsset(string assetId) + { + if (string.IsNullOrEmpty(assetId)) return null; + + if (_assetsById == null) + { + _assetsById = new Dictionary(); + foreach (var asset in Assets) + if (asset != null && !string.IsNullOrEmpty(asset.Id)) + _assetsById[asset.Id] = asset; + } + + return _assetsById.TryGetValue(assetId, out var found) ? found : null; + } + } + + /// + /// Position + rotation + échelle en convention glTF. Les accesseurs Unity passent + /// tous par : c'est ce qui garantit qu'il n'existe + /// qu'une conversion dans le projet. + /// + public class TransformManifest + { + public float[] Position = { 0f, 0f, 0f }; + public float[] Rotation = { 0f, 0f, 0f, 1f }; + public float[] Scale = { 1f, 1f, 1f }; + + public Vector3 UnityPosition => Scene.GltfSpace.Position(Position ?? new[] { 0f, 0f, 0f }); + + public Quaternion UnityRotation => + Scene.GltfSpace.Rotation(Rotation ?? new[] { 0f, 0f, 0f, 1f }); + + public Vector3 UnityScale => Scene.GltfSpace.Scale(Scale ?? new[] { 1f, 1f, 1f }); + + public void ApplyTo(Transform target) + { + target.localPosition = UnityPosition; + target.localRotation = UnityRotation; + target.localScale = UnityScale; + } + } + + public class NavigationManifest + { + /// Le plafond dur du §2.2, appliqué serveur et client. + public const float MaxRadiusMeters = 3f; + + public TransformManifest Spawn = new TransformManifest(); + public float RadiusMeters = MaxRadiusMeters; + public bool ShowBoundary = true; + + public float ClampedRadiusMeters => Mathf.Clamp(RadiusMeters, 0.5f, MaxRadiusMeters); + } + + public class EnvironmentManifest + { + public string LightingPreset = "neutral-indoor"; + public string HdriAssetId; + public float Exposure = 1f; + } + + /// + /// kind est la couture V2 : la V1 ne lit que et + /// refuse le reste explicitement. Un refus lisible vaut mieux qu'une scène + /// vide (§2.3). + /// + public class WorldManifest + { + public string Kind = "mesh"; + public string AssetId; + public TransformManifest Transform = new TransformManifest(); + + public World3DKind ParsedKind => + Kind switch + { + "mesh" => World3DKind.Mesh, + "splat" => World3DKind.Splat, + "panorama" => World3DKind.Panorama, + _ => World3DKind.Unknown + }; + } + + public enum World3DKind + { + Mesh, + Splat, + Panorama, + Unknown + } + + public class SceneObject + { + public string Id; + public string Label; + public string AssetId; + public TransformManifest Transform = new TransformManifest(); + } + + public class ScenePersona + { + public string Id; + + /// Null en V1 : la couture vers l'entité Persona du lot 7 Studio. + public string PersonaId; + + public string Name; + public string AssetId; + public TransformManifest Transform = new TransformManifest(); + public AnimationManifest Animation = new AnimationManifest(); + public bool GazeAtVisitor = true; + public List Audio = new List(); + public List Script = new List(); + } + + public class AnimationManifest + { + public string IdleClip = "Idle"; + public bool Loop = true; + } + + public class SceneHotspot + { + public string Id; + + /// Un hotspot est un GeoPoint côté serveur (§3.2). + public int GeoPointId; + + public TransformManifest Transform = new TransformManifest(); + public List Title = new List(); + public List Description = new List(); + public List Contents = new List(); + } + + public class HotspotContent + { + public int Order; + public List Title = new List(); + public List Description = new List(); + public string AssetId; + } + + /// + /// Toutes les langues arrivent d'un coup : un casque en borne change de langue à + /// chaud, l'export mono-langue existant ne suffit pas (§2.3). + /// + public class LocalizedText + { + public string Language; + public string Value; + } + + public class LocalizedAsset + { + public string Language; + public string AssetId; + } + + public class SceneAsset + { + public string Id; + public string ResourceId; + public string Url; + public string MimeType; + public long SizeBytes; + + /// Le seul moyen d'un delta réel côté casque (§2.4). + public string Sha256; + + /// KTX2 / Meshopt viendront ici, sans casser le contrat. + public List Variants = new List(); + } + + public class AssetVariant + { + public string Kind; + public string Url; + public string MimeType; + public long SizeBytes; + public string Sha256; + } + + public class BudgetManifest + { + public long TotalBytes; + public long LimitBytes; + } + + public class ProvenanceManifest + { + public bool AiGenerated; + public string RightsHolder; + public List Sources = new List(); + } +} diff --git a/unity-overlay/Assets/Scripts/Menu/AimSelector.cs b/unity-overlay/Assets/Scripts/Menu/AimSelector.cs new file mode 100644 index 0000000..b292333 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Menu/AimSelector.cs @@ -0,0 +1,225 @@ +using System; +using UnityEngine; + +namespace MyInfoMate.Vr.Menu +{ + /// + /// Visée et sélection — item E5 du lot XR-4. Trois moyens de désigner un + /// panneau, dans cet ordre de priorité : manette, main, tête. + /// + /// ⚠️ « Au regard » ne veut pas dire eye tracking. Le Quest 2 n'a pas de suivi + /// oculaire — seul le Quest Pro en a. Ce qu'on suit ici est la direction de la + /// tête (head gaze) : le visiteur vise avec son nez, pas avec ses yeux. C'est le + /// seul mode qui marche sur tous les casques, donc le repli garanti. + /// + /// Pourquoi les trois. Une borne publique ne distribue pas de manettes — le + /// visiteur met le casque et c'est tout : il faut que ça marche sans rien dans les + /// mains. Mais en démo ou en installation surveillée, la manette est plus rapide et + /// les mains plus naturelles. Aucun des trois ne suffit seul. + /// + /// La temporisation ne s'applique qu'à la tête, où il n'y a rien pour valider : + /// c'est le seul garde-fou contre le « Midas touch », où tout ce qu'on regarde se + /// déclenche, y compris ce qu'on ne fait que lire. La manette (gâchette) et la main + /// (pincement) ont un geste explicite, donc pas d'attente : imposer 1,2 s à quelqu'un + /// qui vient d'appuyer serait absurde. + /// + /// Dépendance : OVRInput et OVRHand viennent du Meta XR Core + /// SDK, déjà installé (E1) — pas de l'Interaction SDK, qui lui n'est pas configuré. + /// Les mains ne sont détectées que si un OVRHand est présent dans la scène + /// (building block Hand Tracking) ; sinon on retombe sur les deux autres, sans + /// erreur. + /// + public class AimSelector : MonoBehaviour + { + /// + /// 1,2 s à la tête : assez long pour qu'un regard qui balaie ne déclenche rien, + /// assez court pour qu'on n'ait pas l'impression d'attendre. C'est le réglage qui + /// décide si le menu est agréable ou pénible — à confirmer au premier essai réel. + /// + [SerializeField] float dwellSeconds = 1.2f; + + [SerializeField] float maxDistanceMeters = 10f; + + /// Sous ce seuil, un pincement n'en est pas un. + const float PinchThreshold = 0.7f; + + public enum AimSource { Head, Controller, Hand } + + Transform _head; + IAimTarget _current; + float _dwell; + LineRenderer _ray; + + OVRHand[] _hands = Array.Empty(); + + public AimSource Source { get; private set; } = AimSource.Head; + + public event Action Selected; + + public void Configure(Transform head) + { + _head = head; + + // Le hand tracking n'est pas toujours dans la scène : son absence est un cas + // nominal, pas une erreur. + _hands = FindObjectsByType(FindObjectsSortMode.None); + + BuildRay(); + } + + void Update() + { + if (_head == null) return; + + if (!TryAim(out var ray, out var triggered)) + { + Clear(); + return; + } + + DrawRay(ray); + + var target = Probe(ray); + + if (target != _current) + { + _current?.OnAimExit(); + _current = target; + _dwell = 0f; + _current?.OnAimEnter(); + } + + if (_current == null) return; + + // Un geste explicite déclenche tout de suite ; sans geste, c'est le temps + // passé sur la cible qui vaut validation. + if (triggered) + { + Fire(); + return; + } + + if (Source != AimSource.Head) return; + + _dwell += Time.deltaTime; + _current.OnAimProgress(Mathf.Clamp01(_dwell / dwellSeconds)); + + if (_dwell >= dwellSeconds) Fire(); + } + + /// + /// La source active. L'ordre n'est pas un goût : une manette en main est un choix + /// délibéré du visiteur, une main levée aussi ; la tête ne veut rien dire par + /// elle-même, donc elle passe en dernier. + /// + bool TryAim(out Ray ray, out bool triggered) + { + var controller = OVRInput.GetActiveController(); + + if (controller == OVRInput.Controller.RTouch || controller == OVRInput.Controller.LTouch) + { + var position = OVRInput.GetLocalControllerPosition(controller); + var rotation = OVRInput.GetLocalControllerRotation(controller); + + // Les poses des manettes sont locales au rig ; la tête vit dans le même + // repère, donc son parent donne la transformation à appliquer. + var rig = _head.parent != null ? _head.parent : _head; + + Source = AimSource.Controller; + ray = new Ray(rig.TransformPoint(position), rig.rotation * rotation * Vector3.forward); + triggered = OVRInput.GetDown(OVRInput.Button.PrimaryIndexTrigger, controller); + return true; + } + + foreach (var hand in _hands) + { + if (hand == null || !hand.IsTracked || !hand.IsPointerPoseValid) continue; + if (hand.IsSystemGestureInProgress) continue; + + Source = AimSource.Hand; + ray = new Ray(hand.PointerPose.position, hand.PointerPose.forward); + + // Le front montant, pas l'état : un pincement maintenu ne doit pas + // déclencher à chaque image. + triggered = hand.GetFingerPinchStrength(OVRHand.HandFinger.Index) > PinchThreshold + && !_wasPinching; + _wasPinching = hand.GetFingerPinchStrength(OVRHand.HandFinger.Index) > PinchThreshold; + return true; + } + + _wasPinching = false; + Source = AimSource.Head; + ray = new Ray(_head.position, _head.forward); + triggered = false; + return true; + } + + bool _wasPinching; + + void Fire() + { + var selected = _current; + _current = null; + _dwell = 0f; + selected.OnAimExit(); + + MenuFeedback.Select(); + Selected?.Invoke(selected); + } + + void Clear() + { + _current?.OnAimExit(); + _current = null; + _dwell = 0f; + if (_ray != null) _ray.enabled = false; + } + + IAimTarget Probe(Ray ray) => + Physics.Raycast(ray, out var hit, maxDistanceMeters) + ? hit.collider.GetComponentInParent() + : null; + + void BuildRay() + { + var line = new GameObject("AimRay"); + line.transform.SetParent(transform, false); + + _ray = line.AddComponent(); + _ray.useWorldSpace = true; + _ray.positionCount = 2; + _ray.widthMultiplier = 0.004f; + _ray.material = new Material(Shader.Find("Universal Render Pipeline/Unlit")); + _ray.material.color = new Color(0.4f, 0.8f, 1f, 0.5f); + _ray.enabled = false; + } + + /// + /// Le rayon ne se dessine que pour la manette et la main. À la tête, un trait + /// parti du milieu du visage est fixe au centre du champ de vision : il gêne en + /// permanence sans rien apprendre, puisque l'anneau du panneau dit déjà où on vise. + /// + void DrawRay(Ray ray) + { + if (_ray == null) return; + + if (Source == AimSource.Head) + { + _ray.enabled = false; + return; + } + + _ray.enabled = true; + _ray.SetPosition(0, ray.origin); + _ray.SetPosition(1, ray.origin + ray.direction * 2.5f); + } + } + + /// Ce qu'un objet doit savoir faire pour être visable. + public interface IAimTarget + { + void OnAimEnter(); + void OnAimProgress(float ratio); + void OnAimExit(); + } +} diff --git a/unity-overlay/Assets/Scripts/Menu/FloatingMenu.cs b/unity-overlay/Assets/Scripts/Menu/FloatingMenu.cs new file mode 100644 index 0000000..b81415e --- /dev/null +++ b/unity-overlay/Assets/Scripts/Menu/FloatingMenu.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using MyInfoMate.Vr.Net; +using UnityEngine; + +namespace MyInfoMate.Vr.Menu +{ + /// + /// Le menu flottant — le hub de l'app, item E5 du lot XR-4. + /// + /// C'est le rendu de la SectionMenu du CMS, et le seul type de section dont + /// le plan dit qu'il est « nécessaire » : tout le reste s'atteint depuis lui. + /// + /// Disposition en arc, pas en grille plate. Une grille plate oblige à tourner + /// les yeux vers ses bords, qui sont alors vus de biais ; un arc centré sur le + /// visiteur présente chaque panneau de face, à distance constante. C'est le même + /// principe qu'un pupitre incurvé. + /// + public class FloatingMenu : MonoBehaviour + { + /// 2,2 m : au-delà le texte devient petit, en deçà l'œil doit converger. + const float RadiusMeters = 2.2f; + + const float HeightMeters = 1.45f; + + /// Écart angulaire entre deux panneaux, assez large pour que le regard tranche. + const float StepDegrees = 16f; + + /// Au-delà, on passe à une seconde rangée plutôt que d'encercler le visiteur. + const int MaxPerRow = 5; + + readonly List _panels = new List(); + + AimSelector _selector; + + /// L'id de la section choisie. + public event Action SectionChosen; + + public static FloatingMenu Create(Transform head) + { + var root = new GameObject("FloatingMenu"); + var menu = root.AddComponent(); + + menu._selector = root.AddComponent(); + menu._selector.Configure(head); + menu._selector.Selected += menu.OnSelected; + + menu._head = head; + menu.Recenter(); + + return menu; + } + + Transform _head; + + /// + /// Pose le menu devant le visiteur. Appelé au démarrage et à chaque nouvelle + /// visite (mode borne) : le visiteur suivant n'arrive pas forcément orienté comme + /// le précédent, et un menu resté dans son dos est un menu introuvable. + /// + /// Entre deux recentrages, le menu ne suit pas la tête : un menu qui suit + /// est impossible à viser, et donne le mal de cœur. + /// + public void Recenter() + { + if (_head == null) return; + + var forward = _head.forward; + forward.y = 0f; + if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward; + + transform.position = new Vector3(_head.position.x, 0f, _head.position.z); + transform.rotation = Quaternion.LookRotation(forward.normalized); + } + + public void Build(ConfigurationExport export, string language) + { + foreach (var panel in _panels) Destroy(panel.gameObject); + _panels.Clear(); + + var sections = export.RootSections(); + + for (var i = 0; i < sections.Count; i++) + { + var title = ConfigurationExport.Translate(sections[i].Title, language) + ?? sections[i].Label; + + var panel = MenuItemPanel.Create(sections[i].Id, title, transform); + Place(panel.transform, i, sections.Count); + + // La cascade : 40 ms d'écart entre deux panneaux. Assez pour qu'on voie + // le menu se construire, assez peu pour que le dernier soit là avant + // qu'on ait fini de tourner la tête. + panel.PlayAppear(i * 0.04f); + + _panels.Add(panel); + } + } + + /// + /// Les vignettes arrivent après coup : le menu doit être utilisable avant que + /// le premier octet d'image ne soit téléchargé. + /// + public async Task LoadImagesAsync(ConfigurationExport export, ApiClient client) + { + var sections = export.RootSections(); + + for (var i = 0; i < sections.Count && i < _panels.Count; i++) + { + var source = sections[i].ImageSource; + if (string.IsNullOrEmpty(source)) continue; + + var path = await ContentCache.MediaPathAsync(client, source); + if (path == null || _panels[i] == null) continue; + + var texture = new Texture2D(2, 2); + if (texture.LoadImage(File.ReadAllBytes(path))) + _panels[i].SetImage(texture); + } + } + + void Place(Transform panel, int index, int total) + { + var row = index / MaxPerRow; + var inRow = index % MaxPerRow; + var countInRow = Mathf.Min(total - row * MaxPerRow, MaxPerRow); + + var angle = (inRow - (countInRow - 1) / 2f) * StepDegrees; + var radians = angle * Mathf.Deg2Rad; + + var height = HeightMeters - row * (MenuItemPanel.HeightMeters + 0.06f); + + panel.localPosition = new Vector3( + Mathf.Sin(radians) * RadiusMeters, + height, + Mathf.Cos(radians) * RadiusMeters); + + // Chaque panneau pivote vers le centre de l'arc — donc vers le visiteur. + panel.localRotation = Quaternion.Euler(0f, angle, 0f); + } + + void OnSelected(IAimTarget target) + { + if (target is MenuItemPanel panel) SectionChosen?.Invoke(panel.Id); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Menu/ImmersiveBackdrop.cs b/unity-overlay/Assets/Scripts/Menu/ImmersiveBackdrop.cs new file mode 100644 index 0000000..7d661af --- /dev/null +++ b/unity-overlay/Assets/Scripts/Menu/ImmersiveBackdrop.cs @@ -0,0 +1,134 @@ +using System; +using System.Threading.Tasks; +using MyInfoMate.Vr.Net; +using UnityEngine; +using UnityEngine.Video; + +namespace MyInfoMate.Vr.Menu +{ + /// + /// Le fond immersif d'une visite — le reste de l'item E5, spécifié au §4 de + /// DOCS/v2/immersif-frontiere-plan.md. + /// + /// Sans lui, le menu flotte dans le noir. C'est le défaut d'Unity : une scène + /// vide est un vide, et un visiteur qui met le casque se retrouve devant six panneaux + /// suspendus dans le néant. Avec le panorama du lieu derrière, il est dans le + /// musée avant d'avoir choisi quoi que ce soit. + /// + /// Trois différences avec , et elles tiennent toutes à la même + /// chose — un fond n'est pas un contenu : + /// + /// on ne le choisit pas et on n'en sort pas : aucun panneau de retour ; + /// il reste en place tant que la visite dure, y compris entre deux sections ; + /// il s'efface devant une 360 ouverte, puis revient — SkyboxView restaure + /// le ciel précédent en se détruisant, et le ciel précédent, c'est celui-ci. + /// + /// + /// Son absence est un cas nominal : un lieu sans fond garde le noir, sans un mot. + /// + public class ImmersiveBackdrop : MonoBehaviour + { + const int VideoWidth = 4096; + const int VideoHeight = 2048; + + Material _sky; + Texture2D _image; + VideoPlayer _player; + RenderTexture _target; + + /// + /// Pose le fond de cette visite, s'il y en a un. Rend le composant créé, ou null — + /// pas de fond n'est pas une erreur. + /// + public static async Task ApplyAsync( + ConfigurationExport export, ApiClient client) + { + var background = export?.ImmersiveBackground; + if (background == null || string.IsNullOrEmpty(background.ResourceUrl)) return null; + + // Une scène 3D en fond est du ressort du viewer de scène, pas du ciel : la + // rendre ici donnerait un décor plaqué sur une sphère. Le §4 la prévoit, E7 + // sait la charger — le jour où on les branche, c'est ici que ça se décide. + if (background.Kind == ConfigurationExport.ImmersiveBackgroundKind.Scene3D) + { + Debug.Log("[Backdrop] Fond de type Scène 3D : pas encore rendu en fond, " + + "le menu reste sur le ciel par défaut."); + return null; + } + + var root = new GameObject("ImmersiveBackdrop"); + var backdrop = root.AddComponent(); + + await backdrop.LoadAsync(background, client); + return backdrop; + } + + async Task LoadAsync(ConfigurationExport.Backdrop background, ApiClient client) + { + _sky = PanoramicSky.CreateMaterial(); + if (_sky == null) return; + + if (background.Kind == ConfigurationExport.ImmersiveBackgroundKind.Video360) + PlayVideo(background, client); + else + await ShowImage(background, client); + } + + async Task ShowImage(ConfigurationExport.Backdrop background, ApiClient client) + { + var texture = await PanoramicSky.LoadEquirectangularAsync(client, background.ResourceUrl); + if (texture == null || this == null) return; + + _image = texture; + _sky.SetTexture("_MainTex", texture); + RenderSettings.skybox = _sky; + } + + /// + /// ⚠️ Une vidéo en fond tourne en permanence. Sur une borne qui fonctionne + /// huit heures, c'est un décodeur matériel occupé toute la journée et une batterie + /// qui descend plus vite — à mesurer avant de la vendre comme le cas normal. Le son + /// est coupé : un fond qui parle par-dessus le commentaire d'une section serait + /// intenable. + /// + void PlayVideo(ConfigurationExport.Backdrop background, ApiClient client) + { + _target = new RenderTexture(VideoWidth, VideoHeight, 0); + + _player = gameObject.AddComponent(); + _player.source = VideoSource.Url; + _player.renderMode = VideoRenderMode.RenderTexture; + _player.targetTexture = _target; + _player.isLooping = true; + _player.audioOutputMode = VideoAudioOutputMode.None; + _player.errorReceived += (_, message) => + Debug.LogError($"[Backdrop] Lecture vidéo impossible : {message}"); + + _sky.SetTexture("_MainTex", _target); + RenderSettings.skybox = _sky; + + StartPlayback(background.ResourceUrl, client); + } + + async void StartPlayback(string url, ApiClient client) + { + var path = await ContentCache.MediaPathAsync(client, url); + if (path == null || this == null || _player == null) return; + + _player.url = new Uri(path).AbsoluteUri; + _player.Play(); + } + + void OnDestroy() + { + // Le ciel est un réglage global. On ne le remet à null que s'il est encore le + // nôtre : une 360 ouverte par-dessus a déjà pris la place, et lui arracher son + // ciel en repassant par ici la laisserait dans le noir. + if (RenderSettings.skybox == _sky) RenderSettings.skybox = null; + + if (_player != null) _player.Stop(); + if (_target != null) _target.Release(); + if (_image != null) Destroy(_image); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Menu/MenuFeedback.cs b/unity-overlay/Assets/Scripts/Menu/MenuFeedback.cs new file mode 100644 index 0000000..f4e0e21 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Menu/MenuFeedback.cs @@ -0,0 +1,112 @@ +using System.Collections; +using UnityEngine; + +namespace MyInfoMate.Vr.Menu +{ + /// + /// Le retour au survol et à la sélection : un son court, et une vibration quand il y + /// a une manette pour la sentir. + /// + /// Ce que ça règle. En visée à la tête, rien ne confirme qu'on a bien accroché + /// un panneau avant que l'anneau ne commence à se remplir — et l'anneau est en + /// périphérie du regard au moment où on l'attend. Un son de 40 ms dit « tu es + /// dessus » avant même que l'œil ne l'ait vu. + /// + /// Les sons sont synthétisés, pas chargés. Deux raisons : aucun fichier à + /// gérer dans un repo qui n'a pas d'assets, et un clic généré est identique sur tous + /// les casques. Ils sont volontairement discrets — une borne de musée qui claque à + /// chaque regard devient insupportable en une heure. + /// + public class MenuFeedback : MonoBehaviour + { + const int SampleRate = 44100; + + static MenuFeedback _instance; + + AudioSource _source; + AudioClip _hover; + AudioClip _select; + + static MenuFeedback Instance + { + get + { + if (_instance != null) return _instance; + + var root = new GameObject("MenuFeedback"); + DontDestroyOnLoad(root); + + _instance = root.AddComponent(); + _instance.Build(); + return _instance; + } + } + + void Build() + { + _source = gameObject.AddComponent(); + _source.playOnAwake = false; + + // 2D : le retour n'appartient pas à un endroit de la pièce, il appartient au + // geste. Le spatialiser le ferait venir d'un panneau qu'on est en train de + // quitter. + _source.spatialBlend = 0f; + + _hover = Tone("hover", 880f, 0.045f, 0.18f); + _select = Tone("select", 1320f, 0.09f, 0.32f, 660f); + } + + /// + /// Une sinusoïde avec une enveloppe qui décroît. + /// non nul fait un glissando : c'est ce qui distingue « choisi » de « survolé » + /// sans avoir à monter le volume. + /// + static AudioClip Tone(string name, float frequency, float seconds, float volume, + float startFrequency = 0f) + { + var samples = Mathf.RoundToInt(SampleRate * seconds); + var data = new float[samples]; + var phase = 0f; + + for (var i = 0; i < samples; i++) + { + var t = i / (float)samples; + var f = startFrequency > 0f ? Mathf.Lerp(startFrequency, frequency, t) : frequency; + + phase += 2f * Mathf.PI * f / SampleRate; + + // Attaque très courte pour éviter le claquement d'un début abrupt, puis + // décroissance exponentielle. + var envelope = Mathf.Min(1f, t * 40f) * Mathf.Exp(-5f * t); + data[i] = Mathf.Sin(phase) * envelope * volume; + } + + var clip = AudioClip.Create(name, samples, 1, SampleRate, false); + clip.SetData(data, 0); + return clip; + } + + public static void Hover() => Instance.Play(Instance._hover, 0.12f, 0.05f); + + public static void Select() => Instance.Play(Instance._select, 0.5f, 0.12f); + + void Play(AudioClip clip, float vibrationAmplitude, float vibrationSeconds) + { + if (clip != null) _source.PlayOneShot(clip); + + var controller = OVRInput.GetActiveController(); + if (controller != OVRInput.Controller.RTouch && controller != OVRInput.Controller.LTouch) + return; + + StopAllCoroutines(); + StartCoroutine(Vibrate(controller, vibrationAmplitude, vibrationSeconds)); + } + + IEnumerator Vibrate(OVRInput.Controller controller, float amplitude, float seconds) + { + OVRInput.SetControllerVibration(0.4f, amplitude, controller); + yield return new WaitForSeconds(seconds); + OVRInput.SetControllerVibration(0f, 0f, controller); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Menu/MenuItemPanel.cs b/unity-overlay/Assets/Scripts/Menu/MenuItemPanel.cs new file mode 100644 index 0000000..ef434ca --- /dev/null +++ b/unity-overlay/Assets/Scripts/Menu/MenuItemPanel.cs @@ -0,0 +1,330 @@ +using MyInfoMate.Vr.Ui; +using UnityEngine; + +namespace MyInfoMate.Vr.Menu +{ + /// + /// Un panneau du menu flottant : une image, un titre, et l'anneau de progression de + /// la visée — item E5 du lot XR-4. + /// + /// L'anneau ne se remplit qu'en visée à la tête : la manette et la main ont une + /// gâchette et un pincement, donc rien à attendre (voir ). + /// + /// Tout est construit par code, avec le même shader que + /// (Universal Render Pipeline/Unlit). + /// Ce n'est pas un détail de style : ce shader est déjà dans Always Included + /// Shaders, et tout shader supplémentaire embarqué coûte des milliers de + /// variantes et des dizaines de minutes de build (voir le README de l'overlay). + /// + /// Les coins arrondis et le liseré ne viennent donc pas d'un shader, mais + /// d'une texture générée une fois et partagée par tous les panneaux : un rectangle + /// arrondi en alpha, avec un bord plus clair. C'est la seule façon d'avoir une + /// matière sans payer une deuxième compilation de variantes. + /// + public class MenuItemPanel : MonoBehaviour, IAimTarget + { + public const float WidthMeters = 0.5f; + public const float HeightMeters = 0.34f; + + static readonly Color Idle = new Color(0.10f, 0.13f, 0.17f, 0.92f); + static readonly Color Hover = new Color(0.20f, 0.42f, 0.60f, 0.98f); + + /// Le panneau visé avance vers le visiteur : c'est ce qui se lit de plus loin. + const float HoverScale = 1.07f; + + const float TransitionSpeed = 12f; + + /// Assez court pour ne pas faire attendre, assez long pour se voir. + const float AppearSeconds = 0.35f; + + /// + /// Ce que le panneau désigne : un id de section dans le menu, une commande + /// (« suivant », « retour ») ailleurs. Le panneau ne sait pas ce que ça veut + /// dire, et c'est ce qui le rend réutilisable. + /// + public string Id { get; private set; } + + Material _background; + LineRenderer _progress; + Transform _image; + + Vector3 _baseScale = Vector3.one; + bool _baseScaleKnown; + + bool _hovered; + float _hover; + + float _appear = 1f; + float _appearElapsed; + bool _appearing; + + public static MenuItemPanel Create(string id, string title, Transform parent) + { + var root = new GameObject($"Menu — {title}"); + root.transform.SetParent(parent, false); + + var panel = root.AddComponent(); + panel.Build(id, title); + return panel; + } + + void Build(string id, string title) + { + Id = id; + + var background = GameObject.CreatePrimitive(PrimitiveType.Quad); + background.name = "Background"; + background.transform.SetParent(transform, false); + background.transform.localScale = new Vector3(WidthMeters, HeightMeters, 1f); + + _background = background.GetComponent().material = + new Material(Shader.Find("Universal Render Pipeline/Unlit")); + _background.mainTexture = PanelTexture; + Transparent(_background); + _background.color = Idle; + + // Double face. La normale d'un Quad et l'orientation lisible d'un texte ne + // pointent pas du même côté : avec le culling par défaut, l'un des deux + // disparaît, et on ne le découvre que dans le casque. + NoCulling(_background); + + // Le collider est ce que la visée touche. Celui d'un Quad est un + // MeshCollider, qu'on ne peut pas passer en trigger sans qu'il soit convexe : + // une boîte fine fait le même travail, couvre tout le panneau — texte + // compris — et ne pose aucune question à la physique. + Destroy(background.GetComponent()); + var box = background.AddComponent(); + box.size = new Vector3(1f, 1f, 0.02f); + + var label = Label.Create(transform, "Title", Label.Anchor.TopCenter, 0.032f, Color.white); + label.transform.localPosition = new Vector3(0f, -HeightMeters * 0.30f, -0.01f); + label.Text = Label.Wrap(title, 22); + + BuildProgressRing(); + } + + void Start() + { + // Après le Create : PagedView réduit ses commandes à 0,55 juste après. Capter + // l'échelle ici évite que le survol et l'apparition l'écrasent. + _baseScale = transform.localScale; + _baseScaleKnown = true; + } + + /// + /// Fait entrer le panneau. décale son arrivée : + /// c'est ce décalage, et lui seul, qui fait la cascade quand le menu se pose. + /// + public void PlayAppear(float delaySeconds) + { + _appearing = true; + _appearElapsed = -delaySeconds; + _appear = 0f; + ApplyScale(); + } + + void Update() + { + _hover = Mathf.MoveTowards( + _hover, _hovered ? 1f : 0f, Time.deltaTime * TransitionSpeed); + + var color = Color.Lerp(Idle, Hover, _hover); + + if (_appearing) + { + _appearElapsed += Time.deltaTime; + var t = Mathf.Clamp01(_appearElapsed / AppearSeconds); + + // Un léger dépassement : le panneau arrive, dépasse sa taille d'un cheveu + // et se pose. Sans ça l'apparition est linéaire, donc mécanique. + _appear = _appearElapsed <= 0f ? 0f : Overshoot(t); + + // La couleur est toujours recalculée depuis Idle/Hover, jamais modifiée + // sur place : une opacité multipliée image après image tomberait à zéro. + color.a *= Mathf.Clamp01(_appearElapsed / (AppearSeconds * 0.6f)); + + if (t >= 1f) + { + _appearing = false; + _appear = 1f; + } + } + + _background.color = color; + ApplyScale(); + } + + static float Overshoot(float t) + { + const float tension = 1.7f; + var u = t - 1f; + return 1f + u * u * ((tension + 1f) * u + tension); + } + + void ApplyScale() + { + if (!_baseScaleKnown) return; + + transform.localScale = _baseScale + * Mathf.Lerp(1f, HoverScale, _hover) + * Mathf.Lerp(0.7f, 1f, _appear); + } + + /// + /// L'image de la section, une fois téléchargée. Un panneau sans image reste + /// lisible : le titre suffit, et une borne hors ligne au premier démarrage n'a + /// encore aucun média en cache. + /// + public void SetImage(Texture2D texture) + { + if (texture == null) return; + + if (_image == null) + { + var quad = GameObject.CreatePrimitive(PrimitiveType.Quad); + quad.name = "Image"; + quad.transform.SetParent(transform, false); + quad.transform.localPosition = new Vector3(0f, HeightMeters * 0.16f, -0.005f); + quad.transform.localScale = new Vector3(WidthMeters * 0.86f, HeightMeters * 0.5f, 1f); + + // L'image ne doit pas intercepter le regard : c'est le panneau qui est + // la cible, sinon le survol s'éteint dès qu'on regarde la vignette. + Destroy(quad.GetComponent()); + _image = quad.transform; + } + + var material = new Material(Shader.Find("Universal Render Pipeline/Unlit")) + { + mainTexture = texture + }; + NoCulling(material); + _image.GetComponent().material = material; + } + + static void NoCulling(Material material) => material.SetFloat("_Cull", 0f); + + /// + /// URP/Unlit est opaque par défaut : sans ces cinq lignes, l'alpha d'une couleur + /// est simplement ignoré et le panneau est un rectangle plein aux coins carrés, + /// texture arrondie ou pas. + /// + static void Transparent(Material material) + { + material.SetFloat("_Surface", 1f); + material.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha); + material.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + material.SetFloat("_ZWrite", 0f); + material.EnableKeyword("_SURFACE_TYPE_TRANSPARENT"); + material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent; + } + + static Texture2D _panelTexture; + + /// + /// Le rectangle arrondi, généré une fois pour tous les panneaux. L'alpha porte la + /// forme et l'anticrénelage du bord ; le RGB porte le liseré, plus clair que + /// l'intérieur — le matériau le teinte ensuite, donc un seul dessin sert au repos + /// comme au survol. + /// + static Texture2D PanelTexture + { + get + { + if (_panelTexture != null) return _panelTexture; + + const int width = 256; + const int height = 176; + const float radius = 30f; + const float border = 3f; + + var texture = new Texture2D(width, height, TextureFormat.RGBA32, false) + { + wrapMode = TextureWrapMode.Clamp, + filterMode = FilterMode.Bilinear + }; + + var pixels = new Color32[width * height]; + + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + { + var distance = RoundedRectDistance(x + 0.5f, y + 0.5f, width, height, radius); + + // distance < 0 dedans, > 0 dehors. 1,2 px de dégradé suffisent à + // effacer l'escalier du bord à 2,20 m. + var alpha = Mathf.Clamp01(0.5f - distance / 1.2f); + var lit = distance > -border ? 1f : 0.72f; + + pixels[y * width + x] = new Color(lit, lit, lit, alpha); + } + + texture.SetPixels32(pixels); + texture.Apply(); + + _panelTexture = texture; + return _panelTexture; + } + } + + /// Distance signée au rectangle arrondi, en pixels. + static float RoundedRectDistance(float x, float y, float width, float height, float radius) + { + var dx = Mathf.Abs(x - width / 2f) - (width / 2f - radius); + var dy = Mathf.Abs(y - height / 2f) - (height / 2f - radius); + + var outside = new Vector2(Mathf.Max(dx, 0f), Mathf.Max(dy, 0f)).magnitude; + return outside + Mathf.Min(Mathf.Max(dx, dy), 0f) - radius; + } + + void BuildProgressRing() + { + const int segments = 48; + + var ring = new GameObject("Progress"); + ring.transform.SetParent(transform, false); + ring.transform.localPosition = new Vector3(0f, 0f, -0.02f); + + _progress = ring.AddComponent(); + _progress.useWorldSpace = false; + _progress.loop = false; + _progress.positionCount = 0; + _progress.widthMultiplier = 0.006f; + _progress.material = new Material(Shader.Find("Universal Render Pipeline/Unlit")); + _progress.material.color = new Color(0.4f, 0.8f, 1f, 0.9f); + + _ringPoints = new Vector3[segments]; + const float radius = 0.055f; + + for (var i = 0; i < segments; i++) + { + // Départ en haut, sens horaire : c'est le sens d'une minuterie, et + // n'importe qui le lit sans explication. + var angle = Mathf.PI / 2f - i * Mathf.PI * 2f / (segments - 1); + _ringPoints[i] = new Vector3(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, 0f); + } + } + + Vector3[] _ringPoints; + + public void OnAimEnter() + { + _hovered = true; + MenuFeedback.Hover(); + } + + public void OnAimProgress(float ratio) + { + var count = Mathf.RoundToInt(ratio * _ringPoints.Length); + _progress.positionCount = count; + + for (var i = 0; i < count; i++) + _progress.SetPosition(i, _ringPoints[i]); + } + + public void OnAimExit() + { + _hovered = false; + _progress.positionCount = 0; + } + } +} diff --git a/unity-overlay/Assets/Scripts/Menu/PagedView.cs b/unity-overlay/Assets/Scripts/Menu/PagedView.cs new file mode 100644 index 0000000..9f8b10c --- /dev/null +++ b/unity-overlay/Assets/Scripts/Menu/PagedView.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace MyInfoMate.Vr.Menu +{ + /// + /// Une pile de pages qu'on feuillette — la vue commune du Slider, de la + /// Map et de l'Event, item E8 du lot XR-4. + /// + /// Pourquoi une seule vue pour trois types. Une galerie de photos, une liste de + /// points d'intérêt et un programme de la journée sont trois contenus différents dans + /// le CMS, mais dans le casque ce sont les mêmes gestes : une chose à la fois, grande, + /// devant soi, et de quoi passer à la suivante. Les trois renderers ne se distinguent + /// que par ce qu'ils mettent dans les pages — c'est le rôle de + /// . + /// + /// Les pages s'ajoutent au fil du chargement, et la première s'affiche dès qu'elle + /// arrive : sur vingt photos, attendre la vingtième ferait passer le chargement pour + /// une panne. + /// + public class PagedView : MonoBehaviour + { + const string CommandPrevious = "__previous"; + const string CommandNext = "__next"; + const string CommandBack = "__back"; + + const float DistanceMeters = 2.5f; + const float ImageWidthMeters = 1.6f; + const float ImageHeightMeters = 0.9f; + const float CenterHeight = 1.5f; + + public class Page + { + /// Le titre de la page. Toujours présent. + public string Heading; + + /// Description, horaire, adresse… Peut être vide. + public string Body; + + /// Nulle sur une page sans média — le texte tient tout seul. + public Texture2D Image; + } + + readonly List _pages = new List(); + + Material _imageMaterial; + Transform _imageQuad; + Ui.Label _text; + MenuItemPanel _previous; + MenuItemPanel _next; + int _index; + + /// Le visiteur veut revenir au menu. + public event Action Closed; + + /// + /// N'importe quelle action du visiteur. Sert au mode borne : feuilleter est une + /// activité, même si la tête ne bouge presque pas. + /// + public event Action Interacted; + + public int PageCount => _pages.Count; + + public static PagedView Create(Transform head) + { + var root = new GameObject("PagedView"); + var view = root.AddComponent(); + + if (head != null) + { + var forward = head.forward; + forward.y = 0f; + if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward; + + root.transform.position = new Vector3(head.position.x, 0f, head.position.z); + root.transform.rotation = Quaternion.LookRotation(forward.normalized); + } + + var selector = root.AddComponent(); + selector.Configure(head); + selector.Selected += view.OnSelected; + + view.Build(); + return view; + } + + void Build() + { + var quad = GameObject.CreatePrimitive(PrimitiveType.Quad); + quad.name = "Image"; + quad.transform.SetParent(transform, false); + quad.transform.localPosition = new Vector3(0f, CenterHeight, DistanceMeters); + quad.transform.localScale = new Vector3(ImageWidthMeters, ImageHeightMeters, 1f); + + // L'image n'est pas une cible de visée : ce sont les trois commandes qui le + // sont, et un collider plein cadre les masquerait. + Destroy(quad.GetComponent()); + + _imageMaterial = new Material(Shader.Find("Universal Render Pipeline/Unlit")); + _imageMaterial.SetFloat("_Cull", 0f); + _imageMaterial.color = new Color(0.08f, 0.10f, 0.13f, 1f); + quad.GetComponent().material = _imageMaterial; + _imageQuad = quad.transform; + + _text = Ui.Label.Create(transform, "Text", Ui.Label.Anchor.TopCenter, 0.048f, Color.white); + _text.transform.localPosition = new Vector3( + 0f, CenterHeight - ImageHeightMeters * 0.62f, DistanceMeters); + + _previous = Command(CommandPrevious, "◀", -ImageWidthMeters * 0.62f, CenterHeight); + _next = Command(CommandNext, "▶", ImageWidthMeters * 0.62f, CenterHeight); + Command(CommandBack, "Retour au menu", 0f, CenterHeight - ImageHeightMeters * 0.95f); + + UpdateCommands(); + } + + MenuItemPanel Command(string id, string label, float x, float y) + { + var panel = MenuItemPanel.Create(id, label, transform); + panel.transform.localPosition = new Vector3(x, y, DistanceMeters - 0.02f); + panel.transform.localScale = Vector3.one * 0.55f; + return panel; + } + + public void AddPage(Page page) + { + _pages.Add(page); + if (_pages.Count == 1) Display(0); + else UpdateCommands(); + } + + /// Quand il n'y a rien à montrer, le dire — pas laisser un cadre vide. + public void ShowEmpty(string message) + { + _text.Text = Wrap(message, 46); + UpdateCommands(); + } + + void Display(int index) + { + if (index < 0 || index >= _pages.Count) return; + + _index = index; + var page = _pages[index]; + + if (page.Image != null) + { + _imageMaterial.mainTexture = page.Image; + _imageMaterial.color = Color.white; + _imageQuad.gameObject.SetActive(true); + } + else + { + // Sans image, le cadre reste — mais sombre : il porte le texte, qui + // devient alors le contenu principal. + _imageMaterial.mainTexture = null; + _imageMaterial.color = new Color(0.08f, 0.10f, 0.13f, 1f); + } + + var counter = _pages.Count > 1 ? $"\n{index + 1} / {_pages.Count}" : ""; + var body = string.IsNullOrEmpty(page.Body) ? "" : $"\n{page.Body}"; + + _text.Text = Wrap(page.Heading, 46) + Wrap(body, 46) + counter; + + UpdateCommands(); + } + + /// + /// Les flèches disparaissent aux extrémités plutôt que de ne rien faire : un + /// bouton présent qui ne réagit pas se lit comme une panne. + /// + void UpdateCommands() + { + if (_previous != null) _previous.gameObject.SetActive(_index > 0); + if (_next != null) _next.gameObject.SetActive(_index < _pages.Count - 1); + } + + void OnSelected(IAimTarget target) + { + if (!(target is MenuItemPanel panel)) return; + + Interacted?.Invoke(); + + switch (panel.Id) + { + case CommandPrevious: Display(_index - 1); break; + case CommandNext: Display(_index + 1); break; + case CommandBack: Closed?.Invoke(); break; + } + } + + static string Wrap(string text, int columns) + { + if (string.IsNullOrEmpty(text)) return ""; + + var wrapped = new System.Text.StringBuilder(); + + // Les retours à la ligne du contenu sont respectés : c'est la mise en forme + // voulue par le gestionnaire, on ne recolle pas ses paragraphes. + foreach (var paragraph in text.Split('\n')) + { + if (wrapped.Length > 0) wrapped.Append('\n'); + + var line = 0; + foreach (var word in paragraph.Split(' ')) + { + if (line + word.Length > columns) + { + wrapped.Append('\n'); + line = 0; + } + else if (line > 0) + { + wrapped.Append(' '); + line++; + } + + wrapped.Append(word); + line += word.Length; + } + } + + return wrapped.ToString(); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Menu/PanoramicSky.cs b/unity-overlay/Assets/Scripts/Menu/PanoramicSky.cs new file mode 100644 index 0000000..843480b --- /dev/null +++ b/unity-overlay/Assets/Scripts/Menu/PanoramicSky.cs @@ -0,0 +1,81 @@ +using System.IO; +using System.Threading.Tasks; +using MyInfoMate.Vr.Net; +using UnityEngine; + +namespace MyInfoMate.Vr.Menu +{ + /// + /// Ce qu'il faut savoir pour faire d'une équirectangulaire un ciel — mis en commun + /// entre la lecture 360 d'une section () et le fond immersif + /// d'une visite (). + /// + /// Deux pièges vivent ici, et une seule fois. Le shader absent du build (ciel + /// magenta sur le casque, correct dans l'éditeur) et la texture décodée en RGBA32 + /// (134 Mo pour une 8192×4096) sont exactement le genre de choses qu'on corrige d'un + /// côté en oubliant l'autre. + /// + public static class PanoramicSky + { + const string PanoramicShader = "Skybox/Panoramic"; + + /// + /// Le matériau de ciel, ou null si le shader n'a pas survécu au build. + /// + /// ⚠️ Skybox/Panoramic est construit au runtime : aucune scène ne le + /// référence, donc le build l'élimine s'il n'est pas dans Project Settings → + /// Graphics → Always Included Shaders. Le symptôme est un ciel magenta sur le + /// casque, et rien du tout dans l'éditeur — c'est le piège déjà payé avec glTFast. + /// + public static Material CreateMaterial() + { + var shader = Shader.Find(PanoramicShader); + if (shader == null) + { + Debug.LogError($"[Sky] Shader « {PanoramicShader} » absent du build. " + + "Project Settings → Graphics → Always Included Shaders."); + return null; + } + + var material = new Material(shader); + material.SetFloat("_Mapping", 1f); // Latitude-longitude (équirectangulaire) + material.SetFloat("_ImageType", 0f); // 360°, pas 180° + return material; + } + + /// + /// La texture d'une image équirectangulaire mise en cache, ou null. + /// + /// ⚠️ Mémoire. LoadImage décode en RGBA32 : une 8192×4096 occupe + /// 134 Mo de mémoire graphique, pour une seule image, sur un casque qui en + /// a quelques Go partagés avec le système. La compression matérielle (ASTC sur + /// Quest) divise ça par six à huit, au prix d'une seconde au chargement et d'une + /// perte invisible sur une photo. C'est elle qui décide si on peut enchaîner + /// plusieurs 360 sans que l'app se fasse tuer. + /// + public static async Task LoadEquirectangularAsync(ApiClient client, string url) + { + var path = await ContentCache.MediaPathAsync(client, url); + if (path == null) return null; + + // Pas de mipmaps : une équirectangulaire est vue à taille réelle, et les + // niveaux intermédiaires coûteraient un tiers de mémoire pour rien. + var texture = new Texture2D(2, 2, TextureFormat.RGBA32, mipChain: false); + if (!texture.LoadImage(File.ReadAllBytes(path))) + { + Debug.LogError($"[Sky] Image illisible : {url}"); + Object.Destroy(texture); + return null; + } + + // Sans cela, la couture verticale du panorama apparaît comme un trait net. + texture.wrapModeU = TextureWrapMode.Repeat; + texture.wrapModeV = TextureWrapMode.Clamp; + + texture.Compress(highQuality: true); + texture.Apply(updateMipmaps: false, makeNoLongerReadable: true); + + return texture; + } + } +} diff --git a/unity-overlay/Assets/Scripts/Menu/Scene3DView.cs b/unity-overlay/Assets/Scripts/Menu/Scene3DView.cs new file mode 100644 index 0000000..7fe5ca0 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Menu/Scene3DView.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using MyInfoMate.Vr.Manifest; +using MyInfoMate.Vr.Net; +using MyInfoMate.Vr.Scene; +using UnityEngine; + +namespace MyInfoMate.Vr.Menu +{ + /// + /// Une maquette 3D et ses points d'intérêt — item E7 du lot XR-4. + /// + /// Ce fichier ne dessine rien. Le moteur existe déjà : + /// charge un GLB, place les hotspots, gère leur visée et leur audio spatialisé — + /// c'est tout le travail de la piste Scène 3D. Ce qui manquait, c'est la + /// source de données : le manifeste venait d'un fichier poussé à la main, + /// il vient maintenant de la section. + /// + /// La traduction tient en trois lignes parce que les deux modèles ont été pensés + /// l'un pour l'autre : un SceneHotspot porte déjà un GeoPointId, et + /// GeoPoint.LocalTransform est dans la convention du manifeste — pas celle + /// d'Unity. La conversion d'axes reste donc au seul endroit qui la connaît. + /// + public class Scene3DView : MonoBehaviour + { + const string ModelAssetId = "model"; + + SceneBuilder _builder; + MenuItemPanel _back; + Transform _head; + + public event Action Closed; + public event Action Interacted; + + public static Scene3DView Create(Transform head) + { + var root = new GameObject("Scene3DView"); + var view = root.AddComponent(); + view._head = head; + + if (head != null) + { + var forward = head.forward; + forward.y = 0f; + if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward; + + root.transform.position = new Vector3(head.position.x, 0f, head.position.z); + root.transform.rotation = Quaternion.LookRotation(forward.normalized); + } + + var selector = root.AddComponent(); + selector.Configure(head); + selector.Selected += view.OnSelected; + + view._back = MenuItemPanel.Create("__back", "Retour au menu", root.transform); + view._back.transform.localPosition = new Vector3(0f, 0.9f, 2.2f); + view._back.transform.localScale = Vector3.one * 0.6f; + + return view; + } + + public async Task LoadAsync(ConfigurationExport.SectionSummary section, + ConfigurationExport export, + string language) + { + var manifest = BuildManifest(section, export, language); + if (manifest == null) return false; + + _builder = gameObject.AddComponent(); + + // ⚠️ Le rig n'est passé qu'en mode **décor**. En mode objet, on regarde la + // chose de l'extérieur : lui donner une zone de navigation la ferait parcourir + // comme un lieu, et le visiteur se retrouverait *dans* l'épée. + var rig = section.Scene3DMode == ConfigurationExport.Scene3DMode.Scene + ? (_head != null ? _head.root : null) + : null; + + return await _builder.BuildAsync(manifest, _head, rig, language); + } + + /// + /// Où poser le modèle. Un objet se présente devant le visiteur, à portée de + /// regard ; un décor l'entoure, donc il reste à l'origine et c'est le visiteur qui + /// est dedans. + /// + static TransformManifest WorldTransformFor(ConfigurationExport.Scene3DMode mode) + { + if (mode == ConfigurationExport.Scene3DMode.Scene) return new TransformManifest(); + + // 1,20 m de haut, 1,50 m devant : la hauteur d'un objet posé sur un socle, + // à la distance où l'on tend la main sans avancer. + return new TransformManifest + { + Position = new[] { 0f, 1.2f, 1.5f }, + Rotation = new[] { 0f, 0f, 0f, 1f }, + Scale = new[] { 1f, 1f, 1f } + }; + } + + /// + /// Le manifeste que le moteur attend, construit depuis la section. Le modèle + /// devient le décor : c'est bien ce qu'il est ici, la scène entière. + /// + SceneManifest BuildManifest(ConfigurationExport.SectionSummary section, + ConfigurationExport export, + string language) + { + var model = export?.FindResource(section.Model3DResourceId); + var url = model?.Url ?? section.Model3DSource; + + if (string.IsNullOrEmpty(url)) + { + Debug.LogError($"[Model3D] {section.Id} : aucun modèle à afficher."); + return null; + } + + var manifest = new SceneManifest + { + ManifestVersion = SceneManifest.SupportedManifestVersion, + SceneId = section.Id, + CoordinateSystem = SceneManifest.ExpectedCoordinateSystem, + DefaultLanguage = language, + World = new WorldManifest + { + Kind = "mesh", + AssetId = ModelAssetId, + Transform = WorldTransformFor(section.Scene3DMode) + }, + Assets = new List + { + new SceneAsset + { + Id = ModelAssetId, + ResourceId = section.Model3DResourceId, + Url = url, + MimeType = "model/gltf-binary" + } + }, + Hotspots = new List() + }; + + foreach (var point in section.Points) + { + if (point.LocalTransform == null) continue; + + manifest.Hotspots.Add(new SceneHotspot + { + Id = $"poi-{point.Id}", + GeoPointId = point.Id ?? 0, + Transform = TransformOf(point.LocalTransform), + Title = Localized(point.Title), + Description = Localized(point.Description), + Contents = ContentsOf(point, manifest) + }); + } + + return manifest; + } + + static TransformManifest TransformOf(ConfigurationExport.Position3D position) + { + var yaw = (position.RotationY ?? 0f) * Mathf.Deg2Rad / 2f; + + return new TransformManifest + { + Position = new[] { position.X, position.Y, position.Z }, + Rotation = new[] { 0f, Mathf.Sin(yaw), 0f, Mathf.Cos(yaw) }, + Scale = new[] { 1f, 1f, 1f } + }; + } + + static List Localized(List texts) + { + var result = new List(); + foreach (var text in texts ?? new List()) + result.Add(new LocalizedText { Language = text.Language, Value = text.Value }); + + return result; + } + + /// + /// Les contenus d'un point portent leurs médias — c'est de là que vient le + /// commentaire audio. Chaque ressource entre aussi dans Assets : le + /// hotspot ne connaît qu'un identifiant, le manifeste seul porte les URL. + /// + static List ContentsOf(ConfigurationExport.GeoPoint point, + SceneManifest manifest) + { + var contents = new List(); + + foreach (var content in point.Contents ?? new List()) + { + var resource = content.Resource; + string assetId = null; + + if (!string.IsNullOrEmpty(resource?.Url)) + { + assetId = $"asset-{resource.Id}"; + manifest.Assets.Add(new SceneAsset + { + Id = assetId, + ResourceId = resource.Id, + Url = resource.Url + }); + } + + contents.Add(new HotspotContent + { + Order = content.Order ?? 0, + Title = Localized(content.Title), + Description = Localized(content.Description), + AssetId = assetId + }); + } + + return contents; + } + + void OnSelected(IAimTarget target) + { + if (!(target is MenuItemPanel panel) || panel.Id != "__back") return; + + Interacted?.Invoke(); + Closed?.Invoke(); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Menu/SectionPages.cs b/unity-overlay/Assets/Scripts/Menu/SectionPages.cs new file mode 100644 index 0000000..4ae3e8d --- /dev/null +++ b/unity-overlay/Assets/Scripts/Menu/SectionPages.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using MyInfoMate.Vr.Net; +using UnityEngine; + +namespace MyInfoMate.Vr.Menu +{ + /// + /// Remplit une à partir d'une section — item E8 du + /// lot XR-4. C'est ici, et nulle part ailleurs, que vit ce qui distingue un Slider + /// d'une Map ou d'un Event. + /// + /// Trois types sur les quatre retenus au §8. Le quatrième, la Video 360, attend + /// la ressource 360° côté backend. Le Parcours est volontairement rendu comme une Map : + /// « chaque étape = un lieu autour de soi » demande de la 3D, donc `SectionScene3D`. + /// + public static class SectionPages + { + /// Les types qu'on sait ouvrir aujourd'hui. + public static bool CanRender(ConfigurationExport.SectionType type) => + type == ConfigurationExport.SectionType.Slider + || type == ConfigurationExport.SectionType.Map + || type == ConfigurationExport.SectionType.Parcours + || type == ConfigurationExport.SectionType.Event; + + public static async Task FillAsync(PagedView view, + ConfigurationExport.SectionSummary section, + ApiClient client, + string language) + { + switch (section.Type) + { + case ConfigurationExport.SectionType.Slider: + await FillSlider(view, section, client, language); + break; + + case ConfigurationExport.SectionType.Map: + case ConfigurationExport.SectionType.Parcours: + await FillMap(view, section, client, language); + break; + + case ConfigurationExport.SectionType.Event: + FillEvent(view, section, language); + break; + } + + if (view != null && view.PageCount == 0) + view.ShowEmpty("Ce contenu est vide."); + } + + static async Task FillSlider(PagedView view, + ConfigurationExport.SectionSummary section, + ApiClient client, + string language) + { + foreach (var content in section.OrderedContents()) + { + var texture = await LoadImage(client, content.Resource?.Url); + if (view == null) return; + if (texture == null) continue; + + view.AddPage(new PagedView.Page + { + Heading = ConfigurationExport.Translate(content.Title, language) ?? "", + Body = ConfigurationExport.Translate(content.Description, language), + Image = texture + }); + } + } + + /// + /// Une Map devient une liste de points d'intérêt, un par page. + /// + /// ⚠️ Ce n'est pas la Map que le plan promet. Sa valeur en VR, c'est « le plan posé + /// devant soi comme une maquette » — et ça demande un modèle 3D, donc + /// `SectionScene3D` (§9). Une carte Google affichée sur un panneau flottant serait + /// moins bonne qu'un téléphone : autant montrer les lieux eux-mêmes, avec + /// leur photo et leurs horaires, ce que le `GeoPoint` porte déjà. + /// + static async Task FillMap(PagedView view, + ConfigurationExport.SectionSummary section, + ApiClient client, + string language) + { + foreach (var point in section.Points) + { + var texture = await LoadImage(client, point.ImageUrl); + if (view == null) return; + + var parts = new List(); + + var description = ConfigurationExport.Translate(point.Description, language); + if (!string.IsNullOrEmpty(description)) parts.Add(description); + + var schedules = ConfigurationExport.Translate(point.Schedules, language); + if (!string.IsNullOrEmpty(schedules)) parts.Add(schedules); + + view.AddPage(new PagedView.Page + { + Heading = ConfigurationExport.Translate(point.Title, language) ?? "", + Body = string.Join("\n", parts), + Image = texture + }); + } + } + + /// + /// Un Event devient le programme, une page par bloc. + /// + /// « Ce qui se passe aujourd'hui » est l'usage visé (§8) : un casque en + /// écran d'attente dans un office de tourisme. On ne montre donc que la journée en + /// cours — et seulement s'il s'y passe quelque chose : un programme vide le jour + /// même vaut mieux rempli par les dates suivantes que pas rempli du tout. + /// + static void FillEvent(PagedView view, + ConfigurationExport.SectionSummary section, + string language) + { + var blocks = new List(section.Programme); + blocks.Sort((a, b) => Nullable.Compare(a.StartTime, b.StartTime)); + + var today = new List(); + var upcoming = new List(); + + foreach (var block in blocks) + { + if (block.StartTime == null) continue; + + var start = block.StartTime.Value.ToLocalTime(); + if (start.Date == DateTime.Now.Date) today.Add(block); + else if (start > DateTime.Now) upcoming.Add(block); + } + + var shown = today.Count > 0 ? today : upcoming; + + foreach (var block in shown) + { + var start = block.StartTime?.ToLocalTime(); + var end = block.EndTime?.ToLocalTime(); + + var when = start == null ? "" : + today.Count > 0 + ? (end == null ? $"{start:HH:mm}" : $"{start:HH:mm} – {end:HH:mm}") + : $"{start:dd/MM} · {start:HH:mm}"; + + var description = ConfigurationExport.Translate(block.Description, language); + + view.AddPage(new PagedView.Page + { + Heading = ConfigurationExport.Translate(block.Title, language) ?? "", + Body = string.IsNullOrEmpty(description) ? when : $"{when}\n{description}" + }); + } + + if (view.PageCount == 0) + view.ShowEmpty("Rien au programme aujourd'hui."); + } + + static async Task LoadImage(ApiClient client, string url) + { + if (string.IsNullOrEmpty(url)) return null; + + var path = await ContentCache.MediaPathAsync(client, url); + if (path == null) return null; + + var texture = new Texture2D(2, 2); + return texture.LoadImage(File.ReadAllBytes(path)) ? texture : null; + } + } +} diff --git a/unity-overlay/Assets/Scripts/Menu/SkyboxView.cs b/unity-overlay/Assets/Scripts/Menu/SkyboxView.cs new file mode 100644 index 0000000..6dbe5d0 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Menu/SkyboxView.cs @@ -0,0 +1,220 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using MyInfoMate.Vr.Net; +using UnityEngine; +using UnityEngine.Video; + +namespace MyInfoMate.Vr.Menu +{ + /// + /// Lecture 360° — item E6 du lot XR-4, et la seule chose que le casque + /// fait mieux que tout le reste. + /// + /// Une photo ou une vidéo équirectangulaire devient le ciel : le visiteur est + /// dedans, il tourne la tête et le monde suit. C'est ce qu'aucune tablette ne + /// sait faire, et c'est pour ça que le plan (§8) dit que la Video 360 « gagne + /// vraiment » là où un Article flottant est moins bon qu'un écran. + /// + /// Les deux cas passent par le même shader de skybox, donc le même rendu : une image + /// devient une texture, une vidéo devient une RenderTexture qu'un + /// VideoPlayer alimente image par image. + /// + /// ⚠️ Ce shader doit être dans Always Included Shaders, comme + /// Universal Render Pipeline/Unlit : construit au runtime, il n'est référencé + /// par aucune scène et le build l'élimine en silence. Le ciel sort alors magenta sur + /// le casque et correct dans l'éditeur — le piège déjà payé une fois avec glTFast + /// (voir le README de l'overlay). + /// + public class SkyboxView : MonoBehaviour + { + /// Résolution de la cible vidéo. 4096×2048 tient sur un Quest 2. + const int VideoWidth = 4096; + const int VideoHeight = 2048; + + Material _skybox; + Material _previousSkybox; + VideoPlayer _player; + RenderTexture _target; + Texture2D _image; + MenuItemPanel _back; + Transform _head; + + public event Action Closed; + public event Action Interacted; + + public static SkyboxView Create(Transform head) + { + var root = new GameObject("SkyboxView"); + var view = root.AddComponent(); + view._head = head; + + if (head != null) + { + var forward = head.forward; + forward.y = 0f; + if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward; + + root.transform.position = new Vector3(head.position.x, 0f, head.position.z); + root.transform.rotation = Quaternion.LookRotation(forward.normalized); + } + + var selector = root.AddComponent(); + selector.Configure(head); + selector.Selected += view.OnSelected; + + view.Build(); + return view; + } + + void Build() + { + _previousSkybox = RenderSettings.skybox; + + _skybox = PanoramicSky.CreateMaterial(); + if (_skybox == null) return; + + // Le seul élément posé dans la scène : de quoi ressortir. + _back = MenuItemPanel.Create("__back", "Regardez ici pour revenir", transform); + _back.transform.localScale = Vector3.one * 0.6f; + PlaceBack(); + } + + /// + /// Le retour ne reste pas affiché. Toute la valeur d'une 360 est d'y être : + /// un panneau planté en permanence dans le décor rappelle qu'on regarde une image, + /// et c'est précisément ce qu'on cherchait à faire oublier. + /// + /// Il se montre secondes au début — assez pour qu'on + /// sache qu'il existe et où le retrouver — puis s'efface. Il revient dès que le + /// visiteur baisse les yeux, le geste qu'on fait naturellement pour chercher + /// une commande, ou dès qu'il touche une manette. Sans ce rappel, quelqu'un en + /// borne resterait coincé dans la photo. + /// + const float VisibleSeconds = 4f; + + /// Sous cet angle, le visiteur cherche quelque chose plutôt qu'il ne regarde. + const float LookDownDegrees = 25f; + + float _hideAt; + + void Update() + { + if (_back == null || _head == null) return; + + var wants = LookingDown() || ControllerInUse(); + + if (wants) + { + if (!_back.gameObject.activeSelf) PlaceBack(); + _hideAt = Time.time + VisibleSeconds; + } + + _back.gameObject.SetActive(Time.time < _hideAt); + } + + bool LookingDown() => Vector3.Angle(_head.forward, Vector3.down) < 90f - LookDownDegrees; + + static bool ControllerInUse() + { + var controller = OVRInput.GetActiveController(); + return controller == OVRInput.Controller.RTouch + || controller == OVRInput.Controller.LTouch; + } + + /// + /// Reposé sous le regard courant à chaque réapparition : en 360 le visiteur tourne + /// sur lui-même, et un panneau laissé à sa position de départ serait dans son dos + /// au moment où il le cherche. + /// + void PlaceBack() + { + if (_back == null) return; + + var forward = _head != null ? _head.forward : Vector3.forward; + forward.y = 0f; + if (forward.sqrMagnitude < 0.01f) forward = Vector3.forward; + forward.Normalize(); + + _back.transform.position = + (_head != null ? _head.position : Vector3.zero) + forward * 2.2f + Vector3.down * 0.8f; + _back.transform.rotation = Quaternion.LookRotation(forward); + _back.gameObject.SetActive(true); + } + + public async Task LoadAsync(ConfigurationExport.Resource resource, ApiClient client) + { + if (_skybox == null) return; + + if (resource.Type == ConfigurationExport.ResourceKind.Video360) + await PlayVideo(resource, client); + else + await ShowImage(resource, client); + } + + async Task ShowImage(ConfigurationExport.Resource resource, ApiClient client) + { + var texture = await PanoramicSky.LoadEquirectangularAsync(client, resource.Url); + if (texture == null || this == null) return; + + _image = texture; + _skybox.SetTexture("_MainTex", texture); + RenderSettings.skybox = _skybox; + + // Le panneau de retour se montre au début, puis s'efface. + _hideAt = Time.time + VisibleSeconds; + } + + async Task PlayVideo(ConfigurationExport.Resource resource, ApiClient client) + { + // Une vidéo 360 pèse des centaines de Mo : elle est lue depuis le cache + // disque, pas gardée en mémoire. + var path = await ContentCache.MediaPathAsync(client, resource.Url); + if (path == null || this == null) return; + + _target = new RenderTexture(VideoWidth, VideoHeight, 0); + + _player = gameObject.AddComponent(); + _player.source = VideoSource.Url; + _player.url = new Uri(path).AbsoluteUri; + _player.renderMode = VideoRenderMode.RenderTexture; + _player.targetTexture = _target; + _player.isLooping = true; + _player.audioOutputMode = VideoAudioOutputMode.Direct; + _player.errorReceived += (_, message) => + Debug.LogError($"[Skybox] Lecture vidéo impossible : {message}"); + + _skybox.SetTexture("_MainTex", _target); + RenderSettings.skybox = _skybox; + + _hideAt = Time.time + VisibleSeconds; + _player.Play(); + } + + void OnSelected(IAimTarget target) + { + if (!(target is MenuItemPanel panel) || panel.Id != "__back") return; + + Interacted?.Invoke(); + Closed?.Invoke(); + } + + /// + /// Le ciel est un réglage global : sans cette remise en état, le menu + /// s'afficherait par-dessus la dernière image 360 vue, et le visiteur suivant + /// hériterait du décor du précédent. + /// + void OnDestroy() + { + RenderSettings.skybox = _previousSkybox; + + if (_player != null) _player.Stop(); + if (_target != null) _target.Release(); + + // Une équirectangulaire pèse des dizaines de Mo même compressée : laissée + // derrière, elle s'accumulerait à chaque 360 ouverte jusqu'à ce que le + // système tue l'app — au bout de plusieurs visites, donc jamais en test court. + if (_image != null) Destroy(_image); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Net/ApiClient.cs b/unity-overlay/Assets/Scripts/Net/ApiClient.cs new file mode 100644 index 0000000..eb33143 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Net/ApiClient.cs @@ -0,0 +1,160 @@ +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using UnityEngine; +using UnityEngine.Networking; + +namespace MyInfoMate.Vr.Net +{ + /// + /// Le seul endroit du projet qui parle à manager-service. + /// + /// Trois règles tenues ici, parce qu'elles se paient cher ailleurs : + /// la clé d'API part en en-tête X-Api-Key et jamais dans l'URL ; + /// un échec produit une phrase lisible comme , + /// jamais un null silencieux ; et le JSON est en camelCase côté serveur, + /// PascalCase côté C#. + /// + public class ApiClient + { + public const string ApiKeyHeader = "X-Api-Key"; + + /// Une borne sur un wifi de musée : ni instantané, ni infini. + const int TimeoutSeconds = 20; + + static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() + }, + MissingMemberHandling = MissingMemberHandling.Ignore, + NullValueHandling = NullValueHandling.Ignore + }; + + public string BaseUrl { get; } + + /// Null tant que l'appairage n'a pas eu lieu. + public string ApiKey { get; set; } + + public ApiClient(string baseUrl, string apiKey = null) + { + // Une base d'URL avec un slash final produit "//api/..." — accepté par ASP.NET, + // mais pas par tous les proxies devant. + BaseUrl = baseUrl?.TrimEnd('/'); + ApiKey = apiKey; + } + + public class Result + { + public T Value; + + /// Null si tout va bien ; sinon une phrase destinée au visiteur. + public string Error; + + public bool Ok => Error == null; + } + + public Task> GetAsync(string path) => + SendAsync(UnityWebRequest.Get(BaseUrl + path), path); + + public Task> PostAsync(string path, object body) => + SendBody(path, body, UnityWebRequest.kHttpVerbPOST); + + public Task> PutAsync(string path, object body) => + SendBody(path, body, UnityWebRequest.kHttpVerbPUT); + + Task> SendBody(string path, object body, string verb) + { + var json = JsonConvert.SerializeObject(body, Settings); + var request = new UnityWebRequest(BaseUrl + path, verb) + { + uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json)), + downloadHandler = new DownloadHandlerBuffer() + }; + request.SetRequestHeader("Content-Type", "application/json"); + return SendAsync(request, path); + } + + /// + /// Le texte brut, pour l'export de configuration : c'est un + /// FileContentResult côté serveur, et on veut pouvoir l'écrire tel quel + /// dans le cache disque avant de le désérialiser. + /// + public async Task> GetRawAsync(string path) + { + using var request = UnityWebRequest.Get(BaseUrl + path); + var sent = await Send(request, path); + return sent != null + ? new Result { Error = sent } + : new Result { Value = request.downloadHandler.text }; + } + + async Task> SendAsync(UnityWebRequest request, string path) + { + using (request) + { + var sent = await Send(request, path); + if (sent != null) return new Result { Error = sent }; + + // Un 204 n'a pas de corps — c'est le cas nominal de la télémétrie, pas + // une réponse illisible. + if (string.IsNullOrWhiteSpace(request.downloadHandler?.text)) + return new Result(); + + try + { + return new Result + { + Value = JsonConvert.DeserializeObject( + request.downloadHandler.text, Settings) + }; + } + catch (JsonException e) + { + Debug.LogError($"[Api] {path} : réponse illisible — {e.Message}"); + return new Result { Error = "Le serveur a répondu quelque chose d'inattendu." }; + } + } + } + + /// Null si la requête est passée ; sinon la phrase à afficher. + async Task Send(UnityWebRequest request, string path) + { + request.timeout = TimeoutSeconds; + if (!string.IsNullOrEmpty(ApiKey)) request.SetRequestHeader(ApiKeyHeader, ApiKey); + + UnityWebRequestAsyncOperation operation; + try + { + operation = request.SendWebRequest(); + } + catch (System.InvalidOperationException e) + { + // Le cas connu : « Insecure connection not allowed », quand le Player + // interdit le HTTP en clair et que le serveur est en http://. Ça lève + // au lieu de rendre un code, donc sans ce catch l'await remonte + // l'exception et le casque n'affiche rien du tout. + Debug.LogError($"[Api] {path} : requête refusée avant l'envoi — {e.Message}"); + return "Le serveur est injoignable. Vérifiez la connexion du casque."; + } + + while (!operation.isDone) await Task.Yield(); + + if (request.result == UnityWebRequest.Result.Success) return null; + + Debug.LogError($"[Api] {path} : {(long)request.responseCode} {request.error}"); + + // Ces trois codes-là ne sont pas des pannes réseau, ce sont des erreurs + // d'appairage — et c'est ce que la personne devant le casque doit lire. + return request.responseCode switch + { + 401 => "Ce casque n'est plus autorisé. Refaites l'appairage.", + 403 => "Ce casque n'a pas accès à ce contenu.", + 404 => "Ce contenu n'existe plus sur le serveur.", + _ => "Le serveur est injoignable. Vérifiez la connexion du casque." + }; + } + } +} diff --git a/unity-overlay/Assets/Scripts/Net/ConfigurationExport.cs b/unity-overlay/Assets/Scripts/Net/ConfigurationExport.cs new file mode 100644 index 0000000..196ff6a --- /dev/null +++ b/unity-overlay/Assets/Scripts/Net/ConfigurationExport.cs @@ -0,0 +1,359 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Newtonsoft.Json; +using UnityEngine; + +namespace MyInfoMate.Vr.Net +{ + /// + /// Lecture de l'export de configuration — item E3 du lot XR-4. + /// + /// Un seul appel pour tout le contenu : c'est l'arbitrage du lot XR-3, et la + /// raison pour laquelle Unity n'a pas besoin du client généré. ExportConfigurationDTO + /// est le DTO de l'import/export du back-office, déjà stable, et c'est aussi celui que + /// mymuseum-visitapp télécharge pour une visite hors ligne. + /// + /// ⚠️ L'export ne rend qu'une langue à la fois. language traverse jusqu'à + /// Section.GetReferencedResourceIds(language) côté serveur : changer de langue + /// veut dire refaire l'appel, et un casque en borne change de langue à chaud. C'est la + /// question ouverte du lot XR-3 ; en attendant, on recharge. + /// + public class ConfigurationExport + { + public string Id; + public string Label; + public List Sections = new List(); + + /// + /// Tous les médias référencés par la configuration, dans un seul appel. C'est ce + /// qui permet de résoudre la ressource d'une section sans repasser par le réseau. + /// + public List Resources = new List(); + + /// + /// Valeurs de ResourceType côté serveur, persistées en int. Seules + /// celles dont le casque a besoin sont nommées ici ; les autres passent en nombre + /// sans rien casser. + /// + public enum ResourceKind + { + Image = 0, Video = 1, ImageUrl = 2, VideoUrl = 3, Audio = 4, PDF = 5, + JSON = 6, JSONUrl = 7, Word = 8, PowerPoint = 9, Text = 10, + Image360 = 11, Video360 = 12, Model3D = 13 + } + + /// La ressource d'un id, ou null si la configuration ne la porte pas. + public Resource FindResource(string id) + { + if (string.IsNullOrEmpty(id)) return null; + + foreach (var resource in Resources) + if (resource.Id == id) return resource; + + return null; + } + + /// + /// Les 13 types de SectionDTO, persistés en int. On n'en rend que quatre + /// (§8 du plan) — mais on doit tous les lire sans casser, sinon un contenu + /// qui n'est pas pour nous ferait échouer la visite entière. + /// + public enum SectionType + { + Map = 0, Slider = 1, Video = 2, Web = 3, Menu = 4, Quiz = 5, + Article = 6, PDF = 7, Game = 8, Agenda = 9, Weather = 10, + Event = 11, Parcours = 12, + + /// Scène 3D avec points d'intérêt (E5/E7), ajoutée le 2026-09-12. + Scene3D = 13 + } + + /// + /// Ce que le visiteur fait de la scène, et c'est une opposition franche (§4bis du + /// plan de frontière) : soit il manipule un objet posé devant lui — l'épée + /// du roi, caméra orbitale, les points tournent avec —, soit il est dedans + /// et regarde autour — un décor, caméra fixe, les points restent où ils sont. + /// + /// Même GLB, même modèle de point, même éditeur. Ce qui change, c'est où l'on met + /// le visiteur — et ça, aucun fichier ne peut le deviner. + /// + public enum Scene3DMode + { + Asset = 0, + Scene = 1 + } + + /// + /// Le fond du lieu — §4 du plan de frontière. Null quand la visite n'en a pas, + /// ce qui est le cas de toutes celles écrites avant le 2026-09-12. + /// + public Backdrop ImmersiveBackground; + + public enum ImmersiveBackgroundKind + { + Pano = 0, + Video360 = 1, + Scene3D = 2 + } + + /// + /// Nommée Backdrop et non ImmersiveBackground : en C# un champ ne + /// peut pas porter le nom d'un type imbriqué de la même classe, et c'est le champ + /// qui doit garder le nom du JSON. + /// + public class Backdrop + { + public string ResourceId; + public ImmersiveBackgroundKind Kind; + + /// + /// URL posée par le serveur. Le casque n'a pas de client généré et démarre + /// souvent sans réseau : résoudre l'id ici serait un appel qu'il ne peut pas + /// passer. + /// + public string ResourceUrl; + + /// Image plate, pour les canaux qui ne rendent pas l'immersif. + public string FallbackResourceId; + + public string FallbackUrl; + } + + public class SectionSummary + { + public string Id; + public string Label; + public SectionType Type; + public bool IsActive; + public bool IsSubSection; + public string ParentId; + public int? Order; + public string ImageSource; + public List Title = new List(); + public List Description = new List(); + + /// + /// Les médias d'un Slider. Le champ n'existe que sur les types qui en ont — + /// l'export sérialise le sous-type réel de chaque section, pas un `SectionDTO` + /// nu — et reste vide partout ailleurs. + /// + public List Contents = new List(); + + /// Les points d'intérêt d'une Map. + public List Points = new List(); + + /// Le programme d'un Event. + public List Programme = new List(); + + /// Vrai si cette Map porte des parcours guidés plutôt que des POI libres. + public bool IsParcours; + + /// + /// Média d'une section Video : soit un id de ressource, soit une URL + /// (YouTube, Vimeo) — c'est le même champ côté serveur, et seul le premier cas + /// est téléchargeable hors ligne. + /// + public string Source; + + public bool SourceIsUrl => + !string.IsNullOrEmpty(Source) + && Source.StartsWith("http", StringComparison.OrdinalIgnoreCase); + + /// Ressource GLB d'une section scène 3D. + public string Model3DResourceId; + + /// URL du modèle, remplie par le serveur comme ImageSource. + public string Model3DSource; + + /// Objet manipulé ou décor habité. Voir . + public Scene3DMode Scene3DMode; + + /// Les médias, dans l'ordre voulu par le gestionnaire. + public List OrderedContents() + { + var ordered = new List(Contents); + ordered.Sort((a, b) => (a.Order ?? int.MaxValue).CompareTo(b.Order ?? int.MaxValue)); + return ordered; + } + } + + public class Content + { + public int? Order; + public string ResourceId; + public Resource Resource; + public List Title = new List(); + public List Description = new List(); + } + + /// + /// Un point d'intérêt. Le même objet porte déjà titre, description, image et + /// contenus multilingues — c'est ce qui rendra les POI sur modèle 3D presque + /// gratuits le jour où `SectionScene3D` existera (§9 du plan). + /// + public class GeoPoint + { + public int? Id; + public string ImageUrl; + + /// + /// Position sur une maquette 3D, nulle sur un point de carte. Exprimée dans + /// la convention glTF du manifeste : la conversion vers Unity se fait + /// dans GltfSpace, et nulle part ailleurs. + /// + public Position3D LocalTransform; + public List Title = new List(); + public List Description = new List(); + public List Schedules = new List(); + public List Contents = new List(); + } + + public class Position3D + { + public float X; + public float Y; + public float Z; + public float? RotationY; + } + + public class ProgrammeBlock + { + public string Id; + public DateTime? StartTime; + public DateTime? EndTime; + public List Title = new List(); + public List Description = new List(); + } + + public class Resource + { + public string Id; + public string Label; + public ResourceKind Type; + + /// L'URL du fichier. C'est elle qui alimente le cache disque. + public string Url; + + public int? Width; + public int? Height; + + /// Une ressource que le casque sait afficher en immersif. + public bool IsImmersive => + Type == ResourceKind.Image360 || Type == ResourceKind.Video360; + } + + public class Translation + { + public string Language; + public string Value; + } + + /// Le texte dans la langue demandée, ou la première traduction disponible. + public static string Translate(List translations, string language) + { + if (translations == null || translations.Count == 0) return null; + + foreach (var t in translations) + if (string.Equals(t.Language, language, System.StringComparison.OrdinalIgnoreCase)) + return PlainText(t.Value); + + return PlainText(translations[0].Value); + } + + /// + /// Les textes du manager sont saisis dans un éditeur riche : un titre de section + /// arrive en <p>Quiz test</p>. Un TextMesh ne connaît pas + /// le HTML et affiche les balises telles quelles. + /// + /// C'est ici et nulle part ailleurs, parce que est le seul + /// chemin par lequel un texte de l'export atteint l'écran — menu, pages, hotspots. + /// + static string PlainText(string html) + { + if (string.IsNullOrEmpty(html)) return html; + + var text = System.Text.RegularExpressions.Regex.Replace( + html, "|

||", "\n"); + + text = System.Text.RegularExpressions.Regex.Replace(text, "<[^>]+>", ""); + + text = text.Replace(" ", " ").Replace("&", "&") + .Replace("<", "<").Replace(">", ">") + .Replace(""", "\"").Replace("'", "'"); + + return text.Trim(); + } + + /// Les sections de premier niveau, dans l'ordre du manager. + public List RootSections() + { + var roots = new List(); + foreach (var s in Sections) + if (!s.IsSubSection && s.IsActive) roots.Add(s); + + roots.Sort((a, b) => (a.Order ?? int.MaxValue).CompareTo(b.Order ?? int.MaxValue)); + return roots; + } + + /// + /// ⚠️ Volontairement sans paramètre de langue. C'était la question ouverte + /// du lot XR-3 — « l'export doit-il rendre toutes les langues d'un coup pour un + /// casque en borne ? » — et la réponse était déjà dans le code : language + /// ne filtre que les ressources (les audios d'une langue), les textes étant + /// toujours rendus dans toutes leurs traductions. Omettre le paramètre rend donc + /// tout : GetReferencedResourceIds(null) renvoie les médias de toutes les + /// langues. + /// + /// Conséquence concrète : changer de langue à chaud ne demande aucun appel, + /// et le cache disque en garde une copie au lieu d'une par langue. + /// + public static async Task> FetchAsync( + ApiClient client, string configurationId) + { + var raw = await client.GetRawAsync( + $"/api/configuration/{configurationId}/export"); + + if (!raw.Ok) return new ApiClient.Result { Error = raw.Error }; + + return Parse(raw.Value); + } + + public static ApiClient.Result Parse(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return Fail("Le contenu de ce lieu est vide."); + + try + { + var export = JsonConvert.DeserializeObject(json, + new JsonSerializerSettings + { + // L'export est en camelCase, et un type de section inconnu d'une + // version future ne doit pas faire échouer la lecture. + ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver + { + NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy() + }, + MissingMemberHandling = MissingMemberHandling.Ignore, + NullValueHandling = NullValueHandling.Ignore + }); + + if (export == null) return Fail("Le contenu de ce lieu est illisible."); + + return new ApiClient.Result { Value = export }; + } + catch (JsonException e) + { + Debug.LogError($"[Export] JSON illisible : {e.Message}"); + return Fail("Le contenu de ce lieu est illisible."); + } + } + + static ApiClient.Result Fail(string error) + { + Debug.LogError($"[Export] {error}"); + return new ApiClient.Result { Error = error }; + } + } +} diff --git a/unity-overlay/Assets/Scripts/Net/ContentCache.cs b/unity-overlay/Assets/Scripts/Net/ContentCache.cs new file mode 100644 index 0000000..32ecc9a --- /dev/null +++ b/unity-overlay/Assets/Scripts/Net/ContentCache.cs @@ -0,0 +1,180 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using UnityEngine; +using UnityEngine.Networking; + +namespace MyInfoMate.Vr.Net +{ + /// + /// Cache disque du contenu — item E4 du lot XR-4. + /// + /// C'est l'item qui justifie Unity plutôt que WebXR (§10 du plan) : une borne + /// d'accueil tourne 8 h par jour sans personne, et le wifi d'un musée tombe. En natif + /// on écrit sur le disque, sans quota de navigateur. + /// + /// La règle est cache d'abord, jamais réseau d'abord : l'app démarre sur ce + /// qu'elle a, et se rafraîchit ensuite. Un serveur lent ou absent ne doit pas + /// retarder d'une seconde l'affichage d'un contenu déjà téléchargé. + /// + /// Rien n'est jamais servi à moitié : le JSON est écrit dans un fichier temporaire + /// puis déplacé. Une coupure de courant pendant une écriture — le cas nominal pour + /// une borne qu'on débranche le soir — laisse l'ancienne version intacte, jamais un + /// fichier tronqué qui ne se relit pas. + /// + public static class ContentCache + { + static string Root => Path.Combine(Application.persistentDataPath, "content"); + + // Un seul fichier par configuration, pas un par langue : l'export les porte + // toutes (voir ConfigurationExport.FetchAsync). + static string ExportPath(string configurationId) => + Path.Combine(Root, $"{configurationId}.json"); + + static string MediaDirectory => Path.Combine(Root, "media"); + + /// Le contenu en cache, ou null si ce casque n'a jamais rien téléchargé. + public static string ReadExport(string configurationId) + { + var path = ExportPath(configurationId); + + try + { + return File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : null; + } + catch (IOException e) + { + Debug.LogError($"[Cache] Lecture de {path} impossible : {e.Message}"); + return null; + } + } + + public static DateTime? ExportDate(string configurationId) + { + var path = ExportPath(configurationId); + return File.Exists(path) ? File.GetLastWriteTimeUtc(path) : (DateTime?)null; + } + + public static void WriteExport(string configurationId, string json) + { + var path = ExportPath(configurationId); + var temporary = path + ".tmp"; + + try + { + Directory.CreateDirectory(Root); + File.WriteAllText(temporary, json, Encoding.UTF8); + + // File.Move ne remplace pas sur toutes les plateformes ; le couple + // delete + move est la seule forme qui marche partout, et la fenêtre + // entre les deux est couverte par le .tmp qui reste lisible. + if (File.Exists(path)) File.Delete(path); + File.Move(temporary, path); + } + catch (IOException e) + { + Debug.LogError($"[Cache] Écriture de {path} impossible : {e.Message}"); + } + } + + /// + /// Le fichier local d'un média déjà téléchargé, ou null. Sans réseau et + /// sans attente : c'est ce qui permet à un appelant synchrone — une coroutine, + /// typiquement — de jouer un son depuis le disque au lieu de le streamer. + /// + public static string CachedPath(string url) + { + if (string.IsNullOrEmpty(url)) return null; + + var path = LocalPathOf(url); + return File.Exists(path) ? path : null; + } + + static string LocalPathOf(string url) => + Path.Combine(MediaDirectory, HashOf(url) + ExtensionOf(url)); + + /// + /// Le fichier local d'un média, téléchargé si absent. Le nom vient d'un hachage + /// de l'URL : les sources du CMS ne sont pas des noms de fichiers sûrs, et deux + /// ressources peuvent porter le même nom d'affichage. + /// + public static async Task MediaPathAsync(ApiClient client, string url) + { + if (string.IsNullOrEmpty(url)) return null; + + var path = LocalPathOf(url); + if (File.Exists(path)) return path; + + using var request = UnityWebRequest.Get(url); + var operation = request.SendWebRequest(); + while (!operation.isDone) await Task.Yield(); + + if (request.result != UnityWebRequest.Result.Success) + { + Debug.LogError($"[Cache] {url} : {request.error}"); + return null; + } + + try + { + Directory.CreateDirectory(MediaDirectory); + var temporary = path + ".tmp"; + File.WriteAllBytes(temporary, request.downloadHandler.data); + if (File.Exists(path)) File.Delete(path); + File.Move(temporary, path); + return path; + } + catch (IOException e) + { + Debug.LogError($"[Cache] Écriture de {path} impossible : {e.Message}"); + return null; + } + } + + /// Octets occupés par le cache. À afficher un jour dans le manager. + public static long SizeBytes() + { + if (!Directory.Exists(Root)) return 0; + + long total = 0; + foreach (var file in Directory.GetFiles(Root, "*", SearchOption.AllDirectories)) + total += new FileInfo(file).Length; + + return total; + } + + public static void Clear() + { + try + { + if (Directory.Exists(Root)) Directory.Delete(Root, true); + } + catch (IOException e) + { + Debug.LogError($"[Cache] Purge impossible : {e.Message}"); + } + } + + static string HashOf(string value) + { + using var sha = SHA1.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(value)); + var hex = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) hex.Append(b.ToString("x2")); + return hex.ToString(); + } + + /// + /// L'extension compte : Unity choisit son décodeur vidéo et son importeur de + /// texture dessus. Une URL avec une query string ne doit pas la faire perdre. + /// + static string ExtensionOf(string url) + { + var withoutQuery = url.Split('?')[0]; + var extension = Path.GetExtension(withoutQuery); + return string.IsNullOrEmpty(extension) || extension.Length > 6 ? "" : extension; + } + } +} diff --git a/unity-overlay/Assets/Scripts/Net/ContentPreloader.cs b/unity-overlay/Assets/Scripts/Net/ContentPreloader.cs new file mode 100644 index 0000000..4dd74b1 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Net/ContentPreloader.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEngine; + +namespace MyInfoMate.Vr.Net +{ + /// + /// Préchargement des médias d'une visite — la seconde moitié de l'item E4. + /// + /// Le cache disque seul ne suffit pas. Il se remplissait à la demande : une + /// borne branchée dans une salle sans wifi avait son JSON, ses titres, son menu — et + /// pas une image. Le contenu n'arrivait que si le visiteur ouvrait la section + /// pendant que le réseau était là, ce qui est exactement l'inverse de ce qu'on + /// promet. C'est l'argument n°1 qui a fait choisir Unity contre WebXR (§10 du plan) ; + /// sans cette classe, il n'était pas tenu. + /// + /// Trois règles, toutes dictées par l'exploitation : + /// + /// Après le menu, jamais avant. Le visiteur voit son menu en une + /// seconde ; le téléchargement se fait derrière lui. Bloquer le démarrage sur + /// des gigaoctets de vidéo 360 ferait d'une borne un écran d'attente. + /// Un par un. Dix téléchargements parallèles sur le wifi d'un musée se + /// gênent, et une 360 de 2 Go n'a rien à gagner à partager la bande passante + /// avec neuf vignettes. + /// Un échec n'arrête rien. Chaque média manquant sera retenté au + /// prochain démarrage ; les autres sont déjà là. + /// + /// + public static class ContentPreloader + { + public struct Progress + { + public int Done; + public int Total; + public int Failed; + + /// Médias déjà sur le disque au démarrage — jamais retéléchargés. + public int AlreadyCached; + } + + /// + /// Télécharge tout ce que le casque saura afficher hors ligne. Rend le bilan, qui + /// n'a d'autre usage que le journal : rien de tout ceci ne doit se voir. + /// + public static async Task RunAsync( + ConfigurationExport export, ApiClient client, Action advanced = null) + { + var urls = MediaUrlsOf(export); + var progress = new Progress { Total = urls.Count }; + + if (urls.Count == 0) return progress; + + Debug.Log($"[Preload] {urls.Count} médias à mettre en cache."); + + foreach (var url in urls) + { + if (ContentCache.CachedPath(url) != null) + { + progress.AlreadyCached++; + progress.Done++; + } + else if (await ContentCache.MediaPathAsync(client, url) != null) + { + progress.Done++; + } + else + { + // Volontairement silencieux côté visiteur : ContentCache a déjà + // journalisé la cause, et un média manquant n'invalide pas la visite. + progress.Failed++; + } + + advanced?.Invoke(progress); + } + + Debug.Log($"[Preload] Terminé — {progress.Done}/{progress.Total} disponibles " + + $"hors ligne ({progress.AlreadyCached} déjà en cache, " + + $"{progress.Failed} en échec), {ContentCache.SizeBytes() / 1048576} Mo sur le disque."); + + return progress; + } + + /// + /// Toutes les URL téléchargeables de la configuration, sans doublon et dans un + /// ordre utile : les vignettes du menu d'abord, parce que ce sont elles + /// qu'on voit en premier, et les gros médias ensuite. + /// + /// ⚠️ Les types ImageUrl / VideoUrl sont écartés : ce sont des + /// liens externes (YouTube, Vimeo), qui ne se mettent pas en cache et ne + /// s'affichent pas hors ligne. Les PDF et les JSON aussi — le casque ne les rend + /// pas (§8 du plan), les télécharger ne ferait que remplir le disque. + /// + public static List MediaUrlsOf(ConfigurationExport export) + { + var urls = new List(); + var seen = new HashSet(); + + void Add(string url) + { + if (string.IsNullOrEmpty(url)) return; + if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return; + if (seen.Add(url)) urls.Add(url); + } + + if (export?.Sections != null) + foreach (var section in export.Sections) + { + Add(section.ImageSource); + Add(section.Model3DSource); + + if (section.Points != null) + foreach (var point in section.Points) + Add(point.ImageUrl); + } + + if (export?.Resources != null) + foreach (var resource in export.Resources) + if (IsDownloadable(resource.Type)) + Add(resource.Url); + + return urls; + } + + static bool IsDownloadable(ConfigurationExport.ResourceKind kind) + { + switch (kind) + { + case ConfigurationExport.ResourceKind.Image: + case ConfigurationExport.ResourceKind.Video: + case ConfigurationExport.ResourceKind.Audio: + case ConfigurationExport.ResourceKind.Image360: + case ConfigurationExport.ResourceKind.Video360: + case ConfigurationExport.ResourceKind.Model3D: + return true; + default: + return false; + } + } + } +} diff --git a/unity-overlay/Assets/Scripts/Net/ContentService.cs b/unity-overlay/Assets/Scripts/Net/ContentService.cs new file mode 100644 index 0000000..f2f9769 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Net/ContentService.cs @@ -0,0 +1,112 @@ +using System; +using System.Threading.Tasks; +using UnityEngine; + +namespace MyInfoMate.Vr.Net +{ + /// + /// Le contenu d'un lieu, servi cache d'abord — item E4 du lot XR-4. + /// + /// Deux chemins, jamais mélangés : + /// + /// Au démarrage — le cache s'il existe, affiché tout de suite ; + /// le réseau seulement s'il n'y a rien en cache. + /// Ensuite — un rafraîchissement en tâche de fond, dont l'échec est + /// silencieux : une borne hors ligne affiche son contenu de la veille, et + /// ce n'est pas une erreur à montrer au visiteur. + /// + /// + /// C'est aussi ce qui rendra la reprise après coupure gratuite : au redémarrage, le + /// cache est déjà là. + /// + public class ContentService + { + readonly ApiClient _client; + readonly string _configurationId; + + public ContentService(ApiClient client, string configurationId) + { + _client = client; + _configurationId = configurationId; + } + + public class Loaded + { + public ConfigurationExport Export; + + /// Vrai si le contenu vient du disque et non du serveur. + public bool FromCache; + + /// Date du cache servi, pour l'afficher en mode dégradé. + public DateTime? CachedAt; + + public string Error; + + public bool Ok => Error == null; + } + + public async Task LoadAsync() + { + var cached = ContentCache.ReadExport(_configurationId); + + if (cached != null) + { + var parsed = ConfigurationExport.Parse(cached); + if (parsed.Ok) + return new Loaded + { + Export = parsed.Value, + FromCache = true, + CachedAt = ContentCache.ExportDate(_configurationId) + }; + + // Un cache illisible est un cache mort : on ne le garde pas pour + // rejouer le même échec au prochain démarrage. + Debug.LogWarning("[Content] Cache illisible, il est écarté."); + } + + return await FetchAsync(); + } + + /// + /// Rafraîchit le cache sans bloquer l'affichage. Renvoie le contenu neuf, ou null + /// si rien n'a changé ou si le serveur n'a pas répondu — dans les deux cas + /// l'appelant garde ce qu'il montre. + /// + public async Task RefreshAsync() + { + var before = ContentCache.ReadExport(_configurationId); + var raw = await _client.GetRawAsync( + $"/api/configuration/{_configurationId}/export"); + + if (!raw.Ok) + { + Debug.Log($"[Content] Rafraîchissement impossible ({raw.Error}) — " + + "le contenu en cache reste affiché."); + return null; + } + + if (raw.Value == before) return null; + + var parsed = ConfigurationExport.Parse(raw.Value); + if (!parsed.Ok) return null; + + ContentCache.WriteExport(_configurationId, raw.Value); + return parsed.Value; + } + + async Task FetchAsync() + { + var raw = await _client.GetRawAsync( + $"/api/configuration/{_configurationId}/export"); + + if (!raw.Ok) return new Loaded { Error = raw.Error }; + + var parsed = ConfigurationExport.Parse(raw.Value); + if (!parsed.Ok) return new Loaded { Error = parsed.Error }; + + ContentCache.WriteExport(_configurationId, raw.Value); + return new Loaded { Export = parsed.Value }; + } + } +} diff --git a/unity-overlay/Assets/Scripts/Net/PairingService.cs b/unity-overlay/Assets/Scripts/Net/PairingService.cs new file mode 100644 index 0000000..02f884a --- /dev/null +++ b/unity-overlay/Assets/Scripts/Net/PairingService.cs @@ -0,0 +1,179 @@ +using System.Threading.Tasks; +using UnityEngine; + +namespace MyInfoMate.Vr.Net +{ + /// + /// Appairage du casque — item E2 du lot XR-4. + /// + /// Le flux est celui de la tablette, à un paramètre près + /// (tablet-app/lib/Screens/Configuration/config_view.dart:274 et son + /// _fetchAppKey) : + /// + /// code PIN → GET /api/instance/app-key → clé d'API + id d'instance + /// la clé → GET /api/configuration → les configurations de l'instance + /// POST /api/device avec appType = VR → le casque existe dans la flotte + /// + /// + /// ⚠️ Le paramètre qui change tout, c'est appType. Sans lui, le serveur + /// crée une tablette (DeviceController.Create, défaut Tablet) : le casque + /// atterrirait dans l'onglet Kiosk du manager. Avec VR, il est rattaché à + /// l'ApplicationInstance VR — et un 404 signifie que le canal VR n'est pas + /// activé sur cette instance. + /// + public class PairingService + { + /// Valeur 3 de l'enum AppType côté serveur — persistée en int. + public const int AppTypeVr = 3; + + /// Nom de l'enum ApiKeyAppType, envoyé tel quel en query. + const string ApiKeyAppTypeVr = "VrApp"; + + const string PrefsBaseUrl = "myinfomate.baseUrl"; + const string PrefsApiKey = "myinfomate.apiKey"; + const string PrefsInstanceId = "myinfomate.instanceId"; + const string PrefsDeviceId = "myinfomate.deviceId"; + const string PrefsConfigurationId = "myinfomate.configurationId"; + + public class Pairing + { + public string BaseUrl; + public string ApiKey; + public string InstanceId; + public string DeviceId; + public string ConfigurationId; + } + + class AppKeyResponse + { + public string Key; + public string InstanceId; + } + + class ConfigurationSummary + { + public string Id; + public string Label; + } + + class DeviceResponse + { + public string Id; + public string Identifier; + } + + /// + /// L'identifiant matériel du casque. SystemInfo.deviceUniqueIdentifier est + /// stable pour une installation donnée — c'est lui que DeviceController.Create + /// utilise pour reconnaître un appareil déjà enregistré au lieu d'en créer un second. + /// + public static string HeadsetIdentifier => SystemInfo.deviceUniqueIdentifier; + + /// L'appairage précédent, ou null si ce casque n'a jamais été appairé. + public static Pairing Restore() + { + var key = PlayerPrefs.GetString(PrefsApiKey, null); + if (string.IsNullOrEmpty(key)) return null; + + return new Pairing + { + BaseUrl = PlayerPrefs.GetString(PrefsBaseUrl, null), + ApiKey = key, + InstanceId = PlayerPrefs.GetString(PrefsInstanceId, null), + DeviceId = PlayerPrefs.GetString(PrefsDeviceId, null), + ConfigurationId = PlayerPrefs.GetString(PrefsConfigurationId, null) + }; + } + + public static void Forget() + { + foreach (var k in new[] { PrefsBaseUrl, PrefsApiKey, PrefsInstanceId, + PrefsDeviceId, PrefsConfigurationId }) + PlayerPrefs.DeleteKey(k); + PlayerPrefs.Save(); + } + + /// + /// Appaire ce casque. vide = la première + /// configuration de l'instance, ce qui suffit à une borne qui n'en a qu'une. + /// + public async Task> PairAsync( + string baseUrl, string pinCode, string headsetName, string configurationId = null) + { + var client = new ApiClient(baseUrl); + + var keyResult = await client.GetAsync( + $"/api/instance/app-key?pinCode={UnityWebRequestEscape(pinCode)}&appType={ApiKeyAppTypeVr}"); + + if (!keyResult.Ok) return Fail(keyResult.Error); + + if (keyResult.Value == null || string.IsNullOrEmpty(keyResult.Value.Key)) + return Fail("Ce code PIN ne correspond à aucun lieu."); + + client.ApiKey = keyResult.Value.Key; + var instanceId = keyResult.Value.InstanceId; + + if (string.IsNullOrEmpty(configurationId)) + { + var configs = await client.GetAsync( + $"/api/configuration?instanceId={instanceId}"); + + if (!configs.Ok) return Fail(configs.Error); + + if (configs.Value == null || configs.Value.Length == 0) + return Fail("Ce lieu n'a encore aucun contenu publié."); + + configurationId = configs.Value[0].Id; + } + + // Le serveur crée lui-même l'AppConfigurationLink porteur de ce DeviceId : + // c'est ce lien que l'onglet XR du manager affiche comme carte de casque. + var device = await client.PostAsync("/api/device", new + { + identifier = HeadsetIdentifier, + name = headsetName, + instanceId, + configurationId, + appType = AppTypeVr, + connected = true + }); + + if (!device.Ok) + { + // 404 sur cette route ne veut pas dire « introuvable » au sens courant : + // il n'y a pas d'ApplicationInstance VR sur cette instance. + return Fail(device.Error == "Ce contenu n'existe plus sur le serveur." + ? "Le canal VR n'est pas activé pour ce lieu." + : device.Error); + } + + var pairing = new Pairing + { + BaseUrl = client.BaseUrl, + ApiKey = client.ApiKey, + InstanceId = instanceId, + DeviceId = device.Value?.Id, + ConfigurationId = configurationId + }; + + Save(pairing); + return new ApiClient.Result { Value = pairing }; + } + + static void Save(Pairing pairing) + { + PlayerPrefs.SetString(PrefsBaseUrl, pairing.BaseUrl); + PlayerPrefs.SetString(PrefsApiKey, pairing.ApiKey); + PlayerPrefs.SetString(PrefsInstanceId, pairing.InstanceId); + PlayerPrefs.SetString(PrefsDeviceId, pairing.DeviceId); + PlayerPrefs.SetString(PrefsConfigurationId, pairing.ConfigurationId); + PlayerPrefs.Save(); + } + + static ApiClient.Result Fail(string error) => + new ApiClient.Result { Error = error }; + + static string UnityWebRequestEscape(string value) => + UnityEngine.Networking.UnityWebRequest.EscapeURL(value); + } +} diff --git a/unity-overlay/Assets/Scripts/Net/Telemetry.cs b/unity-overlay/Assets/Scripts/Net/Telemetry.cs new file mode 100644 index 0000000..61b20b3 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Net/Telemetry.cs @@ -0,0 +1,88 @@ +using System; +using UnityEngine; + +namespace MyInfoMate.Vr.Net +{ + /// + /// Télémétrie de visite — item E9 du lot XR-4. + /// + /// Les stats du manager fonctionnent sans une ligne de plus côté serveur : + /// VisitEvent porte déjà un AppType, et l'écran Statistiques filtre + /// génériquement sur AppType.values — le canal VR y apparaîtra tout seul au + /// premier événement reçu. + /// + /// ⚠️ Deux différences avec le reste de l'API, vérifiées dans StatsController : + /// la route est POST /api/stats/event (pas /api/visitevent), et + /// appType comme eventType partent en chaîne, parsés par nom + /// côté serveur. Un nom inconnu d'eventType est un 400 ; un appType + /// inconnu retombe silencieusement sur Mobile — c'est-à-dire qu'une faute de + /// frappe ici ferait compter les visites du casque dans le canal mobile. + /// + /// L'envoi est « tire et oublie » : une borne ne doit jamais attendre une statistique, + /// et un événement perdu ne vaut pas un message d'erreur au visiteur. + /// + public class Telemetry + { + /// Le nom, pas la valeur : le serveur parse par nom (Enum.TryParse). + const string AppTypeVr = "VR"; + + readonly ApiClient _client; + readonly string _instanceId; + readonly string _configurationId; + readonly string _language; + + /// + /// Une session = une visite. Sur une borne, elle se renouvelle à chaque retour au + /// menu ou à la repose du casque (E10), pas au lancement de l'app : sinon une + /// journée entière de borne compterait pour un seul visiteur. + /// + public string SessionId { get; private set; } = NewSessionId(); + + public Telemetry(ApiClient client, string instanceId, string configurationId, string language) + { + _client = client; + _instanceId = instanceId; + _configurationId = configurationId; + _language = language; + } + + public void StartNewSession() => SessionId = NewSessionId(); + + public void SectionView(string sectionId) => Send("SectionView", sectionId); + + public void SectionLeave(string sectionId, int seconds) => + Send("SectionLeave", sectionId, seconds); + + public void MenuItemTap(string sectionId) => Send("MenuItemTap", sectionId); + + public void MapPoiTap(string sectionId, string poiTitle) => + Send("MapPoiTap", sectionId, metadata: $"{{\"poi\":\"{Escape(poiTitle)}\"}}"); + + async void Send(string eventType, string sectionId, + int? durationSeconds = null, string metadata = null) + { + var result = await _client.PostAsync("/api/stats/event", new + { + instanceId = _instanceId, + configurationId = _configurationId, + sectionId, + sessionId = SessionId, + eventType, + appType = AppTypeVr, + language = _language, + durationSeconds, + metadata, + timestamp = DateTime.UtcNow + }); + + // La réponse est un 204 sans corps : la désérialisation ne dit rien d'utile, + // seul un échec réseau mérite une trace — et rien ne remonte au visiteur. + if (!result.Ok) Debug.Log($"[Stats] {eventType} non envoyé : {result.Error}"); + } + + static string NewSessionId() => Guid.NewGuid().ToString("N"); + + static string Escape(string value) => + string.IsNullOrEmpty(value) ? "" : value.Replace("\\", "\\\\").Replace("\"", "\\\""); + } +} diff --git a/unity-overlay/Assets/Scripts/Scene/CalibrationCheck.cs b/unity-overlay/Assets/Scripts/Scene/CalibrationCheck.cs new file mode 100644 index 0000000..a805a4d --- /dev/null +++ b/unity-overlay/Assets/Scripts/Scene/CalibrationCheck.cs @@ -0,0 +1,57 @@ +using System.Threading.Tasks; +using UnityEngine; + +namespace MyInfoMate.Vr.Scene +{ + /// + /// Le test qui valide , et le critère de validation n°2 de + /// l'étape S1. + /// + /// Charge calibration.glb — trois branches de longueurs différentes, rouge sur +X, + /// verte sur +Y, bleue sur +Z, exprimées en coordonnées glTF — puis place trois + /// sphères aux mêmes coordonnées, en passant par la conversion du manifeste. + /// + /// Chaque sphère doit coiffer le bout de sa branche. Si l'une part du côté opposé, + /// la conversion est en miroir : basculer sur + /// NegateZ et relancer. Trois longueurs différentes, parce qu'un repère + /// symétrique ne permet pas de voir une inversion. + /// + public class CalibrationCheck : MonoBehaviour + { + static readonly (float[] position, Color color)[] Markers = + { + (new[] { 1.00f, 0f, 0f }, new Color(0.85f, 0.15f, 0.15f)), + (new[] { 0f, 0.60f, 0f }, new Color(0.15f, 0.75f, 0.20f)), + (new[] { 0f, 0f, 0.30f }, new Color(0.15f, 0.35f, 0.90f)), + }; + + public async Task RunAsync(Transform parent) + { + var model = await GltfLoader.LoadAsync( + GltfLoader.StreamingAssetsUrl("calibration.glb"), parent, "Calibration"); + + if (model == null) + { + Debug.LogError("[Calibration] calibration.glb introuvable dans StreamingAssets"); + return; + } + + foreach (var (position, color) in Markers) + { + var marker = GameObject.CreatePrimitive(PrimitiveType.Sphere); + marker.name = "Marker"; + marker.transform.SetParent(parent, false); + marker.transform.localPosition = GltfSpace.Position(position); + marker.transform.localScale = Vector3.one * 0.09f; + // CreatePrimitive pose le matériau du pipeline historique : magenta sous + // URP, et dessiné sans décalage d'oeil, donc dédoublé en stéréo. + marker.GetComponent().material = + new Material(Shader.Find("Universal Render Pipeline/Unlit")) { color = color }; + Destroy(marker.GetComponent()); + } + + Debug.Log($"[Calibration] Convention active : {GltfSpace.Axis}. " + + "Chaque sphère doit coiffer le bout de la branche de sa couleur."); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Scene/GltfLoader.cs b/unity-overlay/Assets/Scripts/Scene/GltfLoader.cs new file mode 100644 index 0000000..7faf186 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Scene/GltfLoader.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading.Tasks; +using GLTFast; +using UnityEngine; + +namespace MyInfoMate.Vr.Scene +{ + /// + /// Chargement d'un GLB au runtime, jamais par glisser-déposer dans l'éditeur. + /// C'est toute la différence : un asset posé dans la scène part dans l'APK et fige + /// le contenu au build. Ici, le fichier peut venir de StreamingAssets aujourd'hui + /// et du cache disque demain, sans changer une ligne d'appelant. + /// + public static class GltfLoader + { + public static async Task LoadAsync(string url, Transform parent, string name) + { + var import = new GltfImport(); + var watch = System.Diagnostics.Stopwatch.StartNew(); + + if (!await import.Load(url)) + { + Debug.LogError($"[GltfLoader] Chargement impossible : {url}"); + return null; + } + + var root = new GameObject(name); + root.transform.SetParent(parent, false); + + if (!await import.InstantiateMainSceneAsync(root.transform)) + { + Debug.LogError($"[GltfLoader] Instanciation impossible : {url}"); + UnityEngine.Object.Destroy(root); + return null; + } + + Debug.Log($"[GltfLoader] {name} chargé en {watch.ElapsedMilliseconds} ms"); + return root; + } + + /// + /// Sur Android, StreamingAssets est à l'intérieur de l'APK : le chemin + /// est une URI jar:file://…!/assets/… que seul UnityWebRequest sait lire. + /// Sur desktop c'est un chemin de fichier ordinaire. glTFast gère les deux, à + /// condition qu'on lui passe une URI et pas un chemin concaténé à la main. + /// + public static string StreamingAssetsUrl(string fileName) + { + var path = System.IO.Path.Combine(Application.streamingAssetsPath, fileName); + return path.Contains("://") ? path : new Uri(path).AbsoluteUri; + } + } +} diff --git a/unity-overlay/Assets/Scripts/Scene/GltfSpace.cs b/unity-overlay/Assets/Scripts/Scene/GltfSpace.cs new file mode 100644 index 0000000..c5f1cc1 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Scene/GltfSpace.cs @@ -0,0 +1,55 @@ +using UnityEngine; + +namespace MyInfoMate.Vr.Scene +{ + /// + /// Conversion glTF → Unity. Le seul endroit du projet où elle existe. + /// + /// glTF est en main droite, Y-up, unités en mètres. Unity est en main + /// gauche. Passer de l'un à l'autre demande d'inverser un axe horizontal. + /// + /// Le décor, lui, est converti par glTFast à l'import. Les coordonnées du + /// manifeste, elles, ne passent par aucun importeur. Si les deux conversions + /// ne sont pas la même, les objets placés sont en miroir par rapport au décor + /// — et c'est invisible sur une scène symétrique, donc découvert tard. + /// + /// D'où : la valeur par défaut est celle que documente + /// glTFast, mais elle se vérifie avec calibration.glb (voir + /// ), elle ne se suppose pas. Si le test montre + /// un miroir, c'est cette seule ligne qui change. + /// + public static class GltfSpace + { + public enum Convention + { + NegateX, + NegateZ + } + + public static Convention Axis = Convention.NegateX; + + public static Vector3 ToUnity(Vector3 p) => + Axis == Convention.NegateX + ? new Vector3(-p.x, p.y, p.z) + : new Vector3(p.x, p.y, -p.z); + + /// + /// Une réflexion inverse le sens des rotations : l'axe est réfléchi et + /// l'angle change de signe. Sur un quaternion, ça revient à inverser le signe + /// des deux composantes que la réflexion ne touche pas. + /// + public static Quaternion ToUnity(Quaternion q) => + Axis == Convention.NegateX + ? new Quaternion(q.x, -q.y, -q.z, q.w) + : new Quaternion(-q.x, -q.y, q.z, q.w); + + /// Les coordonnées du manifeste arrivent en tableaux JSON. + public static Vector3 Position(float[] v) => ToUnity(new Vector3(v[0], v[1], v[2])); + + public static Quaternion Rotation(float[] q) => + ToUnity(new Quaternion(q[0], q[1], q[2], q[3])); + + /// L'échelle est invariante par réflexion — pas de conversion. + public static Vector3 Scale(float[] s) => new Vector3(s[0], s[1], s[2]); + } +} diff --git a/unity-overlay/Assets/Scripts/Scene/HotspotInstance.cs b/unity-overlay/Assets/Scripts/Scene/HotspotInstance.cs new file mode 100644 index 0000000..2790bad --- /dev/null +++ b/unity-overlay/Assets/Scripts/Scene/HotspotInstance.cs @@ -0,0 +1,210 @@ +using System.Collections; +using UnityEngine; +using UnityEngine.Networking; +using MyInfoMate.Vr.Manifest; + +namespace MyInfoMate.Vr.Scene +{ + /// + /// Un point d'intérêt posé dans la scène : un repère visible, visé au regard, qui + /// joue son commentaire audio dans la langue courante. + /// + /// Le déclenchement se fait au regard soutenu, pas à la gâchette. Deux + /// raisons, et la seconde est la vraie : le projet vise deux plugins XR (Meta XR + /// SDK et OpenXR) dont les API de manettes diffèrent, et un visiteur de musée qui + /// n'a jamais tenu une manette Quest ne sait pas laquelle presser. S7 pourra + /// ajouter la gâchette en raccourci — pas la remplacer. + /// + public class HotspotInstance : MonoBehaviour + { + public const float DwellSeconds = 1.2f; + const float MarkerRadius = 0.09f; + + SceneHotspot _hotspot; + SceneManifest _manifest; + Transform _visitorHead; + AudioSource _audio; + Renderer _marker; + + float _gazeSeconds; + string _language; + AudioClip _clip; + string _loadedClipAssetId; + + public SceneHotspot Hotspot => _hotspot; + + public void Configure(SceneHotspot hotspot, SceneManifest manifest, + Transform visitorHead, string language) + { + _hotspot = hotspot; + _manifest = manifest; + _visitorHead = visitorHead; + _language = language; + + hotspot.Transform.ApplyTo(transform); + + BuildMarker(); + + _audio = gameObject.AddComponent(); + + // Spatialisé : un commentaire qui sort de l'objet dont il parle situe le + // point sans qu'on ait à l'indiquer. Un son plat le laisse invisible. + _audio.spatialBlend = 1f; + _audio.rolloffMode = AudioRolloffMode.Linear; + _audio.maxDistance = 8f; + _audio.playOnAwake = false; + } + + void BuildMarker() + { + var sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere); + sphere.name = "Marker"; + sphere.transform.SetParent(transform, false); + sphere.transform.localScale = Vector3.one * (MarkerRadius * 2f); + + _marker = sphere.GetComponent(); + _marker.material = UnlitMaterial(IdleColor); + + // Un collider plus large que le repère : viser une bille de 9 cm à trois + // mètres au regard est nettement plus dur que ça n'en a l'air. + var collider = sphere.GetComponent(); + collider.radius = 1.6f; + } + + static readonly Color IdleColor = new Color(1f, 0.85f, 0.3f, 1f); + static readonly Color GazeColor = new Color(1f, 1f, 1f, 1f); + + /// + /// ⚠️ Shader.Find ne trouve un shader dans un build que s'il y est + /// embarqué. Les shaders URP utilisés uniquement par du code — jamais par un + /// matériau posé dans une scène — sont retirés au build et le rendu + /// devient magenta sur le casque alors qu'il est correct dans l'éditeur. + /// D'où Always Included Shaders dans Project Settings → Graphics. + /// + static Material UnlitMaterial(Color color) + { + var shader = Shader.Find("Universal Render Pipeline/Unlit"); + if (shader == null) + { + Debug.LogError("[Hotspot] Shader URP/Unlit introuvable : il a été retiré " + + "du build. Project Settings → Graphics → Always Included Shaders."); + shader = Shader.Find("Sprites/Default"); + } + + return new Material(shader) { color = color }; + } + + void Update() + { + if (_visitorHead == null) return; + + var looking = IsGazedAt(); + + _gazeSeconds = looking ? _gazeSeconds + Time.deltaTime : 0f; + + if (_marker != null) + _marker.material.color = Color.Lerp( + IdleColor, GazeColor, Mathf.Clamp01(_gazeSeconds / DwellSeconds)); + + if (looking && _gazeSeconds >= DwellSeconds && !_audio.isPlaying) + { + _gazeSeconds = 0f; + StartCoroutine(PlayCurrentLanguage()); + } + } + + bool IsGazedAt() + { + var ray = new Ray(_visitorHead.position, _visitorHead.forward); + return Physics.Raycast(ray, out var hit, 12f) + && hit.collider.transform.IsChildOf(transform); + } + + public void SetLanguage(string language) + { + if (_language == language) return; + + _language = language; + + // Un changement de langue pendant la lecture doit couper : laisser finir + // la phrase dans l'ancienne langue est le genre de détail qui fait dire + // « ça ne marche pas ». + if (_audio != null && _audio.isPlaying) _audio.Stop(); + } + + public void StopAudio() + { + if (_audio != null && _audio.isPlaying) _audio.Stop(); + } + + IEnumerator PlayCurrentLanguage() + { + var assetId = AudioAssetIdFor(_language); + if (string.IsNullOrEmpty(assetId)) + { + Debug.Log($"[Hotspot] {_hotspot.Id} : aucun audio en « {_language} »"); + yield break; + } + + if (_loadedClipAssetId != assetId) + { + yield return LoadClip(assetId); + _loadedClipAssetId = assetId; + } + + if (_clip == null) yield break; + + _audio.clip = _clip; + _audio.Play(); + } + + /// + /// Le premier contenu du hotspot qui porte un audio dans cette langue. La + /// lecture de la suite de contents viendra avec le panneau de S7 ; en + /// S2 on valide qu'un son sort du bon endroit. + /// + string AudioAssetIdFor(string language) + { + if (_hotspot.Contents == null) return null; + + foreach (var content in _hotspot.Contents) + { + if (string.IsNullOrEmpty(content.AssetId)) continue; + + var hasText = content.Description != null + && content.Description.Exists(t => t.Language == language); + + if (hasText || _hotspot.Contents.Count == 1) return content.AssetId; + } + + return null; + } + + IEnumerator LoadClip(string assetId) + { + var url = SceneBuilder.ResolveAssetUrl(_manifest, assetId); + if (url == null) + { + Debug.LogWarning($"[Hotspot] {_hotspot.Id} : asset {assetId} absent du manifeste"); + yield break; + } + + // Le disque d'abord. Le préchargement (E4) a normalement déjà déposé ce + // fichier ; sans ce détour, un hotspot restait muet dès que le wifi tombait, + // alors que le son était sur le casque. + var cached = Net.ContentCache.CachedPath(url); + var source = cached != null ? "file://" + cached : url; + + using var request = UnityWebRequestMultimedia.GetAudioClip(source, AudioType.MPEG); + yield return request.SendWebRequest(); + + if (request.result != UnityWebRequest.Result.Success) + { + Debug.LogWarning($"[Hotspot] audio {assetId} illisible : {request.error}"); + yield break; + } + + _clip = DownloadHandlerAudioClip.GetContent(request); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Scene/NavigationBounds.cs b/unity-overlay/Assets/Scripts/Scene/NavigationBounds.cs new file mode 100644 index 0000000..a82c1c7 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Scene/NavigationBounds.cs @@ -0,0 +1,83 @@ +using UnityEngine; + +namespace MyInfoMate.Vr.Scene +{ + /// + /// Zone de navigation circulaire, plafonnée à 3 m de rayon (§3.2 du cahier des + /// charges). Le plafond est appliqué ici et côté serveur : un client qui + /// fait confiance à la donnée est un client qu'on casse avec une donnée fausse. + /// + /// Deux rôles : matérialiser la limite comme le garde-fou natif du Quest, et + /// ramener le visiteur à l'intérieur s'il en sort. On ne téléporte pas — on + /// repousse : une téléportation surprise dans un casque donne la nausée. + /// + public class NavigationBounds : MonoBehaviour + { + public const float MaxRadiusMeters = 3f; + + [SerializeField] float radiusMeters = 3f; + [SerializeField] Transform visitor; + [SerializeField] Transform head; + [SerializeField] float fadeStartMeters = 0.4f; + + LineRenderer _ring; + + public void Configure(Transform visitorRig, Transform visitorHead, float radius, bool showBoundary) + { + visitor = visitorRig; + head = visitorHead; + radiusMeters = Mathf.Min(radius, MaxRadiusMeters); + if (showBoundary) DrawRing(); + } + + void DrawRing() + { + const int segments = 96; + + _ring = gameObject.AddComponent(); + _ring.useWorldSpace = false; + _ring.loop = true; + _ring.positionCount = segments; + _ring.widthMultiplier = 0.015f; + _ring.material = new Material(Shader.Find("Universal Render Pipeline/Unlit")); + _ring.material.color = new Color(0.4f, 0.8f, 1f, 0.6f); + + for (var i = 0; i < segments; i++) + { + var angle = i * Mathf.PI * 2f / segments; + _ring.SetPosition(i, new Vector3( + Mathf.Cos(angle) * radiusMeters, 0.01f, Mathf.Sin(angle) * radiusMeters)); + } + } + + /// + /// On mesure la tête et on déplace le rig. En VR le rig ne bouge + /// pas quand le visiteur marche : le tracking applique le déplacement à la + /// caméra, à l'intérieur du rig. Mesurer le rig, c'est ne jamais rien mesurer — + /// la contrainte ne se déclenche alors pour aucune distance parcourue. + /// + /// On retranche l'excédent au rig plutôt que de poser la tête sur le cercle : + /// la tête suit le corps, on ne peut que déplacer le repère sous elle. + /// + void LateUpdate() + { + if (visitor == null || head == null) return; + + var local = transform.InverseTransformPoint(head.position); + var flat = new Vector2(local.x, local.z); + if (flat.magnitude <= radiusMeters) return; + + var excess = flat - flat.normalized * radiusMeters; + visitor.position -= transform.TransformVector(new Vector3(excess.x, 0f, excess.y)); + } + + /// Distance à la limite, pour l'assombrissement progressif du bord. + public float ProximityToEdge() + { + if (head == null) return 0f; + var local = transform.InverseTransformPoint(head.position); + var distance = new Vector2(local.x, local.z).magnitude; + return Mathf.InverseLerp(radiusMeters - fadeStartMeters, radiusMeters, distance); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Scene/PersonaInstance.cs b/unity-overlay/Assets/Scripts/Scene/PersonaInstance.cs new file mode 100644 index 0000000..732d17d --- /dev/null +++ b/unity-overlay/Assets/Scripts/Scene/PersonaInstance.cs @@ -0,0 +1,84 @@ +using System.Linq; +using System.Threading.Tasks; +using UnityEngine; + +namespace MyInfoMate.Vr.Scene +{ + /// + /// Un personnage posé dans la scène : GLB chargé au runtime, animation idle en + /// boucle, orientation vers le visiteur. + /// + /// Pas de lip sync, jamais — écarté au niveau produit. Un personnage stylisé qui + /// respire et regarde dans la bonne direction porte l'essentiel de la présence ; + /// une bouche mal synchronisée la détruit. + /// + public class PersonaInstance : MonoBehaviour + { + [SerializeField] Transform lookAtTarget; + [SerializeField] bool gazeAtVisitor = true; + [SerializeField] float gazeDegreesPerSecond = 90f; + + Quaternion _restRotation; + + public async Task LoadAsync(string url, Transform visitorHead) + { + lookAtTarget = visitorHead; + _restRotation = transform.localRotation; + + var model = await GltfLoader.LoadAsync(url, transform, "Model"); + if (model == null) return false; + + PlayIdle(model); + return true; + } + + /// + /// ⚠️ glTFast expose les animations de deux façons selon + /// GltfImportSettings.AnimationMethod : Legacy (composant + /// , le défaut) ou Mecanim (un Animator et + /// son controller). Ce code suit le défaut ; si le personnage reste figé alors + /// que le GLB contient bien des clips, c'est le premier endroit à regarder. + /// + /// On joue le clip nommé + /// « idle » s'il existe, sinon le premier — un pack d'animations tiers ne + /// respecte aucune convention de nommage, et refuser de jouer parce que le + /// nom ne colle pas donnerait un personnage figé sans message d'erreur. + /// + void PlayIdle(GameObject model) + { + var animation = model.GetComponentInChildren(); + if (animation == null) return; + + var clip = animation.Cast() + .FirstOrDefault(s => s.name.ToLowerInvariant().Contains("idle")) + ?? animation.Cast().FirstOrDefault(); + + if (clip == null) + { + Debug.LogWarning($"[Persona] {name} : aucune animation dans le GLB"); + return; + } + + clip.wrapMode = WrapMode.Loop; + animation.Play(clip.name); + } + + void Update() + { + if (!gazeAtVisitor || lookAtTarget == null) return; + + // Rotation sur Y seulement : un personnage qui bascule la tête vers un + // visiteur assis ou grand donne un effet de pantin. + var direction = lookAtTarget.position - transform.position; + direction.y = 0f; + if (direction.sqrMagnitude < 0.01f) return; + + transform.rotation = Quaternion.RotateTowards( + transform.rotation, + Quaternion.LookRotation(direction), + gazeDegreesPerSecond * Time.deltaTime); + } + + public void ReturnToRest() => transform.localRotation = _restRotation; + } +} diff --git a/unity-overlay/Assets/Scripts/Scene/SceneBuilder.cs b/unity-overlay/Assets/Scripts/Scene/SceneBuilder.cs new file mode 100644 index 0000000..0c56512 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Scene/SceneBuilder.cs @@ -0,0 +1,207 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using MyInfoMate.Vr.Manifest; +using UnityEngine; + +namespace MyInfoMate.Vr.Scene +{ + /// + /// Construit une scène à partir d'un manifeste, et remplace à ce titre + /// S1Bootstrap dont les chemins étaient en dur. + /// + /// C'est ici que la règle du §1 de la conception devient vraie : un rebuild + /// seulement pour une fonctionnalité. Changer une position, ajouter un objet, + /// déplacer un hotspot — tout ça se fait dans le JSON, sans recompiler. + /// + /// Le builder ne lit ni ne valide le manifeste : le + /// fait avant, et un manifeste refusé n'arrive jamais jusqu'ici. + /// + public class SceneBuilder : MonoBehaviour + { + public Transform Root { get; private set; } + public NavigationBounds Bounds { get; private set; } + + readonly List _hotspots = new List(); + readonly List _personas = new List(); + + SceneManifest _manifest; + string _language; + + public IReadOnlyList Hotspots => _hotspots; + + public async Task BuildAsync(SceneManifest manifest, Transform visitorHead, + Transform visitorRig, string language) + { + _manifest = manifest; + _language = language ?? manifest.DefaultLanguage; + + var watch = System.Diagnostics.Stopwatch.StartNew(); + + Root = new GameObject($"Scene:{manifest.SceneId}").transform; + Root.SetParent(transform, false); + + var worldLoaded = await LoadWorld(); + + await LoadObjects(); + await LoadPersonas(visitorHead); + + BuildHotspots(visitorHead); + SetUpBounds(visitorHead, visitorRig); + PlaceVisitor(visitorRig); + + // Le chiffre que S1 doit rapporter (§« ce que cette étape mesure »), mais + // mesuré ici sur la scène réelle plutôt que sur un GLB isolé : c'est + // celui-là qui décide si l'écran de progression est un détail ou un sujet. + Debug.Log($"[SceneBuilder] Scène {manifest.SceneId} v{manifest.Version} " + + $"construite en {watch.ElapsedMilliseconds} ms " + + $"({_manifest.Objects.Count} objets, {_personas.Count} personnages, " + + $"{_hotspots.Count} hotspots, budget " + + $"{manifest.Budget?.TotalBytes ?? 0} octets)"); + + return worldLoaded; + } + + async Task LoadWorld() + { + var url = ResolveAssetUrl(_manifest, _manifest.World.AssetId); + if (url == null) + { + Debug.LogError($"[SceneBuilder] décor {_manifest.World.AssetId} absent du manifeste"); + return false; + } + + var holder = new GameObject("World"); + holder.transform.SetParent(Root, false); + _manifest.World.Transform.ApplyTo(holder.transform); + + return await GltfLoader.LoadAsync(url, holder.transform, "Model") != null; + } + + async Task LoadObjects() + { + var objectsRoot = new GameObject("Objects").transform; + objectsRoot.SetParent(Root, false); + + foreach (var sceneObject in _manifest.Objects) + { + var url = ResolveAssetUrl(_manifest, sceneObject.AssetId); + if (url == null) + { + // Un asset manquant n'interrompt pas la scène : sur site, une + // amphore absente vaut mieux qu'une salle noire. + Debug.LogWarning($"[SceneBuilder] objet {sceneObject.Id} : " + + $"asset {sceneObject.AssetId} absent du manifeste"); + continue; + } + + var holder = new GameObject($"Object:{sceneObject.Id}"); + holder.transform.SetParent(objectsRoot, false); + sceneObject.Transform.ApplyTo(holder.transform); + + await GltfLoader.LoadAsync(url, holder.transform, "Model"); + } + } + + async Task LoadPersonas(Transform visitorHead) + { + var personasRoot = new GameObject("Personas").transform; + personasRoot.SetParent(Root, false); + + foreach (var persona in _manifest.Personas) + { + var url = ResolveAssetUrl(_manifest, persona.AssetId); + if (url == null) + { + Debug.LogWarning($"[SceneBuilder] persona {persona.Id} : " + + $"asset {persona.AssetId} absent du manifeste"); + continue; + } + + var holder = new GameObject($"Persona:{persona.Id}"); + holder.transform.SetParent(personasRoot, false); + persona.Transform.ApplyTo(holder.transform); + + var instance = holder.AddComponent(); + + // L'échelle du manifeste s'applique au porteur ; le regard, lui, ne + // vise que si le personnage a été demandé comme tel. + if (!await instance.LoadAsync(url, persona.GazeAtVisitor ? visitorHead : null)) + continue; + + _personas.Add(instance); + } + } + + void BuildHotspots(Transform visitorHead) + { + var hotspotsRoot = new GameObject("Hotspots").transform; + hotspotsRoot.SetParent(Root, false); + + foreach (var hotspot in _manifest.Hotspots) + { + var holder = new GameObject($"Hotspot:{hotspot.Id}"); + holder.transform.SetParent(hotspotsRoot, false); + + var instance = holder.AddComponent(); + instance.Configure(hotspot, _manifest, visitorHead, _language); + + _hotspots.Add(instance); + } + } + + void SetUpBounds(Transform visitorHead, Transform visitorRig) + { + Bounds = new GameObject("NavigationBounds").AddComponent(); + Bounds.transform.SetParent(Root, false); + Bounds.Configure(visitorRig, + visitorHead, + _manifest.Navigation.ClampedRadiusMeters, + _manifest.Navigation.ShowBoundary); + } + + /// + /// L'origine du manifeste est le point de spawn, au sol (§2.1). Un + /// spawn non identitaire est donc l'exception, pas la règle — mais il + /// existe, et il doit déplacer le visiteur, pas le décor : déplacer le décor + /// désaligne le garde-fou natif du casque. + /// + void PlaceVisitor(Transform visitorRig) + { + if (visitorRig == null) return; + + visitorRig.localPosition = _manifest.Navigation.Spawn.UnityPosition; + visitorRig.localRotation = _manifest.Navigation.Spawn.UnityRotation; + } + + public void SetLanguage(string language) + { + _language = language; + foreach (var hotspot in _hotspots) hotspot.SetLanguage(language); + } + + public void StopAllAudio() + { + foreach (var hotspot in _hotspots) hotspot.StopAudio(); + } + + /// + /// Résolution de l'URL d'un asset, et le seul endroit qui la fait. + /// + /// Le manifeste servi par le serveur porte des URL absolues. Mais la règle 2 + /// de la décision D3 impose qu'un manifeste dont les URL ont été réécrites en + /// chemins locaux soit lisible tel quel — c'est ce qui rend l'offline + /// possible, et c'est ce qu'utilise le scene.json de S2 avec ses noms + /// de fichiers nus. Une URL sans schéma est donc un fichier de + /// StreamingAssets aujourd'hui, du cache disque en S6, sans changer d'appelant. + /// + public static string ResolveAssetUrl(SceneManifest manifest, string assetId) + { + var asset = manifest.FindAsset(assetId); + if (asset == null || string.IsNullOrEmpty(asset.Url)) return null; + + return asset.Url.Contains("://") + ? asset.Url + : GltfLoader.StreamingAssetsUrl(asset.Url); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Scene/SceneMessage.cs b/unity-overlay/Assets/Scripts/Scene/SceneMessage.cs new file mode 100644 index 0000000..6e5bf90 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Scene/SceneMessage.cs @@ -0,0 +1,133 @@ +using UnityEngine; + +namespace MyInfoMate.Vr.Scene +{ + /// + /// Une phrase affichée dans le casque, lisible, à hauteur d'yeux. + /// + /// C'est un livrable, pas un raffinement. Une scène qu'on ne sait pas + /// charger — décor en splats, manifeste d'une version inconnue, asset absent — + /// doit le dire. Sur site, personne ne lit une console : un écran noir devient + /// « le casque est cassé », et un message devient un appel au support qui sait + /// quoi corriger. + /// + public class SceneMessage : MonoBehaviour + { + // 3,50 m : à 2 m un texte de cette taille occupe trop de champ pour être lu + // confortablement, et l'oeil doit converger de près. Plus loin, il se lit + // d'un seul regard. + const float DistanceMeters = 3.5f; + const float HeightMeters = 1.5f; + + /// Au-delà de cet écart, le visiteur ne regarde plus le message. + const float ReleaseAngleDegrees = 45f; + + const float RecentringSpeed = 3f; + + Transform _head; + bool _recentring; + + public static SceneMessage Show(string message, Transform visitorHead) + { + var instance = new GameObject("SceneMessage").AddComponent(); + instance.Build(message, visitorHead); + return instance; + } + + void Build(string message, Transform visitorHead) + { + _head = visitorHead; + + var label = Ui.Label.Create( + transform, "Text", Ui.Label.Anchor.MiddleCenter, 0.08f, Color.white); + label.Text = Wrap(message, 40); + + if (_head != null) transform.position = TargetPosition(); + FaceVisitor(); + } + + /// + /// Le message reste posé tant que le visiteur le regarde, et ne se + /// recentre que s'il lui tourne le dos. Le suivre à chaque image le rend + /// illisible — on ne peut pas fixer un texte qui fuit ; ne jamais le suivre + /// le laisserait dans le dos du visiteur, donc jamais lu. + /// + void LateUpdate() + { + if (_head == null) return; + + if (LookingAway()) _recentring = true; + + if (_recentring) + { + var target = TargetPosition(); + transform.position = Vector3.Lerp( + transform.position, target, Time.deltaTime * RecentringSpeed); + + if ((transform.position - target).sqrMagnitude < 0.0004f) _recentring = false; + } + + FaceVisitor(); + } + + bool LookingAway() + { + var toMessage = transform.position - _head.position; + toMessage.y = 0f; + if (toMessage.sqrMagnitude < 0.01f) return true; + + return Vector3.Angle(HeadForward(), toMessage.normalized) > ReleaseAngleDegrees; + } + + Vector3 TargetPosition() + { + return new Vector3(_head.position.x, HeightMeters, _head.position.z) + + HeadForward() * DistanceMeters; + } + + /// Le texte fait face au visiteur, sans jamais s'incliner. + void FaceVisitor() + { + if (_head == null) return; + + var away = transform.position - _head.position; + away.y = 0f; + if (away.sqrMagnitude < 0.01f) return; + + transform.rotation = Quaternion.LookRotation(away.normalized); + } + + Vector3 HeadForward() + { + var forward = _head.forward; + forward.y = 0f; + return forward.sqrMagnitude < 0.01f ? Vector3.forward : forward.normalized; + } + + static string Wrap(string message, int columns) + { + var words = message.Split(' '); + var line = 0; + var wrapped = new System.Text.StringBuilder(); + + foreach (var word in words) + { + if (line + word.Length > columns) + { + wrapped.Append('\n'); + line = 0; + } + else if (line > 0) + { + wrapped.Append(' '); + line++; + } + + wrapped.Append(word); + line += word.Length; + } + + return wrapped.ToString(); + } + } +} diff --git a/unity-overlay/Assets/Scripts/Ui/Label.cs b/unity-overlay/Assets/Scripts/Ui/Label.cs new file mode 100644 index 0000000..c5f4956 --- /dev/null +++ b/unity-overlay/Assets/Scripts/Ui/Label.cs @@ -0,0 +1,151 @@ +using TMPro; +using UnityEngine; + +namespace MyInfoMate.Vr.Ui +{ + /// + /// Tout le texte affiché dans le casque passe par ici. + /// + /// Pourquoi TextMeshPro. TextMesh rend une police bitmap : à 2,20 m + /// dans un casque, chaque titre bave dès qu'on s'en approche, et c'est le premier + /// truc qui fait « prototype ». TMP rend par champ de distance signée — net à + /// n'importe quelle distance, pour le même coût de rendu. + /// + /// ⚠️ TMP a besoin de ses ressources. Window → TextMeshPro → Import TMP + /// Essential Resources, une fois pour le projet. Sans elles, TMP_Settings + /// n'a pas de police par défaut et un TextMeshPro n'affiche rien — + /// silencieusement, dans un build. D'où le repli sur TextMesh plus bas : un + /// texte laid reste lisible, un texte absent transforme une borne en casque cassé. + /// + public class Label : MonoBehaviour + { + public enum Anchor { TopCenter, MiddleCenter } + + /// + /// TMP exprime sa taille en points, à raison de 10 points par unité de monde pour + /// un objet à l'échelle 1. TextMesh, lui, multiplie fontSize par + /// characterSize puis par 0,1. Les deux constantes ci-dessous n'existent + /// que pour que les appelants parlent en mètres, une seule fois. + /// + const float TmpPointsPerMeter = 10f; + + const int LegacyFontSize = 96; + + TMP_Text _tmp; + TextMesh _legacy; + + public static Label Create(Transform parent, string name, Anchor anchor, + float lineHeightMeters, Color color) + { + var root = new GameObject(name); + root.transform.SetParent(parent, false); + + var label = root.AddComponent