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

31 KiB

Phase 2 — Conception

Écrit le 2026-09-03, à partir de l'état des lieux (00) et des décisions D1 à D6 (02).

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

{
  "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.

// 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 :

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.