# Phase 2 — Conception > Écrit le 2026-09-03, à partir de l'état des lieux ([00](00-phase1-etat-des-lieux.md)) et des > décisions D1 à D6 ([02](02-decisions.md)). > > **Ce document ne modifie aucun code.** Les schémas, colonnes et signatures ci-dessous sont des > propositions à lire et à critiquer. Aucune migration EF n'est générée, aucune classe n'est créée. --- ## 1. Le principe, et sa conséquence unique **Le client capture, compose, publie. Il ne modélise rien, ne code rien, ne rebuild rien.** Traduit en règle d'architecture, une seule ligne à laquelle tout le reste obéit : > **Un rebuild d'APK n'est nécessaire que si j'ajoute une fonctionnalité. Jamais si un client change > son contenu.** Test de validation permanent : *si la réponse à « il faut rebuilder ? » est oui pour un changement de contenu, la conception est fausse.* Concrètement, ça interdit trois choses dans le projet Unity : | Interdit | Pourquoi | |---|---| | Un asset de contenu dans `Assets/` | il serait dans l'APK, donc figé au build | | Une scène `.unity` par client ou par monde | idem, et ça multiplie les builds | | Une liste en dur de personnages, de mondes, de langues | c'est de la donnée, elle vient du manifeste | Tout le contenu passe par **une seule porte : le manifeste de scène**, et tout ce qu'il référence est téléchargé au runtime. --- ## 2. Le manifeste de scène — le contrat central C'est la pièce dont dépendent les trois consommateurs : l'app Quest, le viewer web, le back-office. Il est **généré par le serveur**, jamais saisi à la main. ### 2.0 Le format est déjà commun aux deux stacks — ne pas confondre avec la convention **glTF / GLB *est* le format qui marche des deux côtés.** Le même `.glb`, octet pour octet, est lu par three.js (`GLTFLoader`) et par Unity (`glTFast`). Il n'y a **ni conversion de fichier, ni export parallèle, ni format Unity séparé**. C'est précisément pour ça qu'il a été retenu comme format pivot. Et ça vaut aussi pour les optimisations, parce que ce sont des **extensions glTF standard**, pas des formats concurrents : | Extension | three.js | Unity | |---|---|---| | `KHR_texture_basisu` (KTX2) | `KTX2Loader` | `com.unity.cloud.ktx` | | `KHR_draco_mesh_compression` | `DRACOLoader` | `com.unity.cloud.draco` | | `EXT_meshopt_compression` | `MeshoptDecoder` | `com.unity.meshopt.decompress` | | `KHR_materials_*` (PBR core) | natif | natif | ➡️ Le pipeline est **un seul fichier, deux lecteurs**. Un monde généré est vu dans le viewer du back-office, puis publié sur le canal VR, sans transformation entre les deux. **La seule chose qui n'a pas de format commun, ce sont les splats gaussiens** — c'est exactement pourquoi `world.kind` existe (§2.2) : la V1 ne lit que `"mesh"` et refuse le reste explicitement. > ⚠️ **Ne pas confondre avec le §2.1 qui suit.** Le format des *assets* est réglé et partagé. Le > §2.1 traite d'autre chose, beaucoup plus petit : la convention d'axes de **mes propres > coordonnées de placement** dans le manifeste — de la métadonnée, pas de la géométrie. Une > vingtaine de lignes de code, pas un choix de pipeline. ### 2.1 Convention d'axes du manifeste — la partie qu'on ne peut pas se permettre de rater | Règle | Valeur | |---|---| | Système de coordonnées | **glTF : main droite, Y-up, unités en mètres** | | Origine | le **point de spawn** du visiteur, au sol (y = 0) | | Rotation | **quaternion `[x, y, z, w]`** — non ambigu, pas d'ordre d'axes à deviner | | Échelle | `[x, y, z]`, défaut `[1, 1, 1]` | | Angles | jamais de degrés dans le manifeste | **Pourquoi glTF et pas Unity.** C'est la convention des assets eux-mêmes, **et** celle de three.js — qui est nativement main droite Y-up. Donc : - le **viewer web ne convertit rien** : il édite et affiche dans l'espace du manifeste ; - le **placement produit par le client est déjà dans le bon repère**, puisqu'il est produit dans le viewer (décision D2) ; - **Unity est le seul convertisseur**, à un seul endroit du code. C'est exactement la contrainte posée au départ : *« conversion à l'entrée d'Unity uniquement »*. ### L'argument décisif — et ce n'est pas celui auquel on pense La tentation, c'est de stocker en convention **Unity** pour épargner la conversion au client le plus contraint. **Ça ne supprime rien** : le GLB du monde est de toute façon converti par l'importeur. On déplacerait donc juste le désaccord — coordonnées brutes contre géométrie convertie, même risque de miroir, dans l'autre sens. Ce qui départage les deux options n'est donc pas le nombre de conversions, mais **où une erreur de signe fait mal** : | Convention du manifeste | Où vit la conversion | Conséquence d'une erreur de signe | |---|---|---| | **glTF** (retenu) | dans Unity, en lecture | **bug d'affichage.** Une ligne à corriger, aucune donnée à reprendre | | Unity | dans le viewer, en **lecture et écriture** | **corruption de données.** Le client a placé 40 objets, ils sont stockés faux, et la correction demande une migration | Le viewer est l'endroit où la donnée est **écrite**. On n'y met pas la conversion. Et il y a un bénéfice pratique qui achève la question : dans le viewer, le monde est chargé par three.js **sans conversion**. Le client place donc ses objets **dans le même espace que celui où il voit le monde** — la cohérence n'est pas garantie par du code, elle est vraie par construction. ⚠️ **Le piège, et il est réel.** Unity est **main gauche**. glTFast applique une conversion à la géométrie qu'il importe ; mes coordonnées de manifeste, elles, **ne passent par aucun importeur**. Si ma conversion et celle de glTFast ne sont pas la même, les personnages sont en miroir par rapport au monde — et le bug est **invisible sur une scène symétrique**, ce qui le rend cher. ➡️ **Une seule fonction dans tout le projet Unity**, `GltfSpace.ToUnity(Vector3)` / `ToUnity(Quaternion)`, et **son signe est confirmé empiriquement, pas supposé** : un GLB de calibration en forme de **L asymétrique** (branche longue vers +X, branche courte vers +Z, une marque colorée sur une seule face), chargé à côté d'un marqueur placé par manifeste aux mêmes coordonnées. Les deux se superposent, ou la conversion est fausse. Ce GLB de calibration est un livrable de l'étape 1, pas un raffinement. ### 2.2 Schéma ```jsonc { "manifestVersion": 1, // version du FORMAT — change si le schéma change "sceneId": "sec_ab12…", "instanceId": "inst_…", "configurationId": "cfg_…", "version": 42, // version du CONTENU — monotone, +1 à chaque publication "publishedAt": "2026-09-03T10:22:41Z", "languages": ["fr", "nl", "en", "de"], "defaultLanguage": "fr", "coordinateSystem": "gltf/y-up/right-handed/meters", // littéral, vérifié par le client "navigation": { "spawn": { "position": [0,0,0], "rotation": [0,0,0,1] }, "radiusMeters": 3.0, // plafond dur 3.0, appliqué serveur ET client "showBoundary": true }, "environment": { "lightingPreset": "neutral-indoor", "hdriAssetId": null, "exposure": 1.0 }, "world": { "kind": "mesh", // "mesh" | "splat" | "panorama" ← la couture V2 "assetId": "a_world_01", "transform": { "position": [0,0,0], "rotation": [0,0,0,1], "scale": [1,1,1] } }, "objects": [ { "id": "o_1", "label": "Amphore", "assetId": "a_amphora", "transform": { "position": [1.2,0,-0.8], "rotation": [0,0.707,0,0.707], "scale": [1,1,1] } } ], "personas": [ { "id": "p_1", "personaId": null, // null en V1 (pas d'entité Persona) — la couture "name": "Le gouverneur", "assetId": "a_char_governor", "transform": { "position": [-1.5,0,-1.0], "rotation": [0,1,0,0], "scale": [1,1,1] }, "animation": { "idleClip": "Idle", "loop": true }, "gazeAtVisitor": true, "audio": [ { "language": "fr", "assetId": "a_audio_gov_fr" } ], "script": [ { "language": "fr", "value": "Bienvenue dans…" } ] } ], "hotspots": [ { "id": "h_1", "geoPointId": 128, "transform": { "position": [0.4,1.4,-2.1], "rotation": [0,0,0,1] }, "title": [ { "language": "fr", "value": "La mosaïque" } ], "description": [ { "language": "fr", "value": "…" } ], "contents": [ { "order": 0, "title": [ { "language": "fr", "value": "…" } ], "description": [ { "language": "fr", "value": "…" } ], "assetId": "a_audio_mosaic_fr" } ] } ], "assets": [ { "id": "a_world_01", "resourceId": "res_…", "url": "https://…/o/pictures%2Finst_…%2Fres_…?alt=media&token=…", "mimeType": "model/gltf-binary", "sizeBytes": 184320000, "sha256": "9f2c…", "variants": [] } // ← KTX2 / Meshopt viendront ici, sans casser le contrat ], "budget": { "totalBytes": 214000000, "limitBytes": 1073741824 }, "provenance": { "aiGenerated": false, "rightsHolder": "Musée de …", "sources": [] } } ``` ### 2.3 Les cinq choix de structure, et leur raison | Choix | Raison | |---|---| | **`assets[]` séparé des références** | Dédoublonnage, et surtout : c'est la **seule** liste que le téléchargeur parcourt. Le budget et les hash s'y calculent une fois. C'est le motif déjà utilisé par `ExportConfigurationDTO` (`sections` + `resources` à plat) | | **Toutes les langues d'un coup** | Un casque en borne change de langue à chaud. L'export existant est mono-langue (`?language=`) — c'est le seul point où le manifeste s'écarte du contrat existant, et il le doit | | **`version` séparé de `manifestVersion`** | L'un dit « le contenu a changé », l'autre « le schéma a changé ». Les confondre rend impossible de livrer une app qui lit les deux | | **`world.kind`** | La couture pour les splats gaussiens. En V1 le client ne lit que `"mesh"` et **refuse explicitement** les autres — un refus lisible vaut mieux qu'un rendu vide | | **`personaId` nullable** | La couture pour l'entité `Persona` du lot 7 Studio. En V1 il est `null` et `name`/`assetId` portent tout. Quand `Persona` existe, il se remplit et le reste devient dérivé | ### 2.4 Le hash, et ce qu'il achète `sha256` par asset est la seule chose qui permet un **delta réel** : le casque compare, ne retélécharge que ce qui a changé, et détecte un fichier corrompu. Sans lui, un changement de version force le retéléchargement de tout — et à plusieurs centaines de Mo par monde, sur le wifi d'un musée, ça se compte en heures. ⚠️ Il n'existe nulle part aujourd'hui (§2 de l'état des lieux). Il se calcule **à la publication**, pas à l'upload : c'est un job Hangfire qui lit le blob et écrit le hash. --- ## 3. Modèle de données **Tout est additif. Aucune donnée existante n'est touchée. Aucune valeur d'enum n'est réordonnée.** ### 3.1 Le nouveau type de section L'héritage des sections est en **TPH avec un discriminant `string`** (`MyInfoMateDbContext.cs:329-348`). Ajouter un sous-type, c'est donc **une ligne de mapping et des colonnes nullables** — pas de nouvelle table, pas de recopie. ```csharp // Data/SubSection/SectionScene3D.cs public class SectionScene3D : Section { public string? WorldResourceId { get; set; } public Resource? WorldResource { get; set; } public World3DKind WorldKind { get; set; } = World3DKind.Mesh; [Column(TypeName = "jsonb")] public TransformDTO? SpawnTransform { get; set; } public double NavigationRadiusMeters { get; set; } = 3.0; // plafond 3.0, validé serveur public bool ShowBoundary { get; set; } = true; public string? LightingPreset { get; set; } public string? HdriResourceId { get; set; } public double Exposure { get; set; } = 1.0; [Column(TypeName = "jsonb")] public List Objects { get; set; } [Column(TypeName = "jsonb")] public List Personas { get; set; } public List Hotspots { get; set; } // ← relation, pas jsonb : voir 3.2 public int PublishedVersion { get; set; } = 0; public DateTime? PublishedAt { get; set; } public override string GetEmbeddableText(string language) => … public override IEnumerable GetReferencedResourceIds(string language = null) => … } public enum World3DKind { Mesh, Splat, Panorama } // valeurs 0,1,2 — ajouts EN FIN ``` **`Objects` et `Personas` en jsonb, `Hotspots` en relation.** Ce n'est pas une incohérence : - un objet ou un personnage placé est **une position et un id d'asset**, jamais interrogé seul, jamais réutilisé ailleurs → jsonb, exactement comme `SectionMap.MapCategories` ; - un hotspot porte **du contenu éditorial multilingue avec de l'audio**, doit être indexable par le guide IA, et réutilise l'éditeur de POI existant → c'est un `GeoPoint`, entité à part entière. ⚠️ **`GetReferencedResourceIds` est `abstract`, pas `virtual`** — le commentaire de `Section.cs` dit pourquoi : *« la collecte des ressources vivait dans un switch centralisé […] il est devenu faux en silence »*. Le compilateur réclamera l'implémentation. Elle doit rendre : le monde, l'HDRI, chaque asset d'objet, chaque asset et audio de persona, et les ressources de chaque hotspot. **Un oubli ici n'est pas une erreur d'affichage : c'est un asset manquant sur un casque hors ligne, sur site.** ### 3.2 Le hotspot est un `GeoPoint` `GeoPoint` porte **déjà** deux clés étrangères nullables (`SectionMapId`, `SectionEventId`). On en ajoute une troisième — c'est littéralement le motif existant : ```csharp public string? SectionScene3DId { get; set; } [ForeignKey(nameof(SectionScene3DId))] public SectionScene3D? SectionScene3D { get; set; } [Column(TypeName = "jsonb")] public TransformDTO? LocalTransform { get; set; } ``` `LocalTransform` cohabite avec le `Geometry` PostGIS sans conflit : l'un est une lat/lon sur Terre, l'autre un `(x, y, z)` local à une scène. Un `GeoPoint` a l'un **ou** l'autre. ➡️ Ce que ça donne gratuitement : `Title`, `Description`, `Contents` (titre + description + `Resource` audio, multilingue), `ImageResourceId`, et `GetEmbeddableText` — donc **le guide IA sait déjà lire un hotspot 3D**, sans une ligne de plus. ### 3.3 Les enums à étendre — toutes en fin, toutes persistées en int ``` SectionType += Scene3D // valeur 13 ResourceType += Model3D // valeur 11 += Panorama360 // valeur 12 (carte kanban 250, arrive avec) ApiKeyAppType += VrApp // valeur 3 ``` Et le discriminant TPH : `.HasValue("Scene3D")`. ### 3.4 Le device devient multi-canal — lot V-1, inchangé Repris tel quel de `vr-quest-unity-plan.md` §2 : ``` Device += AppType (int, défaut Tablet = 1) DeviceController.Create : résoudre l'ApplicationInstance sur newDevice.appType, au lieu du AppType.Tablet en dur (DeviceController.cs:155) DeviceController.GetAll : paramètre de requête appType optionnel ``` ⚠️ `Device.ConfigurationId` est `[Required]` alors que son propre commentaire dit « OLD WAY → AppConfigurationLink ». Le casque passe par `AppConfigurationLink` ; on remplit quand même le champ pour ne pas violer la contrainte. ### 3.5 La publication — le choix étroit, assumé L'état des lieux (§5) a montré qu'il **n'existe aucun état publié** dans tout le CMS : une modification est immédiatement visible par tous les clients. **Décision : on ne refond pas la publication globale pour la V1.** `PublishedVersion` et `PublishedAt` vivent **sur `SectionScene3D` seulement**. Le manifeste servi à l'app est celui de la dernière publication ; le viewer du back-office lit le brouillon. | Pourquoi pas la refonte globale | | |---|---| | Coût | un cycle de vie brouillon/publié sur `Configuration` touche les 13 types, les 3 clients visiteurs et le mode offline | | Risque | c'est le genre de chantier qui casse ce qui marchait | | Bénéfice V1 | nul : seule la scène 3D a besoin d'un « ne change pas sous les pieds du visiteur » | ⚠️ **Dette assumée, à écrire dans le suivi** : le jour où la publication devient globale, `SectionScene3D.PublishedVersion` doit y être absorbé, pas coexister. C'est une dette **connue et datée**, pas un oubli. ### 3.6 Ce que la migration contient Une seule migration, additive : - colonnes nullables de `SectionScene3D` sur la table `Sections` (TPH) ; - `GeoPoints += SectionScene3DId, LocalTransform` ; - `Devices += AppType` (défaut `1`) ; - `Resources += Sha256` (nullable) ; - rien à supprimer, rien à recopier, aucune donnée existante modifiée. ⚠️ **Contexte à ne pas perdre** : la bascule Postgres n'est pas faite et le schéma est gelé (`STATUS.md` §1ter, lot B). Cette migration est **purement additive**, donc elle ne concurrence pas la bascule — mais elle doit être posée en connaissance de cause, pas en parallèle sans le dire. --- ## 4. Contrat d'API ### 4.1 Ce qui ne change pas La création, la lecture et la mise à jour d'une `SectionScene3D` passent par le **`SectionController` existant**, générique sur `SectionDTO` + `SectionFactory`. Aucun endpoint CRUD nouveau. Les points de couture à toucher sont connus et se comptent : | Fichier | Ce qu'il faut y ajouter | |---|---| | `DTOs/SectionType.cs` | la valeur `Scene3D` | | `Services/SectionFactory.cs` | 4 emplacements : `CreateEmpty`, le `switch` de désérialisation, le `switch` de construction, `ToDTO` | | `Controllers/SectionController.cs` | 1 `switch` | | `Services/IngestionService.cs` | 1 `switch` (indexation RAG) | | `Data/MyInfoMateDbContext.cs` | 1 ligne de discriminant | ### 4.2 Ce qui s'ajoute — côté back-office ``` GET /api/Scene3D/{sectionId}/manifest?draft=true [Authorize ContentEditor] → SceneManifestDTO (brouillon, toutes langues, urls signées) Alimente le viewer web du back-office. POST /api/Scene3D/{sectionId}/publish [Authorize ContentEditor] → { version, publishedAt, totalBytes } Valide (rayon ≤ 3 m, assets présents, budget), incrémente PublishedVersion, enqueue le calcul des hash manquants, journalise dans AuditLog. GET /api/Scene3D/{sectionId}/budget [Authorize Viewer] → { totalBytes, limitBytes, perAsset[] } Affiché en continu dans l'éditeur — pas seulement à la publication. ``` **Le budget est calculé et affiché en continu, pas au moment de publier.** C'est la demande explicite du cahier des charges (« sinon le stockage du casque explose et je le découvre sur site »), et l'expérience de la jauge de stockage du menu latéral — qui ne se rafraîchit pas et affiche « 0 KB » en même temps qu'un refus 413 (bug ouvert, `STATUS.md` §1) — dit exactement ce qu'il ne faut pas refaire : **un chiffre affiché doit être le chiffre qui bloque.** ### 4.3 Ce qui s'ajoute — côté app Quest ``` GET /api/instance/app-key?pinCode={pin}&appType=VrApp [AllowAnonymous] → { key, instanceId } ⚠️ La route réelle est bien "app-key" (InstanceController.cs, HttpGet("app-key")), pas "appKeyByPin" comme l'écrivait le plan du 31/08. POST /api/device { identifier, name, instanceId, appType: VR } → enregistre le casque, le rattache à l'ApplicationInstance VR GET /api/Scene3D/{sectionId}/manifest [X-Api-Key, AppReadAccess] → SceneManifestDTO publié, toutes langues GET {asset.url} téléchargement direct du blob POST /api/stats/visit-event { appType: VR, … } télémétrie MQTT player/{deviceId} notification « du neuf est publié » ``` **Aucun endpoint de contenu spécifique à Unity au-delà du manifeste.** C'est la leçon du lot V-3 du plan d'août, et elle tient : un client non-Flutter ne doit avoir qu'**un** point d'entrée de contenu. ### 4.4 Authentification et fraîcheur Le casque garde sa clé d'API et son `instanceId` en stockage local après appairage. Au démarrage : 1. lecture du **cache local** → l'app est utilisable immédiatement, même sans réseau ; 2. en tâche de fond, `GET manifest` → comparaison de `version` ; 3. si `version` a changé : téléchargement des assets dont le `sha256` diffère ; 4. **application au retour au menu**, jamais pendant qu'un visiteur est immergé ; 5. si le manifeste ne se charge pas : **la dernière version valide reste active**, silencieusement. Le point 5 n'est pas un détail de robustesse, c'est la promesse produit : *une borne ne tombe jamais en panne parce que le wifi du musée a bougé.* --- ## 5. Flux asynchrone de génération de mondes — V1.5 > Rappel de la décision **D5** : **hors V1**. En V1 le monde est un GLB uploadé. Cette section décrit > la greffe, pour qu'on ne code rien aujourd'hui qui l'empêche. Marble **n'est pas un chantier séparé** : c'est une implémentation de plus de l'interface `IGenerationProvider` déjà spécifiée (`studio-plan.md` §3.6). ``` GenerationModel (en base, pas dans le code) Key = "world-default" ProviderKey = "worldlabs" ProviderModelId = "marble-…" ← la seule chaîne qui change quand le marché bouge Kind = World CreditCost = 1500 ← monde standard Params jsonb = { draftCost: 150, meshExportCost: 3500, … } ``` ``` POST /api/Studio/generate { kind: World, inputResourceIds[], … } → validations, Reserve(crédits), GenerationJob(Queued), 202 { jobId, estimatedCredits } Hangfire worker → provider.SubmitAsync → ProviderRequestId, Status=Running → BackgroundJob.Schedule(PollFallback, +5 min) ← filet, la génération dure 5 min à 1 h POST /api/StudioWebhook/worldlabs (HMAC vérifié — patron : StripeWebhookController) → télécharge le GLB, écrit dans le bucket (lot 0), crée la Resource(Model3D), remplit AiProvenance, Charge(crédits réels), Status=Succeeded GET /api/Studio/jobs/{id} polling front à 2 s tant qu'un job est ouvert à l'écran ``` **Trois points propres à Marble** que le Studio générique ne couvre pas : 1. **L'export mesh GLB HQ est facturé une seule fois par monde**, résultat mis en cache ensuite. Le `CreditLedger` doit donc distinguer *générer le monde* et *en exporter le mesh* : deux `Charge` sur le même job, la seconde conditionnelle. 2. **Une génération depuis un input non-panoramique peut ajouter un événement de facturation « pano generation »** — donc le coût réel peut **dépasser** l'estimation affichée. Conséquence de conception : `Reserve` prend une marge, et `Charge` ajuste au réel. Ne jamais afficher l'estimation comme un prix ferme. 3. **Les sources de capture sont des `Resource`** (la vidéo, le panorama). Elles entrent dans `AiProvenance.sources` — c'est ce que demande la traçabilité institutionnelle : modèle, prompt, date, auteur, **sources**. ### Ce qu'on ne doit pas coder aujourd'hui | Interdit en V1 | Parce que | |---|---| | Un `WorldLabsService` appelé depuis le module VR | il court-circuiterait crédits, journal, provenance | | Un nom de modèle Marble dans le code ou l'`appsettings.json` | contrainte explicite : configuration serveur versionnable, catalogue en base | | La clé d'API dans `appsettings.json` | le fichier versionné contient déjà 4 secrets en clair — variable d'environnement, volume `/etc/managerservice` | --- ## 6. Le projet Unity ### 6.1 Structure ``` vr-app/unity/MyInfoMateVR/ Assets/ Scenes/ Boot.unity ← LA scène. Une seule, pour tous les clients, tous les mondes. Scripts/ Boot/ AppBootstrap.cs orchestre : provisioning → cache → manifeste → scène → menu AppState.cs machine à états : Provisioning | Syncing | Menu | InScene | Error Provisioning/ PinCodeFlow.cs saisie du code d'instance, GET app-key, POST device CredentialStore.cs clé d'API + instanceId, persistés Content/ SceneManifest.cs les DTO du manifeste — miroir exact du §2.2 ManifestClient.cs GET manifeste, comparaison de version AssetCache.cs disque, indexé par sha256, éviction, fallback dernière version valide AssetDownloader.cs file de téléchargement, reprise, progression Scene/ GltfSpace.cs ⚠️ LA conversion glTF → Unity. Le seul endroit. SceneBuilder.cs instancie monde, objets, personas, hotspots depuis le manifeste WorldLoader.cs glTFast, refuse explicitement kind != "mesh" en V1 PersonaInstance.cs idle, orientation vers le visiteur, audio spatialisé HotspotInstance.cs raycast, surbrillance, lecture audio NavigationBounds.cs zone circulaire, rendu type garde-fou, plafond 3 m UI/ FloatingMenu.cs sélection de scène, langue, relance audio, sortie KioskSession.cs détection de retrait du casque → reset (décision D4) Telemetry/ VisitEventReporter.cs POST des events avec appType = VR StreamingAssets/ calibration.glb le L asymétrique du §2.1 Packages/manifest.json ProjectSettings/ ``` **Une seule scène Unity.** `Boot.unity` contient le rig VR, le menu et rien d'autre ; tout le reste est instancié au runtime depuis le manifeste. C'est la traduction directe de la règle du §1. ### 6.2 Les quatre scripts qui portent le risque | Script | Le risque | |---|---| | `GltfSpace.cs` | conversion miroir invisible sur une scène symétrique (§2.1). **Validé par la calibration, pas par la relecture** | | `AssetCache.cs` | plusieurs centaines de Mo, coupure réseau, disque plein, fichier corrompu. C'est ici que se joue « la borne ne tombe pas en panne » | | `KioskSession.cs` | un état résiduel entre deux visiteurs (langue changée, audio en cours, position). Le reset doit être **total**, pas un retour de caméra | | `WorldLoader.cs` | un GLB de 200 Mo chargé sur le thread principal fige le casque et donne la nausée. Chargement asynchrone obligatoire, écran de progression diégétique | ### 6.3 Ce que je génère, ce que tu fais | Je génère | Tu fais | |---|---| | Tous les `.cs` ci-dessus | Les réglages de `ProjectSettings` (guide phase 0 §5-6) | | `Packages/manifest.json` | L'import du SDK Meta depuis l'Asset Store | | Un script d'éditeur `[MenuItem]` qui **construit `Boot.unity` par code** | Le premier *Build And Run* | | Le `.gitignore` (fait) | L'appairage du casque | **Pourquoi la scène par code plutôt qu'un `.unity` écrit à la main** : le YAML Unity est plein de GUID et de `fileID` internes. Écrit à la main, il produit un projet qui s'ouvre mais dont un réglage sur deux est faux **en silence** — le pire des cas. Un script d'éditeur est lisible, versionnable, rejouable et diffable. --- ## 7. Le viewer web **Une application web autonome** (décision D3), une seule, **partagée par les quatre fronts** : `manager-app` en édition, `visitapp-web` et `mymuseum-visitapp` en lecture, Unity exclu (rendu natif). ``` vr-app/viewer/ autonome, déployé à part — tranché le 2026-09-03 src/ manifest.ts les mêmes types que SceneManifest.cs, côté TS Viewer.ts THREE.js : scène, caméra orbitale, chargement glTF Editor.ts sélection, raycast au sol, poignée de rotation, undo Bounds.ts le cercle de navigation, à l'échelle Panel.ts valeurs numériques, budget, liste des objets ``` ### Les trois règles à poser dès la V1 Elles gardent la porte ouverte au portage Flutter mobile et à l'offline (D3), sans l'imposer comme périmètre V1 — **si la 3D hors ligne s'avère trop chère, on l'assume et on le signale dans l'éditeur**, comme le fait déjà `SectionVideo` pour une vidéo distante (`SectionVideo.cs:25-27`). Elles ne coûtent rien maintenant, et c'est de toute façon du bon design : 1. **Fichiers statiques**, embarquables dans les assets d'une app Flutter et servables depuis une origine locale. Pas de build qui suppose un serveur. 2. **Aucune URL d'API en dur.** Le viewer ne connaît que le manifeste qu'on lui donne ; toutes les URL d'assets viennent de `assets[].url`. C'est ce qui permet de lui passer un manifeste dont les URL ont été **réécrites en chemins locaux** — exactement ce que fait déjà le pipeline offline. 3. **Le manifeste est une entrée, pas un fetch.** Il s'injecte (`postMessage` / paramètre), pas seulement par URL. Sans ça, pas d'offline. Ces trois règles ne coûtent rien maintenant. Elles coûtent une réécriture si on les découvre au moment du portage. ### Trois exigences non négociables 1. **Aucune conversion de coordonnées.** three.js est nativement main droite Y-up : le viewer lit et écrit dans l'espace du manifeste, point. C'est ce qui fait que le placement produit ici est directement juste dans Unity. 2. **Aperçu indicatif, annoncé comme tel.** Alignement à tenir : même tone mapping, même HDRI d'environnement, **PBR core glTF uniquement** (pas d'extension de matériau exotique). Le mot « aperçu » doit être dans l'interface, pas seulement dans la doc — sinon le client validera une couleur qu'il ne retrouvera pas au casque. 3. **Il est aussi l'éditeur** (décision D2). Donc : état d'édition, undo, enregistrement explicite. C'est la moitié de son coût, et c'est la partie qu'on sous-estime. ### L'intégration dans `manager-app` `manager-app` est en **Flutter Web** : le viewer y entre en **iframe** (`HtmlElementView`), avec un `postMessage` dans les deux sens : ``` manager-app → viewer : { type: "load", manifestUrl, mode: "edit" } viewer → manager-app : { type: "changed", objects, personas, hotspots } { type: "dirty", isDirty: true } ``` Ce n'est pas un contournement : Flutter Web ne peut pas héberger THREE.js autrement. Et le bénéfice est réel — le même viewer se monte comme composant React dans `visitapp-web`, en mode lecture seule. --- ## 8. Ce qui n'est pas dans cette conception, volontairement | Hors périmètre | Où ça va | |---|---| | Les 13 types de section rendus en immersif | Décision D1. `vr-quest-unity-plan.md` §8 garde le cap pour plus tard | | Splats gaussiens | `world.kind = "splat"`, refusé par le client V1. Prototype séparé | | Assistant IA dans le casque | Zéro backend nécessaire (`AiController` prend déjà un `AppType`), mais le TTS est côté client et à réimplémenter en C#. Après la V1 | | Lip sync | Écarté au niveau produit, définitivement | | MDM / flotte de casques | Inutile sous 10 casques. Lot V-5 | | Éditeur 3D complet (gizmos, hiérarchie, matériaux) | Jamais. Le client compose, il ne modélise pas | | KTX2 / Meshopt exécutés serveur | Défaut retenu : GLB servi tel quel en V1, `variants[]` posé dans le contrat | --- ## 9. Les questions soulevées — état au 2026-09-03 1. ~~**Où vit le viewer web ?**~~ **Tranché : `vr-app/viewer/`, autonome.** Et le périmètre s'élargit — il sert **quatre** fronts, `mymuseum-visitapp` compris (WebView, hors ligne). Voir D3 et §7. 2. **Combien de scènes par expérience ?** Une `SectionScene3D` = une scène ; plusieurs scènes = plusieurs sections dans la même `Configuration`, listées par un `SectionMenu` — ce qui marche déjà. **Retenu à titre provisoire**, à confirmer sur un cas concret : c'est le genre de choix qui se valide en composant une vraie expérience, pas sur le papier.