diff --git a/lib/features/crop/crop_screen.dart b/lib/features/crop/crop_screen.dart index 5e59ca6d..3aceec41 100644 --- a/lib/features/crop/crop_screen.dart +++ b/lib/features/crop/crop_screen.dart @@ -389,19 +389,24 @@ class _CropScreenState extends State { Future _onCropConfirm() async { setState(() => _isLoading = true); try { - // La zone de découpe doit refléter le DÉPLACEMENT (pan) et la ROTATION, - // mais PAS le ZOOM. On neutralise donc temporairement _scale à 1.0 pour - // le calcul, tout en conservant _offset. La rotation est appliquée à part - // par cropToSquare(rotationDegrees: _rotation). - final savedScale = _scale; - _scale = 1.0; - final cropRect = _calculateCropRect(); - _scale = savedScale; // on restaure pour l'affichage (retour arrière) + // Facteur d'échelle affichage/source (BoxFit.contain) — identique à + // celui utilisé pour afficher l'image dans l'aperçu. + final imageAspect = _imageSize!.width / _imageSize!.height; + final viewportAspect = _viewportSize.width / _viewportSize.height; + final double displayWidth = imageAspect > viewportAspect + ? _viewportSize.width + : _viewportSize.height * imageAspect; + final double displayPerSourcePx = displayWidth / _imageSize!.width; - // AJOUT DE LA DECOUPE ET DU REDRESSEMENT GÉOMÉTRIQUE PHYSIQUE - final croppedImagePath = await _cropService.cropToSquare( - widget.imagePath, - cropRect, + // Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la + // ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements + // sont remplis en noir → la position choisie est respectée à l'identique. + final croppedImagePath = await _cropService.cropViewport( + sourcePath: widget.imagePath, + offsetDx: _offset.dx, + offsetDy: _offset.dy, + displayPerSourcePx: displayPerSourcePx, + cropSizeDisplay: _cropSize, rotationDegrees: _rotation, ); @@ -438,43 +443,4 @@ class _CropScreenState extends State { } } - CropRect _calculateCropRect() { - if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1); - - final imageAspect = _imageSize!.width / _imageSize!.height; - final viewportAspect = _viewportSize.width / _viewportSize.height; - - double displayWidth, displayHeight; - if (imageAspect > viewportAspect) { - displayWidth = _viewportSize.width; - displayHeight = _viewportSize.width / imageAspect; - } else { - displayHeight = _viewportSize.height; - displayWidth = _viewportSize.height * imageAspect; - } - - final scaledWidth = displayWidth * _scale; - final scaledHeight = displayHeight * _scale; - - final imageCenterX = _viewportSize.width / 2 + _offset.dx; - final imageCenterY = _viewportSize.height / 2 + _offset.dy; - - final imageLeft = imageCenterX - scaledWidth / 2; - final imageTop = imageCenterY - scaledHeight / 2; - - final cropLeft = (_viewportSize.width - _cropSize) / 2; - final cropTop = (_viewportSize.height - _cropSize) / 2; - - final relCropLeft = (cropLeft - imageLeft) / scaledWidth; - final relCropTop = (cropTop - imageTop) / scaledHeight; - final relCropWidth = _cropSize / scaledWidth; - final relCropHeight = _cropSize / scaledHeight; - - return CropRect( - x: relCropLeft.clamp(0.0, 1.0), - y: relCropTop.clamp(0.0, 1.0), - width: relCropWidth.clamp(0.0, 1.0), - height: relCropHeight.clamp(0.0, 1.0), - ); - } } \ No newline at end of file diff --git a/lib/services/image_crop_service.dart b/lib/services/image_crop_service.dart index cd2e34eb..fe7aa76f 100644 --- a/lib/services/image_crop_service.dart +++ b/lib/services/image_crop_service.dart @@ -98,11 +98,122 @@ void _cropIsolateEntry(_CropParams params) { outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85)); } +// AJOUT : Paramètres de la découpe calée sur la fenêtre de visée (padding noir) +class _ViewportCropParams { + final String sourcePath; + final double offsetDx; + final double offsetDy; + final double displayPerSourcePx; // facteur affichage/source (BoxFit.contain) + final double cropSizeDisplay; // côté de la fenêtre de visée, en pixels écran + final double rotationDegrees; + final int outputSize; + final String outputPath; + + _ViewportCropParams({ + required this.sourcePath, + required this.offsetDx, + required this.offsetDy, + required this.displayPerSourcePx, + required this.cropSizeDisplay, + required this.rotationDegrees, + required this.outputSize, + required this.outputPath, + }); +} + +// AJOUT : Découpe fidèle à ce que l'utilisateur voit. Reproduit la translation +// (pan) et la rotation de l'aperçu, ignore le zoom, et remplit en NOIR toute +// zone qui déborde de l'image → la cible reste exactement là où l'utilisateur +// l'a placée, même collée à un bord (aucun recentrage forcé). +void _viewportCropIsolateEntry(_ViewportCropParams p) { + final bytes = File(p.sourcePath).readAsBytesSync(); + final img.Image? src = img.decodeImage(bytes); + if (src == null) { + throw Exception('Impossible de décoder l\'image: ${p.sourcePath}'); + } + + // Rotation autour du centre (canvas agrandi). Préserve l'échelle des pixels. + img.Image rotated = src; + if (p.rotationDegrees != 0.0) { + rotated = img.copyRotate( + src, + angle: p.rotationDegrees, + interpolation: img.Interpolation.linear, + ); + } + + final double f = p.displayPerSourcePx; + // Côté de la fenêtre de visée, exprimé en pixels source (rotation = même échelle) + final int side = math.max(1, (p.cropSizeDisplay / f).round()); + + // Centre de la fenêtre de visée, en pixels de l'image (rotated) : + // le centre de l'image est affiché à (centre_viewport + offset), la fenêtre + // est centrée sur le viewport → décalage de -offset/f par rapport au centre. + final double cropCenterX = rotated.width / 2 - p.offsetDx / f; + final double cropCenterY = rotated.height / 2 - p.offsetDy / f; + + final int srcX = (cropCenterX - side / 2).round(); + final int srcY = (cropCenterY - side / 2).round(); + + // Toile carrée noire opaque ; compositeImage rogne automatiquement les bords + // hors-cadre, donc les débordements restent noirs (padding). + final out = img.Image(width: side, height: side, numChannels: 3); + img.compositeImage(out, rotated, dstX: -srcX, dstY: -srcY); + + img.Image result = out; + if (side != p.outputSize) { + result = img.copyResize( + out, + width: p.outputSize, + height: p.outputSize, + interpolation: img.Interpolation.linear, + ); + } + + File(p.outputPath).writeAsBytesSync(img.encodeJpg(result, quality: 85)); +} + class ImageCropService { final Uuid _uuid = const Uuid(); static const int maxOutputSize = 1024; + /// Découpe carrée calée sur la fenêtre de visée de l'écran de centrage. + /// Le décalage (pan) et la rotation sont respectés, le zoom est ignoré, et + /// tout débordement hors de l'image est rempli en noir (jamais de recentrage). + /// + /// - [offsetDx]/[offsetDy] : translation de l'image dans l'aperçu, en px écran. + /// - [displayPerSourcePx] : facteur d'échelle affichage/source (BoxFit.contain). + /// - [cropSizeDisplay] : côté de la fenêtre carrée de visée, en px écran. + Future cropViewport({ + required String sourcePath, + required double offsetDx, + required double offsetDy, + required double displayPerSourcePx, + required double cropSizeDisplay, + double rotationDegrees = 0.0, + int outputSize = maxOutputSize, + }) async { + final tempDir = await getTemporaryDirectory(); + final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg'; + + final params = _ViewportCropParams( + sourcePath: sourcePath, + offsetDx: offsetDx, + offsetDy: offsetDy, + displayPerSourcePx: displayPerSourcePx, + cropSizeDisplay: cropSizeDisplay, + rotationDegrees: rotationDegrees, + outputSize: outputSize, + outputPath: outputPath, + ); + + // Traitement lourd dans un Isolate → thread UI fluide. + await Isolate.run(() => _viewportCropIsolateEntry(params)); + + return outputPath; + } + Future cropToSquare( String sourcePath, CropRect cropRect, {