From 1da98d8628245656bfe65ddab33c7b35033b525f Mon Sep 17 00:00:00 2001 From: Andy McFadden Date: Sun, 15 Mar 2020 11:49:11 -0700 Subject: [PATCH] Add a progress bar to HTML export Generation of HTML is extremely fast, but compressing thousands of frames for wireframe animated GIFs can take a little while. Sharing bitmaps between threads required two changes: (1) bitmaps need to be "frozen" after being drawn; (2) you can't use Path because BackgroundWorker isn't a STAThread. You can, however, use a DrawingVisual / DrawingContext to do the rendering. Which is really what I should have been doing all along; I just didn't know the approach existed until I was forced to go looking for it. Also, we now do a "run finalizers" call before generating an animated GIF. Without it things explode after more than 10K GDI objects have been allocated. --- CommonWPF/WorkProgress.xaml.cs | 5 +- SourceGen/AsmGen/WpfGui/GenAndAsm.xaml.cs | 2 +- SourceGen/Exporter.cs | 71 +++++++++++++++++++++-- SourceGen/MainController.cs | 20 +++---- SourceGen/Res/Strings.xaml | 2 + SourceGen/Res/Strings.xaml.cs | 4 ++ SourceGen/VisWireframeAnimation.cs | 21 ++++++- SourceGen/Visualization.cs | 48 ++++++++++++--- SourceGen/VisualizationSet.cs | 1 + 9 files changed, 146 insertions(+), 28 deletions(-) diff --git a/CommonWPF/WorkProgress.xaml.cs b/CommonWPF/WorkProgress.xaml.cs index 5dd4fbd..8f0a274 100644 --- a/CommonWPF/WorkProgress.xaml.cs +++ b/CommonWPF/WorkProgress.xaml.cs @@ -107,7 +107,10 @@ namespace CommonWPF { int percent = e.ProgressPercentage; string msg = e.UserState as string; - Debug.Assert(percent >= 0 && percent <= 100); + if (percent < 0 || percent > 100) { + Debug.WriteLine("WorkProgress: bad percent " + percent); + percent = 0; + } if (!string.IsNullOrEmpty(msg)) { messageText.Text = msg; diff --git a/SourceGen/AsmGen/WpfGui/GenAndAsm.xaml.cs b/SourceGen/AsmGen/WpfGui/GenAndAsm.xaml.cs index 814be39..7cfbd5d 100644 --- a/SourceGen/AsmGen/WpfGui/GenAndAsm.xaml.cs +++ b/SourceGen/AsmGen/WpfGui/GenAndAsm.xaml.cs @@ -318,7 +318,7 @@ namespace SourceGen.AsmGen.WpfGui { } private class AsmWorker : WorkProgress.IWorker { - IAssembler mAssembler; + private IAssembler mAssembler; public AssemblerResults Results { get; private set; } public AsmWorker(IAssembler asm) { diff --git a/SourceGen/Exporter.cs b/SourceGen/Exporter.cs index a3eeaf3..d8ce1ce 100644 --- a/SourceGen/Exporter.cs +++ b/SourceGen/Exporter.cs @@ -15,6 +15,7 @@ */ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Text; @@ -446,13 +447,45 @@ namespace SourceGen { private const string HTML_EXPORT_CSS_FILE = "SGStyle.css"; private const string LABEL_LINK_PREFIX = "Sym"; + private class ExportWorker : WorkProgress.IWorker { + private Exporter mExporter; + private string mPathName; + private bool mOverwriteCss; + + public bool Success { get; private set; } + + public ExportWorker(Exporter exp, string pathName, bool overwriteCss) { + mExporter = exp; + mPathName = pathName; + mOverwriteCss = overwriteCss; + } + public object DoWork(BackgroundWorker worker) { + return mExporter.OutputToHtml(worker, mPathName, mOverwriteCss); + } + public void RunWorkerCompleted(object results) { + if (results != null) { + Success = (bool)results; + } + } + } + /// /// Generates HTML output to the specified path. /// /// Full pathname of output file (including ".html"). This /// defines the root directory if there are additional files. /// If set, existing CSS file will be replaced. - public void OutputToHtml(string pathName, bool overwriteCss) { + public void OutputToHtml(Window parent, string pathName, bool overwriteCss) { + ExportWorker ew = new ExportWorker(this, pathName, overwriteCss); + WorkProgress dlg = new WorkProgress(parent, ew, false); + if (dlg.ShowDialog() != true) { + Debug.WriteLine("Export unsuccessful"); + } else { + Debug.WriteLine("Export complete"); + } + } + + private bool OutputToHtml(BackgroundWorker worker, string pathName, bool overwriteCss) { string exportTemplate = RuntimeDataAccess.GetPathName(HTML_EXPORT_TEMPLATE); string tmplStr; try { @@ -463,7 +496,7 @@ namespace SourceGen { pathName, ex.Message); MessageBox.Show(msg, Res.Strings.ERR_FILE_GENERIC_CAPTION, MessageBoxButton.OK, MessageBoxImage.Error); - return; + return false; } // We should only need the _IMG directory if there are visualizations. @@ -478,7 +511,7 @@ namespace SourceGen { imageDirPath); MessageBox.Show(msg, Res.Strings.ERR_FILE_GENERIC_CAPTION, MessageBoxButton.OK, MessageBoxImage.Error); - return; + return false; } exists = true; } catch (FileNotFoundException) { @@ -493,7 +526,7 @@ namespace SourceGen { imageDirPath, ex.Message); MessageBox.Show(msg, Res.Strings.ERR_FILE_GENERIC_CAPTION, MessageBoxButton.OK, MessageBoxImage.Error); - return; + return false; } } @@ -501,6 +534,13 @@ namespace SourceGen { mImageDirPath = imageDirPath; } + if (mImageDirPath == null) { + worker.ReportProgress(0, Res.Strings.EXPORTING_HTML); + } else { + worker.ReportProgress(0, Res.Strings.EXPORTING_HTML_AND_IMAGES); + } + + // Perform some quick substitutions. This could be done more efficiently, // but we're only doing this on the template file, which should be small. tmplStr = tmplStr.Replace("$ProjectName$", mProject.DataFileName); @@ -527,11 +567,13 @@ namespace SourceGen { int splitPoint = tmplStr.IndexOf(CodeLinesStr); if (splitPoint < 0) { Debug.WriteLine("No place to put code"); - return; + return false; } string template1 = tmplStr.Substring(0, splitPoint); string template2 = tmplStr.Substring(splitPoint + CodeLinesStr.Length); + int lastProgressPerc = 0; + // Generate UTF-8 text, without a byte-order mark. using (StreamWriter sw = new StreamWriter(pathName, false, new UTF8Encoding(false))) { sw.Write(template1); @@ -545,12 +587,27 @@ namespace SourceGen { } GenerateHtmlLine(lineIndex, sw, sb); + + if (worker.CancellationPending) { + break; + } + int perc = (lineIndex * 100) / mCodeLineList.Count; + if (perc != lastProgressPerc) { + lastProgressPerc = perc; + worker.ReportProgress(perc); + } } sw.WriteLine("\r\n"); sw.Write(template2); } + if (worker.CancellationPending) { + Debug.WriteLine("Cancel requested, deleting " + pathName); + File.Delete(pathName); + return false; + } + string cssFile = RuntimeDataAccess.GetPathName(HTML_EXPORT_CSS_FILE); string outputDir = Path.GetDirectoryName(pathName); string outputPath = Path.Combine(outputDir, HTML_EXPORT_CSS_FILE); @@ -563,9 +620,11 @@ namespace SourceGen { cssFile, outputPath, ex.Message); MessageBox.Show(msg, Res.Strings.ERR_FILE_GENERIC_CAPTION, MessageBoxButton.OK, MessageBoxImage.Error); - return; + return false; } } + + return true; } /// diff --git a/SourceGen/MainController.cs b/SourceGen/MainController.cs index 3897a76..cff8c0a 100644 --- a/SourceGen/MainController.cs +++ b/SourceGen/MainController.cs @@ -2345,18 +2345,18 @@ namespace SourceGen { eport.Selection = selection; } - // This is generally fast enough that I don't feel the need to create a - // progress window. - try { - Mouse.OverrideCursor = Cursors.Wait; - - if (dlg.GenType == WpfGui.Export.GenerateFileType.Html) { - eport.OutputToHtml(dlg.PathName, dlg.OverwriteCss); - } else { + if (dlg.GenType == WpfGui.Export.GenerateFileType.Html) { + // Generating wireframe animations can be slow, so we need to use a + // progress dialog. + eport.OutputToHtml(mMainWin, dlg.PathName, dlg.OverwriteCss); + } else { + // Text output is generally very fast. Put up a wait cursor just in case. + try { + Mouse.OverrideCursor = Cursors.Wait; eport.OutputToText(dlg.PathName, dlg.TextModeCsv); + } finally { + Mouse.OverrideCursor = null; } - } finally { - Mouse.OverrideCursor = null; } } diff --git a/SourceGen/Res/Strings.xaml b/SourceGen/Res/Strings.xaml index c0d3718..28b4498 100644 --- a/SourceGen/Res/Strings.xaml +++ b/SourceGen/Res/Strings.xaml @@ -81,6 +81,8 @@ limitations under the License. Unable to save project file [File was too large for preview window] Symbol value is incompatible with current multi-mask + Exporting HTML... + Exporting HTML and images... Symbol files and extension scripts must live in the application runtime directory ({0}) or project directory ({1}). File {2} lives elsewhere. File Not In Runtime Directory All files (*.*)|*.* diff --git a/SourceGen/Res/Strings.xaml.cs b/SourceGen/Res/Strings.xaml.cs index aa71c32..7ad3343 100644 --- a/SourceGen/Res/Strings.xaml.cs +++ b/SourceGen/Res/Strings.xaml.cs @@ -143,6 +143,10 @@ namespace SourceGen.Res { (string)Application.Current.FindResource("str_ErrTooLargeForPreview"); public static string ERR_VALUE_INCOMPATIBLE_WITH_MASK = (string)Application.Current.FindResource("str_ErrValueIncompatibleWithMask"); + public static string EXPORTING_HTML = + (string)Application.Current.FindResource("str_ExportingHtml"); + public static string EXPORTING_HTML_AND_IMAGES = + (string)Application.Current.FindResource("str_ExportingHtmlAndImages"); public static string EXTERNAL_FILE_BAD_DIR_FMT = (string)Application.Current.FindResource("str_ExternalFileBadDirFmt"); public static string EXTERNAL_FILE_BAD_DIR_CAPTION = diff --git a/SourceGen/VisWireframeAnimation.cs b/SourceGen/VisWireframeAnimation.cs index b6d0a8b..8b58bdf 100644 --- a/SourceGen/VisWireframeAnimation.cs +++ b/SourceGen/VisWireframeAnimation.cs @@ -79,7 +79,12 @@ namespace SourceGen { public override void SetThumbnail(IVisualizationWireframe visWire, ReadOnlyDictionary parms) { base.SetThumbnail(visWire, parms); - mWireObj = WireframeObject.Create(visWire); + if (visWire == null) { + // Thumbnail cache is being cleared. Throw out the wireframe object too. + mWireObj = null; + } else { + mWireObj = WireframeObject.Create(visWire); + } } /// @@ -99,6 +104,20 @@ namespace SourceGen { bool doPersp = Util.GetFromObjDict(VisGenParams, VisWireframe.P_IS_PERSPECTIVE, true); bool doBfc = Util.GetFromObjDict(VisGenParams, VisWireframe.P_IS_BFC_ENABLED, false); + // Try to avoid System.Runtime.InteropServices.COMException (0x88980003): + // MILERR_WIN32ERROR (Exception from HRESULT: 0x88980003) + // The problem seems to be that the bitmaps are GDI handles, there's a hard limit of + // 10,000, and they don't get released until finalizers run. If we wait for + // pending finalizers the pool stays at a manageable level. If we poke the GC + // every time the memory graph looks better but I suspect there's a performance hit. + //GC.Collect(); + GC.WaitForPendingFinalizers(); + + if (mWireObj == null) { + Debug.WriteLine("EncodeGif: wire obj is null"); + frameCount = 1; + } + for (int frame = 0; frame < frameCount; frame++) { BitmapSource bs = GenerateWireframeImage(mWireObj, dim, curX, curY, curZ, doPersp, doBfc); diff --git a/SourceGen/Visualization.cs b/SourceGen/Visualization.cs index 55c636f..7fa6a82 100644 --- a/SourceGen/Visualization.cs +++ b/SourceGen/Visualization.cs @@ -106,8 +106,11 @@ namespace SourceGen { /// /// Image to show when things are broken. /// - public static readonly BitmapImage BROKEN_IMAGE = - new BitmapImage(new Uri("pack://application:,,,/Res/RedX.png")); + public static readonly BitmapImage BROKEN_IMAGE; + static Visualization() { + BROKEN_IMAGE = new BitmapImage(new Uri("pack://application:,,,/Res/RedX.png")); + BROKEN_IMAGE.Freeze(); + } /// /// Image to overlay on animation visualizations. @@ -185,19 +188,24 @@ namespace SourceGen { } else { CachedImage = ConvertToBitmapSource(vis2d); } + Debug.Assert(CachedImage.IsFrozen); } /// /// Updates the cached thumbnail image. /// - /// Visualization object. + /// Visualization object, or null to clear the thumbnail. /// Visualization parameters. public virtual void SetThumbnail(IVisualizationWireframe visWire, ReadOnlyDictionary parms) { - Debug.Assert(visWire != null); - Debug.Assert(parms != null); - WireframeObject wireObj = WireframeObject.Create(visWire); - CachedImage = GenerateWireframeImage(wireObj, THUMBNAIL_DIM, parms); + if (visWire == null) { + CachedImage = BROKEN_IMAGE; + } else { + Debug.Assert(parms != null); + WireframeObject wireObj = WireframeObject.Create(visWire); + CachedImage = GenerateWireframeImage(wireObj, THUMBNAIL_DIM, parms); + } + Debug.Assert(CachedImage.IsFrozen); } /// @@ -246,6 +254,7 @@ namespace SourceGen { palette, vis2d.GetPixels(), vis2d.Width); + image.Freeze(); return image; } @@ -274,11 +283,17 @@ namespace SourceGen { /// public static BitmapSource GenerateWireframeImage(WireframeObject wireObj, double dim, int eulerX, int eulerY, int eulerZ, bool doPersp, bool doBfc) { + if (wireObj == null) { + // Can happen if the visualization generator is failing on stuff loaded from + // the project file. + return BROKEN_IMAGE; + } + // Generate the path geometry. GeometryGroup geo = GenerateWireframePath(wireObj, dim, eulerX, eulerY, eulerZ, doPersp, doBfc); - // Render Path to bitmap -- https://stackoverflow.com/a/23582564/294248 + // Render geometry to bitmap -- https://stackoverflow.com/a/869767/294248 Rect bounds = geo.GetRenderBounds(null); //Debug.WriteLine("RenderWF dim=" + dim + " bounds=" + bounds + ": " + wireObj); @@ -290,7 +305,18 @@ namespace SourceGen { 96, 96, PixelFormats.Pbgra32); - //RenderOptions.SetEdgeMode(bitmap, EdgeMode.Aliased); <-- doesn't work? + //RenderOptions.SetEdgeMode(bitmap, EdgeMode.Aliased); <-- no apparent effect + + DrawingVisual dv = new DrawingVisual(); + using (DrawingContext dc = dv.RenderOpen()) { + dc.DrawRectangle(Brushes.Black, null, new Rect(0, 0, bounds.Width, bounds.Height)); + Pen pen = new Pen(Brushes.White, 1.0); + dc.DrawGeometry(Brushes.White, pen, geo); + } + bitmap.Render(dv); + +#if false + // Old way: render Path to bitmap -- https://stackoverflow.com/a/23582564/294248 // Clear the bitmap to black. (Is there an easier way?) GeometryGroup bkgnd = new GeometryGroup(); @@ -308,7 +334,9 @@ namespace SourceGen { path.Measure(bounds.Size); path.Arrange(bounds); bitmap.Render(path); +#endif + bitmap.Freeze(); return bitmap; } @@ -405,6 +433,7 @@ namespace SourceGen { private static BitmapSource GenerateBlankImage() { RenderTargetBitmap bmp = new RenderTargetBitmap(1, 1, 96.0, 96.0, PixelFormats.Pbgra32); + bmp.Freeze(); return bmp; } @@ -438,6 +467,7 @@ namespace SourceGen { RenderTargetBitmap bmp = new RenderTargetBitmap(IMAGE_SIZE, IMAGE_SIZE, 96.0, 96.0, PixelFormats.Pbgra32); bmp.Render(visual); + bmp.Freeze(); return bmp; } diff --git a/SourceGen/VisualizationSet.cs b/SourceGen/VisualizationSet.cs index d7ad6fe..0a62f8e 100644 --- a/SourceGen/VisualizationSet.cs +++ b/SourceGen/VisualizationSet.cs @@ -167,6 +167,7 @@ namespace SourceGen { public void RefreshNeeded() { foreach (Visualization vis in mList) { vis.SetThumbnail(null); + vis.SetThumbnail(null, null); } }