Thanks to all who provided input on this last post, where I asked for guidance on how best to design the command-line interface for the Screenshot application, the proposed Plugin of the Month for November. The current code is based largely on this previous post, but may end up being extended - in time - to include better 3D support via the technique shown here.
Here are some design choices that came out of the comments on the last post:
- From Fred Dickinson
- A separate “Settings” option, allowing us to use a sub-menu for our application settings
- From MJohnston
- Display of the last used options when the command launches
- The option to change the background colour to white for the capture
- From Mark Dubbelaar
- Some nice design choices around command and options naming, as well as settings placement
- The option to select a group of objects for which to capture the extents
And an honourable mention goes to Chris Bray, Nikolay Poleschuk and James Meading, who made additional suggestions and suggested possible future directions. Thanks, all of you! So far so good with this crowdsourcing experiment. :-)
Here’s the current state of the C# code, reflecting the above input as best I could:
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Geometry;
using Autodesk.AutoCAD.GraphicsInterface;
using Autodesk.AutoCAD.GraphicsSystem;
using Autodesk.AutoCAD.Runtime;
using System.Drawing.Imaging;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Interop;
using System.Collections;
using System;
namespace ScreenshotTest
{
public class Commands
{
// Specify a key under which we want
// to store our custom data
const string myKey = "AdnPluginScreenshot";
// Define a class for our custom data
public class AppData
{
public bool clipboard;
public bool whitebackground;
public AppData()
{
clipboard = true;
whitebackground = false;
}
}
// A struct for communicating colours to/from AutoCAD
public struct AcColorSettings
{
public UInt32 dwGfxModelBkColor;
public UInt32 dwGfxLayoutBkColor;
public UInt32 dwParallelBkColor;
public UInt32 dwBEditBkColor;
public UInt32 dwCmdLineBkColor;
public UInt32 dwPlotPrevBkColor;
public UInt32 dwSkyGradientZenithColor;
public UInt32 dwSkyGradientHorizonColor;
public UInt32 dwGroundGradientOriginColor;
public UInt32 dwGroundGradientHorizonColor;
public UInt32 dwEarthGradientAzimuthColor;
public UInt32 dwEarthGradientHorizonColor;
public UInt32 dwModelCrossHairColor;
public UInt32 dwLayoutCrossHairColor;
public UInt32 dwParallelCrossHairColor;
public UInt32 dwPerspectiveCrossHairColor;
public UInt32 dwBEditCrossHairColor;
public UInt32 dwParallelGridMajorLines;
public UInt32 dwPerspectiveGridMajorLines;
public UInt32 dwParallelGridMinorLines;
public UInt32 dwPerspectiveGridMinorLines;
public UInt32 dwParallelGridAxisLines;
public UInt32 dwPerspectiveGridAxisLines;
public UInt32 dwTextForeColor;
public UInt32 dwTextBkColor;
public UInt32 dwCmdLineForeColor;
public UInt32 dwAutoTrackingVecColor;
public UInt32 dwLayoutATrackVecColor;
public UInt32 dwParallelATrackVecColor;
public UInt32 dwPerspectiveATrackVecColor;
public UInt32 dwBEditATrackVecColor;
public UInt32 dwModelASnapMarkerColor;
public UInt32 dwLayoutASnapMarkerColor;
public UInt32 dwParallelASnapMarkerColor;
public UInt32 dwPerspectiveASnapMarkerColor;
public UInt32 dwBEditASnapMarkerColor;
public UInt32 dwModelDftingTooltipColor;
public UInt32 dwLayoutDftingTooltipColor;
public UInt32 dwParallelDftingTooltipColor;
public UInt32 dwPerspectiveDftingTooltipColor;
public UInt32 dwBEditDftingTooltipColor;
public UInt32 dwModelDftingTooltipBkColor;
public UInt32 dwLayoutDftingTooltipBkColor;
public UInt32 dwParallelDftingTooltipBkColor;
public UInt32 dwPerspectiveDftingTooltipBkColor;
public UInt32 dwBEditDftingTooltipBkColor;
public UInt32 dwModelLightGlyphs;
public UInt32 dwLayoutLightGlyphs;
public UInt32 dwParallelLightGlyphs;
public UInt32 dwPerspectiveLightGlyphs;
public UInt32 dwBEditLightGlyphs;
public UInt32 dwModelLightHotspot;
public UInt32 dwLayoutLightHotspot;
public UInt32 dwParallelLightHotspot;
public UInt32 dwPerspectiveLightHotspot;
public UInt32 dwBEditLightHotspot;
public UInt32 dwModelLightFalloff;
public UInt32 dwLayoutLightFalloff;
public UInt32 dwParallelLightFalloff;
public UInt32 dwPerspectiveLightFalloff;
public UInt32 dwBEditLightFalloff;
public UInt32 dwModelLightStartLimit;
public UInt32 dwLayoutLightStartLimit;
public UInt32 dwParallelLightStartLimit;
public UInt32 dwPerspectiveLightStartLimit;
public UInt32 dwBEditLightStartLimit;
public UInt32 dwModelLightEndLimit;
public UInt32 dwLayoutLightEndLimit;
public UInt32 dwParallelLightEndLimit;
public UInt32 dwPerspectiveLightEndLimit;
public UInt32 dwBEditLightEndLimit;
public UInt32 dwModelCameraGlyphs;
public UInt32 dwLayoutCameraGlyphs;
public UInt32 dwParallelCameraGlyphs;
public UInt32 dwPerspectiveCameraGlyphs;
public UInt32 dwModelCameraFrustrum;
public UInt32 dwLayoutCameraFrustrum;
public UInt32 dwParallelCameraFrustrum;
public UInt32 dwPerspectiveCameraFrustrum;
public UInt32 dwModelCameraClipping;
public UInt32 dwLayoutCameraClipping;
public UInt32 dwParallelCameraClipping;
public UInt32 dwPerspectiveCameraClipping;
public int nModelCrosshairUseTintXYZ;
public int nLayoutCrosshairUseTintXYZ;
public int nParallelCrosshairUseTintXYZ;
public int nPerspectiveCrosshairUseTintXYZ;
public int nBEditCrossHairUseTintXYZ;
public int nModelATrackVecUseTintXYZ;
public int nLayoutATrackVecUseTintXYZ;
public int nParallelATrackVecUseTintXYZ;
public int nPerspectiveATrackVecUseTintXYZ;
public int nBEditATrackVecUseTintXYZ;
public int nModelDftingTooltipBkUseTintXYZ;
public int nLayoutDftingTooltipBkUseTintXYZ;
public int nParallelDftingTooltipBkUseTintXYZ;
public int nPerspectiveDftingTooltipBkUseTintXYZ;
public int nBEditDftingTooltipBkUseTintXYZ;
public int nParallelGridMajorLineTintXYZ;
public int nPerspectiveGridMajorLineTintXYZ;
public int nParallelGridMinorLineTintXYZ;
public int nPerspectiveGridMinorLineTintXYZ;
public int nParallelGridAxisLineTintXYZ;
public int nPerspectiveGridAxisLineTintXYZ;
};
// For the coordinate tranformation we need...
// A Win32 function:
[DllImport("user32.dll")]
static extern bool ClientToScreen(IntPtr hWnd, ref Point pt);
// And to access the colours in AutoCAD, we need ObjectARX...
[DllImport("acad.exe",
CallingConvention=CallingConvention.Cdecl,
EntryPoint="?acedGetCurrentColors@@YAHPAUAcColorSettings@@@Z"
/*
Here's the x64 version of the EntryPoint:
EntryPoint="?acedGetCurrentColors@@YAHPEAUAcColorSettings@@@Z"
*/
)]
static extern bool acedGetCurrentColors(
out AcColorSettings colorSettings
);
[DllImport("acad.exe",
CallingConvention=CallingConvention.Cdecl,
EntryPoint="?acedSetCurrentColors@@YAHPAUAcColorSettings@@@Z"
/*
Here's the x64 version of the EntryPoint:
EntryPoint="?acedSetCurrentColors@@YAHPEAUAcColorSettings@@@Z"
*/
)]
static extern bool acedSetCurrentColors(
ref AcColorSettings colorSettings
);
// Command to capture the main and active drawing windows
// or a user-selected portion of a drawing
[CommandMethod("SCREENSHOT")]
static public void CaptureScreenShot()
{
Document doc =
Application.DocumentManager.MdiActiveDocument;
Editor ed = doc.Editor;
Hashtable ud = doc.UserData;
AppData ad;
ad = ud[myKey] as AppData;
if (ad == null)
{
object obj = ud[myKey];
if (obj == null)
{
// MyData object not found - first time run
ad = new AppData();
ud.Add(myKey, ad);
}
else
{
// Found something different instead
ed.WriteMessage(
"Found an object of type \"" +
obj.GetType().ToString() +
"\" instead of MyData.");
}
}
if (ad != null)
{
string filename = "";
bool settingsChosen;
PromptPointResult ppr;
do
{
settingsChosen = false;
// Ask the user for the screen window to capture
PrintSettings(ed, ad);
PromptPointOptions ppo =
new PromptPointOptions(
"\nSelect first point of capture window or " +
"[Document/Application/Objects/Settings]: ",
"Document Application Objects Settings"
);
// Get the first point of the capture window,
// or a keyword
ppr = ed.GetPoint(ppo);
if (ppr.Status == PromptStatus.Keyword)
{
if (ppr.StringResult == "Document")
{
// Capture the active document window
if (!ad.clipboard)
filename = GetFileName(ed);
ScreenShotToFile(
Application.DocumentManager.
MdiActiveDocument.Window,
30, 26, 10, 10,
filename,
ad
);
}
else if (ppr.StringResult == "Application")
{
// Capture the entire application window
if (!ad.clipboard)
filename = GetFileName(ed);
ScreenShotToFile(
Application.MainWindow,
0, 0, 0, 0,
filename,
ad
);
}
else if (ppr.StringResult == "Objects")
{
// Ask the user to select a number of entities
PromptSelectionResult psr =
ed.GetSelection();
// Generate screen coordinate points based on the
// drawing points selected
// First we get the viewport number
short vp =
(short)Application.GetSystemVariable("CVPORT");
// Then the handle to the current drawing window
IntPtr hWnd = doc.Window.Handle;
// Get the screen extents of the selected entities
Point pt1, pt2;
GetExtentsOfSelection(
ed, doc, hWnd, vp, psr.Value, out pt1, out pt2
);
// Now save this portion of our screen as a raster
// image
if (!ad.clipboard)
filename = GetFileName(ed);
ScreenShotToFile(pt1, pt2, filename, ad);
}
else if (ppr.StringResult == "Settings")
{
if (GetSettings(ed, ad))
ud[myKey] = ad;
settingsChosen = true;
}
}
}
while (settingsChosen); // Loop if settings were modified
if (ppr.Status == PromptStatus.OK)
{
// Now we're ready to select the second point
Point3d first = ppr.Value;
ppr =
ed.GetCorner(
"\nSelect second point of capture window: ",
first
);
if (ppr.Status != PromptStatus.OK)
return;
Point3d second = ppr.Value;
// Generate screen coordinate points based on the
// drawing points selected
Point pt1, pt2;
// First we get the viewport number
short vp =
(short)Application.GetSystemVariable("CVPORT");
// Then the handle to the current drawing window
IntPtr hWnd = doc.Window.Handle;
// Now calculate the selected corners in screen coordinates
pt1 = ScreenFromDrawingPoint(ed, hWnd, first, vp, true);
pt2 = ScreenFromDrawingPoint(ed, hWnd, second, vp, true);
// Now save this portion of our screen as a raster image
if (!ad.clipboard)
filename = GetFileName(ed);
ScreenShotToFile(pt1, pt2, filename, ad);
}
}
}
// Iterate through a selection-set and get the overall extents
// of the various objects relative to the screen
// (this is imperfect: our extents in WCS may not translate to
// the extents on the screen. A more thorough approach would be
// to get a number of points from an object and check each)
private static void GetExtentsOfSelection(
Editor ed,
Document doc,
IntPtr hWnd,
short vp,
SelectionSet ss,
out Point min,
out Point max
)
{
// Create minimum and maximum points for the "on screen"
// extents of our objects
min = new Point();
max = new Point();
// Know which is the first pass through
bool first = true;
// Some variables to store transformation results
Point pt1 = new Point(), pt2 = new Point();
Transaction tr =
doc.TransactionManager.StartTransaction();
using (tr)
{
foreach (SelectedObject so in ss)
{
DBObject obj =
tr.GetObject(so.ObjectId, OpenMode.ForRead);
Entity ent = obj as Entity;
if (ent != null)
{
// Get the WCS extents of each object
Extents3d ext = ent.GeometricExtents;
// Calculate the extent corners in screen coordinates
// (this may not be the true screen extents, but we'll
// hope it's good enough)
pt1 =
ScreenFromDrawingPoint(
ed, hWnd, ext.MinPoint, vp, false
);
pt2 =
ScreenFromDrawingPoint(
ed, hWnd, ext.MaxPoint, vp, false
);
// The points may not be ordered, so get the min and max
// values for both X and Y from both points
int minX = Math.Min(pt1.X, pt2.X);
int minY = Math.Min(pt1.Y, pt2.Y);
int maxX = Math.Max(pt1.X, pt2.X);
int maxY = Math.Max(pt1.Y, pt2.Y);
// On the first run through, just get the points
if (first)
{
min = new Point(minX, minY);
max = new Point(maxX, maxY);
first = false;
}
else
{
// On subsequent runs through, we need to compare
if (minX < min.X) min.X = minX;
if (minY < min.Y) min.Y = minY;
if (maxX > max.X) max.X = maxX;
if (maxY > max.Y) max.Y = maxY;
}
}
}
tr.Commit();
}
}
// Print the current application settings to the command-line
private static void PrintSettings(Editor ed, AppData ad)
{
ed.WriteMessage(
"\nCurrent settings: Output={0}, Background={1}",
ad.clipboard ? "Clipboard" : "File",
ad.whitebackground ? "ForceToWhite" : "Normal"
);
}
// Ask the user to modify the application settings
private static bool GetSettings(Editor ed, AppData ad)
{
// At our top-level settings prompt, make the default
// to exit back up
PromptKeywordOptions pko =
new PromptKeywordOptions(
"\nSetting to change " +
"[Output/Background/Exit]: ",
"Output Background Exit"
);
pko.Keywords.Default = "Exit";
PromptResult pr;
bool changed = false;
do
{
// Start by printing the current settings
PrintSettings(ed, ad);
pr = ed.GetKeywords(pko);
if (pr.Status == PromptStatus.OK)
{
if (pr.StringResult == "Output")
{
// If Output is selected, ask whether to put the
// image on the clipboard or save to file
PromptKeywordOptions pko2 =
new PromptKeywordOptions(
"\nSave to file or place on the clipboard " +
"[File/Clipboard]: ",
"File Clipboard"
);
// The default depends on our current settings
pko2.Keywords.Default =
(ad.clipboard ? "Clipboard" : "File");
PromptResult pr2 = ed.GetKeywords(pko2);
if (pr2.Status == PromptStatus.OK)
{
// Change the settings, as needed
bool clipboard =
(pr2.StringResult == "Clipboard");
if (ad.clipboard != clipboard)
{
ad.clipboard = clipboard;
changed = true;
}
}
}
else if (pr.StringResult == "Background")
{
// If Background is chosen, ask whether to
// force the background colour to white
// (we could allow selection of a colour,
// but that's out of scope, for now)
PromptKeywordOptions pko3 =
new PromptKeywordOptions(
"\nForce background color to white " +
"[Yes/No]: ",
"Yes No"
);
// The default depends on our current settings
pko3.Keywords.Default =
(ad.whitebackground ? "Yes" : "No");
PromptResult pr3 = ed.GetKeywords(pko3);
if (pr3.Status == PromptStatus.OK)
{
// Change the settings, as needed
bool whitebackground =
(pr3.StringResult == "Yes");
if (ad.whitebackground != whitebackground)
{
ad.whitebackground = whitebackground;
changed = true;
}
}
}
}
}
while(
pr.Status == PromptStatus.OK &&
pr.StringResult != "Exit"
); // Loop until Exit or cancel
return changed;
}
// Ask the user to select a location to save our file to
private static string GetFileName(Editor ed)
{
string filename = "";
// The entries here will drive the behaviour of the
// GetFormatForFile() function
PromptSaveFileOptions pofo =
new PromptSaveFileOptions(
"\nSelect image location: "
);
pofo.Filter =
"Bitmap (*.bmp)|*.bmp|" +
"GIF (*.gif)|*.gif|" +
"JPEG (*.jpg)|*.jpg|" +
"PNG (*.png)|*.png|" +
"TIFF (*.tif)|*.tif";
PromptFileNameResult pfnr =
ed.GetFileNameForSave(pofo);
if (pfnr.Status == PromptStatus.OK)
{
filename = pfnr.StringResult;
// If a file was selected, wait for some time to allow
// the "file already exists" dialog to disappear
// (100 msec = 1/10th of a second - may need tweaking)
System.Threading.Thread.Sleep(100);
}
return filename;
}
// Perform our tranformations to get from UCS
// (or WCS) to screen coordinates
private static Point ScreenFromDrawingPoint(
Editor ed,
IntPtr hWnd,
Point3d pt,
short vpNum,
bool useUcs
)
{
// Transform from UCS to WCS, if needed
Point3d wcsPt =
(useUcs ?
pt.TransformBy(ed.CurrentUserCoordinateSystem)
: pt
);
// Then get the screen coordinates within the client
// and translate these for the overall screen
Point res = ed.PointToScreen(wcsPt, vpNum);
ClientToScreen(hWnd, ref res);
return res;
}
// Save the display of an AutoCAD window as a raster file
// and/or an image on the clipboard
private static void ScreenShotToFile(
Autodesk.AutoCAD.Windows.Window wd,
int top, int bottom, int left, int right,
string filename,
AppData ad
)
{
Point pt = wd.Location;
Size sz = wd.Size;
pt.X += left;
pt.Y += top;
sz.Height -= top + bottom;
sz.Width -= left + right;
SaveScreenPortion(pt, sz, filename, ad);
}
// Save a screen window between two corners as a raster file
// and/or an image on the clipboard
private static void ScreenShotToFile(
Point pt1,
Point pt2,
string filename,
AppData ad
)
{
// Create the top left corner from the two corners
// provided (by taking the min of both X and Y values)
Point pt =
new Point(Math.Min(pt1.X, pt2.X), Math.Min(pt1.Y, pt2.Y));
// Determine the size by subtracting X & Y values and
// taking the absolute value of each
Size sz =
new Size(Math.Abs(pt1.X - pt2.X), Math.Abs(pt1.Y - pt2.Y));
SaveScreenPortion(pt, sz, filename, ad);
}
// Save a portion of the screen display as a raster file
// and/or an image on the clipboard
private static void SaveScreenPortion(
Point pt,
Size sz,
string filename,
AppData ad
)
{
Document doc =
Application.DocumentManager.MdiActiveDocument;
Database db = doc.Database;
Editor ed = doc.Editor;
Manager gsm = doc.GraphicsManager;
AcColorSettings ocs = new AcColorSettings();
if (ad.whitebackground)
{
// Get the current system colours
acedGetCurrentColors(out ocs);
// Take a copy - we'll leave the original to reset
// the values later on, once we've finished
AcColorSettings cs = ocs;
// Make both background colours white (the 3D
// background isn't currently being picked up)
cs.dwGfxModelBkColor = 16777215;
cs.dwGfxLayoutBkColor = 16777215;
//cs.dwParallelBkColor = 16777215;
// Set the modified colours
acedSetCurrentColors(ref cs);
// Update the screen to reflect the changes
ed.Regen();
ed.UpdateScreen();
}
// Set the bitmap object to the size of the window
Bitmap bmp =
new Bitmap(
sz.Width,
sz.Height,
PixelFormat.Format32bppArgb
);
using (bmp)
{
// Create a graphics object from the bitmap
using (Graphics gfx = Graphics.FromImage(bmp))
{
// Take a screenshot of our window
gfx.CopyFromScreen(
pt.X, pt.Y, 0, 0, sz,
CopyPixelOperation.SourceCopy
);
// Save the screenshot to the specified location
if (filename != null && filename != "")
bmp.Save(filename, GetFormatForFile(filename));
// Copy it to the clipboard
if (ad.clipboard)
System.Windows.Forms.Clipboard.SetImage(bmp);
}
}
if (ad.whitebackground)
{
acedSetCurrentColors(ref ocs);
ed.Regen();
ed.UpdateScreen();
}
}
// Return the image format to use for a particular filename
private static ImageFormat GetFormatForFile(string filename)
{
// If all else fails, let's create a PNG
// (might also choose to throw an exception)
ImageFormat imf = ImageFormat.Png;
if (filename.Contains("."))
{
// Get the filename's extension (what follows the last ".")
string ext =
filename.Substring(filename.LastIndexOf(".") + 1);
// Get the first three characters of the extension
if (ext.Length > 3)
ext = ext.Substring(0, 3);
// Choose the format based on the extension (in lowercase)
switch (ext.ToLower())
{
case "bmp":
imf = ImageFormat.Bmp;
break;
case "gif":
imf = ImageFormat.Gif;
break;
case "jpg":
imf = ImageFormat.Jpeg;
break;
case "tif":
imf = ImageFormat.Tiff;
break;
case "wmf":
imf = ImageFormat.Wmf;
break;
default:
imf = ImageFormat.Png;
break;
}
}
return imf;
}
}
}
Some notes on some of the additional features this code introduces…
To temporarily change the background colour to white I’ve resorted to P/Invoking a couple of ObjectARX functions, acedGetCurrentColors() and acedSetCurrentColors(), as these give direct access to AutoCAD’s colour scheme and allow it to be changed on the spot (with the exception of the 3D drawing background – changing this currently doesn’t have any immediate impact on AutoCAD, and I haven’t yet found a way to force AutoCAD to pick up the colour change). The problem with using P/Invoke on functions with decorated names is that the signatures tend to differ between 32- and 64-bit systems (and may also have changed at other times, although the last time this impacted a lot of functions was during the Unicode migration between AutoCAD 2006 and 2007, and mostly affected functions passing strings). I’ve included the modified EntryPoint values for 64-bit systems as comments – these will either become conditional compilation statements, in time, or even something more dynamic (if I can find a way to create DllImport attributes dynamically at runtime). [Thanks to Augusto Gonçalves, from DevTech Americas, for writing the code I plundered to get these two functions working properly.]
I’ve added a small delay after asking the user for a filename: in the case that you need to overwrite a file, the dialogs may not be dismissed quickly enough (at least not when using aero on Vista :-). Here’s a sample capture showing the issue when we don’t have the delay:
To enable capture of the extents defined by a set of objects selected by the user, I’ve added a function called GetExtentsOfSelection() which returns two screen points defining the approximate extents of the various objects. I say approximate, because we’re taking the geometric (WCS) extents of the objects, converting the minimum and maximum values (the coordinates defining the lower-left and upper-right corners) into screen coordinates, and then using the smallest and largest X and Y values to create our new screen capture window. Which is far from perfect: the geometric extents may very well not define the screen extents. To make this more reliable, we could probably take more sampling points on the objects and check those against our extents once converted into screen coordinates. But that seems like a lot of effort – especially as users can always select capture windows themselves – so this is left as an exercise for the obsessive-compulsive reader. (On a side note, my wife often teases me for having borderline obsessive-compulsive tendencies, although she clearly wishes some of them would lead to us having a cleaner, tidier home. :-)
Anyway – that’s pretty much it in terms of the implementation, thus far.
To get a feel for the command-line prompts, here’s the code in action, with the user-inputs in red:
Command: SCREENSHOT
Current settings: Output=Clipboard, Background=Normal
Select first point of capture window or [Document/Application/Objects/Settings]: S
Current settings: Output=Clipboard, Background=Normal
Setting to change [Output/Background/Exit] <Exit>: O
Save to file or place on the clipboard [File/Clipboard] <Clipboard>: F
Current settings: Output=File, Background=Normal
Setting to change [Output/Background/Exit] <Exit>: B
Force background color to white [Yes/No] <No>: Y
Current settings: Output=File, Background=ForceToWhite
Setting to change [Output/Background/Exit] <Exit>:
Current settings: Output=File, Background=ForceToWhite
Select first point of capture window or [Document/Application/Objects/Settings]: D
Regenerating model.
Regenerating model.
Command:
If you get the chance, please give the code a try and let me know what more you’d like to see the SCREENSHOT command do.