October 2014

Sun Mon Tue Wed Thu Fri Sat
      1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31  










« An automatic numbering system for AutoCAD blocks using .NET - Part 1 | Main | An automatic numbering system for AutoCAD blocks using .NET - Part 3 »

May 09, 2008

An automatic numbering system for AutoCAD blocks using .NET - Part 2

In the last post we saw some code to perform simple sequential numbering of blocks (reflected in a particular attribute contained in each block). In this next installment we'll extend the code by introducing a NumberedObjectManager class, which will manage the activities related to maintaining the sequence of numbers used by the various blocks. The main code will create an object of this class which will be used extensively in this and the next post by a number of new commands.

Here's the updated C# code, with changed & new lines marked with a red line-number. For your convenience here is the source file, to save you having to strip off the line numbers.

    1 using Autodesk.AutoCAD.ApplicationServices;

    2 using Autodesk.AutoCAD.Runtime;

    3 using Autodesk.AutoCAD.DatabaseServices;

    4 using Autodesk.AutoCAD.EditorInput;

    5 using Autodesk.AutoCAD.Geometry;

    6 using System.Collections.Generic;

    7

    8 namespace AutoNumberedBubbles

    9 {

   10   public class Commands : IExtensionApplication

   11   {

   12     // Strings identifying the block

   13     // and the attribute name to use

   14

   15     const string blockName = "BUBBLE";

   16     const string attbName = "NUMBER";

   17

   18     // We will use a separate object to

   19     // manage our numbering, and maintain a

   20     // "base" index (the start of the list)

   21

   22     private NumberedObjectManager m_nom;

   23     private int m_baseNumber = 0;

   24

   25     // Constructor

   26

   27     public Commands()

   28     {

   29       m_nom = new NumberedObjectManager();

   30     }

   31

   32     // Functions called on initialization & termination

   33

   34     public void Initialize()

   35     {

   36       try

   37       {

   38         Document doc =

   39           Application.DocumentManager.MdiActiveDocument;

   40         Editor ed = doc.Editor;

   41

   42         ed.WriteMessage(

   43           "\nLNS  Load numbering settings by analyzing the current drawing" +

   44           "\nDMP  Print internal numbering information" +

   45           "\nBAP  Create bubbles at points" +

   46           "\nBIC  Create bubbles at the center of circles"

   47         );

   48       }

   49       catch

   50       { }

   51     }

   52

   53     public void Terminate()

   54     {

   55     }

   56

   57     // Command to extract and display information

   58     // about the internal numbering

   59

   60     [CommandMethod("DMP")]

   61     public void DumpNumberingInformation()

   62     {

   63       Document doc =

   64         Application.DocumentManager.MdiActiveDocument;

   65       Editor ed = doc.Editor;

   66       m_nom.DumpInfo(ed);

   67     }

   68

   69     // Command to analyze the current document and

   70     // understand which indeces have been used and

   71     // which are currently free

   72

   73     [CommandMethod("LNS")]

   74     public void LoadNumberingSettings()

   75     {

   76       Document doc =

   77         Application.DocumentManager.MdiActiveDocument;

   78       Database db = doc.Database;

   79       Editor ed = doc.Editor;

   80

   81       // We need to clear any internal state

   82       // already collected

   83

   84       m_nom.Clear();

   85       m_baseNumber = 0;

   86

   87       // Select all the blocks in the current drawing

   88

   89       TypedValue[] tvs =

   90         new TypedValue[1] {

   91             new TypedValue(

   92               (int)DxfCode.Start,

   93               "INSERT"

   94             )

   95           };

   96       SelectionFilter sf =

   97         new SelectionFilter(tvs);

   98

   99       PromptSelectionResult psr =

  100         ed.SelectAll(sf);

  101

  102       // If it succeeded and we have some blocks...

  103

  104       if (psr.Status == PromptStatus.OK &&

  105           psr.Value.Count > 0)

  106       {

  107         Transaction tr =

  108           db.TransactionManager.StartTransaction();

  109         using (tr)

  110         {

  111           // First get the modelspace and the ID

  112           // of the block for which we're searching

  113

  114           BlockTableRecord ms;

  115           ObjectId blockId;

  116

  117           if (GetBlock(

  118                 db, tr, out ms, out blockId

  119             ))

  120           {

  121             // For each block reference in the drawing...

  122

  123             foreach (SelectedObject o in psr.Value)

  124             {

  125               DBObject obj =

  126                 tr.GetObject(o.ObjectId, OpenMode.ForRead);

  127               BlockReference br = obj as BlockReference;

  128               if (br != null)

  129               {

  130                 // If it's the one we care about...

  131

  132                 if (br.BlockTableRecord == blockId)

  133                 {

  134                   // Check its attribute references...

  135

  136                   int pos = -1;

  137                   AttributeCollection ac =

  138                     br.AttributeCollection;

  139

  140                   foreach (ObjectId id in ac)

  141                   {

  142                     DBObject obj2 =

  143                       tr.GetObject(id, OpenMode.ForRead);

  144                     AttributeReference ar =

  145                       obj2 as AttributeReference;

  146

  147                     // When we find the attribute

  148                     // we care about...

  149

  150                     if (ar.Tag == attbName)

  151                     {

  152                       try

  153                       {

  154                         // Attempt to extract the number from

  155                         // the text string property... use a

  156                         // try-catch block just in case it is

  157                         // non-numeric

  158

  159                         pos =

  160                           int.Parse(ar.TextString);

  161

  162                         // Add the object at the appropriate

  163                         // index

  164

  165                         m_nom.NumberObject(

  166                           o.ObjectId, pos, false

  167                         );

  168                       }

  169                       catch { }

  170                     }

  171                   }

  172                 }

  173               }

  174             }

  175           }

  176           tr.Commit();

  177         }

  178

  179         // Once we have analyzed all the block references...

  180

  181         int start = m_nom.GetLowerBound(true);

  182

  183         // If the first index is non-zero, ask the user if

  184         // they want to rebase the list to begin at the

  185         // current start position

  186

  187         if (start > 0)

  188         {

  189           ed.WriteMessage(

  190             "\nLowest index is {0}. ",

  191             start

  192           );

  193           PromptKeywordOptions pko =

  194             new PromptKeywordOptions(

  195               "Make this the start of the list?"

  196             );

  197           pko.AllowNone = true;

  198           pko.Keywords.Add("Yes");

  199           pko.Keywords.Add("No");

  200           pko.Keywords.Default = "Yes";

  201

  202           PromptResult pkr =

  203             ed.GetKeywords(pko);

  204

  205           if (pkr.Status == PromptStatus.OK)

  206           {

  207             if (pkr.StringResult == "Yes")

  208             {

  209               // We store our own base number

  210               // (the object used to manage objects

  211               // always uses zero-based indeces)

  212

  213               m_baseNumber = start;

  214               m_nom.RebaseList(m_baseNumber);

  215             }

  216           }

  217         }

  218       }

  219     }

  220

  221     // Command to create bubbles at points selected

  222     // by the user - loops until cancelled

  223

  224     [CommandMethod("BAP")]

  225     public void BubblesAtPoints()

  226     {

  227       Document doc =

  228         Application.DocumentManager.MdiActiveDocument;

  229       Database db = doc.Database;

  230       Editor ed = doc.Editor;

  231       Autodesk.AutoCAD.ApplicationServices.

  232       TransactionManager tm =

  233         doc.TransactionManager;

  234

  235       Transaction tr =

  236         tm.StartTransaction();

  237       using (tr)

  238       {

  239         // Get the information about the block

  240         // and attribute definitions we care about

  241

  242         BlockTableRecord ms;

  243         ObjectId blockId;

  244         AttributeDefinition ad;

  245         List<AttributeDefinition> other;

  246

  247         if (GetBlock(

  248               db, tr, out ms, out blockId

  249           ))

  250         {

  251           GetBlockAttributes(

  252             tr, blockId, out ad, out other

  253           );

  254

  255           // By default the modelspace is returned to

  256           // us in read-only state

  257

  258           ms.UpgradeOpen();

  259

  260           // Loop until cancelled

  261

  262           bool finished = false;

  263           while (!finished)

  264           {

  265             PromptPointOptions ppo =

  266               new PromptPointOptions("\nSelect point: ");

  267             ppo.AllowNone = true;

  268

  269             PromptPointResult ppr =

  270               ed.GetPoint(ppo);

  271             if (ppr.Status != PromptStatus.OK)

  272               finished = true;

  273             else

  274               // Call a function to create our bubble

  275               CreateNumberedBubbleAtPoint(

  276                 db, ms, tr, ppr.Value,

  277                 blockId, ad, other

  278               );

  279             tm.QueueForGraphicsFlush();

  280             tm.FlushGraphics();

  281           }

  282         }

  283         tr.Commit();

  284       }

  285     }

  286

  287     // Command to create a bubble at the center of

  288     // each of the selected circles

  289

  290     [CommandMethod("BIC")]

  291     public void BubblesInCircles()

  292     {

  293       Document doc =

  294         Application.DocumentManager.MdiActiveDocument;

  295       Database db = doc.Database;

  296       Editor ed = doc.Editor;

  297

  298       // Allow the user to select circles

  299

  300       TypedValue[] tvs =

  301         new TypedValue[1] {

  302             new TypedValue(

  303               (int)DxfCode.Start,

  304               "CIRCLE"

  305             )

  306           };

  307       SelectionFilter sf =

  308         new SelectionFilter(tvs);

  309

  310       PromptSelectionResult psr =

  311         ed.GetSelection(sf);

  312

  313       if (psr.Status == PromptStatus.OK &&

  314           psr.Value.Count > 0)

  315       {

  316         Transaction tr =

  317           db.TransactionManager.StartTransaction();

  318         using (tr)

  319         {

  320           // Get the information about the block

  321           // and attribute definitions we care about

  322

  323           BlockTableRecord ms;

  324           ObjectId blockId;

  325           AttributeDefinition ad;

  326           List<AttributeDefinition> other;

  327

  328           if (GetBlock(

  329                 db, tr, out ms, out blockId

  330             ))

  331           {

  332             GetBlockAttributes(

  333               tr, blockId, out ad, out other

  334             );

  335

  336             // By default the modelspace is returned to

  337             // us in read-only state

  338

  339             ms.UpgradeOpen();

  340

  341             foreach (SelectedObject o in psr.Value)

  342             {

  343               // For each circle in the selected list...

  344

  345               DBObject obj =

  346                 tr.GetObject(o.ObjectId, OpenMode.ForRead);

  347               Circle c = obj as Circle;

  348               if (c == null)

  349                 ed.WriteMessage(

  350                   "\nObject selected is not a circle."

  351                 );

  352               else

  353                 // Call our numbering function, passing the

  354                 // center of the circle

  355                 CreateNumberedBubbleAtPoint(

  356                   db, ms, tr, c.Center,

  357                   blockId, ad, other

  358                 );

  359             }

  360           }

  361           tr.Commit();

  362         }

  363       }

  364     }

  365

  366     // Internal helper function to open and retrieve

  367     // the model-space and the block def we care about

  368

  369     private bool

  370       GetBlock(

  371         Database db,

  372         Transaction tr,

  373         out BlockTableRecord ms,

  374         out ObjectId blockId

  375       )

  376     {

  377       BlockTable bt =

  378         (BlockTable)tr.GetObject(

  379           db.BlockTableId,

  380           OpenMode.ForRead

  381         );

  382

  383       if (!bt.Has(blockName))

  384       {

  385         Document doc =

  386           Application.DocumentManager.MdiActiveDocument;

  387         Editor ed = doc.Editor;

  388         ed.WriteMessage(

  389           "\nCannot find block definition \"" +

  390           blockName +

  391           "\" in the current drawing."

  392         );

  393

  394         blockId = ObjectId.Null;

  395         ms = null;

  396         return false;

  397       }

  398

  399       ms =

  400         (BlockTableRecord)tr.GetObject(

  401           bt[BlockTableRecord.ModelSpace],

  402           OpenMode.ForRead

  403         );

  404

  405       blockId = bt[blockName];

  406

  407       return true;

  408     }

  409

  410     // Internal helper function to retrieve

  411     // attribute info from our block

  412     // (we return the main attribute def

  413     // and then all the "others")

  414

  415     private void

  416       GetBlockAttributes(

  417         Transaction tr,

  418         ObjectId blockId,

  419         out AttributeDefinition ad,

  420         out List<AttributeDefinition> other

  421       )

  422     {

  423       BlockTableRecord blk =

  424         (BlockTableRecord)tr.GetObject(

  425           blockId,

  426           OpenMode.ForRead

  427         );

  428

  429       ad = null;

  430       other =

  431         new List<AttributeDefinition>();

  432

  433       foreach (ObjectId attId in blk)

  434       {

  435         DBObject obj =

  436           (DBObject)tr.GetObject(

  437             attId,

  438             OpenMode.ForRead

  439           );

  440         AttributeDefinition ad2 =

  441           obj as AttributeDefinition;

  442

  443         if (ad2 != null)

  444         {

  445           if (ad2.Tag == attbName)

  446           {

  447             if (ad2.Constant)

  448             {

  449               Document doc =

  450                 Application.DocumentManager.MdiActiveDocument;

  451               Editor ed = doc.Editor;

  452

  453               ed.WriteMessage(

  454                 "\nAttribute to change is constant!"

  455               );

  456             }

  457             else

  458               ad = ad2;

  459           }

  460           else

  461             if (!ad2.Constant)

  462               other.Add(ad2);

  463         }

  464       }

  465     }

  466

  467     // Internal helper function to create a bubble

  468     // at a particular point

  469

  470     private Entity

  471       CreateNumberedBubbleAtPoint(

  472         Database db,

  473         BlockTableRecord btr,

  474         Transaction tr,

  475         Point3d pt,

  476         ObjectId blockId,

  477         AttributeDefinition ad,

  478         List<AttributeDefinition> other

  479       )

  480     {

  481       //  Create a new block reference

  482

  483       BlockReference br =

  484         new BlockReference(pt, blockId);

  485

  486       // Add it to the database

  487

  488       br.SetDatabaseDefaults();

  489       ObjectId blockRefId = btr.AppendEntity(br);

  490       tr.AddNewlyCreatedDBObject(br, true);

  491

  492       // Create an attribute reference for our main

  493       // attribute definition (where we'll put the

  494       // bubble's number)

  495

  496       AttributeReference ar =

  497         new AttributeReference();

  498

  499       // Add it to the database, and set its position, etc.

  500

  501       ar.SetDatabaseDefaults();

  502       ar.SetAttributeFromBlock(ad, br.BlockTransform);

  503       ar.Position =

  504         ad.Position.TransformBy(br.BlockTransform);

  505       ar.Tag = ad.Tag;

  506

  507       // Set the bubble's number

  508

  509       int bubbleNumber =

  510         m_baseNumber +

  511         m_nom.NextObjectNumber(blockRefId);

  512

  513       ar.TextString = bubbleNumber.ToString();

  514       ar.AdjustAlignment(db);

  515

  516       // Add the attribute to the block reference

  517

  518       br.AttributeCollection.AppendAttribute(ar);

  519       tr.AddNewlyCreatedDBObject(ar, true);

  520

  521       // Now we add attribute references for the

  522       // other attribute definitions

  523

  524       foreach (AttributeDefinition ad2 in other)

  525       {

  526         AttributeReference ar2 =

  527           new AttributeReference();

  528

  529         ar2.SetAttributeFromBlock(ad2, br.BlockTransform);

  530         ar2.Position =

  531           ad2.Position.TransformBy(br.BlockTransform);

  532         ar2.Tag = ad2.Tag;

  533         ar2.TextString = ad2.TextString;

  534         ar2.AdjustAlignment(db);

  535

  536         br.AttributeCollection.AppendAttribute(ar2);

  537         tr.AddNewlyCreatedDBObject(ar2, true);

  538       }

  539       return br;

  540     }

  541   }

  542

  543   // A generic class for managing groups of

  544   // numbered (and ordered) objects

  545

  546   public class NumberedObjectManager

  547   {

  548     // We need to store a list of object IDs, but

  549     // also a list of free positions in the list

  550     // (this allows numbering gaps)

  551

  552     private List<ObjectId> m_ids;

  553     private List<int> m_free;

  554

  555     // Constructor

  556

  557     public NumberedObjectManager()

  558     {

  559       m_ids =

  560         new List<ObjectId>();

  561

  562       m_free =

  563         new List<int>();

  564     }

  565

  566     // Clear the internal lists

  567

  568     public void Clear()

  569     {

  570       m_ids.Clear();

  571       m_free.Clear();

  572     }

  573

  574     // Return the first entry in the ObjectId list

  575     // (specify "true" if you want to skip

  576     // any null object IDs)

  577

  578     public int GetLowerBound(bool ignoreNull)

  579     {

  580       if (ignoreNull)

  581         // Define an in-line predicate to check

  582         // whether an ObjectId is null

  583         return

  584           m_ids.FindIndex(

  585             delegate(ObjectId id)

  586             {

  587               return id != ObjectId.Null;

  588             }

  589           );

  590       else

  591         return 0;

  592     }

  593

  594     // Return the last entry in the ObjectId list

  595

  596     public int GetUpperBound()

  597     {

  598       return m_ids.Count - 1;

  599     }

  600

  601     // Store the specified ObjectId in the next

  602     // available location in the list, and return

  603     // what that is

  604

  605     public int NextObjectNumber(ObjectId id)

  606     {

  607       int pos;

  608       if (m_free.Count > 0)

  609       {

  610         // Get the first free position, then remove

  611         // it from the "free" list

  612

  613         pos = m_free[0];

  614         m_free.RemoveAt(0);

  615         m_ids[pos] = id;

  616       }

  617       else

  618       {

  619         // There are no free slots (gaps in the numbering)

  620         // so we append it to the list

  621

  622         pos = m_ids.Count;

  623         m_ids.Add(id);

  624       }

  625       return pos;

  626     }

  627

  628     // Store an ObjectId in a particular position

  629     // (shuffle == true will "insert" it, shuffling

  630     // the remaining objects down,

  631     // shuffle == false will replace the item in

  632     // that slot)

  633

  634     public void NumberObject(

  635       ObjectId id, int index, bool shuffle)

  636     {

  637       // If we're inserting into the list

  638

  639       if (index < m_ids.Count)

  640       {

  641         if (shuffle)

  642           // Insert takes care of the shuffling

  643           m_ids.Insert(index, id);

  644         else

  645         {

  646           // If we're replacing the existing item, do

  647           // so and then make sure the slot is removed

  648           // from the "free" list, if applicable

  649

  650           m_ids[index] = id;

  651           if (m_free.Contains(index))

  652             m_free.Remove(index);

  653         }

  654       }

  655       else

  656       {

  657         // If we're appending, shuffling is irrelevant,

  658         // but we may need to add additional "free" slots

  659         // if the position comes after the end

  660

  661         while (m_ids.Count < index)

  662         {

  663           m_ids.Add(ObjectId.Null);

  664           m_free.Add(m_ids.LastIndexOf(ObjectId.Null));

  665           m_free.Sort();

  666         }

  667         m_ids.Add(id);

  668       }

  669     }

  670

  671     // Dump out the object list information

  672     // as well as the "free" slots

  673

  674     public void DumpInfo(Editor ed)

  675     {

  676       if (m_ids.Count > 0)

  677       {

  678         ed.WriteMessage("\nIdx ObjectId");

  679

  680         int index = 0;

  681         foreach (ObjectId id in m_ids)

  682           ed.WriteMessage("\n{0} {1}", index++, id);

  683       }

  684

  685       if (m_free.Count > 0)

  686       {

  687         ed.WriteMessage("\n\nFree list: ");

  688

  689         foreach (int pos in m_free)

  690           ed.WriteMessage("{0} ", pos);

  691       }

  692     }

  693

  694     // Remove the initial n items from the list

  695

  696     public void RebaseList(int start)

  697     {

  698       // First we remove the ObjectIds

  699

  700       for (int i=0; i < start; i++)

  701         m_ids.RemoveAt(0);

  702

  703       // Then we go through the "free" list...

  704

  705       int idx = 0;

  706       while (idx < m_free.Count)

  707       {

  708         if (m_free[idx] < start)

  709           // Remove any that refer to the slots

  710           // we've removed

  711           m_free.RemoveAt(idx);

  712         else

  713         {

  714           // Subtracting the number of slots

  715           // we've removed from the other items

  716           m_free[idx] -= start;

  717           idx++;

  718         }

  719       }

  720     }

  721   }

  722 }

