diff --git a/src/DynamoCoreWpf/DynamoCoreWpf.csproj b/src/DynamoCoreWpf/DynamoCoreWpf.csproj index de264f43fac..9e261f3e4ba 100644 --- a/src/DynamoCoreWpf/DynamoCoreWpf.csproj +++ b/src/DynamoCoreWpf/DynamoCoreWpf.csproj @@ -891,6 +891,7 @@ PackageManagerWizard.xaml + diff --git a/src/DynamoCoreWpf/Utilities/Watch3DImageExporter.cs b/src/DynamoCoreWpf/Utilities/Watch3DImageExporter.cs new file mode 100644 index 00000000000..d3a0f1c6909 --- /dev/null +++ b/src/DynamoCoreWpf/Utilities/Watch3DImageExporter.cs @@ -0,0 +1,139 @@ +using System; +using System.IO; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using HelixToolkit.Wpf.SharpDX; + +namespace Dynamo.Wpf.Utilities +{ + /// + /// Captures Background 3D Preview viewport content to PNG files. + /// + internal static class Watch3DImageExporter + { + /// + /// Maximum pixel dimension for exported images. Prevents excessive memory use on + /// high-DPI displays and large viewports when exporting complex geometry. + /// + internal const int DefaultMaxExportDimension = 4096; + + /// + /// Renders the viewport to a bitmap at the requested pixel dimensions. + /// + /// The Helix viewport to capture. + /// Target pixel width. + /// Target pixel height. + /// The rendered bitmap. + internal static BitmapSource RenderViewportBitmapAtSize( + Viewport3DX view, + int targetPixelWidth, + int targetPixelHeight) + { + if (view?.RenderHost == null) throw new ArgumentNullException(nameof(view)); + if (targetPixelWidth <= 0) throw new ArgumentOutOfRangeException(nameof(targetPixelWidth)); + if (targetPixelHeight <= 0) throw new ArgumentOutOfRangeException(nameof(targetPixelHeight)); + + view.InvalidateRender(); + + var dpiScale = view.RenderHost.DpiScale; + var originalWidth = view.RenderHost.ActualWidth; + var originalHeight = view.RenderHost.ActualHeight; + var captureWidth = Math.Max(1, (int)(targetPixelWidth / dpiScale)); + var captureHeight = Math.Max(1, (int)(targetPixelHeight / dpiScale)); + + view.RenderHost.Resize(captureWidth, captureHeight); + try + { + return view.RenderBitmap(); + } + finally + { + if (originalWidth > 0 && originalHeight > 0) + { + view.RenderHost.Resize((int)originalWidth, (int)originalHeight); + } + } + } + + /// + /// Renders the viewport and saves the result as a PNG file. + /// + /// The Helix viewport to capture. + /// Destination file path. + /// + /// Maximum pixel width or height. Use 0 to capture at the current viewport resolution. + /// + /// True when the image was saved successfully. + internal static bool TrySaveViewportToPng(Viewport3DX view, string path, int maxExportDimension = DefaultMaxExportDimension) + { + if (view == null) throw new ArgumentNullException(nameof(view)); + if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path)); + + var bitmap = RenderViewportBitmap(view, maxExportDimension); + SaveBitmapSourceAsPng(bitmap, path); + return true; + } + + /// + /// Renders the viewport to a bitmap, optionally scaling down when the pixel size exceeds + /// . + /// + /// The Helix viewport to capture. + /// + /// Maximum pixel width or height. Use 0 to capture at the current viewport resolution. + /// + /// The rendered bitmap. + internal static BitmapSource RenderViewportBitmap(Viewport3DX view, int maxExportDimension = DefaultMaxExportDimension) + { + if (view?.RenderHost == null) throw new ArgumentNullException(nameof(view)); + + view.InvalidateRender(); + + var bitmap = view.RenderBitmap(); + return ScaleBitmapSourceToMaxDimension(bitmap, maxExportDimension); + } + + private static BitmapSource ScaleBitmapSourceToMaxDimension(BitmapSource bitmapSource, int maxExportDimension) + { + if (bitmapSource == null) throw new ArgumentNullException(nameof(bitmapSource)); + if (maxExportDimension <= 0) + { + return bitmapSource; + } + + var maxPixelDimension = Math.Max(bitmapSource.PixelWidth, bitmapSource.PixelHeight); + if (maxPixelDimension <= maxExportDimension) + { + return bitmapSource; + } + + var scale = maxExportDimension / (double)maxPixelDimension; + var transformedBitmap = new TransformedBitmap(bitmapSource, new ScaleTransform(scale, scale)); + transformedBitmap.Freeze(); + return transformedBitmap; + } + + /// + /// Saves a bitmap source as a PNG file, replacing any existing file at the path. + /// + /// The bitmap to save. + /// Destination file path. + internal static void SaveBitmapSourceAsPng(BitmapSource bitmapSource, string path) + { + if (bitmapSource == null) throw new ArgumentNullException(nameof(bitmapSource)); + if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path)); + + if (File.Exists(path)) + { + File.Delete(path); + } + + using (var stream = File.Create(path)) + { + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmapSource)); + encoder.Save(stream); + } + } + } +} diff --git a/src/DynamoCoreWpf/ViewModels/Core/DynamoViewModel.cs b/src/DynamoCoreWpf/ViewModels/Core/DynamoViewModel.cs index 3ebbb582a54..820455336b5 100644 --- a/src/DynamoCoreWpf/ViewModels/Core/DynamoViewModel.cs +++ b/src/DynamoCoreWpf/ViewModels/Core/DynamoViewModel.cs @@ -3736,6 +3736,13 @@ public void ShowSaveImageDialogAndSave(object parameter) public void ValidateWorkSpaceBeforeToExportAsImage(object parameter) { + if (parameter?.ToString() == Resources.ScreenShotFrom3DParameter || + (parameter?.ToString() == Resources.ScreenShotFrom3DShortcutParameter && BackgroundPreviewViewModel.CanNavigateBackground)) + { + ShowSaveImageDialogAndSave(parameter); + return; + } + OnRequestExportWorkSpaceAsImage(parameter); } diff --git a/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs b/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs index f3f46eb3223..62b7df2a1d8 100644 --- a/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs +++ b/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs @@ -1887,44 +1887,31 @@ private void DynamoViewModelRequestSaveImage(object sender, ImageSaveEventArgs e private void DynamoViewModelRequestSave3DImage(object sender, ImageSaveEventArgs e) { - var dpiX = 0.0; - var dpiY = 0.0; - - // dpi aware, otherwise incorrect images are created - try - { - var scale = VisualTreeHelper.GetDpi(this); - dpiX = scale.PixelsPerInchX; - dpiY = scale.PixelsPerInchY; - } - catch (Exception ex) + if (BackgroundPreview == null) { - Log(ex.ToString()); - - dpiX = 96; - dpiY = 96; + e.Success = false; + return; } - if (BackgroundPreview == null) + if (!dynamoViewModel.BackgroundPreviewViewModel.Active) { e.Success = false; return; } - var bitmapSource = BackgroundPreview.View.RenderBitmap(); - // this image only really needs 24bits per pixel but to match previous implementation we'll use 32bit images. - var rtBitmap = new RenderTargetBitmap(bitmapSource.PixelWidth, bitmapSource.PixelHeight, dpiX, dpiY, PixelFormats.Pbgra32); - rtBitmap.Render(BackgroundPreview.View); - var encoder = new PngBitmapEncoder(); - encoder.Frames.Add(BitmapFrame.Create(rtBitmap)); - if (File.Exists(e.Path)) + try { - File.Delete(e.Path); + Watch3DImageExporter.TrySaveViewportToPng(BackgroundPreview.View, e.Path); } - - using (var stream = File.Create(e.Path)) + catch (OutOfMemoryException ex) + { + e.Success = false; + Log(ex.ToString()); + } + catch (Exception ex) { - encoder.Save(stream); + e.Success = false; + Log(ex.ToString()); } } diff --git a/src/DynamoCoreWpf/Views/Core/WorkspaceView.xaml.cs b/src/DynamoCoreWpf/Views/Core/WorkspaceView.xaml.cs index e7479ec8dc1..5faf1f2cd44 100644 --- a/src/DynamoCoreWpf/Views/Core/WorkspaceView.xaml.cs +++ b/src/DynamoCoreWpf/Views/Core/WorkspaceView.xaml.cs @@ -68,6 +68,8 @@ public enum CursorState private List hitResultsList = new List(); static internal event Action RequestShowNodeAutoCompleteBar; + internal const int MaxWorkspaceImagePixelDimension = 16384; + internal const long MaxWorkspaceImagePixelCount = 67108864; private double currentRenderScale = -1; public WorkspaceViewModel ViewModel @@ -343,94 +345,107 @@ internal Rect GetVisibleBounds() return new Rect(topLeft, bottomRight); } - private RenderTargetBitmap GetRender() + private bool TryGetRenderBounds(out Rect bounds, out double minX, out double minY) { - RenderTargetBitmap rtb; - try - { - var initialized = false; - var bounds = new Rect(); + var initialized = false; + bounds = new Rect(); + minX = 0.0; + minY = 0.0; - double minX = 0.0, minY = 0.0; - var dragCanvas = WpfUtilities.ChildOfType(this); - var childrenCount = VisualTreeHelper.GetChildrenCount(dragCanvas); - for (int index = 0; index < childrenCount; ++index) - { - ContentPresenter contentPresenter = VisualTreeHelper.GetChild(dragCanvas, index) as ContentPresenter; - if (contentPresenter.Children().Count() < 1) continue; + var dragCanvas = WpfUtilities.ChildOfType(this); + if (dragCanvas == null) return false; - var firstChild = VisualTreeHelper.GetChild(contentPresenter, 0); + var childrenCount = VisualTreeHelper.GetChildrenCount(dragCanvas); + for (int index = 0; index < childrenCount; ++index) + { + ContentPresenter contentPresenter = VisualTreeHelper.GetChild(dragCanvas, index) as ContentPresenter; + if (contentPresenter.Children().Count() < 1) continue; - switch (firstChild.GetType().Name) - { - case "NodeView": - case "NoteView": - case "AnnotationView": - break; - - // Until we completely removed InfoBubbleView (or fixed its broken - // size calculation), we will not be including it in our size - // calculation here. This means that the info bubble, if any, will - // still go beyond the boundaries of the final PNG file. I would - // prefer not to add this hack here as it introduces multiple issues - // (including NaN for Grid inside the view and the fix would be too - // ugly to type in). Suffice to say that InfoBubbleView is not - // included in the size calculation for screen capture (work-around - // should be obvious). - // - // case "InfoBubbleView": - // child = WpfUtilities.ChildOfType(child); - // break; - - // We do not take anything other than those above - // into consideration when the canvas size is measured. - default: - continue; - } + var firstChild = VisualTreeHelper.GetChild(contentPresenter, 0); - // Determine the smallest corner of all given visual elements on the - // graph. This smallest top-left corner value will be useful in making - // the offset later on. + switch (firstChild.GetType().Name) + { + case "NodeView": + case "NoteView": + case "AnnotationView": + break; + + // Until we completely removed InfoBubbleView (or fixed its broken + // size calculation), we will not be including it in our size + // calculation here. This means that the info bubble, if any, will + // still go beyond the boundaries of the final PNG file. I would + // prefer not to add this hack here as it introduces multiple issues + // (including NaN for Grid inside the view and the fix would be too + // ugly to type in). Suffice to say that InfoBubbleView is not + // included in the size calculation for screen capture (work-around + // should be obvious). // - var childBounds = VisualTreeHelper.GetDescendantBounds(contentPresenter as Visual); - minX = childBounds.X < minX ? childBounds.X : minX; - minY = childBounds.Y < minY ? childBounds.Y : minY; - childBounds.X = (double)(contentPresenter as Visual).GetValue(Canvas.LeftProperty); - childBounds.Y = (double)(contentPresenter as Visual).GetValue(Canvas.TopProperty); + // case "InfoBubbleView": + // child = WpfUtilities.ChildOfType(child); + // break; + + // We do not take anything other than those above + // into consideration when the canvas size is measured. + default: + continue; + } - if (initialized) - { - bounds.Union(childBounds); - } - else - { - initialized = true; - bounds = childBounds; - } + // Determine the smallest corner of all given visual elements on the + // graph. This smallest top-left corner value will be useful in making + // the offset later on. + // + var childBounds = VisualTreeHelper.GetDescendantBounds(contentPresenter as Visual); + minX = childBounds.X < minX ? childBounds.X : minX; + minY = childBounds.Y < minY ? childBounds.Y : minY; + childBounds.X = (double)(contentPresenter as Visual).GetValue(Canvas.LeftProperty); + childBounds.Y = (double)(contentPresenter as Visual).GetValue(Canvas.TopProperty); + + if (initialized) + { + bounds.Union(childBounds); } + else + { + initialized = true; + bounds = childBounds; + } + } - // Nothing found in the canvas, bail out. - if (!initialized) return null; + if (!initialized) return false; - // Add padding to the edge and make them multiples of two (pad 10px on each side). - bounds.Width = 20 + ((((int)Math.Ceiling(bounds.Width)) + 1) & ~0x01); - bounds.Height = 20 + ((((int)Math.Ceiling(bounds.Height)) + 1) & ~0x01); + // Add padding to the edge and make them multiples of two (pad 10px on each side). + bounds.Width = 20 + ((((int)Math.Ceiling(bounds.Width)) + 1) & ~0x01); + bounds.Height = 20 + ((((int)Math.Ceiling(bounds.Height)) + 1) & ~0x01); + return true; + } - var currentTransformGroup = WorkspaceElements.RenderTransform as TransformGroup; - WorkspaceElements.RenderTransform = new TranslateTransform(10.0 - bounds.X - minX, 10.0 - bounds.Y - minY); + private bool IsRenderBoundsValidAsImage(Rect bounds) + { + if (bounds.Width <= 0 || bounds.Height <= 0) return false; + if (bounds.Width > MaxWorkspaceImagePixelDimension || bounds.Height > MaxWorkspaceImagePixelDimension) return false; + + return bounds.Width * bounds.Height <= MaxWorkspaceImagePixelCount; + } + + private RenderTargetBitmap GetRender(Rect bounds, double minX, double minY) + { + Transform currentTransform = WorkspaceElements.RenderTransform; + WorkspaceElements.RenderTransform = new TranslateTransform(10.0 - bounds.X - minX, 10.0 - bounds.Y - minY); + + try + { WorkspaceElements.UpdateLayout(); - rtb = new RenderTargetBitmap(((int)bounds.Width), + var rtb = new RenderTargetBitmap(((int)bounds.Width), ((int)bounds.Height), 96, 96, PixelFormats.Default); rtb.Render(WorkspaceElements); - WorkspaceElements.RenderTransform = currentTransformGroup; + return rtb; } - catch (Exception) + finally { - throw; + WorkspaceElements.RenderTransform = currentTransform; } - return rtb; } /// @@ -443,8 +458,8 @@ private RenderTargetBitmap GetRender() internal ExportImageResult IsWorkSpaceRenderValidAsImage(bool validating, string path = null) { ExportImageResult result = ExportImageResult.EmptyDrawing; - RenderTargetBitmap workSpaceRender = GetRender(); - if (workSpaceRender == null) return result; + if (!TryGetRenderBounds(out var bounds, out var minX, out var minY)) return result; + if (!IsRenderBoundsValidAsImage(bounds)) return ExportImageResult.NotValidAsImage; result = ExportImageResult.IsValidAsImage; if (validating) @@ -454,6 +469,7 @@ internal ExportImageResult IsWorkSpaceRenderValidAsImage(bool validating, string try { + RenderTargetBitmap workSpaceRender = GetRender(bounds, minX, minY); using (var stm = System.IO.File.Create(path)) { // Encode as PNG format diff --git a/test/DynamoCoreWpfTests/CoreUITests.cs b/test/DynamoCoreWpfTests/CoreUITests.cs index f3d076e0763..d562afb7ddb 100644 --- a/test/DynamoCoreWpfTests/CoreUITests.cs +++ b/test/DynamoCoreWpfTests/CoreUITests.cs @@ -60,6 +60,29 @@ public void CanSaveImage() Assert.False(File.Exists(path)); } + [Test] + [Category("DynamoUI")] + public void CannotSaveImageWhenWorkspaceImageWouldBeTooLarge() + { + var firstNode = new DoubleInput { X = 0, Y = 0 }; + var secondNode = new DoubleInput { X = WorkspaceView.MaxWorkspaceImagePixelDimension + 1000, Y = 0 }; + ViewModel.Model.CurrentWorkspace.AddAndRegisterNode(firstNode, true); + ViewModel.Model.CurrentWorkspace.AddAndRegisterNode(secondNode, true); + DispatcherUtil.DoEvents(); + + var workspace = View.ChildOfType(); + Assert.IsNotNull(workspace, "DynamoView does not have any WorkspaceView"); + + Assert.AreEqual( + WorkspaceView.ExportImageResult.NotValidAsImage, + workspace.IsWorkSpaceRenderValidAsImage(true)); + + string path = Path.Combine(TempFolder, "oversized-output.png"); + ViewModel.SaveImageCommand.Execute(path); + + Assert.False(File.Exists(path)); + } + [Test] [Category("DynamoUI")] public void CannotSaveImageWithBadPath() diff --git a/test/VisualizationTests/BackgroundPreviewExportTests.cs b/test/VisualizationTests/BackgroundPreviewExportTests.cs new file mode 100644 index 00000000000..eb8e5139c2e --- /dev/null +++ b/test/VisualizationTests/BackgroundPreviewExportTests.cs @@ -0,0 +1,121 @@ +using System; +using System.IO; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Dynamo.ViewModels; +using Dynamo.Wpf.Utilities; +using DynamoCoreWpfTests.Utility; +using NUnit.Framework; + +namespace WpfVisualizationTests +{ + [TestFixture] + public class BackgroundPreviewExportTests : VisualizationTest + { + [Test] + public void WhenBackgroundPreviewIsRenderedThenExportCreatesPngFile() + { + OpenVisualizationTest(@"imageComparison\spherecolors.dyn"); + RunCurrentModel(); + ViewModel.BackgroundPreviewViewModel.Active = true; + DispatcherUtil.DoEvents(); + + Assert.That(View.BackgroundPreview, Is.Not.Null); + Assert.That(View.BackgroundPreview.View, Is.Not.Null); + + var exportPath = Path.Combine(Path.GetTempPath(), $"DynamoBackgroundExportTest_{Guid.NewGuid():N}.png"); + + try + { + Watch3DImageExporter.TrySaveViewportToPng( + View.BackgroundPreview.View, + exportPath, + maxExportDimension: 1024); + + Assert.That(File.Exists(exportPath), Is.True); + Assert.That(new FileInfo(exportPath).Length, Is.GreaterThan(0)); + } + finally + { + if (File.Exists(exportPath)) + { + File.Delete(exportPath); + } + } + } + + [Test] + public void WhenBackgroundPreviewExportIsScaledThenExportCreatesNonBlankPngFile() + { + OpenVisualizationTest(@"imageComparison\spherecolors.dyn"); + RunCurrentModel(); + ViewModel.BackgroundPreviewViewModel.Active = true; + DispatcherUtil.DoEvents(); + + Assert.That(View.BackgroundPreview, Is.Not.Null); + Assert.That(View.BackgroundPreview.View, Is.Not.Null); + + var exportPath = Path.Combine(Path.GetTempPath(), $"DynamoBackgroundExportScaledTest_{Guid.NewGuid():N}.png"); + + try + { + Watch3DImageExporter.TrySaveViewportToPng( + View.BackgroundPreview.View, + exportPath, + maxExportDimension: 128); + + Assert.That(File.Exists(exportPath), Is.True); + Assert.That(PngHasImageVariation(exportPath), Is.True); + } + finally + { + if (File.Exists(exportPath)) + { + File.Delete(exportPath); + } + } + } + + [Test] + public void WhenBackgroundPreviewIsInactiveThenExportIsRejected() + { + OpenVisualizationTest(@"imageComparison\spherecolors.dyn"); + RunCurrentModel(); + ViewModel.BackgroundPreviewViewModel.Active = false; + + var args = new ImageSaveEventArgs(Path.Combine(Path.GetTempPath(), "unused.png")); + ViewModel.OnRequestSave3DImage(ViewModel, args); + + Assert.That(args.Success, Is.False); + } + + private static bool PngHasImageVariation(string path) + { + using (var stream = File.OpenRead(path)) + { + var decoder = new PngBitmapDecoder(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad); + var frame = decoder.Frames[0]; + BitmapSource bitmapSource = frame.Format == PixelFormats.Bgra32 + ? frame + : new FormatConvertedBitmap(frame, PixelFormats.Bgra32, null, 0); + + var stride = bitmapSource.PixelWidth * 4; + var pixels = new byte[stride * bitmapSource.PixelHeight]; + bitmapSource.CopyPixels(pixels, stride, 0); + + for (var i = 4; i < pixels.Length; i += 4) + { + if (pixels[i] != pixels[0] || + pixels[i + 1] != pixels[1] || + pixels[i + 2] != pixels[2] || + pixels[i + 3] != pixels[3]) + { + return true; + } + } + + return false; + } + } + } +} diff --git a/test/VisualizationTests/HelixImageComparisonTests.cs b/test/VisualizationTests/HelixImageComparisonTests.cs index 4b49008d8ff..2f6e2da7b1c 100644 --- a/test/VisualizationTests/HelixImageComparisonTests.cs +++ b/test/VisualizationTests/HelixImageComparisonTests.cs @@ -13,6 +13,7 @@ using Dynamo.Controls; using Dynamo.Selection; using Dynamo.Utilities; +using Dynamo.Wpf.Utilities; using DynamoCoreWpfTests.Utility; using HelixToolkit.Wpf.SharpDX; using NUnit.Framework; @@ -43,28 +44,17 @@ protected Watch3DView BackgroundPreview private static BitmapSource DynamoRenderBitmap( Viewport3DX view, int width, int height) { - var w = view.RenderHost.ActualWidth; - var h = view.RenderHost.ActualHeight; - var dpiscaling = view.RenderHost.DpiScale; - view.RenderHost.Resize((int)(width/ dpiscaling), (int)(height/dpiscaling)); - var rtb = view.RenderBitmap(); - view.RenderHost.Resize((int)w, (int)h); - return rtb; + return Watch3DImageExporter.RenderViewportBitmapAtSize(view, width, height); } private static void UpdateTestData(string pathToUpdate, BitmapSource imageFileSource) { - SaveBitMapSourceAsPNG(pathToUpdate, imageFileSource); + Watch3DImageExporter.SaveBitmapSourceAsPng(imageFileSource, pathToUpdate); } private static void SaveBitMapSourceAsPNG(string filePath, BitmapSource bitmapSource) { - using (var fileStream = new FileStream(filePath, FileMode.Create)) - { - BitmapEncoder encoder = new PngBitmapEncoder(); - encoder.Frames.Add(BitmapFrame.Create(bitmapSource)); - encoder.Save(fileStream); - } + Watch3DImageExporter.SaveBitmapSourceAsPng(bitmapSource, filePath); } private static System.Drawing.Bitmap BitmapFromSource(BitmapSource bitmapsource)