Set Bom_Record = Bom_Records.Add (strPartID)

Understanding the generic VBA Collection object will make almost the entire object model more clear for both Autocad and Excel. Software like autocad and excel are pretty much a collection of collections of objects. The format is plural-singular, such that things-thing, layers-layer, worksheets-worksheet.

All these special collections have a close similarity to the generic VBA collection.

Collection.Add (object, strkey)

You can store (add to collection) elementary data types like integer and string, but objects are most useful. The objects do not have to be the same type, but in most custom extended versions, like worksheet and layer collections, the objects are all the same. The key must be unique. In VBA it cannot look like a number even if its a string. Its optional, but the point is storing and retrieving an object by name. There are two more arguments, before and after, which reference keys already in the collection, but they are not often used.

Collection.Item(indexno or strkey)

Item returns the object. If the index is an integer, it returns the object at that position in the list. If a string is passed it looks for the key. If it fails, it errors out. Item is the default method for Collection, so it can be omitted.

Collection(indexno or strkey)

This is why you usually type Worksheets(”Part”) instead of Worksheets.Item(”Part”). The name of the sheet is the unique key in the collection of Worksheets. Or you could type Worksheets(1) or Worksheets.Item(1)

In VBA an object, in this case a worksheet, is returned, requiring an assignment with the Set keyword,

Set ws = ThisWorkbook.Worksheets.Item(1)

You dont have to create or instantiate the Worksheets, but you do for your own Class objects, and Collections work just like a class.

Dim col As Collection
Set col = New Collection

col.Add ws
MsgBox col.Item(1).Name

Since I didnt use a key with Add, index by number is the only option to retrieve.

Collection.Count returns the total number of items
Collection.Remove(indexno or strkey) removes that member.

Thats it, thats the entire oeuvre of Collection.

Indexing is automatic by the Collection. If an item is removed, the trailing indexes move up. You can specify where in the collection to Add, with Before and After, but once it’s there it’s there.

There is a special Loop feature, custom collections can loop with

For Each object in objects
. . .
Next Object

The other technique is

For inc = 1 to col.count
. . .
Next inc

Collections are like arrays, only one dimensional, and they have a name key for retrieval in addition to index location. How then would we use a Collection to save a table of data? By making a class object for each row.

Lets look at how autocad implements Layers. The Layer Properties Manager is a table. Properties are column headings across the top, layer names are rows in column 1.

To create a new layer,

Dim layer As AcadLayer

Set layer = acaddoc.Layers.Add(“string”)

(acaddoc is my connector from excel which contains the ThisDrawing object)

The Collection class Layers is already created. The collection is available just like Worksheets is available. We are creating a new instance of the Layer object, adding a new row. We use the Add method of collection Layers. What we are passing is the string key, not a created object (like we do in the VBA Collection). The Layers Class is creating the Layer object using the key and adding it to the collection. Its passing back the new Layer object to our variable. It created the layer with all default attributes, so now can set the rest of the attributes.

layer.LineType = “Center”
layer.Color = acRed

This is how a lot of objects are made, from a method in the collection. The bare minimum of attributes are required to create. The object is passed back to set the remaining properties.

In the autocad object model, (hold down CTRL and it will open in a new window)

https://help.autodesk.com/view/OARX/2019/ENU/?guid=GUID-A809CD71-4655-44E2-B674-1FE200B9FE30

As you go down the hierarchy, objects and collections alternate. Rectangles are collections and ovals are objects (see key at bottom). Top level is the application object which contains the Documents collection which contains a Document object. The Document has a pointer to Layers collection. It also has a pointer to another collection ModelSpace which has all the visible objects. Modelspace collection has not one Add method to create objects but about 50 different ones, such as AddLine, AddText, etc. Click on ModelSpace and you will see them. All of them pass back their newly created object for further possible modification of attributes.

Since ModelSpace is a collection, you can iterate through it with the collection style loop.

Dim acad_ent As AcadEntity

For Each acad_ent In acaddoc.ModelSpace
Debug.Print acad_ent.ObjectName
Next acad_ent

Or retrieve with Item

Dim inc As Integer

