Friday, September 23, 2022

Update Entities via Data Binding of Modeless WPF Window

This article is in response to a question asked in AutoCAD .NET API discussion forum. 

In last a few year, I use WPF as the main UI option in my AutoCAD .NET API development, only occasionally use Win Form for very simple UI for collecting user inputs. While one can use WPF in about the same way as with Win Form (e.g. writing a lot of code behind the UI to control the behaviors of the UI), the real advantages of using WPF are both its rich UI presentation capability and its data binding. That latter allows us programmer to easily separate the UI and business options, usually through a well-known design pattern MVVM (Model-View-ViewModel). 

The AutoCAD .NET project in this article does almost exactly the work as the question asked in the discussion forum:

1. Create a WPF modeless form;

2. On the form there are some WPF UserControls, which are bound to certain AutoCAD entities in drawing; In this project, the UserControls are WPF Slider controls, the entities are block refeeneces in drawing;

3. CAD user can change the slider's tick, hence the slider's value changes with a given range (0 to 360, in this case). The slider's Value property is bound to a block reference's Rotation property. Therefore, the the slider's value changes, the corresponding block reference in drawing rotates accordingly.

Let's dive into the code.

First some very basic code almost all WPF project needs: the implementation of ICommand and INotifyPropertyChanged:

using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
 
namespace WPFBindingEntities
{
    public class RelayCommand : ICommand
    {
        private Action<object> execute;
        private Func<objectbool> canExecute;
 
        public event EventHandler CanExecuteChanged
        {
            add { CommandManager.RequerySuggested += value; }
            remove { CommandManager.RequerySuggested -= value; }
        }
 
        public RelayCommand(Action<objectexecute, Func<objectboolcanExecute = null)
        {
            this.execute = execute;
            this.canExecute = canExecute;
        }
 
        public bool CanExecute(object parameter)
        {
            return this.canExecute == null || this.canExecute(parameter);
        }
 
        public void Execute(object parameter)
        {
            this.execute(parameter);
        }
    }
 
    public abstract class ViewModelBase : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;
 
        protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(thisnew PropertyChangedEventArgs(propertyName));
        }
    }
}

Now the code for "Model" part of the Model-View-ViewModel pattern, class ControlledBlocks:

using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using System;
using System.Collections.Generic;
 
namespace WPFBindingEntities
{
    public class ControlledBlocks : Dictionary<string, List<ObjectId>>
    {
        public ControlledBlocks()
        {
            Application.DocumentManager.DocumentToBeDestroyed += (oe) =>
            {
                if (ContainsKey(e.Document.Database.FingerprintGuid))
                {
                    Remove(e.Document.Database.FingerprintGuid);
                }
            };
        }
 
        public static Action<ObjectId, double> BlockAdded;
 
        public void AddBlock(ObjectId blkId)
        {
            var key = blkId.Database.FingerprintGuid;
 
            if (!ContainsKey(key))
            {
                Add(key, new List<ObjectId>());
            }
 
            this[key].Add(blkId);
            var rotation = 0.0;
            using (var tran=
                blkId.Database.TransactionManager.StartOpenCloseTransaction())
            {
                var blk=(BlockReference)tran.GetObject(blkId, OpenMode.ForRead);
                rotation= blk.Rotation;
                tran.Commit();
            }
 
            BlockAdded?.Invoke(blkId, rotation);
        }
 
        public void RemoveBlock(ObjectId blkId)
        {
            var key = blkId.Database.FingerprintGuid;
            if (ContainsKey(key))
            {
                var blkIds = this[key];
                foreach (var id in blkIds)
                {
                    if (id==blkId)
                    {
                        blkIds.Remove(id);
                        return;
                    }
                }
            }
        }
 
        public bool IsDoubleSelected(ObjectId blkId)
        {
            var key = blkId.Database.FingerprintGuid;
            if (ContainsKey(key))
            {
                return this[key].Contains(blkId);
            }
            else
            {
                return false;
            }
        }
    }
}