Some information on the changes...

Rather than maintaining an integer for our "current number", we now have an instance of the NumberedObjectManager class, as well as a "base number" should we choose to start the numbering at something other than 0 (see lines 18-23 & 29). We use these variables when numbering the objects at lines 510-511.

We've added a couple of additional commands, LNS (Load Numbering System) and DMP (DuMP numbering system). These commands are announced to the user on load (lines 43-44) and implemented from lines 57-219. The DMP command is really an internal command to show the contents of the number list, while the LNS command analyses the current drawing and determines how the blocks inserted into it have been numbered. Should the numbering start at a higher number than 0, it asks the user whether to re-base the list, which essentially removes the initial entries (from 0 to the new start) and sets the "base number" variable. The function behind the LNS command could have been called automatically on drawing load, but I've chosen to keep it as a command, to allow more control (and to allow the user to re-analyze the drawing, should the numbering for some reason get out of sync).

So why did I choose to implement an analysis command, rather than storing the numbering information in the drawing? Mainly because the quantity of numbered blocks in any real-world (even highly complex) drawing is unlikely to be unmanageable (and therefore slow to analyze), so going to the effort of storing the list in XRecords in the DWG seems like overkill. We would - in any case - want to have something like the LNS command to allow existing drawings to be managed by this system. Seralizing to the drawing is always an option, of course, should your users provide feedback that this approach is innefficient.