For inc = 0 To acaddoc.ModelSpace.count – 1
Debug.Print acaddoc.ModelSpace.Item(inc).ObjectName
Next inc

I am not going to try to explain when counts start with 1 and when they start with 0. Its about 50-50. Some programming pioneer must have insisted on 0. Imagine buying a bag of 6 apples from 0 to 5.

Back to our story,

Our BOM goal is to create a BOM sheet, looping through our parts table join. We will have the raw data in an array, and we want one collection object to populate TreeView, ListView and Worksheet. Our parts have potentially both parents and children. I want 3 levels of parts on the DWG BOM indented, from a practical POV, and i want nearly unlimited levels from a programmers POV. Parts have quantities needed for one assembly, and higher total quantities based on assembly quantities. We will make an object class called Bom_Record and a collection class called Bom_Records. If we can duplicate the behavior of the Layers example, we will know our classes are set up correctly.

Set layer = acaddoc.Layers.Add(“string”)

Set Bom_Record = Bom_records.Add(strpart_ID)

A unary table relationship, where the relationship is between instances of a single entity type, parts in our case, is called a recursive relationship. A part cannot be composed of its own self, but it is composed of other parts. To traverse a deeply nested part a recursive program is required. One way to start writing this is to expect only 3 levels, write the code, and watch for where the code starts repeating itself. Write a boolean function called Hasparts(part_id), if it returns true, then pass the current part_id to a sub that makes a list of the sub-parts. The treeview control and the listview control both have a display entry and a key. Assemble this key as a complete path to the part. To populate the treeview the parent always has to be known. I am going to post the code in raw but working form so the project can be taken to a finish. There are still some other features, such as linking the treeview to the listview, writing the listview to a sheet. I am going to finish the project in visual studio, at a later date. First i need to do some smaller projects in vb.net.

    'entry top level uses global var Records
    'g_assy_ID = assy_id
Sub make_col(assy_id As String, assy_qty As Integer)

    're-sets the collection for input
     Set Records = New Bom_Records
 
    Call make_BOM_Records(assy_id, assy_qty)
End Sub

    
Sub make_BOM_Records(assy_id As String, assy_qty As Integer)
    'the global var Records set previous
    'so it can either be new or a continuation
    
    Dim strkey As String
    Dim ar As Variant
    Dim record As bom_record
     
    ar = return_row("PART", assy_id)
    strkey = assy_id & "\"
    
    Set record = Records.Add(strkey)
        
    record.assy_id = assy_id
    record.part_id = ""
    record.subpart_id = ""
    record.qty = assy_qty
    'record.ex_qty =
    record.UM = ar(1, 3)
    record.desc = ar(1, 2)
  
    'no parent - root record
    record.parent_key = ""
    record.mlevel = 1
     
    Debug.Print " "
    Debug.Print "new run"
    Debug.Print strkey
    
        'begin subparts
    If has_parts(assy_id) Then
        Call collect_parts(assy_id, assy_qty, 2, strkey)
    End If
            
End Sub


 'populates global var Records
 'recursive, assy_id is not necessarily top level
