Monday, October 14, 2019

Proceed Or Cancel Entity Erasing By Asking For Confirmation

A recent discussion in Autodesk's .NET user forum raises the question of how to ask user confirmation when entity is to be erased.

AutoCAD has very good "UNDO" mechanism built in from beginning. In majority of cases when entities to be erased from drawing, asking user to confirm the erasing not only unnecessary, but also quite annoying and interrupting. However, there may be cases when asking for confirmation is legitimate and/or desired. For example, if you have some entities associated to many other entities or data (XData, ExtensionDictionary...) via your application, when these entities are to be erased, you might want to do something, or let user know the consequence of losing some data.

The solution of the original poster of the discussion was to ask user's for confirmation after the fact of erasing, and only bring the erased entities back with "UNDO", if the user does not want the erasing. However, I though it is possible to ask for confirmation before the erasing and only proceed with user's confirmation. Of course, asking confirmation should only target specific entities (the less the better). This led me to turn to Overrule, namely ObjectOverrule.

Overrule was made available via API (AutoCAD 2010, I think). Kean Walmsley (unfortunately he does no longer write about AutoCAD programming) posted an article in his famous block Through the Interface on how to prevent AutoCAD objects from being erased. From his article we can see, it is really easy to stop erasing. It should also be easier to allow some conditions being applied so that the overrule can decide whether erasing goes ahead of not. Also, we can take advantage of Overrule's filtering mechanism to narrow down the scope of target entities easily.

So, I decided give ObjectOverrule a try to see what kind of solution and its usability it would lead to. Here is the custom ObjectOverrule class EraseOverrule:

using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Runtime;
using System;
using System.Collections.Generic;
using System.Linq;
 
namespace OverruledEntityErasing
{
    public enum EraseOverruleMode
    {
        RaiseEvent = 0,
        GatherIds = 1,
        VetoErase = 2,
    }
 
    public class EraseOverrule : ObjectOverrule
    {
        private static EraseOverrule _instance = null;
        private bool _originalOverruling = Overrule.Overruling;
        private IEnumerable<string> _layers = null;
 
        public EraseOverruleMode Mode { setget; } = EraseOverruleMode.RaiseEvent;
        public List<ObjectId> EntitiesToErase { get; } = new List<ObjectId>();
 
        public static EraseOverrule Instance
        {
            get
            {
                if (_instance==null)
                {
                    _instance = new EraseOverrule();
                }
                return _instance;
            }
        }
 
        public event AttemptEraseEventHandler AttemptErase;
        public void Start(IEnumerable<stringlayerFilters)
        {
            _layers = layerFilters;
            EntitiesToErase.Clear();
 
            Overrule.AddOverrule(RXClass.GetClass(typeof(Entity)), thisfalse);
            this.SetCustomFilter();
            Overrule.Overruling = true;
        }
 
        public void Stop()
        {
            Overrule.RemoveOverrule(RXClass.GetClass(typeof(Entity)), this);
            Overrule.Overruling = _originalOverruling;
        }
 
        public override bool IsApplicable(RXObject overruledSubject)
        {
            if (_layers == null || _layers.Count() == 0) return true;
 
            var filtered = FilteredByLayers(overruledSubject as Entity);
            return filtered;
        }
 