The remainder of the changes are for the numbering system itself, the NumberedObjectManager (lines 543-721). As mentioned in the last post, this class has been kept as generic as possible, so it can be used for objects other than blocks, for instance. At the core of the class are two lists:

  • m_ids - a list of ObjectIds, which is the list of numbered objects
  • m_free - a list of free positions (integers) in the object list, which is useful for us to know the gaps in the list created by object deletion, etc.

An alternative implementation would have been to maintain a "map" between list positions and objects, but using two simple lists makes life easier in certain ways: if we want to delete a number we can simply set its value to ObjectId.Null and add its position to the "free" list, and if we want to move an item in the list we can remove it and insert it elsewhere - the other objects simply shift automatically (although they will need to be updated to reflect their new number, of course - more on this in the next post).

The current implementation fills gaps in the list when we number a new object, but we could very easily adjust the code to ignore those gaps and add numbers at the end.

So let's see what happens when we run the LNS command on the drawing we created in the last post. LNS does nothing very visible - although as we previously started the numbering at 1, it does ask us whether we want to rebase the list. To understand how the numbering system works, let's call LNS and DMP twice, and choosing a different re-basing option each time:

Command: LNS

Lowest index is 1. Make this the start of the list? [Yes/No] <Yes>: No

Command: DMP