Sub collect_parts(assy_id As String, assy_qty As Integer, int_level As Integer, parent_key As String)
        Dim ar As Variant
        Dim part_id As String, strkey As String
        Dim part_qty As Integer, ex_qty As Integer
        Dim part_um As String, part_desc As String
        Dim rows As Integer, r As Integer
        Dim record As bom_record
        
        If int_level > g_max_level Then  'normal max is 3 or 9
        'MsgBox "bom level too high"
        Debug.Print "exit collect parts sub at max_level = " & int_level
        Exit Sub
        End If
                
        'returns 5 col joined table for assy_id\parts
        ar = get_parts(assy_id)
        rows = UBound(ar, 1)
      
        For r = 1 To rows
            'assy_id = ar(r, 1)  true for all rows
            part_id = ar(r, 2)
            part_qty = ar(r, 3)
            part_desc = ar(r, 4)
            part_um = ar(r, 5)
            ex_qty = assy_qty * part_qty 'passed in assy_qty

            'key is simplicity
            strkey = parent_key & part_id & "\"
            Set record = Records.Add(strkey)
          
              'the dwg designer wants an indented excel bom with just 3 levels
              'the programmer wants a 2nd option for nearly unlimited levels
              'there is no subpart id coming in from the array, only parent and child
              'i want bom record to be a finished product, so we need logic here
              'to explicitly save 3 levels as assy, part and subpart
              'when level# is > 3 still use subpart for id ; key is accurate complete path\
              
            record.assy_id = ""
                                     
            If int_level = 2 Then
                       record.part_id = part_id
                       record.subpart_id = ""
                                   
                 Else  '3 or more
                       record.part_id = ""
                       record.subpart_id = part_id
            End If
  
            record.qty = part_qty
            record.ex_qty = ex_qty
            record.UM = part_um
            record.desc = part_desc
            
            record.parent_key = parent_key
            record.mlevel = int_level
      
    Debug.Print strkey
            
            'recursive call, increment level, pass the parent key
            If has_parts(part_id) Then
               Call collect_parts(part_id, ex_qty, int_level + 1, strkey)
            End If
        Next r
End Sub

At this time (this is working but not polished code) I have nothing in the bom_record class except public variables. It could be a structure instead.
in class module Bom_Record


Public assy_id As String
Public part_id As String
Public subpart_id As String
Public qty As Integer
Public ex_qty As Integer
Public UM As String
Public desc As String

Public mlevel As Integer
Public mkey As String
Public parent_key As String

Class Bom_Records though has methods that mimic Add and Item. It has only one private variable, the collection object. The Add method creates a New Bom_Record, just like Layers.Add creates a new layer object. Remove always has issues so it is left out, just like autocad VBA cannot allow Remove without checking every single entity in drawing to see if the object is being referenced.

in Class module Bom_Records

Option Explicit
Private col_records As Collection

Private Sub Class_Initialize()
    Set col_records = New Collection
End Sub

Public Function Add(ByVal strkey As String) As bom_record
    Dim objrecord As New bom_record
    objrecord.mkey = strkey
    col_records.Add objrecord, strkey
     Set Add = objrecord
End Function

Public Function Item(ByVal varID As Variant) As bom_record
    Set Item = col_records.Item(varID)
End Function

Property Get count() As Long
    count = col_records.count
End Property

Property Get col() As Collection
    Set col = col_records
End Property

Some Excel Query Tools for BOM Sheets

Some Query Tools for Excel Sheets

A sheet is a named table. One column has all unique values. We search the column for a particular value and return the row. If the table is called PART, if the primary key column is PART_ID, if the search value is “2020_SD1”, the equivalent SQL would return the row –

SELECT *
FROM Part
WHERE Part_ID=”2020_SD1”;

Maybe missing some table qualifiers or parentheses, but essentially.

In VBA the FIND method of the RANGE object returns a RANGE of the first cell found. The END property of the RANGE will return the row.

https://docs.microsoft.com/en-us/office/vba/api/excel.range(object)


Function find_row(strtable As String, strfind As String) As Range
 'assume column1 is key index unique search column
 'return a single row as range
    Dim ws1 As Worksheet, rng As Range
    Set ws1 = ThisWorkbook.Sheets(strtable)
    Set rng = ws1.Range("A1")
    Set rng = ws1.Range(rng.Address, rng.End(xlDown).Address)
    Set rng = ws1.Range(rng.Address).find(strfind, LookIn:=xlValues, lookat:=xlWhole)
    Set rng = ws1.Range(rng.Address, rng.End(xlToRight).Address)
    Set find_row = rng
    'returns error if not found, cant use .address or .end of nothing
End Function

This returns a range. Anytime we have a range, we can load it into directly into an array. Excel VBA autosizes the array with 2 dimensions, rows and columns, even if there is only 1 row.

This table has 3 columns – the return range has 1 row.

Sub test()
    Dim rng As Range
    Dim strtable As String, strfind As String
    strtable = "PART"
    strfind = "2020_SD1"

    Set rng = find_row(strtable, strfind)
    
    Dim ar As Variant
    ar = rng
    
    MsgBox ar(1, 1) & ar(1, 2) & ar(1, 3)
