What I say above is not entirely true.
There is a way to encapsulate your operations/changes into a single, logical undo group that includes the user-initiated operations that triggered your operations, but it is not trivial. Below is a class that you can use to define logical undo groups without having to use commands (e.g., UNDO/Begin/End).
You must create an instance of the class before the user operation that initiates your operation starts, which is typically done in a CommandWillStart event handler.
After the user-initiated operation and your subsequent operations end, you dispose the instance. That should encapsulate the user-initiated operations and your operations that were triggered by the user operation into a single undo group.
/// AcMgdLib - https://github.com/ActivistInvestor/AcMgdLib
///
/// UndoGroup.cs
///
/// Activist Investor / Tony T
///
/// Distributed under the terms of the MIT license
using System;
using System.Runtime.InteropServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Runtime;
using Autodesk.AutoCAD.Runtime.NativeInterop;
using AcRx = Autodesk.AutoCAD.Runtime;
namespace AcMgdLib.DatabaseServices
{
/// <summary>
/// Encapsulation of a logical undo group.
///
/// This class can be used to group multiple
/// operations into a single undo group without
/// the need to execute AutoCAD commands. The
/// instance starts an undo group when it is
/// created, and ends the undo group when it
/// is disposed.
/// </summary>
public class UndoGroup : IDisposable
{
/// <summary>
/// VERSION dependence:
///
/// This code uses P/Invoke to call native methods in
/// acdbXX.dll, which has a version/release-dependent
/// filename (e.g., acdb19.dll... acdb25.dll, etc).
///
/// Hence, you must replace the following string
/// with the name of the file for the product or
/// release you're using.
///
/// If you want to eliminate the version-dependent
/// filename problem, you can use dynamic loading
/// using classes from this library.
///
/// See the following files in the Common folder:
///
/// DllImport.cs
/// AcDbNativeMethods.cs
///
/// Eventually, this code will refactored to avoid
/// the version-dependent filename issue, using the
/// above classes.
/// </summary>
const string ACDB_DLL = "acdb25.dll"; // AutoCAD 2025/2026
Database db;
bool disposed = false;
public UndoGroup(Database db)
{
if(db is null)
throw new ArgumentNullException(nameof(db));
this.db = db;
Begin(db);
}
public void Dispose()
{
if(!disposed)
{
disposed = true;
End(db);
}
}
[DllImport(ACDB_DLL, CallingConvention = CallingConvention.ThisCall,
EntryPoint = "?undoController@AcDbDatabase@@QEBAPEAVAcDbUndoController@@XZ")]
static extern IntPtr GetUndoController(IntPtr db);
[DllImport(ACDB_DLL, CallingConvention = CallingConvention.ThisCall,
EntryPoint = "?beginGroup@AcDbImpUndoController@@UEAA?AW4ErrorStatus@Acad@@XZ")]
static extern ErrorStatus beginGroup(IntPtr controller);
[DllImport(ACDB_DLL, CallingConvention = CallingConvention.ThisCall,
EntryPoint = "?endGroup@AcDbImpUndoController@@UEAA?AW4ErrorStatus@Acad@@XZ")]
static extern ErrorStatus endGroup(IntPtr controller);
static ErrorStatus Begin(Database db)
{
var controller = GetUndoController(db.UnmanagedObject);
if(controller == IntPtr.Zero)
throw new AcRx.Exception(AcRx.ErrorStatus.NullPtr);
return beginGroup(controller);
}
static ErrorStatus End(Database db)
{
var controller = GetUndoController(db.UnmanagedObject);
if(controller == IntPtr.Zero)
throw new AcRx.Exception(AcRx.ErrorStatus.NullPtr);
return endGroup(controller);
}
}
}