The title of this one is a little specific – the post actually deals with the scenario of passing data from .NET to an HTML-defined palette, as well as some other tips & tricks – but it’s something I wanted to show.
Here’s the basic idea: whenever a closed curve gets added to the drawing, we want to display its area as the only item in an HTML palette. We also want the palette to update when objects get erased, etc., which makes life somewhat trickier.
To set the scene, here’s a quick screencast of the finished application in action (I didn’t record audio – it should be obvious what’s happening, though):
The “brief” came from Matthew Shaxted at SOM Chicago: they want to use an HTML palette to display more advanced area calculations – not just the area of the last entity added – but this is a good starting point for them. On a side note, these guys are doing some really cool stuff with WebGL inside AutoCAD, something similar to (but way beyond) the approach shown in this previous post. It’s amazing what can be done with this mechanism.
Right, back to the topic at hand. In most of the previous AutoCAD+JavaScript-related posts, we call through to .NET commands from HTML/JavaScript. We want to do this here, too, but we also want data to travel in the reverse direction, and not just passed back by .NET functions: we need to push data to the palette, updating it when (for instance) new entities are created.
During the research for this post, I iterated through a few different approaches which I feel are worth sharing for context:
- Reload the palette, forcing the page load to call back through from JavaScript to .NET to get the required data
- Very heavy-handed: I literally had to close and re-launch the palette, which caused some ugly UI flicker
- Define a hidden JavaScript command (with “no history” and “no undo marker” defined) that we call from our event handler using SendStringToExecute()
- It worked, but added complexity as we’re having to marshal data via Editor.GetString() (etc.) methods, which also meant setting NOMUTT
- Register a JavaScript method using registerCallback() and call it using Application.Invoke() from .NET
- This is the approach recommended by the online help, which it turns out is out-of-date… there is no connection between JavaScript and acedInvoke() and its equivalents
- Use the same approach but invoke it by calling acjsInvokeAsync() via P/Invoke
- We need to get a native .NET method exposed for this, but at least this approach works (hurray!)
- Thanks to Albert Szilvasy for providing pointers that got me this far :-)
Aside from this there were some other structural issues to deal with: we want to detect when objects get added to and erased from the chosen drawing – ObjectAppended and ObjectErased events are great for this, of course – but we clearly need to wait until CommandEnded to do anything of significance.
Beyond that, though, we also need to be careful not to trample on the undo file. As explained in this very helpful DevBlog post, if you open anything for write during CommandEnded of an undo-related command, this will cause the famous “nothing to redo” problem when someone tries to redo the undone actions. While we don’t open anything directly for write, we do use Editor.SelectLast() to get the most recently created entity in the drawing. Even if we call this via SendStringToExecute() it causes the problem.
So the code implements a rudimentary object list – which tracks curves that are added and erased while it’s in operation – so that we can get the most recently added entity from there. I have no doubt there are complexities we’re not addressing with this fairly crude approach, but then we’re only using it during undo, which mitigates the risk somewhat. For instance, we don’t populate the history when we load the app into a drawing that contains geometry, so if we undo back past the time the app was loaded, we don’t display any area information.
I think that’s enough preamble. Here’s the HTML page defining our palette – I’ve embedded the various JavaScript code, including the extension to the shaping layer – for simplicity:
<!doctype html>
<html>
<head>
<title>Last Area</title>
<style>
html, body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
}
body {
display: table;
}
.centered-on-page {
text-align: center;
display: table-cell;
vertical-align: middle;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: xx-large;
font-weight: bold;
}
</style>
<script
src="http://app.autocad360.com/jsapi/v2/Autodesk.AutoCAD.js">
</script>
<script type="text/javascript">
function displayValue(prop, val) {
// Display the specified value in our div for the specified
// property
var div = document.getElementById(prop);
if (div != null) {
div.innerHTML = val;
}
}
function updatePalette(args) {
// Simply unpack the arguments from JSON and pass
// them through to the generic display function
var obj = JSON.parse(args);
displayValue(obj.propName, obj.propValue);
}
// Shaping layer extension
function lastAreaFromAutoCAD() {
var jsonResponse =
exec(
JSON.stringify({
functionName: 'LastArea',
invokeAsCommand: false,
functionParams: undefined
})
);
var jsonObj = JSON.parse(jsonResponse);
if (jsonObj.retCode !== Acad.ErrorStatus.eJsOk) {
throw Error(jsonObj.retErrorString);
}
return jsonObj.result;
}
</script>
</head>
<body>
<div id="area" class="centered-on-page"/>
<script type="text/javascript">
(function () {
registerCallback("updval", updatePalette);
// On load we call through to .NET to get the area of
// the last entity and then display it
// (we could also have the .NET could reinvoke
// JavaScript if we wanted to keep the display-specific
// logic in one module)
try {
var area = lastAreaFromAutoCAD();
displayValue(
"area", area > 0.0 ? area.toFixed(2) : "No area"
);
}
catch (ex) {
displayValue("area", "No area");
}
})();
</script>
</body>
</html>
Here’s the loading C# code which gets called from the palette but also calls through to it:
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Runtime;
using Autodesk.AutoCAD.Windows;
using System;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
namespace LastAreaPalette
{
public class Commands
{
// Member variables
private PaletteSet _areaps = null; // Our palette
private static Document _launchDoc = null; // Doc launched from
private static bool _update = false; // Flag for refresh
private static ObjectIdCollection _ids = // List to help
new ObjectIdCollection(); // refresh on UNDO
[DllImport(
"AcJsCoreStub.crx", CharSet = CharSet.Auto,
CallingConvention = CallingConvention.Cdecl,
EntryPoint = "acjsInvokeAsync")]
extern static private int acjsInvokeAsync(
string name, string jsonArgs
);
// JavaScript-exposed method allowing our HTML page to query
// the area of the last entity as it loads
[JavaScriptCallback("LastArea")]
public string LastArea(string jsonArgs)
{
// Default return value is failure
var res = "{\"retCode\":1}";
var doc = GetActiveDocument(Application.DocumentManager);
// If we didn't find a document, return
if (doc == null)
return res;
// We could probably get away without locking the document
// - as we only need to read - but it's good practice to
// do it anyway
try {
using (var dl = doc.LockDocument())
{
// Call our internal method and package the results
// as JSON if successful
var area = GetAreaOfLastEntity(doc, ObjectId.Null);
if (area > 0.0)
{
res =
String.Format(
"{{\"retCode\":0, \"result\":{0}}}", area
);
}
}
}
catch { }
return res;
}
// Helper function to retrieve the area of the last entity
// (assuming it's a closed curve). We have an "optional"
// ObjectId argument (we couldn't define ObjectId.Null as the
// default value, as that's not a compile-time constant)
// which we use when undoing (as SelectLast() invalidates
// the undo file if used then)
private double GetAreaOfLastEntity(
Document doc, ObjectId id
)
{
var db = doc.Database;
var ed = doc.Editor;
var res = 0.0;
if (id == ObjectId.Null)
{
// Get the last entity (which returns a selection set)
var psr = ed.SelectLast();
if (psr.Status != PromptStatus.OK || psr.Value.Count != 1)
return res;
id = psr.Value[0].ObjectId;
// If our list of IDs doesn't yet contain it, append it
if (!_ids.Contains(id))
{
_ids.Add(id);
}
}
// Use open/close as we're often called in an event handler
var tr = doc.TransactionManager.StartOpenCloseTransaction();
using (tr)
{
var c = tr.GetObject(id, OpenMode.ForRead) as Curve;
if (c.Closed)
{
res = c.Area;
}
tr.Commit();
}
return res;
}
// Helper to get the document a palette was launched from
// in the case where the active document is null
private Document GetActiveDocument(DocumentCollection dm)
{
var doc = dm.MdiActiveDocument;
if (doc == null)
{
doc = _launchDoc;
}
return doc;
}
[CommandMethod("LAREA")]
public void LastAreaPalette()
{
// We're storing the "launch document" as we're attaching
// various event handlers to it
_launchDoc =
Application.DocumentManager.MdiActiveDocument;
_areaps =
ShowPalette(
_areaps,
new Guid("4169EEA9-E3BA-49C4-9197-265A2E42E4B5"),
"LAREA",
"Last Area",
GetHtmlPathArea(),
true
);
if (_launchDoc != null)
{
// When the document we're connected to is closed,
// we want to close the palette
_launchDoc.BeginDocumentClose +=
(s, e) =>
{
if (_areaps != null)
{
_areaps.Close();
_areaps.Dispose();
_areaps = null;
}
_launchDoc = null;
};
// We're going to monitor when objects get added and
// erased. We'll use CommandEnded to refresh the
// palette at most once per command (might also use
// DocumentManager.DocumentLockModeWillChange)
_launchDoc.Database.ObjectAppended += OnObjectAppended;
_launchDoc.Database.ObjectErased += OnObjectErased;
_launchDoc.CommandEnded += OnCommandEnded;
// When the PaletteSet gets destroyed we remove
// our event handlers
_areaps.PaletteSetDestroy += OnPaletteSetDestroy;
}
}
void OnObjectAppended(object s, ObjectEventArgs e)
{
// If we have a curve, flag the palette for refresh
// and add the curve's ID to our list
if (e != null && e.DBObject is Curve)
{
_update = true;
_ids.Add(e.DBObject.ObjectId);
}
}
void OnObjectErased(object s, ObjectErasedEventArgs e)
{
// If we have a curve, flag the palette for refresh
// and then either add or remove the curve's ID to/from our
// list, depending on whether we're erasing or unerasing
if (e != null && e.DBObject is Curve)
{
_update = true;
var id = e.DBObject.ObjectId;
if (e.Erased)
{
if (_ids.Contains(id))
{
_ids.RemoveAt(_ids.IndexOf(id));
}
}
else if (!e.Erased)
{
_ids.Add(id);
}
}
}
void OnCommandEnded(object s, CommandEventArgs e)
{
if (_update)
{
// When we need to update the display in the palette,
// get the last area and pass it through to our hidden
// UPDPAL command (specifying the "area" div)
_update = false;
// We don't want our refresh function to use
// Editor.SelectLast() if we're undoing,
// so check for that
var isUndoing =
(e.GlobalCommandName == "U" ||
e.GlobalCommandName == "UNDO");
var doc = (Document)s;
var id = ObjectId.Null;
var area = 0.0;
if (!isUndoing || _ids.Count > 0)
{
// If we're undoing, pass the ID of the object at the
// top of our "stack" (which should be valid as
// OnObjectErased() will have popped any being erased)
if (isUndoing)
{
id = _ids[_ids.Count - 1];
}
area = GetAreaOfLastEntity(doc, id);
}
// Invoke our JavaScript function to update the palette
acjsInvokeAsync(
"updval",
"{\"propName\":\"area\",\"propValue\":" +
(area > 0.0 ?
Math.Round(area, 2).ToString() :
"\"No area\"") + "}"
);
}
}
void OnPaletteSetDestroy(object s, EventArgs e)
{
// When our palette is closed, detach the various
// event handlers
if (_launchDoc != null)
{
_launchDoc.Database.ObjectAppended -= OnObjectAppended;
_launchDoc.Database.ObjectErased -= OnObjectErased;
_launchDoc.CommandEnded -= OnCommandEnded;
}
}
// Helper function to show a palette
private PaletteSet ShowPalette(
PaletteSet ps, Guid guid, string cmd, string title, Uri uri,
bool reload = false
)
{
// If the reload flag is true we'll force an unload/reload
// (this isn't strictly needed - given our refresh function -
// but I've left it in for possible future use)
if (reload && ps != null)
{
// Close the palette and make sure we process windows
// messages, otherwise sizing is a problem
ps.Close();
System.Windows.Forms.Application.DoEvents();
ps.Dispose();
ps = null;
}
if (ps == null)
{
ps = new PaletteSet(cmd, guid);
}
else
{
if (ps.Visible)
return ps;
}
if (ps.Count != 0)
{
ps[0].PaletteSet.Remove(0);
}
ps.Add(title, uri);
ps.Visible = true;
return ps;
}
// Helper function to get the path to our HTML files
private static string GetHtmlPath()
{
// Use this approach if loading the HTML from the same
// location as your .NET module
//var asm = Assembly.GetExecutingAssembly();
//return Path.GetDirectoryName(asm.Location) + "\\";
return "http://through-the-interface.typepad.com/files/";
}
private static Uri GetHtmlPathArea()
{
return new Uri(GetHtmlPath() + "lastarea.html");
}
}
}
Something else to note about this, before we finish: rather than create an updarea() function in JavaScript that only updates the area in the palette, I did my best to make this a bit more generic. The updval() method has both the property name and value passed as arguments to it, which means we can use it to update other “divs” in the HTML, should we need to. You can imagine a much more complex palette populated with various fields coming from AutoCAD, for instance, all updated using calls to updval().