End Sub

In the BOM project, we need to join two tables. We need to select all rows in COMPONENT that have the Assy_ID we want and join those to the rows in PART where COMPONENT.Comp_ID = PART.Part_ID to get a list of all sub-parts paired with their attributes from both tables.

I am not an SQL expert, but I got two different versions to work in an old copy of MS Access.
The difference is whether the join is called out in the FROM (preferred in new version SQL) or the WHERE clause.

SELECT COMPONENT.Assy_ID, COMPONENT.Comp_ID, COMPONENT.QTY, PART.DESC, PART.UM
FROM PART INNER JOIN COMPONENT ON PART.Part_ID = COMPONENT.Assy_ID
Where COMPONENT.Assy_ID = “string”;

SELECT COMPONENT.Assy_ID, COMPONENT.Comp_ID, COMPONENT.QTY, PART.DESC, PART.UM
FROM PART, COMPONENT
Where COMPONENT.Assy_ID = “string” and COMPONENT.Comp_ID = PART.Part_ID;

In SQL we would do it all in one query, but in VBA the code will be easier to write and read if we do it in steps.

First if we are to present the list of available assemblies, we need distinct values in the COMPONENT.Assy_ID column.

I found this code, modified it to my needs. It returns a zero index single dimension array. We are going to use arrays for the product of any search. Another great feature of an array is that you can load a listbox on a form simply by Listbox1.List= ar.

Function GetUniqueValues() As Variant
    Dim data As Variant
    Dim temp As Variant
    Dim obj As Object
    Dim i As Long
    
    Set obj = CreateObject("scripting.dictionary")
    'gets col A less header
    'you can even sometimes forget you are passing a range to a variant array
    data = return_col("COMPONENT", "A2")
    'excel sizes the array per the range
    'format data(1 to 50, 1 to 1)
        
    For i = 1 To UBound(data, 1)
        obj(data(i, 1) & "") = ""
    Next
    temp = obj.keys
    GetUniqueValues = temp
End Function

function return_column is just like find_row.

 Function return_col(str_sheet As String, str_cell As String) As Range
    Dim ws1 As Worksheet, rng As Range
    Set ws1 = ThisWorkbook.Sheets(str_sheet)
    Set rng = ws1.Range(str_cell)
    Set rng = ws1.Range(rng.Address, rng.End(xlDown).Address)
    Set return_col = rng
End Function

We have a list of unique COMPONENT.Assy_ID. One is chosen. The next step is to return an array of the COMPONENT table filtered for the rows where COMPONENT.Assy_ID = “string”

Here are our table definitions. Part_ID is a unique key. Assy_ID and Comp_ID are a composite key.

PART ( Part_ID, DESC, UM)
COMPONENT ( Assy_ID, Comp_ID, QTY)

We want to SELECT all columns in COMPONENT for a particular Assy_ID and join them to PART where Comp_ID = Part_ID and show the remaining 2 columns from PART. First lets return the filtered Component table for Assy_ID.

There is a bit going on here, but the function still only does one thing. Basically I run the same loop twice, first to find out how many rows there are, to use that number to re-dimension the array, then run the loop again to save the data. It might not be art but it works fine. The first thing done is to return the entire table COMPONENT to an array for searching.

        'returns COMPONENT table as array filtered for rows with assy_id
Function select_component(assy_id As String) As Variant
        'SELECT * FROM COMPONENT
        'WHERE Assy_ID = "string"
    Dim rows As Integer, cols As Integer
    Dim r As Integer
    Dim findnum As Integer
    
        'return entire table to array for searching
        'dim array (1 to rows, 1 to 3 cols)
    Dim arr As Variant
    arr = return_table("COMPONENT")
    rows = UBound(arr, 1)
    cols = UBound(arr, 2)
 
    'search column1 for assy_id string twice
    'first time to redimension array
    For r = 1 To rows
           If arr(r, 1) = assy_id Then
             findnum = findnum + 1
           End If
    Next r
    
    If findnum <> 0 Then
         Dim ar_result As Variant
         ReDim ar_result(1 To findnum, 1 To cols)
        Else
         Exit Function
        End If
    
     findnum = 0 'reset
         'this is structured for a 3 column table, not any table
     For r = 1 To rows
          If arr(r, 1) = assy_id Then
               findnum = findnum + 1
               ar_result(findnum, 1) = arr(r, 1)
               ar_result(findnum, 2) = arr(r, 2)
               ar_result(findnum, 3) = arr(r, 3)
          End If
     Next r
 
    select_component = ar_result