Because the WPF UI will be modeless, the data (selected block references, which rotation will be controlled by the modeless UI) should be organized in per-document/database base. I use a List<ObjectId> to hold selected block reference IDs and use each document's Database.FingerprintGuid property as the Dictionary's key. I could have used Document, or Document.UnmanagedObject as the key, but considering ObjectId has a property Database, using Database.FingerprintGuid is more convenient, which would become quite obvious if one looks into the code of this class. An static instance of this class is kept alive in entire AutoCAD session. So, inside the class, and event handle is hooked up to DocumentCollection.DocumentToBeDestroyed, so that when an open drawing is about to be closed down, the data corresponding to this document/database is cleared. One would also notice a public Action BlockAdded is exposed as a static property. If  it is assigned somewhere (in the ViewModel, in this case), whenever a block reference is added into this data model, this action is invoked (i.e. a method in ViewModel, which would update the UI to show the newly added block reference). This is a simplified version of the usual event handler delegate/event argument/event approach.

Now the "View" part of the Model-View-ViewModel. The UI, as a modeless window, looks like:

The main view is BlockRotationView class, its XAML code is as following:

<Window x:Class="WPFBindingEntities.BlockRotationView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WPFBindingEntities"
             mc:Ignorable="d"
        Closing="TheWindow_Closing"
        x:Name="TheWindow"
        Width="580" SizeToContent="Height" ShowInTaskbar="False" ResizeMode="NoResize"
        Title="Block Rotation Dashboard">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="30"/>
            <RowDefinition Height="150"/>
        </Grid.RowDefinitions>
        <Grid Grid.Row="0" Margin="5,0,5,0">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="200*"/>
                <ColumnDefinition Width="100"/>
            </Grid.ColumnDefinitions>
            <StackPanel Grid.Column="0" Orientation="Horizontal">
                <TextBlock Text="Monitored Blocks:" VerticalAlignment="Center"/>
                <TextBlock Text="{Binding BlockCount}" VerticalAlignment="Center" 
                           FontWeight="DemiBold" Foreground="Blue" Margin="10,0,0,0"/>
            </StackPanel>
            <Button Grid.Column="1" Content="Add Block >" Width="80" Height="22" 
                    VerticalAlignment="Center" HorizontalAlignment="Right"
                    Command="{Binding AddBlockCommand}" 
                    CommandParameter="{Binding ElementName=TheWindow}"/>
        </Grid>
        <ListBox Grid.Row="1" Margin="5,5,5,5"
                 ItemsSource="{Binding Blocks}" HorizontalContentAlignment="Stretch">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <local:BlockRotationRowView DataContext="{Binding}" />
                </DataTemplate>
            </ListBox.ItemTemplate>           
        </ListBox>
    </Grid>
</Window>

As the window's XMAL code shows, the items in the list box is custom WPF UserControls of class BlockRotationRowView, its XAML code is as following:

<UserControl x:Class="WPFBindingEntities.BlockRotationRowView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WPFBindingEntities"
             mc:Ignorable="d" 
             d:DesignHeight="36" d:DesignWidth="500">
    <Grid Height="36">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="100"/>
            <ColumnDefinition Width="100"/>
            <ColumnDefinition Width="100*"/>
            <ColumnDefinition Width="70"/>
            <ColumnDefinition Width="70"/>
        </Grid.ColumnDefinitions>
        <StackPanel Grid.Column="0" Orientation="Horizontal">
            <TextBlock Text="Handle:" VerticalAlignment="Center" />
            <TextBlock Text="{Binding Handle, Mode=OneTime}" VerticalAlignment="Center" Margin="10,0,0,0"/>
        </StackPanel>
        <StackPanel Grid.Column="1" Orientation="Horizontal">
            <TextBlock Text="Rotation:" VerticalAlignment="Center" />
            <TextBlock Text="{Binding Rotation, Mode=OneWay}" VerticalAlignment="Center" Margin="10,0,0,0"/>
        </StackPanel>
        <Slider Grid.Column="2" VerticalAlignment="Center" Minimum="0.0" Maximum="360.0" 
                Interval="30" IsSnapToTickEnabled="True" Value="{Binding Rotation}" 
                TickPlacement="Both" LargeChange="30" SmallChange="30" TickFrequency="30"/>
        <Button Grid.Column="3" Content="Zoom" Width="60" Height="22" 
                VerticalAlignment="Center" HorizontalAlignment="Right"
                Command="{Binding ZoomCommand}" CommandParameter="{Binding BlockId}"/>
        <Button Grid.Column="4" Content="Remove" Width="60" Height="22" 
                VerticalAlignment="Center" HorizontalAlignment="Right"
                Command="{Binding RemoveCommand}" CommandParameter="{Binding BlockId}"/>
    </Grid>