Idx ObjectId

0 (0)

1 (2129683752)

2 (2129683776)

3 (2129683800)

4 (2129683824)

5 (2129683848)

6 (2129683872)

7 (2129683896)

8 (2129683920)

9 (2129683944)

10 (2129683968)

11 (2129683992)

12 (2129684016)

13 (2129684040)

14 (2129684064)

15 (2129684088)

16 (2129684112)

17 (2129684136)

18 (2129684160)

19 (2129684184)

20 (2129684208)


Free list: 0


Command: LNS

Lowest index is 1. Make this the start of the list? [Yes/No] <Yes>: Yes

Command: DMP


Idx ObjectId

0 (2129683752)

1 (2129683776)

2 (2129683800)

3 (2129683824)

4 (2129683848)

5 (2129683872)

6 (2129683896)

7 (2129683920)

8 (2129683944)

9 (2129683968)

10 (2129683992)

11 (2129684016)

12 (2129684040)

13 (2129684064)

14 (2129684088)

15 (2129684112)

16 (2129684136)

17 (2129684160)

18 (2129684184)

19 (2129684208)

We can see that the first time we run the LNS command, choosing not to re-base the list, we start the list at 0 and have a free slot at that position. If we start adding more numbered blocks, using the BIC or BAP commands, the system will start by numbering an item with 0 before carrying on at 21, 22, etc.

The second time we run the command, we do re-base the list, which means that our m_baseNumber variable will be set to 1 and will be added each time we map the internal list to what is numbered in the drawing.

That's it for today - in the next post we'll look at some more interesting usage of the numbering system, where we highlight, delete and move items in the list, as well as showing how to fill any gaps automatically.

TrackBack

TrackBack URL for this entry:
http://www.typepad.com/services/trackback/6a00d83452464869e200e5521681588833

Listed below are links to weblogs that reference An automatic numbering system for AutoCAD blocks using .NET - Part 2:

blog comments powered by Disqus

Feed/Share

10 Random Posts