End Function

the function return_table. The conventions are – tables start on A1, the first row is column labels, anytime a continous range is returned, the returned type is range, anytime a non-continuous range is returned, the return type is array.

Function return_table(str_sheet As String) As Range
    'returns entire table to range
    Dim ws1 As Worksheet, rng As Range
    Set ws1 = ThisWorkbook.Sheets(str_sheet)
    Set rng = ws1.Range("A1")
    
    Set rng = rng.CurrentRegion
    Set rng = rng.Offset(1, 0).Resize(rng.rows.Count - 1)
    'takes out the label row
       
    Set return_table = rng
End Function

The next step is to make the join with all rows from component and desc and um from part.

       'returns array COMPONENT joined with PART for Assy_ID sub_parts
       'COMP.ASSY_ID, COMP.COMP_ID, COMP.QTY, PART.DESC, PART.UM
       '1 to rows, 1 to 5 cols
 Function join_comp_part(ar3 As Variant) As Variant
       'passed in table is Component filtered for Assy_ID
     Dim rng As Range
     Dim r As Integer, rows As Integer, cols As Integer
        
     rows = UBound(ar3, 1)
     cols = UBound(ar3, 2) 'we know is 3
   
     Dim ar_result As Variant
        '3 cols from COMP, 2 cols from PART
     ReDim ar_result(1 To rows, 1 To 5)
 
     Dim assy_id As String
     Dim comp_id As String
     Dim comp_qty As Integer
     Dim part_desc As String
     Dim part_um As String
     
     For r = 1 To rows
        assy_id = ar3(r, 1)
        comp_id = ar3(r, 2)
        comp_qty = ar3(r, 3)
         
            'WHERE Component.Comp_ID = Part.Part_ID
            'search PART table column1 for part_id, return row as range
        Set rng = find_row("PART", comp_id)
        part_um = rng.Cells(1, 3).Value
        part_desc = rng.Cells(1, 2).Value
         
            ar_result(r, 1) = assy_id
            ar_result(r, 2) = comp_id
            ar_result(r, 3) = comp_qty
            ar_result(r, 4) = part_desc
            ar_result(r, 5) = part_um
     Next r

    join_comp_part = ar_result
End Function

That brings us to a stopping point, because up til now, if you accept the basic table relation as a workable BOM structure, we have simply joined the tables. From here on though, everyone has different needs, and will want to write the next manipulation according to their on-dwg bom style, or formatted to paste into their ERP structure.

So lets recap.

We have two tables. One table is the main PART table. Its a list of every PART identification number in the system. For the excel version, its of course a subset. It has 3 columns, part number, description and unit of measure. In the real world, especially if the intent is to interface directly to ERP, there would be some additional foreign key columns to purchase, track cost, show inventory etc. It has no information about the part using or being used by assemblies.

The second table is the assembly information table I called COMPONENT. It has a double key, the assembly number and the component number, parent and child, the part and sub-part. Neither of these numbers is unique singly in this table but is unique taken together. Both numbers are found in the PART table. I could have called the table ASSEMBLY. A third column has the quantity needed for the assembly.

Pic here

The first main function select_component searches COMPONENT for an Assy_ID and returns the table just for those parts.

Pic here

The second function join_comp_part joins the two tables

Pic here

Thats where we are at. Some subs are called but not shown. I am re-writing code as i go, having the first prototype working, making some fairly major revisions hopefully to make the code simpler, less chunky, more modular. its not finished code that has been in production. it is subject to change. You might notice there is no error checking. My goal is to figure out how i want it to work, includng a form interface, then break it with bad data and add error checking. The next step makes the actual dwg bom with an assembly quantity, extended quantities for sub-parts and loops thru the list looking for sub-parts to the sub-parts. I will show my version of an indented bill of material.

