Thomas Fransolet c8947880ba Ecran VR, section Scene3D et medias immersifs
Canal VR
- L'entree VR affiche VrScreen au lieu de « non configuree » : sous-onglet
  Configuration (AppConfigurationLinkScreen reutilise) et sous-onglet Casques
  (pincode d'appairage, etat, batterie, version, dernier vu).
- Le dialogue de plan SuperAdmin porte aussi les canaux et les add-ons
  (assistant, contenu immersif). Activer un canal cree son
  ApplicationInstance s'il n'existe pas ; le desactiver ne supprime rien.

Medias immersifs
- Types Image360, Video360, Model3D : icones, libelles, extensions, apercu
  dans la Mediatheque et facettes.
- Au depot, la pastille de type bascule Image <-> Image 360 et
  Video <-> Video 360 : rien dans un .jpg ne dit qu'il est
  equirectangulaire. Un .glb est reconnu seul.
- ImageCompressor ne touche plus aux 360 ni aux GLB, sur les deux chemins
  d'upload : ramenee a 2560 px, une equirectangulaire devient illisible dans
  un casque.

Scene3D
- Nouveau type de section (famille des lieux) et son ecran de configuration :
  modele, mode objet/decor, points d'interet.
- Fond immersif d'une configuration, visible si l'instance a l'add-on : le
  type est deduit de la ressource choisie, avec une image de repli.

Client API edite a la main en miroir du backend : SectionScene3DApi,
Scene3DDTO, ImmersiveBackgroundDTO, Position3D, AppType sur les devices,
ResourceType etendu. Libelles FR/EN/NL.

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

431 lines
15 KiB
Dart

import 'dart:convert';
import 'dart:html' as html;
import 'dart:math' as math;
import 'dart:ui_web' as ui_web;
import 'package:flutter/material.dart';
import 'package:manager_api_new/api.dart';
import 'package:manager_app/Components/message_notification.dart';
import 'package:manager_app/Components/resource_input_container.dart';
import 'package:manager_app/Models/managerContext.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
import 'package:provider/provider.dart';
/// Configuration d'une section **maquette 3D** — item E7 du lot XR-4.
///
/// Deux choses : choisir le modèle dans la médiathèque, et **poser ses points
/// d'intérêt dessus**. Le second se fait dans le viewer 3D, affiché ici en iframe.
///
/// <b>Pourquoi une iframe et pas un rendu Flutter.</b> Le viewer existe déjà, en
/// TypeScript/three.js (`vr-app/viewer`, décision D3) : il charge un GLB, laisse
/// déplacer des objets à la souris, et sert aussi à la piste Scène 3D. Le refaire en
/// Flutter demanderait un moteur 3D et rejouerait le même travail. Le manager sait
/// déjà monter une iframe — `policy_screen`, `web_view` et `pdf_web_viewer` le font.
///
/// <b>Le protocole n'est pas inventé ici</b> : le viewer parle en **manifeste de
/// scène** (`bridge.ts`), le même objet que lit le casque (`SceneManifest.cs`). On lui
/// envoie donc un manifeste minimal — le modèle comme décor, les points comme
/// *hotspots* — et il renvoie les positions dès qu'on en déplace un. Un hotspot porte
/// un `geoPointId` précisément pour ce va-et-vient.
class Scene3DConfig extends StatefulWidget {
final Scene3DDTO initialValue;
final ValueChanged<Scene3DDTO> onChanged;
const Scene3DConfig({
Key? key,
required this.initialValue,
required this.onChanged,
}) : super(key: key);
@override
State<Scene3DConfig> createState() => _Scene3DConfigState();
}
class _Scene3DConfigState extends State<Scene3DConfig> {
/// Le repère du manifeste, vérifié littéralement des deux côtés. Un miroir d'axes
/// est invisible sur une maquette symétrique et se paie très cher découvert tard.
static const _coordinateSystem = 'gltf/y-up/right-handed/meters';
/// Le viewer est servi à côté du manager. En développement il tourne sur son
/// propre port (`npm run dev`), d'où la variable de compilation.
static const _viewerUrl =
String.fromEnvironment('VIEWER_URL', defaultValue: '/viewer/index.html');
late Scene3DDTO _dto;
late final String _viewType;
html.IFrameElement? _frame;
bool _viewerReady = false;
@override
void initState() {
super.initState();
_dto = widget.initialValue;
_viewType = 'model3d-viewer-${DateTime.now().microsecondsSinceEpoch}';
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int _) {
final frame = html.IFrameElement()
..src = _viewerUrl
..style.border = 'none'
..style.width = '100%'
..style.height = '100%';
_frame = frame;
return frame;
});
html.window.onMessage.listen(_onViewerMessage);
}
void _onViewerMessage(html.MessageEvent event) {
final message = _asMap(event.data);
if (message == null) return;
switch (message['type']) {
case 'ready':
// Le viewer prévient quand il est prêt : envoyer avant qu'il ne le soit,
// c'est un manifeste perdu sans erreur.
_viewerReady = true;
_sendManifest();
break;
case 'changed':
_readHotspots(message['hotspots']);
break;
case 'error':
debugPrint('[Model3D] viewer : ${message['message']}');
break;
}
}
Map<String, dynamic>? _asMap(dynamic data) {
if (data is Map) return data.cast<String, dynamic>();
if (data is String) {
try {
final decoded = jsonDecode(data);
if (decoded is Map) return decoded.cast<String, dynamic>();
} catch (_) {}
}
return null;
}
/// Reporte sur les points ce que le gestionnaire vient de déplacer, **et
/// l'enregistre**.
///
/// L'écriture part tout de suite plutôt qu'au bouton « Enregistrer » de la
/// section : déplacer un point est un geste continu, et personne ne pense à
/// sauvegarder après avoir bougé une bille dans une maquette.
void _readHotspots(dynamic raw) {
if (raw is! List) return;
var changed = false;
for (final entry in raw) {
final hotspot = _asMap(entry);
final transform = hotspot == null ? null : _asMap(hotspot['transform']);
final position = transform?['position'];
if (hotspot == null || position is! List || position.length < 3) continue;
final geoPointId = (hotspot['geoPointId'] as num?)?.toInt();
final point = (_dto.points ?? []).firstWhere(
(p) => p.id == geoPointId,
orElse: () => GeoPointDTO(),
);
if (point.id == null) continue;
point.localTransform = Position3D(
x: (position[0] as num).toDouble(),
y: (position[1] as num).toDouble(),
z: (position[2] as num).toDouble(),
rotationY: _yawFromQuaternion(transform?['rotation']),
);
changed = true;
_savePoint(point);
}
if (changed) widget.onChanged(_dto);
}
Future<void> _savePoint(GeoPointDTO point) async {
try {
await _api.sectionScene3DUpdatePoint(point);
} catch (e) {
debugPrint('[Model3D] position non enregistrée : $e');
if (mounted) {
showNotification(kError, kWhite,
AppLocalizations.of(context)!.model3DPointSaveError, context, null);
}
}
}
SectionScene3DApi get _api =>
(Provider.of<AppContext>(context, listen: false).getContext() as ManagerAppContext)
.clientAPI!
.sectionScene3DApi!;
Future<void> _addPoint() async {
final l = AppLocalizations.of(context)!;
final sectionId = _dto.id;
if (sectionId == null) return;
try {
final created = await _api.sectionScene3DCreatePoint(
sectionId,
GeoPointDTO(
title: [TranslationDTO(language: 'FR', value: l.model3DNewPoint)],
description: [],
contents: [],
// À l'origine du modèle : c'est là que l'éditeur le montrera, à portée
// de souris, pour qu'on le pose au bon endroit.
localTransform: Position3D(),
),
);
if (created == null) return;
setState(() => (_dto.points ??= []).add(created));
widget.onChanged(_dto);
_sendManifest();
} catch (e) {
debugPrint('[Model3D] création de point : $e');
if (mounted) {
showNotification(kError, kWhite, l.model3DPointSaveError, context, null);
}
}
}
Future<void> _deletePoint(GeoPointDTO point) async {
if (point.id == null) return;
try {
await _api.sectionScene3DDeletePoint(point.id!);
setState(() => _dto.points?.removeWhere((p) => p.id == point.id));
widget.onChanged(_dto);
_sendManifest();
} catch (e) {
debugPrint('[Model3D] suppression de point : $e');
}
}
/// Le manifeste ne stocke qu'un quaternion ; nous n'avons besoin que du lacet —
/// un panneau posé sur une maquette ne bascule pas.
double? _yawFromQuaternion(dynamic rotation) {
if (rotation is! List || rotation.length < 4) return null;
final x = (rotation[0] as num).toDouble();
final y = (rotation[1] as num).toDouble();
final z = (rotation[2] as num).toDouble();
final w = (rotation[3] as num).toDouble();
final yaw = math.atan2(2 * (w * y + x * z), 1 - 2 * (y * y + x * x));
return yaw * 180 / math.pi;
}
List<double> _quaternionFromYaw(double? degrees) {
if (degrees == null || degrees == 0) return [0, 0, 0, 1];
final half = degrees * math.pi / 180 / 2;
return [0, math.sin(half), 0, math.cos(half)];
}
/// Construit le manifeste minimal que le viewer sait lire : le modèle est le
/// décor, les points sont les hotspots. Tout le reste est comblé par ses valeurs
/// par défaut — c'est une maquette, pas une scène immersive.
void _sendManifest() {
final frame = _frame;
final url = _dto.model3DSource;
if (frame?.contentWindow == null || !_viewerReady || url == null) return;
const assetId = 'model';
final manifest = {
'manifestVersion': 1,
'sceneId': _dto.id ?? '',
'instanceId': _dto.instanceId ?? '',
'configurationId': _dto.configurationId ?? '',
'version': 1,
'coordinateSystem': _coordinateSystem,
'world': {
'kind': 'mesh',
'assetId': assetId,
'transform': {
'position': [0, 0, 0],
'rotation': [0, 0, 0, 1],
'scale': [1, 1, 1],
},
},
'assets': [
{
'id': assetId,
'resourceId': _dto.model3DResourceId,
'url': url,
'mimeType': 'model/gltf-binary',
},
],
'hotspots': (_dto.points ?? []).map((point) {
final position = point.localTransform;
return {
'id': 'poi-${point.id}',
'geoPointId': point.id,
'transform': {
'position': [position?.x ?? 0, position?.y ?? 0, position?.z ?? 0],
'rotation': _quaternionFromYaw(position?.rotationY),
'scale': [1, 1, 1],
},
'title': _localized(point.title),
'description': _localized(point.description),
'contents': const [],
};
}).toList(),
};
frame!.contentWindow!.postMessage(
{'type': 'load', 'manifest': manifest, 'mode': 'edit'},
'*',
);
}
List<Map<String, String>> _localized(List<TranslationDTO>? translations) =>
(translations ?? [])
.map((t) => {'language': t.language ?? '', 'value': t.value ?? ''})
.toList();
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ResourceInputContainer(
label: l.model3DResourceLabel,
initialValue: _dto.model3DResourceId,
inResourceTypes: const [ResourceType.Model3D],
onChanged: (ResourceDTO resource) {
setState(() {
_dto.model3DResourceId = resource.id;
_dto.model3DSource = resource.url;
});
widget.onChanged(_dto);
_sendManifest();
},
),
const SizedBox(height: kSpace5),
// Ce que le visiteur fera de la scène : manipuler un objet, ou être dedans.
// Aucun fichier ne peut le deviner — même GLB, même points, deux usages.
Text(l.scene3DModeTitle,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: kSpace2),
SegmentedButton<Scene3DMode>(
segments: [
ButtonSegment(
value: Scene3DMode.Asset,
icon: const Icon(Icons.view_in_ar, size: 16),
label: Text(l.scene3DModeAsset),
),
ButtonSegment(
value: Scene3DMode.Scene,
icon: const Icon(Icons.panorama_photosphere, size: 16),
label: Text(l.scene3DModeScene),
),
],
selected: {_dto.mode ?? Scene3DMode.Asset},
onSelectionChanged: (selection) {
setState(() => _dto.mode = selection.first);
widget.onChanged(_dto);
_sendManifest();
},
),
const SizedBox(height: kSpace2),
Text(
(_dto.mode ?? Scene3DMode.Asset) == Scene3DMode.Asset
? l.scene3DModeAssetHint
: l.scene3DModeSceneHint,
style: kTextHint,
),
const SizedBox(height: kSpace5),
if (_dto.model3DResourceId == null)
Text(l.model3DPickModelFirst, style: kTextHint)
else ...[
Text(l.model3DPlacePoints, style: kTextHint),
const SizedBox(height: kSpace3),
// Hauteur fixe : une iframe n'a pas de taille intrinsèque, et sans
// contrainte elle s'effondre à zéro dans une colonne.
SizedBox(
height: 480,
child: ClipRRect(
borderRadius: BorderRadius.circular(kRadiusCard),
child: HtmlElementView(viewType: _viewType),
),
),
const SizedBox(height: kSpace5),
_pointList(l),
],
],
);
}
/// La liste double le viewer, elle ne le remplace pas : on ajoute et on supprime
/// ici, on place là-bas. Elle sert aussi de repère quand un point s'est retrouvé
/// derrière la maquette et qu'on ne le voit plus.
Widget _pointList(AppLocalizations l) {
final points = _dto.points ?? [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(l.model3DPointsTitle(points.length.toString()),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
const Spacer(),
TextButton.icon(
onPressed: _addPoint,
icon: const Icon(Icons.add, size: 16),
label: Text(l.model3DAddPoint),
),
],
),
if (points.isEmpty)
Text(l.model3DNoPoint, style: kTextHint)
else
...points.map((point) {
final position = point.localTransform;
final placed = position != null &&
(position.x != 0 || position.y != 0 || position.z != 0);
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(placed ? Icons.place : Icons.place_outlined,
size: 18, color: placed ? kPrimaryColor : kInk3),
title: Text(
ConfigurationExportTitle(point.title) ?? l.model3DNewPoint,
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
placed
? '${position.x.toStringAsFixed(2)} · ${position.y.toStringAsFixed(2)} · ${position.z.toStringAsFixed(2)}'
: l.model3DPointNotPlaced,
style: kTextHint,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 18),
color: kInk3,
onPressed: () => _deletePoint(point),
),
);
}),
],
);
}
/// Le titre du point dans la langue d'édition, ou le premier disponible.
String? ConfigurationExportTitle(List<TranslationDTO>? title) {
if (title == null || title.isEmpty) return null;
final fr = title.firstWhere((t) => t.language == 'FR',
orElse: () => title.first);
return (fr.value ?? '').isEmpty ? null : fr.value;
}
}