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. /// /// Pourquoi une iframe et pas un rendu Flutter. 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. /// /// Le protocole n'est pas inventé ici : 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 onChanged; const Scene3DConfig({ Key? key, required this.initialValue, required this.onChanged, }) : super(key: key); @override State createState() => _Scene3DConfigState(); } class _Scene3DConfigState extends State { /// 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? _asMap(dynamic data) { if (data is Map) return data.cast(); if (data is String) { try { final decoded = jsonDecode(data); if (decoded is Map) return decoded.cast(); } 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 _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(context, listen: false).getContext() as ManagerAppContext) .clientAPI! .sectionScene3DApi!; Future _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 _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 _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> _localized(List? 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( 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? 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; } }