The Fundamental Bill of Material Relation

From the designer’s point of view, the Bill of Material turns a sketch into a construction document. Its hard to generalize about BOMs, because of the variability of use. A BOM for a mass produced product is not like one for engineer-to-order/make-to-order. A BOM for a product small enough to be made and shipped in a box is not like one loaded in pieces and assembled on-site. A BOM might be an instruction for an experienced shop to build, or for an in-experienced customer to assemble. It might be a list of spare parts for replacement when the originals wear out. It might be a sales tool meant to inspire confidence – the design approved by the customer before the sale is made. The main customer of the drawing might be the designer who has a problem and uses the drawing to work it out. Next year he will have a similar problem and this drawing will be his prime source. it has to be complete and make sense. The BOM names and identifies the end product and the parts to get there.

Whatever the purpose and appearance of the drawing level BOM, however it is constructed, it is a report. It’s a view of the data in a Parts database. The view can change. The PART table it is based on all have similar features.

A formal database table, whether its in MS Access or your half million dollar ERP, is a collection of related entities. The items in a table are related by being all of one kind. A second kind of relation is the relationship between tables. Every table is named. The columns in a table are named. The rows are not named. There is a fixed number of columns, after the table design is complete, but a variable number of rows. Each row is a record. Every table has one column where the values are unique, no duplicates, no blanks, called the primary key. It provides the key to the record. Another column usually has the foreign key. It has the same kind of data as the primary key, but duplicates are allowed. Matching the primary key from one table and the foreign key of a second table is how tables are related.

Manufactured products are composed of assemblies, which are composed of sub-assemblies and parts. Every tracked item has to have a unique identifying number or name. These all go into one PARTS table. Assys, Sub-Assys, Made Parts and Purchased Parts are all distinct line items in the same PART table. One Assy can use many Parts, and one Part can be used in many Assys. That is called a Many to Many relationship. There is no limit in theory how many levels of Sub-Assys can be used in other Sub-Assys. This cannot be modeled in a single table.

The purpose of formal database structure is to make the storage and retrieval of data efficient, to eliminate duplication of data. The most common relation is between two tables of two different kind of things. When items of the same type in a single table have a relationship between themselves – called a Unary relationship – and it is a many-to-many type, a second table has to be created.

Modeling a relational dabase in Excel for output to Autocad – you can think of this as a pilot project, or a demonstration, or an educational project, but i think its a little bit more. Excel is the BOM autocad has always needed. Its a natural fit. Autocad never had a BOM solution and only introduced tables 2008 (or so). The designer creates the BOM, whether he does it in the window of the ERP program, or Excel, or with pencil and paper. Even if you hire Bill Gates to do your website, at some point he is going to ask you, where is your content? The ERP is the same way. You won’t get to standard parts if you implement with no knowledge of how a parts table works with no part naming conventions at all. Accounting may be satisfied, but engineering is just feeding the ERP beast and gaining nothing from it. With a mock up at least, you have to start thinking about how to name parts so it works over time. Excel can be used to paste into autocad and paste into ERP, so its not quite the same thing as duplicating data. It’s the most convenient editor. It has possible application for designing standard products, which are hard to do in an engineer to order company.

Here is the basic model of the Bill of Material relation – how you capture Assemblies of Sub-assemblies of Parts using database concepts. I will start doing the supporting code in the next post.

I had to make a decision about my on-dwg bom. Remember its just a report and is a view of the data. I chose 3 levels with 3 columns. I called them Assy_ID, Part_ID and LVL3. The part number is in the appropriate column. Thats my version of an indented bill. Google “Indented Bill of Material” for other ideas. So i do not have an infinite recursion of parts here. You could do that, but it would require a LEVEL column with an integer. Those dont seem particularly easy to read. As a first attempt, this works.

I have to credit my text. The E-R diagram and table structure (and probably some of the verbiage) are from
Modern Database Management, Hoffer, Prescott, McFadden, Prentice Hall, Sixth Ed, 2002