        public override void Erase(DBObject dbObjectbool erasing)
        {
            base.Erase(dbObjecterasing);
 
            // If user does "UNDO" to unerase entity back
            // bypass custom overrule process bellow.
            if (!erasingreturn; 
 
            switch (Mode)
            {
                case EraseOverruleMode.VetoErase:
                    throw new Autodesk.AutoCAD.Runtime.Exception(
                        ErrorStatus.NotApplicable);
 
                case EraseOverruleMode.GatherIds:
                    EntitiesToErase.Add(dbObject.ObjectId);
                    throw new Autodesk.AutoCAD.Runtime.Exception(
                        ErrorStatus.NotApplicable);
 
                case EraseOverruleMode.RaiseEvent:
                    var cancel = false;
                    var args = new AttemptEraseEventArgs(dbObject.ObjectId);
                    AttemptErase?.Invoke(thisargs);
                    cancel = args.CancelErasing;
                    if (cancel)
                    {
                        throw new Autodesk.AutoCAD.Runtime.Exception(
                            ErrorStatus.NotApplicable);
                    }
                    break;
            }  
        }
 
        private bool FilteredByLayers(Entity ent)
        {
            var layer = ent.Layer.ToUpper();
            foreach (var l in _layers)
            {
                if (l.ToUpper() == layerreturn true;
            }
            return false;
        }
    }
 
    public class AttemptEraseEventArgs : EventArgs
    {
        public ObjectId EntityId { private setget; }
        public bool CancelErasing { setget; } = false;
        public AttemptEraseEventArgs(ObjectId entId)
        {
            EntityId = entId;
        }
    }
 
    public delegate void AttemptEraseEventHandler(object senderAttemptEraseEventArgs e);
}

The EraseOverrule is derived from ObjectOverrule and overrides its Erase() method. Here are some considerations about this class:

1. I defined an enum type EraseOverruleMode. Each mode results in different behaviour of the overrule (i.e different code logic in overridden Erase() method;

  • EraseOverruleMode.RaiseEvent: an AttemptEraseEventHandler is uased as event, which is raised in overridden Erase() method. This allows external code process, which enables the overrule and subscribes this event to decide if the erasing is to be cancelled or not.;
  • EraseOverruleMode.GatherIds: all erasing to the target entities is cancelled and the ObjectIds of these entities are collected in a collection. The external code process can then test if "erasing attempt" has been applied to some entities. If yes, the code can decide if go ahead with erasing or not. If yes, stop the overrule and do the normal erasing;
  • EraseOverruleMode.VetoErase: this simply veto erasing on target entities.
The code shown later demonstrates how these EraseOverruleModes are used in real AutoCAD erasing process, either when erasing is done with .NET API, or done with AutoCAD built-in commands.

2. Considering EraseOverrule is used either to overrule erasing in .NET API code, or overrule AutoCAD built-in erasing (by "ERASE" command, or other commands that may result in entity being erased), I made it can only be instantiated as singleton object.

3. During debugging, I realized that the Erase() method in the overrule is called both when object is erased and the object is "unerased" by UNDO command. When erased entity is undone, the Erase() method's "bool erasing" argument is false. So, I needed to have this line

if (!erasing) return;

before my overrule logic, which are only needed to handle erasing of target entities.

4. Raising exception in overrule's overridden Erase() method stop erasing works well when the the overrule is used to overrule erasing done by AutoCAD built-in commands, because AutoCAD handles this raised exception in someway. However, if you have .NET API code that does the erasing (i.e. opening an entity for write in a transaction, and call Entity.Erase() method), your code MUST enclose the Entity.Erase() call in a try{...}catch{} block with a blank catch... clause.; or your program would crash AutoCAD because of the exception raised in the overrule. Also, during debugging, the throw new Exception...statement ALWAYS break the debugging run, you can hit F5 to let the debugging run continue ONLY if your code that calls Entity.Erase() has try{...}catch{} wrapped (i.e. the exception is handled).

5. For simplicity, I use Layers as the filter for the EraseOverrule. That means when EraseOverrule is in effect, entities on certain Layers are protected from being erased without being confirmed. It is very easy to code EraseOverrule to filter target objects differently based on needs.

Now move on to see how to use EraseOverrule in 2 different situations: using it with custom erasing process built with AutoCAD .NET API (i.e. doing erasing with our own code in conjunction with this overrule); or using it with AutoCAD built-in erasing process (i.e. ding erasing with AutoCAD built-in commands while the overrule is in effect).

I defined class SpecialEraser that uses EraseOverrule to govern the coded erasing, so that target entities by EraseOverrule would only be erased with user's confirmation. Here is its code:

using Autodesk.AutoCAD.DatabaseServices;
using System.Collections.Generic;
using System.Linq;
 
namespace OverruledEntityErasing
{
    public class SpecialEraser
    {
        private List<ObjectId> _entitiesNotErased = new List<ObjectId>();
        private int _erasedCount  = 0;
        private int _notErasedCount = 0;
        private string[] _protectedLayers = null;
        public SpecialEraser(string[] protectedLayers)
        {
            _protectedLayers = protectedLayers;
        }
 
        #region public methods
 
        public void ConfirmedEraseOneByOne(ObjectId[] entIds)
        {
            if (entIds.Length == 0) return;
            _erasedCount = 0;
            _notErasedCount = 0;
 
            EraseOverrule.Instance.Mode = EraseOverruleMode.RaiseEvent;
            EraseOverrule.Instance.AttemptErase += AttemptIndividualEraseHandler;
            EraseOverrule.Instance.Start(_protectedLayers);
 
            // when each entity is to be erased, if the entity is the overrule's 
            // target, the event handler allow a chance for user to confirm, 
            // in which user can cancel the erasing
            try
            {
                DoErase(entIds);
            }
            finally
            {
                EraseOverrule.Instance.AttemptErase -= AttemptIndividualEraseHandler;
                EraseOverrule.Instance.Stop();
            }
 
            var normalErased = entIds.Length - (_erasedCount + _notErasedCount);
            var msg = 
                $"Normally erased count: {normalErased}\n" +
                $"Confirmedly erased count: {_erasedCount}\n" +
                $"Confirmedly not erased count: {_notErasedCount}";
            MsgBox.ShowInfo(msg);
        }
 
        public void ConfirmedEraseInBatch(ObjectId[] entIds)
        {
            if (entIds.Length == 0) return;
            _entitiesNotErased.Clear();
 
            EraseOverrule.Instance.Mode = EraseOverruleMode.RaiseEvent;
            EraseOverrule.Instance.AttemptErase += AttemptBatchEraseHandler;
            EraseOverrule.Instance.Start(_protectedLayers);
 
            // Do the erasing, if the entity is the overrule's target, the erasing
            // will be all denied, and the entity's objectId is collected for 
            // later to be confirned for real erasing  
            try
            {  
                DoErase(entIds);
            }
            finally
            {
                EraseOverrule.Instance.AttemptErase -= AttemptBatchEraseHandler;
                EraseOverrule.Instance.Stop();
            }
 
            // do actual erasing here at once
            if (_entitiesNotErased.Count>0)
            {
                var erased = entIds.Length - _entitiesNotErased.Count;
                var msg = 
                    $"Erased entity count: {erased}\n" +
                    $"Erase-protected entity count: {_entitiesNotErased.Count}" +
                    "\n\nDo you really want to erase protected entities?";
 
                if (MsgBox.ShowYesNo(msg)
                    == System.Windows.Forms.DialogResult.Yes)
                {
                    DoErase(_entitiesNotErased);
                }
            }
        }
 
        public void ProtectedErase(ObjectId[] entIds)
        {
            if (entIds.Length == 0) return;
 
            EraseOverrule.Instance.Mode = EraseOverruleMode.VetoErase;
            EraseOverrule.Instance.Start(_protectedLayers);
 
            // Do the erasing, if the entity is the overrule's target,
            // erasing is vetoed.
            try
            {
                DoErase(entIds);
            }
            finally
            {
                EraseOverrule.Instance.Stop();
            }
 
            int count = (from id in entIds where !id.IsErased select id).Count();
            var msg = 
                $"{count} out of {entIds.Length} " +
                $"entit{(entIds.Length > 1 ? "ies" : "y")} is proected, thus not erased.";
            MsgBox.ShowInfo(msg);
        }
 
        public List<ObjectIdProtectedEraseWithEntityIds(ObjectId[] entIds)
        {
            if (entIds.Length == 0) return new List<ObjectId>();
 
            List<ObjectIdids = null;
 
            EraseOverrule.Instance.Mode = EraseOverruleMode.GatherIds;
            EraseOverrule.Instance.Start(_protectedLayers);
 
            // Do the erasing, if the entity is the overrule's target,
            // erasing is vetoed, and entity's Id is collected
            try
            {
                DoErase(entIds);
                ids = EraseOverrule.Instance.EntitiesToErase;
            }
            finally
            {
                EraseOverrule.Instance.Stop();
            }
 
            return ids;
        }
 
        public static void DoErase(IEnumerable<ObjectIdentIds)
        {
            using (var tran = 
                entIds.First().Database.TransactionManager.StartTransaction())
            {
                foreach (var id in entIds)
                {
                    var ent = tran.GetObject(idOpenMode.ForWrite);
                    try
                    {
                        ent.Erase();
                    }
                    catch { }
                }
                tran.Commit();
            }
        }
 
        #endregion
 
        #region private methods
 
        private void AttemptIndividualEraseHandler(object senderAttemptEraseEventArgs e)
        {
            var entType = e.EntityId.ObjectClass.DxfName;
            var msg = $"Entity to be erased: {entType}\n\n" +
                "Do you want to erase it?";
 
            if (MsgBox.ShowYesNo(msg) == System.Windows.Forms.DialogResult.No)
            {
                e.CancelErasing = true;
                _notErasedCount++;
            }
            else
            {
                _erasedCount++;
            }
        }
 
        private void AttemptBatchEraseHandler(object senderAttemptEraseEventArgs e)
        {
            _entitiesNotErased.Add(e.EntityId);
            e.CancelErasing = true;
        }
 
        #endregion
    }
}


The code in SpecialEraser class is fairly self-explanatory. Here is the CommandClass that actually uses the SpecialEraser to do the erasing by asking user to confirm of erasing entities "protected by" EraseOverrule; or simply enables EraseOverrule against erasing done by AutoCAD's built-in commands, where CommandEnded event is handled to determine if there was an attempt made to erase entities "protected by" EraseOverrule, if yes, the real erasing only occurs after user's confirmation. Here is the CommandClass code:

using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Runtime;
using CadApp = Autodesk.AutoCAD.ApplicationServices.Application;
 
[assemblyCommandClass(typeof(OverruledEntityErasing.MyCommands))]
 
namespace OverruledEntityErasing
{
    public class MyCommands
    {
        private static bool _overruleOn = false;
        private static string[] _protectedLayers = new string[] { "MyLayer1""MyLayer2" };
 
        #region Command methods: erasing entities with "SpecialEraser" class
 
        [CommandMethod("ConfirmedEraseInBatch"CommandFlags.UsePickSet)]
        public static void DoConfirmedEraseInBatch()
        {
            var dwg = CadApp.DocumentManager.MdiActiveDocument;
            var ed = dwg.Editor;
 
            var entIds = SelectEntities(ed);
            if (entIds == nullreturn;
 
            _overruleOn = false;
            var eraser = new SpecialEraser(_protectedLayers);
            eraser.ConfirmedEraseInBatch(entIds);
        }
 
        [CommandMethod("ConfirmedEraseOneByOne"CommandFlags.UsePickSet)]
        public static void DoConfirmedEraseOneByOne()
        {
            var dwg = CadApp.DocumentManager.MdiActiveDocument;
            var ed = dwg.Editor;
 
            var entIds = SelectEntities(ed);
            if (entIds == nullreturn;
 
            _overruleOn = false;
            var eraser = new SpecialEraser(_protectedLayers);
            eraser.ConfirmedEraseOneByOne(entIds);
        }
 
        [CommandMethod("ProtectedErase"CommandFlags.UsePickSet)]
        public static void DoProtectedErase()
        {
            var dwg = CadApp.DocumentManager.MdiActiveDocument;
            var ed = dwg.Editor;
 
            var entIds = SelectEntities(ed);
            if (entIds == nullreturn;
 
            _overruleOn = false;
            var eraser = new SpecialEraser(_protectedLayers);
            eraser.ProtectedErase(entIds);
        }
 
        [CommandMethod("ProtectedEraseWithIds")]
        public static void DoProtectedEraseWithEntityIdsReturned()
        {
            var dwg = CadApp.DocumentManager.MdiActiveDocument;
            var ed = dwg.Editor;
 
            var entIds = SelectEntities(ed);
            if (entIds == nullreturn;
 
            _overruleOn = false;
 
            var eraser = new SpecialEraser(_protectedLayers);
            var ids = eraser.ProtectedEraseWithEntityIds(entIds);
            if (ids.Count > 0)
            {
                var msg = 
                    $"{ids.Count} entit{(ids.Count > 1 ? "ies" : "y")} " +
                    $"{(ids.Count > 1 ? "is" : "are")} protected from erasing." +
                    "\n\nDo you really want to erase?";
                if (MsgBox.ShowYesNo(msg) ==
                    System.Windows.Forms.DialogResult.Yes)
                {
                    SpecialEraser.DoErase(ids);
                }
            }
        }
 
        #endregion
 
        #region CommandMethods: Turn on/off EraseOverrule against AutoCAD built-in erasing
 
        [CommandMethod("EraseOverrule")]
        public static void EnableEraseOverrule()
        {
            var dwg = CadApp.DocumentManager.MdiActiveDocument;
            var ed = dwg.Editor;
 
            if (!_overruleOn)
            {
                TurnOnEraseOverrule();
                _overruleOn = true;
                ed.WriteMessage("\nEraseOverule is turned on during command execution.\n");
            }
            else
            {
                TurnOffEraseOverrule();
                _overruleOn = false;
                ed.WriteMessage("\nEraseOverule is turned off.\n");
            }
        }
 
        #endregion
 
        #region private methods
 
        private static ObjectId[] SelectEntities(Editor ed)
        {
            var res = ed.GetSelection();
 
            if (res.Status != PromptStatus.OK)
            {
                ed.WriteMessage("\n*Cancel*\n");
                return null;
            }
            else
            {
                return res.Value.GetObjectIds();
            }
        }
 
        private static void TurnOnEraseOverrule()
        {
            EraseOverrule.Instance.Mode = EraseOverruleMode.GatherIds;
            EraseOverrule.Instance.Start(_protectedLayers);
 
            foreach (Document dwg in CadApp.DocumentManager)
            {
                dwg.CommandEnded += CommandEndedHandler;
            }
 
            CadApp.DocumentManager.DocumentCreated += (oe) =>
              {
                  e.Document.CommandEnded += CommandEndedHandler;
              };
 
            CadApp.DocumentManager.DocumentToBeDestroyed += (oe) =>
              {
                  e.Document.CommandEnded -= CommandEndedHandler;
              };
        }
 
        private static void CommandEndedHandler(object senderCommandEventArgs e)
        {
            if (!_overruleOn) return;
 
            var ids = EraseOverrule.Instance.EntitiesToErase;
            if (ids.Count > 0)
            {
                var cmd = e.GlobalCommandName;
                var msg = $"The command \"{cmd}\" attempted to erase {ids.Count} " +
                    $"protected entit{(ids.Count > 1 ? "ies" : "y")}.\n\n" +
                    "Do you really want to erase?";
 
                if (MsgBox.ShowYesNo(msg) == System.Windows.Forms.DialogResult.Yes)
                {
                    try
                    {
                        EraseOverrule.Instance.Stop();
 
                        var dwg = CadApp.DocumentManager.MdiActiveDocument;
                        using (dwg.LockDocument())
                        {
                            SpecialEraser.DoErase(ids);
                        }
                    }
                    finally
                    {
                        EraseOverrule.Instance.Start(_protectedLayers);
                    }
                }
 
                EraseOverrule.Instance.EntitiesToErase.Clear();
            }
        }
 
        private static void TurnOffEraseOverrule()
        {
            foreach (Document dwg in CadApp.DocumentManager)
            {
                dwg.CommandEnded -= CommandEndedHandler;
            }
 
            EraseOverrule.Instance.Stop();
        }
 
        #endregion
    }
}


As usual, following video clips shows how the code works. For simplicity, EraseOverrule is designed to only target entities on specific layers, in this video demo the layers are "MyLayer1" and "MyLayer2" with layer color as Yellow and Cyan. So, the 4 circles of Yellow/Cyan are the "protected entities, and can only be erased after user's confirmation, while other entities can be erased as usual. Each clip is corresponding to a command:

Clip 0 shows the drawing setup: 4 circles on "MyLayer1" and "MyLayer2", thus are "protected" by EraseOverrule, while other entities can be freely erased;
Clip 1 showing command "ConfirmedEraseInBatch";
Clip 2 showing command "ConfirmedEraseOneByOne";
Clip 3 showing command "ProtectedErase";
Clip 4 showing command "ProtectedEraseWithIds".

Review the code of each command before watching the corresponding video clip.

Again, while asking confirmation before erasing entities (or making other changes to entities, for that matter) is possible/doable, I'd be very careful of doing it and always try to avoid it unless it is a must-do.

The source code of entire project can be downloaded here, which is Visual Studio 2019/C# project against .NET Framework 4.8 and AutoCAD 2020 (I also noticed, even the DLL is compiled against AutoCAD 2020 .NET Assemblies, I have no problem NETLOAD the DLL into AutoCAD 2019 and run it).









2 comments: