From 0310d28b5e2898c51c46f40bab23b7dba3464867 Mon Sep 17 00:00:00 2001
From: Thomas Fransolet
Date: Wed, 16 Sep 2026 15:26:07 +0200
Subject: [PATCH] Canal VR Meta Quest : projet Unity, viewer web et
documentation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.gitattributes | 10 +
.gitignore | 58 +
README.md | 80 +
docs/00-phase1-etat-des-lieux.md | 448 +
docs/01-phase0-setup-unity.md | 271 +
docs/02-decisions.md | 192 +
docs/03-phase2-conception.md | 644 +
docs/04-phase3-plan.md | 319 +
tools/make_calibration_glb.py | 135 +
.../Assets/Scripts/Boot/MenuBootstrap.cs | 324 +
.../Assets/Scripts/Boot/PairingBootstrap.cs | 123 +
.../Assets/Scripts/Boot/S1Bootstrap.cs | 82 +
.../Assets/Scripts/Boot/S2Bootstrap.cs | 76 +
.../Assets/Scripts/Editor/BuildBootScene.cs | 87 +
.../Scripts/Editor/IncludeRuntimeShaders.cs | 129 +
.../Assets/Scripts/Kiosk/FleetReporter.cs | 117 +
.../Assets/Scripts/Kiosk/KioskSession.cs | 117 +
.../Assets/Scripts/Manifest/ManifestReader.cs | 140 +
.../Assets/Scripts/Manifest/SceneManifest.cs | 250 +
.../Assets/Scripts/Menu/AimSelector.cs | 225 +
.../Assets/Scripts/Menu/FloatingMenu.cs | 149 +
.../Assets/Scripts/Menu/ImmersiveBackdrop.cs | 134 +
.../Assets/Scripts/Menu/MenuFeedback.cs | 112 +
.../Assets/Scripts/Menu/MenuItemPanel.cs | 330 +
.../Assets/Scripts/Menu/PagedView.cs | 226 +
.../Assets/Scripts/Menu/PanoramicSky.cs | 81 +
.../Assets/Scripts/Menu/Scene3DView.cs | 228 +
.../Assets/Scripts/Menu/SectionPages.cs | 172 +
.../Assets/Scripts/Menu/SkyboxView.cs | 220 +
unity-overlay/Assets/Scripts/Net/ApiClient.cs | 160 +
.../Assets/Scripts/Net/ConfigurationExport.cs | 359 +
.../Assets/Scripts/Net/ContentCache.cs | 180 +
.../Assets/Scripts/Net/ContentPreloader.cs | 141 +
.../Assets/Scripts/Net/ContentService.cs | 112 +
.../Assets/Scripts/Net/PairingService.cs | 179 +
unity-overlay/Assets/Scripts/Net/Telemetry.cs | 88 +
.../Assets/Scripts/Scene/CalibrationCheck.cs | 57 +
.../Assets/Scripts/Scene/GltfLoader.cs | 53 +
.../Assets/Scripts/Scene/GltfSpace.cs | 55 +
.../Assets/Scripts/Scene/HotspotInstance.cs | 210 +
.../Assets/Scripts/Scene/NavigationBounds.cs | 83 +
.../Assets/Scripts/Scene/PersonaInstance.cs | 84 +
.../Assets/Scripts/Scene/SceneBuilder.cs | 207 +
.../Assets/Scripts/Scene/SceneMessage.cs | 133 +
unity-overlay/Assets/Scripts/Ui/Label.cs | 151 +
.../Assets/StreamingAssets/calibration.glb | 3 +
.../Assets/StreamingAssets/scene.json | 110 +
unity-overlay/Packages/manifest.json | 55 +
unity-overlay/README.md | 253 +
.../meta-xr-operator-coordinates/SKILL.md | 119 +
.../meta-xr-operator-grabbed-objects/SKILL.md | 105 +
.../SKILL.md | 93 +
.../SKILL.md | 122 +
.../SKILL.md | 66 +
.../SKILL.md | 58 +
.../meta-xr-operator-unity-workflow/SKILL.md | 72 +
.../.claude/skills/meta-xr-operator/SKILL.md | 59 +
unity/MyInfoMateVR/.vsconfig | 6 +
.../Assets/InputSystem_Actions.inputactions | 1057 ++
.../InputSystem_Actions.inputactions.meta | 14 +
unity/MyInfoMateVR/Assets/Oculus.meta | 8 +
.../Assets/Oculus/OculusProjectConfig.asset | 49 +
.../Oculus/OculusProjectConfig.asset.meta | 8 +
unity/MyInfoMateVR/Assets/Plugins.meta | 8 +
.../MyInfoMateVR/Assets/Plugins/Android.meta | 8 +
.../Plugins/Android/AndroidManifest.xml | 24 +
.../Plugins/Android/AndroidManifest.xml.meta | 7 +
unity/MyInfoMateVR/Assets/Readme.asset | 34 +
unity/MyInfoMateVR/Assets/Readme.asset.meta | 8 +
unity/MyInfoMateVR/Assets/Resources.meta | 8 +
.../Assets/Resources/DevAgentSettings.asset | 25 +
.../Resources/DevAgentSettings.asset.meta | 8 +
.../Resources/ImmersiveDebuggerSettings.asset | 44 +
.../ImmersiveDebuggerSettings.asset.meta | 8 +
.../Assets/Resources/InputActions.asset | 16 +
.../Assets/Resources/InputActions.asset.meta | 8 +
.../MetaXRAcousticMaterialMapping.asset | 16 +
.../MetaXRAcousticMaterialMapping.asset.meta | 8 +
.../Resources/MetaXRAcousticSettings.asset | 18 +
.../MetaXRAcousticSettings.asset.meta | 8 +
.../Resources/MetaXRAudioSettings.asset | 15 +
.../Resources/MetaXRAudioSettings.asset.meta | 8 +
.../Assets/Resources/OVRBuildConfig.asset | 14 +
.../Resources/OVRBuildConfig.asset.meta | 8 +
.../Resources/OVRPlatformToolSettings.asset | 22 +
.../OVRPlatformToolSettings.asset.meta | 8 +
.../Resources/OculusRuntimeSettings.asset | 24 +
.../OculusRuntimeSettings.asset.meta | 8 +
unity/MyInfoMateVR/Assets/Scenes.meta | 8 +
unity/MyInfoMateVR/Assets/Scenes/Boot.unity | 1648 +++
.../Assets/Scenes/Boot.unity.meta | 7 +
.../Assets/Scenes/SampleScene.unity | 1307 ++
.../Assets/Scenes/SampleScene.unity.meta | 7 +
unity/MyInfoMateVR/Assets/Scripts.meta | 8 +
unity/MyInfoMateVR/Assets/Scripts/Boot.meta | 8 +
.../Assets/Scripts/Boot/MenuBootstrap.cs | 324 +
.../Assets/Scripts/Boot/MenuBootstrap.cs.meta | 2 +
.../Assets/Scripts/Boot/PairingBootstrap.cs | 123 +
.../Scripts/Boot/PairingBootstrap.cs.meta | 2 +
.../Assets/Scripts/Boot/S1Bootstrap.cs | 82 +
.../Assets/Scripts/Boot/S1Bootstrap.cs.meta | 2 +
.../Assets/Scripts/Boot/S2Bootstrap.cs | 76 +
.../Assets/Scripts/Boot/S2Bootstrap.cs.meta | 2 +
unity/MyInfoMateVR/Assets/Scripts/Editor.meta | 8 +
.../Assets/Scripts/Editor/BuildBootScene.cs | 87 +
.../Scripts/Editor/BuildBootScene.cs.meta | 2 +
.../Scripts/Editor/IncludeRuntimeShaders.cs | 129 +
.../Editor/IncludeRuntimeShaders.cs.meta | 2 +
unity/MyInfoMateVR/Assets/Scripts/Kiosk.meta | 8 +
.../Assets/Scripts/Kiosk/FleetReporter.cs | 117 +
.../Scripts/Kiosk/FleetReporter.cs.meta | 2 +
.../Assets/Scripts/Kiosk/KioskSession.cs | 117 +
.../Assets/Scripts/Kiosk/KioskSession.cs.meta | 2 +
.../MyInfoMateVR/Assets/Scripts/Manifest.meta | 8 +
.../Assets/Scripts/Manifest/ManifestReader.cs | 140 +
.../Scripts/Manifest/ManifestReader.cs.meta | 2 +
.../Assets/Scripts/Manifest/SceneManifest.cs | 250 +
.../Scripts/Manifest/SceneManifest.cs.meta | 2 +
unity/MyInfoMateVR/Assets/Scripts/Menu.meta | 8 +
.../Assets/Scripts/Menu/AimSelector.cs | 225 +
.../Assets/Scripts/Menu/AimSelector.cs.meta | 2 +
.../Assets/Scripts/Menu/BentoLayout.cs | 123 +
.../Assets/Scripts/Menu/FloatingMenu.cs | 214 +
.../Assets/Scripts/Menu/FloatingMenu.cs.meta | 2 +
.../Assets/Scripts/Menu/ImmersiveBackdrop.cs | 134 +
.../Scripts/Menu/ImmersiveBackdrop.cs.meta | 2 +
.../Assets/Scripts/Menu/MenuFeedback.cs | 112 +
.../Assets/Scripts/Menu/MenuFeedback.cs.meta | 2 +
.../Assets/Scripts/Menu/MenuItemPanel.cs | 348 +
.../Assets/Scripts/Menu/MenuItemPanel.cs.meta | 2 +
.../Assets/Scripts/Menu/PagedView.cs | 226 +
.../Assets/Scripts/Menu/PagedView.cs.meta | 2 +
.../Assets/Scripts/Menu/PanoramicSky.cs | 81 +
.../Assets/Scripts/Menu/PanoramicSky.cs.meta | 2 +
.../Assets/Scripts/Menu/Scene3DView.cs | 228 +
.../Assets/Scripts/Menu/Scene3DView.cs.meta | 2 +
.../Assets/Scripts/Menu/SectionPages.cs | 172 +
.../Assets/Scripts/Menu/SectionPages.cs.meta | 2 +
.../Assets/Scripts/Menu/SkyboxView.cs | 220 +
.../Assets/Scripts/Menu/SkyboxView.cs.meta | 2 +
unity/MyInfoMateVR/Assets/Scripts/Net.meta | 8 +
.../Assets/Scripts/Net/ApiClient.cs | 160 +
.../Assets/Scripts/Net/ApiClient.cs.meta | 2 +
.../Assets/Scripts/Net/ConfigurationExport.cs | 368 +
.../Scripts/Net/ConfigurationExport.cs.meta | 2 +
.../Assets/Scripts/Net/ContentCache.cs | 180 +
.../Assets/Scripts/Net/ContentCache.cs.meta | 2 +
.../Assets/Scripts/Net/ContentPreloader.cs | 141 +
.../Scripts/Net/ContentPreloader.cs.meta | 2 +
.../Assets/Scripts/Net/ContentService.cs | 123 +
.../Assets/Scripts/Net/ContentService.cs.meta | 2 +
.../Assets/Scripts/Net/PairingService.cs | 179 +
.../Assets/Scripts/Net/PairingService.cs.meta | 2 +
.../Assets/Scripts/Net/Telemetry.cs | 88 +
.../Assets/Scripts/Net/Telemetry.cs.meta | 2 +
unity/MyInfoMateVR/Assets/Scripts/Scene.meta | 8 +
.../Assets/Scripts/Scene/CalibrationCheck.cs | 57 +
.../Scripts/Scene/CalibrationCheck.cs.meta | 2 +
.../Assets/Scripts/Scene/GltfLoader.cs | 53 +
.../Assets/Scripts/Scene/GltfLoader.cs.meta | 2 +
.../Assets/Scripts/Scene/GltfSpace.cs | 55 +
.../Assets/Scripts/Scene/GltfSpace.cs.meta | 2 +
.../Assets/Scripts/Scene/HotspotInstance.cs | 210 +
.../Scripts/Scene/HotspotInstance.cs.meta | 2 +
.../Assets/Scripts/Scene/NavigationBounds.cs | 83 +
.../Scripts/Scene/NavigationBounds.cs.meta | 2 +
.../Assets/Scripts/Scene/PersonaInstance.cs | 84 +
.../Scripts/Scene/PersonaInstance.cs.meta | 2 +
.../Assets/Scripts/Scene/SceneBuilder.cs | 207 +
.../Assets/Scripts/Scene/SceneBuilder.cs.meta | 2 +
.../Assets/Scripts/Scene/SceneMessage.cs | 133 +
.../Assets/Scripts/Scene/SceneMessage.cs.meta | 2 +
unity/MyInfoMateVR/Assets/Scripts/Ui.meta | 8 +
unity/MyInfoMateVR/Assets/Scripts/Ui/Label.cs | 151 +
.../Assets/Scripts/Ui/Label.cs.meta | 2 +
unity/MyInfoMateVR/Assets/Settings.meta | 8 +
.../Settings/DefaultVolumeProfile.asset | 983 ++
.../Settings/DefaultVolumeProfile.asset.meta | 8 +
.../Assets/Settings/Mobile_RPAsset.asset | 138 +
.../Assets/Settings/Mobile_RPAsset.asset.meta | 8 +
.../Assets/Settings/Mobile_Renderer.asset | 52 +
.../Settings/Mobile_Renderer.asset.meta | 8 +
.../Assets/Settings/PC_RPAsset.asset | 135 +
.../Assets/Settings/PC_RPAsset.asset.meta | 8 +
.../Assets/Settings/PC_Renderer.asset | 95 +
.../Assets/Settings/PC_Renderer.asset.meta | 8 +
.../Assets/Settings/SampleSceneProfile.asset | 159 +
.../Settings/SampleSceneProfile.asset.meta | 8 +
...niversalRenderPipelineGlobalSettings.asset | 365 +
...salRenderPipelineGlobalSettings.asset.meta | 8 +
.../Settings/glTFastVariants.shadervariants | 165 +
.../glTFastVariants.shadervariants.meta | 8 +
.../MyInfoMateVR/Assets/StreamingAssets.meta | 8 +
.../Assets/StreamingAssets/Sponza.meta | 8 +
.../Sponza/10381718147657362067.jpg | 3 +
.../Sponza/10381718147657362067.jpg.meta | 7 +
.../Sponza/10388182081421875623.jpg | 3 +
.../Sponza/10388182081421875623.jpg.meta | 7 +
.../Sponza/11474523244911310074.jpg | 3 +
.../Sponza/11474523244911310074.jpg.meta | 7 +
.../Sponza/11490520546946913238.jpg | 3 +
.../Sponza/11490520546946913238.jpg.meta | 7 +
.../Sponza/11872827283454512094.jpg | 3 +
.../Sponza/11872827283454512094.jpg.meta | 7 +
.../Sponza/11968150294050148237.jpg | 3 +
.../Sponza/11968150294050148237.jpg.meta | 7 +
.../Sponza/1219024358953944284.jpg | 3 +
.../Sponza/1219024358953944284.jpg.meta | 7 +
.../Sponza/12501374198249454378.jpg | 3 +
.../Sponza/12501374198249454378.jpg.meta | 7 +
.../Sponza/13196865903111448057.jpg | 3 +
.../Sponza/13196865903111448057.jpg.meta | 7 +
.../Sponza/13824894030729245199.jpg | 3 +
.../Sponza/13824894030729245199.jpg.meta | 7 +
.../Sponza/13982482287905699490.jpg | 3 +
.../Sponza/13982482287905699490.jpg.meta | 7 +
.../Sponza/14118779221266351425.jpg | 3 +
.../Sponza/14118779221266351425.jpg.meta | 7 +
.../Sponza/14170708867020035030.jpg | 3 +
.../Sponza/14170708867020035030.jpg.meta | 7 +
.../Sponza/14267839433702832875.jpg | 3 +
.../Sponza/14267839433702832875.jpg.meta | 7 +
.../Sponza/14650633544276105767.jpg | 3 +
.../Sponza/14650633544276105767.jpg.meta | 7 +
.../Sponza/15295713303328085182.jpg | 3 +
.../Sponza/15295713303328085182.jpg.meta | 7 +
.../Sponza/15722799267630235092.jpg | 3 +
.../Sponza/15722799267630235092.jpg.meta | 7 +
.../Sponza/16275776544635328252.png | 3 +
.../Sponza/16275776544635328252.png.meta | 7 +
.../Sponza/16299174074766089871.jpg | 3 +
.../Sponza/16299174074766089871.jpg.meta | 7 +
.../Sponza/16885566240357350108.jpg | 3 +
.../Sponza/16885566240357350108.jpg.meta | 7 +
.../Sponza/17556969131407844942.jpg | 3 +
.../Sponza/17556969131407844942.jpg.meta | 7 +
.../Sponza/17876391417123941155.jpg | 3 +
.../Sponza/17876391417123941155.jpg.meta | 7 +
.../Sponza/2051777328469649772.jpg | 3 +
.../Sponza/2051777328469649772.jpg.meta | 7 +
.../Sponza/2185409758123873465.jpg | 3 +
.../Sponza/2185409758123873465.jpg.meta | 7 +
.../Sponza/2299742237651021498.jpg | 3 +
.../Sponza/2299742237651021498.jpg.meta | 7 +
.../Sponza/2374361008830720677.jpg | 3 +
.../Sponza/2374361008830720677.jpg.meta | 7 +
.../Sponza/2411100444841994089.jpg | 3 +
.../Sponza/2411100444841994089.jpg.meta | 7 +
.../Sponza/2775690330959970771.jpg | 3 +
.../Sponza/2775690330959970771.jpg.meta | 7 +
.../Sponza/2969916736137545357.jpg | 3 +
.../Sponza/2969916736137545357.jpg.meta | 7 +
.../Sponza/332936164838540657.jpg | 3 +
.../Sponza/332936164838540657.jpg.meta | 7 +
.../Sponza/3371964815757888145.jpg | 3 +
.../Sponza/3371964815757888145.jpg.meta | 7 +
.../Sponza/3455394979645218238.jpg | 3 +
.../Sponza/3455394979645218238.jpg.meta | 7 +
.../Sponza/3628158980083700836.jpg | 3 +
.../Sponza/3628158980083700836.jpg.meta | 7 +
.../Sponza/3827035219084910048.jpg | 3 +
.../Sponza/3827035219084910048.jpg.meta | 7 +
.../Sponza/4477655471536070370.jpg | 3 +
.../Sponza/4477655471536070370.jpg.meta | 7 +
.../Sponza/4601176305987539675.jpg | 3 +
.../Sponza/4601176305987539675.jpg.meta | 7 +
.../Sponza/466164707995436622.jpg | 3 +
.../Sponza/466164707995436622.jpg.meta | 7 +
.../Sponza/4675343432951571524.jpg | 3 +
.../Sponza/4675343432951571524.jpg.meta | 7 +
.../Sponza/4871783166746854860.jpg | 3 +
.../Sponza/4871783166746854860.jpg.meta | 7 +
.../Sponza/4910669866631290573.jpg | 3 +
.../Sponza/4910669866631290573.jpg.meta | 7 +
.../Sponza/4975155472559461469.jpg | 3 +
.../Sponza/4975155472559461469.jpg.meta | 7 +
.../Sponza/5061699253647017043.png | 3 +
.../Sponza/5061699253647017043.png.meta | 7 +
.../Sponza/5792855332885324923.jpg | 3 +
.../Sponza/5792855332885324923.jpg.meta | 7 +
.../Sponza/5823059166183034438.jpg | 3 +
.../Sponza/5823059166183034438.jpg.meta | 7 +
.../Sponza/6047387724914829168.jpg | 3 +
.../Sponza/6047387724914829168.jpg.meta | 7 +
.../Sponza/6151467286084645207.jpg | 3 +
.../Sponza/6151467286084645207.jpg.meta | 7 +
.../Sponza/6593109234861095314.jpg | 3 +
.../Sponza/6593109234861095314.jpg.meta | 7 +
.../Sponza/6667038893015345571.jpg | 3 +
.../Sponza/6667038893015345571.jpg.meta | 7 +
.../Sponza/6772804448157695701.jpg | 3 +
.../Sponza/6772804448157695701.jpg.meta | 7 +
.../Sponza/7056944414013900257.jpg | 3 +
.../Sponza/7056944414013900257.jpg.meta | 7 +
.../Sponza/715093869573992647.jpg | 3 +
.../Sponza/715093869573992647.jpg.meta | 7 +
.../Sponza/7268504077753552595.jpg | 3 +
.../Sponza/7268504077753552595.jpg.meta | 7 +
.../Sponza/7441062115984513793.jpg | 3 +
.../Sponza/7441062115984513793.jpg.meta | 7 +
.../Sponza/755318871556304029.jpg | 3 +
.../Sponza/755318871556304029.jpg.meta | 7 +
.../Sponza/759203620573749278.jpg | 3 +
.../Sponza/759203620573749278.jpg.meta | 7 +
.../Sponza/7645212358685992005.jpg | 3 +
.../Sponza/7645212358685992005.jpg.meta | 7 +
.../Sponza/7815564343179553343.jpg | 3 +
.../Sponza/7815564343179553343.jpg.meta | 7 +
.../Sponza/8006627369776289000.png | 3 +
.../Sponza/8006627369776289000.png.meta | 7 +
.../Sponza/8051790464816141987.jpg | 3 +
.../Sponza/8051790464816141987.jpg.meta | 7 +
.../Sponza/8114461559286000061.jpg | 3 +
.../Sponza/8114461559286000061.jpg.meta | 7 +
.../Sponza/8481240838833932244.jpg | 3 +
.../Sponza/8481240838833932244.jpg.meta | 7 +
.../Sponza/8503262930880235456.jpg | 3 +
.../Sponza/8503262930880235456.jpg.meta | 7 +
.../Sponza/8747919177698443163.jpg | 3 +
.../Sponza/8747919177698443163.jpg.meta | 7 +
.../Sponza/8750083169368950601.jpg | 3 +
.../Sponza/8750083169368950601.jpg.meta | 7 +
.../Sponza/8773302468495022225.jpg | 3 +
.../Sponza/8773302468495022225.jpg.meta | 7 +
.../Sponza/8783994986360286082.jpg | 3 +
.../Sponza/8783994986360286082.jpg.meta | 7 +
.../Sponza/9288698199695299068.jpg | 3 +
.../Sponza/9288698199695299068.jpg.meta | 7 +
.../Sponza/9916269861720640319.jpg | 3 +
.../Sponza/9916269861720640319.jpg.meta | 7 +
.../Assets/StreamingAssets/Sponza/Sponza.bin | 3 +
.../StreamingAssets/Sponza/Sponza.bin.meta | 7 +
.../Assets/StreamingAssets/Sponza/Sponza.gltf | 8573 +++++++++++
.../StreamingAssets/Sponza/Sponza.gltf.meta | 7 +
.../Assets/StreamingAssets/Sponza/white.png | 3 +
.../StreamingAssets/Sponza/white.png.meta | 7 +
.../Assets/StreamingAssets/calibration.glb | 3 +
.../StreamingAssets/calibration.glb.meta | 7 +
.../Assets/StreamingAssets/persona.glb | 3 +
.../Assets/StreamingAssets/persona.glb.meta | 7 +
.../Assets/StreamingAssets/scene.json | 110 +
.../Assets/StreamingAssets/scene.json.meta | 7 +
.../Assets/StreamingAssets/world.glb | 3 +
.../Assets/StreamingAssets/world.glb.meta | 7 +
unity/MyInfoMateVR/Assets/TextMesh Pro.meta | 8 +
.../Assets/TextMesh Pro/Fonts.meta | 8 +
.../Fonts/LiberationSans - OFL.txt | 46 +
.../Fonts/LiberationSans - OFL.txt.meta | 8 +
.../TextMesh Pro/Fonts/LiberationSans.ttf | Bin 0 -> 350200 bytes
.../Fonts/LiberationSans.ttf.meta | 19 +
.../Assets/TextMesh Pro/Resources.meta | 8 +
.../Resources/Fonts & Materials.meta | 9 +
.../LiberationSans SDF - Drop Shadow.mat | 106 +
.../LiberationSans SDF - Drop Shadow.mat.meta | 8 +
.../LiberationSans SDF - Fallback.asset | 348 +
.../LiberationSans SDF - Fallback.asset.meta | 8 +
.../LiberationSans SDF - Outline.mat | 104 +
.../LiberationSans SDF - Outline.mat.meta | 8 +
.../LiberationSans SDF.asset | 7821 ++++++++++
.../LiberationSans SDF.asset.meta | 8 +
.../LineBreaking Following Characters.txt | 1 +
...LineBreaking Following Characters.txt.meta | 8 +
.../LineBreaking Leading Characters.txt | 1 +
.../LineBreaking Leading Characters.txt.meta | 8 +
.../TextMesh Pro/Resources/Sprite Assets.meta | 9 +
.../Resources/Sprite Assets/EmojiOne.asset | 659 +
.../Sprite Assets/EmojiOne.asset.meta | 8 +
.../TextMesh Pro/Resources/Style Sheets.meta | 9 +
.../Style Sheets/Default Style Sheet.asset | 81 +
.../Default Style Sheet.asset.meta | 8 +
.../TextMesh Pro/Resources/TMP Settings.asset | 52 +
.../Resources/TMP Settings.asset.meta | 8 +
.../Assets/TextMesh Pro/Shaders.meta | 8 +
.../TextMesh Pro/Shaders/SDFFunctions.hlsl | 178 +
.../Shaders/SDFFunctions.hlsl.meta | 10 +
.../Shaders/TMP_Bitmap-Custom-Atlas.shader | 145 +
.../TMP_Bitmap-Custom-Atlas.shader.meta | 9 +
.../Shaders/TMP_Bitmap-Mobile.shader | 155 +
.../Shaders/TMP_Bitmap-Mobile.shader.meta | 9 +
.../TextMesh Pro/Shaders/TMP_Bitmap.shader | 145 +
.../Shaders/TMP_Bitmap.shader.meta | 9 +
.../Shaders/TMP_SDF Overlay.shader | 326 +
.../Shaders/TMP_SDF Overlay.shader.meta | 9 +
.../TextMesh Pro/Shaders/TMP_SDF SSD.shader | 321 +
.../Shaders/TMP_SDF SSD.shader.meta | 9 +
.../Shaders/TMP_SDF-HDRP LIT.shadergraph | 12074 ++++++++++++++++
.../Shaders/TMP_SDF-HDRP LIT.shadergraph.meta | 10 +
.../Shaders/TMP_SDF-HDRP UNLIT.shadergraph | 11759 +++++++++++++++
.../TMP_SDF-HDRP UNLIT.shadergraph.meta | 10 +
.../Shaders/TMP_SDF-Mobile Masking.shader | 258 +
.../TMP_SDF-Mobile Masking.shader.meta | 9 +
.../Shaders/TMP_SDF-Mobile Overlay.shader | 252 +
.../TMP_SDF-Mobile Overlay.shader.meta | 9 +
.../Shaders/TMP_SDF-Mobile SSD.shader | 106 +
.../Shaders/TMP_SDF-Mobile SSD.shader.meta | 9 +
.../Shaders/TMP_SDF-Mobile-2-Pass.shader | 389 +
.../Shaders/TMP_SDF-Mobile-2-Pass.shader.meta | 9 +
.../Shaders/TMP_SDF-Mobile.shader | 250 +
.../Shaders/TMP_SDF-Mobile.shader.meta | 9 +
.../Shaders/TMP_SDF-Surface-Mobile.shader | 139 +
.../TMP_SDF-Surface-Mobile.shader.meta | 9 +
.../Shaders/TMP_SDF-Surface.shader | 159 +
.../Shaders/TMP_SDF-Surface.shader.meta | 9 +
.../Shaders/TMP_SDF-URP Lit.shadergraph | 11932 +++++++++++++++
.../Shaders/TMP_SDF-URP Lit.shadergraph.meta | 10 +
.../Shaders/TMP_SDF-URP Unlit.shadergraph | 11629 +++++++++++++++
.../TMP_SDF-URP Unlit.shadergraph.meta | 10 +
.../TextMesh Pro/Shaders/TMP_SDF.shader | 326 +
.../TextMesh Pro/Shaders/TMP_SDF.shader.meta | 9 +
.../TextMesh Pro/Shaders/TMP_Sprite.shader | 131 +
.../Shaders/TMP_Sprite.shader.meta | 9 +
.../Assets/TextMesh Pro/Shaders/TMPro.cginc | 84 +
.../TextMesh Pro/Shaders/TMPro.cginc.meta | 9 +
.../TextMesh Pro/Shaders/TMPro_Mobile.cginc | 165 +
.../Shaders/TMPro_Mobile.cginc.meta | 9 +
.../Shaders/TMPro_Properties.cginc | 80 +
.../Shaders/TMPro_Properties.cginc.meta | 9 +
.../TextMesh Pro/Shaders/TMPro_Surface.cginc | 99 +
.../Shaders/TMPro_Surface.cginc.meta | 9 +
.../Assets/TextMesh Pro/Sprites.meta | 8 +
.../Sprites/EmojiOne Attribution.txt | 3 +
.../Sprites/EmojiOne Attribution.txt.meta | 7 +
.../Assets/TextMesh Pro/Sprites/EmojiOne.json | 156 +
.../TextMesh Pro/Sprites/EmojiOne.json.meta | 8 +
.../Assets/TextMesh Pro/Sprites/EmojiOne.png | Bin 0 -> 112319 bytes
.../TextMesh Pro/Sprites/EmojiOne.png.meta | 431 +
unity/MyInfoMateVR/Assets/TutorialInfo.meta | 8 +
.../Assets/TutorialInfo/Icons.meta | 9 +
.../Assets/TutorialInfo/Icons/URP.png | Bin 0 -> 24069 bytes
.../Assets/TutorialInfo/Icons/URP.png.meta | 134 +
.../Assets/TutorialInfo/Layout.wlt | 654 +
.../Assets/TutorialInfo/Layout.wlt.meta | 8 +
.../Assets/TutorialInfo/Scripts.meta | 9 +
.../Assets/TutorialInfo/Scripts/Editor.meta | 9 +
.../Scripts/Editor/ReadmeEditor.cs | 242 +
.../Scripts/Editor/ReadmeEditor.cs.meta | 12 +
.../Assets/TutorialInfo/Scripts/Readme.cs | 16 +
.../TutorialInfo/Scripts/Readme.cs.meta | 12 +
unity/MyInfoMateVR/Assets/XR.meta | 8 +
.../x64/XrApiLayer_METAX_operator.dll | 3 +
.../x64/XrApiLayer_METAX_operator.json | 17 +
unity/MyInfoMateVR/Assets/XR/Loaders.meta | 8 +
.../Assets/XR/Loaders/OpenXRLoader.asset | 14 +
.../Assets/XR/Loaders/OpenXRLoader.asset.meta | 8 +
unity/MyInfoMateVR/Assets/XR/Settings.meta | 8 +
.../XR/Settings/OpenXR Editor Settings.asset | 21 +
.../OpenXR Editor Settings.asset.meta | 8 +
.../XR/Settings/OpenXR Package Settings.asset | 1686 +++
.../OpenXR Package Settings.asset.meta | 8 +
.../XR/XRGeneralSettingsPerBuildTarget.asset | 111 +
...XRGeneralSettingsPerBuildTarget.asset.meta | 8 +
unity/MyInfoMateVR/Packages/manifest.json | 55 +
unity/MyInfoMateVR/Packages/manifest.json.bak | 49 +
.../MyInfoMateVR/Packages/packages-lock.json | 679 +
.../ProjectSettings/AudioManager.asset | 20 +
.../ProjectSettings/ClusterInputManager.asset | 6 +
.../ProjectSettings/DynamicsManager.asset | 36 +
.../ProjectSettings/EditorBuildSettings.asset | 15 +
.../ProjectSettings/EditorSettings.asset | 50 +
.../ProjectSettings/GraphicsSettings.asset | 73 +
.../ProjectSettings/InputManager.asset | 487 +
.../ProjectSettings/MemorySettings.asset | 35 +
.../ProjectSettings/MultiplayerManager.asset | 7 +
.../ProjectSettings/NavMeshAreas.asset | 91 +
.../PackageManagerSettings.asset | 40 +
.../ProjectSettings/Physics2DSettings.asset | 56 +
.../ProjectSettings/PresetManager.asset | 7 +
.../ProjectSettings/ProjectSettings.asset | 948 ++
.../ProjectSettings/ProjectVersion.txt | 2 +
.../ProjectSettings/QualitySettings.asset | 133 +
.../SceneTemplateSettings.json | 121 +
.../ProjectSettings/ShaderGraphSettings.asset | 19 +
.../ProjectSettings/TagManager.asset | 79 +
.../ProjectSettings/TimeManager.asset | 9 +
.../ProjectSettings/URPProjectSettings.asset | 15 +
.../UnityConnectSettings.asset | 40 +
.../ProjectSettings/VFXManager.asset | 12 +
.../VersionControlSettings.asset | 8 +
.../ProjectSettings/XRSettings.asset | 10 +
viewer/README.md | 92 +
viewer/index.html | 18 +
viewer/package-lock.json | 1021 ++
viewer/package.json | 22 +
viewer/scripts/copy-decoders.mjs | 35 +
viewer/src/Bounds.ts | 101 +
viewer/src/Editor.ts | 376 +
viewer/src/Panel.ts | 477 +
viewer/src/Viewer.ts | 360 +
viewer/src/bridge.ts | 109 +
viewer/src/main.ts | 420 +
viewer/src/manifest.ts | 265 +
viewer/src/style.css | 335 +
viewer/tsconfig.json | 16 +
viewer/vite.config.ts | 14 +
494 files changed, 101967 insertions(+)
create mode 100644 .gitattributes
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 docs/00-phase1-etat-des-lieux.md
create mode 100644 docs/01-phase0-setup-unity.md
create mode 100644 docs/02-decisions.md
create mode 100644 docs/03-phase2-conception.md
create mode 100644 docs/04-phase3-plan.md
create mode 100644 tools/make_calibration_glb.py
create mode 100644 unity-overlay/Assets/Scripts/Boot/MenuBootstrap.cs
create mode 100644 unity-overlay/Assets/Scripts/Boot/PairingBootstrap.cs
create mode 100644 unity-overlay/Assets/Scripts/Boot/S1Bootstrap.cs
create mode 100644 unity-overlay/Assets/Scripts/Boot/S2Bootstrap.cs
create mode 100644 unity-overlay/Assets/Scripts/Editor/BuildBootScene.cs
create mode 100644 unity-overlay/Assets/Scripts/Editor/IncludeRuntimeShaders.cs
create mode 100644 unity-overlay/Assets/Scripts/Kiosk/FleetReporter.cs
create mode 100644 unity-overlay/Assets/Scripts/Kiosk/KioskSession.cs
create mode 100644 unity-overlay/Assets/Scripts/Manifest/ManifestReader.cs
create mode 100644 unity-overlay/Assets/Scripts/Manifest/SceneManifest.cs
create mode 100644 unity-overlay/Assets/Scripts/Menu/AimSelector.cs
create mode 100644 unity-overlay/Assets/Scripts/Menu/FloatingMenu.cs
create mode 100644 unity-overlay/Assets/Scripts/Menu/ImmersiveBackdrop.cs
create mode 100644 unity-overlay/Assets/Scripts/Menu/MenuFeedback.cs
create mode 100644 unity-overlay/Assets/Scripts/Menu/MenuItemPanel.cs
create mode 100644 unity-overlay/Assets/Scripts/Menu/PagedView.cs
create mode 100644 unity-overlay/Assets/Scripts/Menu/PanoramicSky.cs
create mode 100644 unity-overlay/Assets/Scripts/Menu/Scene3DView.cs
create mode 100644 unity-overlay/Assets/Scripts/Menu/SectionPages.cs
create mode 100644 unity-overlay/Assets/Scripts/Menu/SkyboxView.cs
create mode 100644 unity-overlay/Assets/Scripts/Net/ApiClient.cs
create mode 100644 unity-overlay/Assets/Scripts/Net/ConfigurationExport.cs
create mode 100644 unity-overlay/Assets/Scripts/Net/ContentCache.cs
create mode 100644 unity-overlay/Assets/Scripts/Net/ContentPreloader.cs
create mode 100644 unity-overlay/Assets/Scripts/Net/ContentService.cs
create mode 100644 unity-overlay/Assets/Scripts/Net/PairingService.cs
create mode 100644 unity-overlay/Assets/Scripts/Net/Telemetry.cs
create mode 100644 unity-overlay/Assets/Scripts/Scene/CalibrationCheck.cs
create mode 100644 unity-overlay/Assets/Scripts/Scene/GltfLoader.cs
create mode 100644 unity-overlay/Assets/Scripts/Scene/GltfSpace.cs
create mode 100644 unity-overlay/Assets/Scripts/Scene/HotspotInstance.cs
create mode 100644 unity-overlay/Assets/Scripts/Scene/NavigationBounds.cs
create mode 100644 unity-overlay/Assets/Scripts/Scene/PersonaInstance.cs
create mode 100644 unity-overlay/Assets/Scripts/Scene/SceneBuilder.cs
create mode 100644 unity-overlay/Assets/Scripts/Scene/SceneMessage.cs
create mode 100644 unity-overlay/Assets/Scripts/Ui/Label.cs
create mode 100644 unity-overlay/Assets/StreamingAssets/calibration.glb
create mode 100644 unity-overlay/Assets/StreamingAssets/scene.json
create mode 100644 unity-overlay/Packages/manifest.json
create mode 100644 unity-overlay/README.md
create mode 100644 unity/MyInfoMateVR/.claude/skills/meta-xr-operator-coordinates/SKILL.md
create mode 100644 unity/MyInfoMateVR/.claude/skills/meta-xr-operator-grabbed-objects/SKILL.md
create mode 100644 unity/MyInfoMateVR/.claude/skills/meta-xr-operator-interaction-grab/SKILL.md
create mode 100644 unity/MyInfoMateVR/.claude/skills/meta-xr-operator-interaction-poke/SKILL.md
create mode 100644 unity/MyInfoMateVR/.claude/skills/meta-xr-operator-unity-meta-quest/SKILL.md
create mode 100644 unity/MyInfoMateVR/.claude/skills/meta-xr-operator-unity-test-mechanics/SKILL.md
create mode 100644 unity/MyInfoMateVR/.claude/skills/meta-xr-operator-unity-workflow/SKILL.md
create mode 100644 unity/MyInfoMateVR/.claude/skills/meta-xr-operator/SKILL.md
create mode 100644 unity/MyInfoMateVR/.vsconfig
create mode 100644 unity/MyInfoMateVR/Assets/InputSystem_Actions.inputactions
create mode 100644 unity/MyInfoMateVR/Assets/InputSystem_Actions.inputactions.meta
create mode 100644 unity/MyInfoMateVR/Assets/Oculus.meta
create mode 100644 unity/MyInfoMateVR/Assets/Oculus/OculusProjectConfig.asset
create mode 100644 unity/MyInfoMateVR/Assets/Oculus/OculusProjectConfig.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Plugins.meta
create mode 100644 unity/MyInfoMateVR/Assets/Plugins/Android.meta
create mode 100644 unity/MyInfoMateVR/Assets/Plugins/Android/AndroidManifest.xml
create mode 100644 unity/MyInfoMateVR/Assets/Plugins/Android/AndroidManifest.xml.meta
create mode 100644 unity/MyInfoMateVR/Assets/Readme.asset
create mode 100644 unity/MyInfoMateVR/Assets/Readme.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Resources.meta
create mode 100644 unity/MyInfoMateVR/Assets/Resources/DevAgentSettings.asset
create mode 100644 unity/MyInfoMateVR/Assets/Resources/DevAgentSettings.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Resources/ImmersiveDebuggerSettings.asset
create mode 100644 unity/MyInfoMateVR/Assets/Resources/ImmersiveDebuggerSettings.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Resources/InputActions.asset
create mode 100644 unity/MyInfoMateVR/Assets/Resources/InputActions.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Resources/MetaXRAcousticMaterialMapping.asset
create mode 100644 unity/MyInfoMateVR/Assets/Resources/MetaXRAcousticMaterialMapping.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Resources/MetaXRAcousticSettings.asset
create mode 100644 unity/MyInfoMateVR/Assets/Resources/MetaXRAcousticSettings.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Resources/MetaXRAudioSettings.asset
create mode 100644 unity/MyInfoMateVR/Assets/Resources/MetaXRAudioSettings.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Resources/OVRBuildConfig.asset
create mode 100644 unity/MyInfoMateVR/Assets/Resources/OVRBuildConfig.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Resources/OVRPlatformToolSettings.asset
create mode 100644 unity/MyInfoMateVR/Assets/Resources/OVRPlatformToolSettings.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Resources/OculusRuntimeSettings.asset
create mode 100644 unity/MyInfoMateVR/Assets/Resources/OculusRuntimeSettings.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scenes.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scenes/Boot.unity
create mode 100644 unity/MyInfoMateVR/Assets/Scenes/Boot.unity.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scenes/SampleScene.unity
create mode 100644 unity/MyInfoMateVR/Assets/Scenes/SampleScene.unity.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Boot.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Boot/MenuBootstrap.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Boot/MenuBootstrap.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Boot/PairingBootstrap.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Boot/PairingBootstrap.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Boot/S1Bootstrap.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Boot/S1Bootstrap.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Boot/S2Bootstrap.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Boot/S2Bootstrap.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Editor.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Editor/BuildBootScene.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Editor/BuildBootScene.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Editor/IncludeRuntimeShaders.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Editor/IncludeRuntimeShaders.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Kiosk.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Kiosk/FleetReporter.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Kiosk/FleetReporter.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Kiosk/KioskSession.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Kiosk/KioskSession.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Manifest.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Manifest/ManifestReader.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Manifest/ManifestReader.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Manifest/SceneManifest.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Manifest/SceneManifest.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/AimSelector.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/AimSelector.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/BentoLayout.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/FloatingMenu.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/FloatingMenu.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/ImmersiveBackdrop.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/ImmersiveBackdrop.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/MenuFeedback.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/MenuFeedback.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/MenuItemPanel.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/MenuItemPanel.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/PagedView.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/PagedView.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/PanoramicSky.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/PanoramicSky.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/Scene3DView.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/Scene3DView.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/SectionPages.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/SectionPages.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/SkyboxView.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Menu/SkyboxView.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/ApiClient.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/ApiClient.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/ConfigurationExport.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/ConfigurationExport.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/ContentCache.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/ContentCache.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/ContentPreloader.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/ContentPreloader.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/ContentService.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/ContentService.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/PairingService.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/PairingService.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/Telemetry.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Net/Telemetry.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/CalibrationCheck.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/CalibrationCheck.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/GltfLoader.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/GltfLoader.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/GltfSpace.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/GltfSpace.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/HotspotInstance.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/HotspotInstance.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/NavigationBounds.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/NavigationBounds.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/PersonaInstance.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/PersonaInstance.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/SceneBuilder.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/SceneBuilder.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/SceneMessage.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Scene/SceneMessage.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Ui.meta
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Ui/Label.cs
create mode 100644 unity/MyInfoMateVR/Assets/Scripts/Ui/Label.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/Settings.meta
create mode 100644 unity/MyInfoMateVR/Assets/Settings/DefaultVolumeProfile.asset
create mode 100644 unity/MyInfoMateVR/Assets/Settings/DefaultVolumeProfile.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Settings/Mobile_RPAsset.asset
create mode 100644 unity/MyInfoMateVR/Assets/Settings/Mobile_RPAsset.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Settings/Mobile_Renderer.asset
create mode 100644 unity/MyInfoMateVR/Assets/Settings/Mobile_Renderer.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Settings/PC_RPAsset.asset
create mode 100644 unity/MyInfoMateVR/Assets/Settings/PC_RPAsset.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Settings/PC_Renderer.asset
create mode 100644 unity/MyInfoMateVR/Assets/Settings/PC_Renderer.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Settings/SampleSceneProfile.asset
create mode 100644 unity/MyInfoMateVR/Assets/Settings/SampleSceneProfile.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset
create mode 100644 unity/MyInfoMateVR/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/Settings/glTFastVariants.shadervariants
create mode 100644 unity/MyInfoMateVR/Assets/Settings/glTFastVariants.shadervariants.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/10381718147657362067.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/10381718147657362067.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/10388182081421875623.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/10388182081421875623.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/11474523244911310074.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/11474523244911310074.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/11490520546946913238.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/11490520546946913238.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/11872827283454512094.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/11872827283454512094.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/11968150294050148237.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/11968150294050148237.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/1219024358953944284.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/1219024358953944284.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/12501374198249454378.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/12501374198249454378.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/13196865903111448057.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/13196865903111448057.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/13824894030729245199.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/13824894030729245199.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/13982482287905699490.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/13982482287905699490.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/14118779221266351425.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/14118779221266351425.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/14170708867020035030.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/14170708867020035030.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/14267839433702832875.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/14267839433702832875.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/14650633544276105767.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/14650633544276105767.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/15295713303328085182.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/15295713303328085182.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/15722799267630235092.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/15722799267630235092.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/16275776544635328252.png
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/16275776544635328252.png.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/16299174074766089871.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/16299174074766089871.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/16885566240357350108.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/16885566240357350108.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/17556969131407844942.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/17556969131407844942.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/17876391417123941155.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/17876391417123941155.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2051777328469649772.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2051777328469649772.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2185409758123873465.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2185409758123873465.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2299742237651021498.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2299742237651021498.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2374361008830720677.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2374361008830720677.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2411100444841994089.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2411100444841994089.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2775690330959970771.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2775690330959970771.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2969916736137545357.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/2969916736137545357.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/332936164838540657.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/332936164838540657.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/3371964815757888145.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/3371964815757888145.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/3455394979645218238.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/3455394979645218238.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/3628158980083700836.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/3628158980083700836.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/3827035219084910048.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/3827035219084910048.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/4477655471536070370.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/4477655471536070370.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/4601176305987539675.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/4601176305987539675.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/466164707995436622.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/466164707995436622.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/4675343432951571524.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/4675343432951571524.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/4871783166746854860.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/4871783166746854860.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/4910669866631290573.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/4910669866631290573.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/4975155472559461469.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/4975155472559461469.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/5061699253647017043.png
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/5061699253647017043.png.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/5792855332885324923.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/5792855332885324923.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/5823059166183034438.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/5823059166183034438.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/6047387724914829168.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/6047387724914829168.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/6151467286084645207.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/6151467286084645207.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/6593109234861095314.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/6593109234861095314.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/6667038893015345571.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/6667038893015345571.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/6772804448157695701.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/6772804448157695701.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/7056944414013900257.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/7056944414013900257.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/715093869573992647.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/715093869573992647.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/7268504077753552595.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/7268504077753552595.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/7441062115984513793.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/7441062115984513793.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/755318871556304029.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/755318871556304029.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/759203620573749278.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/759203620573749278.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/7645212358685992005.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/7645212358685992005.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/7815564343179553343.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/7815564343179553343.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8006627369776289000.png
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8006627369776289000.png.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8051790464816141987.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8051790464816141987.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8114461559286000061.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8114461559286000061.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8481240838833932244.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8481240838833932244.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8503262930880235456.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8503262930880235456.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8747919177698443163.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8747919177698443163.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8750083169368950601.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8750083169368950601.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8773302468495022225.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8773302468495022225.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8783994986360286082.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/8783994986360286082.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/9288698199695299068.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/9288698199695299068.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/9916269861720640319.jpg
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/9916269861720640319.jpg.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/Sponza.bin
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/Sponza.bin.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/Sponza.gltf
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/Sponza.gltf.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/white.png
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/Sponza/white.png.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/calibration.glb
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/calibration.glb.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/persona.glb
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/persona.glb.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/scene.json
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/scene.json.meta
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/world.glb
create mode 100644 unity/MyInfoMateVR/Assets/StreamingAssets/world.glb.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Fonts.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Fonts/LiberationSans - OFL.txt
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Fonts/LiberationSans - OFL.txt.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Fonts/LiberationSans.ttf
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Fonts/LiberationSans.ttf.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Fonts & Materials.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Fonts & Materials/LiberationSans SDF - Drop Shadow.mat
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Fonts & Materials/LiberationSans SDF - Drop Shadow.mat.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Fonts & Materials/LiberationSans SDF - Fallback.asset
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Fonts & Materials/LiberationSans SDF - Fallback.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Fonts & Materials/LiberationSans SDF - Outline.mat
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Fonts & Materials/LiberationSans SDF - Outline.mat.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Fonts & Materials/LiberationSans SDF.asset
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Fonts & Materials/LiberationSans SDF.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/LineBreaking Following Characters.txt
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/LineBreaking Following Characters.txt.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/LineBreaking Leading Characters.txt
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/LineBreaking Leading Characters.txt.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Sprite Assets.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Sprite Assets/EmojiOne.asset
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Sprite Assets/EmojiOne.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Style Sheets.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Style Sheets/Default Style Sheet.asset
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/Style Sheets/Default Style Sheet.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/TMP Settings.asset
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Resources/TMP Settings.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/SDFFunctions.hlsl
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/SDFFunctions.hlsl.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_Bitmap-Custom-Atlas.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_Bitmap-Custom-Atlas.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_Bitmap-Mobile.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_Bitmap-Mobile.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_Bitmap.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_Bitmap.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF Overlay.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF Overlay.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF SSD.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF SSD.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-HDRP LIT.shadergraph
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-HDRP LIT.shadergraph.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-HDRP UNLIT.shadergraph
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-HDRP UNLIT.shadergraph.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Mobile Masking.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Mobile Masking.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Mobile Overlay.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Mobile Overlay.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Mobile SSD.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Mobile SSD.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Mobile-2-Pass.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Mobile-2-Pass.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Mobile.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Mobile.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Surface-Mobile.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Surface-Mobile.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Surface.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-Surface.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-URP Lit.shadergraph
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-URP Lit.shadergraph.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-URP Unlit.shadergraph
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF-URP Unlit.shadergraph.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_SDF.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_Sprite.shader
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMP_Sprite.shader.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMPro.cginc
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMPro.cginc.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMPro_Mobile.cginc
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMPro_Mobile.cginc.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMPro_Properties.cginc
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMPro_Properties.cginc.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMPro_Surface.cginc
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Shaders/TMPro_Surface.cginc.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Sprites.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Sprites/EmojiOne Attribution.txt
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Sprites/EmojiOne Attribution.txt.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Sprites/EmojiOne.json
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Sprites/EmojiOne.json.meta
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Sprites/EmojiOne.png
create mode 100644 unity/MyInfoMateVR/Assets/TextMesh Pro/Sprites/EmojiOne.png.meta
create mode 100644 unity/MyInfoMateVR/Assets/TutorialInfo.meta
create mode 100644 unity/MyInfoMateVR/Assets/TutorialInfo/Icons.meta
create mode 100644 unity/MyInfoMateVR/Assets/TutorialInfo/Icons/URP.png
create mode 100644 unity/MyInfoMateVR/Assets/TutorialInfo/Icons/URP.png.meta
create mode 100644 unity/MyInfoMateVR/Assets/TutorialInfo/Layout.wlt
create mode 100644 unity/MyInfoMateVR/Assets/TutorialInfo/Layout.wlt.meta
create mode 100644 unity/MyInfoMateVR/Assets/TutorialInfo/Scripts.meta
create mode 100644 unity/MyInfoMateVR/Assets/TutorialInfo/Scripts/Editor.meta
create mode 100644 unity/MyInfoMateVR/Assets/TutorialInfo/Scripts/Editor/ReadmeEditor.cs
create mode 100644 unity/MyInfoMateVR/Assets/TutorialInfo/Scripts/Editor/ReadmeEditor.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/TutorialInfo/Scripts/Readme.cs
create mode 100644 unity/MyInfoMateVR/Assets/TutorialInfo/Scripts/Readme.cs.meta
create mode 100644 unity/MyInfoMateVR/Assets/XR.meta
create mode 100644 unity/MyInfoMateVR/Assets/XR/APILayers~/WindowsLayers/x64/XrApiLayer_METAX_operator.dll
create mode 100644 unity/MyInfoMateVR/Assets/XR/APILayers~/WindowsLayers/x64/XrApiLayer_METAX_operator.json
create mode 100644 unity/MyInfoMateVR/Assets/XR/Loaders.meta
create mode 100644 unity/MyInfoMateVR/Assets/XR/Loaders/OpenXRLoader.asset
create mode 100644 unity/MyInfoMateVR/Assets/XR/Loaders/OpenXRLoader.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/XR/Settings.meta
create mode 100644 unity/MyInfoMateVR/Assets/XR/Settings/OpenXR Editor Settings.asset
create mode 100644 unity/MyInfoMateVR/Assets/XR/Settings/OpenXR Editor Settings.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/XR/Settings/OpenXR Package Settings.asset
create mode 100644 unity/MyInfoMateVR/Assets/XR/Settings/OpenXR Package Settings.asset.meta
create mode 100644 unity/MyInfoMateVR/Assets/XR/XRGeneralSettingsPerBuildTarget.asset
create mode 100644 unity/MyInfoMateVR/Assets/XR/XRGeneralSettingsPerBuildTarget.asset.meta
create mode 100644 unity/MyInfoMateVR/Packages/manifest.json
create mode 100644 unity/MyInfoMateVR/Packages/manifest.json.bak
create mode 100644 unity/MyInfoMateVR/Packages/packages-lock.json
create mode 100644 unity/MyInfoMateVR/ProjectSettings/AudioManager.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/ClusterInputManager.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/DynamicsManager.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/EditorBuildSettings.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/EditorSettings.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/GraphicsSettings.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/InputManager.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/MemorySettings.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/MultiplayerManager.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/NavMeshAreas.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/PackageManagerSettings.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/Physics2DSettings.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/PresetManager.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/ProjectSettings.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/ProjectVersion.txt
create mode 100644 unity/MyInfoMateVR/ProjectSettings/QualitySettings.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/SceneTemplateSettings.json
create mode 100644 unity/MyInfoMateVR/ProjectSettings/ShaderGraphSettings.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/TagManager.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/TimeManager.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/URPProjectSettings.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/UnityConnectSettings.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/VFXManager.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/VersionControlSettings.asset
create mode 100644 unity/MyInfoMateVR/ProjectSettings/XRSettings.asset
create mode 100644 viewer/README.md
create mode 100644 viewer/index.html
create mode 100644 viewer/package-lock.json
create mode 100644 viewer/package.json
create mode 100644 viewer/scripts/copy-decoders.mjs
create mode 100644 viewer/src/Bounds.ts
create mode 100644 viewer/src/Editor.ts
create mode 100644 viewer/src/Panel.ts
create mode 100644 viewer/src/Viewer.ts
create mode 100644 viewer/src/bridge.ts
create mode 100644 viewer/src/main.ts
create mode 100644 viewer/src/manifest.ts
create mode 100644 viewer/src/style.css
create mode 100644 viewer/tsconfig.json
create mode 100644 viewer/vite.config.ts
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