1
0
mirror of https://github.com/fadden/6502bench.git synced 2024-11-29 10:50:28 +00:00
6502bench/SourceGen/Visualization.cs
Andy McFadden c47beffcee Add Export feature to visualization editor
It's nice to be able to save images from the visualization editor
for display elsewhere.  This can be done during HTML export, but
that's inconvenient when you just want one image, and doesn't allow
the output size to be specified.

This change adds an Export button to the Edit Visualization dialog.
The current bitmap, wireframe, or wireframe animation can be saved
to a GIF image.  A handful of sizes can be selected from a pop-up
menu.
2020-06-20 17:32:57 -07:00

538 lines
24 KiB
C#

/*
* Copyright 2019 faddenSoft
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using CommonUtil;
using PluginCommon;
namespace SourceGen {
/// <summary>
/// Graphical visualization object. Useful for displaying 2D bitmaps and 3D objects.
///
/// This is generally immutable, except for the CachedImage field.
/// </summary>
/// <remarks>
/// Immutability is useful here because the undo/redo mechanism operates at VisualizationSet
/// granularity. We want to know that the undo/redo operations are operating on objects
/// that weren't changed while sitting in the undo buffer.
///
/// At a basic level, bitmap and wireframe visualizations are the same: you take a
/// visualization generation identifier and a bunch of parameters, and generate Stuff.
/// The nature of the Stuff and what you do with it after are very different, however.
///
/// For a bitmap, we can generate the data once, and then scale or transform it as
/// necessary. Bitmap animations are a collection of bitmap visualizations.
///
/// For wireframes, we generate a WireframeObject using some of the parameters, and then
/// transform it with other parameters. The parameters are stored in a single dictionary,
/// but viewer-only parameters are prefixed with '_', which is not allowed in plugins.
///
/// This class represents the common ground between bitmaps and wireframes. It holds the
/// identifier and parameters, as well as the thumbnail data that we display in the list.
/// </remarks>
public class Visualization {
public const double THUMBNAIL_DIM = 64;
/// <summary>
/// Unique user-specified tag. This may be any valid string that is at least two
/// characters long after the leading and trailing whitespace have been trimmed.
/// </summary>
public string Tag { get; private set; }
/// <summary>
/// Name of visualization generator (extension script function).
/// </summary>
public string VisGenIdent { get; private set; }
/// <summary>
/// Parameters to be passed to the visualization generator.
/// </summary>
/// <remarks>
/// We use a read-only dictionary to reinforce the idea that the plugin shouldn't be
/// modifying the parameter dictionary.
/// </remarks>
public ReadOnlyDictionary<string, object> VisGenParams { get; private set; }
/// <summary>
/// Cached reference to 2D image, useful for thumbnails that we display in the
/// code listing. Not serialized. This always has an image reference; in times
/// of trouble it will point at BROKEN_IMAGE.
/// </summary>
/// <remarks>
/// Because the underlying data never changes, we only need to regenerate the
/// image if the set of active plugins changes.
///
/// For 2D bitmaps this should be close to a 1:1 representation of the original,
/// subject to the limitations of the visualization generator. For other types of
/// data (vector line art, 3D meshes) this is a "snapshot" to help the user identify
/// the data.
/// </remarks>
public BitmapSource CachedImage { get; set; }
/// <summary>
/// Image overlaid on CachedImage. Used to identify thumbnails as animations.
/// </summary>
public BitmapSource OverlayImage { get; set; }
/// <summary>
/// True if CachedImage has something other than the default value.
/// </summary>
public bool HasImage {
get {
return CachedImage != BROKEN_IMAGE && CachedImage != ANIM_OVERLAY_IMAGE;
}
}
/// <summary>
/// Image to show when things are broken.
/// </summary>
public static readonly BitmapImage BROKEN_IMAGE;
static Visualization() {
BROKEN_IMAGE = new BitmapImage(new Uri("pack://application:,,,/Res/RedX.png"));
BROKEN_IMAGE.Freeze();
}
/// <summary>
/// Image to overlay on animation visualizations.
/// </summary>
internal static readonly BitmapSource ANIM_OVERLAY_IMAGE = GenerateAnimOverlayImage();
internal static readonly BitmapSource BLANK_IMAGE = GenerateBlankImage();
//internal static readonly BitmapSource BLACK_IMAGE = GenerateBlackImage();
/// <summary>
/// Serial number, for reference from other Visualization objects. Not serialized.
/// </summary>
/// <remarks>
/// This value is only valid in the current session. It exists because animations
/// need to refer to other Visualization objects, and doing so by Tag gets sticky
/// if a Tag gets renamed. We need a way to uniquely identify a reference to a
/// Visualization that persists across Tag renames and other edits. When the objects
/// are serialized to the project file we don't include the serial, and just reference
/// by Tag.
/// </remarks>
public int SerialNumber { get; private set; }
/// <summary>
/// Serial number source.
/// </summary>
private static int sNextSerial = 1000;
/// <summary>
/// Constructor for a new Visualization.
/// </summary>
/// <param name="tag">Unique identifier.</param>
/// <param name="visGenIdent">Visualization generator identifier.</param>
/// <param name="visGenParams">Parameters for visualization generator.</param>
public Visualization(string tag, string visGenIdent,
ReadOnlyDictionary<string, object> visGenParams)
:this(tag, visGenIdent, visGenParams, null) { }
/// <summary>
/// Constructor for a replacement Visualization.
/// </summary>
/// <param name="tag">Unique identifier.</param>
/// <param name="visGenIdent">Visualization generator identifier.</param>
/// <param name="visGenParams">Parameters for visualization generator.</param>
/// <param name="oldObj">Visualization being replaced, or null if this is new.</param>
public Visualization(string tag, string visGenIdent,
ReadOnlyDictionary<string, object> visGenParams, Visualization oldObj) {
Debug.Assert(!string.IsNullOrEmpty(tag));
Debug.Assert(!string.IsNullOrEmpty(visGenIdent));
Debug.Assert(visGenParams != null);
Tag = tag;
VisGenIdent = visGenIdent;
VisGenParams = visGenParams;
CachedImage = BROKEN_IMAGE;
OverlayImage = BLANK_IMAGE;
if (oldObj == null) {
// not worried about multiple threads
SerialNumber = sNextSerial++;
} else {
Debug.Assert(oldObj.SerialNumber >= 0 && oldObj.SerialNumber < sNextSerial);
SerialNumber = oldObj.SerialNumber;
}
//Debug.WriteLine("NEW VIS: Serial=" + SerialNumber);
}
/// <summary>
/// Updates the cached thumbnail image.
/// </summary>
/// <param name="vis2d">Visualization, or null to clear the thumbnail.</param>
public void SetThumbnail(IVisualization2d vis2d) {
if (vis2d == null) {
CachedImage = BROKEN_IMAGE;
} else {
CachedImage = ConvertToBitmapSource(vis2d);
}
Debug.Assert(CachedImage.IsFrozen);
}
/// <summary>
/// Updates the cached thumbnail image.
/// </summary>
/// <param name="visWire">Visualization object, or null to clear the thumbnail.</param>
/// <param name="parms">Visualization parameters.</param>
public virtual void SetThumbnail(IVisualizationWireframe visWire,
ReadOnlyDictionary<string, object> parms) {
if (visWire == null) {
CachedImage = BROKEN_IMAGE;
} else {
Debug.Assert(parms != null);
WireframeObject wireObj = WireframeObject.Create(visWire);
if (wireObj != null) {
CachedImage = GenerateWireframeImage(wireObj, THUMBNAIL_DIM, parms);
} else {
CachedImage = BROKEN_IMAGE;
}
}
Debug.Assert(CachedImage.IsFrozen);
}
/// <summary>
/// Trims a tag, removing leading/trailing whitespace, and checks it for validity.
/// </summary>
/// <param name="tag">Tag to trim and validate.</param>
/// <param name="isValid">Set to true if the tag is valid.</param>
/// <returns>Trimmed tag string. Returns an empty string if tag is null.</returns>
public static string TrimAndValidateTag(string tag, out bool isValid) {
if (tag == null) {
isValid = false;
return string.Empty;
}
string trimTag = tag.Trim();
if (trimTag.Length < 2) {
isValid = false;
} else {
isValid = true;
}
return trimTag;
}
/// <summary>
/// Converts an IVisualization2d to a BitmapSource for display. The bitmap will be
/// the same size as the original content.
/// </summary>
public static BitmapSource ConvertToBitmapSource(IVisualization2d vis2d) {
// Create indexed color palette.
int[] intPal = vis2d.GetPalette();
List<Color> colors = new List<Color>(intPal.Length);
foreach (int argb in intPal) {
Color col = Color.FromArgb((byte)(argb >> 24), (byte)(argb >> 16),
(byte)(argb >> 8), (byte)argb);
colors.Add(col);
}
BitmapPalette palette = new BitmapPalette(colors);
// indexed-color; see https://stackoverflow.com/a/15272528/294248 for direct color
BitmapSource image = BitmapSource.Create(
vis2d.Width,
vis2d.Height,
96.0,
96.0,
PixelFormats.Indexed8,
palette,
vis2d.GetPixels(),
vis2d.Width);
image.Freeze();
return image;
}
/// <summary>
/// Generates a BitmapSource from IVisualizationWireframe data. Useful for thumbnails
/// and GIF exports.
/// </summary>
/// <param name="visWire">Visualization data.</param>
/// <param name="dim">Output bitmap dimension (width and height).</param>
/// <param name="parms">Parameter set, for rotations and render options.</param>
/// <returns>Rendered bitmap.</returns>
public static BitmapSource GenerateWireframeImage(WireframeObject wireObj,
double dim, ReadOnlyDictionary<string, object> parms) {
int eulerX = Util.GetFromObjDict(parms, VisWireframeAnimation.P_EULER_ROT_X, 0);
int eulerY = Util.GetFromObjDict(parms, VisWireframeAnimation.P_EULER_ROT_Y, 0);
int eulerZ = Util.GetFromObjDict(parms, VisWireframeAnimation.P_EULER_ROT_Z, 0);
bool doPersp = Util.GetFromObjDict(parms, VisWireframe.P_IS_PERSPECTIVE, true);
bool doBfc = Util.GetFromObjDict(parms, VisWireframe.P_IS_BFC_ENABLED, false);
bool doRecenter = Util.GetFromObjDict(parms, VisWireframe.P_IS_RECENTERED, true);
return GenerateWireframeImage(wireObj, dim, eulerX, eulerY, eulerZ, doPersp, doBfc,
doRecenter);
}
/// <summary>
/// Generates a BitmapSource from IVisualizationWireframe data. Useful for thumbnails
/// and GIF exports.
/// </summary>
public static BitmapSource GenerateWireframeImage(WireframeObject wireObj,
double dim, int eulerX, int eulerY, int eulerZ, bool doPersp, bool doBfc,
bool doRecenter) {
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, doRecenter);
// Render geometry to bitmap -- https://stackoverflow.com/a/869767/294248
Rect bounds = geo.GetRenderBounds(null);
//Debug.WriteLine("RenderWF dim=" + dim + " bounds=" + bounds + ": " + wireObj);
// Create bitmap.
RenderTargetBitmap bitmap = new RenderTargetBitmap(
(int)dim,
(int)dim,
96,
96,
PixelFormats.Pbgra32);
//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(null, 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();
bkgnd.Children.Add(new RectangleGeometry(new Rect(0, 0, bounds.Width, bounds.Height)));
Path path = new Path();
path.Data = bkgnd;
path.Stroke = path.Fill = Brushes.Black;
path.Measure(bounds.Size);
path.Arrange(bounds);
bitmap.Render(path);
path = new Path();
path.Data = geo;
path.Stroke = Brushes.White;
path.Measure(bounds.Size);
path.Arrange(bounds);
bitmap.Render(path);
#endif
bitmap.Freeze();
return bitmap;
}
/// <summary>
/// Generates WPF Path geometry from IVisualizationWireframe data. Line widths get
/// scaled if the output area is larger or smaller than the path bounds, so this scales
/// coordinates so they fit within the box.
/// </summary>
/// <param name="visWire">Visualization data.</param>
/// <param name="dim">Width/height to use for path area.</param>
/// <param name="parms">Visualization parameters.</param>
public static GeometryGroup GenerateWireframePath(WireframeObject wireObj,
double dim, ReadOnlyDictionary<string, object> parms) {
int eulerX = Util.GetFromObjDict(parms, VisWireframeAnimation.P_EULER_ROT_X, 0);
int eulerY = Util.GetFromObjDict(parms, VisWireframeAnimation.P_EULER_ROT_Y, 0);
int eulerZ = Util.GetFromObjDict(parms, VisWireframeAnimation.P_EULER_ROT_Z, 0);
bool doPersp = Util.GetFromObjDict(parms, VisWireframe.P_IS_PERSPECTIVE, true);
bool doBfc = Util.GetFromObjDict(parms, VisWireframe.P_IS_BFC_ENABLED, false);
bool doRecenter = Util.GetFromObjDict(parms, VisWireframe.P_IS_RECENTERED, true);
return GenerateWireframePath(wireObj, dim, eulerX, eulerY, eulerZ, doPersp, doBfc,
doRecenter);
}
/// <summary>
/// Generates WPF Path geometry from IVisualizationWireframe data. Line widths get
/// scaled if the output area is larger or smaller than the path bounds, so this scales
/// coordinates so they fit within the box.
/// </summary>
public static GeometryGroup GenerateWireframePath(WireframeObject wireObj,
double dim, int eulerX, int eulerY, int eulerZ, bool doPersp, bool doBfc,
bool doRecenter) {
// WPF path drawing is based on a system where a pixel is drawn at the center
// of its coordinates, and integer coordinates start at the top left edge of
// the drawing area. If you draw a pixel at (0,0), 3/4ths of the pixel will be
// outside the window (visible or not based on ClipToBounds).
//
// If you draw a line from (1,1 to 4,1), the line's length will appear to
// be (4 - 1) = 3. It touches four pixels -- the end point is not exclusive --
// but the filled area is only three, because the thickness doesn't extend the
// line's length, and the line stops at the coordinate at the center of the pixel.
// You're not drawing N pixels, you're drawing from one coordinate point to another.
// If you have a window of size 8x8, and you draw from 0,0 to 7,0, the line will
// extend for half a line-thickness off the top, but will not go past the right/left
// edges. (This becomes very obvious when you're working with an up-scaled 8x8 path.)
//
// Similarly, drawing a horizontal line two units long results in a square, and
// drawing a line that starts and ends at the same point doesn't appear to
// produce anything.
//
// It's possible to clean up the edges by adding 0.5 to all coordinate values.
// This turns out to be important for another reason: a line from (1,1) to (9,1)
// shows up as a double-wide half-bright line, while a line from (1.5,1.5) to
// (9.5,1.5) is drawn as a single-wide full-brightness line. This is because of
// the anti-aliasing. Anti-aliasing can be disabled, but the lines look much
// nicer with it enabled.
//
// The path has an axis-aligned bounding box that covers the pixel centers. If we
// want a path-drawn mesh to animate smoothly we want to ensure that the bounds
// are constant across all renderings of a shape (which could get thinner or wider
// as it rotates), so we plot an invisible point in our desired bottom-right corner.
//
// If we want an 8x8 bitmap, we draw a line from (8,8) to (8,8) to establish the
// bounds, then draw lines with coordinates from 0.5 to 7.5.
GeometryGroup geo = new GeometryGroup();
// Draw invisible line segments to establish Path bounds.
Point topLeft = new Point(0, 0);
Point botRight = new Point(dim, dim);
geo.Children.Add(new LineGeometry(topLeft, topLeft));
geo.Children.Add(new LineGeometry(botRight, botRight));
// Generate a list of clip-space line segments. Coordinate values are in the
// range [-1,1], with +X to the right and +Y upward.
List<WireframeObject.LineSeg> segs = wireObj.Generate(eulerX, eulerY, eulerZ,
doPersp, doBfc, doRecenter);
// Convert clip-space coords to screen. We need to translate to [0,2] with +Y
// toward the bottom of the screen, scale up, round to the nearest whole pixel,
// and add +0.5 to make thumbnail-size bitmaps look crisp.
double scale = (dim - 0.5) / 2;
double adj = 0.5;
foreach (WireframeObject.LineSeg seg in segs) {
Point start = new Point(Math.Round((seg.X0 + 1) * scale) + adj,
Math.Round((1 - seg.Y0) * scale) + adj);
Point end = new Point(Math.Round((seg.X1 + 1) * scale) + adj,
Math.Round((1 - seg.Y1) * scale) + adj);
geo.Children.Add(new LineGeometry(start, end));
}
return geo;
}
/// <summary>
/// Returns a bitmap with a single transparent pixel.
/// </summary>
private static BitmapSource GenerateBlankImage() {
RenderTargetBitmap bmp = new RenderTargetBitmap(1, 1, 96.0, 96.0,
PixelFormats.Pbgra32);
bmp.Freeze();
return bmp;
}
/// <summary>
/// Generate an image to overlay on thumbnails of animations.
/// </summary>
/// <returns></returns>
private static BitmapSource GenerateAnimOverlayImage() {
const int IMAGE_SIZE = 128;
// Glowy "high tech" blue.
SolidColorBrush outlineBrush = new SolidColorBrush(Color.FromArgb(255, 0, 216, 255));
SolidColorBrush fillBrush = new SolidColorBrush(Color.FromArgb(128, 0, 182, 215));
DrawingVisual visual = new DrawingVisual();
using (DrawingContext dc = visual.RenderOpen()) {
// Thanks: https://stackoverflow.com/a/29249100/294248
Point p1 = new Point(IMAGE_SIZE * 5 / 8, IMAGE_SIZE / 2);
Point p2 = new Point(IMAGE_SIZE * 3 / 8, IMAGE_SIZE / 4);
Point p3 = new Point(IMAGE_SIZE * 3 / 8, IMAGE_SIZE * 3 / 4);
StreamGeometry sg = new StreamGeometry();
using (StreamGeometryContext sgc = sg.Open()) {
sgc.BeginFigure(p1, true, true);
PointCollection points = new PointCollection() { p2, p3 };
sgc.PolyLineTo(points, true, true);
}
sg.Freeze();
dc.DrawGeometry(fillBrush, new Pen(outlineBrush, 3), sg);
}
RenderTargetBitmap bmp = new RenderTargetBitmap(IMAGE_SIZE, IMAGE_SIZE, 96.0, 96.0,
PixelFormats.Pbgra32);
bmp.Render(visual);
bmp.Freeze();
return bmp;
}
/// <summary>
/// Returns a bitmap with a single black pixel.
/// </summary>
//private static BitmapSource GenerateBlackImage() {
// BitmapPalette palette = new BitmapPalette(new List<Color> { Colors.Black });
// BitmapSource image = BitmapSource.Create(
// 1,
// 1,
// 96.0,
// 96.0,
// PixelFormats.Indexed8,
// palette,
// new byte[] { 0 },
// 1);
// return image;
//}
public override string ToString() {
return "[Vis: " + Tag + " (" + VisGenIdent + ") count=" + VisGenParams.Count + "]";
}
public static bool operator ==(Visualization a, Visualization b) {
if (ReferenceEquals(a, b)) {
return true; // same object, or both null
}
if (ReferenceEquals(a, null) || ReferenceEquals(b, null)) {
return false; // one is null
}
// All fields must be equal (but we ignore CachedImage).
if (a.Tag != b.Tag || a.VisGenIdent != b.VisGenIdent) {
return false;
}
// Compare the vis gen parameter lists.
if (a.VisGenParams == b.VisGenParams) {
return true;
}
if (a.VisGenParams.Count != b.VisGenParams.Count) {
return false;
}
return Container.CompareDicts(a.VisGenParams, b.VisGenParams);
}
public static bool operator !=(Visualization a, Visualization b) {
return !(a == b);
}
public override bool Equals(object obj) {
return obj is Visualization && this == (Visualization)obj;
}
public override int GetHashCode() {
// TODO(maybe): hash code should factor in VisGenParams items
return Tag.GetHashCode() ^ VisGenIdent.GetHashCode() ^ VisGenParams.Count;
}
}
}