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>
431 lines
15 KiB
Dart
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;
|
|
}
|
|
}
|