import * as THREE from 'three'; import { Bridge, type ViewerMode } from './bridge.js'; import { Editor } from './Editor.js'; import { Panel } from './Panel.js'; import { Viewer, readTransform, type SelectableKind } from './Viewer.js'; import { identityTransform, validateManifest, type SceneAsset, type SceneManifest, } from './manifest.js'; /** * Le point d'entrée : il câble le rendu, l'éditeur, le panneau et le pont, et * c'est tout. Trois façons de recevoir une scène, dans cet ordre de priorité : * * 1. `postMessage` — le cas `manager-app` (E5) et le cas offline (règle 3 de D3) ; * 2. `?manifest=` — le cas `GET /api/Scene3D/{id}/manifest` (E4) ; * 3. un glisser-déposer de `scene.json` + `.glb` — le cas de E3, aujourd'hui, * sans backend. * * Le troisième n'est pas un bricolage de développement : c'est lui qui permet de * composer une scène et de la pousser sur le casque **avant** qu'une seule ligne * de `manager-service` soit écrite. C'est exactement ce que le plan attend de E3. */ // Les décors se rechargent à chaque ajout d'élément. Sans ce cache, un monde de // plusieurs centaines de Mo serait retéléchargé à chaque fois. THREE.Cache.enabled = true; const stage = document.querySelector('#stage')!; const panelHost = document.querySelector('#panel')!; const overlay = document.querySelector('#overlay')!; const viewer = new Viewer(stage); let mode: ViewerMode = 'edit'; let counter = 0; const panel = new Panel(panelHost, { onSelect: (kind, id) => editor.select(viewer.find(kind, id)), onPositionChanged: (axis, value) => { const selection = editor.current; if (!selection) return; const transform = readTransform(selection.node); transform.position[axis] = value; editor.setTransform(selection, transform); }, onYawChanged: (degrees) => { const selection = editor.current; if (!selection) return; // La conversion degrés → quaternion vit à la frontière de l'interface : le // manifeste, lui, n'a jamais vu un degré (§2.1). const quaternion = new THREE.Quaternion().setFromAxisAngle( new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(degrees), ); const transform = readTransform(selection.node); transform.rotation = [quaternion.x, quaternion.y, quaternion.z, quaternion.w]; editor.setTransform(selection, transform); }, onContentChanged: () => { editor.markContentChanged(); const manifest = viewer.currentManifest(); if (manifest) bridge.changed(manifest); }, onAdd: (kind) => void addEntry(kind), onRemove: (kind, id) => void removeEntry(kind, id), onLanguageChanged: () => panel.refreshInspector(), onExport: () => exportManifest(), }); const editor = new Editor(viewer, { onSelect: (selection) => panel.setSelection(selection), onTransform: () => { panel.refreshInspector(); const manifest = viewer.currentManifest(); if (manifest) bridge.changed(manifest); }, onDirty: (isDirty) => { panel.setDirty(isDirty); bridge.dirty(isDirty); }, }); const bridge = new Bridge({ onLoadUrl: (url, nextMode) => void loadFromUrl(url, nextMode), onLoadManifest: (manifest, nextMode) => void load(manifest, nextMode), onSetLanguage: () => panel.refreshInspector(), }); /* ------------------------------------------------------------------ */ /* Chargement */ /* ------------------------------------------------------------------ */ async function loadFromUrl(url: string, nextMode: ViewerMode): Promise { showOverlay('Chargement du manifeste…'); try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); await load(await response.json(), nextMode); } catch (error) { fail(`Manifeste illisible : ${error instanceof Error ? error.message : error}`); } } async function load(raw: unknown, nextMode: ViewerMode): Promise { const result = validateManifest(raw); if (!result.ok) { // Le même refus que côté casque, dans les mêmes termes : c'est ce qui évite // une scène acceptée par l'éditeur et refusée sur site. fail(result.error); return; } mode = nextMode; showOverlay('Chargement de la scène…'); // Le panneau est dans le try avec le rendu, et ce n'est pas cosmétique : une // erreur d'interface laissée hors du try laisse l'overlay figé sur // « Chargement… » — donc un blocage silencieux, indistinguable d'un // téléchargement lent. try { await viewer.load(result.manifest, (step) => showOverlay(`${step}…`)); panel.setManifest(result.manifest); panel.setSelection(undefined); panelHost.classList.toggle('read-only', mode === 'view'); } catch (error) { console.error('[viewer] chargement interrompu', error); fail(`Chargement impossible : ${error instanceof Error ? error.message : error}`); return; } hideOverlay(); bridge.changed(result.manifest); } /* ------------------------------------------------------------------ */ /* Ajout et retrait */ /* ------------------------------------------------------------------ */ async function addEntry(kind: SelectableKind): Promise { const manifest = viewer.currentManifest(); if (!manifest || mode === 'view') return; const id = `${kind[0]}_${Date.now().toString(36)}_${counter++}`; // Posé à deux mètres devant le point de spawn, pas à l'origine : un élément // créé sous les pieds du visiteur est un élément qu'on ne voit pas apparaître. const transform = identityTransform(); transform.position = [0, kind === 'hotspot' ? 1.4 : 0, -2]; switch (kind) { case 'object': manifest.objects.push({ id, label: 'Nouvel objet', assetId: '', transform }); break; case 'persona': manifest.personas.push({ id, personaId: null, name: 'Nouveau personnage', assetId: '', transform, animation: { idleClip: 'Idle', loop: true }, gazeAtVisitor: true, audio: [], script: [], }); break; case 'hotspot': manifest.hotspots.push({ id, geoPointId: 0, transform, title: [{ language: manifest.defaultLanguage, value: "Nouveau point d'intérêt" }], description: [], contents: [{ order: 0, title: [], description: [], assetId: null }], }); break; } await reload(manifest); editor.markContentChanged(); editor.select(viewer.find(kind, id)); } async function removeEntry(kind: SelectableKind, id: string): Promise { const manifest = viewer.currentManifest(); if (!manifest || mode === 'view') return; if (kind === 'object') manifest.objects = manifest.objects.filter((row) => row.id !== id); if (kind === 'persona') manifest.personas = manifest.personas.filter((row) => row.id !== id); if (kind === 'hotspot') manifest.hotspots = manifest.hotspots.filter((row) => row.id !== id); await reload(manifest); editor.markContentChanged(); editor.select(undefined); } /** * Une reconstruction complète du graphe plutôt qu'un ajout incrémental. * * C'est assumé : le manifeste est la source de vérité, et reconstruire depuis lui * garantit que ce qu'on voit est ce qu'on exportera. Un chemin d'ajout séparé, ce * sont deux façons de produire une scène — donc deux occasions de diverger. Le * cache de three.js rend le coût acceptable. */ async function reload(manifest: SceneManifest): Promise { await viewer.load(manifest); panel.setManifest(manifest); bridge.changed(manifest); } /* ------------------------------------------------------------------ */ /* Export */ /* ------------------------------------------------------------------ */ function exportManifest(): void { const manifest = viewer.currentManifest(); if (!manifest) return; // Le poids se recalcule à l'export : le budget affiché doit être celui des // assets réellement référencés, pas celui du manifeste d'origine. manifest.budget = { totalBytes: referencedBytes(manifest), limitBytes: manifest.budget?.limitBytes ?? 1073741824, }; const blob = new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = 'scene.json'; link.click(); URL.revokeObjectURL(link.href); // L'enregistrement est explicite, donc la scène cesse d'être sale ici — et pas // avant. En E5 c'est le bouton Publier qui prendra cette place. editor.markSaved(); } function referencedBytes(manifest: SceneManifest): number { const ids = new Set([manifest.world.assetId]); if (manifest.environment.hdriAssetId) ids.add(manifest.environment.hdriAssetId); for (const entry of manifest.objects) ids.add(entry.assetId); for (const entry of manifest.personas) { ids.add(entry.assetId); for (const audio of entry.audio) ids.add(audio.assetId); } for (const hotspot of manifest.hotspots) { for (const content of hotspot.contents ?? []) { if (content.assetId) ids.add(content.assetId); } } return manifest.assets .filter((asset) => ids.has(asset.id)) .reduce((total, asset) => total + (asset.sizeBytes ?? 0), 0); } /* ------------------------------------------------------------------ */ /* Glisser-déposer : le cas E3, sans backend */ /* ------------------------------------------------------------------ */ document.addEventListener('dragover', (event) => { event.preventDefault(); overlay.classList.add('drop'); }); document.addEventListener('dragleave', () => overlay.classList.remove('drop')); document.addEventListener('drop', (event) => { event.preventDefault(); overlay.classList.remove('drop'); const files = [...(event.dataTransfer?.files ?? [])]; if (files.length > 0) void loadDropped(files); }); /** * On dépose le `scene.json` **et** les `.glb`/`.mp3` qu'il référence, ensemble. * * Les fichiers déposés deviennent des URL `blob:` réécrites dans `assets[].url`. * C'est précisément le scénario que la règle 2 de D3 rend possible — un manifeste * dont les URL pointent ailleurs que vers l'API — et c'est aussi celui de * l'offline en WebView. Le tester tous les jours en E3, c'est s'assurer qu'il * marche encore le jour du portage. * * Le rapprochement se fait sur le **nom de fichier** : le `scene.json` de E2 * porte `"url": "world.glb"`, qui est aussi le nom du fichier dans * `StreamingAssets`. Un seul fichier de scène pour les deux usages. */ async function loadDropped(files: File[]): Promise { const sceneFile = files.find((file) => file.name.toLowerCase().endsWith('.json')); const byName = new Map(); for (const file of files) byName.set(file.name.toLowerCase(), file); const manifest: SceneManifest = sceneFile ? JSON.parse(await sceneFile.text()) : viewer.currentManifest(); if (!manifest) { fail('Dépose un scene.json, et les .glb qu\'il référence avec lui.'); return; } for (const asset of manifest.assets ?? []) { const file = byName.get(basename(asset.url).toLowerCase()); if (!file) continue; asset.url = URL.createObjectURL(file); // Le poids réel du fichier déposé écrase celui du manifeste : c'est le seul // chiffre auquel on peut se fier, et le budget en dépend. asset.sizeBytes = file.size; } const missing = missingAssets(manifest.assets ?? []); await load(manifest, 'edit'); // Après le chargement, pas avant : un asset sans fichier n'empêche pas de // composer (boîte témoin), mais il doit être dit à l'écran. Le laisser en // console seulement, c'est le laisser invisible. if (missing.length > 0) { fail( `${missing.length} fichier(s) manquant(s) : ${missing.map((a) => basename(a.url)).join(', ')}. ` + 'Dépose le scene.json ET les .glb ensemble.', ); } } function missingAssets(assets: SceneAsset[]): SceneAsset[] { return assets.filter((asset) => !asset.url.startsWith('blob:') && !asset.url.includes('://')); } function basename(url: string): string { return url.split(/[\\/]/).pop() ?? url; } /* ------------------------------------------------------------------ */ /* Overlay */ /* ------------------------------------------------------------------ */ function showOverlay(message: string): void { overlay.textContent = message; overlay.hidden = false; overlay.classList.remove('error'); } function hideOverlay(): void { overlay.hidden = true; } function fail(message: string): void { overlay.textContent = message; overlay.hidden = false; overlay.classList.add('error'); console.error(`[viewer] ${message}`); bridge.error(message); } /* ------------------------------------------------------------------ */ /* Modifications non enregistrées */ /* ------------------------------------------------------------------ */ /** * Le garde-fou de fermeture. * * Le badge « Non enregistré » prévient, mais un badge ne retient personne : * fermer l'onglet perdait tout le placement. La décision D2 fait de ce viewer * l'endroit où la donnée est **écrite** — il doit donc se comporter comme un * éditeur, et un éditeur ne laisse pas partir un travail non enregistré sans * poser la question. * * Le navigateur impose son propre libellé et ignore tout texte personnalisé * depuis des années : seul le fait d'annuler l'événement compte. * * En iframe (E5), c'est la page hôte qui portera la question — `manager-app` * reçoit déjà l'état par le message `dirty`, il n'y a rien à ajouter ici. */ window.addEventListener('beforeunload', (event) => { if (!editor.isDirty) return; event.preventDefault(); event.returnValue = ''; }); /* ------------------------------------------------------------------ */ /* Démarrage */ /* ------------------------------------------------------------------ */ const manifestParam = new URLSearchParams(window.location.search).get('manifest'); if (manifestParam) { void loadFromUrl(manifestParam, 'edit'); } else { showOverlay('Dépose ici un scene.json avec ses fichiers .glb'); } bridge.ready();