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>
645 lines
31 KiB
Markdown
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.
|