</UserControl>

The XAML code is almost all I need here for the "view", except for only a few lines of C# code-behind:

using System.Windows;
 
namespace WPFBindingEntities
{
    /// <summary>
    /// Interaction logic for BlockRatationView.xaml
    /// </summary>
    public partial class BlockRotationView : Window
    {
        public BlockRotationView()
        {
            InitializeComponent();
        }
 
        public BlockRotationView(BlockRotationViewModel viewModel):this()
        {
            Loaded += (oe) =>
            {
                DataContext = viewModel;
            };
        }
 
        private void TheWindow_Closing(
            object sender, System.ComponentModel.CancelEventArgs e)
        {
            e.Cancel = true;
            Visibility = Visibility.Hidden;
        }
    }
}

The overloaded view constructor is to make the view data-bound to the view model, while the handling of TheWindow_Closing event is necessary because of the window being modeless: the Closing event handle makes the window effectively behaves as singleton instance.

Now there is "ViewModel" class corresponding to the "View" in the "Model-View-ViewModel": class BlockRotationViewModel and BlockRotationRowViewModel.

This the main view model BlockRotationViewModel class:

using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using System.Collections.ObjectModel;
using CadApp = Autodesk.AutoCAD.ApplicationServices.Application;
 
namespace WPFBindingEntities
{
    public class BlockRotationViewModel : ViewModelBase
    {
        private ControlledBlocks _monitoredBlocks;
        private ObservableCollection<BlockRotationRowViewModel> _blocks;
        private string _currentDbGuid = "";
 
        public BlockRotationViewModel(ControlledBlocks monitoredBlocks)
        {
            _monitoredBlocks = monitoredBlocks;
            _blocks = new ObservableCollection<BlockRotationRowViewModel>();
            AddBlockCommand = new RelayCommand(
                AddBlockToDashboard, (o) => { return true; });
 
            ControlledBlocks.BlockAdded = OnBlockAdded;
            BlockRotationRowViewModel.RemoveBlockAction = OnBlockRemove;
            BlockRotationRowViewModel.ZoomAction = OnBlockZoom;
            BlockRotationRowViewModel.RotateBlockAction = OnBlockRotate;
 
            _currentDbGuid = 
                CadApp.DocumentManager.MdiActiveDocument.Database.FingerprintGuid;
            ResetView();
 
            CadApp.DocumentManager.DocumentActivated += (oe) =>
            {
                if (e.Document.Database.FingerprintGuid != _currentDbGuid)
                {
                    _currentDbGuid = e.Document.Database.FingerprintGuid;
                    ResetView();
                }
            };
        }
 
        public ObservableCollection<BlockRotationRowViewModel> Blocks => _blocks;
        public int BlockCount => _blocks.Count;
        public RelayCommand AddBlockCommand { private setget; }
 
        #region private methods
 
        private void ResetView()
        {
            _blocks.Clear();
            if (_monitoredBlocks.ContainsKey(_currentDbGuid))
            {
                var blkIds = _monitoredBlocks[_currentDbGuid];
                foreach (var Id in blkIds)
                {
                    _blocks.Add(new BlockRotationRowViewModel(Id, 0.0));
                }
            }
        }
 
