vr-app/docs/03-phase2-conception.md
Thomas Fransolet 0310d28b5e Canal VR Meta Quest : projet Unity, viewer web et documentation
Premier commit du cinquième front. Trois parties :

- `unity/MyInfoMateVR/` : le projet Unity (6000.0.83f1, URP, Meta XR SDK 205),
  un APK unique pour tous les clients. Menu flottant à sélection au regard,
  appairage, chargement de scène GLB, POI, cache de contenu, télémétrie.
- `unity-overlay/` : les mêmes scripts à recopier sur un projet Unity neuf, avec
  les pièges rencontrés consignés dans son README.
- `viewer/` : viewer et éditeur de scène web autonome (Vite, TypeScript,
  three.js), partagé avec les autres fronts.
- `docs/` : état des lieux, setup Unity, décisions d'architecture et plan
  d'exécution en 9 étapes.

La scène est décrite par un `scene.json` poussé par `adb push` : l'app le
préfère à celui embarqué dans l'APK.

Les binaires (GLB, textures de l'échantillon Sponza, DLL Meta XR) passent par
Git LFS dès ce premier commit — les y faire entrer après coup demanderait de
réécrire l'historique. Les artefacts régénérés par l'éditeur et par CMake
(`Library/`, `.utmp/`, Burst debug) sont ignorés.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:26:07 +02:00

645 lines
31 KiB
Markdown

# 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<Scene3DObjectDTO> Objects { get; set; }
[Column(TypeName = "jsonb")] public List<Scene3DPersonaDTO> Personas { get; set; }
public List<GeoPoint> 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<string> 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<SectionScene3D>("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.