From 285af0f9b8aec84001bdea3205eaa82c155c6500 Mon Sep 17 00:00:00 2001 From: john pierson Date: Thu, 11 Jun 2026 12:48:12 -0600 Subject: [PATCH 1/5] DYN-10601 Fix Background 3D Preview export crash on large graphs Replace the redundant double-bitmap export path with a single RenderBitmap capture, cap export resolution, and handle failures gracefully instead of crashing Dynamo. Co-authored-by: Cursor --- src/DynamoCoreWpf/DynamoCoreWpf.csproj | 1 + .../Properties/Resources.Designer.cs | 18 ++ .../Properties/Resources.en-US.resx | 6 + src/DynamoCoreWpf/Properties/Resources.resx | 6 + .../Utilities/Watch3DImageExporter.cs | 155 ++++++++++++++++++ .../Views/Core/DynamoView.xaml.cs | 47 +++--- .../BackgroundPreviewExportTests.cs | 58 +++++++ .../HelixImageComparisonTests.cs | 18 +- 8 files changed, 268 insertions(+), 41 deletions(-) create mode 100644 src/DynamoCoreWpf/Utilities/Watch3DImageExporter.cs create mode 100644 test/VisualizationTests/BackgroundPreviewExportTests.cs 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/Properties/Resources.Designer.cs b/src/DynamoCoreWpf/Properties/Resources.Designer.cs index 03f501a0c71..367c026eca6 100644 --- a/src/DynamoCoreWpf/Properties/Resources.Designer.cs +++ b/src/DynamoCoreWpf/Properties/Resources.Designer.cs @@ -374,6 +374,24 @@ public static string CantExportWorkspaceAsImageNotValidMessage { return ResourceManager.GetString("CantExportWorkspaceAsImageNotValidMessage", resourceCulture); } } + + /// + /// Looks up a localized string similar to The Background 3D Preview could not be exported as an image. Try reducing the amount of geometry in the graph or closing other applications to free memory.. + /// + public static string CantExportBackground3DPreviewMessage { + get { + return ResourceManager.GetString("CantExportBackground3DPreviewMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Background 3D Preview cannot be exported because it is not active.. + /// + public static string CantExportBackground3DPreviewInactiveMessage { + get { + return ResourceManager.GetString("CantExportBackground3DPreviewInactiveMessage", resourceCulture); + } + } /// /// Looks up a localized string similar to To ensure accurate geometry rendering and calculation, select the range of geometry sizes that you will be working on:. diff --git a/src/DynamoCoreWpf/Properties/Resources.en-US.resx b/src/DynamoCoreWpf/Properties/Resources.en-US.resx index 14958c67a51..1af706e19f2 100644 --- a/src/DynamoCoreWpf/Properties/Resources.en-US.resx +++ b/src/DynamoCoreWpf/Properties/Resources.en-US.resx @@ -3930,6 +3930,12 @@ In certain complex graphs or host program scenarios, Automatic mode may cause in The workspace cannot be exported as an image because it contains nodes that are too far away from each other. + + The Background 3D Preview could not be exported as an image. Try reducing the amount of geometry in the graph or closing other applications to free memory. + + + The Background 3D Preview cannot be exported because it is not active. + Include Timestamp in Export Image Path diff --git a/src/DynamoCoreWpf/Properties/Resources.resx b/src/DynamoCoreWpf/Properties/Resources.resx index 5f1aae9c455..c12d377c650 100644 --- a/src/DynamoCoreWpf/Properties/Resources.resx +++ b/src/DynamoCoreWpf/Properties/Resources.resx @@ -3921,6 +3921,12 @@ In certain complex graphs or host program scenarios, Automatic mode may cause in The workspace cannot be exported as an image because it contains nodes that are too far away from each other. + + The Background 3D Preview could not be exported as an image. Try reducing the amount of geometry in the graph or closing other applications to free memory. + + + The Background 3D Preview cannot be exported because it is not active. + Include Timestamp in Export Image Path diff --git a/src/DynamoCoreWpf/Utilities/Watch3DImageExporter.cs b/src/DynamoCoreWpf/Utilities/Watch3DImageExporter.cs new file mode 100644 index 00000000000..f5ff1f6d287 --- /dev/null +++ b/src/DynamoCoreWpf/Utilities/Watch3DImageExporter.cs @@ -0,0 +1,155 @@ +using System; +using System.IO; +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 originalWidth = view.RenderHost.ActualWidth; + var originalHeight = view.RenderHost.ActualHeight; + + if (originalWidth <= 0 || originalHeight <= 0) + { + return view.RenderBitmap(); + } + + var dpiScale = view.RenderHost.DpiScale; + var pixelWidth = (int)(originalWidth * dpiScale); + var pixelHeight = (int)(originalHeight * dpiScale); + var resizeApplied = false; + var captureWidth = (int)originalWidth; + var captureHeight = (int)originalHeight; + + if (maxExportDimension > 0) + { + var maxPixelDimension = Math.Max(pixelWidth, pixelHeight); + if (maxPixelDimension > maxExportDimension) + { + var scale = maxExportDimension / (double)maxPixelDimension; + captureWidth = Math.Max(1, (int)(originalWidth * scale)); + captureHeight = Math.Max(1, (int)(originalHeight * scale)); + view.RenderHost.Resize(captureWidth, captureHeight); + resizeApplied = true; + } + } + + try + { + return view.RenderBitmap(); + } + finally + { + if (resizeApplied) + { + view.RenderHost.Resize((int)originalWidth, (int)originalHeight); + } + } + } + + /// + /// 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/Views/Core/DynamoView.xaml.cs b/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs index f3f46eb3223..1b1c3e2bff8 100644 --- a/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs +++ b/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs @@ -1887,44 +1887,37 @@ 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; + dynamoViewModel.ToastManager?.CreateRealTimeInfoWindow( + Res.CantExportBackground3DPreviewInactiveMessage, true); 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()); + dynamoViewModel.ToastManager?.CreateRealTimeInfoWindow( + Res.CantExportBackground3DPreviewMessage, true); + } + catch (Exception ex) { - encoder.Save(stream); + e.Success = false; + Log(ex.ToString()); + dynamoViewModel.ToastManager?.CreateRealTimeInfoWindow( + Res.CantExportBackground3DPreviewMessage, true); } } diff --git a/test/VisualizationTests/BackgroundPreviewExportTests.cs b/test/VisualizationTests/BackgroundPreviewExportTests.cs new file mode 100644 index 00000000000..5176db94d05 --- /dev/null +++ b/test/VisualizationTests/BackgroundPreviewExportTests.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +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 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); + } + } +} 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) From 441938ec14b3426ee961e8a4c3ff386d16e594e8 Mon Sep 17 00:00:00 2001 From: John Date: Fri, 12 Jun 2026 08:48:21 -0600 Subject: [PATCH 2/5] Prevent large workspace image export crashes Measure workspace export bounds before creating the RenderTargetBitmap so oversized graphs are rejected without allocating an unsafe bitmap. Restore the workspace transform after rendering and add a UI regression test that verifies oversized workspace exports are refused without writing a file. --- .../Views/Core/WorkspaceView.xaml.cs | 158 ++++++++++-------- test/DynamoCoreWpfTests/CoreUITests.cs | 23 +++ 2 files changed, 110 insertions(+), 71 deletions(-) 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() From c709957ef4334b443ea7e8a176eb1c4dbc37dbe6 Mon Sep 17 00:00:00 2001 From: John Date: Fri, 12 Jun 2026 13:39:32 -0600 Subject: [PATCH 3/5] Remove background preview export warning Keep Background 3D Preview export failures quiet while preserving failure state and logging. The oversized workspace export warning remains limited to workspace image export. --- .../Properties/Resources.Designer.cs | 18 ------------------ .../Properties/Resources.en-US.resx | 6 ------ src/DynamoCoreWpf/Properties/Resources.resx | 6 ------ .../Views/Core/DynamoView.xaml.cs | 6 ------ 4 files changed, 36 deletions(-) diff --git a/src/DynamoCoreWpf/Properties/Resources.Designer.cs b/src/DynamoCoreWpf/Properties/Resources.Designer.cs index 367c026eca6..03f501a0c71 100644 --- a/src/DynamoCoreWpf/Properties/Resources.Designer.cs +++ b/src/DynamoCoreWpf/Properties/Resources.Designer.cs @@ -374,24 +374,6 @@ public static string CantExportWorkspaceAsImageNotValidMessage { return ResourceManager.GetString("CantExportWorkspaceAsImageNotValidMessage", resourceCulture); } } - - /// - /// Looks up a localized string similar to The Background 3D Preview could not be exported as an image. Try reducing the amount of geometry in the graph or closing other applications to free memory.. - /// - public static string CantExportBackground3DPreviewMessage { - get { - return ResourceManager.GetString("CantExportBackground3DPreviewMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The Background 3D Preview cannot be exported because it is not active.. - /// - public static string CantExportBackground3DPreviewInactiveMessage { - get { - return ResourceManager.GetString("CantExportBackground3DPreviewInactiveMessage", resourceCulture); - } - } /// /// Looks up a localized string similar to To ensure accurate geometry rendering and calculation, select the range of geometry sizes that you will be working on:. diff --git a/src/DynamoCoreWpf/Properties/Resources.en-US.resx b/src/DynamoCoreWpf/Properties/Resources.en-US.resx index 1af706e19f2..14958c67a51 100644 --- a/src/DynamoCoreWpf/Properties/Resources.en-US.resx +++ b/src/DynamoCoreWpf/Properties/Resources.en-US.resx @@ -3930,12 +3930,6 @@ In certain complex graphs or host program scenarios, Automatic mode may cause in The workspace cannot be exported as an image because it contains nodes that are too far away from each other. - - The Background 3D Preview could not be exported as an image. Try reducing the amount of geometry in the graph or closing other applications to free memory. - - - The Background 3D Preview cannot be exported because it is not active. - Include Timestamp in Export Image Path diff --git a/src/DynamoCoreWpf/Properties/Resources.resx b/src/DynamoCoreWpf/Properties/Resources.resx index c12d377c650..5f1aae9c455 100644 --- a/src/DynamoCoreWpf/Properties/Resources.resx +++ b/src/DynamoCoreWpf/Properties/Resources.resx @@ -3921,12 +3921,6 @@ In certain complex graphs or host program scenarios, Automatic mode may cause in The workspace cannot be exported as an image because it contains nodes that are too far away from each other. - - The Background 3D Preview could not be exported as an image. Try reducing the amount of geometry in the graph or closing other applications to free memory. - - - The Background 3D Preview cannot be exported because it is not active. - Include Timestamp in Export Image Path diff --git a/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs b/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs index 1b1c3e2bff8..62b7df2a1d8 100644 --- a/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs +++ b/src/DynamoCoreWpf/Views/Core/DynamoView.xaml.cs @@ -1896,8 +1896,6 @@ private void DynamoViewModelRequestSave3DImage(object sender, ImageSaveEventArgs if (!dynamoViewModel.BackgroundPreviewViewModel.Active) { e.Success = false; - dynamoViewModel.ToastManager?.CreateRealTimeInfoWindow( - Res.CantExportBackground3DPreviewInactiveMessage, true); return; } @@ -1909,15 +1907,11 @@ private void DynamoViewModelRequestSave3DImage(object sender, ImageSaveEventArgs { e.Success = false; Log(ex.ToString()); - dynamoViewModel.ToastManager?.CreateRealTimeInfoWindow( - Res.CantExportBackground3DPreviewMessage, true); } catch (Exception ex) { e.Success = false; Log(ex.ToString()); - dynamoViewModel.ToastManager?.CreateRealTimeInfoWindow( - Res.CantExportBackground3DPreviewMessage, true); } } From e85def1b639b673883c4db630d91cc80836931d1 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 15 Jun 2026 12:45:47 -0600 Subject: [PATCH 4/5] Bypass workspace validation for background export Route explicit Background 3D Preview image exports directly to the save flow so large workspace bounds do not trigger the nodes-too-far-apart warning. Keep workspace validation in place for workspace image exports. --- src/DynamoCoreWpf/ViewModels/Core/DynamoViewModel.cs | 7 +++++++ 1 file changed, 7 insertions(+) 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); } From 38bb2d561f5bee2e814a07632f29fe5576f2985b Mon Sep 17 00:00:00 2001 From: John Date: Tue, 16 Jun 2026 09:26:30 -0600 Subject: [PATCH 5/5] Fix blank background preview exports Capture the current 3D preview before applying the export size cap so Helix does not render from a just-resized, empty back buffer on large graph exports. Add a regression test that forces scaled background preview export and verifies the PNG contains varied image data instead of only checking that a file was written. --- .../Utilities/Watch3DImageExporter.cs | 48 +++++--------- .../BackgroundPreviewExportTests.cs | 63 +++++++++++++++++++ 2 files changed, 79 insertions(+), 32 deletions(-) diff --git a/src/DynamoCoreWpf/Utilities/Watch3DImageExporter.cs b/src/DynamoCoreWpf/Utilities/Watch3DImageExporter.cs index f5ff1f6d287..d3a0f1c6909 100644 --- a/src/DynamoCoreWpf/Utilities/Watch3DImageExporter.cs +++ b/src/DynamoCoreWpf/Utilities/Watch3DImageExporter.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Windows.Media; using System.Windows.Media.Imaging; using HelixToolkit.Wpf.SharpDX; @@ -88,45 +89,28 @@ internal static BitmapSource RenderViewportBitmap(Viewport3DX view, int maxExpor view.InvalidateRender(); - var originalWidth = view.RenderHost.ActualWidth; - var originalHeight = view.RenderHost.ActualHeight; + var bitmap = view.RenderBitmap(); + return ScaleBitmapSourceToMaxDimension(bitmap, maxExportDimension); + } - if (originalWidth <= 0 || originalHeight <= 0) + private static BitmapSource ScaleBitmapSourceToMaxDimension(BitmapSource bitmapSource, int maxExportDimension) + { + if (bitmapSource == null) throw new ArgumentNullException(nameof(bitmapSource)); + if (maxExportDimension <= 0) { - return view.RenderBitmap(); + return bitmapSource; } - var dpiScale = view.RenderHost.DpiScale; - var pixelWidth = (int)(originalWidth * dpiScale); - var pixelHeight = (int)(originalHeight * dpiScale); - var resizeApplied = false; - var captureWidth = (int)originalWidth; - var captureHeight = (int)originalHeight; - - if (maxExportDimension > 0) + var maxPixelDimension = Math.Max(bitmapSource.PixelWidth, bitmapSource.PixelHeight); + if (maxPixelDimension <= maxExportDimension) { - var maxPixelDimension = Math.Max(pixelWidth, pixelHeight); - if (maxPixelDimension > maxExportDimension) - { - var scale = maxExportDimension / (double)maxPixelDimension; - captureWidth = Math.Max(1, (int)(originalWidth * scale)); - captureHeight = Math.Max(1, (int)(originalHeight * scale)); - view.RenderHost.Resize(captureWidth, captureHeight); - resizeApplied = true; - } + return bitmapSource; } - try - { - return view.RenderBitmap(); - } - finally - { - if (resizeApplied) - { - view.RenderHost.Resize((int)originalWidth, (int)originalHeight); - } - } + var scale = maxExportDimension / (double)maxPixelDimension; + var transformedBitmap = new TransformedBitmap(bitmapSource, new ScaleTransform(scale, scale)); + transformedBitmap.Freeze(); + return transformedBitmap; } /// diff --git a/test/VisualizationTests/BackgroundPreviewExportTests.cs b/test/VisualizationTests/BackgroundPreviewExportTests.cs index 5176db94d05..eb8e5139c2e 100644 --- a/test/VisualizationTests/BackgroundPreviewExportTests.cs +++ b/test/VisualizationTests/BackgroundPreviewExportTests.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using System.Windows.Media; +using System.Windows.Media.Imaging; using Dynamo.ViewModels; using Dynamo.Wpf.Utilities; using DynamoCoreWpfTests.Utility; @@ -42,6 +44,38 @@ public void WhenBackgroundPreviewIsRenderedThenExportCreatesPngFile() } } + [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() { @@ -54,5 +88,34 @@ public void WhenBackgroundPreviewIsInactiveThenExportIsRejected() 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; + } + } } }