        private void AddBlockToDashboard(object cmdParam)
        {
            var window = cmdParam as System.Windows.Window;
            var blkId = ObjectId.Null;
            try
            {
                if (window != null) window.Visibility = System.Windows.Visibility.Hidden;
                Application.MainWindow.Focus();
                while (true)
                {
                    blkId = CadHelper.SelectBlock();
                    if (!blkId.IsNull)
                    {
                        if (_monitoredBlocks.IsDoubleSelected(blkId))
                        {
                            CadHelper.WriteMessage(
                                "\nThe block has already been listed in rotation dashboard!");
                        }
                        else
                        {
                            break;
                        }
                    }
                    else
                    {
                        break;
                    }
                }
            }
            finally
            {
                if (window != null) window.Visibility = 
                        System.Windows.Visibility.Visible;
            }
 
            if (blkId == ObjectId.Null) return;
 
            _monitoredBlocks.AddBlock(blkId);
        }
 
        private void OnBlockAdded(ObjectId blkIddouble rotation)
        {
            _blocks.Add(new BlockRotationRowViewModel(blkId, rotation));
            OnPropertyChanged("BlockCount");
        }
 
        private void OnBlockRemove(ObjectId blkId)
        {
            _monitoredBlocks.RemoveBlock(blkId);
            foreach (var blk in _blocks)
            {
                if (blk.BlockId== blkId)
                {
                    _blocks.Remove(blk);
                    break;
                }
            }
            OnPropertyChanged("BlockCount");
        }
 
        private void OnBlockZoom(ObjectId blkId)
        {
            CadHelper.ZoomToEntity(blkId);
        }
 
        private void OnBlockRotate(ObjectId bkIddouble rotation)
        {
            CadHelper.RotateBlock(bkId, rotation);
        }
 
        #endregion
    }
}

And this is BlockRotationRowViewModel class:

using Autodesk.AutoCAD.DatabaseServices;
using System;
 
namespace WPFBindingEntities
{
    public class BlockRotationRowViewModel : ViewModelBase
    {
        private double _rotation = 0.0;
        private ObjectId _blkId = ObjectId.Null;
        
        public BlockRotationRowViewModel(ObjectId blkIddouble rotation)
        {
            _blkId = blkId;
            _rotation= rotation;
            RemoveCommand = new RelayCommand(
                RemoveBlock, (o) => { return true; });
            ZoomCommand = new RelayCommand(
                ZoomToBlock, (o) => { return true; });
 
        }
        public string Handle => _blkId.Handle.ToString();
        public ObjectId BlockId => _blkId;
        public double Rotation
        {
            get => _rotation;
            set
            {
                _rotation = value;
                OnPropertyChanged("Rotation");
                RotateBlockAction?.Invoke(_blkId, _rotation);
            }
        }
        public static Action<ObjectId> RemoveBlockAction;
        public static Action<ObjectId> ZoomAction;
        public static Action<ObjectId, double> RotateBlockAction;
        public RelayCommand RemoveCommand { private setget; }
        public RelayCommand ZoomCommand { private setget; }   
 
        private void RemoveBlock(object cmdParam)
        {
            RemoveBlockAction?.Invoke(_blkId);
        }
 
        private void ZoomToBlock(object cmdParam)
        {
            ZoomAction?.Invoke(_blkId);
        }
    }
}

Of course, all the AutoCAD related operations are organized in a separate class CadHelper, exposed as static methods:


using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using System;
using CadApp = Autodesk.AutoCAD.ApplicationServices.Application;
 
namespace WPFBindingEntities
{
    public class CadHelper
    {
        public static ObjectId SelectBlock()
        {
            var dwg = Application.DocumentManager.MdiActiveDocument;
            var opt = new PromptEntityOptions(
                "\nSelect a block:");
            opt.SetRejectMessage("\nInvalid selection: not a block!");
            opt.AddAllowedClass(typeof(BlockReference), true);
            var res = dwg.Editor.GetEntity(opt);
            if (res.Status== PromptStatus.OK)
            {
                return res.ObjectId;
            }
            else
            {
                return ObjectId.Null;
            }
        }
 
        public static void WriteMessage(string msg)
        {
            var dwg = Application.DocumentManager.MdiActiveDocument;
            dwg.Editor.WriteMessage($"{msg}");
        }
 
        public static void ZoomToEntity(ObjectId entId)
        {
            Extents3d ext;
            using (var tran = 
                entId.Database.TransactionManager.StartOpenCloseTransaction())
            {
                var ent = (Entity)tran.GetObject(entId, OpenMode.ForRead);
                ext = ent.GeometricExtents;
                tran.Commit();
            }
 
            ZoomToExtents(ext, 1.0);
        }
 
        public static void ZoomToExtents(Extents3d extdouble marginRatio)
        {
            var w = ext.MaxPoint.X - ext.MinPoint.X;
            var h= ext.MaxPoint.Y - ext.MinPoint.Y;
            var delta = Math.Max(w, h) * marginRatio;
 
            var minPt = new[]
            {
                ext.MinPoint.X-delta, ext.MinPoint.Y-delta, ext.MinPoint.Z
            };
            var maxPt = new[]
            {
                ext.MaxPoint.X+delta, ext.MaxPoint.Y+delta, ext.MaxPoint.Z
            };
 
            dynamic comApp = 
                Autodesk.AutoCAD.ApplicationServices.Application.AcadApplication;
            comApp.ZoomWindow(minPt, maxPt);
        }
 
        public static void RotateBlock(ObjectId blkIddouble rotation)
        {
            var dwg = CadApp.DocumentManager.MdiActiveDocument;
            if (dwg.Database.FingerprintGuid!=blkId.Database.FingerprintGuid)
            {
                throw new InvalidOperationException(
                    "Entity is not from current drawing database!");
            }
 
            using (dwg.LockDocument())
            {
                using (var tran = 
                    blkId.Database.TransactionManager.StartTransaction())
                {
                    var blk = 
                        (BlockReference)tran.GetObject(blkId, OpenMode.ForWrite);
                    blk.Rotation = Math.PI * rotation / 180.0;
                    tran.Commit();
                }
            }
            CadApp.UpdateScreen();
        }
    }
}

Finally, this the CommandClass, which keeps the data model (ControlledBlocks class), view (BlockRotationView class) and viewmodel (BlockRotationViewModel class) in the Application context as static instance (singleton) because of the view is required to be modeless window:

using Autodesk.AutoCAD.Runtime;
using CadApp = Autodesk.AutoCAD.ApplicationServices.Application;
 
[assembly: CommandClass(typeof(WPFBindingEntities.MyCommands))]
 
namespace WPFBindingEntities
{
    public class MyCommands 
    {
        private static ControlledBlocks _blocks = null;
        private static BlockRotationView _view = null;
        private static BlockRotationViewModel _viewModel = null;
 
        [CommandMethod("BlockRotation", CommandFlags.Session)]
        public static void MonitorBlocks()
        {
            var dwg = CadApp.DocumentManager.MdiActiveDocument;
            var ed = dwg.Editor;
            
            if (_blocks==null)
            {
                _blocks = new ControlledBlocks();
            }
 
            if (_view == null)
            {
                _viewModel = new BlockRotationViewModel(_blocks);
                _view = new BlockRotationView(_viewModel);
                CadApp.ShowModelessWindow(_view);
            }
            else
            {
                _view.Visibility = System.Windows.Visibility.Visible;
            }
        }
 
    }
}

As one can see, doing this project with MVVM pattern, the entire operation is fairly well separated into Model, View, and ViewModel, taking advantage of WPF data binding.

As usual, see the video clip below showing the code in action:


While all the code in the project are posted inside this article, the entire source code can be downloaded here. It is VisualStudio2022 solution and tested against AutoCAD 2022, but it should work with all later versions of AutoCAD.




No comments:

Post a Comment